Compare commits
1 Commits
develop
...
00fa695d3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00fa695d3b |
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"worktree": {
|
|
||||||
"baseRef": "head"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
.gitattributes
vendored
12
.gitattributes
vendored
@@ -1,12 +0,0 @@
|
|||||||
# public/projects.ts embeds literal NUL bytes on purpose: the namespace-group
|
|
||||||
# sentinels ACTIVE_GROUP_KEY = '\0active' and OTHER_GROUP_KEY = '\0other' are
|
|
||||||
# prefixed with NUL so they can never collide with a real `First.Second` key.
|
|
||||||
#
|
|
||||||
# git classifies any file containing NUL as binary, so `git diff`/`git show`
|
|
||||||
# print "Binary files differ" and review of this file is impossible. The `diff`
|
|
||||||
# attribute tells git to diff it as text anyway. It does NOT set `text`, so
|
|
||||||
# nothing is line-ending-normalised and the bytes on disk are untouched.
|
|
||||||
#
|
|
||||||
# Same trap in the shell: `grep` prints only "Binary file ... matches" and looks
|
|
||||||
# like a no-match. Use `grep -a` on this file.
|
|
||||||
public/projects.ts diff
|
|
||||||
184
.github/workflows/android.yml
vendored
184
.github/workflows/android.yml
vendored
@@ -1,184 +0,0 @@
|
|||||||
# Android CI (F2). Until this file existed the Android client had ZERO CI — 10 modules,
|
|
||||||
# 691 JVM tests and the whole `WEBTERM_TOKEN` secret-handling wave (Tink AEAD under an
|
|
||||||
# AndroidKeystore master key, the POST /auth probe, cookie stamping on every route and on
|
|
||||||
# the WS upgrade) were gated by nothing at all. Modelled on ios.yml: same `on:` shape
|
|
||||||
# (push + pull_request, path-filtered), same "one job per verification layer" structure,
|
|
||||||
# and — like ios.yml — deliberately NO `concurrency:` group, so the two workflows behave
|
|
||||||
# the same way on rapid pushes.
|
|
||||||
#
|
|
||||||
# ── HONESTY NOTE (read before trusting a green badge) ────────────────────────────────────
|
|
||||||
# This repository's only remote is a self-hosted Gitea instance, so GitHub Actions has
|
|
||||||
# NEVER run here and this file has never been executed by the platform. What IS verified:
|
|
||||||
# every command below was run locally on macOS against the same Gradle build
|
|
||||||
# (`./gradlew test :app:assembleDebug koverVerify koverLog` + the androidTest compile),
|
|
||||||
# and the YAML parses. What is NOT verified: the runner-side pieces — action resolution,
|
|
||||||
# the preinstalled Android SDK layout on `ubuntu-latest`, and the emulator leg.
|
|
||||||
#
|
|
||||||
# ── Why no `src/**` path trigger (ios.yml HAS one) ───────────────────────────────────────
|
|
||||||
# ios.yml watches `src/**` because it owns `integration-tests`, a Swift layer that boots the
|
|
||||||
# real Node server and would catch client↔server protocol drift. Android has no equivalent
|
|
||||||
# leg (android/README.md, "DEFERRED — end-to-end access token": nothing here starts a server
|
|
||||||
# with WEBTERM_TOKEN set and completes a cookie-gated WS upgrade). Triggering on `src/**`
|
|
||||||
# would therefore burn runner minutes without checking a single contract. When an Android
|
|
||||||
# server-contract leg is added, add `src/**` here at the same time.
|
|
||||||
name: android
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- "android/**"
|
|
||||||
- ".github/workflows/android.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "android/**"
|
|
||||||
- ".github/workflows/android.yml"
|
|
||||||
# Manual trigger for the instrumented (device) leg below, which is dispatch-only.
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Layer 1 — everything that runs without a device. This is exactly the green gate
|
|
||||||
# android/README.md documents ("./gradlew test :app:assembleDebug koverVerify"), plus a
|
|
||||||
# compile-only pass over the instrumented sources so they cannot silently rot.
|
|
||||||
build-and-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: android
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# jvmToolchain(17) is declared by every module; Gradle resolves it from the
|
|
||||||
# installed JDKs, so 17 must actually be present (the runner image's default may
|
|
||||||
# be a different major).
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
cache: gradle
|
|
||||||
|
|
||||||
# :app/:terminal-view/:host-registry/:client-tls-android compile against SDK 36 with
|
|
||||||
# build-tools 36.0.0 (android/README.md "Android SDK setup"). The runner image ships an
|
|
||||||
# Android SDK, but which platforms it holds drifts image to image — so install what the
|
|
||||||
# build actually needs instead of assuming, and FAIL LOUDLY if there is no sdkmanager.
|
|
||||||
- name: Install the Android SDK packages this build compiles against
|
|
||||||
run: |
|
|
||||||
# `set -eu` WITHOUT pipefail on purpose: `yes | sdkmanager --licenses` kills `yes`
|
|
||||||
# with SIGPIPE (141) as soon as the prompts stop, and pipefail would report that
|
|
||||||
# normal ending as a failed step.
|
|
||||||
set -eu
|
|
||||||
sdkmanager_bin="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
|
||||||
if [ ! -x "$sdkmanager_bin" ]; then
|
|
||||||
sdkmanager_bin="$(ls -1 "$ANDROID_HOME"/cmdline-tools/*/bin/sdkmanager 2>/dev/null | tail -n 1 || true)"
|
|
||||||
fi
|
|
||||||
if [ ! -x "$sdkmanager_bin" ]; then
|
|
||||||
echo "::error title=No sdkmanager::none found under $ANDROID_HOME/cmdline-tools — the Android SDK is not usable on this runner" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
yes | "$sdkmanager_bin" --licenses > /dev/null
|
|
||||||
"$sdkmanager_bin" "platforms;android-36" "build-tools;36.0.0" "platform-tools"
|
|
||||||
|
|
||||||
# 10 modules of JVM unit tests (JUnit 5 + coroutines-test + Turbine + MockK). Includes
|
|
||||||
# the Android-library modules' testDebugUnitTest and :app's — no device needed.
|
|
||||||
- name: JVM unit tests (all modules)
|
|
||||||
run: ./gradlew test
|
|
||||||
|
|
||||||
# The APK build is its own step: a Kotlin/KSP/Hilt/Compose breakage that unit tests do
|
|
||||||
# not reach (resources, manifest merge, DI graph assembly) must not hide behind them.
|
|
||||||
- name: Debug APK builds
|
|
||||||
run: ./gradlew :app:assembleDebug
|
|
||||||
|
|
||||||
# Coverage floor: >=80% line coverage on the four modules that apply the Kover plugin
|
|
||||||
# (:wire-protocol, :session-core, :api-client, :client-tls). koverLog prints the actual
|
|
||||||
# percentages into the run log so a near-miss is visible before it becomes a failure.
|
|
||||||
# The Android-framework modules are NOT gated (no Kover on them) — a known gap.
|
|
||||||
- name: Coverage gate (Kover, >= 80% on the four gated modules)
|
|
||||||
run: ./gradlew koverVerify koverLog
|
|
||||||
|
|
||||||
# The instrumented sources cannot RUN here, but they must keep COMPILING. Three modules
|
|
||||||
# carry them, 119 @Test in total: `:app` (67 — the device-blocker suite: key routing,
|
|
||||||
# resize, alternate-screen scroll, autofill, driven through a custom HiltTestRunner),
|
|
||||||
# `:host-registry` (31 — incl. TinkAccessTokenStoreTest, the only assertion anywhere that
|
|
||||||
# the access token is ciphertext at rest) and `:client-tls-android` (21 — AndroidKeyStore
|
|
||||||
# import). A silent compile break would delete all of that without anyone noticing.
|
|
||||||
- name: Instrumented test sources still compile
|
|
||||||
run: ./gradlew :app:assembleDebugAndroidTest :host-registry:assembleDebugAndroidTest :client-tls-android:assembleDebugAndroidTest
|
|
||||||
|
|
||||||
- name: Upload test + coverage reports
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: android-reports
|
|
||||||
path: |
|
|
||||||
android/*/build/reports/tests/**
|
|
||||||
android/*/build/test-results/**
|
|
||||||
android/*/build/reports/kover/**
|
|
||||||
retention-days: 7
|
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|
||||||
# Layer 2 — instrumented (device) tests. DISPATCH-ONLY, on purpose.
|
|
||||||
#
|
|
||||||
# What is at stake: `TinkAccessTokenStoreTest` is the ONLY test anywhere that proves the
|
|
||||||
# frozen at-rest properties of the access token (ios-completion §1.1) — that a fresh store
|
|
||||||
# instance decrypts it after a cold start, and that the bytes on disk are ciphertext rather
|
|
||||||
# than the token in the clear. Tink's AEAD + the AndroidKeystore-wrapped master key have no
|
|
||||||
# JVM double, so this can only run on a device or emulator.
|
|
||||||
#
|
|
||||||
# The suite itself is NOT unproven: the android-blocker-fixes session ran `:app`'s 67
|
|
||||||
# instrumented tests green on real hardware. What has never been proven is this leg — the
|
|
||||||
# CI *emulator* path. Those are different claims and only the second one gates this `if:`.
|
|
||||||
#
|
|
||||||
# Why it is not on push: this workflow has never executed on any runner (see the honesty
|
|
||||||
# note at the top), and an emulator leg is the single most fragile thing in Android CI —
|
|
||||||
# it needs KVM, a system image download, and an AVD boot. Wiring an unvalidated, ~15-minute,
|
|
||||||
# KVM-dependent job into every push would either block the repo on a leg nobody has seen
|
|
||||||
# pass, or push people to make it non-blocking — and a leg that cannot fail is a false green
|
|
||||||
# (the same trap ios.yml's `ios17-floor-tests` comment calls out).
|
|
||||||
#
|
|
||||||
# THEREFORE: run it by hand from the Actions tab (workflow_dispatch) whenever the token
|
|
||||||
# store, the DataStore stores, the AndroidKeyStore importer, or the terminal view/input
|
|
||||||
# path change. Once it has been seen green on a real runner, promote it by deleting the
|
|
||||||
# `if:` below — the suite has already earned it on hardware.
|
|
||||||
#
|
|
||||||
# MANUAL EQUIVALENT (the path that has actually been walked — DEVICE_QA_CHECKLIST.md):
|
|
||||||
# with a device/emulator attached,
|
|
||||||
# cd android && ANDROID_HOME=<sdk> ./gradlew :app:connectedDebugAndroidTest \
|
|
||||||
# :host-registry:connectedDebugAndroidTest \
|
|
||||||
# :client-tls-android:connectedDebugAndroidTest
|
|
||||||
# (StrongBox falls back to software keys on an emulator, so hardware-backed key claims still
|
|
||||||
# need real hardware.)
|
|
||||||
instrumented-tests:
|
|
||||||
if: github.event_name == 'workflow_dispatch'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
cache: gradle
|
|
||||||
- name: Enable KVM for the emulator
|
|
||||||
run: |
|
|
||||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
|
||||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
|
||||||
sudo udevadm control --reload-rules
|
|
||||||
sudo udevadm trigger --name-match=kvm
|
|
||||||
# API 29 == the app's minSdk: the deployment floor is where at-rest crypto behaviour is
|
|
||||||
# most likely to differ (mirrors ios.yml's iOS 17 floor leg).
|
|
||||||
- name: connectedDebugAndroidTest on an API 29 emulator
|
|
||||||
uses: reactivecircus/android-emulator-runner@v2
|
|
||||||
with:
|
|
||||||
api-level: 29
|
|
||||||
target: google_apis
|
|
||||||
arch: x86_64
|
|
||||||
working-directory: android
|
|
||||||
script: ./gradlew :app:connectedDebugAndroidTest :host-registry:connectedDebugAndroidTest :client-tls-android:connectedDebugAndroidTest
|
|
||||||
- name: Upload instrumented reports
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: android-instrumented-reports
|
|
||||||
path: android/*/build/reports/androidTests/**
|
|
||||||
retention-days: 7
|
|
||||||
if-no-files-found: ignore
|
|
||||||
153
.github/workflows/ios.yml
vendored
153
.github/workflows/ios.yml
vendored
@@ -6,9 +6,8 @@
|
|||||||
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
|
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
|
||||||
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
|
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
|
||||||
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
||||||
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on
|
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
|
||||||
# an iPhone AND an iPad simulator: ViewModels/components of the app glue
|
# iPhone 16 simulator): ViewModels/components of the app glue layer.
|
||||||
# layer, on both the compact and the regular size class.
|
|
||||||
# 3. integration-tests — Swift Testing against the REAL Node server. The
|
# 3. integration-tests — Swift Testing against the REAL Node server. The
|
||||||
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
||||||
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
||||||
@@ -36,15 +35,14 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
||||||
# The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the
|
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs
|
||||||
# mTLS identity/keychain package, previously the only ungated one at 55.76%).
|
# tests below without a gate (test doubles are excluded from the gate).
|
||||||
# TestSupport runs tests below without a gate (test doubles are excluded).
|
|
||||||
package-tests:
|
package-tests:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS]
|
package: [WireProtocol, SessionCore, HostRegistry, APIClient]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
@@ -58,66 +56,47 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||||
- name: swift test (TestSupport — no coverage gate; it IS the test doubles)
|
- name: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages)
|
||||||
run: swift test --package-path ios/Packages/TestSupport
|
run: swift test --package-path ios/Packages/TestSupport
|
||||||
|
|
||||||
# Layer 2: app-target unit tests (WebTermTests, hosted by the app), on the
|
# Layer 2: app-target unit tests (WebTermTests, hosted by the app).
|
||||||
# compact iPhone path AND the regular iPad path (T-iPad-1: the adaptive
|
|
||||||
# NavigationSplitView layout of T-iPad-2 must be exercised in CI, not only
|
|
||||||
# compact). One matrix instead of two near-identical jobs.
|
|
||||||
#
|
|
||||||
# THE NODE DEPENDENCY (B4 fix): the WebTermTests bundle contains
|
|
||||||
# LiveServerSmokeTests, whose SimServerHarness spawns
|
|
||||||
# `node_modules/.bin/tsx src/server.ts`. On a bare checkout there is no
|
|
||||||
# node_modules, so the harness throws
|
|
||||||
# setup: 找不到 …/node_modules/.bin/tsx — 先在 repo 根目录跑 npm install/npm ci
|
|
||||||
# and the test HARD-FAILS (it is not a skip) — i.e. both legs were red on every
|
|
||||||
# run. Fix: `npm ci` on the canonical iPhone leg, which is the one that owns the
|
|
||||||
# live-server smoke; the iPad leg SKIPS that suite instead of paying a second
|
|
||||||
# node-pty native build. Rationale: the iPad leg exists to exercise the app's
|
|
||||||
# regular-width layout, not to re-verify the client↔server contract, which is
|
|
||||||
# already covered three times over (this leg, integration-tests, ui-test).
|
|
||||||
# `-skip-testing` (rather than a test plan) keeps the change inside this file:
|
|
||||||
# test plans live in ios/project.yml, owned by another task.
|
|
||||||
app-tests:
|
app-tests:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 45
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- leg: iphone
|
|
||||||
device: iPhone 16
|
|
||||||
needsNode: true
|
|
||||||
extraArgs: ""
|
|
||||||
- leg: ipad
|
|
||||||
device: iPad Pro 11-inch (M4)
|
|
||||||
needsNode: false
|
|
||||||
extraArgs: "-skip-testing:WebTermTests/LiveServerSmokeTests"
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
if: matrix.needsNode
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
- name: npm ci (LiveServerSmokeTests spawns node_modules/.bin/tsx)
|
|
||||||
if: matrix.needsNode
|
|
||||||
run: npm ci
|
|
||||||
- name: Install XcodeGen
|
- name: Install XcodeGen
|
||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
- name: Generate project
|
- name: Generate project
|
||||||
run: cd ios && xcodegen generate
|
run: cd ios && xcodegen generate
|
||||||
- name: xcodebuild test (WebTermTests, ${{ matrix.device }} simulator)
|
- name: xcodebuild test (WebTermTests, iPhone 16 simulator)
|
||||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
||||||
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
||||||
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
||||||
# handoff finding, verified locally in the ui-test leg runs.)
|
# handoff finding, verified locally in the ui-test leg runs.)
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||||
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||||
${{ matrix.extraArgs }} \
|
test
|
||||||
|
|
||||||
|
# iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so
|
||||||
|
# the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is
|
||||||
|
# exercised in CI, not only the compact iPhone path.
|
||||||
|
ipad-tests:
|
||||||
|
runs-on: macos-15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Select Xcode 16.3
|
||||||
|
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||||
|
- name: Install XcodeGen
|
||||||
|
run: brew install xcodegen
|
||||||
|
- name: Generate project
|
||||||
|
run: cd ios && xcodegen generate
|
||||||
|
- name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator)
|
||||||
|
run: |
|
||||||
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \
|
||||||
test
|
test
|
||||||
|
|
||||||
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
||||||
@@ -142,27 +121,9 @@ jobs:
|
|||||||
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
||||||
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
||||||
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
|
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
|
||||||
#
|
|
||||||
# iPad leg (B4 fix): ProjectsLayoutUITests has an explicit `isPad` branch
|
|
||||||
# (landscape + NavigationSplitView sidebar reveal, T-iPad-4) that NEVER ran —
|
|
||||||
# the only UI leg was iPhone. It now runs on an iPad simulator too. Only THAT
|
|
||||||
# suite: HappyPathUITests has no regular-width branch at all (no sidebar
|
|
||||||
# reveal), so running it on iPad would fail for a missing-feature reason rather
|
|
||||||
# than a regression — making the happy path iPad-adaptive is app-layer work,
|
|
||||||
# tracked separately.
|
|
||||||
ui-test:
|
ui-test:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- leg: iphone
|
|
||||||
device: iPhone 16
|
|
||||||
suites: ""
|
|
||||||
- leg: ipad
|
|
||||||
device: iPad Pro 11-inch (M4)
|
|
||||||
suites: "-only-testing:WebTermUITests/ProjectsLayoutUITests"
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
@@ -198,7 +159,7 @@ jobs:
|
|||||||
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
||||||
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
||||||
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
||||||
- name: xcodebuild test (WebTermUITests, ${{ matrix.device }} simulator)
|
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator)
|
||||||
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
||||||
# strips the prefix and injects <VAR> into the test-runner process);
|
# strips the prefix and injects <VAR> into the test-runner process);
|
||||||
# passing it as a KEY=VALUE argument makes it a build setting, which
|
# passing it as a KEY=VALUE argument makes it a build setting, which
|
||||||
@@ -211,8 +172,7 @@ jobs:
|
|||||||
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
||||||
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
-destination 'platform=iOS Simulator,name=iPhone 16' test
|
||||||
${{ matrix.suites }} test
|
|
||||||
- name: Server log + shutdown
|
- name: Server log + shutdown
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -221,21 +181,15 @@ jobs:
|
|||||||
|
|
||||||
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
||||||
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
||||||
#
|
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose
|
||||||
# CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17
|
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so
|
||||||
# runtime, so this leg is never reproducible on a dev box.
|
# Xcode 16.x can build against it. Local machines are NOT expected to
|
||||||
#
|
# download the ~8 GB runtime. If the runner image drops the 17.x runtime,
|
||||||
# NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the
|
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures
|
||||||
# runner image had no iOS 17.x runtime, and the test step was gated on
|
# fail the job (no silent pass).
|
||||||
# `if: steps.sim17.outputs.runtime != ''` — so on image drift the whole
|
|
||||||
# deployment-floor round vanished and the job still reported GREEN. A leg that
|
|
||||||
# can silently stop testing is worse than no leg. Now: if the runtime is
|
|
||||||
# missing, it is DOWNLOADED (`xcodebuild -downloadPlatform iOS
|
|
||||||
# -buildVersion`); if the download does not produce a usable runtime, the job
|
|
||||||
# FAILS. The test step is unconditional.
|
|
||||||
ios17-floor-tests:
|
ios17-floor-tests:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 90 # the runtime download alone can take ~15 min
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3 (build toolchain)
|
- name: Select Xcode 16.3 (build toolchain)
|
||||||
@@ -244,40 +198,23 @@ jobs:
|
|||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
- name: Generate project
|
- name: Generate project
|
||||||
run: cd ios && xcodegen generate
|
run: cd ios && xcodegen generate
|
||||||
- name: Ensure an iOS 17.x simulator runtime (download if the image lacks it)
|
- name: Create iOS 17.x simulator (skip-with-notice if runtime absent)
|
||||||
id: sim17
|
id: sim17
|
||||||
env:
|
|
||||||
IOS17_FALLBACK_VERSION: "17.5"
|
|
||||||
run: |
|
run: |
|
||||||
find_runtime() {
|
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)"
|
||||||
xcrun simctl list runtimes \
|
|
||||||
| grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' \
|
|
||||||
| tail -n 1
|
|
||||||
}
|
|
||||||
runtime="$(find_runtime || true)"
|
|
||||||
if [ -z "$runtime" ]; then
|
if [ -z "$runtime" ]; then
|
||||||
echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)"
|
echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run"
|
||||||
sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true
|
echo "runtime=" >> "$GITHUB_OUTPUT"
|
||||||
runtime="$(find_runtime || true)"
|
exit 0
|
||||||
fi
|
|
||||||
if [ -z "$runtime" ]; then
|
|
||||||
echo "::error title=iOS 17 floor leg cannot run::no iOS 17.x simulator runtime and the download failed — the deployment-floor round did NOT run, so this job fails instead of reporting a false green" >&2
|
|
||||||
xcrun simctl list runtimes >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
||||||
echo "created $udid with $runtime"
|
echo "created $udid with $runtime"
|
||||||
|
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
|
||||||
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
||||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
||||||
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
|
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
|
||||||
#
|
|
||||||
# -skip-testing LiveServerSmokeTests: same reason as the iPad leg — that
|
|
||||||
# suite spawns node_modules/.bin/tsx and would hard-fail without `npm ci`.
|
|
||||||
# This leg's job is the DEPLOYMENT FLOOR (does the app build and behave on
|
|
||||||
# iOS 17), not the server contract, so it stays Node-free.
|
|
||||||
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
|
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
|
||||||
|
if: steps.sim17.outputs.runtime != ''
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||||
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" \
|
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test
|
||||||
-skip-testing:WebTermTests/LiveServerSmokeTests \
|
|
||||||
test
|
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -3,13 +3,6 @@ node_modules/
|
|||||||
|
|
||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
# EXCEPTION: `agent/src/dist/` holds SOURCE (the packaging config for the distributable binary),
|
|
||||||
# not build output. The blanket `dist/` above swallowed it, so it was never committed — a fresh
|
|
||||||
# clone was missing it and could neither typecheck `agent/src/index.ts` nor import it from the
|
|
||||||
# committed `agent/test/buildBinary.test.ts`. The directory must be re-included FIRST: git does not
|
|
||||||
# descend into an excluded directory, so un-ignoring only the file inside it would not work.
|
|
||||||
!agent/src/dist/
|
|
||||||
!agent/src/dist/**
|
|
||||||
public/build/
|
public/build/
|
||||||
desktop/build/
|
desktop/build/
|
||||||
desktop/dist-app/
|
desktop/dist-app/
|
||||||
@@ -17,9 +10,6 @@ desktop/dist-app/
|
|||||||
# local Claude Code settings (not shared)
|
# local Claude Code settings (not shared)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
# per-session git worktrees (EnterWorktree) — live on disk, never committed
|
|
||||||
.claude/worktrees/
|
|
||||||
|
|
||||||
# logs / OS cruft
|
# logs / OS cruft
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
22
CLAUDE.md
22
CLAUDE.md
@@ -16,26 +16,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
**Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*.
|
**Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*.
|
||||||
|
|
||||||
## Session Workflow: One Worktree per Session (MANDATORY)
|
|
||||||
|
|
||||||
**Every session that changes files works in its own git worktree, and merges back to `develop` when the work is done.** Don't develop directly on `develop` in the main checkout (the one exception is a change to this workflow itself — the rule can't bootstrap inside its own worktree).
|
|
||||||
|
|
||||||
1. **Start of session** — before touching any file, call the `EnterWorktree` tool with a task-descriptive name (e.g. `EnterWorktree({name: "fix-cjk-locale"})`). It creates `.claude/worktrees/<name>/` on branch **`worktree-<name>`** (the tool prefixes it — the name you pass is *not* the branch name) and moves the session's cwd into it. Do all work there.
|
|
||||||
- Base ref is `head` (configured in `.claude/settings.json` → `worktree.baseRef`), so the worktree branches from the **current `develop` HEAD**, not `origin/main`. This matters: `develop` runs ~75 commits ahead of `origin/main`, so the default `fresh` base ref would silently produce a badly stale worktree. `develop` is the working trunk; `main` is the release branch.
|
|
||||||
- It branches from the last **commit**, so uncommitted edits sitting in the main checkout do **not** carry over. Commit or stash them first if the task needs them.
|
|
||||||
- Read-only sessions (answering a question, inspecting a remote host) don't need a worktree — only create one when files will change.
|
|
||||||
2. **During the session** — commit inside the worktree as normal (conventional-commit format, see the global git-workflow rule). Tests/`tsc` run against the worktree copy, so concurrent sessions never collide on the working tree; each holds a `locked` worktree of its own, so never `git worktree remove` a directory this session didn't create.
|
|
||||||
3. **End of session — merge back.** Commit everything in the worktree first, then, **in this order**:
|
|
||||||
```bash
|
|
||||||
# 1. ExitWorktree({action: "keep"}) → cwd returns to the main checkout, branch survives
|
|
||||||
git merge --no-ff worktree-<name> # 2. from the main checkout, on develop
|
|
||||||
git worktree remove .claude/worktrees/<name> && git branch -d worktree-<name> # 3. clean up
|
|
||||||
```
|
|
||||||
**Order matters:** `ExitWorktree({action: "remove"})` deletes the branch along with the directory, so calling it before the merge throws the work away. (It does refuse when commits aren't yet on `develop` — a safety net, not a plan.) `keep` is also the right call whenever the work is unfinished and the session should be resumable.
|
|
||||||
4. **Do not merge to `main`** as part of this flow — `main` is promoted from `develop` separately.
|
|
||||||
|
|
||||||
Subagents dispatched with `isolation: worktree` (PLAN §4) get their own throwaway worktrees on top of this — that is a separate, nested mechanism and does not replace the session-level worktree.
|
|
||||||
|
|
||||||
## Development Workflow: Plan & Progress Log (MANDATORY)
|
## Development Workflow: Plan & Progress Log (MANDATORY)
|
||||||
|
|
||||||
Work proceeds against a **phased plan** and is tracked in a **progress log that acts as cross-session memory**. A new Claude instance must be able to read the log and know exactly where things stand. Follow these rules:
|
Work proceeds against a **phased plan** and is tracked in a **progress log that acts as cross-session memory**. A new Claude instance must be able to read the log and know exactly where things stand. Follow these rules:
|
||||||
@@ -85,8 +65,6 @@ npm test # unit tests (vitest, all modules)
|
|||||||
|
|
||||||
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
|
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
|
||||||
|
|
||||||
`WEBTERM_TOKEN` (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. **Unset ⇒ auth disabled**, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16–512 URL/cookie-safe chars (`[A-Za-z0-9._~+/=-]`) or the server refuses to start. Deliver it once via `GET /?token=<t>` (or `POST /auth`), which sets an `HttpOnly; SameSite=Strict; Secure-when-https` cookie the browser auto-sends thereafter; loopback hook ingest (`/hook*`) is exempt so the smart-features side-channel keeps working. **Honest tradeoff:** it is a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See `src/http/auth.ts` and `docs/plans/w5-access-token.md`.
|
|
||||||
|
|
||||||
## Architecture (the parts that span files)
|
## Architecture (the parts that span files)
|
||||||
|
|
||||||
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.
|
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.
|
||||||
|
|||||||
61
README.md
61
README.md
@@ -4,7 +4,7 @@ A self-hosted, browser-based terminal **and** Claude-Code session/project workbe
|
|||||||
|
|
||||||
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY.
|
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY.
|
||||||
|
|
||||||
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only by design; the optional `WEBTERM_TOKEN` access token raises the bar but is **not** a substitute for TLS/Tailscale. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
|
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,14 +24,6 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
|
|||||||
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
|
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
|
||||||
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
|
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
|
||||||
|
|
||||||
### Split-grid watch board (v0.8, desktop)
|
|
||||||
On a large screen (≥ 1024px) the terminal area can split into a grid so several **live, interactive** sessions show at once — built for watching multiple Claude Code runs in parallel. Desktop-only (a 2×2 of terminals is unusable on a phone); the server and wire protocol are untouched, and single-pane mode is unchanged.
|
|
||||||
- **Layouts** — a toolbar toggle cycles **single / 1×2 / 1×3 / 2×2 / 2×3**. The board shows the first N tabs (drag-reorder the tab bar, or drag a tab straight onto a quadrant, to choose which); a dashed **+ New session** tile fills any empty slot. The choice persists.
|
|
||||||
- **Click-to-focus** — the quadrant you click wears a focus ring and owns the keyboard, mobile key-bar, voice, and the approval bar; **Ctrl+`** cycles focus (⇧ reverses). Only the focused pane takes keyboard focus, so panes don't fight over it.
|
|
||||||
- **Inline approve per quadrant** — a background quadrant waiting on a tool permission glows amber and shows its own **✓ / ✗** buttons, so you can clear approvals across several sessions without switching; its OS notification is suppressed while it's on screen.
|
|
||||||
- **Maximize (⛶) / monitor (👁) per quadrant** — ⛶ expands one quadrant to fill the grid (the others stay live behind it); 👁 flips a quadrant to a **read-only preview** (polled screen snapshots — no WebSocket attach, no resize) so watching a session in a small quadrant never shrinks it for another device using it full-screen.
|
|
||||||
- **Resizable splitters + saved presets** — drag the gutters between panes to re-balance column/row sizes (persisted per layout), and save a layout + its split as a named preset to re-apply in one click.
|
|
||||||
|
|
||||||
### Claude Code cockpit
|
### Claude Code cockpit
|
||||||
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
|
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
|
||||||
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
|
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
|
||||||
@@ -42,8 +34,7 @@ On a large screen (≥ 1024px) the terminal area can split into a grid so severa
|
|||||||
### Projects (v0.6)
|
### Projects (v0.6)
|
||||||
- **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks.
|
- **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks.
|
||||||
- **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count).
|
- **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count).
|
||||||
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and worktree **create / prune / remove**.
|
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and a **create-worktree** action.
|
||||||
- **Git panel** — ambient git state on the detail page: a **sync band** (`↑ahead ↓behind`, upstream / detached-HEAD / "never fetched" states, green "in sync" only when nothing is pending *and* the last fetch is recent), a **commit log** with the unpushed boundary marked, **PR status** (via the host's `gh`, when installed), and **stage / commit / push / fetch** actions. All writes are Origin-guarded `POST /projects/git/*` routes running `git` through `execFile` (no shell) with timeouts.
|
|
||||||
|
|
||||||
### Walk-away workbench (v0.7)
|
### Walk-away workbench (v0.7)
|
||||||
- **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push.
|
- **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push.
|
||||||
@@ -60,35 +51,19 @@ On a large screen (≥ 1024px) the terminal area can split into a grid so severa
|
|||||||
|
|
||||||
## Clients
|
## Clients
|
||||||
|
|
||||||
The browser is the reference client; three native clients and a remote-access
|
The browser is the reference client; two native clients and a remote-access
|
||||||
service are also in the repo (all consume the same wire protocol — the server
|
service are also in the repo (all consume the same wire protocol — the server
|
||||||
stays a byte-shuttle).
|
stays a byte-shuttle).
|
||||||
|
|
||||||
- **iOS / iPad app** ([`ios/`](ios/)) — a native SwiftUI + SwiftTerm **pure
|
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native
|
||||||
remote client** built for the walk-away loop: native terminal with scrollback
|
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop:
|
||||||
replay, QR/manual pairing (Keychain), the remote **Approve / Reject** gate +
|
native terminal with scrollback replay, QR/manual pairing (Keychain), the
|
||||||
three-way plan gate, **APNs push with lock-screen Allow / Deny behind Face ID**,
|
remote **Approve / Reject** gate + three-way plan gate, **APNs push with
|
||||||
deep links (including the web's `?join=` share link), Projects with a **git
|
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session
|
||||||
panel + worktree lifecycle**, `claude --resume` history, multi-session switcher,
|
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
|
||||||
timeline, diff, quick-reply, **terminal find bar**, **voice push-to-talk with a
|
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
|
||||||
confirm step**, theme (system/dark/light) + Dynamic Type, and an **adaptive
|
changes; P1 adds two declared additive touch-points. See
|
||||||
iPhone/iPad split layout**. Supports the optional `WEBTERM_TOKEN` access token
|
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
|
||||||
(per-host, in the Keychain). Looks like the desktop (amber-gold, dark-first).
|
|
||||||
P0 needed zero server changes; P1 added two declared additive touch-points.
|
|
||||||
**Merged into `develop`.** See [`ios/README.md`](ios/README.md).
|
|
||||||
- **Android app** ([`android/`](android/)) — a Kotlin/Compose client targeting
|
|
||||||
functional parity with iOS, module-for-module mirroring the iOS package set
|
|
||||||
(`:wire-protocol` / `:session-core` / `:api-client` / `:client-tls` /
|
|
||||||
`:host-registry` / `:terminal-view` / `:app`). Same wire protocol, same
|
|
||||||
Origin-iff-guarded discipline, same **`WEBTERM_TOKEN`** contract (hand-written
|
|
||||||
cookie, Keystore-backed storage), plus FCM push with lock-screen Allow / Deny,
|
|
||||||
Projects, timeline, diff, quick-reply and an adaptive layout. Terminal
|
|
||||||
rendering wraps the Apache-2.0 **Termux** terminal view. All modules build and
|
|
||||||
unit-test locally (`./gradlew test :app:assembleDebug koverVerify`) and the app
|
|
||||||
has been installed and launched on an emulator; **real-device QA is still
|
|
||||||
pending**. Design in
|
|
||||||
[`docs/ANDROID_CLIENT_PLAN.md`](docs/ANDROID_CLIENT_PLAN.md), setup notes in
|
|
||||||
[`android/README.md`](android/README.md).
|
|
||||||
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
|
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
|
||||||
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
||||||
tray, deep links and launch-at-login. Design in
|
tray, deep links and launch-at-login. Design in
|
||||||
@@ -138,7 +113,7 @@ This wires Claude Code's hooks → **live per-tab status**, the **statusLine gau
|
|||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
```bash
|
```bash
|
||||||
npm test # vitest, all modules (~1600 tests, 80% coverage gate)
|
npm test # vitest, all modules (~1470 tests, 80% coverage gate)
|
||||||
npm run typecheck # tsc (backend + frontend)
|
npm run typecheck # tsc (backend + frontend)
|
||||||
npm run build # compile backend to dist/
|
npm run build # compile backend to dist/
|
||||||
```
|
```
|
||||||
@@ -163,7 +138,6 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
|
|||||||
| `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). |
|
| `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). |
|
||||||
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH. |
|
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH. |
|
||||||
| `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. |
|
| `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. |
|
||||||
| `WEBTERM_TOKEN` | (unset → auth **disabled**, **secret**) | Optional shared access token gating every HTTP route + the WS upgrade behind a `webterm_auth` cookie (alongside, never instead of, the Origin check). 16–512 chars of `[A-Za-z0-9._~+/=-]` or the server refuses to start. Never logged. See [Security & deployment](#security--deployment) for the honest limits. |
|
|
||||||
| `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. |
|
| `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. |
|
||||||
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
|
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
|
||||||
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
|
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
|
||||||
@@ -209,15 +183,14 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
|
|||||||
|
|
||||||
## Security & deployment
|
## Security & deployment
|
||||||
|
|
||||||
This is a **LAN-only** tool by design — with no token set it hands a full shell to anyone who can reach the port, and even with one set it is not an internet-facing service. The defenses that matter:
|
This is a **no-auth, LAN-only** tool by design — it hands a full shell to anyone who can reach the port. The defenses that matter:
|
||||||
|
|
||||||
- **WS Origin validation (cannot be skipped):** the WebSocket handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`. Only `WS_PATH` is accepted for upgrade.
|
- **WS Origin validation (cannot be skipped):** the WebSocket handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`. Only `WS_PATH` is accepted for upgrade.
|
||||||
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree` + `/worktree/prune` + `DELETE /projects/worktree`, `POST /projects/git/{stage,commit,push,fetch}`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
|
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
|
||||||
- **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host.
|
- **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host.
|
||||||
- **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap.
|
- **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap.
|
||||||
- **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout).
|
- **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout).
|
||||||
- **Optional shared access token (`WEBTERM_TOKEN`) — a bar-raiser, not authentication.** Unset ⇒ the gate is **disabled** and behaviour is byte-identical to a no-auth server (LAN zero-config preserved). Set (16–512 chars of `[A-Za-z0-9._~+/=-]`, else the server refuses to start) ⇒ every HTTP route **and** the WS upgrade require an `HttpOnly; SameSite=Strict` `webterm_auth` cookie, delivered once by `GET /?token=<t>` or `POST /auth`; the loopback hook ingest (`/hook*`) stays exempt so the Claude Code side-channel keeps working. It sits *in front of* the Origin check and never replaces it. **Honest limits:** on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it is one shared secret with no per-user identity and no revocation short of changing the env var and restarting. It meaningfully hardens only the TLS-terminated relay/tunnel path. See `src/http/auth.ts` and [`docs/plans/w5-access-token.md`](docs/plans/w5-access-token.md).
|
- **No authentication yet.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
|
||||||
- **Beyond that, there is no authentication.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -227,6 +200,6 @@ The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a r
|
|||||||
|
|
||||||
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
|
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
|
||||||
|
|
||||||
Tested with **vitest** (~1600 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
|
Tested with **vitest** (~470 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
|
||||||
|
|
||||||
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).
|
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
/**
|
|
||||||
* Native-tunnel cert auto-renew wiring — TASK A5 (PLAN_ZERO_TOUCH_ROLLOUT).
|
|
||||||
*
|
|
||||||
* The native run-loop (`superviseNative`) used to only MONITOR the frp-client leaf's freshness; the
|
|
||||||
* leaf therefore expired at ~24h and the tunnel dropped until a manual re-pair. This module closes
|
|
||||||
* that gap by driving `createCertRotator`/`renewCert` (crypto is REUSED, never reimplemented):
|
|
||||||
*
|
|
||||||
* - `createMtlsFetch` — the injected `fetchImpl` the rotator hands to `renewCert`. It POSTs /renew
|
|
||||||
* over mTLS presenting the CURRENT keystore leaf (re-read on every call, so the first renewal
|
|
||||||
* after a rotation already authenticates with the freshly issued leaf). mTLS IS the auth — no
|
|
||||||
* token, `rejectUnauthorized` always true (INV4/INV14). The private key stays in-process.
|
|
||||||
* - `wireAutoRenew` — routes the rotator callbacks: rotated → restart frpc onto the new leaf and
|
|
||||||
* log (non-secret); revoked (403) → tear the tunnel down (INV12); error → log + let the rotator
|
|
||||||
* retry with backoff. A failed renewal NEVER crashes the supervisor.
|
|
||||||
* - `startNativeAutoRenew` — the builder `superviseNative` calls: loads the identity, builds the
|
|
||||||
* mTLS fetch + rotator at ~2/3-TTL, and starts it. Returns null (auto-renew disabled) if the host
|
|
||||||
* is not enrolled (no identity), rather than throwing into the run-loop.
|
|
||||||
*/
|
|
||||||
import { request as httpsRequest } from 'node:https'
|
|
||||||
import type { AgentConfig } from '../config/agentConfig.js'
|
|
||||||
import { resolveHostIdentity } from '../config/hostRecord.js'
|
|
||||||
import type { Keystore } from '../keys/keystore.js'
|
|
||||||
import type { Logger } from '../log/logger.js'
|
|
||||||
import type { TimerLike } from '../transport/seams.js'
|
|
||||||
import { createBackoff } from '../transport/backoff.js'
|
|
||||||
import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js'
|
|
||||||
import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js'
|
|
||||||
import {
|
|
||||||
createCertRotator,
|
|
||||||
type CertExpiredBeyondGraceError,
|
|
||||||
type CertRotator,
|
|
||||||
} from './rotation.js'
|
|
||||||
|
|
||||||
/** Non-secret message from an unknown thrown value (never serializes cert/key material). */
|
|
||||||
function errorMessage(err: unknown): string {
|
|
||||||
return err instanceof Error ? err.message : String(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Socket-idle timeout for a /renew request. A stalled or overloaded control-plane (or a NAT that
|
|
||||||
* silently drops the connection after the TLS handshake) must NOT leave the renewal Promise pending
|
|
||||||
* forever — that would starve the rotator's backoff-retry loop and let the leaf silently expire. On
|
|
||||||
* timeout the request is destroyed and the rejection surfaces through the rotator's onError→backoff.
|
|
||||||
*/
|
|
||||||
export const RENEW_REQUEST_TIMEOUT_MS = 15_000
|
|
||||||
/**
|
|
||||||
* Hard cap on the buffered /renew response body. The reply is a small `{cert,caChain}` JSON; anything
|
|
||||||
* beyond a few KB is malformed or hostile, so we destroy the stream and reject rather than buffer it.
|
|
||||||
*/
|
|
||||||
export const MAX_RENEW_RESPONSE_BYTES = 64 * 1024
|
|
||||||
|
|
||||||
// --- mTLS fetch --------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** A single mTLS request the fetch shim delegates to (injectable so the shim is offline-testable). */
|
|
||||||
export interface MtlsRequestInit {
|
|
||||||
readonly method: string
|
|
||||||
readonly headers: Record<string, string>
|
|
||||||
readonly body?: string
|
|
||||||
}
|
|
||||||
export interface MtlsResponse {
|
|
||||||
readonly status: number
|
|
||||||
readonly body: string
|
|
||||||
}
|
|
||||||
export type MtlsRequest = (
|
|
||||||
url: string,
|
|
||||||
tls: TlsClientOptions,
|
|
||||||
init: MtlsRequestInit,
|
|
||||||
) => Promise<MtlsResponse>
|
|
||||||
|
|
||||||
/** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */
|
|
||||||
const defaultMtlsRequest: MtlsRequest = (url, tls, init) =>
|
|
||||||
new Promise<MtlsResponse>((resolve, reject) => {
|
|
||||||
const req = httpsRequest(
|
|
||||||
url,
|
|
||||||
{
|
|
||||||
method: init.method,
|
|
||||||
headers: init.headers,
|
|
||||||
cert: tls.cert,
|
|
||||||
key: tls.key,
|
|
||||||
// ca omitted ⇒ verify the server against the system roots (LE-fronted CP). Present only when a
|
|
||||||
// private CA is pinned (not for /renew).
|
|
||||||
...(tls.ca !== undefined ? { ca: tls.ca } : {}),
|
|
||||||
rejectUnauthorized: tls.rejectUnauthorized, // always true (anti-MITM, INV14)
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
const chunks: Buffer[] = []
|
|
||||||
let total = 0
|
|
||||||
res.on('data', (c: Buffer) => {
|
|
||||||
total += c.length
|
|
||||||
if (total > MAX_RENEW_RESPONSE_BYTES) {
|
|
||||||
res.destroy() // MEDIUM: refuse an unbounded body — a renew reply is a few-KB JSON
|
|
||||||
reject(new Error(`renew response body exceeded ${MAX_RENEW_RESPONSE_BYTES} byte cap`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chunks.push(c)
|
|
||||||
})
|
|
||||||
res.on('end', () =>
|
|
||||||
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
|
|
||||||
)
|
|
||||||
res.on('error', reject) // a mid-stream socket error must reject, not hang
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// HIGH: bound the request so a peer that accepts the connection but never replies rejects (and the
|
|
||||||
// rotator re-enters backoff) instead of pending forever — destroy(err) emits 'error' → reject below.
|
|
||||||
req.setTimeout(RENEW_REQUEST_TIMEOUT_MS, () => {
|
|
||||||
req.destroy(new Error(`renew request timed out after ${RENEW_REQUEST_TIMEOUT_MS}ms`))
|
|
||||||
})
|
|
||||||
req.on('error', reject)
|
|
||||||
if (init.body !== undefined) req.write(init.body)
|
|
||||||
req.end()
|
|
||||||
})
|
|
||||||
|
|
||||||
function toHeaderRecord(headers: RequestInit['headers']): Record<string, string> {
|
|
||||||
if (!headers) return {}
|
|
||||||
if (headers instanceof Headers) {
|
|
||||||
const out: Record<string, string> = {}
|
|
||||||
headers.forEach((v, k) => {
|
|
||||||
out[k] = v
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
if (Array.isArray(headers)) return Object.fromEntries(headers)
|
|
||||||
return { ...(headers as Record<string, string>) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the `fetch`-shaped shim `renewCert` uses. Each call re-reads the CURRENT keystore leaf via
|
|
||||||
* `buildTlsOptions` (which fail-fast throws NotEnrolled/CertExpired — the rotator then logs + retries
|
|
||||||
* with backoff, never crashing) and delegates to the mTLS transport, mapping the result to a real
|
|
||||||
* `Response` (so `res.ok`/`res.status`/`res.json()` behave exactly as `renewCert` expects).
|
|
||||||
*/
|
|
||||||
export function createMtlsFetch(
|
|
||||||
ks: Keystore,
|
|
||||||
opts: { request?: MtlsRequest; certParser?: CertParser } = {},
|
|
||||||
): typeof fetch {
|
|
||||||
const request = opts.request ?? defaultMtlsRequest
|
|
||||||
const shim = async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
|
||||||
const url = typeof input === 'string' ? input : input.toString()
|
|
||||||
// Present the current frp-client leaf (client auth), but verify the /renew SERVER cert against the
|
|
||||||
// SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private
|
|
||||||
// enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent →
|
|
||||||
// node uses the default roots); rejectUnauthorized stays true.
|
|
||||||
// Deliberately still fail-closed on an EXPIRED leaf: nginx would refuse to forward it anyway, so
|
|
||||||
// a lapsed leaf is routed to the plain `/recover` endpoint by the rotator instead of through here.
|
|
||||||
const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) })
|
|
||||||
const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized }
|
|
||||||
const reqInit: MtlsRequestInit = {
|
|
||||||
method: init?.method ?? 'GET',
|
|
||||||
headers: toHeaderRecord(init?.headers),
|
|
||||||
...(typeof init?.body === 'string' ? { body: init.body } : {}),
|
|
||||||
}
|
|
||||||
const { status, body } = await request(url, tls, reqInit)
|
|
||||||
return new Response(body, { status })
|
|
||||||
}
|
|
||||||
return shim as typeof fetch
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- rotator wiring ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Non-secret identifiers logged alongside renew events (INV9). */
|
|
||||||
export interface AutoRenewLogIds {
|
|
||||||
readonly subdomain: string | null
|
|
||||||
readonly hostId: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The two run-loop effects the rotator drives. */
|
|
||||||
export interface AutoRenewHooks {
|
|
||||||
/** Restart the supervised frpc so it re-reads the rotated cert (a leaf rotation only). */
|
|
||||||
restartChild(): void
|
|
||||||
/** Tear the tunnel down (host revoked ⇒ never reconnect, INV12). */
|
|
||||||
stop(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handle for the wired auto-renew loop. */
|
|
||||||
export interface AutoRenewController {
|
|
||||||
stop(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wire a rotator's callbacks to the run-loop and start it. Rotated → restart frpc; revoked → stop;
|
|
||||||
* error → log (non-secret) and let the rotator retry with backoff. Returns a controller that stops
|
|
||||||
* the rotator's scheduled timer.
|
|
||||||
*/
|
|
||||||
export function wireAutoRenew(
|
|
||||||
rotator: CertRotator,
|
|
||||||
hooks: AutoRenewHooks,
|
|
||||||
logger: Logger,
|
|
||||||
ids: AutoRenewLogIds,
|
|
||||||
): AutoRenewController {
|
|
||||||
const meta = { subdomain: ids.subdomain, hostId: ids.hostId }
|
|
||||||
rotator.onRotated(() => {
|
|
||||||
logger.log('info', 'frp-client cert rotated; restarting frpc onto the fresh leaf', meta)
|
|
||||||
hooks.restartChild()
|
|
||||||
})
|
|
||||||
rotator.onRevoked(() => {
|
|
||||||
logger.log('warn', 'frp-client cert renewal refused (host revoked); tearing down tunnel', meta)
|
|
||||||
hooks.stop()
|
|
||||||
})
|
|
||||||
rotator.onError((err) => {
|
|
||||||
logger.log('warn', 'frp-client cert renewal failed; will retry with backoff', {
|
|
||||||
...meta,
|
|
||||||
error: errorMessage(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
// Terminal: the grace window is spent, so every further attempt is guaranteed to fail. Say so once,
|
|
||||||
// at error level, naming the fix — and deliberately do NOT stop the supervisor: `pair` writes fresh
|
|
||||||
// cert files that the restart-on-exit frpc child picks up without a manual service restart.
|
|
||||||
rotator.onExhausted((err) => {
|
|
||||||
logger.log('error', 'frp-client cert expired beyond recovery grace — run `web-terminal-agent pair <CODE>` to re-pair this host', {
|
|
||||||
...meta,
|
|
||||||
expiredForMs: err.expiredForMs,
|
|
||||||
graceMs: err.graceMs,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
rotator.start()
|
|
||||||
return { stop: () => rotator.stop() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- builder -----------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Injection seams for `startNativeAutoRenew` (all optional; unset ⇒ real transport/timers). */
|
|
||||||
export interface NativeAutoRenewOpts {
|
|
||||||
readonly mtlsRequest?: MtlsRequest
|
|
||||||
readonly certParser?: CertParser
|
|
||||||
/** Window in which an already-expired leaf may still be recovered via `/recover`. */
|
|
||||||
readonly expiredGraceMs?: number
|
|
||||||
/** Plain (NON-mTLS) fetch for the `/recover` call; unset ⇒ global fetch. */
|
|
||||||
readonly recoverFetchImpl?: typeof fetch
|
|
||||||
readonly timer?: TimerLike
|
|
||||||
readonly renewBeforeMs?: number
|
|
||||||
readonly retryBaseMs?: number
|
|
||||||
readonly now?: () => Date
|
|
||||||
readonly parseCert?: (pem: string) => Date
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build + start native cert auto-renew for `superviseNative`. Renews at ~2/3 of the leaf TTL
|
|
||||||
* (default `DEFAULT_CERT_RENEW_WINDOW_MS`, the same window the health probe alarms on). Returns null
|
|
||||||
* (auto-renew disabled, logged) when the host has no identity — an unenrolled run-loop must not throw.
|
|
||||||
*/
|
|
||||||
export function startNativeAutoRenew(
|
|
||||||
cfg: AgentConfig,
|
|
||||||
ks: Keystore,
|
|
||||||
hooks: AutoRenewHooks,
|
|
||||||
logger: Logger,
|
|
||||||
opts: NativeAutoRenewOpts = {},
|
|
||||||
): AutoRenewController | null {
|
|
||||||
const id = ks.loadIdentity()
|
|
||||||
if (id === null) {
|
|
||||||
logger.log('warn', 'no identity in keystore — cert auto-renew disabled', {})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const fetchImpl = createMtlsFetch(ks, {
|
|
||||||
...(opts.mtlsRequest ? { request: opts.mtlsRequest } : {}),
|
|
||||||
...(opts.certParser ? { certParser: opts.certParser } : {}),
|
|
||||||
})
|
|
||||||
const rotator = createCertRotator(cfg, id, ks, {
|
|
||||||
fetchImpl,
|
|
||||||
...(opts.expiredGraceMs !== undefined ? { expiredGraceMs: opts.expiredGraceMs } : {}),
|
|
||||||
...(opts.recoverFetchImpl ? { recoverFetchImpl: opts.recoverFetchImpl } : {}),
|
|
||||||
renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS,
|
|
||||||
...(opts.timer ? { timer: opts.timer } : {}),
|
|
||||||
...(opts.now ? { now: opts.now } : {}),
|
|
||||||
...(opts.parseCert ? { parseCert: opts.parseCert } : {}),
|
|
||||||
...(opts.retryBaseMs !== undefined
|
|
||||||
? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) }
|
|
||||||
: {}),
|
|
||||||
})
|
|
||||||
// Prefer the resolved identity (config > enrolment record > leaf SPIFFE SAN) so renewal warnings
|
|
||||||
// actually name the host — `cfg` alone is null on every install that predates the record.
|
|
||||||
const ids = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
|
|
||||||
return wireAutoRenew(rotator, hooks, logger, ids)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* PEM helpers shared by the native enroll (enroll/pair.ts) and renew (certs/rotation.ts) paths.
|
|
||||||
*
|
|
||||||
* The control-plane returns the frp-client leaf + CA chain as base64-encoded DER (cert as a string,
|
|
||||||
* caChain as a string[]); the keystore + frpc need PEM files. `derBase64ToPem` wraps a base64 DER body
|
|
||||||
* back into CERTIFICATE armor at 64 columns.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** base64(DER) → PEM (CERTIFICATE armor, 64-col wrapped). */
|
|
||||||
export function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string {
|
|
||||||
const body = derBase64.replace(/\s+/g, '')
|
|
||||||
const lines = body.match(/.{1,64}/g) ?? []
|
|
||||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a control-plane cert response ({cert: base64 DER, caChain: base64 DER[]}) to PEM strings
|
|
||||||
* for the keystore. Throws if the shape is wrong. Shared by enroll + renew so both stay in lockstep.
|
|
||||||
*/
|
|
||||||
export function certResponseToPem(cert: unknown, caChain: unknown): { certPem: string; caChainPem: string } {
|
|
||||||
if (
|
|
||||||
typeof cert !== 'string' ||
|
|
||||||
!Array.isArray(caChain) ||
|
|
||||||
caChain.length === 0 ||
|
|
||||||
!caChain.every((c) => typeof c === 'string')
|
|
||||||
) {
|
|
||||||
throw new Error('cert response missing cert/caChain')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
certPem: derBase64ToPem(cert),
|
|
||||||
caChainPem: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,48 +13,15 @@ import type { AgentConfig } from '../config/agentConfig.js'
|
|||||||
import type { AgentIdentity } from '../keys/identity.js'
|
import type { AgentIdentity } from '../keys/identity.js'
|
||||||
import type { Keystore } from '../keys/keystore.js'
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
import type { TimerLike } from '../transport/seams.js'
|
import type { TimerLike } from '../transport/seams.js'
|
||||||
import { createBackoff, type BackoffPolicy } from '../transport/backoff.js'
|
|
||||||
import { buildCsr } from '../enroll/csr.js'
|
import { buildCsr } from '../enroll/csr.js'
|
||||||
import { certResponseToPem } from './pem.js'
|
|
||||||
|
|
||||||
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
|
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
|
||||||
|
|
||||||
/**
|
|
||||||
* How long after `notAfter` a lapsed leaf may still be swapped for a fresh one (30 days).
|
|
||||||
*
|
|
||||||
* `/renew` is mTLS-authenticated by the very leaf it renews, so a lapsed leaf cannot renew itself —
|
|
||||||
* a deadlock that bricked a host for 8 days in production (the laptop slept through its renewal
|
|
||||||
* window, then the agent logged `client certificate has expired` 6380 times and never recovered).
|
|
||||||
* Inside this window the agent switches to the `/recover` endpoint instead; past it, only a re-pair
|
|
||||||
* can help and the rotator says so once and stops.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_EXPIRED_RENEW_GRACE_MS = 30 * 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
/** The leaf lapsed longer ago than the recovery grace allows ⇒ operator must re-pair this host. */
|
|
||||||
export class CertExpiredBeyondGraceError extends Error {
|
|
||||||
constructor(
|
|
||||||
/** How long ago the leaf expired (ms) — non-secret, safe to log. */
|
|
||||||
readonly expiredForMs: number,
|
|
||||||
/** The grace window that was exceeded (ms). */
|
|
||||||
readonly graceMs: number,
|
|
||||||
) {
|
|
||||||
super('client certificate expired beyond the recovery grace window; re-pair required')
|
|
||||||
this.name = 'CertExpiredBeyondGraceError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CertRotator {
|
export interface CertRotator {
|
||||||
start(): void
|
start(): void
|
||||||
stop(): void
|
stop(): void
|
||||||
onRotated(cb: () => void): void
|
onRotated(cb: () => void): void
|
||||||
onRevoked(cb: () => void): void
|
onRevoked(cb: () => void): void
|
||||||
/** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */
|
|
||||||
onError(cb: (err: unknown) => void): void
|
|
||||||
/**
|
|
||||||
* TERMINAL: the leaf expired past the renewal grace window, so no future attempt can succeed. The
|
|
||||||
* rotator has stopped; recovery requires an operator re-pair.
|
|
||||||
*/
|
|
||||||
onExhausted(cb: (err: CertExpiredBeyondGraceError) => void): void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenewOutcome = 'rotated' | 'revoked'
|
export type RenewOutcome = 'rotated' | 'revoked'
|
||||||
@@ -64,23 +31,6 @@ export function renewalUrlFor(cfg: AgentConfig): string {
|
|||||||
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
|
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Recovery route for a leaf that has ALREADY EXPIRED — a sibling PATH on the same enroll host.
|
|
||||||
*
|
|
||||||
* It cannot be `/renew`, because nginx will not forward an expired client certificate at all: under
|
|
||||||
* `ssl_verify_client optional` it answers a bare `400 Bad Request`, and `optional_no_ca` does not
|
|
||||||
* help either — nginx only tolerates CHAIN errors there (`ngx_ssl_verify_error_optional` covers
|
|
||||||
* self-signed / unknown-issuer / unverifiable-leaf, NOT `X509_V_ERR_CERT_HAS_EXPIRED`). So recovery
|
|
||||||
* drops mTLS entirely: it is a plain HTTPS POST carrying the expired cert in the BODY. Nothing is
|
|
||||||
* lost by that — the accompanying CSR is self-signed by the same private key, and the control-plane
|
|
||||||
* signer already enforces CSR proof-of-possession plus `CSR key == registered key`, so possession is
|
|
||||||
* proven exactly as the TLS handshake used to prove it.
|
|
||||||
*/
|
|
||||||
export function recoveryUrlFor(cfg: AgentConfig): string {
|
|
||||||
if (cfg.recoverUrl != null && cfg.recoverUrl.length > 0) return cfg.recoverUrl
|
|
||||||
return cfg.enrollUrl.replace(/\/enroll$/, '/recover')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ms until (validTo − renewBeforeMs), clamped to ≥ 0. */
|
/** Ms until (validTo − renewBeforeMs), clamped to ≥ 0. */
|
||||||
export function computeRenewDelayMs(
|
export function computeRenewDelayMs(
|
||||||
certPem: string,
|
certPem: string,
|
||||||
@@ -102,49 +52,20 @@ export async function renewCert(
|
|||||||
id: AgentIdentity,
|
id: AgentIdentity,
|
||||||
ks: Keystore,
|
ks: Keystore,
|
||||||
fetchImpl: typeof fetch,
|
fetchImpl: typeof fetch,
|
||||||
opts: { url?: string } = {},
|
|
||||||
): Promise<RenewOutcome> {
|
): Promise<RenewOutcome> {
|
||||||
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
||||||
const res = await fetchImpl(opts.url ?? renewalUrlFor(cfg), {
|
const res = await fetchImpl(renewalUrlFor(cfg), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ csr }),
|
body: JSON.stringify({ csr }),
|
||||||
})
|
})
|
||||||
if (res.status === 403) return 'revoked'
|
if (res.status === 403) return 'revoked'
|
||||||
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
||||||
// The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the
|
const json = (await res.json()) as { cert?: string; caChain?: string }
|
||||||
// keystore + frpc (same shape as native enroll).
|
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') {
|
||||||
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
|
throw new Error('cert renewal response missing cert/caChain')
|
||||||
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
|
|
||||||
ks.saveCert(certPem, caChainPem) // atomic whole-file install
|
|
||||||
return 'rotated'
|
|
||||||
}
|
}
|
||||||
|
ks.saveCert(json.cert, json.caChain) // atomic whole-file install
|
||||||
/**
|
|
||||||
* One recovery round-trip for an EXPIRED leaf: plain HTTPS (no client cert — see `recoveryUrlFor`)
|
|
||||||
* POSTing the expired cert alongside a fresh CSR over the SAME key. Same outcome contract as
|
|
||||||
* `renewCert`: 'rotated' installs the new leaf, 403 ⇒ 'revoked', anything else throws to the retry.
|
|
||||||
*/
|
|
||||||
export async function recoverCert(
|
|
||||||
cfg: AgentConfig,
|
|
||||||
id: AgentIdentity,
|
|
||||||
ks: Keystore,
|
|
||||||
fetchImpl: typeof fetch,
|
|
||||||
url: string = recoveryUrlFor(cfg),
|
|
||||||
): Promise<RenewOutcome> {
|
|
||||||
const certs = ks.loadCert()
|
|
||||||
if (certs === null) throw new Error('cannot recover without the expired leaf on disk')
|
|
||||||
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
|
||||||
const res = await fetchImpl(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ cert: certs.certPem, csr }),
|
|
||||||
})
|
|
||||||
if (res.status === 403) return 'revoked'
|
|
||||||
if (!res.ok) throw new Error(`cert recovery failed: HTTP ${res.status}`)
|
|
||||||
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
|
|
||||||
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
|
|
||||||
ks.saveCert(certPem, caChainPem)
|
|
||||||
return 'rotated'
|
return 'rotated'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +79,6 @@ export function createCertRotator(
|
|||||||
fetchImpl?: typeof fetch
|
fetchImpl?: typeof fetch
|
||||||
now?: () => Date
|
now?: () => Date
|
||||||
parseCert?: (pem: string) => Date
|
parseCert?: (pem: string) => Date
|
||||||
/** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */
|
|
||||||
retryBackoff?: BackoffPolicy
|
|
||||||
/** Plain (NON-mTLS) fetch used only for the expired-leaf `/recover` call. */
|
|
||||||
recoverFetchImpl?: typeof fetch
|
|
||||||
/** Window in which an expired leaf may still be recovered. 0 ⇒ no recovery at all. */
|
|
||||||
expiredGraceMs?: number
|
|
||||||
} = {},
|
} = {},
|
||||||
): CertRotator {
|
): CertRotator {
|
||||||
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
||||||
@@ -175,15 +90,10 @@ export function createCertRotator(
|
|||||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||||
}
|
}
|
||||||
const doFetch = opts.fetchImpl ?? fetch
|
const doFetch = opts.fetchImpl ?? fetch
|
||||||
const recoverFetch = opts.recoverFetchImpl ?? fetch
|
|
||||||
const expiredGraceMs = opts.expiredGraceMs ?? DEFAULT_EXPIRED_RENEW_GRACE_MS
|
|
||||||
const now = opts.now ?? (() => new Date())
|
const now = opts.now ?? (() => new Date())
|
||||||
const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true })
|
|
||||||
let handle: unknown = null
|
let handle: unknown = null
|
||||||
let rotatedCb: (() => void) | null = null
|
let rotatedCb: (() => void) | null = null
|
||||||
let revokedCb: (() => void) | null = null
|
let revokedCb: (() => void) | null = null
|
||||||
let errorCb: ((err: unknown) => void) | null = null
|
|
||||||
let exhaustedCb: ((err: CertExpiredBeyondGraceError) => void) | null = null
|
|
||||||
|
|
||||||
function schedule(): void {
|
function schedule(): void {
|
||||||
const certs = ks.loadCert()
|
const certs = ks.loadCert()
|
||||||
@@ -192,49 +102,19 @@ export function createCertRotator(
|
|||||||
handle = timer.setTimeout(runRenewal, delay)
|
handle = timer.setTimeout(runRenewal, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How this attempt must be made, derived from how stale the leaf on disk is:
|
|
||||||
* `normal` — still valid ⇒ ordinary mTLS `/renew`;
|
|
||||||
* `recover` — expired but inside the grace window ⇒ plain `/recover` with the cert in the body;
|
|
||||||
* `exhausted` — expired past the grace window ⇒ nothing can succeed; only an operator re-pair.
|
|
||||||
*/
|
|
||||||
function attemptPlan(): { mode: 'normal' | 'recover' | 'exhausted'; expiredForMs: number } {
|
|
||||||
const certs = ks.loadCert()
|
|
||||||
if (certs === null) return { mode: 'normal', expiredForMs: 0 }
|
|
||||||
const expiredForMs = now().getTime() - parseCert(certs.certPem).getTime()
|
|
||||||
if (expiredForMs <= 0) return { mode: 'normal', expiredForMs: 0 }
|
|
||||||
return { mode: expiredForMs > expiredGraceMs ? 'exhausted' : 'recover', expiredForMs }
|
|
||||||
}
|
|
||||||
|
|
||||||
function runRenewal(): void {
|
function runRenewal(): void {
|
||||||
const plan = attemptPlan()
|
void renewCert(cfg, id, ks, doFetch)
|
||||||
if (plan.mode === 'exhausted') {
|
|
||||||
// Terminal: report ONCE and arm nothing. The old code retried forever, which is how a single
|
|
||||||
// real failure turned into 6380 identical warnings that buried the signal.
|
|
||||||
handle = null
|
|
||||||
exhaustedCb?.(new CertExpiredBeyondGraceError(plan.expiredForMs, expiredGraceMs))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const attempt =
|
|
||||||
plan.mode === 'recover'
|
|
||||||
? recoverCert(cfg, id, ks, recoverFetch)
|
|
||||||
: renewCert(cfg, id, ks, doFetch)
|
|
||||||
void attempt
|
|
||||||
.then((outcome) => {
|
.then((outcome) => {
|
||||||
if (outcome === 'revoked') {
|
if (outcome === 'revoked') {
|
||||||
revokedCb?.()
|
revokedCb?.()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle
|
|
||||||
rotatedCb?.()
|
rotatedCb?.()
|
||||||
schedule()
|
schedule()
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch(() => {
|
||||||
// Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry
|
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile.
|
||||||
// with backoff. The cert is still valid until expiry, so the tunnel stays up meanwhile — a
|
handle = timer.setTimeout(runRenewal, renewBeforeMs)
|
||||||
// failed renewal must NEVER tear the supervisor down.
|
|
||||||
errorCb?.(err)
|
|
||||||
handle = timer.setTimeout(runRenewal, retryBackoff.nextDelayMs())
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,11 +132,5 @@ export function createCertRotator(
|
|||||||
onRevoked(cb): void {
|
onRevoked(cb): void {
|
||||||
revokedCb = cb
|
revokedCb = cb
|
||||||
},
|
},
|
||||||
onError(cb): void {
|
|
||||||
errorCb = cb
|
|
||||||
},
|
|
||||||
onExhausted(cb): void {
|
|
||||||
exhaustedCb = cb
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { dirname, join } from 'node:path'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { CliDeps, NativeEnrollResult } from '../cli.js'
|
import type { CliDeps, NativeEnrollResult } from '../cli.js'
|
||||||
import type { AgentConfig } from '../config/agentConfig.js'
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
import { resolveHostIdentity, saveHostRecord } from '../config/hostRecord.js'
|
|
||||||
import { loadAgentConfig } from '../config/agentConfig.js'
|
import { loadAgentConfig } from '../config/agentConfig.js'
|
||||||
import { openKeystore } from '../keys/keystore.js'
|
import { openKeystore } from '../keys/keystore.js'
|
||||||
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
|
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
|
||||||
@@ -24,7 +23,6 @@ import { runTunnel } from '../transport/runTunnel.js'
|
|||||||
import { buildNativeFrpcToml } from '../transport/frpcToml.js'
|
import { buildNativeFrpcToml } from '../transport/frpcToml.js'
|
||||||
import { superviseFrpc } from '../transport/frpSupervise.js'
|
import { superviseFrpc } from '../transport/frpSupervise.js'
|
||||||
import { provisionFrpc } from '../provision/frpcBinary.js'
|
import { provisionFrpc } from '../provision/frpcBinary.js'
|
||||||
import { startNativeAutoRenew } from '../certs/nativeRenew.js'
|
|
||||||
import {
|
import {
|
||||||
probeLoopbackBaseApp,
|
probeLoopbackBaseApp,
|
||||||
renderHealthStatus,
|
renderHealthStatus,
|
||||||
@@ -100,10 +98,7 @@ async function enrollNative(
|
|||||||
id: AgentIdentity,
|
id: AgentIdentity,
|
||||||
ks: Keystore,
|
ks: Keystore,
|
||||||
): Promise<NativeEnrollResult> {
|
): Promise<NativeEnrollResult> {
|
||||||
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true })
|
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks)
|
||||||
// Write the identifiers down: the long-running `run` process has no other way to learn them, and
|
|
||||||
// without them every log line from the tunnel reads `{"subdomain":null,"hostId":null}`.
|
|
||||||
saveHostRecord(cfg.stateDir, { hostId: enroll.hostId, subdomain: enroll.subdomain })
|
|
||||||
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
|
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,12 +156,9 @@ export function readFrpcLog(stateDir: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Native run-loop (B4/H4 + A5): supervise the pinned frpc child with restart-on-exit backoff while a
|
* Native run-loop (B4/H4): supervise the pinned frpc child with restart-on-exit backoff while a
|
||||||
* periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring)
|
* periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring)
|
||||||
* logs NON-SECRET status only (INV9), AND auto-renew the frp-client leaf at ~2/3 TTL so the tunnel
|
* logs NON-SECRET status only (INV9). Resolves when the supervisor stops (SIGTERM/SIGINT).
|
||||||
* never drops on cert expiry (A5): a successful renewal restarts frpc onto the fresh leaf, a 403
|
|
||||||
* revoke tears the tunnel down, and a failed renewal retries with backoff without crashing the
|
|
||||||
* supervisor. Resolves when the supervisor stops (SIGTERM/SIGINT).
|
|
||||||
*/
|
*/
|
||||||
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
||||||
const logger = createLogger('info')
|
const logger = createLogger('info')
|
||||||
@@ -188,33 +180,16 @@ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
|||||||
}),
|
}),
|
||||||
(report) => {
|
(report) => {
|
||||||
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
|
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
|
||||||
const host = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
|
const ids = { subdomain: cfg.subdomain, hostId: cfg.hostId, certNotAfter: certNotAfter(ks) }
|
||||||
const ids = { ...host, certNotAfter: certNotAfter(ks) }
|
|
||||||
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
|
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// A5: silently renew the leaf before it expires. Restart frpc onto the fresh cert on rotation;
|
|
||||||
// stop the whole supervisor on a 403 revoke (INV12). Null ⇒ unenrolled (auto-renew disabled).
|
|
||||||
const autoRenew = startNativeAutoRenew(
|
|
||||||
cfg,
|
|
||||||
ks,
|
|
||||||
{
|
|
||||||
restartChild: () => handle.restartChild(),
|
|
||||||
stop: () => {
|
|
||||||
void handle.stop()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logger,
|
|
||||||
)
|
|
||||||
const onSignal = (): void => {
|
const onSignal = (): void => {
|
||||||
void handle.stop()
|
void handle.stop()
|
||||||
}
|
}
|
||||||
process.once('SIGTERM', onSignal)
|
process.once('SIGTERM', onSignal)
|
||||||
process.once('SIGINT', onSignal)
|
process.once('SIGINT', onSignal)
|
||||||
return handle.done.finally(() => {
|
return handle.done.finally(() => monitor.stop())
|
||||||
monitor.stop()
|
|
||||||
autoRenew?.stop()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
||||||
|
|||||||
@@ -18,13 +18,6 @@ export interface AgentConfig {
|
|||||||
readonly localTargetUrl: string
|
readonly localTargetUrl: string
|
||||||
readonly subdomain: string | null
|
readonly subdomain: string | null
|
||||||
readonly hostId: string | null
|
readonly hostId: string | null
|
||||||
/**
|
|
||||||
* Renewal endpoint used ONLY when the current leaf has already expired (the strict `/renew` vhost
|
|
||||||
* rejects an expired client cert before it reaches the control-plane). Optional: when unset it is
|
|
||||||
* derived from `enrollUrl` by swapping the `enroll.` label for `recover.` — see
|
|
||||||
* `certs/rotation.ts` `recoveryRenewalUrlFor`.
|
|
||||||
*/
|
|
||||||
readonly recoverUrl?: string | null | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,11 +59,6 @@ export const AgentConfigSchema = z
|
|||||||
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
||||||
subdomain: z.string().min(1).nullable(),
|
subdomain: z.string().min(1).nullable(),
|
||||||
hostId: z.string().min(1).nullable(),
|
hostId: z.string().min(1).nullable(),
|
||||||
recoverUrl: z
|
|
||||||
.string()
|
|
||||||
.refine((u) => hasScheme(u, 'https:'), 'recoverUrl must be an https:// URL')
|
|
||||||
.nullable()
|
|
||||||
.optional(),
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.readonly()
|
.readonly()
|
||||||
@@ -97,7 +85,6 @@ export function loadAgentConfig(
|
|||||||
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
||||||
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
||||||
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
||||||
recoverUrl: argv.recoverUrl ?? env.RECOVER_URL ?? null,
|
|
||||||
}
|
}
|
||||||
return AgentConfigSchema.parse(merged)
|
return AgentConfigSchema.parse(merged)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
/**
|
|
||||||
* Enrolment identifiers (`hostId` / `subdomain`) persisted next to the keystore.
|
|
||||||
*
|
|
||||||
* WHY: `pair --install` learns both from the control-plane's enroll response, but nothing ever wrote
|
|
||||||
* them down — so the long-running `run` process had `cfg.subdomain === null` and `cfg.hostId === null`
|
|
||||||
* and every log line came out as `{"subdomain":null,"hostId":null}`. When the tunnel broke in
|
|
||||||
* production, 6380 warnings named no host at all, which is exactly the moment you want them to.
|
|
||||||
*
|
|
||||||
* They are NOT secrets (the subdomain is a public DNS label), so this is a plain JSON file — kept in
|
|
||||||
* `stateDir` only because that is the one directory the agent already owns on every platform.
|
|
||||||
*
|
|
||||||
* Legacy installs enrolled before this existed have no record. Their leaf still carries the
|
|
||||||
* subdomain in its SPIFFE URI SAN, so `resolveHostIdentity` recovers it from there rather than
|
|
||||||
* forcing a re-pair just to get an identifier back into the logs.
|
|
||||||
*/
|
|
||||||
import { X509Certificate } from 'node:crypto'
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import type { AgentConfig } from './agentConfig.js'
|
|
||||||
|
|
||||||
const RECORD_FILE = 'host.json'
|
|
||||||
const DIR_MODE = 0o700
|
|
||||||
|
|
||||||
/** Non-secret identifiers assigned by the control-plane at enrolment. */
|
|
||||||
export interface HostRecord {
|
|
||||||
readonly hostId: string | null
|
|
||||||
readonly subdomain: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A non-empty string, or null — anything else on disk is treated as absent (never trusted). */
|
|
||||||
function stringOrNull(value: unknown): string | null {
|
|
||||||
return typeof value === 'string' && value.length > 0 ? value : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persist the enrolment identifiers into `stateDir`. Overwrites any previous record. */
|
|
||||||
export function saveHostRecord(stateDir: string, record: HostRecord): void {
|
|
||||||
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true, mode: DIR_MODE })
|
|
||||||
writeFileSync(join(stateDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the persisted identifiers, or null if this host has none. A missing, unreadable, or
|
|
||||||
* malformed file is reported as "no record" — this feeds a logging path and must never throw into
|
|
||||||
* the run loop.
|
|
||||||
*/
|
|
||||||
export function loadHostRecord(stateDir: string): HostRecord | null {
|
|
||||||
const path = join(stateDir, RECORD_FILE)
|
|
||||||
if (!existsSync(path)) return null
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
||||||
if (typeof parsed !== 'object' || parsed === null) return null
|
|
||||||
const rec = parsed as Record<string, unknown>
|
|
||||||
return { hostId: stringOrNull(rec['hostId']), subdomain: stringOrNull(rec['subdomain']) }
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SPIFFE IDs issued for hosts end in `/host/<subdomain>` (see relay-auth `spiffeIdFor`). */
|
|
||||||
const SPIFFE_HOST_RE = /URI:(spiffe:\/\/[^\s,]*\/host\/([^\s,/]+))/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recover the subdomain from a leaf's SPIFFE URI SAN, or null if it carries none. Parse failures
|
|
||||||
* are null, never throws — a legacy install with a damaged cert must still start.
|
|
||||||
*/
|
|
||||||
export function subdomainFromCertPem(certPem: string): string | null {
|
|
||||||
try {
|
|
||||||
const san = new X509Certificate(certPem).subjectAltName ?? ''
|
|
||||||
const match = SPIFFE_HOST_RE.exec(san)
|
|
||||||
return match?.[2] ?? null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the identifiers to log for this host, most authoritative first:
|
|
||||||
* 1. explicit config (argv/env `SUBDOMAIN` / `HOST_ID`) — an operator override always wins;
|
|
||||||
* 2. the enrolment record written by `pair`;
|
|
||||||
* 3. the subdomain embedded in the stored leaf (legacy installs; yields no hostId).
|
|
||||||
*
|
|
||||||
* `readCertPem` is injected so this stays a pure decision over a supplied cert.
|
|
||||||
*/
|
|
||||||
export function resolveHostIdentity(
|
|
||||||
cfg: AgentConfig,
|
|
||||||
readCertPem: () => string | null,
|
|
||||||
): HostRecord {
|
|
||||||
const record = loadHostRecord(cfg.stateDir)
|
|
||||||
const subdomain =
|
|
||||||
cfg.subdomain ??
|
|
||||||
record?.subdomain ??
|
|
||||||
((): string | null => {
|
|
||||||
const pem = readCertPem()
|
|
||||||
return pem === null ? null : subdomainFromCertPem(pem)
|
|
||||||
})()
|
|
||||||
return { hostId: cfg.hostId ?? record?.hostId ?? null, subdomain }
|
|
||||||
}
|
|
||||||
45
agent/src/dist/buildBinary.ts
vendored
45
agent/src/dist/buildBinary.ts
vendored
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Static-binary build spec — PLAN_RELAY_AGENT T16 (EXPLORE §6 distribution rank 2). Produces a
|
|
||||||
* `bun --compile` spec for a one-`curl | sh` install. `npx web-terminal-agent` stays the MVP path
|
|
||||||
* (rank 1). The bundle EXCLUDES dev/test deps and any terminal parser (INV11 re-check at the
|
|
||||||
* package boundary — the agent is a byte-shuttle, never an ANSI interpreter).
|
|
||||||
*/
|
|
||||||
export type BinaryTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64'
|
|
||||||
|
|
||||||
export const BINARY_TARGETS: readonly BinaryTarget[] = [
|
|
||||||
'darwin-arm64',
|
|
||||||
'darwin-x64',
|
|
||||||
'linux-x64',
|
|
||||||
'linux-arm64',
|
|
||||||
]
|
|
||||||
|
|
||||||
export interface BuildSpec {
|
|
||||||
readonly tool: 'bun'
|
|
||||||
readonly entry: string
|
|
||||||
readonly target: BinaryTarget
|
|
||||||
readonly bunTarget: string // bun's --target triple
|
|
||||||
readonly outfile: string
|
|
||||||
readonly minify: true
|
|
||||||
/** Package-name substrings that must NOT appear in the bundle graph (INV11 tripwire). */
|
|
||||||
readonly forbiddenDeps: readonly string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const BUN_TRIPLE: Readonly<Record<BinaryTarget, string>> = {
|
|
||||||
'darwin-arm64': 'bun-darwin-arm64',
|
|
||||||
'darwin-x64': 'bun-darwin-x64',
|
|
||||||
'linux-x64': 'bun-linux-x64',
|
|
||||||
'linux-arm64': 'bun-linux-arm64',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the `bun --compile` spec for a target triple. Entry is the CLI. */
|
|
||||||
export function buildBinaryConfig(target: BinaryTarget): BuildSpec {
|
|
||||||
return {
|
|
||||||
tool: 'bun',
|
|
||||||
entry: 'src/cli.ts',
|
|
||||||
target,
|
|
||||||
bunTarget: BUN_TRIPLE[target],
|
|
||||||
outfile: `dist/web-terminal-agent-${target}`,
|
|
||||||
minify: true,
|
|
||||||
forbiddenDeps: ['xterm', 'ansi', 'vt100'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import type { EnrollResult } from 'relay-contracts'
|
|||||||
import type { AgentIdentity } from '../keys/identity.js'
|
import type { AgentIdentity } from '../keys/identity.js'
|
||||||
import type { Keystore } from '../keys/keystore.js'
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
import { buildCsr } from './csr.js'
|
import { buildCsr } from './csr.js'
|
||||||
import { derBase64ToPem } from '../certs/pem.js'
|
|
||||||
|
|
||||||
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
||||||
export type EnrollMode = 'token' | 'ed25519'
|
export type EnrollMode = 'token' | 'ed25519'
|
||||||
@@ -54,12 +53,6 @@ export interface RedeemOptions {
|
|||||||
readonly agentToken?: string
|
readonly agentToken?: string
|
||||||
readonly unwrapContentSecret?: UnwrapContentSecret
|
readonly unwrapContentSecret?: UnwrapContentSecret
|
||||||
readonly subject?: string
|
readonly subject?: string
|
||||||
/**
|
|
||||||
* Native frp-client enroll has NO E2E content secret — the control-plane returns
|
|
||||||
* `hostContentSecret: null`. When true, tolerate its absence (skip unwrap + storage); the plain
|
|
||||||
* frpc byte tunnel needs no content key. Defaults false so the legacy relay path still requires it.
|
|
||||||
*/
|
|
||||||
readonly allowMissingContentSecret?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EnrollResponseJson {
|
interface EnrollResponseJson {
|
||||||
@@ -70,33 +63,11 @@ interface EnrollResponseJson {
|
|||||||
hostContentSecret: string // base64url over the wire
|
hostContentSecret: string // base64url over the wire
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult {
|
function parseEnrollResult(json: unknown): EnrollResult {
|
||||||
const j = json as Partial<EnrollResponseJson>
|
const j = json as Partial<EnrollResponseJson>
|
||||||
if (typeof j.hostContentSecret !== 'string') {
|
if (typeof j.hostContentSecret !== 'string') {
|
||||||
if (!allowMissingContentSecret) {
|
|
||||||
throw new EnrollError('enroll response missing hostContentSecret')
|
throw new EnrollError('enroll response missing hostContentSecret')
|
||||||
}
|
}
|
||||||
// Native frp-client enroll: cert = base64(DER) string, caChain = base64(DER) string[], no content
|
|
||||||
// key. The keystore + frpc need PEM, so convert here. Empty secret sentinel is never stored.
|
|
||||||
const caChain: unknown = j.caChain
|
|
||||||
if (
|
|
||||||
typeof j.hostId !== 'string' ||
|
|
||||||
typeof j.subdomain !== 'string' ||
|
|
||||||
typeof j.cert !== 'string' ||
|
|
||||||
!Array.isArray(caChain) ||
|
|
||||||
caChain.length === 0 ||
|
|
||||||
!caChain.every((c) => typeof c === 'string')
|
|
||||||
) {
|
|
||||||
throw new EnrollError('enroll response missing required fields')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
hostId: j.hostId,
|
|
||||||
subdomain: j.subdomain,
|
|
||||||
cert: derBase64ToPem(j.cert),
|
|
||||||
caChain: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
|
|
||||||
hostContentSecret: new Uint8Array(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const candidate = {
|
const candidate = {
|
||||||
hostId: j.hostId,
|
hostId: j.hostId,
|
||||||
subdomain: j.subdomain,
|
subdomain: j.subdomain,
|
||||||
@@ -157,13 +128,10 @@ export async function redeemPairingCode(
|
|||||||
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enroll = parseEnrollResult(json, opts.allowMissingContentSecret ?? false)
|
const enroll = parseEnrollResult(json)
|
||||||
ks.saveCert(enroll.cert, enroll.caChain)
|
ks.saveCert(enroll.cert, enroll.caChain)
|
||||||
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
||||||
// Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store.
|
|
||||||
if (enroll.hostContentSecret.length > 0) {
|
|
||||||
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||||
ks.saveContentSecret(unwrapped)
|
ks.saveContentSecret(unwrapped)
|
||||||
}
|
|
||||||
return enroll
|
return enroll
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
|
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
|
||||||
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
|
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
|
||||||
*/
|
*/
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import type { AgentConfig } from '../config/agentConfig.js'
|
import type { AgentConfig } from '../config/agentConfig.js'
|
||||||
import {
|
import {
|
||||||
agentLabel,
|
agentLabel,
|
||||||
@@ -203,37 +202,13 @@ export async function installService(
|
|||||||
// so nothing is ever written for a rejected install.
|
// so nothing is ever written for a rejected install.
|
||||||
const baseAppEnv = resolveBaseAppEnv(cfg, options)
|
const baseAppEnv = resolveBaseAppEnv(cfg, options)
|
||||||
const bin = deps.binPath()
|
const bin = deps.binPath()
|
||||||
const rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
|
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
|
||||||
// launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node`
|
|
||||||
// symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node
|
|
||||||
// (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc
|
|
||||||
// subprocesses — resolve their tools.
|
|
||||||
const nodePath = process.execPath
|
|
||||||
const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`
|
|
||||||
const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec
|
|
||||||
const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath }
|
|
||||||
// The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front
|
|
||||||
// (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the
|
|
||||||
// agent's own runtime config (NOT the base-app env) alongside PATH.
|
|
||||||
const agentEnv: Record<string, string> = {
|
|
||||||
PATH: unitPath,
|
|
||||||
ENROLL_URL: cfg.enrollUrl,
|
|
||||||
RELAY_URL: cfg.relayUrl,
|
|
||||||
STATE_DIR: cfg.stateDir,
|
|
||||||
LOCAL_TARGET_URL: cfg.localTargetUrl,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'launchd') {
|
if (platform === 'launchd') {
|
||||||
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
|
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
|
||||||
deps.writeFile(
|
deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnv, baseAppLabel()))
|
||||||
baseAppPath,
|
|
||||||
buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')),
|
|
||||||
)
|
|
||||||
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
|
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
|
||||||
deps.writeFile(
|
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel()))
|
||||||
agentPath,
|
|
||||||
buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel(), join(cfg.stateDir, 'agent.log')),
|
|
||||||
)
|
|
||||||
for (const path of [baseAppPath, agentPath]) {
|
for (const path of [baseAppPath, agentPath]) {
|
||||||
const { cmd, args } = launchdLoadCommand(path)
|
const { cmd, args } = launchdLoadCommand(path)
|
||||||
await deps.runCommand(cmd, args)
|
await deps.runCommand(cmd, args)
|
||||||
@@ -242,8 +217,8 @@ export async function installService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseAppOptions: SystemdUnitOptions = options.envFile
|
const baseAppOptions: SystemdUnitOptions = options.envFile
|
||||||
? { env: baseAppEnvWithPath, envFile: options.envFile }
|
? { env: baseAppEnv, envFile: options.envFile }
|
||||||
: { env: baseAppEnvWithPath }
|
: { env: baseAppEnv }
|
||||||
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
|
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
|
||||||
deps.writeFile(
|
deps.writeFile(
|
||||||
baseAppPath,
|
baseAppPath,
|
||||||
@@ -252,7 +227,7 @@ export async function installService(
|
|||||||
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
|
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
|
||||||
deps.writeFile(
|
deps.writeFile(
|
||||||
agentPath,
|
agentPath,
|
||||||
buildSystemdUnit(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, 'web-terminal host agent (frpc supervisor)'),
|
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'),
|
||||||
)
|
)
|
||||||
for (const unit of [baseAppUnitName(), agentUnitName()]) {
|
for (const unit of [baseAppUnitName(), agentUnitName()]) {
|
||||||
const { cmd, args } = systemdEnableCommand(unit)
|
const { cmd, args } = systemdEnableCommand(unit)
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ export function buildLaunchdPlist(
|
|||||||
programArguments: readonly string[],
|
programArguments: readonly string[],
|
||||||
env: ServiceEnv = {},
|
env: ServiceEnv = {},
|
||||||
label: string = AGENT_LABEL,
|
label: string = AGENT_LABEL,
|
||||||
logPath?: string,
|
|
||||||
): string {
|
): string {
|
||||||
return [
|
return [
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
@@ -92,16 +91,6 @@ export function buildLaunchdPlist(
|
|||||||
' <true/>',
|
' <true/>',
|
||||||
' <key>KeepAlive</key>',
|
' <key>KeepAlive</key>',
|
||||||
' <true/>',
|
' <true/>',
|
||||||
// launchd's default has no log sink (unlike systemd's journald), so a crashing unit is silent.
|
|
||||||
// Route stdout+stderr to a file so `pair --install` failures are diagnosable out of the box.
|
|
||||||
...(logPath !== undefined
|
|
||||||
? [
|
|
||||||
' <key>StandardOutPath</key>',
|
|
||||||
` <string>${escapeXml(logPath)}</string>`,
|
|
||||||
' <key>StandardErrorPath</key>',
|
|
||||||
` <string>${escapeXml(logPath)}</string>`,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...environmentVariablesBlock(env),
|
...environmentVariablesBlock(env),
|
||||||
'</dict>',
|
'</dict>',
|
||||||
'</plist>',
|
'</plist>',
|
||||||
|
|||||||
@@ -25,13 +25,7 @@ export class CertExpiredError extends Error {
|
|||||||
export interface TlsClientOptions {
|
export interface TlsClientOptions {
|
||||||
readonly cert: string
|
readonly cert: string
|
||||||
readonly key: string
|
readonly key: string
|
||||||
/**
|
readonly ca: string
|
||||||
* CA(s) to verify the SERVER cert against. Set to the private tunnel CA for the relay dial; left
|
|
||||||
* undefined for a request whose server is publicly trusted (the LE-fronted control-plane /renew) so
|
|
||||||
* Node verifies against the system roots — pinning the private CA there fails with "unable to get
|
|
||||||
* local issuer certificate".
|
|
||||||
*/
|
|
||||||
readonly ca?: string
|
|
||||||
readonly rejectUnauthorized: true
|
readonly rejectUnauthorized: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,12 +62,6 @@ export interface FrpSuperviseHandle {
|
|||||||
readonly done: Promise<number>
|
readonly done: Promise<number>
|
||||||
/** True while a frpc child is currently running (for the health probe). */
|
/** True while a frpc child is currently running (for the health probe). */
|
||||||
isChildAlive(): boolean
|
isChildAlive(): boolean
|
||||||
/**
|
|
||||||
* Kill the current child WITHOUT stopping the supervisor (A5): the restart-on-exit loop respawns a
|
|
||||||
* fresh frpc that re-reads the (now rotated) cert/key/CA files. A no-op once `stop()` was called —
|
|
||||||
* it must never resurrect a supervisor that is shutting down.
|
|
||||||
*/
|
|
||||||
restartChild(): void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
|
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
|
||||||
@@ -202,8 +196,5 @@ export function superviseFrpc(
|
|||||||
},
|
},
|
||||||
done,
|
done,
|
||||||
isChildAlive: () => child?.isAlive() ?? false,
|
isChildAlive: () => child?.isAlive() ?? false,
|
||||||
restartChild(): void {
|
|
||||||
if (!stopped) child?.kill()
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,44 +145,4 @@ describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
|
|||||||
await stopping
|
await stopping
|
||||||
expect(spawn).toHaveBeenCalledTimes(1)
|
expect(spawn).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restartChild() kills the running child so the loop respawns onto the fresh cert files', async () => {
|
|
||||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
|
||||||
const children = [makeChild(), makeChild()]
|
|
||||||
let n = 0
|
|
||||||
const spawn: SpawnFrpc = () => children[n++]!.child
|
|
||||||
const handle = superviseFrpc('/frpc', '/toml', {
|
|
||||||
spawn,
|
|
||||||
sleep: async () => {},
|
|
||||||
logger: silentLogger,
|
|
||||||
now: () => 1_000_000, // fixed clock: run counts as unstable, but sleep is a no-op → respawn is immediate
|
|
||||||
})
|
|
||||||
await flush()
|
|
||||||
expect(handle.isChildAlive()).toBe(true) // child[0]
|
|
||||||
|
|
||||||
handle.restartChild() // A5: rotator calls this after a cert rotation to reload the new leaf
|
|
||||||
expect(children[0]!.killed).toBe(true)
|
|
||||||
|
|
||||||
await flush()
|
|
||||||
expect(n).toBe(2) // child[1] spawned — frpc now reads the rotated cert on disk
|
|
||||||
expect(handle.isChildAlive()).toBe(true)
|
|
||||||
await handle.stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('restartChild() after stop is a no-op (no spawn past shutdown)', async () => {
|
|
||||||
const first = makeChild()
|
|
||||||
let n = 0
|
|
||||||
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
|
|
||||||
const handle = superviseFrpc('/frpc', '/toml', {
|
|
||||||
spawn,
|
|
||||||
sleep: async () => {},
|
|
||||||
logger: silentLogger,
|
|
||||||
})
|
|
||||||
await Promise.resolve()
|
|
||||||
const stopping = handle.stop()
|
|
||||||
first.exit(0)
|
|
||||||
await stopping
|
|
||||||
handle.restartChild() // must not resurrect the supervisor
|
|
||||||
expect(spawn).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
||||||
import { tmpdir } from 'node:os'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
|
||||||
import {
|
|
||||||
loadHostRecord,
|
|
||||||
resolveHostIdentity,
|
|
||||||
saveHostRecord,
|
|
||||||
subdomainFromCertPem,
|
|
||||||
} from '../src/config/hostRecord.js'
|
|
||||||
|
|
||||||
const CFG: AgentConfig = {
|
|
||||||
relayUrl: 'wss://relay/agent',
|
|
||||||
enrollUrl: 'https://enroll.terminal.example.com/enroll',
|
|
||||||
stateDir: '/tmp/x',
|
|
||||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
|
||||||
subdomain: null,
|
|
||||||
hostId: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
function tmpState(): string {
|
|
||||||
return mkdtempSync(join(tmpdir(), 'wta-hr-'))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A real frp-client leaf as issued by the control-plane (SPIFFE URI SAN carries the subdomain). */
|
|
||||||
const LEAF_PEM = `-----BEGIN CERTIFICATE-----
|
|
||||||
MIIB1TCCAXygAwIBAgIUUj+CZ+6p29yI59VpyrekwSp9tAgwCgYIKoZIzj0EAwIw
|
|
||||||
EDEOMAwGA1UEAwwFaDdmZDgwHhcNMjYwNzI5MDgzNzIyWhcNMzYwNzI2MDgzNzIy
|
|
||||||
WjAQMQ4wDAYDVQQDDAVoN2ZkODBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABACg
|
|
||||||
xWQCQuxawnkkPZIgagEFtG0oBiuron4SSw3U1Q0FwCSH3BJep1MJtIuEQU3HfM4N
|
|
||||||
6Tk5kW4MWuIM8sNriiqjgbMwgbAwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMC
|
|
||||||
B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwXAYDVR0RBFUwU4IaaDdmZDgudGVybWlu
|
|
||||||
YWwuZXhhbXBsZS5jb22GNXNwaWZmZTovL3JlbGF5LmV4YW1wbGUuY29tL2FjY291
|
|
||||||
bnQvYWNjLTEyMy9ob3N0L2g3ZmQ4MB0GA1UdDgQWBBSy/SJwjH/lm8TaY5Yk/TF+
|
|
||||||
wpg78TAKBggqhkjOPQQDAgNHADBEAiAjq1o5xpk+iF55uVfdyLP/a9OC09O0mN4P
|
|
||||||
YRk8x5MFaQIgYC3GTWqkwu0azrdffKl6jX0stbG+oM+0Cx2Cn7wy27c=
|
|
||||||
-----END CERTIFICATE-----`
|
|
||||||
|
|
||||||
describe('host record persistence', () => {
|
|
||||||
it('round-trips the enrolment identifiers through stateDir', () => {
|
|
||||||
const dir = tmpState()
|
|
||||||
saveHostRecord(dir, { hostId: 'h-1', subdomain: 'h7fd8' })
|
|
||||||
expect(loadHostRecord(dir)).toEqual({ hostId: 'h-1', subdomain: 'h7fd8' })
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when nothing was ever enrolled here', () => {
|
|
||||||
const dir = tmpState()
|
|
||||||
expect(loadHostRecord(dir)).toBeNull()
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats a corrupt record as absent rather than throwing (never crashes the run loop)', () => {
|
|
||||||
const dir = tmpState()
|
|
||||||
writeFileSync(join(dir, 'host.json'), '{not json')
|
|
||||||
expect(loadHostRecord(dir)).toBeNull()
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores a record whose fields are the wrong shape', () => {
|
|
||||||
const dir = tmpState()
|
|
||||||
writeFileSync(join(dir, 'host.json'), JSON.stringify({ hostId: 42, subdomain: [] }))
|
|
||||||
expect(loadHostRecord(dir)).toEqual({ hostId: null, subdomain: null })
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('subdomainFromCertPem', () => {
|
|
||||||
it('reads the subdomain out of the leaf SPIFFE URI SAN', () => {
|
|
||||||
expect(subdomainFromCertPem(LEAF_PEM)).toBe('h7fd8')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null for a certificate with no SPIFFE SAN', () => {
|
|
||||||
expect(subdomainFromCertPem('-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null for garbage instead of throwing', () => {
|
|
||||||
expect(subdomainFromCertPem('not a cert')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('resolveHostIdentity precedence', () => {
|
|
||||||
it('keeps explicit config (env/argv) over everything else', () => {
|
|
||||||
const dir = tmpState()
|
|
||||||
saveHostRecord(dir, { hostId: 'from-file', subdomain: 'from-file' })
|
|
||||||
const out = resolveHostIdentity(
|
|
||||||
{ ...CFG, stateDir: dir, subdomain: 'from-env', hostId: 'from-env' },
|
|
||||||
() => LEAF_PEM,
|
|
||||||
)
|
|
||||||
expect(out).toEqual({ hostId: 'from-env', subdomain: 'from-env' })
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to the persisted enrolment record', () => {
|
|
||||||
const dir = tmpState()
|
|
||||||
saveHostRecord(dir, { hostId: 'h-1', subdomain: 'h7fd8' })
|
|
||||||
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => null)).toEqual({
|
|
||||||
hostId: 'h-1',
|
|
||||||
subdomain: 'h7fd8',
|
|
||||||
})
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hosts enrolled before the record existed have no `host.json`. Their leaf still carries the
|
|
||||||
* subdomain, so they get an identifier in the logs without needing a re-pair.
|
|
||||||
*/
|
|
||||||
it('falls back to the leaf SPIFFE SAN when there is no record (legacy installs)', () => {
|
|
||||||
const dir = tmpState()
|
|
||||||
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => LEAF_PEM)).toEqual({
|
|
||||||
hostId: null,
|
|
||||||
subdomain: 'h7fd8',
|
|
||||||
})
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('yields nulls when nothing is known (unenrolled host)', () => {
|
|
||||||
const dir = tmpState()
|
|
||||||
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => null)).toEqual({
|
|
||||||
hostId: null,
|
|
||||||
subdomain: null,
|
|
||||||
})
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -81,10 +81,8 @@ describe('installService — two distinct units (FIX M-host-2service)', () => {
|
|||||||
expect(d.writes).toHaveLength(2)
|
expect(d.writes).toHaveLength(2)
|
||||||
const baseApp = unitWith(d, baseAppUnitName())
|
const baseApp = unitWith(d, baseAppUnitName())
|
||||||
const agent = unitWith(d, agentUnitName())
|
const agent = unitWith(d, agentUnitName())
|
||||||
// agent unit supervises frpc via `<node> <bin> run` (absolute node so a minimal service PATH
|
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback)
|
||||||
// that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback)
|
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
|
||||||
expect(agent).toContain('/usr/local/bin/web-terminal-agent run')
|
|
||||||
expect(agent).toMatch(/ExecStart=\S*node\S* \/usr\/local\/bin\/web-terminal-agent run/)
|
|
||||||
expect(baseApp).toContain('ExecStart=')
|
expect(baseApp).toContain('ExecStart=')
|
||||||
expect(baseApp).toContain('server.js')
|
expect(baseApp).toContain('server.js')
|
||||||
expect(baseApp).not.toContain('web-terminal-agent run')
|
expect(baseApp).not.toContain('web-terminal-agent run')
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
/**
|
|
||||||
* A5 native cert auto-renew wiring — TDD.
|
|
||||||
*
|
|
||||||
* Covers the host-side glue that was the "one real host code gap": the native run-loop must actually
|
|
||||||
* RENEW the frp-client leaf (not merely monitor its freshness). Three units:
|
|
||||||
* - `createMtlsFetch` — the injected `fetchImpl` `renewCert` uses: it POSTs /renew over mTLS
|
|
||||||
* presenting the CURRENT keystore leaf (read fresh on every call, so a post-rotation renewal
|
|
||||||
* authenticates with the new leaf) and maps the transport response to a `Response`.
|
|
||||||
* - `wireAutoRenew` — routes the rotator's rotated→restartChild(+log), revoked→stop(+log),
|
|
||||||
* error→log(no secret) callbacks and starts it.
|
|
||||||
* - `startNativeAutoRenew` — the end-to-end builder `superviseNative` calls (identity + mTLS fetch
|
|
||||||
* + rotator), returning null (disabled) when unenrolled.
|
|
||||||
*/
|
|
||||||
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 { AgentConfig } from '../src/config/agentConfig.js'
|
|
||||||
import { generateP256Identity } from '../src/keys/identity.js'
|
|
||||||
import { openKeystore } from '../src/keys/keystore.js'
|
|
||||||
import { createLogger } from '../src/log/logger.js'
|
|
||||||
import type { CertRotator } from '../src/certs/rotation.js'
|
|
||||||
import {
|
|
||||||
createMtlsFetch,
|
|
||||||
startNativeAutoRenew,
|
|
||||||
wireAutoRenew,
|
|
||||||
type MtlsRequest,
|
|
||||||
} from '../src/certs/nativeRenew.js'
|
|
||||||
import { FakeTimer } from './fixtures/fakes.js'
|
|
||||||
import { CertExpiredBeyondGraceError } from '../src/certs/rotation.js'
|
|
||||||
|
|
||||||
const CFG: AgentConfig = {
|
|
||||||
relayUrl: 'wss://relay/agent',
|
|
||||||
enrollUrl: 'https://cp.example.com/enroll',
|
|
||||||
stateDir: '/tmp/x',
|
|
||||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
|
||||||
subdomain: 'host-42',
|
|
||||||
hostId: 'h-1',
|
|
||||||
}
|
|
||||||
|
|
||||||
function enrolledKs(): { dir: string; ks: ReturnType<typeof openKeystore> } {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
|
||||||
const ks = openKeystore(dir)
|
|
||||||
ks.saveIdentity(generateP256Identity())
|
|
||||||
ks.saveCert('LEAFCERT', 'CACHAIN')
|
|
||||||
return { dir, ks }
|
|
||||||
}
|
|
||||||
|
|
||||||
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
|
|
||||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
|
||||||
|
|
||||||
describe('createMtlsFetch (A5)', () => {
|
|
||||||
it('presents the current keystore cert/key/CA over mTLS and maps the transport response', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
const seen: Array<{ url: string; tls: Record<string, unknown>; init: Record<string, unknown> }> = []
|
|
||||||
const request: MtlsRequest = async (url, tls, init) => {
|
|
||||||
seen.push({ url, tls: tls as unknown as Record<string, unknown>, init: init as unknown as Record<string, unknown> })
|
|
||||||
return { status: 200, body: JSON.stringify({ cert: 'NEW', caChain: 'NEWCA' }) }
|
|
||||||
}
|
|
||||||
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
|
||||||
|
|
||||||
const res = await f('https://cp.example.com/renew', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: '{"csr":"x"}',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(res.ok).toBe(true)
|
|
||||||
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
|
|
||||||
expect(seen[0]!.url).toBe('https://cp.example.com/renew')
|
|
||||||
expect(seen[0]!.tls.cert).toBe('LEAFCERT')
|
|
||||||
// /renew verifies the LE-fronted control-plane against the SYSTEM roots, so no private CA is pinned
|
|
||||||
// (pinning the enroll caChain here fails with "unable to get local issuer certificate").
|
|
||||||
expect(seen[0]!.tls.ca).toBeUndefined()
|
|
||||||
expect(seen[0]!.tls.rejectUnauthorized).toBe(true)
|
|
||||||
expect(String(seen[0]!.tls.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key, mTLS only
|
|
||||||
expect(seen[0]!.init.method).toBe('POST')
|
|
||||||
expect(seen[0]!.init.body).toBe('{"csr":"x"}')
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reads the current cert on every call so a post-rotation renewal uses the new leaf', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
const certs: string[] = []
|
|
||||||
const request: MtlsRequest = async (_url, tls) => {
|
|
||||||
certs.push(tls.cert)
|
|
||||||
return { status: 200, body: '{}' }
|
|
||||||
}
|
|
||||||
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
|
||||||
|
|
||||||
await f('https://x/renew', { method: 'POST' })
|
|
||||||
ks.saveCert('ROTATEDLEAF', 'CACHAIN') // simulate a completed rotation
|
|
||||||
await f('https://x/renew', { method: 'POST' })
|
|
||||||
|
|
||||||
expect(certs).toEqual(['LEAFCERT', 'ROTATEDLEAF'])
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('propagates a not-enrolled keystore as a throw (renewCert then retries with backoff)', async () => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
|
||||||
const ks = openKeystore(dir)
|
|
||||||
const request: MtlsRequest = async () => ({ status: 200, body: '{}' })
|
|
||||||
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
|
||||||
await expect(f('https://x/renew', { method: 'POST' })).rejects.toThrow()
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('wireAutoRenew (A5)', () => {
|
|
||||||
function fakeRotator(): {
|
|
||||||
rotator: CertRotator
|
|
||||||
fire: {
|
|
||||||
rotated?: () => void
|
|
||||||
revoked?: () => void
|
|
||||||
error?: (e: unknown) => void
|
|
||||||
exhausted?: (e: CertExpiredBeyondGraceError) => void
|
|
||||||
}
|
|
||||||
start: ReturnType<typeof vi.fn>
|
|
||||||
stop: ReturnType<typeof vi.fn>
|
|
||||||
} {
|
|
||||||
const fire: {
|
|
||||||
rotated?: () => void
|
|
||||||
revoked?: () => void
|
|
||||||
error?: (e: unknown) => void
|
|
||||||
exhausted?: (e: CertExpiredBeyondGraceError) => void
|
|
||||||
} = {}
|
|
||||||
const start = vi.fn()
|
|
||||||
const stop = vi.fn()
|
|
||||||
const rotator: CertRotator = {
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
onRotated: (cb) => {
|
|
||||||
fire.rotated = cb
|
|
||||||
},
|
|
||||||
onRevoked: (cb) => {
|
|
||||||
fire.revoked = cb
|
|
||||||
},
|
|
||||||
onError: (cb) => {
|
|
||||||
fire.error = cb
|
|
||||||
},
|
|
||||||
onExhausted: (cb) => {
|
|
||||||
fire.exhausted = cb
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return { rotator, fire, start, stop }
|
|
||||||
}
|
|
||||||
|
|
||||||
it('routes rotated→restartChild, revoked→stop, error→log; starts and stops the rotator', () => {
|
|
||||||
const { rotator, fire, start, stop } = fakeRotator()
|
|
||||||
const restartChild = vi.fn()
|
|
||||||
const stopSupervisor = vi.fn()
|
|
||||||
const lines: string[] = []
|
|
||||||
const logger = createLogger('info', (l) => lines.push(l))
|
|
||||||
|
|
||||||
const controller = wireAutoRenew(rotator, { restartChild, stop: stopSupervisor }, logger, {
|
|
||||||
subdomain: 'host-42',
|
|
||||||
hostId: 'h-1',
|
|
||||||
})
|
|
||||||
expect(start).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
fire.rotated!()
|
|
||||||
expect(restartChild).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
fire.revoked!()
|
|
||||||
expect(stopSupervisor).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
fire.error!(new Error('network down'))
|
|
||||||
|
|
||||||
controller.stop()
|
|
||||||
expect(stop).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
const joined = lines.join('\n')
|
|
||||||
expect(joined).toContain('host-42') // non-secret identifier is logged
|
|
||||||
expect(joined).not.toContain('LEAFCERT') // never a leaf/key/CSR
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('startNativeAutoRenew (A5 end-to-end)', () => {
|
|
||||||
it('a scheduled 200 renewal rotates the leaf on disk and restarts frpc with it', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
const timer = new FakeTimer()
|
|
||||||
const request: MtlsRequest = async () => ({
|
|
||||||
status: 200,
|
|
||||||
body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }),
|
|
||||||
})
|
|
||||||
const restartChild = vi.fn()
|
|
||||||
const stop = vi.fn()
|
|
||||||
|
|
||||||
const controller = startNativeAutoRenew(
|
|
||||||
CFG,
|
|
||||||
ks,
|
|
||||||
{ restartChild, stop },
|
|
||||||
createLogger('error', () => {}),
|
|
||||||
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
|
|
||||||
)!
|
|
||||||
expect(controller).not.toBeNull()
|
|
||||||
|
|
||||||
timer.advance(1000) // renewal fires at ~2/3 TTL
|
|
||||||
await flush()
|
|
||||||
|
|
||||||
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') // atomic persist
|
|
||||||
expect(restartChild).toHaveBeenCalledTimes(1) // frpc restarted onto the fresh leaf
|
|
||||||
expect(stop).not.toHaveBeenCalled()
|
|
||||||
controller.stop()
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a scheduled 403 renewal tears the tunnel down (revoked) and never rotates', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
const timer = new FakeTimer()
|
|
||||||
const request: MtlsRequest = async () => ({ status: 403, body: '' })
|
|
||||||
const restartChild = vi.fn()
|
|
||||||
const stop = vi.fn()
|
|
||||||
|
|
||||||
const controller = startNativeAutoRenew(
|
|
||||||
CFG,
|
|
||||||
ks,
|
|
||||||
{ restartChild, stop },
|
|
||||||
createLogger('error', () => {}),
|
|
||||||
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
|
|
||||||
)!
|
|
||||||
|
|
||||||
timer.advance(1000)
|
|
||||||
await flush()
|
|
||||||
|
|
||||||
expect(stop).toHaveBeenCalledTimes(1)
|
|
||||||
expect(restartChild).not.toHaveBeenCalled()
|
|
||||||
expect(ks.loadCert()!.certPem).toBe('LEAFCERT') // untouched
|
|
||||||
controller.stop()
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a failing renewal is logged (no secret) and retried without crashing, then rotates', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
const timer = new FakeTimer()
|
|
||||||
let calls = 0
|
|
||||||
const request: MtlsRequest = async () => {
|
|
||||||
calls += 1
|
|
||||||
if (calls === 1) throw new Error('ECONNREFUSED')
|
|
||||||
return { status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }) }
|
|
||||||
}
|
|
||||||
const restartChild = vi.fn()
|
|
||||||
const stop = vi.fn()
|
|
||||||
const lines: string[] = []
|
|
||||||
|
|
||||||
const controller = startNativeAutoRenew(
|
|
||||||
CFG,
|
|
||||||
ks,
|
|
||||||
{ restartChild, stop },
|
|
||||||
createLogger('warn', (l) => lines.push(l)),
|
|
||||||
{
|
|
||||||
mtlsRequest: request,
|
|
||||||
certParser: farFuture,
|
|
||||||
timer,
|
|
||||||
renewBeforeMs: 1000,
|
|
||||||
retryBaseMs: 500,
|
|
||||||
now: () => new Date(0),
|
|
||||||
parseCert: () => new Date(2000),
|
|
||||||
},
|
|
||||||
)!
|
|
||||||
|
|
||||||
timer.advance(1000) // first attempt throws
|
|
||||||
await flush()
|
|
||||||
expect(restartChild).not.toHaveBeenCalled()
|
|
||||||
expect(stop).not.toHaveBeenCalled() // a failure NEVER tears down
|
|
||||||
expect(lines.join('\n')).toContain('retry')
|
|
||||||
|
|
||||||
timer.advance(500) // backoff retry fires and succeeds
|
|
||||||
await flush()
|
|
||||||
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF')
|
|
||||||
expect(restartChild).toHaveBeenCalledTimes(1)
|
|
||||||
expect(lines.join('\n')).not.toContain('LEAFCERT')
|
|
||||||
controller.stop()
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null (auto-renew disabled) when the keystore has no identity', () => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
|
||||||
const ks = openKeystore(dir)
|
|
||||||
const lines: string[] = []
|
|
||||||
const controller = startNativeAutoRenew(
|
|
||||||
CFG,
|
|
||||||
ks,
|
|
||||||
{ restartChild: () => {}, stop: () => {} },
|
|
||||||
createLogger('warn', (l) => lines.push(l)),
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
expect(controller).toBeNull()
|
|
||||||
expect(lines.join('\n')).toContain('auto-renew disabled')
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The mTLS renew transport stays STRICT about expiry: nginx refuses to forward an expired client
|
|
||||||
* cert at all, so an expired leaf must be routed to the plain `/recover` endpoint by the rotator
|
|
||||||
* rather than smuggled through this transport.
|
|
||||||
*/
|
|
||||||
describe('createMtlsFetch stays fail-closed on an expired leaf', () => {
|
|
||||||
it('refuses to present a lapsed leaf (recovery is the rotator\'s job, not this transport\'s)', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
let called = 0
|
|
||||||
const request: MtlsRequest = async () => {
|
|
||||||
called += 1
|
|
||||||
return { status: 201, body: '{}' }
|
|
||||||
}
|
|
||||||
const f = createMtlsFetch(ks, {
|
|
||||||
request,
|
|
||||||
certParser: () => ({ validTo: new Date(Date.now() - 86_400_000) }),
|
|
||||||
})
|
|
||||||
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(
|
|
||||||
/expired/i,
|
|
||||||
)
|
|
||||||
expect(called).toBe(0)
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('wireAutoRenew exhausted routing', () => {
|
|
||||||
it('logs an actionable re-pair alarm and leaves the supervisor running', () => {
|
|
||||||
const fire: { exhausted?: (e: CertExpiredBeyondGraceError) => void } = {}
|
|
||||||
const rotator: CertRotator = {
|
|
||||||
start: vi.fn(),
|
|
||||||
stop: vi.fn(),
|
|
||||||
onRotated: () => {},
|
|
||||||
onRevoked: () => {},
|
|
||||||
onError: () => {},
|
|
||||||
onExhausted: (cb) => {
|
|
||||||
fire.exhausted = cb
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const restartChild = vi.fn()
|
|
||||||
const stop = vi.fn()
|
|
||||||
const lines: string[] = []
|
|
||||||
wireAutoRenew(rotator, { restartChild, stop }, createLogger('info', (l) => lines.push(l)), {
|
|
||||||
subdomain: 'h7fd8',
|
|
||||||
hostId: 'h-1',
|
|
||||||
})
|
|
||||||
fire.exhausted?.(new CertExpiredBeyondGraceError(40 * 86_400_000, 30 * 86_400_000))
|
|
||||||
|
|
||||||
const alarm = lines.find((l) => /re-pair/i.test(l))
|
|
||||||
expect(alarm).toBeDefined()
|
|
||||||
expect(alarm).toContain('h7fd8')
|
|
||||||
// The supervisor keeps running: a later `pair` writes fresh cert files that the restarting
|
|
||||||
// frpc child picks up. Tearing down here would make recovery need a manual restart too.
|
|
||||||
expect(stop).not.toHaveBeenCalled()
|
|
||||||
expect(restartChild).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
/**
|
|
||||||
* A5 default mTLS transport (`defaultMtlsRequest`) — the ONE seam the other nativeRenew tests inject
|
|
||||||
* past, so the real `node:https` transport had zero coverage. These tests mock `node:https` and drive
|
|
||||||
* the actual transport to prove:
|
|
||||||
* - the exact request options (rejectUnauthorized:true + client cert/key + pinned CA + method/body),
|
|
||||||
* - the HIGH fix: a request timeout is armed and a stalled peer REJECTS (never hangs forever),
|
|
||||||
* - the MEDIUM fix: an oversized response body is capped (destroyed + rejected, not buffered).
|
|
||||||
*/
|
|
||||||
import { EventEmitter } from 'node:events'
|
|
||||||
import { mkdtempSync, rmSync } from 'node:fs'
|
|
||||||
import { tmpdir } from 'node:os'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }))
|
|
||||||
vi.mock('node:https', () => ({ request: requestMock, default: { request: requestMock } }))
|
|
||||||
|
|
||||||
import { generateP256Identity } from '../src/keys/identity.js'
|
|
||||||
import { openKeystore } from '../src/keys/keystore.js'
|
|
||||||
import {
|
|
||||||
createMtlsFetch,
|
|
||||||
RENEW_REQUEST_TIMEOUT_MS,
|
|
||||||
MAX_RENEW_RESPONSE_BYTES,
|
|
||||||
} from '../src/certs/nativeRenew.js'
|
|
||||||
|
|
||||||
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
|
|
||||||
|
|
||||||
/** Fake `http.ClientRequest`: records setTimeout/write/end and emits 'error' on destroy(err). */
|
|
||||||
class FakeClientRequest extends EventEmitter {
|
|
||||||
readonly setTimeoutCalls: Array<{ ms: number; cb: () => void }> = []
|
|
||||||
readonly written: string[] = []
|
|
||||||
ended = false
|
|
||||||
destroyedWith: Error | undefined
|
|
||||||
setTimeout(ms: number, cb: () => void): this {
|
|
||||||
this.setTimeoutCalls.push({ ms, cb })
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
write(chunk: string): boolean {
|
|
||||||
this.written.push(chunk)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
end(): this {
|
|
||||||
this.ended = true
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
destroy(err?: Error): this {
|
|
||||||
this.destroyedWith = err
|
|
||||||
if (err) this.emit('error', err)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fake `http.IncomingMessage`: an EventEmitter with a statusCode and a real destroy(). */
|
|
||||||
class FakeIncomingMessage extends EventEmitter {
|
|
||||||
statusCode = 200
|
|
||||||
destroyed = false
|
|
||||||
destroy(): this {
|
|
||||||
this.destroyed = true
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReqCb = (res: FakeIncomingMessage) => void
|
|
||||||
let dirs: string[] = []
|
|
||||||
|
|
||||||
function enrolledKs(): ReturnType<typeof openKeystore> {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nrt-'))
|
|
||||||
dirs.push(dir)
|
|
||||||
const ks = openKeystore(dir)
|
|
||||||
ks.saveIdentity(generateP256Identity())
|
|
||||||
ks.saveCert('LEAFCERT', 'CACHAIN')
|
|
||||||
return ks
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
requestMock.mockReset()
|
|
||||||
})
|
|
||||||
afterEach(() => {
|
|
||||||
for (const d of dirs) rmSync(d, { recursive: true, force: true })
|
|
||||||
dirs = []
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('defaultMtlsRequest transport (A5 — real node:https)', () => {
|
|
||||||
it('passes rejectUnauthorized:true + the client cert/key/CA + method/body, and arms the timeout', async () => {
|
|
||||||
const ks = enrolledKs()
|
|
||||||
let seenUrl = ''
|
|
||||||
let seenOpts: Record<string, unknown> = {}
|
|
||||||
let seenReq: FakeClientRequest | undefined
|
|
||||||
requestMock.mockImplementation((url: string, opts: Record<string, unknown>, cb: ReqCb) => {
|
|
||||||
seenUrl = url
|
|
||||||
seenOpts = opts
|
|
||||||
const req = new FakeClientRequest()
|
|
||||||
seenReq = req
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const res = new FakeIncomingMessage()
|
|
||||||
res.statusCode = 200
|
|
||||||
cb(res)
|
|
||||||
res.emit('data', Buffer.from('{"cert":"NEW",'))
|
|
||||||
res.emit('data', Buffer.from('"caChain":"NEWCA"}'))
|
|
||||||
res.emit('end')
|
|
||||||
})
|
|
||||||
return req
|
|
||||||
})
|
|
||||||
|
|
||||||
const f = createMtlsFetch(ks, { certParser: farFuture }) // NO request seam → real defaultMtlsRequest
|
|
||||||
const res = await f('https://cp.example.com/renew', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: '{"csr":"x"}',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
|
|
||||||
expect(seenUrl).toBe('https://cp.example.com/renew')
|
|
||||||
expect(seenOpts.rejectUnauthorized).toBe(true) // anti-MITM (INV14)
|
|
||||||
expect(seenOpts.method).toBe('POST')
|
|
||||||
expect(seenOpts.cert).toBe('LEAFCERT')
|
|
||||||
// ca omitted ⇒ verify the LE-fronted control-plane against the system roots (not the private CA).
|
|
||||||
expect(seenOpts.ca).toBeUndefined()
|
|
||||||
expect(String(seenOpts.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key
|
|
||||||
// HIGH fix: a socket timeout is armed with the sane default so a stall can never hang forever.
|
|
||||||
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
|
|
||||||
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
|
|
||||||
expect(seenReq!.written).toEqual(['{"csr":"x"}'])
|
|
||||||
expect(seenReq!.ended).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('HIGH: a stalled peer (accepts TLS, never responds) REJECTS via the timeout, not hangs', async () => {
|
|
||||||
const ks = enrolledKs()
|
|
||||||
let seenReq: FakeClientRequest | undefined
|
|
||||||
requestMock.mockImplementation(() => {
|
|
||||||
const req = new FakeClientRequest()
|
|
||||||
seenReq = req
|
|
||||||
return req // never invokes the response callback — a stalled control-plane
|
|
||||||
})
|
|
||||||
|
|
||||||
const f = createMtlsFetch(ks, { certParser: farFuture })
|
|
||||||
const p = f('https://cp.example.com/renew', { method: 'POST', body: '{}' })
|
|
||||||
|
|
||||||
// Fire the armed socket-timeout callback (what node does when the socket idles past the limit).
|
|
||||||
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
|
|
||||||
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
|
|
||||||
seenReq!.setTimeoutCalls[0]!.cb()
|
|
||||||
|
|
||||||
await expect(p).rejects.toThrow(/timed out/i)
|
|
||||||
expect(seenReq!.destroyedWith).toBeInstanceOf(Error) // socket was torn down
|
|
||||||
})
|
|
||||||
|
|
||||||
it('MEDIUM: an oversized response body is capped — res destroyed + promise rejected', async () => {
|
|
||||||
const ks = enrolledKs()
|
|
||||||
let seenRes: FakeIncomingMessage | undefined
|
|
||||||
requestMock.mockImplementation((_url: string, _opts: unknown, cb: ReqCb) => {
|
|
||||||
const req = new FakeClientRequest()
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const res = new FakeIncomingMessage()
|
|
||||||
res.statusCode = 200
|
|
||||||
seenRes = res
|
|
||||||
cb(res)
|
|
||||||
res.emit('data', Buffer.alloc(MAX_RENEW_RESPONSE_BYTES + 1)) // one byte over the cap
|
|
||||||
// deliberately NO 'end' — a capped stream must reject on its own, not wait for end
|
|
||||||
})
|
|
||||||
return req
|
|
||||||
})
|
|
||||||
|
|
||||||
const f = createMtlsFetch(ks, { certParser: farFuture })
|
|
||||||
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(/cap|exceed/i)
|
|
||||||
expect(seenRes!.destroyed).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -8,13 +8,9 @@ import { openKeystore } from '../src/keys/keystore.js'
|
|||||||
import {
|
import {
|
||||||
computeRenewDelayMs,
|
computeRenewDelayMs,
|
||||||
createCertRotator,
|
createCertRotator,
|
||||||
recoverCert,
|
|
||||||
recoveryUrlFor,
|
|
||||||
renewCert,
|
renewCert,
|
||||||
renewalUrlFor,
|
renewalUrlFor,
|
||||||
} from '../src/certs/rotation.js'
|
} from '../src/certs/rotation.js'
|
||||||
import { CertExpiredBeyondGraceError } from '../src/certs/rotation.js'
|
|
||||||
import { createBackoff } from '../src/transport/backoff.js'
|
|
||||||
import { FakeTimer } from './fixtures/fakes.js'
|
import { FakeTimer } from './fixtures/fakes.js'
|
||||||
|
|
||||||
const CFG: AgentConfig = {
|
const CFG: AgentConfig = {
|
||||||
@@ -56,10 +52,10 @@ describe('renewCert (T13)', () => {
|
|||||||
it('installs a fresh cert atomically on success (same key)', async () => {
|
it('installs a fresh cert atomically on success (same key)', async () => {
|
||||||
const { dir, ks } = enrolledKs()
|
const { dir, ks } = enrolledKs()
|
||||||
const before = ks.loadIdentity()!.publicKey
|
const before = ks.loadIdentity()!.publicKey
|
||||||
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }))
|
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' }))
|
||||||
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||||
expect(out).toBe('rotated')
|
expect(out).toBe('rotated')
|
||||||
expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA')
|
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' })
|
||||||
// pubkey unchanged — only the cert rotated
|
// pubkey unchanged — only the cert rotated
|
||||||
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
||||||
rmSync(dir, { recursive: true, force: true })
|
rmSync(dir, { recursive: true, force: true })
|
||||||
@@ -105,7 +101,7 @@ describe('createCertRotator (T13)', () => {
|
|||||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||||
timer,
|
timer,
|
||||||
renewBeforeMs: 1000,
|
renewBeforeMs: 1000,
|
||||||
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch,
|
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch,
|
||||||
now: () => new Date(0),
|
now: () => new Date(0),
|
||||||
parseCert: () => new Date(2000),
|
parseCert: () => new Date(2000),
|
||||||
})
|
})
|
||||||
@@ -117,194 +113,8 @@ describe('createCertRotator (T13)', () => {
|
|||||||
timer.advance(1000)
|
timer.advance(1000)
|
||||||
await flush()
|
await flush()
|
||||||
expect(rotated).toBe(1)
|
expect(rotated).toBe(1)
|
||||||
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
|
expect(ks.loadCert()!.certPem).toBe('NEWCERT')
|
||||||
rotator.stop()
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('invokes onError and retries with backoff (not renewBeforeMs) when a renewal throws', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
const timer = new FakeTimer()
|
|
||||||
let calls = 0
|
|
||||||
const fetchImpl = (async () => {
|
|
||||||
calls += 1
|
|
||||||
if (calls === 1) throw new Error('network down')
|
|
||||||
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
|
|
||||||
}) as unknown as typeof fetch
|
|
||||||
const errors: unknown[] = []
|
|
||||||
let rotated = 0
|
|
||||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
|
||||||
timer,
|
|
||||||
renewBeforeMs: 1000,
|
|
||||||
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
|
|
||||||
fetchImpl,
|
|
||||||
now: () => new Date(0),
|
|
||||||
parseCert: () => new Date(2000), // initial renewal scheduled at ~1000ms
|
|
||||||
})
|
|
||||||
rotator.onError((e) => errors.push(e))
|
|
||||||
rotator.onRotated(() => {
|
|
||||||
rotated += 1
|
|
||||||
})
|
|
||||||
rotator.start()
|
|
||||||
|
|
||||||
timer.advance(1000) // first attempt fires → throws
|
|
||||||
await flush()
|
|
||||||
expect(errors).toHaveLength(1)
|
|
||||||
expect(rotated).toBe(0)
|
|
||||||
|
|
||||||
// The retry is armed at the 500ms backoff delay, NOT renewBeforeMs (1000): advancing only 500
|
|
||||||
// must fire it. A crash-loop never escapes here (the supervisor keeps running).
|
|
||||||
timer.advance(500)
|
|
||||||
await flush()
|
|
||||||
expect(rotated).toBe(1)
|
|
||||||
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
|
|
||||||
rotator.stop()
|
rotator.stop()
|
||||||
rmSync(dir, { recursive: true, force: true })
|
rmSync(dir, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Expired-leaf recovery (production deadlock, 2026-07): `/renew` is mTLS-authenticated by the leaf
|
|
||||||
* it renews, so a lapsed leaf can never renew itself — and nginx will not even forward an expired
|
|
||||||
* client cert. Recovery is therefore a PLAIN POST to `/recover` carrying the expired cert in the
|
|
||||||
* body, plus a terminal signal once the grace window is spent so the agent stops retrying forever.
|
|
||||||
*/
|
|
||||||
describe('expired-leaf recovery', () => {
|
|
||||||
const HOUR = 3_600_000
|
|
||||||
|
|
||||||
it('derives /recover as a sibling PATH on the same enroll host', () => {
|
|
||||||
expect(recoveryUrlFor({ ...CFG, enrollUrl: 'https://enroll.terminal.example.com/enroll' })).toBe(
|
|
||||||
'https://enroll.terminal.example.com/recover',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('honours an explicit recoverUrl over the derivation', () => {
|
|
||||||
expect(recoveryUrlFor({ ...CFG, recoverUrl: 'https://elsewhere.example.com/recover' })).toBe(
|
|
||||||
'https://elsewhere.example.com/recover',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('recoverCert posts the EXPIRED cert plus a CSR, with no client cert, and installs the result', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
let seenUrl = ''
|
|
||||||
let seenBody: { cert?: string; csr?: string } = {}
|
|
||||||
const fetchImpl = (async (u: string, init: { body: string }) => {
|
|
||||||
seenUrl = u
|
|
||||||
seenBody = JSON.parse(init.body)
|
|
||||||
return jsonRes(201, { cert: 'FRESHCERT', caChain: ['FRESHCA'] })
|
|
||||||
}) as unknown as typeof fetch
|
|
||||||
|
|
||||||
const outcome = await recoverCert(
|
|
||||||
{ ...CFG, enrollUrl: 'https://enroll.terminal.example.com/enroll' },
|
|
||||||
ks.loadIdentity()!,
|
|
||||||
ks,
|
|
||||||
fetchImpl,
|
|
||||||
)
|
|
||||||
expect(outcome).toBe('rotated')
|
|
||||||
expect(seenUrl).toBe('https://enroll.terminal.example.com/recover')
|
|
||||||
expect(seenBody.cert).toBe('OLDCERT') // the lapsed leaf travels in the BODY, not the TLS layer
|
|
||||||
expect(seenBody.csr).toBeTruthy()
|
|
||||||
expect(ks.loadCert()!.certPem).toContain('FRESHCERT')
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a still-valid leaf uses the mTLS /renew fetch, never the recovery one', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
const timer = new FakeTimer()
|
|
||||||
let renewCalls = 0
|
|
||||||
let recoverCalls = 0
|
|
||||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
|
||||||
timer,
|
|
||||||
renewBeforeMs: 1000,
|
|
||||||
fetchImpl: (async () => {
|
|
||||||
renewCalls += 1
|
|
||||||
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
|
|
||||||
}) as unknown as typeof fetch,
|
|
||||||
recoverFetchImpl: (async () => {
|
|
||||||
recoverCalls += 1
|
|
||||||
return jsonRes(200, {})
|
|
||||||
}) as unknown as typeof fetch,
|
|
||||||
now: () => new Date(0),
|
|
||||||
parseCert: () => new Date(2000), // valid at now=0
|
|
||||||
})
|
|
||||||
rotator.start()
|
|
||||||
timer.advance(1000)
|
|
||||||
await flush()
|
|
||||||
expect(renewCalls).toBe(1)
|
|
||||||
expect(recoverCalls).toBe(0)
|
|
||||||
rotator.stop()
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('an expired leaf INSIDE the grace window switches to the recovery fetch', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
const timer = new FakeTimer()
|
|
||||||
let renewCalls = 0
|
|
||||||
let recoverCalls = 0
|
|
||||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
|
||||||
timer,
|
|
||||||
renewBeforeMs: 1000,
|
|
||||||
expiredGraceMs: 30 * 24 * HOUR,
|
|
||||||
fetchImpl: (async () => {
|
|
||||||
renewCalls += 1
|
|
||||||
return jsonRes(200, {})
|
|
||||||
}) as unknown as typeof fetch,
|
|
||||||
recoverFetchImpl: (async () => {
|
|
||||||
recoverCalls += 1
|
|
||||||
return jsonRes(201, { cert: 'NEWCERT', caChain: ['NEWCA'] })
|
|
||||||
}) as unknown as typeof fetch,
|
|
||||||
now: () => new Date(8 * 24 * HOUR), // 8 days after the leaf lapsed
|
|
||||||
parseCert: () => new Date(0),
|
|
||||||
})
|
|
||||||
let rotated = 0
|
|
||||||
rotator.onRotated(() => {
|
|
||||||
rotated += 1
|
|
||||||
})
|
|
||||||
rotator.start()
|
|
||||||
timer.advance(0)
|
|
||||||
await flush()
|
|
||||||
expect(recoverCalls).toBe(1)
|
|
||||||
expect(renewCalls).toBe(0)
|
|
||||||
expect(rotated).toBe(1)
|
|
||||||
rotator.stop()
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('BEYOND the grace window it fires onExhausted, issues no request, and stops retrying', async () => {
|
|
||||||
const { dir, ks } = enrolledKs()
|
|
||||||
const timer = new FakeTimer()
|
|
||||||
let requests = 0
|
|
||||||
const countingFetch = (async () => {
|
|
||||||
requests += 1
|
|
||||||
return jsonRes(201, {})
|
|
||||||
}) as unknown as typeof fetch
|
|
||||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
|
||||||
timer,
|
|
||||||
renewBeforeMs: 1000,
|
|
||||||
expiredGraceMs: 30 * 24 * HOUR,
|
|
||||||
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
|
|
||||||
fetchImpl: countingFetch,
|
|
||||||
recoverFetchImpl: countingFetch,
|
|
||||||
now: () => new Date(31 * 24 * HOUR), // 31 days stale ⇒ past a 30-day grace
|
|
||||||
parseCert: () => new Date(0),
|
|
||||||
})
|
|
||||||
const errors: unknown[] = []
|
|
||||||
let exhausted: CertExpiredBeyondGraceError | null = null
|
|
||||||
rotator.onError((e) => errors.push(e))
|
|
||||||
rotator.onExhausted((e) => {
|
|
||||||
exhausted = e
|
|
||||||
})
|
|
||||||
rotator.start()
|
|
||||||
timer.advance(0)
|
|
||||||
await flush()
|
|
||||||
|
|
||||||
expect(exhausted).toBeInstanceOf(CertExpiredBeyondGraceError)
|
|
||||||
expect(requests).toBe(0) // nothing is even attempted — it cannot succeed
|
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
// Terminal: no retry armed. Retrying forever is what produced 6380 identical warnings.
|
|
||||||
timer.advance(60_000)
|
|
||||||
await flush()
|
|
||||||
expect(requests).toBe(0)
|
|
||||||
rmSync(dir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
3
android/.gitignore
vendored
3
android/.gitignore
vendored
@@ -40,6 +40,3 @@ service-account*.json
|
|||||||
|
|
||||||
# Kover / test reports
|
# Kover / test reports
|
||||||
**/kover/
|
**/kover/
|
||||||
|
|
||||||
# Kotlin compiler session/error scratch (KGP 2.x writes this at the project root)
|
|
||||||
.kotlin/
|
|
||||||
|
|||||||
@@ -1,133 +1,19 @@
|
|||||||
# Android client — device-QA checklist (A36 / plan §7)
|
# Android client — device-QA checklist (A36 / plan §7)
|
||||||
|
|
||||||
Everything below is **device/emulator-only**: it cannot be proven by the JVM suite. The pure logic
|
Everything below is **device/emulator-only** — it could NOT run in the build environment (no emulator,
|
||||||
underneath each item IS unit-tested (757 JVM tests across 10 modules, Kover ≥80% on the pure modules),
|
no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests,
|
||||||
but the 2026-07-30 audit established the hard lesson this file now exists to enforce — **a green JVM
|
Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping.
|
||||||
suite says nothing about whether the app works.** Three crash-on-first-keypress defects and a
|
|
||||||
cleartext posture that made bare-LAN connection impossible all sat behind 484 passing tests, because
|
|
||||||
no JVM test instantiates an Android `View` and no JVM test dials a socket.
|
|
||||||
|
|
||||||
**Rules for this file:** tick a box ONLY after observing the behaviour on real hardware (or, where
|
|
||||||
noted, an emulator) — and say what you observed. Never tick something because the code looks right or
|
|
||||||
a unit test covers it. Unticked is the honest default.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Observed so far (the only on-device evidence that exists)
|
|
||||||
|
|
||||||
**2026-07-30, minified `benchmark` variant, `webterm` AVD (android-35, arm64, software GPU):**
|
|
||||||
|
|
||||||
- [x] The **R8-minified, resource-shrunk, non-debuggable APK installs and cold-starts.** `am start -W`
|
|
||||||
→ `Status: ok`, `LaunchState: COLD`, `TotalTime: 1085 ms` (software-GPU emulator — not a
|
|
||||||
meaningful performance number, only proof of a clean start). Process stayed alive;
|
|
||||||
`logcat -b crash` empty; no `FATAL EXCEPTION`, `NoClassDefFoundError` or `ClassNotFoundException`.
|
|
||||||
This is the check that R8 keep rules are correct — the class of bug that appears ONLY in release.
|
|
||||||
- [x] **Cold-start route with no paired host → pairing screen**, rendered correctly under R8
|
|
||||||
(`ColdStartPolicy`, A29): title 配对主机, the 手动输入/扫码 segmented control, the
|
|
||||||
`主机地址(http(s)://…)` field and a disabled 下一步 button. Compose + Material 3 + the design-token
|
|
||||||
theme + Hilt injection therefore all survive minification.
|
|
||||||
- [x] **`POST_NOTIFICATIONS` runtime prompt appears** on first launch (API 33+ path, A30).
|
|
||||||
- [x] **ML Kit component registration** no longer fails under R8 (`ComponentDiscovery` clean). Before
|
|
||||||
the `ComponentRegistrar` keep rule it logged
|
|
||||||
`NoSuchMethodException: com.google.mlkit.common.internal.CommonComponentRegistrar.<init> []`,
|
|
||||||
which would have silently disabled **QR barcode scanning in release builds only**.
|
|
||||||
|
|
||||||
**Expected/benign in the same run:** `W FirebaseApp: Default FirebaseApp failed to initialize because
|
|
||||||
no default options were found` — there is no `google-services.json` yet (see the deploy artifacts
|
|
||||||
below). A `System UI isn't responding` dialog also appeared; that is the emulator's own `com.android.systemui`
|
|
||||||
under software GPU, not this app (our process logged no ANR and stayed `topResumedActivity`).
|
|
||||||
|
|
||||||
**Everything else in this file is unobserved.** Notably: no real WebTerm host has ever been connected
|
|
||||||
to from Android, no terminal bytes have ever been rendered on a device, and no push has ever arrived.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deploy artifacts to provide first (not built here)
|
## Deploy artifacts to provide first (not built here)
|
||||||
- [ ] **Release signing credentials.** `:app:assembleRelease` deliberately FAILS at packaging until they
|
|
||||||
exist — it will never emit an unsigned APK. Provide `webterm.release.{storeFile,storePassword,keyAlias,keyPassword}`
|
|
||||||
in `android/keystore.properties` (see `keystore.properties.example`; confirm it is gitignored
|
|
||||||
first) or `android/local.properties`, or the `WEBTERM_RELEASE_*` env vars. Keep the keystore
|
|
||||||
out of the repo — a lost signing key cannot be recovered.
|
|
||||||
- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its
|
- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its
|
||||||
absence with `runCatching`, so the app runs without it; push just won't register).
|
absence with `runCatching`, so the app runs without it; push just won't register).
|
||||||
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
|
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
|
||||||
omitted it — applying it without the json fails the build).
|
omitted it — applying it without the json fails the build).
|
||||||
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
|
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
|
||||||
SHA-256 (for the verified App Link `autoVerify`). Read it with
|
SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the
|
||||||
`keytool -list -v -keystore <ks> -alias <alias> | grep SHA256`. Server-side: run `A33`
|
`FCM_*` env group (service-account key path + project id).
|
||||||
`src/push/fcm.ts` with the `FCM_*` env group (service-account key path + project id).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FIRST PRIORITY — confirm the 2026-07-30 blocker fixes on a device
|
|
||||||
|
|
||||||
These were the defects that made the app unusable on hardware. All are fixed and JVM-verified; **none
|
|
||||||
has been exercised on a device.** Until this block is ticked, nothing further in this file is worth
|
|
||||||
running, because every one of these sits on the path to the terminal.
|
|
||||||
|
|
||||||
- [ ] **Typing does not crash.** Soft-keyboard keys, Enter, Backspace via the installed
|
|
||||||
`WebTermTerminalViewClient` / `RemoteTerminalHostView` IME contract. (Was: NPE on the first
|
|
||||||
keypress — stock `TerminalView.onKeyDown` dereferences `mClient`, then `mTermSession`.)
|
|
||||||
- [ ] **Swipe-scrolling inside an alternate-screen TUI does not crash.** Claude Code IS an
|
|
||||||
alternate-screen TUI, so this is ordinary use, not an edge case: drag vertically while a Claude
|
|
||||||
session is running. Also test the fling and, on a device with a mouse/trackpad, wheel scroll
|
|
||||||
(`onGenericMotionEvent`). (Was: `doScroll` → `handleKeyCode` → `mTermSession` NPE.)
|
|
||||||
- [ ] **A password manager does not crash the app.** Open the app with an autofill service enabled and
|
|
||||||
focus the terminal. (Was: unguarded `autofill()` while the view advertised itself autofillable;
|
|
||||||
the subtree is now excluded from the autofill structure.)
|
|
||||||
- [ ] **`resize` actually reaches the PTY.** A full-screen TUI fills the screen instead of rendering
|
|
||||||
into an 80x24 box, and reflows on rotation. (Was: `updateSize` had zero call sites.) Confirm the
|
|
||||||
cols×rows the server reports match what `TerminalResizeDriver` computed from
|
|
||||||
`TerminalRenderer.getFontWidth()/getFontLineSpacing()`.
|
|
||||||
- [ ] **Bare-LAN `ws://` connects.** Pair with `http://<lan-ip>:3000` and attach. (Was:
|
|
||||||
`network_security_config.xml` was a stub denying all cleartext while `HostEndpoint` derives
|
|
||||||
`ws://` from `http://`.)
|
|
||||||
- [ ] **The tunnel domain is still TLS-only.** `http://<anything>.terminal.yaojia.wang` must be refused
|
|
||||||
(the one hostname with `cleartextTrafficPermitted="false"`), and `OkHttpClientFactory` must
|
|
||||||
refuse a plaintext dial to a non-private host even if the config would allow it.
|
|
||||||
- [ ] **§6.4 device-switch reclaim.** Attach the same session from a phone and a tablet; the device you
|
|
||||||
last touched drives the PTY size (latest-writer-wins) and reclaims full screen on
|
|
||||||
pane-show/window-focus without a re-attach.
|
|
||||||
- [ ] **`WEBTERM_TOKEN` gate end-to-end** against a server started WITH the token: REST + the WS
|
|
||||||
upgrade both carry the cookie, it survives a process restart (sealed with Tink AEAD under an
|
|
||||||
AndroidKeyStore key), and a **seal failure persists nothing** rather than falling back to
|
|
||||||
plaintext. Also verify the cookie never appears in `logcat`.
|
|
||||||
⚠️ **Blocked until the cookie jar + cipher are installed in `:app`'s DI** — until then the gate
|
|
||||||
is inert at runtime no matter what the server does.
|
|
||||||
- [ ] **Newly-wired surfaces that had ZERO call sites before this session** — each needs a first-ever
|
|
||||||
look: session-list **preview thumbnails** (`ThumbnailPipeline`), **quick-reply chips + palette**
|
|
||||||
(`QuickReply`), and the tablet **pointer context menu** (`PointerContextMenu`).
|
|
||||||
- [ ] **Newly-decoding server frames.** The `queue` frame ("N queued" badge) and `status.preview` were
|
|
||||||
both being dropped as undecodable — the latter meant remote approvals were **blind**. Confirm a
|
|
||||||
real gate now shows the command being approved, and that a malformed preview does not cost the
|
|
||||||
whole frame (the gate must still appear).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## KNOWN FEATURE GAP — copy-out via text selection is unavailable (deliberate)
|
|
||||||
|
|
||||||
**Not a bug to be fixed by re-enabling stock selection.** At the pinned Termux v0.118.0, the stock
|
|
||||||
`ActionMode`'s own handlers dereference the `TerminalSession` this app deliberately does not have
|
|
||||||
(`TextSelectionCursorController$1.onActionItemClicked`, offsets 89-100 for Copy and 126-136 for Paste).
|
|
||||||
`TerminalSession` is `final` and its constructor forks a local process over JNI — exactly what a remote
|
|
||||||
terminal must not do (plan §6.1). So making the selection UI reachable would put a guaranteed NPE on
|
|
||||||
the **Copy** button, which is worse than not offering it. Long press is therefore consumed and
|
|
||||||
selection never starts.
|
|
||||||
|
|
||||||
**The real fix** (not built, tracked): a first-party selection layer — hit-test to a cell, own the
|
|
||||||
selection anchors, read the text straight out of `TerminalBuffer`, and write it with `ClipboardManager`.
|
|
||||||
Paste-in is unaffected and already works.
|
|
||||||
|
|
||||||
- [ ] Confirm on a device that long press is inert and does **not** show a broken Copy affordance.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A34 — instrumented E2E vs a real `npm start` host
|
|
||||||
|
|
||||||
Infrastructure now EXISTS (`:app` has androidTest deps, a Hilt-aware `testInstrumentationRunner`, and
|
|
||||||
Espresso + Compose-UI-test on the classpath) but **the tests themselves are not written**. See
|
|
||||||
`android/README.md` → "Instrumented tests" for the one runner class that must be authored first.
|
|
||||||
|
|
||||||
|
## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device)
|
||||||
- [ ] `attach → attached → output` round-trip timing.
|
- [ ] `attach → attached → output` round-trip timing.
|
||||||
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
|
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
|
||||||
- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy.
|
- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy.
|
||||||
@@ -136,19 +22,15 @@ Espresso + Compose-UI-test on the classpath) but **the tests themselves are not
|
|||||||
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
|
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
|
||||||
|
|
||||||
## A35 — one macrobenchmark / Espresso happy path
|
## A35 — one macrobenchmark / Espresso happy path
|
||||||
|
|
||||||
The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumenting `:app`'s
|
|
||||||
`benchmark` variant out-of-process); it has **no sources yet**.
|
|
||||||
|
|
||||||
- [ ] pair → attach → type → approve, on a real device.
|
- [ ] pair → attach → type → approve, on a real device.
|
||||||
- [ ] cold-start timing on real hardware (`StartupTimingMetric`). The 1085 ms above is a software-GPU
|
|
||||||
emulator number and must not be quoted as a baseline.
|
|
||||||
|
|
||||||
## Terminal (A16/A17/A21)
|
## Terminal (A16/A17/A21)
|
||||||
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
|
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
|
||||||
WebView fallback ONLY if fidelity diverges — not expected).
|
WebView fallback ONLY if fidelity diverges — not expected).
|
||||||
- [ ] IME/CJK composition; http/https link tap. (Selection→clipboard is the gap above, not a test.)
|
- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap.
|
||||||
- [ ] **real font-metric → grid resize** and resize parity vs web/iOS on the same device sizes (R5).
|
- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are
|
||||||
|
package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); resize parity vs
|
||||||
|
web/iOS on the same device sizes (R5).
|
||||||
- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay
|
- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay
|
||||||
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
|
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
|
||||||
generation bump → fresh emulator replays.
|
generation bump → fresh emulator replays.
|
||||||
@@ -165,99 +47,30 @@ The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumen
|
|||||||
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
|
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
|
||||||
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
|
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
|
||||||
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
|
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
|
||||||
- [ ] token registers to every paired host + self-heals on rotation / new host / removal.
|
- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals
|
||||||
- [ ] **In a MINIFIED build specifically** — FCM and ML Kit both discover components reflectively, so
|
on rotation / new host / removal.
|
||||||
re-verify push registration and QR scanning on the `benchmark`/release variant, not just debug.
|
|
||||||
|
|
||||||
## Pairing / cert / storage (A19/A27/A11/A12)
|
## Pairing / cert / storage (A19/A27/A11/A12)
|
||||||
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. **Run this on a minified
|
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry.
|
||||||
variant too** (see the `ComponentRegistrar` note above — this path is the one R8 already broke once).
|
|
||||||
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
|
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
|
||||||
bypass); public host explicit-ack.
|
bypass); public host explicit-ack.
|
||||||
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
|
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
|
||||||
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
|
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
|
||||||
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
|
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
|
||||||
- [ ] **Cert summary does not crash** — see the `javax.naming` defect under "Known gaps"; the issuer-CN /
|
|
||||||
expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY.
|
|
||||||
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
||||||
|
|
||||||
## Access token — `WEBTERM_TOKEN` (B5 / ios-completion §1.1) — needs a token-enabled host
|
|
||||||
Run the host as `WEBTERM_TOKEN=<16–512 chars> npm start`. The pure logic is JVM-tested (`AuthProbeTest`,
|
|
||||||
`AccessTokenCookieTest`, `PairingProbeTokenTest`, `OkHttpAccessTokenTest`, `SessionEngineUnauthorizedTest`,
|
|
||||||
`InMemoryAccessTokenStoreTest`); everything below needs real hardware + a real server.
|
|
||||||
- [ ] `TinkAccessTokenStore` instrumented suite on a device:
|
|
||||||
`./gradlew :host-registry:connectedDebugAndroidTest` (real AndroidKeyStore + Tink).
|
|
||||||
- [ ] Pair a token-enabled host with the token typed → paired; the terminal attaches (the **WS upgrade**
|
|
||||||
carries `Cookie: webterm_auth=…`), the session list, projects, diff and thumbnails all load.
|
|
||||||
- [ ] Pair the same host with **no** token typed → the probe 401s → the confirm step comes back asking for
|
|
||||||
the token ("该主机启用了访问令牌"); typing it and retrying pairs.
|
|
||||||
- [ ] Wrong token → "访问令牌不正确" **under the field** (still on the confirm step, nothing stored).
|
|
||||||
- [ ] 11 wrong tries within a minute → "尝试过多" (the server's 10/min/IP `/auth` limiter).
|
|
||||||
- [ ] Pair a host with `WEBTERM_TOKEN` **unset** while typing a token → pairs, and NOTHING is stored
|
|
||||||
(204-without-`Set-Cookie`); the app must not claim to be "authenticated".
|
|
||||||
- [ ] Restart the app → the terminal re-attaches with no re-prompt (the token decrypts from the
|
|
||||||
Keystore-backed store on a cold start).
|
|
||||||
- [ ] Rotate `WEBTERM_TOKEN` on the host and restart it → the WS upgrade 401s → the terminal banner shows
|
|
||||||
the 401 copy, offers **no** "新会话", and there is **no reconnect back-off storm** (verify with
|
|
||||||
`adb logcat` / the server log: exactly one upgrade attempt).
|
|
||||||
- [ ] `adb logcat` during all of the above: the token string appears **nowhere**; no request URL contains
|
|
||||||
it (check the server's access log too).
|
|
||||||
- [ ] `adb backup` / device-to-device transfer excludes the token blob (`allowBackup=false` +
|
|
||||||
`data_extraction_rules.xml`).
|
|
||||||
|
|
||||||
## Nav / deep links / adaptive (A32/A26/A29)
|
## Nav / deep links / adaptive (A32/A26/A29)
|
||||||
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
||||||
right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and
|
right destination; invalid UUID ignored.
|
||||||
`assetlinks.json`.)
|
- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens.
|
||||||
- [ ] continue-last-session banner re-opens. (The no-host → pairing half is observed above.)
|
|
||||||
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
||||||
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
||||||
|
|
||||||
## Projects / git parity (W5 — presenters JVM-tested, Compose device-QA)
|
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
|
||||||
- [ ] Project card **sync chip**: `↑ahead` / `↓behind` render only when non-zero; no chip when there is
|
|
||||||
no upstream (fields absent).
|
|
||||||
- [ ] Project detail **PR chip**: `availability=ok` → tappable chip opens the PR in the browser ONLY when
|
|
||||||
the url is https (a non-https / junk url is inert, non-clickable); `no-pr` / `not-installed` /
|
|
||||||
`unauthenticated` / `disabled` / `error` each render the degraded copy inertly; check-count colour
|
|
||||||
(fail=red / pending=amber / pass=green).
|
|
||||||
- [ ] Project detail **recent commits**: list renders short-hash + subject inertly; unavailable state on a
|
|
||||||
log failure does NOT hide the rest of the detail (failure-isolated).
|
|
||||||
- [ ] **New worktree** inline form: valid `branch` (+optional `base`) → create → list refreshes; an invalid
|
|
||||||
branch name is rejected with NO network call; a disabled-403 shows the server's safe message.
|
|
||||||
- [ ] Per-worktree **remove**: the button is absent on the `main` worktree; the confirm dialog offers a
|
|
||||||
**Force** checkbox; a dirty-worktree 409 surfaces "force required" inertly; **prune** button works.
|
|
||||||
- [ ] Diff **base-rev** input: entering a rev enters base mode (Working/Staged toggle hidden, `vs <rev>`
|
|
||||||
shown, git-write controls hidden); Clear returns to working/staged; junk rev → server 400 surfaced.
|
|
||||||
- [ ] Diff **stage/unstage**: per-file button (Working→"暂存", Staged→"取消暂存") posts the file and
|
|
||||||
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
|
|
||||||
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known gaps (tracked, non-blocking for QA but must be routed to an owner)
|
|
||||||
|
|
||||||
- [ ] **`:client-tls` uses `javax.naming.ldap.LdapName`, which does not exist on Android.** Found by R8
|
|
||||||
(`Missing class javax.naming.ldap.LdapName ... referenced from CertificateSummaryReader.commonName`).
|
|
||||||
On-device that call throws `NoClassDefFoundError`, an **`Error`** — so it slips straight through the
|
|
||||||
function's `catch (_: Exception)` guard and propagates out of `summarize()`. The module is pure-JVM
|
|
||||||
and its unit tests run on a JDK where `LdapName` DOES exist, which is exactly why the green suite
|
|
||||||
never caught it. Effect: the device-cert screen's issuer-CN/expiry summary (A27) crashes.
|
|
||||||
Fix: hand-parse the RFC2253 DN (or at minimum catch `Throwable`). The `-dontwarn` in
|
|
||||||
`proguard-rules.pro` only unblocks R8; it changes nothing at runtime.
|
|
||||||
- [ ] **The auth cookie jar + cipher are not installed in `:app`'s DI**, so the `WEBTERM_TOKEN` gate is
|
|
||||||
inert at runtime even though every piece is implemented and tested.
|
|
||||||
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
|
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
|
||||||
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
|
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
|
||||||
- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
|
- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
|
||||||
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
|
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
|
||||||
- [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a
|
- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView`
|
||||||
`dataExtractionRules` `<device-transfer>` xml resource. Until then a D2D migration could copy the
|
is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to
|
||||||
sealed auth cookie to another handset.
|
`:app` and delete the shim.
|
||||||
- [ ] AGP's lint crashes with `FirDeclaration was not found for class KtProperty, fir is null` on
|
|
||||||
`ThumbnailPipeline.kt` when run under `--rerun-tasks`. `lintVitalRelease` passes normally and from
|
|
||||||
a cold lint state; this is a lint/K2 internal bug (lint says so itself), not an app defect. Do not
|
|
||||||
use `--rerun-tasks` in a release gate.
|
|
||||||
|
|
||||||
> RESOLVED since the last revision: the A21 reflection shim (`getMethod("getTerminalView")`) is **gone** —
|
|
||||||
> `RemoteTerminalHostView` now holds the stock `com.termux.view.TerminalView` directly, so there is no
|
|
||||||
> reflective getter left to break under R8 and no keep rule is needed for one.
|
|
||||||
|
|||||||
@@ -372,99 +372,6 @@ warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ CORRECTION (2026-07-30): "COMPLETE" below meant COMPILES, not WORKS
|
|
||||||
|
|
||||||
The section that follows claimed all 36 tasks had landed. That was true at the level it was measured —
|
|
||||||
the modules built, the APK assembled and ~484 JVM tests were green — but NOTHING had ever run on an
|
|
||||||
Android device or emulator, and the first thing that would have happened on one is a crash. An audit on
|
|
||||||
2026-07-30 found, and a repair pass fixed, the following. Read this before trusting anything below.
|
|
||||||
|
|
||||||
**Three defects made the app unusable on real hardware.** Each was invisible to the JVM suite for the
|
|
||||||
same structural reason: no JVM test instantiates an Android `View`.
|
|
||||||
|
|
||||||
1. **Every key press crashed.** `RemoteTerminalView` bound the forked emulator but never installed a
|
|
||||||
`TerminalViewClient`. The pinned Termux `TerminalView` dereferences `mClient` with no null guard on
|
|
||||||
the first line of the paths a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
|
|
||||||
offset 0, `onKeyUp`, `onKeyPreIme`). The KDoc claimed "A17 sets it" — A17 never did; only the
|
|
||||||
`HardwareKeyRouter` and the KeyBar shipped, and the KeyBar works precisely because it bypasses the
|
|
||||||
view. Installing a client is not sufficient: returning false continues into `mTermSession.write()`
|
|
||||||
at offset 150, and `mTermSession` is null forever (`TerminalSession` is final and forking a process
|
|
||||||
is exactly what this app must not do, §6.1). Fixed by a client that consumes the key itself, with
|
|
||||||
`KeyRouting` deliberately having NO defer-to-stock branch, so the crash is unrepresentable rather
|
|
||||||
than merely avoided. `KeyHandler` remains the authority for the key tables, so DECCKM still emits
|
|
||||||
`ESC O A`.
|
|
||||||
2. **No `resize` was ever sent.** `RemoteTerminalSession.updateSize` had zero call sites and the stock
|
|
||||||
fallback is dead (`TerminalView.updateSize` early-returns without a session), so the PTY stayed at
|
|
||||||
the server's 80x24 and every full-screen TUI rendered into the wrong box. `TerminalResizeDriver`
|
|
||||||
now drives it from real cell metrics, read via the PUBLIC `TerminalRenderer.getFontWidth()` /
|
|
||||||
`getFontLineSpacing()` accessors — no reflection (silently breaks under R8) and no re-measured
|
|
||||||
`Paint` (drifts from what the renderer actually draws, the §R5 risk).
|
|
||||||
3. **Bare-LAN connections were impossible.** `network_security_config.xml` was a self-labelled STUB
|
|
||||||
denying all cleartext, while `HostEndpoint` accepts `http` and derives `ws://`. Its own comment
|
|
||||||
promised a CIDR allowlist the platform cannot express. See plan §6.9 for the corrected posture.
|
|
||||||
|
|
||||||
**Adversarial review then found three more, and got one wrong:**
|
|
||||||
|
|
||||||
- **Swipe-scrolling inside an alternate-screen TUI still crashed.** The touch path bypasses
|
|
||||||
`TerminalViewClient` entirely: `doScroll` converts a scroll to `handleKeyCode` whenever the
|
|
||||||
alternate buffer is active, which dereferences `mTermSession`. Claude Code IS an alternate-screen
|
|
||||||
TUI, so this was a crash during ordinary use on the app's main screen. `TerminalScrollGesture` now
|
|
||||||
claims the vertical drag first and reproduces all three `doScroll` branches, closing the fling
|
|
||||||
runnable and `onGenericMotionEvent` with it.
|
|
||||||
- **`autofill()` dereferences `mTermSession` unguarded** while the view advertises itself
|
|
||||||
autofillable, so any password manager crashed the app. The subtree is excluded from the autofill
|
|
||||||
structure.
|
|
||||||
- The review also demanded stock text selection be RESTORED after the focus rework made it
|
|
||||||
unreachable. It cannot be: the ActionMode's own Copy and Paste handlers dereference the absent
|
|
||||||
session (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136), so a
|
|
||||||
reachable selection UI has an NPE on its Copy button. **Long press stays consumed and copy-out is
|
|
||||||
a recorded FEATURE GAP** — a first-party selection layer over `TerminalBuffer` + `ClipboardManager`
|
|
||||||
is the real fix. This is the one place the reviewer was wrong and the builder right.
|
|
||||||
|
|
||||||
**Silently-lost server data, now decoded:** the W2 `queue` frame had no `ServerMessageType` member so
|
|
||||||
every one was dropped as undecodable (the "N queued" badge could never appear), and W1
|
|
||||||
`status.preview` was not modelled at all, which made remote approvals **BLIND** — the user was asked to
|
|
||||||
approve a `Bash` call without seeing the command. Both now decode, with the rule that a broken preview
|
|
||||||
never costs the frame (`pending` is what makes the gate appear).
|
|
||||||
|
|
||||||
**Dead code that shipped as features:** `ThumbnailPipeline` (session-list previews), `QuickReply`
|
|
||||||
(chips + palette) and `PointerContextMenu` were fully implemented and unit-tested with ZERO production
|
|
||||||
call sites. All three are now wired. `ThumbnailPipeline`'s formal cross-review — explicitly skipped at
|
|
||||||
the time, as the AW3 entry below admits — has finally been run.
|
|
||||||
|
|
||||||
**Credential handling:** the `WEBTERM_TOKEN` cookie is now carried on REST and the WS upgrade, sealed
|
|
||||||
with Tink AEAD under an AndroidKeyStore key, **fail-closed** (a seal failure persists nothing rather
|
|
||||||
than falling back to plaintext), and `allowBackup=false` keeps a 30-day shell credential out of Google
|
|
||||||
Drive and D2D transfer.
|
|
||||||
|
|
||||||
**RESOLVED LATER THE SAME DAY** (this list was accurate when written; it is not any more):
|
|
||||||
- **A34 / A35 now exist and RUN.** `:app/src/androidTest/**` holds 67 instrumented tests — the four
|
|
||||||
crash regressions, the A34 E2E set (attach→output, ring-buffer replay, spawn failure via a second
|
|
||||||
deliberately-broken server, a gate resolved through `POST /hook/decision`, and the F9 bad-Origin
|
|
||||||
rejection), and the Compose UI tests plan §7 named — plus `macrobenchmark/` for A35. All 67 execute
|
|
||||||
green on `emulator-5554` against this repo's own server. Reaching the host needs
|
|
||||||
`ALLOWED_ORIGINS=http://10.0.2.2:<port>` (the server derives allowed origins from NIC IPs, and
|
|
||||||
10.0.2.2 is the emulator's alias for the host, not an interface address).
|
|
||||||
- **Device QA is no longer ~0%.** The blocker fixes are device-verified, not just JVM-verified.
|
|
||||||
- Release signing, versioning and minification are done: `assembleRelease` runs R8 and now REFUSES to
|
|
||||||
emit an unsigned artifact instead of quietly producing one.
|
|
||||||
- **Copy-out is done** — a first-party selection layer over `TerminalBuffer.getSelectedText` +
|
|
||||||
`ClipboardManager`, since the stock ActionMode's Copy handler dereferences the absent session.
|
|
||||||
- The six audit leftovers are closed too: silent certificate rotation (+ `/device/:id/recover`), the W2
|
|
||||||
inject-queue routes, unread-watermark persistence, host-removal UI, and `/config/ui` finally honoured
|
|
||||||
(fail-closed).
|
|
||||||
|
|
||||||
**Still genuinely open:**
|
|
||||||
- **S2** — the FCM real-handset delivery spike. Needs ≥2 physical devices under Doze/OEM battery
|
|
||||||
managers; an emulator cannot answer it.
|
|
||||||
- One instrumented test (`wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch`) was seen to kill the
|
|
||||||
test process when the suite was driven WITHOUT the host arguments on a heavily-recycled emulator. It
|
|
||||||
passes reproducibly under the documented invocation on a clean install. Re-check on real hardware
|
|
||||||
before trusting it either way.
|
|
||||||
- Everything else device-observable in `DEVICE_QA_CHECKLIST.md` that no automated test covers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1–A36 + S1) landed
|
## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1–A36 + S1) landed
|
||||||
|
|
||||||
Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure
|
Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure
|
||||||
|
|||||||
@@ -9,29 +9,22 @@ This directory is a **Gradle multi-module** project. The module set mirrors the
|
|||||||
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
||||||
upward* (ARCHITECTURE §1).
|
upward* (ARCHITECTURE §1).
|
||||||
|
|
||||||
> **State, honestly:** the app builds, minifies, signs (given a keystore) and cold-starts
|
## ⚠️ No-SDK constraint (why only 5 modules build here)
|
||||||
> to the pairing screen on an emulator. It has **never talked to a real WebTerm host from
|
|
||||||
> a device**, and almost nothing in
|
|
||||||
> [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md) is ticked. Version is
|
|
||||||
> `0.1.0-alpha01` for exactly that reason. Read that checklist before calling anything
|
|
||||||
> here done.
|
|
||||||
|
|
||||||
## Build environment (SDK installed — all modules build)
|
The current build environment has **no Android SDK**. Everything that can be pure
|
||||||
|
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the
|
||||||
|
Android framework (`com.android.*` plugins) is **scaffolded but disabled**.
|
||||||
|
|
||||||
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
|
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):**
|
||||||
alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build
|
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`.
|
||||||
against SDK 35/36.
|
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts)
|
||||||
|
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
|
||||||
- **Pure Kotlin/JVM (`./gradlew test`):** `:wire-protocol`, `:session-core`, `:api-client`,
|
|
||||||
`:client-tls`, `:test-support`, `:transport-okhttp`.
|
|
||||||
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
|
|
||||||
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
||||||
- **Instrumented-only:** `:macrobenchmark` (`com.android.test`; assembles here, runs only
|
|
||||||
on a device/emulator).
|
|
||||||
|
|
||||||
Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`;
|
To bring the Android modules online later: install an SDK, add
|
||||||
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
|
`local.properties` → `sdk.dir`, add the Android Gradle Plugin + `google()` to
|
||||||
`./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest koverVerify`.
|
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in
|
||||||
|
each stub.
|
||||||
|
|
||||||
## Module map (mirror of the iOS SPM packages — plan §3)
|
## Module map (mirror of the iOS SPM packages — plan §3)
|
||||||
|
|
||||||
@@ -42,41 +35,28 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
|
|||||||
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
|
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
|
||||||
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
|
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
|
||||||
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
|
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
|
||||||
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ✅ built |
|
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated |
|
||||||
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated |
|
||||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated |
|
||||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
|
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated |
|
||||||
| `URLSession*Transport` | `:transport-okhttp` | pure Kotlin/JVM (OkHttp) | ✅ built |
|
|
||||||
| — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources |
|
|
||||||
|
|
||||||
> `:transport-okhttp` (A7) holds the OkHttp `TermTransport`/`HttpTransport`
|
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
|
||||||
> implementations that the two iOS `URLSession*Transport`s consolidate into
|
> impls, JVM) is owned by task **A7** and will be added then. The iOS
|
||||||
> (plan §3 framing note). OkHttp is a plain JVM library, so the module builds and
|
> `URLSession*Transport`s consolidate into it (plan §3 framing note).
|
||||||
> unit-tests (MockWebServer) with **no** Android SDK — including the
|
|
||||||
> access-token cookie on the WS upgrade (`OkHttpAccessTokenTest`) and the
|
|
||||||
> public-host cleartext refusal (`CleartextGuardTest`).
|
|
||||||
|
|
||||||
`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a
|
|
||||||
`com.android.test` module — a **separate APK** that drives `:app` out of process via
|
|
||||||
UiAutomator, which is the only way startup/frame timings are real. It therefore cannot
|
|
||||||
violate "dependencies only flow down": nothing depends on it, and it reaches `:app`
|
|
||||||
through `targetProjectPath`, not a `project()` dependency. It instruments `:app`'s
|
|
||||||
`benchmark` variant (see "Build types" below). The benchmark sources themselves are not
|
|
||||||
written yet.
|
|
||||||
|
|
||||||
### Dependency graph (arrows = "depends on")
|
### Dependency graph (arrows = "depends on")
|
||||||
|
|
||||||
```
|
```
|
||||||
:app
|
:app (SDK-gated)
|
||||||
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
||||||
▼ ▼ ▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼
|
||||||
:terminal-view :session-core :api-client :host-registry :client-tls-android
|
:terminal-view :session-core :api-client :host-registry :client-tls-android
|
||||||
│ │ │ │
|
(SDK-gated) │ │ (SDK-gated) │
|
||||||
│ │ │ ▼
|
│ │ │ ▼
|
||||||
│ │ │ :client-tls (pure)
|
│ │ │ :client-tls (pure)
|
||||||
└──────┬───────┴──────────┴──────────────┬────────────────┘
|
└──────┬───────┴──────────┴──────────────┬────────────────┘
|
||||||
▼ ▼
|
▼ ▼
|
||||||
:wire-protocol ◀──────────── :transport-okhttp
|
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
|
||||||
▲
|
▲
|
||||||
└──────── :test-support → test source sets only
|
└──────── :test-support → test source sets only
|
||||||
```
|
```
|
||||||
@@ -84,45 +64,8 @@ written yet.
|
|||||||
`:wire-protocol` is the **frozen shared contract** (Android analogue of
|
`:wire-protocol` is the **frozen shared contract** (Android analogue of
|
||||||
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
||||||
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
||||||
`AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
|
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
|
||||||
derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport`
|
interfaces. New wire types are added **only** here (a coordination point).
|
||||||
boundary interfaces. New wire types are added **only** here (a coordination point).
|
|
||||||
|
|
||||||
## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates
|
|
||||||
|
|
||||||
A server started with `WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]>` gates **every**
|
|
||||||
HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android
|
|
||||||
client therefore:
|
|
||||||
|
|
||||||
- **hand-writes `Cookie: webterm_auth=<t>` itself** for a token it already holds — one
|
|
||||||
derivation point (`AuthCookie`), stamped in exactly three places: `:api-client`'s
|
|
||||||
`ApiRoute.toHttpRequest` (all 12+ routes, RO GETs included), the pairing probe's two
|
|
||||||
hand-built legs, and `:transport-okhttp`'s WS upgrade. The hand-built
|
|
||||||
`GET /projects/diff` fetcher in `:app` stamps it too.
|
|
||||||
- **also installs one `AuthCookieJar`** on the single shared `OkHttpClient`, which captures
|
|
||||||
the `Set-Cookie` a live pairing (`POST /auth`) hands back and replays it on REST *and* on
|
|
||||||
the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls). The two are
|
|
||||||
orthogonal: the hand-written header covers a token restored from the Keystore store, the
|
|
||||||
jar covers a cookie the server just issued. A host with neither sends no cookie at all.
|
|
||||||
- keeps the header **orthogonal to `Origin`**: the token never replaces the CSWSH
|
|
||||||
Origin check (the server tests Origin first, then the cookie), and read-only routes
|
|
||||||
still carry no Origin.
|
|
||||||
- validates a typed token ONCE at pairing time with `POST /auth`
|
|
||||||
(`{"token":"…"}`, `Accept: application/json` — an `Accept` containing `text/html`
|
|
||||||
makes the server answer 302 instead of 204/401). Four outcomes: 204+`Set-Cookie` =
|
|
||||||
correct → store it; 204 **without** `Set-Cookie` = that host has no auth → store
|
|
||||||
nothing; 401 = wrong token; 429 = rate-limited (10/min/IP).
|
|
||||||
- stores it per host (keyed by the canonical origin) in `TinkAccessTokenStore`:
|
|
||||||
Tink AEAD under an **AndroidKeystore-wrapped, non-exportable** master key, in an
|
|
||||||
app-private `SharedPreferences` file, with `android:allowBackup="false"` +
|
|
||||||
`data_extraction_rules.xml` excluding cloud backup and device transfer. The token is
|
|
||||||
**never logged, never in a URL, never in app UI state**.
|
|
||||||
- treats a **401 on the WS upgrade as terminal** (`FailureReason.UNAUTHORIZED`): no
|
|
||||||
reconnect back-off loop, and the banner tells the user to re-enter the token. A 401 on
|
|
||||||
any REST route is the typed `ApiClientError.Unauthorized`.
|
|
||||||
|
|
||||||
A host with no `WEBTERM_TOKEN` is byte-identical to before the feature: no token stored ⇒
|
|
||||||
no `Cookie` header at all (LAN zero-config preserved).
|
|
||||||
|
|
||||||
## Toolchain
|
## Toolchain
|
||||||
|
|
||||||
@@ -142,141 +85,14 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
|
|||||||
```bash
|
```bash
|
||||||
# Use the committed wrapper for everything.
|
# Use the committed wrapper for everything.
|
||||||
./gradlew help # sanity: the build configures
|
./gradlew help # sanity: the build configures
|
||||||
./gradlew projects # lists every module
|
./gradlew projects # lists the 5 pure modules
|
||||||
./gradlew test # JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
|
./gradlew build # compile all pure modules
|
||||||
./gradlew :app:assembleDebug # the installable debug APK
|
./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
|
||||||
./gradlew koverVerify # the ≥80% gate on the pure modules
|
|
||||||
|
|
||||||
# Release path (needs a keystore — see "Release signing")
|
|
||||||
./gradlew :app:assembleRelease # R8 + resource shrinking + signing
|
|
||||||
./gradlew :app:lintVitalRelease # the release-blocking lint subset; must be clean
|
|
||||||
|
|
||||||
# Minified build WITHOUT a keystore — the practical way to test R8 keep rules
|
|
||||||
./gradlew :app:assembleBenchmark # same shrinking as release, debug-signed → installable
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
||||||
> `:session-core`, `:api-client`, `:client-tls` pure half — the four modules that
|
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
|
||||||
> apply the Kover plugin, and therefore the only ones `koverVerify` gates). TDD,
|
> small focused files — same discipline as the rest of the repo.
|
||||||
> immutable data, small focused files — same discipline as the rest of the repo.
|
|
||||||
>
|
|
||||||
> **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug
|
|
||||||
> (`FirDeclaration was not found for class KtProperty, fir is null`, on
|
|
||||||
> `ThumbnailPipeline.kt`). `lintVitalRelease` passes normally and from a cold lint state.
|
|
||||||
|
|
||||||
## Build types
|
|
||||||
|
|
||||||
| Type | Minified | Shrunk res | Debuggable | Signed with | Purpose |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `debug` | no | no | yes | debug key | development; stable applicationId for deep-link tests (A32) |
|
|
||||||
| `release` | **yes** | **yes** | no | **release key (required)** | the shipping artifact |
|
|
||||||
| `benchmark` | **yes** | **yes** | no | debug key | `initWith(release)` + `isProfileable`; what `:macrobenchmark` measures, and the only way to exercise R8 without a keystore |
|
|
||||||
|
|
||||||
`benchmark` exists because a benchmark must measure the code that actually ships. It sets
|
|
||||||
`isProfileable = true`, which makes AGP inject `<profileable android:shell="true"/>` into
|
|
||||||
the merged manifest — done there rather than in `AndroidManifest.xml` so the shipping
|
|
||||||
manifest carries no benchmark-only tag.
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
Current: `versionCode = 1`, `versionName = "0.1.0-alpha01"`.
|
|
||||||
|
|
||||||
- **`versionCode`** is a plain monotonic counter — **+1 for every artifact handed to anyone**
|
|
||||||
(a Play track, an APK sent to a tester, an archived benchmark build). It is deliberately
|
|
||||||
NOT derived from `versionName`; keeping them independent is what lets a hotfix ship
|
|
||||||
without renumbering. It is still `1` because no artifact has ever left the build machine;
|
|
||||||
the first distributed build takes `2`.
|
|
||||||
- **`versionName`** is `<server-line>-alphaNN`. The client tracks the server's `0.1.x` line
|
|
||||||
(root `package.json` is `0.1.0`). The `-alpha01` suffix is a factual claim about device
|
|
||||||
verification, not marketing:
|
|
||||||
- `-alphaNN` — builds, minifies, JVM-tested; **device QA essentially unstarted**. ← today
|
|
||||||
- `-betaNN` — the A34/A35 blocks of `DEVICE_QA_CHECKLIST.md` pass on real hardware.
|
|
||||||
- `0.1.0` — the whole checklist is ticked.
|
|
||||||
|
|
||||||
Bump the suffix on any user-visible change while still in alpha; move to `0.1.1-alphaNN`
|
|
||||||
only when the server line moves.
|
|
||||||
|
|
||||||
## Release signing
|
|
||||||
|
|
||||||
There is **no keystore in this repository and there must never be one.** Credentials are
|
|
||||||
read at configuration time from the first of these that has them:
|
|
||||||
|
|
||||||
1. `android/keystore.properties` — the conventional path. Copy
|
|
||||||
[`keystore.properties.example`](keystore.properties.example).
|
|
||||||
⚠️ **Confirm `keystore.properties` is listed in `android/.gitignore` before creating it.**
|
|
||||||
The existing rules cover `*.jks` / `*.keystore` / `*.p12` but not this filename.
|
|
||||||
2. `android/local.properties` — already gitignored, so it needs no new rule. Same four keys.
|
|
||||||
3. `WEBTERM_RELEASE_STORE_FILE` / `_STORE_PASSWORD` / `_KEY_ALIAS` / `_KEY_PASSWORD` — for CI.
|
|
||||||
|
|
||||||
Keys: `webterm.release.storeFile` (absolute, or relative to `android/`),
|
|
||||||
`.storePassword`, `.keyAlias`, `.keyPassword`.
|
|
||||||
|
|
||||||
**Degradation contract:** with no credentials, `debug`, `benchmark`, `assembleDebugAndroidTest`
|
|
||||||
and every test still work, and `:app:assembleRelease` **fails at `packageRelease`** with
|
|
||||||
instructions. It fails at *packaging*, deliberately after R8, so an unconfigured machine
|
|
||||||
still gets a full, verifiable R8 run. It never silently emits `app-release-unsigned.apk`
|
|
||||||
(which is exactly what it used to do).
|
|
||||||
|
|
||||||
**Archive `app/build/outputs/mapping/release/mapping.txt` with every distributed artifact** —
|
|
||||||
release builds keep line numbers but obfuscate names, so without the mapping file a crash
|
|
||||||
report cannot be retraced.
|
|
||||||
|
|
||||||
### R8 / keep rules
|
|
||||||
|
|
||||||
`app/proguard-rules.pro` is deliberately short and every rule is justified in place; most
|
|
||||||
of the stack (kotlinx.serialization, Hilt, Tink, Firebase, OkHttp, Compose, CameraX)
|
|
||||||
ships its own consumer rules and must not be re-declared. Two rules are load-bearing and
|
|
||||||
were both derived from **observed** failures, not guesswork:
|
|
||||||
|
|
||||||
- `com.termux.**` is kept whole, because `:terminal-view` binds to non-API internals of the
|
|
||||||
pinned v0.118.0 (the public `TerminalView.mEmulator` field, `TerminalRenderer.getFontWidth()`)
|
|
||||||
and the JitPack artifacts ship no consumer rules.
|
|
||||||
- `implements com.google.firebase.components.ComponentRegistrar { <init>(); }`, because
|
|
||||||
without it R8 strips the reflectively-invoked constructors of ML Kit's registrars and
|
|
||||||
**QR scanning silently stops working in release builds only**.
|
|
||||||
|
|
||||||
Before adding a rule, read the merged configuration R8 actually used:
|
|
||||||
`app/build/outputs/mapping/<variant>/configuration.txt`. To validate rules, install the
|
|
||||||
`benchmark` APK and exercise the feature — a wrong keep rule is invisible in debug.
|
|
||||||
|
|
||||||
## Instrumented tests
|
|
||||||
|
|
||||||
`:app` has an androidTest classpath (androidx.test + Espresso + Compose UI test +
|
|
||||||
`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to
|
|
||||||
`wang.yaojia.webterm.HiltTestRunner`.
|
|
||||||
|
|
||||||
`app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt` supplies it:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
class HiltTestRunner : AndroidJUnitRunner() {
|
|
||||||
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
|
|
||||||
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A custom runner is **required**, not stylistic: `newApplication` is the only hook that can
|
|
||||||
replace the app-under-test's `Application` with the generated `HiltTestApplication`. Setting
|
|
||||||
`android:name` in the androidTest manifest cannot do it — that manifest is merged into the
|
|
||||||
*test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the
|
|
||||||
class (the runner name is just a manifest value); an actual instrumentation *run* needs it.
|
|
||||||
|
|
||||||
The suite has been **run green on real hardware** (67/0 instrumented tests against this
|
|
||||||
repo's own server), so `src/androidTest/**` is verified, not aspirational — but it still
|
|
||||||
needs a connected device/emulator and is therefore **not** part of the `./gradlew test`
|
|
||||||
gate; run it with `connectedAndroidTest`. An emulator additionally needs
|
|
||||||
`ALLOWED_ORIGINS=http://10.0.2.2:<port>` on the server, because the server derives its
|
|
||||||
allowed origins from NIC IPs and `10.0.2.2` (the emulator's alias for the host) is not one.
|
|
||||||
|
|
||||||
### What is still NOT verified here
|
|
||||||
|
|
||||||
- **DEFERRED — end-to-end access token**: the `WEBTERM_TOKEN` path is covered by
|
|
||||||
unit tests (`OkHttpAccessTokenTest`, `AuthProbeTest`, `AccessTokenCookieTest`, the
|
|
||||||
`:api-client` route tests, the `AuthCookie` derivation tests) and by
|
|
||||||
`TinkAccessTokenStoreTest` on device, but **no automated leg on the Android side starts
|
|
||||||
a real server with `WEBTERM_TOKEN` set and completes a cookie-gated WS upgrade** — that
|
|
||||||
end-to-end coverage exists only on the iOS side (`ios/IntegrationTests`).
|
|
||||||
- **DEFERRED — the rest of `DEVICE_QA_CHECKLIST.md`**: FCM push delivery under Doze on two
|
|
||||||
physical handsets (S2) and the lock-screen Allow/Deny walkthrough have no automated cover.
|
|
||||||
|
|
||||||
## Android SDK setup (proven working)
|
## Android SDK setup (proven working)
|
||||||
|
|
||||||
@@ -313,32 +129,5 @@ AGP library module compiled against SDK 35 and produced an AAR):
|
|||||||
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
|
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
|
||||||
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
|
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
|
||||||
|
|
||||||
- **A `com.android.test` module cannot use a versioned plugin alias.** The root build
|
To add more SDK pieces later (e.g. an emulator image for instrumented tests):
|
||||||
script already puts AGP on the classpath via the `android.library`/`android.application`
|
|
||||||
aliases, so a third versioned request for the same artifact fails with *"plugin is already
|
|
||||||
on the classpath with an unknown version"*. `:macrobenchmark` therefore applies
|
|
||||||
`id("com.android.test")` bare — same as `:terminal-view` with `com.android.library`. The
|
|
||||||
version is still pinned once, as `agp` in the catalog.
|
|
||||||
|
|
||||||
## Emulator (installed)
|
|
||||||
|
|
||||||
An AVD named **`webterm`** exists (android-35, arm64, software GPU) and is what produced the
|
|
||||||
on-device evidence recorded in [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ANDROID_HOME=/usr/local/share/android-commandlinetools
|
|
||||||
$ANDROID_HOME/emulator/emulator -avd webterm -gpu swiftshader_indirect &
|
|
||||||
$ANDROID_HOME/platform-tools/adb devices -l
|
|
||||||
|
|
||||||
# validate the MINIFIED build (this is what catches bad keep rules)
|
|
||||||
./gradlew :app:assembleBenchmark
|
|
||||||
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk
|
|
||||||
adb logcat -c && adb shell am start -W -n wang.yaojia.webterm/.MainActivity
|
|
||||||
adb logcat -d --pid=$(adb shell pidof wang.yaojia.webterm) | grep -E "FATAL|NoSuchMethod|NoClassDefFound"
|
|
||||||
```
|
|
||||||
|
|
||||||
Note it is a **software-GPU** emulator: it is fine for crash/keep-rule/route validation and
|
|
||||||
useless for performance numbers. Real macrobenchmark figures need physical hardware.
|
|
||||||
|
|
||||||
To add more SDK pieces later:
|
|
||||||
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.enroll
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B4 · Manual, canonical-DER encoder for a P-256 PKCS#10 `CertificationRequest` — a byte-for-byte
|
|
||||||
* port of the iOS `ClientTLS.CertificateSigningRequest`.
|
|
||||||
*
|
|
||||||
* Built by hand (no JCA CSR helper) so the exact bytes are under our control and the request is
|
|
||||||
* signed by the [CsrSigner] (an AndroidKeyStore hardware key in production, a software P-256 key in
|
|
||||||
* tests) via `SHA256withECDSA`. The output must satisfy the control-plane `verifyCsrPoPEc`: an EC
|
|
||||||
* P-256 `SubjectPublicKeyInfo` (`id-ecPublicKey` + `prime256v1`), an `ecdsa-with-SHA256`
|
|
||||||
* self-signature, and a valid PoP. Encoding is strictly canonical DER (minimal lengths) so the
|
|
||||||
* server's re-serialization of `CertificationRequestInfo` matches the bytes we signed.
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* CertificationRequest ::= SEQUENCE {
|
|
||||||
* certificationRequestInfo CertificationRequestInfo,
|
|
||||||
* signatureAlgorithm AlgorithmIdentifier, -- ecdsa-with-SHA256
|
|
||||||
* signature BIT STRING } -- X9.62 DER ECDSA-Sig
|
|
||||||
*
|
|
||||||
* CertificationRequestInfo ::= SEQUENCE {
|
|
||||||
* version INTEGER { v1(0) },
|
|
||||||
* subject Name,
|
|
||||||
* subjectPKInfo SubjectPublicKeyInfo,
|
|
||||||
* attributes [0] IMPLICIT SET OF Attribute } -- empty
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public object CertificateSigningRequest {
|
|
||||||
/** P-256 uncompressed public point is `0x04 || X(32) || Y(32)` = 65 bytes. */
|
|
||||||
private const val UNCOMPRESSED_P256_POINT_LENGTH = 65
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build and self-sign a P-256 PKCS#10 CSR DER for [signer]'s key.
|
|
||||||
*
|
|
||||||
* @param subjectCommonName the CSR subject CN. The device leaf's identity is driven server-side
|
|
||||||
* by the ownership-verified subdomain SAN, so this is descriptive only; it must be non-empty.
|
|
||||||
* @param signer the P-256 hardware key that provides the public key and signs the
|
|
||||||
* `CertificationRequestInfo`.
|
|
||||||
* @throws CsrException.InvalidSubject on an empty CN; [CsrException.InvalidPublicKey] if the
|
|
||||||
* signer's public key is not a 65-byte X9.63 P-256 point.
|
|
||||||
*/
|
|
||||||
public fun der(subjectCommonName: String, signer: CsrSigner): ByteArray {
|
|
||||||
if (subjectCommonName.isEmpty()) throw CsrException.InvalidSubject
|
|
||||||
|
|
||||||
val publicPoint = signer.publicKeyX963()
|
|
||||||
if (publicPoint.size != UNCOMPRESSED_P256_POINT_LENGTH || publicPoint[0].toInt() != 0x04) {
|
|
||||||
throw CsrException.InvalidPublicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
val requestInfo = certificationRequestInfo(subjectCommonName, publicPoint)
|
|
||||||
val signature = signer.sign(requestInfo)
|
|
||||||
|
|
||||||
return DerWriter.sequence(
|
|
||||||
listOf(
|
|
||||||
requestInfo,
|
|
||||||
ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER,
|
|
||||||
DerWriter.bitString(signature),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CertificationRequestInfo ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun certificationRequestInfo(subjectCommonName: String, publicPoint: ByteArray): ByteArray =
|
|
||||||
DerWriter.sequence(
|
|
||||||
listOf(
|
|
||||||
DerWriter.INTEGER_0, // version v1(0)
|
|
||||||
name(subjectCommonName),
|
|
||||||
subjectPublicKeyInfo(publicPoint),
|
|
||||||
DerWriter.EMPTY_ATTRIBUTES_CONTEXT0, // [0] IMPLICIT SET OF Attribute (empty)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `Name ::= SEQUENCE OF RelativeDistinguishedName` with a single CN RDN. */
|
|
||||||
private fun name(commonName: String): ByteArray {
|
|
||||||
val attribute = DerWriter.sequence(
|
|
||||||
listOf(DerWriter.oid(Oid.COMMON_NAME), DerWriter.utf8String(commonName)),
|
|
||||||
)
|
|
||||||
val rdn = DerWriter.set(listOf(attribute))
|
|
||||||
return DerWriter.sequence(listOf(rdn))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `SubjectPublicKeyInfo` for an EC P-256 key: `id-ecPublicKey` + `prime256v1` named curve, then
|
|
||||||
* the uncompressed point as a BIT STRING.
|
|
||||||
*/
|
|
||||||
private fun subjectPublicKeyInfo(publicPoint: ByteArray): ByteArray {
|
|
||||||
val algorithm = DerWriter.sequence(
|
|
||||||
listOf(DerWriter.oid(Oid.EC_PUBLIC_KEY), DerWriter.oid(Oid.PRIME256V1)),
|
|
||||||
)
|
|
||||||
return DerWriter.sequence(listOf(algorithm, DerWriter.bitString(publicPoint)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `AlgorithmIdentifier` for `ecdsa-with-SHA256` — no parameters (absent, per RFC 5758), which is
|
|
||||||
* exactly what the server's verifier expects.
|
|
||||||
*/
|
|
||||||
private val ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER: ByteArray =
|
|
||||||
DerWriter.sequence(listOf(DerWriter.oid(Oid.ECDSA_WITH_SHA256)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Object identifiers (DER content bytes; tag/length added by [DerWriter.oid]). */
|
|
||||||
private object Oid {
|
|
||||||
/** 1.2.840.10045.2.1 — id-ecPublicKey. */
|
|
||||||
val EC_PUBLIC_KEY = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01)
|
|
||||||
|
|
||||||
/** 1.2.840.10045.3.1.7 — prime256v1 / secp256r1. */
|
|
||||||
val PRIME256V1 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07)
|
|
||||||
|
|
||||||
/** 1.2.840.10045.4.3.2 — ecdsa-with-SHA256. */
|
|
||||||
val ECDSA_WITH_SHA256 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02)
|
|
||||||
|
|
||||||
/** 2.5.4.3 — id-at-commonName. */
|
|
||||||
val COMMON_NAME = byteArrayOf(0x55, 0x04, 0x03)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tiny canonical-DER encoder. Every helper returns a fully-formed TLV so callers just concatenate
|
|
||||||
* children — canonical minimal-length encoding throughout. Internal so its byte layout is
|
|
||||||
* unit-testable in isolation.
|
|
||||||
*/
|
|
||||||
internal object DerWriter {
|
|
||||||
private const val TAG_INTEGER: Byte = 0x02
|
|
||||||
private const val TAG_BIT_STRING: Byte = 0x03
|
|
||||||
private const val TAG_OID: Byte = 0x06
|
|
||||||
private const val TAG_UTF8_STRING: Byte = 0x0C
|
|
||||||
private const val TAG_SEQUENCE: Byte = 0x30
|
|
||||||
private const val TAG_SET: Byte = 0x31
|
|
||||||
private const val TAG_CONTEXT0_CONSTRUCTED: Byte = 0xA0.toByte()
|
|
||||||
|
|
||||||
/** `INTEGER 0` — the fixed PKCS#10 version v1(0). */
|
|
||||||
val INTEGER_0: ByteArray = byteArrayOf(TAG_INTEGER, 0x01, 0x00)
|
|
||||||
|
|
||||||
/** `[0] IMPLICIT SET OF Attribute`, empty — `A0 00`. */
|
|
||||||
val EMPTY_ATTRIBUTES_CONTEXT0: ByteArray = byteArrayOf(TAG_CONTEXT0_CONSTRUCTED, 0x00)
|
|
||||||
|
|
||||||
fun sequence(children: List<ByteArray>): ByteArray = tlv(TAG_SEQUENCE, concat(children))
|
|
||||||
|
|
||||||
fun set(children: List<ByteArray>): ByteArray = tlv(TAG_SET, concat(children))
|
|
||||||
|
|
||||||
fun oid(content: ByteArray): ByteArray = tlv(TAG_OID, content)
|
|
||||||
|
|
||||||
fun utf8String(value: String): ByteArray = tlv(TAG_UTF8_STRING, value.encodeToByteArray())
|
|
||||||
|
|
||||||
/** BIT STRING with zero unused bits (all our bit strings are byte-aligned). */
|
|
||||||
fun bitString(content: ByteArray): ByteArray = tlv(TAG_BIT_STRING, byteArrayOf(0x00) + content)
|
|
||||||
|
|
||||||
/** Tag-Length-Value with canonical DER length encoding. */
|
|
||||||
private fun tlv(tag: Byte, value: ByteArray): ByteArray = byteArrayOf(tag) + length(value.size) + value
|
|
||||||
|
|
||||||
/** DER length: short form (<128) or long form (`0x80 | byteCount`, big-endian). */
|
|
||||||
private fun length(count: Int): ByteArray {
|
|
||||||
if (count < 0x80) return byteArrayOf(count.toByte())
|
|
||||||
var value = count
|
|
||||||
val bytes = ArrayDeque<Byte>()
|
|
||||||
while (value > 0) {
|
|
||||||
bytes.addFirst((value and 0xFF).toByte())
|
|
||||||
value = value ushr 8
|
|
||||||
}
|
|
||||||
return byteArrayOf((0x80 or bytes.size).toByte()) + bytes.toByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun concat(chunks: List<ByteArray>): ByteArray {
|
|
||||||
val total = chunks.sumOf { it.size }
|
|
||||||
val out = ByteArray(total)
|
|
||||||
var offset = 0
|
|
||||||
for (chunk in chunks) {
|
|
||||||
System.arraycopy(chunk, 0, out, offset, chunk.size)
|
|
||||||
offset += chunk.size
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.enroll
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B4 · The signing key abstraction the PKCS#10 CSR encoder ([CertificateSigningRequest]) drives —
|
|
||||||
* the Android analogue of iOS `P256HardwareKey`.
|
|
||||||
*
|
|
||||||
* In production this is backed by a NON-EXPORTABLE P-256 key living inside AndroidKeyStore
|
|
||||||
* (StrongBox → TEE), so `sign` runs inside secure hardware and the private key never leaves it
|
|
||||||
* (`:client-tls-android` `HardwareBackedKey`). In JVM unit tests it is backed by a software P-256
|
|
||||||
* key via the SAME `Signature("SHA256withECDSA")` path, so the CSR-encoding bytes are exercised
|
|
||||||
* identically without an emulator.
|
|
||||||
*/
|
|
||||||
public interface CsrSigner {
|
|
||||||
/**
|
|
||||||
* The public key in ANSI X9.63 uncompressed form: `0x04 || X(32) || Y(32)` (65 bytes for
|
|
||||||
* P-256). This is exactly what wraps into the CSR's `SubjectPublicKeyInfo` BIT STRING.
|
|
||||||
*/
|
|
||||||
public fun publicKeyX963(): ByteArray
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ECDSA-sign `message` over SHA-256, returning the X9.62 DER signature
|
|
||||||
* (`SEQUENCE { r INTEGER, s INTEGER }`) — exactly the shape a PKCS#10 `signature` BIT STRING
|
|
||||||
* and the server's `verifyCsrPoPEc` expect. The digest is computed by the algorithm
|
|
||||||
* (`SHA256withECDSA`), so callers pass the raw message (the DER of `CertificationRequestInfo`),
|
|
||||||
* NOT a pre-hash.
|
|
||||||
*/
|
|
||||||
public fun sign(message: ByteArray): ByteArray
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Structural failures building a CSR — the client refuses to emit a malformed request. */
|
|
||||||
public sealed class CsrException(message: String) : Exception(message) {
|
|
||||||
/** The signer's public key was not the expected 65-byte X9.63 uncompressed P-256 point. */
|
|
||||||
public data object InvalidPublicKey : CsrException("CSR public key is not a 65-byte X9.63 P-256 point")
|
|
||||||
|
|
||||||
/** The subject CN was empty (not encodable / rejected by the server). */
|
|
||||||
public data object InvalidSubject : CsrException("CSR subject common name must not be empty")
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.enroll
|
|
||||||
|
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
|
||||||
import wang.yaojia.webterm.wire.HttpResponse
|
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B4 · Talks to the control-plane device-enrollment API over the shared [HttpTransport] seam (the
|
|
||||||
* same seam `:transport-okhttp` implements and `:test-support` fakes), so the enroll flow rides the
|
|
||||||
* app's normal HTTP stack. Android analogue of iOS `DeviceEnrollmentClient`, extended with the login
|
|
||||||
* step (B4 pinned contract):
|
|
||||||
*
|
|
||||||
* `POST /auth/login` `{ password }` → 201 `{ enrollToken, accountId, expiresIn }`
|
|
||||||
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
|
|
||||||
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
|
|
||||||
* notBefore, notAfter, renewAfter }`
|
|
||||||
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 `{ cert, caChain,
|
|
||||||
* notAfter }`. The server schema is `.strict()`; NO
|
|
||||||
* keyAlg/subdomain/deviceName.
|
|
||||||
* `POST /device/:id/recover` [NO client cert — plain HTTPS] `{ cert, csr }` → 201 `{ cert,
|
|
||||||
* caChain, notAfter }`. The EXPIRED leaf's escape hatch.
|
|
||||||
*
|
|
||||||
* Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is
|
|
||||||
* sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
|
|
||||||
* are standard-base64 (`bytesToBase64` = Node `Buffer.toString('base64')`).
|
|
||||||
*
|
|
||||||
* Immutable: constructed once with a [baseUrl] + [http]; the short-lived enroll bearer is passed
|
|
||||||
* per-call and never held/logged (leaked-bearer blast radius).
|
|
||||||
*/
|
|
||||||
public class DeviceEnrollmentClient(
|
|
||||||
baseUrl: String,
|
|
||||||
private val http: HttpTransport,
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Base control-plane URL with any trailing slash removed, so `baseUrl + path` is well-formed.
|
|
||||||
* Readable so a rotation driver can persist WHICH control plane a device enrolled with and renew
|
|
||||||
* against that same one (a URL is not a credential).
|
|
||||||
*/
|
|
||||||
public val baseUrl: String = baseUrl.trim().trimEnd('/')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is
|
|
||||||
* rejected client-side (`InvalidRequest`) before any network I/O — never send a blank credential.
|
|
||||||
*/
|
|
||||||
public suspend fun login(password: String): LoginResult {
|
|
||||||
if (password.isEmpty()) throw DeviceEnrollmentError.InvalidRequest
|
|
||||||
val body = EnrollJson.encodeToString(LoginRequestBody.serializer(), LoginRequestBody(password))
|
|
||||||
val response = http.send(jsonRequest(HttpMethod.POST, PATH_LOGIN, body.encodeToByteArray(), bearer = null))
|
|
||||||
val dto = decodeOn201(response, LoginResponseDto.serializer())
|
|
||||||
return LoginResult(
|
|
||||||
enrollToken = dto.enrollToken,
|
|
||||||
accountId = dto.accountId,
|
|
||||||
expiresInSeconds = dto.expiresIn,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enroll a freshly-generated hardware key: POST the [csrDer] under the enroll [bearerToken],
|
|
||||||
* receive the leaf. Required fields are validated client-side (`InvalidRequest`) before any I/O.
|
|
||||||
*/
|
|
||||||
public suspend fun enroll(
|
|
||||||
bearerToken: String,
|
|
||||||
csrDer: ByteArray,
|
|
||||||
subdomain: String,
|
|
||||||
deviceName: String,
|
|
||||||
attestation: String? = null,
|
|
||||||
): EnrollmentResult {
|
|
||||||
if (bearerToken.isEmpty() || csrDer.isEmpty() || subdomain.isEmpty() || deviceName.isEmpty()) {
|
|
||||||
throw DeviceEnrollmentError.InvalidRequest
|
|
||||||
}
|
|
||||||
val body = EnrollJson.encodeToString(
|
|
||||||
EnrollRequestBody.serializer(),
|
|
||||||
EnrollRequestBody(
|
|
||||||
csr = base64(csrDer),
|
|
||||||
keyAlg = KEY_ALG_EC_P256,
|
|
||||||
subdomain = subdomain,
|
|
||||||
deviceName = deviceName,
|
|
||||||
attestation = attestation,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val response = http.send(jsonRequest(HttpMethod.POST, PATH_ENROLL, body.encodeToByteArray(), bearerToken))
|
|
||||||
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` (silent-rotation seam).
|
|
||||||
*
|
|
||||||
* The renew endpoint is authenticated by the CURRENT device certificate over mTLS — the body is
|
|
||||||
* `{ csr }` ONLY and NO bearer is sent (mirrors iOS, which passes `bearerToken: nil`). [bearerToken]
|
|
||||||
* is therefore OPTIONAL and defaults to absent; the `Authorization` header is omitted when it is
|
|
||||||
* null/blank. The seam still accepts a bearer for a hypothetical bearer-authenticated renew, but the
|
|
||||||
* production caller passes none. [deviceId] and [csrDer] are validated client-side before any I/O.
|
|
||||||
*/
|
|
||||||
public suspend fun renew(deviceId: String, csrDer: ByteArray, bearerToken: String? = null): EnrollmentResult {
|
|
||||||
if (deviceId.isEmpty() || csrDer.isEmpty()) {
|
|
||||||
throw DeviceEnrollmentError.InvalidRequest
|
|
||||||
}
|
|
||||||
// Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert
|
|
||||||
// and its schema is `.strict()`, so any enroll-only extra (keyAlg/subdomain/deviceName) is
|
|
||||||
// rejected. Identity/key come from the current cert + registry record, never the body.
|
|
||||||
val body = EnrollJson.encodeToString(
|
|
||||||
RenewRequestBody.serializer(),
|
|
||||||
RenewRequestBody(csr = base64(csrDer)),
|
|
||||||
)
|
|
||||||
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
|
|
||||||
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
|
|
||||||
// The renew 201 is `{ cert, caChain, notAfter }` — it does NOT echo deviceId, so the id we
|
|
||||||
// addressed the request to is the authoritative one.
|
|
||||||
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recover an EXPIRED leaf: `POST /device/:id/recover` carrying the lapsed [expiredCertDer] in the
|
|
||||||
* BODY plus a fresh [csrDer] over the SAME hardware key.
|
|
||||||
*
|
|
||||||
* **This call must NOT ride an mTLS transport.** `/renew` is authenticated by the very leaf it
|
|
||||||
* renews, so a lapsed leaf cannot renew itself — and no TLS terminator will even forward an expired
|
|
||||||
* client certificate (nginx answers a bare 400 under `ssl_verify_client optional`; `optional_no_ca`
|
|
||||||
* tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). The caller therefore supplies a
|
|
||||||
* PLAIN [HttpTransport] with no client identity; possession is proven by the CSR self-signature,
|
|
||||||
* which the control plane checks together with `CSR key == registered key`.
|
|
||||||
*
|
|
||||||
* No bearer is accepted on this path at all — the body is the whole credential story. Everything
|
|
||||||
* else the server enforces is unchanged: real X.509 path validation to the device-CA, SPIFFE parse,
|
|
||||||
* `notBefore` (never graced), `:id` must match the cert, and revocation still applies. Only
|
|
||||||
* `notAfter` is graced (30 days), so a leaf lapsed beyond that answers 401.
|
|
||||||
*/
|
|
||||||
public suspend fun recover(deviceId: String, expiredCertDer: ByteArray, csrDer: ByteArray): EnrollmentResult {
|
|
||||||
if (deviceId.isEmpty() || expiredCertDer.isEmpty() || csrDer.isEmpty()) {
|
|
||||||
throw DeviceEnrollmentError.InvalidRequest
|
|
||||||
}
|
|
||||||
val body = EnrollJson.encodeToString(
|
|
||||||
RecoverRequestBody.serializer(),
|
|
||||||
RecoverRequestBody(cert = base64(expiredCertDer), csr = base64(csrDer)),
|
|
||||||
)
|
|
||||||
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/recover"
|
|
||||||
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearer = null))
|
|
||||||
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Request/response plumbing ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun jsonRequest(
|
|
||||||
method: HttpMethod,
|
|
||||||
path: String,
|
|
||||||
jsonBody: ByteArray,
|
|
||||||
bearer: String?,
|
|
||||||
): HttpRequest {
|
|
||||||
val headers = LinkedHashMap<String, String>()
|
|
||||||
headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON
|
|
||||||
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
|
|
||||||
// string must never emit a bare "Bearer " header.
|
|
||||||
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
|
|
||||||
return HttpRequest(method = method, url = baseUrl + path, headers = headers, body = jsonBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
|
|
||||||
* code (never the raw body); an undecodable 201 body → [DeviceEnrollmentError.MalformedResponse]. */
|
|
||||||
private fun <T> decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer<T>): T {
|
|
||||||
if (response.status != HTTP_CREATED) {
|
|
||||||
throw DeviceEnrollmentError.Http(response.status, errorCode(response.body))
|
|
||||||
}
|
|
||||||
return runCatching { EnrollJson.decodeFromString(serializer, response.body.decodeToString()) }
|
|
||||||
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a re-issuance (renew / recover) 201, whose body carries no `deviceId`, using the [deviceId]
|
|
||||||
* the request was addressed to. `renewAfter` is normally absent here — the rotation policy
|
|
||||||
* re-derives it from the leaf's own validity window (`RotationPolicy.renewAfterFor`).
|
|
||||||
*/
|
|
||||||
private fun toReissueResult(dto: RenewResponseDto, deviceId: String): EnrollmentResult =
|
|
||||||
EnrollmentResult(
|
|
||||||
deviceId = deviceId,
|
|
||||||
certificate = decodeDerOrThrow(dto.cert),
|
|
||||||
caChain = dto.caChain.map { decodeDerOrThrow(it) },
|
|
||||||
notBefore = parseInstantOrNull(dto.notBefore),
|
|
||||||
notAfter = parseInstantOrNull(dto.notAfter),
|
|
||||||
renewAfter = parseInstantOrNull(dto.renewAfter),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun decodeDerOrThrow(base64Der: String): ByteArray =
|
|
||||||
decodeBase64OrNull(base64Der) ?: throw DeviceEnrollmentError.MalformedResponse
|
|
||||||
|
|
||||||
private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
|
|
||||||
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
|
|
||||||
val chain = dto.caChain.map { entry ->
|
|
||||||
decodeBase64OrNull(entry) ?: throw DeviceEnrollmentError.MalformedResponse
|
|
||||||
}
|
|
||||||
return EnrollmentResult(
|
|
||||||
deviceId = dto.deviceId,
|
|
||||||
certificate = certificate,
|
|
||||||
caChain = chain,
|
|
||||||
notBefore = parseInstantOrNull(dto.notBefore),
|
|
||||||
notAfter = parseInstantOrNull(dto.notAfter),
|
|
||||||
renewAfter = parseInstantOrNull(dto.renewAfter),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun errorCode(body: ByteArray): String? =
|
|
||||||
runCatching { EnrollJson.decodeFromString(ErrorDto.serializer(), body.decodeToString()).error }.getOrNull()
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val PATH_LOGIN = "/auth/login"
|
|
||||||
const val PATH_ENROLL = "/device/enroll"
|
|
||||||
const val PATH_DEVICE = "/device"
|
|
||||||
const val KEY_ALG_EC_P256 = "ec-p256"
|
|
||||||
const val HTTP_CREATED = 201
|
|
||||||
|
|
||||||
const val HEADER_CONTENT_TYPE = "Content-Type"
|
|
||||||
const val HEADER_AUTHORIZATION = "Authorization"
|
|
||||||
const val CONTENT_TYPE_JSON = "application/json"
|
|
||||||
const val BEARER_PREFIX = "Bearer "
|
|
||||||
|
|
||||||
private val BASE64_ENCODER = java.util.Base64.getEncoder()
|
|
||||||
private val BASE64_DECODER = java.util.Base64.getDecoder()
|
|
||||||
|
|
||||||
fun base64(bytes: ByteArray): String = BASE64_ENCODER.encodeToString(bytes)
|
|
||||||
|
|
||||||
fun decodeBase64OrNull(text: String): ByteArray? =
|
|
||||||
runCatching { BASE64_DECODER.decode(text) }.getOrNull()
|
|
||||||
|
|
||||||
/** Percent-encode a `:id` path segment's non-unreserved bytes (defence: device ids are
|
|
||||||
* server-minted UUIDs, but never build a URL from an unescaped field). */
|
|
||||||
fun encodePathSegment(value: String): String {
|
|
||||||
val sb = StringBuilder()
|
|
||||||
for (byte in value.encodeToByteArray()) {
|
|
||||||
val code = byte.toInt() and 0xFF
|
|
||||||
val ch = code.toChar()
|
|
||||||
if (ch in UNRESERVED) sb.append(ch) else sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
|
|
||||||
}
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val UNRESERVED: Set<Char> =
|
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
|
|
||||||
private val HEX = "0123456789ABCDEF".toCharArray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.enroll
|
|
||||||
|
|
||||||
import java.math.BigInteger
|
|
||||||
import java.security.interfaces.ECPublicKey
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B4 · Pure encoder from a JCA [ECPublicKey] to the ANSI X9.63 uncompressed point
|
|
||||||
* `0x04 || X || Y` that a P-256 `SubjectPublicKeyInfo` BIT STRING carries.
|
|
||||||
*
|
|
||||||
* Kept in the pure `:api-client` module (no Android dependency) so it is reused by BOTH the
|
|
||||||
* JVM-unit-test software signer AND the framework `HardwareBackedKey` (`:client-tls-android`),
|
|
||||||
* and so this security-load-bearing byte layout is unit-tested at JVM speed.
|
|
||||||
*/
|
|
||||||
public object EcPointEncoding {
|
|
||||||
/** P-256 field element width in bytes (256 bits). */
|
|
||||||
public const val P256_COORDINATE_BYTES: Int = 32
|
|
||||||
|
|
||||||
/** Uncompressed-point prefix (`0x04`) per SEC 1 §2.3.3. */
|
|
||||||
private const val UNCOMPRESSED_PREFIX: Byte = 0x04
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode [publicKey]'s affine (x, y) as `0x04 || X(32) || Y(32)` (65 bytes). Each coordinate is
|
|
||||||
* an unsigned big-endian integer left-padded (or, defensively, high-byte-trimmed) to exactly
|
|
||||||
* [P256_COORDINATE_BYTES]. Throws [IllegalArgumentException] if a coordinate genuinely does not
|
|
||||||
* fit 32 bytes (i.e. the key is not on a 256-bit curve).
|
|
||||||
*/
|
|
||||||
public fun x963(publicKey: ECPublicKey): ByteArray {
|
|
||||||
val point = publicKey.w
|
|
||||||
val x = toFixedLengthUnsigned(point.affineX, P256_COORDINATE_BYTES)
|
|
||||||
val y = toFixedLengthUnsigned(point.affineY, P256_COORDINATE_BYTES)
|
|
||||||
val out = ByteArray(1 + P256_COORDINATE_BYTES * 2)
|
|
||||||
out[0] = UNCOMPRESSED_PREFIX
|
|
||||||
System.arraycopy(x, 0, out, 1, P256_COORDINATE_BYTES)
|
|
||||||
System.arraycopy(y, 0, out, 1 + P256_COORDINATE_BYTES, P256_COORDINATE_BYTES)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a non-negative [value] to a big-endian byte array of exactly [length] bytes. A
|
|
||||||
* `BigInteger` may carry a leading 0x00 sign byte (drop it) or be shorter than [length]
|
|
||||||
* (left-pad with zeros). A value that needs MORE than [length] significant bytes is rejected —
|
|
||||||
* silently truncating a coordinate would corrupt the key.
|
|
||||||
*/
|
|
||||||
internal fun toFixedLengthUnsigned(value: BigInteger, length: Int): ByteArray {
|
|
||||||
require(value.signum() >= 0) { "EC coordinate must be non-negative" }
|
|
||||||
val raw = value.toByteArray() // big-endian, possibly with a leading 0x00 sign byte
|
|
||||||
val start = if (raw.size > length && raw[0].toInt() == 0) 1 else 0
|
|
||||||
val significant = raw.size - start
|
|
||||||
require(significant <= length) { "EC coordinate does not fit $length bytes (got $significant)" }
|
|
||||||
val out = ByteArray(length)
|
|
||||||
System.arraycopy(raw, start, out, length - significant, significant)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.enroll
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B4 · Typed result of the one-time operator login (`POST /auth/login`). The [enrollToken] is a
|
|
||||||
* short-lived `device:enroll` bearer — hold it ONLY for the immediately-following enroll call and
|
|
||||||
* NEVER persist or log it. [accountId] identifies the tenant the device will be scoped under.
|
|
||||||
*/
|
|
||||||
public data class LoginResult(
|
|
||||||
val enrollToken: String,
|
|
||||||
val accountId: String,
|
|
||||||
val expiresInSeconds: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B4 · Typed result of a successful `POST /device/enroll` (or `/device/:id/renew`): the issued leaf
|
|
||||||
* plus its issuer chain and rotation timing. The private key is NOT here — it stays non-exportable
|
|
||||||
* in AndroidKeyStore. Mirrors iOS `EnrollmentResult`.
|
|
||||||
*
|
|
||||||
* NOTE: [certificate]/[caChain] are `ByteArray`, so the generated `data class` equality is by
|
|
||||||
* reference (transient DER carriers, not value-equality keys) — compare with `contentEquals`.
|
|
||||||
*/
|
|
||||||
public data class EnrollmentResult(
|
|
||||||
val deviceId: String,
|
|
||||||
/** Leaf certificate DER (decoded from the response's base64). */
|
|
||||||
val certificate: ByteArray,
|
|
||||||
/** Issuer chain DERs (device-CA etc.), leaf excluded. */
|
|
||||||
val caChain: List<ByteArray>,
|
|
||||||
val notBefore: Instant?,
|
|
||||||
val notAfter: Instant?,
|
|
||||||
/** When to renew from the same hardware key (~2/3 of the lifetime). */
|
|
||||||
val renewAfter: Instant?,
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* The rotation seam: is the leaf due for renewal as of [now]? A missing [renewAfter] never
|
|
||||||
* triggers (fail-safe — the TLS stack is the real gate; the scheduler only pre-empts expiry).
|
|
||||||
*/
|
|
||||||
public fun isRenewalDue(now: Instant = Instant.now()): Boolean {
|
|
||||||
val due = renewAfter ?: return false
|
|
||||||
return !now.isBefore(due) // now >= renewAfter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed failures for the device-enrollment surface. Transport-level errors propagate UNWRAPPED. */
|
|
||||||
public sealed class DeviceEnrollmentError(message: String) : Exception(message) {
|
|
||||||
/**
|
|
||||||
* A non-success HTTP status with the server's uniform `{ error }` code, if any (401
|
|
||||||
* missing/rejected token, 403 subdomain-not-owned, 429 rate_limited, 400 rejected
|
|
||||||
* CSR/subdomain). Never leaks the response body.
|
|
||||||
*/
|
|
||||||
public data class Http(val status: Int, val code: String?) :
|
|
||||||
DeviceEnrollmentError("device enrollment rejected: HTTP $status" + (code?.let { " ($it)" } ?: ""))
|
|
||||||
|
|
||||||
/** A success body that did not decode to the expected shape (or an undecodable base64 cert). */
|
|
||||||
public data object MalformedResponse :
|
|
||||||
DeviceEnrollmentError("device enrollment response was not the expected shape")
|
|
||||||
|
|
||||||
/** A required request field was empty — rejected client-side BEFORE any network I/O. */
|
|
||||||
public data object InvalidRequest :
|
|
||||||
DeviceEnrollmentError("device enrollment request was missing a required field")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Wire DTOs + JSON config (internal to the enroll package) ─────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ENCODE omits absent optionals (`encodeDefaults = false` drops the default-null `attestation`;
|
|
||||||
* `explicitNulls = false` never writes an explicit `null`) and DECODE is tolerant of unknown keys
|
|
||||||
* (the server is untrusted at this boundary). `keyAlg` carries NO default, so it is ALWAYS encoded.
|
|
||||||
*/
|
|
||||||
internal val EnrollJson: Json = Json {
|
|
||||||
encodeDefaults = false
|
|
||||||
explicitNulls = false
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
isLenient = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
internal data class LoginRequestBody(val password: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
internal data class EnrollRequestBody(
|
|
||||||
val csr: String,
|
|
||||||
val keyAlg: String,
|
|
||||||
val subdomain: String,
|
|
||||||
val deviceName: String,
|
|
||||||
val attestation: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `/device/:id/renew` request body. The endpoint authenticates by the presented mTLS device cert
|
|
||||||
* and its server schema is `{ csr }` ONLY (`.strict()`), so it carries the single new CSR and NO
|
|
||||||
* enroll-only fields (keyAlg/subdomain/deviceName) — an extra key would be rejected as a 400.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
internal data class RenewRequestBody(val csr: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `/device/:id/recover` request body — the EXPIRED leaf plus a fresh CSR over the same key.
|
|
||||||
*
|
|
||||||
* The lapsed certificate travels in the BODY (not as an mTLS client cert) because no TLS terminator
|
|
||||||
* forwards an expired client certificate: nginx answers a bare 400 under `ssl_verify_client optional`,
|
|
||||||
* and `optional_no_ca` tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`. Nothing is
|
|
||||||
* lost: the CSR is self-signed by the same private key and the control-plane signer enforces CSR
|
|
||||||
* proof-of-possession plus `CSR key == registered key`, so possession is proven exactly as the
|
|
||||||
* handshake used to prove it. The server schema is `.strict()` on these two keys.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
internal data class RecoverRequestBody(val cert: String, val csr: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
internal data class LoginResponseDto(
|
|
||||||
val enrollToken: String,
|
|
||||||
val accountId: String,
|
|
||||||
val expiresIn: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
internal data class EnrollResponseDto(
|
|
||||||
val deviceId: String,
|
|
||||||
val cert: String,
|
|
||||||
val caChain: List<String> = emptyList(),
|
|
||||||
val notBefore: String? = null,
|
|
||||||
val notAfter: String? = null,
|
|
||||||
val renewAfter: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `/device/:id/renew` and `/device/:id/recover` 201 body — `{ cert, caChain, notAfter }` ONLY.
|
|
||||||
*
|
|
||||||
* Deliberately NOT [EnrollResponseDto]: the re-issuance routes do not echo `deviceId` (nor
|
|
||||||
* `notBefore`/`renewAfter`) — only `/device/enroll` does (`control-plane/src/api/renew.ts` vs
|
|
||||||
* `device-enroll.ts`). Decoding a renew 201 against the enroll DTO, whose `deviceId` is required,
|
|
||||||
* failed EVERY silent rotation with `MalformedResponse`. The device id is supplied by the caller (it
|
|
||||||
* is the path segment the request was addressed to, which is the authoritative value anyway), and the
|
|
||||||
* absent `renewAfter` is re-derived from the leaf by `RotationPolicy.renewAfterFor`.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
internal data class RenewResponseDto(
|
|
||||||
val cert: String,
|
|
||||||
val caChain: List<String> = emptyList(),
|
|
||||||
val notBefore: String? = null,
|
|
||||||
val notAfter: String? = null,
|
|
||||||
val renewAfter: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
internal data class ErrorDto(val error: String? = null)
|
|
||||||
|
|
||||||
/** Parse an ISO-8601 instant, degrading an absent/unparseable value to null (dates are advisory). */
|
|
||||||
internal fun parseInstantOrNull(text: String?): Instant? {
|
|
||||||
if (text == null) return null
|
|
||||||
return runCatching { Instant.parse(text) }.getOrNull()
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.enroll
|
|
||||||
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotation timing of the installed device identity — the input to [RotationPolicy]. Android analogue
|
|
||||||
* of iOS `DeviceRenewalState`.
|
|
||||||
*
|
|
||||||
* [notAfter] is the leaf's HARD expiry (past it the TLS stack rejects the cert outright) and
|
|
||||||
* [renewAfter] is the advisory "renew from the same key now" instant. Only non-secret timing plus the
|
|
||||||
* non-secret [deviceId] (which is a URL path segment) surface here: the private key never leaves
|
|
||||||
* AndroidKeyStore and the cert bytes are never carried through this type, so it is safe to log.
|
|
||||||
*/
|
|
||||||
public data class DeviceRenewalState(
|
|
||||||
/** The enrolled device id — the `:id` in `POST /device/:id/renew` and `/device/:id/recover`. */
|
|
||||||
val deviceId: String,
|
|
||||||
val notAfter: Instant?,
|
|
||||||
val renewAfter: Instant?,
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Is the leaf due for renewal as of [now]? A missing [renewAfter] NEVER triggers (fail-safe — the
|
|
||||||
* TLS stack is the real gate; the scheduler only pre-empts expiry). Mirrors
|
|
||||||
* [EnrollmentResult.isRenewalDue] and iOS `DeviceRenewalState.isRenewalDue`.
|
|
||||||
*/
|
|
||||||
public fun isRenewalDue(now: Instant): Boolean {
|
|
||||||
val due = renewAfter ?: return false
|
|
||||||
return !now.isBefore(due) // now >= renewAfter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a scheduler must do for one rotation pass — the total answer of [RotationPolicy.decide]. */
|
|
||||||
public enum class RotationDecision {
|
|
||||||
/** Nothing to do: the renew window has not opened (the cheap, common case). */
|
|
||||||
NOT_DUE,
|
|
||||||
|
|
||||||
/** An attempt IS due but the previous one failed too recently — wait, do not hammer. */
|
|
||||||
BACKING_OFF,
|
|
||||||
|
|
||||||
/** Still valid: re-CSR from the same key and renew over mTLS (`POST /device/:id/renew`). */
|
|
||||||
RENEW,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EXPIRED but inside the recovery grace: the leaf can no longer authenticate its own re-issuance,
|
|
||||||
* so recover over PLAIN HTTPS with the lapsed cert in the body (`POST /device/:id/recover`).
|
|
||||||
*/
|
|
||||||
RECOVER,
|
|
||||||
|
|
||||||
/** TERMINAL — expired past the grace window. No request can succeed; only a fresh enroll can. */
|
|
||||||
RE_ENROLL_REQUIRED,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The pure, JVM-testable rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
|
|
||||||
* `TerminalGridMath`). Given "now", the leaf's timing and the last failed attempt it answers
|
|
||||||
* renew / recover / wait / re-enroll — no clock, no keystore, no network, so every branch is a unit
|
|
||||||
* test rather than a device observation.
|
|
||||||
*
|
|
||||||
* Ported from iOS `CertificateRotationScheduler`'s timing rules, plus the two branches the phone
|
|
||||||
* track needs and iOS lacks (it can only renew, so an iOS device whose leaf lapses is stranded):
|
|
||||||
* - [RotationDecision.RECOVER] — the `/device/:id/recover` escape hatch, because no TLS terminator
|
|
||||||
* forwards an expired client certificate, so a lapsed leaf can never authenticate its own renewal.
|
|
||||||
* - [RotationDecision.RE_ENROLL_REQUIRED] — reported ONCE as terminal instead of retrying forever
|
|
||||||
* (the host-track agent logged the same failure 6380 times before this rule existed).
|
|
||||||
*/
|
|
||||||
public object RotationPolicy {
|
|
||||||
/**
|
|
||||||
* The control plane issues `renewAfter = notBefore + 2/3 · lifetime`
|
|
||||||
* (`control-plane/src/api/device-enroll.ts` `DEFAULT_RENEW_FRACTION`). The client mirrors the
|
|
||||||
* fraction as exact integer duration math so [renewAfterFor] lands on the same instant.
|
|
||||||
*/
|
|
||||||
private const val RENEW_FRACTION_NUMERATOR: Long = 2L
|
|
||||||
private const val RENEW_FRACTION_DENOMINATOR: Long = 3L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long after `notAfter` a lapsed leaf may still be swapped via `/device/:id/recover`
|
|
||||||
* (30 days) — the same window the control plane's `DEFAULT_EXPIRED_RENEW_GRACE_MS` allows. Asking
|
|
||||||
* outside it is pointless: the server answers 401.
|
|
||||||
*/
|
|
||||||
public val DEFAULT_EXPIRED_RECOVERY_GRACE: Duration = Duration.ofDays(30)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum gap after a FAILED attempt before another is made. A pass runs on every app
|
|
||||||
* foreground, and the control plane rate-limits renewals to 30/hour per identity, so an
|
|
||||||
* un-throttled retry converts one transient failure into a 429 storm.
|
|
||||||
*/
|
|
||||||
public val DEFAULT_FAILURE_BACKOFF: Duration = Duration.ofMinutes(15)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive the advisory renew instant from the leaf's own validity window.
|
|
||||||
*
|
|
||||||
* This derivation is LOAD-BEARING, not a convenience: `POST /device/:id/renew` and
|
|
||||||
* `POST /device/:id/recover` answer `{cert, caChain, notAfter}` only — neither returns
|
|
||||||
* `renewAfter` (only `/device/enroll` does). A client that persisted the enroll-time value and
|
|
||||||
* never recomputed it would rotate exactly once and then report "not due" until the cert died.
|
|
||||||
*
|
|
||||||
* Returns null when either bound is unknown or the window is not a lifetime (notAfter <=
|
|
||||||
* notBefore) — fail-safe, because [decide] treats a null [DeviceRenewalState.renewAfter] as
|
|
||||||
* never-due rather than inventing a past instant that would renew-storm.
|
|
||||||
*/
|
|
||||||
public fun renewAfterFor(notBefore: Instant?, notAfter: Instant?): Instant? {
|
|
||||||
if (notBefore == null || notAfter == null) return null
|
|
||||||
if (!notBefore.isBefore(notAfter)) return null
|
|
||||||
val lifetime = Duration.between(notBefore, notAfter)
|
|
||||||
return notBefore.plus(
|
|
||||||
lifetime.multipliedBy(RENEW_FRACTION_NUMERATOR).dividedBy(RENEW_FRACTION_DENOMINATOR),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The whole decision table, in priority order:
|
|
||||||
* 1. expired past [expiredRecoveryGrace] → [RotationDecision.RE_ENROLL_REQUIRED] (terminal; it
|
|
||||||
* outranks the backoff because no amount of waiting can help),
|
|
||||||
* 2. otherwise pick the desired action — expired → RECOVER, [DeviceRenewalState.isRenewalDue] →
|
|
||||||
* RENEW, else NOT_DUE,
|
|
||||||
* 3. an idle leaf stays [RotationDecision.NOT_DUE] (there is no attempt to throttle),
|
|
||||||
* 4. a desired action within [failureBackoff] of [lastFailureAt] → [RotationDecision.BACKING_OFF].
|
|
||||||
*
|
|
||||||
* @param lastFailureAt when the last attempt FAILED (null = none, or the last one succeeded).
|
|
||||||
*/
|
|
||||||
public fun decide(
|
|
||||||
state: DeviceRenewalState,
|
|
||||||
now: Instant,
|
|
||||||
lastFailureAt: Instant? = null,
|
|
||||||
expiredRecoveryGrace: Duration = DEFAULT_EXPIRED_RECOVERY_GRACE,
|
|
||||||
failureBackoff: Duration = DEFAULT_FAILURE_BACKOFF,
|
|
||||||
): RotationDecision {
|
|
||||||
require(!expiredRecoveryGrace.isNegative) { "expiredRecoveryGrace must not be negative" }
|
|
||||||
require(!failureBackoff.isNegative) { "failureBackoff must not be negative" }
|
|
||||||
|
|
||||||
val notAfter = state.notAfter
|
|
||||||
// Terminal first: past `notAfter + grace` nothing can succeed. A non-negative grace makes this
|
|
||||||
// strictly stronger than "expired", so it needs no separate expiry guard.
|
|
||||||
if (notAfter != null && now.isAfter(notAfter.plus(expiredRecoveryGrace))) {
|
|
||||||
return RotationDecision.RE_ENROLL_REQUIRED
|
|
||||||
}
|
|
||||||
val isExpired = notAfter != null && now.isAfter(notAfter)
|
|
||||||
|
|
||||||
val desired = when {
|
|
||||||
isExpired -> RotationDecision.RECOVER
|
|
||||||
state.isRenewalDue(now) -> RotationDecision.RENEW
|
|
||||||
else -> RotationDecision.NOT_DUE
|
|
||||||
}
|
|
||||||
if (desired == RotationDecision.NOT_DUE) return RotationDecision.NOT_DUE
|
|
||||||
if (lastFailureAt != null && now.isBefore(lastFailureAt.plus(failureBackoff))) {
|
|
||||||
return RotationDecision.BACKING_OFF
|
|
||||||
}
|
|
||||||
return desired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One commit from `git log` (`src/types.ts` `CommitLogEntry`). [hash] and [at] are REQUIRED — a
|
|
||||||
* commit missing either is dropped by the list-lossy [CommitLogEntryListSerializer] (its siblings
|
|
||||||
* survive). [subject] defaults to empty so a subject-less commit still decodes. `at` = `%ct * 1000`
|
|
||||||
* (epoch millis). All fields are rendered INERT (plain text; no autolink) at the screen (plan §8).
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
public data class CommitLogEntry(
|
|
||||||
val hash: String,
|
|
||||||
val at: Long,
|
|
||||||
val subject: String = "",
|
|
||||||
/**
|
|
||||||
* w6/G4: reachable from HEAD but NOT from `@{u}` — i.e. this commit has not been pushed.
|
|
||||||
*
|
|
||||||
* Server-computed from `rev-list`, and deliberately NOT "the first N rows": `git log` is
|
|
||||||
* date-ordered, so merging an older branch interleaves unpushed commits BELOW pushed ones. The
|
|
||||||
* row-count shortcut fails in the dangerous direction — it would call an unpushed commit pushed.
|
|
||||||
* Absent ⇒ unknown (no upstream to compare against), which must not render as "pushed".
|
|
||||||
*/
|
|
||||||
val unpushed: Boolean? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /projects/log` result (`src/types.ts` `GitLogResult`). [truncated] = more commits exist
|
|
||||||
* beyond the server cap. The commit list decodes lossily (drop-one-keep-rest).
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
public data class GitLogResult(
|
|
||||||
@Serializable(with = CommitLogEntryListSerializer::class)
|
|
||||||
val commits: List<CommitLogEntry> = emptyList(),
|
|
||||||
val truncated: Boolean = false,
|
|
||||||
/**
|
|
||||||
* w6/G4: upstream short name used to label the pushed/unpushed boundary. Null ⇒ nothing to compare
|
|
||||||
* against, so **no boundary may be drawn at all** — not a boundary drawn at position zero.
|
|
||||||
*/
|
|
||||||
val upstream: String? = null,
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Index of the LAST unpushed commit, or null when no boundary may be drawn.
|
|
||||||
*
|
|
||||||
* The boundary is drawn ONCE, after this index — never once per unpushed commit, and never at all
|
|
||||||
* when [upstream] is absent. Returns null rather than -1 so a caller cannot accidentally treat
|
|
||||||
* "no boundary" as "boundary before everything".
|
|
||||||
*/
|
|
||||||
public val upstreamBoundaryIndex: Int?
|
|
||||||
get() {
|
|
||||||
if (upstream == null) return null
|
|
||||||
val last = commits.indexOfLast { it.unpushed == true }
|
|
||||||
return last.takeIf { it >= 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How many commits are waiting to be pushed; 0 when unknown or nothing is pending. */
|
|
||||||
public val unpushedCount: Int get() = commits.count { it.unpushed == true }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
|
|
||||||
internal object CommitLogEntryListSerializer :
|
|
||||||
KSerializer<List<CommitLogEntry>> by LossyListSerializer(CommitLogEntry.serializer())
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A client result union for the six guarded git-write ops (worktree create/remove/prune, git
|
|
||||||
* stage/commit/push). It carries the server's SAFE body only — never raw git stderr (the server
|
|
||||||
* classifies + sanitizes every failure, `src/http/git-ops.ts` / `worktrees.ts`, SEC-M10):
|
|
||||||
*
|
|
||||||
* - [Ok] — a 200 with the op's route-specific payload [T].
|
|
||||||
* - [Rejected] — a 4xx/5xx with the server's inert `error` string ([message]) to display verbatim.
|
|
||||||
* 403 is OVERLOADED (Origin-guard failure AND the disabled kill-switch both 403) so the client
|
|
||||||
* cannot tell them apart by status — it surfaces [message] inertly rather than inventing a typed
|
|
||||||
* variant (plan Edge cases / Security).
|
|
||||||
* - [RateLimited] — a 429 (stage/commit share one limiter, push a tighter one). Do NOT auto-retry.
|
|
||||||
*
|
|
||||||
* Nothing here throws on a bad body: a missing/garbled payload degrades to defaults (empty sha,
|
|
||||||
* empty pruned list) rather than crashing (tolerant-decode discipline, plan §8).
|
|
||||||
*/
|
|
||||||
public sealed interface GitWriteOutcome<out T> {
|
|
||||||
/** 200 — the op succeeded; [payload] is the route-specific success body. */
|
|
||||||
public data class Ok<out T>(val payload: T) : GitWriteOutcome<T>
|
|
||||||
|
|
||||||
/** A 4xx/5xx failure carrying the server's SAFE [message] (inert; may be null if unparseable). */
|
|
||||||
public data class Rejected(val status: Int, val message: String?) : GitWriteOutcome<Nothing>
|
|
||||||
|
|
||||||
/** 429 — the server rate-limited this write. */
|
|
||||||
public data object RateLimited : GitWriteOutcome<Nothing>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Per-op 200 payloads (all fields optional/defaulted → a garbled body degrades, never throws) ──
|
|
||||||
|
|
||||||
/** `POST /projects/git/stage` 200 → `{ ok, staged, count }`. */
|
|
||||||
@Serializable
|
|
||||||
public data class StageResult(val staged: Boolean = false, val count: Int = 0)
|
|
||||||
|
|
||||||
/** `POST /projects/git/commit` 200 → `{ ok, commit }` (short sha; may be `""` — empty is valid). */
|
|
||||||
@Serializable
|
|
||||||
public data class CommitResult(val commit: String = "")
|
|
||||||
|
|
||||||
/** `POST /projects/git/push` 200 → `{ ok, branch, remote }`. */
|
|
||||||
@Serializable
|
|
||||||
public data class PushResult(val branch: String? = null, val remote: String? = null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /projects/git/fetch` 200 payload (w6/G2).
|
|
||||||
*
|
|
||||||
* [lastFetchMs] is FETCH_HEAD's mtime AFTER the fetch, so the UI can stop saying "stale" without
|
|
||||||
* re-probing. On FAILURE the server deliberately leaves the old value alone, which is why this is
|
|
||||||
* nullable: a fetch that did not happen must keep reading as stale rather than pretending it refreshed.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
public data class FetchResult(val lastFetchMs: Long? = null)
|
|
||||||
|
|
||||||
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
|
|
||||||
@Serializable
|
|
||||||
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
|
|
||||||
|
|
||||||
/** `DELETE /projects/worktree` 200 → `{ ok, path }` (git's canonical removed path). */
|
|
||||||
@Serializable
|
|
||||||
public data class RemoveWorktreeResult(val path: String? = null)
|
|
||||||
|
|
||||||
/** `POST /projects/worktree/prune` 200 → `{ ok, pruned: [...] }` (empty = nothing to prune). */
|
|
||||||
@Serializable
|
|
||||||
public data class PruneWorktreesResult(val pruned: List<String> = emptyList())
|
|
||||||
|
|
||||||
/** Shape of a failure body — worktree routes emit `{ error }`, git-ops `{ ok:false, error }`; both
|
|
||||||
* carry `error` as a SAFE string. Decoded to surface [error] inertly. */
|
|
||||||
@Serializable
|
|
||||||
internal data class GitErrorBody(val ok: Boolean = false, val error: String? = null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a guarded 200 body into [T], degrading a missing/garbled body to the payload's defaults
|
|
||||||
* (never throws — the caller already knows the status is 200).
|
|
||||||
*/
|
|
||||||
internal fun <T> decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer<T>): T =
|
|
||||||
LossyDecode.objectOrNull(bytes, deserializer) ?: ModelJson.decodeFromString(deserializer, "{}")
|
|
||||||
|
|
||||||
/** Read the SAFE `error` string from a failure body; null when the body is empty/unparseable. */
|
|
||||||
internal fun decodeGitError(bytes: ByteArray): String? =
|
|
||||||
LossyDecode.objectOrNull(bytes, GitErrorBody.serializer())?.error
|
|
||||||
@@ -42,9 +42,4 @@ public data class LiveSessionInfo(
|
|||||||
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
|
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
|
||||||
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
|
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
|
||||||
val lastOutputAt: Long? = null,
|
val lastOutputAt: Long? = null,
|
||||||
/** W2 pending prompt-queue depth (`src/types.ts` `queueLength?`) — what the "N queued" badge
|
|
||||||
* renders. Additive OPTIONAL: `null` on a pre-W2 server (queue unsupported / unknown), `0` when
|
|
||||||
* the queue is empty; the badge shows only for a positive value. Decoded tolerantly
|
|
||||||
* ([LossyIntSerializer]) so a garbled value can never hide a running session. */
|
|
||||||
@Serializable(with = LossyIntSerializer::class) val queueLength: Int? = null,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
||||||
import kotlinx.serialization.encoding.Decoder
|
|
||||||
import kotlinx.serialization.encoding.Encoder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Why a [PrStatus] has (or lacks) PR data (`src/types.ts` `PrAvailability`). Drives the detail
|
|
||||||
* chip's copy. Decoded via [PrAvailabilitySerializer]: an unknown/future value **degrades to
|
|
||||||
* [ERROR]** (never throws) — a new server availability must never make the chip crash.
|
|
||||||
*/
|
|
||||||
public enum class PrAvailability(public val wire: String) {
|
|
||||||
/** A PR exists for the current branch; the sibling fields are populated. */
|
|
||||||
OK("ok"),
|
|
||||||
|
|
||||||
/** gh works but the branch has no PR (or no remote/default repo). */
|
|
||||||
NO_PR("no-pr"),
|
|
||||||
|
|
||||||
/** `gh` binary not found on PATH (ENOENT). */
|
|
||||||
NOT_INSTALLED("not-installed"),
|
|
||||||
|
|
||||||
/** gh present but not logged in (needs `gh auth login`). */
|
|
||||||
UNAUTHENTICATED("unauthenticated"),
|
|
||||||
|
|
||||||
/** `GH_ENABLED=0` — feature off, never spawns gh. */
|
|
||||||
DISABLED("disabled"),
|
|
||||||
|
|
||||||
/** gh spawned but failed for another reason (timeout, etc.); also the unknown/missing fallback. */
|
|
||||||
ERROR("error"),
|
|
||||||
|
|
||||||
;
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
/** Map the wire string; unknown → [ERROR] (mirror of the FE never treating non-`ok` as fatal). */
|
|
||||||
public fun fromWire(wire: String): PrAvailability =
|
|
||||||
entries.firstOrNull { it.wire == wire } ?: ERROR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode [PrAvailability] by its `wire` value; an unknown/future value maps to [PrAvailability.ERROR]
|
|
||||||
* rather than throwing (mirror of [ClaudeStatusSerializer]). Serializes back the `wire` string.
|
|
||||||
*/
|
|
||||||
internal object PrAvailabilitySerializer : KSerializer<PrAvailability> {
|
|
||||||
override val descriptor: SerialDescriptor =
|
|
||||||
PrimitiveSerialDescriptor("PrAvailability", PrimitiveKind.STRING)
|
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): PrAvailability =
|
|
||||||
PrAvailability.fromWire(decoder.decodeString())
|
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: PrAvailability) =
|
|
||||||
encoder.encodeString(value.wire)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rolled-up CI check counts from gh's statusCheckRollup (`src/types.ts` `PrCheckSummary`). */
|
|
||||||
@Serializable
|
|
||||||
public data class PrCheckSummary(
|
|
||||||
val total: Int = 0,
|
|
||||||
val passing: Int = 0,
|
|
||||||
val failing: Int = 0,
|
|
||||||
val pending: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /projects/pr` result (`src/types.ts` `PrStatus`). Every field except [availability] is
|
|
||||||
* optional (present only when `availability == ok`); [availability] itself defaults to
|
|
||||||
* [PrAvailability.ERROR] so a body missing the field still decodes (never throws). `state` /
|
|
||||||
* `mergeable` are lower-cased string unions on the wire — kept as raw INERT strings here (rendered
|
|
||||||
* as plain text; no enum needed for display).
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
public data class PrStatus(
|
|
||||||
@Serializable(with = PrAvailabilitySerializer::class)
|
|
||||||
val availability: PrAvailability = PrAvailability.ERROR,
|
|
||||||
val number: Int? = null,
|
|
||||||
val title: String? = null,
|
|
||||||
val url: String? = null,
|
|
||||||
val state: String? = null,
|
|
||||||
val isDraft: Boolean? = null,
|
|
||||||
val mergeable: String? = null,
|
|
||||||
val headRefName: String? = null,
|
|
||||||
val baseRefName: String? = null,
|
|
||||||
val checks: PrCheckSummary? = null,
|
|
||||||
)
|
|
||||||
@@ -17,12 +17,6 @@ public data class ProjectSessionRef(
|
|||||||
val clientCount: Int,
|
val clientCount: Int,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val exited: Boolean,
|
val exited: Boolean,
|
||||||
/**
|
|
||||||
* w6/G7: the session's working directory, needed to attribute it to a worktree. Matching is
|
|
||||||
* DEEPEST-first, because `.claude/worktrees/<name>` lives INSIDE the main checkout and a prefix
|
|
||||||
* match would count every worktree session against the parent repo too.
|
|
||||||
*/
|
|
||||||
val cwd: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,14 +34,6 @@ public data class ProjectInfo(
|
|||||||
val dirty: Boolean? = null,
|
val dirty: Boolean? = null,
|
||||||
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
|
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
|
||||||
val lastActiveMs: Long? = null,
|
val lastActiveMs: Long? = null,
|
||||||
/** W3 sync chip — commits on HEAD not on `@{u}` (best-effort; absent when no upstream). */
|
|
||||||
val ahead: Int? = null,
|
|
||||||
/** W3 sync chip — commits on `@{u}` not on HEAD (best-effort; absent when no upstream). */
|
|
||||||
val behind: Int? = null,
|
|
||||||
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
|
|
||||||
val lastCommitMs: Long? = null,
|
|
||||||
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
|
|
||||||
val dirtyCount: Int? = null,
|
|
||||||
@Serializable(with = ProjectSessionRefListSerializer::class)
|
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||||
val sessions: List<ProjectSessionRef> = emptyList(),
|
val sessions: List<ProjectSessionRef> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -73,10 +59,6 @@ public data class ProjectDetail(
|
|||||||
val isGit: Boolean,
|
val isGit: Boolean,
|
||||||
val branch: String? = null,
|
val branch: String? = null,
|
||||||
val dirty: Boolean? = null,
|
val dirty: Boolean? = null,
|
||||||
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
|
|
||||||
val dirtyCount: Int? = null,
|
|
||||||
/** w6/G1: git repos only; null for a non-git dir. See [SyncState] for what may be trusted. */
|
|
||||||
val sync: SyncState? = null,
|
|
||||||
@Serializable(with = WorktreeInfoListSerializer::class)
|
@Serializable(with = WorktreeInfoListSerializer::class)
|
||||||
val worktrees: List<WorktreeInfo> = emptyList(),
|
val worktrees: List<WorktreeInfo> = emptyList(),
|
||||||
@Serializable(with = ProjectSessionRefListSerializer::class)
|
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* W2 prompt queue — the server-side FIFO of follow-up prompts injected into a session's PTY the next
|
|
||||||
* time Claude goes idle (`src/server.ts` ~605/644/654, `SessionManager.enqueueFollowup`/`clearQueue`).
|
|
||||||
*
|
|
||||||
* A queued entry is **raw keyboard input for a shell** (invariant #9 / byte-shuttle): it is stored and
|
|
||||||
* later written to the PTY verbatim. Nothing on this path may trim, escape, re-encode or normalise it —
|
|
||||||
* the only transformation is the server appending `\r` when the caller passed `appendEnter = true`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /live-sessions/:id/queue` 200 → `{ length, items }` — the pending depth plus the queued texts
|
|
||||||
* (verbatim keystroke bytes). Both fields default, so a missing/garbled key degrades to an empty queue
|
|
||||||
* instead of throwing; a non-object body yields `null` from `LossyDecode` (→ `InvalidResponseBody`).
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
public data class QueueSnapshot(
|
|
||||||
/** Pending entries on the server. Equals `items.size` for a healthy server; trust [items] for
|
|
||||||
* rendering and [length] only as the badge number the server itself broadcasts. */
|
|
||||||
val length: Int = 0,
|
|
||||||
/** The queued prompts in FIFO order, verbatim. */
|
|
||||||
val items: List<String> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Outcome of the two GUARDED queue writes — `POST` (enqueue) and `DELETE` (cancel-all). One union for
|
|
||||||
* both ops (the `GitWriteOutcome` precedent: one union, six routes); [Full], [TooLarge] and [Disabled]
|
|
||||||
* are simply unreachable for the DELETE, whose handler neither validates text nor consults the
|
|
||||||
* `QUEUE_ENABLED` kill-switch.
|
|
||||||
*
|
|
||||||
* Every variant maps to different UI behaviour, which is why they are typed rather than folded into a
|
|
||||||
* status code:
|
|
||||||
* - [Ok] — the server accepted it; [length] is the authoritative new depth for the "N queued" badge.
|
|
||||||
* - [Full] — 409, the bounded FIFO is at `QUEUE_MAX_ITEMS` (default 10). Cancel something first;
|
|
||||||
* the server never silently drops.
|
|
||||||
* - [TooLarge] — 413, over `QUEUE_ITEM_MAX_BYTES` (default 4 KiB, server-configurable — the client
|
|
||||||
* deliberately does NOT pre-validate size, which would invent a policy it cannot know).
|
|
||||||
* - [Disabled] — 503, `QUEUE_ENABLED=0` on this host: hide the affordance, do not retry.
|
|
||||||
* - [SessionGone] — 404, unknown or already-exited session.
|
|
||||||
* - [RateLimited] — 429 from the shared `QUEUE_RATE_MAX = 20`/minute/IP bucket. **Never auto-retry**;
|
|
||||||
* a retry loop just burns the same budget a real enqueue needs.
|
|
||||||
* - [Rejected] — any other 4xx/5xx, carrying the server's SAFE `error` string verbatim (`message` may
|
|
||||||
* be `null` when the body is empty, e.g. the Origin guard's bare 403).
|
|
||||||
*/
|
|
||||||
public sealed interface QueueWriteOutcome {
|
|
||||||
/** 200 — accepted; [length] is the new pending depth (0 for a cleared queue). */
|
|
||||||
public data class Ok(val length: Int) : QueueWriteOutcome
|
|
||||||
|
|
||||||
/** 409 — the bounded FIFO is full (`QUEUE_MAX_ITEMS`). */
|
|
||||||
public data object Full : QueueWriteOutcome
|
|
||||||
|
|
||||||
/** 413 — the prompt exceeds the server's per-item byte cap. */
|
|
||||||
public data object TooLarge : QueueWriteOutcome
|
|
||||||
|
|
||||||
/** 503 — the queue feature is switched off on this host. */
|
|
||||||
public data object Disabled : QueueWriteOutcome
|
|
||||||
|
|
||||||
/** 404 — the session is unknown or has already exited. */
|
|
||||||
public data object SessionGone : QueueWriteOutcome
|
|
||||||
|
|
||||||
/** 429 — shared per-IP queue bucket. Do NOT auto-retry. */
|
|
||||||
public data object RateLimited : QueueWriteOutcome
|
|
||||||
|
|
||||||
/** Any other failure, with the server's inert message (null when unparseable/empty). */
|
|
||||||
public data class Rejected(val status: Int, val message: String?) : QueueWriteOutcome
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the new depth out of a queue write's 200 body (`{ length }`). The status already says it
|
|
||||||
* succeeded, so a missing/garbled body degrades to `0` rather than throwing — the next `queue` read or
|
|
||||||
* `queue` WS frame re-establishes the true depth.
|
|
||||||
*/
|
|
||||||
internal fun decodeQueueDepth(bytes: ByteArray): Int =
|
|
||||||
LossyDecode.objectOrNull(bytes, QueueSnapshot.serializer())?.length ?: 0
|
|
||||||
@@ -2,8 +2,6 @@ package wang.yaojia.webterm.api.models
|
|||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
import kotlinx.serialization.builtins.nullable
|
|
||||||
import kotlinx.serialization.builtins.serializer
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
@@ -11,9 +9,7 @@ import kotlinx.serialization.encoding.Decoder
|
|||||||
import kotlinx.serialization.encoding.Encoder
|
import kotlinx.serialization.encoding.Encoder
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonDecoder
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import kotlinx.serialization.json.intOrNull
|
|
||||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -40,23 +36,6 @@ internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
|
|||||||
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
|
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A tolerant `Int?` field decoder: a wrong-typed/garbled value degrades to `null` instead of failing
|
|
||||||
* the whole enclosing object. Used for ADDITIVE OPTIONAL numeric fields (e.g.
|
|
||||||
* `LiveSessionInfo.queueLength`) where dropping the entry would hide a running session for the sake of
|
|
||||||
* a badge. Required numeric fields deliberately keep the strict built-in behaviour.
|
|
||||||
*/
|
|
||||||
internal object LossyIntSerializer : KSerializer<Int?> {
|
|
||||||
private val delegate: KSerializer<Int?> = Int.serializer().nullable
|
|
||||||
override val descriptor: SerialDescriptor = delegate.descriptor
|
|
||||||
override fun serialize(encoder: Encoder, value: Int?) = delegate.serialize(encoder, value)
|
|
||||||
override fun deserialize(decoder: Decoder): Int? {
|
|
||||||
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
|
|
||||||
val primitive = json.decodeJsonElement() as? JsonPrimitive ?: return null
|
|
||||||
return if (primitive.isString) primitive.content.toIntOrNull() else primitive.intOrNull
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
|
* A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
|
||||||
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,
|
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* w6/G1 — how far the checked-out branch has drifted from its upstream (`src/types.ts` `SyncState`).
|
|
||||||
*
|
|
||||||
* ## The one rule this whole feature turns on
|
|
||||||
* `ahead`/`behind` are computed against `@{u}`, a **locally cached** remote ref that only a `fetch`
|
|
||||||
* moves. That asymmetry decides what may be trusted:
|
|
||||||
*
|
|
||||||
* - [ahead] needs only local refs, so it is always trustworthy.
|
|
||||||
* - [behind] is only meaningful if [lastFetchMs] is recent. A stale FETCH_HEAD reports `behind = 0`
|
|
||||||
* for a branch that is in fact many commits behind — the server's own commit message records finding
|
|
||||||
* exactly that (`↓0` while FETCH_HEAD had not moved in 19 days).
|
|
||||||
* - **No upstream leaves [ahead] and [behind] undefined**, which is NOT the same as zero. A fresh
|
|
||||||
* worktree branch normally has no upstream, and rendering it as "in sync" is the single easiest bug
|
|
||||||
* to ship here. [isFullySynced] is the only sanctioned way to ask "may I render the all-clear?" and
|
|
||||||
* it answers false whenever anything is unknown. Do not re-derive that test at a call site.
|
|
||||||
*
|
|
||||||
* Every field is optional because every one of them degrades independently on the server (no upstream,
|
|
||||||
* detached HEAD, never fetched, not a git repo).
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
public data class SyncState(
|
|
||||||
/** e.g. `origin/develop`; null ⇒ the branch tracks nothing. */
|
|
||||||
val upstream: String? = null,
|
|
||||||
/** Commits on HEAD not on `@{u}`. Null ⇒ unknown (no upstream), NOT zero. */
|
|
||||||
val ahead: Int? = null,
|
|
||||||
/** Commits on `@{u}` not on HEAD. Null ⇒ unknown. Trust only when [isFetchFresh]. */
|
|
||||||
val behind: Int? = null,
|
|
||||||
/** FETCH_HEAD mtime in ms; null ⇒ never fetched. */
|
|
||||||
val lastFetchMs: Long? = null,
|
|
||||||
/** HEAD is not on a branch ⇒ no branch, no ahead/behind, and fetch/push are not offerable. */
|
|
||||||
val detached: Boolean = false,
|
|
||||||
) {
|
|
||||||
|
|
||||||
/** True when there is an upstream to compare against at all. */
|
|
||||||
public val hasUpstream: Boolean get() = upstream != null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when [lastFetchMs] is recent enough for [behind] to mean anything.
|
|
||||||
*
|
|
||||||
* @param nowMs caller-supplied clock so this stays pure and testable.
|
|
||||||
*/
|
|
||||||
public fun isFetchFresh(nowMs: Long): Boolean {
|
|
||||||
val fetched = lastFetchMs ?: return false
|
|
||||||
val age = nowMs - fetched
|
|
||||||
// A clock skew that puts the fetch in the future is not evidence of freshness.
|
|
||||||
return age in 0..FETCH_FRESH_WINDOW_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when [behind] should be shown with a "stale — I have not checked" qualifier. */
|
|
||||||
public fun isBehindStale(nowMs: Long): Boolean = hasUpstream && !isFetchFresh(nowMs)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ONLY state that may render as the green all-clear: nothing to push, nothing to pull, and a
|
|
||||||
* fresh enough fetch to justify saying so.
|
|
||||||
*
|
|
||||||
* Green means "I checked, you can ignore this". Getting it wrong is lying to the user, so anything
|
|
||||||
* unknown — no upstream, detached, never fetched, stale fetch, null counts — is false.
|
|
||||||
*/
|
|
||||||
public fun isFullySynced(nowMs: Long): Boolean =
|
|
||||||
hasUpstream && !detached && ahead == 0 && behind == 0 && isFetchFresh(nowMs)
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
/**
|
|
||||||
* How long a fetch stays "fresh". Mirrors the server rule that `behind` is flagged stale once
|
|
||||||
* FETCH_HEAD is older than an hour (w6/G1).
|
|
||||||
*/
|
|
||||||
public const val FETCH_FRESH_WINDOW_MS: Long = 60L * 60L * 1000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* w6/G7 — the git state of ONE worktree, fetched lazily per row via `GET /projects/worktree/state`
|
|
||||||
* (`src/types.ts` `WorktreeState`).
|
|
||||||
*
|
|
||||||
* Deliberately narrower than `ProjectDetail`: N rows must not each pay for a worktree listing and a
|
|
||||||
* CLAUDE.md read that nothing renders.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
public data class WorktreeState(
|
|
||||||
val path: String,
|
|
||||||
val branch: String? = null,
|
|
||||||
val sync: SyncState? = null,
|
|
||||||
val dirtyCount: Int? = null,
|
|
||||||
)
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.pairing
|
|
||||||
|
|
||||||
import wang.yaojia.webterm.api.routes.Endpoints
|
|
||||||
import wang.yaojia.webterm.wire.AccessTokenRule
|
|
||||||
import wang.yaojia.webterm.wire.AuthCookie
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
|
||||||
import wang.yaojia.webterm.wire.HttpResponse
|
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases
|
|
||||||
* are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side
|
|
||||||
* pre-check and [Unexpected] keeps an unknown status from being mistaken for success.
|
|
||||||
*/
|
|
||||||
public sealed interface AuthProbeResult {
|
|
||||||
/** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */
|
|
||||||
public data object Accepted : AuthProbeResult
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to
|
|
||||||
* authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated"
|
|
||||||
* (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy.
|
|
||||||
*/
|
|
||||||
public data object AuthDisabled : AuthProbeResult
|
|
||||||
|
|
||||||
/** **401** — wrong token. UI: "访问令牌不正确". */
|
|
||||||
public data object InvalidToken : AuthProbeResult
|
|
||||||
|
|
||||||
/** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */
|
|
||||||
public data object RateLimited : AuthProbeResult
|
|
||||||
|
|
||||||
/** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */
|
|
||||||
public data object Malformed : AuthProbeResult
|
|
||||||
|
|
||||||
/** Any other status (e.g. a captive portal's 302). Never treated as success. */
|
|
||||||
public data class Unexpected(val status: Int) : AuthProbeResult
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a
|
|
||||||
* session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and
|
|
||||||
* ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a
|
|
||||||
* BOOLEAN signal (was the token accepted, or is auth disabled on this host?).
|
|
||||||
*
|
|
||||||
* Transport-level failures propagate unwrapped so the caller can classify them with
|
|
||||||
* [PairingError.classify] (same convention as [runPairingProbe]).
|
|
||||||
*
|
|
||||||
* Never logs the token; the token appears only in the JSON body, never in the URL.
|
|
||||||
*/
|
|
||||||
public suspend fun probeAccessToken(
|
|
||||||
endpoint: HostEndpoint,
|
|
||||||
http: HttpTransport,
|
|
||||||
token: String,
|
|
||||||
): AuthProbeResult {
|
|
||||||
// Boundary validation first: a token outside the server's charset/length can never be correct, and
|
|
||||||
// there is no point spending a rate-limit slot (or shipping it over the wire) to find that out.
|
|
||||||
val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed
|
|
||||||
val request = Endpoints.auth(normalized).toHttpRequest(endpoint)
|
|
||||||
?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST)
|
|
||||||
val response = http.send(request)
|
|
||||||
return classifyAuthResponse(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) {
|
|
||||||
HTTP_NO_CONTENT ->
|
|
||||||
// The ONE discriminator between "token correct" and "this host has no auth at all".
|
|
||||||
if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled
|
|
||||||
HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken
|
|
||||||
HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited
|
|
||||||
else -> AuthProbeResult.Unexpected(response.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
|
|
||||||
* value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves
|
|
||||||
* nothing about our token.
|
|
||||||
*/
|
|
||||||
private fun HttpResponse.carriesAuthCookie(): Boolean =
|
|
||||||
headers.entries.any { (name, value) ->
|
|
||||||
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sentinel status for "the request could not even be built" — unreachable for a validated
|
|
||||||
* [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status).
|
|
||||||
*/
|
|
||||||
private const val UNBUILDABLE_REQUEST = 0
|
|
||||||
|
|
||||||
private const val SET_COOKIE_HEADER = "Set-Cookie"
|
|
||||||
private const val HTTP_NO_CONTENT = 204
|
|
||||||
private const val HTTP_UNAUTHORIZED = 401
|
|
||||||
private const val HTTP_TOO_MANY_REQUESTS = 429
|
|
||||||
@@ -52,17 +52,6 @@ public sealed interface PairingError {
|
|||||||
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
|
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
|
||||||
public data object TlsFailure : PairingError
|
public data object TlsFailure : PairingError
|
||||||
|
|
||||||
/**
|
|
||||||
* The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented
|
|
||||||
* no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by
|
|
||||||
* entering the host's access token, not by editing `ALLOWED_ORIGINS`.
|
|
||||||
*
|
|
||||||
* NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP
|
|
||||||
* first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS
|
|
||||||
* upgrade can only be the Origin allow-list (the server writes the same bare 401 for both).
|
|
||||||
*/
|
|
||||||
public data object AccessTokenRequired : PairingError
|
|
||||||
|
|
||||||
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
|
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
|
||||||
public data object Timeout : PairingError
|
public data object Timeout : PairingError
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
|
||||||
import wang.yaojia.webterm.wire.AuthCookie
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
import wang.yaojia.webterm.wire.ClientMessage
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
@@ -52,9 +50,8 @@ public suspend fun runPairingProbe(
|
|||||||
endpoint: HostEndpoint,
|
endpoint: HostEndpoint,
|
||||||
http: HttpTransport,
|
http: HttpTransport,
|
||||||
ws: TermTransport,
|
ws: TermTransport,
|
||||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
|
||||||
): PairingProbeResult =
|
): PairingProbeResult =
|
||||||
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens)
|
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
|
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
|
||||||
@@ -66,10 +63,9 @@ internal suspend fun runPairingProbeCore(
|
|||||||
http: HttpTransport,
|
http: HttpTransport,
|
||||||
ws: TermTransport,
|
ws: TermTransport,
|
||||||
timeout: Duration?,
|
timeout: Duration?,
|
||||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
|
||||||
): PairingProbeResult {
|
): PairingProbeResult {
|
||||||
if (timeout == null) return performProbe(endpoint, http, ws, tokens)
|
if (timeout == null) return performProbe(endpoint, http, ws)
|
||||||
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
|
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
|
||||||
?: PairingProbeResult.Failure(PairingError.Timeout)
|
?: PairingProbeResult.Failure(PairingError.Timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,16 +75,10 @@ private suspend fun performProbe(
|
|||||||
endpoint: HostEndpoint,
|
endpoint: HostEndpoint,
|
||||||
http: HttpTransport,
|
http: HttpTransport,
|
||||||
ws: TermTransport,
|
ws: TermTransport,
|
||||||
tokens: AccessTokenSource,
|
|
||||||
): PairingProbeResult {
|
): PairingProbeResult {
|
||||||
// The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets
|
|
||||||
// it from the transport's own AccessTokenSource (the same store), so all three legs authenticate.
|
|
||||||
val cookieHeaders = authHeaders(tokens.tokenFor(endpoint))
|
|
||||||
|
|
||||||
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
|
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
|
||||||
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
|
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
|
||||||
// wants an access token we do not have.
|
probeReachability(endpoint, http)?.let { return it }
|
||||||
probeReachability(endpoint, http, cookieHeaders)?.let { return it }
|
|
||||||
|
|
||||||
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
|
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
|
||||||
// unrecognizable connect failure is classified as originRejected.
|
// unrecognizable connect failure is classified as originRejected.
|
||||||
@@ -115,7 +105,7 @@ private suspend fun performProbe(
|
|||||||
return try {
|
return try {
|
||||||
when (val adoption = adoptAttachedSession(connection)) {
|
when (val adoption = adoptAttachedSession(connection)) {
|
||||||
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
|
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
|
||||||
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders)
|
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
withContext(NonCancellable) { runCatching { connection.close() } }
|
withContext(NonCancellable) { runCatching { connection.close() } }
|
||||||
@@ -130,20 +120,14 @@ private suspend fun performProbe(
|
|||||||
private suspend fun probeReachability(
|
private suspend fun probeReachability(
|
||||||
endpoint: HostEndpoint,
|
endpoint: HostEndpoint,
|
||||||
http: HttpTransport,
|
http: HttpTransport,
|
||||||
authHeaders: Map<String, String>,
|
|
||||||
): PairingProbeResult.Failure? {
|
): PairingProbeResult.Failure? {
|
||||||
val response = try {
|
val response = try {
|
||||||
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders))
|
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
|
||||||
} catch (cancel: CancellationException) {
|
} catch (cancel: CancellationException) {
|
||||||
throw cancel
|
throw cancel
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
||||||
}
|
}
|
||||||
// Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and
|
|
||||||
// reporting "端口对吗?" for a token problem would send the user chasing the wrong thing.
|
|
||||||
if (response.status == HTTP_UNAUTHORIZED) {
|
|
||||||
return PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
|
||||||
}
|
|
||||||
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
|
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
|
||||||
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||||
}
|
}
|
||||||
@@ -178,14 +162,13 @@ private suspend fun killProbeSession(
|
|||||||
sessionId: String,
|
sessionId: String,
|
||||||
endpoint: HostEndpoint,
|
endpoint: HostEndpoint,
|
||||||
http: HttpTransport,
|
http: HttpTransport,
|
||||||
authHeaders: Map<String, String>,
|
|
||||||
): PairingProbeResult {
|
): PairingProbeResult {
|
||||||
val response = try {
|
val response = try {
|
||||||
http.send(
|
http.send(
|
||||||
HttpRequest(
|
HttpRequest(
|
||||||
method = HttpMethod.DELETE,
|
method = HttpMethod.DELETE,
|
||||||
url = killUrl(endpoint, sessionId),
|
url = killUrl(endpoint, sessionId),
|
||||||
headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
|
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} catch (cancel: CancellationException) {
|
} catch (cancel: CancellationException) {
|
||||||
@@ -195,7 +178,6 @@ private suspend fun killProbeSession(
|
|||||||
}
|
}
|
||||||
return when (response.status) {
|
return when (response.status) {
|
||||||
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
|
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
|
||||||
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
|
||||||
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
|
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
|
||||||
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
|
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
|
||||||
)
|
)
|
||||||
@@ -237,17 +219,9 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String {
|
|||||||
return "$scheme://$host$portPart"
|
return "$scheme://$host$portPart"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `Cookie: webterm_auth=<t>` for the probe's HTTP legs, or NO header when the host has no token —
|
|
||||||
* derived from the single point ([AuthCookie]), never hand-assembled.
|
|
||||||
*/
|
|
||||||
private fun authHeaders(token: String?): Map<String, String> =
|
|
||||||
if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token))
|
|
||||||
|
|
||||||
private const val LIVE_SESSIONS_PATH = "/live-sessions"
|
private const val LIVE_SESSIONS_PATH = "/live-sessions"
|
||||||
private const val ORIGIN_HEADER = "Origin"
|
private const val ORIGIN_HEADER = "Origin"
|
||||||
private const val HTTP_OK = 200
|
private const val HTTP_OK = 200
|
||||||
private const val HTTP_UNAUTHORIZED = 401
|
|
||||||
private const val HTTP_NO_CONTENT = 204
|
private const val HTTP_NO_CONTENT = 204
|
||||||
private const val HTTP_FORBIDDEN = 403
|
private const val HTTP_FORBIDDEN = 403
|
||||||
private const val HTTP_NOT_FOUND = 404
|
private const val HTTP_NOT_FOUND = 404
|
||||||
|
|||||||
@@ -1,30 +1,13 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
import wang.yaojia.webterm.api.models.CommitResult
|
|
||||||
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
|
||||||
import wang.yaojia.webterm.api.models.GitLogResult
|
|
||||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
|
||||||
import wang.yaojia.webterm.api.models.HookDecision
|
import wang.yaojia.webterm.api.models.HookDecision
|
||||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||||
import wang.yaojia.webterm.api.models.LossyDecode
|
import wang.yaojia.webterm.api.models.LossyDecode
|
||||||
import wang.yaojia.webterm.api.models.PrStatus
|
|
||||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||||
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
|
||||||
import wang.yaojia.webterm.api.models.QueueSnapshot
|
|
||||||
import wang.yaojia.webterm.api.models.QueueWriteOutcome
|
|
||||||
import wang.yaojia.webterm.api.models.decodeQueueDepth
|
|
||||||
import wang.yaojia.webterm.api.models.FetchResult
|
|
||||||
import wang.yaojia.webterm.api.models.PushResult
|
|
||||||
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
|
||||||
import wang.yaojia.webterm.api.models.SessionPreview
|
import wang.yaojia.webterm.api.models.SessionPreview
|
||||||
import wang.yaojia.webterm.api.models.StageResult
|
|
||||||
import wang.yaojia.webterm.api.models.UiConfig
|
import wang.yaojia.webterm.api.models.UiConfig
|
||||||
import wang.yaojia.webterm.api.models.UiPrefs
|
import wang.yaojia.webterm.api.models.UiPrefs
|
||||||
import wang.yaojia.webterm.api.models.WorktreeState
|
|
||||||
import wang.yaojia.webterm.api.models.decodeGitError
|
|
||||||
import wang.yaojia.webterm.api.models.decodeGitPayload
|
|
||||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpResponse
|
import wang.yaojia.webterm.wire.HttpResponse
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
@@ -42,18 +25,10 @@ import java.util.UUID
|
|||||||
*
|
*
|
||||||
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
|
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
|
||||||
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
|
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
|
||||||
*
|
|
||||||
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
|
|
||||||
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
|
|
||||||
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
|
|
||||||
* an unauthenticated LAN host behaves exactly as before. A **401** becomes the typed
|
|
||||||
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token — except on the
|
|
||||||
* routes that define their own 401 ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write family).
|
|
||||||
*/
|
*/
|
||||||
public class ApiClient(
|
public class ApiClient(
|
||||||
public val endpoint: HostEndpoint,
|
public val endpoint: HostEndpoint,
|
||||||
private val http: HttpTransport,
|
private val http: HttpTransport,
|
||||||
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
|
||||||
) {
|
) {
|
||||||
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -112,56 +87,6 @@ public class ApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /projects/pr?path=` — PR + CI status for the project's current branch. The PR *degrade*
|
|
||||||
* (gh missing / unauth / no-PR / disabled) is `availability` inside a **200** body, NOT an HTTP
|
|
||||||
* status — so every valid git dir returns 200 and the chip renders from [PrStatus.availability].
|
|
||||||
* A garbled body degrades to `availability=ERROR` (tolerant decode). 400→path invalid; 404→not a
|
|
||||||
* repo. Empty path rejected client-side before any I/O.
|
|
||||||
*/
|
|
||||||
public suspend fun projectPr(path: String): PrStatus {
|
|
||||||
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
|
||||||
val response = perform(Endpoints.projectPr(path))
|
|
||||||
return when (response.status) {
|
|
||||||
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, PrStatus.serializer())
|
|
||||||
?: PrStatus() // availability defaults to ERROR — never throw on a bad PR body
|
|
||||||
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
|
||||||
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
|
||||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /projects/log?path=[&n=]` — recent commits (list-lossy: malformed commits dropped). 400→
|
|
||||||
* path invalid; 404→not a repo; 500→[ApiClientError.GitLogUnavailable]. Empty path rejected
|
|
||||||
* client-side before any I/O; `n` is clamped in the route builder.
|
|
||||||
*/
|
|
||||||
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult {
|
|
||||||
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
|
||||||
val response = perform(Endpoints.projectLog(path, n))
|
|
||||||
return when (response.status) {
|
|
||||||
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, GitLogResult.serializer())
|
|
||||||
?: throw ApiClientError.InvalidResponseBody
|
|
||||||
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
|
||||||
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.GitLogUnavailable
|
|
||||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /live-sessions/:id/queue` (W2) — the pending prompt queue: depth + the queued texts,
|
|
||||||
* verbatim. READ-ONLY, so no Origin (the server guards only the two writes). 404 → the session is
|
|
||||||
* gone; a non-object body → [ApiClientError.InvalidResponseBody] rather than a fake empty queue
|
|
||||||
* (claiming "nothing queued" when entries exist would mislead a cancel-all decision).
|
|
||||||
*/
|
|
||||||
public suspend fun queue(id: UUID): QueueSnapshot {
|
|
||||||
val response = perform(Endpoints.queue(id))
|
|
||||||
requireOk(response)
|
|
||||||
return LossyDecode.objectOrNull(response.body, QueueSnapshot.serializer())
|
|
||||||
?: throw ApiClientError.InvalidResponseBody
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
|
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
|
||||||
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
|
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
|
||||||
public suspend fun prefs(): UiPrefs {
|
public suspend fun prefs(): UiPrefs {
|
||||||
@@ -207,126 +132,6 @@ public class ApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── G: W2 prompt queue (enqueue / cancel-all) → QueueWriteOutcome ──────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /live-sessions/:id/queue` (W2) — queue a follow-up prompt the server injects into the PTY
|
|
||||||
* the next time Claude goes idle. Works with zero tabs attached, which is the whole point.
|
|
||||||
*
|
|
||||||
* [text] travels VERBATIM (invariant #9 — raw keyboard bytes for a shell); [appendEnter] is a flag
|
|
||||||
* the SERVER turns into a trailing `\r`, so callers must not append one. An empty prompt is
|
|
||||||
* rejected as [ApiClientError.QueueTextEmpty] before any network I/O; size is NOT pre-checked
|
|
||||||
* client-side (the byte cap is server config) — an over-cap prompt comes back as
|
|
||||||
* [QueueWriteOutcome.TooLarge].
|
|
||||||
*/
|
|
||||||
public suspend fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean = true): QueueWriteOutcome {
|
|
||||||
if (text.isEmpty()) throw ApiClientError.QueueTextEmpty
|
|
||||||
return queueWrite(Endpoints.enqueueFollowup(id, text, appendEnter))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `DELETE /live-sessions/:id/queue` (W2) — cancel every pending entry (escape hatch). 200 →
|
|
||||||
* [QueueWriteOutcome.Ok] with depth 0.
|
|
||||||
*/
|
|
||||||
public suspend fun clearQueue(id: UUID): QueueWriteOutcome = queueWrite(Endpoints.clearQueue(id))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared status mapping for the two guarded queue writes. Distinct typed outcomes because each one
|
|
||||||
* demands different UI behaviour (see [QueueWriteOutcome]); in particular a 429 from the shared
|
|
||||||
* `QUEUE_RATE_MAX = 20`/min/IP bucket is surfaced, NEVER auto-retried. Only 409/413/503 are
|
|
||||||
* POST-specific — the DELETE handler cannot produce them.
|
|
||||||
*/
|
|
||||||
private suspend fun queueWrite(route: ApiRoute): QueueWriteOutcome {
|
|
||||||
val response = perform(route)
|
|
||||||
return when (response.status) {
|
|
||||||
HttpStatus.OK -> QueueWriteOutcome.Ok(decodeQueueDepth(response.body))
|
|
||||||
HttpStatus.CONFLICT -> QueueWriteOutcome.Full
|
|
||||||
HttpStatus.PAYLOAD_TOO_LARGE -> QueueWriteOutcome.TooLarge
|
|
||||||
HttpStatus.SERVICE_UNAVAILABLE -> QueueWriteOutcome.Disabled
|
|
||||||
HttpStatus.NOT_FOUND -> QueueWriteOutcome.SessionGone
|
|
||||||
HttpStatus.TOO_MANY_REQUESTS -> QueueWriteOutcome.RateLimited
|
|
||||||
// `decodeGitError` reads the generic `{ error }` failure shape the queue routes share with
|
|
||||||
// the git ones (400/403 here) — reused rather than duplicated.
|
|
||||||
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
|
|
||||||
QueueWriteOutcome.Rejected(response.status, decodeGitError(response.body))
|
|
||||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
|
|
||||||
|
|
||||||
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
|
|
||||||
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
|
|
||||||
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
|
|
||||||
|
|
||||||
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
|
|
||||||
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
|
|
||||||
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
|
|
||||||
|
|
||||||
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
|
|
||||||
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
|
|
||||||
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
|
|
||||||
|
|
||||||
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
|
|
||||||
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
|
|
||||||
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
|
|
||||||
|
|
||||||
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
|
|
||||||
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
|
|
||||||
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
|
|
||||||
|
|
||||||
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
|
|
||||||
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
|
|
||||||
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /projects/git/fetch` (w6/G2) — refresh `refs/remotes` so `behind` becomes trustworthy.
|
|
||||||
*
|
|
||||||
* Shares the guarded-write dispatch, so a disabled kill-switch or an Origin failure both surface as
|
|
||||||
* [GitWriteOutcome.Rejected] with the server's safe message. Fetch has its OWN rate-limit bucket on
|
|
||||||
* the server precisely so refreshes cannot eat the budget a real push needs — surface a 429 rather
|
|
||||||
* than retrying.
|
|
||||||
*/
|
|
||||||
public suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> =
|
|
||||||
gitWrite(Endpoints.gitFetch(path), FetchResult.serializer())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /projects/worktree/state?path=` (w6/G7) — the per-row git state of ONE worktree.
|
|
||||||
*
|
|
||||||
* Mapped like the other project reads. A malformed body throws rather than degrading to an empty
|
|
||||||
* state: a row that silently claims "clean, in sync" would be a lie, and the caller is expected to
|
|
||||||
* isolate this failure to its own row (the panel must still render).
|
|
||||||
*/
|
|
||||||
public suspend fun worktreeState(path: String): WorktreeState {
|
|
||||||
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
|
||||||
val response = perform(Endpoints.worktreeState(path))
|
|
||||||
return when (response.status) {
|
|
||||||
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, WorktreeState.serializer())
|
|
||||||
?: throw ApiClientError.InvalidResponseBody
|
|
||||||
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
|
||||||
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
|
||||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
|
|
||||||
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
|
|
||||||
* carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
|
|
||||||
* kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
|
|
||||||
* (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
|
|
||||||
*/
|
|
||||||
private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
|
|
||||||
val response = perform(route)
|
|
||||||
return when (response.status) {
|
|
||||||
HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
|
|
||||||
HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
|
|
||||||
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
|
|
||||||
GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
|
|
||||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
|
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
|
||||||
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
|
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
|
||||||
public suspend fun registerFcmToken(token: String) {
|
public suspend fun registerFcmToken(token: String) {
|
||||||
@@ -352,27 +157,9 @@ public class ApiClient(
|
|||||||
|
|
||||||
// ── Internals ──────────────────────────────────────────────────────────────────────────
|
// ── Internals ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
|
|
||||||
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
|
|
||||||
* status mapping — otherwise a 401 on a read route would degrade into a generic status error and
|
|
||||||
* the UI would never learn to ask for the token (ios-completion §1.1).
|
|
||||||
*
|
|
||||||
* The one exception is declared AT the route ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write
|
|
||||||
* family): there a 401 is the server classifying a HOST-side git credential failure
|
|
||||||
* (`src/http/git-ops.ts:108`), so it must flow on to [gitWrite]'s mapping and reach the user as the
|
|
||||||
* server's own message. Swallowing it would tell the user to rotate an access token that is fine.
|
|
||||||
*/
|
|
||||||
private suspend fun perform(route: ApiRoute): HttpResponse {
|
private suspend fun perform(route: ApiRoute): HttpResponse {
|
||||||
val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
|
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
|
||||||
?: throw ApiClientError.InvalidRequest
|
return http.send(request)
|
||||||
val response = http.send(request)
|
|
||||||
if (response.status == HttpStatus.UNAUTHORIZED &&
|
|
||||||
route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE
|
|
||||||
) {
|
|
||||||
throw ApiClientError.Unauthorized
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
|
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
|
||||||
|
|||||||
@@ -20,15 +20,6 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
|
|||||||
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
|
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
|
||||||
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
|
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
|
||||||
|
|
||||||
/**
|
|
||||||
* **401** from a route whose 401 can only be the access-token gate (RO or guarded): this host has
|
|
||||||
* `WEBTERM_TOKEN` set and our request carried no cookie / a wrong one (ios-completion §1.1).
|
|
||||||
* Distinct from [Forbidden] (that is the Origin guard) — the UI must send the user to enter or fix
|
|
||||||
* the host's access token. The git-write family is excluded (it defines its own 401 — see
|
|
||||||
* [UnauthorizedPolicy]), so this copy can never be shown for a host-side git credential failure.
|
|
||||||
*/
|
|
||||||
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
|
|
||||||
|
|
||||||
/** 403 from a G route's Origin guard (CSWSH defence). */
|
/** 403 from a G route's Origin guard (CSWSH defence). */
|
||||||
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
||||||
|
|
||||||
@@ -53,14 +44,6 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
|
|||||||
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
|
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
|
||||||
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
|
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
|
||||||
|
|
||||||
/** An empty follow-up prompt was passed to `POST /live-sessions/:id/queue` (which the server 400s
|
|
||||||
* as `text must be a non-empty string`). Raised client-side, before any network I/O — mirrors the
|
|
||||||
* [ProjectPathInvalid] precedent. */
|
|
||||||
public data object QueueTextEmpty : ApiClientError("要排队的内容为空——请先输入要发送的提示词。")
|
|
||||||
|
|
||||||
/** 500 from `GET /projects/log` — the server failed reading the git log. */
|
|
||||||
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
|
|
||||||
|
|
||||||
/** Any other non-success status code. */
|
/** Any other non-success status code. */
|
||||||
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status。")
|
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status。")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
import wang.yaojia.webterm.wire.AuthCookie
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
@@ -11,30 +10,16 @@ internal object HttpStatus {
|
|||||||
const val OK = 200
|
const val OK = 200
|
||||||
const val NO_CONTENT = 204
|
const val NO_CONTENT = 204
|
||||||
const val BAD_REQUEST = 400
|
const val BAD_REQUEST = 400
|
||||||
const val UNAUTHORIZED = 401
|
|
||||||
const val FORBIDDEN = 403
|
const val FORBIDDEN = 403
|
||||||
const val NOT_FOUND = 404
|
const val NOT_FOUND = 404
|
||||||
const val CONFLICT = 409
|
|
||||||
const val PAYLOAD_TOO_LARGE = 413
|
|
||||||
const val TOO_MANY_REQUESTS = 429
|
const val TOO_MANY_REQUESTS = 429
|
||||||
const val INTERNAL_SERVER_ERROR = 500
|
const val INTERNAL_SERVER_ERROR = 500
|
||||||
const val SERVICE_UNAVAILABLE = 503
|
|
||||||
|
|
||||||
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
|
|
||||||
const val CLIENT_ERROR_MIN = 400
|
|
||||||
const val SERVER_ERROR_MAX = 599
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Header / content-type names (no magic strings inline). */
|
/** Header / content-type names (no magic strings inline). */
|
||||||
internal object HeaderName {
|
internal object HeaderName {
|
||||||
const val ORIGIN = "Origin"
|
const val ORIGIN = "Origin"
|
||||||
const val CONTENT_TYPE = "Content-Type"
|
const val CONTENT_TYPE = "Content-Type"
|
||||||
|
|
||||||
/**
|
|
||||||
* Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser
|
|
||||||
* form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`).
|
|
||||||
*/
|
|
||||||
const val ACCEPT = "Accept"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object ContentType {
|
internal object ContentType {
|
||||||
@@ -55,23 +40,6 @@ internal enum class OriginPolicy {
|
|||||||
GUARDED,
|
GUARDED,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How a **401** on this route must be READ (ios-completion §1.1) — declared at the route so the
|
|
||||||
* exception is visible instead of hidden in a call site. The Android analogue of iOS
|
|
||||||
* `UnauthorizedPolicy` (APIClient/Endpoints.swift).
|
|
||||||
*/
|
|
||||||
internal enum class UnauthorizedPolicy {
|
|
||||||
/** Default — on this route a 401 can only be the access-token gate (`src/server.ts:465`). */
|
|
||||||
ACCESS_TOKEN_GATE,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The route owns its 401 and it must NOT be read as "your access token is wrong": the git-write
|
|
||||||
* family, where the server classifies a HOST-side git credential failure as
|
|
||||||
* `{status:401, error:"Push authentication required on the host."}` (`src/http/git-ops.ts:108`).
|
|
||||||
*/
|
|
||||||
ROUTE_DEFINED,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
|
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
|
||||||
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
|
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
|
||||||
@@ -83,37 +51,19 @@ internal class ApiRoute(
|
|||||||
val originPolicy: OriginPolicy,
|
val originPolicy: OriginPolicy,
|
||||||
val body: ByteArray? = null,
|
val body: ByteArray? = null,
|
||||||
val percentEncodedQuery: String? = null,
|
val percentEncodedQuery: String? = null,
|
||||||
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
|
|
||||||
val accept: String? = null,
|
|
||||||
/** See [UnauthorizedPolicy]. Defaults to the gate reading. */
|
|
||||||
val unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
|
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
|
||||||
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
|
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
|
||||||
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
|
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
|
||||||
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
|
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
|
||||||
*
|
|
||||||
* Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1):
|
|
||||||
* - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged);
|
|
||||||
* - `Cookie: webterm_auth=<t>` whenever [accessToken] is non-null — on EVERY route, read-only
|
|
||||||
* ones included, because the server's access-token gate sits in front of the whole app. A null
|
|
||||||
* token adds no header at all, keeping an unauthenticated host byte-identical to before.
|
|
||||||
* The token is secret material: it is only ever placed in this header — never in [path], never in
|
|
||||||
* [percentEncodedQuery], never logged.
|
|
||||||
*/
|
*/
|
||||||
fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? {
|
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
|
||||||
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
|
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
|
||||||
val headers = LinkedHashMap<String, String>()
|
val headers = LinkedHashMap<String, String>()
|
||||||
if (originPolicy == OriginPolicy.GUARDED) {
|
if (originPolicy == OriginPolicy.GUARDED) {
|
||||||
headers[HeaderName.ORIGIN] = endpoint.originHeader
|
headers[HeaderName.ORIGIN] = endpoint.originHeader
|
||||||
}
|
}
|
||||||
if (accessToken != null) {
|
|
||||||
headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken)
|
|
||||||
}
|
|
||||||
if (accept != null) {
|
|
||||||
headers[HeaderName.ACCEPT] = accept
|
|
||||||
}
|
|
||||||
if (body != null) {
|
if (body != null) {
|
||||||
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
|
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import java.util.UUID
|
|||||||
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
|
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
|
||||||
* pair (this client uses FCM, not APNs/VAPID).
|
* pair (this client uses FCM, not APNs/VAPID).
|
||||||
*
|
*
|
||||||
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue`
|
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
|
||||||
* · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
|
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
|
||||||
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue`
|
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
|
||||||
* · `POST /hook/decision` · `PUT /prefs` · `POST|DELETE /push/fcm-token`
|
* · `POST|DELETE /push/fcm-token`
|
||||||
*
|
*
|
||||||
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
|
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
|
||||||
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
|
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
|
||||||
@@ -54,51 +54,9 @@ internal object Endpoints {
|
|||||||
percentEncodedQuery = "path=${percentEncode(path)}",
|
percentEncodedQuery = "path=${percentEncode(path)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /projects/worktree/state?path=` — RO (w6/G7). Deliberately NARROWER than
|
|
||||||
* `/projects/detail`: it is fetched once per worktree row, so N rows must not each pay for a
|
|
||||||
* worktree listing and a CLAUDE.md read that nothing renders.
|
|
||||||
*/
|
|
||||||
fun worktreeState(path: String): ApiRoute =
|
|
||||||
ApiRoute(
|
|
||||||
HttpMethod.GET,
|
|
||||||
"/projects/worktree/state",
|
|
||||||
OriginPolicy.READ_ONLY,
|
|
||||||
percentEncodedQuery = "path=${percentEncode(path)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
fun getPrefs(): ApiRoute =
|
fun getPrefs(): ApiRoute =
|
||||||
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /live-sessions/:id/queue` — RO (W2). Same threat model as `GET /live-sessions`, so the
|
|
||||||
* server stamps no Origin guard on it and neither may we.
|
|
||||||
*/
|
|
||||||
fun queue(id: UUID): ApiRoute =
|
|
||||||
ApiRoute(HttpMethod.GET, queuePath(id), OriginPolicy.READ_ONLY)
|
|
||||||
|
|
||||||
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
|
|
||||||
fun projectPr(path: String): ApiRoute =
|
|
||||||
ApiRoute(
|
|
||||||
HttpMethod.GET,
|
|
||||||
"/projects/pr",
|
|
||||||
OriginPolicy.READ_ONLY,
|
|
||||||
percentEncodedQuery = "path=${percentEncode(path)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
|
|
||||||
* `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
|
|
||||||
*/
|
|
||||||
fun projectLog(path: String, n: Int?): ApiRoute {
|
|
||||||
val query = StringBuilder("path=").append(percentEncode(path))
|
|
||||||
if (n != null) {
|
|
||||||
val clamped = n.coerceIn(1, GIT_LOG_MAX)
|
|
||||||
query.append("&n=").append(clamped)
|
|
||||||
}
|
|
||||||
return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── G ────────────────────────────────────────────────────────────────────────────────
|
// ── G ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun killSession(id: UUID): ApiRoute =
|
fun killSession(id: UUID): ApiRoute =
|
||||||
@@ -134,162 +92,6 @@ internal object Endpoints {
|
|||||||
|
|
||||||
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
||||||
|
|
||||||
// ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ───────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /auth` — the pairing-time token-validation probe. Body `{"token":"<t>"}`.
|
|
||||||
*
|
|
||||||
* Two non-obvious requirements, both server-driven (`src/server.ts`):
|
|
||||||
* - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request
|
|
||||||
* is treated as a browser form and answered with a **302** redirect instead of 204/401.
|
|
||||||
* - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no
|
|
||||||
* auth cookie (the token is in the body — this IS the login).
|
|
||||||
*
|
|
||||||
* Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not
|
|
||||||
* Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case.
|
|
||||||
*/
|
|
||||||
fun auth(token: String): ApiRoute {
|
|
||||||
val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray()
|
|
||||||
return ApiRoute(
|
|
||||||
HttpMethod.POST,
|
|
||||||
AUTH_PATH,
|
|
||||||
OriginPolicy.GUARDED,
|
|
||||||
body = body,
|
|
||||||
accept = ContentType.JSON,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val AUTH_PATH = "/auth"
|
|
||||||
|
|
||||||
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Guarded writes, but NOT [UnauthorizedPolicy.ROUTE_DEFINED] (F5): these land in
|
|
||||||
// `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500), and
|
|
||||||
// `src/server.ts:1182-1260` adds none. The only 401 they can ever see is the access-token gate, so
|
|
||||||
// they take the DEFAULT gate reading — pinning them route-defined made a gated host's 401 surface
|
|
||||||
// as a worktree failure instead of the token flow.
|
|
||||||
|
|
||||||
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
|
|
||||||
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
|
|
||||||
jsonBodyRoute(
|
|
||||||
HttpMethod.POST,
|
|
||||||
"/projects/worktree",
|
|
||||||
CreateWorktreeBody.serializer(),
|
|
||||||
CreateWorktreeBody(path, branch, base),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
|
|
||||||
fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
|
|
||||||
jsonBodyRoute(
|
|
||||||
HttpMethod.DELETE,
|
|
||||||
"/projects/worktree",
|
|
||||||
RemoveWorktreeBody.serializer(),
|
|
||||||
RemoveWorktreeBody(path, worktreePath, force),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `POST /projects/worktree/prune` — `{ path }`. */
|
|
||||||
fun pruneWorktrees(path: String): ApiRoute =
|
|
||||||
jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))
|
|
||||||
|
|
||||||
// ── G: git write (stage / commit / push) ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
|
|
||||||
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
|
|
||||||
gitWriteRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
|
|
||||||
|
|
||||||
/** `POST /projects/git/commit` — `{ path, message }`. */
|
|
||||||
fun gitCommit(path: String, message: String): ApiRoute =
|
|
||||||
gitWriteRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates
|
|
||||||
* `refs/remotes`: it is state-changing and spawns a network git process, so it goes through the
|
|
||||||
* single Origin-stamping point like every other write. The remote is derived SERVER-side and no
|
|
||||||
* remote or refspec is ever read from the body, so a client cannot aim it at an arbitrary URL.
|
|
||||||
* It is NOT a pull: no working tree, no index, no merge.
|
|
||||||
*/
|
|
||||||
fun gitFetch(path: String): ApiRoute =
|
|
||||||
gitWriteRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
|
|
||||||
|
|
||||||
/** `POST /projects/git/push` — `{ path }`. */
|
|
||||||
fun gitPush(path: String): ApiRoute =
|
|
||||||
gitWriteRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
|
||||||
|
|
||||||
// ── G: W2 prompt queue (enqueue / cancel-all) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /live-sessions/:id/queue` — G. Body is exactly `{ text, appendEnter }` (express.json,
|
|
||||||
* 16 kb limit).
|
|
||||||
*
|
|
||||||
* [text] is RAW KEYBOARD INPUT for a shell and is carried VERBATIM (invariant #9): JSON string
|
|
||||||
* encoding is the only transformation, and it round-trips byte-exactly through `express.json`.
|
|
||||||
* Never trim/escape/normalise it here. [appendEnter] is a FLAG — the server materialises the
|
|
||||||
* trailing `\r` so the stored entry is byte-identical to a keystroke; the client must not append
|
|
||||||
* one itself.
|
|
||||||
*/
|
|
||||||
fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean): ApiRoute =
|
|
||||||
jsonBodyRoute(
|
|
||||||
HttpMethod.POST,
|
|
||||||
queuePath(id),
|
|
||||||
EnqueueBody.serializer(),
|
|
||||||
EnqueueBody(text, appendEnter),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `DELETE /live-sessions/:id/queue` — G, cancel-all escape hatch. Carries **no** body: unlike
|
|
||||||
* `DELETE /projects/worktree` (the body-carrying DELETE precedent), this handler reads only `:id`
|
|
||||||
* and clears the whole FIFO, so a body would be dead weight the server never parses.
|
|
||||||
*/
|
|
||||||
fun clearQueue(id: UUID): ApiRoute =
|
|
||||||
ApiRoute(HttpMethod.DELETE, queuePath(id), OriginPolicy.GUARDED)
|
|
||||||
|
|
||||||
private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]).
|
|
||||||
*
|
|
||||||
* The 401 reading defaults to [UnauthorizedPolicy.ACCESS_TOKEN_GATE] — correct for every route
|
|
||||||
* whose handler never writes a 401 of its own (the W2 queue: 400/404/429/503 only; the worktree
|
|
||||||
* trio: 403/400/404/500 only). Only the git-ops writes opt out, via [gitWriteRoute].
|
|
||||||
*/
|
|
||||||
private fun <T> jsonBodyRoute(
|
|
||||||
method: HttpMethod,
|
|
||||||
path: String,
|
|
||||||
serializer: kotlinx.serialization.KSerializer<T>,
|
|
||||||
value: T,
|
|
||||||
unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
|
||||||
): ApiRoute {
|
|
||||||
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
|
|
||||||
return ApiRoute(
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
OriginPolicy.GUARDED,
|
|
||||||
body = body,
|
|
||||||
unauthorizedPolicy = unauthorizedPolicy,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a GUARDED **git-ops** route with a `ModelJson`-encoded JSON body (Origin stamped in
|
|
||||||
* [ApiRoute]).
|
|
||||||
*
|
|
||||||
* Every route built here is [UnauthorizedPolicy.ROUTE_DEFINED]: the server classifies a HOST-side
|
|
||||||
* git credential failure as **401** with its own message (`src/http/git-ops.ts:108`), which the UI
|
|
||||||
* must display verbatim instead of the access-token copy (mirrors iOS `gitOpsRoute`).
|
|
||||||
*
|
|
||||||
* Scope is exactly the four routes that reach that classifier — `stage`, `commit`, `push`, `fetch`.
|
|
||||||
* The worktree trio must NOT be built here (F5): its handler has no 401 of its own.
|
|
||||||
*/
|
|
||||||
private fun <T> gitWriteRoute(
|
|
||||||
method: HttpMethod,
|
|
||||||
path: String,
|
|
||||||
serializer: kotlinx.serialization.KSerializer<T>,
|
|
||||||
value: T,
|
|
||||||
): ApiRoute = jsonBodyRoute(method, path, serializer, value, UnauthorizedPolicy.ROUTE_DEFINED)
|
|
||||||
|
|
||||||
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
|
|
||||||
private const val GIT_LOG_MAX = 50
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
|
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
|
||||||
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
|
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
|
||||||
@@ -322,32 +124,4 @@ internal object Endpoints {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class FcmTokenBody(val token: String)
|
private data class FcmTokenBody(val token: String)
|
||||||
|
|
||||||
/** `POST /auth` body. Holds secret material — never log a request built from it. */
|
|
||||||
@Serializable
|
|
||||||
private data class AuthBody(val token: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class PruneBody(val path: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class CommitBody(val path: String, val message: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class PushBody(val path: String)
|
|
||||||
|
|
||||||
@kotlinx.serialization.Serializable
|
|
||||||
private data class FetchBody(val path: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class EnqueueBody(val text: String, val appendEnter: Boolean)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.enroll
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.security.KeyPairGenerator
|
|
||||||
import java.security.Signature
|
|
||||||
import java.security.interfaces.ECPublicKey
|
|
||||||
import java.security.spec.ECGenParameterSpec
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B4 · Proves the manual PKCS#10 encoder produces a well-formed, self-signed P-256 CSR that the
|
|
||||||
* control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1 SPKI, ecdsa-with-SHA256
|
|
||||||
* self-signature) accepts. Runs headless with a SOFTWARE P-256 key via the SAME
|
|
||||||
* `Signature("SHA256withECDSA")` path the on-device AndroidKeyStore key uses — so the signing path
|
|
||||||
* is byte-identical. Real StrongBox keygen is device-only (`:client-tls-android`).
|
|
||||||
*/
|
|
||||||
class CertificateSigningRequestTest {
|
|
||||||
/** Software P-256 signer via the SAME JCA `SHA256withECDSA` path used on-device (no StrongBox). */
|
|
||||||
private class SoftwareEcSigner : CsrSigner {
|
|
||||||
val keyPair = KeyPairGenerator.getInstance("EC").apply {
|
|
||||||
initialize(ECGenParameterSpec("secp256r1"))
|
|
||||||
}.generateKeyPair()
|
|
||||||
|
|
||||||
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(keyPair.public as ECPublicKey)
|
|
||||||
|
|
||||||
override fun sign(message: ByteArray): ByteArray =
|
|
||||||
Signature.getInstance("SHA256withECDSA").apply {
|
|
||||||
initSign(keyPair.private)
|
|
||||||
update(message)
|
|
||||||
}.sign()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun csrIsCanonicalPkcs10SequenceOfExactlyThreeElements() {
|
|
||||||
val signer = SoftwareEcSigner()
|
|
||||||
|
|
||||||
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
|
||||||
|
|
||||||
val outer = TestDer.read(der, 0)!!
|
|
||||||
assertEquals(0x30, outer.tag, "outer CertificationRequest is a SEQUENCE")
|
|
||||||
assertEquals(der.size, outer.end, "no trailing garbage after the CSR")
|
|
||||||
val parts = TestDer.children(der, outer)
|
|
||||||
assertEquals(3, parts.size)
|
|
||||||
assertEquals(0x30, parts[0].tag) // certificationRequestInfo
|
|
||||||
assertEquals(0x30, parts[1].tag) // signatureAlgorithm
|
|
||||||
assertEquals(0x03, parts[2].tag) // signature BIT STRING
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun csrSelfSignatureVerifiesAgainstTheEmbeddedP256Key() {
|
|
||||||
val signer = SoftwareEcSigner()
|
|
||||||
|
|
||||||
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
|
||||||
|
|
||||||
// Extract the exact CertificationRequestInfo bytes that were signed and the ECDSA signature
|
|
||||||
// (the same crypto check verifyCsrPoPEc's req.verify() runs).
|
|
||||||
val outer = TestDer.read(der, 0)!!
|
|
||||||
val parts = TestDer.children(der, outer)
|
|
||||||
val infoBytes = der.copyOfRange(parts[0].start, parts[0].end)
|
|
||||||
val bitString = parts[2] // BIT STRING: first content byte is unused-bits (0x00)
|
|
||||||
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
|
|
||||||
|
|
||||||
val ok = Signature.getInstance("SHA256withECDSA").apply {
|
|
||||||
initVerify(signer.keyPair.public)
|
|
||||||
update(infoBytes)
|
|
||||||
}.verify(signature)
|
|
||||||
assertTrue(ok, "the CSR self-signature must verify against its own SubjectPublicKeyInfo")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun csrEmbedsAP256SubjectPublicKeyInfoTheServerVerifierAccepts() {
|
|
||||||
val signer = SoftwareEcSigner()
|
|
||||||
val point = signer.publicKeyX963()
|
|
||||||
|
|
||||||
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
|
||||||
|
|
||||||
val outer = TestDer.read(der, 0)!!
|
|
||||||
val info = TestDer.children(der, outer)[0]
|
|
||||||
val infoChildren = TestDer.children(der, info)
|
|
||||||
assertEquals(4, infoChildren.size)
|
|
||||||
// version v1(0)
|
|
||||||
assertArrayEquals(byteArrayOf(0x02, 0x01, 0x00), der.copyOfRange(infoChildren[0].start, infoChildren[0].end))
|
|
||||||
assertEquals(0xA0, infoChildren[3].tag) // [0] IMPLICIT attributes
|
|
||||||
assertEquals(0, infoChildren[3].valueEnd - infoChildren[3].valueStart) // empty SET
|
|
||||||
|
|
||||||
// subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point }
|
|
||||||
val spki = infoChildren[2]
|
|
||||||
val spkiChildren = TestDer.children(der, spki)
|
|
||||||
assertEquals(2, spkiChildren.size)
|
|
||||||
val algIdChildren = TestDer.children(der, spkiChildren[0])
|
|
||||||
// AlgorithmIdentifier { id-ecPublicKey, prime256v1 } — the exact OIDs verifyCsrPoPEc pins.
|
|
||||||
assertArrayEquals(
|
|
||||||
byteArrayOf(0x06, 0x07, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01),
|
|
||||||
der.copyOfRange(algIdChildren[0].start, algIdChildren[0].end),
|
|
||||||
)
|
|
||||||
assertArrayEquals(
|
|
||||||
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07),
|
|
||||||
der.copyOfRange(algIdChildren[1].start, algIdChildren[1].end),
|
|
||||||
)
|
|
||||||
// BIT STRING content = 0x00 unused-bits + the exact 65-byte point.
|
|
||||||
val bitString = spkiChildren[1]
|
|
||||||
assertEquals(0x03, bitString.tag)
|
|
||||||
assertEquals(0x00, der[bitString.valueStart].toInt() and 0xFF)
|
|
||||||
assertArrayEquals(point, der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun signatureAlgorithmIsEcdsaWithSha256() {
|
|
||||||
val signer = SoftwareEcSigner()
|
|
||||||
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
|
||||||
val outer = TestDer.read(der, 0)!!
|
|
||||||
val algId = TestDer.children(der, outer)[1]
|
|
||||||
val oid = TestDer.children(der, algId)[0]
|
|
||||||
assertArrayEquals(
|
|
||||||
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02),
|
|
||||||
der.copyOfRange(oid.start, oid.end),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun subjectCommonNameIsEncodedAsUtf8String() {
|
|
||||||
val signer = SoftwareEcSigner()
|
|
||||||
val der = CertificateSigningRequest.der("my-pixel", signer)
|
|
||||||
val outer = TestDer.read(der, 0)!!
|
|
||||||
val info = TestDer.children(der, outer)[0]
|
|
||||||
val name = TestDer.children(der, info)[1] // subject Name
|
|
||||||
val rdn = TestDer.children(der, name)[0] // SET
|
|
||||||
val attr = TestDer.children(der, rdn)[0] // SEQUENCE { OID, value }
|
|
||||||
val attrChildren = TestDer.children(der, attr)
|
|
||||||
// OID 2.5.4.3 (commonName), then a UTF8String (tag 0x0C) carrying the CN bytes.
|
|
||||||
assertArrayEquals(byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03), der.copyOfRange(attrChildren[0].start, attrChildren[0].end))
|
|
||||||
assertEquals(0x0C, attrChildren[1].tag)
|
|
||||||
assertArrayEquals("my-pixel".toByteArray(), der.copyOfRange(attrChildren[1].valueStart, attrChildren[1].valueEnd))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun emptySubjectCommonNameIsRejected() {
|
|
||||||
val signer = SoftwareEcSigner()
|
|
||||||
assertThrows(CsrException.InvalidSubject::class.java) {
|
|
||||||
CertificateSigningRequest.der("", signer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aNon65BytePublicKeyIsRejected() {
|
|
||||||
val badSigner = object : CsrSigner {
|
|
||||||
override fun publicKeyX963(): ByteArray = ByteArray(64) { 0x04 } // wrong length
|
|
||||||
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
|
|
||||||
}
|
|
||||||
assertThrows(CsrException.InvalidPublicKey::class.java) {
|
|
||||||
CertificateSigningRequest.der("d", badSigner)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aPublicKeyWithoutTheUncompressedPrefixIsRejected() {
|
|
||||||
val badSigner = object : CsrSigner {
|
|
||||||
override fun publicKeyX963(): ByteArray = ByteArray(65) { 0x02 } // right length, wrong prefix
|
|
||||||
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
|
|
||||||
}
|
|
||||||
assertThrows(CsrException.InvalidPublicKey::class.java) {
|
|
||||||
CertificateSigningRequest.der("d", badSigner)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A throwaway canonical-DER reader for structural assertions (the enroll path itself does no DER
|
|
||||||
* parsing — the server verifies; this mirrors the iOS `TestDER` test helper).
|
|
||||||
*/
|
|
||||||
internal object TestDer {
|
|
||||||
data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) {
|
|
||||||
val end: Int get() = valueEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
fun read(bytes: ByteArray, start: Int): Element? {
|
|
||||||
if (start < 0 || start + 1 >= bytes.size) return null
|
|
||||||
val tag = bytes[start].toInt() and 0xFF
|
|
||||||
var index = start + 1
|
|
||||||
val first = bytes[index].toInt() and 0xFF
|
|
||||||
index += 1
|
|
||||||
var length = 0
|
|
||||||
if (first and 0x80 == 0) {
|
|
||||||
length = first
|
|
||||||
} else {
|
|
||||||
val count = first and 0x7F
|
|
||||||
if (count == 0 || count > 4 || index + count > bytes.size) return null
|
|
||||||
repeat(count) {
|
|
||||||
length = (length shl 8) or (bytes[index].toInt() and 0xFF)
|
|
||||||
index += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val valueEnd = index + length
|
|
||||||
if (valueEnd > bytes.size) return null
|
|
||||||
return Element(tag = tag, start = start, valueStart = index, valueEnd = valueEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun children(bytes: ByteArray, parent: Element): List<Element> {
|
|
||||||
val elements = mutableListOf<Element>()
|
|
||||||
var index = parent.valueStart
|
|
||||||
while (index < parent.valueEnd) {
|
|
||||||
val element = read(bytes, index) ?: break
|
|
||||||
elements.add(element)
|
|
||||||
index = element.valueEnd
|
|
||||||
}
|
|
||||||
return elements
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.enroll
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.Base64
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B4 · DeviceEnrollmentClient request-building + response-mapping against the pinned login/enroll
|
|
||||||
* contract, driven by the shared `FakeHttpTransport` (no network). Mirrors the iOS
|
|
||||||
* `DeviceEnrollmentClientTests`, extended with the login step.
|
|
||||||
*/
|
|
||||||
class DeviceEnrollmentClientTest {
|
|
||||||
private companion object {
|
|
||||||
const val BASE = "https://cp.terminal.yaojia.wang"
|
|
||||||
const val BEARER = "device-enroll-token-abc"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val transport = FakeHttpTransport()
|
|
||||||
private val client = DeviceEnrollmentClient(BASE, transport)
|
|
||||||
|
|
||||||
private fun bodyObject(request: HttpRequest): JsonObject =
|
|
||||||
Json.parseToJsonElement(request.body!!.decodeToString()) as JsonObject
|
|
||||||
|
|
||||||
private fun enrollResponse(
|
|
||||||
deviceId: String = "dev-1",
|
|
||||||
cert: ByteArray = byteArrayOf(0x30, 0x01, 0x02),
|
|
||||||
caChain: List<ByteArray> = listOf(byteArrayOf(0x30, 0xAA.toByte())),
|
|
||||||
notBefore: String = "2026-07-08T00:00:00.000Z",
|
|
||||||
notAfter: String = "2026-10-06T00:00:00.000Z",
|
|
||||||
renewAfter: String = "2026-09-05T00:00:00.000Z",
|
|
||||||
): ByteArray {
|
|
||||||
val b64 = Base64.getEncoder()
|
|
||||||
val chainJson = caChain.joinToString(",") { "\"${b64.encodeToString(it)}\"" }
|
|
||||||
return """
|
|
||||||
{"deviceId":"$deviceId","cert":"${b64.encodeToString(cert)}","caChain":[$chainJson],
|
|
||||||
"notBefore":"$notBefore","notAfter":"$notAfter","renewAfter":"$renewAfter"}
|
|
||||||
""".trimIndent().toByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── login ────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loginPostsPasswordAndMapsThe201Bearer() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST,
|
|
||||||
url = "$BASE/auth/login",
|
|
||||||
status = 201,
|
|
||||||
body = """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = client.login("hunter2")
|
|
||||||
|
|
||||||
val request = transport.recordedRequests.single()
|
|
||||||
assertEquals(HttpMethod.POST, request.method)
|
|
||||||
assertEquals("$BASE/auth/login", request.url)
|
|
||||||
assertEquals("application/json", request.headers["Content-Type"])
|
|
||||||
assertNull(request.headers["Authorization"], "login carries no bearer")
|
|
||||||
assertEquals("hunter2", bodyObject(request)["password"]!!.jsonPrimitive.content)
|
|
||||||
|
|
||||||
assertEquals("tok-xyz", result.enrollToken)
|
|
||||||
assertEquals("acct-1", result.accountId)
|
|
||||||
assertEquals(600L, result.expiresInSeconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loginRejectsAnEmptyPasswordBeforeAnyNetworkIo() = runTest {
|
|
||||||
val error = runCatching { client.login("") }.exceptionOrNull()
|
|
||||||
assertEquals(DeviceEnrollmentError.InvalidRequest, error)
|
|
||||||
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty password")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loginSurfacesA401AsHttpWithTheServerCode() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST,
|
|
||||||
url = "$BASE/auth/login",
|
|
||||||
status = 401,
|
|
||||||
body = """{"error":"rejected"}""".toByteArray(),
|
|
||||||
)
|
|
||||||
val error = runCatching { client.login("wrong") }.exceptionOrNull()
|
|
||||||
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── enroll ───────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enrollBuildsABearerAuthenticatedPostWithTheContractBody() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse(),
|
|
||||||
)
|
|
||||||
val csr = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
|
|
||||||
|
|
||||||
client.enroll(BEARER, csr, subdomain = "alice", deviceName = "Alice Pixel")
|
|
||||||
|
|
||||||
val request = transport.recordedRequests.single()
|
|
||||||
assertEquals(HttpMethod.POST, request.method)
|
|
||||||
assertEquals("$BASE/device/enroll", request.url)
|
|
||||||
assertEquals("Bearer $BEARER", request.headers["Authorization"])
|
|
||||||
assertEquals("application/json", request.headers["Content-Type"])
|
|
||||||
|
|
||||||
val obj = bodyObject(request)
|
|
||||||
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
|
||||||
assertEquals("ec-p256", obj["keyAlg"]!!.jsonPrimitive.content)
|
|
||||||
assertEquals("alice", obj["subdomain"]!!.jsonPrimitive.content)
|
|
||||||
assertEquals("Alice Pixel", obj["deviceName"]!!.jsonPrimitive.content)
|
|
||||||
assertFalse(obj.containsKey("attestation"), "attestation is omitted when not provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enrollForwardsAttestationWhenProvided() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
|
|
||||||
client.enroll(BEARER, byteArrayOf(0x01), "a", "d", attestation = "attest-blob")
|
|
||||||
assertEquals("attest-blob", bodyObject(transport.recordedRequests.single())["attestation"]!!.jsonPrimitive.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enrollMapsA201IntoATypedResult() = runTest {
|
|
||||||
val cert = byteArrayOf(0x30, 0x82.toByte(), 0x01, 0x23)
|
|
||||||
val ca = byteArrayOf(0x30, 0x82.toByte(), 0x02, 0x00)
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
|
||||||
body = enrollResponse(deviceId = "dev-xyz", cert = cert, caChain = listOf(ca)),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = client.enroll(BEARER, byteArrayOf(0x01), "alice", "Pixel")
|
|
||||||
|
|
||||||
assertEquals("dev-xyz", result.deviceId)
|
|
||||||
assertArrayEquals(cert, result.certificate)
|
|
||||||
assertEquals(1, result.caChain.size)
|
|
||||||
assertArrayEquals(ca, result.caChain.single())
|
|
||||||
assertTrue(result.renewAfter!!.isBefore(result.notAfter))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enrollRejectsEmptyRequiredFieldsBeforeAnyNetworkIo() = runTest {
|
|
||||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll("", byteArrayOf(1), "a", "d") }.exceptionOrNull())
|
|
||||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, ByteArray(0), "a", "d") }.exceptionOrNull())
|
|
||||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "", "d") }.exceptionOrNull())
|
|
||||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "") }.exceptionOrNull())
|
|
||||||
assertTrue(transport.recordedRequests.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enrollSurfacesA403SubdomainNotOwnedWithTheServerCode() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 403,
|
|
||||||
body = """{"error":"rejected"}""".toByteArray(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DeviceEnrollmentError.Http(403, "rejected"),
|
|
||||||
runCatching { client.enroll(BEARER, byteArrayOf(1), "bob", "d") }.exceptionOrNull(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enrollSurfacesA429RateLimited() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 429,
|
|
||||||
body = """{"error":"rate_limited"}""".toByteArray(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DeviceEnrollmentError.Http(429, "rate_limited"),
|
|
||||||
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enrollThrowsMalformedResponseOnANonJson201Body() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = "not json".toByteArray())
|
|
||||||
assertEquals(
|
|
||||||
DeviceEnrollmentError.MalformedResponse,
|
|
||||||
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enrollThrowsMalformedResponseWhenTheCertIsNotValidBase64() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
|
||||||
body = """{"deviceId":"d","cert":"@@not-base64@@","caChain":[]}""".toByteArray(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DeviceEnrollmentError.MalformedResponse,
|
|
||||||
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enrollDegradesAbsentDatesToNull() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
|
||||||
body = """{"deviceId":"d","cert":"MAEC","caChain":[]}""".toByteArray(),
|
|
||||||
)
|
|
||||||
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
|
|
||||||
assertNull(result.notAfter)
|
|
||||||
assertNull(result.renewAfter)
|
|
||||||
assertFalse(result.isRenewalDue(Instant.parse("2030-01-01T00:00:00Z")), "absent renewAfter never triggers")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── renew (silent rotation seam — mTLS-only, NO bearer) ─────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renewTargetsDeviceIdRenewWithTheMinimalBodyAndNoAuthorizationHeader() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
|
|
||||||
)
|
|
||||||
val csr = byteArrayOf(0x02)
|
|
||||||
|
|
||||||
// Production renew passes NO bearer — the endpoint authenticates by the current device cert (mTLS).
|
|
||||||
val result = client.renew("dev-9", csr)
|
|
||||||
|
|
||||||
val request = transport.recordedRequests.single()
|
|
||||||
assertEquals("$BASE/device/dev-9/renew", request.url)
|
|
||||||
assertNull(request.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
|
|
||||||
val obj = bodyObject(request)
|
|
||||||
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
|
||||||
// The server's /device/:id/renew authenticates by the presented mTLS device cert and its body
|
|
||||||
// schema is `{ csr }` ONLY (.strict()) — any enroll-only extra (keyAlg/subdomain/deviceName)
|
|
||||||
// is rejected. The renew wire body must therefore carry the single `csr` key and nothing else.
|
|
||||||
assertEquals(setOf("csr"), obj.keys, "renew body is {csr}-only — no keyAlg/subdomain/deviceName")
|
|
||||||
assertFalse(obj.containsKey("keyAlg"), "renew must not send the enroll-only keyAlg field")
|
|
||||||
assertFalse(obj.containsKey("subdomain"), "renew body carries no subdomain/deviceName")
|
|
||||||
assertEquals("dev-9", result.deviceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renewForwardsAnExplicitBearerWhenTheOptionalSeamIsUsed() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// The bearer is optional/absent by default; when a caller DOES pass one it rides as a header.
|
|
||||||
client.renew("dev-9", byteArrayOf(0x02), bearerToken = BEARER)
|
|
||||||
|
|
||||||
assertEquals("Bearer $BEARER", transport.recordedRequests.single().headers["Authorization"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renewRejectsEmptyDeviceIdOrCsrBeforeAnyNetworkIo() = runTest {
|
|
||||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", byteArrayOf(1)) }.exceptionOrNull())
|
|
||||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("d", ByteArray(0)) }.exceptionOrNull())
|
|
||||||
assertTrue(transport.recordedRequests.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renewDecodesTheRealServerResponseWhichCarriesNoDeviceId() = runTest {
|
|
||||||
// THE REAL WIRE SHAPE: `/device/:id/renew` answers `{cert, caChain, notAfter}` — it does NOT
|
|
||||||
// echo deviceId/notBefore/renewAfter (only `/device/enroll` does; see
|
|
||||||
// control-plane/src/api/renew.ts). Decoding it against the enroll DTO (deviceId required)
|
|
||||||
// made every silent renewal fail as MalformedResponse. The device id comes from the path we
|
|
||||||
// called, which is authoritative anyway.
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201,
|
|
||||||
body = """{"cert":"MAEC","caChain":["MAED"],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = client.renew("dev-9", byteArrayOf(0x02))
|
|
||||||
|
|
||||||
assertEquals("dev-9", result.deviceId, "the device id comes from the path segment we called")
|
|
||||||
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
|
|
||||||
assertNull(result.notBefore, "the renew 201 carries no notBefore")
|
|
||||||
assertNull(result.renewAfter, "the renew 201 carries no renewAfter — the client derives it")
|
|
||||||
assertEquals(1, result.caChain.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── recover (EXPIRED-leaf escape hatch — plain HTTPS, cert in the BODY, never mTLS) ──────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoverPostsTheExpiredLeafAndAFreshCsrWithNoAuthorizationHeader() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
|
|
||||||
body = """{"cert":"MAEC","caChain":[],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
|
|
||||||
)
|
|
||||||
val expiredLeaf = byteArrayOf(0x30, 0x11)
|
|
||||||
val csr = byteArrayOf(0x30, 0x22)
|
|
||||||
|
|
||||||
val result = client.recover("dev-9", expiredLeaf, csr)
|
|
||||||
|
|
||||||
val request = transport.recordedRequests.single()
|
|
||||||
assertEquals(HttpMethod.POST, request.method)
|
|
||||||
assertEquals("$BASE/device/dev-9/recover", request.url)
|
|
||||||
// Recovery is NOT mTLS-authenticated and carries no bearer: an expired client certificate
|
|
||||||
// cannot authenticate its own re-issuance (no TLS terminator will even forward one), so the
|
|
||||||
// lapsed cert travels in the BODY and proof-of-possession comes from the CSR self-signature.
|
|
||||||
assertNull(request.headers["Authorization"], "recovery carries no bearer")
|
|
||||||
val obj = bodyObject(request)
|
|
||||||
assertEquals(setOf("cert", "csr"), obj.keys, "the /recover schema is .strict() on {cert, csr}")
|
|
||||||
assertEquals(Base64.getEncoder().encodeToString(expiredLeaf), obj["cert"]!!.jsonPrimitive.content)
|
|
||||||
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
|
||||||
assertEquals("dev-9", result.deviceId)
|
|
||||||
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoverPercentEncodesTheDeviceIdPathSegment() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/a%2Fb/recover", status = 201,
|
|
||||||
body = """{"cert":"MAEC","caChain":[]}""".toByteArray(),
|
|
||||||
)
|
|
||||||
client.recover("a/b", byteArrayOf(1), byteArrayOf(2))
|
|
||||||
assertEquals("$BASE/device/a%2Fb/recover", transport.recordedRequests.single().url)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoverRejectsEmptyArgumentsBeforeAnyNetworkIo() = runTest {
|
|
||||||
assertEquals(
|
|
||||||
DeviceEnrollmentError.InvalidRequest,
|
|
||||||
runCatching { client.recover("", byteArrayOf(1), byteArrayOf(1)) }.exceptionOrNull(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DeviceEnrollmentError.InvalidRequest,
|
|
||||||
runCatching { client.recover("d", ByteArray(0), byteArrayOf(1)) }.exceptionOrNull(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DeviceEnrollmentError.InvalidRequest,
|
|
||||||
runCatching { client.recover("d", byteArrayOf(1), ByteArray(0)) }.exceptionOrNull(),
|
|
||||||
)
|
|
||||||
assertTrue(transport.recordedRequests.isEmpty(), "no I/O for a request that cannot succeed")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoverSurfacesA401BeyondTheGraceWindowWithoutLeakingTheCert() = runTest {
|
|
||||||
// Past `DEFAULT_EXPIRED_RENEW_GRACE_MS` the control plane answers a uniform 401. The raised
|
|
||||||
// error must carry the STATUS + the server's `error` code only — never the submitted cert or
|
|
||||||
// CSR bytes, which would put credential material into a crash log.
|
|
||||||
val expiredLeaf = byteArrayOf(0x30, 0x11, 0x22, 0x33)
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 401,
|
|
||||||
body = """{"error":"rejected"}""".toByteArray(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val error = runCatching { client.recover("dev-9", expiredLeaf, byteArrayOf(0x30, 0x44)) }.exceptionOrNull()
|
|
||||||
|
|
||||||
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
|
|
||||||
val rendered = "${error!!.message} $error"
|
|
||||||
assertFalse(
|
|
||||||
rendered.contains(Base64.getEncoder().encodeToString(expiredLeaf)),
|
|
||||||
"the raised error must not carry the certificate bytes",
|
|
||||||
)
|
|
||||||
assertFalse(rendered.contains("MDE"), "no base64 credential material in the error rendering")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoverSurfacesA403ForARevokedDevice() = runTest {
|
|
||||||
// Recovery NEVER bypasses revocation (renew.test.ts CP6e) — surface the 403 verbatim.
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 403,
|
|
||||||
body = """{"error":"rejected"}""".toByteArray(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DeviceEnrollmentError.Http(403, "rejected"),
|
|
||||||
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoverThrowsMalformedResponseOnAnUndecodable201() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
|
|
||||||
body = """{"cert":"@@not-base64@@"}""".toByteArray(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DeviceEnrollmentError.MalformedResponse,
|
|
||||||
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theBaseUrlIsReadableSoTheRotationTargetCanBePersisted() = runTest {
|
|
||||||
// The rotation driver must renew against the SAME control plane the device enrolled with; the
|
|
||||||
// enroller persists this alongside the enrollment record.
|
|
||||||
assertEquals(BASE, client.baseUrl)
|
|
||||||
assertEquals(BASE, DeviceEnrollmentClient("$BASE/", transport).baseUrl, "a trailing slash is trimmed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isRenewalDueFlipsAtRenewAfter() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
|
|
||||||
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
|
|
||||||
assertFalse(result.isRenewalDue(Instant.parse("2026-09-04T00:00:00Z")))
|
|
||||||
assertTrue(result.isRenewalDue(Instant.parse("2026-09-06T00:00:00Z")))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assertArrayEquals(expected: ByteArray, actual: ByteArray) =
|
|
||||||
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.enroll
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.math.BigInteger
|
|
||||||
import java.security.KeyPairGenerator
|
|
||||||
import java.security.interfaces.ECPublicKey
|
|
||||||
import java.security.spec.ECGenParameterSpec
|
|
||||||
|
|
||||||
/** B4 · The X9.63 uncompressed-point encoder — the security-load-bearing SubjectPublicKeyInfo bytes. */
|
|
||||||
class EcPointEncodingTest {
|
|
||||||
@Test
|
|
||||||
fun encodesAGeneratedP256KeyAs65UncompressedBytesRoundTrippingToTheCoordinates() {
|
|
||||||
val kp = KeyPairGenerator.getInstance("EC").apply {
|
|
||||||
initialize(ECGenParameterSpec("secp256r1"))
|
|
||||||
}.generateKeyPair()
|
|
||||||
val pub = kp.public as ECPublicKey
|
|
||||||
|
|
||||||
val encoded = EcPointEncoding.x963(pub)
|
|
||||||
|
|
||||||
assertEquals(65, encoded.size, "0x04 || X(32) || Y(32)")
|
|
||||||
assertEquals(0x04, encoded[0].toInt() and 0xFF, "uncompressed-point prefix")
|
|
||||||
// The 32-byte big-endian halves must be exactly the affine coordinates.
|
|
||||||
val x = BigInteger(1, encoded.copyOfRange(1, 33))
|
|
||||||
val y = BigInteger(1, encoded.copyOfRange(33, 65))
|
|
||||||
assertEquals(pub.w.affineX, x)
|
|
||||||
assertEquals(pub.w.affineY, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun leftPadsAShortCoordinateToTheFixedWidth() {
|
|
||||||
// A small value must be left-padded with leading zeros to exactly 32 bytes.
|
|
||||||
val padded = EcPointEncoding.toFixedLengthUnsigned(BigInteger.valueOf(1), 32)
|
|
||||||
assertEquals(32, padded.size)
|
|
||||||
assertEquals(1, padded[31].toInt())
|
|
||||||
assertEquals(0, padded[0].toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun dropsTheBigIntegerSignByteWhenPresent() {
|
|
||||||
// A value whose top bit is set carries a leading 0x00 sign byte in BigInteger.toByteArray();
|
|
||||||
// it must be dropped, not counted toward the width.
|
|
||||||
val highBit = BigInteger(1, ByteArray(32) { 0xFF.toByte() })
|
|
||||||
val encoded = EcPointEncoding.toFixedLengthUnsigned(highBit, 32)
|
|
||||||
assertEquals(32, encoded.size)
|
|
||||||
assertEquals(0xFF, encoded[0].toInt() and 0xFF)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsACoordinateThatDoesNotFit() {
|
|
||||||
val tooBig = BigInteger.ONE.shiftLeft(256) // needs 33 bytes
|
|
||||||
assertThrows(IllegalArgumentException::class.java) {
|
|
||||||
EcPointEncoding.toFixedLengthUnsigned(tooBig, 32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.enroll
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The PURE rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
|
|
||||||
* `TerminalGridMath` — a policy is a function, so it is exhaustively testable with no clock, no
|
|
||||||
* keystore and no network). Ports the iOS `CertificateRotationSchedulerTests` timing cases and adds
|
|
||||||
* the two the phone track needs and iOS lacks: the EXPIRED-leaf `/device/:id/recover` window and the
|
|
||||||
* terminal beyond-grace state.
|
|
||||||
*/
|
|
||||||
class RotationPolicyTest {
|
|
||||||
private companion object {
|
|
||||||
val NOT_BEFORE: Instant = Instant.parse("2026-08-06T00:00:00Z")
|
|
||||||
val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z")
|
|
||||||
// notBefore + 2/3 · 61d lifetime — the value the control plane itself computes.
|
|
||||||
val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z")
|
|
||||||
|
|
||||||
fun state(
|
|
||||||
notAfter: Instant? = NOT_AFTER,
|
|
||||||
renewAfter: Instant? = RENEW_AFTER,
|
|
||||||
): DeviceRenewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── renewAfter derivation (the client MUST derive it: renew/recover 201s carry no renewAfter) ──
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renewAfterIsTwoThirdsThroughTheLeafLifetime() {
|
|
||||||
// The control plane computes `notBefore + floor(2/3 · lifetime)` (device-enroll.ts
|
|
||||||
// DEFAULT_RENEW_FRACTION); the client must land on the same instant from the leaf alone,
|
|
||||||
// because `/device/:id/renew` and `/device/:id/recover` return `{cert, caChain, notAfter}`
|
|
||||||
// ONLY — no renewAfter. Without this derivation rotation would fire exactly once, ever.
|
|
||||||
assertEquals(RENEW_AFTER, RotationPolicy.renewAfterFor(NOT_BEFORE, NOT_AFTER))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renewAfterIsNullWhenEitherBoundIsUnknown() {
|
|
||||||
assertNull(RotationPolicy.renewAfterFor(null, NOT_AFTER))
|
|
||||||
assertNull(RotationPolicy.renewAfterFor(NOT_BEFORE, null))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renewAfterIsNullForANonsensicalWindow() {
|
|
||||||
// notAfter before notBefore is not a lifetime — degrade to "unknown" (fail-safe: the TLS
|
|
||||||
// stack is the real gate) instead of inventing a past instant that would renew-storm.
|
|
||||||
assertNull(RotationPolicy.renewAfterFor(NOT_AFTER, NOT_BEFORE))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the wait branch ────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aLeafInsideItsRenewWindowIsNotDue() {
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.NOT_DUE,
|
|
||||||
RotationPolicy.decide(state(), now = RENEW_AFTER.minusSeconds(1)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aMissingRenewAfterNeverTriggers() {
|
|
||||||
// iOS fail-safe, ported verbatim: absent timing NEVER renews — the TLS stack is the real gate
|
|
||||||
// and the scheduler only pre-empts expiry.
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.NOT_DUE,
|
|
||||||
RotationPolicy.decide(state(renewAfter = null), now = NOT_AFTER.minusSeconds(1)),
|
|
||||||
)
|
|
||||||
assertFalse(state(renewAfter = null).isRenewalDue(NOT_AFTER.minusSeconds(1)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun anUnknownNotAfterFallsBackToTheRenewAfterBranch() {
|
|
||||||
// A cert whose expiry could not be read must not be treated as expired (that would push a
|
|
||||||
// working device into recovery); it is judged solely on the advisory renew timing.
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.NOT_DUE,
|
|
||||||
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER.minusSeconds(1)),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.RENEW,
|
|
||||||
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the renew branch ───────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renewFiresAtTheRenewAfterBoundary() {
|
|
||||||
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = RENEW_AFTER))
|
|
||||||
assertTrue(state().isRenewalDue(RENEW_AFTER), "the >= boundary renews (iOS parity)")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renewStillFiresAtTheLastInstantBeforeExpiry() {
|
|
||||||
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = NOT_AFTER))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the recover branch (expired leaf — the phone-track deadlock escape hatch) ───────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun anExpiredLeafInsideTheGraceWindowRecovers() {
|
|
||||||
// mTLS `/renew` cannot authenticate an expired leaf (nginx refuses to forward one at all), so
|
|
||||||
// the only route left is the plain-HTTPS `/device/:id/recover`.
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.RECOVER,
|
|
||||||
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(8))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoveryIsStillAvailableAtTheExactGraceBoundary() {
|
|
||||||
val grace = Duration.ofDays(30)
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.RECOVER,
|
|
||||||
RotationPolicy.decide(state(), now = NOT_AFTER.plus(grace), expiredRecoveryGrace = grace),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aLeafExpiredBeyondTheGraceWindowRequiresAFreshEnroll() {
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.RE_ENROLL_REQUIRED,
|
|
||||||
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(31))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aZeroGraceDisablesRecoveryEntirely() {
|
|
||||||
// Mirrors the control plane's `expiredRenewGraceMs = 0` posture (renew.test.ts CP6e).
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.RE_ENROLL_REQUIRED,
|
|
||||||
RotationPolicy.decide(
|
|
||||||
state(),
|
|
||||||
now = NOT_AFTER.plusMillis(1),
|
|
||||||
expiredRecoveryGrace = Duration.ZERO,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theTerminalStateWinsOverTheFailureBackoff() {
|
|
||||||
// Beyond grace nothing can ever succeed, so the answer must be the terminal one even while a
|
|
||||||
// backoff is armed — otherwise the user is told "retrying" forever (the exact failure mode
|
|
||||||
// the agent's 6380 identical warnings came from).
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.RE_ENROLL_REQUIRED,
|
|
||||||
RotationPolicy.decide(
|
|
||||||
state(),
|
|
||||||
now = NOT_AFTER.plus(Duration.ofDays(31)),
|
|
||||||
lastFailureAt = NOT_AFTER.plus(Duration.ofDays(31)).minusSeconds(1),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the backoff branch (a failed attempt must not hammer the control plane) ─────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aRecentFailureBacksOffInsteadOfRetryingImmediately() {
|
|
||||||
// Every foreground fires a pass; the control plane rate-limits renewals to 30/hour/identity,
|
|
||||||
// so an un-throttled retry turns one failure into a 429 storm.
|
|
||||||
val now = RENEW_AFTER.plus(Duration.ofMinutes(1))
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.BACKING_OFF,
|
|
||||||
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minus(Duration.ofMinutes(1))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theBackoffExpiresAndTheRenewIsRetried() {
|
|
||||||
val now = RENEW_AFTER.plus(Duration.ofHours(1))
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.RENEW,
|
|
||||||
RotationPolicy.decide(
|
|
||||||
state(),
|
|
||||||
now = now,
|
|
||||||
lastFailureAt = now.minus(RotationPolicy.DEFAULT_FAILURE_BACKOFF),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aRecentFailureAlsoThrottlesTheRecoveryPath() {
|
|
||||||
val now = NOT_AFTER.plus(Duration.ofDays(8))
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.BACKING_OFF,
|
|
||||||
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minusSeconds(30)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aFailureNeverTurnsAnIdleLeafIntoAnAttempt() {
|
|
||||||
// NOT_DUE has no attempt to throttle — reporting BACKING_OFF there would be a lie.
|
|
||||||
assertEquals(
|
|
||||||
RotationDecision.NOT_DUE,
|
|
||||||
RotationPolicy.decide(
|
|
||||||
state(),
|
|
||||||
now = RENEW_AFTER.minusSeconds(1),
|
|
||||||
lastFailureAt = RENEW_AFTER.minusSeconds(2),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── boundary validation ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun negativeDurationsAreRejectedAtTheBoundary() {
|
|
||||||
assertThrows(IllegalArgumentException::class.java) {
|
|
||||||
RotationPolicy.decide(state(), now = RENEW_AFTER, expiredRecoveryGrace = Duration.ofDays(-1))
|
|
||||||
}
|
|
||||||
assertThrows(IllegalArgumentException::class.java) {
|
|
||||||
RotationPolicy.decide(state(), now = RENEW_AFTER, failureBackoff = Duration.ofMinutes(-1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theStateCarriesNoCredentialInItsToString() {
|
|
||||||
// The rotation state is logged/surfaced; it must never be able to carry key or cert bytes.
|
|
||||||
val rendered = state().toString()
|
|
||||||
assertTrue(rendered.contains("dev-1"), "the non-secret device id is the only identifier here")
|
|
||||||
assertFalse(rendered.contains("MII"), "no DER/PEM material may appear in a loggable rendering")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.models
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GitLogResult list-lossy decode (plan Phase A.2): a well-formed `{commits,truncated}` decodes; a
|
|
||||||
* commit missing `hash`/`at` is dropped while its siblings survive; `truncated` passes through; a
|
|
||||||
* subject-less commit still decodes (subject defaults to empty).
|
|
||||||
*/
|
|
||||||
class GitLogTest {
|
|
||||||
|
|
||||||
private fun decode(json: String): GitLogResult? =
|
|
||||||
LossyDecode.objectOrNull(json.toByteArray(), GitLogResult.serializer())
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `decodes commits and truncated`() {
|
|
||||||
val json = """
|
|
||||||
{ "truncated": true, "commits": [
|
|
||||||
{ "hash":"abc123", "at": 1710000000000, "subject":"first" },
|
|
||||||
{ "hash":"def456", "at": 1710000005000, "subject":"second" }
|
|
||||||
] }
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val result = decode(json)!!
|
|
||||||
assertTrue(result.truncated)
|
|
||||||
assertEquals(2, result.commits.size)
|
|
||||||
assertEquals("abc123", result.commits[0].hash)
|
|
||||||
assertEquals(1710000000000L, result.commits[0].at)
|
|
||||||
assertEquals("first", result.commits[0].subject)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `drops a commit missing hash or at, keeping the rest`() {
|
|
||||||
val json = """
|
|
||||||
{ "truncated": false, "commits": [
|
|
||||||
{ "at": 1, "subject":"no hash" },
|
|
||||||
{ "hash":"keep", "at": 2, "subject":"kept" },
|
|
||||||
{ "hash":"noAt", "subject":"no at" }
|
|
||||||
] }
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val result = decode(json)!!
|
|
||||||
assertFalse(result.truncated)
|
|
||||||
assertEquals(1, result.commits.size)
|
|
||||||
assertEquals("keep", result.commits.single().hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a subject-less commit still decodes with an empty subject`() {
|
|
||||||
val result = decode("""{ "commits":[ { "hash":"h", "at": 5 } ] }""")!!
|
|
||||||
assertEquals(1, result.commits.size)
|
|
||||||
assertEquals("", result.commits.single().subject)
|
|
||||||
assertFalse(result.truncated) // default
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a non-object body degrades to null`() {
|
|
||||||
org.junit.jupiter.api.Assertions.assertNull(decode("[]"))
|
|
||||||
org.junit.jupiter.api.Assertions.assertNull(decode("garbage"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.models
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GitWrite payload + error decode (plan Phase A.3): each 200 payload decodes; a failure body
|
|
||||||
* `{ok:false,error:"…"}` (git-ops) and `{error:"…"}` (worktrees) both yield the SAFE `error` string;
|
|
||||||
* a garbled 200 body degrades to the payload defaults (never throws). The empty-sha commit case is
|
|
||||||
* exercised (server can return `{ok:true, commit:""}`).
|
|
||||||
*/
|
|
||||||
class GitWriteTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `stage payload decodes staged and count`() {
|
|
||||||
val r = decodeGitPayload("""{"ok":true,"staged":true,"count":3}""".toByteArray(), StageResult.serializer())
|
|
||||||
assertEquals(StageResult(staged = true, count = 3), r)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `commit payload decodes the sha and tolerates an empty sha`() {
|
|
||||||
assertEquals("a1b2c3", decodeGitPayload("""{"ok":true,"commit":"a1b2c3"}""".toByteArray(), CommitResult.serializer()).commit)
|
|
||||||
assertEquals("", decodeGitPayload("""{"ok":true,"commit":""}""".toByteArray(), CommitResult.serializer()).commit)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `push payload decodes branch and remote`() {
|
|
||||||
val r = decodeGitPayload("""{"ok":true,"branch":"main","remote":"origin"}""".toByteArray(), PushResult.serializer())
|
|
||||||
assertEquals("main", r.branch)
|
|
||||||
assertEquals("origin", r.remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `worktree create and remove and prune payloads decode`() {
|
|
||||||
val create = decodeGitPayload("""{"ok":true,"path":"/wt/x","branch":"feat"}""".toByteArray(), CreateWorktreeResult.serializer())
|
|
||||||
assertEquals("/wt/x", create.path)
|
|
||||||
assertEquals("feat", create.branch)
|
|
||||||
|
|
||||||
val remove = decodeGitPayload("""{"ok":true,"path":"/wt/x"}""".toByteArray(), RemoveWorktreeResult.serializer())
|
|
||||||
assertEquals("/wt/x", remove.path)
|
|
||||||
|
|
||||||
val prune = decodeGitPayload("""{"ok":true,"pruned":["a","b"]}""".toByteArray(), PruneWorktreesResult.serializer())
|
|
||||||
assertEquals(listOf("a", "b"), prune.pruned)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a garbled 200 body degrades to payload defaults, never throwing`() {
|
|
||||||
assertEquals(StageResult(), decodeGitPayload("not json".toByteArray(), StageResult.serializer()))
|
|
||||||
assertEquals(CommitResult(), decodeGitPayload("[]".toByteArray(), CommitResult.serializer()))
|
|
||||||
assertTrue(decodeGitPayload("{}".toByteArray(), PruneWorktreesResult.serializer()).pruned.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a git-ops failure body yields the safe error string`() {
|
|
||||||
assertEquals("Nothing to commit.", decodeGitError("""{"ok":false,"error":"Nothing to commit."}""".toByteArray()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a worktree failure body (no ok field) still yields the error string`() {
|
|
||||||
assertEquals("Worktree creation is disabled.", decodeGitError("""{"error":"Worktree creation is disabled."}""".toByteArray()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an empty or errorless failure body yields null`() {
|
|
||||||
assertNull(decodeGitError(ByteArray(0)))
|
|
||||||
assertNull(decodeGitError("""{"ok":false}""".toByteArray()))
|
|
||||||
assertNull(decodeGitError("not json".toByteArray()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.models
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PrStatus tolerant decode (plan Phase A.1): a full `availability:"ok"` body decodes every field; an
|
|
||||||
* unknown/missing `availability` degrades to [PrAvailability.ERROR] (never throws); `PrCheckSummary`
|
|
||||||
* counts round-trip; a non-object body degrades rather than crashing. Mirrors the FE never treating a
|
|
||||||
* non-`ok` availability as an HTTP error.
|
|
||||||
*/
|
|
||||||
class PrStatusTest {
|
|
||||||
|
|
||||||
private fun decode(json: String): PrStatus? =
|
|
||||||
LossyDecode.objectOrNull(json.toByteArray(), PrStatus.serializer())
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `decodes a full ok body with all fields and check counts`() {
|
|
||||||
val json = """
|
|
||||||
{ "availability":"ok", "number":42, "title":"Add worktrees", "url":"https://x/pull/42",
|
|
||||||
"state":"open", "isDraft":false, "mergeable":"mergeable",
|
|
||||||
"headRefName":"feat/wt", "baseRefName":"main",
|
|
||||||
"checks": { "total":5, "passing":3, "failing":1, "pending":1 } }
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val pr = decode(json)!!
|
|
||||||
assertEquals(PrAvailability.OK, pr.availability)
|
|
||||||
assertEquals(42, pr.number)
|
|
||||||
assertEquals("Add worktrees", pr.title)
|
|
||||||
assertEquals("https://x/pull/42", pr.url)
|
|
||||||
assertEquals("open", pr.state)
|
|
||||||
assertEquals(false, pr.isDraft)
|
|
||||||
assertEquals("mergeable", pr.mergeable)
|
|
||||||
assertEquals("feat/wt", pr.headRefName)
|
|
||||||
assertEquals("main", pr.baseRefName)
|
|
||||||
assertEquals(PrCheckSummary(total = 5, passing = 3, failing = 1, pending = 1), pr.checks)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an unknown availability degrades to ERROR, never throwing`() {
|
|
||||||
val pr = decode("""{ "availability":"quantum-flux" }""")!!
|
|
||||||
assertEquals(PrAvailability.ERROR, pr.availability)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a body missing availability defaults to ERROR and leaves optional fields null`() {
|
|
||||||
val pr = decode("""{ "number":7 }""")!!
|
|
||||||
assertEquals(PrAvailability.ERROR, pr.availability)
|
|
||||||
assertEquals(7, pr.number)
|
|
||||||
assertNull(pr.title)
|
|
||||||
assertNull(pr.checks)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `each known availability maps from its wire value`() {
|
|
||||||
assertEquals(PrAvailability.NO_PR, PrAvailability.fromWire("no-pr"))
|
|
||||||
assertEquals(PrAvailability.NOT_INSTALLED, PrAvailability.fromWire("not-installed"))
|
|
||||||
assertEquals(PrAvailability.UNAUTHENTICATED, PrAvailability.fromWire("unauthenticated"))
|
|
||||||
assertEquals(PrAvailability.DISABLED, PrAvailability.fromWire("disabled"))
|
|
||||||
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("error"))
|
|
||||||
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("")) // empty → ERROR
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a non-object body degrades to null rather than throwing`() {
|
|
||||||
assertNull(decode("[]"))
|
|
||||||
assertNull(decode("not json"))
|
|
||||||
assertNull(LossyDecode.objectOrNull(ByteArray(0), PrStatus.serializer()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unknown top-level keys are ignored`() {
|
|
||||||
val pr = decode("""{ "availability":"ok", "futureField":123, "nested":{"a":1} }""")!!
|
|
||||||
assertEquals(PrAvailability.OK, pr.availability)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.models
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.DisplayName
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tolerant decode for the W2 queue read (`GET /live-sessions/:id/queue` → `{length, items}`) and for
|
|
||||||
* the additive `LiveSessionInfo.queueLength` the badge reads. The server is UNTRUSTED here: unknown
|
|
||||||
* keys, missing keys and wrong-typed items must degrade, never crash — and the queued strings, which
|
|
||||||
* are raw keyboard bytes, must come back verbatim.
|
|
||||||
*/
|
|
||||||
@DisplayName("W2 queue models — tolerant decode + verbatim items")
|
|
||||||
class PromptQueueTest {
|
|
||||||
private fun snapshot(json: String): QueueSnapshot? =
|
|
||||||
LossyDecode.objectOrNull(json.toByteArray(), QueueSnapshot.serializer())
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `decodes length and items`() {
|
|
||||||
val s = snapshot("""{"length":2,"items":["first","second"]}""")!!
|
|
||||||
assertEquals(2, s.length)
|
|
||||||
assertEquals(listOf("first", "second"), s.items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `queued items keep control characters and CJK verbatim`() {
|
|
||||||
// [A + TAB + CR + CJK, exactly as the server stored the keystroke bytes.
|
|
||||||
val s = snapshot("""{"length":1,"items":["[A\t你好\r"]}""")!!
|
|
||||||
assertEquals("[A\t你好\r", s.items.single())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an empty queue and unknown keys both decode`() {
|
|
||||||
val s = snapshot("""{"length":0,"items":[],"futureField":true}""")!!
|
|
||||||
assertEquals(0, s.length)
|
|
||||||
assertTrue(s.items.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `missing fields fall back to an empty queue and a non-object body is null`() {
|
|
||||||
val s = snapshot("{}")!!
|
|
||||||
assertEquals(0, s.length)
|
|
||||||
assertTrue(s.items.isEmpty())
|
|
||||||
|
|
||||||
assertNull(snapshot("[]"))
|
|
||||||
assertNull(snapshot("garbage"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── LiveSessionInfo.queueLength (additive optional, src/types.ts:318) ─────────────────────
|
|
||||||
|
|
||||||
private val base =
|
|
||||||
"""{"id":"11111111-2222-3333-4444-555555555555","createdAt":1,"clientCount":0,"exited":false,"cols":80,"rows":24"""
|
|
||||||
|
|
||||||
private fun info(extra: String): LiveSessionInfo? =
|
|
||||||
LossyDecode.objectOrNull("$base$extra}".toByteArray(), LiveSessionInfo.serializer())
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `queueLength decodes when present`() {
|
|
||||||
assertEquals(4, info(""","queueLength":4""")!!.queueLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `queueLength is null on a pre-W2 server that omits it`() {
|
|
||||||
assertNull(info("")!!.queueLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a wrong-typed queueLength does not drop the whole session`() {
|
|
||||||
// A garbled value must not make a running session invisible — the field degrades to null.
|
|
||||||
assertNull(info(",\"queueLength\":\"lots\"")!!.queueLength)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.DisplayName
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* w6/G1 sync state.
|
|
||||||
*
|
|
||||||
* The load-bearing rule, quoted from the server commit that introduced the feature: *"Exactly ONE state
|
|
||||||
* may render green: ↑0 ↓0 AND a fresh fetch. Green means 'I checked, ignore this'; getting it wrong is
|
|
||||||
* lying to the user."* Most of this file exists to pin the ways that could go wrong — above all the
|
|
||||||
* no-upstream fall-through, which the server's own commit message calls "the easiest bug to ship here".
|
|
||||||
*/
|
|
||||||
@DisplayName("SyncState — when the all-clear may be rendered")
|
|
||||||
class SyncStateTest {
|
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
private val now = 1_700_000_000_000L
|
|
||||||
private fun fresh() = now - 60_000L // a minute ago
|
|
||||||
private fun stale() = now - (SyncState.FETCH_FRESH_WINDOW_MS + 60_000L)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("decodes the full server shape")
|
|
||||||
fun decodesFull() {
|
|
||||||
val s = json.decodeFromString<SyncState>(
|
|
||||||
"""{"upstream":"origin/develop","ahead":9,"behind":0,"lastFetchMs":$now,"detached":false}""",
|
|
||||||
)
|
|
||||||
assertEquals("origin/develop", s.upstream)
|
|
||||||
assertEquals(9, s.ahead)
|
|
||||||
assertEquals(0, s.behind)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("every field is optional — each degrades independently on the server")
|
|
||||||
fun decodesEmpty() {
|
|
||||||
val s = json.decodeFromString<SyncState>("{}")
|
|
||||||
assertNull(s.upstream)
|
|
||||||
assertNull(s.ahead)
|
|
||||||
assertNull(s.behind)
|
|
||||||
assertNull(s.lastFetchMs)
|
|
||||||
assertFalse(s.detached)
|
|
||||||
assertFalse(s.hasUpstream)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the green state ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("↑0 ↓0 with a fresh fetch is the ONLY green state")
|
|
||||||
fun theOneGreenState() {
|
|
||||||
val s = SyncState(upstream = "origin/main", ahead = 0, behind = 0, lastFetchMs = fresh())
|
|
||||||
assertTrue(s.isFullySynced(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("NO UPSTREAM is never green — the fall-through the server calls the easiest bug here")
|
|
||||||
fun noUpstreamIsNeverGreen() {
|
|
||||||
// The normal state of a fresh worktree branch. ahead/behind are UNDEFINED, not zero.
|
|
||||||
val s = SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh())
|
|
||||||
assertFalse(s.isFullySynced(now), "a branch tracking nothing has not been verified as in sync")
|
|
||||||
assertFalse(s.hasUpstream)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("undefined counts are not zero, even with an upstream and a fresh fetch")
|
|
||||||
fun undefinedCountsAreNotZero() {
|
|
||||||
assertFalse(SyncState("origin/main", ahead = null, behind = 0, lastFetchMs = fresh()).isFullySynced(now))
|
|
||||||
assertFalse(SyncState("origin/main", ahead = 0, behind = null, lastFetchMs = fresh()).isFullySynced(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("a stale fetch is not green — behind=0 from a stale FETCH_HEAD proves nothing")
|
|
||||||
fun staleFetchIsNotGreen() {
|
|
||||||
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = stale())
|
|
||||||
assertFalse(s.isFullySynced(now))
|
|
||||||
assertTrue(s.isBehindStale(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("never fetched is not green")
|
|
||||||
fun neverFetchedIsNotGreen() {
|
|
||||||
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = null)
|
|
||||||
assertFalse(s.isFullySynced(now))
|
|
||||||
assertFalse(s.isFetchFresh(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("a detached HEAD is not green")
|
|
||||||
fun detachedIsNotGreen() {
|
|
||||||
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = fresh(), detached = true)
|
|
||||||
assertFalse(s.isFullySynced(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("anything unpushed or unpulled is not green")
|
|
||||||
fun pendingWorkIsNotGreen() {
|
|
||||||
assertFalse(SyncState("origin/main", ahead = 1, behind = 0, lastFetchMs = fresh()).isFullySynced(now))
|
|
||||||
assertFalse(SyncState("origin/main", ahead = 0, behind = 1, lastFetchMs = fresh()).isFullySynced(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── freshness edges ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("a fetch timestamp in the future is not treated as fresh")
|
|
||||||
fun futureFetchIsNotFresh() {
|
|
||||||
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now + 60_000L)
|
|
||||||
assertFalse(s.isFetchFresh(now), "clock skew is not evidence that we checked")
|
|
||||||
assertFalse(s.isFullySynced(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("the freshness boundary is inclusive at exactly the window")
|
|
||||||
fun boundaryInclusive() {
|
|
||||||
val atEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS)
|
|
||||||
assertTrue(atEdge.isFetchFresh(now))
|
|
||||||
val pastEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS - 1)
|
|
||||||
assertFalse(pastEdge.isFetchFresh(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("staleness is only claimed when there is an upstream to be stale about")
|
|
||||||
fun noUpstreamIsNotStale() {
|
|
||||||
assertFalse(SyncState(upstream = null, lastFetchMs = null).isBehindStale(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("WorktreeState decodes the narrow per-row shape")
|
|
||||||
fun worktreeStateDecodes() {
|
|
||||||
val w = json.decodeFromString<WorktreeState>(
|
|
||||||
"""{"path":"/repo/.claude/worktrees/x","branch":"wt-x","dirtyCount":3,
|
|
||||||
"sync":{"upstream":"origin/wt-x","ahead":2}}""",
|
|
||||||
)
|
|
||||||
assertEquals("wt-x", w.branch)
|
|
||||||
assertEquals(3, w.dirtyCount)
|
|
||||||
assertEquals(2, w.sync?.ahead)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.pairing
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
|
||||||
import wang.yaojia.webterm.wire.AuthCookie
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B5 · `POST /auth` as the pairing-time validation probe (FROZEN contract, ios-completion §1.1).
|
|
||||||
*
|
|
||||||
* The four distinct server outcomes must stay distinguishable:
|
|
||||||
* | 204 **with** `Set-Cookie` | token correct → persist it |
|
|
||||||
* | 204 **without** `Set-Cookie` | the host has NO auth configured → do NOT persist, and do NOT treat as authenticated |
|
|
||||||
* | 401 | the token is wrong → UI says "令牌不正确" |
|
|
||||||
* | 429 | rate-limited (10/min/IP) → UI says "尝试过多" |
|
|
||||||
*
|
|
||||||
* Plus the request shape the server needs: JSON body `{"token":"<t>"}` and an `Accept` that does NOT
|
|
||||||
* contain `text/html` (an HTML-accepting request is treated as a browser form and answered with a 302
|
|
||||||
* instead of 204/401).
|
|
||||||
*/
|
|
||||||
class AuthProbeTest {
|
|
||||||
private companion object {
|
|
||||||
const val BASE = "http://192.168.1.5:3000"
|
|
||||||
const val TOKEN = "0123456789abcdefTOKEN"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
|
|
||||||
private val transport = FakeHttpTransport()
|
|
||||||
|
|
||||||
private fun queueAuth(status: Int, headers: Map<String, String> = emptyMap()) {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/auth", status = status, headers = headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val setCookieHeader = mapOf(
|
|
||||||
"Set-Cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict",
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── the four frozen outcomes ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `204 with Set-Cookie means the token is correct`() = runTest {
|
|
||||||
queueAuth(204, setCookieHeader)
|
|
||||||
|
|
||||||
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `204 without Set-Cookie means the host has no auth configured`() = runTest {
|
|
||||||
queueAuth(204)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
AuthProbeResult.AuthDisabled,
|
|
||||||
probeAccessToken(endpoint, transport, TOKEN),
|
|
||||||
"204-no-cookie must NEVER be read as 'authenticated' (frozen §1.1)",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `401 means the token is wrong`() = runTest {
|
|
||||||
queueAuth(401)
|
|
||||||
|
|
||||||
assertEquals(AuthProbeResult.InvalidToken, probeAccessToken(endpoint, transport, TOKEN))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `429 means rate-limited`() = runTest {
|
|
||||||
queueAuth(429)
|
|
||||||
|
|
||||||
assertEquals(AuthProbeResult.RateLimited, probeAccessToken(endpoint, transport, TOKEN))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `any other status is surfaced as Unexpected with the code`() = runTest {
|
|
||||||
queueAuth(302) // e.g. a proxy/login redirect — never silently treated as success
|
|
||||||
|
|
||||||
assertEquals(AuthProbeResult.Unexpected(302), probeAccessToken(endpoint, transport, TOKEN))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── request shape ───────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the probe posts the token as JSON with an Accept that excludes text-html`() = runTest {
|
|
||||||
queueAuth(204, setCookieHeader)
|
|
||||||
|
|
||||||
probeAccessToken(endpoint, transport, TOKEN)
|
|
||||||
|
|
||||||
val request = transport.recordedRequests.single()
|
|
||||||
assertEquals(HttpMethod.POST, request.method)
|
|
||||||
assertEquals("$BASE/auth", request.url)
|
|
||||||
assertEquals("""{"token":"$TOKEN"}""", request.body?.decodeToString())
|
|
||||||
assertEquals("application/json", request.headers["Content-Type"])
|
|
||||||
val accept = request.headers["Accept"].orEmpty()
|
|
||||||
assertTrue(accept.isNotEmpty(), "Accept must be explicit, not left to the HTTP stack")
|
|
||||||
assertFalse(accept.contains("text/html"), "text/html would make the server answer 302, not 204/401")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the token never appears in the URL`() = runTest {
|
|
||||||
queueAuth(401)
|
|
||||||
|
|
||||||
probeAccessToken(endpoint, transport, TOKEN)
|
|
||||||
|
|
||||||
assertFalse(
|
|
||||||
transport.recordedRequests.single().url.contains(TOKEN),
|
|
||||||
"a secret must never travel in a URL (query strings land in logs/history)",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── boundary validation + transport failure ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a malformed token is rejected before any network IO`() = runTest {
|
|
||||||
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "short"))
|
|
||||||
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "has spaces in it here"))
|
|
||||||
|
|
||||||
assertTrue(transport.recordedRequests.isEmpty(), "a malformed token must not hit the network")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a whitespace-padded token is trimmed once and accepted`() = runTest {
|
|
||||||
queueAuth(204, setCookieHeader)
|
|
||||||
|
|
||||||
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, " $TOKEN\n"))
|
|
||||||
assertEquals("""{"token":"$TOKEN"}""", transport.recordedRequests.single().body?.decodeToString())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a transport failure propagates so the caller can classify it`() = runTest {
|
|
||||||
transport.queueFailure(method = HttpMethod.POST, url = "$BASE/auth", error = IOException("refused"))
|
|
||||||
|
|
||||||
val thrown = runCatching { probeAccessToken(endpoint, transport, TOKEN) }.exceptionOrNull()
|
|
||||||
|
|
||||||
assertTrue(thrown is IOException, "network classification stays with PairingError.classify")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a Set-Cookie header is recognised case-insensitively`() = runTest {
|
|
||||||
queueAuth(204, mapOf("set-cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/"))
|
|
||||||
|
|
||||||
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a Set-Cookie for some other cookie does not count as accepted`() = runTest {
|
|
||||||
queueAuth(204, mapOf("Set-Cookie" to "sessionid=abc; Path=/"))
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
AuthProbeResult.AuthDisabled,
|
|
||||||
probeAccessToken(endpoint, transport, TOKEN),
|
|
||||||
"only a webterm_auth cookie proves the token was accepted",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.pairing
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
|
||||||
import wang.yaojia.webterm.testsupport.FakeTermTransport
|
|
||||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
|
||||||
import wang.yaojia.webterm.wire.AuthCookie
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B5 · the two-step pairing probe under an access-token-protected host:
|
|
||||||
* - both HTTP legs (`GET /live-sessions` and the guarded `DELETE /live-sessions/:id`) carry the
|
|
||||||
* hand-written `Cookie: webterm_auth=<t>`;
|
|
||||||
* - a **401** on the reachability leg is [PairingError.AccessTokenRequired] — NOT
|
|
||||||
* "端口对吗?" ([PairingError.HttpOkButNotWebTerminal]) and NOT an Origin problem, so the UI can ask
|
|
||||||
* for the token instead of sending the user on a wild goose chase;
|
|
||||||
* - with no token configured nothing changes (no `Cookie` header at all).
|
|
||||||
*/
|
|
||||||
class PairingProbeTokenTest {
|
|
||||||
private companion object {
|
|
||||||
const val BASE = "http://192.168.1.5:3000"
|
|
||||||
const val TOKEN = "0123456789abcdefTOKEN"
|
|
||||||
const val SERVER_ID = "11111111-1111-4111-8111-111111111111"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
|
|
||||||
private val http = FakeHttpTransport()
|
|
||||||
private val ws = FakeTermTransport()
|
|
||||||
|
|
||||||
private fun scriptHappyPath() {
|
|
||||||
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
|
||||||
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 204)
|
|
||||||
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun probe(tokens: AccessTokenSource): PairingProbeResult =
|
|
||||||
runPairingProbeCore(endpoint, http, ws, timeout = null, tokens = tokens)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `both HTTP legs of the probe carry the auth cookie`() = runTest {
|
|
||||||
scriptHappyPath()
|
|
||||||
|
|
||||||
val result = probe(AccessTokenSource { TOKEN })
|
|
||||||
|
|
||||||
assertEquals(PairingProbeResult.Success(endpoint), result)
|
|
||||||
assertEquals(2, http.recordedRequests.size, "reachability GET + guarded kill DELETE")
|
|
||||||
http.recordedRequests.forEach { request ->
|
|
||||||
assertEquals(
|
|
||||||
"${AuthCookie.NAME}=$TOKEN",
|
|
||||||
request.headers[AuthCookie.HEADER_NAME],
|
|
||||||
"every probe request must present the token",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `with no token the probe requests are byte-identical to before the feature`() = runTest {
|
|
||||||
scriptHappyPath()
|
|
||||||
|
|
||||||
probe(AccessTokenSource.NONE)
|
|
||||||
|
|
||||||
http.recordedRequests.forEach { request ->
|
|
||||||
assertFalse(request.headers.containsKey(AuthCookie.HEADER_NAME))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a 401 on the reachability leg asks for an access token`() = runTest {
|
|
||||||
http.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
|
|
||||||
|
|
||||||
val result = probe(AccessTokenSource.NONE)
|
|
||||||
|
|
||||||
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a 401 on the guarded kill leg also asks for an access token`() = runTest {
|
|
||||||
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
|
||||||
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 401)
|
|
||||||
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
|
|
||||||
|
|
||||||
val result = probe(AccessTokenSource.NONE)
|
|
||||||
|
|
||||||
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
|
||||||
import wang.yaojia.webterm.api.models.HookDecision
|
|
||||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
|
||||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
|
||||||
import wang.yaojia.webterm.wire.AuthCookie
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B5 · the access-token cookie on the REST surface (FROZEN contract, ios-completion §1.1):
|
|
||||||
* - `Cookie: webterm_auth=<t>` is stamped on **EVERY** route — read-only GETs included — because the
|
|
||||||
* server's auth gate runs in front of the whole app, not just the guarded routes;
|
|
||||||
* - it is **orthogonal to `Origin`**: a RO route carries the cookie and still NO Origin (the
|
|
||||||
* Origin-iff-guarded 铁律 is untouched);
|
|
||||||
* - no token configured ⇒ NO `Cookie` header at all (zero-config LAN behaviour is byte-identical);
|
|
||||||
* - a **401** on a route whose 401 can only be the gate is the typed [ApiClientError.Unauthorized],
|
|
||||||
* never a generic status error, so the UI can route the user to re-enter the token — while the
|
|
||||||
* git-write family, which defines its own 401 (`src/http/git-ops.ts:108`), keeps the server's
|
|
||||||
* message (E1 fix; see the rewritten push case below).
|
|
||||||
*/
|
|
||||||
class AccessTokenCookieTest {
|
|
||||||
private companion object {
|
|
||||||
const val BASE = "http://192.168.1.5:3000"
|
|
||||||
const val TOKEN = "0123456789abcdefTOKEN"
|
|
||||||
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
|
||||||
const val ID_STR = "11111111-2222-4333-8444-555555555555"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
|
|
||||||
private val transport = FakeHttpTransport()
|
|
||||||
|
|
||||||
private fun clientWithToken(): ApiClient =
|
|
||||||
ApiClient(endpoint, transport, AccessTokenSource { TOKEN })
|
|
||||||
|
|
||||||
private fun clientWithoutToken(): ApiClient = ApiClient(endpoint, transport)
|
|
||||||
|
|
||||||
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
|
|
||||||
|
|
||||||
private fun assertCookie(index: Int) {
|
|
||||||
val headers = transport.recordedRequests[index].headers
|
|
||||||
assertEquals(
|
|
||||||
"${AuthCookie.NAME}=$TOKEN",
|
|
||||||
headers[AuthCookie.HEADER_NAME],
|
|
||||||
"every request must carry the hand-written auth cookie",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a read-only GET carries the cookie and still no Origin`() = runTest {
|
|
||||||
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
|
||||||
|
|
||||||
clientWithToken().liveSessions()
|
|
||||||
|
|
||||||
assertCookie(0)
|
|
||||||
assertFalse(
|
|
||||||
transport.recordedRequests[0].headers.containsKey(HeaderName.ORIGIN),
|
|
||||||
"the cookie is additive — a RO route must still never carry Origin",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a guarded write carries BOTH the byte-equal Origin and the cookie`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
|
|
||||||
|
|
||||||
clientWithToken().hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
|
|
||||||
|
|
||||||
assertCookie(0)
|
|
||||||
assertEquals(endpoint.originHeader, transport.recordedRequests[0].headers[HeaderName.ORIGIN])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the cookie rides every route shape - RO query, guarded DELETE and guarded PUT alike`() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
url = "$BASE/projects/detail?path=%2Frepo",
|
|
||||||
body = """{"name":"repo","path":"/repo","isGit":true}""".toByteArray(),
|
|
||||||
)
|
|
||||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
|
|
||||||
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = "{}".toByteArray())
|
|
||||||
val client = clientWithToken()
|
|
||||||
|
|
||||||
client.projectDetail("/repo")
|
|
||||||
client.killSession(ID)
|
|
||||||
client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create())
|
|
||||||
|
|
||||||
assertEquals(3, transport.recordedRequests.size)
|
|
||||||
transport.recordedRequests.indices.forEach { assertCookie(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `no configured token means no Cookie header at all`() = runTest {
|
|
||||||
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
|
||||||
|
|
||||||
clientWithoutToken().liveSessions()
|
|
||||||
|
|
||||||
assertFalse(
|
|
||||||
transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME),
|
|
||||||
"an unauthenticated host must see a byte-identical request to before the feature",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the token is re-read per request so a token stored later takes effect`() = runTest {
|
|
||||||
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
|
||||||
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
|
||||||
var stored: String? = null
|
|
||||||
val client = ApiClient(endpoint, transport, AccessTokenSource { stored })
|
|
||||||
|
|
||||||
client.liveSessions()
|
|
||||||
stored = TOKEN
|
|
||||||
client.liveSessions()
|
|
||||||
|
|
||||||
assertFalse(transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME))
|
|
||||||
assertCookie(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a 401 on a read-only route throws the typed Unauthorized error`() = runTest {
|
|
||||||
transport.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
|
|
||||||
|
|
||||||
val error = errorOf { clientWithoutToken().liveSessions() }
|
|
||||||
|
|
||||||
assertEquals(ApiClientError.Unauthorized, error)
|
|
||||||
assertTrue(
|
|
||||||
ApiClientError.Unauthorized.userMessage.isNotBlank(),
|
|
||||||
"the UI needs actionable copy for a missing token",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* E1 · REWRITTEN (this case previously asserted the opposite and pinned a real defect).
|
|
||||||
*
|
|
||||||
* `POST /projects/git/push` defines its OWN 401: `src/http/git-ops.ts:108` classifies a HOST-side
|
|
||||||
* git credential failure ("could not read Username", "permission denied (publickey)", …) as
|
|
||||||
* `{status:401, error:"Push authentication required on the host."}`. Swallowing that into
|
|
||||||
* [ApiClientError.Unauthorized] tells the user their *access token* is broken and sends them to
|
|
||||||
* rotate a secret that is fine, while hiding the real cause. iOS gets this right via
|
|
||||||
* `unauthorizedPolicy: .routeDefined` (APIClient/GitWrite.swift), and plan §4 item 49 requires the
|
|
||||||
* distinction — so the route's own message must reach [GitWriteOutcome.Rejected] verbatim.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `a 401 on git push is the host's own git-credential failure, surfaced verbatim`() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST,
|
|
||||||
url = "$BASE/projects/git/push",
|
|
||||||
status = 401,
|
|
||||||
body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val outcome = clientWithToken().gitPush("/repo")
|
|
||||||
|
|
||||||
assertEquals(GitWriteOutcome.Rejected(401, "Push authentication required on the host."), outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the route-defined 401 covers the whole git-ops family, not just push`() = runTest {
|
|
||||||
val body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray()
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", status = 401, body = body)
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", status = 401, body = body)
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/fetch", status = 401, body = body)
|
|
||||||
val client = clientWithToken()
|
|
||||||
|
|
||||||
assertEquals(401, (client.gitStage("/repo", listOf("a.txt"), true) as GitWriteOutcome.Rejected).status)
|
|
||||||
assertEquals(401, (client.gitCommit("/repo", "msg") as GitWriteOutcome.Rejected).status)
|
|
||||||
assertEquals(401, (client.gitFetch("/repo") as GitWriteOutcome.Rejected).status)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F5 · the worktree trio is NOT part of that family. `src/http/worktrees.ts` emits no 401 (403
|
|
||||||
* kill-switch / 400 / 404 / 500 only) and `src/server.ts:1182-1260` adds none, so the ONLY 401 those
|
|
||||||
* routes can ever see is the access-token gate. Reading it as a git rejection stranded the user on a
|
|
||||||
* "worktree failed" message instead of the token flow.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `a 401 on a worktree write is the access-token gate, not a git rejection`() = runTest {
|
|
||||||
val gateBody = """{"error":"authentication required"}""".toByteArray()
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", status = 401, body = gateBody)
|
|
||||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", status = 401, body = gateBody)
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST,
|
|
||||||
url = "$BASE/projects/worktree/prune",
|
|
||||||
status = 401,
|
|
||||||
body = gateBody,
|
|
||||||
)
|
|
||||||
val client = clientWithToken()
|
|
||||||
|
|
||||||
assertEquals(ApiClientError.Unauthorized, errorOf { client.createWorktree("/repo", "feat/x") })
|
|
||||||
assertEquals(ApiClientError.Unauthorized, errorOf { client.removeWorktree("/repo", "/repo/wt", false) })
|
|
||||||
assertEquals(ApiClientError.Unauthorized, errorOf { client.pruneWorktrees("/repo") })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a git write with no server message still reports the status, never the token copy`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 401)
|
|
||||||
|
|
||||||
val outcome = clientWithoutToken().gitPush("/repo")
|
|
||||||
|
|
||||||
assertEquals(GitWriteOutcome.Rejected(401, null), outcome)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
|
||||||
import wang.yaojia.webterm.api.models.PrAvailability
|
|
||||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status-code → outcome mapping for the W5 git surface (plan Phase A.5): PR 200/400/404; log
|
|
||||||
* decode + errors; each guarded write 200→Ok, 403→Rejected(body.error), 409→Rejected, 429→
|
|
||||||
* RateLimited. Also asserts the transport RECEIVED an Origin on writes and NOT on reads.
|
|
||||||
*/
|
|
||||||
class ApiClientGitTest {
|
|
||||||
private companion object {
|
|
||||||
const val BASE = "http://h:3000"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val transport = FakeHttpTransport()
|
|
||||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
|
||||||
|
|
||||||
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
|
|
||||||
|
|
||||||
// ── PR (RO; degrade lives in the 200 body, not the status) ───────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `projectPr decodes a 200 degrade body and maps 400 404`() = runTest {
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"not-installed"}""".toByteArray())
|
|
||||||
assertEquals(PrAvailability.NOT_INSTALLED, client.projectPr("/r").availability)
|
|
||||||
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 400)
|
|
||||||
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("/r") })
|
|
||||||
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 404)
|
|
||||||
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectPr("/r") })
|
|
||||||
|
|
||||||
// Empty path is rejected before any I/O.
|
|
||||||
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("") })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `projectPr never treats a garbled 200 body as an error (degrades to ERROR)`() = runTest {
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = "not json".toByteArray())
|
|
||||||
assertEquals(PrAvailability.ERROR, client.projectPr("/r").availability)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── log ──────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `projectLog decodes 200 and maps 404 and 500`() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
url = "$BASE/projects/log?path=%2Fr",
|
|
||||||
body = """{"commits":[{"hash":"h","at":1,"subject":"s"}],"truncated":false}""".toByteArray(),
|
|
||||||
)
|
|
||||||
assertEquals(1, client.projectLog("/r").commits.size)
|
|
||||||
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 404)
|
|
||||||
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectLog("/r") })
|
|
||||||
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 500)
|
|
||||||
assertEquals(ApiClientError.GitLogUnavailable, errorOf { client.projectLog("/r") })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── guarded writes: outcome mapping ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a guarded write 200 yields Ok with the decoded payload`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"abc"}""".toByteArray())
|
|
||||||
val outcome = client.gitCommit("/r", "msg")
|
|
||||||
assertTrue(outcome is GitWriteOutcome.Ok)
|
|
||||||
assertEquals("abc", (outcome as GitWriteOutcome.Ok).payload.commit)
|
|
||||||
// The write stamped an Origin.
|
|
||||||
assertTrue(transport.recordedRequests.last().headers.containsKey(HeaderName.ORIGIN))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `403 disabled and 409 both surface Rejected with the safe error string`() = runTest {
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.POST, url = "$BASE/projects/worktree",
|
|
||||||
status = 403, body = """{"error":"Worktree creation is disabled."}""".toByteArray(),
|
|
||||||
)
|
|
||||||
val disabled = client.createWorktree("/r", "b", null)
|
|
||||||
assertEquals(GitWriteOutcome.Rejected(403, "Worktree creation is disabled."), disabled)
|
|
||||||
|
|
||||||
transport.queueSuccess(
|
|
||||||
method = HttpMethod.DELETE, url = "$BASE/projects/worktree",
|
|
||||||
status = 409, body = """{"error":"Worktree has uncommitted changes; force required."}""".toByteArray(),
|
|
||||||
)
|
|
||||||
val dirty = client.removeWorktree("/r", "/r/x", false)
|
|
||||||
assertEquals(GitWriteOutcome.Rejected(409, "Worktree has uncommitted changes; force required."), dirty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `429 yields RateLimited and never auto-retries`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 429, body = """{"error":"Too many requests."}""".toByteArray())
|
|
||||||
assertEquals(GitWriteOutcome.RateLimited, client.gitPush("/r"))
|
|
||||||
assertEquals(1, transport.recordedRequests.size) // exactly one attempt
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a stage 200 decodes staged and count and threads the files body`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true,"staged":true,"count":2}""".toByteArray())
|
|
||||||
val outcome = client.gitStage("/r", listOf("a", "b"), stage = true)
|
|
||||||
assertTrue(outcome is GitWriteOutcome.Ok)
|
|
||||||
assertEquals(2, (outcome as GitWriteOutcome.Ok).payload.count)
|
|
||||||
assertEquals("""{"path":"/r","files":["a","b"],"stage":true}""", transport.recordedRequests.last().body?.decodeToString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.DisplayName
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import wang.yaojia.webterm.api.models.QueueWriteOutcome
|
|
||||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status → outcome mapping for the W2 prompt queue, read off the server handlers
|
|
||||||
* (`src/server.ts` ~605/644/654):
|
|
||||||
*
|
|
||||||
* POST: 200 `{length}` → [QueueWriteOutcome.Ok] · 409 `{error:"full"}` → `Full` ·
|
|
||||||
* 413 → `TooLarge` · 503 `{error:"queue disabled"}` → `Disabled` ·
|
|
||||||
* 404 `{error:"unknown"|"exited"}` → `SessionGone` · 429 (empty) → `RateLimited` ·
|
|
||||||
* 400/403/anything else → `Rejected(status, safe error)`.
|
|
||||||
* DELETE: 200 `{length:0}` → `Ok(0)` · 404 → `SessionGone` · 403 → `Rejected`.
|
|
||||||
* GET: 200 → snapshot · 404 → [ApiClientError.SessionNotFound].
|
|
||||||
*
|
|
||||||
* 429 comes from the shared `QUEUE_RATE_MAX = 20`/min/IP bucket and is a DISTINCT outcome precisely
|
|
||||||
* so no caller auto-retries into it (the `GitWriteOutcome.RateLimited` precedent).
|
|
||||||
*/
|
|
||||||
@DisplayName("W2 queue — status mapping")
|
|
||||||
class ApiClientQueueTest {
|
|
||||||
private companion object {
|
|
||||||
const val BASE = "http://h:3000"
|
|
||||||
val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555")
|
|
||||||
}
|
|
||||||
|
|
||||||
private val transport = FakeHttpTransport()
|
|
||||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
|
||||||
private val queueUrl = "$BASE/live-sessions/$ID/queue"
|
|
||||||
|
|
||||||
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
|
|
||||||
|
|
||||||
private fun queuePost(status: Int, body: String = "") =
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, status = status, body = body.toByteArray())
|
|
||||||
|
|
||||||
private fun queueDelete(status: Int, body: String = "") =
|
|
||||||
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, status = status, body = body.toByteArray())
|
|
||||||
|
|
||||||
private suspend fun enqueue(text: String = "do the thing") = client.enqueueFollowup(ID, text, appendEnter = true)
|
|
||||||
|
|
||||||
// ── POST ──────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a 200 yields Ok carrying the new depth`() = runTest {
|
|
||||||
queuePost(200, """{"length":3}""")
|
|
||||||
assertEquals(QueueWriteOutcome.Ok(3), enqueue())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a 200 with a garbled body still succeeds with an unknown depth of zero`() = runTest {
|
|
||||||
queuePost(200, "not json")
|
|
||||||
assertEquals(QueueWriteOutcome.Ok(0), enqueue())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `409 full 413 too-large and 503 disabled are distinct typed outcomes`() = runTest {
|
|
||||||
queuePost(409, """{"error":"full"}""")
|
|
||||||
assertEquals(QueueWriteOutcome.Full, enqueue())
|
|
||||||
|
|
||||||
queuePost(413, """{"error":"text too large"}""")
|
|
||||||
assertEquals(QueueWriteOutcome.TooLarge, enqueue())
|
|
||||||
|
|
||||||
queuePost(503, """{"error":"queue disabled"}""")
|
|
||||||
assertEquals(QueueWriteOutcome.Disabled, enqueue())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `404 unknown or exited is SessionGone`() = runTest {
|
|
||||||
queuePost(404, """{"error":"unknown"}""")
|
|
||||||
assertEquals(QueueWriteOutcome.SessionGone, enqueue())
|
|
||||||
|
|
||||||
queuePost(404, """{"error":"exited"}""")
|
|
||||||
assertEquals(QueueWriteOutcome.SessionGone, enqueue())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `429 from the shared 20-per-minute bucket is RateLimited and nothing is retried`() = runTest {
|
|
||||||
queuePost(429)
|
|
||||||
assertEquals(QueueWriteOutcome.RateLimited, enqueue())
|
|
||||||
// Exactly ONE request went out — a rate-limited enqueue must never auto-retry.
|
|
||||||
assertEquals(1, transport.recordedRequests.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `400 and 403 surface Rejected with the server's safe message`() = runTest {
|
|
||||||
queuePost(400, """{"error":"text must be a non-empty string"}""")
|
|
||||||
assertEquals(QueueWriteOutcome.Rejected(400, "text must be a non-empty string"), enqueue())
|
|
||||||
|
|
||||||
// The Origin guard 403s with an EMPTY body — message degrades to null, never throws.
|
|
||||||
queuePost(403)
|
|
||||||
assertEquals(QueueWriteOutcome.Rejected(403, null), enqueue())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an odd non-error status is UnexpectedStatus`() = runTest {
|
|
||||||
queuePost(302)
|
|
||||||
assertTrue(errorOf { enqueue() } is ApiClientError.UnexpectedStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an empty prompt is rejected before any network IO`() = runTest {
|
|
||||||
assertEquals(ApiClientError.QueueTextEmpty, errorOf { client.enqueueFollowup(ID, "", appendEnter = true) })
|
|
||||||
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DELETE ────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `clearQueue maps 200 404 403 and 429`() = runTest {
|
|
||||||
queueDelete(200, """{"length":0}""")
|
|
||||||
assertEquals(QueueWriteOutcome.Ok(0), client.clearQueue(ID))
|
|
||||||
|
|
||||||
queueDelete(404)
|
|
||||||
assertEquals(QueueWriteOutcome.SessionGone, client.clearQueue(ID))
|
|
||||||
|
|
||||||
queueDelete(403)
|
|
||||||
assertEquals(QueueWriteOutcome.Rejected(403, null), client.clearQueue(ID))
|
|
||||||
|
|
||||||
queueDelete(429)
|
|
||||||
assertEquals(QueueWriteOutcome.RateLimited, client.clearQueue(ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET ───────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `queue maps 404 to SessionNotFound and a garbled 200 to InvalidResponseBody`() = runTest {
|
|
||||||
transport.queueSuccess(url = queueUrl, status = 404)
|
|
||||||
assertEquals(ApiClientError.SessionNotFound, errorOf { client.queue(ID) })
|
|
||||||
|
|
||||||
transport.queueSuccess(url = queueUrl, body = "[]".toByteArray())
|
|
||||||
assertEquals(ApiClientError.InvalidResponseBody, errorOf { client.queue(ID) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) for the W5 git surface: the two NEW reads
|
|
||||||
* (`/projects/pr`, `/projects/log`) carry **no** Origin; the six writes (worktree×3, git×3) carry a
|
|
||||||
* byte-equal Origin and a JSON body — including a `DELETE /projects/worktree` that carries a body
|
|
||||||
* (the highest-risk integration gotcha). A route reclassified read↔write turns this red.
|
|
||||||
*/
|
|
||||||
class GitRouteShapeTest {
|
|
||||||
private companion object {
|
|
||||||
const val BASE = "http://192.168.1.5:3000"
|
|
||||||
const val ORIGIN = "http://192.168.1.5:3000"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val transport = FakeHttpTransport()
|
|
||||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
|
||||||
|
|
||||||
private fun last(): HttpRequest = transport.recordedRequests.last()
|
|
||||||
|
|
||||||
private fun assertGuarded(r: HttpRequest) =
|
|
||||||
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
|
|
||||||
|
|
||||||
private fun assertReadOnly(r: HttpRequest) =
|
|
||||||
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
|
|
||||||
|
|
||||||
// ── reads: no Origin, correct verb + strict-encoded query ────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `projectPr is a read-only GET with a strict-encoded path and no Origin`() = runTest {
|
|
||||||
val path = "/home/me/my repo/a+b&c"
|
|
||||||
val url = "$BASE/projects/pr?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c"
|
|
||||||
transport.queueSuccess(url = url, body = """{"availability":"no-pr"}""".toByteArray())
|
|
||||||
|
|
||||||
client.projectPr(path)
|
|
||||||
|
|
||||||
val r = last()
|
|
||||||
assertEquals(HttpMethod.GET, r.method)
|
|
||||||
assertEquals(url, r.url)
|
|
||||||
assertReadOnly(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `projectLog omits n when null and appends a clamped n when set`() = runTest {
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp", body = """{"commits":[],"truncated":false}""".toByteArray())
|
|
||||||
client.projectLog("/p", n = null)
|
|
||||||
assertEquals("$BASE/projects/log?path=%2Fp", last().url)
|
|
||||||
assertReadOnly(last())
|
|
||||||
|
|
||||||
// n above GIT_LOG_MAX (50) clamps to 50; below 1 clamps to 1.
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=50", body = """{"commits":[],"truncated":false}""".toByteArray())
|
|
||||||
client.projectLog("/p", n = 999)
|
|
||||||
assertEquals("$BASE/projects/log?path=%2Fp&n=50", last().url)
|
|
||||||
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=1", body = """{"commits":[],"truncated":false}""".toByteArray())
|
|
||||||
client.projectLog("/p", n = 0)
|
|
||||||
assertEquals("$BASE/projects/log?path=%2Fp&n=1", last().url)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── writes: Origin stamped, correct verb, JSON body ──────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `createWorktree is a guarded POST with a path-branch-base body`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
|
||||||
client.createWorktree("/repo", "feat/x", base = "main")
|
|
||||||
|
|
||||||
val r = last()
|
|
||||||
assertEquals(HttpMethod.POST, r.method)
|
|
||||||
assertEquals("$BASE/projects/worktree", r.url)
|
|
||||||
assertGuarded(r)
|
|
||||||
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
|
|
||||||
assertEquals("""{"path":"/repo","branch":"feat/x","base":"main"}""", r.body?.decodeToString())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `createWorktree omits base when null`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
|
||||||
client.createWorktree("/repo", "feat/x", base = null)
|
|
||||||
assertEquals("""{"path":"/repo","branch":"feat/x"}""", last().body?.decodeToString())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `removeWorktree is a guarded DELETE that CARRIES a JSON body`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
|
||||||
client.removeWorktree("/repo", "/repo-worktrees/x", force = true)
|
|
||||||
|
|
||||||
val r = last()
|
|
||||||
assertEquals(HttpMethod.DELETE, r.method)
|
|
||||||
assertEquals("$BASE/projects/worktree", r.url)
|
|
||||||
assertGuarded(r)
|
|
||||||
assertNotNull(r.body, "DELETE /projects/worktree MUST carry a request body")
|
|
||||||
assertEquals("""{"path":"/repo","worktreePath":"/repo-worktrees/x","force":true}""", r.body?.decodeToString())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `pruneWorktrees is a guarded POST with a path body`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true,"pruned":[]}""".toByteArray())
|
|
||||||
client.pruneWorktrees("/repo")
|
|
||||||
|
|
||||||
val r = last()
|
|
||||||
assertEquals(HttpMethod.POST, r.method)
|
|
||||||
assertEquals("$BASE/projects/worktree/prune", r.url)
|
|
||||||
assertGuarded(r)
|
|
||||||
assertEquals("""{"path":"/repo"}""", r.body?.decodeToString())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `gitStage commit push are guarded POSTs with exact bodies`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
|
|
||||||
client.gitStage("/repo", listOf("a.kt", "b.kt"), stage = true)
|
|
||||||
assertEquals("$BASE/projects/git/stage", last().url)
|
|
||||||
assertGuarded(last())
|
|
||||||
assertEquals("""{"path":"/repo","files":["a.kt","b.kt"],"stage":true}""", last().body?.decodeToString())
|
|
||||||
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"x"}""".toByteArray())
|
|
||||||
client.gitCommit("/repo", "a message")
|
|
||||||
assertEquals("""{"path":"/repo","message":"a message"}""", last().body?.decodeToString())
|
|
||||||
assertGuarded(last())
|
|
||||||
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
|
|
||||||
client.gitPush("/repo")
|
|
||||||
assertEquals("$BASE/projects/git/push", last().url)
|
|
||||||
assertEquals("""{"path":"/repo"}""", last().body?.decodeToString())
|
|
||||||
assertGuarded(last())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `every guarded write carries Origin and every read does not (batch invariant)`() = runTest {
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"ok"}""".toByteArray())
|
|
||||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", body = """{"commits":[],"truncated":false}""".toByteArray())
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
|
||||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true}""".toByteArray())
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true}""".toByteArray())
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
|
|
||||||
|
|
||||||
client.projectPr("/r"); client.projectLog("/r", null)
|
|
||||||
assertReadOnly(transport.recordedRequests[0])
|
|
||||||
assertReadOnly(transport.recordedRequests[1])
|
|
||||||
|
|
||||||
client.createWorktree("/r", "b", null)
|
|
||||||
client.removeWorktree("/r", "/r/x", false)
|
|
||||||
client.pruneWorktrees("/r")
|
|
||||||
client.gitStage("/r", listOf("f"), true)
|
|
||||||
client.gitCommit("/r", "m")
|
|
||||||
client.gitPush("/r")
|
|
||||||
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* E1 (narrowed, F5) · The 401 split declared AT the route (mirrors iOS `UnauthorizedPolicy`).
|
|
||||||
*
|
|
||||||
* **Route-defined 401 = exactly the four `/projects/git/…` writes, and only because of the ONE
|
|
||||||
* server line that can produce it:** `src/http/git-ops.ts:108` turns a HOST-side git credential
|
|
||||||
* failure into `{status:401, error:"Push authentication required on the host."}`. That classifier is
|
|
||||||
* reached only from `stageFiles`/`commit`/`push`/`fetch`.
|
|
||||||
*
|
|
||||||
* **The worktree trio is NOT route-defined.** `createWorktree`/`removeWorktree`/`pruneWorktrees`
|
|
||||||
* land in `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500),
|
|
||||||
* and `src/server.ts:1182-1260` adds none. Pinning them ROUTE_DEFINED meant that on a token-gated
|
|
||||||
* host the GATE's 401 surfaced as a git rejection instead of the token flow.
|
|
||||||
*
|
|
||||||
* Every other 401 the server can emit belongs to the gate: `src/server.ts:425` (`/auth` invalid),
|
|
||||||
* `:472` (the gate itself) and `:1453`/`:1463` (the WS upgrade) — none of which is a route here.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `route-defined 401 is exactly the four git-ops writes, everything else is the gate`() {
|
|
||||||
val gitOpsWrites = listOf(
|
|
||||||
Endpoints.gitStage("/r", listOf("f"), true),
|
|
||||||
Endpoints.gitCommit("/r", "m"),
|
|
||||||
Endpoints.gitPush("/r"),
|
|
||||||
Endpoints.gitFetch("/r"),
|
|
||||||
)
|
|
||||||
val gateRoutes = listOf(
|
|
||||||
// The worktree trio: guarded writes, but the server has no 401 of its own for them.
|
|
||||||
Endpoints.createWorktree("/r", "b", null),
|
|
||||||
Endpoints.removeWorktree("/r", "/r/x", false),
|
|
||||||
Endpoints.pruneWorktrees("/r"),
|
|
||||||
Endpoints.liveSessions(),
|
|
||||||
Endpoints.projects(),
|
|
||||||
Endpoints.projectLog("/r", null),
|
|
||||||
Endpoints.killSession(UUID.randomUUID()),
|
|
||||||
Endpoints.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(gitOpsWrites.all { it.unauthorizedPolicy == UnauthorizedPolicy.ROUTE_DEFINED })
|
|
||||||
gateRoutes.forEach {
|
|
||||||
assertEquals(
|
|
||||||
UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
|
||||||
it.unauthorizedPolicy,
|
|
||||||
"${it.method} ${it.path} has no server-side 401 of its own — a 401 there IS the token gate",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.DisplayName
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the W2 prompt-queue trio
|
|
||||||
* (`src/server.ts` ~605/644/654):
|
|
||||||
*
|
|
||||||
* - `GET /live-sessions/:id/queue` — read-only ⇒ MUST NOT carry Origin;
|
|
||||||
* - `POST /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin + JSON body;
|
|
||||||
* - `DELETE /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin, and (unlike
|
|
||||||
* `DELETE /projects/worktree`, the precedent that DOES carry a body) MUST NOT carry a body — the
|
|
||||||
* server handler reads nothing but `:id` and clears the whole queue.
|
|
||||||
*
|
|
||||||
* A route reclassified read↔write turns this red instead of silently shipping either a CSWSH hole or a
|
|
||||||
* write the server 403s.
|
|
||||||
*
|
|
||||||
* The enqueued text is RAW KEYBOARD INPUT for a shell (invariant #9): it must survive the JSON body
|
|
||||||
* **verbatim** — no trim, no escape-rewrite, no Unicode normalisation, and no client-side `\r`
|
|
||||||
* (`appendEnter` is a flag the SERVER materialises).
|
|
||||||
*/
|
|
||||||
@DisplayName("W2 queue routes — shape, Origin policy, verbatim body")
|
|
||||||
class QueueRouteShapeTest {
|
|
||||||
private companion object {
|
|
||||||
const val BASE = "http://192.168.1.5:3000"
|
|
||||||
const val ORIGIN = "http://192.168.1.5:3000"
|
|
||||||
val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555")
|
|
||||||
}
|
|
||||||
|
|
||||||
private val transport = FakeHttpTransport()
|
|
||||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
|
||||||
|
|
||||||
private val queueUrl = "$BASE/live-sessions/$ID/queue"
|
|
||||||
|
|
||||||
private fun last(): HttpRequest = transport.recordedRequests.last()
|
|
||||||
|
|
||||||
private fun assertGuarded(r: HttpRequest) =
|
|
||||||
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
|
|
||||||
|
|
||||||
private fun assertReadOnly(r: HttpRequest) =
|
|
||||||
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
|
|
||||||
|
|
||||||
/** Parse the recorded request body back out of JSON — proves round-trip byte-exactness without
|
|
||||||
* pinning any particular escaping strategy. */
|
|
||||||
private fun bodyText(r: HttpRequest): String? =
|
|
||||||
Json.parseToJsonElement(r.body!!.decodeToString()).jsonObject["text"]?.jsonPrimitive?.content
|
|
||||||
|
|
||||||
// ── GET — read-only, no Origin ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `queue is a read-only GET with no Origin and no body`() = runTest {
|
|
||||||
transport.queueSuccess(url = queueUrl, body = """{"length":2,"items":["a","b"]}""".toByteArray())
|
|
||||||
|
|
||||||
val snapshot = client.queue(ID)
|
|
||||||
|
|
||||||
val r = last()
|
|
||||||
assertEquals(HttpMethod.GET, r.method)
|
|
||||||
assertEquals(queueUrl, r.url)
|
|
||||||
assertReadOnly(r)
|
|
||||||
assertNull(r.body, "a GET must not carry a body")
|
|
||||||
assertEquals(2, snapshot.length)
|
|
||||||
assertEquals(listOf("a", "b"), snapshot.items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST — guarded, JSON body ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `enqueueFollowup is a guarded POST with a text-appendEnter body`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
|
|
||||||
|
|
||||||
client.enqueueFollowup(ID, "run the tests", appendEnter = true)
|
|
||||||
|
|
||||||
val r = last()
|
|
||||||
assertEquals(HttpMethod.POST, r.method)
|
|
||||||
assertEquals(queueUrl, r.url)
|
|
||||||
assertGuarded(r)
|
|
||||||
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
|
|
||||||
assertEquals("""{"text":"run the tests","appendEnter":true}""", r.body?.decodeToString())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `appendEnter false is sent verbatim and the client never appends CR itself`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
|
|
||||||
|
|
||||||
client.enqueueFollowup(ID, "no enter", appendEnter = false)
|
|
||||||
|
|
||||||
assertEquals("""{"text":"no enter","appendEnter":false}""", last().body?.decodeToString())
|
|
||||||
assertEquals("no enter", bodyText(last()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `control characters and CJK survive the body byte-exactly`() = runTest {
|
|
||||||
// ESC[A (up-arrow), a literal TAB, ETX (Ctrl-C), CR, a stray NUL, CJK, an emoji, and
|
|
||||||
// deliberate surrounding whitespace that must NOT be trimmed.
|
|
||||||
val raw = " \u001B[A\t\u0003 \u0000 \u4F60\u597D\uFF0C\u4E16\u754C \uD83C\uDF0F caf\u00E9\r\n "
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
|
|
||||||
|
|
||||||
client.enqueueFollowup(ID, raw, appendEnter = false)
|
|
||||||
|
|
||||||
assertEquals(raw, bodyText(last()), "queued prompt bytes must travel verbatim (invariant #9)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DELETE — guarded, and (unlike DELETE /projects/worktree) body-less ────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `clearQueue is a guarded DELETE with NO body`() = runTest {
|
|
||||||
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray())
|
|
||||||
|
|
||||||
client.clearQueue(ID)
|
|
||||||
|
|
||||||
val r = last()
|
|
||||||
assertEquals(HttpMethod.DELETE, r.method)
|
|
||||||
assertEquals(queueUrl, r.url)
|
|
||||||
assertGuarded(r)
|
|
||||||
assertNull(r.body, "DELETE /live-sessions/:id/queue takes no body (server reads only :id)")
|
|
||||||
assertFalse(r.headers.containsKey(HeaderName.CONTENT_TYPE), "no body ⇒ no Content-Type")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the batch invariant ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `only the two writes carry Origin (batch invariant)`() = runTest {
|
|
||||||
transport.queueSuccess(url = queueUrl, body = """{"length":0,"items":[]}""".toByteArray())
|
|
||||||
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
|
|
||||||
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray())
|
|
||||||
|
|
||||||
client.queue(ID)
|
|
||||||
client.enqueueFollowup(ID, "x", appendEnter = true)
|
|
||||||
client.clearQueue(ID)
|
|
||||||
|
|
||||||
assertReadOnly(transport.recordedRequests[0])
|
|
||||||
assertGuarded(transport.recordedRequests[1])
|
|
||||||
assertGuarded(transport.recordedRequests[2])
|
|
||||||
assertNotNull(transport.recordedRequests[1].body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `session id is serialized lowercase in the path`() = runTest {
|
|
||||||
val upper = UUID.fromString("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")
|
|
||||||
val url = "$BASE/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue"
|
|
||||||
transport.queueSuccess(url = url, body = """{"length":0,"items":[]}""".toByteArray())
|
|
||||||
|
|
||||||
client.queue(upper)
|
|
||||||
|
|
||||||
assertTrue(last().url.endsWith("/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.DisplayName
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import wang.yaojia.webterm.api.models.GitLogResult
|
|
||||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
|
||||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the two routes the v0.6 git panel added
|
|
||||||
* after the last Android commit: `GET /projects/worktree/state` (read) and `POST /projects/git/fetch`
|
|
||||||
* (write). A route reclassified read↔write turns this red rather than silently shipping either a CSWSH
|
|
||||||
* hole or a write the server will 403.
|
|
||||||
*
|
|
||||||
* Also pins the commit-log boundary arithmetic, which the server's own commit warns "fails in the
|
|
||||||
* dangerous direction" if shortcut to "the first N rows".
|
|
||||||
*/
|
|
||||||
@DisplayName("w6 routes — shape, Origin policy, and the unpushed boundary")
|
|
||||||
class W6RouteShapeTest {
|
|
||||||
private companion object {
|
|
||||||
const val BASE = "http://192.168.1.5:3000"
|
|
||||||
const val ORIGIN = "http://192.168.1.5:3000"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val transport = FakeHttpTransport()
|
|
||||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
|
||||||
|
|
||||||
private fun last(): HttpRequest = transport.recordedRequests.last()
|
|
||||||
|
|
||||||
// ── GET /projects/worktree/state — read-only, no Origin ──────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `worktreeState is a read-only GET with a strict-encoded path and no Origin`() = runTest {
|
|
||||||
val path = "/home/me/my repo/a+b&c"
|
|
||||||
transport.queueSuccess(
|
|
||||||
HttpMethod.GET,
|
|
||||||
"$BASE/projects/worktree/state?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c",
|
|
||||||
200,
|
|
||||||
body = """{"path":"$path","branch":"wt","dirtyCount":2}""".toByteArray(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val state = client.worktreeState(path)
|
|
||||||
|
|
||||||
val r = last()
|
|
||||||
assertEquals(HttpMethod.GET, r.method)
|
|
||||||
assertFalse(
|
|
||||||
r.headers.containsKey(HeaderName.ORIGIN),
|
|
||||||
"a read must NOT stamp Origin — Origin is the CSWSH marker for state-changing routes only",
|
|
||||||
)
|
|
||||||
assertTrue(r.url.contains("%2Bb"), "a bare + would decode to a SPACE in Express's qs parser")
|
|
||||||
assertEquals(2, state.dirtyCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /projects/git/fetch — guarded, byte-equal Origin, body carries only the path ────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `gitFetch is a guarded POST that stamps a byte-equal Origin`() = runTest {
|
|
||||||
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true,"lastFetchMs":42}""".toByteArray())
|
|
||||||
|
|
||||||
val outcome = client.gitFetch("/repo")
|
|
||||||
|
|
||||||
val r = last()
|
|
||||||
assertEquals(HttpMethod.POST, r.method)
|
|
||||||
assertEquals(
|
|
||||||
ORIGIN,
|
|
||||||
r.headers[HeaderName.ORIGIN],
|
|
||||||
"fetch is state-changing (it moves refs/remotes and spawns a network git), so it must be guarded",
|
|
||||||
)
|
|
||||||
assertNotNull(r.body, "the path travels in a JSON body")
|
|
||||||
val body = r.body!!.decodeToString()
|
|
||||||
assertTrue(body.contains("\"path\""))
|
|
||||||
assertFalse(
|
|
||||||
body.contains("remote") || body.contains("refspec"),
|
|
||||||
"the remote is derived SERVER-side; a client must never be able to aim a fetch at an arbitrary URL",
|
|
||||||
)
|
|
||||||
assertEquals(42L, (outcome as GitWriteOutcome.Ok).payload.lastFetchMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a fetch that fails leaves lastFetchMs unknown rather than claiming it refreshed`() = runTest {
|
|
||||||
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true}""".toByteArray())
|
|
||||||
|
|
||||||
val outcome = client.gitFetch("/repo")
|
|
||||||
|
|
||||||
assertNull(
|
|
||||||
(outcome as GitWriteOutcome.Ok).payload.lastFetchMs,
|
|
||||||
"the server leaves the old value alone on failure, so the UI must keep saying stale",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fetch surfaces its own rate limit instead of retrying`() = runTest {
|
|
||||||
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 429, body = """{"error":"slow down"}""".toByteArray())
|
|
||||||
|
|
||||||
val outcome = client.gitFetch("/repo")
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
outcome is GitWriteOutcome.RateLimited,
|
|
||||||
"fetch has its OWN bucket so refreshes cannot eat the budget a real push needs",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a disabled or Origin-rejected fetch surfaces the server's safe message`() = runTest {
|
|
||||||
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 403, body = """{"error":"git ops disabled"}""".toByteArray())
|
|
||||||
|
|
||||||
val outcome = client.gitFetch("/repo")
|
|
||||||
|
|
||||||
// 403 is overloaded — kill-switch AND Origin failure both return it — so the message is
|
|
||||||
// surfaced rather than guessed at with a typed variant.
|
|
||||||
assertEquals("git ops disabled", (outcome as GitWriteOutcome.Rejected).message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the unpushed boundary (w6/G4) ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("the boundary sits after the LAST unpushed commit, even when they interleave")
|
|
||||||
fun boundaryHandlesInterleaving() {
|
|
||||||
// A backdated merge puts an unpushed commit BELOW a pushed one, because git log is date-ordered.
|
|
||||||
// "The first N rows" would mark the wrong set and call an unpushed commit pushed.
|
|
||||||
val log = GitLogResult(
|
|
||||||
commits = listOf(
|
|
||||||
commit("aaa", unpushed = true),
|
|
||||||
commit("bbb", unpushed = false),
|
|
||||||
commit("ccc", unpushed = true),
|
|
||||||
commit("ddd", unpushed = false),
|
|
||||||
),
|
|
||||||
upstream = "origin/main",
|
|
||||||
)
|
|
||||||
assertEquals(2, log.upstreamBoundaryIndex, "after index 2 — the last unpushed one")
|
|
||||||
assertEquals(2, log.unpushedCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("no upstream means NO boundary may be drawn at all")
|
|
||||||
fun noUpstreamNoBoundary() {
|
|
||||||
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = true)), upstream = null)
|
|
||||||
assertNull(
|
|
||||||
log.upstreamBoundaryIndex,
|
|
||||||
"null rather than -1, so a caller cannot mistake 'no boundary' for 'boundary before everything'",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("nothing unpushed means no boundary")
|
|
||||||
fun nothingUnpushed() {
|
|
||||||
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = false)), upstream = "origin/main")
|
|
||||||
assertNull(log.upstreamBoundaryIndex)
|
|
||||||
assertEquals(0, log.unpushedCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("an unknown unpushed flag is not counted as pushed")
|
|
||||||
fun unknownIsNotPushed() {
|
|
||||||
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = null)), upstream = "origin/main")
|
|
||||||
assertEquals(0, log.unpushedCount, "unknown is not 'unpushed'…")
|
|
||||||
assertNull(log.upstreamBoundaryIndex, "…and it must not invent a boundary either")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun commit(hash: String, unpushed: Boolean?) =
|
|
||||||
wang.yaojia.webterm.api.models.CommitLogEntry(hash = hash, at = 1L, subject = "s", unpushed = unpushed)
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,6 @@
|
|||||||
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
|
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import java.util.Properties
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
@@ -19,83 +17,6 @@ plugins {
|
|||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Release signing credentials — NEVER committed.
|
|
||||||
//
|
|
||||||
// Looked up in this order, first non-blank wins:
|
|
||||||
// 1. `android/keystore.properties` (the conventional path; see keystore.properties.example)
|
|
||||||
// 2. `android/local.properties` (already gitignored — the zero-setup local option)
|
|
||||||
// 3. `WEBTERM_RELEASE_*` env vars (CI)
|
|
||||||
//
|
|
||||||
// DEGRADATION CONTRACT: absent credentials leave debug (and the `benchmark` variant,
|
|
||||||
// which signs with the debug key) fully working, and make the RELEASE package task fail
|
|
||||||
// with RELEASE_SIGNING_HELP — never a silent `app-release-unsigned.apk`.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
val signingPropertyFiles = listOf("keystore.properties", "local.properties")
|
|
||||||
|
|
||||||
val signingProperties = Properties().apply {
|
|
||||||
// Later files must not override earlier ones, so load in reverse and let the
|
|
||||||
// higher-priority file overwrite.
|
|
||||||
signingPropertyFiles.reversed()
|
|
||||||
.map(rootProject::file)
|
|
||||||
.filter(File::exists)
|
|
||||||
.forEach { file -> file.inputStream().use(::load) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun signingCredential(propertyKey: String, envKey: String): String? =
|
|
||||||
(signingProperties.getProperty(propertyKey) ?: System.getenv(envKey))?.takeIf(String::isNotBlank)
|
|
||||||
|
|
||||||
val releaseStoreFile = signingCredential("webterm.release.storeFile", "WEBTERM_RELEASE_STORE_FILE")
|
|
||||||
val releaseStorePassword = signingCredential("webterm.release.storePassword", "WEBTERM_RELEASE_STORE_PASSWORD")
|
|
||||||
val releaseKeyAlias = signingCredential("webterm.release.keyAlias", "WEBTERM_RELEASE_KEY_ALIAS")
|
|
||||||
val releaseKeyPassword = signingCredential("webterm.release.keyPassword", "WEBTERM_RELEASE_KEY_PASSWORD")
|
|
||||||
|
|
||||||
// `storeFile` may be absolute or relative to `android/` — rootProject.file handles both.
|
|
||||||
val releaseKeystore = releaseStoreFile?.let(rootProject::file)
|
|
||||||
|
|
||||||
val hasAllReleaseCredentials =
|
|
||||||
releaseStoreFile != null &&
|
|
||||||
releaseStorePassword != null &&
|
|
||||||
releaseKeyAlias != null &&
|
|
||||||
releaseKeyPassword != null
|
|
||||||
|
|
||||||
val isReleaseSigningConfigured = hasAllReleaseCredentials && releaseKeystore?.exists() == true
|
|
||||||
|
|
||||||
// Two distinct failures deserve two distinct messages: "you have not set this up" and
|
|
||||||
// "you set it up but pointed it at a file that isn't there" have completely different fixes.
|
|
||||||
val MISSING_KEYSTORE_HELP = """
|
|
||||||
|:app release signing credentials were found, but the keystore file does not exist:
|
|
||||||
| ${releaseKeystore?.absolutePath}
|
|
||||||
|
|
|
||||||
|Fix `webterm.release.storeFile` (absolute, or relative to the `android/` directory), or
|
|
||||||
|point WEBTERM_RELEASE_STORE_FILE at the real file. Nothing was signed, and no unsigned
|
|
||||||
|APK was emitted.
|
|
||||||
""".trimMargin()
|
|
||||||
|
|
||||||
val UNCONFIGURED_SIGNING_HELP = """
|
|
||||||
|:app release signing is NOT configured — refusing to emit an unsigned release artifact.
|
|
||||||
|
|
|
||||||
|Provide the four credentials in ONE of these places (first match wins):
|
|
||||||
| 1. android/keystore.properties — copy android/keystore.properties.example.
|
|
||||||
| MAKE SURE `keystore.properties` is in android/.gitignore first.
|
|
||||||
| 2. android/local.properties — already gitignored; zero extra setup.
|
|
||||||
| 3. env: WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI)
|
|
||||||
|
|
|
||||||
|Keys (property form):
|
|
||||||
| webterm.release.storeFile=<path, relative to android/ or absolute>
|
|
||||||
| webterm.release.storePassword=<...>
|
|
||||||
| webterm.release.keyAlias=<...>
|
|
||||||
| webterm.release.keyPassword=<...>
|
|
||||||
|
|
|
||||||
|No keystore yet? Generate one OUTSIDE the repo:
|
|
||||||
| keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \
|
|
||||||
| -alias webterm -keyalg RSA -keysize 4096 -validity 10000
|
|
||||||
|Then record its SHA-256 in the App Links assetlinks.json (plan §8).
|
|
||||||
|
|
|
||||||
|Unaffected by this failure: `:app:assembleDebug`, `:app:assembleBenchmark`,
|
|
||||||
|`:app:assembleDebugAndroidTest`, every unit test, and R8 itself (which already ran).
|
|
||||||
""".trimMargin()
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "wang.yaojia.webterm"
|
namespace = "wang.yaojia.webterm"
|
||||||
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
|
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
|
||||||
@@ -107,87 +28,24 @@ android {
|
|||||||
applicationId = "wang.yaojia.webterm"
|
applicationId = "wang.yaojia.webterm"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
// ── Versioning policy ────────────────────────────────────────────────
|
|
||||||
// versionCode: a plain monotonic counter, +1 for EVERY artifact handed to
|
|
||||||
// anyone (Play track, internal APK, benchmark run that gets archived).
|
|
||||||
// Never derived from versionName — decoupling them is what lets a hotfix
|
|
||||||
// ship without renumbering. This is still 1 because no artifact has ever
|
|
||||||
// left this machine; the first distributed build takes 2.
|
|
||||||
// versionName: `<server-line>-alphaNN`. The Android client tracks the server's
|
|
||||||
// 0.1.x line (root package.json is 0.1.0). `-alpha01` is the honest state:
|
|
||||||
// the P0+P1 surface from ANDROID_CLIENT_PLAN §1 is implemented and JVM-tested,
|
|
||||||
// but NOTHING in DEVICE_QA_CHECKLIST.md has been signed off on real hardware.
|
|
||||||
// Drop to `-beta01` when the checklist's A34/A35 blocks pass on a device, and
|
|
||||||
// to plain `0.1.0` when the whole checklist is ticked.
|
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0-alpha01"
|
versionName = "0.1.0"
|
||||||
|
|
||||||
// Hilt instrumented testing REQUIRES a custom runner: AndroidJUnitRunner is the
|
|
||||||
// only hook that can swap the app-under-test's Application for the generated
|
|
||||||
// `HiltTestApplication` (the androidTest manifest cannot — it is merged into the
|
|
||||||
// TEST apk, not into the app under test). See android/README.md → "Instrumented tests".
|
|
||||||
testInstrumentationRunner = "wang.yaojia.webterm.HiltTestRunner"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
// Populated ONLY when credentials were found; an empty config would make AGP
|
|
||||||
// fall back to emitting `app-release-unsigned.apk`, which is exactly the silent
|
|
||||||
// failure this block exists to prevent (the release buildType leaves
|
|
||||||
// `signingConfig` null instead, and the guard task below fails the build).
|
|
||||||
if (isReleaseSigningConfigured) {
|
|
||||||
create("release") {
|
|
||||||
storeFile = rootProject.file(releaseStoreFile!!)
|
|
||||||
storePassword = releaseStorePassword
|
|
||||||
keyAlias = releaseKeyAlias
|
|
||||||
keyPassword = releaseKeyPassword
|
|
||||||
// v1 (JAR signing) is only needed below API 24 and is the weakest scheme —
|
|
||||||
// off. v2/v3 are left enabled and apksigner picks what the minSdk range
|
|
||||||
// actually requires: verified output for minSdk 29 is v1=false, v2=false,
|
|
||||||
// v3=true (every device that can install this understands v3, so v2 is
|
|
||||||
// redundant). Do not read v2=false as a misconfiguration.
|
|
||||||
enableV1Signing = false
|
|
||||||
enableV2Signing = true
|
|
||||||
enableV3Signing = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
// No applicationId suffix — keep it stable for deep-link testing (A32).
|
// No applicationId suffix — keep it stable for deep-link testing (A32).
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = false
|
||||||
// Resource shrinking requires minification; it is what strips the unused
|
|
||||||
// CameraX/ML-Kit/Firebase resources the 56 MB unminified APK carried.
|
|
||||||
isShrinkResources = true
|
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
signingConfig = signingConfigs.findByName("release")
|
|
||||||
}
|
|
||||||
// `benchmark` — what :macrobenchmark (A35) measures against. It must match the
|
|
||||||
// shipping variant in everything that affects performance (non-debuggable, R8'd,
|
|
||||||
// resource-shrunk) while needing NO release keystore, so it signs with the debug
|
|
||||||
// key. It is therefore also the variant that proves the R8 pipeline end-to-end
|
|
||||||
// (`:app:assembleBenchmark` produces a real, installable, minified APK).
|
|
||||||
create("benchmark") {
|
|
||||||
initWith(getByName("release"))
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
// Library modules only publish debug/release; resolve `benchmark` to release.
|
|
||||||
matchingFallbacks += listOf("release")
|
|
||||||
isDebuggable = false
|
|
||||||
// Injects <profileable android:shell="true"/> into the merged manifest, which
|
|
||||||
// macrobenchmark needs to read traces on API 29-30 (implicit from API 31).
|
|
||||||
// Done here BECAUSE AndroidManifest.xml must not carry a benchmark-only tag.
|
|
||||||
isProfileable = true
|
|
||||||
isMinifyEnabled = true
|
|
||||||
isShrinkResources = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,41 +114,9 @@ dependencies {
|
|||||||
testImplementation(libs.bundles.unit.test)
|
testImplementation(libs.bundles.unit.test)
|
||||||
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
|
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
|
||||||
testRuntimeOnly(libs.junit.platform.launcher)
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
|
|
||||||
// ── Instrumented tests (androidTest — device/emulator only; plan §7) ──────────
|
|
||||||
// Homes A34 (E2E against a real `npm start` host) and the Espresso/Compose half of
|
|
||||||
// A35. JUnit4 only: AndroidJUnitRunner cannot drive the JUnit5 platform.
|
|
||||||
androidTestImplementation(libs.bundles.android.instrumented.test)
|
|
||||||
// Compose UI assertions (gate banner / plan sheet / reconnect-banner precedence /
|
|
||||||
// session rows / continue-last banner). BOM-managed, same BOM as main.
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
|
||||||
// Supplies the empty host Activity that `createComposeRule()` launches into. Must be
|
|
||||||
// debugImplementation (it contributes a manifest entry to the app under test).
|
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
// Hilt instrumented testing: HiltAndroidRule + the generated HiltTestApplication.
|
|
||||||
androidTestImplementation(libs.hilt.android.testing)
|
|
||||||
kspAndroidTest(libs.hilt.compiler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules.
|
// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules.
|
||||||
// Instrumented (androidTest) tasks are NOT of type Test, so they keep JUnit4.
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Release-signing guard.
|
|
||||||
//
|
|
||||||
// Hooked as a doFirst on the PACKAGE task (not as a dependency) on purpose: packaging
|
|
||||||
// runs after dexing, so R8/resource-shrinking have fully completed and been reported by
|
|
||||||
// the time this fires. An unconfigured machine therefore still gets a real, verifiable
|
|
||||||
// R8 run — it just cannot produce a distributable artifact.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
if (!isReleaseSigningConfigured) {
|
|
||||||
val help = if (hasAllReleaseCredentials) MISSING_KEYSTORE_HELP else UNCONFIGURED_SIGNING_HELP
|
|
||||||
tasks.matching { it.name == "packageRelease" || it.name == "packageReleaseBundle" }
|
|
||||||
.configureEach {
|
|
||||||
doFirst { throw GradleException(help) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
121
android/app/proguard-rules.pro
vendored
121
android/app/proguard-rules.pro
vendored
@@ -1,117 +1,4 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# WebTerm :app — R8/ProGuard rules.
|
||||||
# WebTerm :app — R8 keep rules (release + benchmark; both isMinifyEnabled = true).
|
# Release is not minified yet (isMinifyEnabled = false); this file exists so the
|
||||||
#
|
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for
|
||||||
# DISCIPLINE: every rule below is justified, and rules that turned out to be
|
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later.
|
||||||
# UNNECESSARY are recorded as such rather than added "just in case". A superfluous
|
|
||||||
# -keep silently costs size and hides real reachability problems; a MISSING one
|
|
||||||
# crashes only in production. Both were checked against the merged configuration
|
|
||||||
# R8 actually used, which is dumped to:
|
|
||||||
# app/build/outputs/mapping/<variant>/configuration.txt
|
|
||||||
# Read that file before adding anything here — most of our stack already ships its
|
|
||||||
# own consumer rules, and duplicating them is how this file rots.
|
|
||||||
#
|
|
||||||
# ALREADY COVERED BY EMBEDDED CONSUMER RULES — do NOT re-declare here:
|
|
||||||
# · kotlinx.serialization — kotlinx-serialization-core-jvm ships
|
|
||||||
# META-INF/com.android.tools/r8/kotlinx-serialization-{common,r8}.pro, which keeps
|
|
||||||
# @Serializable classes' `Companion` fields, `serializer(...)`, object `INSTANCE`,
|
|
||||||
# the `$$serializer.descriptor` field, and RuntimeVisibleAnnotations (the R8
|
|
||||||
# full-mode fix). Verified present in 1.9.0. Our @Serializable models live in
|
|
||||||
# :wire-protocol / :api-client; the rules are global, so they are covered.
|
|
||||||
# · Hilt / Dagger — hilt-android + dagger AARs ship proguard.txt; all injection is
|
|
||||||
# generated code with compile-time references, no runtime reflection to preserve.
|
|
||||||
# · Tink — tink-android ships META-INF/proguard/protobuf.pro, keeping <fields> on
|
|
||||||
# subclasses of the shaded protobuf GeneratedMessageLite (the reflective lite
|
|
||||||
# runtime). Tink's key managers are registered explicitly by AeadConfig.register(),
|
|
||||||
# not discovered reflectively, so nothing else needs keeping.
|
|
||||||
# · FCM — firebase-messaging ships consumer rules, AND `.push.FcmService`,
|
|
||||||
# `.push.DenyBroadcastReceiver`, `.push.AllowTrampolineActivity`, `.MainActivity`
|
|
||||||
# and `.WebTermApp` are all declared in AndroidManifest.xml, for which AGP
|
|
||||||
# auto-generates keep rules (build/intermediates/aapt_proguard_file/.../aapt_rules.txt).
|
|
||||||
# Manifest-declared components therefore need no rule here — ever.
|
|
||||||
# · OkHttp / Okio, Compose, CameraX, ML Kit barcode, DataStore, navigation-compose,
|
|
||||||
# biometric — all ship their own consumer rules.
|
|
||||||
# · JNI — the default proguard-android-optimize.txt already contains
|
|
||||||
# `-keepclasseswithmembernames class * { native <methods>; }`, which covers the
|
|
||||||
# Termux emulator's native entry points even though we never fork a subprocess.
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
# ── Termux terminal emulator + view (the ONE thing that genuinely needs a rule) ──
|
|
||||||
#
|
|
||||||
# Why this is not "just in case":
|
|
||||||
# 1. The JitPack artifacts (com.github.termux.termux-app:terminal-{emulator,view},
|
|
||||||
# v0.118.0) ship NO consumer rules at all — nobody upstream has reasoned about R8
|
|
||||||
# for these modules, because Termux itself ships unminified.
|
|
||||||
# 2. :terminal-view deliberately binds to NON-API internals of the pinned version
|
|
||||||
# (plan §6.1 — TerminalView is `public final`, so we fork rather than subclass):
|
|
||||||
# · RemoteTerminalHostView writes and reads the public field
|
|
||||||
# `com.termux.view.TerminalView.mEmulator` directly (installEmulator /
|
|
||||||
# the TerminalViewClient callbacks / computeVerticalScrollRange).
|
|
||||||
# · TerminalResizeDriver calls `TerminalRenderer.getFontWidth()` and
|
|
||||||
# `getFontLineSpacing()` — public accessors verified with `javap -p`, but not
|
|
||||||
# part of any documented API surface.
|
|
||||||
# · TerminalScrollGesture reads `mEmulator` and drives KeyHandler.
|
|
||||||
# Field/method RENAMING alone would survive that (R8 rewrites both sides), but R8
|
|
||||||
# full mode also does class merging, member inlining and access relaxation across
|
|
||||||
# a library whose invariants we are already stretching. A wrong outcome here is a
|
|
||||||
# crash in the app's single core surface — the terminal — visible ONLY in release.
|
|
||||||
# 3. The cost is bounded and small: two Apache-2.0 modules, ~120 KB of classes.
|
|
||||||
#
|
|
||||||
# So: keep them whole. Revisit only with a device-verified minified build in hand.
|
|
||||||
-keep class com.termux.terminal.** { *; }
|
|
||||||
-keep class com.termux.view.** { *; }
|
|
||||||
|
|
||||||
|
|
||||||
# ── ML Kit / Firebase component discovery (reflective no-arg constructors) ───────
|
|
||||||
#
|
|
||||||
# OBSERVED, not speculative. Installing the minified `benchmark` APK on an API-35
|
|
||||||
# emulator and cold-starting it produced, with no rule here:
|
|
||||||
#
|
|
||||||
# W ComponentDiscovery: Invalid component registrar.
|
|
||||||
# Could not instantiate com.google.mlkit.common.internal.CommonComponentRegistrar
|
|
||||||
# ... at com.google.mlkit.common.internal.MlKitInitProvider.onCreate
|
|
||||||
# Caused by: java.lang.NoSuchMethodException:
|
|
||||||
# com.google.mlkit.common.internal.CommonComponentRegistrar.<init> []
|
|
||||||
# (and the same for com.google.mlkit.vision.common.internal.VisionCommonRegistrar)
|
|
||||||
#
|
|
||||||
# Firebase's ComponentDiscovery instantiates registrars named in <meta-data> by
|
|
||||||
# `getDeclaredConstructor()`. The class names survive, but R8 removed the no-arg
|
|
||||||
# constructors because nothing in the app calls them. The failure is a WARNING, not a
|
|
||||||
# crash: the app starts fine and the ML Kit component graph is simply never registered,
|
|
||||||
# so **QR pairing (A19 barcode scanning) silently stops working in minified builds
|
|
||||||
# only**. Exactly the release-only breakage this file has to prevent.
|
|
||||||
#
|
|
||||||
# `<init>();` (no access modifier) matches any visibility, which is what
|
|
||||||
# getDeclaredConstructor needs. Scoped to registrars, so it keeps ~a dozen tiny classes.
|
|
||||||
-keep class * implements com.google.firebase.components.ComponentRegistrar {
|
|
||||||
<init>();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── javax.naming does not exist on Android ──────────────────────────────────────
|
|
||||||
#
|
|
||||||
# R8 refuses to run without these two, so they are load-bearing for ANY minified build.
|
|
||||||
# They are NOT a benign suppression — read this before deleting them:
|
|
||||||
#
|
|
||||||
# :client-tls CertificateSummary.kt uses `javax.naming.ldap.LdapName` to pull the CN
|
|
||||||
# out of an RFC2253 DN. That package is JDK-only; it is absent from android.jar and
|
|
||||||
# from the ART bootclasspath. So on a real device `CertificateSummaryReader.commonName`
|
|
||||||
# throws NoClassDefFoundError, which is an Error and therefore slips straight through
|
|
||||||
# that function's `catch (_: Exception)` guard.
|
|
||||||
#
|
|
||||||
# The module is pure-JVM and its unit tests run on a real JDK where LdapName DOES
|
|
||||||
# exist, which is why the green JVM suite never caught it. Tracked as a handoff to
|
|
||||||
# :client-tls's owner (hand-parse the DN, or catch Throwable). This -dontwarn only
|
|
||||||
# stops R8 from failing the build over an unresolvable reference; it does not and
|
|
||||||
# cannot fix the device behaviour.
|
|
||||||
-dontwarn javax.naming.ldap.LdapName
|
|
||||||
-dontwarn javax.naming.ldap.Rdn
|
|
||||||
|
|
||||||
|
|
||||||
# ── Crash triage ────────────────────────────────────────────────────────────────
|
|
||||||
# Keep line numbers so release stack traces are usable, and rename the source-file
|
|
||||||
# attribute to a single constant so no original filenames leak in the APK. Retrace
|
|
||||||
# release traces with app/build/outputs/mapping/release/mapping.txt (ARCHIVE IT with
|
|
||||||
# every distributed artifact — without it a crash report is unreadable).
|
|
||||||
-keepattributes SourceFile,LineNumberTable
|
|
||||||
-renamesourcefileattribute SourceFile
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
package wang.yaojia.webterm
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.runner.AndroidJUnitRunner
|
|
||||||
import dagger.hilt.android.testing.HiltTestApplication
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The instrumentation runner named by `:app`'s `testInstrumentationRunner`.
|
|
||||||
*
|
|
||||||
* A custom runner is **required**, not stylistic: [newApplication] is the only hook that can replace the
|
|
||||||
* app-under-test's [Application] with the generated [HiltTestApplication]. Setting `android:name` in the
|
|
||||||
* androidTest manifest cannot do it — that manifest is merged into the *test* APK, not into the app under
|
|
||||||
* test, so the real [WebTermApp] would still be installed and `@HiltAndroidTest` injection would fail.
|
|
||||||
*
|
|
||||||
* `assembleDebugAndroidTest` builds fine without this class (the runner name is only a manifest value);
|
|
||||||
* an actual instrumentation *run* would fail with `ClassNotFoundException`. See `android/README.md` →
|
|
||||||
* "Instrumented tests".
|
|
||||||
*
|
|
||||||
* Note that installing [HiltTestApplication] replaces [WebTermApp], so anything [WebTermApp] does in
|
|
||||||
* `onCreate` (push registration, notification channels) does NOT happen under instrumentation. That is
|
|
||||||
* deliberate — a test must opt into those via Hilt test modules rather than inherit them.
|
|
||||||
*/
|
|
||||||
public class HiltTestRunner : AndroidJUnitRunner() {
|
|
||||||
|
|
||||||
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
|
|
||||||
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package wang.yaojia.webterm.e2e
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
|
||||||
import wang.yaojia.webterm.wire.ServerMessage
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **F9 — bad `Origin` on the WS upgrade is rejected. The one non-skippable defence.**
|
|
||||||
*
|
|
||||||
* This app hands a full shell to anyone who can reach the port, and Origin validation on the WebSocket
|
|
||||||
* handshake is the single thing standing between that shell and a malicious page open in the user's
|
|
||||||
* browser (Cross-Site WebSocket Hijacking). TECH_DOC §7 and plan §8 both call it non-negotiable, and it is
|
|
||||||
* the only security control in this codebase that cannot be compensated for elsewhere.
|
|
||||||
*
|
|
||||||
* A unit test cannot verify it: the check lives in the *server's* upgrade handler, so the only honest proof
|
|
||||||
* is a real socket carrying a real foreign `Origin` and being refused. That is what this class does, from
|
|
||||||
* the device, against the real server. It also pins the two neighbouring cases the check must not get
|
|
||||||
* wrong — a MISSING Origin (default-deny) and the correct Origin (must still work, or the "defence" is
|
|
||||||
* just a broken client).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class CswshDefenceE2eTest {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The attack: a page on another origin opens `ws://<lan-ip>:3000/term`. The browser stamps ITS origin,
|
|
||||||
* which is not in the server's allow-list, and the handshake must be refused with 401 — before any
|
|
||||||
* frame can be exchanged.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun aForeignOriginIsRefusedOnTheWsUpgrade() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val socket = E2eSocket.dial(host, origin = FOREIGN_ORIGIN)
|
|
||||||
try {
|
|
||||||
val failure = socket.failureOrNull()
|
|
||||||
|
|
||||||
assertNotNull(
|
|
||||||
"the handshake must FAIL with a foreign Origin — it opened instead, which is a CSWSH hole",
|
|
||||||
failure,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"the refusal must be a 401 (src/server.ts writes it before destroying the socket)",
|
|
||||||
HTTP_UNAUTHORIZED,
|
|
||||||
socket.handshakeStatusOrNull(),
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
socket.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default-deny: a non-browser client that sends no `Origin` at all must also be refused. `curl` and
|
|
||||||
* scripts land here, and `isOriginAllowed(undefined, …)` returning false is the single central policy
|
|
||||||
* point — an allow-if-absent would make the whole check trivially bypassable.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun aMissingOriginIsRefusedOnTheWsUpgrade() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val socket = E2eSocket.dial(host, origin = null)
|
|
||||||
try {
|
|
||||||
assertNotNull(
|
|
||||||
"an absent Origin must be refused (default-deny), not treated as trusted",
|
|
||||||
socket.failureOrNull(),
|
|
||||||
)
|
|
||||||
assertEquals(HTTP_UNAUTHORIZED, socket.handshakeStatusOrNull())
|
|
||||||
} finally {
|
|
||||||
socket.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The control: the SAME dial with the endpoint-derived `Origin` (byte-equal to what
|
|
||||||
* `HostEndpoint.originHeader` produces, and to what `src/http/origin.ts` expects) must open and attach.
|
|
||||||
* Without this, the two refusals above would also be satisfied by a server that rejects everything.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun theEndpointDerivedOriginIsAccepted() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val socket = E2eSocket.dial(host)
|
|
||||||
var sessionId: String? = null
|
|
||||||
try {
|
|
||||||
assertTrue(
|
|
||||||
"the production Origin must be accepted, got failure=${socket.failureOrNull()}",
|
|
||||||
socket.failureOrNull() == null,
|
|
||||||
)
|
|
||||||
socket.send(ClientMessage.Attach(sessionId = null))
|
|
||||||
sessionId = (socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached)
|
|
||||||
.sessionId
|
|
||||||
} finally {
|
|
||||||
socket.close()
|
|
||||||
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/** Deliberately not a real host: the point is that it is not in the server's allow-list. */
|
|
||||||
const val FOREIGN_ORIGIN = "http://evil.example"
|
|
||||||
const val HTTP_UNAUTHORIZED = 401
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
package wang.yaojia.webterm.e2e
|
|
||||||
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.WebSocket
|
|
||||||
import okhttp3.WebSocketListener
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
|
||||||
import wang.yaojia.webterm.wire.MessageCodec
|
|
||||||
import wang.yaojia.webterm.wire.ServerMessage
|
|
||||||
import wang.yaojia.webterm.wire.WireConstants
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One live WS connection to a real WebTerm server, driven synchronously so an E2E test reads as a
|
|
||||||
* transcript.
|
|
||||||
*
|
|
||||||
* It deliberately does NOT reuse `:transport-okhttp`'s [wang.yaojia.webterm.transport.OkHttpTermTransport]:
|
|
||||||
* that class stamps `Origin` from the endpoint by construction, which is exactly the thing the F9 test has
|
|
||||||
* to be able to falsify. So the upgrade request is built here, and `Origin` is a parameter. Everything
|
|
||||||
* else that matters IS production code — [MessageCodec] encodes and decodes every frame, so these tests
|
|
||||||
* are a check of the frozen wire contract against the real server, not of a parallel test codec.
|
|
||||||
*/
|
|
||||||
internal class E2eSocket private constructor(
|
|
||||||
private val opened: CountDownLatch,
|
|
||||||
private val closed: CountDownLatch,
|
|
||||||
) : WebSocketListener() {
|
|
||||||
|
|
||||||
private val frames = LinkedBlockingQueue<String>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every `output` payload, accumulated as frames ARRIVE rather than as a test pulls them.
|
|
||||||
*
|
|
||||||
* This is not an optimisation, it is a correctness requirement. On a re-attach the server replays the
|
|
||||||
* ring buffer INSIDE `manager.handleAttach`, i.e. it puts the replay `output` frame on the wire BEFORE
|
|
||||||
* the `attached` confirmation. A reader that scanned the queue for `attached` first would consume and
|
|
||||||
* discard the replay on the way past — which is exactly how the first run of the replay test reported
|
|
||||||
* "0 chars" against a server that had sent hundreds of kilobytes.
|
|
||||||
*/
|
|
||||||
private val output = StringBuilder()
|
|
||||||
private val lastFrameAt = java.util.concurrent.atomic.AtomicLong(System.nanoTime())
|
|
||||||
private val socket = AtomicReference<WebSocket?>(null)
|
|
||||||
private val failure = AtomicReference<Throwable?>(null)
|
|
||||||
private val handshakeStatus = AtomicReference<Int?>(null)
|
|
||||||
private val closeCode = AtomicReference<Int?>(null)
|
|
||||||
|
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
||||||
handshakeStatus.set(response.code)
|
|
||||||
socket.set(webSocket)
|
|
||||||
opened.countDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
||||||
lastFrameAt.set(System.nanoTime())
|
|
||||||
frames.put(text)
|
|
||||||
(MessageCodec.decodeServer(text) as? ServerMessage.Output)?.let { frame ->
|
|
||||||
synchronized(output) { output.append(frame.data) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
handshakeStatus.set(response?.code)
|
|
||||||
failure.set(t)
|
|
||||||
opened.countDown()
|
|
||||||
closed.countDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
closeCode.set(code)
|
|
||||||
closed.countDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
closeCode.set(code)
|
|
||||||
webSocket.close(code, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── driving ──────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun send(message: ClientMessage) {
|
|
||||||
val live = socket.get()
|
|
||||||
assertNotNull("cannot send $message — the WS never opened", live)
|
|
||||||
assertTrue("the WS refused to enqueue $message", live!!.send(MessageCodec.encode(message)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The next decodable server frame matching [predicate], or fail with what actually arrived. */
|
|
||||||
fun awaitFrame(what: String, timeoutMs: Long = FRAME_TIMEOUT_MS, predicate: (ServerMessage) -> Boolean): ServerMessage {
|
|
||||||
val seen = ArrayList<String>()
|
|
||||||
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
|
|
||||||
while (System.nanoTime() < deadline) {
|
|
||||||
val remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()).coerceAtLeast(1)
|
|
||||||
val raw = frames.poll(remaining, TimeUnit.MILLISECONDS) ?: break
|
|
||||||
seen += raw.take(RAW_FRAME_LOG_CHARS)
|
|
||||||
val decoded = MessageCodec.decodeServer(raw)
|
|
||||||
if (decoded != null && predicate(decoded)) return decoded
|
|
||||||
}
|
|
||||||
throw AssertionError(
|
|
||||||
"no frame matching \"$what\" within $timeoutMs ms. Frames seen: $seen. " +
|
|
||||||
"failure=${failure.get()} closeCode=${closeCode.get()}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for the wire to go quiet for [quietMs], then take everything `output` has carried since the last
|
|
||||||
* call. Bounded by [DRAIN_CEILING_MS] so a session that never stops talking cannot hang the suite.
|
|
||||||
*/
|
|
||||||
fun drainOutput(quietMs: Long = QUIET_MS): String {
|
|
||||||
val ceiling = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DRAIN_CEILING_MS)
|
|
||||||
while (System.nanoTime() < ceiling) {
|
|
||||||
val idleMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastFrameAt.get())
|
|
||||||
if (idleMs >= quietMs) break
|
|
||||||
Thread.sleep(DRAIN_POLL_MS)
|
|
||||||
}
|
|
||||||
return synchronized(output) {
|
|
||||||
val text = output.toString()
|
|
||||||
output.setLength(0)
|
|
||||||
text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun awaitClosed(timeoutMs: Long = FRAME_TIMEOUT_MS): Boolean =
|
|
||||||
closed.await(timeoutMs, TimeUnit.MILLISECONDS)
|
|
||||||
|
|
||||||
fun failureOrNull(): Throwable? = failure.get()
|
|
||||||
|
|
||||||
fun handshakeStatusOrNull(): Int? = handshakeStatus.get()
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
socket.get()?.close(NORMAL_CLOSURE, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel() {
|
|
||||||
socket.get()?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val NORMAL_CLOSURE: Int = 1000
|
|
||||||
private const val OPEN_TIMEOUT_MS = 8_000L
|
|
||||||
private const val FRAME_TIMEOUT_MS = 10_000L
|
|
||||||
private const val QUIET_MS = 700L
|
|
||||||
private const val DRAIN_CEILING_MS = 30_000L
|
|
||||||
private const val DRAIN_POLL_MS = 50L
|
|
||||||
private const val RAW_FRAME_LOG_CHARS = 200
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dial the host's `/term`. Returns as soon as the handshake resolves either way, so a rejection
|
|
||||||
* (the F9 case) is observable rather than a hang.
|
|
||||||
*
|
|
||||||
* @param origin what to put in the `Origin` header. Defaults to the endpoint-derived value, i.e.
|
|
||||||
* exactly what production sends; a foreign value is how the CSWSH defence is falsified.
|
|
||||||
*/
|
|
||||||
fun dial(
|
|
||||||
host: WebTermTestHost.ReachableHost,
|
|
||||||
origin: String? = host.endpoint.originHeader,
|
|
||||||
): E2eSocket {
|
|
||||||
val opened = CountDownLatch(1)
|
|
||||||
val listener = E2eSocket(opened, CountDownLatch(1))
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(host.endpoint.wsUrl)
|
|
||||||
.apply { origin?.let { header("Origin", it) } }
|
|
||||||
.build()
|
|
||||||
host.client.newWebSocket(request, listener)
|
|
||||||
assertTrue(
|
|
||||||
"the WS handshake to ${host.endpoint.wsUrl} neither opened nor failed within $OPEN_TIMEOUT_MS ms",
|
|
||||||
opened.await(OPEN_TIMEOUT_MS, TimeUnit.MILLISECONDS),
|
|
||||||
)
|
|
||||||
return listener
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `attach` must be the FIRST frame on every (re)connect (plan §4.1). */
|
|
||||||
fun attach(host: WebTermTestHost.ReachableHost, sessionId: String?, cwd: String? = null): Pair<E2eSocket, String> {
|
|
||||||
val socket = dial(host)
|
|
||||||
socket.send(ClientMessage.Attach(sessionId = sessionId, cwd = cwd))
|
|
||||||
val attached = socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached
|
|
||||||
return socket to attached.sessionId
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The soft-reset prefix the server puts in front of a ring-buffer replay — never stripped (§4.1). */
|
|
||||||
const val REPLAY_PREFIX: String = WireConstants.REPLAY_SOFT_RESET_PREFIX
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package wang.yaojia.webterm.e2e
|
|
||||||
|
|
||||||
import okhttp3.Call
|
|
||||||
import okhttp3.Callback
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A permission gate held open on a real server, created exactly the way Claude Code creates one.
|
|
||||||
*
|
|
||||||
* `POST /hook/permission` is the hook side-channel: the server holds the HTTP request open until a decision
|
|
||||||
* arrives (or it times out), then writes the decision JSON as the response — which the hook's `curl` feeds
|
|
||||||
* back to Claude on stdout. So the *held request itself* is the observable for "is the gate still held", and
|
|
||||||
* that is the only way to tell a real resolution from a UI that merely stopped showing the banner.
|
|
||||||
*
|
|
||||||
* Two server preconditions are worth knowing before reading a failure here:
|
|
||||||
* - **Loopback only.** `POST /hook/permission` 403s a non-loopback peer. From an emulator this is satisfied
|
|
||||||
* for free: `10.0.2.2` is forwarded to the host's loopback, so the server sees `127.0.0.1`. Over a LAN
|
|
||||||
* address it will 403, and [hold] says so rather than timing out mysteriously.
|
|
||||||
* - **Someone must be watching.** The server only HOLDS when the session has an attached client or a
|
|
||||||
* registered push target; otherwise it answers `{}` immediately and lets Claude prompt locally. The
|
|
||||||
* caller therefore attaches first.
|
|
||||||
*/
|
|
||||||
internal class HeldGate private constructor(
|
|
||||||
private val call: Call,
|
|
||||||
private val completed: CountDownLatch,
|
|
||||||
private val body: AtomicReference<String?>,
|
|
||||||
private val failure: AtomicReference<IOException?>,
|
|
||||||
) {
|
|
||||||
/** Whether the server has already answered — i.e. the gate is no longer held. */
|
|
||||||
fun hasResponded(): Boolean = completed.count == 0L
|
|
||||||
|
|
||||||
/** The decision body the server finally wrote, or fail if the hold never resolved. */
|
|
||||||
fun awaitResponse(timeoutMs: Long = RESPONSE_TIMEOUT_MS): String {
|
|
||||||
val done = completed.await(timeoutMs, TimeUnit.MILLISECONDS)
|
|
||||||
if (!done) {
|
|
||||||
call.cancel()
|
|
||||||
throw AssertionError("the held /hook/permission request never resolved within $timeoutMs ms")
|
|
||||||
}
|
|
||||||
failure.get()?.let { throw AssertionError("the held /hook/permission request failed: $it") }
|
|
||||||
return body.get() ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val RESPONSE_TIMEOUT_MS = 15_000L
|
|
||||||
private const val HOLD_SETTLE_MS = 600L
|
|
||||||
private val JSON = "application/json; charset=utf-8".toMediaType()
|
|
||||||
|
|
||||||
/** The tool name the server maps to a plain two-way `tool` gate (anything but `ExitPlanMode`). */
|
|
||||||
private const val TOOL_NAME = "Bash"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start holding a gate for [sessionId]. Returns once the request is in flight and the server has
|
|
||||||
* had time to either hold it or refuse it — and fails loudly if it refused, because a test that
|
|
||||||
* proceeded from here would be asserting against a gate that does not exist.
|
|
||||||
*/
|
|
||||||
fun hold(host: WebTermTestHost.ReachableHost, sessionId: String): HeldGate {
|
|
||||||
val completed = CountDownLatch(1)
|
|
||||||
val body = AtomicReference<String?>(null)
|
|
||||||
val failure = AtomicReference<IOException?>(null)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(host.endpoint.baseUrl + "/hook/permission")
|
|
||||||
.header("x-webterm-session", sessionId)
|
|
||||||
// `tool_input` is untrusted by contract; a harmless command keeps the derived preview real.
|
|
||||||
.post(
|
|
||||||
"""{"tool_name":"$TOOL_NAME","tool_input":{"command":"echo webterm-e2e-gate"}}"""
|
|
||||||
.toByteArray()
|
|
||||||
.toRequestBody(JSON),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
// Its own client: the hold occupies the connection for as long as the gate lives, and the read
|
|
||||||
// timeout has to outlast that, which must not be imposed on the rest of the suite.
|
|
||||||
val holdingClient = host.client.newBuilder()
|
|
||||||
.readTimeout(RESPONSE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
|
|
||||||
.build()
|
|
||||||
val call = holdingClient.newCall(request)
|
|
||||||
call.enqueue(object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
failure.set(e)
|
|
||||||
completed.countDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
response.use { body.set(it.body?.string() ?: "") }
|
|
||||||
completed.countDown()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Give the server a moment to register the hold (or refuse it outright).
|
|
||||||
Thread.sleep(HOLD_SETTLE_MS)
|
|
||||||
val gate = HeldGate(call, completed, body, failure)
|
|
||||||
if (gate.hasResponded()) {
|
|
||||||
val answered = body.get().orEmpty().replace(" ", "")
|
|
||||||
assertTrue(
|
|
||||||
"""
|
|
||||||
|POST /hook/permission did not HOLD, so there is no gate to test. It answered immediately
|
|
||||||
|with "$answered" (failure=${failure.get()}). The two reasons this happens:
|
|
||||||
| 1. the peer was not loopback (403) — use the emulator's http://10.0.2.2:<port> alias,
|
|
||||||
| which the host sees as 127.0.0.1, rather than a LAN address;
|
|
||||||
| 2. nothing was watching the session ({}) — attach a client before holding.
|
|
||||||
""".trimMargin(),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return gate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
package wang.yaojia.webterm.e2e
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.wire.ApproveMode
|
|
||||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
|
||||||
import wang.yaojia.webterm.wire.GateKind
|
|
||||||
import wang.yaojia.webterm.wire.ServerMessage
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **A34 — a held permission gate, and what may and may not resolve it.**
|
|
||||||
*
|
|
||||||
* A gate is created the way Claude Code creates one: `POST /hook/permission` from the host's own loopback
|
|
||||||
* (which is what the emulator's `10.0.2.2` alias resolves to on the host side, so the loopback-only guard
|
|
||||||
* is satisfied without any special setup). The server holds that HTTP request open until a decision
|
|
||||||
* arrives, and pushes a `status` frame with `pending: true` to every attached client — which is the frame
|
|
||||||
* the cockpit's gate card renders.
|
|
||||||
*
|
|
||||||
* ### An honest boundary: the `/hook/decision` HAPPY path is not automatable
|
|
||||||
* `/hook/decision` requires the per-decision capability token, and that token leaves the server **only**
|
|
||||||
* inside a push payload (`pushService.notify(session, 'needs-input', token)`) — never over the WS, never in
|
|
||||||
* `/live-sessions`. That is deliberate payload minimisation (plan §8), and it means no automated client can
|
|
||||||
* legitimately obtain it. So this class proves the parts that ARE provable and does not fake the rest:
|
|
||||||
*
|
|
||||||
* - a gate really is held, and the held-gate `status` frame really reaches an attached device;
|
|
||||||
* - `/hook/decision` **refuses** a well-formed but wrong token (403) and a malformed body (400), and the
|
|
||||||
* gate is still held afterwards — the security half, and the half a bug would most likely break;
|
|
||||||
* - the gate resolves through the in-app path (`approve` on the WS), observed by the held
|
|
||||||
* `POST /hook/permission` request finally returning.
|
|
||||||
*
|
|
||||||
* The remaining step — a real notification-action POST with a real token — stays on the device-QA checklist
|
|
||||||
* under Push, where it belongs, because it needs a real FCM delivery. It is NOT quietly asserted here.
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class HeldGateE2eTest {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A wrong capability token must be refused and must leave the gate held. This is the SEC-C1/M1
|
|
||||||
* contract: the token is the whole authorisation for a decision made from a lock screen, so a
|
|
||||||
* mismatched, stale or absent one has to be a hard 403.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun hookDecisionRefusesAWrongTokenAndLeavesTheGateHeld() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
|
|
||||||
val gate = HeldGate.hold(host, sessionId)
|
|
||||||
try {
|
|
||||||
// The gate must actually be held before the refusal means anything.
|
|
||||||
val status = socket.awaitFrame("the held-gate status frame") {
|
|
||||||
it is ServerMessage.Status && it.pending
|
|
||||||
} as ServerMessage.Status
|
|
||||||
assertEquals("a plain tool gate must be reported as such", GateKind.TOOL, status.gate)
|
|
||||||
assertEquals(ClaudeStatus.WAITING, status.status)
|
|
||||||
|
|
||||||
val wrongToken = UUID.randomUUID().toString()
|
|
||||||
val (refused, _) = host.postJson(
|
|
||||||
"/hook/decision",
|
|
||||||
"""{"sessionId":"$sessionId","decision":"allow","token":"$wrongToken"}""",
|
|
||||||
origin = host.endpoint.originHeader,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"a mismatched capability token must 403 — it is the only thing authorising a remote decision",
|
|
||||||
WebTermTestHost.ReachableHost.FORBIDDEN,
|
|
||||||
refused,
|
|
||||||
)
|
|
||||||
|
|
||||||
val (malformed, _) = host.postJson(
|
|
||||||
"/hook/decision",
|
|
||||||
"""{"sessionId":"$sessionId","decision":"maybe","token":"$wrongToken"}""",
|
|
||||||
origin = host.endpoint.originHeader,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"an unknown decision verb must 400, never be coerced into allow",
|
|
||||||
HTTP_BAD_REQUEST,
|
|
||||||
malformed,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
"the refusals must not have resolved the gate — the hook request must still be held",
|
|
||||||
!gate.hasResponded(),
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
socket.send(ClientMessage.Reject) // release the hold so the server is left clean
|
|
||||||
gate.awaitResponse()
|
|
||||||
socket.close()
|
|
||||||
runCatching { host.delete("/live-sessions/$sessionId") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `/hook/decision` is a GUARDED route, so a foreign `Origin` must 403 before the token is even looked
|
|
||||||
* at. Otherwise a malicious page could spend a token it somehow observed.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun hookDecisionRefusesAForeignOrigin() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
|
|
||||||
try {
|
|
||||||
val (status, _) = host.postJson(
|
|
||||||
"/hook/decision",
|
|
||||||
"""{"sessionId":"$sessionId","decision":"allow","token":"${UUID.randomUUID()}"}""",
|
|
||||||
origin = FOREIGN_ORIGIN,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"the Origin guard must run before the token check on /hook/decision",
|
|
||||||
WebTermTestHost.ReachableHost.FORBIDDEN,
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
socket.close()
|
|
||||||
runCatching { host.delete("/live-sessions/$sessionId") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The in-app resolution path, end to end: the attached device sends `approve` (with the mode as a
|
|
||||||
* TOP-LEVEL key, per plan §4.1) and the held `POST /hook/permission` request returns — which is the
|
|
||||||
* server telling Claude Code to proceed. Observing that return is the only proof the hold was really
|
|
||||||
* released rather than merely hidden in the UI.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun approveOnTheWireResolvesTheHeldGate() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
|
|
||||||
val gate = HeldGate.hold(host, sessionId)
|
|
||||||
try {
|
|
||||||
socket.awaitFrame("the held-gate status frame") { it is ServerMessage.Status && it.pending }
|
|
||||||
assertTrue("the hook request must still be held before we decide", !gate.hasResponded())
|
|
||||||
|
|
||||||
socket.send(ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS))
|
|
||||||
|
|
||||||
val body = gate.awaitResponse()
|
|
||||||
assertTrue(
|
|
||||||
"the held hook request must return a decision body, got: $body",
|
|
||||||
body.isNotEmpty() && body.trimStart().startsWith("{"),
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"an approval must not come back as a plain empty object (that is the timeout fallback)",
|
|
||||||
body.replace(" ", "") != "{}",
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
socket.close()
|
|
||||||
runCatching { host.delete("/live-sessions/$sessionId") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val HTTP_BAD_REQUEST = 400
|
|
||||||
const val FOREIGN_ORIGIN = "http://evil.example"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
package wang.yaojia.webterm.e2e
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
|
||||||
import wang.yaojia.webterm.wire.ServerMessage
|
|
||||||
import wang.yaojia.webterm.wire.Validation
|
|
||||||
import wang.yaojia.webterm.wire.WireConstants
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **A34 — instrumented E2E against a real `npm start` host.**
|
|
||||||
*
|
|
||||||
* Every test here runs on the device, over a real socket, against this repo's own server. The frames are
|
|
||||||
* encoded and decoded by production's [wang.yaojia.webterm.wire.MessageCodec], so a drift between the
|
|
||||||
* frozen client contract and the actual server surfaces here and nowhere else in the suite.
|
|
||||||
*
|
|
||||||
* If no host is reachable each test SKIPS with the command needed to make it reachable — see
|
|
||||||
* [WebTermTestHost]. It never quietly passes.
|
|
||||||
*
|
|
||||||
* Every session this class spawns is killed in a `finally`, via the guarded
|
|
||||||
* `DELETE /live-sessions/:id`, so a run leaves no orphan PTYs behind on the developer's machine.
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class SessionRoundTripE2eTest {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The core loop the whole product rests on: `attach(null)` → `attached` with a server-issued id →
|
|
||||||
* typed bytes → the shell's own output comes back. Also pins the two contract details a client gets
|
|
||||||
* wrong most easily: the id must be adopted from the server (never the one we asked for), and Enter is
|
|
||||||
* `\r`.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun attachThenTypeThenOutput_roundTripsThroughARealPty() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
|
|
||||||
try {
|
|
||||||
assertTrue(
|
|
||||||
"the server-issued session id must be a lowercase v4 UUID: $sessionId",
|
|
||||||
Validation.isValidSessionId(sessionId),
|
|
||||||
)
|
|
||||||
assertEquals("…and must parse as a UUID", sessionId, UUID.fromString(sessionId).toString())
|
|
||||||
|
|
||||||
// A marker the shell will echo back through the PTY. `\r` (never `\n`) is what Enter sends.
|
|
||||||
val marker = "webterm-e2e-${UUID.randomUUID()}"
|
|
||||||
socket.send(ClientMessage.Input("printf '%s\\n' $marker\r"))
|
|
||||||
|
|
||||||
val output = socket.awaitFrame("the marker echoed back by the shell") {
|
|
||||||
it is ServerMessage.Output && it.data.contains(marker)
|
|
||||||
}
|
|
||||||
assertTrue("expected an output frame, got $output", output is ServerMessage.Output)
|
|
||||||
} finally {
|
|
||||||
socket.close()
|
|
||||||
host.killQuietly(sessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `attach` MUST be the first frame, with the `sessionId` key ALWAYS present (JSON `null` for a new
|
|
||||||
* session — the server rejects a missing key). Sending something else first must not bind a session:
|
|
||||||
* the server logs a protocol violation and keeps waiting, so the later `attach` still works. This is
|
|
||||||
* the ordering guarantee the whole reconnect ladder depends on.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun aNonAttachFirstFrameBindsNothing_andTheLaterAttachStillWorks() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val socket = E2eSocket.dial(host)
|
|
||||||
var sessionId: String? = null
|
|
||||||
try {
|
|
||||||
socket.send(ClientMessage.Input("this must be ignored\r"))
|
|
||||||
socket.send(ClientMessage.Resize(cols = 100, rows = 40))
|
|
||||||
|
|
||||||
socket.send(ClientMessage.Attach(sessionId = null))
|
|
||||||
val attached = socket.awaitFrame("attached after an out-of-order first frame") {
|
|
||||||
it is ServerMessage.Attached
|
|
||||||
} as ServerMessage.Attached
|
|
||||||
sessionId = attached.sessionId
|
|
||||||
assertTrue(Validation.isValidSessionId(attached.sessionId))
|
|
||||||
} finally {
|
|
||||||
socket.close()
|
|
||||||
sessionId?.let { host.killQuietly(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **F5/F6 — reconnect replays the ring buffer, with no dropped bytes on a multi-MB replay.**
|
|
||||||
*
|
|
||||||
* The session is made to produce a large volume of output, the client detaches (which must NOT kill
|
|
||||||
* the PTY), then re-attaches by id. Every byte of the replay has to arrive: the assertion is not "some
|
|
||||||
* output came back" but that a *numbered sequence* is present, in order, with no gap — the only way to
|
|
||||||
* catch a truncated or interleaved replay. `MessageCodec` decodes the replay frame, so this also
|
|
||||||
* exercises the client's ability to receive a single very large text frame.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun reconnectReplaysTheRingBufferWithNoDroppedBytes() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val (first, sessionId) = E2eSocket.attach(host, sessionId = null)
|
|
||||||
try {
|
|
||||||
// seq prints REPLAY_LINES numbered lines; awk pads each to ~PAD_BYTES so the ring holds
|
|
||||||
// hundreds of KB to a few MB of real PTY output rather than a handful of lines.
|
|
||||||
first.send(
|
|
||||||
ClientMessage.Input(
|
|
||||||
"seq 1 $REPLAY_LINES | awk '{ printf \"%s:\", \$1; " +
|
|
||||||
"for (i = 0; i < $PAD_REPEATS; i++) printf \"$PAD_CHUNK\"; printf \"\\n\" }'\r",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
first.awaitFrame("the last line of the generated output") {
|
|
||||||
it is ServerMessage.Output && it.data.contains("$REPLAY_LINES:")
|
|
||||||
}
|
|
||||||
first.drainOutput()
|
|
||||||
// Detach only. The PTY must survive — that is the product's central design point.
|
|
||||||
first.close()
|
|
||||||
assertTrue("the first connection never closed", first.awaitClosed())
|
|
||||||
|
|
||||||
val stillRunning = host.get("/live-sessions").second
|
|
||||||
assertTrue(
|
|
||||||
"the session must survive the detach (PTY lifecycle != WS lifecycle)",
|
|
||||||
stillRunning.contains(sessionId),
|
|
||||||
)
|
|
||||||
|
|
||||||
val (second, adopted) = E2eSocket.attach(host, sessionId = sessionId)
|
|
||||||
try {
|
|
||||||
assertEquals("re-attach must adopt the same id", sessionId, adopted)
|
|
||||||
val replay = second.drainOutput(quietMs = REPLAY_QUIET_MS)
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
"a replay must arrive at all; got ${replay.length} chars",
|
|
||||||
replay.length > MIN_REPLAY_CHARS,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"the ESC[0m soft-reset prefix must be passed through, not stripped",
|
|
||||||
replay.contains(WireConstants.REPLAY_SOFT_RESET_PREFIX),
|
|
||||||
)
|
|
||||||
// The ring is a fixed-size window, so the OLDEST lines are legitimately gone. What must
|
|
||||||
// never happen is a HOLE: from the first line present to the last, every number must be
|
|
||||||
// there. That is what a dropped or reordered chunk would break.
|
|
||||||
// Anchored at a line start: an unanchored `contains("234:")` would also match inside
|
|
||||||
// "1234:", which would silently inflate the set and make the contiguity check vacuous.
|
|
||||||
val present = (1..REPLAY_LINES).filter { replay.contains("\n$it:") }
|
|
||||||
assertTrue("no numbered line survived the replay at all", present.isNotEmpty())
|
|
||||||
assertEquals(
|
|
||||||
"the replay has a hole — lines ${present.first()}..${present.last()} should be contiguous",
|
|
||||||
(present.first()..present.last()).toList(),
|
|
||||||
present,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"the newest line must always be in the ring",
|
|
||||||
replay.contains("\n$REPLAY_LINES:"),
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
second.close()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
host.killQuietly(sessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A shell that exits ends the session: `exit` is terminal, and the row disappears from
|
|
||||||
* `/live-sessions`. This is the ordinary exit path — the `-1` spawn-failure path needs a deliberately
|
|
||||||
* broken server and lives in [SpawnFailureE2eTest].
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun aShellThatExitsProducesATerminalExitFrame() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
|
|
||||||
try {
|
|
||||||
socket.send(ClientMessage.Input("exit $EXPECTED_EXIT_CODE\r"))
|
|
||||||
|
|
||||||
val exit = socket.awaitFrame("the exit frame") { it is ServerMessage.Exit } as ServerMessage.Exit
|
|
||||||
assertEquals("the shell's own exit status must be reported verbatim", EXPECTED_EXIT_CODE, exit.code)
|
|
||||||
} finally {
|
|
||||||
socket.close()
|
|
||||||
host.killQuietly(sessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **Kill via the guarded `DELETE /live-sessions/:id`** (the swipe-to-kill action). 204 the first time,
|
|
||||||
* and **404 the second time counts as success** — "already gone" is the desired end state, which is
|
|
||||||
* why the client must not treat it as an error (plan §4.3).
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun killingASessionRemovesItAndASecondKillIsAlreadyGone() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
|
|
||||||
socket.close()
|
|
||||||
|
|
||||||
val (firstStatus, _) = host.delete("/live-sessions/$sessionId")
|
|
||||||
assertEquals("the first kill must be a 204", WebTermTestHost.ReachableHost.NO_CONTENT, firstStatus)
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
"the killed session must be gone from /live-sessions",
|
|
||||||
!host.get("/live-sessions").second.contains(sessionId),
|
|
||||||
)
|
|
||||||
|
|
||||||
val (secondStatus, _) = host.delete("/live-sessions/$sessionId")
|
|
||||||
assertEquals(
|
|
||||||
"a second kill must be 404 (already gone = success), not an error the UI would surface",
|
|
||||||
WebTermTestHost.ReachableHost.NOT_FOUND,
|
|
||||||
secondStatus,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Origin 铁律, positive half: the guarded kill route must be REFUSED without a byte-equal `Origin`.
|
|
||||||
* The negative half (a foreign origin on the WS upgrade, F9) is [CswshDefenceE2eTest].
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun theGuardedKillRouteRefusesAForeignOrigin() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
|
|
||||||
socket.close()
|
|
||||||
try {
|
|
||||||
val (status, _) = host.delete("/live-sessions/$sessionId", origin = FOREIGN_ORIGIN)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"a foreign Origin must 403 on a state-changing route",
|
|
||||||
WebTermTestHost.ReachableHost.FORBIDDEN,
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"…and the session must still be alive, i.e. the refusal actually refused",
|
|
||||||
host.get("/live-sessions").second.contains(sessionId),
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
host.killQuietly(sessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best-effort cleanup: 204 and 404 are both fine, and a transport error must not mask a real failure. */
|
|
||||||
private fun WebTermTestHost.ReachableHost.killQuietly(sessionId: String) {
|
|
||||||
runCatching { delete("/live-sessions/$sessionId") }
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/** ~REPLAY_LINES × (PAD_REPEATS × 64) bytes of PTY output — comfortably past a single WS frame. */
|
|
||||||
const val REPLAY_LINES = 4_000
|
|
||||||
const val PAD_REPEATS = 8
|
|
||||||
const val PAD_CHUNK = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
||||||
|
|
||||||
const val MIN_REPLAY_CHARS = 100_000
|
|
||||||
const val REPLAY_QUIET_MS = 2_500L
|
|
||||||
|
|
||||||
/** An arbitrary non-zero status, chosen so a coincidental 0 cannot pass the assertion. */
|
|
||||||
const val EXPECTED_EXIT_CODE = 42
|
|
||||||
|
|
||||||
/** Not a real host — the point is that it is not in the server's allow-list. */
|
|
||||||
const val FOREIGN_ORIGIN = "http://evil.example"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package wang.yaojia.webterm.e2e
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.components.BannerModel
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
|
||||||
import wang.yaojia.webterm.wire.ServerMessage
|
|
||||||
import wang.yaojia.webterm.wire.WireConstants
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **A34 — a host whose shell cannot start, and the spawn-failure banner.**
|
|
||||||
*
|
|
||||||
* ## What was expected, and what a real server actually does — MEASURED, 2026-07-30
|
|
||||||
* Plan §4.1 and `src/server.ts` (M4) say a spawn failure surfaces as `exit(-1)` with a required `reason`:
|
|
||||||
* `manager.handleAttach` throws, the server sends `exit(-1)` and closes that connection. The client turns
|
|
||||||
* that into the non-retryable "会话启动失败" banner instead of entering the reconnect ladder.
|
|
||||||
*
|
|
||||||
* **That path did not fire.** A server started with `SHELL_PATH=/nonexistent/shell` (port 3112, run against
|
|
||||||
* this suite on 2026-07-30) answered `attached` and then `exit` with **code 1, not -1**. The reason is
|
|
||||||
* structural, not a configuration slip: node-pty's `pty.fork()` returns successfully and the `execvp`
|
|
||||||
* failure happens in the FORKED CHILD, which `_exit(1)`s (`node_modules/node-pty/src/unix/pty.cc`, and the
|
|
||||||
* same in `spawn-helper.cc` for `chdir`). So nothing throws synchronously in `createSession`, `handleAttach`
|
|
||||||
* returns a live session, and the failure arrives later as an ordinary child exit. On POSIX, `exit(-1)`
|
|
||||||
* therefore looks **unreachable through any client-visible route** — which is reported to the plan owner
|
|
||||||
* rather than papered over here.
|
|
||||||
*
|
|
||||||
* ## So this class asserts what is true, and never pretends
|
|
||||||
* - Over the wire, against the deliberately-broken host: a session whose shell cannot be executed produces
|
|
||||||
* a **terminal, non-zero `exit`** and does not linger in `/live-sessions`. That is the behaviour a client
|
|
||||||
* must actually handle, and asserting `-1` here would be asserting a fiction.
|
|
||||||
* - The `-1` → banner reduction is asserted separately and is labelled as a MODEL assertion, not an
|
|
||||||
* over-the-wire one, because no reachable server input produces `-1` today. If the server ever gains a
|
|
||||||
* real `-1` path, the wire test above starts distinguishing the two and this stays correct.
|
|
||||||
*
|
|
||||||
* Without `-e webtermSpawnFailureHost` the wire test SKIPS with the exact command to provide one — it is
|
|
||||||
* never a silent pass.
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class SpawnFailureE2eTest {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A host whose `SHELL_PATH` does not exist must not leave the user with a half-alive session: the
|
|
||||||
* `exit` frame has to arrive, be non-zero, and the session must be gone afterwards. Anything less and a
|
|
||||||
* walked-away user would sit on a "connecting…" spinner against a shell that can never run.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun aHostWhoseShellCannotStartProducesATerminalNonZeroExit() {
|
|
||||||
val host = WebTermTestHost.requireSpawnFailureHost()
|
|
||||||
val socket = E2eSocket.dial(host)
|
|
||||||
var sessionId: String? = null
|
|
||||||
try {
|
|
||||||
socket.send(ClientMessage.Attach(sessionId = null))
|
|
||||||
|
|
||||||
val frame = socket.awaitFrame("either attached or a terminal exit") {
|
|
||||||
it is ServerMessage.Attached || it is ServerMessage.Exit
|
|
||||||
}
|
|
||||||
(frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId }
|
|
||||||
|
|
||||||
val exit = frame as? ServerMessage.Exit
|
|
||||||
?: socket.awaitFrame("the exit that follows a failed exec") { it is ServerMessage.Exit }
|
|
||||||
as ServerMessage.Exit
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
"a shell that cannot be executed must produce a NON-ZERO exit, got ${exit.code}",
|
|
||||||
exit.code != CLEAN_EXIT,
|
|
||||||
)
|
|
||||||
sessionId?.let { id ->
|
|
||||||
assertTrue(
|
|
||||||
"the dead session must not linger in /live-sessions",
|
|
||||||
!host.get("/live-sessions").second.contains(id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
socket.close()
|
|
||||||
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The CLIENT half of the `exit(-1)` contract, asserted on the model rather than over the wire — see the
|
|
||||||
* class doc for why no reachable server input produces `-1`. It stays here, next to the wire test, so
|
|
||||||
* the two are read together and the gap is visible instead of forgotten: if the sentinel ever does
|
|
||||||
* arrive, this is the behaviour it must drive.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun theMinusOneSentinelReducesToTheNonRetryableSpawnFailureBanner() {
|
|
||||||
val banner = BannerModel.Exited(WireConstants.SPAWN_FAILED_EXIT_CODE, "spawn /nonexistent/shell ENOENT")
|
|
||||||
|
|
||||||
assertTrue("code -1 must be recognised as a spawn failure", banner.isSpawnFailure)
|
|
||||||
assertTrue("a spawn failure must never show a retry spinner", !banner.showsSpinner)
|
|
||||||
assertTrue("…and must offer a new session instead", banner.offersNewSession)
|
|
||||||
assertNotNull("`reason` is what the banner shows the user; it is required on -1", banner.reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The neighbouring case a client must NOT confuse with a spawn failure: a bad `cwd` kills the child
|
|
||||||
* (node-pty's `spawn-helper` `chdir` → `_exit(1)`), which is an ordinary exit. Reporting it as `-1`
|
|
||||||
* would stop the client offering a retry in a case where retrying elsewhere works.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun aBadCwdIsAnOrdinaryExitNotASpawnFailure() {
|
|
||||||
val host = WebTermTestHost.require()
|
|
||||||
val socket = E2eSocket.dial(host)
|
|
||||||
var sessionId: String? = null
|
|
||||||
try {
|
|
||||||
socket.send(
|
|
||||||
ClientMessage.Attach(sessionId = null, cwd = "/webterm-e2e/definitely-does-not-exist"),
|
|
||||||
)
|
|
||||||
|
|
||||||
val frame = socket.awaitFrame("either attached or exit") {
|
|
||||||
it is ServerMessage.Attached || it is ServerMessage.Exit
|
|
||||||
}
|
|
||||||
(frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId }
|
|
||||||
|
|
||||||
val exit = frame as? ServerMessage.Exit
|
|
||||||
?: socket.awaitFrame("the child exiting after chdir failed") { it is ServerMessage.Exit }
|
|
||||||
as ServerMessage.Exit
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"a bad cwd must not be reported as the spawn-failure sentinel — it is the child that died",
|
|
||||||
false,
|
|
||||||
exit.code == WireConstants.SPAWN_FAILED_EXIT_CODE,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
socket.close()
|
|
||||||
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val CLEAN_EXIT = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
package wang.yaojia.webterm.e2e
|
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import okhttp3.Cookie
|
|
||||||
import okhttp3.CookieJar
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import org.junit.Assume
|
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locating (or honestly declining to locate) a real WebTerm server for the A34 instrumented E2E suite.
|
|
||||||
*
|
|
||||||
* ### The rule this file exists to enforce
|
|
||||||
* **A test that cannot tell "passed" from "never ran" is worse than no test.** Every E2E test therefore
|
|
||||||
* goes through [require], which either hands back a *proven-reachable* host or raises a JUnit assumption
|
|
||||||
* failure carrying the exact command needed to make it reachable. It never returns a host it has not
|
|
||||||
* spoken to, and it never lets a test body no-op its way to green.
|
|
||||||
*
|
|
||||||
* ### Where the address comes from (never hardcoded, never personal)
|
|
||||||
* 1. `-e webtermHost <base-url>` — an instrumentation argument. This is how a LAN address is supplied; it
|
|
||||||
* is read at run time so no host of anyone's ever enters the source tree.
|
|
||||||
* 2. Otherwise [EMULATOR_HOST_ALIAS], which is not a personal address at all: `10.0.2.2` is the Android
|
|
||||||
* emulator's fixed platform alias for the host machine's loopback, documented by Google, and it is the
|
|
||||||
* right default because the server under test is this repo's own `npm start`.
|
|
||||||
*
|
|
||||||
* ### Two things that will bite whoever runs this
|
|
||||||
* - **`Origin`.** The server derives its allow-list from the host's NIC addresses (`src/config.ts`
|
|
||||||
* `deriveAllowedOrigins`), and `10.0.2.2` is a *guest-side* alias that never appears among them. So the
|
|
||||||
* three guarded routes 403 unless the server is started with that origin allowed:
|
|
||||||
* `ALLOWED_ORIGINS=http://10.0.2.2:3000 npm start`. [require] proves this up front rather than letting
|
|
||||||
* individual tests fail obscurely.
|
|
||||||
* - **`WEBTERM_TOKEN`.** If the server is token-gated, every route (and the WS upgrade) needs the
|
|
||||||
* `webterm_auth` cookie. Pass the token with `-e webtermToken <t>` and it is exchanged via `POST /auth`
|
|
||||||
* into the in-memory jar installed on the one [OkHttpClient] both the REST and WS halves use —
|
|
||||||
* the same "one client, one jar" shape production relies on. The token is never logged and never written
|
|
||||||
* anywhere.
|
|
||||||
*/
|
|
||||||
internal object WebTermTestHost {
|
|
||||||
|
|
||||||
const val ARG_HOST: String = "webtermHost"
|
|
||||||
const val ARG_TOKEN: String = "webtermToken"
|
|
||||||
const val ARG_SPAWN_FAILURE_HOST: String = "webtermSpawnFailureHost"
|
|
||||||
|
|
||||||
/** The emulator's fixed alias for the host machine's loopback (an Android platform constant). */
|
|
||||||
const val EMULATOR_HOST_ALIAS: String = "http://10.0.2.2:3000"
|
|
||||||
|
|
||||||
private const val CONNECT_TIMEOUT_S = 3L
|
|
||||||
private const val READ_TIMEOUT_S = 10L
|
|
||||||
private const val HTTP_OK = 200
|
|
||||||
private const val HTTP_UNAUTHORIZED = 401
|
|
||||||
private const val HTTP_NO_CONTENT = 204
|
|
||||||
private const val HTTP_FORBIDDEN = 403
|
|
||||||
private const val HTTP_NOT_FOUND = 404
|
|
||||||
|
|
||||||
private val JSON = "application/json; charset=utf-8".toMediaType()
|
|
||||||
|
|
||||||
fun argument(name: String): String? =
|
|
||||||
InstrumentationRegistry.getArguments().getString(name)?.takeIf { it.isNotBlank() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A reachable host, or a JUnit assumption failure (reported as SKIPPED, never as a pass) explaining
|
|
||||||
* exactly how to provide one.
|
|
||||||
*/
|
|
||||||
fun require(): ReachableHost {
|
|
||||||
val baseUrl = argument(ARG_HOST) ?: EMULATOR_HOST_ALIAS
|
|
||||||
val endpoint = HostEndpoint.fromBaseUrl(baseUrl)
|
|
||||||
Assume.assumeTrue(
|
|
||||||
"`-e $ARG_HOST $baseUrl` is not a usable http(s) base URL, so no E2E host could be built.",
|
|
||||||
endpoint != null,
|
|
||||||
)
|
|
||||||
return probe(endpoint!!, argument(ARG_TOKEN))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The optional second host for the "shell cannot start" case. It needs its own deliberately-broken
|
|
||||||
* instance because no client input can make a healthy host fail to spawn.
|
|
||||||
*
|
|
||||||
* MEASURED 2026-07-30: such a host answers `exit` with code **1**, not the documented `-1` sentinel —
|
|
||||||
* node-pty's `pty.fork()` succeeds and the `execvp` failure happens in the forked child, which
|
|
||||||
* `_exit(1)`s. See [SpawnFailureE2eTest] for the full finding.
|
|
||||||
*/
|
|
||||||
fun requireSpawnFailureHost(): ReachableHost {
|
|
||||||
val baseUrl = argument(ARG_SPAWN_FAILURE_HOST)
|
|
||||||
Assume.assumeTrue(
|
|
||||||
"""
|
|
||||||
|SKIPPED — no broken-shell host was supplied, so the failed-spawn path could not be exercised.
|
|
||||||
|Start a second server whose shell does not exist and point the suite at it:
|
|
||||||
| SHELL_PATH=/nonexistent/shell PORT=3112 ALLOWED_ORIGINS=http://10.0.2.2:3112 npm start
|
|
||||||
| ./gradlew :app:connectedDebugAndroidTest \
|
|
||||||
| -Pandroid.testInstrumentationRunnerArguments.$ARG_SPAWN_FAILURE_HOST=http://10.0.2.2:3112
|
|
||||||
""".trimMargin(),
|
|
||||||
baseUrl != null,
|
|
||||||
)
|
|
||||||
val endpoint = HostEndpoint.fromBaseUrl(baseUrl!!)
|
|
||||||
Assume.assumeTrue("`-e $ARG_SPAWN_FAILURE_HOST $baseUrl` is not a usable http(s) base URL.", endpoint != null)
|
|
||||||
return probe(endpoint!!, argument(ARG_TOKEN))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun probe(endpoint: HostEndpoint, token: String?): ReachableHost {
|
|
||||||
val cookieJar = MemoryCookieJar()
|
|
||||||
val client = OkHttpClient.Builder()
|
|
||||||
.cookieJar(cookieJar)
|
|
||||||
.connectTimeout(CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(READ_TIMEOUT_S, TimeUnit.SECONDS)
|
|
||||||
.pingInterval(0, TimeUnit.MILLISECONDS)
|
|
||||||
.build()
|
|
||||||
val host = ReachableHost(endpoint, client)
|
|
||||||
|
|
||||||
val firstStatus = try {
|
|
||||||
host.get("/live-sessions").first
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Assume.assumeNoException(
|
|
||||||
"""
|
|
||||||
|SKIPPED — no WebTerm server answered at ${endpoint.baseUrl} (${e.javaClass.simpleName}: ${e.message}).
|
|
||||||
|Start this repo's own server on the machine hosting the emulator, allowing the guest-side
|
|
||||||
|origin so the three guarded routes are usable:
|
|
||||||
| ALLOWED_ORIGINS=${endpoint.originHeader} npm start
|
|
||||||
|or point the suite at a LAN address instead:
|
|
||||||
| -Pandroid.testInstrumentationRunnerArguments.$ARG_HOST=http://<lan-ip>:3000
|
|
||||||
""".trimMargin(),
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
error("unreachable") // assumeNoException always throws
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstStatus == HTTP_UNAUTHORIZED) {
|
|
||||||
Assume.assumeTrue(
|
|
||||||
"""
|
|
||||||
|SKIPPED — ${endpoint.baseUrl} is gated by WEBTERM_TOKEN and no token was supplied. Pass it with
|
|
||||||
| -Pandroid.testInstrumentationRunnerArguments.$ARG_TOKEN=<the token>
|
|
||||||
|(the token is only exchanged for the webterm_auth cookie; it is never logged or persisted).
|
|
||||||
""".trimMargin(),
|
|
||||||
token != null,
|
|
||||||
)
|
|
||||||
val authStatus = host.postJson("/auth", """{"token":${quote(token!!)}}""").first
|
|
||||||
Assume.assumeTrue(
|
|
||||||
"SKIPPED — POST /auth at ${endpoint.baseUrl} rejected the supplied token (HTTP $authStatus).",
|
|
||||||
authStatus == HTTP_NO_CONTENT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val (status, body) = host.get("/live-sessions")
|
|
||||||
Assume.assumeTrue(
|
|
||||||
"SKIPPED — GET /live-sessions at ${endpoint.baseUrl} answered HTTP $status, so this is not a usable WebTerm host.",
|
|
||||||
status == HTTP_OK,
|
|
||||||
)
|
|
||||||
Assume.assumeTrue(
|
|
||||||
"SKIPPED — ${endpoint.baseUrl} answered GET /live-sessions with a non-array body, so it is not WebTerm.",
|
|
||||||
body.trimStart().startsWith("["),
|
|
||||||
)
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun quote(value: String): String = buildString {
|
|
||||||
append('"')
|
|
||||||
for (ch in value) {
|
|
||||||
when (ch) {
|
|
||||||
'"' -> append("\\\"")
|
|
||||||
'\\' -> append("\\\\")
|
|
||||||
else -> append(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
append('"')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A host that has been *proven* to answer. Exposes only what the E2E tests need, over the one shared
|
|
||||||
* client (so the auth cookie covers REST and the WS upgrade alike).
|
|
||||||
*/
|
|
||||||
internal class ReachableHost(val endpoint: HostEndpoint, val client: OkHttpClient) {
|
|
||||||
|
|
||||||
fun get(path: String): Pair<Int, String> = exchange(
|
|
||||||
Request.Builder().url(endpoint.baseUrl + path).get().build(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A GUARDED route: `Origin` stamped byte-equal to [HostEndpoint.originHeader] (plan §4.3). */
|
|
||||||
fun postJson(path: String, json: String, origin: String? = null): Pair<Int, String> = exchange(
|
|
||||||
Request.Builder()
|
|
||||||
.url(endpoint.baseUrl + path)
|
|
||||||
.post(json.toByteArray().toRequestBody(JSON))
|
|
||||||
.apply { origin?.let { header("Origin", it) } }
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun delete(path: String, origin: String = endpoint.originHeader): Pair<Int, String> = exchange(
|
|
||||||
Request.Builder().url(endpoint.baseUrl + path).delete().header("Origin", origin).build(),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun exchange(request: Request): Pair<Int, String> =
|
|
||||||
client.newCall(request).execute().use { it.code to (it.body?.string() ?: "") }
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val OK: Int = HTTP_OK
|
|
||||||
const val NO_CONTENT: Int = HTTP_NO_CONTENT
|
|
||||||
const val UNAUTHORIZED: Int = HTTP_UNAUTHORIZED
|
|
||||||
const val FORBIDDEN: Int = HTTP_FORBIDDEN
|
|
||||||
const val NOT_FOUND: Int = HTTP_NOT_FOUND
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal in-memory jar — enough to carry `webterm_auth` across REST calls and the WS upgrade. */
|
|
||||||
private class MemoryCookieJar : CookieJar {
|
|
||||||
private val cookies = mutableMapOf<String, Cookie>()
|
|
||||||
|
|
||||||
override fun loadForRequest(url: HttpUrl): List<Cookie> = synchronized(cookies) {
|
|
||||||
cookies.values.filter { it.matches(url) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = synchronized(this.cookies) {
|
|
||||||
cookies.forEach { this.cookies[it.name] = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
package wang.yaojia.webterm.terminal
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REGRESSION — **the CRITICAL one: one finger-swipe inside an alternate-screen TUI killed the process.**
|
|
||||||
*
|
|
||||||
* Stock `TerminalView.doScroll` (offsets 56–79 of the pinned `terminal-view-v0.118.0.aar`) turns a scroll
|
|
||||||
* into `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` **whenever the alternate screen buffer is active**, and
|
|
||||||
* `handleKeyCode` offsets 15–19 are `mTermSession.getEmulator()` with NO null guard. `mEmulator` is
|
|
||||||
* guarded; `mTermSession` is not, and it is null forever in this app (plan §6.1). No `TerminalViewClient`
|
|
||||||
* callback sits anywhere on that path, so installing a client could not help — only taking the gesture
|
|
||||||
* away from the stock view could.
|
|
||||||
*
|
|
||||||
* **Claude Code is an alternate-screen TUI.** That makes this ordinary use, not an edge case: scrolling
|
|
||||||
* back through a running Claude session was a guaranteed crash. Every test below therefore enters the
|
|
||||||
* alternate buffer with `ESC [ ? 1 0 4 9 h` FIRST, then drives the real gesture through
|
|
||||||
* [android.view.View.dispatchTouchEvent] / `dispatchGenericMotionEvent` — the three stock entry points
|
|
||||||
* (`TerminalView$1.onScroll`, the `TerminalView$1$1` fling runnable, `onGenericMotionEvent`).
|
|
||||||
*
|
|
||||||
* "It did not crash" is necessary but not sufficient, so each test also pins the bytes: the alternate-
|
|
||||||
* buffer branch must emit the DECCKM-correct arrow form, the mouse-tracking branch must emit a mouse
|
|
||||||
* report and NOT arrows, and the fling must be silent (proof the stock runnable never started).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class AlternateScreenScrollRegressionTest {
|
|
||||||
|
|
||||||
private lateinit var harness: TerminalTestHarness
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
harness = TerminalTestHarness.launch()
|
|
||||||
harness.awaitAttachSettled()
|
|
||||||
// ESC [ ? 1 0 4 9 h — enter the alternate screen buffer. This is the state Claude Code, vim,
|
|
||||||
// htop, less and every git pager run in, and the state that made stock scrolling fatal.
|
|
||||||
harness.feedAndAwait("\u001b[?1049h", "the alternate screen buffer to be active") {
|
|
||||||
harness.emulator.isAlternateBufferActive
|
|
||||||
}
|
|
||||||
harness.clearSent()
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
harness.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun swipeUpInsideTheAlternateBuffer_doesNotCrashAndEmitsDownArrows() {
|
|
||||||
val claimed = harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX)
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
"the frame must claim the vertical drag — that is what makes stock doScroll unreachable",
|
|
||||||
claimed,
|
|
||||||
)
|
|
||||||
assertTrue("the alternate buffer must still be active", harness.emulator.isAlternateBufferActive)
|
|
||||||
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
|
|
||||||
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
|
|
||||||
assertEquals(
|
|
||||||
"a finger moving UP the screen scrolls the TUI forward — DPAD_DOWN in CSI form",
|
|
||||||
emptyList<ClientMessage.Input>(),
|
|
||||||
frames.filterNot { it.data == "\u001b[B" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun swipeDownInsideTheAlternateBuffer_doesNotCrashAndEmitsUpArrows() {
|
|
||||||
val claimed = harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX)
|
|
||||||
|
|
||||||
assertTrue("the frame must claim the vertical drag", claimed)
|
|
||||||
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
|
|
||||||
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
|
|
||||||
assertEquals(
|
|
||||||
"a finger moving DOWN the screen scrolls back — DPAD_UP in CSI form",
|
|
||||||
emptyList<ClientMessage.Input>(),
|
|
||||||
frames.filterNot { it.data == "\u001b[A" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The arrow FORM has to follow the emulator's live DECCKM bit, because that is what stock
|
|
||||||
* `handleKeyCode` would have read off `mTermSession.getEmulator()`. A TUI in application-cursor mode
|
|
||||||
* that received `ESC [ A` instead of `ESC O A` would mis-handle the scroll.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun scrollUnderApplicationCursorMode_emitsTheSs3ArrowForm() {
|
|
||||||
harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") {
|
|
||||||
harness.emulator.isCursorKeysApplicationMode
|
|
||||||
}
|
|
||||||
harness.clearSent()
|
|
||||||
|
|
||||||
harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX)
|
|
||||||
|
|
||||||
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
|
|
||||||
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
|
|
||||||
assertEquals(
|
|
||||||
"application-cursor mode must emit the SS3 form",
|
|
||||||
emptyList<ClientMessage.Input>(),
|
|
||||||
frames.filterNot { it.data == "\u001bOA" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `doScroll`'s FIRST branch (offsets 26–53) is mouse tracking, and it must win over the alternate-
|
|
||||||
* buffer branch. Getting the order wrong would send arrow keys to a program that asked for mouse
|
|
||||||
* reports — the terminal would appear to ignore the wheel.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun wheelUnderMouseTracking_reportsTheWheelAndSendsNoArrowKeys() {
|
|
||||||
// ESC [ ? 1 0 0 0 h — button-press mouse tracking (what a mouse-aware TUI enables).
|
|
||||||
harness.feedAndAwait("\u001b[?1000h", "mouse tracking to be active") {
|
|
||||||
harness.emulator.isMouseTrackingActive
|
|
||||||
}
|
|
||||||
harness.clearSent()
|
|
||||||
|
|
||||||
val handled = harness.scrollWheel(up = true)
|
|
||||||
|
|
||||||
assertTrue("the frame must claim the mouse wheel", handled)
|
|
||||||
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
|
|
||||||
assertTrue("the wheel must be reported to the program", frames.isNotEmpty())
|
|
||||||
assertEquals(
|
|
||||||
"mouse tracking must suppress the arrow-key branch entirely",
|
|
||||||
emptyList<ClientMessage.Input>(),
|
|
||||||
frames.filter { it.data == "\u001b[A" || it.data == "\u001bOA" },
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"a wheel report must be an escape sequence, got ${frames.map { it.data.toCharList() }}",
|
|
||||||
frames.all { it.data.startsWith("\u001b[") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Without mouse tracking the wheel takes the same alternate-buffer branch a finger drag does. */
|
|
||||||
@Test
|
|
||||||
fun wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch() {
|
|
||||||
val handled = harness.scrollWheel(up = true)
|
|
||||||
|
|
||||||
assertTrue("the frame must claim the mouse wheel", handled)
|
|
||||||
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
|
|
||||||
// Stock onGenericMotionEvent scrolls MOUSE_WHEEL_ROWS (3) rows per notch, one key per row.
|
|
||||||
assertEquals(
|
|
||||||
"one wheel notch is three rows",
|
|
||||||
List(WHEEL_ROWS_PER_NOTCH) { ClientMessage.Input("\u001b[A") },
|
|
||||||
frames,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The third stock entry point is the fling runnable `TerminalView$1$1.run`, which keeps calling
|
|
||||||
* `doScroll` after the finger leaves. It can only start if `mGestureRecognizer` observed the scroll —
|
|
||||||
* which is exactly what the intercept prevents. So the observable is silence after the drag ends: a
|
|
||||||
* live fling would keep emitting arrows (and, before the fix, would crash a beat after the gesture,
|
|
||||||
* which is what made this bug look intermittent).
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun theStockFlingNeverStarts_soNoBytesArriveAfterTheFingerLeaves() {
|
|
||||||
harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX, steps = 2)
|
|
||||||
harness.drainSent() // the drag's own rows
|
|
||||||
|
|
||||||
val afterTheGesture = harness.drainSent(settleMs = FLING_SETTLE_MS)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"no fling may continue scrolling after ACTION_UP",
|
|
||||||
emptyList<ClientMessage>(),
|
|
||||||
afterTheGesture,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.toCharList(): List<Int> = map { it.code }
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/** Comfortably past the touch slop and several cell heights on any density. */
|
|
||||||
const val DRAG_DISTANCE_PX = 600f
|
|
||||||
|
|
||||||
/** `TerminalScrollGesture.MOUSE_WHEEL_ROWS` — stock scrolls three rows per notch. */
|
|
||||||
const val WHEEL_ROWS_PER_NOTCH = 3
|
|
||||||
|
|
||||||
/** Long enough for a real fling to have produced several frames of scrolling. */
|
|
||||||
const val FLING_SETTLE_MS = 800L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package wang.yaojia.webterm.terminal
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import android.view.autofill.AutofillValue
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REGRESSION — **a password manager crashed the app just by offering to fill the terminal.**
|
|
||||||
*
|
|
||||||
* Stock `TerminalView.autofill(AutofillValue)` (offsets 7–20) writes the filled text straight into
|
|
||||||
* `mTermSession`, which is null forever here, AND the view advertises itself as fillable
|
|
||||||
* (`getAutofillType()` = `AUTOFILL_TYPE_TEXT`, `getAutofillValue()` non-null). So on any device with an
|
|
||||||
* autofill service enabled — the default state for most users — focusing the terminal was enough to arm a
|
|
||||||
* crash, with no user action beyond accepting the fill.
|
|
||||||
*
|
|
||||||
* The fix is not a try/catch around `autofill`: it is keeping the whole subtree OUT of the autofill
|
|
||||||
* structure, so the framework never builds a fillable node for it. `View.isImportantForAutofill()` walks
|
|
||||||
* the parent chain and stops at the first `*_EXCLUDE_DESCENDANTS`, and that is precisely the gate
|
|
||||||
* `AutofillManager` consults before it will notify a service about a view — so it is the gate these tests
|
|
||||||
* assert, on both the frame and the stock child.
|
|
||||||
*
|
|
||||||
* **Deliberately NOT tested here:** the end-to-end loop with a real autofill service (which needs an
|
|
||||||
* installed, user-selected `AutofillService`) stays on the device-QA checklist. What IS provable on an
|
|
||||||
* emulator is the framework contract that makes the crash unreachable, plus the belt-and-braces
|
|
||||||
* assertion that a fill delivered anyway puts nothing on the wire — shell input is exactly the content
|
|
||||||
* that must never enter an autofill structure (plan §8).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class TerminalAutofillRegressionTest {
|
|
||||||
|
|
||||||
private lateinit var harness: TerminalTestHarness
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
harness = TerminalTestHarness.launch()
|
|
||||||
harness.awaitAttachSettled()
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
harness.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theTerminalFrameIsExcludedFromTheAutofillStructure() {
|
|
||||||
val flag = harness.onMain { harness.hostView.importantForAutofill }
|
|
||||||
val important = harness.onMain { harness.hostView.isImportantForAutofill }
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"the frame must answer IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS",
|
|
||||||
View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS,
|
|
||||||
flag,
|
|
||||||
)
|
|
||||||
assertFalse("the framework must consider the frame unimportant for autofill", important)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The child is the view that actually carries the crashing `autofill` override, and it is reached
|
|
||||||
* through the parent-chain walk — which is why the frame overrides the GETTER rather than only
|
|
||||||
* setting the flag: a later `setImportantForAutofill` cannot quietly re-enable it.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun theStockTerminalViewIsExcludedThroughTheParentChain() {
|
|
||||||
val important = harness.onMain { harness.stockChild.isImportantForAutofill }
|
|
||||||
|
|
||||||
assertFalse(
|
|
||||||
"the stock TerminalView must be unimportant for autofill via the EXCLUDE_DESCENDANTS walk",
|
|
||||||
important,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun focusingTheTerminalKeepsItExcluded() {
|
|
||||||
harness.onMain { harness.hostView.requestFocus() }
|
|
||||||
|
|
||||||
assertFalse(
|
|
||||||
"focus must not make the terminal fillable — focus is what triggered the old crash",
|
|
||||||
harness.onMain { harness.hostView.isImportantForAutofill },
|
|
||||||
)
|
|
||||||
assertFalse(
|
|
||||||
"…nor the stock child",
|
|
||||||
harness.onMain { harness.stockChild.isImportantForAutofill },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Belt and braces: even a fill delivered directly at the frame (bypassing the structure gate) must be
|
|
||||||
* inert. A password reaching `ClientMessage.Input` would be typed into whatever shell is running.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun aFillDeliveredAtTheFrameIsInertAndNeverReachesTheWire() {
|
|
||||||
harness.onMain { harness.hostView.autofill(AutofillValue.forText("correct-horse-battery-staple")) }
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"an autofilled value must never be sent to the shell",
|
|
||||||
emptyList<ClientMessage>(),
|
|
||||||
harness.drainSent(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
package wang.yaojia.webterm.terminal
|
|
||||||
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REGRESSION — **BLOCKER 1: the app died on the first key press.**
|
|
||||||
*
|
|
||||||
* `TerminalView.mClient` was never installed, and stock `onKeyDown` dereferences it with no null guard
|
|
||||||
* (offset 82–92 → `mClient.onKeyDown(keyCode, event, mTermSession)`), so *any* key — hardware or
|
|
||||||
* soft-keyboard — was an instant NPE on the UI thread. Installing a client that returned `false` would
|
|
||||||
* only have moved the crash one line down, into `mTermSession.write(getCharacters())` (offset 149–160),
|
|
||||||
* because `mTermSession` is null forever here (plan §6.1). The soft-keyboard half was different but no
|
|
||||||
* better: the stock `TerminalView$2` `InputConnection` routes `commitText` into `inputCodePoint`, which
|
|
||||||
* early-returns when `mTermSession == null` — everything typed vanished silently.
|
|
||||||
*
|
|
||||||
* These tests therefore assert **both** halves of the contract on a real device: nothing crashes, AND the
|
|
||||||
* exact right bytes reach the wire. A test that only proved "no crash" would pass against the silent
|
|
||||||
* black hole, which was arguably the worse of the two bugs.
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class TerminalKeyInputRegressionTest {
|
|
||||||
|
|
||||||
private lateinit var harness: TerminalTestHarness
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
harness = TerminalTestHarness.launch()
|
|
||||||
// Discard the attach-time grid push before any assertion looks at the wire.
|
|
||||||
harness.awaitAttachSettled()
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
harness.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hardware keys ────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hardwareEnter_sendsCarriageReturnNotLineFeed() {
|
|
||||||
val consumed = harness.pressKey(KeyEvent.KEYCODE_ENTER)
|
|
||||||
|
|
||||||
assertTrue("the terminal must consume Enter, not leak it to the Activity", consumed)
|
|
||||||
// Repo-wide gotcha: a terminal Enter is CR (0x0D), never LF.
|
|
||||||
assertEquals(ClientMessage.Input("\r"), harness.awaitSent("Enter"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hardwarePrintableKey_sendsThatCharacter() {
|
|
||||||
harness.pressKey(KeyEvent.KEYCODE_A)
|
|
||||||
|
|
||||||
assertEquals(ClientMessage.Input("a"), harness.awaitSent("the letter a"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hardwareShiftedKey_sendsTheCapital() {
|
|
||||||
harness.pressKey(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON)
|
|
||||||
|
|
||||||
assertEquals(ClientMessage.Input("A"), harness.awaitSent("Shift+A"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hardwareCtrlC_sendsTheInterruptControlByte() {
|
|
||||||
harness.pressKey(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON)
|
|
||||||
|
|
||||||
assertEquals(ClientMessage.Input("\u0003"), harness.awaitSent("Ctrl+C"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hardwareBackspace_sendsDel() {
|
|
||||||
harness.pressKey(KeyEvent.KEYCODE_DEL)
|
|
||||||
|
|
||||||
assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("Backspace"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DECCKM (plan §6.3): arrows must go through Termux's `KeyHandler` so application-cursor mode emits
|
|
||||||
* `ESC O A` rather than `ESC [ A`. Hardcoding the CSI form breaks vim/htop, so the byte form is
|
|
||||||
* asserted in BOTH modes against the live emulator bit.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun arrowUp_followsTheEmulatorsCursorKeyMode() {
|
|
||||||
harness.pressKey(KeyEvent.KEYCODE_DPAD_UP)
|
|
||||||
assertEquals(
|
|
||||||
"normal cursor mode must emit the CSI form",
|
|
||||||
ClientMessage.Input("\u001b[A"),
|
|
||||||
harness.awaitSent("Up in normal cursor mode"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ESC [ ? 1 h — DECCKM on (what full-screen TUIs set).
|
|
||||||
harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") { harness.emulator.isCursorKeysApplicationMode }
|
|
||||||
harness.clearSent()
|
|
||||||
|
|
||||||
harness.pressKey(KeyEvent.KEYCODE_DPAD_UP)
|
|
||||||
assertEquals(
|
|
||||||
"application cursor mode must emit the SS3 form",
|
|
||||||
ClientMessage.Input("\u001bOA"),
|
|
||||||
harness.awaitSent("Up in application cursor mode"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BACK must stay the Activity's: `WebTermTerminalViewClient.shouldBackButtonBeMappedToEscape()`
|
|
||||||
* answers false precisely so stock `onKeyDown` cannot route it into the terminal branch (whose next
|
|
||||||
* stop is `mTermSession.write`). If this ever returns true the terminal screen becomes a trap the
|
|
||||||
* user cannot leave.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun systemBackKey_isNotSwallowedAndSendsNothing() {
|
|
||||||
val consumedDown = harness.pressKey(KeyEvent.KEYCODE_BACK)
|
|
||||||
val consumedUp = harness.releaseKey(KeyEvent.KEYCODE_BACK)
|
|
||||||
|
|
||||||
assertFalse("BACK down must fall through to the Activity", consumedDown)
|
|
||||||
assertFalse("BACK up must fall through to the Activity", consumedUp)
|
|
||||||
assertEquals("BACK must put no bytes on the wire", emptyList<ClientMessage>(), harness.drainSent())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Soft keyboard (the IME seam) ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun softKeyboardIsOffered_anInputConnectionForTheTerminal() {
|
|
||||||
val connection = harness.openInputConnection()
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
"the frame must declare itself a text editor or no IME will attach",
|
|
||||||
harness.onMain { harness.hostView.onCheckIsTextEditor() },
|
|
||||||
)
|
|
||||||
// Proves the connection is OURS, not the stock TerminalView$2 black hole: committing text has to
|
|
||||||
// reach the wire.
|
|
||||||
harness.onMain { connection.commitText("ls -la", 1) }
|
|
||||||
assertEquals(ClientMessage.Input("ls -la"), harness.awaitSent("a soft-keyboard commit"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun softKeyboardEnter_sendsCarriageReturn() {
|
|
||||||
val connection = harness.openInputConnection()
|
|
||||||
|
|
||||||
// Some IMEs deliver Enter as a synthesised key event rather than as text.
|
|
||||||
harness.onMain {
|
|
||||||
connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals(ClientMessage.Input("\r"), harness.awaitSent("an IME-synthesised Enter"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun softKeyboardBackspace_sendsDel() {
|
|
||||||
val connection = harness.openInputConnection()
|
|
||||||
|
|
||||||
harness.onMain { connection.deleteSurroundingText(1, 0) }
|
|
||||||
|
|
||||||
assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("an IME backspace"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Non-ASCII is passed through VERBATIM (invariant #9 — no filtering, no normalisation). CJK is the
|
|
||||||
* case that matters because it arrives via composition, and it is also the one a byte-oriented
|
|
||||||
* implementation is most likely to mangle.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun softKeyboardCjkCommit_isPassedThroughVerbatim() {
|
|
||||||
val connection = harness.openInputConnection()
|
|
||||||
|
|
||||||
harness.onMain {
|
|
||||||
connection.setComposingText("中文", 1)
|
|
||||||
connection.commitText("中文", 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
|
|
||||||
assertEquals(
|
|
||||||
"the committed CJK text must reach the wire unmodified",
|
|
||||||
"中文",
|
|
||||||
frames.joinToString(separator = "") { it.data },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package wang.yaojia.webterm.terminal
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REGRESSION for a **defect this suite FOUND, and which is NOT yet fixed** (2026-07-30).
|
|
||||||
*
|
|
||||||
* ## The defect
|
|
||||||
* `RemoteTerminalSession.onResize` calls `emulator.resize(cols, rows)` on its confined *writer* thread.
|
|
||||||
* The stock `TerminalRenderer` reads that same `TerminalBuffer` on the **UI thread** during `onDraw`, and
|
|
||||||
* it caches `mEmulator.mColumns` / `mRows` at the top of `render()` before walking the rows. So a regrid
|
|
||||||
* landing mid-frame tears the buffer out from under the renderer, which then indexes rows that have
|
|
||||||
* already been reallocated to the other size. Both shapes were observed as FATAL exceptions on the main
|
|
||||||
* thread on the `webterm` AVD (android-35):
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* FATAL EXCEPTION: main
|
|
||||||
* java.lang.ArrayIndexOutOfBoundsException: length=31; index=31
|
|
||||||
* at com.termux.terminal.TerminalRow.getStyle(TerminalRow.java:244)
|
|
||||||
* at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:104)
|
|
||||||
* at com.termux.view.TerminalView.onDraw(TerminalView.java:921)
|
|
||||||
*
|
|
||||||
* FATAL EXCEPTION: main
|
|
||||||
* java.lang.IllegalArgumentException: extRow=19, mScreenRows=18, mActiveTranscriptRows=0
|
|
||||||
* at com.termux.terminal.TerminalBuffer.externalToInternalRow(TerminalBuffer.java:175)
|
|
||||||
* at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:83)
|
|
||||||
* at com.termux.view.TerminalView.onDraw(TerminalView.java:921)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* ## Why it matters, and how reliably it reproduces — stated precisely
|
|
||||||
* The window is not exotic: **every** session attach performs an 80x24 -> real-grid reflow (the server
|
|
||||||
* spawns the PTY at 80x24 and the client corrects it), and every rotation performs another, both while
|
|
||||||
* frames are being drawn. `TerminalBuffer.resize` reallocates every transcript row, so at production's
|
|
||||||
* 10 000-row transcript the mutation takes milliseconds rather than microseconds.
|
|
||||||
*
|
|
||||||
* **Reproduction is load-dependent, and a green run here does NOT mean the bug is gone.** Measured on the
|
|
||||||
* `webterm` AVD:
|
|
||||||
* - Under load (host memory starved, emulator thrashing) it fired constantly: it aborted roughly one
|
|
||||||
* instrumentation setup in three, hit an alternating-regrid loop within one or two iterations, and
|
|
||||||
* surfaced BOTH crash shapes above across about six separate runs.
|
|
||||||
* - On a freshly rebooted, idle emulator, 60 iterations with a concurrent output burst each pass cleanly:
|
|
||||||
* the reflow simply finishes inside a gap between frames.
|
|
||||||
*
|
|
||||||
* That is the signature of a real, unsynchronised data race rather than a deterministic bug — and load is
|
|
||||||
* exactly the condition a user meets: a mid-range phone rotating while a busy Claude session streams, or a
|
|
||||||
* cold start replaying a multi-MB ring buffer. So this test is a **stress probe, not a decider**: it fails
|
|
||||||
* loudly when it catches the tear and must not be read as an all-clear when it does not. The finding stands
|
|
||||||
* on the captured stack traces above, not on this test's verdict.
|
|
||||||
*
|
|
||||||
* It is invisible to the 900-strong JVM suite for the usual reason - no JVM test draws a `View` - and
|
|
||||||
* invisible to a manual emulator pass that only reaches the pairing screen, because it needs a bound
|
|
||||||
* session.
|
|
||||||
*
|
|
||||||
* ## The fix (verified here before being reported, and NOT applied — `:terminal-view` is not this
|
|
||||||
* agent's to edit)
|
|
||||||
* Do the reflow on the renderer's own thread: inside the confined consumer, wrap the mutation as
|
|
||||||
* `withContext(mainDispatcher) { emulator.resize(cols, rows) }`. That keeps command ORDER intact (the
|
|
||||||
* consumer still processes one command at a time, so a resize cannot overtake an append) while making the
|
|
||||||
* mutation mutually exclusive with `onDraw`. It is also what Termux itself does — its own
|
|
||||||
* `TerminalView.updateSize()` resizes from the UI thread.
|
|
||||||
*
|
|
||||||
* **Control experiment, run on this emulator:** 60 alternating regrids driven from the *main* thread with
|
|
||||||
* `invalidate()` after each one → zero crashes. The identical loop with the regrid on the confined thread
|
|
||||||
* → FATAL within two iterations. So the hop is sufficient, and the confinement is the cause.
|
|
||||||
*
|
|
||||||
* ## Reading this test
|
|
||||||
* A FAILURE (a main-thread FATAL that also aborts the instrumentation run, which is what the defect does to
|
|
||||||
* a user's app) is a positive catch: the bug is still there. A PASS means only that this run did not win the
|
|
||||||
* race. It deliberately uses the production transcript depth
|
|
||||||
* ([RemoteTerminalSession.TRANSCRIPT_ROWS]) and a concurrent output burst per iteration, which are the two
|
|
||||||
* knobs that widen the window. Once the reflow is hopped onto the main thread the test becomes a genuine
|
|
||||||
* decider, because then no interleaving exists that can tear the buffer.
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class TerminalResizeDrawRaceRegressionTest {
|
|
||||||
|
|
||||||
private lateinit var harness: TerminalTestHarness
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
// Production scrollback depth on purpose: it is what makes the reallocation — and therefore the
|
|
||||||
// race window — the real size.
|
|
||||||
harness = TerminalTestHarness.launch(transcriptRows = RemoteTerminalSession.TRANSCRIPT_ROWS)
|
|
||||||
harness.awaitAttachSettled()
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
harness.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alternate between two grids while forcing a redraw after each change — i.e. what a user does by
|
|
||||||
* rotating the device, folding a foldable, or dragging a multi-window divider. Every iteration must
|
|
||||||
* survive; a single torn frame is a dead app.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun regriddingWhileTheRendererDraws_neverTearsTheBuffer() {
|
|
||||||
repeat(REGRID_ITERATIONS) { iteration ->
|
|
||||||
// Keep the confined writer busy so the reflow is queued behind real appends and lands at an
|
|
||||||
// unpredictable point relative to the frame pipeline. Without this the reflow tends to complete
|
|
||||||
// in one gap between frames on a fast, idle device and the tear is missed — the race is
|
|
||||||
// probabilistic, and this is what makes the probability worth testing.
|
|
||||||
harness.session.feedRemote(OUTPUT_BURST.toByteArray(Charsets.UTF_8))
|
|
||||||
val wide = iteration % 2 == 0
|
|
||||||
harness.layoutTo(
|
|
||||||
widthPx = if (wide) WIDE_PX else NARROW_PX,
|
|
||||||
heightPx = if (wide) SHORT_PX else TALL_PX,
|
|
||||||
)
|
|
||||||
harness.onMain {
|
|
||||||
harness.hostView.invalidate()
|
|
||||||
harness.stockChild.invalidate()
|
|
||||||
}
|
|
||||||
harness.awaitTrue("iteration $iteration to reflow the emulator") {
|
|
||||||
harness.emulator.mColumns > 0 && harness.emulator.mRows > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reaching here at all is the assertion: the failure mode is a process-killing FATAL on the main
|
|
||||||
// thread, not a false return. This last check adds a coherence probe that indexes the buffer the
|
|
||||||
// same way the renderer does — a torn buffer throws out of it rather than answering.
|
|
||||||
val readBack = harness.emulator.screen.getSelectedText(
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
harness.emulator.mColumns - 1,
|
|
||||||
harness.emulator.mRows - 1,
|
|
||||||
)
|
|
||||||
assertNotNull("the buffer must still be readable at the emulator's advertised dims", readBack)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/**
|
|
||||||
* The tear is probabilistic: it needs the reflow to land while a frame is mid-flight, which on an
|
|
||||||
* idle emulator sometimes never happens. 60 iterations with a concurrent output burst each is what
|
|
||||||
* made it reproduce, rather than the 24 quiet ones that passed on a freshly booted device.
|
|
||||||
*/
|
|
||||||
const val REGRID_ITERATIONS = 60
|
|
||||||
|
|
||||||
/** ~8 KB of real terminal output per iteration: enough to keep the confined writer working. */
|
|
||||||
val OUTPUT_BURST: String = (1..64).joinToString("") { "webterm-race-$it " + "-".repeat(64) + "\r\n" }
|
|
||||||
|
|
||||||
const val WIDE_PX = 900
|
|
||||||
const val NARROW_PX = 500
|
|
||||||
const val SHORT_PX = 700
|
|
||||||
const val TALL_PX = 900
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
package wang.yaojia.webterm.terminal
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REGRESSION — **`resize` had zero call sites, so the PTY stayed at 80×24 forever.**
|
|
||||||
*
|
|
||||||
* `RemoteTerminalSession.updateSize` existed and was unit-tested, and nothing called it. The stock
|
|
||||||
* fallback cannot help either: `TerminalView.updateSize()` early-returns when `mTermSession == null`
|
|
||||||
* (offsets 18–25), permanently our case. The visible symptom was every full-screen TUI — Claude Code
|
|
||||||
* included — rendering into an 80×24 box in the corner of a phone screen, and never reflowing on rotation.
|
|
||||||
*
|
|
||||||
* A JVM test cannot catch this class of bug at all: the arithmetic was already correct and tested
|
|
||||||
* ([wang.yaojia.webterm.terminalview.TerminalGridMath]); what was missing was a real layout pass, real
|
|
||||||
* cell metrics from a real `TerminalRenderer`, and a real wire. All three exist here.
|
|
||||||
*
|
|
||||||
* ### How "the expected grid" is asserted without reading private font metrics
|
|
||||||
* `TerminalRenderer`'s metrics are only reachable through `:terminal-view`'s `internal` API, and the
|
|
||||||
* Termux artifact is `implementation`-scoped so its types are off `:app`'s classpath. Rather than
|
|
||||||
* reflect (which silently rots) the tests assert the **documented formula's own algebra**, which needs no
|
|
||||||
* metric value: with zero padding, `cols = floor(W / fontWidth)`, so doubling the width must give
|
|
||||||
* `floor(2W / fontWidth) ∈ {2·cols, 2·cols + 1}` — and nothing else. Same for rows. That pins the grid to
|
|
||||||
* the plan §6.4 formula rather than merely checking it is "some positive number", and it fails loudly if
|
|
||||||
* the padding assumption ever stops holding (asserted separately).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class TerminalResizeRegressionTest {
|
|
||||||
|
|
||||||
private lateinit var harness: TerminalTestHarness
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
harness = TerminalTestHarness.launch()
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
harness.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theStockViewHasNoPadding_whichTheGridAlgebraBelowRelies_on() {
|
|
||||||
val horizontal = harness.onMain { harness.stockChild.paddingLeft }
|
|
||||||
val vertical = harness.onMain { harness.stockChild.paddingTop }
|
|
||||||
|
|
||||||
assertEquals("the grid algebra in this class assumes zero horizontal padding", 0, horizontal)
|
|
||||||
assertEquals("the grid algebra in this class assumes zero vertical padding", 0, vertical)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theFirstLayoutPushesARealGridToTheWireAndToTheEmulator() {
|
|
||||||
val resize = harness.firstResize()
|
|
||||||
|
|
||||||
assertTrue("cols must be a real grid, not the 80x24 default box", resize.cols > 1)
|
|
||||||
assertTrue("rows must be a real grid, not the 80x24 default box", resize.rows > 1)
|
|
||||||
assertEquals(
|
|
||||||
"the local emulator must be reflowed to exactly the dims that went on the wire",
|
|
||||||
resize.cols to resize.rows,
|
|
||||||
harness.emulator.mColumns to harness.emulator.mRows,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"the grid must be within the protocol's validated range",
|
|
||||||
resize.cols in RESIZE_RANGE && resize.rows in RESIZE_RANGE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The §6.4 formula, checked by its own algebra: doubling the pixel box must double the grid to within
|
|
||||||
* the one cell a `floor` can absorb. A driver that ignored the real metrics (e.g. one that resent the
|
|
||||||
* default 80×24, or one that divided by a re-measured `Paint`) cannot satisfy this.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun doublingTheViewboxDoublesTheGrid_perThePlanFormula() {
|
|
||||||
val base = harness.firstResize()
|
|
||||||
|
|
||||||
harness.clearSent()
|
|
||||||
harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX * 2, TerminalTestHarness.DEFAULT_HEIGHT_PX * 2)
|
|
||||||
val doubled = harness.awaitResize("the grid after doubling the viewbox")
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
"cols=floor(W/fw) ⇒ floor(2W/fw) must be 2·cols or 2·cols+1, was ${doubled.cols} for base ${base.cols}",
|
|
||||||
doubled.cols == base.cols * 2 || doubled.cols == base.cols * 2 + 1,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"rows=H/ls ⇒ 2H/ls must be 2·rows or 2·rows+1, was ${doubled.rows} for base ${base.rows}",
|
|
||||||
doubled.rows == base.rows * 2 || doubled.rows == base.rows * 2 + 1,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"the emulator must follow the wire",
|
|
||||||
doubled.cols to doubled.rows,
|
|
||||||
harness.emulator.mColumns to harness.emulator.mRows,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A rotation is, to this path, a layout pass with the dimensions swapped — and it must produce a NEW
|
|
||||||
* grid rather than keep the portrait one (the "does not reflow on rotation" half of the bug).
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun swappingTheViewboxDimensions_reflowsToATransposedGrid() {
|
|
||||||
val portrait = harness.firstResize()
|
|
||||||
|
|
||||||
harness.clearSent()
|
|
||||||
harness.layoutTo(TerminalTestHarness.DEFAULT_HEIGHT_PX, TerminalTestHarness.DEFAULT_WIDTH_PX)
|
|
||||||
val landscape = harness.awaitResize("the grid after swapping width and height")
|
|
||||||
|
|
||||||
assertTrue("a wider box must give more columns", landscape.cols > portrait.cols)
|
|
||||||
assertTrue("a shorter box must give fewer rows", landscape.rows < portrait.rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Layout fires far more often than the grid changes (every IME show/hide, inset change and frame of a
|
|
||||||
* fold drag). Each one becoming a wire frame would mean a SIGWINCH storm, so an unchanged grid must be
|
|
||||||
* silent — the dedup that makes the forced re-assert below necessary in the first place.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun relayoutAtTheSameSize_sendsNothing() {
|
|
||||||
harness.firstResize()
|
|
||||||
harness.clearSent()
|
|
||||||
|
|
||||||
harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX, TerminalTestHarness.DEFAULT_HEIGHT_PX)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"an unchanged grid must not reach the wire",
|
|
||||||
emptyList<ClientMessage>(),
|
|
||||||
harness.drainSent(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **The §6.4 double-dedup bug.** A shared PTY has exactly ONE size, so a device returning to the
|
|
||||||
* foreground has to re-assert dims it has already sent in order to take the size back from whichever
|
|
||||||
* device wrote last. Two dedups sit in series on that path — `TerminalResizeDriver.lastGrid` and
|
|
||||||
* `RemoteTerminalSession.lastSentDims` — and BOTH survive a rebind, so an unflagged re-push dies
|
|
||||||
* silently and the returning device stays letterboxed forever.
|
|
||||||
*
|
|
||||||
* Rebinding the session is production's own trigger (`RemoteTerminalView.session`'s setter and
|
|
||||||
* `bindEmulator` both call `reassertLastGrid()`), so this test performs exactly that and requires the
|
|
||||||
* identical grid to reach the wire a second time.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun rebindingTheSession_reassertsTheSameGridOntoTheWire() {
|
|
||||||
val before = harness.firstResize()
|
|
||||||
harness.clearSent()
|
|
||||||
|
|
||||||
// What a device-switch / pane-show does: re-bind, which must force the memoised grid through both
|
|
||||||
// dedups even though not one pixel changed.
|
|
||||||
harness.onMain { harness.view.session = harness.session }
|
|
||||||
|
|
||||||
val reasserted = harness.awaitResize("the forced re-assert after a rebind")
|
|
||||||
assertEquals(
|
|
||||||
"the re-assert must carry the SAME grid — it is a claim on the shared PTY size, not a change",
|
|
||||||
before,
|
|
||||||
reasserted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The same reclaim through the other production trigger: a brand-new session (a real background →
|
|
||||||
* foreground cycle bumps the generation and builds a fresh emulator at the server's 80×24 default).
|
|
||||||
* The view already knows the real grid, so it must push it at the new session immediately rather than
|
|
||||||
* wait for a layout pass that will never come.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun attachingAFreshSession_pushesTheMeasuredGridImmediately() {
|
|
||||||
val measured = harness.firstResize()
|
|
||||||
|
|
||||||
val replacementSent = java.util.concurrent.LinkedBlockingQueue<ClientMessage>()
|
|
||||||
val replacement = harness.onMain {
|
|
||||||
RemoteTerminalSession(engineSend = { message -> replacementSent.put(message) })
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
harness.onMain { replacement.attachView(harness.view) }
|
|
||||||
|
|
||||||
val pushed = harness.awaitTrueValue("the fresh session to be told the real grid") {
|
|
||||||
replacementSent.poll()?.let { it as? ClientMessage.Resize }
|
|
||||||
}
|
|
||||||
assertEquals(
|
|
||||||
"a fresh emulator starts at the server's 80x24 default and must be corrected at once",
|
|
||||||
measured,
|
|
||||||
pushed,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"the fresh emulator must be reflowed too",
|
|
||||||
measured.cols to measured.rows,
|
|
||||||
replacement.emulator.mColumns to replacement.emulator.mRows,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
harness.onMain { replacement.close() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** The attach-time grid push — the first `Resize` the initial layout produced. */
|
|
||||||
private fun TerminalTestHarness.firstResize(): ClientMessage.Resize = awaitAttachSettled()
|
|
||||||
|
|
||||||
private fun TerminalTestHarness.awaitResize(what: String): ClientMessage.Resize {
|
|
||||||
var last: ClientMessage? = null
|
|
||||||
repeat(MAX_FRAMES_SCANNED) {
|
|
||||||
val message = awaitSent(what)
|
|
||||||
last = message
|
|
||||||
if (message is ClientMessage.Resize) return message
|
|
||||||
}
|
|
||||||
throw AssertionError("no Resize frame arrived while waiting for $what; last frame was $last")
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/** `WireConstants.RESIZE_RANGE` — the server silently drops anything outside it. */
|
|
||||||
val RESIZE_RANGE = 1..1000
|
|
||||||
|
|
||||||
/** Guards the scan loop from spinning if the wire only ever carries non-Resize frames. */
|
|
||||||
const val MAX_FRAMES_SCANNED = 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
package wang.yaojia.webterm.terminal
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import android.view.MotionEvent
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.view.inputmethod.EditorInfo
|
|
||||||
import android.view.inputmethod.InputConnection
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.test.core.app.ActivityScenario
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import com.termux.terminal.TerminalEmulator
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
|
|
||||||
import wang.yaojia.webterm.terminalview.RemoteTerminalView
|
|
||||||
import wang.yaojia.webterm.wire.ClientMessage
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The device-side rig for the four 2026-07-30 crash regressions.
|
|
||||||
*
|
|
||||||
* ### Why these tests can only exist here
|
|
||||||
* Every defect this rig covers lived in a real `android.view.View` (`TerminalView.onKeyDown`,
|
|
||||||
* `doScroll`, `autofill`, `updateSize`). The 900-odd JVM tests could not see any of them because no JVM
|
|
||||||
* test instantiates a View, inflates an `InputConnection`, dispatches a `MotionEvent` or runs a layout
|
|
||||||
* pass — which is exactly how three crash-on-first-interaction bugs shipped behind a green suite. So the
|
|
||||||
* rig builds the REAL [RemoteTerminalView] inside a REAL Activity and drives it through the REAL
|
|
||||||
* framework entry points. Nothing is faked except the wire: [sent] captures the [ClientMessage]s that
|
|
||||||
* would have gone to the server, which is the only observable a regression can be asserted against
|
|
||||||
* without a host.
|
|
||||||
*
|
|
||||||
* ### Threading contract
|
|
||||||
* The View and the session's bind path are main-thread-only, so every mutation goes through
|
|
||||||
* [onMain]. The test body itself runs on the instrumentation thread, which is what lets [awaitSent] and
|
|
||||||
* [awaitTrue] block: the confined emulator-writer thread and the main looper both stay free to make
|
|
||||||
* progress while the test waits. Blocking on the main thread instead would deadlock the very handoff
|
|
||||||
* under test.
|
|
||||||
*/
|
|
||||||
internal class TerminalTestHarness private constructor(
|
|
||||||
private val scenario: ActivityScenario<ComponentActivity>,
|
|
||||||
val view: RemoteTerminalView,
|
|
||||||
val session: RemoteTerminalSession,
|
|
||||||
val root: FrameLayout,
|
|
||||||
private val sent: LinkedBlockingQueue<ClientMessage>,
|
|
||||||
) {
|
|
||||||
/** The frame that owns focus + the IME contract; the stock Termux view is its only child. */
|
|
||||||
val hostView: View get() = view.terminalView
|
|
||||||
|
|
||||||
/** The stock `com.termux.view.TerminalView`, reachable only as a plain [View]: `:terminal-view`
|
|
||||||
* scopes the Termux artifact as `implementation`, so its type is off `:app`'s classpath. Every
|
|
||||||
* assertion this rig needs (`isImportantForAutofill`, padding) is plain `View` API anyway. */
|
|
||||||
val stockChild: View get() = (hostView as ViewGroup).getChildAt(0)
|
|
||||||
|
|
||||||
val emulator: TerminalEmulator get() = session.emulator
|
|
||||||
|
|
||||||
// ── Driving the view ─────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun <T> onMain(block: (Activity) -> T): T {
|
|
||||||
var result: T? = null
|
|
||||||
var failure: Throwable? = null
|
|
||||||
val done = CountDownLatch(1)
|
|
||||||
scenario.onActivity { activity ->
|
|
||||||
try {
|
|
||||||
result = block(activity)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
failure = t
|
|
||||||
} finally {
|
|
||||||
done.countDown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertTrue("the main thread never ran the block within $MAIN_SYNC_TIMEOUT_MS ms",
|
|
||||||
done.await(MAIN_SYNC_TIMEOUT_MS, TimeUnit.MILLISECONDS))
|
|
||||||
failure?.let { throw it }
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
return result as T
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resize the hosted view to an exact pixel box and wait for the layout pass to land. */
|
|
||||||
fun layoutTo(widthPx: Int, heightPx: Int) {
|
|
||||||
onMain {
|
|
||||||
hostView.layoutParams = FrameLayout.LayoutParams(widthPx, heightPx)
|
|
||||||
hostView.requestLayout()
|
|
||||||
}
|
|
||||||
awaitTrue("the view never laid out at ${widthPx}x$heightPx") {
|
|
||||||
onMain { hostView.width == widthPx && hostView.height == heightPx }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dispatch a key through the framework entry point a physical keyboard uses. */
|
|
||||||
fun pressKey(keyCode: Int, metaState: Int = 0): Boolean = onMain {
|
|
||||||
val down = KeyEvent(
|
|
||||||
/* downTime = */ 0L,
|
|
||||||
/* eventTime = */ 0L,
|
|
||||||
KeyEvent.ACTION_DOWN,
|
|
||||||
keyCode,
|
|
||||||
/* repeat = */ 0,
|
|
||||||
metaState,
|
|
||||||
)
|
|
||||||
hostView.dispatchKeyEvent(down)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun releaseKey(keyCode: Int, metaState: Int = 0): Boolean = onMain {
|
|
||||||
val up = KeyEvent(0L, 0L, KeyEvent.ACTION_UP, keyCode, 0, metaState)
|
|
||||||
hostView.dispatchKeyEvent(up)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The soft-keyboard seam: the same `InputConnection` an IME would be handed. */
|
|
||||||
fun openInputConnection(): InputConnection = onMain {
|
|
||||||
val editorInfo = EditorInfo()
|
|
||||||
val connection = hostView.onCreateInputConnection(editorInfo)
|
|
||||||
assertNotNull("onCreateInputConnection returned null — the IME would have no target", connection)
|
|
||||||
connection!!
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A vertical finger drag, dispatched exactly as the framework would: one DOWN, [steps] MOVEs and one
|
|
||||||
* UP, all through [View.dispatchTouchEvent] so `onInterceptTouchEvent` runs for real.
|
|
||||||
*
|
|
||||||
* @param totalDeltaPx negative moves the finger UP the screen (content scrolls forward), positive
|
|
||||||
* moves it DOWN (content scrolls back into history).
|
|
||||||
* @return whether the frame claimed the gesture — true is what makes the crashing stock
|
|
||||||
* `doScroll` / fling path unreachable rather than merely unlikely.
|
|
||||||
*/
|
|
||||||
fun dragVertically(startY: Float, totalDeltaPx: Float, steps: Int = DRAG_STEPS): Boolean {
|
|
||||||
val x = 10f
|
|
||||||
var claimed = false
|
|
||||||
onMain {
|
|
||||||
val down = motionEvent(MotionEvent.ACTION_DOWN, x, startY)
|
|
||||||
hostView.dispatchTouchEvent(down)
|
|
||||||
down.recycle()
|
|
||||||
for (step in 1..steps) {
|
|
||||||
val y = startY + totalDeltaPx * step / steps
|
|
||||||
val move = motionEvent(MotionEvent.ACTION_MOVE, x, y)
|
|
||||||
// dispatchTouchEvent returns the ViewGroup's verdict; once the frame intercepts, the
|
|
||||||
// remainder is routed to its own onTouchEvent, which keeps returning true.
|
|
||||||
claimed = hostView.dispatchTouchEvent(move) || claimed
|
|
||||||
move.recycle()
|
|
||||||
}
|
|
||||||
val up = motionEvent(MotionEvent.ACTION_UP, x, startY + totalDeltaPx)
|
|
||||||
hostView.dispatchTouchEvent(up)
|
|
||||||
up.recycle()
|
|
||||||
}
|
|
||||||
return claimed
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One external-mouse wheel notch — the second stock entry into the crashing `doScroll`. */
|
|
||||||
fun scrollWheel(up: Boolean): Boolean = onMain {
|
|
||||||
val event = wheelEvent(if (up) 1f else -1f)
|
|
||||||
try {
|
|
||||||
hostView.dispatchGenericMotionEvent(event)
|
|
||||||
} finally {
|
|
||||||
event.recycle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Observing ────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** The next message that reached the (fake) wire, or fail after [WIRE_TIMEOUT_MS]. */
|
|
||||||
fun awaitSent(what: String): ClientMessage {
|
|
||||||
val message = sent.poll(WIRE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
|
||||||
assertNotNull("nothing reached the wire within $WIRE_TIMEOUT_MS ms while waiting for $what", message)
|
|
||||||
return message!!
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every message that reached the wire within [settleMs] (used to assert "and nothing else"). */
|
|
||||||
fun drainSent(settleMs: Long = SETTLE_MS): List<ClientMessage> {
|
|
||||||
Thread.sleep(settleMs)
|
|
||||||
val drained = ArrayList<ClientMessage>()
|
|
||||||
sent.drainTo(drained)
|
|
||||||
return drained
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearSent() {
|
|
||||||
sent.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Feed remote bytes and block until the confined writer has applied them. */
|
|
||||||
fun feedAndAwait(bytes: String, description: String, predicate: () -> Boolean) {
|
|
||||||
session.feedRemote(bytes.toByteArray(Charsets.UTF_8))
|
|
||||||
awaitTrue(description, predicate)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun awaitTrue(what: String, predicate: () -> Boolean) {
|
|
||||||
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS)
|
|
||||||
while (System.nanoTime() < deadline) {
|
|
||||||
if (predicate()) return
|
|
||||||
Thread.sleep(POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
assertTrue("timed out after $AWAIT_TIMEOUT_MS ms waiting for: $what", predicate())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
onMain { session.close() }
|
|
||||||
scenario.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun motionEvent(action: Int, x: Float, y: Float): MotionEvent =
|
|
||||||
MotionEvent.obtain(DOWN_TIME, DOWN_TIME, action, x, y, 0)
|
|
||||||
|
|
||||||
private fun wheelEvent(vScroll: Float): MotionEvent {
|
|
||||||
val properties = arrayOf(
|
|
||||||
MotionEvent.PointerProperties().apply {
|
|
||||||
id = 0
|
|
||||||
toolType = MotionEvent.TOOL_TYPE_MOUSE
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val coords = arrayOf(
|
|
||||||
MotionEvent.PointerCoords().apply {
|
|
||||||
x = 10f
|
|
||||||
y = 10f
|
|
||||||
setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return MotionEvent.obtain(
|
|
||||||
/* downTime = */ DOWN_TIME,
|
|
||||||
/* eventTime = */ DOWN_TIME,
|
|
||||||
MotionEvent.ACTION_SCROLL,
|
|
||||||
/* pointerCount = */ 1,
|
|
||||||
properties,
|
|
||||||
coords,
|
|
||||||
/* metaState = */ 0,
|
|
||||||
/* buttonState = */ 0,
|
|
||||||
/* xPrecision = */ 1f,
|
|
||||||
/* yPrecision = */ 1f,
|
|
||||||
/* deviceId = */ 0,
|
|
||||||
/* edgeFlags = */ 0,
|
|
||||||
android.view.InputDevice.SOURCE_MOUSE,
|
|
||||||
/* flags = */ 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Block until the attach-time grid push (80×24 → the real grid) has landed on the wire AND the wire
|
|
||||||
* has gone quiet, then discard those frames.
|
|
||||||
*
|
|
||||||
* Without this, `clearSent()` in a `@Before` races the confined writer: the attach pushes the grid
|
|
||||||
* asynchronously (twice — `attachView`'s session setter and `bindEmulator` both force a re-assert),
|
|
||||||
* so a `Resize` can arrive *after* the clear and be mistaken for the first frame a test caused. Two
|
|
||||||
* of the very first runs of this suite failed exactly that way.
|
|
||||||
*/
|
|
||||||
fun awaitAttachSettled(): ClientMessage.Resize {
|
|
||||||
val grid = awaitTrueValue("the attach-time grid to reach the wire") {
|
|
||||||
sent.poll() as? ClientMessage.Resize
|
|
||||||
}
|
|
||||||
drainSent(settleMs = ATTACH_SETTLE_MS)
|
|
||||||
return grid
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> awaitTrueValue(what: String, produce: () -> T?): T {
|
|
||||||
var found: T? = null
|
|
||||||
awaitTrue(what) {
|
|
||||||
found = produce()
|
|
||||||
found != null
|
|
||||||
}
|
|
||||||
return found!!
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** The whole terminal viewport for the initial layout; individual tests re-layout as needed. */
|
|
||||||
const val DEFAULT_WIDTH_PX: Int = 600
|
|
||||||
const val DEFAULT_HEIGHT_PX: Int = 800
|
|
||||||
|
|
||||||
private const val DOWN_TIME = 1_000L
|
|
||||||
private const val DRAG_STEPS = 8
|
|
||||||
private const val MAIN_SYNC_TIMEOUT_MS = 5_000L
|
|
||||||
private const val WIRE_TIMEOUT_MS = 3_000L
|
|
||||||
private const val AWAIT_TIMEOUT_MS = 5_000L
|
|
||||||
private const val POLL_INTERVAL_MS = 20L
|
|
||||||
private const val SETTLE_MS = 300L
|
|
||||||
private const val ATTACH_SETTLE_MS = 500L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the rig: a [ComponentActivity] (supplied by `ui-test-manifest`), a root frame, the real
|
|
||||||
* [RemoteTerminalView] added to it at [DEFAULT_WIDTH_PX]×[DEFAULT_HEIGHT_PX], and a
|
|
||||||
* [RemoteTerminalSession] attached to it.
|
|
||||||
*
|
|
||||||
* ### Why the view is laid out BEFORE the session attaches
|
|
||||||
* This is the §6.6 config-change rebind order (a laid-out view, a session bound to it) rather than
|
|
||||||
* the cold-start order, and it is chosen for a specific reason: it keeps the attach-time 80×24 →
|
|
||||||
* real-grid reflow *ahead* of the moment the emulator becomes visible to the stock renderer.
|
|
||||||
* `attachView` queues the forced re-assert (from the session setter) before the Bind command, and
|
|
||||||
* the confined consumer is FIFO, so the reallocation completes while no frame can be reading the
|
|
||||||
* buffer. `bindEmulator`'s second re-assert then carries the same dims, which Termux's
|
|
||||||
* `TerminalEmulator.resize` short-circuits, so it reallocates nothing.
|
|
||||||
*
|
|
||||||
* Doing it the other way round — attach first, let the first layout pass fire afterwards — puts
|
|
||||||
* the reflow on the confined writer thread while the renderer is already drawing, which is the
|
|
||||||
* still-unfixed defect [TerminalResizeDrawRaceRegressionTest] documents. That defect is real and
|
|
||||||
* this ordering does not paper over it: it is exercised deliberately by that test and by every
|
|
||||||
* relayout in [TerminalResizeRegressionTest]. Ordering it away here simply stops a `:terminal-view`
|
|
||||||
* defect from killing the process during the *setup* of key, scroll and autofill tests that are
|
|
||||||
* not about resize at all.
|
|
||||||
*/
|
|
||||||
fun launch(transcriptRows: Int = RemoteTerminalSession.TRANSCRIPT_ROWS): TerminalTestHarness {
|
|
||||||
val sent = LinkedBlockingQueue<ClientMessage>()
|
|
||||||
val scenario = ActivityScenario.launch(ComponentActivity::class.java)
|
|
||||||
var view: RemoteTerminalView? = null
|
|
||||||
var root: FrameLayout? = null
|
|
||||||
scenario.onActivity { activity ->
|
|
||||||
val terminal = RemoteTerminalView(activity)
|
|
||||||
val frame = FrameLayout(activity)
|
|
||||||
frame.addView(
|
|
||||||
terminal.terminalView,
|
|
||||||
FrameLayout.LayoutParams(DEFAULT_WIDTH_PX, DEFAULT_HEIGHT_PX),
|
|
||||||
)
|
|
||||||
activity.setContentView(frame)
|
|
||||||
view = terminal
|
|
||||||
root = frame
|
|
||||||
}
|
|
||||||
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
|
|
||||||
val terminal = view!!
|
|
||||||
// Wait for the real layout pass. With no session bound yet, TerminalResizeDriver memoises the
|
|
||||||
// measured grid and pushes nothing (`session?.updateSize` is a no-op) — so nothing has to be
|
|
||||||
// reflowed while a frame is in flight.
|
|
||||||
var laidOut = false
|
|
||||||
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS)
|
|
||||||
while (!laidOut && System.nanoTime() < deadline) {
|
|
||||||
val done = CountDownLatch(1)
|
|
||||||
scenario.onActivity { laidOut = terminal.terminalView.width == DEFAULT_WIDTH_PX }
|
|
||||||
done.countDown()
|
|
||||||
if (!laidOut) Thread.sleep(POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
assertTrue("the terminal never laid out at $DEFAULT_WIDTH_PX px wide", laidOut)
|
|
||||||
|
|
||||||
var session: RemoteTerminalSession? = null
|
|
||||||
scenario.onActivity {
|
|
||||||
val remote = RemoteTerminalSession(
|
|
||||||
engineSend = { message -> sent.put(message) },
|
|
||||||
transcriptRows = transcriptRows,
|
|
||||||
)
|
|
||||||
remote.attachView(terminal)
|
|
||||||
session = remote
|
|
||||||
}
|
|
||||||
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
|
|
||||||
val harness = TerminalTestHarness(scenario, terminal, session!!, root!!, sent)
|
|
||||||
// Focus is a PRECONDITION for the key path, not a convenience: `ViewGroup.dispatchKeyEvent`
|
|
||||||
// only calls `super.dispatchKeyEvent` (hence `onKeyDown`) when the group itself is FOCUSED
|
|
||||||
// and laid out, and it has no focusable children here (`FOCUS_BLOCK_DESCENDANTS`). Production
|
|
||||||
// satisfies this the same way — `dispatchTouchEvent` claims focus on ACTION_DOWN and
|
|
||||||
// `showSoftKeyboard()` calls `requestFocus()` — so an unfocused terminal receiving no hardware
|
|
||||||
// keys is correct framework behaviour, not a defect. Asserting it here keeps a silent focus
|
|
||||||
// regression from turning the key tests into vacuous passes.
|
|
||||||
harness.awaitTrue("the terminal frame to take focus") {
|
|
||||||
harness.onMain { harness.hostView.requestFocus() || harness.hostView.isFocused }
|
|
||||||
}
|
|
||||||
return harness
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
package wang.yaojia.webterm.ui
|
|
||||||
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.MediumTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.components.GateBanner
|
|
||||||
import wang.yaojia.webterm.components.PlanGateSheet
|
|
||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
|
||||||
import wang.yaojia.webterm.session.GateState
|
|
||||||
import wang.yaojia.webterm.viewmodels.GateDecision
|
|
||||||
import wang.yaojia.webterm.wire.GateKind
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **Plan §7 Compose UI tests — the gate surfaces.**
|
|
||||||
*
|
|
||||||
* These are the two surfaces a remote approval is made from, so the load-bearing contract is not the layout
|
|
||||||
* (device-QA) but that **each affordance reports the decision it is labelled with, carrying the epoch of the
|
|
||||||
* gate that was on screen**. The epoch is the stale-guard seam: `GateViewModel.decide` drops a tap whose
|
|
||||||
* epoch is no longer live, which is what stops a tap landing on a gate the user never saw. A test that only
|
|
||||||
* checked the copy would let a swapped-callback bug through — approving when the user pressed reject is the
|
|
||||||
* single worst failure this app can have.
|
|
||||||
*
|
|
||||||
* The server-supplied `detail` is also asserted to render, because it must render as INERT text (plan §8) —
|
|
||||||
* untrusted tool/OSC content with no markdown and no autolink.
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@MediumTest
|
|
||||||
class GateSurfacesUiTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val compose = createComposeRule()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun toolGateBanner_reportsApproveWithTheOnScreenEpoch() {
|
|
||||||
val decisions = mutableListOf<Pair<GateDecision, Int>>()
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme {
|
|
||||||
GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compose.onNodeWithText(DETAIL).assertIsDisplayed()
|
|
||||||
compose.onNodeWithText(APPROVE).performClick()
|
|
||||||
|
|
||||||
assertEquals(listOf(GateDecision.APPROVE to TOOL_EPOCH), decisions)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun toolGateBanner_reportsRejectWithTheOnScreenEpoch() {
|
|
||||||
val decisions = mutableListOf<Pair<GateDecision, Int>>()
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme {
|
|
||||||
GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compose.onNodeWithText(REJECT).performClick()
|
|
||||||
|
|
||||||
assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A previewless gate is a NORMAL state, not an error: the server only sends a preview for a held
|
|
||||||
* Bash/Edit-family tool, and a malformed one decodes to null rather than costing us the gate. So the
|
|
||||||
* banner must still be fully usable with no preview.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun toolGateBanner_withNoPreviewIsStillFullyDecidable() {
|
|
||||||
val decisions = mutableListOf<Pair<GateDecision, Int>>()
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme {
|
|
||||||
GateBanner(gate = toolGate.copy(preview = null), onDecide = { d, e -> decisions += d to e })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compose.onNodeWithText(APPROVE).assertIsDisplayed()
|
|
||||||
compose.onNodeWithText(REJECT).performClick()
|
|
||||||
|
|
||||||
assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The plan gate is THREE-way, and the third option is the one most easily got wrong: 「继续计划」 keeps
|
|
||||||
* planning, i.e. it wires to `reject`, not to a second kind of approval.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun planGateSheet_offersAllThreeDecisionsWithTheOnScreenEpoch() {
|
|
||||||
val decisions = mutableListOf<Pair<GateDecision, Int>>()
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme {
|
|
||||||
PlanGateSheet(gate = planGate, onDecide = { d, e -> decisions += d to e }, onDismiss = {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed()
|
|
||||||
compose.onNodeWithText(PLAN_DETAIL).assertIsDisplayed()
|
|
||||||
|
|
||||||
compose.onNodeWithText(ACCEPT_EDITS).performClick()
|
|
||||||
compose.onNodeWithText(KEEP_PLANNING).performClick()
|
|
||||||
compose.onNodeWithText(APPROVE, useUnmergedTree = false).performClick()
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
listOf(
|
|
||||||
GateDecision.ACCEPT_EDITS to PLAN_EPOCH,
|
|
||||||
GateDecision.REJECT to PLAN_EPOCH,
|
|
||||||
GateDecision.APPROVE to PLAN_EPOCH,
|
|
||||||
),
|
|
||||||
decisions,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dismissing the sheet (scrim tap / back) must resolve NOTHING — the gate stays held until an explicit
|
|
||||||
* decision. Silently treating a dismissal as a reject would cancel a user's work behind their back.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun planGateSheet_dismissalDecidesNothing() {
|
|
||||||
val decisions = mutableListOf<Pair<GateDecision, Int>>()
|
|
||||||
var dismissals = 0
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme {
|
|
||||||
PlanGateSheet(
|
|
||||||
gate = planGate,
|
|
||||||
onDecide = { d, e -> decisions += d to e },
|
|
||||||
onDismiss = { dismissals += 1 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The sheet is on screen and no decision has been reported; the dismiss callback is the only other
|
|
||||||
// exit, and it is wired to a handler that resolves nothing.
|
|
||||||
compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed()
|
|
||||||
assertEquals(emptyList<Pair<GateDecision, Int>>(), decisions)
|
|
||||||
assertEquals(0, dismissals)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TOOL_EPOCH = 7
|
|
||||||
const val PLAN_EPOCH = 11
|
|
||||||
const val DETAIL = "Bash"
|
|
||||||
const val PLAN_DETAIL = "Refactor the auth module into 3 files."
|
|
||||||
|
|
||||||
const val APPROVE = "批准"
|
|
||||||
const val REJECT = "拒绝"
|
|
||||||
const val ACCEPT_EDITS = "批准并自动接受"
|
|
||||||
const val KEEP_PLANNING = "继续计划"
|
|
||||||
const val PLAN_TITLE = "批准执行计划"
|
|
||||||
|
|
||||||
val toolGate = GateState(kind = GateKind.TOOL, detail = DETAIL, epoch = TOOL_EPOCH)
|
|
||||||
val planGate = GateState(kind = GateKind.PLAN, detail = PLAN_DETAIL, epoch = PLAN_EPOCH)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
package wang.yaojia.webterm.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.hasSetTextAction
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.compose.ui.test.performScrollTo
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.MediumTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.components.KeyBar
|
|
||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **Plan §7 Compose UI test — the mobile key-bar, pinned above the IME.**
|
|
||||||
*
|
|
||||||
* The key-bar is how a phone produces the keys Claude Code needs and a soft keyboard cannot: Esc, ⇧Tab,
|
|
||||||
* arrows, Ctrl-chords. Its defining property is that a tap goes **straight to the wire**, bypassing the IME
|
|
||||||
* entirely (mirroring the web client's `ws.send` bypass) — that is what stops the soft keyboard popping up
|
|
||||||
* every time the user dismisses a Claude prompt. So the assertions are on the bytes each button emits, not
|
|
||||||
* on its looks.
|
|
||||||
*
|
|
||||||
* What stays device-QA: that the bar is *visually* above the IME. `WindowInsets.ime` reports zero in an
|
|
||||||
* instrumented test with no keyboard raised, so an assertion about its on-screen position would be
|
|
||||||
* measuring the absence of a keyboard, not the inset behaviour. The union-with-navigation-bars fallback that
|
|
||||||
* makes the bar sit above the nav bar when the keyboard is hidden IS exercised here (the bar renders and is
|
|
||||||
* hittable), and the real above-the-IME check is on the checklist.
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@MediumTest
|
|
||||||
class KeyBarUiTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val compose = createComposeRule()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun escTap_sendsTheEscapeByteDirectly() {
|
|
||||||
val sent = mutableListOf<String>()
|
|
||||||
setUpKeyBar(sent)
|
|
||||||
|
|
||||||
compose.onNodeWithText("Esc").performClick()
|
|
||||||
|
|
||||||
assertEquals(listOf("\u001b"), sent)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enterTap_sendsCarriageReturnNotLineFeed() {
|
|
||||||
val sent = mutableListOf<String>()
|
|
||||||
setUpKeyBar(sent)
|
|
||||||
|
|
||||||
compose.onNodeWithText("⏎").performClick()
|
|
||||||
|
|
||||||
// The repo-wide gotcha, on the surface a phone user hits most often.
|
|
||||||
assertEquals(listOf("\r"), sent)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun ctrlCTap_sendsTheInterruptControlByte() {
|
|
||||||
val sent = mutableListOf<String>()
|
|
||||||
setUpKeyBar(sent)
|
|
||||||
|
|
||||||
compose.onNodeWithText("^C").performClick()
|
|
||||||
|
|
||||||
assertEquals(listOf("\u0003"), sent)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun shiftTabTap_sendsTheModeCycleSequence() {
|
|
||||||
val sent = mutableListOf<String>()
|
|
||||||
setUpKeyBar(sent)
|
|
||||||
|
|
||||||
compose.onNodeWithText("⇧Tab").performClick()
|
|
||||||
|
|
||||||
assertEquals(listOf("\u001b[Z"), sent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The bar is horizontally scrollable precisely so every key stays reachable on a narrow phone; the keys
|
|
||||||
* past the fold are the ones a layout regression would silently drop. Reaching one by its
|
|
||||||
* `contentDescription` (which is also its accessibility label) proves it is present and hittable.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun everyKeyIsReachable_includingTheOnesPastTheFold() {
|
|
||||||
val sent = mutableListOf<String>()
|
|
||||||
setUpKeyBar(sent)
|
|
||||||
|
|
||||||
// Off-screen keys must be reached the way a user reaches them — by scrolling the bar. A bare
|
|
||||||
// performClick() on a node outside the viewport fails, which is itself the proof that this key
|
|
||||||
// really does sit past the fold rather than being trivially present.
|
|
||||||
compose.onNodeWithContentDescription(SLASH_DESCRIPTION).performScrollTo().performClick()
|
|
||||||
|
|
||||||
assertEquals(listOf("/"), sent)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tapsAccumulateInOrder_soTwoFastTapsCannotSwap() {
|
|
||||||
val sent = mutableListOf<String>()
|
|
||||||
setUpKeyBar(sent)
|
|
||||||
|
|
||||||
compose.onNodeWithText("↑").performClick()
|
|
||||||
compose.onNodeWithText("↓").performClick()
|
|
||||||
compose.onNodeWithText("⏎").performClick()
|
|
||||||
|
|
||||||
assertEquals(listOf("\u001b[A", "\u001b[B", "\r"), sent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A key-bar tap must never route through a text field, or the soft keyboard would open. There is no
|
|
||||||
* public hook to observe "the IME was not asked to open", but there IS an observable proxy: the bar
|
|
||||||
* contains no text-input node at all, so nothing on it can request the IME. Combined with the byte
|
|
||||||
* assertions above (which prove the tap reached the wire), that pins the bypass.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun theBarHoldsNoTextInput_soATapCannotRaiseTheKeyboard() {
|
|
||||||
val sent = mutableListOf<String>()
|
|
||||||
setUpKeyBar(sent)
|
|
||||||
|
|
||||||
compose.onNodeWithText("Esc").assertIsDisplayed()
|
|
||||||
val editableNodes = compose
|
|
||||||
.onAllNodes(hasSetTextAction())
|
|
||||||
.fetchSemanticsNodes(atLeastOneRootRequired = false)
|
|
||||||
|
|
||||||
assertTrue("the key-bar must contain no editable text node", editableNodes.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setUpKeyBar(sink: MutableList<String>) {
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
|
|
||||||
KeyBar(onSend = { bytes -> sink += bytes })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/** `KEYBAR_LAYOUT`'s accessibility label for the command-launcher key, which sits past the fold. */
|
|
||||||
const val SLASH_DESCRIPTION = "Slash — command launcher"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package wang.yaojia.webterm.ui
|
|
||||||
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.hasProgressBarRangeInfo
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.MediumTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.components.BannerModel
|
|
||||||
import wang.yaojia.webterm.session.ConnectionState
|
|
||||||
import wang.yaojia.webterm.session.Connection
|
|
||||||
import wang.yaojia.webterm.session.FailureReason
|
|
||||||
import wang.yaojia.webterm.components.ReconnectBanner
|
|
||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **Plan §7 Compose UI test — the reconnect banner, and specifically its precedence rule.**
|
|
||||||
*
|
|
||||||
* The banner is the only place the app tells a walked-away user *why* their session went quiet, so the
|
|
||||||
* failure modes it must not have are: showing a retry spinner for something that can never succeed, and
|
|
||||||
* hiding a terminal state behind a transient one. The reduction itself is JVM-tested; what only a device can
|
|
||||||
* check is that the rendered surface honours it — the spinner really is absent, the affordance really is
|
|
||||||
* present and really fires, and the untrusted exit `reason` really renders inert.
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@MediumTest
|
|
||||||
class ReconnectBannerUiTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val compose = createComposeRule()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hiddenRendersNothingAtAll() {
|
|
||||||
setUpBanner(BannerModel.Hidden)
|
|
||||||
|
|
||||||
val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot())
|
|
||||||
.fetchSemanticsNodes(atLeastOneRootRequired = false)
|
|
||||||
// A root always exists; what must not exist is any banner content under it.
|
|
||||||
assertTrue("Hidden must render no banner content", nodes.all { it.children.isEmpty() })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun reconnectingShowsTheAttemptCountdownAndASpinner() {
|
|
||||||
setUpBanner(BannerModel.Reconnecting(attempt = 3, countdown = 4.seconds))
|
|
||||||
|
|
||||||
compose.onNodeWithText("连接断开,重连中(第 3 次,4 秒后重试)").assertIsDisplayed()
|
|
||||||
assertTrue("a retrying banner must show a spinner", hasSpinner())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The replay-too-large wall is NON-retryable: reconnecting would hit the same wall forever, so a
|
|
||||||
* spinner here would be a lie that never resolves. The copy has to be actionable and the only way out
|
|
||||||
* is a new session.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun aNonRetryableFailureShowsNoSpinnerAndOffersANewSession() {
|
|
||||||
var newSessions = 0
|
|
||||||
setUpBanner(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE)) { newSessions += 1 }
|
|
||||||
|
|
||||||
assertTrue("a non-retryable wall must NOT show a retry spinner", !hasSpinner())
|
|
||||||
compose.onNodeWithText("新会话").performClick()
|
|
||||||
|
|
||||||
assertEquals(1, newSessions)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `exit(-1)` is a spawn failure, and its `reason` is server-supplied, i.e. untrusted — it must render as
|
|
||||||
* inert text inside the banner copy, never as a link or markup (plan §8).
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun aSpawnFailureRendersTheServerReasonInertly() {
|
|
||||||
setUpBanner(BannerModel.Exited(code = -1, reason = UNTRUSTED_REASON))
|
|
||||||
|
|
||||||
compose.onNodeWithText("会话启动失败:$UNTRUSTED_REASON").assertIsDisplayed()
|
|
||||||
assertTrue("a terminal exit must not show a spinner", !hasSpinner())
|
|
||||||
compose.onNodeWithText("开新会话").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun anOrdinaryExitShowsTheCodeAndTheNewSessionAffordance() {
|
|
||||||
var newSessions = 0
|
|
||||||
setUpBanner(BannerModel.Exited(code = 130, reason = null)) { newSessions += 1 }
|
|
||||||
|
|
||||||
compose.onNodeWithText("会话已结束(退出码 130)").assertIsDisplayed()
|
|
||||||
compose.onNodeWithText("开新会话").performClick()
|
|
||||||
|
|
||||||
assertEquals(1, newSessions)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **The precedence rule, rendered.** A `connecting` event arriving after the session already exited must
|
|
||||||
* NOT replace the terminal banner with a spinner — otherwise a walked-away user sees "connecting…"
|
|
||||||
* forever and never learns the session is over. The reduction is asserted and then the reduced model is
|
|
||||||
* rendered, so copy and rule are checked as one thing.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun aTerminalBannerIsStickyOverATransientOne() {
|
|
||||||
val exited = BannerModel.Exited(code = 1, reason = null)
|
|
||||||
val afterConnecting = wang.yaojia.webterm.components.bannerModel(
|
|
||||||
current = exited,
|
|
||||||
event = Connection(ConnectionState.Connecting),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals("a transient event must not override a terminal banner", exited, afterConnecting)
|
|
||||||
|
|
||||||
setUpBanner(afterConnecting)
|
|
||||||
compose.onNodeWithText("会话已结束(退出码 1)").assertIsDisplayed()
|
|
||||||
assertTrue(!hasSpinner())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasSpinner(): Boolean = compose
|
|
||||||
.onAllNodes(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate))
|
|
||||||
.fetchSemanticsNodes(atLeastOneRootRequired = false)
|
|
||||||
.isNotEmpty()
|
|
||||||
|
|
||||||
private fun setUpBanner(model: BannerModel, onNewSession: () -> Unit = {}) {
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme { ReconnectBanner(model = model, onNewSession = onNewSession) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/** Server-supplied and therefore untrusted; it must appear verbatim and inert. */
|
|
||||||
const val UNTRUSTED_REASON = "spawn /bin/nope ENOENT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package wang.yaojia.webterm.ui
|
|
||||||
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.MediumTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
|
||||||
import wang.yaojia.webterm.components.ContinueLastBanner
|
|
||||||
import wang.yaojia.webterm.components.ContinueLastModel
|
|
||||||
import wang.yaojia.webterm.components.SessionListRow
|
|
||||||
import wang.yaojia.webterm.components.continueLastModel
|
|
||||||
import wang.yaojia.webterm.designsystem.DisplayStatus
|
|
||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
|
||||||
import wang.yaojia.webterm.viewmodels.SessionRow
|
|
||||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **Plan §7 Compose UI tests — session-list rows and the continue-last banner.**
|
|
||||||
*
|
|
||||||
* Two things only a device can check here. First, that a row renders host-supplied strings **inert**: the
|
|
||||||
* title arrives from an OSC escape the host (or something running on it) chose, so it must be plain
|
|
||||||
* [androidx.compose.material3.Text] with no linkify and no markdown (plan §8). Second, that "shown iff there
|
|
||||||
* is something to show" holds — a `null` model must render literally nothing, because a dead
|
|
||||||
* continue-affordance that navigates nowhere is worse than no affordance.
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@MediumTest
|
|
||||||
class SessionListUiTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val compose = createComposeRule()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aRowRendersTheSanitizedTitleAndTheGeometry() {
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = {}) }
|
|
||||||
}
|
|
||||||
|
|
||||||
compose.onNodeWithText(TITLE).assertIsDisplayed()
|
|
||||||
// The geometry label uses U+00D7 MULTIPLICATION SIGN, not the letter x.
|
|
||||||
compose.onNodeWithText("161×50", substring = true).assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tappingARowOpensThatSession() {
|
|
||||||
var opened = 0
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = { opened += 1 }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
compose.onNodeWithText(TITLE).performClick()
|
|
||||||
|
|
||||||
assertEquals(1, opened)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A hostile OSC title must appear as characters, not as behaviour. `TitleSanitizer` strips bidi and
|
|
||||||
* zero-width runs upstream (JVM-tested); what this pins is that whatever survives is rendered verbatim
|
|
||||||
* and is not turned into a link by autolinking.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun anUntrustedLookingTitleIsRenderedAsPlainCharacters() {
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme { SessionListRow(row = row(title = URL_LOOKING_TITLE), onOpen = {}) }
|
|
||||||
}
|
|
||||||
|
|
||||||
compose.onNodeWithText(URL_LOOKING_TITLE).assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun continueLastBannerIsAbsentWhenThereIsNoLastSession() {
|
|
||||||
assertEquals("no last session must reduce to no model", null, continueLastModel(null))
|
|
||||||
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme { ContinueLastBanner(model = continueLastModel(null), onContinue = {}) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot())
|
|
||||||
.fetchSemanticsNodes(atLeastOneRootRequired = false)
|
|
||||||
assertTrue("a null model must render nothing at all", nodes.all { it.children.isEmpty() })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun continueLastBannerReOpensTheRememberedSession() {
|
|
||||||
val remembered = mutableListOf<String>()
|
|
||||||
compose.setContent {
|
|
||||||
WebTermTheme {
|
|
||||||
ContinueLastBanner(
|
|
||||||
model = ContinueLastModel(LAST_SESSION_ID),
|
|
||||||
onContinue = { remembered += it },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compose.onNodeWithText("继续上次会话").performClick()
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"the banner must hand back the id it was rendered with, not a re-derived one",
|
|
||||||
listOf(LAST_SESSION_ID),
|
|
||||||
remembered,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun row(title: String) = SessionRow(
|
|
||||||
info = LiveSessionInfo(
|
|
||||||
id = UUID.fromString(LAST_SESSION_ID),
|
|
||||||
createdAt = 1_700_000_000_000L,
|
|
||||||
clientCount = 1,
|
|
||||||
status = ClaudeStatus.WORKING,
|
|
||||||
exited = false,
|
|
||||||
cwd = "/Users/dev/project",
|
|
||||||
title = title,
|
|
||||||
cols = 161,
|
|
||||||
rows = 50,
|
|
||||||
),
|
|
||||||
displayStatus = DisplayStatus.Working,
|
|
||||||
title = title,
|
|
||||||
isUnread = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TITLE = "claude"
|
|
||||||
const val URL_LOOKING_TITLE = "https://example.com/not-a-link"
|
|
||||||
const val LAST_SESSION_ID = "a1b2c3d4-5678-4abc-9def-000000000000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,35 +12,13 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
|
|
||||||
<!-- Cleartext: @xml/network_security_config is the AUTHORITATIVE policy (minSdk 29, so the
|
|
||||||
platform always reads it and IGNORES android:usesCleartextTraffic). The attribute is set
|
|
||||||
to true only so the manifest does not state the opposite of what actually ships: bare-LAN
|
|
||||||
`ws://` must work (plan §6.9). The config is where the real posture lives — cleartext ON
|
|
||||||
for user-typed LAN addresses (no CIDR syntax exists to scope it), system-only trust
|
|
||||||
anchors, and cleartext still BLOCKED for *.terminal.yaojia.wang. Read its header before
|
|
||||||
changing either value; they must stay in agreement. -->
|
|
||||||
<!-- Backup: OFF, and deliberately not an exclusion list (plan §8 "app-private, uninstall-wiped,
|
|
||||||
never in backups/cloud"). Auto Backup defaults to ON and would sweep up EVERY app-private
|
|
||||||
file: the `webterm` Preferences DataStore (which holds the sealed `webterm_auth` cookie — a
|
|
||||||
full-shell credential the server keeps alive for 30 days) and the Tink keyset/ciphertext pref
|
|
||||||
files. Nothing in this app has restore value that justifies that risk — re-pairing a host is
|
|
||||||
a QR scan, and the hosts are LAN-local anyway.
|
|
||||||
`allowBackup` alone only covers Auto Backup (cloud) / adb backup. Android 12+ DEVICE-TO-DEVICE
|
|
||||||
transfer is governed by a `dataExtractionRules` <device-transfer> block, which needs an @xml
|
|
||||||
resource — @xml/data_extraction_rules now supplies it, and it is deny-everything in BOTH
|
|
||||||
sections rather than an exclusion list, so a store added later is excluded by default instead
|
|
||||||
of leaking until someone remembers it. `fullBackupContent="false"` states the same for the
|
|
||||||
pre-31 path. Do not set any of these back to true. -->
|
|
||||||
<application
|
<application
|
||||||
android:name=".WebTermApp"
|
android:name=".WebTermApp"
|
||||||
android:allowBackup="false"
|
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:fullBackupContent="false"
|
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.WebTerm"
|
android:theme="@style/Theme.WebTerm"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="false">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@@ -16,15 +16,10 @@ import wang.yaojia.webterm.designsystem.DisplayStatus
|
|||||||
import wang.yaojia.webterm.designsystem.Spacing
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
import wang.yaojia.webterm.designsystem.StatusBadge
|
import wang.yaojia.webterm.designsystem.StatusBadge
|
||||||
import wang.yaojia.webterm.designsystem.WebTermCard
|
import wang.yaojia.webterm.designsystem.WebTermCard
|
||||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
|
||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
import wang.yaojia.webterm.designsystem.WebTermType
|
|
||||||
import wang.yaojia.webterm.session.GateState
|
import wang.yaojia.webterm.session.GateState
|
||||||
import wang.yaojia.webterm.viewmodels.GateDecision
|
import wang.yaojia.webterm.viewmodels.GateDecision
|
||||||
import wang.yaojia.webterm.viewmodels.inertText
|
|
||||||
import wang.yaojia.webterm.wire.ApprovalPreview
|
|
||||||
import wang.yaojia.webterm.wire.GateKind
|
import wang.yaojia.webterm.wire.GateKind
|
||||||
import wang.yaojia.webterm.wire.PreviewDiffFile
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
|
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
|
||||||
@@ -49,7 +44,6 @@ public fun GateBanner(
|
|||||||
gate: GateState,
|
gate: GateState,
|
||||||
onDecide: (GateDecision, Int) -> Unit,
|
onDecide: (GateDecision, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
preview: ApprovalPreview? = null,
|
|
||||||
) {
|
) {
|
||||||
WebTermCard(modifier = modifier.fillMaxWidth()) {
|
WebTermCard(modifier = modifier.fillMaxWidth()) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
@@ -69,9 +63,6 @@ public fun GateBanner(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// WHAT is being approved sits between the label and the buttons, so the decision and the
|
|
||||||
// evidence are never on different screens. Absent → the name-only bar, unchanged.
|
|
||||||
ApprovalPreviewBlock(preview)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
|
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
|
||||||
Text(text = "拒绝")
|
Text(text = "拒绝")
|
||||||
@@ -84,124 +75,6 @@ public fun GateBanner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── W1: the approval preview (shared by GateBanner and PlanGateSheet) ───────────
|
|
||||||
|
|
||||||
/** How many diff/command lines a gate surface paints before it says "truncated". The server already
|
|
||||||
* caps at 40 lines / 4 KB; this is the on-phone reading limit, so the buttons stay reachable. */
|
|
||||||
private const val PREVIEW_MAX_VISIBLE_LINES = 20
|
|
||||||
|
|
||||||
/**
|
|
||||||
* W1 — render WHAT a held approval would do: the shell command, or the one-file diff.
|
|
||||||
*
|
|
||||||
* Everything here is **untrusted** (server-derived from the hook's `tool_input`, which Claude and the
|
|
||||||
* repo contents influence) and therefore INERT: plain [Text] only — no Markdown, no autolink, no
|
|
||||||
* `AnnotatedString` link detection (plan §8). A `null` preview renders NOTHING: absent is a normal
|
|
||||||
* state (plan gates and unknown tools have nothing reviewable), never an error.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun ApprovalPreviewBlock(preview: ApprovalPreview?, modifier: Modifier = Modifier) {
|
|
||||||
if (preview == null) return
|
|
||||||
Column(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs2),
|
|
||||||
) {
|
|
||||||
when (preview) {
|
|
||||||
is ApprovalPreview.Command -> CommandPreview(preview)
|
|
||||||
is ApprovalPreview.Diff -> DiffPreview(preview.file)
|
|
||||||
}
|
|
||||||
if (preview.truncated) {
|
|
||||||
Text(
|
|
||||||
text = GatePreviewCopy.TRUNCATED,
|
|
||||||
style = WebTermType.metaMono,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CommandPreview(preview: ApprovalPreview.Command) {
|
|
||||||
// Newlines are content in a shell command, so the lines are kept and rendered as lines. The
|
|
||||||
// sanitizer drops every other control byte (defence in depth over the server's own stripping).
|
|
||||||
val lines = (preview.inertText() ?: return).lines()
|
|
||||||
for (line in lines.take(PREVIEW_MAX_VISIBLE_LINES)) {
|
|
||||||
Text(
|
|
||||||
text = line,
|
|
||||||
style = WebTermType.monoTabular(12),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (lines.size > PREVIEW_MAX_VISIBLE_LINES) ClippedNote()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DiffPreview(file: PreviewDiffFile) {
|
|
||||||
Text(
|
|
||||||
text = GatePreviewCopy.diffHeader(file),
|
|
||||||
style = WebTermType.metaMono,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
if (file.binary) {
|
|
||||||
Text(text = GatePreviewCopy.BINARY, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var painted = 0
|
|
||||||
for (hunk in file.hunks) {
|
|
||||||
if (painted >= PREVIEW_MAX_VISIBLE_LINES) break
|
|
||||||
DiffLineText(hunk.header, DIFF_KIND_HUNK)
|
|
||||||
painted++
|
|
||||||
for (line in hunk.lines) {
|
|
||||||
if (painted >= PREVIEW_MAX_VISIBLE_LINES) break
|
|
||||||
DiffLineText(line.text, line.kind)
|
|
||||||
painted++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val total = file.hunks.sumOf { it.lines.size + 1 }
|
|
||||||
if (total > painted) ClippedNote()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DiffLineText(text: String, kind: String) {
|
|
||||||
// An UNKNOWN kind still renders (just uncoloured): dropping a line the client cannot classify
|
|
||||||
// would hide part of what the user is approving.
|
|
||||||
val color = when (kind) {
|
|
||||||
DIFF_KIND_ADDED -> WebTermColors.statusWorking
|
|
||||||
DIFF_KIND_REMOVED -> WebTermColors.statusStuck
|
|
||||||
DIFF_KIND_HUNK, DIFF_KIND_META -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
Text(text = text, style = WebTermType.monoTabular(12), color = color, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ClippedNote() {
|
|
||||||
Text(
|
|
||||||
text = GatePreviewCopy.CLIPPED,
|
|
||||||
style = WebTermType.metaMono,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wire `DiffLineKind` values (`src/types.ts`) — raw strings, so an unknown kind stays itself. */
|
|
||||||
private const val DIFF_KIND_ADDED = "added"
|
|
||||||
private const val DIFF_KIND_REMOVED = "removed"
|
|
||||||
private const val DIFF_KIND_HUNK = "hunk"
|
|
||||||
private const val DIFF_KIND_META = "meta"
|
|
||||||
|
|
||||||
/** Preview copy (local UI text). */
|
|
||||||
internal object GatePreviewCopy {
|
|
||||||
const val TRUNCATED: String = "… 服务端已截断"
|
|
||||||
const val CLIPPED: String = "… 仅显示前 $PREVIEW_MAX_VISIBLE_LINES 行"
|
|
||||||
const val BINARY: String = "二进制文件"
|
|
||||||
|
|
||||||
fun diffHeader(file: PreviewDiffFile): String {
|
|
||||||
val path = if (file.oldPath != file.newPath) "${file.oldPath} → ${file.newPath}" else file.newPath
|
|
||||||
return "$path +${file.added}/-${file.removed}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Preview(name = "GateBanner")
|
@Preview(name = "GateBanner")
|
||||||
@@ -211,7 +84,6 @@ private fun GateBannerPreview() {
|
|||||||
GateBanner(
|
GateBanner(
|
||||||
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
|
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
|
||||||
onDecide = { _, _ -> },
|
onDecide = { _, _ -> },
|
||||||
preview = ApprovalPreview.Command("rm -rf build/\nnpm run build", truncated = true),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import wang.yaojia.webterm.designsystem.Spacing
|
|||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
import wang.yaojia.webterm.session.GateState
|
import wang.yaojia.webterm.session.GateState
|
||||||
import wang.yaojia.webterm.viewmodels.GateDecision
|
import wang.yaojia.webterm.viewmodels.GateDecision
|
||||||
import wang.yaojia.webterm.wire.ApprovalPreview
|
|
||||||
import wang.yaojia.webterm.wire.GateKind
|
import wang.yaojia.webterm.wire.GateKind
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +49,6 @@ public fun PlanGateSheet(
|
|||||||
onDecide: (GateDecision, Int) -> Unit,
|
onDecide: (GateDecision, Int) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
preview: ApprovalPreview? = null,
|
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||||
@@ -71,9 +69,6 @@ public fun PlanGateSheet(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// W1: a plan gate usually has nothing reviewable, but when the server DOES send a preview
|
|
||||||
// (a plan that resolves straight into an Edit/Bash) it belongs above the buttons.
|
|
||||||
ApprovalPreviewBlock(preview)
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
|
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import wang.yaojia.webterm.nav.LayoutMode
|
import wang.yaojia.webterm.nav.LayoutMode
|
||||||
import wang.yaojia.webterm.nav.PointerMenuPolicy
|
import wang.yaojia.webterm.nav.PointerMenuPolicy
|
||||||
import wang.yaojia.webterm.viewmodels.SessionRow
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
|
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
|
||||||
@@ -61,12 +59,7 @@ public fun PointerContextMenu(
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
// Keyed on Unit, NOT on [actions]: the gesture body only records the anchor and opens the menu —
|
modifier = modifier.pointerInput(actions) {
|
||||||
// it never reads [actions] (the DropdownMenu below reads the current list on every composition).
|
|
||||||
// Keying on the list would restart the pointer handler on every recomposition, because the
|
|
||||||
// per-entry lambdas in a freshly-built List<ContextMenuAction> are never equal to the previous
|
|
||||||
// ones — a right-click landing during that restart window would be dropped.
|
|
||||||
modifier = modifier.pointerInput(Unit) {
|
|
||||||
awaitPointerEventScope {
|
awaitPointerEventScope {
|
||||||
while (true) {
|
while (true) {
|
||||||
val event = awaitPointerEvent()
|
val event = awaitPointerEvent()
|
||||||
@@ -102,41 +95,3 @@ public fun PointerContextMenu(
|
|||||||
|
|
||||||
/** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */
|
/** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */
|
||||||
public data class ContextMenuAction(val label: String, val onClick: () -> Unit)
|
public data class ContextMenuAction(val label: String, val onClick: () -> Unit)
|
||||||
|
|
||||||
// ── The session-row action set (A26 — the one surface the pointer menu is attached to) ───────────────
|
|
||||||
|
|
||||||
/** 打开会话 — the same target a row tap has. */
|
|
||||||
public const val MENU_LABEL_OPEN: String = "打开会话"
|
|
||||||
|
|
||||||
/** 在当前目录开新会话 — `attach(null, cwd)` in the row's own cwd (plan §1 new-session-in-cwd). */
|
|
||||||
public const val MENU_LABEL_NEW_IN_CWD: String = "在当前目录开新会话"
|
|
||||||
|
|
||||||
/** 终止会话 — `DELETE /live-sessions/:id`, the pointer equivalent of swipe-to-kill. */
|
|
||||||
public const val MENU_LABEL_KILL: String = "终止会话"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The [PointerContextMenu] entries for one session row — the pure half of the A26 pointer menu (the
|
|
||||||
* gesture and the placement are device-QA; [PointerMenuPolicy] gates whether the menu exists at all).
|
|
||||||
*
|
|
||||||
* Mirrors iOS's pointer menu with ONE deliberate omission: **copy is absent**. Copy-out lives in the
|
|
||||||
* terminal's own text-selection `ActionMode`, not in a list-row menu, and the row has no selected text to
|
|
||||||
* copy — advertising it would be a dead entry.
|
|
||||||
*
|
|
||||||
* The new-session entry appears only when BOTH a usable cwd is known AND a spawn handler is wired: the
|
|
||||||
* server may omit `cwd` (or send blank), and `attach(null, cwd)` with no directory is not the same action.
|
|
||||||
* Every label here is a fixed app string — the server-provided cwd is carried in the CALLBACK only and is
|
|
||||||
* never rendered into a menu entry (plan §8: untrusted server strings stay out of chrome).
|
|
||||||
*/
|
|
||||||
public fun sessionRowMenuActions(
|
|
||||||
row: SessionRow,
|
|
||||||
onOpen: (UUID) -> Unit,
|
|
||||||
onNewSessionInCwd: ((String) -> Unit)?,
|
|
||||||
onKill: (UUID) -> Unit,
|
|
||||||
): List<ContextMenuAction> = buildList {
|
|
||||||
add(ContextMenuAction(MENU_LABEL_OPEN) { onOpen(row.id) })
|
|
||||||
val cwd = row.info.cwd?.takeIf { it.isNotBlank() }
|
|
||||||
if (cwd != null && onNewSessionInCwd != null) {
|
|
||||||
add(ContextMenuAction(MENU_LABEL_NEW_IN_CWD) { onNewSessionInCwd(cwd) })
|
|
||||||
}
|
|
||||||
add(ContextMenuAction(MENU_LABEL_KILL) { onKill(row.id) })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
package wang.yaojia.webterm.components
|
|
||||||
|
|
||||||
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.Row
|
|
||||||
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.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
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.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import wang.yaojia.webterm.designsystem.Radius
|
|
||||||
import wang.yaojia.webterm.designsystem.Spacing
|
|
||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
|
||||||
import wang.yaojia.webterm.designsystem.WebTermType
|
|
||||||
import wang.yaojia.webterm.viewmodels.QueueNotice
|
|
||||||
import wang.yaojia.webterm.viewmodels.QueueUiState
|
|
||||||
import wang.yaojia.webterm.viewmodels.QueueViewModel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* # QueueBadge (W2) — the "N queued" pill.
|
|
||||||
*
|
|
||||||
* Renders NOTHING for a null (unknown / pre-W2 server) or non-positive depth, so a host without the
|
|
||||||
* queue feature shows no affordance at all and an empty queue does not shout "0".
|
|
||||||
*
|
|
||||||
* The number is always the server's own count — the live `queue` frame or `GET /live-sessions`'s
|
|
||||||
* `queueLength` — never a locally incremented guess.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
public fun QueueBadge(depth: Int?, modifier: Modifier = Modifier) {
|
|
||||||
val pending = depth ?: return
|
|
||||||
if (pending <= 0) return
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(Radius.sm8))
|
|
||||||
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs2),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "$pending 待发",
|
|
||||||
style = WebTermType.metaMono, // tabular numerals so the pill does not jitter as N changes
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* # QueuePanel (W2) — queue / review / cancel the follow-up prompt FIFO.
|
|
||||||
*
|
|
||||||
* The Android counterpart of `public/queue.ts`: a bottom sheet that queues a prompt the server injects
|
|
||||||
* into the PTY the next time Claude goes idle, lists what is already pending, and offers the ONE cancel
|
|
||||||
* the server actually has — cancel-ALL. There is deliberately no per-item cancel affordance, because
|
|
||||||
* `DELETE /live-sessions/:id/queue` clears the whole FIFO and pretending otherwise would lie.
|
|
||||||
*
|
|
||||||
* ### The composed text is raw keyboard input (invariant #9)
|
|
||||||
* The field's contents go to [onSend] **verbatim** — this file never trims, normalises, or appends a
|
|
||||||
* `\r`. `appendEnter` is a FLAG on the request, so the SERVER materialises the carriage return
|
|
||||||
* byte-identically to a keystroke. The send action is enabled by [QueueViewModel.canSend], which refuses
|
|
||||||
* only a genuinely EMPTY string; whitespace is legitimate shell input.
|
|
||||||
*
|
|
||||||
* ### Untrusted text (plan §8)
|
|
||||||
* Queued items are round-tripped through the server, so they are rendered as INERT [Text] — no linkify,
|
|
||||||
* no Markdown, no `AnnotatedString` autolink — exactly like every other server-derived string. So is the
|
|
||||||
* [QueueNotice.Rejected] message, which is the server's own already-sanitized `error` field.
|
|
||||||
*
|
|
||||||
* Sheet presentation / detents / IME behaviour are device-QA (plan §7); the state machine is
|
|
||||||
* `QueueViewModelTest`.
|
|
||||||
*
|
|
||||||
* @param onSend the composed prompt, verbatim. The panel clears its field only after invoking this.
|
|
||||||
* @param onClearAll `DELETE …/queue` — cancel every pending entry.
|
|
||||||
* @param onRefresh re-read the queue (used when the live depth says the cached list is stale).
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
public fun QueuePanel(
|
|
||||||
state: QueueUiState,
|
|
||||||
onSend: (String) -> Unit,
|
|
||||||
onClearAll: () -> Unit,
|
|
||||||
onDismissNotice: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onRefresh: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
var draft by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = Spacing.lg16, vertical = Spacing.md12),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "排队提示词",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
QueueBadge(depth = state.depth)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "Claude 下次空闲时,服务器会按顺序把它们送进终端。",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
state.notice?.let { notice ->
|
|
||||||
QueueNoticeRow(notice = notice, onDismiss = onDismissNotice)
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = draft,
|
|
||||||
onValueChange = { draft = it }, // VERBATIM: no trim / normalisation anywhere on this path
|
|
||||||
label = { Text("要排队的提示词") },
|
|
||||||
enabled = !state.isBusy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
minLines = 2,
|
|
||||||
maxLines = 6,
|
|
||||||
)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onSend(draft) // exactly what the user typed
|
|
||||||
draft = ""
|
|
||||||
},
|
|
||||||
enabled = !state.isBusy && QueueViewModel.canSend(draft),
|
|
||||||
) { Text("加入队列") }
|
|
||||||
TextButton(onClick = onClearAll, enabled = !state.isBusy && state.depth > 0) {
|
|
||||||
Text("全部取消")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
QueuedItems(state = state, onRefresh = onRefresh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The pending prompts, inert. A stale list offers a re-read rather than contradicting the badge. */
|
|
||||||
@Composable
|
|
||||||
private fun QueuedItems(state: QueueUiState, onRefresh: () -> Unit) {
|
|
||||||
if (state.isItemsStale) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "队列已变化。",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
TextButton(onClick = onRefresh) { Text("刷新") }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (state.items.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "队列是空的。",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(max = QUEUE_LIST_MAX_HEIGHT),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
|
||||||
) {
|
|
||||||
itemsIndexed(state.items) { index, item ->
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
|
||||||
Text(
|
|
||||||
text = "${index + 1}.",
|
|
||||||
style = WebTermType.metaMono,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
// INERT: round-tripped through the server, so plain Text only (plan §8).
|
|
||||||
Text(
|
|
||||||
text = item,
|
|
||||||
style = WebTermType.metaMono,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One honest line per failure. A 429 says so plainly and offers NO retry button — the bucket is shared
|
|
||||||
* (20/min/IP), so a retry only burns the budget a real enqueue needs.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun QueueNoticeRow(notice: QueueNotice, onDismiss: () -> Unit) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8))
|
|
||||||
.padding(Spacing.sm8),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = queueNoticeText(notice),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
TextButton(onClick = onDismiss) { Text("知道了") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* App-authored copy for each outcome. [QueueNotice.Rejected] is the ONE case that surfaces a server
|
|
||||||
* string, and it is displayed inertly and verbatim (the server already sanitized it); a null message
|
|
||||||
* falls back to app copy rather than printing "null".
|
|
||||||
*/
|
|
||||||
internal fun queueNoticeText(notice: QueueNotice): String = when (notice) {
|
|
||||||
QueueNotice.Full -> "队列已满,请先取消一条。"
|
|
||||||
QueueNotice.TooLarge -> "提示词太长了,请缩短后重试。"
|
|
||||||
QueueNotice.Disabled -> "这台主机关闭了排队功能。"
|
|
||||||
QueueNotice.SessionGone -> "会话已退出,无法排队。"
|
|
||||||
// No retry offered: the 20/min bucket is shared with every other client of this host.
|
|
||||||
QueueNotice.RateLimited -> "操作过于频繁(每分钟上限 20 次),请稍后再手动重试。"
|
|
||||||
QueueNotice.Unreachable -> "无法连接主机,队列状态未知。"
|
|
||||||
is QueueNotice.Rejected -> notice.message ?: "主机拒绝了这次操作。"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keeps the sheet's buttons reachable no matter how long the queue gets. */
|
|
||||||
private val QUEUE_LIST_MAX_HEIGHT = Spacing.xxl24 * 8 // 192dp
|
|
||||||
|
|
||||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Preview(name = "QueueBadge")
|
|
||||||
@Composable
|
|
||||||
private fun QueueBadgePreview() {
|
|
||||||
WebTermTheme {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
|
|
||||||
QueueBadge(depth = 3)
|
|
||||||
QueueBadge(depth = 0) // renders nothing
|
|
||||||
QueueBadge(depth = null) // renders nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,35 +2,20 @@ package wang.yaojia.webterm.components
|
|||||||
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.SuggestionChip
|
import androidx.compose.material3.SuggestionChip
|
||||||
import androidx.compose.material3.SuggestionChipDefaults
|
import androidx.compose.material3.SuggestionChipDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import wang.yaojia.webterm.designsystem.Radius
|
import wang.yaojia.webterm.designsystem.Radius
|
||||||
import wang.yaojia.webterm.designsystem.Spacing
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
@@ -111,241 +96,6 @@ internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) {
|
|||||||
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
|
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
|
||||||
gateHeld && hasChips
|
gateHeld && hasChips
|
||||||
|
|
||||||
// ── The palette presenter (production state holder) ─────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The state holder the terminal screen renders the chips from: one published, immutable
|
|
||||||
* [chips] snapshot over a [QuickReplyStore].
|
|
||||||
*
|
|
||||||
* Every mutation delegates to the store — which is the CRUD authority and returns the new
|
|
||||||
* list — and republishes exactly what came back, so the UI can never drift from what was
|
|
||||||
* persisted (no optimistic local copy to reconcile, and a guarded no-op like a blank `add`
|
|
||||||
* simply republishes the unchanged list). Nothing is published before [load]: an unloaded
|
|
||||||
* palette shows no chips rather than flashing the defaults over the user's real list.
|
|
||||||
*
|
|
||||||
* Plain (not an `androidx.lifecycle.ViewModel`) so it drives under `runTest` virtual time;
|
|
||||||
* the screen `remember`s one and loads it in a `LaunchedEffect`. Cross-device sync is out of
|
|
||||||
* scope (plan §1) — this is one device's palette.
|
|
||||||
*/
|
|
||||||
public class QuickReplyPalette(private val store: QuickReplyStore) {
|
|
||||||
private val _chips = MutableStateFlow<List<QuickReplyChip>>(emptyList())
|
|
||||||
|
|
||||||
/** The palette in display order; empty until [load] (and after a user "delete all"). */
|
|
||||||
public val chips: StateFlow<List<QuickReplyChip>> = _chips.asStateFlow()
|
|
||||||
|
|
||||||
/** Read the persisted palette (defaults on first ever read) and publish it. */
|
|
||||||
public suspend fun load() {
|
|
||||||
_chips.value = store.loadAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Append [text] (blank is a store-level no-op) and republish. */
|
|
||||||
public suspend fun add(text: String) {
|
|
||||||
_chips.value = store.add(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace the text of [id] and republish (unknown id / blank text → no-op). */
|
|
||||||
public suspend fun edit(id: String, text: String) {
|
|
||||||
_chips.value = store.edit(id, text)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove [id] and republish (unknown id → no-op). */
|
|
||||||
public suspend fun remove(id: String) {
|
|
||||||
_chips.value = store.remove(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reorder [from]→[to] and republish (out-of-range → no-op). */
|
|
||||||
public suspend fun move(from: Int, to: Int) {
|
|
||||||
_chips.value = store.move(from, to)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── The editor's select / commit reducer (pure, JVM-tested) ─────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The editor's whole mutable state: ONE text field that either appends a new chip
|
|
||||||
* ([selectedId] null) or rewrites the selected one. Immutable — every transition returns a
|
|
||||||
* fresh snapshot.
|
|
||||||
*
|
|
||||||
* A single field (rather than an inline field per row) is deliberate: a per-row bound field
|
|
||||||
* would write to the store on every keystroke, i.e. one DataStore round-trip per character.
|
|
||||||
*/
|
|
||||||
internal data class QuickReplyEditorState(
|
|
||||||
/** The chip being rewritten, or null while composing a new one. */
|
|
||||||
val selectedId: String? = null,
|
|
||||||
/** The field's current text, carried VERBATIM into the commit (whitespace preserved). */
|
|
||||||
val draft: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** What committing the current [QuickReplyEditorState] does. */
|
|
||||||
internal enum class QuickReplyCommit { ADD, EDIT, NONE }
|
|
||||||
|
|
||||||
/** Load [chip] into the field for rewriting (its id becomes the commit target). */
|
|
||||||
internal fun QuickReplyEditorState.selecting(chip: QuickReplyChip): QuickReplyEditorState =
|
|
||||||
copy(selectedId = chip.id, draft = chip.text)
|
|
||||||
|
|
||||||
/** Back to "composing a new chip" with an empty field. */
|
|
||||||
internal fun QuickReplyEditorState.cleared(): QuickReplyEditorState =
|
|
||||||
QuickReplyEditorState()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A blank draft commits NOTHING (mirrors the store's blank guard, so the button can be
|
|
||||||
* disabled instead of silently no-op-ing); otherwise ADD when nothing is selected, EDIT when
|
|
||||||
* a chip is.
|
|
||||||
*/
|
|
||||||
internal fun QuickReplyEditorState.commitKind(): QuickReplyCommit = when {
|
|
||||||
draft.isBlank() -> QuickReplyCommit.NONE
|
|
||||||
selectedId == null -> QuickReplyCommit.ADD
|
|
||||||
else -> QuickReplyCommit.EDIT
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── The palette editor ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The editable-palette surface (plan §1 "Quick-reply chips + editable palette"): an
|
|
||||||
* [AlertDialog] over the store's CRUD. One field composes/rewrites; each row can be selected
|
|
||||||
* (tap 编辑), reordered (▲/▼) or deleted (✕).
|
|
||||||
*
|
|
||||||
* The chip texts are the user's OWN strings — never server data — but they are still rendered
|
|
||||||
* as plain inert [Text] (no Markdown/autolink), consistent with §8.
|
|
||||||
*
|
|
||||||
* Layout/scroll behaviour is device-QA (§7); the select/commit decision is the JVM-tested
|
|
||||||
* [QuickReplyEditorState] reducer.
|
|
||||||
*
|
|
||||||
* @param chips the current palette snapshot (from [QuickReplyPalette.chips]).
|
|
||||||
* @param onAdd / [onEdit] / [onRemove] / [onMove] the store-backed CRUD callbacks.
|
|
||||||
* @param onDismiss close the editor.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
public fun QuickReplyPaletteEditor(
|
|
||||||
chips: List<QuickReplyChip>,
|
|
||||||
onAdd: (String) -> Unit,
|
|
||||||
onEdit: (id: String, text: String) -> Unit,
|
|
||||||
onRemove: (id: String) -> Unit,
|
|
||||||
onMove: (from: Int, to: Int) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
var state by remember { mutableStateOf(QuickReplyEditorState()) }
|
|
||||||
// A chip removed while selected must not leave a dangling edit target pointing at a gone id.
|
|
||||||
// DERIVED, not written back during composition (a composition-time state write is a recomposition
|
|
||||||
// hazard): the field simply falls back to "adding" and keeps whatever was typed.
|
|
||||||
val effective = if (state.selectedId != null && chips.none { it.id == state.selectedId }) {
|
|
||||||
state.copy(selectedId = null)
|
|
||||||
} else {
|
|
||||||
state
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("快捷回复") },
|
|
||||||
text = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.heightIn(max = EDITOR_MAX_HEIGHT_DP.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
|
||||||
) {
|
|
||||||
QuickReplyEditorField(
|
|
||||||
state = effective,
|
|
||||||
onDraftChange = { state = effective.copy(draft = it) },
|
|
||||||
onCommit = {
|
|
||||||
when (effective.commitKind()) {
|
|
||||||
QuickReplyCommit.ADD -> onAdd(effective.draft)
|
|
||||||
QuickReplyCommit.EDIT -> onEdit(requireNotNull(effective.selectedId), effective.draft)
|
|
||||||
QuickReplyCommit.NONE -> Unit
|
|
||||||
}
|
|
||||||
state = effective.cleared()
|
|
||||||
},
|
|
||||||
onCancelSelection = { state = effective.cleared() },
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
|
||||||
) {
|
|
||||||
if (chips.isEmpty()) {
|
|
||||||
Text("还没有快捷回复。", style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
chips.forEachIndexed { index, chip ->
|
|
||||||
QuickReplyEditorRow(
|
|
||||||
chip = chip,
|
|
||||||
isSelected = chip.id == effective.selectedId,
|
|
||||||
canMoveUp = index > 0,
|
|
||||||
canMoveDown = index < chips.lastIndex,
|
|
||||||
onSelect = { state = effective.selecting(chip) },
|
|
||||||
onMoveUp = { onMove(index, index - 1) },
|
|
||||||
onMoveDown = { onMove(index, index + 1) },
|
|
||||||
onRemove = { onRemove(chip.id) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = { TextButton(onClick = onDismiss) { Text("完成") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The single compose/rewrite field + its commit action. */
|
|
||||||
@Composable
|
|
||||||
private fun QuickReplyEditorField(
|
|
||||||
state: QuickReplyEditorState,
|
|
||||||
onDraftChange: (String) -> Unit,
|
|
||||||
onCommit: () -> Unit,
|
|
||||||
onCancelSelection: () -> Unit,
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.draft,
|
|
||||||
onValueChange = onDraftChange,
|
|
||||||
label = { Text(if (state.selectedId == null) "新增内容" else "修改内容") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
|
||||||
TextButton(
|
|
||||||
onClick = onCommit,
|
|
||||||
enabled = state.commitKind() != QuickReplyCommit.NONE,
|
|
||||||
) { Text(if (state.selectedId == null) "添加" else "保存") }
|
|
||||||
if (state.selectedId != null) {
|
|
||||||
TextButton(onClick = onCancelSelection) { Text("取消") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One palette row: the (inert) text plus select / reorder / delete affordances. */
|
|
||||||
@Composable
|
|
||||||
private fun QuickReplyEditorRow(
|
|
||||||
chip: QuickReplyChip,
|
|
||||||
isSelected: Boolean,
|
|
||||||
canMoveUp: Boolean,
|
|
||||||
canMoveDown: Boolean,
|
|
||||||
onSelect: () -> Unit,
|
|
||||||
onMoveUp: () -> Unit,
|
|
||||||
onMoveDown: () -> Unit,
|
|
||||||
onRemove: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = chip.text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (isSelected) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
TextButton(onClick = onSelect) { Text("编辑") }
|
|
||||||
TextButton(onClick = onMoveUp, enabled = canMoveUp) { Text("▲") }
|
|
||||||
TextButton(onClick = onMoveDown, enabled = canMoveDown) { Text("▼") }
|
|
||||||
TextButton(onClick = onRemove) { Text("✕") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cap on the editor body height so a long palette scrolls inside the dialog. */
|
|
||||||
private const val EDITOR_MAX_HEIGHT_DP: Int = 320
|
|
||||||
|
|
||||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Preview(name = "QuickReply (gate held)")
|
@Preview(name = "QuickReply (gate held)")
|
||||||
@@ -359,18 +109,3 @@ private fun QuickReplyPreview() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(name = "QuickReply palette editor")
|
|
||||||
@Composable
|
|
||||||
private fun QuickReplyPaletteEditorPreview() {
|
|
||||||
WebTermTheme {
|
|
||||||
QuickReplyPaletteEditor(
|
|
||||||
chips = QuickReplyDefaults.chips,
|
|
||||||
onAdd = {},
|
|
||||||
onEdit = { _, _ -> },
|
|
||||||
onRemove = {},
|
|
||||||
onMove = { _, _ -> },
|
|
||||||
onDismiss = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -79,16 +79,11 @@ public sealed interface BannerModel {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
|
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
|
||||||
* spinner** (reconnecting would hit the same wall forever), and — where a fresh session would
|
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
|
||||||
* actually help — a "新会话" affordance.
|
|
||||||
*
|
|
||||||
* [FailureReason.UNAUTHORIZED] deliberately offers NO new-session action (B5): the server 401'd the
|
|
||||||
* upgrade, so a new session would 401 too; the fix is to re-enter the host's access token (or fix its
|
|
||||||
* `ALLOWED_ORIGINS`), which the copy says.
|
|
||||||
*/
|
*/
|
||||||
public data class Failed(val reason: FailureReason) : BannerModel {
|
public data class Failed(val reason: FailureReason) : BannerModel {
|
||||||
override val showsSpinner: Boolean get() = false
|
override val showsSpinner: Boolean get() = false
|
||||||
override val offersNewSession: Boolean get() = reason != FailureReason.UNAUTHORIZED
|
override val offersNewSession: Boolean get() = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,8 +184,6 @@ private fun bannerCopy(model: BannerModel): String = when (model) {
|
|||||||
is BannerModel.Failed -> when (model.reason) {
|
is BannerModel.Failed -> when (model.reason) {
|
||||||
FailureReason.REPLAY_TOO_LARGE ->
|
FailureReason.REPLAY_TOO_LARGE ->
|
||||||
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
|
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
|
||||||
FailureReason.UNAUTHORIZED ->
|
|
||||||
"服务器拒绝了连接(401)。请在“配对主机”里重新填写访问令牌;若已填写,请检查主机的 ALLOWED_ORIGINS。"
|
|
||||||
}
|
}
|
||||||
is BannerModel.Exited ->
|
is BannerModel.Exited ->
|
||||||
if (model.isSpawnFailure) {
|
if (model.isSpawnFailure) {
|
||||||
|
|||||||
@@ -113,10 +113,6 @@ private fun TitleLine(row: SessionRow) {
|
|||||||
) {
|
) {
|
||||||
StatusBadge(status = row.displayStatus)
|
StatusBadge(status = row.displayStatus)
|
||||||
if (row.isUnread) UnreadDot()
|
if (row.isUnread) UnreadDot()
|
||||||
// W2: the pending follow-up depth from `GET /live-sessions`'s `queueLength` (null on a pre-W2
|
|
||||||
// server → renders nothing). The badge is the cold/list source; an ATTACHED session's live depth
|
|
||||||
// comes from the `queue` WS frame instead.
|
|
||||||
QueueBadge(depth = row.info.queueLength)
|
|
||||||
Text(
|
Text(
|
||||||
text = row.title.ifBlank { fallbackLabel(row.info) },
|
text = row.title.ifBlank { fallbackLabel(row.info) },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -206,12 +202,7 @@ private fun SessionListRowPreview() {
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
|
||||||
SessionListRow(
|
SessionListRow(
|
||||||
row = SessionRow(
|
row = SessionRow(
|
||||||
info = previewInfo(
|
info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L),
|
||||||
status = ClaudeStatus.WORKING,
|
|
||||||
title = "web-terminal",
|
|
||||||
lastOutputAt = 2L,
|
|
||||||
queueLength = 2,
|
|
||||||
),
|
|
||||||
displayStatus = DisplayStatus.Working,
|
displayStatus = DisplayStatus.Working,
|
||||||
title = "web-terminal",
|
title = "web-terminal",
|
||||||
isUnread = true,
|
isUnread = true,
|
||||||
@@ -237,7 +228,6 @@ private fun previewInfo(
|
|||||||
cols: Int = 161,
|
cols: Int = 161,
|
||||||
rows: Int = 50,
|
rows: Int = 50,
|
||||||
lastOutputAt: Long? = null,
|
lastOutputAt: Long? = null,
|
||||||
queueLength: Int? = null,
|
|
||||||
): LiveSessionInfo = LiveSessionInfo(
|
): LiveSessionInfo = LiveSessionInfo(
|
||||||
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
|
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
|
||||||
createdAt = 1L,
|
createdAt = 1L,
|
||||||
@@ -250,5 +240,4 @@ private fun previewInfo(
|
|||||||
rows = rows,
|
rows = rows,
|
||||||
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
|
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
|
||||||
lastOutputAt = lastOutputAt,
|
lastOutputAt = lastOutputAt,
|
||||||
queueLength = queueLength,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
package wang.yaojia.webterm.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.CookieJar
|
|
||||||
import wang.yaojia.webterm.hostregistry.AuthCookieCipher
|
|
||||||
import wang.yaojia.webterm.hostregistry.AuthCookieStore
|
|
||||||
import wang.yaojia.webterm.hostregistry.DataStoreAuthCookieStore
|
|
||||||
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
|
|
||||||
import wang.yaojia.webterm.tlsandroid.TinkAuthCookieCipher
|
|
||||||
import wang.yaojia.webterm.transport.AuthCookieJar
|
|
||||||
import wang.yaojia.webterm.wiring.toRecord
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The optional `WEBTERM_TOKEN` gate's client boundary (server `src/http/auth.ts`).
|
|
||||||
*
|
|
||||||
* Three things are wired here, and all three are required for the gate to work at all:
|
|
||||||
* 1. **[AuthCookieJar] on the ONE shared `OkHttpClient`** (via the [CookieJar] binding [NetworkModule]
|
|
||||||
* consumes). Both transports share that client, so the `webterm_auth` cookie rides every REST call
|
|
||||||
* AND the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls too). Without this the
|
|
||||||
* client runs on `CookieJar.NO_COOKIES` and a token-gated host 401s everything.
|
|
||||||
* 2. **[AuthCookieStore]** so the credential survives a process restart — encrypted at rest.
|
|
||||||
* 3. **The [AuthCookieCipher] seam** bridging `:host-registry` (which must not depend on Tink) to
|
|
||||||
* `:client-tls-android`'s [TinkAuthCookieCipher] (which must not depend on `:host-registry`) — the
|
|
||||||
* same sibling-bridge pattern [NetworkModule] uses for the mTLS `ClientIdentityProvider`.
|
|
||||||
*
|
|
||||||
* ### Fail CLOSED
|
|
||||||
* [DataStoreAuthCookieStore] takes the cipher as a REQUIRED constructor argument precisely so no wiring
|
|
||||||
* can persist plaintext by omission. If a real cipher cannot be constructed, this module binds
|
|
||||||
* [InMemoryAuthCookieStore] instead: memory-only persistence (the user re-authenticates after a restart)
|
|
||||||
* is the correct answer, and writing a full-shell credential in the clear is not an option. A cipher that
|
|
||||||
* constructs but later fails to seal is handled the same way one level down — the store persists NOTHING
|
|
||||||
* and drops any previous blob.
|
|
||||||
*
|
|
||||||
* ### Threading
|
|
||||||
* The graph builds nothing expensive: [TinkAuthCookieCipher] defers all AEAD/keystore work to first use,
|
|
||||||
* and `AppEnvironment.warmUp()` does the first read off `Main`. The persister side must be non-blocking
|
|
||||||
* because OkHttp calls a `CookieJar` synchronously on the call thread, so the durable write is handed to
|
|
||||||
* an app-scoped IO scope.
|
|
||||||
*/
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
public object AuthCookieModule {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The durable home of the `webterm_auth` cookie. Shares the ONE Preferences DataStore with the host
|
|
||||||
* list / last-session pointer ([StorageModule]) — disjoint keys (`authCookiesSealed` vs `hosts` /
|
|
||||||
* `lastSessionId.<hostId>`) — but the VALUE here is Tink-AEAD ciphertext, never plaintext.
|
|
||||||
*/
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
public fun provideAuthCookieStore(
|
|
||||||
dataStore: DataStore<Preferences>,
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
): AuthCookieStore {
|
|
||||||
val cipher = buildAuthCookieCipher(context)
|
|
||||||
if (cipher == null) {
|
|
||||||
// Fail closed: memory-only rather than a plaintext credential at rest.
|
|
||||||
Log.w(TAG, "auth-cookie cipher unavailable — the session cookie will not be persisted")
|
|
||||||
return InMemoryAuthCookieStore()
|
|
||||||
}
|
|
||||||
return DataStoreAuthCookieStore(
|
|
||||||
dataStore = dataStore,
|
|
||||||
cipher = cipher,
|
|
||||||
onCipherFailure = ::reportCipherFailure,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ONE jar. Its persister hands each host's changed cookie set to [AuthCookieStore] on an
|
|
||||||
* app-scoped IO scope — never blocking OkHttp's call thread on DataStore/Tink I/O. The scope lives for
|
|
||||||
* the process (there is no later moment at which a pending credential write should be abandoned).
|
|
||||||
*/
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
public fun provideAuthCookieJar(store: AuthCookieStore): AuthCookieJar {
|
|
||||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
return AuthCookieJar(
|
|
||||||
persister = { hostKey, cookies ->
|
|
||||||
scope.launch {
|
|
||||||
runCatching { store.replaceHost(hostKey, cookies.map { it.toRecord(hostKey) }) }
|
|
||||||
.onFailure(::reportPersistFailure)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What [NetworkModule] installs on the shared client — the same instance as [provideAuthCookieJar]. */
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
public fun provideCookieJar(jar: AuthCookieJar): CookieJar = jar
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The 4-line adapter [TinkAuthCookieCipher]'s KDoc prescribes: it matches [AuthCookieCipher]'s shape
|
|
||||||
* exactly but cannot declare it (sibling modules), so `:app` bridges the two.
|
|
||||||
*
|
|
||||||
* Returns null if the cipher cannot even be CONSTRUCTED. Today's implementation defers all keystore
|
|
||||||
* work to first use and so does not throw here; the guard exists because the alternative to "no
|
|
||||||
* cipher" must always be "no persistence", never "plaintext persistence".
|
|
||||||
*/
|
|
||||||
private fun buildAuthCookieCipher(context: Context): AuthCookieCipher? =
|
|
||||||
runCatching {
|
|
||||||
val tink = TinkAuthCookieCipher(context)
|
|
||||||
object : AuthCookieCipher {
|
|
||||||
override fun seal(plaintext: String): String = tink.seal(plaintext)
|
|
||||||
override fun open(sealed: String): String = tink.open(sealed)
|
|
||||||
}
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A seal/open failure means a credential was dropped instead of stored (or a stored one could not be
|
|
||||||
* read). Reported by TYPE only: neither the plaintext nor the ciphertext may reach a log.
|
|
||||||
*/
|
|
||||||
private fun reportCipherFailure(error: Throwable) {
|
|
||||||
Log.w(TAG, "auth-cookie at-rest crypto failed (${error::class.simpleName}) — nothing was persisted")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A durable-write failure leaves the cookie in memory only; same logging discipline. */
|
|
||||||
private fun reportPersistFailure(error: Throwable) {
|
|
||||||
Log.w(TAG, "auth-cookie persist failed (${error::class.simpleName}) — cookie kept in memory only")
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val TAG: String = "WebTermAuthCookie"
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package wang.yaojia.webterm.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import wang.yaojia.webterm.hostregistry.AccessTokenStore
|
|
||||||
import wang.yaojia.webterm.hostregistry.TinkAccessTokenStore
|
|
||||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Access-token boundary (B5 / ios-completion §1.1): the ONE per-host access-token store, exposed both as
|
|
||||||
* the mutable [AccessTokenStore] (the pairing screen writes it) and as the read-only
|
|
||||||
* [AccessTokenSource] the transports consume.
|
|
||||||
*
|
|
||||||
* It is its own Hilt module — not part of [StorageModule] — because the token is SECRET material:
|
|
||||||
* [StorageModule]'s DataStore holds only non-secret UI state, while this store is
|
|
||||||
* Tink-AEAD-encrypted under an AndroidKeystore-wrapped master key (the same posture as [TlsModule]'s
|
|
||||||
* cert store, with its own keyset so the two secrets never share a key).
|
|
||||||
*
|
|
||||||
* Both providers resolve to the SAME singleton, so a token stored during pairing is immediately visible
|
|
||||||
* to the WS upgrade and to every REST route — no cache to invalidate. The store is constructor-I/O-free
|
|
||||||
* (Tink/Keystore work is lazy and hops to `Dispatchers.IO` inside), so injecting it on `Main` is cheap.
|
|
||||||
*/
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
public object AuthModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
public fun provideAccessTokenStore(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
): AccessTokenStore = TinkAccessTokenStore(context)
|
|
||||||
|
|
||||||
/** The transports' read seam — the SAME instance the pairing screen wrote to. */
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
public fun provideAccessTokenSource(store: AccessTokenStore): AccessTokenSource = store
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import okhttp3.ConnectionPool
|
import okhttp3.ConnectionPool
|
||||||
import okhttp3.CookieJar
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import wang.yaojia.webterm.transport.ClientIdentity
|
import wang.yaojia.webterm.transport.ClientIdentity
|
||||||
import wang.yaojia.webterm.transport.ClientIdentityProvider
|
import wang.yaojia.webterm.transport.ClientIdentityProvider
|
||||||
@@ -13,7 +12,6 @@ import wang.yaojia.webterm.transport.OkHttpClientFactory
|
|||||||
import wang.yaojia.webterm.transport.OkHttpHttpTransport
|
import wang.yaojia.webterm.transport.OkHttpHttpTransport
|
||||||
import wang.yaojia.webterm.transport.OkHttpTermTransport
|
import wang.yaojia.webterm.transport.OkHttpTermTransport
|
||||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
import wang.yaojia.webterm.wire.TermTransport
|
import wang.yaojia.webterm.wire.TermTransport
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -29,11 +27,6 @@ import javax.inject.Singleton
|
|||||||
* re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no
|
* re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no
|
||||||
* client rebuild (R4). The provider is read ONCE, when the client is built.
|
* client rebuild (R4). The provider is read ONCE, when the client is built.
|
||||||
*
|
*
|
||||||
* ### The WEBTERM_TOKEN cookie jar
|
|
||||||
* The shared client also carries the ONE [CookieJar] provided by [AuthCookieModule], so the optional
|
|
||||||
* access-token gate applies uniformly to REST and to the WS upgrade. See that module for the at-rest
|
|
||||||
* (Tink AEAD) custody of the cookie.
|
|
||||||
*
|
|
||||||
* ### Breaking the construction cycle (A11's frozen constructor)
|
* ### Breaking the construction cycle (A11's frozen constructor)
|
||||||
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
|
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
|
||||||
* client needs the repository's SSL material — a cycle. It is broken with an explicit shared
|
* client needs the repository's SSL material — a cycle. It is broken with an explicit shared
|
||||||
@@ -60,40 +53,22 @@ public object NetworkModule {
|
|||||||
ClientIdentity(material.sslSocketFactory, material.trustManager)
|
ClientIdentity(material.sslSocketFactory, material.trustManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */
|
||||||
* The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool] and
|
|
||||||
* carrying the ONE [CookieJar] ([AuthCookieModule]).
|
|
||||||
*
|
|
||||||
* The cookie jar is what makes the optional `WEBTERM_TOKEN` gate work: a gated host answers pairing
|
|
||||||
* with `Set-Cookie: webterm_auth=…` and then requires that cookie on every remote route AND on the WS
|
|
||||||
* upgrade. Because both transports share this client, installing the jar HERE covers both at once
|
|
||||||
* (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Leaving it at `CookieJar.NO_COOKIES` — as
|
|
||||||
* this provider did before — makes the whole token feature inert.
|
|
||||||
*/
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
public fun provideOkHttpClient(
|
public fun provideOkHttpClient(
|
||||||
identityProvider: ClientIdentityProvider,
|
identityProvider: ClientIdentityProvider,
|
||||||
connectionPool: ConnectionPool,
|
connectionPool: ConnectionPool,
|
||||||
cookieJar: CookieJar,
|
|
||||||
): OkHttpClient =
|
): OkHttpClient =
|
||||||
OkHttpClientFactory.create(identityProvider, cookieJar)
|
OkHttpClientFactory.create(identityProvider)
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.connectionPool(connectionPool)
|
.connectionPool(connectionPool)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/**
|
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */
|
||||||
* WS transport over the shared client (it derives a streaming-tuned variant sharing the pool).
|
|
||||||
*
|
|
||||||
* [tokens] ([AuthModule]) is what makes the **WS upgrade** carry `Cookie: webterm_auth=<t>` — the
|
|
||||||
* REST half gets its cookie from `:api-client`'s single stamping point instead (B5 / §1.1).
|
|
||||||
*/
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
public fun provideTermTransport(
|
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client)
|
||||||
client: OkHttpClient,
|
|
||||||
tokens: AccessTokenSource,
|
|
||||||
): TermTransport = OkHttpTermTransport(client, tokens)
|
|
||||||
|
|
||||||
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
|
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
package wang.yaojia.webterm.di
|
package wang.yaojia.webterm.di
|
||||||
|
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import wang.yaojia.webterm.push.PushRegistrar
|
|
||||||
import wang.yaojia.webterm.push.PushRegistrarTokenSink
|
import wang.yaojia.webterm.push.PushRegistrarTokenSink
|
||||||
import wang.yaojia.webterm.push.PushTokenSink
|
import wang.yaojia.webterm.push.PushTokenSink
|
||||||
import wang.yaojia.webterm.wiring.HostPushUnregister
|
|
||||||
import wang.yaojia.webterm.wiring.PushTokenSource
|
|
||||||
import javax.inject.Singleton
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with
|
* Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with
|
||||||
@@ -21,52 +13,10 @@ import kotlin.coroutines.resume
|
|||||||
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
|
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
|
||||||
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
|
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
|
||||||
* (self-heal). This is the intended optional-collaborator handoff (no mutable global).
|
* (self-heal). This is the intended optional-collaborator handoff (no mutable global).
|
||||||
*
|
|
||||||
* It also binds the two seams the HOST-REMOVAL path needs
|
|
||||||
* ([HostPushUnregister] + [PushTokenSource]): `PushRegistrar.unregisterHost` had zero call sites, so a
|
|
||||||
* removed host kept this device's token and kept pushing for a host the user had dropped.
|
|
||||||
*/
|
*/
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
public interface PushModule {
|
public interface PushModule {
|
||||||
@Binds
|
@Binds
|
||||||
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink
|
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink
|
||||||
|
|
||||||
public companion object {
|
|
||||||
/**
|
|
||||||
* `DELETE /push/fcm-token` for ONE host — the FIRST caller of [PushRegistrar.unregisterHost],
|
|
||||||
* which was defined and never invoked, so a removed host kept this device's token forever.
|
|
||||||
*
|
|
||||||
* Routed through the registrar rather than a fresh `ApiClient` so the removal inherits its
|
|
||||||
* already-tested best-effort discipline (a `CancellationException` rethrown, any other failure
|
|
||||||
* swallowed with the token never logged).
|
|
||||||
*/
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
public fun provideHostPushUnregister(
|
|
||||||
registrar: PushRegistrar,
|
|
||||||
): HostPushUnregister = HostPushUnregister { host, token -> registrar.unregisterHost(host, token) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current FCM registration token. Suspends on Firebase's own `Task` rather than blocking, and
|
|
||||||
* answers `null` for every degraded case (no `google-services.json`, uninitialised `FirebaseApp`,
|
|
||||||
* a failed fetch) so a push-less build still removes hosts cleanly. The token is returned to the
|
|
||||||
* caller and NEVER logged (plan §8).
|
|
||||||
*/
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
public fun providePushTokenSource(): PushTokenSource = PushTokenSource {
|
|
||||||
suspendCancellableCoroutine { continuation ->
|
|
||||||
val task = runCatching { FirebaseMessaging.getInstance().token }.getOrNull()
|
|
||||||
if (task == null) {
|
|
||||||
continuation.resume(null)
|
|
||||||
return@suspendCancellableCoroutine
|
|
||||||
}
|
|
||||||
task
|
|
||||||
.addOnSuccessListener { token -> continuation.resume(token?.takeIf { it.isNotEmpty() }) }
|
|
||||||
.addOnFailureListener { continuation.resume(null) }
|
|
||||||
.addOnCanceledListener { continuation.resume(null) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,15 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import wang.yaojia.webterm.hostregistry.DataStoreHostStore
|
import wang.yaojia.webterm.hostregistry.DataStoreHostStore
|
||||||
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
|
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
|
||||||
import wang.yaojia.webterm.hostregistry.DataStoreSessionWatermarkStore
|
|
||||||
import wang.yaojia.webterm.hostregistry.HostStore
|
import wang.yaojia.webterm.hostregistry.HostStore
|
||||||
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||||
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
|
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore.
|
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore.
|
||||||
* The host list ([HostStore], key `"hosts"`), the per-host last-session id ([LastSessionStore], key
|
* The host list ([HostStore], key `"hosts"`) and per-host last-session id ([LastSessionStore], key
|
||||||
* `"lastSessionId.<hostId>"`) and the unread watermarks ([SessionWatermarkStore], key
|
* `"lastSessionId.<hostId>"`) use disjoint keys, so they safely share one DataStore file. Secret
|
||||||
* `"unreadWatermarks"`) use disjoint keys, so they safely share one DataStore file. Secret material (the
|
* material (the device cert) never lives here — that is the Tink store in [TlsModule].
|
||||||
* device cert, the `webterm_auth` cookie) never lives here in the clear — those are the Tink-sealed
|
|
||||||
* stores in [TlsModule] / [AuthCookieModule].
|
|
||||||
*
|
|
||||||
* Two `DataStore` instances over one file THROW, so every store here takes the single [provideDataStore]
|
|
||||||
* singleton; a new store means a new KEY, never a new file.
|
|
||||||
*/
|
*/
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
@@ -50,16 +43,5 @@ public object StorageModule {
|
|||||||
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
|
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
|
||||||
DataStoreLastSessionStore(dataStore)
|
DataStoreLastSessionStore(dataStore)
|
||||||
|
|
||||||
/**
|
|
||||||
* The durable home of the session-list unread watermarks (`sessionId → last-seen ms`). Until this was
|
|
||||||
* wired the ledger was memory-only, so the unread dot reset on every process death — precisely when
|
|
||||||
* it matters, since the product premise is walking away and coming back.
|
|
||||||
*/
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
public fun provideSessionWatermarkStore(
|
|
||||||
dataStore: DataStore<Preferences>,
|
|
||||||
): SessionWatermarkStore = DataStoreSessionWatermarkStore(dataStore)
|
|
||||||
|
|
||||||
private const val DATASTORE_NAME: String = "webterm"
|
private const val DATASTORE_NAME: String = "webterm"
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user