Compare commits
84 Commits
f9964a517d
...
feat/tunne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e254918b1c | ||
|
|
542fde9580 | ||
|
|
e7f3bd05f0 | ||
|
|
31054450fc | ||
|
|
4fe1981997 | ||
|
|
7b3fe1b124 | ||
|
|
4ea8f7862a | ||
|
|
cf88e7c588 | ||
|
|
34e4a88059 | ||
|
|
99cafdbdbb | ||
|
|
57725f7ef2 | ||
|
|
fc3b849a08 | ||
|
|
a24465623e | ||
|
|
a25633a63b | ||
|
|
5b9ca321d2 | ||
|
|
cb04516d52 | ||
|
|
d0c249c739 | ||
|
|
bb0949553c | ||
|
|
e38e6d1689 | ||
|
|
5337281e85 | ||
|
|
6b8269c1c1 | ||
|
|
c1c837c54f | ||
|
|
89678c7949 | ||
|
|
7af4a68ef5 | ||
|
|
6efed9772e | ||
|
|
1a8984e851 | ||
|
|
d77f1ff62c | ||
|
|
5e7e4b22f2 | ||
|
|
bfe1be1dfe | ||
|
|
aa1912b962 | ||
|
|
95b9cccf07 | ||
|
|
242a4e0dc1 | ||
|
|
86d100a9ad | ||
|
|
3492f0cf1f | ||
|
|
d36deb6922 | ||
|
|
a887638557 | ||
|
|
d01a69eb72 | ||
|
|
c44725618c | ||
|
|
660a40491a | ||
|
|
823432b1c8 | ||
|
|
77502ec4fe | ||
|
|
9c0097a305 | ||
|
|
f40b8f9400 | ||
|
|
4871e8ac3d | ||
|
|
aa956fcbb4 | ||
|
|
cc4d3129cc | ||
|
|
5098643355 | ||
|
|
5c8c87fb7a | ||
|
|
a2b14ab6e7 | ||
|
|
95438cdc12 | ||
|
|
2ab93c9682 | ||
|
|
cbaa08daba | ||
|
|
9b41ffa574 | ||
|
|
ba85871227 | ||
|
|
3d17bed322 | ||
|
|
340ea48478 | ||
|
|
c0279fd6e2 | ||
|
|
0ad7c31549 | ||
|
|
ad5cf06207 | ||
|
|
3020184054 | ||
|
|
a09c131539 | ||
|
|
1529d2c94c | ||
|
|
cf8cfccab4 | ||
|
|
2af57e6686 | ||
|
|
e4c327e25e | ||
|
|
03612323c0 | ||
|
|
7b3ba8491a | ||
|
|
7fd2ef3fc8 | ||
|
|
b273b61795 | ||
|
|
e554c053ee | ||
|
|
d6809c65c4 | ||
|
|
4f1d3ebc6b | ||
|
|
88b960bac5 | ||
|
|
67b0a43b39 | ||
|
|
59de784c8a | ||
|
|
bf5241e87b | ||
|
|
46698b8b5e | ||
|
|
99bc6fd9f6 | ||
|
|
a390130205 | ||
|
|
3323bc81c0 | ||
|
|
32fb4b09b1 | ||
|
|
985ff8a178 | ||
|
|
7b4adf5072 | ||
|
|
dc5d073374 |
220
.github/workflows/ios.yml
vendored
Normal file
220
.github/workflows/ios.yml
vendored
Normal file
@@ -0,0 +1,220 @@
|
||||
# iOS CI (T-iOS-16; hardens the T-iOS-1 skeleton). Three layers, PLAN_IOS_CLIENT §9:
|
||||
#
|
||||
# 1. package-tests — per-package `swift test` + OWN-SOURCES coverage gate
|
||||
# (>= 80%, plan §9). NOTE: the plan §9 raw llvm-cov command reads export
|
||||
# TOTALS, which count statically-linked DEPENDENCY sources compiled into
|
||||
# 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
|
||||
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
||||
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
|
||||
# iPhone 16 simulator): ViewModels/components of the app glue layer.
|
||||
# 3. integration-tests — Swift Testing against the REAL Node server. The
|
||||
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
||||
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
||||
# whitelist, src/config.ts:187-226 — no ALLOWED_ORIGINS needed). This layer
|
||||
# is the standing anti-drift gate between the Swift-replicated protocol
|
||||
# and the server implementation — hence the src/** path triggers below.
|
||||
name: ios
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "ios/**"
|
||||
- ".github/workflows/ios.yml"
|
||||
# integration layer guards client<->server protocol drift:
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "ios/**"
|
||||
- ".github/workflows/ios.yml"
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
|
||||
jobs:
|
||||
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
||||
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs
|
||||
# tests below without a gate (test doubles are excluded from the gate).
|
||||
package-tests:
|
||||
runs-on: macos-15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: [WireProtocol, SessionCore, HostRegistry, APIClient]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- name: swift test + own-sources coverage gate (>= 80%)
|
||||
run: ios/IntegrationTests/scripts/coverage-gate.sh ${{ matrix.package }}
|
||||
|
||||
testsupport-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: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages)
|
||||
run: swift test --package-path ios/Packages/TestSupport
|
||||
|
||||
# Layer 2: app-target unit tests (WebTermTests, hosted by the app).
|
||||
app-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, iPhone 16 simulator)
|
||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
||||
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
||||
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
||||
# handoff finding, verified locally in the ui-test leg runs.)
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
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
|
||||
|
||||
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
||||
# npm ci compiles node-pty (needs the Xcode toolchain — present on the
|
||||
# runner); the Swift ServerHarness then boots the server itself.
|
||||
integration-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
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: npm ci (builds node-pty native module)
|
||||
run: npm ci
|
||||
- name: swift test vs real server (ServerHarness boots tsx src/server.ts)
|
||||
run: swift test --package-path ios/IntegrationTests
|
||||
|
||||
# Layer 4 (W5, plan §9): the ONE scripted XCUITest happy path — 配对(手输) →
|
||||
# 列表 → attach → 输入 → gate approve — against a live loopback server. The
|
||||
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
||||
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
||||
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
|
||||
ui-test:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: npm ci (builds node-pty native module)
|
||||
run: npm ci
|
||||
- name: Start web-terminal server on loopback
|
||||
run: |
|
||||
PORT=3217 BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \
|
||||
nohup node_modules/.bin/tsx src/server.ts > uitest-server.log 2>&1 &
|
||||
echo $! > uitest-server.pid
|
||||
ready=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:3217/live-sessions > /dev/null; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "server did not come up on 127.0.0.1:3217" >&2
|
||||
cat uitest-server.log >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
# The 输入 step taps the KeyBar, which is the terminal's
|
||||
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
||||
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
||||
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
||||
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator)
|
||||
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
||||
# strips the prefix and injects <VAR> into the test-runner process);
|
||||
# passing it as a KEY=VALUE argument makes it a build setting, which
|
||||
# never reaches the runner (verified empirically, 2026-07-05).
|
||||
# NO CODE_SIGNING_ALLOWED=NO here: the app must be (ad-hoc) signed or
|
||||
# the data-protection keychain rejects every SecItem call (-34018) and
|
||||
# pairing dies at "保存到本机失败" (verified empirically, run 6).
|
||||
# Simulator builds sign locally without any certificate.
|
||||
env:
|
||||
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' test
|
||||
- name: Server log + shutdown
|
||||
if: always()
|
||||
run: |
|
||||
cat uitest-server.log || true
|
||||
kill "$(cat uitest-server.pid)" 2>/dev/null || true
|
||||
|
||||
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
||||
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
||||
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose
|
||||
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so
|
||||
# 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,
|
||||
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures
|
||||
# fail the job (no silent pass).
|
||||
ios17-floor-tests:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3 (build toolchain)
|
||||
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: Create iOS 17.x simulator (skip-with-notice if runtime absent)
|
||||
id: sim17
|
||||
run: |
|
||||
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)"
|
||||
if [ -z "$runtime" ]; then
|
||||
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"
|
||||
echo "runtime=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
||||
echo "created $udid with $runtime"
|
||||
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
|
||||
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
||||
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
|
||||
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
|
||||
if: steps.sim17.outputs.runtime != ''
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test
|
||||
30
.github/workflows/relay-tripwire.yml
vendored
Normal file
30
.github/workflows/relay-tripwire.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# PERMANENT cross-tenant isolation tripwire (INV1) — see docs/PLAN_RELAY_AUTH_ISOLATION.md T13.
|
||||
#
|
||||
# This job MUST be a REQUIRED status check. A green build is impossible if cross-tenant isolation
|
||||
# regresses (device A reaching host B). DO NOT delete, skip, or make this non-required. If it ever
|
||||
# goes red, BLOCK the merge (CRITICAL per code-review.md).
|
||||
name: relay-tripwire
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
cross-tenant-tripwire:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: relay-auth
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Install relay-auth (+ local relay-contracts)
|
||||
run: npm install
|
||||
- name: Strict typecheck
|
||||
run: npx tsc --noEmit
|
||||
- name: THE cross-tenant tripwire (A -> B must be 403)
|
||||
run: npx vitest run tripwire/cross-tenant
|
||||
- name: Full relay-auth suite
|
||||
run: npx vitest run
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,6 +4,8 @@ node_modules/
|
||||
# build output
|
||||
dist/
|
||||
public/build/
|
||||
desktop/build/
|
||||
desktop/dist-app/
|
||||
|
||||
# local Claude Code settings (not shared)
|
||||
.claude/settings.local.json
|
||||
@@ -16,3 +18,6 @@ npm-debug.log*
|
||||
# test coverage
|
||||
coverage/
|
||||
.gstack/
|
||||
|
||||
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
|
||||
deploy/.env
|
||||
|
||||
242
README.md
242
README.md
@@ -1,103 +1,205 @@
|
||||
# Web Terminal
|
||||
|
||||
A browser-based terminal that exposes the **host machine's local shell** over WebSocket. Open `http://<host-ip>:3000` from any device on your LAN (phone, tablet, another computer) and you get a live, interactive terminal — primarily for **vibe coding**: hand Claude Code a task, walk away, reconnect from anywhere to check on it.
|
||||
A self-hosted, browser-based terminal **and** Claude-Code session/project workbench. It exposes the **host machine's local shell** over WebSocket: open `http://<host-ip>:3000` from any device on your LAN (phone, tablet, another laptop) and you get a live, interactive shell. The core use case is **vibe coding** — hand Claude Code a task, walk away, then reconnect from anywhere to check on it, approve a tool call from your phone's lock screen, or kick off the next one.
|
||||
|
||||
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the output replays.
|
||||
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, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security](#security).
|
||||
> ⚠️ **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).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-tab** — each tab is an independent shell session. Tabs auto-name to the current folder (double-click to rename), show a connection dot (🟢/🟡/🔴) and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory.
|
||||
- **Claude Code cockpit** (with hooks, see below) — each tab shows Claude's status (⚙ working / ⏳ needs approval / ✓ idle), sends a browser notification when it needs you, and lets you **tap Approve / Reject** on a tool request with no typing.
|
||||
- **tmux keepalive** (optional) — run the shell inside tmux so sessions survive a server/host restart, not just a disconnect.
|
||||
- **Mobile + desktop shortcut bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / Tab / `/` (the Claude Code keys a phone keyboard can't produce).
|
||||
- **Session keepalive + replay** — the PTY keeps running across disconnects; reconnect replays a ~2 MB scrollback ring buffer.
|
||||
- **Toolbar** — 🔍 scrollback search · ⚙ themes & font size · ▦ all-sessions dashboard · 📱 QR connect (scan to open on another device). Clickable links. Installable as a PWA.
|
||||
### Core terminal
|
||||
- **WebSocket terminal** — xterm.js in the browser, node-pty on the host. Keystrokes shuttle to the shell; output streams back. No terminal parsing on the server.
|
||||
- **Sessions survive disconnects** — the PTY lifecycle is decoupled from the WebSocket. Closing a tab *detaches* a client; the shell keeps running. Reconnect replays a ~2 MB scrollback ring buffer.
|
||||
- **Auto-reconnect** — exponential backoff (1s/2s/4s… capped at 30s), carrying the `localStorage` session id, so a flaky network or a phone waking from sleep just resumes.
|
||||
- **Mobile touch key-bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / ^R / ^L / ^D / Tab / `/` — the high-frequency Claude Code keys a soft keyboard can't produce, sent as raw bytes (no soft-keyboard pop).
|
||||
- **Multi-device session mirroring** — many devices can attach to the *same* session at once. Output, exit and status broadcast to all; any device can type (shared control). PTY sizing is **latest-writer-wins**: whichever device you're actively using drives the size and stays full-screen.
|
||||
|
||||
## Requirements
|
||||
### Tabs & home
|
||||
- **Multi-tab** — each tab is an independent shell. Tabs auto-name to the current folder (double-click to rename), show a connection dot and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory.
|
||||
- **Home Sessions chooser** — opening the app lands on a chooser, not a blank shell. It shows the host's running sessions as **live preview thumbnails** (read-only renders of each screen) so you can see what each one is doing, plus a dashed **+ New session** tile. Pick one to open (full scrollback replay) or start fresh.
|
||||
- **Per-session Open / Kill** — manage sessions right from the chooser; no separate manage page.
|
||||
- **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.
|
||||
|
||||
- Node.js ≥ 18 (developed on v24)
|
||||
- macOS/Linux. On macOS, `node-pty` compiles a native addon — install **Xcode Command Line Tools** (`xcode-select --install`) if `npm install` fails.
|
||||
### 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`.
|
||||
- **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).
|
||||
- **Plan-mode / permission-mode relay** — start a session in a chosen `--permission-mode` (default / acceptEdits / plan / auto). When Claude exits plan mode, the approval bar becomes a **three-way** gate (approve+auto / approve+review / keep planning). The high-risk `auto` mode is gated behind `ALLOW_AUTO_MODE`.
|
||||
- **tmux keepalive** — run the shell inside tmux so sessions survive a **server/host restart**, not just a disconnect. Auto-detected, or forced with `USE_TMUX`.
|
||||
- **Session history / resume** — browse past Claude Code sessions (from `~/.claude/projects`) and resume one.
|
||||
|
||||
## Install
|
||||
### 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.
|
||||
- **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 a **create-worktree** action.
|
||||
|
||||
### 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.
|
||||
- **Voice dictation** — push-to-talk mic on the input bar (Web Speech API); release to send the transcript as input (optional auto-Enter). Hidden where unsupported; audio never touches the server.
|
||||
- **Quick-reply chips + saved-prompt palette** — tap-to-send chips (`yes`, `continue`, `1/2/3`, `Esc`) plus a persistent palette of your own named snippets.
|
||||
- **Activity timeline** — a human-readable, timestamped stream of what happened while you were gone ("ran Bash · edited 3 files · waiting for approval · done"), built from discrete hook events in a bounded per-session ring.
|
||||
- **Stuck / idle alert** — if a session goes silent past a threshold (no output, not idle, not exited) it fires a one-shot "possibly stuck" alert through the same notification channel. Runs on the existing reaper tick (no extra timer).
|
||||
- **statusLine telemetry → per-tab gauges** — a statusLine script feeds back context-usage, cumulative cost, model, lines +/−, PR state, and rate-limit (5h/7d) telemetry, rendered as **per-tab gauges** on tabs, thumbnails and project cards. Stale telemetry greys out.
|
||||
|
||||
### UX
|
||||
- Themed UI (Amber dark theme + light / solarized, adjustable font size), scrollback **search**, an all-sessions **dashboard**, **share-session QR/link** (`?join=<id>`), **connect-device QR**, a keyboard-shortcut **cheat-sheet**, an installable **PWA**, clickable links, and a line-icon toolbar with hover tooltips.
|
||||
|
||||
---
|
||||
|
||||
## Clients
|
||||
|
||||
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
|
||||
stays a byte-shuttle).
|
||||
|
||||
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native
|
||||
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop:
|
||||
native terminal with scrollback replay, QR/manual pairing (Keychain), the
|
||||
remote **Approve / Reject** gate + three-way plan gate, **APNs push with
|
||||
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session
|
||||
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
|
||||
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
|
||||
changes; P1 adds two declared additive touch-points. See
|
||||
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
|
||||
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
|
||||
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
||||
tray, deep links and launch-at-login. Design in
|
||||
[`docs/DESKTOP_PLAN.md`](docs/DESKTOP_PLAN.md). *(Built; packaged & launch-
|
||||
verified on Apple Silicon.)*
|
||||
- **Rendezvous relay** (`term-relay/` · `agent/` · `relay-e2e/` · `relay-web/` ·
|
||||
`relay-auth/` · `relay-contracts/`) — an "ngrok-for-Claude-Code" with
|
||||
**end-to-end encryption** to reach a self-hosted host from anywhere, without a
|
||||
VPN or port-forwarding. Security-audited but **pre-production / not deployed**;
|
||||
see [`docs/PLAN_RELAY_INDEX.md`](docs/PLAN_RELAY_INDEX.md) and
|
||||
[`docs/DEPLOY_RELAY.md`](docs/DEPLOY_RELAY.md). For remote access **today**, use
|
||||
**Tailscale** (§ Security & deployment below).
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### Prerequisites
|
||||
- **Node.js ≥ 18** (developed on v24).
|
||||
- **macOS / Linux.** On macOS, `node-pty` compiles a native addon — install **Xcode Command Line Tools** (`xcode-select --install`) if `npm install` fails. After a major Node version bump, run `npm rebuild`.
|
||||
|
||||
### Install & run
|
||||
```bash
|
||||
npm install # installs deps; postinstall makes node-pty's spawn-helper executable
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm run build:web # bundle the frontend (public/main.ts → public/build/main.js)
|
||||
npm install # installs deps (incl. node-pty native build); postinstall fixes the spawn-helper bit
|
||||
npm run build:web # bundle the frontend (public/main.ts → public/build/) with esbuild
|
||||
npm start # serve on 0.0.0.0:3000
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
Then find your LAN IP and open it from any device on the same network:
|
||||
```bash
|
||||
# find your LAN IP (macOS):
|
||||
ipconfig getifaddr en0
|
||||
# open http://<that-ip>:3000 on any device on the same network
|
||||
ipconfig getifaddr en0 # macOS
|
||||
# open http://<that-ip>:3000
|
||||
```
|
||||
|
||||
For frontend development, run `npm run dev:web` (esbuild --watch) alongside `npm start`.
|
||||
|
||||
## Claude Code cockpit (optional)
|
||||
|
||||
To see Claude's status per tab and approve/reject tool calls from your phone, install the hooks once:
|
||||
For frontend development, run `npm run dev:web` (esbuild `--watch`) alongside `npm start`.
|
||||
|
||||
### Enable the Claude Code cockpit (optional but recommended)
|
||||
```bash
|
||||
npm run setup-hooks # adds http hooks to ~/.claude/settings.json (backs it up)
|
||||
npm run setup-hooks # adds the hooks + statusLine to ~/.claude/settings.json (backs it up)
|
||||
# npm run setup-hooks -- --remove # to uninstall
|
||||
```
|
||||
This wires Claude Code's hooks → **live per-tab status**, the **statusLine gauges**, and **push** notifications. The hooks are a no-op outside web-terminal (they only fire when `$WEBTERM_*` env vars are set in spawned shells), so they're safe to leave installed. Then run `claude` inside a tab.
|
||||
|
||||
Then run `claude` inside a tab. The hooks POST to the server (loopback only) when Claude starts/stops/needs permission; the tab badge updates live, and a `PermissionRequest` shows an **Approve / Reject** bar. The hooks are a no-op outside web-terminal (they curl `$WEBTERM_HOOK_URL`, which is only set in spawned shells), so they're safe to leave installed.
|
||||
> **Login shells (why hooks can always find `node`)** — sessions spawn the shell as a **login shell** (`zsh -l`, POSIX only), so it loads your full profile (`~/.zprofile`, `~/.zshrc`, …) and rebuilds `PATH`. Without this, a GUI-launched app (desktop build) or a long-lived tmux keepalive can hand the shell a minimal `PATH`, and hooks that call an nvm-/brew-managed `node` fail with `node: command not found`. If you still hit that on an old session, start a fresh one so it picks up the login-shell `PATH`.
|
||||
|
||||
**tmux keepalive** — run with `USE_TMUX=1` (or just have `tmux` on PATH; it auto-detects) to keep sessions alive across a server restart:
|
||||
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
USE_TMUX=1 npm start
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All via environment variables (no hardcoding):
|
||||
|
||||
| Var | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `PORT` | `3000` | listen port |
|
||||
| `BIND_HOST` | `0.0.0.0` | listen address |
|
||||
| `SHELL_PATH` | `$SHELL` or `/bin/zsh` | shell to spawn |
|
||||
| `IDLE_TTL` | `86400` (s) | reclaim a detached session after this idle time |
|
||||
| `SCROLLBACK_BYTES` | `2097152` | per-session replay ring buffer (bytes) |
|
||||
| `MAX_PAYLOAD_BYTES` | `1048576` | max single WS frame |
|
||||
| `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 |
|
||||
|
||||
`allowedOrigins` is **derived from the host's network-interface IPs** (plus `localhost` and anything in `ALLOWED_ORIGINS`) — never from `BIND_HOST`, since `0.0.0.0` is never a real browser Origin.
|
||||
|
||||
## Security
|
||||
|
||||
This is a no-auth, LAN-only tool by design. The defenses that matter:
|
||||
|
||||
- **Origin check (cannot be skipped):** the WS 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`.
|
||||
- **Path-scoped upgrades:** only `/term` is accepted for WS upgrade.
|
||||
- **Frame size cap:** oversized frames are rejected (`MAX_PAYLOAD_BYTES`).
|
||||
- **Never expose to the public internet.** `ws://` is **unencrypted** — on an untrusted network (café/office Wi-Fi) traffic (including keystrokes: passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also lets you use `wss://` (the frontend auto-selects `wss` on HTTPS).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm test # vitest, all modules
|
||||
npm test # vitest, all modules (~1470 tests, 80% coverage gate)
|
||||
npm run typecheck # tsc (backend + frontend)
|
||||
npm run build # compile backend to dist/
|
||||
```
|
||||
|
||||
Real-PTY end-to-end tests (`test/integration/`) auto-skip where `posix_spawn` is unavailable (e.g. sandboxes) and run everywhere else.
|
||||
---
|
||||
|
||||
## How it works
|
||||
## Configuration
|
||||
|
||||
The server is a **byte-shuttle, not a terminal**: `node-pty` provides a pseudo-terminal so the shell believes it has a real TTY, and `xterm.js` in the browser interprets the ANSI bytes and renders. The server never parses terminal semantics. PTY lifecycle is decoupled from the WebSocket — a disconnect *detaches* (the PTY keeps running) rather than killing it, which is what makes session survival work.
|
||||
All config is via environment variables (no hardcoding). Invalid values fail fast at startup.
|
||||
|
||||
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (why) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (how).
|
||||
### Core
|
||||
| Var | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `PORT` | `3000` | Listen port. |
|
||||
| `BIND_HOST` | `0.0.0.0` | Listen address. |
|
||||
| `SHELL_PATH` | `$SHELL` or `/bin/zsh` | Shell to spawn. |
|
||||
| `IDLE_TTL` | `86400` (s) | Reclaim a detached session after this idle time (no new output since detach). |
|
||||
| `SCROLLBACK_BYTES` | `2097152` (2 MB) | Per-session replay ring buffer size. |
|
||||
| `MAX_PAYLOAD_BYTES` | `1048576` (1 MB) | Max single WS frame; oversized frames are rejected. |
|
||||
| `WS_PATH` | `/term` | The only path accepted for WS upgrade. |
|
||||
| `MAX_SESSIONS` | `50` | Cap on concurrent sessions (DoS guard). |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
|
||||
|
||||
### Projects (v0.6)
|
||||
| Var | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `PROJECT_ROOTS` | `$HOME` | Comma-separated absolute roots to scan for git repos (`~`/`~/...` expanded; relative paths rejected). |
|
||||
| `PROJECT_SCAN_DEPTH` | `4` | How deep to descend looking for `.git`. |
|
||||
| `PROJECT_SCAN_TTL` | `10000` (ms) | Cache TTL for repo discovery. |
|
||||
| `PROJECT_DIRTY_CHECK` | `1` (on) | Run `git status --porcelain` to flag dirty repos. |
|
||||
| `EDITOR_CMD` | `code` | Command the host runs for the project "VS Code" button. |
|
||||
|
||||
### Walk-away workbench (v0.7)
|
||||
| Var | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `VAPID_PUBLIC_KEY` | (unset → push off) | Web Push public key, exposed to the service worker. |
|
||||
| `VAPID_PRIVATE_KEY` | (unset, **secret**) | Web Push private key; required to enable push. Never logged. |
|
||||
| `VAPID_SUBJECT` | `mailto:admin@localhost` | VAPID `sub`. |
|
||||
| `PUSH_STORE_PATH` | `~/.web-terminal-push-subs.json` | Persisted push-subscription store. |
|
||||
| `PUSH_MAX_SUBS` | `50` | Max stored push subscriptions (DoS guard). |
|
||||
| `NOTIFY_DONE` | `1` (on) | Send the low-priority DONE push on Stop/SessionEnd. |
|
||||
| `NOTIFY_DND` | `0` (off) | Global do-not-disturb default. |
|
||||
| `DECISION_TOKEN_TTL_MS` | = `PERM_TIMEOUT_MS` | Lifetime of a lock-screen decision capability token. |
|
||||
| `TIMELINE_MAX` | `200` | Per-session activity-timeline event ring cap. |
|
||||
| `TIMELINE_ENABLED` | `1` (on) | Capture/serve the activity timeline. |
|
||||
| `STUCK_TTL` | `600` (s) | Silence window before a "possibly stuck" alert; `0` disables. |
|
||||
| `STUCK_ALERT` | `1` (on) | Master switch for stuck alerts. |
|
||||
| `DIFF_TIMEOUT_MS` | `2000` | Timeout for a single `git diff`. |
|
||||
| `DIFF_MAX_BYTES` | `2097152` (2 MB) | Patch truncation cap. |
|
||||
| `DIFF_MAX_FILES` | `300` | Max files returned before the diff is marked `truncated`. |
|
||||
| `STATUSLINE_TTL_MS` | `30000` | After this with no update, per-tab telemetry greys out as stale. |
|
||||
| `WORKTREE_ENABLED` | `1` (on) | Master switch for the create-worktree feature (the only write-to-disk action). |
|
||||
| `WORKTREE_ROOT` | (unset → `<repo>-worktrees`) | Root that new worktrees must land inside. |
|
||||
| `WORKTREE_TIMEOUT_MS` | `10000` | Timeout for `git worktree add`. |
|
||||
| `DEFAULT_PERMISSION_MODE` | `default` | `--permission-mode` used when a session is started without an explicit choice (`default`/`acceptEdits`/`plan`/`auto`). |
|
||||
| `ALLOW_AUTO_MODE` | `0` (off) | Whether the high-risk `auto` (bypass-permissions) mode is offered/honored. |
|
||||
|
||||
### Spawn-injected env (set by the server / read by hook scripts — not user config)
|
||||
`WEBTERM_SESSION`, `WEBTERM_HOOK_URL`, `WEBTERM_STATUSLINE_URL` are injected into each spawned shell so the hooks/statusLine know which session they belong to and where to POST. The optional ntfy/Pushover bridge reads `WEBTERM_NTFY_URL` / `WEBTERM_NTFY_TOPIC` / `WEBTERM_NTFY_TOKEN` and `WEBTERM_PUSHOVER_TOKEN` / `WEBTERM_PUSHOVER_USER` — secrets stay in env, never written into `settings.json` or command argv.
|
||||
|
||||
---
|
||||
|
||||
## Security & deployment
|
||||
|
||||
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.
|
||||
- **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.
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a real pseudo-terminal, the server shuttles raw bytes over the WebSocket, and `xterm.js` in the browser interprets ANSI and renders. The server never parses terminal semantics — every "smart" feature (status, timeline, telemetry, diff, push) rides an **out-of-band side-channel** (loopback HTTP/JSON from Claude Code hooks, or `git` subprocesses), keeping the terminal stream a pure pipe.
|
||||
|
||||
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** (~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).
|
||||
|
||||
1609
agent/package-lock.json
generated
Normal file
1609
agent/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
agent/package.json
Normal file
34
agent/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "web-terminal-agent",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "P2 — Host Agent for the rendezvous-relay service. Ed25519 per-host identity, single-use pairing redemption (§4.5), outbound mTLS wss tunnel holding the §4.1 mux (codec via relay-contracts), loopback splice to the UNCHANGED web-terminal, heartbeat/backoff, cert auto-rotation, fast revocation, and the agent-side E2E endpoint (§4.4). Ciphertext-shuttle on the customer's own machine. See docs/PLAN_RELAY_AGENT.md.",
|
||||
"bin": {
|
||||
"web-terminal-agent": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node\nimport{createRequire as __cjs}from\"node:module\";const require=__cjs(import.meta.url);'",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"relay-contracts": "file:../relay-contracts",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
136
agent/src/certs/rotation.ts
Normal file
136
agent/src/certs/rotation.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Short-lived cert rotation — PLAN_RELAY_AGENT T13 (INV14). Renews the mTLS cert BEFORE expiry via
|
||||
* P3's renewal endpoint using a fresh CSR over the SAME Ed25519 key (pubkey unchanged, only the
|
||||
* cert rotates). Installs the new cert atomically (keystore writeFile is whole-file). A 403 from
|
||||
* the renewal endpoint ⇒ the host was revoked ⇒ onRevoked (⇒ T14 teardown, INV12).
|
||||
*
|
||||
* NOTE (cross-plan / open Q#5): the renewal URL + its auth (mTLS with the current cert vs a short
|
||||
* renewal token) is P3-owned. This derives `${enrollUrl}` → `.../renew` and injects fetch; when P3
|
||||
* freezes the route, adjust `renewalUrlFor`. Single integration point.
|
||||
*/
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import type { TimerLike } from '../transport/seams.js'
|
||||
import { buildCsr } from '../enroll/csr.js'
|
||||
|
||||
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
|
||||
|
||||
export interface CertRotator {
|
||||
start(): void
|
||||
stop(): void
|
||||
onRotated(cb: () => void): void
|
||||
onRevoked(cb: () => void): void
|
||||
}
|
||||
|
||||
export type RenewOutcome = 'rotated' | 'revoked'
|
||||
|
||||
/** Derive P3's renewal route from the enroll URL (integration point, open Q#5). */
|
||||
export function renewalUrlFor(cfg: AgentConfig): string {
|
||||
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
|
||||
}
|
||||
|
||||
/** Ms until (validTo − renewBeforeMs), clamped to ≥ 0. */
|
||||
export function computeRenewDelayMs(
|
||||
certPem: string,
|
||||
renewBeforeMs: number,
|
||||
now: Date,
|
||||
parse: (pem: string) => Date = (p) => new Date(new X509Certificate(p).validTo),
|
||||
): number {
|
||||
const validTo = parse(certPem).getTime()
|
||||
return Math.max(0, validTo - renewBeforeMs - now.getTime())
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform one renewal round-trip. Returns 'rotated' (new cert stored) or 'revoked' (403). Any
|
||||
* other HTTP/network failure throws (caller retries with backoff; the tunnel stays up until the
|
||||
* cert actually expires).
|
||||
*/
|
||||
export async function renewCert(
|
||||
cfg: AgentConfig,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<RenewOutcome> {
|
||||
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
||||
const res = await fetchImpl(renewalUrlFor(cfg), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ csr }),
|
||||
})
|
||||
if (res.status === 403) return 'revoked'
|
||||
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
||||
const json = (await res.json()) as { cert?: string; caChain?: string }
|
||||
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') {
|
||||
throw new Error('cert renewal response missing cert/caChain')
|
||||
}
|
||||
ks.saveCert(json.cert, json.caChain) // atomic whole-file install
|
||||
return 'rotated'
|
||||
}
|
||||
|
||||
export function createCertRotator(
|
||||
cfg: AgentConfig,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
opts: {
|
||||
renewBeforeMs?: number
|
||||
timer?: TimerLike
|
||||
fetchImpl?: typeof fetch
|
||||
now?: () => Date
|
||||
parseCert?: (pem: string) => Date
|
||||
} = {},
|
||||
): CertRotator {
|
||||
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
||||
const parseCert = opts.parseCert ?? ((p) => new Date(new X509Certificate(p).validTo))
|
||||
const timer = opts.timer ?? {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const now = opts.now ?? (() => new Date())
|
||||
let handle: unknown = null
|
||||
let rotatedCb: (() => void) | null = null
|
||||
let revokedCb: (() => void) | null = null
|
||||
|
||||
function schedule(): void {
|
||||
const certs = ks.loadCert()
|
||||
if (certs === null) return
|
||||
const delay = computeRenewDelayMs(certs.certPem, renewBeforeMs, now(), parseCert)
|
||||
handle = timer.setTimeout(runRenewal, delay)
|
||||
}
|
||||
|
||||
function runRenewal(): void {
|
||||
void renewCert(cfg, id, ks, doFetch)
|
||||
.then((outcome) => {
|
||||
if (outcome === 'revoked') {
|
||||
revokedCb?.()
|
||||
return
|
||||
}
|
||||
rotatedCb?.()
|
||||
schedule()
|
||||
})
|
||||
.catch(() => {
|
||||
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile.
|
||||
handle = timer.setTimeout(runRenewal, renewBeforeMs)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
start(): void {
|
||||
schedule()
|
||||
},
|
||||
stop(): void {
|
||||
if (handle !== null) timer.clearTimeout(handle)
|
||||
handle = null
|
||||
},
|
||||
onRotated(cb): void {
|
||||
rotatedCb = cb
|
||||
},
|
||||
onRevoked(cb): void {
|
||||
revokedCb = cb
|
||||
},
|
||||
}
|
||||
}
|
||||
171
agent/src/cli.ts
Normal file
171
agent/src/cli.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* CLI entrypoint — PLAN_RELAY_AGENT T5, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
|
||||
* `pair | run | status | install | uninstall`. All side effects (network/FS/tunnel/provision) are
|
||||
* injected via `CliDeps` so `runCli` stays pure and offline-testable.
|
||||
*
|
||||
* `pair <CODE> --install` is the NATIVE zero-touch onboard: P-256 keygen (FIX H-host-2) → CSR →
|
||||
* POST /enroll → provision the pinned frpc binary → write frpc.toml → install BOTH units (base-app
|
||||
* + agent, base-app env routed to the base-app unit; the agent unit supervises frpc — NOT the old
|
||||
* relay `runTunnel` rendezvous) → print `https://<sub>.terminal.<domain>`.
|
||||
*
|
||||
* `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
|
||||
*/
|
||||
import type { AgentConfig } from './config/agentConfig.js'
|
||||
import type { AgentIdentity } from './keys/identity.js'
|
||||
import type { Keystore } from './keys/keystore.js'
|
||||
import { assertNativeZone, type InstallOptions } from './service/install.js'
|
||||
import { subdomainOrigin } from './service/originConfig.js'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
|
||||
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||||
const COMMANDS: readonly CliCommand[] = ['pair', 'run', 'status', 'install', 'uninstall']
|
||||
|
||||
export interface CliArgs {
|
||||
readonly command: CliCommand
|
||||
readonly code?: string
|
||||
readonly flags: Readonly<Record<string, string | boolean>>
|
||||
}
|
||||
|
||||
export class CliUsageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'CliUsageError'
|
||||
}
|
||||
}
|
||||
|
||||
/** The non-secret enrollment result the native onboard needs (host id + assigned subdomain). */
|
||||
export interface NativeEnrollResult {
|
||||
readonly hostId: string
|
||||
readonly subdomain: string
|
||||
}
|
||||
|
||||
export interface CliDeps {
|
||||
loadConfig(): AgentConfig
|
||||
openKeystore(stateDir: string): Keystore
|
||||
/** Ed25519 identity for the legacy relay path. */
|
||||
generateIdentity(): AgentIdentity
|
||||
/** P-256 identity for the native frp-client key (FIX H-host-2); private key never leaves the host. */
|
||||
generateP256Identity(): AgentIdentity
|
||||
/** Legacy relay redemption (Ed25519, E2E rendezvous). */
|
||||
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
|
||||
/** Native enroll: build the P-256 CSR, POST /enroll, store the returned cert; return ids only. */
|
||||
enrollNative(
|
||||
cfg: AgentConfig,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
): Promise<NativeEnrollResult>
|
||||
/** Download + verify + place the pinned frpc binary (B3); returns its path. */
|
||||
provisionFrpc(cfg: AgentConfig): Promise<string>
|
||||
/** Write the native `frpc.toml` for `subdomain` (base-app env is routed via installService). */
|
||||
writeFrpcConfig(cfg: AgentConfig, subdomain: string): void
|
||||
/** True iff a written native `frpc.toml` exists in `cfg.stateDir` (native-onboard signal). */
|
||||
nativeConfigExists(cfg: AgentConfig): boolean
|
||||
/** Legacy relay run-loop (Ed25519 WS rendezvous, T10 backoff + T9 heartbeat). */
|
||||
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||
/** Native run-loop: supervise the pinned frpc child (restart-on-exit backoff + health probe). */
|
||||
superviseFrpc(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||
/** Resolve per-host install inputs (S0 env incl. loopback BIND_HOST, tunnel origin) from env/flags. */
|
||||
resolveInstallOptions(): InstallOptions
|
||||
installService(cfg: AgentConfig, options: InstallOptions): Promise<void>
|
||||
uninstallService(): Promise<void>
|
||||
print(line: string): void
|
||||
}
|
||||
|
||||
/** Parse argv (already sliced past node/script) into a typed CliArgs. */
|
||||
export function parseArgs(argv: readonly string[]): CliArgs {
|
||||
const [command, ...rest] = argv
|
||||
if (command === undefined || !COMMANDS.includes(command as CliCommand)) {
|
||||
throw new CliUsageError(`unknown command '${command ?? ''}' (expected ${COMMANDS.join(' | ')})`)
|
||||
}
|
||||
const flags: Record<string, string | boolean> = {}
|
||||
const positionals: string[] = []
|
||||
for (const token of rest) {
|
||||
if (token.startsWith('--')) {
|
||||
const [k, v] = token.slice(2).split('=')
|
||||
flags[k!] = v ?? true
|
||||
} else {
|
||||
positionals.push(token)
|
||||
}
|
||||
}
|
||||
const args: CliArgs = { command: command as CliCommand, flags }
|
||||
if (command === 'pair') {
|
||||
const code = positionals[0]
|
||||
if (code === undefined) throw new CliUsageError('usage: web-terminal-agent pair <CODE>')
|
||||
return { ...args, code }
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Native zero-touch onboard for `pair <CODE> --install` (B5). Order is load-bearing: keygen(P-256)
|
||||
* → enroll (CSR + POST /enroll) → provision frpc (so the binary exists before the agent unit
|
||||
* starts) → write frpc.toml → install BOTH units (which start them) → print the tunnel URL.
|
||||
*/
|
||||
async function pairInstallNative(
|
||||
code: string,
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
deps: CliDeps,
|
||||
): Promise<number> {
|
||||
const options = deps.resolveInstallOptions()
|
||||
if (!options.domain) {
|
||||
throw new CliUsageError(
|
||||
'native install requires TUNNEL_DOMAIN — the origin is https://<sub>.terminal.<domain>',
|
||||
)
|
||||
}
|
||||
assertNativeZone(options.zone) // FIX L-host-zone: native ⇒ `terminal`
|
||||
|
||||
const id = deps.generateP256Identity() // keygen (P-256, FIX H-host-2)
|
||||
ks.saveIdentity(id)
|
||||
const enroll = await deps.enrollNative(cfg, code, id, ks) // CSR → POST /enroll → store cert
|
||||
await deps.provisionFrpc(cfg) // pinned frpc binary on disk before the service starts (B3)
|
||||
deps.writeFrpcConfig(cfg, enroll.subdomain) // frpc.toml
|
||||
await deps.installService(cfg, options) // base-app + agent units; base-app env routed → started
|
||||
|
||||
deps.print(subdomainOrigin(enroll.subdomain, options.domain, options.zone))
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
|
||||
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
||||
const cfg = deps.loadConfig()
|
||||
const ks = deps.openKeystore(cfg.stateDir)
|
||||
switch (args.command) {
|
||||
case 'pair': {
|
||||
if (args.flags['install']) return pairInstallNative(args.code!, cfg, ks, deps)
|
||||
// Legacy relay pair (Ed25519 rendezvous, no install).
|
||||
const id = ks.loadIdentity() ?? deps.generateIdentity()
|
||||
ks.saveIdentity(id)
|
||||
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
||||
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
||||
return 0
|
||||
}
|
||||
case 'run': {
|
||||
const id = ks.loadIdentity()
|
||||
if (id === null || ks.loadCert() === null) {
|
||||
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
|
||||
}
|
||||
// Native onboard = a P-256 frp-client identity + a written frpc.toml. Supervise frpc as a
|
||||
// child (restart-on-exit backoff + health probe) instead of the legacy Ed25519 relay tunnel.
|
||||
if (id.alg === 'p256' && deps.nativeConfigExists(cfg)) {
|
||||
return deps.superviseFrpc(cfg, ks)
|
||||
}
|
||||
return deps.runTunnel(cfg, ks)
|
||||
}
|
||||
case 'status': {
|
||||
const enrolled = ks.loadIdentity() !== null && ks.loadCert() !== null
|
||||
// INV9: print only non-secret identifiers.
|
||||
deps.print(`enrolled: ${enrolled}`)
|
||||
deps.print(`host_id: ${cfg.hostId ?? '(none)'}`)
|
||||
deps.print(`subdomain: ${cfg.subdomain ?? '(none)'}`)
|
||||
return 0
|
||||
}
|
||||
case 'install':
|
||||
await deps.installService(cfg, deps.resolveInstallOptions())
|
||||
return 0
|
||||
case 'uninstall':
|
||||
await deps.uninstallService()
|
||||
return 0
|
||||
}
|
||||
}
|
||||
232
agent/src/cli/deps.ts
Normal file
232
agent/src/cli/deps.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* CliDeps factory — PLAN_RELAY_PHASE1 C2, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
|
||||
* Wires the abstract `CliDeps` seams (consumed by `runCli`) to their real implementations: env-driven
|
||||
* config, the on-disk keystore, identity generation (Ed25519 + P-256), §4.5 pairing redemption, the
|
||||
* native enroll + frpc provisioning + frpc.toml writer, the supervised tunnel, and the two-unit OS
|
||||
* service install. All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable.
|
||||
*/
|
||||
import { execFile } from 'node:child_process'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, userInfo } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { CliDeps, NativeEnrollResult } from '../cli.js'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import { loadAgentConfig } from '../config/agentConfig.js'
|
||||
import { openKeystore } from '../keys/keystore.js'
|
||||
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { redeemPairingCode } from '../enroll/pair.js'
|
||||
import { runTunnel } from '../transport/runTunnel.js'
|
||||
import { buildNativeFrpcToml } from '../transport/frpcToml.js'
|
||||
import { superviseFrpc } from '../transport/frpSupervise.js'
|
||||
import { provisionFrpc } from '../provision/frpcBinary.js'
|
||||
import {
|
||||
probeLoopbackBaseApp,
|
||||
renderHealthStatus,
|
||||
runHealthProbe,
|
||||
startHealthMonitor,
|
||||
} from '../health/probe.js'
|
||||
import { createLogger } from '../log/logger.js'
|
||||
import { ensureAllowedOrigin } from '../service/originConfig.js'
|
||||
import {
|
||||
buildInstallOptions,
|
||||
detectPlatform,
|
||||
NATIVE_ORIGIN_ZONE,
|
||||
installService as installServiceUnit,
|
||||
uninstallService as uninstallServiceUnit,
|
||||
type InstallDeps,
|
||||
type ServicePlatform,
|
||||
} from '../service/install.js'
|
||||
|
||||
/** Keystore file names in `stateDir` (kept in lockstep with `keys/keystore.ts`). */
|
||||
const KEYSTORE_CERT = 'agent.cert.pem'
|
||||
const KEYSTORE_KEY = 'agent.key.pem'
|
||||
const KEYSTORE_CA = 'agent.ca.pem'
|
||||
const FRPC_TOML = 'frpc.toml'
|
||||
const FRPC_LOG = 'frpc.log'
|
||||
const BASE_APP_ENV_FILE = 'base-app.env'
|
||||
const DEFAULT_LOCAL_PORT = 3000
|
||||
|
||||
/** Resolve this process's own executable path (the bundled `dist/cli.js`) for the service unit. */
|
||||
function selfBinPath(): string {
|
||||
return fileURLToPath(import.meta.url)
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, args: readonly string[]): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
execFile(cmd, [...args], (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
function realInstallDeps(): InstallDeps {
|
||||
return {
|
||||
writeFile: (path, content) => {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content)
|
||||
},
|
||||
runCommand,
|
||||
getuid: () => (typeof process.getuid === 'function' ? process.getuid() : 0),
|
||||
homedir,
|
||||
username: () => userInfo().username,
|
||||
binPath: selfBinPath,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map the current OS to its service manager, or fail fast with a clear message. */
|
||||
function requirePlatform(): ServicePlatform {
|
||||
const platform = detectPlatform(process.platform)
|
||||
if (platform === null) {
|
||||
throw new Error(`service install/uninstall is unsupported on platform '${process.platform}'`)
|
||||
}
|
||||
return platform
|
||||
}
|
||||
|
||||
/** Parse a positive-integer PORT from the env (falls back to the base-app default 3000). */
|
||||
function resolveLocalPort(): number {
|
||||
const raw = process.env.PORT
|
||||
const port = raw ? Number.parseInt(raw, 10) : NaN
|
||||
return Number.isInteger(port) && port > 0 ? port : DEFAULT_LOCAL_PORT
|
||||
}
|
||||
|
||||
/** Native enroll: build the P-256 CSR + POST /enroll (via the frozen redeem flow), store the cert. */
|
||||
async function enrollNative(
|
||||
cfg: AgentConfig,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
): Promise<NativeEnrollResult> {
|
||||
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks)
|
||||
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the native `frpc.toml` into `stateDir`, pointing frpc at this host's keystore cert/key/CA and
|
||||
* the loopback base app. Also materializes the base-app ALLOWED_ORIGINS env file. The frps shared
|
||||
* token comes from `FRP_AUTH_TOKEN` (deploy secret; never logged). NOTE: the frpc binary run/e2e is
|
||||
* pending the B3 tar.gz extraction; this seam only emits the config.
|
||||
*/
|
||||
function writeFrpcConfig(cfg: AgentConfig, subdomain: string): void {
|
||||
const domain = process.env.TUNNEL_DOMAIN
|
||||
const toml = buildNativeFrpcToml({
|
||||
subdomain,
|
||||
localPort: resolveLocalPort(),
|
||||
authToken: process.env.FRP_AUTH_TOKEN ?? '',
|
||||
certFile: join(cfg.stateDir, KEYSTORE_CERT),
|
||||
keyFile: join(cfg.stateDir, KEYSTORE_KEY),
|
||||
trustedCaFile: join(cfg.stateDir, KEYSTORE_CA),
|
||||
})
|
||||
mkdirSync(cfg.stateDir, { recursive: true })
|
||||
writeFileSync(join(cfg.stateDir, FRPC_TOML), toml, { mode: 0o600 })
|
||||
if (domain) {
|
||||
ensureAllowedOrigin(join(cfg.stateDir, BASE_APP_ENV_FILE), subdomain, domain, undefined, NATIVE_ORIGIN_ZONE)
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored frp-client leaf's `notAfter`, or null if no cert/parse failure (non-secret metadata). */
|
||||
function certNotAfter(ks: Keystore): Date | null {
|
||||
const cert = ks.loadCert()
|
||||
if (cert === null) return null
|
||||
try {
|
||||
return new X509Certificate(cert.certPem).validToDate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single frpc log path in `stateDir`. Used by BOTH the supervisor's file-logging spawn (writer)
|
||||
* and `readFrpcLog` (reader) so the health probe can never scan a different file than frpc writes.
|
||||
*/
|
||||
export function frpcLogPath(stateDir: string): string {
|
||||
return join(stateDir, FRPC_LOG)
|
||||
}
|
||||
|
||||
/** Read the accumulated frpc log (empty string if not yet written) for the proxy-started scan. */
|
||||
export function readFrpcLog(stateDir: string): string {
|
||||
const path = frpcLogPath(stateDir)
|
||||
if (!existsSync(path)) return ''
|
||||
try {
|
||||
return readFileSync(path, 'utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* logs NON-SECRET status only (INV9). Resolves when the supervisor stops (SIGTERM/SIGINT).
|
||||
*/
|
||||
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
||||
const logger = createLogger('info')
|
||||
const binPath = join(cfg.stateDir, 'bin', 'frpc')
|
||||
const tomlPath = join(cfg.stateDir, FRPC_TOML)
|
||||
// Tee the frpc child's stdout/stderr into `<stateDir>/frpc.log` (the SAME path `readFrpcLog`
|
||||
// scans below) so the proxy-started health sub-check has real content — without this wiring the
|
||||
// log stays empty and `HealthReport.healthy` can never be true (B4/H4 goal).
|
||||
const handle = superviseFrpc(binPath, tomlPath, { logger, logFile: frpcLogPath(cfg.stateDir) })
|
||||
const port = resolveLocalPort()
|
||||
const monitor = startHealthMonitor(
|
||||
() =>
|
||||
runHealthProbe({
|
||||
isFrpcAlive: () => handle.isChildAlive(),
|
||||
probeBaseApp: () => probeLoopbackBaseApp(port, (url) => fetch(url)),
|
||||
readFrpcLog: () => readFrpcLog(cfg.stateDir),
|
||||
certNotAfter: () => certNotAfter(ks),
|
||||
now: () => new Date(),
|
||||
}),
|
||||
(report) => {
|
||||
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
|
||||
const ids = { subdomain: cfg.subdomain, hostId: cfg.hostId, certNotAfter: certNotAfter(ks) }
|
||||
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
|
||||
},
|
||||
)
|
||||
const onSignal = (): void => {
|
||||
void handle.stop()
|
||||
}
|
||||
process.once('SIGTERM', onSignal)
|
||||
process.once('SIGINT', onSignal)
|
||||
return handle.done.finally(() => monitor.stop())
|
||||
}
|
||||
|
||||
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
||||
export function createCliDeps(): CliDeps {
|
||||
return {
|
||||
loadConfig: () => loadAgentConfig(process.env),
|
||||
openKeystore: (stateDir) => openKeystore(stateDir),
|
||||
generateIdentity: () => generateIdentity(),
|
||||
generateP256Identity: () => generateP256Identity(),
|
||||
redeem: (cfg: AgentConfig, code, id, ks) => redeemPairingCode(cfg.enrollUrl, code, id, ks),
|
||||
enrollNative: (cfg, code, id, ks) => enrollNative(cfg, code, id, ks),
|
||||
provisionFrpc: async (cfg) => {
|
||||
const result = await provisionFrpc({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
binDir: join(cfg.stateDir, 'bin'),
|
||||
})
|
||||
return result.binPath
|
||||
},
|
||||
writeFrpcConfig: (cfg, subdomain) => writeFrpcConfig(cfg, subdomain),
|
||||
nativeConfigExists: (cfg) => existsSync(join(cfg.stateDir, FRPC_TOML)),
|
||||
superviseFrpc: (cfg, ks) => superviseNative(cfg, ks),
|
||||
runTunnel: async (cfg, ks) => {
|
||||
const handle = await runTunnel(cfg, ks)
|
||||
const onSignal = (): void => {
|
||||
void handle.stop()
|
||||
}
|
||||
process.once('SIGTERM', onSignal)
|
||||
process.once('SIGINT', onSignal)
|
||||
return handle.done
|
||||
},
|
||||
resolveInstallOptions: () => buildInstallOptions(process.env),
|
||||
installService: (cfg, options) =>
|
||||
installServiceUnit(cfg, requirePlatform(), realInstallDeps(), options),
|
||||
uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }),
|
||||
print: (line) => {
|
||||
process.stdout.write(`${line}\n`)
|
||||
},
|
||||
}
|
||||
}
|
||||
90
agent/src/config/agentConfig.ts
Normal file
90
agent/src/config/agentConfig.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Agent configuration — PLAN_RELAY_AGENT T2. Zod-validated at the startup boundary (INV9):
|
||||
* - relayUrl MUST be wss:// (encrypted tunnel only)
|
||||
* - enrollUrl MUST be https:// (enrollment over TLS only)
|
||||
* - localTargetUrl MUST be ws:// to a LOOPBACK host (anti-SSRF: the agent forwards ONLY to
|
||||
* the local web-terminal, never an arbitrary target).
|
||||
* Fail-fast on missing/invalid values — never silently default a security-relevant field.
|
||||
*/
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
||||
|
||||
export interface AgentConfig {
|
||||
readonly relayUrl: string
|
||||
readonly enrollUrl: string
|
||||
readonly stateDir: string
|
||||
readonly localTargetUrl: string
|
||||
readonly subdomain: string | null
|
||||
readonly hostId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or
|
||||
* ::1). Uses the shared strict check so a crafted suffixed hostname such as
|
||||
* `ws://127.0.0.1.attacker.example.com:3000` — which the outbound dial would DNS-resolve and connect
|
||||
* to wherever it points — is REJECTED, closing the anti-SSRF bypass (not merely `startsWith('127.')`).
|
||||
*/
|
||||
export function isLoopbackWsUrl(url: string): boolean {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (parsed.protocol !== 'ws:') return false
|
||||
return isLoopbackHostLiteral(parsed.hostname)
|
||||
}
|
||||
|
||||
function hasScheme(url: string, scheme: string): boolean {
|
||||
try {
|
||||
return new URL(url).protocol === scheme
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const AgentConfigSchema = z
|
||||
.object({
|
||||
relayUrl: z
|
||||
.string()
|
||||
.refine((u) => hasScheme(u, 'wss:'), 'relayUrl must be a wss:// URL'),
|
||||
enrollUrl: z
|
||||
.string()
|
||||
.refine((u) => hasScheme(u, 'https:'), 'enrollUrl must be an https:// URL'),
|
||||
stateDir: z.string().min(1),
|
||||
localTargetUrl: z
|
||||
.string()
|
||||
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
||||
subdomain: z.string().min(1).nullable(),
|
||||
hostId: z.string().min(1).nullable(),
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
|
||||
const DEFAULT_LOCAL_TARGET = 'ws://127.0.0.1:3000'
|
||||
|
||||
/** Default state dir where key/cert/content-secret live (~/.web-terminal-agent). */
|
||||
export function defaultStateDir(): string {
|
||||
return join(homedir(), '.web-terminal-agent')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + validate an AgentConfig from env + explicit argv overrides (argv wins). Throws a
|
||||
* ZodError (fail-fast) if any required field is missing or fails a scheme/loopback refinement.
|
||||
*/
|
||||
export function loadAgentConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
argv: Partial<AgentConfig> = {},
|
||||
): AgentConfig {
|
||||
const merged = {
|
||||
relayUrl: argv.relayUrl ?? env.RELAY_URL,
|
||||
enrollUrl: argv.enrollUrl ?? env.ENROLL_URL,
|
||||
stateDir: argv.stateDir ?? env.STATE_DIR ?? defaultStateDir(),
|
||||
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
||||
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
||||
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
||||
}
|
||||
return AgentConfigSchema.parse(merged)
|
||||
}
|
||||
144
agent/src/e2e/hostEndpoint.ts
Normal file
144
agent/src/e2e/hostEndpoint.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Host E2E endpoint (§4.4) — PLAN_RELAY_AGENT T15. The HOST side of the authenticated ECDH: absorb
|
||||
* ClientHello → verify the device-auth-proof FIRST → reply HostHello → derive DirectionalKeys →
|
||||
* per-stream seal(h2c)/open(c2h). Distinct from live frames, every replay-bound output is ALSO
|
||||
* sealed under K_content via the T19 ReplaySealer (FIX 3).
|
||||
*
|
||||
* ANTI-MITM (INV2): the device-auth-proof `verifyDeviceProof` is P5-OWNED and reaches this module
|
||||
* INJECTED (FIX 6b) — this file MUST NOT `import { verifyDeviceAuthProof } from 'relay-e2e'`. The
|
||||
* proof is verified BEFORE any key derivation; a forged proof ⇒ MitmAbortError, NO HostHello, NO
|
||||
* DirectionalKeys. A no-stub guard test asserts this file imports no verifier and that swapping the
|
||||
* injected verifier for `async () => true` makes the MITM test fail.
|
||||
*
|
||||
* BLOCKING GATE (open Q#2 — DEFERRED): the real §4.4 crypto (`sealFrame`/`openFrame`/
|
||||
* `createE2ESession`) + host-handshake wiring (`createHostHandshake`) live in P4 `relay-e2e/`, and
|
||||
* the P5 verifier + forged-proof vector are a hard pre-W4 gate. P4 is NOT built yet, so those are
|
||||
* INJECTED here as seams typed to the frozen relay-contracts signatures — production passes the
|
||||
* relay-e2e impls verbatim, NEVER a stub. INV11: even after open(), bytes go to loopback OPAQUE.
|
||||
*/
|
||||
import type {
|
||||
ClientHello,
|
||||
E2ESession,
|
||||
HandshakeResult,
|
||||
HostHello,
|
||||
} from 'relay-contracts'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { FrameTransform } from '../transport/streamRouter.js'
|
||||
import type { ReplaySealer } from './replaySeal.js'
|
||||
|
||||
/** Host-side device-proof verifier — INJECTED (P5 issues+verifies), bound to the ClientHello. */
|
||||
export type VerifyDeviceProof = (
|
||||
proof: string,
|
||||
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
|
||||
) => Promise<boolean>
|
||||
|
||||
export class MitmAbortError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'MitmAbortError'
|
||||
}
|
||||
}
|
||||
|
||||
/** P4 host-handshake wiring seam (`createHostHandshake`), typed to §4.4 shapes. */
|
||||
export interface HostHandshake {
|
||||
respond(clientHello: ClientHello): Promise<{ hello: HostHello; result: HandshakeResult }>
|
||||
}
|
||||
export type CreateHostHandshake = (deps: {
|
||||
verifyDeviceProof: VerifyDeviceProof
|
||||
identity: AgentIdentity
|
||||
}) => HostHandshake
|
||||
|
||||
/** P4 §4.4 crypto core, injected (impls live in relay-e2e/). */
|
||||
export interface E2ECryptoDeps {
|
||||
createHostHandshake: CreateHostHandshake
|
||||
createE2ESession(role: 'host', result: HandshakeResult): E2ESession
|
||||
/** Wire codec for the HostHello reply (P4/P6-owned framing). */
|
||||
encodeHostHello(hello: HostHello): Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the HostHello + HandshakeResult for a ClientHello. The proof is verified FIRST; a forged
|
||||
* proof aborts with MitmAbortError and NO key derivation (anti-MITM). FIX 2: the result carries
|
||||
* DirectionalKeys{c2h,h2c}, never a single sessionKey.
|
||||
*/
|
||||
export async function makeHostHello(
|
||||
clientHello: ClientHello,
|
||||
id: AgentIdentity,
|
||||
verifyDeviceProof: VerifyDeviceProof,
|
||||
deps: Pick<E2ECryptoDeps, 'createHostHandshake'>,
|
||||
): Promise<{ hello: HostHello; result: HandshakeResult }> {
|
||||
const ok = await verifyDeviceProof(clientHello.deviceAuthProof, {
|
||||
clientEphPub: clientHello.clientEphPub,
|
||||
clientNonce: clientHello.clientNonce,
|
||||
})
|
||||
if (!ok) {
|
||||
throw new MitmAbortError('device-auth-proof verification failed — aborting, no keys derived')
|
||||
}
|
||||
const handshake = deps.createHostHandshake({ verifyDeviceProof, identity: id })
|
||||
return handshake.respond(clientHello)
|
||||
}
|
||||
|
||||
/**
|
||||
* FrameTransform + a `seedSession` seam. The wiring layer intercepts the first (ClientHello) frame,
|
||||
* runs `makeHostHello` (async, verify-first), then calls `seedSession` with the derived
|
||||
* E2ESession + the encoded HostHello (queued as a control frame the router flushes upstream).
|
||||
* After seeding: inbound = session.open(c2h) → loopback (OPAQUE, INV11); outbound = session.seal
|
||||
* (h2c) → tunnel AND replay.seal(K_content) — two DISTINCT ciphertexts (FIX 3).
|
||||
*/
|
||||
export interface E2ETransform extends FrameTransform {
|
||||
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void
|
||||
}
|
||||
|
||||
interface StreamE2EState {
|
||||
session: E2ESession | null
|
||||
readonly control: Uint8Array[]
|
||||
}
|
||||
|
||||
export function createE2ETransform(
|
||||
_id: AgentIdentity,
|
||||
_verifyDeviceProof: VerifyDeviceProof,
|
||||
replay: ReplaySealer,
|
||||
): E2ETransform {
|
||||
const streams = new Map<number, StreamE2EState>()
|
||||
|
||||
function stateFor(streamId: number): StreamE2EState {
|
||||
let s = streams.get(streamId)
|
||||
if (s === undefined) {
|
||||
s = { session: null, control: [] }
|
||||
streams.set(streamId, s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
return {
|
||||
openStream(streamId: number): void {
|
||||
stateFor(streamId)
|
||||
},
|
||||
closeStream(streamId: number): void {
|
||||
streams.delete(streamId)
|
||||
},
|
||||
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void {
|
||||
const s = stateFor(streamId)
|
||||
s.session = session
|
||||
s.control.push(hostHelloBytes)
|
||||
},
|
||||
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null {
|
||||
const s = stateFor(streamId)
|
||||
if (s.session === null) return null // handshake not yet seeded; wiring layer handles it
|
||||
return s.session.open(cipher) // opaque plaintext to loopback (INV11)
|
||||
},
|
||||
outbound(streamId: number, plain: Uint8Array): Uint8Array {
|
||||
const s = stateFor(streamId)
|
||||
if (s.session === null) {
|
||||
throw new MitmAbortError('cannot seal before the E2E session is established')
|
||||
}
|
||||
replay.seal(plain) // FIX 3: recoverable K_content seal, DISTINCT from the live h2c frame
|
||||
return s.session.seal(plain)
|
||||
},
|
||||
takeControlFrames(streamId: number): Uint8Array[] {
|
||||
const s = streams.get(streamId)
|
||||
if (s === undefined || s.control.length === 0) return []
|
||||
return s.control.splice(0, s.control.length)
|
||||
},
|
||||
}
|
||||
}
|
||||
70
agent/src/e2e/replaySeal.ts
Normal file
70
agent/src/e2e/replaySeal.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Replay-frame sealer (recoverable K_content) — PLAN_RELAY_AGENT T19 (FIX 3).
|
||||
*
|
||||
* Live host→client frames use the EPHEMERAL DirectionalKeys.h2c (forward-secret, gone after
|
||||
* reconnect). But "refresh the page and the Claude session is still there" needs the ring-buffer /
|
||||
* preview ciphertext to be RECOVERABLE — so every replay-bound output is ALSO sealed under the
|
||||
* host-scoped recoverable K_content = deriveContentKey({ hostContentSecret, sessionId, alg }),
|
||||
* DISTINCT from the live h2c frame. The browser re-derives the identical K_content (P5) and opens
|
||||
* it (P6). This is the single agent-side consumer of the FIX 3 recoverable key.
|
||||
*
|
||||
* INTEGRATION SEAM: the frozen §4.4 replay-crypto IMPLEMENTATIONS live in P4 `relay-e2e/`
|
||||
* (`deriveContentKey`, `sealReplayFrame`). P4 is not built yet, so they are INJECTED here typed to
|
||||
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
|
||||
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
|
||||
* NEVER logged, NEVER sent to the relay (INV2/INV9).
|
||||
*
|
||||
* PER-GENERATION EPOCH (F6 fix): K_content = HKDF(hostContentSecret, salt=sessionId, info=const) is
|
||||
* byte-identical for a given (host, sessionId) and RECOVERABLE by design — but the deterministic seal
|
||||
* nonce is seq, which resets to 0 on every sealer reconstruction (restart / re-attach). Two sealer
|
||||
* GENERATIONS would therefore seal DISTINCT plaintext under the SAME (key, nonce) → catastrophic AEAD
|
||||
* reuse. Each `createReplaySealer` call mints a FRESH, NON-SECRET `epoch` (randomUUID) that is folded
|
||||
* into K_content derivation, so a restart / new generation yields a FRESH key even for the same
|
||||
* (hostContentSecret, sessionId); seq=0 can never collide across generations. The epoch is exposed on
|
||||
* the sealer so the wiring can persist it with the ring buffer / replay stream and serve it to the
|
||||
* browser, which re-derives the matching key. Recoverability WITHIN one generation (same epoch ⇒ same
|
||||
* key) is preserved.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||
|
||||
/** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */
|
||||
export interface ReplayCrypto {
|
||||
deriveContentKey(params: ReplayKeyParams): AeadKey
|
||||
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope
|
||||
}
|
||||
|
||||
export interface ReplaySealer {
|
||||
/**
|
||||
* The fresh, NON-SECRET per-generation epoch folded into K_content (F6). The wiring persists it
|
||||
* with the ring buffer / replay stream so the browser re-derives the matching key.
|
||||
*/
|
||||
readonly epoch: string
|
||||
/** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */
|
||||
seal(plaintext: Uint8Array): E2EEnvelope
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-(host, session) replay sealer for ONE generation. A FRESH `epoch` is minted per call
|
||||
* and folded into K_content, which is derived ONCE from { hostContentSecret, sessionId, alg, epoch };
|
||||
* seq is strictly monotonic from 0 (INV13). A restart / re-attach constructs a NEW generation with a
|
||||
* NEW epoch ⇒ a FRESH key, so seq=0 never collides across generations (F6).
|
||||
*/
|
||||
export function createReplaySealer(
|
||||
hostContentSecret: Uint8Array,
|
||||
sessionId: string,
|
||||
alg: AeadAlg,
|
||||
crypto: ReplayCrypto,
|
||||
): ReplaySealer {
|
||||
const epoch = randomUUID()
|
||||
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg, epoch })
|
||||
let seq = 0n
|
||||
return {
|
||||
epoch,
|
||||
seal(plaintext: Uint8Array): E2EEnvelope {
|
||||
const env = crypto.sealReplayFrame(key, seq, plaintext)
|
||||
seq += 1n
|
||||
return env
|
||||
},
|
||||
}
|
||||
}
|
||||
118
agent/src/enroll/csr.ts
Normal file
118
agent/src/enroll/csr.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* PKCS#10 CSR over the Ed25519 identity — PLAN_RELAY_AGENT T4.
|
||||
*
|
||||
* Node has no built-in CSR generator, so this is a compact, self-contained DER encoder that
|
||||
* emits a standard PKCS#10 CertificationRequest signed with the in-process Ed25519 key (INV4 —
|
||||
* only the PUBLIC key + a signature leave; the private key is never serialized here).
|
||||
*
|
||||
* NOTE (cross-plan / open Q#1): the exact SPIFFE-style cert PROFILE (SAN = subdomain vs a SPIFFE
|
||||
* URI) is set by P3's CA. This builds the standard subject-CN PKCS#10 shape; when P3 freezes its
|
||||
* profile, extend `buildCsr`'s attributes here. That is the single integration point.
|
||||
*/
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
|
||||
// --- minimal DER encoding helpers -------------------------------------------------------------
|
||||
|
||||
function derLen(len: number): Uint8Array {
|
||||
if (len < 0x80) return Uint8Array.from([len])
|
||||
const bytes: number[] = []
|
||||
let n = len
|
||||
while (n > 0) {
|
||||
bytes.unshift(n & 0xff)
|
||||
n >>= 8
|
||||
}
|
||||
return Uint8Array.from([0x80 | bytes.length, ...bytes])
|
||||
}
|
||||
|
||||
function tlv(tag: number, value: Uint8Array): Uint8Array {
|
||||
const len = derLen(value.length)
|
||||
const out = new Uint8Array(1 + len.length + value.length)
|
||||
out[0] = tag
|
||||
out.set(len, 1)
|
||||
out.set(value, 1 + len.length)
|
||||
return out
|
||||
}
|
||||
|
||||
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
||||
const total = chunks.reduce((s, c) => s + c.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const c of chunks) {
|
||||
out.set(c, off)
|
||||
off += c.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const SEQUENCE = 0x30
|
||||
const SET = 0x31
|
||||
const INTEGER = 0x02
|
||||
const BIT_STRING = 0x03
|
||||
const OID = 0x06
|
||||
const UTF8_STRING = 0x0c
|
||||
const CONTEXT_0 = 0xa0
|
||||
|
||||
// OID 2.5.4.3 (commonName), 1.3.101.112 (Ed25519), 1.2.840.10045.4.3.2 (ecdsa-with-SHA256) as
|
||||
// pre-encoded DER value bytes.
|
||||
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
|
||||
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
|
||||
const OID_ECDSA_WITH_SHA256 = Uint8Array.from([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
|
||||
|
||||
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
|
||||
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
|
||||
return tlv(SEQUENCE, concat([algId, pubBits]))
|
||||
}
|
||||
|
||||
/**
|
||||
* SubjectPublicKeyInfo bytes for the CSR. For P-256, `id.publicKey` IS already the full EC SPKI DER
|
||||
* (built by `keys/identity.ts`), so it is embedded verbatim; for Ed25519 the raw 32-byte key is
|
||||
* wrapped into the fixed SPKI structure.
|
||||
*/
|
||||
function spkiFor(id: AgentIdentity): Uint8Array {
|
||||
return id.alg === 'p256' ? id.publicKey : spkiFromRawEd25519(id.publicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* The signatureAlgorithm AlgorithmIdentifier: `SEQUENCE { OID }` (no parameters — RFC 5758 §3.2 for
|
||||
* ecdsa-with-SHA256, and Ed25519 likewise omits parameters).
|
||||
*/
|
||||
function sigAlgFor(id: AgentIdentity): Uint8Array {
|
||||
const oid = id.alg === 'p256' ? OID_ECDSA_WITH_SHA256 : OID_ED25519
|
||||
return tlv(SEQUENCE, tlv(OID, oid))
|
||||
}
|
||||
|
||||
/** X.501 Name with a single CN=<subject> RDN. */
|
||||
function nameFromCn(cn: string): Uint8Array {
|
||||
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||
const rdn = tlv(SET, atv)
|
||||
return tlv(SEQUENCE, rdn)
|
||||
}
|
||||
|
||||
function toPem(der: Uint8Array, label: string): string {
|
||||
const b64 = Buffer.from(der).toString('base64')
|
||||
const lines = b64.match(/.{1,64}/g) ?? []
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the identity's key. The
|
||||
* Ed25519 and P-256 (ecdsa-with-SHA256, FIX H-host-2) paths share this one encoder — only the SPKI
|
||||
* and the signatureAlgorithm differ. The private key is used in-process only; never serialized into
|
||||
* the output (INV4).
|
||||
*/
|
||||
export function buildCsr(id: AgentIdentity, subject: string): string {
|
||||
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||
const name = nameFromCn(subject)
|
||||
const spki = spkiFor(id)
|
||||
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
||||
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
||||
|
||||
const signature = id.sign(requestInfo) // over CertificationRequestInfo, per id.alg
|
||||
const sigAlg = sigAlgFor(id)
|
||||
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||
|
||||
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||
return toPem(csr, 'CERTIFICATE REQUEST')
|
||||
}
|
||||
137
agent/src/enroll/pair.ts
Normal file
137
agent/src/enroll/pair.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* §4.5 pairing-code redemption (agent side) — PLAN_RELAY_AGENT T4.
|
||||
*
|
||||
* REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5).
|
||||
* RETURN: the FROZEN EnrollResult (imported from relay-contracts, never redeclared), validated
|
||||
* with EnrollResultSchema at the boundary (untrusted external data). On success the FIX 3
|
||||
* `hostContentSecret` (wrapped to this agent's Ed25519 identity by P3) is UNWRAPPED in-process
|
||||
* and persisted 0600 via the keystore; the WRAPPED bytes are never stored, the unwrapped secret
|
||||
* is never logged/re-sent (INV5/INV9).
|
||||
*
|
||||
* Only the PUBLIC key + CSR leave the host (INV4).
|
||||
*/
|
||||
import { EnrollResultSchema, decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { buildCsr } from './csr.js'
|
||||
|
||||
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
||||
export type EnrollMode = 'token' | 'ed25519'
|
||||
export const DEFAULT_ENROLL_MODE: EnrollMode = 'ed25519'
|
||||
|
||||
export class EnrollError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'EnrollError'
|
||||
}
|
||||
}
|
||||
export class PairingCodeSpentError extends EnrollError {
|
||||
constructor() {
|
||||
super('pairing code already redeemed (single-use)')
|
||||
this.name = 'PairingCodeSpentError'
|
||||
}
|
||||
}
|
||||
export class PairingCodeExpiredError extends EnrollError {
|
||||
constructor() {
|
||||
super('pairing code expired')
|
||||
this.name = 'PairingCodeExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the P3-wrapped `hostContentSecret` using the agent's enrollment identity (in-process).
|
||||
* P3's exact wrap scheme is not yet frozen (cross-plan integration point); this seam is injected
|
||||
* so the real unwrap drops in without touching the redeem flow. Default = passthrough.
|
||||
*/
|
||||
export type UnwrapContentSecret = (wrapped: Uint8Array, id: AgentIdentity) => Uint8Array
|
||||
const passthroughUnwrap: UnwrapContentSecret = (wrapped) => wrapped
|
||||
|
||||
export interface RedeemOptions {
|
||||
readonly fetchImpl?: typeof fetch
|
||||
readonly mode?: EnrollMode
|
||||
readonly agentToken?: string
|
||||
readonly unwrapContentSecret?: UnwrapContentSecret
|
||||
readonly subject?: string
|
||||
}
|
||||
|
||||
interface EnrollResponseJson {
|
||||
hostId: string
|
||||
subdomain: string
|
||||
cert: string
|
||||
caChain: string
|
||||
hostContentSecret: string // base64url over the wire
|
||||
}
|
||||
|
||||
function parseEnrollResult(json: unknown): EnrollResult {
|
||||
const j = json as Partial<EnrollResponseJson>
|
||||
if (typeof j.hostContentSecret !== 'string') {
|
||||
throw new EnrollError('enroll response missing hostContentSecret')
|
||||
}
|
||||
const candidate = {
|
||||
hostId: j.hostId,
|
||||
subdomain: j.subdomain,
|
||||
cert: j.cert,
|
||||
caChain: j.caChain,
|
||||
hostContentSecret: decodeBase64UrlBytes(j.hostContentSecret),
|
||||
}
|
||||
const result = EnrollResultSchema.safeParse(candidate)
|
||||
if (!result.success) {
|
||||
throw new EnrollError(`enroll response failed schema validation: ${result.error.message}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem `code` at `enrollUrl`. Sends only pubkey + CSR (INV4); never persists the raw code
|
||||
* (INV5). Stores the returned cert + CA chain and the unwrapped hostContentSecret 0600.
|
||||
*/
|
||||
export async function redeemPairingCode(
|
||||
enrollUrl: string,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
opts: RedeemOptions = {},
|
||||
): Promise<EnrollResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const mode = opts.mode ?? DEFAULT_ENROLL_MODE
|
||||
const unwrap = opts.unwrapContentSecret ?? passthroughUnwrap
|
||||
|
||||
const body =
|
||||
mode === 'token'
|
||||
? { code, agentToken: opts.agentToken ?? '' }
|
||||
: {
|
||||
code,
|
||||
agentPubkey: encodeBase64UrlBytes(id.publicKey),
|
||||
csr: buildCsr(id, opts.subject ?? 'web-terminal-agent'),
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await doFetch(enrollUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new EnrollError(`enroll request failed: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
if (res.status === 409) throw new PairingCodeSpentError()
|
||||
if (res.status === 410) throw new PairingCodeExpiredError()
|
||||
if (!res.ok) throw new EnrollError(`enroll returned HTTP ${res.status}`)
|
||||
|
||||
let json: unknown
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch (err) {
|
||||
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
const enroll = parseEnrollResult(json)
|
||||
ks.saveCert(enroll.cert, enroll.caChain)
|
||||
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
||||
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||
ks.saveContentSecret(unwrapped)
|
||||
return enroll
|
||||
}
|
||||
182
agent/src/health/probe.ts
Normal file
182
agent/src/health/probe.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Native-tunnel health probe — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2 / §5, INV9).
|
||||
*
|
||||
* Reports four independent sub-checks that together say whether this host's native frp tunnel is
|
||||
* actually serving:
|
||||
* (a) frpcAlive — the supervised frpc child process is running;
|
||||
* (b) baseAppReachable — the loopback base app answers `GET http://127.0.0.1:PORT` (loopback ONLY);
|
||||
* (c) proxyStarted — frpc logged a "start proxy success" line (control channel + proxy up);
|
||||
* (d) certFresh — the frp-client leaf's `notAfter` is beyond the renewal window (not expiring).
|
||||
*
|
||||
* Every side effect is an INJECTABLE seam (process-liveness checker, loopback HTTP probe, log
|
||||
* scanner, clock) so the logic is pure and offline-testable. The status renderer emits ONLY
|
||||
* non-secret identifiers (subdomain, host id, cert expiry date, boolean flags) — NEVER keys, certs,
|
||||
* tokens, or CSRs (INV9). No `console.log`.
|
||||
*/
|
||||
|
||||
/** Default renewal window: 8h — one third of the 24h host frp-client TTL (renew at ~2/3 TTL). */
|
||||
export const DEFAULT_CERT_RENEW_WINDOW_MS = 8 * 60 * 60 * 1000
|
||||
|
||||
/** frpc emits this line on the control channel once a proxy is registered and forwarding. */
|
||||
export const FRPC_START_SUCCESS_RE = /start proxy success/i
|
||||
|
||||
/** Loopback host the base-app probe targets — hardcoded so the probe can never reach off-host. */
|
||||
export const LOOPBACK_PROBE_HOST = '127.0.0.1'
|
||||
|
||||
const MIN_PORT = 1
|
||||
const MAX_PORT = 65535
|
||||
|
||||
/** The four independent sub-checks plus the derived overall verdict. */
|
||||
export interface HealthReport {
|
||||
/** The supervised frpc child is running. */
|
||||
readonly frpcAlive: boolean
|
||||
/** `GET http://127.0.0.1:PORT` returned a response (base app is up on loopback). */
|
||||
readonly baseAppReachable: boolean
|
||||
/** frpc logged "start proxy success" (the tunnel proxy is registered). */
|
||||
readonly proxyStarted: boolean
|
||||
/** The frp-client cert's `notAfter` is beyond the renewal window (not near expiry). */
|
||||
readonly certFresh: boolean
|
||||
/** True iff all four sub-checks pass. */
|
||||
readonly healthy: boolean
|
||||
}
|
||||
|
||||
/** Injectable side effects the probe consumes; each is independently faked in tests. */
|
||||
export interface HealthProbeSeams {
|
||||
/** Process-liveness checker (the supervisor exposes whether its frpc child is alive). */
|
||||
readonly isFrpcAlive: () => boolean
|
||||
/** Loopback HTTP probe of the base app; resolves true iff it answered ok. */
|
||||
readonly probeBaseApp: () => Promise<boolean>
|
||||
/** Returns the accumulated frpc stdout/log text to scan for the success line. */
|
||||
readonly readFrpcLog: () => string
|
||||
/** The stored frp-client leaf's `notAfter`, or null if no cert is available. */
|
||||
readonly certNotAfter: () => Date | null
|
||||
/** Current time (injected for deterministic expiry tests). */
|
||||
readonly now: () => Date
|
||||
}
|
||||
|
||||
export interface HealthProbeConfig {
|
||||
/** Certs within this many ms of `notAfter` are "near expiry" (default 8h). */
|
||||
readonly renewWindowMs?: number
|
||||
}
|
||||
|
||||
/** Pure: true iff the frpc log contains a "start proxy success" line. */
|
||||
export function frpcProxyStarted(logText: string): boolean {
|
||||
return FRPC_START_SUCCESS_RE.test(logText)
|
||||
}
|
||||
|
||||
/** Pure: true iff `notAfter` is strictly beyond the renewal window from `now` (not near expiry). */
|
||||
export function certIsFresh(notAfter: Date | null, now: Date, renewWindowMs: number): boolean {
|
||||
if (notAfter === null) return false
|
||||
return notAfter.getTime() - now.getTime() > renewWindowMs
|
||||
}
|
||||
|
||||
/** Minimal shape of a fetch response the loopback probe cares about. */
|
||||
export interface LoopbackResponse {
|
||||
readonly ok: boolean
|
||||
}
|
||||
|
||||
/** Injectable loopback HTTP fetch (real wiring passes global `fetch`). */
|
||||
export type LoopbackFetch = (url: string) => Promise<LoopbackResponse>
|
||||
|
||||
/**
|
||||
* Probe the loopback base app with `GET http://127.0.0.1:PORT/`. The host is hardcoded loopback, so
|
||||
* the probe can never reach an off-host target (anti-SSRF). Any thrown/rejected fetch — or a
|
||||
* non-integer/out-of-range port — resolves to `false` rather than propagating (a probe never throws).
|
||||
*/
|
||||
export async function probeLoopbackBaseApp(port: number, fetchImpl: LoopbackFetch): Promise<boolean> {
|
||||
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) return false
|
||||
const url = `http://${LOOPBACK_PROBE_HOST}:${port}/`
|
||||
try {
|
||||
const res = await fetchImpl(url)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Run all four sub-checks and derive the overall verdict. Never throws (each seam is guarded). */
|
||||
export async function runHealthProbe(
|
||||
seams: HealthProbeSeams,
|
||||
config: HealthProbeConfig = {},
|
||||
): Promise<HealthReport> {
|
||||
const renewWindowMs = config.renewWindowMs ?? DEFAULT_CERT_RENEW_WINDOW_MS
|
||||
const frpcAlive = seams.isFrpcAlive()
|
||||
const baseAppReachable = await seams.probeBaseApp()
|
||||
const proxyStarted = frpcProxyStarted(seams.readFrpcLog())
|
||||
const certFresh = certIsFresh(seams.certNotAfter(), seams.now(), renewWindowMs)
|
||||
const healthy = frpcAlive && baseAppReachable && proxyStarted && certFresh
|
||||
return { frpcAlive, baseAppReachable, proxyStarted, certFresh, healthy }
|
||||
}
|
||||
|
||||
/** Non-secret identifiers safe to print in `status` (INV9): NO key/cert/token/CSR material. */
|
||||
export interface StatusIdentifiers {
|
||||
readonly subdomain: string | null
|
||||
readonly hostId: string | null
|
||||
readonly certNotAfter: Date | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `status` lines from non-secret identifiers + a health report (INV9). Emits the subdomain,
|
||||
* host id, cert EXPIRY DATE (never the cert bytes), and the boolean sub-check flags — never any key,
|
||||
* cert, token, or CSR material.
|
||||
*/
|
||||
export function renderHealthStatus(
|
||||
ids: StatusIdentifiers,
|
||||
report: HealthReport,
|
||||
): readonly string[] {
|
||||
return [
|
||||
`subdomain: ${ids.subdomain ?? '(none)'}`,
|
||||
`host_id: ${ids.hostId ?? '(none)'}`,
|
||||
`cert_expiry: ${ids.certNotAfter ? ids.certNotAfter.toISOString() : '(unknown)'}`,
|
||||
`frpc_alive: ${report.frpcAlive}`,
|
||||
`base_app_reachable: ${report.baseAppReachable}`,
|
||||
`proxy_started: ${report.proxyStarted}`,
|
||||
`cert_fresh: ${report.certFresh}`,
|
||||
`healthy: ${report.healthy}`,
|
||||
]
|
||||
}
|
||||
|
||||
/** Default periodic health-monitor interval (30s). */
|
||||
export const DEFAULT_HEALTH_INTERVAL_MS = 30_000
|
||||
|
||||
/** Minimal injectable interval timer (fake-timer-testable). */
|
||||
export interface IntervalTimer {
|
||||
setInterval(cb: () => void, ms: number): unknown
|
||||
clearInterval(handle: unknown): void
|
||||
}
|
||||
|
||||
const realIntervalTimer: IntervalTimer = {
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
|
||||
/** Handle to a running health monitor. */
|
||||
export interface HealthMonitor {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a periodic health monitor: every `intervalMs`, run `probe()` and hand the report to
|
||||
* `onReport` (the run-loop wires this to a redacting logger — non-secret lines only). A rejected
|
||||
* probe is swallowed (a monitor must never crash the supervisor). `stop()` clears the interval.
|
||||
*/
|
||||
export function startHealthMonitor(
|
||||
probe: () => Promise<HealthReport>,
|
||||
onReport: (report: HealthReport) => void,
|
||||
opts: { intervalMs?: number; timer?: IntervalTimer } = {},
|
||||
): HealthMonitor {
|
||||
const intervalMs = opts.intervalMs ?? DEFAULT_HEALTH_INTERVAL_MS
|
||||
const timer = opts.timer ?? realIntervalTimer
|
||||
const handle = timer.setInterval(() => {
|
||||
void probe()
|
||||
.then(onReport)
|
||||
.catch(() => {
|
||||
/* a probe failure is itself an unhealthy signal; never let it crash the monitor */
|
||||
})
|
||||
}, intervalMs)
|
||||
return {
|
||||
stop(): void {
|
||||
timer.clearInterval(handle)
|
||||
},
|
||||
}
|
||||
}
|
||||
32
agent/src/index.ts
Normal file
32
agent/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* web-terminal-agent (P2) — public barrel. The host agent: Ed25519 identity, §4.5 pairing, the
|
||||
* §4.1 mux tunnel + loopback splice, mTLS dial + cert rotation, fast revocation, and the §4.4 E2E
|
||||
* endpoint. Imports the FROZEN shared surface from relay-contracts read-only; wires P4 relay-e2e
|
||||
* crypto via injected seams (see docs/PLAN_RELAY_AGENT.md).
|
||||
*/
|
||||
export * from './config/agentConfig.js'
|
||||
export * from './log/logger.js'
|
||||
export * from './transport/seams.js'
|
||||
export * from './keys/identity.js'
|
||||
export * from './keys/keystore.js'
|
||||
export * from './enroll/csr.js'
|
||||
export * from './enroll/pair.js'
|
||||
export { parseArgs, runCli, CliUsageError } from './cli.js'
|
||||
export type { CliArgs, CliCommand, CliDeps } from './cli.js'
|
||||
export * from './transport/frpScaffold.js'
|
||||
export * from './transport/tunnel.js'
|
||||
export * from './transport/loopback.js'
|
||||
export * from './transport/streamRouter.js'
|
||||
export * from './transport/heartbeat.js'
|
||||
export * from './transport/backoff.js'
|
||||
export * from './transport/flowControl.js'
|
||||
export * from './transport/dial.js'
|
||||
export * from './certs/rotation.js'
|
||||
export * from './lifecycle/revocation.js'
|
||||
export * from './e2e/replaySeal.js'
|
||||
export * from './e2e/hostEndpoint.js'
|
||||
export * from './dist/buildBinary.js'
|
||||
export * from './service/install.js'
|
||||
export * from './service/launchd.js'
|
||||
export * from './service/systemd.js'
|
||||
export * from './service/originConfig.js'
|
||||
143
agent/src/keys/identity.ts
Normal file
143
agent/src/keys/identity.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
|
||||
*
|
||||
* Two key algorithms share one `AgentIdentity` shape (discriminated by `alg`):
|
||||
* - `ed25519` — the relay E2E rendezvous path (unchanged).
|
||||
* - `p256` — the native-tunnel HOST frp-client key (FIX H-host-2): the `frp-client-CA` is P-256,
|
||||
* so the host key, its CSR (ECDSA-with-SHA256), and its leaf are all P-256.
|
||||
* `AgentIdentity` exposes ONLY the public key, the §4.2 enroll fingerprint, and an in-process
|
||||
* `sign()`; there is NO API that returns or serializes the private key. The raw private key material
|
||||
* is held in a module-private closure and, for P-256, NEVER leaves the host (only the pubkey + CSR do).
|
||||
*/
|
||||
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
|
||||
import type { KeyObject } from 'node:crypto'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
|
||||
/** Which key algorithm an identity carries. Drives CSR SPKI + signatureAlgorithm encoding. */
|
||||
export type KeyAlg = 'ed25519' | 'p256'
|
||||
|
||||
export interface AgentIdentity {
|
||||
/** Which key algorithm this identity uses (`ed25519` = relay path, `p256` = native frp-client). */
|
||||
readonly alg: KeyAlg
|
||||
/**
|
||||
* The registry-stored public key bytes (§4.2 agent_pubkey):
|
||||
* - `ed25519`: the raw 32-byte public key;
|
||||
* - `p256`: the EC SubjectPublicKeyInfo DER (what the control-plane P-256 gate compares).
|
||||
*/
|
||||
readonly publicKey: Uint8Array
|
||||
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
|
||||
readonly enrollFpr: string
|
||||
/**
|
||||
* Signature over `message` using the in-process private key (never returns the key):
|
||||
* - `ed25519`: a raw 64-byte Ed25519 signature;
|
||||
* - `p256`: a DER `ECDSA-Sig-Value` (ecdsa-with-SHA256) — the PKCS#10 signatureValue shape.
|
||||
*/
|
||||
sign(message: Uint8Array): Uint8Array
|
||||
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
|
||||
exportPrivatePkcs8Pem(): string
|
||||
/** The underlying private KeyObject — for in-process crypto (CSR/mTLS) ONLY, never serialized to the wire. */
|
||||
privateKeyObject(): KeyObject
|
||||
}
|
||||
|
||||
/** SHA-256 fingerprint of a raw Ed25519 public key, base64url — §4.2 enroll_fpr. */
|
||||
export function computeEnrollFpr(publicKey: Uint8Array): string {
|
||||
const digest = createHash('sha256').update(publicKey).digest()
|
||||
return encodeBase64UrlBytes(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
/** Raw 32-byte Ed25519 public key from a KeyObject (strips the SPKI DER prefix). */
|
||||
function rawPublicKey(pub: KeyObject): Uint8Array {
|
||||
const der = pub.export({ type: 'spki', format: 'der' })
|
||||
// Ed25519 SPKI is a fixed 44-byte structure; the raw key is the trailing 32 bytes.
|
||||
return new Uint8Array(der.subarray(der.length - 32))
|
||||
}
|
||||
|
||||
function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
|
||||
const rawPub = rawPublicKey(publicKey)
|
||||
const enrollFpr = computeEnrollFpr(rawPub)
|
||||
return {
|
||||
alg: 'ed25519',
|
||||
publicKey: rawPub,
|
||||
enrollFpr,
|
||||
sign(message: Uint8Array): Uint8Array {
|
||||
return new Uint8Array(sign(null, message, privateKey))
|
||||
},
|
||||
exportPrivatePkcs8Pem(): string {
|
||||
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
},
|
||||
privateKeyObject(): KeyObject {
|
||||
return privateKey
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P-256 identity (FIX H-host-2). `publicKey` is the EC SubjectPublicKeyInfo DER (the exact bytes the
|
||||
* control-plane frp-client gate compares against the registry); `sign` produces a DER `ECDSA-Sig-Value`
|
||||
* over the SHA-256 digest — the PKCS#10 / X.509 signatureValue shape. The private key never leaves
|
||||
* this closure (INV4); only the SPKI + CSR are emitted off-host.
|
||||
*/
|
||||
function buildP256Identity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
|
||||
const spkiDer = new Uint8Array(publicKey.export({ type: 'spki', format: 'der' }))
|
||||
const enrollFpr = computeEnrollFpr(spkiDer)
|
||||
return {
|
||||
alg: 'p256',
|
||||
publicKey: spkiDer,
|
||||
enrollFpr,
|
||||
sign(message: Uint8Array): Uint8Array {
|
||||
// ecdsa-with-SHA256 → DER ECDSA-Sig-Value (node's default dsaEncoding is 'der').
|
||||
return new Uint8Array(sign('sha256', message, privateKey))
|
||||
},
|
||||
exportPrivatePkcs8Pem(): string {
|
||||
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
},
|
||||
privateKeyObject(): KeyObject {
|
||||
return privateKey
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
|
||||
export function generateIdentity(): AgentIdentity {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
return buildIdentity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh EC P-256 identity for the native-tunnel host frp-client key (FIX H-host-2). The
|
||||
* private key is held in-memory only (INV4) and NEVER serialized off-host; only the pubkey + CSR leave.
|
||||
*/
|
||||
export function generateP256Identity(): AgentIdentity {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
return buildP256Identity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Reconstruct an Ed25519 identity from a stored PKCS#8 PEM private key (keystore load path). */
|
||||
export function identityFromPrivatePem(pem: string): AgentIdentity {
|
||||
const privateKey = createPrivateKey(pem)
|
||||
const publicKey = createPublicKey(privateKey)
|
||||
return buildIdentity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Reconstruct a P-256 identity from a stored PKCS#8 PEM private key (keystore load path). */
|
||||
export function p256IdentityFromPrivatePem(pem: string): AgentIdentity {
|
||||
const privateKey = createPrivateKey(pem)
|
||||
const publicKey = createPublicKey(privateKey)
|
||||
return buildP256Identity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
|
||||
export function verifySignature(
|
||||
publicKey: Uint8Array,
|
||||
message: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
): boolean {
|
||||
const spkiPrefix = Uint8Array.from([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
])
|
||||
const der = new Uint8Array(spkiPrefix.length + publicKey.length)
|
||||
der.set(spkiPrefix, 0)
|
||||
der.set(publicKey, spkiPrefix.length)
|
||||
const pub = createPublicKey({ key: Buffer.from(der), format: 'der', type: 'spki' })
|
||||
return verify(null, message, pub, signature)
|
||||
}
|
||||
136
agent/src/keys/keystore.ts
Normal file
136
agent/src/keys/keystore.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* On-disk keystore — PLAN_RELAY_AGENT T3 (INV4/INV5). Persists ONLY to `stateDir`, every secret
|
||||
* file mode `0600`. Stores: the Ed25519 private key (PKCS#8 PEM), the mTLS cert + CA chain, and
|
||||
* the FIX 3 unwrapped `hostContentSecret`. The raw pairing code is NEVER written here (T4).
|
||||
*/
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createPrivateKey } from 'node:crypto'
|
||||
import type { AgentIdentity } from './identity.js'
|
||||
import { identityFromPrivatePem, p256IdentityFromPrivatePem } from './identity.js'
|
||||
|
||||
const SECRET_MODE = 0o600
|
||||
const DIR_MODE = 0o700
|
||||
|
||||
export interface Keystore {
|
||||
saveIdentity(id: AgentIdentity): void
|
||||
loadIdentity(): AgentIdentity | null
|
||||
saveCert(certPem: string, caChainPem: string): void
|
||||
loadCert(): { certPem: string; caChainPem: string } | null
|
||||
saveContentSecret(secret: Uint8Array): void
|
||||
loadContentSecret(): Uint8Array | null
|
||||
}
|
||||
|
||||
const KEY_FILE = 'agent.key.pem'
|
||||
const CERT_FILE = 'agent.cert.pem'
|
||||
const CA_FILE = 'agent.ca.pem'
|
||||
const CONTENT_SECRET_FILE = 'content.secret'
|
||||
|
||||
/** Corrupt/unreadable key material surfaces as a typed error (never a silent empty read). */
|
||||
export class KeystoreError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'KeystoreError'
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(stateDir: string): void {
|
||||
if (!existsSync(stateDir)) {
|
||||
mkdirSync(stateDir, { recursive: true, mode: DIR_MODE })
|
||||
return
|
||||
}
|
||||
// Refuse to write secrets into a world-/group-accessible directory (INV5 hard error).
|
||||
const mode = statSync(stateDir).mode & 0o777
|
||||
if ((mode & 0o077) !== 0) {
|
||||
throw new KeystoreError(
|
||||
`stateDir ${stateDir} is group/world accessible (mode ${mode.toString(8)}); refusing to write secrets`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct an `AgentIdentity` from a stored PKCS#8 PEM, branching on the key's algorithm
|
||||
* discriminant (the PKCS#8 AlgorithmIdentifier OID, surfaced as `asymmetricKeyType`): an Ed25519
|
||||
* key → the relay-path builder; an EC P-256 key → the native frp-client builder (EC SPKI publicKey).
|
||||
* A saved P-256 identity therefore round-trips as `alg: 'p256'` with a usable signing key, while
|
||||
* Ed25519 stays byte-identical. Any other algorithm is a hard error (never silently mislabeled).
|
||||
*/
|
||||
function identityFromStoredPem(pem: string): AgentIdentity {
|
||||
const key = createPrivateKey(pem)
|
||||
const alg = key.asymmetricKeyType
|
||||
if (alg === 'ed25519') return identityFromPrivatePem(pem)
|
||||
if (alg === 'ec') {
|
||||
// An `ec` key alone is not proof of P-256 — a P-384/secp256k1 key also reports `ec`. The native
|
||||
// frp-client path is P-256 ONLY, so assert the named curve before treating it as such; any other
|
||||
// curve is a hard error (never silently mislabeled as a usable P-256 identity).
|
||||
const curve = key.asymmetricKeyDetails?.namedCurve
|
||||
if (curve !== 'prime256v1') {
|
||||
throw new Error(
|
||||
`unsupported stored EC identity curve: ${curve ?? 'unknown'} (only prime256v1/P-256 is supported)`,
|
||||
)
|
||||
}
|
||||
return p256IdentityFromPrivatePem(pem)
|
||||
}
|
||||
throw new Error(`unsupported stored identity key algorithm: ${alg ?? 'unknown'}`)
|
||||
}
|
||||
|
||||
function writeSecret(path: string, data: string | Uint8Array): void {
|
||||
writeFileSync(path, data, { mode: SECRET_MODE })
|
||||
// Enforce 0600 even if a prior umask/file left it wider.
|
||||
chmodSync(path, SECRET_MODE)
|
||||
}
|
||||
|
||||
export function openKeystore(stateDir: string): Keystore {
|
||||
const keyPath = join(stateDir, KEY_FILE)
|
||||
const certPath = join(stateDir, CERT_FILE)
|
||||
const caPath = join(stateDir, CA_FILE)
|
||||
const secretPath = join(stateDir, CONTENT_SECRET_FILE)
|
||||
|
||||
return {
|
||||
saveIdentity(id: AgentIdentity): void {
|
||||
ensureDir(stateDir)
|
||||
writeSecret(keyPath, id.exportPrivatePkcs8Pem())
|
||||
},
|
||||
loadIdentity(): AgentIdentity | null {
|
||||
if (!existsSync(keyPath)) return null
|
||||
let pem: string
|
||||
try {
|
||||
pem = readFileSync(keyPath, 'utf8')
|
||||
} catch (err) {
|
||||
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
|
||||
}
|
||||
try {
|
||||
return identityFromStoredPem(pem)
|
||||
} catch (err) {
|
||||
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
|
||||
}
|
||||
},
|
||||
saveCert(certPem: string, caChainPem: string): void {
|
||||
ensureDir(stateDir)
|
||||
writeSecret(certPath, certPem)
|
||||
writeSecret(caPath, caChainPem)
|
||||
},
|
||||
loadCert(): { certPem: string; caChainPem: string } | null {
|
||||
if (!existsSync(certPath) || !existsSync(caPath)) return null
|
||||
return {
|
||||
certPem: readFileSync(certPath, 'utf8'),
|
||||
caChainPem: readFileSync(caPath, 'utf8'),
|
||||
}
|
||||
},
|
||||
saveContentSecret(secret: Uint8Array): void {
|
||||
ensureDir(stateDir)
|
||||
writeSecret(secretPath, secret)
|
||||
},
|
||||
loadContentSecret(): Uint8Array | null {
|
||||
if (!existsSync(secretPath)) return null
|
||||
return new Uint8Array(readFileSync(secretPath))
|
||||
},
|
||||
}
|
||||
}
|
||||
68
agent/src/lifecycle/revocation.ts
Normal file
68
agent/src/lifecycle/revocation.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Revocation + GOAWAY teardown — PLAN_RELAY_AGENT T14 (INV12). Distinguishes a `revoked` GOAWAY
|
||||
* (immediate teardown, NO reconnect) from the graceful `operatorDrain`/`shutdown` reasons
|
||||
* (finish in-flight, reconnect elsewhere) using ONLY the FROZEN §4.1 3-value `GoAwayReason` +
|
||||
* `decodeGoAwayReason` — never a locally-invented numeric mapping (cross-plan drift = silent
|
||||
* INV12 defeat). An unknown numeric code FAILS CLOSED to `revoked`.
|
||||
*
|
||||
* The RevocationState seam is imported from transport/seams.ts (W0), not redeclared. Its
|
||||
* `isRevoked()` is what T10's reconnectLoop consumes (one-way edge, no task cycle).
|
||||
*/
|
||||
import { decodeGoAwayReason } from 'relay-contracts'
|
||||
import type { GoAwayReason } from 'relay-contracts'
|
||||
import type { RevocationState, RevokeReason } from '../transport/seams.js'
|
||||
|
||||
export type GoAwayAction = 'drain' | 'revoked'
|
||||
|
||||
/**
|
||||
* Pure exhaustive map over the FROZEN 3-value union. `operatorDrain`/`shutdown` → drain (reconnect
|
||||
* elsewhere); `revoked` → revoked (stop). No integer literals — the reason is already a label.
|
||||
*/
|
||||
export function classifyGoAway(reason: GoAwayReason): GoAwayAction {
|
||||
switch (reason) {
|
||||
case 'operatorDrain':
|
||||
case 'shutdown':
|
||||
return 'drain'
|
||||
case 'revoked':
|
||||
return 'revoked'
|
||||
}
|
||||
}
|
||||
|
||||
export function createRevocationState(onTeardown: () => void): RevocationState {
|
||||
let revoked = false
|
||||
return {
|
||||
isRevoked(): boolean {
|
||||
return revoked
|
||||
},
|
||||
markRevoked(_reason: RevokeReason): void {
|
||||
if (revoked) return
|
||||
revoked = true
|
||||
onTeardown() // close all streams + tunnel immediately
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a decoded GOAWAY reason to the revocation state. On `revoked`, marks the state revoked
|
||||
* (⇒ teardown + reconnect suppression). Returns the action taken.
|
||||
*/
|
||||
export function applyGoAway(reason: GoAwayReason, state: RevocationState): GoAwayAction {
|
||||
const action = classifyGoAway(reason)
|
||||
if (action === 'revoked') state.markRevoked('goaway-revoked')
|
||||
return action
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a raw GOAWAY wire code. Decodes via the FROZEN decoder; an UNKNOWN code (decoder throws)
|
||||
* FAILS CLOSED to `revoked` (a default that guessed "drain" would defeat INV12).
|
||||
*/
|
||||
export function applyGoAwayCode(code: number, state: RevocationState): GoAwayAction {
|
||||
let reason: GoAwayReason
|
||||
try {
|
||||
reason = decodeGoAwayReason(code)
|
||||
} catch {
|
||||
state.markRevoked('goaway-revoked') // fail closed
|
||||
return 'revoked'
|
||||
}
|
||||
return applyGoAway(reason, state)
|
||||
}
|
||||
75
agent/src/log/logger.ts
Normal file
75
agent/src/log/logger.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Structured redacting logger — PLAN_RELAY_AGENT T2 (INV9: secrets never logged, no console.log).
|
||||
*
|
||||
* Meta keys whose name matches a secret substring are replaced with `[REDACTED]` before the
|
||||
* line is emitted, so a caller that accidentally passes `{ privateKey, cert, pairingCode,
|
||||
* agentToken }` can never leak the value. Emission goes through an injectable sink (default
|
||||
* stderr) — `console.log` is never used.
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export interface Logger {
|
||||
log(level: LogLevel, msg: string, meta?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
/** Sink for a formatted line. Default writes to stderr (NOT console.log). */
|
||||
export type LogSink = (line: string) => void
|
||||
|
||||
const LEVEL_ORDER: Readonly<Record<LogLevel, number>> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
}
|
||||
|
||||
/**
|
||||
* Substrings that mark a meta key as secret (case-insensitive). Matches `privateKey` (key),
|
||||
* `cert`, `caChain` (chain? no — cert covers it via 'cert'), `pairingCode` (code), `agentToken`
|
||||
* (token), etc. `nonce`/`streamId`/`hostId` intentionally do NOT match (they are not secrets).
|
||||
*/
|
||||
const REDACT_SUBSTRINGS: readonly string[] = [
|
||||
'key',
|
||||
'cert',
|
||||
'code',
|
||||
'token',
|
||||
'secret',
|
||||
'proof',
|
||||
'password',
|
||||
'pem',
|
||||
]
|
||||
|
||||
const REDACTED = '[REDACTED]'
|
||||
|
||||
function isSecretKey(name: string): boolean {
|
||||
const lower = name.toLowerCase()
|
||||
return REDACT_SUBSTRINGS.some((s) => lower.includes(s))
|
||||
}
|
||||
|
||||
function redactMeta(meta: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
out[k] = isSecretKey(k) ? REDACTED : v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const defaultSink: LogSink = (line) => {
|
||||
process.stderr.write(`${line}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a redacting logger at a minimum level. Lines below `level` are dropped. Secret meta
|
||||
* values are redacted by key name before serialization (INV9).
|
||||
*/
|
||||
export function createLogger(level: LogLevel, sink: LogSink = defaultSink): Logger {
|
||||
const threshold = LEVEL_ORDER[level]
|
||||
return {
|
||||
log(msgLevel, msg, meta) {
|
||||
if (LEVEL_ORDER[msgLevel] < threshold) return
|
||||
const safeMeta = meta ? redactMeta(meta) : undefined
|
||||
const metaPart = safeMeta ? ` ${JSON.stringify(safeMeta)}` : ''
|
||||
sink(`${msgLevel.toUpperCase()} ${msg}${metaPart}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
30
agent/src/main.ts
Normal file
30
agent/src/main.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* CLI bootstrap — PLAN_RELAY_PHASE1 C2. The `dist/cli.js` entrypoint (the `#!/usr/bin/env node`
|
||||
* shebang is prepended at build time via esbuild `--banner`, NOT here). Reads argv, builds the
|
||||
* real CliDeps, dispatches through `runCli`, and maps any error to a clean stderr line + exit code
|
||||
* (usage errors ⇒ 2, everything else ⇒ 1) so no invocation ever crashes with a raw stack trace.
|
||||
*/
|
||||
import { parseArgs, runCli, CliUsageError } from './cli.js'
|
||||
import { createCliDeps } from './cli/deps.js'
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const argv = process.argv.slice(2)
|
||||
const deps = createCliDeps()
|
||||
try {
|
||||
return await runCli(parseArgs(argv), deps)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`web-terminal-agent: ${message}\n`)
|
||||
return err instanceof CliUsageError ? 2 : 1
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then((code) => {
|
||||
process.exitCode = code
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`web-terminal-agent: fatal ${message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
29
agent/src/net/loopbackLiteral.ts
Normal file
29
agent/src/net/loopbackLiteral.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Strict loopback-literal check — the single source of truth for both S-GATE-style guards:
|
||||
* - `service/install.ts` isLoopbackBindHost (BIND_HOST S-GATE, FIX C-host-1, CRITICAL)
|
||||
* - `config/agentConfig.ts` isLoopbackWsUrl (localTargetUrl anti-SSRF)
|
||||
*
|
||||
* Both previously used `value.startsWith('127.')`, which treats an arbitrary suffixed HOSTNAME
|
||||
* such as `127.0.0.1.attacker.example.com` or `127.evil.net` as loopback. Because Node resolves
|
||||
* non-literal hosts via DNS before bind()/dial, that prefix match let a crafted value defeat the
|
||||
* exact invariant the guard exists to enforce. This module fails closed: it accepts a value ONLY
|
||||
* when it is an EXACT loopback literal — `localhost`, `::1`/`[::1]`, or a fully-parsed IPv4 address
|
||||
* in 127.0.0.0/8 with NO trailing characters. Anything else (a hostname, a partial IP, `0.0.0.0`,
|
||||
* `::`) is rejected.
|
||||
*/
|
||||
import { isIPv4 } from 'node:net'
|
||||
|
||||
/** Non-IPv4 loopback literals accepted verbatim (localhost + the IPv6 loopback, with/without brackets). */
|
||||
const LOOPBACK_LITERALS: ReadonlySet<string> = new Set(['localhost', '::1', '[::1]'])
|
||||
|
||||
/**
|
||||
* True iff `host` is an EXACT loopback literal: `localhost`, `::1`, `[::1]`, or a well-formed IPv4
|
||||
* address in 127.0.0.0/8 (the whole string must parse as a dotted-quad — no trailing label). A
|
||||
* suffixed hostname like `127.0.0.1.attacker.example.com` is NOT loopback and returns false.
|
||||
*/
|
||||
export function isLoopbackHostLiteral(host: string): boolean {
|
||||
if (LOOPBACK_LITERALS.has(host)) return true
|
||||
// isIPv4 requires the FULL string to be a dotted-quad, so no trailing hostname label can slip
|
||||
// through; the first-octet check then confines it to the 127.0.0.0/8 loopback block.
|
||||
return isIPv4(host) && host.split('.')[0] === '127'
|
||||
}
|
||||
259
agent/src/provision/frpcBinary.ts
Normal file
259
agent/src/provision/frpcBinary.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Pinned frpc binary provisioner — TASK B3 (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
|
||||
*
|
||||
* Mirrors the `dist/buildBinary.ts` verify-download discipline: detect OS/arch, select a PINNED
|
||||
* release `{ version, url, sha256 }`, download the artifact TO DISK (a temp file), verify its
|
||||
* SHA-256 against the pin BEFORE the binary is placed or executed, then place it atomically
|
||||
* (temp -> rename) with the exec bit. On hash mismatch NOTHING is placed and the temp is removed.
|
||||
*
|
||||
* Non-negotiable (§5): never `curl | sh` a streamed secret, never disable TLS verification (no `-k`)
|
||||
* — the default fetch enforces `https:` and there is no insecure escape hatch.
|
||||
*
|
||||
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
|
||||
* a host cannot exec a `.tar.gz`. So AFTER the archive hash matches its pin (and only then) the bytes
|
||||
* are handed to the path-traversal-hardened extractor in `untar.ts`, which pulls out ONLY the inner
|
||||
* `frpc`; that verified binary is what gets placed. Extraction failure (no `frpc`, corrupt gzip/tar,
|
||||
* unsafe entry name, oversize) places nothing and throws `FrpcProvisionError`.
|
||||
*
|
||||
* The network fetch and the filesystem are INJECTABLE seams (`ProvisionFrpcDeps`) so tests exercise
|
||||
* URL/arch selection, hash-match extraction+placement, hash-mismatch rejection, extraction failures,
|
||||
* and unsupported-platform errors with no network access.
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { extractFrpcBinary } from './untar.js'
|
||||
|
||||
export type FrpcPlatform = 'darwin-arm64' | 'darwin-amd64' | 'linux-arm64' | 'linux-amd64'
|
||||
|
||||
export const FRPC_PLATFORMS: readonly FrpcPlatform[] = [
|
||||
'darwin-arm64',
|
||||
'darwin-amd64',
|
||||
'linux-arm64',
|
||||
'linux-amd64',
|
||||
]
|
||||
|
||||
/** A pinned frpc release artifact for one platform. `sha256` is lowercase hex over the artifact. */
|
||||
export interface FrpcReleaseRef {
|
||||
readonly version: string
|
||||
readonly url: string
|
||||
readonly sha256: string
|
||||
}
|
||||
|
||||
const FRPC_VERSION = '0.61.1'
|
||||
const RELEASE_BASE = `https://github.com/fatedier/frp/releases/download/v${FRPC_VERSION}`
|
||||
|
||||
/**
|
||||
* The pinned release map. URLs point at the real frp v0.61.1 assets and the `sha256` fields are the
|
||||
* REAL published digests from `frp_sha256_checksums.txt` (verified against the downloaded
|
||||
* darwin_arm64 archive on 2026-07-09). To bump frp: update `FRPC_VERSION` and paste the new digests
|
||||
* from that release's checksum file. Tests inject their own release map.
|
||||
*/
|
||||
export const FRPC_RELEASES: Readonly<Record<FrpcPlatform, FrpcReleaseRef>> = {
|
||||
'darwin-arm64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_arm64.tar.gz`,
|
||||
sha256: '3e65f13a17a284bd6013e6bb6856bc2720074cea6094cc446c1f4c3932154c2d',
|
||||
},
|
||||
'darwin-amd64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_amd64.tar.gz`,
|
||||
sha256: '403a0ee5e92f083a863d984b7af1e9d70ba2aaa28e87f42f1fe085adf76b8491',
|
||||
},
|
||||
'linux-arm64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_arm64.tar.gz`,
|
||||
sha256: 'af6366f2b43920ebfe6235dba6060770399ed1fb18601e5818552bd46a7621f8',
|
||||
},
|
||||
'linux-amd64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_amd64.tar.gz`,
|
||||
sha256: 'bff260b68ca7b1461182a46c4f34e9709ba32764eed30a15dd94ac97f50a2c40',
|
||||
},
|
||||
}
|
||||
|
||||
/** Node `os.arch()` values mapped to frp's arch tokens. */
|
||||
const ARCH_MAP: Readonly<Record<string, 'arm64' | 'amd64'>> = {
|
||||
arm64: 'arm64',
|
||||
x64: 'amd64',
|
||||
}
|
||||
const SUPPORTED_OS: ReadonlySet<string> = new Set(['darwin', 'linux'])
|
||||
|
||||
const BIN_NAME = 'frpc'
|
||||
const TMP_NAME = 'frpc.download.tmp'
|
||||
const EXEC_MODE = 0o755
|
||||
const DIR_MODE = 0o700
|
||||
|
||||
/** Map `os.platform()` + `os.arch()` to a supported `FrpcPlatform`, or `null` if unsupported. */
|
||||
export function detectFrpcPlatform(platform: string, arch: string): FrpcPlatform | null {
|
||||
const mappedArch = ARCH_MAP[arch]
|
||||
if (!SUPPORTED_OS.has(platform) || mappedArch === undefined) return null
|
||||
const key = `${platform}-${mappedArch}` as FrpcPlatform
|
||||
return FRPC_PLATFORMS.includes(key) ? key : null
|
||||
}
|
||||
|
||||
/** Injectable network fetch: returns the artifact bytes for `url`. */
|
||||
export type FrpcFetch = (url: string) => Promise<Uint8Array>
|
||||
|
||||
/** Injectable filesystem seam (all async, mirrors `node:fs/promises`). */
|
||||
export interface FrpcFsDeps {
|
||||
mkdir(dir: string): Promise<void>
|
||||
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||
rename(from: string, to: string): Promise<void>
|
||||
chmod(path: string, mode: number): Promise<void>
|
||||
rm(path: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface ProvisionFrpcDeps {
|
||||
readonly fetch: FrpcFetch
|
||||
readonly fs: FrpcFsDeps
|
||||
/** Override the digest function (default: node:crypto SHA-256, lowercase hex). */
|
||||
readonly computeSha256?: (data: Uint8Array) => string
|
||||
}
|
||||
|
||||
export interface ProvisionFrpcOptions {
|
||||
/** `os.platform()` (e.g. `'darwin'`, `'linux'`). */
|
||||
readonly platform: string
|
||||
/** `os.arch()` (e.g. `'arm64'`, `'x64'`). */
|
||||
readonly arch: string
|
||||
/** Directory the verified `frpc` binary is placed into. */
|
||||
readonly binDir: string
|
||||
/** Release map to select from; defaults to the pinned `FRPC_RELEASES`. */
|
||||
readonly releases?: Readonly<Record<FrpcPlatform, FrpcReleaseRef>>
|
||||
}
|
||||
|
||||
export interface ProvisionResult {
|
||||
readonly binPath: string
|
||||
readonly version: string
|
||||
readonly platform: FrpcPlatform
|
||||
}
|
||||
|
||||
/** A verify-download failure (unsupported platform, fetch error, or integrity mismatch). */
|
||||
export class FrpcProvisionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FrpcProvisionError'
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSha256(data: Uint8Array): string {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
/** Default HTTPS fetch — enforces `https:` (never `-k`, never plain http) and a 2xx status. */
|
||||
async function defaultFetch(url: string): Promise<Uint8Array> {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
throw new FrpcProvisionError(`invalid frpc download URL: ${url}`)
|
||||
}
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new FrpcProvisionError(`frpc download refuses non-https URL: ${url}`)
|
||||
}
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url)
|
||||
} catch (err: unknown) {
|
||||
// Surface transport failures (DNS, refused, TLS) through the SAME typed error as every other
|
||||
// failure path, so callers (e.g. the autoupdate rollback path) can pattern-match uniformly.
|
||||
throw new FrpcProvisionError(
|
||||
`frpc download failed: ${err instanceof Error ? err.message : 'network error'} for ${url}`,
|
||||
)
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new FrpcProvisionError(`frpc download failed: HTTP ${res.status} for ${url}`)
|
||||
}
|
||||
return new Uint8Array(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
const defaultFsDeps: FrpcFsDeps = {
|
||||
mkdir: async (dir) => {
|
||||
await mkdir(dir, { recursive: true, mode: DIR_MODE })
|
||||
},
|
||||
writeFile: async (path, data) => {
|
||||
await writeFile(path, data, { mode: 0o600 })
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
await rename(from, to)
|
||||
},
|
||||
chmod: async (path, mode) => {
|
||||
await chmod(path, mode)
|
||||
},
|
||||
rm: async (path) => {
|
||||
await rm(path, { force: true })
|
||||
},
|
||||
}
|
||||
|
||||
function defaultDeps(): ProvisionFrpcDeps {
|
||||
return { fetch: defaultFetch, fs: defaultFsDeps, computeSha256: defaultSha256 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Download + verify + extract + place the pinned frpc binary for the current platform.
|
||||
*
|
||||
* Order (verify-before-extract-before-exec): detect platform -> select pin -> fetch archive ->
|
||||
* write the unverified archive to a per-invocation temp -> SHA-256 verify against the pin. On
|
||||
* mismatch: remove the temp and throw (no extraction, nothing placed). On match: gunzip+untar to
|
||||
* pull ONLY the inner `frpc` (path-traversal-safe), overwrite the temp with that verified binary,
|
||||
* chmod exec, and atomic-rename into place. Any extraction failure removes the temp and throws.
|
||||
* Nothing is ever placed or made executable before the digest matches the pin.
|
||||
*/
|
||||
export async function provisionFrpc(
|
||||
opts: ProvisionFrpcOptions,
|
||||
deps: ProvisionFrpcDeps = defaultDeps(),
|
||||
): Promise<ProvisionResult> {
|
||||
const platform = detectFrpcPlatform(opts.platform, opts.arch)
|
||||
if (platform === null) {
|
||||
throw new FrpcProvisionError(
|
||||
`unsupported platform for frpc: ${opts.platform}/${opts.arch} (supported: ${FRPC_PLATFORMS.join(', ')})`,
|
||||
)
|
||||
}
|
||||
|
||||
const releases = opts.releases ?? FRPC_RELEASES
|
||||
const release = releases[platform]
|
||||
if (release === undefined) {
|
||||
throw new FrpcProvisionError(`no pinned frpc release for platform ${platform}`)
|
||||
}
|
||||
|
||||
const computeSha256 = deps.computeSha256 ?? defaultSha256
|
||||
// Per-invocation-unique temp path: two concurrent provisions (e.g. overlapping autoupdate polls)
|
||||
// must never write/rename through the same temp file (TOCTOU). Same path is used for write+rm+rename.
|
||||
const tmpPath = join(opts.binDir, `${TMP_NAME}.${process.pid}.${randomBytes(6).toString('hex')}`)
|
||||
const binPath = join(opts.binDir, BIN_NAME)
|
||||
|
||||
const bytes = await deps.fetch(release.url)
|
||||
|
||||
await deps.fs.mkdir(opts.binDir)
|
||||
await deps.fs.writeFile(tmpPath, bytes)
|
||||
|
||||
const digest = computeSha256(bytes).toLowerCase()
|
||||
const expected = release.sha256.toLowerCase()
|
||||
if (digest !== expected) {
|
||||
await deps.fs.rm(tmpPath)
|
||||
throw new FrpcProvisionError(
|
||||
`frpc SHA-256 mismatch for ${platform}: expected ${expected}, got ${digest} — nothing placed`,
|
||||
)
|
||||
}
|
||||
|
||||
// Verified — extract ONLY the inner `frpc` from the archive. The extractor selects by basename and
|
||||
// rejects any unsafe entry name, so nothing derived from the archive can escape binDir. On any
|
||||
// extraction failure the (still-archive) temp is removed and nothing is placed.
|
||||
let frpcBytes: Uint8Array
|
||||
try {
|
||||
frpcBytes = extractFrpcBinary(bytes)
|
||||
} catch (err: unknown) {
|
||||
await deps.fs.rm(tmpPath)
|
||||
const detail = err instanceof Error ? err.message : 'unknown error'
|
||||
throw new FrpcProvisionError(
|
||||
`frpc extraction failed for ${platform}: ${detail} — nothing placed`,
|
||||
)
|
||||
}
|
||||
|
||||
// Overwrite the temp with the verified extracted binary, grant exec, then promote atomically.
|
||||
await deps.fs.writeFile(tmpPath, frpcBytes)
|
||||
await deps.fs.chmod(tmpPath, EXEC_MODE)
|
||||
await deps.fs.rename(tmpPath, binPath)
|
||||
|
||||
return { binPath, version: release.version, platform }
|
||||
}
|
||||
159
agent/src/provision/untar.ts
Normal file
159
agent/src/provision/untar.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Minimal, path-traversal-hardened tar extractor for the frp release archive — TASK B3 extraction
|
||||
* step (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
|
||||
*
|
||||
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
|
||||
* the host cannot exec a `.tar.gz`, so after `frpcBinary.ts` has VERIFIED the archive's SHA-256
|
||||
* against its pin it hands the bytes here to pull out ONLY the inner `frpc`.
|
||||
*
|
||||
* SECURITY (§5): this does NOT reconstruct the archive's directory tree on disk. The caller writes
|
||||
* the returned bytes to a FIXED destination it owns; entries are selected purely by matching the
|
||||
* (sanitized) tar name's BASENAME. Any candidate entry whose name is absolute, contains a NUL, or
|
||||
* has a `..` path segment is REJECTED (never silently skipped, so a `../frpc` decoy cannot be used
|
||||
* to derive a path). A malicious tarball can therefore never cause a write outside the caller's dir.
|
||||
*
|
||||
* Format scope: real frp archives are plain USTAR (magic `ustar `) with 100-byte names (no PAX/GNU
|
||||
* long-name/sparse entries) — verified against frp v0.61.1. The parser reads only what USTAR needs:
|
||||
* name (0..100), size octal (124..136), typeflag (156); regular files are typeflag `0` or legacy NUL.
|
||||
*/
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
|
||||
const TAR_BLOCK = 512
|
||||
const NAME_OFFSET = 0
|
||||
const NAME_LEN = 100
|
||||
const SIZE_OFFSET = 124
|
||||
const SIZE_LEN = 12
|
||||
const TYPEFLAG_OFFSET = 156
|
||||
|
||||
// USTAR regular-file type flags: '0' (0x30) and the legacy NUL (0x00). Everything else (dir '5',
|
||||
// symlink '2', PAX 'x'/'g', GNU 'L', …) is not a plain file and is skipped when matching.
|
||||
const TYPE_REGULAR = 0x30
|
||||
const TYPE_LEGACY = 0x00
|
||||
|
||||
// Hard ceiling on a single extracted entry. frp's `frpc` is ~14 MB; 256 MB rejects a corrupt/hostile
|
||||
// size field before it can drive a huge allocation or an out-of-bounds read.
|
||||
const MAX_ENTRY_BYTES = 256 * 1024 * 1024
|
||||
|
||||
const FRPC_BASENAME = 'frpc'
|
||||
|
||||
/** A tar/gzip extraction failure (corrupt gzip, malformed/truncated tar, unsafe or missing entry). */
|
||||
export class TarExtractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'TarExtractError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `name` could escape a destination directory: contains a NUL, is an absolute path, or has
|
||||
* any `..` path segment. Both `/` and `\` are treated as separators (defense in depth).
|
||||
*/
|
||||
function isUnsafeEntryName(name: string): boolean {
|
||||
if (name.length === 0) return true
|
||||
if (name.includes('\0')) return true
|
||||
if (name.startsWith('/') || name.startsWith('\\')) return true
|
||||
return name.split(/[/\\]/).some((segment) => segment === '..')
|
||||
}
|
||||
|
||||
/** Last `/`- or `\`-separated segment of a tar entry name. */
|
||||
function basename(name: string): string {
|
||||
const parts = name.split(/[/\\]/)
|
||||
return parts[parts.length - 1] ?? name
|
||||
}
|
||||
|
||||
/** Decode the NUL-terminated USTAR name field of the header block at `off`. */
|
||||
function readName(tar: Uint8Array, off: number): string {
|
||||
const raw = tar.subarray(off + NAME_OFFSET, off + NAME_OFFSET + NAME_LEN)
|
||||
const nul = raw.indexOf(0)
|
||||
return new TextDecoder().decode(raw.subarray(0, nul < 0 ? NAME_LEN : nul))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a NUL/space-terminated octal numeric field. Leading spaces are tolerated (GNU pads them);
|
||||
* any non-octal digit yields `NaN` so the caller can reject a corrupt header.
|
||||
*/
|
||||
function readOctal(tar: Uint8Array, start: number, len: number): number {
|
||||
let value = 0
|
||||
let seenDigit = false
|
||||
for (let i = start; i < start + len; i++) {
|
||||
const c = tar[i]
|
||||
if (c === undefined || c === 0x00 || c === 0x20) {
|
||||
if (seenDigit) break
|
||||
continue
|
||||
}
|
||||
if (c < 0x30 || c > 0x37) return Number.NaN
|
||||
value = value * 8 + (c - 0x30)
|
||||
seenDigit = true
|
||||
}
|
||||
return seenDigit ? value : 0
|
||||
}
|
||||
|
||||
/** True if the 512-byte header block at `off` is entirely zero (the end-of-archive marker). */
|
||||
function isZeroBlock(tar: Uint8Array, off: number): boolean {
|
||||
for (let i = off; i < off + TAR_BLOCK; i++) {
|
||||
if (tar[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the bytes of the FIRST regular-file entry whose safe basename equals `targetBasename`.
|
||||
*
|
||||
* Selection is by basename only — the archive's directory structure is never mapped to a path. A
|
||||
* candidate whose name is unsafe (absolute / NUL / `..`) throws rather than being used. Missing
|
||||
* entry, a truncated/corrupt tar, or an over-size entry all throw `TarExtractError` (place nothing).
|
||||
*/
|
||||
export function extractTarFileByBasename(tar: Uint8Array, targetBasename: string): Uint8Array {
|
||||
let off = 0
|
||||
while (off + TAR_BLOCK <= tar.length) {
|
||||
if (isZeroBlock(tar, off)) break // end-of-archive
|
||||
|
||||
const size = readOctal(tar, off + SIZE_OFFSET, SIZE_LEN)
|
||||
if (!Number.isInteger(size) || size < 0) {
|
||||
throw new TarExtractError('corrupt tar: invalid entry size field')
|
||||
}
|
||||
if (size > MAX_ENTRY_BYTES) {
|
||||
throw new TarExtractError(`tar entry exceeds max size (${size} > ${MAX_ENTRY_BYTES})`)
|
||||
}
|
||||
|
||||
const contentStart = off + TAR_BLOCK
|
||||
const contentEnd = contentStart + size
|
||||
if (contentEnd > tar.length) {
|
||||
throw new TarExtractError('corrupt tar: entry content is truncated')
|
||||
}
|
||||
|
||||
const typeflag = tar[off + TYPEFLAG_OFFSET]
|
||||
const isRegular = typeflag === TYPE_REGULAR || typeflag === TYPE_LEGACY
|
||||
if (isRegular) {
|
||||
const name = readName(tar, off)
|
||||
if (basename(name) === targetBasename) {
|
||||
if (isUnsafeEntryName(name)) {
|
||||
throw new TarExtractError(`refusing unsafe tar entry name: ${JSON.stringify(name)}`)
|
||||
}
|
||||
return tar.slice(contentStart, contentEnd) // copy — never a view into the archive buffer
|
||||
}
|
||||
}
|
||||
|
||||
// Advance past this entry's content, padded up to the next 512-byte boundary.
|
||||
off = contentEnd + ((TAR_BLOCK - (size % TAR_BLOCK)) % TAR_BLOCK)
|
||||
}
|
||||
throw new TarExtractError(`no "${targetBasename}" file entry found in tar archive`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gunzip a verified frp `.tar.gz` and return the inner `frpc` binary's bytes. `gunzipSync` is used
|
||||
* on already-hash-verified input (the pin gate runs first), so there is no attacker-controlled
|
||||
* decompression-bomb surface here; a corrupt/non-gzip payload throws `TarExtractError`.
|
||||
*/
|
||||
export function extractFrpcBinary(archiveGz: Uint8Array): Uint8Array {
|
||||
return extractTarFileByBasename(gunzip(archiveGz), FRPC_BASENAME)
|
||||
}
|
||||
|
||||
function gunzip(archiveGz: Uint8Array): Uint8Array {
|
||||
try {
|
||||
return new Uint8Array(gunzipSync(archiveGz))
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : 'not a gzip stream'
|
||||
throw new TarExtractError(`corrupt frpc archive: gunzip failed (${detail})`)
|
||||
}
|
||||
}
|
||||
254
agent/src/service/install.ts
Normal file
254
agent/src/service/install.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Service install dispatcher — PLAN_RELAY_AGENT T17, re-targeted for the native tunnel
|
||||
* (PLAN_TUNNEL_AUTOMATION B5). Detects the platform and emits TWO durable units — the base-app and
|
||||
* the agent — then loads/enables both. REFUSES to install as root (EXPLORE §4d least privilege).
|
||||
* All IO is injected so the logic is unit-testable without touching the real system.
|
||||
*
|
||||
* Two safety controls are load-bearing here:
|
||||
* - FIX C-host-1 (S-GATE, CRITICAL): the base-app unit MUST bind loopback. A non-loopback
|
||||
* `BIND_HOST` (e.g. `0.0.0.0`) is REJECTED — the throw happens before any file is written, so a
|
||||
* rejected install emits nothing. An absent value is normalized to `127.0.0.1`.
|
||||
* - 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.
|
||||
*/
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import {
|
||||
agentLabel,
|
||||
baseAppLabel,
|
||||
buildLaunchdPlist,
|
||||
launchdLoadCommand,
|
||||
launchdPlistPath,
|
||||
launchdUnloadCommand,
|
||||
type ServiceEnv,
|
||||
} from './launchd.js'
|
||||
import {
|
||||
agentUnitName,
|
||||
baseAppUnitName,
|
||||
buildSystemdUnit,
|
||||
systemdDisableCommand,
|
||||
systemdEnableCommand,
|
||||
systemdUnitPath,
|
||||
type SystemdUnitOptions,
|
||||
} from './systemd.js'
|
||||
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
|
||||
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
||||
|
||||
export type ServicePlatform = 'launchd' | 'systemd'
|
||||
|
||||
/**
|
||||
* Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
|
||||
* existing callers (and relay callers) are unaffected. `env` is the BASE-APP env (routed to the
|
||||
* base-app unit only); `baseAppExec` overrides the base-app `ExecStart` argv.
|
||||
*/
|
||||
export interface InstallOptions {
|
||||
/** Base-app env injected into the base-app unit (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
|
||||
readonly env?: ServiceEnv
|
||||
/** systemd `EnvironmentFile=` path (preferred over inline for values best kept off the unit). */
|
||||
readonly envFile?: string
|
||||
/** DNS zone label for the tunnel origin (`terminal` for native-tunnel hosts, default `term`). */
|
||||
readonly zone?: string
|
||||
/** Parent domain (e.g. `yaojia.wang`); with `cfg.subdomain` derives the tunnel ALLOWED_ORIGINS. */
|
||||
readonly domain?: string
|
||||
/** Base-app process argv (default `['node','dist/server.js']`). */
|
||||
readonly baseAppExec?: readonly string[]
|
||||
}
|
||||
|
||||
/** S0 base-app env vars the install CLI bakes into the base-app unit, passed through verbatim. */
|
||||
const BASE_APP_ENV_KEYS = [
|
||||
'ALLOWED_ORIGINS',
|
||||
'PORT',
|
||||
'SHELL_PATH',
|
||||
'IDLE_TTL',
|
||||
'USE_TMUX',
|
||||
'SCROLLBACK_BYTES',
|
||||
'MAX_PAYLOAD_BYTES',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Tunnel hosts MUST bind loopback. At the relay the device-cert mTLS is the ONLY auth gate, so a
|
||||
* default `0.0.0.0` bind (`src/config.ts`) would serve an unauth'd shell on the LAN, bypassing mTLS
|
||||
* entirely (PLAN_NATIVE_TUNNEL S0/R2, FIX C-host-1). This is the normalized loopback default.
|
||||
*/
|
||||
export const TUNNEL_DEFAULT_BIND_HOST = '127.0.0.1'
|
||||
|
||||
/** Native-tunnel origin zone → `https://<subdomain>.terminal.<domain>`; overridable via TUNNEL_ZONE. */
|
||||
export const TUNNEL_ORIGIN_ZONE = 'terminal'
|
||||
|
||||
/** Native-tunnel origin zone label; native installs MUST use this (FIX L-host-zone). */
|
||||
export const NATIVE_ORIGIN_ZONE = 'terminal'
|
||||
|
||||
/** Base-app process argv when the caller does not override it. */
|
||||
const DEFAULT_BASE_APP_EXEC: readonly string[] = ['node', 'dist/server.js']
|
||||
|
||||
/** A base-app BIND_HOST that is not loopback — the S-GATE fail-closed error (FIX C-host-1). */
|
||||
export class BindHostError extends Error {
|
||||
constructor(value: string) {
|
||||
super(
|
||||
`refusing to install: BIND_HOST="${value}" is not loopback. A tunnel host MUST bind ` +
|
||||
'127.0.0.1/::1/localhost — the device-cert mTLS at the relay is the only auth gate, so a ' +
|
||||
'0.0.0.0 (or LAN-IP) bind would serve an unauth\'d shell on the LAN. [FIX C-host-1 S-GATE]',
|
||||
)
|
||||
this.name = 'BindHostError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `value` is a loopback bind address (a well-formed 127.0.0.0/8 IPv4 literal, ::1, or
|
||||
* localhost). Delegates to the shared strict check so a suffixed hostname such as
|
||||
* `127.0.0.1.attacker.example.com` — which Node would DNS-resolve before bind() — is REJECTED,
|
||||
* not treated as loopback (FIX C-host-1 S-GATE).
|
||||
*/
|
||||
function isLoopbackBindHost(value: string): boolean {
|
||||
return isLoopbackHostLiteral(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* S-GATE (FIX C-host-1): normalize an absent/empty BIND_HOST to loopback; REJECT any non-loopback
|
||||
* value (throws `BindHostError`). The emitted base-app unit can therefore never bind `0.0.0.0`.
|
||||
*/
|
||||
export function normalizeBindHost(value: string | undefined): string {
|
||||
if (value === undefined || value.length === 0) return TUNNEL_DEFAULT_BIND_HOST
|
||||
if (!isLoopbackBindHost(value)) throw new BindHostError(value)
|
||||
return value
|
||||
}
|
||||
|
||||
/** Assert a native-tunnel install uses the `terminal` zone (FIX L-host-zone). Throws otherwise. */
|
||||
export function assertNativeZone(zone: string | undefined): void {
|
||||
if (zone !== NATIVE_ORIGIN_ZONE) {
|
||||
throw new Error(
|
||||
`native tunnel install requires zone="${NATIVE_ORIGIN_ZONE}" (got "${zone ?? '(default term)'}")` +
|
||||
' — the base-app origin must be https://<sub>.terminal.<domain> [FIX L-host-zone]',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2).
|
||||
* Sources the S0 base-app env — normalizing/gating `BIND_HOST` to loopback (S-GATE: throws on a
|
||||
* non-loopback value so no install can ever emit a LAN-exposed unit) — plus the tunnel-origin
|
||||
* `domain`/`zone` used to derive ALLOWED_ORIGINS. Pure/immutable (env is a parameter).
|
||||
*/
|
||||
export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
|
||||
const passthrough = Object.fromEntries(
|
||||
BASE_APP_ENV_KEYS.map((key) => [key, env[key]] as [string, string | undefined]).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0,
|
||||
),
|
||||
)
|
||||
// S-GATE at env-read time: a non-loopback BIND_HOST fails closed here (before any install).
|
||||
const serviceEnv: ServiceEnv = { BIND_HOST: normalizeBindHost(env.BIND_HOST), ...passthrough }
|
||||
const domain = env.TUNNEL_DOMAIN
|
||||
const envFile = env.AGENT_ENV_FILE
|
||||
const baseAppEntry = env.BASE_APP_ENTRY
|
||||
return {
|
||||
env: serviceEnv,
|
||||
...(domain ? { domain, zone: env.TUNNEL_ZONE || TUNNEL_ORIGIN_ZONE } : {}),
|
||||
...(envFile ? { envFile } : {}),
|
||||
...(baseAppEntry ? { baseAppExec: ['node', baseAppEntry] } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export class RootRefusedError extends Error {
|
||||
constructor() {
|
||||
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
||||
this.name = 'RootRefusedError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstallDeps {
|
||||
writeFile(path: string, content: string): void
|
||||
runCommand(cmd: string, args: readonly string[]): Promise<void>
|
||||
getuid(): number
|
||||
homedir(): string
|
||||
username(): string
|
||||
binPath(): string
|
||||
}
|
||||
|
||||
/** Map a Node platform to its service manager, or null if unsupported. */
|
||||
export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
|
||||
if (os === 'darwin') return 'launchd'
|
||||
if (os === 'linux') return 'systemd'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the BASE-APP env injected into the base-app unit. Runs the S-GATE on `BIND_HOST` (throws
|
||||
* `BindHostError` on a non-loopback value, before any write) and, when a `domain` (+ `cfg.subdomain`)
|
||||
* is supplied, merges the tunnel origin `https://<subdomain>.<zone>.<domain>` into ALLOWED_ORIGINS —
|
||||
* never weakening an origin the caller already provided. Immutable.
|
||||
*/
|
||||
function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
|
||||
const base = options.env ?? {}
|
||||
const bindHost = normalizeBindHost(base.BIND_HOST) // S-GATE — throws on non-loopback
|
||||
const withBind: ServiceEnv = { ...base, BIND_HOST: bindHost }
|
||||
if (!options.domain || !cfg.subdomain) return withBind
|
||||
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
|
||||
return { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write + load BOTH service units for `platform`: the base-app (`node dist/server.js` + base-app
|
||||
* env) and the agent (`<bin> run`, supervises frpc — no base-app env). Throws RootRefusedError if
|
||||
* running as root, or BindHostError (S-GATE) BEFORE any write if the base-app BIND_HOST is not
|
||||
* loopback (so a rejected install emits nothing).
|
||||
*/
|
||||
export async function installService(
|
||||
cfg: AgentConfig,
|
||||
platform: ServicePlatform,
|
||||
deps: InstallDeps,
|
||||
options: InstallOptions = {},
|
||||
): Promise<void> {
|
||||
if (deps.getuid() === 0) throw new RootRefusedError()
|
||||
// Resolve (and S-GATE) the base-app env BEFORE any IO — a non-loopback BIND_HOST throws here,
|
||||
// so nothing is ever written for a rejected install.
|
||||
const baseAppEnv = resolveBaseAppEnv(cfg, options)
|
||||
const bin = deps.binPath()
|
||||
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
|
||||
|
||||
if (platform === 'launchd') {
|
||||
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
|
||||
deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnv, baseAppLabel()))
|
||||
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
|
||||
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel()))
|
||||
for (const path of [baseAppPath, agentPath]) {
|
||||
const { cmd, args } = launchdLoadCommand(path)
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const baseAppOptions: SystemdUnitOptions = options.envFile
|
||||
? { env: baseAppEnv, envFile: options.envFile }
|
||||
: { env: baseAppEnv }
|
||||
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
|
||||
deps.writeFile(
|
||||
baseAppPath,
|
||||
buildSystemdUnit(baseAppExec.join(' '), deps.username(), baseAppOptions, 'web-terminal base app (loopback)'),
|
||||
)
|
||||
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
|
||||
deps.writeFile(
|
||||
agentPath,
|
||||
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'),
|
||||
)
|
||||
for (const unit of [baseAppUnitName(), agentUnitName()]) {
|
||||
const { cmd, args } = systemdEnableCommand(unit)
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unload/disable BOTH service units for `platform` (agent first, then base-app). */
|
||||
export async function uninstallService(
|
||||
platform: ServicePlatform,
|
||||
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
|
||||
): Promise<void> {
|
||||
if (platform === 'launchd') {
|
||||
for (const label of [agentLabel(), baseAppLabel()]) {
|
||||
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const unit of [agentUnitName(), baseAppUnitName()]) {
|
||||
const { cmd, args } = systemdDisableCommand(unit)
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
106
agent/src/service/launchd.ts
Normal file
106
agent/src/service/launchd.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
||||
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the plist can inject a caller-supplied per-host env map
|
||||
* (BIND_HOST, ALLOWED_ORIGINS, PORT, SHELL_PATH, IDLE_TTL, USE_TMUX, …) as a launchd
|
||||
* `<key>EnvironmentVariables</key><dict>…</dict>` block. Values are XML-escaped and keys are
|
||||
* sorted for deterministic, immutable output.
|
||||
*
|
||||
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on `label` +
|
||||
* `programArguments`, so a native-tunnel install emits TWO distinct plists — the base-app
|
||||
* (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which supervises
|
||||
* frpc). Base-app env is routed to the base-app plist ONLY by the caller (`install.ts`).
|
||||
*/
|
||||
|
||||
/** Shared env-map shape for the durable-service writers (launchd + systemd). Immutable. */
|
||||
export type ServiceEnv = Readonly<Record<string, string>>
|
||||
|
||||
/** The native-tunnel agent unit (supervises frpc). */
|
||||
const AGENT_LABEL = 'com.web-terminal.agent'
|
||||
/** The base-app unit (`node dist/server.js`, loopback-bound). */
|
||||
const BASE_APP_LABEL = 'com.web-terminal.base-app'
|
||||
|
||||
export function agentLabel(): string {
|
||||
return AGENT_LABEL
|
||||
}
|
||||
export function baseAppLabel(): string {
|
||||
return BASE_APP_LABEL
|
||||
}
|
||||
/** Back-compat alias for the agent label. */
|
||||
export function launchdLabel(): string {
|
||||
return AGENT_LABEL
|
||||
}
|
||||
|
||||
export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
|
||||
return `${homedir}/Library/LaunchAgents/${label}.plist`
|
||||
}
|
||||
|
||||
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** Build the `<key>EnvironmentVariables</key><dict>…</dict>` lines (empty array when env is empty). */
|
||||
function environmentVariablesBlock(env: ServiceEnv): readonly string[] {
|
||||
const entries = Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
|
||||
if (entries.length === 0) return []
|
||||
const lines = [' <key>EnvironmentVariables</key>', ' <dict>']
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(` <key>${escapeXml(key)}</key>`)
|
||||
lines.push(` <string>${escapeXml(value)}</string>`)
|
||||
}
|
||||
lines.push(' </dict>')
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Build the `<key>ProgramArguments</key><array>…</array>` lines. */
|
||||
function programArgumentsBlock(programArguments: readonly string[]): readonly string[] {
|
||||
const lines = [' <key>ProgramArguments</key>', ' <array>']
|
||||
for (const arg of programArguments) {
|
||||
lines.push(` <string>${escapeXml(arg)}</string>`)
|
||||
}
|
||||
lines.push(' </array>')
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a plist for `label` running `programArguments` (e.g. `[bin, 'run']` or `[node, serverJs]`);
|
||||
* RunAtLoad + KeepAlive (restart on failure). When `env` is non-empty, a launchd
|
||||
* `EnvironmentVariables` dict is injected. Pure/immutable — returns a fresh string.
|
||||
*/
|
||||
export function buildLaunchdPlist(
|
||||
programArguments: readonly string[],
|
||||
env: ServiceEnv = {},
|
||||
label: string = AGENT_LABEL,
|
||||
): string {
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||
'<plist version="1.0">',
|
||||
'<dict>',
|
||||
' <key>Label</key>',
|
||||
` <string>${escapeXml(label)}</string>`,
|
||||
...programArgumentsBlock(programArguments),
|
||||
' <key>RunAtLoad</key>',
|
||||
' <true/>',
|
||||
' <key>KeepAlive</key>',
|
||||
' <true/>',
|
||||
...environmentVariablesBlock(env),
|
||||
'</dict>',
|
||||
'</plist>',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function launchdLoadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'launchctl', args: ['load', plistPath] }
|
||||
}
|
||||
export function launchdUnloadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'launchctl', args: ['unload', plistPath] }
|
||||
}
|
||||
81
agent/src/service/originConfig.ts
Normal file
81
agent/src/service/originConfig.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
||||
* APPENDS `https://<subdomain>.<zone>.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
||||
* as CONFIG — NO `src/` code edit. AUGMENTS, never weakens, the Origin/CSWSH check: existing
|
||||
* origins are always preserved (EXPLORE §3 "do not weaken the check").
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the DNS zone label is PARAMETERIZED. Relay callers keep the historical
|
||||
* `term` zone (default), while native-tunnel hosts pass `terminal` so the base app trusts
|
||||
* `https://<name>.terminal.<domain>`. The default is preserved so existing callers/tests are
|
||||
* unaffected — the zone is opt-in per call, never hard-flipped.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
export interface OriginFsDeps {
|
||||
exists(path: string): boolean
|
||||
read(path: string): string
|
||||
write(path: string, content: string): void
|
||||
}
|
||||
|
||||
const defaultFs: OriginFsDeps = {
|
||||
exists: existsSync,
|
||||
read: (p) => readFileSync(p, 'utf8'),
|
||||
write: (p, c) => writeFileSync(p, c),
|
||||
}
|
||||
|
||||
/** Default DNS zone label (relay hosts). Native-tunnel hosts pass `terminal`. */
|
||||
export const DEFAULT_ORIGIN_ZONE = 'term'
|
||||
|
||||
const KEY = 'ALLOWED_ORIGINS'
|
||||
|
||||
/** Compose the subdomain origin the base app must trust: `https://<subdomain>.<zone>.<domain>`. */
|
||||
export function subdomainOrigin(
|
||||
subdomain: string,
|
||||
domain: string,
|
||||
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||
): string {
|
||||
return `https://${subdomain}.${zone}.${domain}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `origin` into a comma-separated ALLOWED_ORIGINS value, preserving every existing origin and
|
||||
* de-duplicating. Returns the merged CSV; never removes an origin. Pure/immutable.
|
||||
*/
|
||||
export function mergeOrigins(current: string | undefined, origin: string): string {
|
||||
const origins = (current ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
if (origins.includes(origin)) return origins.join(',')
|
||||
return [...origins, origin].join(',')
|
||||
}
|
||||
|
||||
function upsertOriginLine(content: string, origin: string): string {
|
||||
const lines = content.length === 0 ? [] : content.split('\n')
|
||||
let found = false
|
||||
const next = lines.map((line) => {
|
||||
if (!line.startsWith(`${KEY}=`)) return line
|
||||
found = true
|
||||
return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
|
||||
})
|
||||
if (!found) next.push(`${KEY}=${origin}`)
|
||||
return next.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
||||
* existing origin. Creates the file/line if absent. `zone` selects the DNS zone label (default
|
||||
* preserves relay callers).
|
||||
*/
|
||||
export function ensureAllowedOrigin(
|
||||
baseAppEnvPath: string,
|
||||
subdomain: string,
|
||||
domain: string,
|
||||
fs: OriginFsDeps = defaultFs,
|
||||
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||
): void {
|
||||
const origin = subdomainOrigin(subdomain, domain, zone)
|
||||
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
||||
const updated = upsertOriginLine(existing, origin)
|
||||
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
||||
}
|
||||
143
agent/src/service/systemd.ts
Normal file
143
agent/src/service/systemd.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
|
||||
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the unit can inject the per-host tunnel env via an `EnvironmentFile=`
|
||||
* line (preferred — keeps values out of the world-readable unit) and/or inline `Environment=`
|
||||
* lines from a caller-supplied env map. Inline `Environment=` is emitted after `EnvironmentFile=`
|
||||
* so an explicit value overrides the file on conflict.
|
||||
*
|
||||
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on the full
|
||||
* `ExecStart` command + `Description`, so a native-tunnel install emits TWO distinct units — the
|
||||
* base-app (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which
|
||||
* supervises frpc). Base-app env is routed to the base-app unit ONLY by the caller (`install.ts`).
|
||||
*/
|
||||
import type { ServiceEnv } from './launchd.js'
|
||||
|
||||
/** The native-tunnel agent unit (supervises frpc). */
|
||||
const AGENT_UNIT = 'web-terminal-agent.service'
|
||||
/** The base-app unit (`node dist/server.js`, loopback-bound). */
|
||||
const BASE_APP_UNIT = 'web-terminal-base-app.service'
|
||||
|
||||
/** DEL (0x7F) and everything below the printable ASCII range are rejected in env values. */
|
||||
const FIRST_PRINTABLE_ASCII = 0x20
|
||||
const DEL_CODE = 0x7f
|
||||
|
||||
export interface SystemdUnitOptions {
|
||||
/** Inline env map → one `Environment="KEY=value"` line each (keys sorted, values escaped). */
|
||||
readonly env?: ServiceEnv
|
||||
/** Path referenced by a single `EnvironmentFile=` line (preferred over inline for secrets). */
|
||||
readonly envFile?: string
|
||||
}
|
||||
|
||||
export function agentUnitName(): string {
|
||||
return AGENT_UNIT
|
||||
}
|
||||
export function baseAppUnitName(): string {
|
||||
return BASE_APP_UNIT
|
||||
}
|
||||
/** Back-compat alias for the agent unit name. */
|
||||
export function systemdUnitName(): string {
|
||||
return AGENT_UNIT
|
||||
}
|
||||
|
||||
export function systemdUnitPath(homedir: string, unitName: string = AGENT_UNIT): string {
|
||||
return `${homedir}/.config/systemd/user/${unitName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `value` contains any control character (C0 range below 0x20, or DEL 0x7F). A raw
|
||||
* newline/CR would terminate the current line and let the remainder inject arbitrary directives into
|
||||
* the `[Service]` section — so such values are rejected rather than escaped.
|
||||
*/
|
||||
function hasControlChar(value: string): boolean {
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0)
|
||||
if (code === undefined) continue
|
||||
if (code < FIRST_PRINTABLE_ASCII || code === DEL_CODE) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject ANY field interpolated into the unit that carries a control character. EVERY value written
|
||||
* into the unit (ExecStart, User, Description, EnvironmentFile path, and each Environment key+value)
|
||||
* flows through this one guard, so no field can smuggle a newline that starts a new `[Service]`
|
||||
* directive. Fail loud rather than escape — these fields are never legitimately multi-line.
|
||||
*/
|
||||
function assertNoControlChar(fieldName: string, value: string): void {
|
||||
if (hasControlChar(value)) {
|
||||
throw new Error(
|
||||
`refusing to write systemd unit: ${fieldName} contains a control character ` +
|
||||
'(newline/CR/etc.) that could corrupt the [Service] section',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Quote a systemd `Environment=` value: reject control chars (key AND value), then escape `\` and `"`. */
|
||||
function quoteEnvAssignment(key: string, value: string): string {
|
||||
assertNoControlChar(`Environment key '${key}'`, key)
|
||||
assertNoControlChar(`Environment value for '${key}'`, value)
|
||||
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
return `"${key}=${escaped}"`
|
||||
}
|
||||
|
||||
/** Build the `EnvironmentFile=` / `Environment=` lines (empty array when neither is supplied). */
|
||||
function environmentLines(options: SystemdUnitOptions): readonly string[] {
|
||||
const lines: string[] = []
|
||||
if (options.envFile) {
|
||||
assertNoControlChar('EnvironmentFile path', options.envFile)
|
||||
lines.push(`EnvironmentFile=${options.envFile}`)
|
||||
}
|
||||
const entries = Object.entries(options.env ?? {}).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(`Environment=${quoteEnvAssignment(key, value)}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a unit whose `ExecStart` is `execStart` (a full command line, e.g. `<bin> run` or
|
||||
* `node dist/server.js`); Restart=on-failure; User=<user> (never root). When `options.env`/
|
||||
* `options.envFile` are supplied, the per-host tunnel env is injected. Pure/immutable.
|
||||
*/
|
||||
export function buildSystemdUnit(
|
||||
execStart: string,
|
||||
user: string,
|
||||
options: SystemdUnitOptions = {},
|
||||
description: string = 'web-terminal host agent',
|
||||
): string {
|
||||
// Guard every non-env field the same way env values are guarded — a newline in ExecStart/User/
|
||||
// Description would otherwise inject a `[Service]` directive on the next line.
|
||||
assertNoControlChar('ExecStart', execStart)
|
||||
assertNoControlChar('User', user)
|
||||
assertNoControlChar('Description', description)
|
||||
return [
|
||||
'[Unit]',
|
||||
`Description=${description}`,
|
||||
'After=network-online.target',
|
||||
'',
|
||||
'[Service]',
|
||||
'Type=simple',
|
||||
`ExecStart=${execStart}`,
|
||||
'Restart=on-failure',
|
||||
'RestartSec=1',
|
||||
`User=${user}`,
|
||||
...environmentLines(options),
|
||||
'',
|
||||
'[Install]',
|
||||
'WantedBy=default.target',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function systemdEnableCommand(
|
||||
unitName: string = AGENT_UNIT,
|
||||
): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', unitName] }
|
||||
}
|
||||
export function systemdDisableCommand(
|
||||
unitName: string = AGENT_UNIT,
|
||||
): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', unitName] }
|
||||
}
|
||||
63
agent/src/transport/backoff.ts
Normal file
63
agent/src/transport/backoff.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Reconnection / backoff — PLAN_RELAY_AGENT T10. REUSES the base app's 1/2/4…cap-30s policy
|
||||
* (EXPLORE §3). `reconnectLoop` only CONSUMES an injected `isRevoked()` (the W0 seam) — a revoked
|
||||
* host short-circuits and never reconnects (INV12). No task edge to T14.
|
||||
*/
|
||||
import type { Tunnel } from './tunnel.js'
|
||||
|
||||
export const BACKOFF_BASE_MS = 1_000
|
||||
export const BACKOFF_CAP_MS = 30_000
|
||||
|
||||
export interface BackoffPolicy {
|
||||
nextDelayMs(): number
|
||||
reset(): void
|
||||
}
|
||||
|
||||
/** Exponential backoff 1s,2s,4s…capped at 30s, optional [0.5×,1×] jitter. */
|
||||
export function createBackoff(
|
||||
opts: { baseMs?: number; capMs?: number; jitter?: boolean; rng?: () => number } = {},
|
||||
): BackoffPolicy {
|
||||
const baseMs = opts.baseMs ?? BACKOFF_BASE_MS
|
||||
const capMs = opts.capMs ?? BACKOFF_CAP_MS
|
||||
const jitter = opts.jitter ?? false
|
||||
const rng = opts.rng ?? Math.random
|
||||
let attempt = 0
|
||||
return {
|
||||
nextDelayMs(): number {
|
||||
const raw = Math.min(baseMs * 2 ** attempt, capMs)
|
||||
attempt += 1
|
||||
if (!jitter) return raw
|
||||
return Math.round(raw * (0.5 + rng() * 0.5)) // [0.5×, 1×]
|
||||
},
|
||||
reset(): void {
|
||||
attempt = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type Sleep = (ms: number) => Promise<void>
|
||||
const realSleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/**
|
||||
* Dial with backoff until success (resolves the connected Tunnel) or the host is revoked
|
||||
* (resolves null — never reconnect). Each dial failure waits `backoff.nextDelayMs()`; a success
|
||||
* resets the backoff.
|
||||
*/
|
||||
export async function reconnectLoop(
|
||||
dial: () => Promise<Tunnel>,
|
||||
backoff: BackoffPolicy,
|
||||
isRevoked: () => boolean,
|
||||
sleep: Sleep = realSleep,
|
||||
): Promise<Tunnel | null> {
|
||||
while (!isRevoked()) {
|
||||
try {
|
||||
const tunnel = await dial()
|
||||
backoff.reset()
|
||||
return tunnel
|
||||
} catch {
|
||||
if (isRevoked()) return null
|
||||
await sleep(backoff.nextDelayMs())
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
103
agent/src/transport/dial.ts
Normal file
103
agent/src/transport/dial.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Outbound mTLS dial — PLAN_RELAY_AGENT T12 (INV14/INV4). Builds a wss:// client authenticated by
|
||||
* the client cert + IN-PROCESS Ed25519 key + pinned CA chain, `rejectUnauthorized: true`, and NO
|
||||
* bearer/agent token (mTLS IS the auth). Absent/expired cert ⇒ fail-fast, no dial.
|
||||
*/
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
export class NotEnrolledError extends Error {
|
||||
constructor() {
|
||||
super('agent is not enrolled (no identity/cert in keystore)')
|
||||
this.name = 'NotEnrolledError'
|
||||
}
|
||||
}
|
||||
export class CertExpiredError extends Error {
|
||||
constructor() {
|
||||
super('client certificate has expired; renew before dialling')
|
||||
this.name = 'CertExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
/** TLS material for the wss client. NOTE: `rejectUnauthorized` is ALWAYS true (anti-MITM). */
|
||||
export interface TlsClientOptions {
|
||||
readonly cert: string
|
||||
readonly key: string
|
||||
readonly ca: string
|
||||
readonly rejectUnauthorized: true
|
||||
}
|
||||
|
||||
export interface CertInfo {
|
||||
readonly validTo: Date
|
||||
}
|
||||
export type CertParser = (certPem: string) => CertInfo
|
||||
const defaultCertParser: CertParser = (pem) => ({ validTo: new Date(new X509Certificate(pem).validTo) })
|
||||
|
||||
/**
|
||||
* Assemble the mTLS options from the keystore. Throws NotEnrolledError if key/cert are missing,
|
||||
* CertExpiredError if the cert is past `validTo`. There is NO token field by construction (INV4).
|
||||
*/
|
||||
export function buildTlsOptions(
|
||||
ks: Keystore,
|
||||
opts: { now?: Date; certParser?: CertParser } = {},
|
||||
): TlsClientOptions {
|
||||
const id = ks.loadIdentity()
|
||||
const certs = ks.loadCert()
|
||||
if (id === null || certs === null) throw new NotEnrolledError()
|
||||
const parse = opts.certParser ?? defaultCertParser
|
||||
const now = opts.now ?? new Date()
|
||||
if (parse(certs.certPem).validTo.getTime() < now.getTime()) throw new CertExpiredError()
|
||||
return {
|
||||
cert: certs.certPem,
|
||||
key: id.exportPrivatePkcs8Pem(),
|
||||
ca: certs.caChainPem,
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawTlsWs {
|
||||
send(data: Uint8Array): void
|
||||
close(): void
|
||||
on(event: string, cb: (...args: unknown[]) => void): void
|
||||
once(event: string, cb: (...args: unknown[]) => void): void
|
||||
}
|
||||
export type TlsWsConstructor = new (url: string, opts: TlsClientOptions) => RawTlsWs
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
return null
|
||||
}
|
||||
|
||||
function adapt(raw: RawTlsWs): WsLike {
|
||||
return {
|
||||
send: (d) => raw.send(d),
|
||||
on(ev, cb) {
|
||||
if (ev === 'message') {
|
||||
raw.on('message', (data: unknown) => {
|
||||
const bytes = toU8(data)
|
||||
if (bytes !== null) cb(bytes)
|
||||
})
|
||||
} else {
|
||||
raw.on(ev, cb)
|
||||
}
|
||||
},
|
||||
close: () => raw.close(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Dial the relay's /agent endpoint over mTLS wss. Resolves the connected WsLike on open. */
|
||||
export function dialRelay(
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
opts: { Ctor: TlsWsConstructor; now?: Date; certParser?: CertParser },
|
||||
): Promise<WsLike> {
|
||||
const tls = buildTlsOptions(ks, { ...(opts.now ? { now: opts.now } : {}), ...(opts.certParser ? { certParser: opts.certParser } : {}) })
|
||||
return new Promise<WsLike>((resolve, reject) => {
|
||||
const raw = new opts.Ctor(cfg.relayUrl, tls)
|
||||
raw.once('open', () => resolve(adapt(raw)))
|
||||
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
|
||||
})
|
||||
}
|
||||
41
agent/src/transport/flowControl.ts
Normal file
41
agent/src/transport/flowControl.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Per-stream flow control — PLAN_RELAY_AGENT T11. CONSUMES the §4.1 WINDOW_UPDATE credit protocol
|
||||
* (P1 owns the protocol). Per-stream credit means one heavy vim/top redraw can't starve another
|
||||
* stream. streamId 0 is the connection-level window applied to the whole link.
|
||||
*/
|
||||
export interface FlowController {
|
||||
consume(streamId: number, bytes: number): boolean
|
||||
grant(streamId: number, credit: number): void
|
||||
initWindow(streamId: number, initialCredit: number): void
|
||||
}
|
||||
|
||||
const CONNECTION_STREAM_ID = 0
|
||||
|
||||
export function createFlowController(): FlowController {
|
||||
const windows = new Map<number, number>()
|
||||
// Connection-level window is unbounded until explicitly initialized.
|
||||
windows.set(CONNECTION_STREAM_ID, Number.POSITIVE_INFINITY)
|
||||
|
||||
function remaining(streamId: number): number {
|
||||
return windows.get(streamId) ?? 0
|
||||
}
|
||||
|
||||
return {
|
||||
initWindow(streamId: number, initialCredit: number): void {
|
||||
windows.set(streamId, initialCredit)
|
||||
},
|
||||
grant(streamId: number, credit: number): void {
|
||||
windows.set(streamId, remaining(streamId) + credit)
|
||||
},
|
||||
consume(streamId: number, bytes: number): boolean {
|
||||
const conn = remaining(CONNECTION_STREAM_ID)
|
||||
const stream = remaining(streamId)
|
||||
if (stream < bytes || conn < bytes) return false // credit exhausted → pause
|
||||
windows.set(streamId, stream - bytes)
|
||||
if (conn !== Number.POSITIVE_INFINITY) {
|
||||
windows.set(CONNECTION_STREAM_ID, conn - bytes)
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
72
agent/src/transport/frpScaffold.ts
Normal file
72
agent/src/transport/frpScaffold.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* v0.8 frpc-wrap stepping-stone — PLAN_RELAY_AGENT T6. Fastest path to the café demo: wraps a
|
||||
* child `frpc` presenting the shared v0.8 `agentToken`, registering the subdomain, forwarding to
|
||||
* 127.0.0.1:3000. EXPLICITLY a stepping-stone — the native mux (T7–T11) replaces it at v0.9.
|
||||
*
|
||||
* Forwards ONLY to loopback (anti-SSRF). Retired once EnrollMode==='ed25519' (guard below).
|
||||
*/
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import { isLoopbackWsUrl } from '../config/agentConfig.js'
|
||||
import type { EnrollMode } from '../enroll/pair.js'
|
||||
|
||||
export interface FrpScaffold {
|
||||
start(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
onExit(cb: (code: number) => void): void
|
||||
}
|
||||
|
||||
/** Minimal child-process seam so tests inject a fake spawn (no real frpc needed). */
|
||||
export interface ChildLike {
|
||||
on(ev: 'exit', cb: (code: number | null) => void): void
|
||||
kill(): void
|
||||
}
|
||||
export type SpawnImpl = (cmd: string, args: readonly string[]) => ChildLike
|
||||
|
||||
const LOOPBACK_IP = '127.0.0.1'
|
||||
const LOCAL_PORT = 3000
|
||||
|
||||
/** Build the frpc.toml. local_ip is ALWAYS loopback; tls is enabled. */
|
||||
export function buildFrpcToml(cfg: AgentConfig): string {
|
||||
if (!isLoopbackWsUrl(cfg.localTargetUrl)) {
|
||||
throw new Error('frpScaffold refuses a non-loopback localTargetUrl (anti-SSRF)')
|
||||
}
|
||||
const subdomain = cfg.subdomain ?? ''
|
||||
return [
|
||||
'[common]',
|
||||
'tls_enable = true',
|
||||
'',
|
||||
'[web-terminal]',
|
||||
'type = "tcp"',
|
||||
`local_ip = "${LOOPBACK_IP}"`,
|
||||
`local_port = ${LOCAL_PORT}`,
|
||||
`subdomain = "${subdomain}"`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** True once the native Ed25519 substrate is active — `run` must NOT wire frpc then. */
|
||||
export function isFrpRetired(mode: EnrollMode): boolean {
|
||||
return mode === 'ed25519'
|
||||
}
|
||||
|
||||
/** Spawn (a mockable) frpc child with a generated config. */
|
||||
export function spawnFrpc(cfg: AgentConfig, frpcPath: string, spawnImpl: SpawnImpl): FrpScaffold {
|
||||
const toml = buildFrpcToml(cfg) // validates loopback before spawning
|
||||
void toml
|
||||
let child: ChildLike | null = null
|
||||
const exitCbs: Array<(code: number) => void> = []
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
child = spawnImpl(frpcPath, ['-c', 'frpc.toml'])
|
||||
child.on('exit', (code) => {
|
||||
for (const cb of exitCbs) cb(code ?? 0)
|
||||
})
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
child?.kill()
|
||||
},
|
||||
onExit(cb: (code: number) => void): void {
|
||||
exitCbs.push(cb)
|
||||
},
|
||||
}
|
||||
}
|
||||
200
agent/src/transport/frpSupervise.ts
Normal file
200
agent/src/transport/frpSupervise.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Native-tunnel frpc supervisor — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2).
|
||||
*
|
||||
* Spawns the pinned `frpc` child with the written `frpc.toml` and keeps it running: on every exit
|
||||
* it restarts the child after an exponential backoff (REUSES `transport/backoff.ts` — the same
|
||||
* 1s/2s/4s…cap-30s policy as the relay reconnect). A child that ran longer than a stability window
|
||||
* resets the backoff, so a healthy long-lived tunnel that finally dies restarts fast, while a
|
||||
* crash-loop stays capped at 30s. All IO — spawn, sleep, clock — is injected so the loop is
|
||||
* fully offline-testable (no real frpc / no real timers). No `console.log` (INV9 redacting logger).
|
||||
*
|
||||
* SCOPE (deferral #11): the real `frpc` binary run is pending B3 tar.gz extraction; the default
|
||||
* `spawn` shells out to the provisioned binary, but tests inject a fake child so nothing executes.
|
||||
*
|
||||
* LOG CAPTURE (B4/H4 wiring fix): when a `logFile` is supplied, the default spawn tees the frpc
|
||||
* child's stdout+stderr into that file (truncated per (re)spawn) so the health probe's log scanner
|
||||
* (`readFrpcLog` → `frpcProxyStarted`) has real content to match "start proxy success" against.
|
||||
* Truncate-per-spawn keeps the file bounded to the CURRENT child and free of a stale success line
|
||||
* from a dead one — a freshly connected frpc always re-logs the success line.
|
||||
*/
|
||||
import { spawn as nodeSpawn } from 'node:child_process'
|
||||
import { createWriteStream, mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { createBackoff, type BackoffPolicy, type Sleep } from './backoff.js'
|
||||
import { createLogger, type Logger } from '../log/logger.js'
|
||||
|
||||
/** A child process seam the supervisor drives (a fake child in tests; a real frpc in prod). */
|
||||
export interface FrpcChild {
|
||||
/** Register a one-shot exit handler (`null` code ⇒ killed by signal or spawn error). */
|
||||
onExit(cb: (code: number | null) => void): void
|
||||
/** Whether the child is still running (feeds the health probe's `isFrpcAlive`). */
|
||||
isAlive(): boolean
|
||||
/** Request termination. */
|
||||
kill(): void
|
||||
}
|
||||
|
||||
/** Spawn a frpc child running `frpc -c <tomlPath>`. */
|
||||
export type SpawnFrpc = (binPath: string, tomlPath: string) => FrpcChild
|
||||
|
||||
/** Injectable seams for the supervisor; unset fields default to real spawn/sleep/clock/logger. */
|
||||
export interface FrpSuperviseDeps {
|
||||
spawn: SpawnFrpc
|
||||
backoff: BackoffPolicy
|
||||
sleep: Sleep
|
||||
logger: Logger
|
||||
now: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervisor overrides: any `FrpSuperviseDeps` seam plus an optional `logFile`. When `logFile` is
|
||||
* set and no explicit `spawn` is given, the default spawn tees frpc's stdout/stderr into that file
|
||||
* so the health probe can scan it. An explicit `spawn` (tests) always wins over `logFile`.
|
||||
*/
|
||||
export interface FrpSuperviseOptions extends Partial<FrpSuperviseDeps> {
|
||||
readonly logFile?: string
|
||||
}
|
||||
|
||||
/** Handle returned by `superviseFrpc`: stop the loop, or await its terminal exit code. */
|
||||
export interface FrpSuperviseHandle {
|
||||
/** Request graceful shutdown (kills the child); resolves once the loop has fully stopped. */
|
||||
stop(): Promise<void>
|
||||
/** Resolves with an exit code (always 0 — a stopped supervisor is a clean shutdown). */
|
||||
readonly done: Promise<number>
|
||||
/** True while a frpc child is currently running (for the health probe). */
|
||||
isChildAlive(): boolean
|
||||
}
|
||||
|
||||
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
|
||||
export const STABLE_RUN_MS = 60_000
|
||||
|
||||
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
/**
|
||||
* Default spawn (no log capture): launch the provisioned frpc binary, discarding its stdout/stderr.
|
||||
* `stdio: 'ignore'` (not `'pipe'`) is deliberate — an unconsumed pipe fills its OS buffer (~64KB) and
|
||||
* then BLOCKS the child. Log capture is opt-in via `createFileLoggingSpawn` (see `logFile`).
|
||||
*/
|
||||
const realSpawn: SpawnFrpc = (binPath, tomlPath) => {
|
||||
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'ignore', 'ignore'] })
|
||||
let alive = true
|
||||
return {
|
||||
onExit(cb: (code: number | null) => void): void {
|
||||
cp.once('exit', (code) => {
|
||||
alive = false
|
||||
cb(code)
|
||||
})
|
||||
cp.once('error', () => {
|
||||
alive = false
|
||||
cb(null)
|
||||
})
|
||||
},
|
||||
isAlive: () => alive,
|
||||
kill: () => {
|
||||
cp.kill()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn frpc and TEE its stdout+stderr into `logFile` (truncated per spawn) so the health probe's
|
||||
* `readFrpcLog` scanner has real content. A log-write failure is swallowed — persisting the log for
|
||||
* the probe must never crash the supervised tunnel.
|
||||
*/
|
||||
export function createFileLoggingSpawn(logFile: string): SpawnFrpc {
|
||||
return (binPath, tomlPath) => {
|
||||
mkdirSync(dirname(logFile), { recursive: true })
|
||||
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
// flags:'w' truncates so the file reflects only the current child (no stale success line).
|
||||
const log = createWriteStream(logFile, { flags: 'w' })
|
||||
log.on('error', () => {
|
||||
/* a log-write failure is non-fatal to the tunnel; the probe just sees no success line */
|
||||
})
|
||||
cp.stdout?.pipe(log, { end: false })
|
||||
cp.stderr?.pipe(log, { end: false })
|
||||
let alive = true
|
||||
const closeLog = (): void => {
|
||||
log.end()
|
||||
}
|
||||
return {
|
||||
onExit(cb: (code: number | null) => void): void {
|
||||
cp.once('exit', (code) => {
|
||||
alive = false
|
||||
closeLog()
|
||||
cb(code)
|
||||
})
|
||||
cp.once('error', () => {
|
||||
alive = false
|
||||
closeLog()
|
||||
cb(null)
|
||||
})
|
||||
},
|
||||
isAlive: () => alive,
|
||||
kill: () => {
|
||||
cp.kill()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeps(o?: FrpSuperviseOptions): FrpSuperviseDeps {
|
||||
const defaultSpawn = o?.logFile !== undefined ? createFileLoggingSpawn(o.logFile) : realSpawn
|
||||
return {
|
||||
spawn: o?.spawn ?? defaultSpawn,
|
||||
backoff: o?.backoff ?? createBackoff({ jitter: true }),
|
||||
sleep: o?.sleep ?? realSleep,
|
||||
logger: o?.logger ?? createLogger('info'),
|
||||
now: o?.now ?? Date.now,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start supervising frpc. Returns immediately with a handle; the restart loop runs in the
|
||||
* background. `stop()` sets the stop flag, kills the live child, and awaits loop termination.
|
||||
*/
|
||||
export function superviseFrpc(
|
||||
binPath: string,
|
||||
tomlPath: string,
|
||||
overrides?: FrpSuperviseOptions,
|
||||
): FrpSuperviseHandle {
|
||||
const { spawn, backoff, sleep, logger, now } = resolveDeps(overrides)
|
||||
|
||||
let stopped = false
|
||||
let child: FrpcChild | null = null
|
||||
|
||||
/** Spawn one frpc child and resolve when it exits. */
|
||||
function runOnce(): Promise<number | null> {
|
||||
return new Promise<number | null>((resolve) => {
|
||||
const c = spawn(binPath, tomlPath)
|
||||
child = c
|
||||
c.onExit((code) => {
|
||||
child = null
|
||||
resolve(code)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function loop(): Promise<number> {
|
||||
while (!stopped) {
|
||||
const startedAt = now()
|
||||
const exitCode = await runOnce()
|
||||
if (stopped) break
|
||||
if (now() - startedAt >= STABLE_RUN_MS) backoff.reset()
|
||||
const delayMs = backoff.nextDelayMs()
|
||||
logger.log('warn', 'frpc exited — restarting after backoff', { exitCode, delayMs })
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const done = loop()
|
||||
|
||||
return {
|
||||
async stop(): Promise<void> {
|
||||
stopped = true
|
||||
child?.kill()
|
||||
await done
|
||||
},
|
||||
done,
|
||||
isChildAlive: () => child?.isAlive() ?? false,
|
||||
}
|
||||
}
|
||||
155
agent/src/transport/frpcToml.ts
Normal file
155
agent/src/transport/frpcToml.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Native-tunnel frpc.toml writer — TASK B2h (PLAN_TUNNEL_AUTOMATION §3.2 / PLAN_NATIVE_TUNNEL §4).
|
||||
*
|
||||
* Emits the frp v0.61 TOML that a host's `frpc` presents to the VPS:
|
||||
* - dials `serverAddr:443`, SNI-routed by nginx `ssl_preread` to frps :7000;
|
||||
* - control-channel mTLS (frp-client cert/key + trusted CA) + shared token;
|
||||
* - one `[[proxies]]` of `type = "http"` exposing the loopback base app as `<sub>.terminal...`.
|
||||
*
|
||||
* SUPERSEDES the retired v0.8 `[common]/tls_enable` shape in `frpScaffold.ts` (do not reuse that
|
||||
* grammar — this is the native-tunnel writer).
|
||||
*
|
||||
* ANTI-SSRF (hard invariant): `localIP` MUST be loopback. frpc forwards ONLY to the local base app,
|
||||
* never an arbitrary target — a non-loopback `localIP` would turn the tunnel into an open proxy.
|
||||
* All inputs are validated at this boundary (fail-fast, clear messages); no `console.log`.
|
||||
*/
|
||||
|
||||
const DEFAULT_SERVER_ADDR = '8.138.1.192'
|
||||
const SERVER_PORT = 443
|
||||
const TLS_SERVER_NAME = 'frp.terminal.yaojia.wang'
|
||||
const DEFAULT_LOCAL_IP = '127.0.0.1'
|
||||
const MIN_PORT = 1
|
||||
const MAX_PORT = 65535
|
||||
const MAX_LABEL_LEN = 63
|
||||
const DEL_CHAR_CODE = 0x7f
|
||||
const FIRST_PRINTABLE_CODE = 0x20
|
||||
|
||||
/** Loopback forms accepted for `localIP` (anti-SSRF allowlist). */
|
||||
const LOOPBACK_IPS: readonly string[] = ['127.0.0.1', '::1', 'localhost']
|
||||
|
||||
/** RFC 1035 DNS label: 1-63 chars, alnum, internal hyphens only (no leading/trailing hyphen). */
|
||||
const LABEL_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
||||
|
||||
export interface NativeFrpcOptions {
|
||||
/** Subdomain label -> reachable at `https://<subdomain>.terminal.yaojia.wang`. Label-safe. */
|
||||
readonly subdomain: string
|
||||
/** Loopback port of the local base app frpc forwards to (1-65535). */
|
||||
readonly localPort: number
|
||||
/** Shared frps auth token (kept out of logs; the file itself is chmod 600 by the caller). */
|
||||
readonly authToken: string
|
||||
/** Keystore path to this host's frp-client leaf cert (control-channel mTLS). */
|
||||
readonly certFile: string
|
||||
/** Keystore path to the matching private key. */
|
||||
readonly keyFile: string
|
||||
/** Keystore path to the CA that signed the frps control cert (server verification). */
|
||||
readonly trustedCaFile: string
|
||||
/** VPS address; defaults to the deployed relay `8.138.1.192`. */
|
||||
readonly serverAddr?: string
|
||||
/** Local forward target IP; MUST be loopback. Defaults to `127.0.0.1`. */
|
||||
readonly localIP?: string
|
||||
}
|
||||
|
||||
/** A validation failure at the frpc.toml boundary. */
|
||||
export class FrpcTomlError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FrpcTomlError'
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff `value` contains an ASCII control character (C0 range or DEL). */
|
||||
function hasControlChar(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const code = value.charCodeAt(i)
|
||||
if (code < FIRST_PRINTABLE_CODE || code === DEL_CHAR_CODE) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Non-empty, control-char-free path/secret at the boundary. */
|
||||
function assertPresent(field: string, value: string): void {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new FrpcTomlError(`${field} is required`)
|
||||
}
|
||||
if (hasControlChar(value)) {
|
||||
throw new FrpcTomlError(`${field} contains control characters`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape a value for a TOML basic (double-quoted) string: backslash + quote (Windows paths). */
|
||||
function tomlBasicString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function assertLoopback(localIP: string): void {
|
||||
if (!LOOPBACK_IPS.includes(localIP)) {
|
||||
throw new FrpcTomlError(
|
||||
`localIP "${localIP}" is not loopback — frpc must forward only to the local base app (anti-SSRF)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertSubdomain(subdomain: string): void {
|
||||
if (typeof subdomain !== 'string' || subdomain.length === 0) {
|
||||
throw new FrpcTomlError('subdomain is required')
|
||||
}
|
||||
if (subdomain.length > MAX_LABEL_LEN || !LABEL_RE.test(subdomain)) {
|
||||
throw new FrpcTomlError(
|
||||
`subdomain "${subdomain}" is not a valid DNS label (alnum + internal hyphens, 1-63 chars)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertLocalPort(localPort: number): void {
|
||||
if (!Number.isInteger(localPort) || localPort < MIN_PORT || localPort > MAX_PORT) {
|
||||
throw new FrpcTomlError(
|
||||
`localPort must be an integer in ${MIN_PORT}-${MAX_PORT}, got ${localPort}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a validated frp v0.61 `frpc.toml` for the native mTLS tunnel. Throws `FrpcTomlError` on any
|
||||
* invalid input (fail-fast). The returned string is the full config file contents.
|
||||
*/
|
||||
export function buildNativeFrpcToml(opts: NativeFrpcOptions): string {
|
||||
assertSubdomain(opts.subdomain)
|
||||
assertLocalPort(opts.localPort)
|
||||
assertPresent('authToken', opts.authToken)
|
||||
assertPresent('certFile', opts.certFile)
|
||||
assertPresent('keyFile', opts.keyFile)
|
||||
assertPresent('trustedCaFile', opts.trustedCaFile)
|
||||
|
||||
const serverAddr = opts.serverAddr ?? DEFAULT_SERVER_ADDR
|
||||
assertPresent('serverAddr', serverAddr)
|
||||
|
||||
const localIP = opts.localIP ?? DEFAULT_LOCAL_IP
|
||||
assertLoopback(localIP)
|
||||
|
||||
const lines: readonly string[] = [
|
||||
`serverAddr = "${tomlBasicString(serverAddr)}"`,
|
||||
`serverPort = ${SERVER_PORT}`,
|
||||
'',
|
||||
'auth.method = "token"',
|
||||
`auth.token = "${tomlBasicString(opts.authToken)}"`,
|
||||
'',
|
||||
'# control-channel mTLS: present this host frp-client cert; verify the frps control cert',
|
||||
'transport.tls.enable = true',
|
||||
`transport.tls.serverName = "${TLS_SERVER_NAME}"`,
|
||||
'transport.tls.disableCustomTLSFirstByte = true',
|
||||
`transport.tls.certFile = "${tomlBasicString(opts.certFile)}"`,
|
||||
`transport.tls.keyFile = "${tomlBasicString(opts.keyFile)}"`,
|
||||
`transport.tls.trustedCaFile = "${tomlBasicString(opts.trustedCaFile)}"`,
|
||||
'',
|
||||
'loginFailExit = false',
|
||||
'',
|
||||
'[[proxies]]',
|
||||
`name = "${opts.subdomain}"`,
|
||||
'type = "http"',
|
||||
`localIP = "${localIP}"`,
|
||||
`localPort = ${opts.localPort}`,
|
||||
`subdomain = "${opts.subdomain}"`,
|
||||
'',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
84
agent/src/transport/heartbeat.ts
Normal file
84
agent/src/transport/heartbeat.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* §4.1 heartbeat — PLAN_RELAY_AGENT T9. PING every 15s on streamId 0; a missed PONG within the
|
||||
* interval ⇒ the tunnel is dead (⇒ T10 reconnect). Also replies PONG (echoing the 8-byte token)
|
||||
* to an inbound PING. Timers are injectable (TimerLike) for deterministic fake-timer tests.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { TimerLike } from './seams.js'
|
||||
import { pingHeader, pongHeader, type Tunnel } from './tunnel.js'
|
||||
|
||||
export const HEARTBEAT_INTERVAL_MS = 15_000
|
||||
|
||||
export interface Heartbeat {
|
||||
onPing(token: Uint8Array): void
|
||||
onPong(token: Uint8Array): void
|
||||
start(): void
|
||||
stop(): void
|
||||
onDead(cb: () => void): void
|
||||
}
|
||||
|
||||
const realTimer: TimerLike = {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
|
||||
export function createHeartbeat(
|
||||
tunnel: Tunnel,
|
||||
opts: { intervalMs?: number; timer?: TimerLike; genToken?: () => Uint8Array } = {},
|
||||
): Heartbeat {
|
||||
const intervalMs = opts.intervalMs ?? HEARTBEAT_INTERVAL_MS
|
||||
const timer = opts.timer ?? realTimer
|
||||
const genToken = opts.genToken ?? (() => new Uint8Array(randomBytes(8)))
|
||||
|
||||
let interval: unknown = null
|
||||
let deadline: unknown = null
|
||||
let pending = false
|
||||
let deadCb: (() => void) | null = null
|
||||
let dead = false
|
||||
|
||||
function fireDead(): void {
|
||||
if (dead) return
|
||||
dead = true
|
||||
stop()
|
||||
deadCb?.()
|
||||
}
|
||||
|
||||
function sendPing(): void {
|
||||
pending = true
|
||||
tunnel.send(pingHeader(), genToken())
|
||||
deadline = timer.setTimeout(() => {
|
||||
if (pending) fireDead()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (interval !== null) timer.clearInterval(interval)
|
||||
if (deadline !== null) timer.clearTimeout(deadline)
|
||||
interval = null
|
||||
deadline = null
|
||||
}
|
||||
|
||||
return {
|
||||
onPing(token: Uint8Array): void {
|
||||
tunnel.send(pongHeader(), token) // echo the token byte-exact
|
||||
},
|
||||
onPong(): void {
|
||||
pending = false
|
||||
if (deadline !== null) {
|
||||
timer.clearTimeout(deadline)
|
||||
deadline = null
|
||||
}
|
||||
},
|
||||
start(): void {
|
||||
dead = false
|
||||
sendPing()
|
||||
interval = timer.setInterval(sendPing, intervalMs)
|
||||
},
|
||||
stop,
|
||||
onDead(cb: () => void): void {
|
||||
deadCb = cb
|
||||
},
|
||||
}
|
||||
}
|
||||
71
agent/src/transport/loopback.ts
Normal file
71
agent/src/transport/loopback.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Loopback forwarder — PLAN_RELAY_AGENT T8. One OPEN ⇒ one fresh ws://127.0.0.1:3000<path>
|
||||
* socket, REPLAYING the real browser `Origin` (§4.1 MuxOpen.originHeader) so the UNCHANGED base
|
||||
* app's Origin check still passes end-to-end (CSWSH protection preserved — EXPLORE §3).
|
||||
*
|
||||
* The raw `ws` constructor is injectable so the URL/Origin wiring is unit-testable without a real
|
||||
* socket. The target is always loopback (validated upstream in config).
|
||||
*/
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
export type DialLoopback = (path: string, origin: string) => Promise<WsLike>
|
||||
|
||||
/** Minimal surface of a raw `ws` client the adapter needs. */
|
||||
export interface RawWs {
|
||||
send(data: Uint8Array): void
|
||||
close(): void
|
||||
on(event: string, cb: (...args: unknown[]) => void): void
|
||||
once(event: string, cb: (...args: unknown[]) => void): void
|
||||
}
|
||||
export type WsConstructor = new (
|
||||
url: string,
|
||||
opts?: { headers?: Record<string, string> },
|
||||
) => RawWs
|
||||
|
||||
/** Join the loopback target with the request path (avoids a double slash). */
|
||||
export function buildLoopbackUrl(target: string, path: string): string {
|
||||
const base = target.endsWith('/') ? target.slice(0, -1) : target
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
return null
|
||||
}
|
||||
|
||||
function adapt(raw: RawWs): WsLike {
|
||||
return {
|
||||
send(d: Uint8Array): void {
|
||||
raw.send(d)
|
||||
},
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
|
||||
if (ev === 'message') {
|
||||
raw.on('message', (data: unknown) => {
|
||||
const bytes = toU8(data)
|
||||
if (bytes !== null) cb(bytes)
|
||||
})
|
||||
} else {
|
||||
raw.on(ev, cb)
|
||||
}
|
||||
},
|
||||
close(): void {
|
||||
raw.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DialLoopback bound to `target`. Resolves once the loopback socket is open; rejects on
|
||||
* a pre-open error (so a down base app surfaces as a typed dial failure, not a silent hang).
|
||||
*/
|
||||
export function dialLoopback(target: string, Ctor: WsConstructor): DialLoopback {
|
||||
return (path: string, origin: string): Promise<WsLike> =>
|
||||
new Promise<WsLike>((resolve, reject) => {
|
||||
const url = buildLoopbackUrl(target, path)
|
||||
const raw = new Ctor(url, { headers: { Origin: origin } })
|
||||
raw.once('open', () => resolve(adapt(raw)))
|
||||
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
|
||||
})
|
||||
}
|
||||
152
agent/src/transport/runTunnel.ts
Normal file
152
agent/src/transport/runTunnel.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Long-running tunnel supervisor — PLAN_RELAY_PHASE1 C2. Ports the PROVEN cafeDemo assembly
|
||||
* (dialRelay → holdTunnel → createStreamRouter → dialLoopback, plus heartbeat) into a supervised
|
||||
* loop that survives disconnects: exponential backoff reconnect (T10 policy), heartbeat liveness
|
||||
* (T9), and GOAWAY/revocation-aware teardown (T14, INV12 — a revoked host NEVER reconnects).
|
||||
*
|
||||
* All IO is injectable via `RunTunnelDeps` so the loop is unit-testable with fakes (see cafeDemo);
|
||||
* the two-arg `runTunnel(cfg, ks)` default path wires the real `ws` sockets. INV2 is preserved: the
|
||||
* router splices OPAQUE bytes (identityTransform) — no terminal parsing happens here.
|
||||
*/
|
||||
import { WebSocket } from 'ws'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { createLogger, type Logger } from '../log/logger.js'
|
||||
import { createRevocationState, applyGoAway } from '../lifecycle/revocation.js'
|
||||
import { dialRelay, type TlsWsConstructor } from './dial.js'
|
||||
import { dialLoopback, type DialLoopback, type WsConstructor } from './loopback.js'
|
||||
import { holdTunnel, type Tunnel } from './tunnel.js'
|
||||
import { createStreamRouter, identityTransform } from './streamRouter.js'
|
||||
import { createHeartbeat } from './heartbeat.js'
|
||||
import { createBackoff, reconnectLoop, type BackoffPolicy, type Sleep } from './backoff.js'
|
||||
import type { TimerLike, WsLike } from './seams.js'
|
||||
|
||||
/** Handle returned by `runTunnel`: stop the supervisor, or await its terminal exit code. */
|
||||
export interface TunnelHandle {
|
||||
/** Request graceful shutdown; resolves once the supervisor loop has fully stopped. */
|
||||
stop(): Promise<void>
|
||||
/** Resolves with a process exit code when the loop ends (stopped or host revoked ⇒ 0). */
|
||||
readonly done: Promise<number>
|
||||
}
|
||||
|
||||
/** Injectable seams for the supervisor. All optional; unset fields default to real `ws` IO. */
|
||||
export interface RunTunnelDeps {
|
||||
connectRelay(): Promise<WsLike>
|
||||
dialLoopback: DialLoopback
|
||||
logger: Logger
|
||||
timer: TimerLike
|
||||
sleep: Sleep
|
||||
backoff: BackoffPolicy
|
||||
}
|
||||
|
||||
const realTimer: TimerLike = {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
function resolveDeps(cfg: AgentConfig, ks: Keystore, o?: Partial<RunTunnelDeps>): RunTunnelDeps {
|
||||
return {
|
||||
connectRelay:
|
||||
o?.connectRelay ?? (() => dialRelay(cfg, ks, { Ctor: WebSocket as unknown as TlsWsConstructor })),
|
||||
dialLoopback: o?.dialLoopback ?? dialLoopback(cfg.localTargetUrl, WebSocket as unknown as WsConstructor),
|
||||
logger: o?.logger ?? createLogger('info'),
|
||||
timer: o?.timer ?? realTimer,
|
||||
sleep: o?.sleep ?? realSleep,
|
||||
backoff: o?.backoff ?? createBackoff({ jitter: true }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the supervised tunnel. Returns immediately with a handle; the reconnect loop runs in the
|
||||
* background. Never awaits the first connection (an unreachable relay would otherwise hang the
|
||||
* caller with no way to `stop()`).
|
||||
*/
|
||||
export async function runTunnel(
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
overrides?: Partial<RunTunnelDeps>,
|
||||
): Promise<TunnelHandle> {
|
||||
const { connectRelay, dialLoopback: dialLb, logger, timer, sleep, backoff } = resolveDeps(cfg, ks, overrides)
|
||||
|
||||
let stopped = false
|
||||
let currentTunnel: Tunnel | null = null
|
||||
let currentSocket: WsLike | null = null
|
||||
|
||||
// INV12: revocation tears the live tunnel down immediately and suppresses all reconnects.
|
||||
const revocation = createRevocationState(() => currentTunnel?.close())
|
||||
const shouldStop = (): boolean => stopped || revocation.isRevoked()
|
||||
|
||||
async function dialTunnel(): Promise<Tunnel> {
|
||||
const socket = await connectRelay()
|
||||
currentSocket = socket
|
||||
return holdTunnel(socket)
|
||||
}
|
||||
|
||||
/** Run ONE tunnel session; resolves when this session ends (dead heartbeat / close / GOAWAY). */
|
||||
function runSession(tunnel: Tunnel, socket: WsLike): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const router = createStreamRouter(cfg, tunnel, dialLb, identityTransform, logger)
|
||||
const heartbeat = createHeartbeat(tunnel, { timer })
|
||||
tunnel.dispatchTo(router, heartbeat)
|
||||
|
||||
let settled = false
|
||||
const endSession = (): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
heartbeat.stop()
|
||||
resolve()
|
||||
}
|
||||
|
||||
heartbeat.onDead(() => {
|
||||
logger.log('warn', 'heartbeat missed — tunnel presumed down, will reconnect')
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
socket.on('close', () => endSession())
|
||||
socket.on('error', () => {
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
tunnel.onGoAway((reason) => {
|
||||
const action = applyGoAway(reason, revocation) // 'revoked' ⇒ no reconnect (INV12)
|
||||
logger.log('info', 'received GOAWAY', { action })
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
|
||||
heartbeat.start()
|
||||
})
|
||||
}
|
||||
|
||||
async function supervise(): Promise<number> {
|
||||
while (!shouldStop()) {
|
||||
const tunnel = await reconnectLoop(dialTunnel, backoff, shouldStop, sleep)
|
||||
if (tunnel === null || currentSocket === null) break
|
||||
if (shouldStop()) {
|
||||
// stop()/revoke raced with the in-flight dial — discard the fresh tunnel.
|
||||
tunnel.close()
|
||||
break
|
||||
}
|
||||
currentTunnel = tunnel
|
||||
logger.log('info', 'relay tunnel established')
|
||||
await runSession(tunnel, currentSocket)
|
||||
currentTunnel = null
|
||||
currentSocket = null
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const done = supervise()
|
||||
|
||||
return {
|
||||
async stop(): Promise<void> {
|
||||
stopped = true
|
||||
currentTunnel?.close()
|
||||
await done
|
||||
},
|
||||
done,
|
||||
}
|
||||
}
|
||||
43
agent/src/transport/seams.ts
Normal file
43
agent/src/transport/seams.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* W0 shared injection seams (DEPENDENCY-CYCLE BREAKER) — PLAN_RELAY_AGENT §2 / T2.
|
||||
*
|
||||
* These are intra-`agent/` seam *types only*. NO cross-plan frozen contract lives here —
|
||||
* those stay in `relay-contracts/`. Declaring them once at W0 lets W2/W3 transport tasks
|
||||
* (T7/T10/T12/T14) consume a stable type WITHOUT a task-level cycle (see §3 T10↔T14 note).
|
||||
*
|
||||
* This module MUST remain side-effect-free and runtime-dependency-free (type-only): a test
|
||||
* asserts importing it pulls in no `ws`/crypto runtime, so W2/W3 can depend on it freely.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal WS surface the transport layer needs. dial/tunnel/backoff/loopback share it so no
|
||||
* per-task redeclare and no drift. Adapters wrap the real `ws` socket into this shape.
|
||||
*/
|
||||
export interface WsLike {
|
||||
send(d: Uint8Array): void
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** Why a host stopped tunnelling. `renewal-refused`/`goaway-revoked` ⇒ do NOT reconnect. */
|
||||
export type RevokeReason = 'renewal-refused' | 'goaway-revoked' | 'operator'
|
||||
|
||||
/**
|
||||
* Revocation seam — T14 IMPLEMENTS it; T10's reconnectLoop only CONSUMES `isRevoked()`.
|
||||
* One-way edge (T14 → wires T10's loop), so there is no task cycle.
|
||||
*/
|
||||
export interface RevocationState {
|
||||
isRevoked(): boolean
|
||||
markRevoked(reason: RevokeReason): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal timer seam so heartbeat (T9) / cert rotation (T13) are testable with fake timers
|
||||
* without depending on Node's global timer types leaking into the transport surface.
|
||||
*/
|
||||
export interface TimerLike {
|
||||
setTimeout(cb: () => void, ms: number): unknown
|
||||
clearTimeout(handle: unknown): void
|
||||
setInterval(cb: () => void, ms: number): unknown
|
||||
clearInterval(handle: unknown): void
|
||||
}
|
||||
148
agent/src/transport/streamRouter.ts
Normal file
148
agent/src/transport/streamRouter.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Stream router — PLAN_RELAY_AGENT T8. Maps §4.1 streamId ⇄ a loopback socket. Each OPEN gets a
|
||||
* FRESH per-stream allocation (socket + transform state); there are NO global mutable buffers, so
|
||||
* cross-tenant/cross-stream buffer bleed is structurally impossible (EXPLORE §4b failure #3).
|
||||
*
|
||||
* MANDATORY INV1 defense-in-depth: the FIRST thing handleOpen does — before any allocation or
|
||||
* dial — is compare MuxOpen.subdomain (§4.1) against this agent's enrolled subdomain. A mismatch
|
||||
* is RST and NEVER dialed (metadata-only audit log, INV10). Belt-and-suspenders to relay authz.
|
||||
*/
|
||||
import type { MuxOpen } from 'relay-contracts'
|
||||
import { MuxOpenSchema } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { Logger } from '../log/logger.js'
|
||||
import type { WsLike } from './seams.js'
|
||||
import type { DialLoopback } from './loopback.js'
|
||||
import { closeHeader, dataHeader, type Tunnel } from './tunnel.js'
|
||||
|
||||
/**
|
||||
* Per-stream cipher transform. Identity in v0.9 (plaintext passthrough); replaced by the E2E
|
||||
* codec in v0.10 (T15). `takeControlFrames` lets an E2E transform emit host→client control frames
|
||||
* (e.g. HostHello) that the router forwards upstream — no-op for the identity transform.
|
||||
*/
|
||||
export interface FrameTransform {
|
||||
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null
|
||||
outbound(streamId: number, plain: Uint8Array): Uint8Array
|
||||
openStream(streamId: number): void
|
||||
closeStream(streamId: number): void
|
||||
takeControlFrames?(streamId: number): Uint8Array[]
|
||||
}
|
||||
|
||||
export const identityTransform: FrameTransform = {
|
||||
inbound: (_s, cipher) => cipher,
|
||||
outbound: (_s, plain) => plain,
|
||||
openStream: () => {},
|
||||
closeStream: () => {},
|
||||
}
|
||||
|
||||
export interface StreamRouter {
|
||||
handleOpen(open: MuxOpen): void
|
||||
handleData(streamId: number, payload: Uint8Array): void
|
||||
handleClose(streamId: number, rst: boolean): void
|
||||
activeStreamCount(): number
|
||||
}
|
||||
|
||||
interface StreamState {
|
||||
socket: WsLike | null
|
||||
readonly pending: Uint8Array[] // inbound bytes buffered until the loopback socket is open
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
export function createStreamRouter(
|
||||
cfg: AgentConfig,
|
||||
tunnel: Tunnel,
|
||||
dial: DialLoopback,
|
||||
transform: FrameTransform,
|
||||
logger: Logger,
|
||||
): StreamRouter {
|
||||
const streams = new Map<number, StreamState>()
|
||||
|
||||
function flushControlFrames(streamId: number): void {
|
||||
const frames = transform.takeControlFrames?.(streamId) ?? []
|
||||
for (const frame of frames) {
|
||||
tunnel.send(dataHeader(streamId, frame.length), frame)
|
||||
}
|
||||
}
|
||||
|
||||
function teardown(streamId: number, rst: boolean): void {
|
||||
const state = streams.get(streamId)
|
||||
if (state === undefined) return
|
||||
// Delete FIRST so a synchronous socket 'close' event can't re-enter this teardown.
|
||||
streams.delete(streamId)
|
||||
state.closed = true
|
||||
transform.closeStream(streamId)
|
||||
tunnel.send(closeHeader(streamId, rst), new Uint8Array(0))
|
||||
state.socket?.close()
|
||||
}
|
||||
|
||||
return {
|
||||
handleOpen(open: MuxOpen): void {
|
||||
// INV1 defense-in-depth — FIRST statement, before any allocation or dial.
|
||||
if (open.subdomain !== cfg.subdomain) {
|
||||
tunnel.sendRst(open.streamId)
|
||||
logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId })
|
||||
return
|
||||
}
|
||||
if (!MuxOpenSchema.safeParse(open).success) {
|
||||
tunnel.sendRst(open.streamId)
|
||||
return
|
||||
}
|
||||
if (streams.has(open.streamId)) {
|
||||
tunnel.sendRst(open.streamId) // duplicate OPEN for a live stream
|
||||
return
|
||||
}
|
||||
|
||||
const state: StreamState = { socket: null, pending: [], closed: false }
|
||||
streams.set(open.streamId, state)
|
||||
transform.openStream(open.streamId)
|
||||
|
||||
dial(open.requestPath, open.originHeader)
|
||||
.then((socket) => {
|
||||
if (state.closed) {
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
state.socket = socket
|
||||
// loopback output → transform.outbound → tunnel DATA
|
||||
socket.on('message', (data: unknown) => {
|
||||
if (!(data instanceof Uint8Array)) return
|
||||
const cipher = transform.outbound(open.streamId, data)
|
||||
tunnel.send(dataHeader(open.streamId, cipher.length), cipher)
|
||||
})
|
||||
socket.on('close', () => teardown(open.streamId, false))
|
||||
// flush anything buffered before the socket opened
|
||||
for (const buffered of state.pending) socket.send(buffered)
|
||||
state.pending.length = 0
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.log('error', 'loopback dial failed', { streamId: open.streamId })
|
||||
void err
|
||||
teardown(open.streamId, true)
|
||||
})
|
||||
},
|
||||
|
||||
handleData(streamId: number, payload: Uint8Array): void {
|
||||
const state = streams.get(streamId)
|
||||
if (state === undefined) {
|
||||
tunnel.sendRst(streamId) // DATA before OPEN / after CLOSE / unknown stream → RST
|
||||
return
|
||||
}
|
||||
const plain = transform.inbound(streamId, payload)
|
||||
flushControlFrames(streamId) // E2E: emit HostHello etc. (no-op in v0.9)
|
||||
if (plain === null) return // consumed (handshake), nothing to forward
|
||||
if (state.socket === null) {
|
||||
state.pending.push(plain)
|
||||
return
|
||||
}
|
||||
state.socket.send(plain)
|
||||
},
|
||||
|
||||
handleClose(streamId: number, rst: boolean): void {
|
||||
teardown(streamId, rst)
|
||||
},
|
||||
|
||||
activeStreamCount(): number {
|
||||
return streams.size
|
||||
},
|
||||
}
|
||||
}
|
||||
161
agent/src/transport/tunnel.ts
Normal file
161
agent/src/transport/tunnel.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* §4.1 tunnel holder — PLAN_RELAY_AGENT T7. Holds ONE physical mux over a WsLike socket:
|
||||
* encodes outbound frames, decodes inbound frames (via the FROZEN relay-contracts codec — never
|
||||
* re-implemented), and dispatches by type to the router (OPEN/DATA/CLOSE) or heartbeat
|
||||
* (PING/PONG) or connection-level control (GOAWAY/WINDOW_UPDATE, streamId 0).
|
||||
*
|
||||
* INV11: payloads are OPAQUE — no ANSI/terminal parsing here. Malformed frames RST the affected
|
||||
* stream (never the whole tunnel). After an inbound GOAWAY the tunnel DRAINS: no new OPEN.
|
||||
*
|
||||
* Design note (vs plan `holdTunnel(socket, router, heartbeat)`): to keep the construction graph
|
||||
* ACYCLIC (router/heartbeat are built WITH the tunnel), wiring is a two-phase `dispatchTo()`
|
||||
* call rather than constructor args. Same behavior, no task cycle. Recorded as a deviation.
|
||||
*/
|
||||
import {
|
||||
decodeGoaway,
|
||||
decodeMuxFrame,
|
||||
decodeOpen,
|
||||
encodeGoaway,
|
||||
encodeMuxFrame,
|
||||
} from 'relay-contracts'
|
||||
import type { GoAwayReason, MuxFrameHeader, MuxOpen } from 'relay-contracts'
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
const EMPTY = new Uint8Array(0)
|
||||
|
||||
/** Handlers the tunnel dispatches decoded frames to (wired post-construction). */
|
||||
export interface StreamHandlers {
|
||||
handleOpen(open: MuxOpen): void
|
||||
handleData(streamId: number, payload: Uint8Array): void
|
||||
handleClose(streamId: number, rst: boolean): void
|
||||
}
|
||||
export interface HeartbeatSink {
|
||||
onPing(token: Uint8Array): void
|
||||
onPong(token: Uint8Array): void
|
||||
}
|
||||
|
||||
export interface Tunnel {
|
||||
send(header: MuxFrameHeader, payload: Uint8Array): void
|
||||
onFrame(cb: (h: MuxFrameHeader, payload: Uint8Array) => void): void
|
||||
onGoAway(cb: (reason: GoAwayReason) => void): void
|
||||
goAway(lastStreamId: number, reason: GoAwayReason): void
|
||||
sendRst(streamId: number): void
|
||||
dispatchTo(router: StreamHandlers, heartbeat: HeartbeatSink): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
// --- frame-header builders (shared across the transport layer) --------------------------------
|
||||
|
||||
export function dataHeader(streamId: number, payloadLen: number): MuxFrameHeader {
|
||||
return { version: 1, type: 'data', fin: false, rst: false, streamId, payloadLen }
|
||||
}
|
||||
export function closeHeader(streamId: number, rst: boolean): MuxFrameHeader {
|
||||
return { version: 1, type: 'close', fin: !rst, rst, streamId, payloadLen: 0 }
|
||||
}
|
||||
export function rstHeader(streamId: number): MuxFrameHeader {
|
||||
return closeHeader(streamId, true)
|
||||
}
|
||||
export function pingHeader(): MuxFrameHeader {
|
||||
return { version: 1, type: 'ping', fin: false, rst: false, streamId: 0, payloadLen: 8 }
|
||||
}
|
||||
export function pongHeader(): MuxFrameHeader {
|
||||
return { version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 8 }
|
||||
}
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (Array.isArray(data) && data[0] instanceof Uint8Array) return data[0] as Uint8Array
|
||||
return null
|
||||
}
|
||||
|
||||
export function holdTunnel(socket: WsLike): Tunnel {
|
||||
let frameCb: ((h: MuxFrameHeader, payload: Uint8Array) => void) | null = null
|
||||
let goAwayCb: ((reason: GoAwayReason) => void) | null = null
|
||||
let handlers: StreamHandlers | null = null
|
||||
let heartbeat: HeartbeatSink | null = null
|
||||
let draining = false
|
||||
|
||||
function send(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||
socket.send(encodeMuxFrame(header, payload))
|
||||
}
|
||||
function sendRst(streamId: number): void {
|
||||
send(rstHeader(streamId), EMPTY)
|
||||
}
|
||||
|
||||
function dispatch(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||
frameCb?.(header, payload)
|
||||
switch (header.type) {
|
||||
case 'ping':
|
||||
heartbeat?.onPing(payload)
|
||||
return
|
||||
case 'pong':
|
||||
heartbeat?.onPong(payload)
|
||||
return
|
||||
case 'goaway': {
|
||||
const { reason } = decodeGoaway(payload)
|
||||
draining = true
|
||||
goAwayCb?.(reason)
|
||||
return
|
||||
}
|
||||
case 'open': {
|
||||
if (draining) {
|
||||
sendRst(header.streamId) // drain: refuse new streams
|
||||
return
|
||||
}
|
||||
let open: MuxOpen
|
||||
try {
|
||||
open = decodeOpen(payload)
|
||||
} catch {
|
||||
sendRst(header.streamId) // malformed OPEN → RST that stream, tunnel stays up
|
||||
return
|
||||
}
|
||||
handlers?.handleOpen(open)
|
||||
return
|
||||
}
|
||||
case 'data':
|
||||
handlers?.handleData(header.streamId, payload)
|
||||
return
|
||||
case 'close':
|
||||
handlers?.handleClose(header.streamId, header.rst)
|
||||
return
|
||||
case 'windowUpdate':
|
||||
return // consumed by the flow controller at the wiring layer (T11)
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('message', (...args: unknown[]) => {
|
||||
const bytes = toU8(args[0])
|
||||
if (bytes === null) return
|
||||
let decoded: { header: MuxFrameHeader; payload: Uint8Array }
|
||||
try {
|
||||
decoded = decodeMuxFrame(bytes)
|
||||
} catch {
|
||||
return // malformed framing: drop the frame, keep the tunnel alive (robust framing)
|
||||
}
|
||||
dispatch(decoded.header, decoded.payload)
|
||||
})
|
||||
|
||||
return {
|
||||
send,
|
||||
sendRst,
|
||||
onFrame(cb): void {
|
||||
frameCb = cb
|
||||
},
|
||||
onGoAway(cb): void {
|
||||
goAwayCb = cb
|
||||
},
|
||||
goAway(lastStreamId: number, reason: GoAwayReason): void {
|
||||
draining = true
|
||||
const payload = encodeGoaway(lastStreamId, reason)
|
||||
send({ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length }, payload)
|
||||
},
|
||||
dispatchTo(router: StreamHandlers, hb: HeartbeatSink): void {
|
||||
handlers = router
|
||||
heartbeat = hb
|
||||
},
|
||||
close(): void {
|
||||
socket.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
64
agent/test/acceptance/cafeDemo.test.ts
Normal file
64
agent/test/acceptance/cafeDemo.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../../src/config/agentConfig.js'
|
||||
import { createLogger } from '../../src/log/logger.js'
|
||||
import { holdTunnel, dataHeader } from '../../src/transport/tunnel.js'
|
||||
import { createStreamRouter, identityTransform } from '../../src/transport/streamRouter.js'
|
||||
import { FakeWs } from '../fixtures/fakes.js'
|
||||
|
||||
/**
|
||||
* T18 café-demo agent slice: pair (implied) → dial (mock) → OPEN → loopback splice against a stub
|
||||
* ws://127.0.0.1:3000 echo server → bytes flow BOTH ways (EXPLORE §5 demo, agent portion).
|
||||
*/
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
const flush = () => new Promise((r) => setImmediate(r))
|
||||
|
||||
describe('café-demo agent slice (T18)', () => {
|
||||
it('splices bytes both ways through the loopback echo', async () => {
|
||||
const upstream = new FakeWs()
|
||||
const tunnel = holdTunnel(upstream)
|
||||
const loopback = new FakeWs()
|
||||
const dial = vi.fn(async () => loopback)
|
||||
const router = createStreamRouter(CFG, tunnel, dial as never, identityTransform, createLogger('error', () => {}))
|
||||
tunnel.dispatchTo(router, { onPing: () => {}, onPong: () => {} })
|
||||
|
||||
// relay → agent: OPEN + a keystroke
|
||||
ws_emitOpen(upstream, OPEN)
|
||||
await flush()
|
||||
expect(dial).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
|
||||
upstream.emitMessage(encodeMuxFrame(dataHeader(5, 3), new Uint8Array([104, 105, 10]))) // "hi\n"
|
||||
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
|
||||
|
||||
// agent ← base app: echo output flows back as DATA upstream
|
||||
loopback.emit('message', new Uint8Array([79, 75])) // "OK"
|
||||
const last = decodeMuxFrame(upstream.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('data')
|
||||
expect([...last.payload]).toEqual([79, 75])
|
||||
})
|
||||
})
|
||||
|
||||
function ws_emitOpen(upstream: FakeWs, open: MuxOpen): void {
|
||||
const payload = encodeOpen(open)
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
63
agent/test/agentConfig.test.ts
Normal file
63
agent/test/agentConfig.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentConfigSchema, isLoopbackWsUrl, loadAgentConfig } from '../src/config/agentConfig.js'
|
||||
|
||||
const base = {
|
||||
relayUrl: 'wss://relay.example.com/agent',
|
||||
enrollUrl: 'https://example.com/enroll',
|
||||
stateDir: '/tmp/wta',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: null,
|
||||
hostId: null,
|
||||
}
|
||||
|
||||
describe('AgentConfig validation', () => {
|
||||
it('parses a valid config', () => {
|
||||
expect(() => AgentConfigSchema.parse(base)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-wss relayUrl', () => {
|
||||
expect(() => AgentConfigSchema.parse({ ...base, relayUrl: 'http://relay.example.com' })).toThrow()
|
||||
expect(() => AgentConfigSchema.parse({ ...base, relayUrl: 'ws://relay.example.com' })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-https enrollUrl', () => {
|
||||
expect(() => AgentConfigSchema.parse({ ...base, enrollUrl: 'http://example.com/enroll' })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-loopback localTargetUrl (anti-SSRF)', () => {
|
||||
expect(() => AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://10.0.0.5:3000' })).toThrow()
|
||||
expect(() => AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://evil.example.com:3000' })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts loopback localTargetUrl variants', () => {
|
||||
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://127.5.5.5:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://[::1]:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://10.0.0.5:3000')).toBe(false)
|
||||
expect(isLoopbackWsUrl('wss://127.0.0.1:3000')).toBe(false)
|
||||
})
|
||||
|
||||
it('REGRESSION: rejects a crafted suffixed-hostname target (anti-SSRF bypass)', () => {
|
||||
// Hostname, not a loopback literal — the outbound dial would DNS-resolve and connect out.
|
||||
expect(isLoopbackWsUrl('ws://127.0.0.1.attacker.example.com:3000/x')).toBe(false)
|
||||
expect(isLoopbackWsUrl('ws://127.evil.net:3000')).toBe(false)
|
||||
expect(() =>
|
||||
AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://127.0.0.1.attacker.example.com:3000' }),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('loadAgentConfig fails fast on a missing relayUrl', () => {
|
||||
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
|
||||
})
|
||||
|
||||
it('loadAgentConfig reads env and lets argv override', () => {
|
||||
const cfg = loadAgentConfig(
|
||||
{ RELAY_URL: 'wss://a/agent', ENROLL_URL: 'https://a/enroll' } as unknown as NodeJS.ProcessEnv,
|
||||
{ subdomain: 'host-42' },
|
||||
)
|
||||
expect(cfg.relayUrl).toBe('wss://a/agent')
|
||||
expect(cfg.subdomain).toBe('host-42')
|
||||
expect(cfg.localTargetUrl).toBe('ws://127.0.0.1:3000')
|
||||
})
|
||||
})
|
||||
62
agent/test/backoff.test.ts
Normal file
62
agent/test/backoff.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createBackoff, reconnectLoop, BACKOFF_CAP_MS } from '../src/transport/backoff.js'
|
||||
import type { Tunnel } from '../src/transport/tunnel.js'
|
||||
|
||||
describe('createBackoff (T10)', () => {
|
||||
it('follows 1s,2s,4s…cap 30s', () => {
|
||||
const b = createBackoff()
|
||||
const seq = [b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs()]
|
||||
expect(seq).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000])
|
||||
expect(BACKOFF_CAP_MS).toBe(30_000)
|
||||
})
|
||||
|
||||
it('reset() returns to 1s', () => {
|
||||
const b = createBackoff()
|
||||
b.nextDelayMs()
|
||||
b.nextDelayMs()
|
||||
b.reset()
|
||||
expect(b.nextDelayMs()).toBe(1000)
|
||||
})
|
||||
|
||||
it('jitter stays within [0.5×, 1×]', () => {
|
||||
const b = createBackoff({ jitter: true, rng: () => 0 })
|
||||
expect(b.nextDelayMs()).toBe(500) // 1000 * 0.5
|
||||
const b2 = createBackoff({ jitter: true, rng: () => 1 })
|
||||
expect(b2.nextDelayMs()).toBe(1000) // 1000 * 1.0
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconnectLoop (T10, INV12)', () => {
|
||||
const fakeTunnel = {} as Tunnel
|
||||
|
||||
it('resolves on the first successful dial and resets backoff', async () => {
|
||||
const dial = vi.fn().mockRejectedValueOnce(new Error('down')).mockResolvedValueOnce(fakeTunnel)
|
||||
const backoff = createBackoff()
|
||||
const reset = vi.spyOn(backoff, 'reset')
|
||||
const sleeps: number[] = []
|
||||
const result = await reconnectLoop(dial, backoff, () => false, async (ms) => {
|
||||
sleeps.push(ms)
|
||||
})
|
||||
expect(result).toBe(fakeTunnel)
|
||||
expect(sleeps).toEqual([1000])
|
||||
expect(reset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a revoked host does not reconnect (INV12)', async () => {
|
||||
const dial = vi.fn().mockResolvedValue(fakeTunnel)
|
||||
const result = await reconnectLoop(dial, createBackoff(), () => true, async () => {})
|
||||
expect(dial).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('stops retrying once revoked mid-loop', async () => {
|
||||
let revoked = false
|
||||
const dial = vi.fn(async () => {
|
||||
revoked = true
|
||||
throw new Error('down')
|
||||
})
|
||||
const result = await reconnectLoop(dial, createBackoff(), () => revoked, async () => {})
|
||||
expect(result).toBeNull()
|
||||
expect(dial).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
27
agent/test/buildBinary.test.ts
Normal file
27
agent/test/buildBinary.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BINARY_TARGETS, buildBinaryConfig } from '../src/dist/buildBinary.js'
|
||||
|
||||
describe('buildBinaryConfig (T16)', () => {
|
||||
it('produces a bun --compile spec per target with cli.ts entry', () => {
|
||||
for (const target of BINARY_TARGETS) {
|
||||
const spec = buildBinaryConfig(target)
|
||||
expect(spec.tool).toBe('bun')
|
||||
expect(spec.entry).toBe('src/cli.ts')
|
||||
expect(spec.target).toBe(target)
|
||||
expect(spec.bunTarget).toContain('bun-')
|
||||
expect(spec.outfile).toContain(target)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries the INV11 forbidden-dep tripwire (no terminal parser in the bundle)', () => {
|
||||
const spec = buildBinaryConfig('darwin-arm64')
|
||||
expect(spec.forbiddenDeps).toContain('xterm')
|
||||
expect(spec.forbiddenDeps).toContain('ansi')
|
||||
})
|
||||
|
||||
it('covers the four supported triples', () => {
|
||||
expect([...BINARY_TARGETS].sort()).toEqual(
|
||||
['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'],
|
||||
)
|
||||
})
|
||||
})
|
||||
253
agent/test/cli.test.ts
Normal file
253
agent/test/cli.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import type { Keystore } from '../src/keys/keystore.js'
|
||||
import type { AgentIdentity } from '../src/keys/identity.js'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
|
||||
import type { InstallOptions } from '../src/service/install.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
|
||||
return {
|
||||
alg,
|
||||
publicKey: new Uint8Array(32),
|
||||
enrollFpr: 'fpr',
|
||||
sign: () => new Uint8Array(64),
|
||||
exportPrivatePkcs8Pem: () => 'PEM',
|
||||
privateKeyObject: () => ({}) as never,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeKeystore(enrolled: boolean, alg: AgentIdentity['alg'] = 'ed25519'): Keystore {
|
||||
return {
|
||||
saveIdentity: vi.fn(),
|
||||
loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
|
||||
saveCert: vi.fn(),
|
||||
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
|
||||
saveContentSecret: vi.fn(),
|
||||
loadContentSecret: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
const NATIVE_OPTIONS: InstallOptions = {
|
||||
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
|
||||
domain: 'yaojia.wang',
|
||||
zone: 'terminal',
|
||||
}
|
||||
|
||||
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
|
||||
const out: string[] = []
|
||||
const d: CliDeps = {
|
||||
loadConfig: () => CFG,
|
||||
openKeystore: () => fakeKeystore(enrolled),
|
||||
generateIdentity: () => fakeIdentity('ed25519'),
|
||||
generateP256Identity: () => fakeIdentity('p256'),
|
||||
redeem: async (): Promise<EnrollResult> => ({
|
||||
hostId: 'h-1',
|
||||
subdomain: 'host-42',
|
||||
cert: 'C',
|
||||
caChain: 'CA',
|
||||
hostContentSecret: new Uint8Array([1]),
|
||||
}),
|
||||
enrollNative: async () => ({ hostId: 'h-1', subdomain: 'host-42' }),
|
||||
provisionFrpc: async () => '/opt/frpc',
|
||||
writeFrpcConfig: vi.fn(),
|
||||
nativeConfigExists: () => false,
|
||||
runTunnel: async () => 0,
|
||||
superviseFrpc: async () => 0,
|
||||
resolveInstallOptions: () => NATIVE_OPTIONS,
|
||||
installService: vi.fn(async () => {}),
|
||||
uninstallService: vi.fn(async () => {}),
|
||||
print: (l) => out.push(l),
|
||||
...overrides,
|
||||
}
|
||||
return { d, out }
|
||||
}
|
||||
|
||||
describe('parseArgs (T5)', () => {
|
||||
it('parses `pair ABCD-1234`', () => {
|
||||
expect(parseArgs(['pair', 'ABCD-1234'])).toEqual({ command: 'pair', code: 'ABCD-1234', flags: {} })
|
||||
})
|
||||
|
||||
it('rejects an unknown command', () => {
|
||||
expect(() => parseArgs(['frobnicate'])).toThrow(CliUsageError)
|
||||
})
|
||||
|
||||
it('requires a code on pair', () => {
|
||||
expect(() => parseArgs(['pair'])).toThrow(CliUsageError)
|
||||
})
|
||||
|
||||
it('collects flags', () => {
|
||||
expect(parseArgs(['pair', 'X', '--install'])).toEqual({
|
||||
command: 'pair',
|
||||
code: 'X',
|
||||
flags: { install: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCli — legacy relay pair (no --install)', () => {
|
||||
it('pair happy path calls redeem and prints no secrets', async () => {
|
||||
const { d, out } = deps()
|
||||
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
|
||||
expect(code).toBe(0)
|
||||
expect(out.join('\n')).toContain('host-42')
|
||||
expect(out.join('\n')).not.toContain('PEM')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCli — native pair --install onboard (B5)', () => {
|
||||
it('wires keygen(P-256)→enroll→provisionFrpc→write toml+env→install both→print URL, in order', async () => {
|
||||
const calls: string[] = []
|
||||
const enrolledIds: AgentIdentity[] = []
|
||||
const generateP256Identity = vi.fn(() => {
|
||||
calls.push('keygen')
|
||||
return fakeIdentity('p256')
|
||||
})
|
||||
const enrollNative = vi.fn(async (_cfg: AgentConfig, _code: string, id: AgentIdentity) => {
|
||||
calls.push('enroll')
|
||||
enrolledIds.push(id)
|
||||
return { hostId: 'h-1', subdomain: 'host-42' }
|
||||
})
|
||||
const provisionFrpc = vi.fn(async () => {
|
||||
calls.push('provision')
|
||||
return '/opt/frpc'
|
||||
})
|
||||
const writeFrpcConfig = vi.fn(() => {
|
||||
calls.push('writeConfig')
|
||||
})
|
||||
const installService = vi.fn(async () => {
|
||||
calls.push('install')
|
||||
})
|
||||
const { d, out } = deps({
|
||||
generateP256Identity,
|
||||
enrollNative,
|
||||
provisionFrpc,
|
||||
writeFrpcConfig,
|
||||
installService,
|
||||
})
|
||||
|
||||
const code = await runCli(parseArgs(['pair', 'ABCD-1234', '--install']), d)
|
||||
|
||||
expect(code).toBe(0)
|
||||
expect(calls).toEqual(['keygen', 'enroll', 'provision', 'writeConfig', 'install'])
|
||||
// CSR is built from a P-256 identity (FIX H-host-2)
|
||||
expect(enrolledIds[0]!.alg).toBe('p256')
|
||||
// enroll seam received the pairing code
|
||||
expect(enrollNative).toHaveBeenCalledWith(CFG, 'ABCD-1234', expect.anything(), expect.anything())
|
||||
// both units installed with the resolved options (env routed to base-app by installService)
|
||||
expect(installService).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
|
||||
// frpc.toml written for the returned subdomain
|
||||
expect(writeFrpcConfig).toHaveBeenCalledWith(CFG, 'host-42')
|
||||
// prints the final tunnel URL
|
||||
expect(out.join('\n')).toContain('https://host-42.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('does NOT use the legacy relay redeem path on --install', async () => {
|
||||
const redeem = vi.fn()
|
||||
const { d } = deps({ redeem: redeem as unknown as CliDeps['redeem'] })
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
expect(redeem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves the freshly generated P-256 identity before enrolling', async () => {
|
||||
const ks = fakeKeystore(false)
|
||||
const { d } = deps({ openKeystore: () => ks })
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
expect(ks.saveIdentity).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a native install whose zone is not `terminal` (FIX L-host-zone)', async () => {
|
||||
const { d } = deps({
|
||||
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'term' }),
|
||||
})
|
||||
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toThrow(/terminal/)
|
||||
})
|
||||
|
||||
it('rejects a native install with no TUNNEL_DOMAIN (cannot form the origin)', async () => {
|
||||
const { d } = deps({ resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }) })
|
||||
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toBeInstanceOf(CliUsageError)
|
||||
})
|
||||
|
||||
it('prints no key/cert material during --install (INV9)', async () => {
|
||||
const { d, out } = deps()
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
const joined = out.join('\n')
|
||||
expect(joined).not.toContain('PEM')
|
||||
expect(joined).not.toContain('CA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCli — install / run / status', () => {
|
||||
it('install threads the resolved InstallOptions into installService (S2 env injection)', async () => {
|
||||
const install = vi.fn(async () => {})
|
||||
const { d } = deps({ resolveInstallOptions: () => NATIVE_OPTIONS, installService: install })
|
||||
const code = await runCli(parseArgs(['install']), d)
|
||||
expect(code).toBe(0)
|
||||
expect(install).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
|
||||
})
|
||||
|
||||
it('run before pairing fails fast', async () => {
|
||||
const { d } = deps({}, false)
|
||||
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
|
||||
})
|
||||
|
||||
it('legacy run (Ed25519 identity, no frpc.toml) drives runTunnel — not frpc supervision', async () => {
|
||||
const runTunnel = vi.fn(async () => 0)
|
||||
const superviseFrpc = vi.fn(async () => 0)
|
||||
const { d } = deps(
|
||||
{ runTunnel, superviseFrpc, nativeConfigExists: () => false },
|
||||
true, // enrolled with the default Ed25519 identity
|
||||
)
|
||||
const code = await runCli({ command: 'run', flags: {} }, d)
|
||||
expect(code).toBe(0)
|
||||
expect(runTunnel).toHaveBeenCalledWith(CFG, expect.anything())
|
||||
expect(superviseFrpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('native run (P-256 identity + frpc.toml) supervises frpc — not the legacy relay', async () => {
|
||||
const runTunnel = vi.fn(async () => 0)
|
||||
const superviseFrpc = vi.fn(async () => 0)
|
||||
const { d } = deps({
|
||||
openKeystore: () => fakeKeystore(true, 'p256'),
|
||||
nativeConfigExists: () => true,
|
||||
runTunnel,
|
||||
superviseFrpc,
|
||||
})
|
||||
const code = await runCli({ command: 'run', flags: {} }, d)
|
||||
expect(code).toBe(0)
|
||||
expect(superviseFrpc).toHaveBeenCalledWith(CFG, expect.anything())
|
||||
expect(runTunnel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('native identity but missing frpc.toml falls back to the legacy relay path', async () => {
|
||||
const runTunnel = vi.fn(async () => 0)
|
||||
const superviseFrpc = vi.fn(async () => 0)
|
||||
const { d } = deps({
|
||||
openKeystore: () => fakeKeystore(true, 'p256'),
|
||||
nativeConfigExists: () => false,
|
||||
runTunnel,
|
||||
superviseFrpc,
|
||||
})
|
||||
await runCli({ command: 'run', flags: {} }, d)
|
||||
expect(runTunnel).toHaveBeenCalled()
|
||||
expect(superviseFrpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('status prints no key/cert material (INV9)', async () => {
|
||||
const { d, out } = deps({}, true)
|
||||
await runCli({ command: 'status', flags: {} }, d)
|
||||
const joined = out.join('\n')
|
||||
expect(joined).toContain('subdomain: host-42')
|
||||
expect(joined).not.toContain('PEM')
|
||||
expect(joined).not.toContain('CA')
|
||||
})
|
||||
})
|
||||
123
agent/test/csr.test.ts
Normal file
123
agent/test/csr.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { X509Certificate, createPublicKey, verify } from 'node:crypto'
|
||||
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
|
||||
import { buildCsr } from '../src/enroll/csr.js'
|
||||
|
||||
// --- minimal DER reader (test-only) — walks the PKCS#10 outer SEQUENCE into its 3 children -------
|
||||
interface Tlv {
|
||||
readonly tag: number
|
||||
/** the full tag+length+value bytes (what was signed, for the CertificationRequestInfo). */
|
||||
readonly tlv: Uint8Array
|
||||
readonly content: Uint8Array
|
||||
}
|
||||
|
||||
function readTlv(buf: Uint8Array, off: number): { node: Tlv; next: number } {
|
||||
const tag = buf[off]!
|
||||
let i = off + 1
|
||||
const first = buf[i]!
|
||||
let len: number
|
||||
if (first < 0x80) {
|
||||
len = first
|
||||
i += 1
|
||||
} else {
|
||||
const n = first & 0x7f
|
||||
len = 0
|
||||
for (let k = 0; k < n; k++) len = (len << 8) | buf[i + 1 + k]!
|
||||
i += 1 + n
|
||||
}
|
||||
return { node: { tag, tlv: buf.subarray(off, i + len), content: buf.subarray(i, i + len) }, next: i + len }
|
||||
}
|
||||
|
||||
function pemToDer(pem: string): Uint8Array {
|
||||
const b64 = pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '')
|
||||
return new Uint8Array(Buffer.from(b64, 'base64'))
|
||||
}
|
||||
|
||||
/** The three children of the PKCS#10 outer SEQUENCE: [certificationRequestInfo, sigAlg, signature]. */
|
||||
function csrChildren(pem: string): readonly Tlv[] {
|
||||
const der = pemToDer(pem)
|
||||
const outer = readTlv(der, 0).node
|
||||
const children: Tlv[] = []
|
||||
let p = 0
|
||||
while (p < outer.content.length) {
|
||||
const { node, next } = readTlv(outer.content, p)
|
||||
children.push(node)
|
||||
p = next
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
describe('PKCS#10 CSR (T4)', () => {
|
||||
it('emits a PEM CERTIFICATE REQUEST', () => {
|
||||
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
|
||||
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
|
||||
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
|
||||
})
|
||||
|
||||
it('does not contain private-key material (INV4)', () => {
|
||||
const id = generateIdentity()
|
||||
const csr = buildCsr(id, 'host-42')
|
||||
expect(csr).not.toContain('PRIVATE KEY')
|
||||
expect(csr).not.toContain(id.exportPrivatePkcs8Pem().split('\n')[1]!)
|
||||
})
|
||||
|
||||
it('produces a syntactically decodable DER structure', () => {
|
||||
// Node can't parse a CSR directly, but the base64 body must be valid DER (a SEQUENCE).
|
||||
const csr = buildCsr(generateIdentity(), 'host-42')
|
||||
const b64 = csr
|
||||
.replace(/-----[A-Z ]+-----/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
const der = Buffer.from(b64, 'base64')
|
||||
expect(der[0]).toBe(0x30) // outer SEQUENCE tag
|
||||
expect(der.length).toBeGreaterThan(64)
|
||||
// sanity: X509Certificate exists (crypto available) — unrelated smoke to keep import used
|
||||
expect(typeof X509Certificate).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('P-256 PKCS#10 CSR (FIX H-host-2)', () => {
|
||||
it('emits a PEM CERTIFICATE REQUEST for a P-256 identity', () => {
|
||||
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
|
||||
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
|
||||
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
|
||||
expect(csr).not.toContain('PRIVATE KEY')
|
||||
})
|
||||
|
||||
it('round-trips as a valid PKCS#10: signatureAlgorithm is ecdsa-with-SHA256', () => {
|
||||
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
|
||||
const [, sigAlg] = csrChildren(csr)
|
||||
// sigAlg = SEQUENCE { OID 1.2.840.10045.4.3.2 } (no parameters, RFC 5758 §3.2)
|
||||
const oidTlv = readTlv(sigAlg!.content, 0).node
|
||||
expect(Array.from(oidTlv.content)).toEqual([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
|
||||
// no parameters: the sigAlg SEQUENCE holds ONLY the OID.
|
||||
expect(oidTlv.tlv.length).toBe(sigAlg!.content.length)
|
||||
})
|
||||
|
||||
it('self-signature verifies over the CertificationRequestInfo (verifyCsrPoPEc semantics)', () => {
|
||||
const id = generateP256Identity()
|
||||
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
|
||||
const [reqInfo, , sigVal] = csrChildren(csr)
|
||||
// signatureValue BIT STRING content = 0x00 (unused bits) || DER ECDSA-Sig-Value.
|
||||
expect(sigVal!.tag).toBe(0x03)
|
||||
const signature = sigVal!.content.subarray(1)
|
||||
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
|
||||
// Verify over the EXACT CertificationRequestInfo bytes that buildCsr signed.
|
||||
expect(verify('sha256', reqInfo!.tlv, pub, signature)).toBe(true)
|
||||
// Tampering with the signed body breaks PoP.
|
||||
const tampered = Uint8Array.from(reqInfo!.tlv)
|
||||
tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 0xff
|
||||
expect(verify('sha256', tampered, pub, signature)).toBe(false)
|
||||
})
|
||||
|
||||
it('embeds the identity EC SPKI verbatim as the CSR subjectPublicKeyInfo', () => {
|
||||
const id = generateP256Identity()
|
||||
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
|
||||
const [reqInfo] = csrChildren(csr)
|
||||
// requestInfo = SEQUENCE { version, name, spki, [0] attributes }; the spki is the 3rd child.
|
||||
const inner = reqInfo!.content
|
||||
const version = readTlv(inner, 0)
|
||||
const name = readTlv(inner, version.next)
|
||||
const spki = readTlv(inner, name.next).node
|
||||
expect(Buffer.from(spki.tlv).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
})
|
||||
})
|
||||
103
agent/test/deps.test.ts
Normal file
103
agent/test/deps.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* B4/H4 wiring test — closes the frpc-log capture loop that `HealthReport.healthy` depends on.
|
||||
*
|
||||
* Regression guarded: nothing used to WRITE `<stateDir>/frpc.log`, so `readFrpcLog` always returned
|
||||
* '' and `frpcProxyStarted` was permanently false — health could never be true. This exercises the
|
||||
* real production path end-to-end: `createFileLoggingSpawn` (the spawn `superviseNative` injects)
|
||||
* tees a child's stdout into the exact file `readFrpcLog` scans, and `frpcProxyStarted` detects the
|
||||
* "start proxy success" line. Writer path and reader path share `frpcLogPath`, so they can't diverge.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createFileLoggingSpawn, type FrpcChild } from '../src/transport/frpSupervise.js'
|
||||
import { frpcLogPath, readFrpcLog } from '../src/cli/deps.js'
|
||||
import { frpcProxyStarted } from '../src/health/probe.js'
|
||||
|
||||
const SUCCESS_LINE = '[web-terminal] start proxy success'
|
||||
|
||||
/** Poll `predicate` until true or `timeoutMs` elapses (real IO flush is async). */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true
|
||||
await new Promise((r) => setTimeout(r, 25))
|
||||
}
|
||||
return predicate()
|
||||
}
|
||||
|
||||
/** Write an executable fake `frpc` (node shebang) that repeatedly prints the success line to stdout. */
|
||||
function writeFakeFrpc(dir: string): string {
|
||||
const bin = join(dir, 'fake-frpc.mjs')
|
||||
const script =
|
||||
`#!${process.execPath}\n` +
|
||||
`process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)})\n` +
|
||||
`setInterval(() => process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)}), 100)\n`
|
||||
writeFileSync(bin, script, { mode: 0o755 })
|
||||
return bin
|
||||
}
|
||||
|
||||
describe('B4/H4 frpc-log capture wiring (createFileLoggingSpawn ↔ readFrpcLog)', () => {
|
||||
const dirs: string[] = []
|
||||
let child: FrpcChild | null = null
|
||||
|
||||
afterEach(() => {
|
||||
child?.kill()
|
||||
child = null
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('tees the frpc child stdout into the file readFrpcLog scans → proxyStarted becomes true', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
|
||||
dirs.push(dir)
|
||||
|
||||
// Before any child runs, the log is empty and the proxy is not started.
|
||||
expect(readFrpcLog(dir)).toBe('')
|
||||
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
|
||||
|
||||
// Spawn via the SAME factory superviseNative injects, pointed at the SAME path it reads.
|
||||
const bin = writeFakeFrpc(dir)
|
||||
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
|
||||
child = spawn(bin, join(dir, 'frpc.toml'))
|
||||
|
||||
const detected = await waitFor(() => frpcProxyStarted(readFrpcLog(dir)))
|
||||
expect(detected).toBe(true)
|
||||
expect(readFrpcLog(dir)).toContain('start proxy success')
|
||||
})
|
||||
|
||||
it('createFileLoggingSpawn truncates a stale log so a dead child’s success line is not reused', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
|
||||
dirs.push(dir)
|
||||
|
||||
// Simulate a leftover log from a previous (now dead) frpc that had succeeded.
|
||||
writeFileSync(frpcLogPath(dir), `${SUCCESS_LINE}\n`)
|
||||
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(true)
|
||||
|
||||
// A fresh spawn whose child never prints the line must NOT keep reporting the stale success.
|
||||
const bin = join(dir, 'silent-frpc.mjs')
|
||||
writeFileSync(bin, `#!${process.execPath}\nsetInterval(() => {}, 100)\n`, { mode: 0o755 })
|
||||
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
|
||||
child = spawn(bin, join(dir, 'frpc.toml'))
|
||||
|
||||
// Truncate-on-spawn clears the stale line; the silent child adds none.
|
||||
const cleared = await waitFor(() => readFrpcLog(dir) === '')
|
||||
expect(cleared).toBe(true)
|
||||
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpcLogPath / readFrpcLog (deterministic)', () => {
|
||||
it('frpcLogPath joins frpc.log under the state dir', () => {
|
||||
expect(frpcLogPath('/state/x')).toBe(join('/state/x', 'frpc.log'))
|
||||
})
|
||||
|
||||
it('readFrpcLog returns "" when the log file does not exist', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
|
||||
try {
|
||||
expect(readFrpcLog(dir)).toBe('')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
112
agent/test/dial.test.ts
Normal file
112
agent/test/dial.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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 { generateIdentity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
CertExpiredError,
|
||||
NotEnrolledError,
|
||||
buildTlsOptions,
|
||||
dialRelay,
|
||||
type RawTlsWs,
|
||||
type TlsWsConstructor,
|
||||
} from '../src/transport/dial.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay.example.com/agent',
|
||||
enrollUrl: 'https://example.com/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function enrolledKs() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-dial-'))
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateIdentity())
|
||||
ks.saveCert('CERTPEM', 'CAPEM')
|
||||
return { dir, ks }
|
||||
}
|
||||
|
||||
const future: () => { validTo: Date } = () => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||
const past: () => { validTo: Date } = () => ({ validTo: new Date(Date.now() - 1000) })
|
||||
|
||||
describe('buildTlsOptions (T12, INV14/INV4)', () => {
|
||||
it('wires cert + key + CA and forces rejectUnauthorized true', () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const tls = buildTlsOptions(ks, { certParser: future })
|
||||
expect(tls.cert).toBe('CERTPEM')
|
||||
expect(tls.ca).toBe('CAPEM')
|
||||
expect(tls.key).toContain('PRIVATE KEY') // in-process key PEM
|
||||
expect(tls.rejectUnauthorized).toBe(true)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('carries NO bearer/agent token (mTLS is the auth)', () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const tls = buildTlsOptions(ks, { certParser: future })
|
||||
expect(JSON.stringify(tls)).not.toMatch(/token|authorization|bearer/i)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('missing cert → NotEnrolledError (fail-fast)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-dial-'))
|
||||
expect(() => buildTlsOptions(openKeystore(dir))).toThrow(NotEnrolledError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('expired cert → CertExpiredError', () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
expect(() => buildTlsOptions(ks, { certParser: past })).toThrow(CertExpiredError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dialRelay (T12)', () => {
|
||||
it('constructs the wss client with the TLS options and resolves on open', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
let capturedUrl = ''
|
||||
let capturedOpts: unknown
|
||||
class FakeTlsWs implements RawTlsWs {
|
||||
constructor(url: string, opts: unknown) {
|
||||
capturedUrl = url
|
||||
capturedOpts = opts
|
||||
queueMicrotask(() => this.openCb?.())
|
||||
}
|
||||
private openCb?: () => void
|
||||
send(): void {}
|
||||
close(): void {}
|
||||
on(): void {}
|
||||
once(ev: string, cb: () => void): void {
|
||||
if (ev === 'open') this.openCb = cb
|
||||
}
|
||||
}
|
||||
const ws = await dialRelay(CFG, ks, { Ctor: FakeTlsWs as unknown as TlsWsConstructor, certParser: future })
|
||||
expect(capturedUrl).toBe('wss://relay.example.com/agent')
|
||||
expect((capturedOpts as { rejectUnauthorized: boolean }).rejectUnauthorized).toBe(true)
|
||||
expect(ws).toBeDefined()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects when the server errors before open (bad chain / MITM)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
class FakeTlsWs implements RawTlsWs {
|
||||
private errCb?: (e: unknown) => void
|
||||
constructor() {
|
||||
queueMicrotask(() => this.errCb?.(new Error('unable to verify leaf signature')))
|
||||
}
|
||||
send(): void {}
|
||||
close(): void {}
|
||||
on(): void {}
|
||||
once(ev: string, cb: (e: unknown) => void): void {
|
||||
if (ev === 'error') this.errCb = cb
|
||||
}
|
||||
}
|
||||
const p = dialRelay(CFG, ks, { Ctor: FakeTlsWs as unknown as TlsWsConstructor, certParser: future })
|
||||
await expect(p).rejects.toThrow(/verify/)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
63
agent/test/fixtures/fakes.ts
vendored
Normal file
63
agent/test/fixtures/fakes.ts
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { WsLike, TimerLike } from '../../src/transport/seams.js'
|
||||
|
||||
/** In-memory WsLike double: records sent frames, lets tests emit inbound events. */
|
||||
export class FakeWs implements WsLike {
|
||||
readonly sent: Uint8Array[] = []
|
||||
closed = false
|
||||
private readonly listeners = new Map<string, Array<(...a: unknown[]) => void>>()
|
||||
|
||||
send(d: Uint8Array): void {
|
||||
this.sent.push(d)
|
||||
}
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
|
||||
const arr = this.listeners.get(ev) ?? []
|
||||
arr.push(cb)
|
||||
this.listeners.set(ev, arr)
|
||||
}
|
||||
close(): void {
|
||||
this.closed = true
|
||||
this.emit('close')
|
||||
}
|
||||
emit(ev: string, ...args: unknown[]): void {
|
||||
for (const cb of this.listeners.get(ev) ?? []) cb(...args)
|
||||
}
|
||||
emitMessage(bytes: Uint8Array): void {
|
||||
this.emit('message', bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic controllable timer for heartbeat/rotation tests. */
|
||||
export class FakeTimer implements TimerLike {
|
||||
private seq = 0
|
||||
private readonly timeouts = new Map<number, { cb: () => void; ms: number }>()
|
||||
private readonly intervals = new Map<number, { cb: () => void; ms: number }>()
|
||||
|
||||
setTimeout(cb: () => void, ms: number): unknown {
|
||||
const id = this.seq++
|
||||
this.timeouts.set(id, { cb, ms })
|
||||
return id
|
||||
}
|
||||
clearTimeout(handle: unknown): void {
|
||||
this.timeouts.delete(handle as number)
|
||||
}
|
||||
setInterval(cb: () => void, ms: number): unknown {
|
||||
const id = this.seq++
|
||||
this.intervals.set(id, { cb, ms })
|
||||
return id
|
||||
}
|
||||
clearInterval(handle: unknown): void {
|
||||
this.intervals.delete(handle as number)
|
||||
}
|
||||
/** Fire every armed timeout whose delay is ≤ ms (single shot) and every interval once. */
|
||||
advance(ms: number): void {
|
||||
for (const [id, t] of [...this.timeouts]) {
|
||||
if (t.ms <= ms) {
|
||||
this.timeouts.delete(id)
|
||||
t.cb()
|
||||
}
|
||||
}
|
||||
for (const t of [...this.intervals.values()]) {
|
||||
if (t.ms <= ms) t.cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
37
agent/test/flowControl.test.ts
Normal file
37
agent/test/flowControl.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createFlowController } from '../src/transport/flowControl.js'
|
||||
|
||||
describe('FlowController (T11)', () => {
|
||||
it('pauses at zero credit and resumes on grant', () => {
|
||||
const fc = createFlowController()
|
||||
fc.initWindow(1, 10)
|
||||
expect(fc.consume(1, 8)).toBe(true)
|
||||
expect(fc.consume(1, 8)).toBe(false) // only 2 credit left → pause
|
||||
fc.grant(1, 16)
|
||||
expect(fc.consume(1, 8)).toBe(true)
|
||||
})
|
||||
|
||||
it('credit is per-stream: exhausting A does not pause B (starvation guard)', () => {
|
||||
const fc = createFlowController()
|
||||
fc.initWindow(1, 4)
|
||||
fc.initWindow(2, 100)
|
||||
expect(fc.consume(1, 4)).toBe(true)
|
||||
expect(fc.consume(1, 1)).toBe(false) // A exhausted
|
||||
expect(fc.consume(2, 50)).toBe(true) // B unaffected
|
||||
})
|
||||
|
||||
it('connection-level (streamId 0) credit caps the whole link', () => {
|
||||
const fc = createFlowController()
|
||||
fc.initWindow(0, 5) // connection window
|
||||
fc.initWindow(1, 1000)
|
||||
expect(fc.consume(1, 5)).toBe(true)
|
||||
expect(fc.consume(1, 1)).toBe(false) // connection window exhausted despite stream credit
|
||||
fc.grant(0, 10)
|
||||
expect(fc.consume(1, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('an uninitialized stream has no credit', () => {
|
||||
const fc = createFlowController()
|
||||
expect(fc.consume(42, 1)).toBe(false)
|
||||
})
|
||||
})
|
||||
53
agent/test/frpScaffold.test.ts
Normal file
53
agent/test/frpScaffold.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import {
|
||||
buildFrpcToml,
|
||||
isFrpRetired,
|
||||
spawnFrpc,
|
||||
type ChildLike,
|
||||
} from '../src/transport/frpScaffold.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: null,
|
||||
}
|
||||
|
||||
describe('frpScaffold (T6, v0.8 only)', () => {
|
||||
it('generates a loopback-only tls frpc.toml', () => {
|
||||
const toml = buildFrpcToml(CFG)
|
||||
expect(toml).toContain('local_ip = "127.0.0.1"')
|
||||
expect(toml).toContain('local_port = 3000')
|
||||
expect(toml).toContain('subdomain = "host-42"')
|
||||
expect(toml).toContain('tls_enable = true')
|
||||
expect(toml).not.toMatch(/local_ip = "(?!127\.0\.0\.1)/)
|
||||
})
|
||||
|
||||
it('refuses a non-loopback local target (anti-SSRF)', () => {
|
||||
expect(() => buildFrpcToml({ ...CFG, localTargetUrl: 'ws://10.0.0.5:3000' })).toThrow()
|
||||
})
|
||||
|
||||
it('propagates child exit to onExit', async () => {
|
||||
let exitHandler: ((code: number | null) => void) | null = null
|
||||
const child: ChildLike = {
|
||||
on: (_ev, cb) => {
|
||||
exitHandler = cb
|
||||
},
|
||||
kill: vi.fn(),
|
||||
}
|
||||
const scaffold = spawnFrpc(CFG, '/usr/bin/frpc', () => child)
|
||||
const seen: number[] = []
|
||||
scaffold.onExit((c) => seen.push(c))
|
||||
await scaffold.start()
|
||||
exitHandler!(7)
|
||||
expect(seen).toEqual([7])
|
||||
})
|
||||
|
||||
it('retirement guard: retired once ed25519', () => {
|
||||
expect(isFrpRetired('ed25519')).toBe(true)
|
||||
expect(isFrpRetired('token')).toBe(false)
|
||||
})
|
||||
})
|
||||
148
agent/test/frpSupervise.test.ts
Normal file
148
agent/test/frpSupervise.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
import { createBackoff } from '../src/transport/backoff.js'
|
||||
import {
|
||||
STABLE_RUN_MS,
|
||||
superviseFrpc,
|
||||
type FrpcChild,
|
||||
type SpawnFrpc,
|
||||
} from '../src/transport/frpSupervise.js'
|
||||
|
||||
const silentLogger = createLogger('error', () => {})
|
||||
|
||||
/**
|
||||
* A fake frpc child whose exit is driven from the test. `kill()` models a real process: it dies,
|
||||
* firing the exit handler (so the supervisor's `stop()` — which kills the live child — can unblock).
|
||||
* exit/kill fire the handler at most once.
|
||||
*/
|
||||
function makeChild(): { child: FrpcChild; exit: (code: number | null) => void; killed: boolean } {
|
||||
let onExit: ((code: number | null) => void) | null = null
|
||||
let alive = true
|
||||
const state = { killed: false }
|
||||
const fire = (code: number | null): void => {
|
||||
if (!alive) return
|
||||
alive = false
|
||||
onExit?.(code)
|
||||
}
|
||||
return {
|
||||
child: {
|
||||
onExit: (cb) => {
|
||||
onExit = cb
|
||||
},
|
||||
isAlive: () => alive,
|
||||
kill: () => {
|
||||
state.killed = true
|
||||
fire(null)
|
||||
},
|
||||
},
|
||||
exit: (code) => fire(code),
|
||||
get killed() {
|
||||
return state.killed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
|
||||
it('spawns the frpc binary with -c <toml> via the child seam', async () => {
|
||||
const spawn: SpawnFrpc = vi.fn(() => makeChild().child)
|
||||
superviseFrpc('/opt/agent/bin/frpc', '/state/frpc.toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(spawn).toHaveBeenCalledWith('/opt/agent/bin/frpc', '/state/frpc.toml')
|
||||
})
|
||||
|
||||
it('restarts the child on exit, backing off 1s → 2s → 4s', async () => {
|
||||
const children = [makeChild(), makeChild(), makeChild(), makeChild()]
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = () => children[n++]!.child
|
||||
const sleeps: number[] = []
|
||||
// now() fixed so no run counts as "stable" ⇒ backoff monotonically increases.
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
backoff: createBackoff(),
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms)
|
||||
},
|
||||
logger: silentLogger,
|
||||
now: () => 1000,
|
||||
})
|
||||
|
||||
// Crash three times; each crash schedules the next spawn after the growing backoff.
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
children[i]!.exit(1)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(sleeps).toEqual([1000, 2000, 4000])
|
||||
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('resets the backoff after a run that stayed up past the stability window', async () => {
|
||||
const children = [makeChild(), makeChild(), makeChild()]
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = () => children[n++]!.child
|
||||
const sleeps: number[] = []
|
||||
let clock = 0
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
backoff: createBackoff(),
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms)
|
||||
},
|
||||
logger: silentLogger,
|
||||
now: () => clock,
|
||||
})
|
||||
|
||||
// First run crashes instantly ⇒ backoff 1s.
|
||||
children[0]!.exit(1)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
// Second run stays up past STABLE_RUN_MS before dying ⇒ backoff resets to 1s (not 2s).
|
||||
clock += STABLE_RUN_MS + 1
|
||||
children[1]!.exit(1)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sleeps).toEqual([1000, 1000])
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('stop() halts the loop and kills the live child; done resolves 0', async () => {
|
||||
const c = makeChild()
|
||||
const spawn: SpawnFrpc = () => c.child
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(handle.isChildAlive()).toBe(true)
|
||||
|
||||
// stop() kills the child; that fires the exit handler and the loop observes `stopped` → breaks.
|
||||
const stopping = handle.stop()
|
||||
c.exit(null)
|
||||
await expect(stopping).resolves.toBeUndefined()
|
||||
await expect(handle.done).resolves.toBe(0)
|
||||
expect(c.killed).toBe(true)
|
||||
})
|
||||
|
||||
it('does not restart after stop (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
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
416
agent/test/frpcBinary.test.ts
Normal file
416
agent/test/frpcBinary.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import {
|
||||
detectFrpcPlatform,
|
||||
FRPC_PLATFORMS,
|
||||
FRPC_RELEASES,
|
||||
provisionFrpc,
|
||||
type FrpcPlatform,
|
||||
type FrpcReleaseRef,
|
||||
type ProvisionFrpcDeps,
|
||||
} from '../src/provision/frpcBinary.js'
|
||||
import {
|
||||
extractTarFileByBasename,
|
||||
extractFrpcBinary,
|
||||
TarExtractError,
|
||||
} from '../src/provision/untar.js'
|
||||
|
||||
function sha256Hex(data: Uint8Array): string {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-test tar/gzip fixture builder — hand-builds real USTAR blocks so the
|
||||
// extractor is exercised against the SAME on-disk layout frp ships (dir entry
|
||||
// + regular files), with zero network access.
|
||||
// ---------------------------------------------------------------------------
|
||||
const TAR_BLOCK = 512
|
||||
const TYPE_FILE = '0'
|
||||
const TYPE_DIR = '5'
|
||||
|
||||
interface TarEntrySpec {
|
||||
readonly name: string
|
||||
readonly content: Uint8Array
|
||||
readonly typeflag?: string
|
||||
}
|
||||
|
||||
function writeOctalField(block: Uint8Array, offset: number, len: number, value: number): void {
|
||||
const s = value.toString(8).padStart(len - 1, '0')
|
||||
block.set(new TextEncoder().encode(s), offset)
|
||||
block[offset + len - 1] = 0 // NUL terminator
|
||||
}
|
||||
|
||||
function tarHeader(name: string, size: number, typeflag: string): Uint8Array {
|
||||
const block = new Uint8Array(TAR_BLOCK)
|
||||
const enc = new TextEncoder()
|
||||
block.set(enc.encode(name).subarray(0, 100), 0)
|
||||
writeOctalField(block, 100, 8, 0o755) // mode
|
||||
writeOctalField(block, 108, 8, 0) // uid
|
||||
writeOctalField(block, 116, 8, 0) // gid
|
||||
writeOctalField(block, 124, 12, size) // size
|
||||
writeOctalField(block, 136, 12, 0) // mtime
|
||||
block[156] = typeflag.charCodeAt(0)
|
||||
block.set(enc.encode('ustar'), 257) // magic "ustar\0"
|
||||
block[263] = 0x30 // version "00"
|
||||
block[264] = 0x30
|
||||
// checksum: fields spaces during compute, then written as 6 octal + NUL + space
|
||||
for (let i = 148; i < 156; i++) block[i] = 0x20
|
||||
let sum = 0
|
||||
for (let i = 0; i < TAR_BLOCK; i++) sum += block[i] ?? 0
|
||||
block.set(enc.encode(sum.toString(8).padStart(6, '0')), 148)
|
||||
block[154] = 0
|
||||
block[155] = 0x20
|
||||
return block
|
||||
}
|
||||
|
||||
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((n, p) => n + p.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const p of parts) {
|
||||
out.set(p, off)
|
||||
off += p.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function makeTar(entries: readonly TarEntrySpec[]): Uint8Array {
|
||||
const parts: Uint8Array[] = []
|
||||
for (const e of entries) {
|
||||
parts.push(tarHeader(e.name, e.content.length, e.typeflag ?? TYPE_FILE))
|
||||
parts.push(e.content)
|
||||
const pad = (TAR_BLOCK - (e.content.length % TAR_BLOCK)) % TAR_BLOCK
|
||||
if (pad > 0) parts.push(new Uint8Array(pad))
|
||||
}
|
||||
parts.push(new Uint8Array(TAR_BLOCK * 2)) // end-of-archive: two zero blocks
|
||||
return concatBytes(parts)
|
||||
}
|
||||
|
||||
function makeTarGz(entries: readonly TarEntrySpec[]): Uint8Array {
|
||||
return new Uint8Array(gzipSync(makeTar(entries)))
|
||||
}
|
||||
|
||||
const FRPC_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0xde, 0xad, 0xbe, 0xef]) // fake "ELF" frpc
|
||||
const FRPS_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x11, 0x22, 0x33]) // decoy frps
|
||||
|
||||
/** A realistic frp archive layout: dir entry + frpc + decoy frps + LICENSE. */
|
||||
function realisticFrpTarGz(prefix = 'frp_0.61.1_darwin_arm64'): Uint8Array {
|
||||
return makeTarGz([
|
||||
{ name: `${prefix}/`, content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||||
{ name: `${prefix}/frps`, content: FRPS_BYTES },
|
||||
{ name: `${prefix}/frpc`, content: FRPC_BYTES },
|
||||
{ name: `${prefix}/LICENSE`, content: new TextEncoder().encode('MIT') },
|
||||
])
|
||||
}
|
||||
|
||||
interface FakeFsState {
|
||||
writes: Map<string, Uint8Array>
|
||||
renames: Array<{ from: string; to: string }>
|
||||
removed: string[]
|
||||
chmods: Array<{ path: string; mode: number }>
|
||||
mkdirs: string[]
|
||||
}
|
||||
|
||||
function makeFakeDeps(bytesByUrl: Record<string, Uint8Array>): {
|
||||
deps: ProvisionFrpcDeps
|
||||
state: FakeFsState
|
||||
fetchedUrls: string[]
|
||||
} {
|
||||
const state: FakeFsState = {
|
||||
writes: new Map(),
|
||||
renames: [],
|
||||
removed: [],
|
||||
chmods: [],
|
||||
mkdirs: [],
|
||||
}
|
||||
const fetchedUrls: string[] = []
|
||||
const deps: ProvisionFrpcDeps = {
|
||||
fetch: async (url) => {
|
||||
fetchedUrls.push(url)
|
||||
const bytes = bytesByUrl[url]
|
||||
if (!bytes) throw new Error(`test: no fixture bytes for ${url}`)
|
||||
return bytes
|
||||
},
|
||||
fs: {
|
||||
mkdir: async (dir) => {
|
||||
state.mkdirs.push(dir)
|
||||
},
|
||||
writeFile: async (path, data) => {
|
||||
state.writes.set(path, data)
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
state.renames.push({ from, to })
|
||||
const data = state.writes.get(from)
|
||||
if (data) {
|
||||
state.writes.delete(from)
|
||||
state.writes.set(to, data)
|
||||
}
|
||||
},
|
||||
chmod: async (path, mode) => {
|
||||
state.chmods.push({ path, mode })
|
||||
},
|
||||
rm: async (path) => {
|
||||
state.removed.push(path)
|
||||
state.writes.delete(path)
|
||||
},
|
||||
},
|
||||
}
|
||||
return { deps, state, fetchedUrls }
|
||||
}
|
||||
|
||||
function releaseOverride(
|
||||
platform: FrpcPlatform,
|
||||
ref: FrpcReleaseRef,
|
||||
): Record<FrpcPlatform, FrpcReleaseRef> {
|
||||
return { ...FRPC_RELEASES, [platform]: ref }
|
||||
}
|
||||
|
||||
const BIN_DIR = '/opt/wt/bin'
|
||||
const BIN_PATH = '/opt/wt/bin/frpc'
|
||||
|
||||
describe('detectFrpcPlatform (B3)', () => {
|
||||
it('maps darwin/linux × arm64/amd64 (node x64 → amd64)', () => {
|
||||
expect(detectFrpcPlatform('darwin', 'arm64')).toBe('darwin-arm64')
|
||||
expect(detectFrpcPlatform('darwin', 'x64')).toBe('darwin-amd64')
|
||||
expect(detectFrpcPlatform('linux', 'arm64')).toBe('linux-arm64')
|
||||
expect(detectFrpcPlatform('linux', 'x64')).toBe('linux-amd64')
|
||||
})
|
||||
|
||||
it('returns null for unsupported platform/arch', () => {
|
||||
expect(detectFrpcPlatform('win32', 'x64')).toBeNull()
|
||||
expect(detectFrpcPlatform('linux', 'ia32')).toBeNull()
|
||||
expect(detectFrpcPlatform('freebsd', 'arm64')).toBeNull()
|
||||
expect(detectFrpcPlatform('darwin', 'mips')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FRPC_RELEASES pinning', () => {
|
||||
it('pins a release per supported platform (https url, semver, 64-hex sha256)', () => {
|
||||
expect([...FRPC_PLATFORMS].sort()).toEqual([
|
||||
'darwin-amd64',
|
||||
'darwin-arm64',
|
||||
'linux-amd64',
|
||||
'linux-arm64',
|
||||
])
|
||||
for (const platform of FRPC_PLATFORMS) {
|
||||
const ref = FRPC_RELEASES[platform]
|
||||
expect(ref.url.startsWith('https://')).toBe(true)
|
||||
expect(ref.url.endsWith('.tar.gz')).toBe(true)
|
||||
expect(ref.version).toMatch(/^\d+\.\d+\.\d+$/)
|
||||
expect(ref.sha256).toMatch(/^[0-9a-f]{64}$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('pins REAL (non-placeholder) frp v0.61.1 checksums', () => {
|
||||
for (const platform of FRPC_PLATFORMS) {
|
||||
expect(FRPC_RELEASES[platform].sha256).not.toBe('0'.repeat(64))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractTarFileByBasename (tar path-traversal safe extractor)', () => {
|
||||
it('returns the bytes of the first regular file whose basename matches', () => {
|
||||
const tar = makeTar([
|
||||
{ name: 'frp_x/', content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
|
||||
expect(extractTarFileByBasename(tar, 'frps')).toEqual(FRPS_BYTES)
|
||||
})
|
||||
|
||||
it('does NOT match a directory entry that shares the basename', () => {
|
||||
const tar = makeTar([
|
||||
{ name: 'frpc/', content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
|
||||
})
|
||||
|
||||
it('throws when no matching file entry exists', () => {
|
||||
const tar = makeTar([{ name: 'frp_x/frps', content: FRPS_BYTES }])
|
||||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(TarExtractError)
|
||||
})
|
||||
|
||||
it('REJECTS a matching entry whose name contains a ".." traversal segment', () => {
|
||||
const tar = makeTar([{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES }])
|
||||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|traversal|\.\./i)
|
||||
})
|
||||
|
||||
it('REJECTS a matching entry with an absolute path name', () => {
|
||||
const tar = makeTar([{ name: '/etc/frpc', content: FRPC_BYTES }])
|
||||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|absolute/i)
|
||||
})
|
||||
|
||||
it('throws on a corrupt (truncated) tar rather than reading out of bounds', () => {
|
||||
const full = makeTar([{ name: 'frp_x/frpc', content: FRPC_BYTES }])
|
||||
const truncated = full.subarray(0, TAR_BLOCK + 4) // header + partial content
|
||||
expect(() => extractTarFileByBasename(truncated, 'frpc')).toThrow(TarExtractError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFrpcBinary (gunzip + extract)', () => {
|
||||
it('gunzips then extracts the inner frpc bytes', () => {
|
||||
expect(extractFrpcBinary(realisticFrpTarGz())).toEqual(FRPC_BYTES)
|
||||
})
|
||||
|
||||
it('throws on non-gzip input', () => {
|
||||
expect(() => extractFrpcBinary(new Uint8Array([1, 2, 3, 4]))).toThrow(TarExtractError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('provisionFrpc (B3 verify-download + extract discipline)', () => {
|
||||
it('selects the arch URL, VERIFIES the archive, and places the INNER frpc (not the archive)', async () => {
|
||||
const archive = realisticFrpTarGz()
|
||||
const url = 'https://example.test/frp-darwin-arm64.tar.gz'
|
||||
const releases = releaseOverride('darwin-arm64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state, fetchedUrls } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
const result = await provisionFrpc(
|
||||
{ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases },
|
||||
deps,
|
||||
)
|
||||
|
||||
expect(fetchedUrls).toEqual([url])
|
||||
expect(result.binPath).toBe(BIN_PATH)
|
||||
expect(result.version).toBe('0.61.1')
|
||||
expect(result.platform).toBe('darwin-arm64')
|
||||
// atomic place: renamed into the final path, made executable
|
||||
expect(state.renames.some((r) => r.to === BIN_PATH)).toBe(true)
|
||||
expect(state.chmods.some((c) => (c.mode & 0o111) !== 0)).toBe(true)
|
||||
// the placed file is the EXTRACTED frpc binary, NOT the .tar.gz archive
|
||||
const placed = state.writes.get(BIN_PATH)
|
||||
expect(placed).toEqual(FRPC_BYTES)
|
||||
expect(placed).not.toEqual(archive)
|
||||
})
|
||||
|
||||
it('ignores the decoy frps entry and places frpc even when frps precedes it', async () => {
|
||||
const archive = makeTarGz([
|
||||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
const url = 'https://example.test/frp.tar.gz'
|
||||
const releases = releaseOverride('linux-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps)
|
||||
|
||||
expect(state.writes.get(BIN_PATH)).toEqual(FRPC_BYTES)
|
||||
})
|
||||
|
||||
it('REJECTS on sha256 mismatch and places NO binary (temp cleaned up, no extraction)', async () => {
|
||||
const archive = realisticFrpTarGz()
|
||||
const url = 'https://example.test/frp-linux-amd64.tar.gz'
|
||||
const releases = releaseOverride('linux-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: 'f'.repeat(64), // deliberately wrong
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow(/sha-?256|hash|integrity|mismatch/i)
|
||||
|
||||
expect(state.renames).toEqual([])
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.removed.length).toBeGreaterThan(0) // unverified temp removed
|
||||
})
|
||||
|
||||
it('never places or execs before verifying (mismatch leaves nothing executable)', async () => {
|
||||
const archive = realisticFrpTarGz()
|
||||
const url = 'https://example.test/bad.tar.gz'
|
||||
const releases = releaseOverride('linux-arm64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: '0'.repeat(64),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'linux', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(state.chmods.every((c) => c.path !== BIN_PATH)).toBe(true)
|
||||
})
|
||||
|
||||
it('throws and places nothing when the verified archive has NO frpc entry', async () => {
|
||||
const archive = makeTarGz([
|
||||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||||
{ name: 'frp_x/LICENSE', content: new TextEncoder().encode('MIT') },
|
||||
])
|
||||
const url = 'https://example.test/no-frpc.tar.gz'
|
||||
const releases = releaseOverride('darwin-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'darwin', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow(/extract|frpc|tar/i)
|
||||
|
||||
expect(state.renames).toEqual([])
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.removed.length).toBeGreaterThan(0) // temp removed on extraction failure
|
||||
})
|
||||
|
||||
it('REJECTS a traversal frpc entry and never writes outside binDir', async () => {
|
||||
const archive = makeTarGz([
|
||||
{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
const url = 'https://example.test/evil.tar.gz'
|
||||
const releases = releaseOverride('linux-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow()
|
||||
|
||||
// nothing placed, and every write that ever happened stayed inside binDir
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.renames).toEqual([])
|
||||
expect([...state.writes.keys()].every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
|
||||
expect(state.removed.every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
|
||||
})
|
||||
|
||||
it('REJECTS a hash-matching but corrupt (non-gzip) archive after the gate', async () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) // hashes fine, not a gzip
|
||||
const url = 'https://example.test/corrupt.tar.gz'
|
||||
const releases = releaseOverride('darwin-arm64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(garbage),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: garbage })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow(/extract|gzip|tar/i)
|
||||
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.removed.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('throws a clear error on an unsupported platform (nothing fetched)', async () => {
|
||||
const { deps, fetchedUrls } = makeFakeDeps({})
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'win32', arch: 'x64', binDir: BIN_DIR }, deps),
|
||||
).rejects.toThrow(/unsupported|platform/i)
|
||||
expect(fetchedUrls).toEqual([])
|
||||
})
|
||||
})
|
||||
100
agent/test/frpcToml.test.ts
Normal file
100
agent/test/frpcToml.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildNativeFrpcToml, type NativeFrpcOptions } from '../src/transport/frpcToml.js'
|
||||
|
||||
const BASE: NativeFrpcOptions = {
|
||||
subdomain: 'alice',
|
||||
localPort: 3000,
|
||||
authToken: 'super-secret-token',
|
||||
certFile: '/home/alice/.web-terminal-agent/frpc.cert.pem',
|
||||
keyFile: '/home/alice/.web-terminal-agent/frpc.key.pem',
|
||||
trustedCaFile: '/home/alice/.web-terminal-agent/frps-ctrl-ca.pem',
|
||||
}
|
||||
|
||||
describe('buildNativeFrpcToml (B2h)', () => {
|
||||
it('emits the native-tunnel server/port/tls keys (PLAN_NATIVE_TUNNEL §4)', () => {
|
||||
const toml = buildNativeFrpcToml(BASE)
|
||||
expect(toml).toContain('serverAddr = "8.138.1.192"')
|
||||
expect(toml).toContain('serverPort = 443')
|
||||
expect(toml).toContain('transport.tls.enable = true')
|
||||
expect(toml).toContain('transport.tls.serverName = "frp.terminal.yaojia.wang"')
|
||||
expect(toml).toContain('transport.tls.disableCustomTLSFirstByte = true')
|
||||
expect(toml).toContain(`transport.tls.certFile = "${BASE.certFile}"`)
|
||||
expect(toml).toContain(`transport.tls.keyFile = "${BASE.keyFile}"`)
|
||||
expect(toml).toContain(`transport.tls.trustedCaFile = "${BASE.trustedCaFile}"`)
|
||||
expect(toml).toContain('auth.method = "token"')
|
||||
expect(toml).toContain('auth.token = "super-secret-token"')
|
||||
})
|
||||
|
||||
it('emits a well-formed [[proxies]] http block bound to loopback', () => {
|
||||
const toml = buildNativeFrpcToml(BASE)
|
||||
expect(toml).toContain('[[proxies]]')
|
||||
expect(toml).toContain('type = "http"')
|
||||
expect(toml).toContain('subdomain = "alice"')
|
||||
expect(toml).toContain('localIP = "127.0.0.1"')
|
||||
expect(toml).toContain('localPort = 3000')
|
||||
// the proxy block comes after the server/tls preamble
|
||||
expect(toml.indexOf('[[proxies]]')).toBeGreaterThan(toml.indexOf('serverAddr'))
|
||||
})
|
||||
|
||||
it('does NOT emit the retired v0.8 [common]/tls_enable shape', () => {
|
||||
const toml = buildNativeFrpcToml(BASE)
|
||||
expect(toml).not.toContain('[common]')
|
||||
expect(toml).not.toContain('tls_enable')
|
||||
})
|
||||
|
||||
it('honours a configurable serverAddr (default 8.138.1.192)', () => {
|
||||
expect(buildNativeFrpcToml(BASE)).toContain('serverAddr = "8.138.1.192"')
|
||||
expect(buildNativeFrpcToml({ ...BASE, serverAddr: '10.9.8.7' })).toContain(
|
||||
'serverAddr = "10.9.8.7"',
|
||||
)
|
||||
})
|
||||
|
||||
it('THROWS when localIP is non-loopback (anti-SSRF hard invariant)', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '10.0.0.5' })).toThrow(/loopback/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '0.0.0.0' })).toThrow(/loopback/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '192.168.1.9' })).toThrow(/loopback/i)
|
||||
})
|
||||
|
||||
it('accepts the three explicit loopback localIP forms', () => {
|
||||
for (const ip of ['127.0.0.1', '::1', 'localhost']) {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: ip })).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an empty or non-label-safe subdomain', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'has space' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '-bad' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'bad-' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a'.repeat(64) })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a/b' })).toThrow(/subdomain/i)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range or non-integer localPort', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 0 })).toThrow(/port/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 70000 })).toThrow(/port/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 3000.5 })).toThrow(/port/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: -1 })).toThrow(/port/i)
|
||||
})
|
||||
|
||||
it('rejects missing keystore paths and an empty auth token', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, certFile: '' })).toThrow(/certFile/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: '' })).toThrow(/keyFile/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, trustedCaFile: '' })).toThrow(/trustedCaFile/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, authToken: '' })).toThrow(/token/i)
|
||||
})
|
||||
|
||||
it('escapes backslashes and quotes in Windows-style paths (valid TOML basic string)', () => {
|
||||
const winCert = 'C:\\Users\\alice\\.web-terminal-agent\\frpc.cert.pem'
|
||||
const toml = buildNativeFrpcToml({ ...BASE, certFile: winCert })
|
||||
expect(toml).toContain(
|
||||
'transport.tls.certFile = "C:\\\\Users\\\\alice\\\\.web-terminal-agent\\\\frpc.cert.pem"',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects control characters in paths/token (TOML-injection guard)', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, authToken: 'a\nb' })).toThrow()
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, certFile: 'a\nb' })).toThrow()
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: 'a"b\nq' })).toThrow()
|
||||
})
|
||||
})
|
||||
58
agent/test/heartbeat.test.ts
Normal file
58
agent/test/heartbeat.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeMuxFrame } from 'relay-contracts'
|
||||
import { holdTunnel } from '../src/transport/tunnel.js'
|
||||
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../src/transport/heartbeat.js'
|
||||
import { FakeWs, FakeTimer } from './fixtures/fakes.js'
|
||||
|
||||
describe('Heartbeat (T9, §4.1)', () => {
|
||||
it('replies PONG echoing the inbound PING token byte-exact', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const hb = createHeartbeat(tunnel, { timer: new FakeTimer() })
|
||||
const token = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
hb.onPing(token)
|
||||
const frame = decodeMuxFrame(ws.sent.at(-1)!)
|
||||
expect(frame.header.type).toBe('pong')
|
||||
expect(Buffer.from(frame.payload).equals(Buffer.from(token))).toBe(true)
|
||||
})
|
||||
|
||||
it('fires onDead when no PONG arrives within the interval', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const timer = new FakeTimer()
|
||||
const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000 })
|
||||
let dead = false
|
||||
hb.onDead(() => {
|
||||
dead = true
|
||||
})
|
||||
hb.start() // sends first ping, arms deadline
|
||||
timer.advance(1000) // deadline fires, no pong seen
|
||||
expect(dead).toBe(true)
|
||||
})
|
||||
|
||||
it('does not fire onDead when PONG arrives in time', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const timer = new FakeTimer()
|
||||
const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000, genToken: () => new Uint8Array(8) })
|
||||
let dead = false
|
||||
hb.onDead(() => {
|
||||
dead = true
|
||||
})
|
||||
hb.start()
|
||||
hb.onPong(new Uint8Array(8)) // clears pending before the deadline
|
||||
timer.advance(1000)
|
||||
expect(dead).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes the frozen 15s interval', () => {
|
||||
expect(HEARTBEAT_INTERVAL_MS).toBe(15_000)
|
||||
})
|
||||
|
||||
it('stop() cancels timers (no leak)', () => {
|
||||
const ws = new FakeWs()
|
||||
const hb = createHeartbeat(holdTunnel(ws), { timer: new FakeTimer(), intervalMs: 1000 })
|
||||
hb.start()
|
||||
expect(() => hb.stop()).not.toThrow()
|
||||
})
|
||||
})
|
||||
147
agent/test/hostEndpoint.test.ts
Normal file
147
agent/test/hostEndpoint.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
AeadAlg,
|
||||
ClientHello,
|
||||
E2ESession,
|
||||
HandshakeResult,
|
||||
HostHello,
|
||||
} from 'relay-contracts'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import {
|
||||
MitmAbortError,
|
||||
createE2ETransform,
|
||||
makeHostHello,
|
||||
type CreateHostHandshake,
|
||||
type VerifyDeviceProof,
|
||||
} from '../src/e2e/hostEndpoint.js'
|
||||
import type { ReplaySealer } from '../src/e2e/replaySeal.js'
|
||||
|
||||
const ALG: AeadAlg = 'aes-256-gcm'
|
||||
|
||||
function clientHello(proof: string): ClientHello {
|
||||
return {
|
||||
clientEphPub: new Uint8Array([1, 2, 3]),
|
||||
clientNonce: new Uint8Array([4, 5, 6]),
|
||||
aeadOffer: [ALG],
|
||||
deviceAuthProof: proof,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeHandshake(keysByte: number): CreateHostHandshake {
|
||||
return () => ({
|
||||
async respond(): Promise<{ hello: HostHello; result: HandshakeResult }> {
|
||||
const result: HandshakeResult = {
|
||||
keys: {
|
||||
c2h: new Uint8Array([keysByte]) as never,
|
||||
h2c: new Uint8Array([keysByte + 1]) as never,
|
||||
},
|
||||
aead: ALG,
|
||||
transcript: new Uint8Array([0xff]),
|
||||
}
|
||||
const hello: HostHello = {
|
||||
hostEphPub: new Uint8Array([7]),
|
||||
hostNonce: new Uint8Array([8]),
|
||||
aeadChoice: ALG,
|
||||
enrollFpr: 'fpr',
|
||||
sig: new Uint8Array([9]),
|
||||
}
|
||||
return { hello, result }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The load-bearing MITM verifier: only a specific proof passes.
|
||||
const realVerifier: VerifyDeviceProof = async (proof) => proof === 'VALID-PROOF'
|
||||
|
||||
describe('makeHostHello (T15, anti-MITM)', () => {
|
||||
it('derives DirectionalKeys{c2h,h2c} on a valid proof (FIX 2 — no single sessionKey)', async () => {
|
||||
const { result } = await makeHostHello(clientHello('VALID-PROOF'), generateIdentity(), realVerifier, {
|
||||
createHostHandshake: fakeHandshake(0x10),
|
||||
})
|
||||
expect(result.keys).toHaveProperty('c2h')
|
||||
expect(result.keys).toHaveProperty('h2c')
|
||||
expect(result).not.toHaveProperty('sessionKey')
|
||||
})
|
||||
|
||||
it('ABORTS on a forged proof: no keys, no HostHello (MitmAbortError)', async () => {
|
||||
const respond = vi.fn()
|
||||
const spyHandshake: CreateHostHandshake = () => ({ respond: respond as never })
|
||||
await expect(
|
||||
makeHostHello(clientHello('FORGED'), generateIdentity(), realVerifier, {
|
||||
createHostHandshake: spyHandshake,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MitmAbortError)
|
||||
expect(respond).not.toHaveBeenCalled() // no key derivation reached
|
||||
})
|
||||
|
||||
it('no-stub guard: swapping the verifier for always-true makes the MITM test FAIL', async () => {
|
||||
const stub: VerifyDeviceProof = async () => true
|
||||
// With the stubbed verifier, the forged proof WRONGLY completes — proving the verifier is
|
||||
// load-bearing (a real build forbids this stub via the import guard below).
|
||||
const out = await makeHostHello(clientHello('FORGED'), generateIdentity(), stub, {
|
||||
createHostHandshake: fakeHandshake(0x20),
|
||||
})
|
||||
expect(out.result.keys).toBeDefined()
|
||||
})
|
||||
|
||||
it('no-stub CI guard: hostEndpoint.ts imports NO verifier from relay-e2e (FIX 6b)', () => {
|
||||
const raw = readFileSync(join(import.meta.dirname, '..', 'src', 'e2e', 'hostEndpoint.ts'), 'utf8')
|
||||
const src = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') // strip comments
|
||||
expect(src).not.toMatch(/import[^\n]*verifyDeviceAuthProof/)
|
||||
expect(src).not.toMatch(/from ['"]relay-e2e['"]/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createE2ETransform (T15)', () => {
|
||||
function fakeSession(): E2ESession {
|
||||
// Reversible transform that HIDES the plaintext (XOR) so INV2 assertions are meaningful.
|
||||
return {
|
||||
role: 'host',
|
||||
seal: (pt) => Uint8Array.from([0xe0, ...pt.map((b) => b ^ 0x55)]),
|
||||
open: (ct) => Uint8Array.from(ct.slice(1)).map((b) => b ^ 0x55),
|
||||
rederive: () => {},
|
||||
}
|
||||
}
|
||||
function fakeReplay(): ReplaySealer & { calls: number } {
|
||||
const r = {
|
||||
calls: 0,
|
||||
epoch: 'test-epoch',
|
||||
seal(_pt: Uint8Array) {
|
||||
r.calls += 1
|
||||
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }
|
||||
},
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
it('outbound seals under BOTH the live session and the replay key (FIX 3, INV2)', () => {
|
||||
const replay = fakeReplay()
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, replay)
|
||||
t.openStream(1)
|
||||
t.seedSession(1, fakeSession(), new Uint8Array([0x48])) // HostHello control frame
|
||||
const marker = new TextEncoder().encode('PLAIN')
|
||||
const sealed = t.outbound(1, marker)
|
||||
expect(replay.calls).toBe(1) // replay-bound seal happened
|
||||
expect(Buffer.from(sealed).includes(Buffer.from(marker))).toBe(false) // ciphertext, not plaintext
|
||||
expect(t.takeControlFrames!(1)).toEqual([new Uint8Array([0x48])]) // HostHello flushed once
|
||||
expect(t.takeControlFrames!(1)).toEqual([])
|
||||
})
|
||||
|
||||
it('inbound before seeding returns null (handshake pending); after, opens opaque', () => {
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
|
||||
const session = fakeSession()
|
||||
t.openStream(1)
|
||||
expect(t.inbound(1, new Uint8Array([1, 2]))).toBeNull()
|
||||
t.seedSession(1, session, new Uint8Array([0x48]))
|
||||
const ct = session.seal(new Uint8Array([9, 9])) // c2h ciphertext
|
||||
expect([...t.inbound(1, ct)!]).toEqual([9, 9])
|
||||
})
|
||||
|
||||
it('outbound before the session is established aborts (no silent plaintext leak)', () => {
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
|
||||
t.openStream(1)
|
||||
expect(() => t.outbound(1, new Uint8Array([1]))).toThrow(MitmAbortError)
|
||||
})
|
||||
})
|
||||
105
agent/test/identity.test.ts
Normal file
105
agent/test/identity.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createPublicKey, verify } from 'node:crypto'
|
||||
import {
|
||||
computeEnrollFpr,
|
||||
generateIdentity,
|
||||
generateP256Identity,
|
||||
identityFromPrivatePem,
|
||||
p256IdentityFromPrivatePem,
|
||||
verifySignature,
|
||||
} from '../src/keys/identity.js'
|
||||
|
||||
describe('AgentIdentity (INV4)', () => {
|
||||
it('generates distinct keypairs', () => {
|
||||
const a = generateIdentity()
|
||||
const b = generateIdentity()
|
||||
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
|
||||
expect(a.publicKey.length).toBe(32)
|
||||
})
|
||||
|
||||
it('sign/verify round-trips', () => {
|
||||
const id = generateIdentity()
|
||||
const msg = new TextEncoder().encode('transcript-bytes')
|
||||
const sig = id.sign(msg)
|
||||
expect(verifySignature(id.publicKey, msg, sig)).toBe(true)
|
||||
expect(verifySignature(id.publicKey, new TextEncoder().encode('tampered'), sig)).toBe(false)
|
||||
})
|
||||
|
||||
it('enrollFpr is deterministic base64url(SHA-256(pubkey))', () => {
|
||||
const id = generateIdentity()
|
||||
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
|
||||
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
|
||||
})
|
||||
|
||||
it('reloads the same identity from PEM', () => {
|
||||
const id = generateIdentity()
|
||||
const pem = id.exportPrivatePkcs8Pem()
|
||||
const reloaded = identityFromPrivatePem(pem)
|
||||
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
|
||||
})
|
||||
|
||||
it('security: no API returns raw private-key bytes', () => {
|
||||
const id = generateIdentity()
|
||||
// The only private-key surface is a PEM export for the 0600 keystore and an in-process
|
||||
// KeyObject; there is NO Uint8Array/raw-bytes getter for the private key.
|
||||
expect('privateKey' in id).toBe(false)
|
||||
const src = readFileSync(
|
||||
join(import.meta.dirname, '..', 'src', 'keys', 'identity.ts'),
|
||||
'utf8',
|
||||
)
|
||||
// No function exports raw private key bytes onto the network surface.
|
||||
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
|
||||
})
|
||||
|
||||
it('keeps the Ed25519 alg tag (no P-256 regression)', () => {
|
||||
expect(generateIdentity().alg).toBe('ed25519')
|
||||
})
|
||||
})
|
||||
|
||||
describe('P-256 AgentIdentity (FIX H-host-2 — native frp-client key)', () => {
|
||||
it('generates distinct EC P-256 keypairs tagged alg=p256', () => {
|
||||
const a = generateP256Identity()
|
||||
const b = generateP256Identity()
|
||||
expect(a.alg).toBe('p256')
|
||||
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
|
||||
// publicKey is the EC SubjectPublicKeyInfo DER (outer SEQUENCE), importable as a public key.
|
||||
expect(a.publicKey[0]).toBe(0x30)
|
||||
const pub = createPublicKey({ key: Buffer.from(a.publicKey), format: 'der', type: 'spki' })
|
||||
expect(pub.asymmetricKeyType).toBe('ec')
|
||||
expect(pub.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
|
||||
})
|
||||
|
||||
it('sign produces a DER ECDSA signature that verifies under SHA-256', () => {
|
||||
const id = generateP256Identity()
|
||||
const msg = new TextEncoder().encode('certificationRequestInfo-bytes')
|
||||
const sig = id.sign(msg)
|
||||
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
|
||||
expect(verify('sha256', msg, pub, sig)).toBe(true)
|
||||
expect(verify('sha256', new TextEncoder().encode('tampered'), pub, sig)).toBe(false)
|
||||
})
|
||||
|
||||
it('enrollFpr is deterministic base64url(SHA-256(spki))', () => {
|
||||
const id = generateP256Identity()
|
||||
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
|
||||
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
|
||||
})
|
||||
|
||||
it('reloads the same P-256 identity from PEM (keystore load path)', () => {
|
||||
const id = generateP256Identity()
|
||||
const pem = id.exportPrivatePkcs8Pem()
|
||||
const reloaded = p256IdentityFromPrivatePem(pem)
|
||||
expect(reloaded.alg).toBe('p256')
|
||||
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
|
||||
})
|
||||
|
||||
it('security: P-256 identity exposes no raw private-key getter', () => {
|
||||
const id = generateP256Identity()
|
||||
expect('privateKey' in id).toBe(false)
|
||||
// the PEM export is the only serialization surface; it is a PRIVATE key PEM (0600 keystore only).
|
||||
expect(id.exportPrivatePkcs8Pem()).toContain('PRIVATE KEY')
|
||||
})
|
||||
})
|
||||
420
agent/test/install.test.ts
Normal file
420
agent/test/install.test.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import {
|
||||
BindHostError,
|
||||
RootRefusedError,
|
||||
assertNativeZone,
|
||||
buildInstallOptions,
|
||||
detectPlatform,
|
||||
installService,
|
||||
normalizeBindHost,
|
||||
uninstallService,
|
||||
type InstallDeps,
|
||||
} from '../src/service/install.js'
|
||||
import { agentLabel, baseAppLabel, buildLaunchdPlist } from '../src/service/launchd.js'
|
||||
import { agentUnitName, baseAppUnitName, buildSystemdUnit } from '../src/service/systemd.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
type FakeDeps = InstallDeps & {
|
||||
writes: Array<[string, string]>
|
||||
runs: Array<[string, readonly string[]]>
|
||||
}
|
||||
|
||||
function deps(uid = 501): FakeDeps {
|
||||
const writes: Array<[string, string]> = []
|
||||
const runs: Array<[string, readonly string[]]> = []
|
||||
return {
|
||||
writes,
|
||||
runs,
|
||||
writeFile: (p, c) => writes.push([p, c]),
|
||||
runCommand: async (cmd, args) => {
|
||||
runs.push([cmd, args])
|
||||
},
|
||||
getuid: () => uid,
|
||||
homedir: () => '/home/alice',
|
||||
username: () => 'alice',
|
||||
binPath: () => '/usr/local/bin/web-terminal-agent',
|
||||
}
|
||||
}
|
||||
|
||||
/** The unit content whose path contains `needle` (e.g. `'base-app'`, `'agent'`). */
|
||||
function unitWith(d: FakeDeps, needle: string): string {
|
||||
const hit = d.writes.find(([path]) => path.includes(needle))
|
||||
if (!hit) throw new Error(`no unit written whose path contains '${needle}'`)
|
||||
return hit[1]
|
||||
}
|
||||
|
||||
describe('detectPlatform (T17)', () => {
|
||||
it('maps darwin→launchd, linux→systemd, else null', () => {
|
||||
expect(detectPlatform('darwin')).toBe('launchd')
|
||||
expect(detectPlatform('linux')).toBe('systemd')
|
||||
expect(detectPlatform('win32')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installService — least privilege (T17)', () => {
|
||||
it('refuses to install as root (negative, least privilege)', async () => {
|
||||
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
|
||||
})
|
||||
|
||||
it('root refusal emits nothing', async () => {
|
||||
const d = deps(0)
|
||||
await expect(installService(CFG, 'systemd', d)).rejects.toBeInstanceOf(RootRefusedError)
|
||||
expect(d.writes).toHaveLength(0)
|
||||
expect(d.runs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installService — two distinct units (FIX M-host-2service)', () => {
|
||||
it('systemd: writes a base-app unit AND an agent unit, both run-as-user, both enabled', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d)
|
||||
// exactly two units
|
||||
expect(d.writes).toHaveLength(2)
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
const agent = unitWith(d, agentUnitName())
|
||||
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback)
|
||||
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
|
||||
expect(baseApp).toContain('ExecStart=')
|
||||
expect(baseApp).toContain('server.js')
|
||||
expect(baseApp).not.toContain('web-terminal-agent run')
|
||||
// both least-privilege + restart-on-failure
|
||||
for (const unit of [baseApp, agent]) {
|
||||
expect(unit).toContain('User=alice')
|
||||
expect(unit).not.toContain('User=root')
|
||||
expect(unit).toContain('Restart=on-failure')
|
||||
}
|
||||
// both enabled
|
||||
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
|
||||
expect(d.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('launchd: writes a base-app plist AND an agent plist, both with KeepAlive, both loaded', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d)
|
||||
expect(d.writes).toHaveLength(2)
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
const agent = unitWith(d, agentLabel())
|
||||
expect(agent).toContain('<string>run</string>')
|
||||
expect(baseApp).toContain('server.js')
|
||||
expect(baseApp).not.toContain('<string>run</string>')
|
||||
for (const plist of [baseApp, agent]) {
|
||||
expect(plist).toContain('<key>KeepAlive</key>')
|
||||
}
|
||||
expect(d.writes.every(([path]) => path.includes('LaunchAgents'))).toBe(true)
|
||||
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
|
||||
expect(d.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('routes base-app env to the base-app unit ONLY (never onto the agent unit)', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } })
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
const agent = unitWith(d, agentUnitName())
|
||||
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(baseApp).toContain('Environment="PORT=3000"')
|
||||
// the agent unit must NOT carry the base-app env
|
||||
expect(agent).not.toContain('BIND_HOST')
|
||||
expect(agent).not.toContain('PORT=3000')
|
||||
})
|
||||
|
||||
it('uninstall tears down BOTH units (launchd unload)', async () => {
|
||||
const d = deps()
|
||||
await uninstallService('launchd', d)
|
||||
const targets = d.runs.map(([, args]) => args[args.length - 1])
|
||||
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
|
||||
expect(targets.some((t) => t?.includes(baseAppLabel()))).toBe(true)
|
||||
expect(targets.some((t) => t?.includes(agentLabel()))).toBe(true)
|
||||
})
|
||||
|
||||
it('uninstall tears down BOTH units (systemd disable)', async () => {
|
||||
const d = deps()
|
||||
await uninstallService('systemd', d)
|
||||
const units = d.runs.map(([, args]) => args[args.length - 1])
|
||||
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
|
||||
expect(units).toContain(baseAppUnitName())
|
||||
expect(units).toContain(agentUnitName())
|
||||
})
|
||||
})
|
||||
|
||||
describe('BIND_HOST loopback S-GATE (FIX C-host-1, CRITICAL)', () => {
|
||||
it('normalizeBindHost defaults an absent value to loopback', () => {
|
||||
expect(normalizeBindHost(undefined)).toBe('127.0.0.1')
|
||||
expect(normalizeBindHost('')).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('normalizeBindHost accepts loopback forms (127.0.0.0/8, ::1, localhost)', () => {
|
||||
expect(normalizeBindHost('127.0.0.1')).toBe('127.0.0.1')
|
||||
expect(normalizeBindHost('127.0.0.2')).toBe('127.0.0.2')
|
||||
expect(normalizeBindHost('::1')).toBe('::1')
|
||||
expect(normalizeBindHost('localhost')).toBe('localhost')
|
||||
})
|
||||
|
||||
it('normalizeBindHost REJECTS 0.0.0.0 and other non-loopback values', () => {
|
||||
expect(() => normalizeBindHost('0.0.0.0')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('192.168.1.10')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('::')).toThrow(BindHostError)
|
||||
})
|
||||
|
||||
it('REGRESSION: rejects a suffixed hostname that merely starts with 127. (S-GATE bypass)', () => {
|
||||
// These are hostnames, not loopback literals — Node would DNS-resolve them before bind().
|
||||
expect(() => normalizeBindHost('127.0.0.1.attacker.example.com')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('127.evil.net')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('127.0.0.1x')).toThrow(BindHostError)
|
||||
expect(() => buildInstallOptions({ BIND_HOST: '127.0.0.1.attacker.example.com' })).toThrow(
|
||||
BindHostError,
|
||||
)
|
||||
})
|
||||
|
||||
it('buildInstallOptions throws on BIND_HOST=0.0.0.0 (fail-closed at env read)', () => {
|
||||
expect(() => buildInstallOptions({ BIND_HOST: '0.0.0.0' })).toThrow(BindHostError)
|
||||
})
|
||||
|
||||
it('NEGATIVE: installService with BIND_HOST=0.0.0.0 throws AND emits nothing', async () => {
|
||||
const d = deps()
|
||||
await expect(
|
||||
installService(CFG, 'systemd', d, { env: { BIND_HOST: '0.0.0.0', PORT: '3000' } }),
|
||||
).rejects.toBeInstanceOf(BindHostError)
|
||||
expect(d.writes).toHaveLength(0)
|
||||
expect(d.runs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('NEGATIVE (launchd): a 0.0.0.0 install emits no plist', async () => {
|
||||
const d = deps()
|
||||
await expect(
|
||||
installService(CFG, 'launchd', d, { env: { BIND_HOST: '0.0.0.0' } }),
|
||||
).rejects.toBeInstanceOf(BindHostError)
|
||||
expect(d.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('the emitted base-app unit can NEVER contain BIND_HOST=0.0.0.0 (normalized when absent)', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, { env: { PORT: '3000' } })
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(baseApp).not.toContain('0.0.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInstallOptions — env → InstallOptions (S0/S2 + S-GATE)', () => {
|
||||
it('defaults BIND_HOST to loopback so a tunnel install is never LAN-exposed (S0/R2)', () => {
|
||||
const options = buildInstallOptions({})
|
||||
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1' })
|
||||
})
|
||||
|
||||
it('honours an explicit loopback BIND_HOST and passes through the S0 base-app env vars', () => {
|
||||
const options = buildInstallOptions({
|
||||
BIND_HOST: '127.0.0.2',
|
||||
PORT: '3000',
|
||||
SHELL_PATH: '/bin/zsh',
|
||||
IDLE_TTL: '86400',
|
||||
USE_TMUX: '1',
|
||||
ALLOWED_ORIGINS: 'https://keep.me',
|
||||
SCROLLBACK_BYTES: '2097152',
|
||||
MAX_PAYLOAD_BYTES: '1048576',
|
||||
})
|
||||
expect(options.env).toEqual({
|
||||
BIND_HOST: '127.0.0.2',
|
||||
PORT: '3000',
|
||||
SHELL_PATH: '/bin/zsh',
|
||||
IDLE_TTL: '86400',
|
||||
USE_TMUX: '1',
|
||||
ALLOWED_ORIGINS: 'https://keep.me',
|
||||
SCROLLBACK_BYTES: '2097152',
|
||||
MAX_PAYLOAD_BYTES: '1048576',
|
||||
})
|
||||
})
|
||||
|
||||
it('AG3: passes SCROLLBACK_BYTES and MAX_PAYLOAD_BYTES through as base-app config', () => {
|
||||
const options = buildInstallOptions({ SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576' })
|
||||
expect(options.env).toEqual({
|
||||
BIND_HOST: '127.0.0.1',
|
||||
SCROLLBACK_BYTES: '2097152',
|
||||
MAX_PAYLOAD_BYTES: '1048576',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits unset/empty passthrough vars', () => {
|
||||
const options = buildInstallOptions({ PORT: '', SHELL_PATH: '/bin/bash' })
|
||||
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1', SHELL_PATH: '/bin/bash' })
|
||||
})
|
||||
|
||||
it('derives domain + default `terminal` zone from TUNNEL_DOMAIN', () => {
|
||||
const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang' })
|
||||
expect(options.domain).toBe('yaojia.wang')
|
||||
expect(options.zone).toBe('terminal')
|
||||
})
|
||||
|
||||
it('lets TUNNEL_ZONE override the origin zone and carries AGENT_ENV_FILE through', () => {
|
||||
const options = buildInstallOptions({
|
||||
TUNNEL_DOMAIN: 'yaojia.wang',
|
||||
TUNNEL_ZONE: 'term',
|
||||
AGENT_ENV_FILE: '/etc/wt.env',
|
||||
})
|
||||
expect(options.zone).toBe('term')
|
||||
expect(options.envFile).toBe('/etc/wt.env')
|
||||
})
|
||||
|
||||
it('omits domain/zone when no TUNNEL_DOMAIN is set', () => {
|
||||
const options = buildInstallOptions({})
|
||||
expect(options.domain).toBeUndefined()
|
||||
expect(options.zone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tunnel-origin derivation into the base-app unit (FIX L-host-zone)', () => {
|
||||
it('assertNativeZone accepts `terminal` and rejects `term`/undefined', () => {
|
||||
expect(() => assertNativeZone('terminal')).not.toThrow()
|
||||
expect(() => assertNativeZone('term')).toThrow(/terminal/)
|
||||
expect(() => assertNativeZone(undefined)).toThrow(/terminal/)
|
||||
})
|
||||
|
||||
it('merges https://<sub>.terminal.<domain> into the base-app ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
expect(baseApp).toContain('<key>ALLOWED_ORIGINS</key>')
|
||||
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
|
||||
})
|
||||
|
||||
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, {
|
||||
env: { ALLOWED_ORIGINS: 'https://keep.me' },
|
||||
domain: 'yaojia.wang',
|
||||
zone: 'terminal',
|
||||
})
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('does not derive an origin when the config has no subdomain', async () => {
|
||||
const d = deps()
|
||||
await installService({ ...CFG, subdomain: null }, 'launchd', d, {
|
||||
domain: 'yaojia.wang',
|
||||
zone: 'terminal',
|
||||
})
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
expect(baseApp).not.toContain('ALLOWED_ORIGINS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install CLI seam end-to-end — resolved env reaches the base-app unit (S2)', () => {
|
||||
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
|
||||
|
||||
it('launchd: the base-app plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
expect(baseApp).toContain('<key>BIND_HOST</key>')
|
||||
expect(baseApp).toContain('<string>127.0.0.1</string>')
|
||||
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
|
||||
expect(baseApp).toContain('<key>PORT</key>')
|
||||
expect(baseApp).not.toContain('0.0.0.0')
|
||||
})
|
||||
|
||||
it('systemd: the base-app unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(baseApp).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
|
||||
expect(baseApp).toContain('Environment="PORT=3000"')
|
||||
expect(baseApp).not.toContain('0.0.0.0')
|
||||
})
|
||||
|
||||
it('systemd: emits EnvironmentFile= (before inline Environment) on the base-app unit', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, {
|
||||
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
|
||||
envFile: '/etc/web-terminal.env',
|
||||
})
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('EnvironmentFile=/etc/web-terminal.env')
|
||||
expect(baseApp.indexOf('EnvironmentFile=')).toBeLessThan(baseApp.indexOf('Environment='))
|
||||
})
|
||||
})
|
||||
|
||||
describe('unit writers — escaping & control-char hardening', () => {
|
||||
it('launchd: escapes XML-significant characters in env values', () => {
|
||||
const plist = buildLaunchdPlist(['/bin/agent', 'run'], { X: `a&b<c>d"e'f` })
|
||||
expect(plist).toContain('<string>a&b<c>d"e'f</string>')
|
||||
expect(plist).not.toContain('a&b<c>d')
|
||||
})
|
||||
|
||||
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', () => {
|
||||
const plist = buildLaunchdPlist(['/bin/agent', 'run'], {
|
||||
BIND_HOST: '127.0.0.1',
|
||||
ALLOWED_ORIGINS: 'https://a',
|
||||
PORT: '3000',
|
||||
})
|
||||
expect(plist).toContain('<key>EnvironmentVariables</key>')
|
||||
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
|
||||
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
|
||||
})
|
||||
|
||||
it('launchd: no env → no EnvironmentVariables block', () => {
|
||||
const plist = buildLaunchdPlist(['/bin/agent', 'run'])
|
||||
expect(plist).not.toContain('EnvironmentVariables')
|
||||
})
|
||||
|
||||
it('systemd: escapes backslash and double-quote in Environment values', () => {
|
||||
const unit = buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a"b\\c' } })
|
||||
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
|
||||
})
|
||||
|
||||
it('systemd: rejects a newline in an env value (no [Service] directive injection)', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\nExecStartPre=/x' } }),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('systemd: rejects a carriage return in an env value', () => {
|
||||
expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\rb' } })).toThrow(
|
||||
/control character/,
|
||||
)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the ExecStart command (no [Service] directive injection)', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run\nExecStartPre=/x', 'alice'),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the User field', () => {
|
||||
expect(() => buildSystemdUnit('/bin/agent run', 'alice\nExecStartPre=/x')).toThrow(
|
||||
/control character/,
|
||||
)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the Description field', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', {}, 'desc\n[Service]\nExecStartPre=/x'),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the EnvironmentFile path', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', { envFile: '/etc/x.env\nExecStartPre=/y' }),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in an Environment KEY (not just the value)', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', { env: { 'X\nExecStartPre=/y': 'v' } }),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('systemd: default (no env) omits Environment lines', () => {
|
||||
const unit = buildSystemdUnit('/bin/agent run', 'alice')
|
||||
expect(unit).not.toContain('Environment')
|
||||
})
|
||||
})
|
||||
117
agent/test/keystore.test.ts
Normal file
117
agent/test/keystore.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'
|
||||
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
|
||||
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
function freshDir(): string {
|
||||
const d = mkdtempSync(join(tmpdir(), 'wta-ks-'))
|
||||
dirs.push(d)
|
||||
return d
|
||||
}
|
||||
afterEach(() => {
|
||||
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function mode(path: string): number {
|
||||
return statSync(path).mode & 0o777
|
||||
}
|
||||
|
||||
describe('Keystore (INV4/INV5)', () => {
|
||||
it('persists the private key 0600 and reloads it', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const id = generateIdentity()
|
||||
ks.saveIdentity(id)
|
||||
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
|
||||
const reloaded = ks.loadIdentity()
|
||||
expect(reloaded).not.toBeNull()
|
||||
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
})
|
||||
|
||||
it('persists cert + CA chain 0600', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveCert('CERTPEM', 'CAPEM')
|
||||
expect(mode(join(dir, 'agent.cert.pem'))).toBe(0o600)
|
||||
expect(mode(join(dir, 'agent.ca.pem'))).toBe(0o600)
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'CERTPEM', caChainPem: 'CAPEM' })
|
||||
})
|
||||
|
||||
it('FIX 3: round-trips hostContentSecret 0600', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const secret = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
ks.saveContentSecret(secret)
|
||||
expect(mode(join(dir, 'content.secret'))).toBe(0o600)
|
||||
expect(Buffer.from(ks.loadContentSecret()!).equals(Buffer.from(secret))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when nothing is stored yet', () => {
|
||||
const ks = openKeystore(freshDir())
|
||||
expect(ks.loadIdentity()).toBeNull()
|
||||
expect(ks.loadCert()).toBeNull()
|
||||
expect(ks.loadContentSecret()).toBeNull()
|
||||
})
|
||||
|
||||
it('throws a typed error on a corrupt key file', () => {
|
||||
const dir = freshDir()
|
||||
writeFileSync(join(dir, 'agent.key.pem'), 'not-a-pem')
|
||||
expect(() => openKeystore(dir).loadIdentity()).toThrow(KeystoreError)
|
||||
})
|
||||
|
||||
it('refuses to write into a group/world-accessible dir (INV5)', () => {
|
||||
const dir = join(freshDir(), 'wideopen')
|
||||
mkdirSync(dir, { mode: 0o755 })
|
||||
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
|
||||
})
|
||||
|
||||
it('keeps the Ed25519 identity byte-identical across save/load (no P-256 regression)', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const id = generateIdentity()
|
||||
ks.saveIdentity(id)
|
||||
const reloaded = ks.loadIdentity()
|
||||
expect(reloaded!.alg).toBe('ed25519')
|
||||
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
|
||||
})
|
||||
|
||||
it('FIX H-host-2: round-trips a P-256 frp-client identity (alg + usable signing key)', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const id = generateP256Identity()
|
||||
ks.saveIdentity(id)
|
||||
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
|
||||
|
||||
const reloaded = ks.loadIdentity()
|
||||
expect(reloaded).not.toBeNull()
|
||||
// loadIdentity() branched on the stored key's alg discriminant → P-256, not Ed25519.
|
||||
expect(reloaded!.alg).toBe('p256')
|
||||
// EC SubjectPublicKeyInfo DER (outer SEQUENCE) preserved exactly.
|
||||
expect(reloaded!.publicKey[0]).toBe(0x30)
|
||||
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
|
||||
|
||||
// The reloaded key still signs: a DER ECDSA signature that verifies under the original pubkey.
|
||||
const msg = new TextEncoder().encode('csr-bytes')
|
||||
const sig = reloaded!.sign(msg)
|
||||
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
|
||||
expect(verify('sha256', msg, pub, sig)).toBe(true)
|
||||
})
|
||||
|
||||
it('AG1: rejects a stored EC key on a non-P256 curve (e.g. secp384r1) with a clear error', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
// Plant a valid PKCS#8 EC key on the WRONG curve directly at the key path — `ec` alone must not
|
||||
// be mistaken for P-256; loadIdentity must assert the named curve and fail closed.
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'secp384r1' })
|
||||
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
writeFileSync(join(dir, 'agent.key.pem'), pem, { mode: 0o600 })
|
||||
expect(() => ks.loadIdentity()).toThrow(KeystoreError)
|
||||
expect(() => ks.loadIdentity()).toThrow(/prime256v1|P-256|curve/)
|
||||
})
|
||||
})
|
||||
43
agent/test/logger.test.ts
Normal file
43
agent/test/logger.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
|
||||
function capture() {
|
||||
const lines: string[] = []
|
||||
return { lines, sink: (l: string) => lines.push(l) }
|
||||
}
|
||||
|
||||
describe('redacting logger (INV9)', () => {
|
||||
it('never emits secret meta values', () => {
|
||||
const { lines, sink } = capture()
|
||||
const log = createLogger('debug', sink)
|
||||
log.log('info', 'enrolled', {
|
||||
privateKey: 'SECRET-PRIV-KEY',
|
||||
cert: 'SECRET-CERT',
|
||||
pairingCode: 'ABCD-1234',
|
||||
agentToken: 'tok-xyz',
|
||||
})
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).not.toContain('SECRET-PRIV-KEY')
|
||||
expect(joined).not.toContain('SECRET-CERT')
|
||||
expect(joined).not.toContain('ABCD-1234')
|
||||
expect(joined).not.toContain('tok-xyz')
|
||||
expect(joined).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it('passes non-secret meta (nonce) through', () => {
|
||||
const { lines, sink } = capture()
|
||||
const log = createLogger('debug', sink)
|
||||
log.log('debug', 'frame', { nonce: 'abc123', streamId: 7 })
|
||||
expect(lines[0]).toContain('abc123')
|
||||
expect(lines[0]).toContain('7')
|
||||
})
|
||||
|
||||
it('drops messages below the threshold level', () => {
|
||||
const { lines, sink } = capture()
|
||||
const log = createLogger('warn', sink)
|
||||
log.log('debug', 'noisy')
|
||||
log.log('error', 'boom')
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toContain('boom')
|
||||
})
|
||||
})
|
||||
49
agent/test/loopback.test.ts
Normal file
49
agent/test/loopback.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLoopbackUrl, dialLoopback, type RawWs, type WsConstructor } from '../src/transport/loopback.js'
|
||||
|
||||
describe('buildLoopbackUrl (T8)', () => {
|
||||
it('joins target + path without a double slash', () => {
|
||||
expect(buildLoopbackUrl('ws://127.0.0.1:3000', '/term?join=x')).toBe('ws://127.0.0.1:3000/term?join=x')
|
||||
expect(buildLoopbackUrl('ws://127.0.0.1:3000/', 'term')).toBe('ws://127.0.0.1:3000/term')
|
||||
})
|
||||
})
|
||||
|
||||
class FakeRawWs implements RawWs {
|
||||
static last: FakeRawWs | null = null
|
||||
readonly url: string
|
||||
readonly origin: string | undefined
|
||||
private readonly handlers = new Map<string, (...a: unknown[]) => void>()
|
||||
constructor(url: string, opts?: { headers?: Record<string, string> }) {
|
||||
this.url = url
|
||||
this.origin = opts?.headers?.['Origin']
|
||||
FakeRawWs.last = this
|
||||
}
|
||||
send(): void {}
|
||||
close(): void {}
|
||||
on(): void {}
|
||||
once(event: string, cb: (...a: unknown[]) => void): void {
|
||||
this.handlers.set(event, cb)
|
||||
}
|
||||
fire(event: string, ...args: unknown[]): void {
|
||||
this.handlers.get(event)?.(...args)
|
||||
}
|
||||
}
|
||||
|
||||
describe('dialLoopback (T8)', () => {
|
||||
it('replays Origin and resolves on open', async () => {
|
||||
const dial = dialLoopback('ws://127.0.0.1:3000', FakeRawWs as unknown as WsConstructor)
|
||||
const promise = dial('/term?join=x', 'https://host-42.term.example.com')
|
||||
FakeRawWs.last!.fire('open')
|
||||
const ws = await promise
|
||||
expect(FakeRawWs.last!.url).toBe('ws://127.0.0.1:3000/term?join=x')
|
||||
expect(FakeRawWs.last!.origin).toBe('https://host-42.term.example.com')
|
||||
expect(ws).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects on a pre-open error (base app down)', async () => {
|
||||
const dial = dialLoopback('ws://127.0.0.1:3000', FakeRawWs as unknown as WsConstructor)
|
||||
const promise = dial('/term', 'https://x')
|
||||
FakeRawWs.last!.fire('error', new Error('ECONNREFUSED'))
|
||||
await expect(promise).rejects.toThrow('ECONNREFUSED')
|
||||
})
|
||||
})
|
||||
39
agent/test/loopbackLiteral.test.ts
Normal file
39
agent/test/loopbackLiteral.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isLoopbackHostLiteral } from '../src/net/loopbackLiteral.js'
|
||||
|
||||
describe('isLoopbackHostLiteral — strict loopback-literal check (FIX C-host-1 / anti-SSRF)', () => {
|
||||
it('accepts exact loopback literals', () => {
|
||||
expect(isLoopbackHostLiteral('localhost')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('::1')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('[::1]')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts any well-formed IPv4 in 127.0.0.0/8', () => {
|
||||
expect(isLoopbackHostLiteral('127.0.0.1')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('127.0.0.2')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('127.5.5.5')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('127.255.255.255')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-loopback IPs and wildcards', () => {
|
||||
expect(isLoopbackHostLiteral('0.0.0.0')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('192.168.1.10')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('10.0.0.5')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('::')).toBe(false)
|
||||
})
|
||||
|
||||
it('REJECTS suffixed hostnames that merely start with 127. (the S-GATE bypass)', () => {
|
||||
expect(isLoopbackHostLiteral('127.0.0.1.attacker.example.com')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.evil.net')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.0.0.1.evil.example.com')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.0.0.1x')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed / non-dotted-quad partials and out-of-range octets', () => {
|
||||
expect(isLoopbackHostLiteral('127.1')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.0.0.256')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('0127.0.0.1')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127')).toBe(false)
|
||||
})
|
||||
})
|
||||
84
agent/test/originConfig.test.ts
Normal file
84
agent/test/originConfig.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_ORIGIN_ZONE,
|
||||
ensureAllowedOrigin,
|
||||
mergeOrigins,
|
||||
subdomainOrigin,
|
||||
type OriginFsDeps,
|
||||
} from '../src/service/originConfig.js'
|
||||
|
||||
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
||||
const store = { content: initial }
|
||||
const fs: OriginFsDeps = {
|
||||
exists: () => store.content !== null,
|
||||
read: () => store.content ?? '',
|
||||
write: (_p, c) => {
|
||||
store.content = c
|
||||
},
|
||||
}
|
||||
return { fs, get: () => store.content ?? '' }
|
||||
}
|
||||
|
||||
const PATH = '/etc/web-terminal.env'
|
||||
|
||||
describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
|
||||
it('composes https://<subdomain>.term.<domain>', () => {
|
||||
expect(subdomainOrigin('host-42', 'example.com')).toBe('https://host-42.term.example.com')
|
||||
})
|
||||
|
||||
it('appends the origin when the file has none', () => {
|
||||
const { fs, get } = memFs('PORT=3000\n')
|
||||
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||
expect(get()).toContain('ALLOWED_ORIGINS=https://host-42.term.example.com')
|
||||
expect(get()).toContain('PORT=3000')
|
||||
})
|
||||
|
||||
it('is idempotent (no duplicate on a second run)', () => {
|
||||
const { fs, get } = memFs('ALLOWED_ORIGINS=https://host-42.term.example.com\n')
|
||||
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||
const matches = get().match(/host-42\.term\.example\.com/g) ?? []
|
||||
expect(matches).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves existing origins (never weakens the Origin check)', () => {
|
||||
const { fs, get } = memFs('ALLOWED_ORIGINS=https://existing.example.com\n')
|
||||
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||
expect(get()).toContain('https://existing.example.com')
|
||||
expect(get()).toContain('https://host-42.term.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('zone parameterization (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
it('defaults to the historical `term` zone', () => {
|
||||
expect(DEFAULT_ORIGIN_ZONE).toBe('term')
|
||||
expect(subdomainOrigin('t1', 'yaojia.wang')).toBe('https://t1.term.yaojia.wang')
|
||||
})
|
||||
|
||||
it('composes the `terminal` zone for native-tunnel hosts', () => {
|
||||
expect(subdomainOrigin('t1', 'yaojia.wang', 'terminal')).toBe('https://t1.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('ensureAllowedOrigin writes the caller-selected zone', () => {
|
||||
const { fs, get } = memFs('PORT=3000\n')
|
||||
ensureAllowedOrigin(PATH, 't1', 'yaojia.wang', fs, 'terminal')
|
||||
expect(get()).toContain('ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang')
|
||||
expect(get()).not.toContain('.term.yaojia.wang')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeOrigins (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
it('appends to an empty/undefined value', () => {
|
||||
expect(mergeOrigins(undefined, 'https://a.example.com')).toBe('https://a.example.com')
|
||||
expect(mergeOrigins('', 'https://a.example.com')).toBe('https://a.example.com')
|
||||
})
|
||||
|
||||
it('de-duplicates an origin already present', () => {
|
||||
expect(mergeOrigins('https://a.example.com', 'https://a.example.com')).toBe('https://a.example.com')
|
||||
})
|
||||
|
||||
it('appends a new origin, trimming whitespace, preserving existing ones', () => {
|
||||
expect(mergeOrigins(' https://a.example.com , https://b.example.com ', 'https://c.example.com')).toBe(
|
||||
'https://a.example.com,https://b.example.com,https://c.example.com',
|
||||
)
|
||||
})
|
||||
})
|
||||
105
agent/test/pair.test.ts
Normal file
105
agent/test/pair.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
DEFAULT_ENROLL_MODE,
|
||||
EnrollError,
|
||||
PairingCodeExpiredError,
|
||||
PairingCodeSpentError,
|
||||
redeemPairingCode,
|
||||
} from '../src/enroll/pair.js'
|
||||
|
||||
const ENROLL_URL = 'https://example.com/enroll'
|
||||
const VALID_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function freshKs() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-pair-'))
|
||||
return { dir, ks: openKeystore(dir) }
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function okBody(wrappedSecret: Uint8Array) {
|
||||
return {
|
||||
hostId: VALID_UUID,
|
||||
subdomain: 'host-42',
|
||||
cert: 'CERTPEM',
|
||||
caChain: 'CAPEM',
|
||||
hostContentSecret: encodeBase64UrlBytes(wrappedSecret),
|
||||
}
|
||||
}
|
||||
|
||||
describe('redeemPairingCode (§4.5, T4)', () => {
|
||||
it('defaults to ed25519 mode', () => {
|
||||
expect(DEFAULT_ENROLL_MODE).toBe('ed25519')
|
||||
})
|
||||
|
||||
it('sends only pubkey + csr, never the private key (INV4)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const id = generateIdentity()
|
||||
const fetchImpl = vi.fn(async (_u: string | URL | Request, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init!.body)) as Record<string, unknown>
|
||||
expect(body).toHaveProperty('agentPubkey')
|
||||
expect(body).toHaveProperty('csr')
|
||||
expect(JSON.stringify(body)).not.toContain('PRIVATE KEY')
|
||||
expect(body).not.toHaveProperty('privateKey')
|
||||
return jsonResponse(200, okBody(new Uint8Array([9, 9, 9])))
|
||||
})
|
||||
await redeemPairingCode(ENROLL_URL, 'ABCD-1234', id, ks, { fetchImpl: fetchImpl as unknown as typeof fetch })
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('stores cert + unwrapped content secret; wrapped bytes not persisted (FIX 3)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const id = generateIdentity()
|
||||
const wrapped = new Uint8Array([1, 2, 3])
|
||||
const unwrapped = new Uint8Array([7, 7, 7])
|
||||
const fetchImpl = async () => jsonResponse(200, okBody(wrapped))
|
||||
await redeemPairingCode(ENROLL_URL, 'ABCD-1234', id, ks, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
unwrapContentSecret: () => unwrapped,
|
||||
})
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'CERTPEM', caChainPem: 'CAPEM' })
|
||||
const stored = ks.loadContentSecret()!
|
||||
expect(Buffer.from(stored).equals(Buffer.from(unwrapped))).toBe(true)
|
||||
expect(Buffer.from(stored).equals(Buffer.from(wrapped))).toBe(false)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('maps 409 → PairingCodeSpentError (single-use)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const fetchImpl = async () => jsonResponse(409, {})
|
||||
await expect(
|
||||
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||
).rejects.toBeInstanceOf(PairingCodeSpentError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('maps 410 → PairingCodeExpiredError', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const fetchImpl = async () => jsonResponse(410, {})
|
||||
await expect(
|
||||
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||
).rejects.toBeInstanceOf(PairingCodeExpiredError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects a schema-mismatched response (boundary validation)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const fetchImpl = async () => jsonResponse(200, { hostId: 'not-a-uuid', subdomain: 'x' })
|
||||
await expect(
|
||||
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||
).rejects.toBeInstanceOf(EnrollError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
216
agent/test/probe.test.ts
Normal file
216
agent/test/probe.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DEFAULT_CERT_RENEW_WINDOW_MS,
|
||||
certIsFresh,
|
||||
frpcProxyStarted,
|
||||
probeLoopbackBaseApp,
|
||||
renderHealthStatus,
|
||||
runHealthProbe,
|
||||
startHealthMonitor,
|
||||
type HealthProbeSeams,
|
||||
type HealthReport,
|
||||
type IntervalTimer,
|
||||
} from '../src/health/probe.js'
|
||||
|
||||
/** All-passing seams; each test overrides exactly one to prove sub-check independence. */
|
||||
function healthySeams(over: Partial<HealthProbeSeams> = {}): HealthProbeSeams {
|
||||
return {
|
||||
isFrpcAlive: () => true,
|
||||
probeBaseApp: async () => true,
|
||||
readFrpcLog: () => 'proxy [web-terminal] start proxy success',
|
||||
certNotAfter: () => new Date('2026-08-01T00:00:00Z'),
|
||||
now: () => new Date('2026-07-08T00:00:00Z'),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('frpcProxyStarted (log scan)', () => {
|
||||
it('detects the frpc start-proxy-success line', () => {
|
||||
expect(frpcProxyStarted('2026/07/08 [I] [proxy_manager] start proxy success')).toBe(true)
|
||||
})
|
||||
|
||||
it('is false before frpc reports success', () => {
|
||||
expect(frpcProxyStarted('login to server success\nstart proxy ...')).toBe(false)
|
||||
expect(frpcProxyStarted('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('certIsFresh (near-expiry check)', () => {
|
||||
const now = new Date('2026-07-08T00:00:00Z')
|
||||
|
||||
it('is fresh when notAfter is beyond the renewal window', () => {
|
||||
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS + 60_000)
|
||||
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(true)
|
||||
})
|
||||
|
||||
it('is NOT fresh when notAfter is inside the renewal window', () => {
|
||||
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS - 60_000)
|
||||
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a missing cert (null notAfter) as not fresh', () => {
|
||||
expect(certIsFresh(null, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeLoopbackBaseApp (loopback-only)', () => {
|
||||
it('targets 127.0.0.1:PORT and returns true on an ok response', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string) => ({ ok: url.includes('127.0.0.1:3000') }))
|
||||
await expect(probeLoopbackBaseApp(3000, fetchImpl)).resolves.toBe(true)
|
||||
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:3000/')
|
||||
})
|
||||
|
||||
it('returns false on a non-ok response', async () => {
|
||||
await expect(probeLoopbackBaseApp(3000, async () => ({ ok: false }))).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a rejected fetch (a probe never throws)', async () => {
|
||||
await expect(
|
||||
probeLoopbackBaseApp(3000, async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range port without fetching', async () => {
|
||||
const fetchImpl = vi.fn(async () => ({ ok: true }))
|
||||
await expect(probeLoopbackBaseApp(0, fetchImpl)).resolves.toBe(false)
|
||||
await expect(probeLoopbackBaseApp(70000, fetchImpl)).resolves.toBe(false)
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('runHealthProbe (aggregate verdict)', () => {
|
||||
it('is healthy when all four sub-checks pass', async () => {
|
||||
const report = await runHealthProbe(healthySeams())
|
||||
expect(report).toEqual<HealthReport>({
|
||||
frpcAlive: true,
|
||||
baseAppReachable: true,
|
||||
proxyStarted: true,
|
||||
certFresh: true,
|
||||
healthy: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('is unhealthy if frpc is dead', async () => {
|
||||
const report = await runHealthProbe(healthySeams({ isFrpcAlive: () => false }))
|
||||
expect(report.frpcAlive).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
|
||||
it('is unhealthy if the base app is unreachable', async () => {
|
||||
const report = await runHealthProbe(healthySeams({ probeBaseApp: async () => false }))
|
||||
expect(report.baseAppReachable).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
|
||||
it('is unhealthy if the proxy never started', async () => {
|
||||
const report = await runHealthProbe(healthySeams({ readFrpcLog: () => 'connecting...' }))
|
||||
expect(report.proxyStarted).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
|
||||
it('is unhealthy if the cert is near expiry', async () => {
|
||||
const report = await runHealthProbe(
|
||||
healthySeams({
|
||||
certNotAfter: () => new Date('2026-07-08T01:00:00Z'), // 1h out, inside 8h window
|
||||
}),
|
||||
)
|
||||
expect(report.certFresh).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderHealthStatus (INV9 — non-secret only)', () => {
|
||||
const report: HealthReport = {
|
||||
frpcAlive: true,
|
||||
baseAppReachable: true,
|
||||
proxyStarted: true,
|
||||
certFresh: true,
|
||||
healthy: true,
|
||||
}
|
||||
|
||||
it('prints subdomain, host id, expiry date, and flags', () => {
|
||||
const lines = renderHealthStatus(
|
||||
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
|
||||
report,
|
||||
)
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).toContain('subdomain: alice')
|
||||
expect(joined).toContain('host_id: h-1')
|
||||
expect(joined).toContain('cert_expiry: 2026-08-01T00:00:00.000Z')
|
||||
expect(joined).toContain('healthy: true')
|
||||
})
|
||||
|
||||
it('leaks NO key/cert/token/CSR material', () => {
|
||||
const lines = renderHealthStatus(
|
||||
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
|
||||
report,
|
||||
)
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).not.toMatch(/PRIVATE KEY|BEGIN CERTIFICATE|BEGIN CERTIFICATE REQUEST/)
|
||||
expect(joined.toLowerCase()).not.toMatch(/token|secret|csr|pem/)
|
||||
})
|
||||
|
||||
it('renders (none)/(unknown) placeholders when identifiers are absent', () => {
|
||||
const lines = renderHealthStatus({ subdomain: null, hostId: null, certNotAfter: null }, report)
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).toContain('subdomain: (none)')
|
||||
expect(joined).toContain('cert_expiry: (unknown)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('startHealthMonitor (periodic)', () => {
|
||||
function fakeTimer(): { timer: IntervalTimer; fire: () => void; cleared: boolean } {
|
||||
let cb: (() => void) | null = null
|
||||
const state = { cleared: false }
|
||||
return {
|
||||
timer: {
|
||||
setInterval: (fn) => {
|
||||
cb = fn
|
||||
return 1
|
||||
},
|
||||
clearInterval: () => {
|
||||
state.cleared = true
|
||||
},
|
||||
},
|
||||
fire: () => cb?.(),
|
||||
get cleared() {
|
||||
return state.cleared
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('runs the probe on each tick and reports it', async () => {
|
||||
const report: HealthReport = {
|
||||
frpcAlive: true,
|
||||
baseAppReachable: true,
|
||||
proxyStarted: true,
|
||||
certFresh: true,
|
||||
healthy: true,
|
||||
}
|
||||
const probe = vi.fn(async () => report)
|
||||
const seen: HealthReport[] = []
|
||||
const ft = fakeTimer()
|
||||
const monitor = startHealthMonitor(probe, (r) => seen.push(r), { timer: ft.timer })
|
||||
|
||||
ft.fire()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
expect(seen).toEqual([report])
|
||||
|
||||
monitor.stop()
|
||||
expect(ft.cleared).toBe(true)
|
||||
})
|
||||
|
||||
it('swallows a rejected probe (monitor never crashes)', async () => {
|
||||
const ft = fakeTimer()
|
||||
const onReport = vi.fn()
|
||||
startHealthMonitor(async () => Promise.reject(new Error('boom')), onReport, { timer: ft.timer })
|
||||
ft.fire()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(onReport).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
113
agent/test/replaySeal.test.ts
Normal file
113
agent/test/replaySeal.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||
import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js'
|
||||
|
||||
/**
|
||||
* Fake AEAD. The derived key is a tag that DEPENDS on every ReplayKeyParams field — crucially on
|
||||
* `epoch` (F6), so two sealer generations for the same (secret, sessionId, alg) yield DIFFERENT keys.
|
||||
* The tag leads with `epoch` so key[0] also varies per generation; ciphertext = plaintext XOR key[0]
|
||||
* (a UUID's first char is a hex digit 0x30–0x66 → always nonzero, so the marker is always hidden).
|
||||
*/
|
||||
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[]; keys: Uint8Array[] } {
|
||||
const derivations: ReplayKeyParams[] = []
|
||||
const keys: Uint8Array[] = []
|
||||
return {
|
||||
derivations,
|
||||
keys,
|
||||
deriveContentKey(params: ReplayKeyParams): AeadKey {
|
||||
derivations.push(params)
|
||||
const tag = new TextEncoder().encode(
|
||||
`${params.epoch}:${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}:${params.alg}`,
|
||||
)
|
||||
keys.push(tag)
|
||||
return tag as unknown as AeadKey
|
||||
},
|
||||
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
|
||||
const kb = (key as unknown as Uint8Array)[0] ?? 0x5a
|
||||
const ciphertext = plaintext.map((b) => b ^ kb)
|
||||
return { seq, nonce: new Uint8Array([Number(seq & 0xffn)]), ciphertext, tag: new Uint8Array([0xaa]) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const SECRET = new Uint8Array([1, 2, 3, 4])
|
||||
|
||||
describe('createReplaySealer (T19, FIX 3)', () => {
|
||||
it('folds the exposed per-generation epoch into K_content, deriving it exactly once', () => {
|
||||
const c = fakeCrypto()
|
||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
expect(c.derivations).toHaveLength(1)
|
||||
expect(c.derivations[0]!.epoch).toBe(sealer.epoch)
|
||||
expect(sealer.epoch).toMatch(/^[0-9a-f-]{36}$/) // randomUUID shape
|
||||
})
|
||||
|
||||
it('recoverable WITHIN one generation: re-deriving with the exposed epoch yields the same key', () => {
|
||||
const c = fakeCrypto()
|
||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
// The browser re-derives from the SAME (secret, sessionId, alg, epoch) carried with the ring buffer.
|
||||
const browser = fakeCrypto()
|
||||
const browserKey = browser.deriveContentKey({
|
||||
hostContentSecret: SECRET,
|
||||
sessionId: 'sess-1',
|
||||
alg: 'aes-256-gcm',
|
||||
epoch: sealer.epoch,
|
||||
}) as unknown as Uint8Array
|
||||
expect(Buffer.from(browserKey).equals(Buffer.from(c.keys[0]!))).toBe(true)
|
||||
})
|
||||
|
||||
it('a different sessionId → a different key (per-session separation)', () => {
|
||||
const c = fakeCrypto()
|
||||
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
createReplaySealer(SECRET, 'sess-2', 'aes-256-gcm', c)
|
||||
expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId)
|
||||
})
|
||||
|
||||
it('F6 regression: two generations for the SAME (secret, sessionId, alg) get DIFFERENT epochs → DIFFERENT keys, so seq=0 never collides', () => {
|
||||
const c = fakeCrypto()
|
||||
const gen1 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
const gen2 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
// Fresh epoch per generation…
|
||||
expect(gen2.epoch).not.toBe(gen1.epoch)
|
||||
// …therefore distinct K_content even though (secret, sessionId, alg) are identical…
|
||||
expect(Buffer.from(c.keys[1]!).equals(Buffer.from(c.keys[0]!))).toBe(false)
|
||||
// …so the seq=0 seal of generation 2 uses a DIFFERENT key than the seq=0 seal of generation 1
|
||||
// (this is exactly the (key, nonce) reuse F6 prevents — same nonce, but a fresh key).
|
||||
const s1 = gen1.seal(new Uint8Array([0x41, 0x42, 0x43]))
|
||||
const s2 = gen2.seal(new Uint8Array([0x41, 0x42, 0x43]))
|
||||
expect(s1.seq).toBe(0n)
|
||||
expect(s2.seq).toBe(0n)
|
||||
expect(s1.nonce).toEqual(s2.nonce) // same deterministic nonce (seq=0)…
|
||||
})
|
||||
|
||||
it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => {
|
||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||
const marker = new TextEncoder().encode('SECRET-MARKER')
|
||||
const e0 = sealer.seal(marker)
|
||||
const e1 = sealer.seal(marker)
|
||||
expect(e0.seq).toBe(0n)
|
||||
expect(e1.seq).toBe(1n)
|
||||
expect(Buffer.from(e0.ciphertext).includes(Buffer.from(marker))).toBe(false)
|
||||
})
|
||||
|
||||
it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => {
|
||||
const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||
// model a live seal with a different key byte (0x11 is below the 0x30–0x66 hex-digit epoch prefix)
|
||||
const liveKey = new Uint8Array([0x11]) as unknown as AeadKey
|
||||
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
|
||||
const rep = replay.seal(new Uint8Array([0x41, 0x42]))
|
||||
expect(Buffer.from(rep.ciphertext).equals(Buffer.from(live.ciphertext))).toBe(false)
|
||||
})
|
||||
|
||||
it('the hostContentSecret is never mutated', () => {
|
||||
const secret = new Uint8Array([9, 9, 9])
|
||||
const spy = vi.fn()
|
||||
createReplaySealer(secret, 's', 'aes-256-gcm', {
|
||||
deriveContentKey: (p) => {
|
||||
spy(p.hostContentSecret)
|
||||
return new Uint8Array([1]) as unknown as AeadKey
|
||||
},
|
||||
sealReplayFrame: (_k, seq, pt) => ({ seq, nonce: new Uint8Array(), ciphertext: pt, tag: new Uint8Array() }),
|
||||
})
|
||||
expect([...secret]).toEqual([9, 9, 9])
|
||||
})
|
||||
})
|
||||
69
agent/test/revocation.test.ts
Normal file
69
agent/test/revocation.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { GOAWAY_REASON_TO_CODE } from 'relay-contracts'
|
||||
import {
|
||||
applyGoAway,
|
||||
applyGoAwayCode,
|
||||
classifyGoAway,
|
||||
createRevocationState,
|
||||
} from '../src/lifecycle/revocation.js'
|
||||
|
||||
describe('classifyGoAway (T14, frozen 3-value union)', () => {
|
||||
it('maps operatorDrain and shutdown → drain, revoked → revoked', () => {
|
||||
expect(classifyGoAway('operatorDrain')).toBe('drain')
|
||||
expect(classifyGoAway('shutdown')).toBe('drain')
|
||||
expect(classifyGoAway('revoked')).toBe('revoked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createRevocationState (T14, INV12)', () => {
|
||||
it('revoke tears down once and makes isRevoked() true (suppresses reconnect)', () => {
|
||||
let teardowns = 0
|
||||
const state = createRevocationState(() => {
|
||||
teardowns += 1
|
||||
})
|
||||
expect(state.isRevoked()).toBe(false)
|
||||
state.markRevoked('goaway-revoked')
|
||||
expect(state.isRevoked()).toBe(true)
|
||||
state.markRevoked('operator') // idempotent
|
||||
expect(teardowns).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyGoAway / applyGoAwayCode (T14)', () => {
|
||||
it('a revoked GOAWAY tears down; a drain GOAWAY does not', () => {
|
||||
const revokeState = createRevocationState(() => {})
|
||||
expect(applyGoAway('revoked', revokeState)).toBe('revoked')
|
||||
expect(revokeState.isRevoked()).toBe(true)
|
||||
|
||||
const drainState = createRevocationState(() => {})
|
||||
expect(applyGoAway('operatorDrain', drainState)).toBe('drain')
|
||||
expect(applyGoAway('shutdown', drainState)).toBe('drain')
|
||||
expect(drainState.isRevoked()).toBe(false)
|
||||
})
|
||||
|
||||
it('decodes the frozen wire codes via decodeGoAwayReason', () => {
|
||||
const state = createRevocationState(() => {})
|
||||
expect(applyGoAwayCode(GOAWAY_REASON_TO_CODE.operatorDrain, state)).toBe('drain')
|
||||
expect(applyGoAwayCode(GOAWAY_REASON_TO_CODE.revoked, state)).toBe('revoked')
|
||||
})
|
||||
|
||||
it('an unknown reason code FAILS CLOSED to revoked (INV12 safety)', () => {
|
||||
const state = createRevocationState(() => {})
|
||||
expect(applyGoAwayCode(99, state)).toBe('revoked')
|
||||
expect(state.isRevoked()).toBe(true)
|
||||
})
|
||||
|
||||
it('cross-plan-drift guard: no bare integer reason literal in revocation.ts', () => {
|
||||
const src = readFileSync(
|
||||
join(import.meta.dirname, '..', 'src', 'lifecycle', 'revocation.ts'),
|
||||
'utf8',
|
||||
)
|
||||
// The reason mapping must come only from the frozen union/decoder — never a hardcoded
|
||||
// `=== 2` / `reason: 2` style integer. (Comments are allowed to mention numbers.)
|
||||
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||
expect(code).not.toMatch(/reason\s*[=:]\s*\d/)
|
||||
expect(code).not.toMatch(/===\s*[123]\b/)
|
||||
})
|
||||
})
|
||||
120
agent/test/rotation.test.ts
Normal file
120
agent/test/rotation.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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 { generateIdentity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
computeRenewDelayMs,
|
||||
createCertRotator,
|
||||
renewCert,
|
||||
renewalUrlFor,
|
||||
} from '../src/certs/rotation.js'
|
||||
import { FakeTimer } from './fixtures/fakes.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://example.com/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function enrolledKs() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-rot-'))
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateIdentity())
|
||||
ks.saveCert('OLDCERT', 'OLDCA')
|
||||
return { dir, ks }
|
||||
}
|
||||
|
||||
function jsonRes(status: number, body: unknown): Response {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response
|
||||
}
|
||||
|
||||
describe('cert rotation math (T13)', () => {
|
||||
it('derives P3 renewal URL from enrollUrl', () => {
|
||||
expect(renewalUrlFor(CFG)).toBe('https://example.com/renew')
|
||||
})
|
||||
|
||||
it('renews renewBeforeMs before expiry, clamped at 0', () => {
|
||||
const now = new Date('2026-01-01T00:00:00Z')
|
||||
const parse = () => new Date('2026-01-01T01:00:00Z') // expires in 1h
|
||||
expect(computeRenewDelayMs('C', 5 * 60_000, now, parse)).toBe(55 * 60_000)
|
||||
const parsePast = () => new Date('2025-01-01T00:00:00Z')
|
||||
expect(computeRenewDelayMs('C', 5 * 60_000, now, parsePast)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renewCert (T13)', () => {
|
||||
it('installs a fresh cert atomically on success (same key)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const before = ks.loadIdentity()!.publicKey
|
||||
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)
|
||||
expect(out).toBe('rotated')
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' })
|
||||
// pubkey unchanged — only the cert rotated
|
||||
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('403 → revoked (⇒ T14 teardown, INV12)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const fetchImpl = async () => jsonRes(403, {})
|
||||
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||
expect(out).toBe('revoked')
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'OLDCERT', caChainPem: 'OLDCA' }) // untouched
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r))
|
||||
|
||||
describe('createCertRotator (T13)', () => {
|
||||
it('fires onRevoked when the scheduled renewal returns 403', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
fetchImpl: (async () => jsonRes(403, {})) as unknown as typeof fetch,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000), // expires 2s after epoch → delay ~1000ms
|
||||
})
|
||||
let revoked = false
|
||||
rotator.onRevoked(() => {
|
||||
revoked = true
|
||||
})
|
||||
rotator.start()
|
||||
timer.advance(1000) // scheduled renewal fires
|
||||
await flush()
|
||||
expect(revoked).toBe(true)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rotates and reschedules on a successful renewal (seamless)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000),
|
||||
})
|
||||
let rotated = 0
|
||||
rotator.onRotated(() => {
|
||||
rotated += 1
|
||||
})
|
||||
rotator.start()
|
||||
timer.advance(1000)
|
||||
await flush()
|
||||
expect(rotated).toBe(1)
|
||||
expect(ks.loadCert()!.certPem).toBe('NEWCERT')
|
||||
rotator.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
128
agent/test/runTunnel.test.ts
Normal file
128
agent/test/runTunnel.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import type { Keystore } from '../src/keys/keystore.js'
|
||||
import { createBackoff } from '../src/transport/backoff.js'
|
||||
import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js'
|
||||
import { FakeTimer, FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
/**
|
||||
* C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop —
|
||||
* bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12).
|
||||
*/
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
const KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||
|
||||
function emitOpen(upstream: FakeWs, open: MuxOpen): void {
|
||||
const payload = encodeOpen(open)
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
function emitGoAwayRevoked(upstream: FakeWs): void {
|
||||
const payload = encodeGoaway(0, 'revoked')
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function baseDeps(over: Partial<RunTunnelDeps>): Partial<RunTunnelDeps> {
|
||||
return {
|
||||
timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test
|
||||
sleep: async () => {},
|
||||
backoff: createBackoff(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('runTunnel supervisor (C2)', () => {
|
||||
it('splices bytes both ways through the loopback', async () => {
|
||||
const upstream = new FakeWs()
|
||||
const loopback = new FakeWs()
|
||||
const connectRelay = vi.fn(async () => upstream)
|
||||
const dialLoopback = vi.fn(async () => loopback)
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never }))
|
||||
await flush()
|
||||
|
||||
emitOpen(upstream, OPEN)
|
||||
await flush()
|
||||
expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
|
||||
upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10])))
|
||||
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
|
||||
|
||||
loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA
|
||||
const last = decodeMuxFrame(upstream.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('data')
|
||||
expect([...last.payload]).toEqual([79, 75])
|
||||
|
||||
await handle.stop()
|
||||
expect(await handle.done).toBe(0)
|
||||
})
|
||||
|
||||
it('reconnects after the tunnel dies', async () => {
|
||||
const sockets = [new FakeWs(), new FakeWs()]
|
||||
let i = 0
|
||||
const connectRelay = vi.fn(async () => sockets[i++]!)
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||
|
||||
sockets[0]!.emit('close') // first session dies ⇒ supervisor redials
|
||||
await flush()
|
||||
expect(connectRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => {
|
||||
const connectRelay = vi.fn(async () => new FakeWs())
|
||||
let socket: FakeWs | undefined
|
||||
const wrapped = vi.fn(async () => {
|
||||
socket = new FakeWs()
|
||||
return socket
|
||||
})
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
expect(wrapped).toHaveBeenCalledTimes(1)
|
||||
|
||||
emitGoAwayRevoked(socket!)
|
||||
await flush()
|
||||
|
||||
expect(await handle.done).toBe(0)
|
||||
expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation
|
||||
expect(connectRelay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stop() ends the loop with exit code 0 and does not reconnect', async () => {
|
||||
const connectRelay = vi.fn(async () => new FakeWs())
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
|
||||
await handle.stop()
|
||||
expect(await handle.done).toBe(0)
|
||||
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
30
agent/test/scaffold.test.ts
Normal file
30
agent/test/scaffold.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import * as contracts from 'relay-contracts'
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8'),
|
||||
) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
||||
|
||||
describe('package scaffold', () => {
|
||||
it('resolves the frozen relay-contracts surface', () => {
|
||||
// §4.1 codec + §4.4 shapes + §4.5 pairing must all be importable.
|
||||
expect(typeof contracts.encodeMuxFrame).toBe('function')
|
||||
expect(typeof contracts.decodeMuxFrame).toBe('function')
|
||||
expect(typeof contracts.decodeGoAwayReason).toBe('function')
|
||||
expect(contracts.EnrollResultSchema).toBeDefined()
|
||||
})
|
||||
|
||||
it('declares web-terminal-agent bin (INDEX §4.5 distribution)', () => {
|
||||
expect(pkg.dependencies?.['relay-contracts']).toBe('file:../relay-contracts')
|
||||
})
|
||||
|
||||
it('INV11 tripwire: no ANSI/xterm/vt100 dependency', () => {
|
||||
const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
||||
const forbidden = /xterm|ansi|vt100/i
|
||||
for (const name of Object.keys(allDeps)) {
|
||||
expect(forbidden.test(name), `forbidden terminal-parser dep: ${name}`).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
20
agent/test/seams.test.ts
Normal file
20
agent/test/seams.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
describe('transport seams (W0 cycle-breaker)', () => {
|
||||
it('is a type-only module with no runtime side effects or transport deps', async () => {
|
||||
const src = readFileSync(
|
||||
join(import.meta.dirname, '..', 'src', 'transport', 'seams.ts'),
|
||||
'utf8',
|
||||
)
|
||||
// No runtime imports: the seam file must not pull ws/crypto/node runtime.
|
||||
expect(src).not.toMatch(/from ['"]ws['"]/)
|
||||
expect(src).not.toMatch(/from ['"]node:crypto['"]/)
|
||||
// Importing it must not throw or emit anything.
|
||||
const mod = await import('../src/transport/seams.js')
|
||||
expect(mod).toBeDefined()
|
||||
// Interfaces are erased at runtime, so the module has no runtime exports.
|
||||
expect(Object.keys(mod)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
59
agent/test/security/tripwires.test.ts
Normal file
59
agent/test/security/tripwires.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const SRC = join(import.meta.dirname, '..', '..', 'src')
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) out.push(...walk(path))
|
||||
else if (name.endsWith('.ts')) out.push(path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const files = walk(SRC)
|
||||
function code(path: string): string {
|
||||
return readFileSync(path, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||
}
|
||||
|
||||
describe('agent security tripwires (T18, INDEX §3)', () => {
|
||||
it('INV9: no console.log anywhere in src', () => {
|
||||
for (const f of files) {
|
||||
expect(code(f), `console.log in ${f}`).not.toMatch(/console\.log/)
|
||||
}
|
||||
})
|
||||
|
||||
it('INV11: no ANSI/xterm/vt100 import anywhere in src (byte-shuttle)', () => {
|
||||
for (const f of files) {
|
||||
expect(code(f), `terminal-parser import in ${f}`).not.toMatch(/from ['"][^'"]*(xterm|ansi|vt100)[^'"]*['"]/)
|
||||
}
|
||||
})
|
||||
|
||||
it('INV2 anti-MITM: hostEndpoint imports NO verifier from relay-e2e (FIX 6b)', () => {
|
||||
const he = code(join(SRC, 'e2e', 'hostEndpoint.ts'))
|
||||
expect(he).not.toMatch(/from ['"]relay-e2e['"]/)
|
||||
expect(he).not.toMatch(/verifyDeviceAuthProof/)
|
||||
})
|
||||
|
||||
it('INV12: revocation.ts uses the frozen decoder, no bare integer reason literal', () => {
|
||||
const rev = code(join(SRC, 'lifecycle', 'revocation.ts'))
|
||||
expect(rev).toMatch(/decodeGoAwayReason/)
|
||||
expect(rev).not.toMatch(/reason\s*[=:]\s*\d/)
|
||||
expect(rev).not.toMatch(/===\s*[123]\b/)
|
||||
})
|
||||
|
||||
it('INV4: identity exposes no raw private-key byte export', () => {
|
||||
const id = code(join(SRC, 'keys', 'identity.ts'))
|
||||
expect(id).not.toMatch(/exportPrivateRaw|privateKeyBytes|rawPrivateKey/)
|
||||
})
|
||||
|
||||
it('every src module is well under the 800-line hard cap', () => {
|
||||
for (const f of files) {
|
||||
const lines = readFileSync(f, 'utf8').split('\n').length
|
||||
expect(lines, `${f} is ${lines} lines`).toBeLessThan(800)
|
||||
}
|
||||
})
|
||||
})
|
||||
140
agent/test/streamRouter.test.ts
Normal file
140
agent/test/streamRouter.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeMuxFrame, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
import { holdTunnel, dataHeader } from '../src/transport/tunnel.js'
|
||||
import { createStreamRouter, identityTransform } from '../src/transport/streamRouter.js'
|
||||
import { FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
|
||||
const silentLogger = createLogger('error', () => {})
|
||||
const flush = () => new Promise((r) => setImmediate(r))
|
||||
|
||||
function setup(dialImpl?: (path: string, origin: string) => Promise<FakeWs>) {
|
||||
const upstream = new FakeWs()
|
||||
const tunnel = holdTunnel(upstream)
|
||||
const loopbacks: FakeWs[] = []
|
||||
const dial = vi.fn(
|
||||
dialImpl ??
|
||||
(async () => {
|
||||
const ws = new FakeWs()
|
||||
loopbacks.push(ws)
|
||||
return ws
|
||||
}),
|
||||
)
|
||||
const router = createStreamRouter(CFG, tunnel, dial as never, identityTransform, silentLogger)
|
||||
return { upstream, router, dial, loopbacks }
|
||||
}
|
||||
|
||||
function lastFrame(ws: FakeWs) {
|
||||
return decodeMuxFrame(ws.sent.at(-1)!)
|
||||
}
|
||||
|
||||
describe('StreamRouter (T8)', () => {
|
||||
it('dials loopback with the request path and replayed Origin', async () => {
|
||||
const { router, dial } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
expect(dial).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
})
|
||||
|
||||
it('INV1 defense-in-depth: foreign subdomain is RST and NEVER dialed', async () => {
|
||||
const { router, dial, upstream } = setup()
|
||||
router.handleOpen({ ...OPEN, subdomain: 'host-999' })
|
||||
await flush()
|
||||
expect(dial).not.toHaveBeenCalled()
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('close')
|
||||
expect(f.header.rst).toBe(true)
|
||||
expect(router.activeStreamCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('splices loopback output → tunnel DATA', async () => {
|
||||
const { router, upstream, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
const bytes = new Uint8Array([9, 8, 7])
|
||||
loopbacks[0]!.emit('message', bytes)
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('data')
|
||||
expect(Buffer.from(f.payload).equals(Buffer.from(bytes))).toBe(true)
|
||||
})
|
||||
|
||||
it('forwards tunnel DATA → loopback socket (buffering pre-open)', async () => {
|
||||
const { router, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
router.handleData(5, new Uint8Array([1, 2])) // arrives before dial resolves → buffered
|
||||
await flush()
|
||||
router.handleData(5, new Uint8Array([3, 4]))
|
||||
expect(loopbacks[0]!.sent.map((b) => [...b])).toEqual([[1, 2], [3, 4]])
|
||||
})
|
||||
|
||||
it('DATA on an unknown stream is RST (illegal transition)', () => {
|
||||
const { router, upstream } = setup()
|
||||
router.handleData(999, new Uint8Array([1]))
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('close')
|
||||
expect(f.header.rst).toBe(true)
|
||||
})
|
||||
|
||||
it('two concurrent streams get separate sockets (no cross-stream bleed)', async () => {
|
||||
const { router, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
router.handleOpen({ ...OPEN, streamId: 6 })
|
||||
await flush()
|
||||
router.handleData(5, new Uint8Array([0xaa]))
|
||||
router.handleData(6, new Uint8Array([0xbb]))
|
||||
expect(loopbacks[0]!.sent).toHaveLength(1)
|
||||
expect(loopbacks[1]!.sent).toHaveLength(1)
|
||||
expect([...loopbacks[0]!.sent[0]!]).toEqual([0xaa])
|
||||
expect([...loopbacks[1]!.sent[0]!]).toEqual([0xbb])
|
||||
expect(router.activeStreamCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('CLOSE frees the loopback socket', async () => {
|
||||
const { router, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
router.handleClose(5, false)
|
||||
expect(loopbacks[0]!.closed).toBe(true)
|
||||
expect(router.activeStreamCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('loopback connect-refused → RST upstream (typed, no swallow)', async () => {
|
||||
const { router, upstream } = setup(async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
})
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('close')
|
||||
expect(f.header.rst).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('opaque splice sanity (INV11)', () => {
|
||||
it('does not import any terminal parser (identity transform)', () => {
|
||||
const bytes = new Uint8Array([0x1b, 0x5b, 0x41])
|
||||
expect(identityTransform.outbound(1, bytes)).toBe(bytes)
|
||||
// encode/decode round-trip stays byte-exact
|
||||
const framed = encodeMuxFrame(dataHeader(1, bytes.length), bytes)
|
||||
expect(Buffer.from(decodeMuxFrame(framed).payload).equals(Buffer.from(bytes))).toBe(true)
|
||||
})
|
||||
})
|
||||
162
agent/test/tunnel.test.ts
Normal file
162
agent/test/tunnel.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
decodeMuxFrame,
|
||||
encodeMuxFrame,
|
||||
encodeOpen,
|
||||
encodeGoaway,
|
||||
type MuxFrameHeader,
|
||||
type MuxOpen,
|
||||
} from 'relay-contracts'
|
||||
import { holdTunnel, dataHeader } from '../src/transport/tunnel.js'
|
||||
import { FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'deadbeef',
|
||||
capabilityTokenRef: 'jti-1',
|
||||
}
|
||||
|
||||
function openFrame(open: MuxOpen): Uint8Array {
|
||||
const payload = encodeOpen(open)
|
||||
const header: MuxFrameHeader = {
|
||||
version: 1,
|
||||
type: 'open',
|
||||
fin: false,
|
||||
rst: false,
|
||||
streamId: open.streamId,
|
||||
payloadLen: payload.length,
|
||||
}
|
||||
return encodeMuxFrame(header, payload)
|
||||
}
|
||||
|
||||
function stubHandlers() {
|
||||
return {
|
||||
handleOpen: vi.fn(),
|
||||
handleData: vi.fn(),
|
||||
handleClose: vi.fn(),
|
||||
}
|
||||
}
|
||||
const stubHb = { onPing: vi.fn(), onPong: vi.fn() }
|
||||
|
||||
describe('Tunnel (T7, §4.1)', () => {
|
||||
it('round-trips a frame and yields decoded header+payload via onFrame', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const seen: Array<{ h: MuxFrameHeader; p: Uint8Array }> = []
|
||||
tunnel.onFrame((h, p) => seen.push({ h, p }))
|
||||
ws.emitMessage(openFrame(OPEN))
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]!.h.type).toBe('open')
|
||||
expect(seen[0]!.h.streamId).toBe(5)
|
||||
})
|
||||
|
||||
it('dispatches OPEN/DATA to the router', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
ws.emitMessage(openFrame(OPEN))
|
||||
expect(handlers.handleOpen).toHaveBeenCalledOnce()
|
||||
const data = new Uint8Array([1, 2, 3])
|
||||
ws.emitMessage(encodeMuxFrame(dataHeader(5, 3), data))
|
||||
expect(handlers.handleData).toHaveBeenCalledWith(5, expect.any(Uint8Array))
|
||||
})
|
||||
|
||||
it('INV11: DATA payload passes through opaque (no parsing)', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
const opaque = new Uint8Array([0x1b, 0x5b, 0x33, 0x31, 0x6d]) // looks like an ANSI seq
|
||||
ws.emitMessage(encodeMuxFrame(dataHeader(7, opaque.length), opaque))
|
||||
const forwarded = handlers.handleData.mock.calls[0]![1] as Uint8Array
|
||||
expect(Buffer.from(forwarded).equals(Buffer.from(opaque))).toBe(true)
|
||||
})
|
||||
|
||||
it('drains after inbound GOAWAY: no new OPEN accepted', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
const goaway = encodeGoaway(0, 'operatorDrain')
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: goaway.length },
|
||||
goaway,
|
||||
),
|
||||
)
|
||||
ws.emitMessage(openFrame({ ...OPEN, streamId: 9 }))
|
||||
expect(handlers.handleOpen).not.toHaveBeenCalled()
|
||||
// and it RST'd the refused stream
|
||||
const last = decodeMuxFrame(ws.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('close')
|
||||
expect(last.header.rst).toBe(true)
|
||||
})
|
||||
|
||||
it('malformed framing does not crash the tunnel', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
tunnel.dispatchTo(stubHandlers(), stubHb)
|
||||
expect(() => ws.emitMessage(new Uint8Array([0, 1, 2]))).not.toThrow()
|
||||
})
|
||||
|
||||
it('dispatches CLOSE→handleClose and PONG→heartbeat.onPong', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
const hb = { onPing: vi.fn(), onPong: vi.fn() }
|
||||
tunnel.dispatchTo(handlers, hb)
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame({ version: 1, type: 'close', fin: false, rst: true, streamId: 5, payloadLen: 0 }, new Uint8Array(0)),
|
||||
)
|
||||
expect(handlers.handleClose).toHaveBeenCalledWith(5, true)
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame({ version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 2 }, new Uint8Array([1, 2])),
|
||||
)
|
||||
expect(hb.onPong).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('send/goAway/sendRst/close emit well-formed frames', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
tunnel.send(dataHeader(3, 2), new Uint8Array([9, 9]))
|
||||
expect(decodeMuxFrame(ws.sent.at(-1)!).header.type).toBe('data')
|
||||
tunnel.goAway(3, 'shutdown')
|
||||
expect(decodeMuxFrame(ws.sent.at(-1)!).header.type).toBe('goaway')
|
||||
tunnel.sendRst(3)
|
||||
const rst = decodeMuxFrame(ws.sent.at(-1)!)
|
||||
expect(rst.header.type).toBe('close')
|
||||
expect(rst.header.rst).toBe(true)
|
||||
tunnel.close()
|
||||
expect(ws.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('windowUpdate frames are dispatched without disturbing the stream handlers', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame({ version: 1, type: 'windowUpdate', fin: false, rst: false, streamId: 1, payloadLen: 4 }, new Uint8Array([0, 0, 0, 5])),
|
||||
)
|
||||
expect(handlers.handleData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onGoAway surfaces the frozen reason', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const reasons: string[] = []
|
||||
tunnel.onGoAway((r) => reasons.push(r))
|
||||
const goaway = encodeGoaway(3, 'revoked')
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: goaway.length },
|
||||
goaway,
|
||||
),
|
||||
)
|
||||
expect(reasons).toEqual(['revoked'])
|
||||
})
|
||||
})
|
||||
23
agent/tsconfig.json
Normal file
23
agent/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
13
agent/vitest.config.ts
Normal file
13
agent/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['**/*.d.ts', 'src/index.ts'],
|
||||
},
|
||||
},
|
||||
})
|
||||
42
android/.gitignore
vendored
Normal file
42
android/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
**/build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!src/**/build/
|
||||
|
||||
# Gradle caches / config-cache
|
||||
.gradle/configuration-cache/
|
||||
|
||||
# IDE — IntelliJ IDEA / Android Studio
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
captures/
|
||||
.navigation/
|
||||
|
||||
# Local machine config (never commit)
|
||||
local.properties
|
||||
|
||||
# Android build outputs (relevant once the SDK-gated modules are enabled)
|
||||
*.apk
|
||||
*.aab
|
||||
*.ap_
|
||||
*.dex
|
||||
release/
|
||||
proguard/
|
||||
|
||||
# Secrets — never commit
|
||||
google-services.json
|
||||
**/google-services.json
|
||||
*.keystore
|
||||
*.jks
|
||||
*.p12
|
||||
service-account*.json
|
||||
|
||||
# OS cruft
|
||||
.DS_Store
|
||||
|
||||
# Kover / test reports
|
||||
**/kover/
|
||||
76
android/DEVICE_QA_CHECKLIST.md
Normal file
76
android/DEVICE_QA_CHECKLIST.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Android client — device-QA checklist (A36 / plan §7)
|
||||
|
||||
Everything below is **device/emulator-only** — it could NOT run in the build environment (no emulator,
|
||||
no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests,
|
||||
Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping.
|
||||
|
||||
## Deploy artifacts to provide first (not built here)
|
||||
- [ ] `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).
|
||||
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
|
||||
omitted it — applying it without the json fails the build).
|
||||
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
|
||||
SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the
|
||||
`FCM_*` env group (service-account key path + project id).
|
||||
|
||||
## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device)
|
||||
- [ ] `attach → attached → output` round-trip timing.
|
||||
- [ ] 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.
|
||||
- [ ] kill via `DELETE /live-sessions/:id` (swipe-to-kill) removes the row (404 = already-gone = success).
|
||||
- [ ] `POST /hook/decision` resolves a held gate (from a push Allow/Deny).
|
||||
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
|
||||
|
||||
## A35 — one macrobenchmark / Espresso happy path
|
||||
- [ ] pair → attach → type → approve, on a real device.
|
||||
|
||||
## Terminal (A16/A17/A21)
|
||||
- [ ] 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).
|
||||
- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap.
|
||||
- [ ] **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
|
||||
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
|
||||
generation bump → fresh emulator replays.
|
||||
- [ ] key-bar above the IME (soft keyboard never pops on a key-bar tap); hardware chords; DECCKM arrows
|
||||
emit `ESC O A` under app-cursor mode (vim/htop).
|
||||
- [ ] `FLAG_SECURE` + privacy cover blanks the recents thumbnail on `ON_STOP`.
|
||||
- [ ] off-main chunked append does not ANR on a multi-MB replay.
|
||||
|
||||
## Push (A30/A31 · R1/S2)
|
||||
- [ ] **S2 spike:** data-only high-priority delivery latency/loss on ≥2 real handsets (incl. one
|
||||
Xiaomi/Huawei/Samsung) under Doze / OEM battery managers / force-stop — quantify loss, drive the
|
||||
"delivery not guaranteed while backgrounded" user-facing copy.
|
||||
- [ ] **Deny** from the lock screen (BroadcastReceiver, no app open, expedited POST).
|
||||
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
|
||||
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
|
||||
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
|
||||
- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals
|
||||
on rotation / new host / removal.
|
||||
|
||||
## Pairing / cert / storage (A19/A27/A11/A12)
|
||||
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry.
|
||||
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
|
||||
bypass); public host explicit-ack.
|
||||
- [ ] 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
|
||||
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
|
||||
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
||||
|
||||
## Nav / deep links / adaptive (A32/A26/A29)
|
||||
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
||||
right destination; invalid UUID ignored.
|
||||
- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens.
|
||||
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
||||
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
||||
|
||||
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
|
||||
- [ ] 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.
|
||||
- [ ] 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).
|
||||
- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView`
|
||||
is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to
|
||||
`:app` and delete the shim.
|
||||
416
android/PROGRESS_ANDROID.md
Normal file
416
android/PROGRESS_ANDROID.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Android Client — Progress (this-session working log)
|
||||
|
||||
> **Why this file exists (temporary):** `docs/PROGRESS_LOG.md` (the canonical cross-session
|
||||
> memory) was being **concurrently edited by another live session** (the tunnel-automation
|
||||
> workstream on branch `feat/tunnel-automation`) while this Android work ran on the SAME working
|
||||
> tree. To avoid a read-modify-write clobber of that session's log, the Android orchestrator
|
||||
> records progress here instead. **FOLD THESE ENTRIES INTO `docs/PROGRESS_LOG.md` once the
|
||||
> concurrent session is done** (they belong under the `🤖 ANDROID CLIENT` section).
|
||||
>
|
||||
> **Git status note:** nothing below is committed. The Android lane (`android/**`,
|
||||
> `src/push/fcm.ts`, `test/push-fcm.test.ts`, `src/server.ts` FCM wiring, `package.json`
|
||||
> google-auth-library) is file-disjoint from the tunnel work, but the shared `.git`/index means
|
||||
> **no `git add -A`** — commit the Android lane by explicit path once branch strategy is settled.
|
||||
|
||||
Plan: [`../docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md). 36 tasks, waves AW0–AW6.
|
||||
Prior state (commit `4ea8f78`/`4fe1981`): AW0 + AW1 pure-Kotlin foundation (218 tests) + Android
|
||||
SDK/AGP 9.2.1 toolchain proven.
|
||||
|
||||
---
|
||||
|
||||
## [x] Wave R1 — A7 + A14 + A33 (DONE, orchestrator-verified 2026-07-08)
|
||||
|
||||
One implementation Workflow (3 TDD builders in parallel) → 6-agent adversarial cross-review (2
|
||||
independent lenses/task) → fix Workflow (must-fixes + regression tests) → adversarial re-verify →
|
||||
**orchestrator independent unified gate** (not just agent self-report).
|
||||
|
||||
**Unified verification (measured):**
|
||||
- `cd android && ./gradlew test` → **BUILD SUCCESSFUL, 250 tests, 0 failures** (wire-protocol 47 /
|
||||
session-core 75 / api-client 69 / client-tls 27 / test-support 15 / **transport-okhttp 17**).
|
||||
- `npx vitest run test/` → **54 files, 1533 tests, 0 failures** (incl. new `test/push-fcm.test.ts`
|
||||
60 tests; no regression of the existing server suite).
|
||||
- `npm run typecheck` (tsc main + web) → clean.
|
||||
|
||||
### [x] A7 `:transport-okhttp` — OkHttp WS + REST transport (pure JVM; MockWebServer-tested)
|
||||
- Files: `transport-okhttp/src/main/.../transport/{OkHttpClientFactory, OkHttpTermTransport,
|
||||
OkHttpWebSocketConnection, OkHttpHttpTransport}.kt` + 3 test files. Implements frozen
|
||||
`TermTransport`/`PingableTermTransport`/`HttpTransport` verbatim (contracts unmodified).
|
||||
- WS: `connect()` stamps `Origin = endpoint.originHeader` on the upgrade (CSWSH; asserted
|
||||
byte-equal); `frames` = `channelFlow` draining an unlimited listener mailbox — clean close →
|
||||
normal completion, `onFailure` → error (distinguishable); `send`→`webSocket.send`,
|
||||
`close`→`close(1000)` (**detach, never kill**). REST: non-2xx RETURNED, transport-failure THROWN,
|
||||
headers verbatim (no self-added Origin). Shared `OkHttpClient` `.cache(null)` (§8) + default
|
||||
system trust; mTLS via a LOCAL `ClientIdentityProvider` fun-interface seam (module depends only
|
||||
on `:wire-protocol`, no `:client-tls` edge).
|
||||
- **Cross-review fixes (regression-tested):** (1) HIGH — handshake socket leak on mid-dial cancel:
|
||||
`openConnection` now tears down the just-created WS on any throwable (incl. `CancellationException`)
|
||||
via an idempotent `cancel()` (`webSocket.cancel()`+`finish(null)`) before rethrowing — proven
|
||||
red→green by revert. (2) HIGH — the redundant transport-level 16 MiB frame cap
|
||||
(`TransportFrameTooLargeException`) was **deleted**: it was unreachable from `:session-core` (which
|
||||
may depend only on `:wire-protocol`), and `SessionEngine` already self-measures frames and emits
|
||||
`REPLAY_TOO_LARGE` — the single authoritative classifier. (3) MEDIUM — pump rethrows
|
||||
`CancellationException` (defensive; kept as a contract lock).
|
||||
|
||||
### [x] A14 `SessionEngine` — pure lifecycle state machine (`:session-core`, runTest virtual time)
|
||||
- Files: `session-core/src/main/.../session/SessionEngine.kt` + `SessionEngineTest.kt` (15 tests).
|
||||
Composes ReconnectMachine/PingScheduler/GateTracker/AwayDigest over an injected `TermTransport` +
|
||||
dispatcher/TimeSource; NO OkHttp/Android imports.
|
||||
- Verified: attach-first ordering, adopt-server-id, connect-now-on-foreground, **close≠kill**,
|
||||
oversized-replay→terminal `REPLAY_TOO_LARGE`, gate-decision epoch drop, backoff ladder
|
||||
1→2→4→8→16→30, ping 25s/2-miss — all under virtual time via the fakes.
|
||||
- **Cross-review fixes (regression-tested):** (1) HIGH (found independently by BOTH reviewers) —
|
||||
`close()` during the dial/attach window failed to detach the freshly-opened connection: engine now
|
||||
re-checks the `closed` flag after dial and after attach, closing + returning terminal with no
|
||||
further emits (2 new tests: close-during-dial, close-during-attach). (4) MEDIUM — gate double-send:
|
||||
`decideGate()` now locally retires the held gate after a successful send so a second same-epoch tap
|
||||
is dropped (new test). (2) MEDIUM — `started` flip moved onto the confined dispatcher (invariant
|
||||
#4). (3) MEDIUM — suspend-call `runCatching` replaced with cancel-rethrowing `ignoringNonCancel`.
|
||||
- **KNOWN follow-up routed to A15 (AW2):** the engine does not close its live WS when its *scope* is
|
||||
cancelled (only on explicit `close()`). Intended teardown is explicit `engine.close()` (per §6.6),
|
||||
so **the `RetainedSessionHolder` MUST call `engine.close()` before cancelling the engine scope**;
|
||||
additionally consider a `finally`-close in the engine for defense-in-depth. Non-blocking for R1
|
||||
(server PTY survives either way; only affects prompt-vs-TCP-timeout detach).
|
||||
|
||||
### [x] A33 server FCM — the ONE additive server change (§4.5)
|
||||
- Files: NEW `src/push/fcm.ts` (`NotifyService` impl behind a seam) + `POST/DELETE /push/fcm-token`
|
||||
route + `initFcm`/`normalizeFcmToken` wiring in `src/server.ts` (combined via
|
||||
`combineNotifyServices` beside web-push/APNs — zero new event paths) + `FCM_*` env
|
||||
(all-or-disabled) + `test/push-fcm.test.ts` (60 tests). Added `google-auth-library@^10` to
|
||||
`package.json` deps (R7 decision).
|
||||
- Data-only high-priority messages; payload minimized to `sessionId`/`cls`/`token` (no
|
||||
notification block, no cwd/command); loose FCM-token validator; token/key never logged; disabled
|
||||
cleanly when `FCM_*` incomplete.
|
||||
- **Cross-review fix (regression-tested):** HIGH — removed `validateStatus:()=>true`, which had
|
||||
silently defeated google-auth-library's built-in 401/403 refresh-and-retry (the whole reason R7
|
||||
chose the lib). Now wraps `client.request` in try/catch, reading `{status,body}` from
|
||||
`GaxiosError.response`; a 401 goes through the lib's refresh before surfacing; a no-response
|
||||
transport error is a send failure (token never falsely pruned). 3 new tests (401→refresh→200,
|
||||
404/UNREGISTERED→prune, no-response→keep).
|
||||
|
||||
**Not run here (deferred per plan §7):** instrumented/device tests (no emulator installed) and the
|
||||
S2 FCM real-handset delivery spike (needs ≥2 physical devices under Doze/OEM battery managers).
|
||||
|
||||
---
|
||||
|
||||
## [~] Wave R2 — A13 (done) · A11 + A12 (building) · S1 → moved to head of AW3
|
||||
|
||||
### [x] A13 `:app` Compose baseline (DONE, orchestrator-verified 2026-07-08)
|
||||
First real full Android app module — establishes the UI-stack version matrix every later Android
|
||||
task reuses. Workflow: TDD builder → kotlin + design cross-review.
|
||||
- Files: `app/build.gradle.kts`, `AndroidManifest.xml`, `WebTermApp.kt` (@HiltAndroidApp),
|
||||
`MainActivity.kt` (@AndroidEntryPoint), `di/AppModule.kt` (minimal Hilt), `designsystem/{DesignSpec,
|
||||
Tokens,Typography,Theme,Primitives}.kt`, `res/{values,xml}/*`, `DesignSpecTest.kt` (5 JVM tests).
|
||||
- **Version matrix PROVEN** (`:app:assembleDebug` → 32.7 MB APK, `:app:testDebugUnitTest` green):
|
||||
AGP 9.2.1 · Kotlin 2.3.21 · compose-compiler plugin 2.3.21 · Compose BOM 2025.11.01 (material3
|
||||
1.4.0, ui 1.9.5, material3.adaptive 1.2.0) · Hilt 2.60.1 via KSP 2.3.9 · **compileSdk 36**
|
||||
(installed `platforms;android-36`; SDK-37 unavailable — cmdline-tools too old), minSdk 29,
|
||||
targetSdk 35. Design system mirrors iOS DS 1:1 (amber-gold #E3A64A/#C9892F, semantic status
|
||||
colors, 8pt scale, tabular mono numerals, dark-first, fixed terminal-canvas #100F0D/#ECE9E3/gold).
|
||||
- **Cross-review fix (orchestrator applied + re-verified assemble green):** both reviewers flagged
|
||||
the primitives (StatusBadge/TelemetryChip/WebTermCard) hardcoding `WebTermColors.dark.*` → would
|
||||
render wrong in light theme. Routed through `MaterialTheme.colorScheme.{onSurfaceVariant,
|
||||
surfaceVariant,outline}` (Theme.kt already maps those). Also fixed preview `.dp` literals →
|
||||
`Spacing.*`. Docs synced: `android/README.md` + `settings.gradle.kts` recipe + `[[android-build-env]]`
|
||||
memory now say compileSdk 36 / android-36.
|
||||
- Deferred (per §7): instrumented/Compose-UI tests (no emulator). Follow-up for AW3: add a
|
||||
`Motion.gated()` helper (reduce-motion) to Tokens.kt when the first animation lands (LocalReduceMotion
|
||||
+ DesignSpec.MOTION_* already present); register a ContentObserver on ANIMATOR_DURATION_SCALE then.
|
||||
|
||||
### [x] A11 `:client-tls-android` — ClientTLS framework half (DONE, orchestrator-verified)
|
||||
Catalog pre-staged (tink 1.15.0, datastore-preferences 1.1.1, androidx-test); enabled in settings.
|
||||
Build workflow → security+kotlin cross-review → fix workflow → adversarial security re-verify →
|
||||
orchestrator applied 3 more fixes the re-verify caught + independent compile gate.
|
||||
- Files: `client-tls-android/src/main/.../tlsandroid/{AndroidKeyStoreImporter, TinkCertStore(+CertStore
|
||||
iface), ReReadingX509KeyManager, IdentityRepository(+ClientSslMaterial), StoredIdentityMetadata}.kt`
|
||||
+ androidTest `{Fixtures, AndroidKeyStoreImporterTest, IdentityRepositoryTest}.kt`.
|
||||
- ONE key home = AndroidKeyStore (non-exportable, per-handshake re-reading `X509KeyManager`); Tink AEAD
|
||||
encrypts ONLY the cert-chain+metadata blob; NO `.p12`/passphrase persisted; validate-before-persist;
|
||||
`connectionPool.evictAll()` on rotate/remove. Exposes `IdentityRepository`/`ClientSslMaterial` as the
|
||||
seam A15 bridges to `:transport-okhttp`'s `ClientIdentityProvider` (NO `:transport-okhttp` dep).
|
||||
- **Security must-fix (rotation safety) — resolved via single-commit + adversarial follow-through:**
|
||||
the re-verify caught the fixer's first attempt still lost the prior identity. Final design:
|
||||
**ping-pong staging slot → one atomic durable `SharedPreferences.commit()` live-pointer flip**
|
||||
(`StoredIdentityMetadata.keyStoreAlias`). Any failure before the flip → prior identity fully live;
|
||||
after → new identity fully live; stores never diverge. Orchestrator then fixed 3 residual bugs the
|
||||
security re-verify empirically proved: **(1 HIGH)** `currentLive()` used `liveOverride?.value ?:
|
||||
initialIdentity` which collapsed `Box(null)` (explicitly removed) into the stale cached identity →
|
||||
a *removed* cert stayed "live" and could be presented with a dangling key; now resolves via Box
|
||||
presence. **(3)** `TinkCertStore.save()/clear()` switched `apply()`→`commit()` (durable + throws on
|
||||
failure) so a crash-window can't leave the pointer naming a just-deleted key (both-identities-lost).
|
||||
**(2)** widened staging-key cleanup to cover the key-readback/encode steps. Added instrumented
|
||||
regression `remove_afterStartupTouch_reportsNoIdentity_notStaleCached`.
|
||||
- Verified (orchestrator): `./gradlew :client-tls-android:assembleDebug :client-tls-android:compileDebugAndroidTestKotlin`
|
||||
→ BUILD SUCCESSFUL (main + instrumented compile). All AndroidKeyStore/Tink tests run on device (§7).
|
||||
|
||||
### [x] A12 `:host-registry` — Host/HostStore + DataStore + LastSessionStore (DONE, both reviewers approved)
|
||||
- Files: `host-registry/src/main/.../hostregistry/{Host, HostStore(+immutable transforms),
|
||||
InMemoryHostStore, DataStoreHostStore, LastSessionStore, DataStoreLastSessionStore, HostCodec}.kt`
|
||||
+ tests (JVM unit: HostStoreTransforms/InMemoryHostStore/HostCodec/InMemoryLastSessionStore) + androidTest.
|
||||
- Immutable HostStore (upsert/remove return new lists, position-preserved, unknown-id no-op);
|
||||
DataStore read-modify-write; HostEndpoint re-validated on load; LastSessionStore set-on-adopted /
|
||||
clear-on-exited. **Orchestrator fix:** `DataStoreLastSessionStore` now guards the persisted id with
|
||||
the frozen v4 `Validation.isValidSessionId` (was `UUID.fromString` format-only).
|
||||
- Verified (orchestrator): `./gradlew :host-registry:assembleDebug :host-registry:testDebugUnitTest`
|
||||
→ BUILD SUCCESSFUL, **17 JVM unit tests green**; DataStore-backed tests are androidTest (device QA).
|
||||
|
||||
### S1 renderer spike — relocated to the head of AW3 (it gates A16, the XL renderer task).
|
||||
|
||||
**Wave R2 complete.** Android modules now: 6 pure-JVM (250 tests) + `:app` + `:host-registry` +
|
||||
`:client-tls-android` (framework, assemble+unit-verified here; instrumented deferred to device QA).
|
||||
|
||||
---
|
||||
|
||||
## [x] Wave AW2 — A15 `:app` wiring + DI FREEZE (DONE, orchestrator-verified 2026-07-09)
|
||||
|
||||
The convergence/contract task that gates all of AW4. Build → arch+coroutine cross-review (BOTH blocked)
|
||||
→ fix (6 must-fixes) → arch+coroutine re-verify (caught 1 more real bug) → residual fix → orchestrator
|
||||
added the final test lock + independent gate. `./gradlew :app:assembleDebug :app:testDebugUnitTest
|
||||
:session-core:test` all green (EventBus 6 tests, RetainedSessionHolder 4, DesignSpec 5, SessionEngine 15).
|
||||
|
||||
- Files: `app/src/main/.../wiring/{EventBus, TerminalSessionController, RetainedSessionHolder,
|
||||
AppEnvironment, ColdStartPolicy, ApiClientFactory, SessionEngineFactory}.kt` + `di/{NetworkModule,
|
||||
TlsModule, StorageModule, SessionModule}.kt` (replaced A13's placeholder AppModule) + tests.
|
||||
- **Frozen contracts (AW4 depends on these):** per-session `EventBus` (NOT @Singleton) with two typed
|
||||
sub-streams `outputBytes(): Flow<ByteArray>` (Output-only, UTF-8 decode off-Main via flowOn) +
|
||||
`controlEvents(): Flow<SessionEvent>` (non-Output); `TerminalSessionController` (start() decoupled
|
||||
from bind(), eager mailbox registration); `RetainedSessionHolder` (@HiltViewModel, config-survival,
|
||||
single-session-for-life BoundKey guard, close-join-before-cancel teardown); `AppEnvironment.warmUp()`
|
||||
(off-Main first identity touch); `ColdStartPolicy`; factories for per-host ApiClient + per-session
|
||||
SessionEngine. mTLS bridge: A11 `IdentityRepository` → A7 `ClientIdentityProvider` → ONE shared
|
||||
`OkHttpClient` (.cache(null), WS+REST) — construction cycle broken via a shared `ConnectionPool`.
|
||||
- **Coordination change (authorized):** `SessionEngine.close()` now returns its `Job` so teardown can
|
||||
`join()` the clean detach BEFORE cancelling the confinement scope (structural close-before-cancel).
|
||||
- **Cross-review caught + fixed:** (HIGH) EventBus was @Singleton → cross-session event bleed → now
|
||||
per-session; (HIGH) bind()-started-engine-before-UI-subscribe → lost `attached`+replay → decoupled
|
||||
start() + eager registration; (HIGH) eager DI did AndroidKeyStore/Tink I/O on Main → dagger.Lazy +
|
||||
warmUp() on IO; (HIGH, re-verify) `register()`'s finally-close made the reused controller's output/
|
||||
events flows DEAD after one config-change (blank terminal on rotation) → mailbox now lives for the
|
||||
session (receiveAsFlow consume=false), released only by `EventBus.close()` at teardown; + typed
|
||||
sub-streams, structural close, bind mis-route guard, @Volatile started. R10 (per-consumer UNLIMITED
|
||||
channels: slow consumer neither stalls nor drops) unit-tested; confinement invariant #4 intact.
|
||||
|
||||
## [~] Wave AW3 — A16 `:terminal-view` DONE · A17 KeyBar + A18 ThumbnailPipeline building
|
||||
|
||||
### [x] S1 + A16 `:terminal-view` (XL) — the renderer, DONE + orchestrator-verified 2026-07-09
|
||||
**S1 gate PASSED headless** (no device): a Termux `TerminalEmulator` (no JNI/subprocess) fed canned WS
|
||||
bytes renders into a readable `TerminalBuffer` ("hello", cursorCol 5); DSR reply routes out via
|
||||
`TerminalOutput.write`→engineSend; DECCKM `ESC[?1h` flips to `ESC OA`. **Termux resolved via JitPack**
|
||||
`com.github.termux.termux-app:{terminal-view,terminal-emulator}:v0.118.0` (only transitive dep
|
||||
androidx.annotation; no guava → R2 shim unneeded; Apache-2.0 scope held — never termux-shared/app).
|
||||
JitPack repo + coordinate added to settings/catalog; `:terminal-view` enabled.
|
||||
- Files: `terminal-view/src/main/.../terminalview/{RemoteTerminalSession, RemoteTerminalView,
|
||||
TerminalGridMath, NoOpTerminalSessionClient, LinkPolicy}.kt` + 5 test suites (17 unit tests).
|
||||
- `RemoteTerminalSession` = the FORK (no subprocess): a single confined-writer via an ordered
|
||||
`Channel<TerminalCommand>` (Feed/Resize/Bind/Unbind); append chunked 4 KiB + yield(); `onScreenUpdated`
|
||||
posted to an injected main dispatcher. pendingOutput queued-then-flushed in order (ESC[0m preserved).
|
||||
Resize math extracted as pure `TerminalGridMath` (R5), JVM-tested. Titles pass RAW (no :session-core
|
||||
edge — :app wires TitleSanitizer); OSC-52 declined; http/https LinkPolicy.
|
||||
- **Sound deviation (§6.1):** at v0.118.0 Termux `TerminalView`/`TerminalSession` are `final` →
|
||||
`RemoteTerminalView` is a COMPOSITION wrapper binding the forked emulator via the public `mEmulator`
|
||||
field (rendering stays 100% stock). Documented.
|
||||
- **Cross-review (correctness approved; threading blocked→fixed):** HIGH — bind published `mEmulator`
|
||||
to the renderer SYNC before the pendingOutput flush → bind-time UI-read-vs-confined-append race;
|
||||
fixed by routing the publish through the Bind command (flush on confined thread, THEN post
|
||||
publish+onScreenUpdated to Main together; test captures buffer state AT publish). MEDIUM — confined
|
||||
command loop now try/catches (rethrows Cancellation) so a hostile escape byte can't freeze output.
|
||||
LOW — added `kotlinx-coroutines-android` (else `Dispatchers.Main` crashes on-device). Verified:
|
||||
`:terminal-view:assembleDebug` + `testDebugUnitTest` 17/17 green.
|
||||
- **Accepted (not a defect):** steady-state append runs concurrently with the UI-thread `onDraw` — this
|
||||
is the intended §6.2 single-WRITER model (matches upstream Termux; torn read self-corrects next frame).
|
||||
- Deferred to device QA (§7): glyph/true-color rendering, IME/CJK, selection→clipboard, link-tap,
|
||||
rotation-rebind, real font-metric→grid (TerminalRenderer metrics are package-private → measured
|
||||
on-device, fed to the tested TerminalGridMath).
|
||||
|
||||
### [x] A17 KeyBar (DONE, green) — `app/.../components/KeyBar.kt` + test (12 tests)
|
||||
Compose mobile key-bar overlay porting `public/keybar.ts` (17 buttons, order/glyphs 1:1). Each tap →
|
||||
`KeyByteMap.bytes(key)` sent VERBATIM via an injected `onSend` (A21 wires `controller::sendInput`),
|
||||
bypassing IME/BasicTextField (soft keyboard never pops). Pinned above IME via
|
||||
`windowInsetsPadding(WindowInsets.ime.union(navigationBars))`, horizontally scrollable, hidden when
|
||||
`screenWidthDp>768` OR a hardware keyboard is present. **DECCKM split** = pure
|
||||
`HardwareKeyRouter.resolve()`: ⇧Tab→ESC[Z, Esc→ESC, Ctrl+{C,R,O,L,T,B,D}→control byte; everything else
|
||||
(arrows/Enter/Tab/unmapped) → `DeferToTerminal` so Termux `KeyHandler` emits DECCKM-correct `ESC OA`
|
||||
(never hardcoded `ESC[A`). Verified `:app` 27 tests green (KeyBar 12). Cross-reviewed: reviewers
|
||||
stalled (infra), but the byte contract is unit-locked. Device-QA: layout/IME-inset/real key delivery.
|
||||
A21 install: `remote.onKeyCommand = { kc,ev -> HardwareKeyRouter.handle(kc,ev,controller::sendInput) }`
|
||||
+ `KeyBar(onSend = controller::sendInput)`.
|
||||
|
||||
### [x] A18 ThumbnailPipeline (DONE, green — review deferred) — `app/.../wiring/ThumbnailPipeline.kt` + test (5 tests)
|
||||
Off-screen preview rasterisation (§6.7): `LruCache` keyed `(sessionId, lastOutputAt)` (unchanged ⇒
|
||||
cache hit, no re-render); fair `Semaphore(2)` FIFO fetch+render cap; in-flight dedup via
|
||||
`Map<Key,Deferred<Bitmap>>` under a `Mutex` (2nd same-key request awaits the 1st); failure caches a
|
||||
PLACEHOLDER (no retry storm); preview fetched over the shared **mTLS** `ApiClient` (`GET
|
||||
/live-sessions/:id/preview`, 256 KiB cap); off-screen `TerminalEmulator` (no view) → `Canvas` cell
|
||||
painter behind a `Rasterizer` seam (bitmap draw device-QA'd; concurrency/cache logic JVM-tested).
|
||||
**NOTE:** A18's build agent + all 4 AW3 reviewers stalled ~3.5h (infra degradation); the agent had
|
||||
written the file first. Orchestrator fixed a 1-char test-name error (illegal `;` in a backtick name),
|
||||
re-verified `:app:assembleDebug + testDebugUnitTest` → **32 tests green**. A18's formal cross-review was
|
||||
NOT run — flag it for the AW6 acceptance pass (concurrency code warrants a second look).
|
||||
|
||||
**Wave AW3 complete.** `:terminal-view` (17) + `:app` (32) + 6 pure-JVM (250) all green. The terminal
|
||||
render path — the plan's dominant risk — is proven and hardened.
|
||||
|
||||
---
|
||||
|
||||
## [~] Wave AW4 — 11 UI screens (A19–A29)
|
||||
|
||||
### [x] A21 Terminal screen + reconnect/EXIT banner + new-session-in-cwd (built, green — integration pass in flight)
|
||||
Files: `components/ReconnectBanner.kt` (pure `bannerModel` reducer: exited/failed OUTRANK
|
||||
connecting/reconnecting; exit -1 spawn-failure; REPLAY_TOO_LARGE no-spinner+new-session), `wiring/
|
||||
TerminalSessionControllerImpl.kt` (wraps the holder controller `by base`, wires output→feedRemote
|
||||
before start, OSC title→TitleSanitizer), `screens/TerminalScreen.kt` (generation-keyed AndroidView,
|
||||
KeyBar/HardwareKeyRouter, FLAG_SECURE privacy shade, new-session-in-cwd → attach(null,cwd)). `:app` 64
|
||||
tests (ReconnectBanner 8 + NewSessionInCwd 3).
|
||||
|
||||
### [x] A22 Gate/cockpit surfaces (built, green, reviewer approved)
|
||||
Files: `viewmodels/GateViewModel.kt` (two-line epoch stale-guard, decide via controller.decideGate,
|
||||
approve.mode top-level) + `components/{GateBanner (tool 2-btn), PlanGateSheet (plan 3-way sheet),
|
||||
TelemetryChips, AwayDigestView}.kt`. Built but not yet composed into the terminal screen (→ integration).
|
||||
|
||||
### [x] Terminal-path integration + A15 seam refinement (DONE, orchestrator-verified — `:app` 66 tests)
|
||||
Both re-verifiers confirmed (arch 7/0 broken cosmetic; kotlin 7/0). Frozen-contract changes:
|
||||
`TerminalSessionController.events` (single mailbox) → `controlEvents()` (per-consumer mailbox, no
|
||||
split); `RetainedSessionHolder.generation` → `mutableIntStateOf`; holder now owns
|
||||
`TerminalSessionControllerImpl` + `RemoteTerminalSession` (config-survival §6.6 — emulator+scrollback
|
||||
content survive rotation, no replay round-trip); GateViewModel + gate surfaces composed into
|
||||
TerminalScreen (haptic reduce-motion gated); warmUp error/retry pane. Confinement invariant #4 intact.
|
||||
Residual (cosmetic, tracked for AW6 doc pass): dangling `events` KDoc refs; scroll *offset* (not
|
||||
content) resets on rotation. Tests: TerminalSessionControllerTest (2, multi-consumer no-split),
|
||||
RetainedSessionHolder (4), GateViewModel (10).
|
||||
|
||||
### [x] AW4b — A19 Pairing + A20 Session list + A24 Diff + A25 Quick-reply (DONE, `:app` 111 tests green)
|
||||
Pre-staged CameraX (1.4.1) + ML Kit barcode (17.3.0) for A19 QR. All 4 reviewers approve/warn, 0 must-fix.
|
||||
- **A19 Pairing** — QR (CameraX+MLKit) / manual, both through the ONE `HostEndpoint.fromBaseUrl`
|
||||
validator; confirm-before-network; §5.4 tiers (public explicit-ack; tunnel cert-gated choke point
|
||||
retry can't bypass; fail-safe unknown→PUBLIC; tunnel-TLS→client-cert copy); no cert import; inert.
|
||||
- **A20 Session list** — STARTED-scoped poll, status/telemetry/thumbnail/cols×rows/sanitized-title/
|
||||
unread-dot rows, swipe-kill (optimistic, 404=success), multi-host switch, host menu (配对新主机/设备证书).
|
||||
SessionListViewModelTest 11.
|
||||
- **A24 Diff** — staged flag as STRING "1"/"0", files→hunks→lines flatten, lossy decode, inert read-only.
|
||||
A24's builder died mid-run (API drop) leaving a missing `LaunchedEffect` import (blocked :app compile)
|
||||
+ no test; orchestrator added the import + wrote DiffViewModelTest (8 tests: fromWire, flatten order,
|
||||
lossy decode, VM phase transitions + staged re-fetch).
|
||||
- **A25 Quick-reply** — DataStore CRUD palette (immutable), verbatim send, float-while-gate-held.
|
||||
|
||||
### [x] AW4c — A23 Projects + A27 CertScreen + A28 Timeline + A29 Cold-start (DONE, `:app` 164 tests green)
|
||||
Reviews: A27 approve; A23/A29 warn; all 0 must-fix except A28's "wired to away-digest expand" (a
|
||||
composition/nav wiring → tracked for the app-assembly pass below, not a code defect).
|
||||
- **A23 Projects** — ProjectGrouping BYTE-IDENTICAL group keys to web/iOS (" active"/" other" sentinels,
|
||||
first-seen-cased namespace keys, MIN_GROUP_SIZE=2); favourites/collapse via `/prefs` unknown-key-
|
||||
preserving (R11: never PUT if never loaded); detail page; open-Claude-here → attach(null,cwd,"claude\r");
|
||||
grid column seam (A26 owns full adaptive). ProjectsViewModelTest 12.
|
||||
- **A27 ClientCertScreen** — import(SAF .p12)/rotate/remove over `:client-tls-android` IdentityRepository;
|
||||
validate-before-persist keeps prior cert on bad passphrase; summary (issuer/subject-CN/expiry/EXPIRED);
|
||||
passphrase scrubbed, never logged; confirm-gated remove; inert. ClientCertViewModelTest 14.
|
||||
- **A28 Timeline** — TimelineSheet(ModalBottomSheet) + TimelineViewModel over `/events`, lossy decode,
|
||||
tokenized event colors. TimelineViewModelTest 16. (away-digest→sheet wiring → app-assembly.)
|
||||
- **A29 Cold-start** — SessionActivityBridge (set LastSessionStore on adopted / clear on exited),
|
||||
ContinueLastBanner (stack+sidebar), ColdStartPolicy route (no host→pairing). Tests: bridge 6 + policy 2.
|
||||
|
||||
### [x] A26 Adaptive large-screen + nav shell (DONE, review warn 0 must-fix)
|
||||
Pure `LayoutPolicy.mode(WindowWidth)` (compact→STACK, medium/expanded→LIST_DETAIL) + `PointerMenuPolicy.
|
||||
enabled(mode, sw>=600)` — both JVM-tested (LayoutPolicyTest 8). `AdaptiveHome` = NavigationSuiteScaffold +
|
||||
ListDetailPaneScaffold keyed on `currentWindowAdaptiveInfo()`. `PointerContextMenu` = `pointerInput`
|
||||
secondary-click → `DropdownMenu` (NOT ContextMenuArea), gated. Full NavGraph wiring → A32/app-assembly.
|
||||
|
||||
**Wave AW4 COMPLETE — all 11 screens (A19–A29) built + verified; `:app` green.**
|
||||
|
||||
---
|
||||
|
||||
## [~] Wave AW5 — push + deep-links (built; wiring → app-assembly)
|
||||
|
||||
### [x] A30 FCM client (green — `:app` 222 tests, PushDecisionTest 14)
|
||||
`push/{FcmService, NotificationBuilder, DenyBroadcastReceiver, AllowTrampolineActivity, PushPayload,
|
||||
PushDecisionSubmitter, PushTokenSink}.kt`. Data-only payload → notification built LOCALLY; **Deny** =
|
||||
BroadcastReceiver (goAsync + expedited POST, no UI, auth-free); **Allow** = translucent
|
||||
excludeFromRecents FragmentActivity hosting `BiometricPrompt` → POST (R1). Token single-use, only in
|
||||
FLAG_IMMUTABLE extras → ApiClient, never persisted/logged (test-asserted); payload minimized (no
|
||||
cwd/command/bytes). Multi-host: tries each paired host until 204 (foreign host 403s harmlessly).
|
||||
Biometric = BIOMETRIC_STRONG+negativeButton (minSdk-29 valid combo). Manifest (service/receiver/activity)
|
||||
+ `Theme.WebTerm.Translucent` **applied by orchestrator; `:app` assembles**.
|
||||
|
||||
### [x] A31 PushRegistrar (green — PushRegistrarTest 7)
|
||||
`push/PushRegistrar.kt` — POST /push/fcm-token to each paired host, self-heal (token rotation / new host
|
||||
/ removal→DELETE), token not logged. Review HIGH: not yet invoked in the app → **app-assembly** (DI:
|
||||
@Binds PushTokenSink + on-start/host-change invocation).
|
||||
|
||||
### [x] A32 DeepLinkRouter + NavGraph (green, review approve — DeepLinkRouterTest)
|
||||
`nav/{DeepLinkRouter, NavGraph}.kt` — pure `webterminal://open?host=&join=` + verified App-Links parser,
|
||||
v4-UUID whitelist on host+join (invalid ignored+counted, never partial), one router for cold/warm/push.
|
||||
Manifest intent-filters (custom scheme + `autoVerify` https) **applied by orchestrator; `:app` assembles**.
|
||||
|
||||
### [x] APP-ASSEMBLY — the app is a RUNNABLE whole (DONE, `:app` 227 tests, APK builds)
|
||||
`MainActivity` (@AndroidEntryPoint) → `WebTermNavHost` composing all screens; start destination from
|
||||
`ColdStartPolicy`; inter-screen callbacks wired (row/project/continue → terminal, host-menu →
|
||||
pairing/cert, away-digest → timeline); `AdaptiveHome` for large screens; PushRegistrar DI (@Binds
|
||||
PushTokenSink + on-start/host-change invocation); deep links via DeepLinkRouter (onCreate+onNewIntent);
|
||||
warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid+acyclic. Manifest
|
||||
(push components + deep-link intent-filters) applied by orchestrator.
|
||||
- **Review HIGH fixed by orchestrator:** cold-start deep links were dropped by a composition race
|
||||
(navigate before the NavHost graph was set) → moved `DeepLinkEffect` inside the `route != null`
|
||||
branch so it runs only after `WebTermNavHost` composed (graph set). Re-verified `:app` 227 green.
|
||||
- Minor gaps tracked (see DEVICE_QA_CHECKLIST): push-tap→specific-gate, diff inbound link, host-remove
|
||||
UI, the A21 Termux-view reflection shim.
|
||||
|
||||
**Wave AW5 COMPLETE.**
|
||||
|
||||
---
|
||||
|
||||
## [x] Wave AW6 — acceptance (DONE, orchestrator-verified 2026-07-09)
|
||||
- **[x] A36 coverage gate:** Kover 0.9.1 applied to the 4 pure modules with an enforced `koverVerify`
|
||||
`minBound(80)` rule. Measured line coverage: **wire-protocol 91.0%** (added a HostClassifier/TimelineEvent
|
||||
test — the type lives here but was only tested cross-module, so its own report read 77% → 91%),
|
||||
**session-core 95.2%, api-client 89.2%, client-tls 96.4%**. `./gradlew koverVerify` GREEN.
|
||||
- **[x] Device-QA checklist:** `android/DEVICE_QA_CHECKLIST.md` — captures **A34** (instrumented E2E vs
|
||||
real `npm start`: attach→attached→output, reconnect replay, kill, hook/decision, **bad-Origin reject
|
||||
F9**) + **A35** (pair→attach→type→approve macrobenchmark) + the **S2** FCM real-handset spike + every
|
||||
per-task device-deferred item + the deploy artifacts (google-services.json, assetlinks.json, FCM_* env).
|
||||
A34/A35 are device/instrumented by definition (plan §7) — deferred to real hardware, not run here.
|
||||
- **[x] FINAL UNIFIED GATE (orchestrator, measured):** `./gradlew test :app:assembleDebug
|
||||
:app:testDebugUnitTest koverVerify` → **BUILD SUCCESSFUL**. ~**484 JVM tests** (257 pure/transport +
|
||||
227 `:app`) + Kover ≥80% + the full `:app` APK + `:terminal-view`/`:host-registry`/`:client-tls-android`
|
||||
all assemble. Server side (A33 FCM) unchanged since R1 (1533 server tests green).
|
||||
|
||||
---
|
||||
|
||||
## ✅ 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
|
||||
JVM) + `:app :terminal-view :host-registry :client-tls-android` (framework). The Android app **builds to
|
||||
an APK** with functional parity to the iOS P0+P1 scope; the Termux renderer seam (the plan's dominant
|
||||
risk) is proven working headless. S2 (FCM real-device delivery) is the one spike inherently requiring
|
||||
≥2 physical handsets — documented in the checklist. Everything device-observable is deferred to
|
||||
`DEVICE_QA_CHECKLIST.md` per plan §7 (no emulator/Firebase/host in this env).
|
||||
|
||||
**Method:** every wave ran a Workflow (TDD builders → 2-lens adversarial cross-review → fix workflow →
|
||||
adversarial re-verify → orchestrator-independent gate). Cross-validation caught + fixed ~20 real defects
|
||||
before they reached a screen (transport socket leaks, engine close-during-dial + gate double-send, an
|
||||
FCM token-refresh regression, a security bug where a REMOVED client cert stayed live in TLS, cross-session
|
||||
event bleed, blank-terminal-on-rotation, terminal-freeze-on-hostile-bytes, cold-start deep-link drop, …).
|
||||
|
||||
**GIT / not committed:** all Android work is on-disk + test-verified but UNCOMMITTED — a concurrent
|
||||
session was running the tunnel-automation workstream on this same `feat/tunnel-automation` working tree,
|
||||
so the orchestrator held ALL git ops and never touched `docs/PROGRESS_LOG.md`. **TODO for the user:**
|
||||
reconcile the two sessions, then commit the Android lane by explicit path and fold this file into
|
||||
`docs/PROGRESS_LOG.md`.
|
||||
|
||||
### Tracked APP-ASSEMBLY items (a final wiring pass composes screens into the NavGraph + connects
|
||||
### callbacks): away-digest expand → TimelineSheet (A28); host menu → pairing/cert (A20→A19/A27);
|
||||
### project open → terminal (A23→A21); continue-last → terminal (A29); session row → terminal (A20→A21);
|
||||
### and the A21 reflection shim → `libs.termux.terminal.view` on `:app` (dep now available to add).
|
||||
Cross-review of A21/A22 surfaced interlocking frozen-seam gaps being fixed together: (1 HIGH)
|
||||
`controller.events` was a single mailbox but banner+gate both collect → event split → made
|
||||
multi-consumer (`controlEvents()` per consumer); (2 HIGH) `holder.generation` made Compose-observable
|
||||
(mutableIntStateOf) so a real-background bump re-keys the AndroidView; (3 HIGH) RemoteTerminalSession/
|
||||
controller moved into the config-surviving RetainedSessionHolder so §6.6 rotation keeps the emulator+
|
||||
scrollback; (4) A22 gate surfaces composed into TerminalScreen; (5) warmUp error handling. A21 also
|
||||
flagged for later: the reflection shim to reach the impl-scoped Termux `TerminalView` from `:app`
|
||||
(clean fix = add `libs.termux.terminal.view` to `:app`); adopted-session-id survival across
|
||||
real-background → A29's LastSessionStore.
|
||||
|
||||
### Remaining AW4 screens (independent of the terminal path, next batches):
|
||||
A19 Pairing (QR/confirm/tiers) · A20 Session list+dashboard+host menu · A23 Projects+grouping+detail ·
|
||||
A24 Diff viewer · A25 Quick-reply chips+palette · A27 ClientCertScreen · A28 Timeline sheet (wires to
|
||||
A22 away-digest expand) · A29 Cold-start UX (ColdStartPolicy + LastSessionStore lifecycle) · A26
|
||||
Adaptive large-screen (LAST — deps A20+A21).
|
||||
- **[ ] AW2** A15 wiring/DI freeze (carry the A14 scope-close follow-up above).
|
||||
- **[ ] AW3** A16/A17/A18 · **[ ] AW4** A19–A29 · **[ ] AW5** A30–A32 · **[ ] AW6** A34–A36.
|
||||
133
android/README.md
Normal file
133
android/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# WebTerm — Android client
|
||||
|
||||
A native Android client for the WebTerm browser-terminal server, targeting functional
|
||||
parity with the shipped **iOS** client. See the full design in
|
||||
[`docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md) (stack §2, module
|
||||
architecture §3, server contract §4, task waves §5).
|
||||
|
||||
This directory is a **Gradle multi-module** project. The module set mirrors the iOS
|
||||
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
||||
upward* (ARCHITECTURE §1).
|
||||
|
||||
## ⚠️ No-SDK constraint (why only 5 modules build here)
|
||||
|
||||
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**.
|
||||
|
||||
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):**
|
||||
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`.
|
||||
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts)
|
||||
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
|
||||
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
||||
|
||||
To bring the Android modules online later: install an SDK, add
|
||||
`local.properties` → `sdk.dir`, add the Android Gradle Plugin + `google()` to
|
||||
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in
|
||||
each stub.
|
||||
|
||||
## Module map (mirror of the iOS SPM packages — plan §3)
|
||||
|
||||
| iOS SPM package | Android module | Kind | Status |
|
||||
|------------------------|------------------------|-------------------------------|--------|
|
||||
| WireProtocol | `:wire-protocol` | pure Kotlin/JVM | ✅ built |
|
||||
| SessionCore (reducers) | `:session-core` | pure Kotlin/JVM | ✅ built |
|
||||
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
|
||||
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
|
||||
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
|
||||
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated |
|
||||
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated |
|
||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated |
|
||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated |
|
||||
|
||||
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
|
||||
> impls, JVM) is owned by task **A7** and will be added then. The iOS
|
||||
> `URLSession*Transport`s consolidate into it (plan §3 framing note).
|
||||
|
||||
### Dependency graph (arrows = "depends on")
|
||||
|
||||
```
|
||||
:app (SDK-gated)
|
||||
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
:terminal-view :session-core :api-client :host-registry :client-tls-android
|
||||
(SDK-gated) │ │ (SDK-gated) │
|
||||
│ │ │ ▼
|
||||
│ │ │ :client-tls (pure)
|
||||
└──────┬───────┴──────────┴──────────────┬────────────────┘
|
||||
▼ ▼
|
||||
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
|
||||
▲
|
||||
└──────── :test-support → test source sets only
|
||||
```
|
||||
|
||||
`:wire-protocol` is the **frozen shared contract** (Android analogue of
|
||||
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
||||
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
||||
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
|
||||
interfaces. New wire types are added **only** here (a coordination point).
|
||||
|
||||
## Toolchain
|
||||
|
||||
- **Gradle** 9.6.1 (via the committed wrapper — always use `./gradlew`).
|
||||
- **Kotlin** 2.3.21 (matches the Kotlin embedded in Gradle 9.6.1).
|
||||
- **JVM toolchain** 17 (`jvmToolchain(17)` in every module).
|
||||
- Versions are pinned in the version catalog
|
||||
[`gradle/libs.versions.toml`](gradle/libs.versions.toml): kotlinx-serialization-json,
|
||||
kotlinx-coroutines-core/-test, JUnit5 (Jupiter), Turbine, MockK.
|
||||
|
||||
Pure modules apply `kotlin("jvm")` + `kotlin("plugin.serialization")`, wire the
|
||||
`libs.bundles.unit-test` bundle into `testImplementation`, and run tests on the
|
||||
JUnit Platform (`tasks.test { useJUnitPlatform() }`).
|
||||
|
||||
## Build & test
|
||||
|
||||
```bash
|
||||
# Use the committed wrapper for everything.
|
||||
./gradlew help # sanity: the build configures
|
||||
./gradlew projects # lists the 5 pure modules
|
||||
./gradlew build # compile all pure modules
|
||||
./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
|
||||
```
|
||||
|
||||
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
||||
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
|
||||
> small focused files — same discipline as the rest of the repo.
|
||||
|
||||
## Android SDK setup (proven working)
|
||||
|
||||
The pure JVM modules need only a JDK + Gradle. The **Android-framework** modules
|
||||
(`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android` — plan AW2+)
|
||||
need the Android SDK. This machine is set up and the toolchain is **proven** (an
|
||||
AGP library module compiled against SDK 35 and produced an AAR):
|
||||
|
||||
- **SDK location:** `/usr/local/share/android-commandlinetools`
|
||||
(installed via `brew install --cask android-commandlinetools`).
|
||||
- **Installed packages:** `platform-tools`, `platforms;android-35`, `platforms;android-36`,
|
||||
`build-tools;35.0.0`, `build-tools;36.0.0`. (`:app` compiles against SDK **36** — the
|
||||
Kotlin-2.3.21-contemporaneous androidx/Compose line refuses SDK 35; `platforms;android-37`
|
||||
is not fetchable here as the cmdline-tools are too old to parse the v4 repo XML.)
|
||||
- **`android/local.properties`** (gitignored) points Gradle at it:
|
||||
`sdk.dir=/usr/local/share/android-commandlinetools`.
|
||||
- **Shell env** (for `sdkmanager`/`adb`): `export ANDROID_HOME=/usr/local/share/android-commandlinetools`.
|
||||
|
||||
### Wiring an Android module (the working recipe)
|
||||
|
||||
- Repos: `google()` is in both `pluginManagement` and `dependencyResolutionManagement`
|
||||
in `settings.gradle.kts` (needed to resolve AGP + androidx).
|
||||
- Plugin: **AGP 9.2.1** (`libs.plugins.android.library` / `.android.application`),
|
||||
compatible with Gradle 9.6.1.
|
||||
- **Gotcha:** AGP 9 has **built-in Kotlin** — apply ONLY the android plugin. Adding
|
||||
`org.jetbrains.kotlin.android` errors with "no longer required since AGP 9.0".
|
||||
- Module block: `android { namespace = "…"; compileSdk = 36; defaultConfig { minSdk = 29 } }`.
|
||||
(Framework modules target `compileSdk = 36`; `targetSdk` stays `35` per plan §2.)
|
||||
- **`:app` UI-stack version matrix** (A13, proven `:app:assembleDebug` green): AGP 9.2.1 ·
|
||||
Kotlin 2.3.21 · Compose-compiler plugin `org.jetbrains.kotlin.plugin.compose` = 2.3.21 ·
|
||||
Compose BOM `2025.11.01` (→ material3 1.4.0, ui/foundation 1.9.5, material3.adaptive 1.2.0,
|
||||
material3-adaptive-navigation-suite 1.4.0) · Hilt (dagger) 2.60.1 via KSP `2.3.9` ·
|
||||
androidx core-ktx 1.17.0 / activity-compose 1.12.4 / lifecycle 2.10.0. Apply plugins:
|
||||
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
|
||||
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
|
||||
|
||||
To add more SDK pieces later (e.g. an emulator image for instrumented tests):
|
||||
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
||||
39
android/api-client/build.gradle.kts
Normal file
39
android/api-client/build.gradle.kts
Normal file
@@ -0,0 +1,39 @@
|
||||
// :api-client — pure REST client logic (12 routes, tolerant decode, Origin-iff-
|
||||
// guarded, strict query encoding, prefs unknown-key preservation, pairing probe /
|
||||
// PairingError / HostClassifier tiers). Consumes HttpTransport by interface only.
|
||||
// Depends only on :wire-protocol.
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kover)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":wire-protocol"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(project(":test-support"))
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// A36 acceptance gate: >=80% line coverage on this pure module (plan §7).
|
||||
kover {
|
||||
reports {
|
||||
verify {
|
||||
rule {
|
||||
minBound(80)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
/**
|
||||
* The two verdicts `POST /hook/decision` accepts — anything else is a 400 server-side. The [wire]
|
||||
* value is what goes into the request body (`{ sessionId, decision, token }`).
|
||||
*/
|
||||
public enum class HookDecision(public val wire: String) {
|
||||
ALLOW("allow"),
|
||||
DENY("deny"),
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import wang.yaojia.webterm.wire.StatusTelemetry
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* One running (or just-exited) server session from `GET /live-sessions` (read-only discovery — NO
|
||||
* Origin header). Mirrors `src/types.ts` `LiveSessionInfo` and iOS `APIClient.LiveSessionInfo`
|
||||
* field-for-field.
|
||||
*
|
||||
* Tolerant decode at the untrusted boundary (plan §4):
|
||||
* - identity/geometry ([id]/[createdAt]/[clientCount]/[exited]/[cols]/[rows]) are REQUIRED — an
|
||||
* entry missing them is dropped by `LossyDecode.listOrNull`;
|
||||
* - an unrecognized `status` string maps to [ClaudeStatus.UNKNOWN] (a future server status must
|
||||
* not make a running session invisible);
|
||||
* - absent `cwd`/`telemetry`/`lastOutputAt` degrade to `null`.
|
||||
*
|
||||
* NOTE: ms timestamps ([createdAt]/[lastOutputAt]) are `Long` — epoch-ms overflows a 32-bit `Int`
|
||||
* (Swift's `Int` is 64-bit, so iOS used `Int`).
|
||||
*/
|
||||
@Serializable
|
||||
public data class LiveSessionInfo(
|
||||
@Serializable(with = UuidSerializer::class) val id: UUID,
|
||||
/** Server `Date.now()` at spawn (ms since epoch). */
|
||||
val createdAt: Long,
|
||||
/** Devices currently attached (JOIN/mirror semantics). */
|
||||
val clientCount: Int,
|
||||
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
|
||||
val exited: Boolean,
|
||||
val cwd: String? = null,
|
||||
/** Tab title / derived label (e.g. 'claude', 'shell'), OSC-title-derived server-side (`src/types.ts`
|
||||
* `title?`). HOST/ATTACKER-influenced — run through `TitleSanitizer` before any UI/list use (§8).
|
||||
* Additive optional field; absent → `null`. */
|
||||
val title: String? = null,
|
||||
/** Current PTY size (latest-writer-wins on the server). */
|
||||
val cols: Int,
|
||||
val rows: Int,
|
||||
/** Latest statusLine telemetry, if any (B2). */
|
||||
val telemetry: StatusTelemetry? = null,
|
||||
/** 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". */
|
||||
val lastOutputAt: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
|
||||
/**
|
||||
* Tolerant decode config for the UNTRUSTED server boundary (plan §4 / A8). The server is an
|
||||
* untrusted input source here: unknown keys are ignored, malformed list elements are dropped one
|
||||
* by one, and nothing crashes on bad input. The Android analogue of iOS's per-field `try?`
|
||||
* tolerance in `APIClient/Models.swift`.
|
||||
*
|
||||
* ENCODE is deterministic (`ignoreUnknownKeys`/`isLenient` only affect parsing) so the same
|
||||
* instance also encodes request bodies (`/hook/decision`, `/push/fcm-token`, `PUT /prefs`).
|
||||
*/
|
||||
internal val ModelJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-element / per-body tolerant decoders (mirror iOS `LiveSessionInfo.decodeList` /
|
||||
* `LossyBox` / `LossyList`).
|
||||
*
|
||||
* - [listOrNull]: a non-array top level yields `null` (the caller raises
|
||||
* `ApiClientError.InvalidResponseBody` — the pairing "port speaks HTTP but isn't web-terminal"
|
||||
* signal); malformed elements are dropped, good ones kept.
|
||||
* - [listOrEmpty]: a non-array top level yields `[]` (matches iOS `TimelineEvent.decodeList`, which
|
||||
* the server itself returns when timeline capture is disabled).
|
||||
* - [objectOrNull]: a single object that fails to decode yields `null` (caller raises
|
||||
* `InvalidResponseBody`).
|
||||
*/
|
||||
internal object LossyDecode {
|
||||
fun <T> listOrNull(bytes: ByteArray, element: KSerializer<T>): List<T>? {
|
||||
val array = parseArray(bytes) ?: return null
|
||||
return array.mapNotNull { el -> runCatching { ModelJson.decodeFromJsonElement(element, el) }.getOrNull() }
|
||||
}
|
||||
|
||||
fun <T> listOrEmpty(bytes: ByteArray, element: KSerializer<T>): List<T> =
|
||||
listOrNull(bytes, element) ?: emptyList()
|
||||
|
||||
fun <T> objectOrNull(bytes: ByteArray, deserializer: KSerializer<T>): T? =
|
||||
runCatching { ModelJson.decodeFromString(deserializer, bytes.decodeToString()) }.getOrNull()
|
||||
|
||||
private fun parseArray(bytes: ByteArray): JsonArray? =
|
||||
runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) as? JsonArray }.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* One running session belonging to a project (`src/types.ts` `ProjectSessionRef`). The server
|
||||
* mirrors [LiveSessionInfo] fields into this ref. Tolerant: unknown status → [ClaudeStatus.UNKNOWN],
|
||||
* absent title → `null`; missing id/clientCount/createdAt/exited drops the ref (nested lossy list).
|
||||
*/
|
||||
@Serializable
|
||||
public data class ProjectSessionRef(
|
||||
@Serializable(with = UuidSerializer::class) val id: UUID,
|
||||
val title: String? = null,
|
||||
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
|
||||
val clientCount: Int,
|
||||
val createdAt: Long,
|
||||
val exited: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* A discovered project (git repo or recently-used cwd) — `GET /projects` (`src/types.ts`
|
||||
* `ProjectInfo`). There is NO `namespace` field on the wire; namespace grouping is a client concept
|
||||
* surfaced only via `UiPrefs.collapsed` group-keys.
|
||||
*/
|
||||
@Serializable
|
||||
public data class ProjectInfo(
|
||||
val name: String,
|
||||
val path: String,
|
||||
val isGit: Boolean,
|
||||
val branch: String? = null,
|
||||
/** Uncommitted changes; only present when the server runs the dirty check. */
|
||||
val dirty: Boolean? = null,
|
||||
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
|
||||
val lastActiveMs: Long? = null,
|
||||
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||
val sessions: List<ProjectSessionRef> = emptyList(),
|
||||
)
|
||||
|
||||
/** One entry from `git worktree list --porcelain` (`src/types.ts` `WorktreeInfo`). */
|
||||
@Serializable
|
||||
public data class WorktreeInfo(
|
||||
val path: String,
|
||||
/** Branch name; `null` on detached HEAD. */
|
||||
val branch: String? = null,
|
||||
val head: String? = null,
|
||||
val isMain: Boolean = false,
|
||||
val isCurrent: Boolean = false,
|
||||
val locked: Boolean? = null,
|
||||
val prunable: Boolean? = null,
|
||||
)
|
||||
|
||||
/** Detailed view of one project — `GET /projects/detail?path=` (`src/types.ts` `ProjectDetail`). */
|
||||
@Serializable
|
||||
public data class ProjectDetail(
|
||||
val name: String,
|
||||
val path: String,
|
||||
val isGit: Boolean,
|
||||
val branch: String? = null,
|
||||
val dirty: Boolean? = null,
|
||||
@Serializable(with = WorktreeInfoListSerializer::class)
|
||||
val worktrees: List<WorktreeInfo> = emptyList(),
|
||||
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||
val sessions: List<ProjectSessionRef> = emptyList(),
|
||||
val hasClaudeMd: Boolean = false,
|
||||
/** CLAUDE.md content (server-truncated for display) when present. */
|
||||
val claudeMd: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
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
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Decode a server session id as [UUID]. Server ids are lowercase `crypto.randomUUID()` strings;
|
||||
* a non-UUID string throws, so `LossyDecode` drops that list element (matches iOS decoding
|
||||
* `UUID.self` — a malformed id must not surface). Serializes back lowercase (`UUID.toString()`).
|
||||
*/
|
||||
internal object UuidSerializer : KSerializer<UUID> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
|
||||
override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode [ClaudeStatus] by its `wire` value; an unknown/future value maps to
|
||||
* [ClaudeStatus.UNKNOWN] rather than dropping the entry — a new server status must never make a
|
||||
* running session invisible (iOS `rawStatus.flatMap(...) ?? .unknown`).
|
||||
*/
|
||||
internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ClaudeStatus", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder): ClaudeStatus =
|
||||
ClaudeStatus.fromWire(decoder.decodeString()) ?: ClaudeStatus.UNKNOWN
|
||||
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`,
|
||||
* `ProjectDetail.worktrees`). A bad nested element must not fail the whole parent object.
|
||||
*/
|
||||
internal class LossyListSerializer<T>(private val element: KSerializer<T>) : KSerializer<List<T>> {
|
||||
private val delegate = ListSerializer(element)
|
||||
override val descriptor: SerialDescriptor = delegate.descriptor
|
||||
override fun serialize(encoder: Encoder, value: List<T>) = delegate.serialize(encoder, value)
|
||||
override fun deserialize(decoder: Decoder): List<T> {
|
||||
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
|
||||
val array = json.decodeJsonElement() as? JsonArray ?: return emptyList()
|
||||
return array.mapNotNull { runCatching { json.json.decodeFromJsonElement(element, it) }.getOrNull() }
|
||||
}
|
||||
}
|
||||
|
||||
internal object ProjectSessionRefListSerializer :
|
||||
KSerializer<List<ProjectSessionRef>> by LossyListSerializer(ProjectSessionRef.serializer())
|
||||
|
||||
internal object WorktreeInfoListSerializer :
|
||||
KSerializer<List<WorktreeInfo>> by LossyListSerializer(WorktreeInfo.serializer())
|
||||
@@ -0,0 +1,17 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* `GET /live-sessions/:id/preview` response: the tail of the session's ring buffer for a
|
||||
* read-only thumbnail (no attach, no client registered). [data] is opaque ANSI/UTF-8 — feed it to
|
||||
* a terminal, never parse it. All fields required (a malformed body → `InvalidResponseBody`).
|
||||
*/
|
||||
@Serializable
|
||||
public data class SessionPreview(
|
||||
@Serializable(with = UuidSerializer::class) val id: UUID,
|
||||
val cols: Int,
|
||||
val rows: Int,
|
||||
val data: String,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /config/ui` response (`{ allowAutoMode }`). Reserved for a future permission-mode switcher
|
||||
* (filters the high-risk raw `auto` mode); the plan-gate three-way UI does not consume it.
|
||||
*/
|
||||
@Serializable
|
||||
public data class UiConfig(
|
||||
val allowAutoMode: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
|
||||
/**
|
||||
* The cross-device Projects-UI prefs blob (`GET /prefs` RO · `PUT /prefs` G) — an
|
||||
* OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web client
|
||||
* (`favourites: string[]`, `collapsed: { group-key: true }`); ALL OTHER top-level keys are
|
||||
* preserved verbatim across decode → mutate → encode, so an Android `PUT` can never clobber prefs
|
||||
* written by the web/iOS client or a future server (`PUT` replaces the WHOLE blob server-side, so
|
||||
* key preservation is correctness, not politeness).
|
||||
*
|
||||
* Immutable snapshot: [withFavourites]/[withCollapsed] return NEW copies that replace exactly one
|
||||
* known key and carry every other key through untouched. Numbers round-trip verbatim because a
|
||||
* parsed [JsonPrimitive] keeps its source token (`42` never re-encodes as `42.0`).
|
||||
*/
|
||||
public class UiPrefs private constructor(private val storage: JsonObject) {
|
||||
|
||||
/**
|
||||
* Favourited project paths (★): non-empty JSON strings, de-duplicated, original order kept.
|
||||
* Wrong-typed entries are dropped, not fatal (mirrors `public/prefs.ts sanitizePrefs`).
|
||||
*/
|
||||
public val favourites: List<String>
|
||||
get() {
|
||||
val array = storage[KEY_FAVOURITES] as? JsonArray ?: return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
for (element in array) {
|
||||
val primitive = element as? JsonPrimitive ?: continue
|
||||
if (!primitive.isString) continue
|
||||
val path = primitive.content
|
||||
if (path.isNotEmpty()) out.add(path)
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespace group-key → collapsed. Only literal `true` values count (expanded is the default;
|
||||
* both web and server sanitizers agree). A JSON string `"true"` does NOT count.
|
||||
*/
|
||||
public val collapsed: Map<String, Boolean>
|
||||
get() {
|
||||
val obj = storage[KEY_COLLAPSED] as? JsonObject ?: return emptyMap()
|
||||
val out = LinkedHashMap<String, Boolean>()
|
||||
for ((key, value) in obj) {
|
||||
if (key.isEmpty()) continue
|
||||
if (value is JsonPrimitive && !value.isString && value.booleanOrNull == true) {
|
||||
out[key] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** New snapshot with `favourites` replaced; every other key untouched (position preserved). */
|
||||
public fun withFavourites(favourites: List<String>): UiPrefs {
|
||||
val next = LinkedHashMap<String, JsonElement>(storage)
|
||||
next[KEY_FAVOURITES] = JsonArray(favourites.map { JsonPrimitive(it) })
|
||||
return UiPrefs(JsonObject(next))
|
||||
}
|
||||
|
||||
/** New snapshot with `collapsed` replaced; every other key untouched (position preserved). */
|
||||
public fun withCollapsed(collapsed: Map<String, Boolean>): UiPrefs {
|
||||
val next = LinkedHashMap<String, JsonElement>(storage)
|
||||
next[KEY_COLLAPSED] = JsonObject(collapsed.mapValues { JsonPrimitive(it.value) })
|
||||
return UiPrefs(JsonObject(next))
|
||||
}
|
||||
|
||||
/** Encode the FULL blob (known + unknown keys) for `PUT /prefs`. */
|
||||
public fun encodeBody(): ByteArray =
|
||||
ModelJson.encodeToString(JsonObject.serializer(), storage).encodeToByteArray()
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is UiPrefs && other.storage == storage
|
||||
override fun hashCode(): Int = storage.hashCode()
|
||||
override fun toString(): String = "UiPrefs(storage=$storage)"
|
||||
|
||||
public companion object {
|
||||
private const val KEY_FAVOURITES = "favourites"
|
||||
private const val KEY_COLLAPSED = "collapsed"
|
||||
|
||||
/** Fresh prefs (e.g. first write from a device that never fetched). */
|
||||
public fun create(
|
||||
favourites: List<String> = emptyList(),
|
||||
collapsed: Map<String, Boolean> = emptyMap(),
|
||||
): UiPrefs = UiPrefs(
|
||||
JsonObject(
|
||||
mapOf(
|
||||
KEY_FAVOURITES to JsonArray(favourites.map { JsonPrimitive(it) }),
|
||||
KEY_COLLAPSED to JsonObject(collapsed.mapValues { JsonPrimitive(it.value) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Decode a `/prefs` body. `null` = top level is not a JSON object — callers surface
|
||||
* `InvalidResponseBody` LOUDLY instead of degrading to empty prefs (an empty-based `PUT`
|
||||
* would wipe the server blob).
|
||||
*/
|
||||
public fun decode(bytes: ByteArray): UiPrefs? {
|
||||
val element = runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) }.getOrNull()
|
||||
return (element as? JsonObject)?.let { UiPrefs(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import wang.yaojia.webterm.wire.HostClassifier
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.PortUnreachableException
|
||||
import java.net.SocketException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.net.UnknownServiceException
|
||||
import java.security.cert.CertPathBuilderException
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import java.security.cert.CertificateException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Error taxonomy for the pairing probe (Android port of iOS `APIClient.PairingError`). Each case
|
||||
* maps one probe failure mode to actionable UI copy (`PairingViewModel`, A19, renders these).
|
||||
*
|
||||
* DEVIATION from iOS (plan R12): the iOS `localNetworkDenied` case is DROPPED — Android has no
|
||||
* iOS-style Local-Network permission prompt (a plain WS to a LAN IP needs no runtime permission),
|
||||
* so the ENETDOWN→localNetworkDenied diagnosis is dead weight here. iOS `atsBlocked` is kept as
|
||||
* [CleartextBlocked]: the genuine Android analogue is a `network_security_config` cleartext block
|
||||
* surfaced as [java.net.UnknownServiceException] (plan §6.9/§8 cleartext posture).
|
||||
*/
|
||||
public sealed interface PairingError {
|
||||
/** Nothing answered — connection refused / no route / DNS failure (`ConnectException`, `UnknownHostException`, …). */
|
||||
public data class HostUnreachable(val underlying: String) : PairingError
|
||||
|
||||
/**
|
||||
* The port speaks HTTP but `GET /live-sessions` did not return the web-terminal shape (a JSON
|
||||
* array) — "端口对吗?". Also used when probe ② connects but the socket never speaks our protocol.
|
||||
*/
|
||||
public data object HttpOkButNotWebTerminal : PairingError
|
||||
|
||||
/**
|
||||
* The WS upgrade (or the guarded kill round-trip) was rejected — the host's Origin whitelist
|
||||
* does not contain our dialed origin. [hint] carries the exact `ALLOWED_ORIGINS=<origin>` line,
|
||||
* always derived from [HostEndpoint.originHeader] (never hand-assembled; default ports omitted).
|
||||
*/
|
||||
public data class OriginRejected(val hint: String) : PairingError
|
||||
|
||||
/**
|
||||
* Cleartext (`ws://`/`http://`) to a host outside the app's `network_security_config` allowlist
|
||||
* was blocked by the platform ([java.net.UnknownServiceException]). Android analogue of iOS
|
||||
* `atsBlocked`. [host] is the dialed host for the actionable copy.
|
||||
*/
|
||||
public data class CleartextBlocked(val host: String) : PairingError
|
||||
|
||||
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
|
||||
public data object TlsFailure : PairingError
|
||||
|
||||
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
|
||||
public data object Timeout : PairingError
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* Actionable copy for [OriginRejected] — always derived from the SINGLE origin source
|
||||
* ([HostEndpoint.originHeader]), never hand-assembled. Ported verbatim from iOS.
|
||||
*/
|
||||
public fun originRejectedHint(endpoint: HostEndpoint): String =
|
||||
"服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=${endpoint.originHeader}" +
|
||||
"(与 App 连接的 URL 完全一致)后重启 web-terminal,再重试配对。"
|
||||
|
||||
/**
|
||||
* Classify a transport-level [Throwable] thrown by [wang.yaojia.webterm.wire.HttpTransport]
|
||||
* or [wang.yaojia.webterm.wire.TermTransport] into the probe taxonomy. Walks the bounded
|
||||
* `cause` chain (OkHttp wraps causes; never trust an error graph not to cycle) so wrapped
|
||||
* roots (e.g. an `IOException` wrapping a `ConnectException`) are seen.
|
||||
*
|
||||
* @param unrecognizedFallback used by probe step ② — after step ① proved the host reachable
|
||||
* AND web-terminal-shaped, an upgrade failure with no recognizable network cause is, by
|
||||
* elimination, the server's Origin 401 (its only upgrade-reject path).
|
||||
*/
|
||||
public fun classify(
|
||||
error: Throwable,
|
||||
endpoint: HostEndpoint,
|
||||
unrecognizedFallback: PairingError? = null,
|
||||
): PairingError {
|
||||
val chain = causeChain(error)
|
||||
if (chain.any { it is UnknownServiceException }) {
|
||||
return CleartextBlocked(host = HostClassifier.hostOf(endpoint))
|
||||
}
|
||||
if (chain.any { isTimeout(it) }) return Timeout
|
||||
if (chain.any { isTls(it) }) return TlsFailure
|
||||
if (chain.any { isConnectivity(it) }) {
|
||||
return HostUnreachable(underlying = describe(error))
|
||||
}
|
||||
return unrecognizedFallback ?: HostUnreachable(underlying = describe(error))
|
||||
}
|
||||
|
||||
private const val MAX_CAUSE_DEPTH = 8
|
||||
|
||||
/** Bounded walk of [Throwable.cause] with an identity cycle-guard (defensive). */
|
||||
private fun causeChain(error: Throwable): List<Throwable> {
|
||||
val chain = mutableListOf<Throwable>()
|
||||
var current: Throwable? = error
|
||||
while (current != null && chain.size < MAX_CAUSE_DEPTH) {
|
||||
if (chain.any { it === current }) break
|
||||
chain.add(current)
|
||||
current = current.cause
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
// `SocketTimeoutException` extends `InterruptedIOException`; OkHttp's overall call-timeout
|
||||
// also surfaces as a bare `InterruptedIOException` — both mean "timed out".
|
||||
private fun isTimeout(e: Throwable): Boolean = e is InterruptedIOException
|
||||
|
||||
private fun isTls(e: Throwable): Boolean =
|
||||
e is SSLException ||
|
||||
e is CertificateException ||
|
||||
e is CertPathValidatorException ||
|
||||
e is CertPathBuilderException
|
||||
|
||||
// `ConnectException` / `NoRouteToHostException` / `PortUnreachableException` all extend
|
||||
// `SocketException`; listed explicitly for readability. `UnknownHostException` is a DNS
|
||||
// failure (extends `IOException`, not `SocketException`).
|
||||
private fun isConnectivity(e: Throwable): Boolean =
|
||||
e is ConnectException ||
|
||||
e is NoRouteToHostException ||
|
||||
e is PortUnreachableException ||
|
||||
e is SocketException ||
|
||||
e is UnknownHostException
|
||||
|
||||
private fun describe(error: Throwable): String =
|
||||
error.message ?: error::class.simpleName ?: "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.MessageCodec
|
||||
import wang.yaojia.webterm.wire.ServerMessage
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import wang.yaojia.webterm.wire.TransportConnection
|
||||
import wang.yaojia.webterm.wire.Tunables
|
||||
import java.net.URI
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Result of a pairing probe — the validated [HostEndpoint] on success, a [PairingError] on failure.
|
||||
* Android analogue of iOS `Result<HostEndpoint, PairingError>`. The pairing UI (A19) constructs the
|
||||
* persisted `Host{id,name}` from the returned endpoint (id/name are not the probe's to know).
|
||||
*/
|
||||
public sealed interface PairingProbeResult {
|
||||
public data class Success(val endpoint: HostEndpoint) : PairingProbeResult
|
||||
|
||||
public data class Failure(val error: PairingError) : PairingProbeResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Public probe entry (Android port of iOS `runPairingProbe`). Two-step probe:
|
||||
* 1. `GET /live-sessions` (NO Origin) — reachability + web-terminal shape.
|
||||
* 2. WS `attach(null)` → adopt the server-issued `attached` id → close → **immediately
|
||||
* `DELETE /live-sessions/:id` WITH Origin** (verifies the Origin guard on both the upgrade and
|
||||
* the guarded-HTTP side, and never leaks the probe's orphan session).
|
||||
*
|
||||
* **Confirm-before-network contract:** callers MUST run this only after the user confirmed a
|
||||
* scanned/typed host (A19). Step ① already talks to the network and step ② spawns a PTY on the
|
||||
* target machine — nothing here is speculative, so the UI gate is the caller's responsibility.
|
||||
*
|
||||
* The wall-clock deadline is [Tunables.PAIRING_PROBE_TIMEOUT]; tests drive [runPairingProbeCore]
|
||||
* with an explicit (or `null`) timeout for deterministic virtual-time coverage.
|
||||
*/
|
||||
public suspend fun runPairingProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
): PairingProbeResult =
|
||||
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
|
||||
|
||||
/**
|
||||
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
|
||||
* still apply — fast, race-free tests); otherwise the whole probe is cancelled past the deadline and
|
||||
* resolves [PairingError.Timeout]. Cancellation is the coroutine analogue of iOS's `group.cancelAll`.
|
||||
*/
|
||||
internal suspend fun runPairingProbeCore(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
timeout: Duration?,
|
||||
): PairingProbeResult {
|
||||
if (timeout == null) return performProbe(endpoint, http, ws)
|
||||
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
|
||||
?: PairingProbeResult.Failure(PairingError.Timeout)
|
||||
}
|
||||
|
||||
// ── Probe body ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun performProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
): PairingProbeResult {
|
||||
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
|
||||
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
|
||||
probeReachability(endpoint, http)?.let { return it }
|
||||
|
||||
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
|
||||
// unrecognizable connect failure is classified as originRejected.
|
||||
val connection: TransportConnection = try {
|
||||
ws.connect(endpoint)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
return PairingProbeResult.Failure(
|
||||
PairingError.classify(
|
||||
error,
|
||||
endpoint,
|
||||
unrecognizedFallback = PairingError.OriginRejected(
|
||||
PairingError.originRejectedHint(endpoint),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Once connected, the connection MUST be closed on every exit path — including the timeout/cancel
|
||||
// path, where withTimeoutOrNull cancels us mid-`firstOrNull`. Without this finally, a cancel skips
|
||||
// close() and leaks the WS + its orphan PTY session on the host. NonCancellable so the close still
|
||||
// runs while we are already being cancelled.
|
||||
return try {
|
||||
when (val adoption = adoptAttachedSession(connection)) {
|
||||
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
|
||||
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) { runCatching { connection.close() } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe step ①. Returns a [PairingProbeResult.Failure] to short-circuit, or `null` to proceed.
|
||||
* Reachable + web-terminal-shaped = HTTP 200 with a JSON-array body (an HTML admin page, a 404, or
|
||||
* a non-array body are all "not web-terminal").
|
||||
*/
|
||||
private suspend fun probeReachability(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
): PairingProbeResult.Failure? {
|
||||
val response = try {
|
||||
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
||||
}
|
||||
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
|
||||
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Send `attach(null)` (explicit JSON `"sessionId":null` via [MessageCodec]) and wait for the
|
||||
* server-issued `attached` id, skipping any other or undecodable frame (untrusted server; only
|
||||
* `attached` matters here). A stream that ends or errors before speaking our protocol is NOT an
|
||||
* Origin problem — the upgrade already succeeded — so it maps to [PairingError.HttpOkButNotWebTerminal].
|
||||
*/
|
||||
private suspend fun adoptAttachedSession(connection: TransportConnection): Adoption =
|
||||
try {
|
||||
connection.send(MessageCodec.encode(ClientMessage.Attach(sessionId = null)))
|
||||
val sessionId = connection.frames
|
||||
.mapNotNull { frame -> (MessageCodec.decodeServer(frame) as? ServerMessage.Attached)?.sessionId }
|
||||
.firstOrNull()
|
||||
if (sessionId != null) Adoption.Success(sessionId) else Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||
}
|
||||
|
||||
/**
|
||||
* The guarded kill round-trip is part of pairing verification itself (`DELETE` exercises the
|
||||
* HTTP-side Origin guard the later `hookDecision` will need) AND guarantees the probe leaves no
|
||||
* orphan session. 204 = killed, 404 = already gone (both success), 403 = Origin guard rejected us.
|
||||
*/
|
||||
private suspend fun killProbeSession(
|
||||
sessionId: String,
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
): PairingProbeResult {
|
||||
val response = try {
|
||||
http.send(
|
||||
HttpRequest(
|
||||
method = HttpMethod.DELETE,
|
||||
url = killUrl(endpoint, sessionId),
|
||||
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
|
||||
),
|
||||
)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
||||
}
|
||||
return when (response.status) {
|
||||
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
|
||||
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
|
||||
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
|
||||
)
|
||||
else -> PairingProbeResult.Failure(
|
||||
PairingError.HostUnreachable(underlying = "HTTP ${response.status}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface Adoption {
|
||||
data class Success(val sessionId: String) : Adoption
|
||||
|
||||
data class Failure(val error: PairingError) : Adoption
|
||||
}
|
||||
|
||||
// ── URL derivation + shape check ────────────────────────────────────────────────────────────────
|
||||
|
||||
private val PROBE_JSON = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
|
||||
/** Web-terminal shape = the body parses as a JSON array (the server replies `[]` when idle). */
|
||||
private fun isJsonArray(body: ByteArray): Boolean =
|
||||
runCatching { PROBE_JSON.parseToJsonElement(body.decodeToString()) is JsonArray }.getOrDefault(false)
|
||||
|
||||
private fun liveSessionsUrl(endpoint: HostEndpoint): String = httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH
|
||||
|
||||
private fun killUrl(endpoint: HostEndpoint, sessionId: String): String =
|
||||
httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH + "/" + sessionId
|
||||
|
||||
/**
|
||||
* `scheme://host[:port]` from the endpoint's dialed URL — path/query/fragment/credentials dropped
|
||||
* (same derivation philosophy as [HostEndpoint.wsUrl]). The dialed port is kept verbatim; the host
|
||||
* is left as [java.net.URI] returns it (IPv6 literals are already bracketed).
|
||||
*/
|
||||
private fun httpBaseUrl(endpoint: HostEndpoint): String {
|
||||
val uri = URI(endpoint.baseUrl)
|
||||
val scheme = (uri.scheme ?: "http").lowercase()
|
||||
val host = uri.host ?: ""
|
||||
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||
return "$scheme://$host$portPart"
|
||||
}
|
||||
|
||||
private const val LIVE_SESSIONS_PATH = "/live-sessions"
|
||||
private const val ORIGIN_HEADER = "Origin"
|
||||
private const val HTTP_OK = 200
|
||||
private const val HTTP_NO_CONTENT = 204
|
||||
private const val HTTP_FORBIDDEN = 403
|
||||
private const val HTTP_NOT_FOUND = 404
|
||||
@@ -0,0 +1,173 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||
import wang.yaojia.webterm.api.models.LossyDecode
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||
import wang.yaojia.webterm.api.models.SessionPreview
|
||||
import wang.yaojia.webterm.api.models.UiConfig
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TimelineEvent
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Typed client for the server's HTTP surface (12 frozen routes). Pure — consumes [HttpTransport]
|
||||
* by interface (no OkHttp/Android); `:transport-okhttp` (A7) provides the real impl, the
|
||||
* `:test-support` fake queues canned responses.
|
||||
*
|
||||
* **Origin 铁律 (CSWSH, plan §4.3):** only the guarded (state-changing) routes stamp
|
||||
* `Origin: endpoint.originHeader`; the read-only GETs never do. Stamping lives in ONE place
|
||||
* ([ApiRoute.toHttpRequest]) and the value is single-point-derived by [HostEndpoint].
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
public class ApiClient(
|
||||
public val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
) {
|
||||
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
||||
|
||||
/** `GET /live-sessions` — the discovery list every device polls. */
|
||||
public suspend fun liveSessions(): List<LiveSessionInfo> {
|
||||
val response = perform(Endpoints.liveSessions())
|
||||
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
|
||||
return LossyDecode.listOrNull(response.body, LiveSessionInfo.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
/** `GET /live-sessions/:id/preview` — ring-buffer tail for a read-only thumbnail (no attach). */
|
||||
public suspend fun preview(id: UUID): SessionPreview {
|
||||
val response = perform(Endpoints.preview(id))
|
||||
requireOk(response)
|
||||
return LossyDecode.objectOrNull(response.body, SessionPreview.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
/** `GET /live-sessions/:id/events` — the activity timeline. A non-array body (timeline capture
|
||||
* disabled) → `[]`; unknown-class entries survive shape-decode and are filtered downstream. */
|
||||
public suspend fun events(id: UUID): List<TimelineEvent> {
|
||||
val response = perform(Endpoints.events(id))
|
||||
requireOk(response)
|
||||
return LossyDecode.listOrEmpty(response.body, TimelineEvent.serializer())
|
||||
}
|
||||
|
||||
/** `GET /config/ui` — `{ allowAutoMode }`. */
|
||||
public suspend fun uiConfig(): UiConfig {
|
||||
val response = perform(Endpoints.uiConfig())
|
||||
requireOk(response)
|
||||
return LossyDecode.objectOrNull(response.body, UiConfig.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
/** `GET /projects` — discovered projects with their running sessions merged in. */
|
||||
public suspend fun projects(): List<ProjectInfo> {
|
||||
val response = perform(Endpoints.projects())
|
||||
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
|
||||
return LossyDecode.listOrNull(response.body, ProjectInfo.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
/** `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one project. An empty path is
|
||||
* rejected client-side (mirror of the server's 400) before any network I/O. */
|
||||
public suspend fun projectDetail(path: String): ProjectDetail {
|
||||
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
||||
val response = perform(Endpoints.projectDetail(path))
|
||||
return when (response.status) {
|
||||
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, ProjectDetail.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
||||
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
||||
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.ProjectDetailUnavailable
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** `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). */
|
||||
public suspend fun prefs(): UiPrefs {
|
||||
val response = perform(Endpoints.getPrefs())
|
||||
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
|
||||
return UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
// ── G (state-changing — Origin required, byte-equal) ───────────────────────────────────
|
||||
|
||||
/** `DELETE /live-sessions/:id`. 204 = success; 404 = already gone (also success on the server,
|
||||
* but iOS surfaces it as `SessionNotFound` — matched here). */
|
||||
public suspend fun killSession(id: UUID) {
|
||||
val response = perform(Endpoints.killSession(id))
|
||||
when (response.status) {
|
||||
HttpStatus.NO_CONTENT -> Unit
|
||||
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
|
||||
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** `POST /hook/decision` — resolve a held remote approval with a single-use `token` (push
|
||||
* payload only; NEVER persist it). 403 → stale/mismatched token; 429 → rate-limited. */
|
||||
public suspend fun hookDecision(sessionId: UUID, decision: HookDecision, token: String) {
|
||||
val response = perform(Endpoints.hookDecision(sessionId, decision, token))
|
||||
when (response.status) {
|
||||
HttpStatus.NO_CONTENT -> Unit
|
||||
HttpStatus.FORBIDDEN -> throw ApiClientError.DecisionRejected
|
||||
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** `PUT /prefs` — replace the whole blob. Returns the server's sanitized echo — treat IT as the
|
||||
* new source of truth, not the input. 403 = Origin guard. */
|
||||
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs {
|
||||
val response = perform(Endpoints.putPrefs(prefs))
|
||||
return when (response.status) {
|
||||
HttpStatus.OK -> UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
|
||||
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** `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. */
|
||||
public suspend fun registerFcmToken(token: String) {
|
||||
sendFcmToken(token, Endpoints::registerFcmToken)
|
||||
}
|
||||
|
||||
/** `DELETE /push/fcm-token` — unregister (idempotent → 204 even for an unknown token). */
|
||||
public suspend fun unregisterFcmToken(token: String) {
|
||||
sendFcmToken(token, Endpoints::unregisterFcmToken)
|
||||
}
|
||||
|
||||
private suspend fun sendFcmToken(token: String, build: (String) -> ApiRoute) {
|
||||
val normalized = FcmTokenRule.normalize(token) ?: throw ApiClientError.InvalidFcmToken
|
||||
val response = perform(build(normalized))
|
||||
when (response.status) {
|
||||
HttpStatus.NO_CONTENT -> Unit
|
||||
HttpStatus.BAD_REQUEST -> throw ApiClientError.InvalidFcmToken
|
||||
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
|
||||
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun perform(route: ApiRoute): HttpResponse {
|
||||
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
|
||||
return http.send(request)
|
||||
}
|
||||
|
||||
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
|
||||
private fun requireOk(response: HttpResponse) {
|
||||
when (response.status) {
|
||||
HttpStatus.OK -> Unit
|
||||
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
/**
|
||||
* Typed failures for [ApiClient] calls (explicit error handling, plan §4). Transport-level errors
|
||||
* thrown by `HttpTransport.send` propagate UNWRAPPED (the pairing classifier reads their shape).
|
||||
*
|
||||
* [userMessage] is UI-ready copy (matches the iOS `APIClientError.message` 话术). The no-arg cases
|
||||
* are singletons (`object`) so tests can assert by identity/equality; [UnexpectedStatus] carries the
|
||||
* offending code.
|
||||
*/
|
||||
public sealed class ApiClientError(public val userMessage: String) : Exception(userMessage) {
|
||||
/** The request could not be built from the endpoint (malformed base URL — should not happen for
|
||||
* a validated `HostEndpoint`; surfaced instead of crashing). */
|
||||
public data object InvalidRequest : ApiClientError("无法构造请求(主机地址异常)。")
|
||||
|
||||
/** A 2xx arrived but the body is not the endpoint's shape. For `/live-sessions` this is the
|
||||
* pairing probe's "the port speaks HTTP but is not web-terminal" signal. */
|
||||
public data object InvalidResponseBody : ApiClientError("服务器响应不是预期格式——端口对吗?")
|
||||
|
||||
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
|
||||
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
|
||||
|
||||
/** 403 from a G route's Origin guard (CSWSH defence). */
|
||||
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
||||
|
||||
/** 403 from `POST /hook/decision`: the capability token is missing / mismatched / STALE —
|
||||
* tokens are single-use and expiring by design. */
|
||||
public data object DecisionRejected : ApiClientError("审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。")
|
||||
|
||||
/** 429: the endpoint is rate-limited per IP by fixed server policy. */
|
||||
public data object RateLimited : ApiClientError("操作过于频繁,服务器已限流,请稍后再试。")
|
||||
|
||||
/** The FCM registration token failed the client-side charset/length check, or the server echoed
|
||||
* a 400. */
|
||||
public data object InvalidFcmToken : ApiClientError("推送注册令牌格式异常,请重启 App 重新注册推送。")
|
||||
|
||||
/** 400 from `GET /projects/detail` — the `path` query parameter is missing/empty. Also raised
|
||||
* client-side for an empty path, before any network I/O. */
|
||||
public data object ProjectPathInvalid : ApiClientError("项目路径为空或不合法。")
|
||||
|
||||
/** 404 from `GET /projects/detail` — no project at that path (moved/deleted/not a directory). */
|
||||
public data object ProjectNotFound : ApiClientError("项目不存在(路径可能已移动或删除)。")
|
||||
|
||||
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
|
||||
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
|
||||
|
||||
/** Any other non-success status code. */
|
||||
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status。")
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import java.net.URI
|
||||
|
||||
/** Named HTTP status codes used by the client (no magic numbers, plan §4). */
|
||||
internal object HttpStatus {
|
||||
const val OK = 200
|
||||
const val NO_CONTENT = 204
|
||||
const val BAD_REQUEST = 400
|
||||
const val FORBIDDEN = 403
|
||||
const val NOT_FOUND = 404
|
||||
const val TOO_MANY_REQUESTS = 429
|
||||
const val INTERNAL_SERVER_ERROR = 500
|
||||
}
|
||||
|
||||
/** Header / content-type names (no magic strings inline). */
|
||||
internal object HeaderName {
|
||||
const val ORIGIN = "Origin"
|
||||
const val CONTENT_TYPE = "Content-Type"
|
||||
}
|
||||
|
||||
internal object ContentType {
|
||||
const val JSON = "application/json"
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a route mutates server state — THE security split (plan §4.3): `Origin` is stamped
|
||||
* **iff** [GUARDED]. If the server ever reclassifies a RO route as guarded, tests go red instead of
|
||||
* passing by coincidence.
|
||||
*/
|
||||
internal enum class OriginPolicy {
|
||||
/** Read-only GET — MUST NOT carry Origin. */
|
||||
READ_ONLY,
|
||||
|
||||
/** State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal; server 403s a
|
||||
* missing/foreign Origin (CSWSH defence). */
|
||||
GUARDED,
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* sites). Origin stamping happens HERE and only here (single point).
|
||||
*/
|
||||
internal class ApiRoute(
|
||||
val method: HttpMethod,
|
||||
val path: String,
|
||||
val originPolicy: OriginPolicy,
|
||||
val body: ByteArray? = null,
|
||||
val percentEncodedQuery: String? = null,
|
||||
) {
|
||||
/**
|
||||
* 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
|
||||
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
|
||||
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
|
||||
*/
|
||||
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
|
||||
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
|
||||
val headers = LinkedHashMap<String, String>()
|
||||
if (originPolicy == OriginPolicy.GUARDED) {
|
||||
headers[HeaderName.ORIGIN] = endpoint.originHeader
|
||||
}
|
||||
if (body != null) {
|
||||
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
|
||||
}
|
||||
return HttpRequest(method = method, url = url, headers = headers, body = body)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Rebuild `<scheme>://<host>[:<port>]<path>[?<query>]` from the dialed base URL, keeping the
|
||||
* dialed port verbatim (like `wsUrl`, unlike the default-port-dropping Origin). The path and
|
||||
* pre-encoded query are appended verbatim; any path/query/fragment/credentials the base URL
|
||||
* carried are dropped.
|
||||
*/
|
||||
fun buildUrl(baseUrl: String, path: String, query: String?): String? {
|
||||
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
val host = uri.host ?: return null
|
||||
if (host.isEmpty()) return null
|
||||
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||
val queryPart = if (query != null) "?$query" else ""
|
||||
return "$scheme://$serializedHost$portPart$path$queryPart"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.api.models.ModelJson
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Builders for the frozen endpoints (12 routes, verified against `src/http/…` + iOS `Endpoints` /
|
||||
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
|
||||
* pair (this client uses FCM, not APNs/VAPID).
|
||||
*
|
||||
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
|
||||
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
|
||||
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
|
||||
* · `POST|DELETE /push/fcm-token`
|
||||
*
|
||||
* 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` +
|
||||
* `/push/fcm-token`), which is still PENDING — so FCM push is non-functional against the current
|
||||
* server until A33 lands. The client builders exist now so the token lifecycle is ready the moment
|
||||
* the server route ships; do not "fix" this as a mismatch.
|
||||
*/
|
||||
internal object Endpoints {
|
||||
// ── RO ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fun liveSessions(): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/live-sessions", OriginPolicy.READ_ONLY)
|
||||
|
||||
fun preview(id: UUID): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/preview", OriginPolicy.READ_ONLY)
|
||||
|
||||
fun events(id: UUID): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/events", OriginPolicy.READ_ONLY)
|
||||
|
||||
fun uiConfig(): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/config/ui", OriginPolicy.READ_ONLY)
|
||||
|
||||
fun projects(): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/projects", OriginPolicy.READ_ONLY)
|
||||
|
||||
/**
|
||||
* `GET /projects/detail?path=` — RO. The ONE place `path` gets percent-encoded, with a strict
|
||||
* RFC 3986 unreserved-only set (deliberately stricter than URL-query-allowed: a bare `+` decodes
|
||||
* to a SPACE in Express's qs parser, and `&`/`=` would split the parameter).
|
||||
*/
|
||||
fun projectDetail(path: String): ApiRoute =
|
||||
ApiRoute(
|
||||
HttpMethod.GET,
|
||||
"/projects/detail",
|
||||
OriginPolicy.READ_ONLY,
|
||||
percentEncodedQuery = "path=${percentEncode(path)}",
|
||||
)
|
||||
|
||||
fun getPrefs(): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
||||
|
||||
// ── G ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fun killSession(id: UUID): ApiRoute =
|
||||
ApiRoute(HttpMethod.DELETE, "/live-sessions/${pathId(id)}", OriginPolicy.GUARDED)
|
||||
|
||||
/** Body is exactly `{ sessionId, decision, token }`. `token` is single-use (push payload only) —
|
||||
* callers must never persist/log it. */
|
||||
fun hookDecision(sessionId: UUID, decision: HookDecision, token: String): ApiRoute {
|
||||
val body = ModelJson.encodeToString(
|
||||
HookDecisionBody.serializer(),
|
||||
HookDecisionBody(pathId(sessionId), decision.wire, token),
|
||||
).encodeToByteArray()
|
||||
return ApiRoute(HttpMethod.POST, "/hook/decision", OriginPolicy.GUARDED, body = body)
|
||||
}
|
||||
|
||||
/** `PUT /prefs` — G. Replaces the whole blob (server echoes a sanitized 200). */
|
||||
fun putPrefs(prefs: UiPrefs): ApiRoute =
|
||||
ApiRoute(HttpMethod.PUT, "/prefs", OriginPolicy.GUARDED, body = prefs.encodeBody())
|
||||
|
||||
fun registerFcmToken(normalized: String): ApiRoute =
|
||||
fcmTokenRoute(HttpMethod.POST, normalized)
|
||||
|
||||
fun unregisterFcmToken(normalized: String): ApiRoute =
|
||||
fcmTokenRoute(HttpMethod.DELETE, normalized)
|
||||
|
||||
private fun fcmTokenRoute(method: HttpMethod, normalized: String): ApiRoute {
|
||||
val body = ModelJson.encodeToString(
|
||||
FcmTokenBody.serializer(),
|
||||
FcmTokenBody(normalized),
|
||||
).encodeToByteArray()
|
||||
return ApiRoute(method, FCM_TOKEN_PATH, OriginPolicy.GUARDED, body = body)
|
||||
}
|
||||
|
||||
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
||||
|
||||
/**
|
||||
* 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
|
||||
* on the JVM (unlike Swift's uppercase `uuidString`).
|
||||
*/
|
||||
private fun pathId(id: UUID): String = id.toString()
|
||||
|
||||
/** Strict RFC 3986 unreserved set — everything else is percent-encoded over UTF-8 bytes. */
|
||||
private val UNRESERVED: Set<Char> =
|
||||
("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~").toSet()
|
||||
|
||||
private fun percentEncode(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 HEX = "0123456789ABCDEF".toCharArray()
|
||||
|
||||
@Serializable
|
||||
private data class HookDecisionBody(val sessionId: String, val decision: String, val token: String)
|
||||
|
||||
@Serializable
|
||||
private data class FcmTokenBody(val token: String)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
/**
|
||||
* Client-side FCM registration-token validator (validate at the boundary, plan §4). Deliberately
|
||||
* LOOSE, per plan §4.5: non-empty, bounded length, `base64url` charset plus `:` — the FCM token
|
||||
* format/length is undocumented and changes, so a strict length regex risks rejecting valid tokens.
|
||||
*
|
||||
* Unlike iOS's APNs hex rule, FCM tokens are case-sensitive → NOT lowercased. [normalize] returns
|
||||
* the token unchanged when valid, or `null` (→ `ApiClientError.InvalidFcmToken` before any I/O).
|
||||
*/
|
||||
internal object FcmTokenRule {
|
||||
/** Generous headroom bound; real tokens are ~150–250 chars but the ceiling is undocumented. */
|
||||
private const val MAX_LENGTH = 4096
|
||||
|
||||
/** base64url alphabet (`A–Z a–z 0–9 - _`) plus the `:` that appears in FCM tokens. */
|
||||
private val ALLOWED: Set<Char> =
|
||||
(('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', ':')).toSet()
|
||||
|
||||
fun normalize(raw: String): String? {
|
||||
if (raw.isEmpty() || raw.length > MAX_LENGTH) return null
|
||||
if (!raw.all { it in ALLOWED }) return null
|
||||
return raw
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* The prefs round-trip correctness trap: `PUT /prefs` replaces the WHOLE blob, so decode → mutate
|
||||
* one known key → encode MUST carry every unknown top-level key through verbatim (including exact
|
||||
* integer formatting), or an Android write clobbers web/iOS/future-server prefs.
|
||||
*/
|
||||
class UiPrefsTest {
|
||||
@Test
|
||||
fun sanitizesFavouritesAndCollapsedLikeTheWebClient() {
|
||||
val prefs = UiPrefs.decode(
|
||||
"""
|
||||
{"favourites":["/a","/b","/a","",123],"collapsed":{"g1":true,"g2":false,"g3":"true","":true}}
|
||||
""".trimIndent().toByteArray(),
|
||||
)!!
|
||||
|
||||
// favourites: non-empty strings only, de-duplicated, order preserved; the number 123 dropped.
|
||||
assertEquals(listOf("/a", "/b"), prefs.favourites)
|
||||
// collapsed: only literal `true`; false, string "true", and the empty key are all dropped.
|
||||
assertEquals(mapOf("g1" to true), prefs.collapsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mutatingOneKeyPreservesUnknownKeysAndIntegerFormatting() {
|
||||
val original = UiPrefs.decode(
|
||||
"""
|
||||
{"favourites":["/old"],"collapsed":{"g":true},"schemaVersion":7,"vendor":{"nested":42,"ratio":1.5}}
|
||||
""".trimIndent().toByteArray(),
|
||||
)!!
|
||||
|
||||
val mutated = original.withFavourites(listOf("/new"))
|
||||
val encoded = mutated.encodeBody().decodeToString()
|
||||
|
||||
// Known key was replaced...
|
||||
assertEquals(listOf("/new"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
|
||||
// ...collapsed (untouched known key) survived...
|
||||
assertEquals(mapOf("g" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
|
||||
// ...and every unknown key survived verbatim, integers still integers (42, not 42.0).
|
||||
assertTrue(encoded.contains("\"schemaVersion\":7"), "unknown scalar key must round-trip: $encoded")
|
||||
assertTrue(encoded.contains("\"nested\":42"), "nested integer must not become 42.0: $encoded")
|
||||
assertTrue(encoded.contains("\"ratio\":1.5"), "nested double must round-trip: $encoded")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withCollapsedReplacesOnlyThatKey() {
|
||||
val original = UiPrefs.decode("""{"favourites":["/keep"],"extra":"x"}""".toByteArray())!!
|
||||
val encoded = original.withCollapsed(mapOf("ns" to true)).encodeBody().decodeToString()
|
||||
|
||||
assertEquals(listOf("/keep"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
|
||||
assertEquals(mapOf("ns" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
|
||||
assertTrue(encoded.contains("\"extra\":\"x\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createBuildsAFreshBlobWithBothKnownKeys() {
|
||||
val encoded = UiPrefs.create(favourites = listOf("/a"), collapsed = mapOf("g" to true))
|
||||
.encodeBody().decodeToString()
|
||||
assertEquals("""{"favourites":["/a"],"collapsed":{"g":true}}""", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonObjectBodyDecodesToNull() {
|
||||
assertNull(UiPrefs.decode("[]".toByteArray()))
|
||||
assertNull(UiPrefs.decode("\"hi\"".toByteArray()))
|
||||
assertNull(UiPrefs.decode("not json".toByteArray()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.wire.HostClassifier
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HostNetworkTier
|
||||
|
||||
/** Ports the tier table of iOS `HostClassificationTests` (plan §5.4, fail-safe unknown→public). */
|
||||
class HostClassifierTest {
|
||||
@Test
|
||||
fun loopbackHostsClassifyAsLoopback() {
|
||||
val hosts = listOf("localhost", "LOCALHOST", "127.0.0.1", "127.5.9.200", "::1", "[::1]")
|
||||
hosts.forEach { assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(it), it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rfc1918AndLinkLocalAndMdnsClassifyAsPrivateLan() {
|
||||
val hosts = listOf(
|
||||
"10.0.0.5", "10.255.255.255",
|
||||
"172.16.0.1", "172.20.10.1", "172.31.255.255",
|
||||
"192.168.0.9", "192.168.1.1",
|
||||
"169.254.1.1",
|
||||
"mac-mini.local", "MAC-MINI.LOCAL", "printer.local",
|
||||
)
|
||||
hosts.forEach { assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(it), it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cgnatAndMagicDnsClassifyAsTailscale() {
|
||||
val hosts = listOf(
|
||||
"100.64.0.1", "100.100.1.1", "100.127.255.255",
|
||||
"mac.tailnet.ts.net", "MAC.TAILNET.TS.NET", "host.ts.net",
|
||||
)
|
||||
hosts.forEach { assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(it), it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everythingElseFailsSafeToPublic() {
|
||||
val hosts = listOf(
|
||||
// routable public IPs
|
||||
"8.8.8.8", "203.0.113.7",
|
||||
// hostnames
|
||||
"example.com", "claude.ai",
|
||||
// boundary-miss IPv4 (just outside the private/tailscale ranges)
|
||||
"172.15.0.1", "172.32.0.1", "100.63.0.1", "100.128.0.1",
|
||||
// malformed / out-of-range / wrong arity → fail-safe public
|
||||
"256.1.1.1", "1.2.3", "999.999.999.999", "not a host", "",
|
||||
// non-loopback IPv6 (ULA / documentation) → fail-safe public (iOS v1 scope)
|
||||
"fd00::1", "2001:db8::1",
|
||||
)
|
||||
hosts.forEach { assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(it), it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifyEndpointDelegatesToHost() {
|
||||
val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
|
||||
val tailscale = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
|
||||
val public = requireNotNull(HostEndpoint.fromBaseUrl("https://example.com"))
|
||||
|
||||
assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(lan))
|
||||
assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(tailscale))
|
||||
assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(public))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user