Compare commits
31 Commits
main
...
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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,3 +18,6 @@ npm-debug.log*
|
|||||||
# test coverage
|
# test coverage
|
||||||
coverage/
|
coverage/
|
||||||
.gstack/
|
.gstack/
|
||||||
|
|
||||||
|
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
|
||||||
|
deploy/.env
|
||||||
|
|||||||
@@ -107,11 +107,13 @@ npm run setup-hooks # adds the hooks + statusLine to ~/.claude/s
|
|||||||
```
|
```
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
> **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`.
|
||||||
|
|
||||||
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
```bash
|
```bash
|
||||||
npm test # vitest, all modules (~470 tests, 80% coverage gate)
|
npm test # vitest, all modules (~1470 tests, 80% coverage gate)
|
||||||
npm run typecheck # tsc (backend + frontend)
|
npm run typecheck # tsc (backend + frontend)
|
||||||
npm run build # compile backend to dist/
|
npm run build # compile backend to dist/
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"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": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`.
|
* CLI entrypoint — PLAN_RELAY_AGENT T5, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
|
||||||
* All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO.
|
* `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).
|
* `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
|
||||||
*/
|
*/
|
||||||
import type { AgentConfig } from './config/agentConfig.js'
|
import type { AgentConfig } from './config/agentConfig.js'
|
||||||
import type { AgentIdentity } from './keys/identity.js'
|
import type { AgentIdentity } from './keys/identity.js'
|
||||||
import type { Keystore } from './keys/keystore.js'
|
import type { Keystore } from './keys/keystore.js'
|
||||||
|
import { assertNativeZone, type InstallOptions } from './service/install.js'
|
||||||
|
import { subdomainOrigin } from './service/originConfig.js'
|
||||||
import type { EnrollResult } from 'relay-contracts'
|
import type { EnrollResult } from 'relay-contracts'
|
||||||
|
|
||||||
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||||||
@@ -24,13 +33,41 @@ export class CliUsageError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
export interface CliDeps {
|
||||||
loadConfig(): AgentConfig
|
loadConfig(): AgentConfig
|
||||||
openKeystore(stateDir: string): Keystore
|
openKeystore(stateDir: string): Keystore
|
||||||
|
/** Ed25519 identity for the legacy relay path. */
|
||||||
generateIdentity(): AgentIdentity
|
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>
|
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>
|
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||||
installService(cfg: AgentConfig): Promise<void>
|
/** 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>
|
uninstallService(): Promise<void>
|
||||||
print(line: string): void
|
print(line: string): void
|
||||||
}
|
}
|
||||||
@@ -60,23 +97,60 @@ export function parseArgs(argv: readonly string[]): CliArgs {
|
|||||||
return args
|
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). */
|
/** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
|
||||||
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
||||||
const cfg = deps.loadConfig()
|
const cfg = deps.loadConfig()
|
||||||
const ks = deps.openKeystore(cfg.stateDir)
|
const ks = deps.openKeystore(cfg.stateDir)
|
||||||
switch (args.command) {
|
switch (args.command) {
|
||||||
case 'pair': {
|
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()
|
const id = ks.loadIdentity() ?? deps.generateIdentity()
|
||||||
ks.saveIdentity(id)
|
ks.saveIdentity(id)
|
||||||
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
||||||
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
||||||
if (args.flags['install']) await deps.installService(cfg)
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
case 'run': {
|
case 'run': {
|
||||||
if (ks.loadIdentity() === null || ks.loadCert() === null) {
|
const id = ks.loadIdentity()
|
||||||
|
if (id === null || ks.loadCert() === null) {
|
||||||
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
|
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)
|
return deps.runTunnel(cfg, ks)
|
||||||
}
|
}
|
||||||
case 'status': {
|
case 'status': {
|
||||||
@@ -88,7 +162,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
case 'install':
|
case 'install':
|
||||||
await deps.installService(cfg)
|
await deps.installService(cfg, deps.resolveInstallOptions())
|
||||||
return 0
|
return 0
|
||||||
case 'uninstall':
|
case 'uninstall':
|
||||||
await deps.uninstallService()
|
await deps.uninstallService()
|
||||||
|
|||||||
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`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
||||||
|
|
||||||
export interface AgentConfig {
|
export interface AgentConfig {
|
||||||
readonly relayUrl: string
|
readonly relayUrl: string
|
||||||
@@ -19,9 +20,12 @@ export interface AgentConfig {
|
|||||||
readonly hostId: string | null
|
readonly hostId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOOPBACK_HOSTNAMES: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
|
/**
|
||||||
|
* True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or
|
||||||
/** True iff `url` is ws:// to a loopback host (127.0.0.0/8, localhost, or ::1). */
|
* ::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 {
|
export function isLoopbackWsUrl(url: string): boolean {
|
||||||
let parsed: URL
|
let parsed: URL
|
||||||
try {
|
try {
|
||||||
@@ -30,8 +34,7 @@ export function isLoopbackWsUrl(url: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (parsed.protocol !== 'ws:') return false
|
if (parsed.protocol !== 'ws:') return false
|
||||||
const host = parsed.hostname
|
return isLoopbackHostLiteral(parsed.hostname)
|
||||||
return LOOPBACK_HOSTNAMES.includes(host) || host.startsWith('127.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasScheme(url: string, scheme: string): boolean {
|
function hasScheme(url: string, scheme: string): boolean {
|
||||||
|
|||||||
@@ -52,9 +52,11 @@ const OID = 0x06
|
|||||||
const UTF8_STRING = 0x0c
|
const UTF8_STRING = 0x0c
|
||||||
const CONTEXT_0 = 0xa0
|
const CONTEXT_0 = 0xa0
|
||||||
|
|
||||||
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
|
// 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_CN = Uint8Array.from([0x55, 0x04, 0x03])
|
||||||
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
|
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). */
|
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
|
||||||
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||||
@@ -63,6 +65,24 @@ function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
|||||||
return tlv(SEQUENCE, concat([algId, pubBits]))
|
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. */
|
/** X.501 Name with a single CN=<subject> RDN. */
|
||||||
function nameFromCn(cn: string): Uint8Array {
|
function nameFromCn(cn: string): Uint8Array {
|
||||||
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||||
@@ -77,18 +97,20 @@ function toPem(der: Uint8Array, label: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
|
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the identity's key. The
|
||||||
* The private key is used in-process only; never serialized into the output (INV4).
|
* 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 {
|
export function buildCsr(id: AgentIdentity, subject: string): string {
|
||||||
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||||
const name = nameFromCn(subject)
|
const name = nameFromCn(subject)
|
||||||
const spki = spkiFromRawEd25519(id.publicKey)
|
const spki = spkiFor(id)
|
||||||
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
||||||
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
||||||
|
|
||||||
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
|
const signature = id.sign(requestInfo) // over CertificationRequestInfo, per id.alg
|
||||||
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
const sigAlg = sigAlgFor(id)
|
||||||
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||||
|
|
||||||
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||||
|
|||||||
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
|
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
|
||||||
*
|
*
|
||||||
* Ed25519 keypair generated locally with node:crypto. `AgentIdentity` exposes ONLY the public
|
* Two key algorithms share one `AgentIdentity` shape (discriminated by `alg`):
|
||||||
* key, the §4.2 enroll fingerprint, and an in-process `sign()`; there is NO API that returns or
|
* - `ed25519` — the relay E2E rendezvous path (unchanged).
|
||||||
* serializes the private key. The raw private key material is held in a module-private closure.
|
* - `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 { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
|
||||||
import type { KeyObject } from 'node:crypto'
|
import type { KeyObject } from 'node:crypto'
|
||||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||||
|
|
||||||
|
/** Which key algorithm an identity carries. Drives CSR SPKI + signatureAlgorithm encoding. */
|
||||||
|
export type KeyAlg = 'ed25519' | 'p256'
|
||||||
|
|
||||||
export interface AgentIdentity {
|
export interface AgentIdentity {
|
||||||
/** Ed25519 raw 32-byte public key → stored in host registry (§4.2 agent_pubkey). */
|
/** 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
|
readonly publicKey: Uint8Array
|
||||||
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
|
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
|
||||||
readonly enrollFpr: string
|
readonly enrollFpr: string
|
||||||
/** Ed25519 signature over `message`, using the in-process private key. Never returns the key. */
|
/**
|
||||||
|
* 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
|
sign(message: Uint8Array): Uint8Array
|
||||||
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
|
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
|
||||||
exportPrivatePkcs8Pem(): string
|
exportPrivatePkcs8Pem(): string
|
||||||
@@ -39,6 +56,7 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
|
|||||||
const rawPub = rawPublicKey(publicKey)
|
const rawPub = rawPublicKey(publicKey)
|
||||||
const enrollFpr = computeEnrollFpr(rawPub)
|
const enrollFpr = computeEnrollFpr(rawPub)
|
||||||
return {
|
return {
|
||||||
|
alg: 'ed25519',
|
||||||
publicKey: rawPub,
|
publicKey: rawPub,
|
||||||
enrollFpr,
|
enrollFpr,
|
||||||
sign(message: Uint8Array): Uint8Array {
|
sign(message: Uint8Array): Uint8Array {
|
||||||
@@ -53,19 +71,61 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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). */
|
/** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
|
||||||
export function generateIdentity(): AgentIdentity {
|
export function generateIdentity(): AgentIdentity {
|
||||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||||
return buildIdentity(privateKey, publicKey)
|
return buildIdentity(privateKey, publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reconstruct an identity from a stored PKCS#8 PEM private key (keystore load path). */
|
/**
|
||||||
|
* 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 {
|
export function identityFromPrivatePem(pem: string): AgentIdentity {
|
||||||
const privateKey = createPrivateKey(pem)
|
const privateKey = createPrivateKey(pem)
|
||||||
const publicKey = createPublicKey(privateKey)
|
const publicKey = createPublicKey(privateKey)
|
||||||
return buildIdentity(privateKey, publicKey)
|
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. */
|
/** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
|
||||||
export function verifySignature(
|
export function verifySignature(
|
||||||
publicKey: Uint8Array,
|
publicKey: Uint8Array,
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from 'node:fs'
|
} from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
import { createPrivateKey } from 'node:crypto'
|
||||||
import type { AgentIdentity } from './identity.js'
|
import type { AgentIdentity } from './identity.js'
|
||||||
import { identityFromPrivatePem } from './identity.js'
|
import { identityFromPrivatePem, p256IdentityFromPrivatePem } from './identity.js'
|
||||||
|
|
||||||
const SECRET_MODE = 0o600
|
const SECRET_MODE = 0o600
|
||||||
const DIR_MODE = 0o700
|
const DIR_MODE = 0o700
|
||||||
@@ -54,6 +55,32 @@ function ensureDir(stateDir: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
function writeSecret(path: string, data: string | Uint8Array): void {
|
||||||
writeFileSync(path, data, { mode: SECRET_MODE })
|
writeFileSync(path, data, { mode: SECRET_MODE })
|
||||||
// Enforce 0600 even if a prior umask/file left it wider.
|
// Enforce 0600 even if a prior umask/file left it wider.
|
||||||
@@ -80,7 +107,7 @@ export function openKeystore(stateDir: string): Keystore {
|
|||||||
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
|
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return identityFromPrivatePem(pem)
|
return identityFromStoredPem(pem)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
|
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
|
|||||||
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})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,152 @@
|
|||||||
/**
|
/**
|
||||||
* Service install dispatcher — PLAN_RELAY_AGENT T17. Detects the platform, writes the unit, and
|
* Service install dispatcher — PLAN_RELAY_AGENT T17, re-targeted for the native tunnel
|
||||||
* loads it. REFUSES to install as root (EXPLORE §4d least privilege). All IO is injected so the
|
* (PLAN_TUNNEL_AUTOMATION B5). Detects the platform and emits TWO durable units — the base-app and
|
||||||
* logic is unit-testable without touching the real system.
|
* 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 type { AgentConfig } from '../config/agentConfig.js'
|
||||||
import {
|
import {
|
||||||
|
agentLabel,
|
||||||
|
baseAppLabel,
|
||||||
buildLaunchdPlist,
|
buildLaunchdPlist,
|
||||||
launchdLoadCommand,
|
launchdLoadCommand,
|
||||||
launchdPlistPath,
|
launchdPlistPath,
|
||||||
launchdUnloadCommand,
|
launchdUnloadCommand,
|
||||||
|
type ServiceEnv,
|
||||||
} from './launchd.js'
|
} from './launchd.js'
|
||||||
import {
|
import {
|
||||||
|
agentUnitName,
|
||||||
|
baseAppUnitName,
|
||||||
buildSystemdUnit,
|
buildSystemdUnit,
|
||||||
systemdDisableCommand,
|
systemdDisableCommand,
|
||||||
systemdEnableCommand,
|
systemdEnableCommand,
|
||||||
systemdUnitPath,
|
systemdUnitPath,
|
||||||
|
type SystemdUnitOptions,
|
||||||
} from './systemd.js'
|
} from './systemd.js'
|
||||||
|
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
|
||||||
|
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
||||||
|
|
||||||
export type ServicePlatform = 'launchd' | 'systemd'
|
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 {
|
export class RootRefusedError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
||||||
@@ -42,37 +170,85 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
|
/**
|
||||||
export async function installService(
|
* Resolve the BASE-APP env injected into the base-app unit. Runs the S-GATE on `BIND_HOST` (throws
|
||||||
_cfg: AgentConfig,
|
* `BindHostError` on a non-loopback value, before any write) and, when a `domain` (+ `cfg.subdomain`)
|
||||||
platform: ServicePlatform,
|
* is supplied, merges the tunnel origin `https://<subdomain>.<zone>.<domain>` into ALLOWED_ORIGINS —
|
||||||
deps: InstallDeps,
|
* never weakening an origin the caller already provided. Immutable.
|
||||||
): Promise<void> {
|
*/
|
||||||
if (deps.getuid() === 0) throw new RootRefusedError()
|
function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
|
||||||
const bin = deps.binPath()
|
const base = options.env ?? {}
|
||||||
if (platform === 'launchd') {
|
const bindHost = normalizeBindHost(base.BIND_HOST) // S-GATE — throws on non-loopback
|
||||||
const path = launchdPlistPath(deps.homedir())
|
const withBind: ServiceEnv = { ...base, BIND_HOST: bindHost }
|
||||||
deps.writeFile(path, buildLaunchdPlist(bin))
|
if (!options.domain || !cfg.subdomain) return withBind
|
||||||
const { cmd, args } = launchdLoadCommand(path)
|
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
|
||||||
await deps.runCommand(cmd, args)
|
return { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) }
|
||||||
return
|
|
||||||
}
|
|
||||||
const path = systemdUnitPath(deps.homedir())
|
|
||||||
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
|
|
||||||
const { cmd, args } = systemdEnableCommand()
|
|
||||||
await deps.runCommand(cmd, args)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unload the service unit for `platform`. */
|
/**
|
||||||
|
* 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(
|
export async function uninstallService(
|
||||||
platform: ServicePlatform,
|
platform: ServicePlatform,
|
||||||
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
|
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (platform === 'launchd') {
|
if (platform === 'launchd') {
|
||||||
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir()))
|
for (const label of [agentLabel(), baseAppLabel()]) {
|
||||||
|
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
|
||||||
await deps.runCommand(cmd, args)
|
await deps.runCommand(cmd, args)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { cmd, args } = systemdDisableCommand()
|
for (const unit of [agentUnitName(), baseAppUnitName()]) {
|
||||||
|
const { cmd, args } = systemdDisableCommand(unit)
|
||||||
await deps.runCommand(cmd, args)
|
await deps.runCommand(cmd, args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,97 @@
|
|||||||
/**
|
/**
|
||||||
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
* 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).
|
* 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`).
|
||||||
*/
|
*/
|
||||||
const LABEL = 'com.web-terminal.agent'
|
|
||||||
|
|
||||||
|
/** 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 {
|
export function launchdLabel(): string {
|
||||||
return LABEL
|
return AGENT_LABEL
|
||||||
}
|
}
|
||||||
|
|
||||||
export function launchdPlistPath(homedir: string): string {
|
export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
|
||||||
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
|
return `${homedir}/Library/LaunchAgents/${label}.plist`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */
|
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
|
||||||
export function buildLaunchdPlist(binPath: string): string {
|
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 [
|
return [
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
'<?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">',
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||||
'<plist version="1.0">',
|
'<plist version="1.0">',
|
||||||
'<dict>',
|
'<dict>',
|
||||||
' <key>Label</key>',
|
' <key>Label</key>',
|
||||||
` <string>${LABEL}</string>`,
|
` <string>${escapeXml(label)}</string>`,
|
||||||
' <key>ProgramArguments</key>',
|
...programArgumentsBlock(programArguments),
|
||||||
' <array>',
|
|
||||||
` <string>${binPath}</string>`,
|
|
||||||
' <string>run</string>',
|
|
||||||
' </array>',
|
|
||||||
' <key>RunAtLoad</key>',
|
' <key>RunAtLoad</key>',
|
||||||
' <true/>',
|
' <true/>',
|
||||||
' <key>KeepAlive</key>',
|
' <key>KeepAlive</key>',
|
||||||
' <true/>',
|
' <true/>',
|
||||||
|
...environmentVariablesBlock(env),
|
||||||
'</dict>',
|
'</dict>',
|
||||||
'</plist>',
|
'</plist>',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
||||||
* APPENDS `https://<subdomain>.term.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
* 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
|
* 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").
|
* 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'
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
|
||||||
@@ -18,23 +23,40 @@ const defaultFs: OriginFsDeps = {
|
|||||||
write: (p, c) => writeFileSync(p, c),
|
write: (p, c) => writeFileSync(p, c),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compose the subdomain origin the base app must trust. */
|
/** Default DNS zone label (relay hosts). Native-tunnel hosts pass `terminal`. */
|
||||||
export function subdomainOrigin(subdomain: string, domain: string): string {
|
export const DEFAULT_ORIGIN_ZONE = 'term'
|
||||||
return `https://${subdomain}.term.${domain}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const KEY = 'ALLOWED_ORIGINS'
|
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 {
|
function upsertOriginLine(content: string, origin: string): string {
|
||||||
const lines = content.length === 0 ? [] : content.split('\n')
|
const lines = content.length === 0 ? [] : content.split('\n')
|
||||||
let found = false
|
let found = false
|
||||||
const next = lines.map((line) => {
|
const next = lines.map((line) => {
|
||||||
if (!line.startsWith(`${KEY}=`)) return line
|
if (!line.startsWith(`${KEY}=`)) return line
|
||||||
found = true
|
found = true
|
||||||
const current = line.slice(KEY.length + 1)
|
return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
|
||||||
const origins = current.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
|
||||||
if (origins.includes(origin)) return line // idempotent — already trusted
|
|
||||||
return `${KEY}=${[...origins, origin].join(',')}`
|
|
||||||
})
|
})
|
||||||
if (!found) next.push(`${KEY}=${origin}`)
|
if (!found) next.push(`${KEY}=${origin}`)
|
||||||
return next.join('\n')
|
return next.join('\n')
|
||||||
@@ -42,15 +64,17 @@ function upsertOriginLine(content: string, origin: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
||||||
* existing origin. Creates the file/line if absent.
|
* existing origin. Creates the file/line if absent. `zone` selects the DNS zone label (default
|
||||||
|
* preserves relay callers).
|
||||||
*/
|
*/
|
||||||
export function ensureAllowedOrigin(
|
export function ensureAllowedOrigin(
|
||||||
baseAppEnvPath: string,
|
baseAppEnvPath: string,
|
||||||
subdomain: string,
|
subdomain: string,
|
||||||
domain: string,
|
domain: string,
|
||||||
fs: OriginFsDeps = defaultFs,
|
fs: OriginFsDeps = defaultFs,
|
||||||
|
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||||
): void {
|
): void {
|
||||||
const origin = subdomainOrigin(subdomain, domain)
|
const origin = subdomainOrigin(subdomain, domain, zone)
|
||||||
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
||||||
const updated = upsertOriginLine(existing, origin)
|
const updated = upsertOriginLine(existing, origin)
|
||||||
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
||||||
|
|||||||
@@ -1,30 +1,129 @@
|
|||||||
/**
|
/**
|
||||||
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
|
* 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).
|
* 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`).
|
||||||
*/
|
*/
|
||||||
const UNIT_NAME = 'web-terminal-agent.service'
|
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 {
|
export function systemdUnitName(): string {
|
||||||
return UNIT_NAME
|
return AGENT_UNIT
|
||||||
}
|
}
|
||||||
|
|
||||||
export function systemdUnitPath(homedir: string): string {
|
export function systemdUnitPath(homedir: string, unitName: string = AGENT_UNIT): string {
|
||||||
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
|
return `${homedir}/.config/systemd/user/${unitName}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). */
|
/**
|
||||||
export function buildSystemdUnit(binPath: string, user: string): string {
|
* 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 [
|
return [
|
||||||
'[Unit]',
|
'[Unit]',
|
||||||
'Description=web-terminal host agent (rendezvous relay)',
|
`Description=${description}`,
|
||||||
'After=network-online.target',
|
'After=network-online.target',
|
||||||
'',
|
'',
|
||||||
'[Service]',
|
'[Service]',
|
||||||
'Type=simple',
|
'Type=simple',
|
||||||
`ExecStart=${binPath} run`,
|
`ExecStart=${execStart}`,
|
||||||
'Restart=on-failure',
|
'Restart=on-failure',
|
||||||
'RestartSec=1',
|
'RestartSec=1',
|
||||||
`User=${user}`,
|
`User=${user}`,
|
||||||
|
...environmentLines(options),
|
||||||
'',
|
'',
|
||||||
'[Install]',
|
'[Install]',
|
||||||
'WantedBy=default.target',
|
'WantedBy=default.target',
|
||||||
@@ -32,9 +131,13 @@ export function buildSystemdUnit(binPath: string, user: string): string {
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function systemdEnableCommand(): { cmd: string; args: readonly string[] } {
|
export function systemdEnableCommand(
|
||||||
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', UNIT_NAME] }
|
unitName: string = AGENT_UNIT,
|
||||||
|
): { cmd: string; args: readonly string[] } {
|
||||||
|
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', unitName] }
|
||||||
}
|
}
|
||||||
export function systemdDisableCommand(): { cmd: string; args: readonly string[] } {
|
export function systemdDisableCommand(
|
||||||
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', UNIT_NAME] }
|
unitName: string = AGENT_UNIT,
|
||||||
|
): { cmd: string; args: readonly string[] } {
|
||||||
|
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', unitName] }
|
||||||
}
|
}
|
||||||
|
|||||||
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')
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,10 +33,20 @@ describe('AgentConfig validation', () => {
|
|||||||
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
|
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
|
||||||
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
|
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
|
||||||
expect(isLoopbackWsUrl('ws://127.5.5.5: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('ws://10.0.0.5:3000')).toBe(false)
|
||||||
expect(isLoopbackWsUrl('wss://127.0.0.1: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', () => {
|
it('loadAgentConfig fails fast on a missing relayUrl', () => {
|
||||||
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
|
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Keystore } from '../src/keys/keystore.js'
|
|||||||
import type { AgentIdentity } from '../src/keys/identity.js'
|
import type { AgentIdentity } from '../src/keys/identity.js'
|
||||||
import type { EnrollResult } from 'relay-contracts'
|
import type { EnrollResult } from 'relay-contracts'
|
||||||
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
|
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
|
||||||
|
import type { InstallOptions } from '../src/service/install.js'
|
||||||
|
|
||||||
const CFG: AgentConfig = {
|
const CFG: AgentConfig = {
|
||||||
relayUrl: 'wss://relay/agent',
|
relayUrl: 'wss://relay/agent',
|
||||||
@@ -14,8 +15,9 @@ const CFG: AgentConfig = {
|
|||||||
hostId: 'h-1',
|
hostId: 'h-1',
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeIdentity(): AgentIdentity {
|
function fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
|
||||||
return {
|
return {
|
||||||
|
alg,
|
||||||
publicKey: new Uint8Array(32),
|
publicKey: new Uint8Array(32),
|
||||||
enrollFpr: 'fpr',
|
enrollFpr: 'fpr',
|
||||||
sign: () => new Uint8Array(64),
|
sign: () => new Uint8Array(64),
|
||||||
@@ -24,10 +26,10 @@ function fakeIdentity(): AgentIdentity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeKeystore(enrolled: boolean): Keystore {
|
function fakeKeystore(enrolled: boolean, alg: AgentIdentity['alg'] = 'ed25519'): Keystore {
|
||||||
return {
|
return {
|
||||||
saveIdentity: vi.fn(),
|
saveIdentity: vi.fn(),
|
||||||
loadIdentity: () => (enrolled ? fakeIdentity() : null),
|
loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
|
||||||
saveCert: vi.fn(),
|
saveCert: vi.fn(),
|
||||||
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
|
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
|
||||||
saveContentSecret: vi.fn(),
|
saveContentSecret: vi.fn(),
|
||||||
@@ -35,12 +37,19 @@ function fakeKeystore(enrolled: boolean): Keystore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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[] } {
|
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
const d: CliDeps = {
|
const d: CliDeps = {
|
||||||
loadConfig: () => CFG,
|
loadConfig: () => CFG,
|
||||||
openKeystore: () => fakeKeystore(enrolled),
|
openKeystore: () => fakeKeystore(enrolled),
|
||||||
generateIdentity: fakeIdentity,
|
generateIdentity: () => fakeIdentity('ed25519'),
|
||||||
|
generateP256Identity: () => fakeIdentity('p256'),
|
||||||
redeem: async (): Promise<EnrollResult> => ({
|
redeem: async (): Promise<EnrollResult> => ({
|
||||||
hostId: 'h-1',
|
hostId: 'h-1',
|
||||||
subdomain: 'host-42',
|
subdomain: 'host-42',
|
||||||
@@ -48,7 +57,13 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
|
|||||||
caChain: 'CA',
|
caChain: 'CA',
|
||||||
hostContentSecret: new Uint8Array([1]),
|
hostContentSecret: new Uint8Array([1]),
|
||||||
}),
|
}),
|
||||||
|
enrollNative: async () => ({ hostId: 'h-1', subdomain: 'host-42' }),
|
||||||
|
provisionFrpc: async () => '/opt/frpc',
|
||||||
|
writeFrpcConfig: vi.fn(),
|
||||||
|
nativeConfigExists: () => false,
|
||||||
runTunnel: async () => 0,
|
runTunnel: async () => 0,
|
||||||
|
superviseFrpc: async () => 0,
|
||||||
|
resolveInstallOptions: () => NATIVE_OPTIONS,
|
||||||
installService: vi.fn(async () => {}),
|
installService: vi.fn(async () => {}),
|
||||||
uninstallService: vi.fn(async () => {}),
|
uninstallService: vi.fn(async () => {}),
|
||||||
print: (l) => out.push(l),
|
print: (l) => out.push(l),
|
||||||
@@ -79,7 +94,7 @@ describe('parseArgs (T5)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('runCli (T5)', () => {
|
describe('runCli — legacy relay pair (no --install)', () => {
|
||||||
it('pair happy path calls redeem and prints no secrets', async () => {
|
it('pair happy path calls redeem and prints no secrets', async () => {
|
||||||
const { d, out } = deps()
|
const { d, out } = deps()
|
||||||
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
|
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
|
||||||
@@ -87,12 +102,97 @@ describe('runCli (T5)', () => {
|
|||||||
expect(out.join('\n')).toContain('host-42')
|
expect(out.join('\n')).toContain('host-42')
|
||||||
expect(out.join('\n')).not.toContain('PEM')
|
expect(out.join('\n')).not.toContain('PEM')
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('pair --install installs the service', async () => {
|
describe('runCli — native pair --install onboard (B5)', () => {
|
||||||
const install = vi.fn(async () => {})
|
it('wires keygen(P-256)→enroll→provisionFrpc→write toml+env→install both→print URL, in order', async () => {
|
||||||
const { d } = deps({ installService: install })
|
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)
|
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||||
expect(install).toHaveBeenCalledOnce()
|
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 () => {
|
it('run before pairing fails fast', async () => {
|
||||||
@@ -100,6 +200,48 @@ describe('runCli (T5)', () => {
|
|||||||
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
|
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 () => {
|
it('status prints no key/cert material (INV9)', async () => {
|
||||||
const { d, out } = deps({}, true)
|
const { d, out } = deps({}, true)
|
||||||
await runCli({ command: 'status', flags: {} }, d)
|
await runCli({ command: 'status', flags: {} }, d)
|
||||||
|
|||||||
@@ -1,8 +1,52 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { X509Certificate } from 'node:crypto'
|
import { X509Certificate, createPublicKey, verify } from 'node:crypto'
|
||||||
import { generateIdentity } from '../src/keys/identity.js'
|
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
|
||||||
import { buildCsr } from '../src/enroll/csr.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)', () => {
|
describe('PKCS#10 CSR (T4)', () => {
|
||||||
it('emits a PEM CERTIFICATE REQUEST', () => {
|
it('emits a PEM CERTIFICATE REQUEST', () => {
|
||||||
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
|
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
|
||||||
@@ -30,3 +74,50 @@ describe('PKCS#10 CSR (T4)', () => {
|
|||||||
expect(typeof X509Certificate).toBe('function')
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
import { createPublicKey, verify } from 'node:crypto'
|
||||||
import {
|
import {
|
||||||
computeEnrollFpr,
|
computeEnrollFpr,
|
||||||
generateIdentity,
|
generateIdentity,
|
||||||
|
generateP256Identity,
|
||||||
identityFromPrivatePem,
|
identityFromPrivatePem,
|
||||||
|
p256IdentityFromPrivatePem,
|
||||||
verifySignature,
|
verifySignature,
|
||||||
} from '../src/keys/identity.js'
|
} from '../src/keys/identity.js'
|
||||||
|
|
||||||
@@ -50,4 +53,53 @@ describe('AgentIdentity (INV4)', () => {
|
|||||||
// No function exports raw private key bytes onto the network surface.
|
// No function exports raw private key bytes onto the network surface.
|
||||||
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
import {
|
import {
|
||||||
|
BindHostError,
|
||||||
RootRefusedError,
|
RootRefusedError,
|
||||||
|
assertNativeZone,
|
||||||
|
buildInstallOptions,
|
||||||
detectPlatform,
|
detectPlatform,
|
||||||
installService,
|
installService,
|
||||||
|
normalizeBindHost,
|
||||||
uninstallService,
|
uninstallService,
|
||||||
type InstallDeps,
|
type InstallDeps,
|
||||||
} from '../src/service/install.js'
|
} 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 = {
|
const CFG: AgentConfig = {
|
||||||
relayUrl: 'wss://relay/agent',
|
relayUrl: 'wss://relay/agent',
|
||||||
@@ -17,7 +23,12 @@ const CFG: AgentConfig = {
|
|||||||
hostId: 'h-1',
|
hostId: 'h-1',
|
||||||
}
|
}
|
||||||
|
|
||||||
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
|
type FakeDeps = InstallDeps & {
|
||||||
|
writes: Array<[string, string]>
|
||||||
|
runs: Array<[string, readonly string[]]>
|
||||||
|
}
|
||||||
|
|
||||||
|
function deps(uid = 501): FakeDeps {
|
||||||
const writes: Array<[string, string]> = []
|
const writes: Array<[string, string]> = []
|
||||||
const runs: Array<[string, readonly string[]]> = []
|
const runs: Array<[string, readonly string[]]> = []
|
||||||
return {
|
return {
|
||||||
@@ -34,6 +45,13 @@ function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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)', () => {
|
describe('detectPlatform (T17)', () => {
|
||||||
it('maps darwin→launchd, linux→systemd, else null', () => {
|
it('maps darwin→launchd, linux→systemd, else null', () => {
|
||||||
expect(detectPlatform('darwin')).toBe('launchd')
|
expect(detectPlatform('darwin')).toBe('launchd')
|
||||||
@@ -42,35 +60,361 @@ describe('detectPlatform (T17)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('installService (T17)', () => {
|
describe('installService — least privilege (T17)', () => {
|
||||||
it('refuses to install as root (negative, least privilege)', async () => {
|
it('refuses to install as root (negative, least privilege)', async () => {
|
||||||
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
|
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('systemd: writes a run-as-user unit and enables it', async () => {
|
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()
|
const d = deps()
|
||||||
await installService(CFG, 'systemd', d)
|
await installService(CFG, 'systemd', d)
|
||||||
const [, unit] = d.writes[0]!
|
// exactly two units
|
||||||
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
|
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).toContain('User=alice')
|
||||||
expect(unit).not.toContain('User=root')
|
expect(unit).not.toContain('User=root')
|
||||||
expect(unit).toContain('Restart=on-failure')
|
expect(unit).toContain('Restart=on-failure')
|
||||||
expect(d.runs[0]![0]).toBe('systemctl')
|
}
|
||||||
|
// both enabled
|
||||||
|
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
|
||||||
|
expect(d.runs).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
|
it('launchd: writes a base-app plist AND an agent plist, both with KeepAlive, both loaded', async () => {
|
||||||
const d = deps()
|
const d = deps()
|
||||||
await installService(CFG, 'launchd', d)
|
await installService(CFG, 'launchd', d)
|
||||||
const [path, plist] = d.writes[0]!
|
expect(d.writes).toHaveLength(2)
|
||||||
expect(path).toContain('LaunchAgents')
|
const baseApp = unitWith(d, baseAppLabel())
|
||||||
expect(plist).toContain('<string>run</string>')
|
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(plist).toContain('<key>KeepAlive</key>')
|
||||||
expect(d.runs[0]![0]).toBe('launchctl')
|
}
|
||||||
|
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('uninstall unloads cleanly', async () => {
|
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()
|
const d = deps()
|
||||||
await uninstallService('launchd', d)
|
await uninstallService('launchd', d)
|
||||||
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||||||
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { generateIdentity } from '../src/keys/identity.js'
|
import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'
|
||||||
|
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
|
||||||
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
|
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
|
||||||
|
|
||||||
const dirs: string[] = []
|
const dirs: string[] = []
|
||||||
@@ -67,4 +68,50 @@ describe('Keystore (INV4/INV5)', () => {
|
|||||||
mkdirSync(dir, { mode: 0o755 })
|
mkdirSync(dir, { mode: 0o755 })
|
||||||
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
|
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/)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { ensureAllowedOrigin, subdomainOrigin, type OriginFsDeps } from '../src/service/originConfig.js'
|
import {
|
||||||
|
DEFAULT_ORIGIN_ZONE,
|
||||||
|
ensureAllowedOrigin,
|
||||||
|
mergeOrigins,
|
||||||
|
subdomainOrigin,
|
||||||
|
type OriginFsDeps,
|
||||||
|
} from '../src/service/originConfig.js'
|
||||||
|
|
||||||
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
||||||
const store = { content: initial }
|
const store = { content: initial }
|
||||||
@@ -41,3 +47,38 @@ describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
|
|||||||
expect(get()).toContain('https://host-42.term.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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package wang.yaojia.webterm.api.pairing
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.net.UnknownServiceException
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
import javax.net.ssl.SSLHandshakeException
|
||||||
|
|
||||||
|
/** Re-derives iOS `PairingError.classify` for JVM exceptions (plan R12: drop localNetworkDenied). */
|
||||||
|
class PairingErrorTest {
|
||||||
|
private val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun connectionRefusedMapsToHostUnreachable() {
|
||||||
|
val result = PairingError.classify(ConnectException("Connection refused"), lan)
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dnsFailureMapsToHostUnreachable() {
|
||||||
|
val result = PairingError.classify(UnknownHostException("no such host"), lan)
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun socketTimeoutMapsToTimeout() {
|
||||||
|
assertEquals(PairingError.Timeout, PairingError.classify(SocketTimeoutException("read timed out"), lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun interruptedIoMapsToTimeout() {
|
||||||
|
// OkHttp's overall call-timeout surfaces as a bare InterruptedIOException.
|
||||||
|
assertEquals(PairingError.Timeout, PairingError.classify(InterruptedIOException("timeout"), lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sslHandshakeFailureMapsToTlsFailure() {
|
||||||
|
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLHandshakeException("handshake_failure"), lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun genericSslAndCertFailuresMapToTlsFailure() {
|
||||||
|
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLException("tls"), lan))
|
||||||
|
assertEquals(PairingError.TlsFailure, PairingError.classify(CertificateException("bad cert"), lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cleartextBlockMapsToCleartextBlockedWithHost() {
|
||||||
|
val err = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted")
|
||||||
|
val result = PairingError.classify(err, lan)
|
||||||
|
assertInstanceOf(PairingError.CleartextBlocked::class.java, result)
|
||||||
|
assertEquals("192.168.1.5", (result as PairingError.CleartextBlocked).host)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrappedCauseIsWalked() {
|
||||||
|
// OkHttp frequently wraps the real cause; the chain walk must see it.
|
||||||
|
val wrappedConnect = IOException("unexpected end of stream", ConnectException("refused"))
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(wrappedConnect, lan))
|
||||||
|
|
||||||
|
val wrappedTls = IOException("io", SSLHandshakeException("handshake"))
|
||||||
|
assertEquals(PairingError.TlsFailure, PairingError.classify(wrappedTls, lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unrecognizedErrorUsesFallbackWhenProvided() {
|
||||||
|
// Probe step ②: after ① passed, an unclassifiable connect failure is the Origin 401.
|
||||||
|
val fallback = PairingError.OriginRejected("hint")
|
||||||
|
assertEquals(fallback, PairingError.classify(IllegalStateException("weird"), lan, unrecognizedFallback = fallback))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unrecognizedErrorWithoutFallbackIsHostUnreachable() {
|
||||||
|
assertInstanceOf(
|
||||||
|
PairingError.HostUnreachable::class.java,
|
||||||
|
PairingError.classify(IllegalStateException("weird"), lan),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun causeCycleTerminates() {
|
||||||
|
// A→B→A cycle must not loop forever; B is a ConnectException so it classifies as reachable-failure.
|
||||||
|
val a = IOException("a")
|
||||||
|
val b = ConnectException("b")
|
||||||
|
a.initCause(b)
|
||||||
|
b.initCause(a)
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(a, lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun originRejectedHintDerivesFromOriginHeaderWithoutDefaultPort() {
|
||||||
|
val lanHint = PairingError.originRejectedHint(lan)
|
||||||
|
assertTrue(lanHint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))
|
||||||
|
|
||||||
|
val tls = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
|
||||||
|
val tlsHint = PairingError.originRejectedHint(tls)
|
||||||
|
assertTrue(tlsHint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
|
||||||
|
assertFalse(tlsHint.contains(":443"), "default https port must be omitted (no :443 迷信)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package wang.yaojia.webterm.api.pairing
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeTermTransport
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.net.UnknownServiceException
|
||||||
|
import javax.net.ssl.SSLHandshakeException
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ports iOS `PairingProbeTests`: the two-step probe (① GET reachability/shape, ② WS attach→adopt→
|
||||||
|
* kill-with-Origin), each failure mode mapped to a [PairingError], full-success leaves no orphan
|
||||||
|
* session, and the injected-deadline timeout — all under `runTest` virtual time (zero real waits).
|
||||||
|
*/
|
||||||
|
class PairingProbeTest {
|
||||||
|
private data class Fixture(
|
||||||
|
val endpoint: HostEndpoint,
|
||||||
|
val http: FakeHttpTransport,
|
||||||
|
val ws: FakeTermTransport,
|
||||||
|
val liveSessionsUrl: String,
|
||||||
|
val killUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fixture(base: String = BASE): Fixture {
|
||||||
|
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(base))
|
||||||
|
return Fixture(
|
||||||
|
endpoint = endpoint,
|
||||||
|
http = FakeHttpTransport(),
|
||||||
|
ws = FakeTermTransport(),
|
||||||
|
liveSessionsUrl = "$base/live-sessions",
|
||||||
|
killUrl = "$base/live-sessions/$SESSION_ID",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-timeout cases use `timeout = null` so the probe never enters the race — deterministic. */
|
||||||
|
private suspend fun runProbe(f: Fixture, timeout: Duration? = null): PairingProbeResult =
|
||||||
|
runPairingProbeCore(f.endpoint, f.http, f.ws, timeout)
|
||||||
|
|
||||||
|
private fun failureError(result: PairingProbeResult): PairingError {
|
||||||
|
assertInstanceOf(PairingProbeResult.Failure::class.java, result)
|
||||||
|
return (result as PairingProbeResult.Failure).error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Probe ① failure branches ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneConnectionRefusedMapsToHostUnreachableAndNeverTouchesWs() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueFailure(url = f.liveSessionsUrl, error = ConnectException("refused"))
|
||||||
|
|
||||||
|
val result = runProbe(f)
|
||||||
|
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(result))
|
||||||
|
assertTrue(f.ws.connectAttempts.isEmpty(), "probe must not touch WS when ① fails")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneHtmlBodyMapsToNotWebTerminal() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "<html><body>router admin</body></html>".toByteArray())
|
||||||
|
|
||||||
|
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOne404MapsToNotWebTerminal() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, status = 404)
|
||||||
|
|
||||||
|
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneNonArrayJsonMapsToNotWebTerminal() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "{\"ok\":true}".toByteArray())
|
||||||
|
|
||||||
|
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneTlsFailureMapsToTlsFailure() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueFailure(url = f.liveSessionsUrl, error = SSLHandshakeException("handshake_failure"))
|
||||||
|
|
||||||
|
assertEquals(PairingError.TlsFailure, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneTransportTimeoutMapsToTimeout() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueFailure(url = f.liveSessionsUrl, error = SocketTimeoutException("read timed out"))
|
||||||
|
|
||||||
|
assertEquals(PairingError.Timeout, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneCleartextBlockMapsToCleartextBlockedWithHost() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueFailure(
|
||||||
|
url = f.liveSessionsUrl,
|
||||||
|
error = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val error = failureError(runProbe(f))
|
||||||
|
assertInstanceOf(PairingError.CleartextBlocked::class.java, error)
|
||||||
|
assertEquals("192.168.1.5", (error as PairingError.CleartextBlocked).host)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneDnsFailureMapsToHostUnreachable() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueFailure(url = f.liveSessionsUrl, error = UnknownHostException("nxdomain"))
|
||||||
|
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Probe ② failure branches ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() = runTest {
|
||||||
|
// ① passes; ② upgrade fails (a 401 is a shapeless connect error at the transport layer).
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.ws.scriptConnectFailure()
|
||||||
|
|
||||||
|
val error = failureError(runProbe(f))
|
||||||
|
assertInstanceOf(PairingError.OriginRejected::class.java, error)
|
||||||
|
val hint = (error as PairingError.OriginRejected).hint
|
||||||
|
assertTrue(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"), hint)
|
||||||
|
assertFalse(hint.contains(":443"), hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun originRejectedHintOmitsDefaultPortForHttps() = runTest {
|
||||||
|
val f = fixture(base = "https://mac.tailnet.ts.net")
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.ws.scriptConnectFailure()
|
||||||
|
|
||||||
|
val error = failureError(runProbe(f))
|
||||||
|
assertInstanceOf(PairingError.OriginRejected::class.java, error)
|
||||||
|
val hint = (error as PairingError.OriginRejected).hint
|
||||||
|
assertTrue(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"), hint)
|
||||||
|
assertFalse(hint.contains(":443"), hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() = runTest {
|
||||||
|
// A queued finish is flushed into the connection at connect → stream closes before `attached`.
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.ws.finishFrames()
|
||||||
|
|
||||||
|
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full pass + kill round-trip ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullProbeSuccessAttachesWithNullSessionIdThenKillsWithOrigin() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
|
||||||
|
f.ws.emit(ATTACHED_FRAME)
|
||||||
|
|
||||||
|
val result = runProbe(f)
|
||||||
|
|
||||||
|
// Success payload is the probed endpoint.
|
||||||
|
assertEquals(PairingProbeResult.Success(f.endpoint), result)
|
||||||
|
|
||||||
|
// attach(null) is the only WS frame, with an explicit JSON null sessionId key.
|
||||||
|
assertEquals(1, f.ws.sentFrames.size)
|
||||||
|
val attach = Json.parseToJsonElement(f.ws.sentFrames.single()).jsonObject
|
||||||
|
assertEquals("attach", attach["type"]?.jsonPrimitive?.content)
|
||||||
|
assertTrue(attach["sessionId"] is JsonNull, "sessionId key must be present and JSON null")
|
||||||
|
|
||||||
|
// Exactly two HTTP requests: RO GET (NO Origin) then guarded DELETE (byte-equal Origin).
|
||||||
|
val requests = f.http.recordedRequests
|
||||||
|
assertEquals(2, requests.size)
|
||||||
|
assertFalse(requests.first().headers.containsKey("Origin"), "RO GET must not carry Origin")
|
||||||
|
val kill = requests.last()
|
||||||
|
assertEquals(HttpMethod.DELETE, kill.method)
|
||||||
|
assertEquals(f.killUrl, kill.url)
|
||||||
|
assertEquals(f.endpoint.originHeader, kill.headers["Origin"])
|
||||||
|
|
||||||
|
// The probe never holds the connection.
|
||||||
|
assertEquals(1, f.ws.closeCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publicWrapperHappyPathReturnsEndpoint() = runTest {
|
||||||
|
// The public entry uses the default Tunables deadline; immediate fakes never trip it.
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
|
||||||
|
f.ws.emit(ATTACHED_FRAME)
|
||||||
|
|
||||||
|
val result = runPairingProbe(f.endpoint, f.http, f.ws)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Success(f.endpoint), result)
|
||||||
|
assertEquals(1, f.ws.closeCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
|
||||||
|
f.ws.emit("""{"type":"output","data":"[0mreplay"}""")
|
||||||
|
f.ws.emit(ATTACHED_FRAME)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun killRejectedByOriginGuardMapsToOriginRejected() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 403)
|
||||||
|
f.ws.emit(ATTACHED_FRAME)
|
||||||
|
|
||||||
|
assertInstanceOf(PairingError.OriginRejected::class.java, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun killReturning404StillCountsAsSuccess() = runTest {
|
||||||
|
// Session exited between attach and kill — the goal state (no orphan) is already reached.
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 404)
|
||||||
|
f.ws.emit(ATTACHED_FRAME)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timeout (virtual time, zero real waits) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun probeTimesOutWhenServerNeverSendsAttached() = runTest {
|
||||||
|
// ① passes; ② connects but the server never replies `attached` → the probe hangs on the
|
||||||
|
// frame stream until the injected deadline cancels it.
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
|
||||||
|
val result = runProbe(f, timeout = 10.seconds)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun timeoutPathAlwaysClosesTheConnectionExactlyOnce() = runTest {
|
||||||
|
// ① passes; ② connects but the server never sends `attached` → the probe hangs on the frame
|
||||||
|
// stream until the injected deadline cancels it MID-adopt. The connection must still close
|
||||||
|
// (try/finally + NonCancellable), or the probe leaks the WS + its orphan session.
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
|
||||||
|
val result = runProbe(f, timeout = 10.seconds)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
|
||||||
|
assertEquals(1, f.ws.closeCallCount, "the probe must close the WS even on the timeout path")
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://192.168.1.5:3000"
|
||||||
|
const val SESSION_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||||
|
const val ATTACHED_FRAME = """{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.api.models.HookDecision
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Status-code → typed [ApiClientError] mapping per route, plus transport errors propagating raw. */
|
||||||
|
class ApiClientErrorMappingTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://h:3000"
|
||||||
|
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||||
|
const val ID_STR = "11111111-2222-4333-8444-555555555555"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||||
|
|
||||||
|
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun killSessionMapsNotFoundAndForbidden() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 404)
|
||||||
|
assertEquals(ApiClientError.SessionNotFound, errorOf { client.killSession(ID) })
|
||||||
|
|
||||||
|
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 403)
|
||||||
|
assertEquals(ApiClientError.Forbidden, errorOf { client.killSession(ID) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hookDecisionMapsForbiddenToRejectedAnd429ToRateLimited() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 403)
|
||||||
|
assertEquals(ApiClientError.DecisionRejected, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") })
|
||||||
|
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 429)
|
||||||
|
assertEquals(ApiClientError.RateLimited, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun projectDetailMapsEmptyPathAndAllServerErrorStatuses() = runTest {
|
||||||
|
// Empty path is rejected BEFORE any I/O.
|
||||||
|
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("") })
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty(), "empty path must not hit the network")
|
||||||
|
|
||||||
|
val url = "$BASE/projects/detail?path=%2Fp"
|
||||||
|
transport.queueSuccess(url = url, status = 400)
|
||||||
|
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("/p") })
|
||||||
|
transport.queueSuccess(url = url, status = 404)
|
||||||
|
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectDetail("/p") })
|
||||||
|
transport.queueSuccess(url = url, status = 500)
|
||||||
|
assertEquals(ApiClientError.ProjectDetailUnavailable, errorOf { client.projectDetail("/p") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun putPrefsMapsForbiddenAndUnexpected() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 403)
|
||||||
|
assertEquals(ApiClientError.Forbidden, errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) })
|
||||||
|
|
||||||
|
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 418)
|
||||||
|
assertEquals(ApiClientError.UnexpectedStatus(418), errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readOnlyRoutesMapNonOkToUnexpectedOrSessionNotFound() = runTest {
|
||||||
|
transport.queueSuccess(url = "$BASE/live-sessions", status = 500, body = "boom".toByteArray())
|
||||||
|
assertEquals(ApiClientError.UnexpectedStatus(500), errorOf { client.liveSessions() })
|
||||||
|
|
||||||
|
// preview/events/uiConfig go through requireOk: 404 → SessionNotFound.
|
||||||
|
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/preview", status = 404)
|
||||||
|
assertEquals(ApiClientError.SessionNotFound, errorOf { client.preview(ID) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun transportLevelErrorsPropagateUnwrapped() = runTest {
|
||||||
|
transport.queueFailure(url = "$BASE/live-sessions", error = IOException("connection refused"))
|
||||||
|
val error = errorOf { client.liveSessions() }
|
||||||
|
assertTrue(error is IOException)
|
||||||
|
assertEquals("connection refused", error?.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.api.models.HookDecision
|
||||||
|
import wang.yaojia.webterm.api.models.UiPrefs
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) across all 12 routes. Verifies method, URL,
|
||||||
|
* the presence/absence of the `Origin` header, and the exact JSON body for the mutating routes.
|
||||||
|
*/
|
||||||
|
class ApiRouteShapeTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://192.168.1.5:3000"
|
||||||
|
const val ORIGIN = "http://192.168.1.5:3000"
|
||||||
|
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||||
|
const val ID_STR = "11111111-2222-4333-8444-555555555555"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||||
|
|
||||||
|
private fun lastRequest(): HttpRequest = transport.recordedRequests.last()
|
||||||
|
|
||||||
|
private fun assertGuarded(request: HttpRequest) =
|
||||||
|
assertEquals(ORIGIN, request.headers[HeaderName.ORIGIN], "guarded route must stamp byte-equal Origin")
|
||||||
|
|
||||||
|
private fun assertReadOnly(request: HttpRequest) =
|
||||||
|
assertFalse(request.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
|
||||||
|
|
||||||
|
// ── read-only routes: correct verb+url, NO Origin ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun liveSessionsIsPlainReadOnlyGet() = runTest {
|
||||||
|
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
||||||
|
client.liveSessions()
|
||||||
|
|
||||||
|
val request = lastRequest()
|
||||||
|
assertEquals(HttpMethod.GET, request.method)
|
||||||
|
assertEquals("$BASE/live-sessions", request.url)
|
||||||
|
assertReadOnly(request)
|
||||||
|
assertNull(request.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun previewEventsUiConfigProjectsPrefsAreReadOnlyGets() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
url = "$BASE/live-sessions/$ID_STR/preview",
|
||||||
|
body = """{"id":"$ID_STR","cols":80,"rows":24,"data":""}""".toByteArray(),
|
||||||
|
)
|
||||||
|
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "[]".toByteArray())
|
||||||
|
transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray())
|
||||||
|
transport.queueSuccess(url = "$BASE/projects", body = "[]".toByteArray())
|
||||||
|
transport.queueSuccess(url = "$BASE/prefs", body = "{}".toByteArray())
|
||||||
|
|
||||||
|
client.preview(ID)
|
||||||
|
client.events(ID)
|
||||||
|
client.uiConfig()
|
||||||
|
client.projects()
|
||||||
|
client.prefs()
|
||||||
|
|
||||||
|
val urls = transport.recordedRequests.map { it.url }
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"$BASE/live-sessions/$ID_STR/preview",
|
||||||
|
"$BASE/live-sessions/$ID_STR/events",
|
||||||
|
"$BASE/config/ui",
|
||||||
|
"$BASE/projects",
|
||||||
|
"$BASE/prefs",
|
||||||
|
),
|
||||||
|
urls,
|
||||||
|
)
|
||||||
|
transport.recordedRequests.forEach {
|
||||||
|
assertEquals(HttpMethod.GET, it.method)
|
||||||
|
assertReadOnly(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun projectDetailStrictlyPercentEncodesPathInTheQuery() = runTest {
|
||||||
|
val path = "/home/me/my repo/a+b&c"
|
||||||
|
transport.queueSuccess(
|
||||||
|
url = "$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c",
|
||||||
|
body = """{"name":"repo","path":"$path","isGit":true}""".toByteArray(),
|
||||||
|
)
|
||||||
|
|
||||||
|
client.projectDetail(path)
|
||||||
|
|
||||||
|
val request = lastRequest()
|
||||||
|
assertEquals(HttpMethod.GET, request.method)
|
||||||
|
assertEquals("$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c", request.url)
|
||||||
|
assertReadOnly(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── guarded routes: correct verb+url, Origin stamped, exact body ────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun killSessionIsGuardedDeleteWithNoBody() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
|
||||||
|
client.killSession(ID)
|
||||||
|
|
||||||
|
val request = lastRequest()
|
||||||
|
assertEquals(HttpMethod.DELETE, request.method)
|
||||||
|
assertEquals("$BASE/live-sessions/$ID_STR", request.url)
|
||||||
|
assertGuarded(request)
|
||||||
|
assertNull(request.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hookDecisionIsGuardedPostWithExactBodyAndJsonContentType() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
|
||||||
|
client.hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
|
||||||
|
|
||||||
|
val request = lastRequest()
|
||||||
|
assertEquals(HttpMethod.POST, request.method)
|
||||||
|
assertEquals("$BASE/hook/decision", request.url)
|
||||||
|
assertGuarded(request)
|
||||||
|
assertEquals(ContentType.JSON, request.headers[HeaderName.CONTENT_TYPE])
|
||||||
|
assertEquals(
|
||||||
|
"""{"sessionId":"$ID_STR","decision":"allow","token":"cap-tok-123"}""",
|
||||||
|
request.body?.decodeToString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun putPrefsIsGuardedPutEchoingTheFullBlob() = runTest {
|
||||||
|
val body = """{"favourites":["/a"],"collapsed":{}}"""
|
||||||
|
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = body.toByteArray())
|
||||||
|
|
||||||
|
client.putPrefs(UiPrefs.create(favourites = listOf("/a")))
|
||||||
|
|
||||||
|
val request = lastRequest()
|
||||||
|
assertEquals(HttpMethod.PUT, request.method)
|
||||||
|
assertEquals("$BASE/prefs", request.url)
|
||||||
|
assertGuarded(request)
|
||||||
|
assertEquals(body, request.body?.decodeToString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fcmTokenRegisterAndUnregisterAreGuardedWithTokenBody() = runTest {
|
||||||
|
val token = "fVoT9x-abc_DEF:api-901"
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/push/fcm-token", status = 204)
|
||||||
|
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/push/fcm-token", status = 204)
|
||||||
|
|
||||||
|
client.registerFcmToken(token)
|
||||||
|
client.unregisterFcmToken(token)
|
||||||
|
|
||||||
|
val post = transport.recordedRequests[0]
|
||||||
|
val delete = transport.recordedRequests[1]
|
||||||
|
assertEquals(HttpMethod.POST, post.method)
|
||||||
|
assertEquals(HttpMethod.DELETE, delete.method)
|
||||||
|
assertTrue(post.url.endsWith("/push/fcm-token"))
|
||||||
|
assertGuarded(post)
|
||||||
|
assertGuarded(delete)
|
||||||
|
assertEquals("""{"token":"$token"}""", post.body?.decodeToString())
|
||||||
|
assertEquals("""{"token":"$token"}""", delete.body?.decodeToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
|
||||||
|
/** The intentionally-LOOSE FCM token validator (plan §4.5) + register/unregister error mapping. */
|
||||||
|
class FcmTokenTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://h:3000"
|
||||||
|
const val URL = "$BASE/push/fcm-token"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptsBase64UrlPlusColonAndIsCaseSensitive() {
|
||||||
|
// base64url chars + ':' , mixed case preserved (FCM tokens are case-sensitive → not lowered).
|
||||||
|
val token = "cRZ7-x_Y9:APA91bH-Ab_CdEf"
|
||||||
|
assertEquals(token, FcmTokenRule.normalize(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsEmptyOverlongAndOutOfCharsetTokens() {
|
||||||
|
assertNull(FcmTokenRule.normalize(""))
|
||||||
|
assertNull(FcmTokenRule.normalize("a".repeat(4097)))
|
||||||
|
assertNull(FcmTokenRule.normalize("has space"))
|
||||||
|
assertNull(FcmTokenRule.normalize("has/slash"))
|
||||||
|
assertNull(FcmTokenRule.normalize("emoji😀"))
|
||||||
|
assertNotNull(FcmTokenRule.normalize("a".repeat(4096))) // exactly at the bound is fine
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invalidTokenIsRejectedBeforeAnyNetworkIO() = runTest {
|
||||||
|
val error = runCatching { client.registerFcmToken("bad token") }.exceptionOrNull()
|
||||||
|
assertEquals(ApiClientError.InvalidFcmToken, error)
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun registerMaps400To403To429() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 400)
|
||||||
|
assertEquals(ApiClientError.InvalidFcmToken, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
|
||||||
|
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 403)
|
||||||
|
assertEquals(ApiClientError.Forbidden, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
|
||||||
|
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 429)
|
||||||
|
assertEquals(ApiClientError.RateLimited, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun registerAndUnregisterSucceedOn204() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 204)
|
||||||
|
transport.queueSuccess(method = HttpMethod.DELETE, url = URL, status = 204)
|
||||||
|
// No exception = success.
|
||||||
|
client.registerFcmToken("okTok")
|
||||||
|
client.unregisterFcmToken("okTok")
|
||||||
|
assertEquals(2, transport.recordedRequests.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** End-to-end decode of each model from a well-formed body (exercises the custom serializers). */
|
||||||
|
class HappyPathDecodeTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://h:3000"
|
||||||
|
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||||
|
const val ID_STR = "11111111-2222-4333-8444-555555555555"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun previewDecodesGeometryAndOpaqueData() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
url = "$BASE/live-sessions/$ID_STR/preview",
|
||||||
|
body = """{"id":"$ID_STR","cols":100,"rows":30,"data":"[0mhello"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val preview = client.preview(ID)
|
||||||
|
assertEquals(ID, preview.id)
|
||||||
|
assertEquals(100, preview.cols)
|
||||||
|
assertEquals("[0mhello", preview.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uiConfigDecodesAllowAutoMode() = runTest {
|
||||||
|
transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray())
|
||||||
|
assertTrue(client.uiConfig().allowAutoMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun liveSessionsDecodesTelemetry() = runTest {
|
||||||
|
val body = """
|
||||||
|
[{"id":"$ID_STR","createdAt":10,"clientCount":1,"status":"working","exited":false,"cols":80,"rows":24,
|
||||||
|
"telemetry":{"at":99,"model":"opus","costUsd":0.42,"linesAdded":10,"pr":{"number":7,"url":"http://x"}}}]
|
||||||
|
""".trimIndent()
|
||||||
|
transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray())
|
||||||
|
|
||||||
|
val session = client.liveSessions().single()
|
||||||
|
assertEquals("opus", session.telemetry?.model)
|
||||||
|
assertEquals(0.42, session.telemetry?.costUsd)
|
||||||
|
assertEquals(7, session.telemetry?.pr?.number)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun projectDetailDecodesWorktreesSessionsAndClaudeMd() = runTest {
|
||||||
|
val body = """
|
||||||
|
{"name":"repo","path":"/r","isGit":true,"branch":"main","dirty":true,
|
||||||
|
"worktrees":[{"path":"/r","branch":"main","isMain":true,"isCurrent":true},
|
||||||
|
{"path":"/r-wt","head":"abc123","isMain":false,"isCurrent":false}],
|
||||||
|
"sessions":[{"id":"$ID_STR","title":"claude","status":"waiting","clientCount":1,"createdAt":5,"exited":false}],
|
||||||
|
"hasClaudeMd":true,"claudeMd":"# Repo"}
|
||||||
|
""".trimIndent()
|
||||||
|
transport.queueSuccess(url = "$BASE/projects/detail?path=%2Fr", body = body.toByteArray())
|
||||||
|
|
||||||
|
val detail = client.projectDetail("/r")
|
||||||
|
assertEquals("main", detail.branch)
|
||||||
|
assertEquals(2, detail.worktrees.size)
|
||||||
|
assertTrue(detail.worktrees[0].isMain)
|
||||||
|
assertEquals("abc123", detail.worktrees[1].head)
|
||||||
|
assertEquals("claude", detail.sessions.single().title)
|
||||||
|
assertEquals("# Repo", detail.claudeMd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun prefsRoundTripsThroughGetAndPutEcho() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
url = "$BASE/prefs",
|
||||||
|
body = """{"favourites":["/a"],"collapsed":{"g":true},"vendor":1}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val prefs = client.prefs()
|
||||||
|
assertEquals(listOf("/a"), prefs.favourites)
|
||||||
|
|
||||||
|
// PUT returns the server's sanitized echo — treat IT as truth (server dropped "vendor").
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.PUT,
|
||||||
|
url = "$BASE/prefs",
|
||||||
|
body = """{"favourites":["/a","/b"],"collapsed":{}}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val echoed = client.putPrefs(prefs.withFavourites(listOf("/a", "/b")))
|
||||||
|
assertEquals(listOf("/a", "/b"), echoed.favourites)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun prefsNonObjectBodyThrowsInvalidResponseBody() = runTest {
|
||||||
|
transport.queueSuccess(url = "$BASE/prefs", body = "[]".toByteArray())
|
||||||
|
assertEquals(
|
||||||
|
ApiClientError.InvalidResponseBody,
|
||||||
|
runCatching { client.prefs() }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Tolerant decode at the UNTRUSTED server boundary (plan §4): drop bad elements, never crash. */
|
||||||
|
class TolerantDecodeTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://h:3000"
|
||||||
|
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||||
|
const val ID_STR = "11111111-2222-4333-8444-555555555555"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun liveSessionsDropsMalformedEntriesAndMapsUnknownStatusToUnknown() = runTest {
|
||||||
|
// Entry 1: valid, unknown status string. Entry 2: missing required clientCount → dropped.
|
||||||
|
// Entry 3: non-UUID id → dropped. Entry 4: valid, big-ms createdAt (Long), no telemetry.
|
||||||
|
val body = """
|
||||||
|
[
|
||||||
|
{"id":"$ID_STR","createdAt":1700000000000,"clientCount":2,"status":"reticulating","exited":false,"cols":80,"rows":24},
|
||||||
|
{"id":"22222222-2222-4222-8222-222222222222","createdAt":1,"status":"idle","exited":false,"cols":80,"rows":24},
|
||||||
|
{"id":"not-a-uuid","createdAt":1,"clientCount":1,"status":"idle","exited":false,"cols":80,"rows":24},
|
||||||
|
{"id":"33333333-3333-4333-8333-333333333333","createdAt":1700000000001,"clientCount":0,"status":"working","exited":true,"cwd":"/w","cols":120,"rows":40,"lastOutputAt":1700000000005}
|
||||||
|
]
|
||||||
|
""".trimIndent()
|
||||||
|
transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray())
|
||||||
|
|
||||||
|
val sessions = client.liveSessions()
|
||||||
|
|
||||||
|
assertEquals(2, sessions.size)
|
||||||
|
assertEquals(ID, sessions[0].id)
|
||||||
|
assertEquals(ClaudeStatus.UNKNOWN, sessions[0].status) // unknown wire value → UNKNOWN, not dropped
|
||||||
|
assertEquals(1_700_000_000_000L, sessions[0].createdAt) // ms fits Long, would overflow Int
|
||||||
|
assertEquals(ClaudeStatus.WORKING, sessions[1].status)
|
||||||
|
assertEquals(1_700_000_000_005L, sessions[1].lastOutputAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun liveSessionsNonArrayBodyThrowsInvalidResponseBody() = runTest {
|
||||||
|
transport.queueSuccess(url = "$BASE/live-sessions", body = "{}".toByteArray())
|
||||||
|
assertEquals(
|
||||||
|
ApiClientError.InvalidResponseBody,
|
||||||
|
runCatching { client.liveSessions() }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun projectsKeepsProjectButDropsOnlyItsMalformedNestedSession() = runTest {
|
||||||
|
val body = """
|
||||||
|
[
|
||||||
|
{"name":"repo","path":"/r","isGit":true,"branch":"main","sessions":[
|
||||||
|
{"id":"$ID_STR","status":"working","clientCount":1,"createdAt":1,"exited":false},
|
||||||
|
{"id":"bad","status":"working","clientCount":1,"createdAt":1,"exited":false}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
""".trimIndent()
|
||||||
|
transport.queueSuccess(url = "$BASE/projects", body = body.toByteArray())
|
||||||
|
|
||||||
|
val projects = client.projects()
|
||||||
|
|
||||||
|
assertEquals(1, projects.size) // project survives its bad nested session
|
||||||
|
assertEquals(1, projects[0].sessions.size)
|
||||||
|
assertEquals(ID, projects[0].sessions[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eventsNonArrayBodyDegradesToEmptyAndUnknownClassSurvives() = runTest {
|
||||||
|
// A non-array body (timeline capture disabled) → [] rather than an error.
|
||||||
|
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "{}".toByteArray())
|
||||||
|
assertTrue(client.events(ID).isEmpty())
|
||||||
|
|
||||||
|
// Unknown class decodes fine (shape ok); hasKnownClass=false for downstream filtering.
|
||||||
|
transport.queueSuccess(
|
||||||
|
url = "$BASE/live-sessions/$ID_STR/events",
|
||||||
|
body = """[{"at":5,"class":"weird","label":"did a thing"}]""".toByteArray(),
|
||||||
|
)
|
||||||
|
val events = client.events(ID)
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
assertFalse(events[0].hasKnownClass)
|
||||||
|
assertEquals("did a thing", events[0].label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun singleObjectRoutesThrowInvalidResponseBodyOnGarbage() = runTest {
|
||||||
|
transport.queueSuccess(url = "$BASE/config/ui", body = "not json".toByteArray())
|
||||||
|
assertEquals(
|
||||||
|
ApiClientError.InvalidResponseBody,
|
||||||
|
runCatching { client.uiConfig() }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
android/app/build.gradle.kts
Normal file
122
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// :app — the Android application (Compose Material 3 + Adaptive design system,
|
||||||
|
// Hilt DI skeleton, launcher MainActivity). Mirrors iOS App/WebTerm.
|
||||||
|
//
|
||||||
|
// A13 establishes the Android UI-stack version matrix for every later Android task
|
||||||
|
// (the app-stack baseline, analogous to how A1 established the JVM catalog). All
|
||||||
|
// UI versions are resolved in gradle/libs.versions.toml.
|
||||||
|
//
|
||||||
|
// Plugins (AGP 9 has BUILT-IN Kotlin → never apply org.jetbrains.kotlin.android):
|
||||||
|
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.compose.compiler)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.hilt)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "wang.yaojia.webterm"
|
||||||
|
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
|
||||||
|
// Kotlin 2.3.21 requires compiling against SDK 36+ (BOM 2025.11 → ui 1.9.5).
|
||||||
|
// targetSdk stays 35 per plan §2 (compileSdk ≥ targetSdk is the normal rule).
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "wang.yaojia.webterm"
|
||||||
|
minSdk = 29
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
// No applicationId suffix — keep it stable for deep-link testing (A32).
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(17)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Modules A15 wires into the DI graph.
|
||||||
|
implementation(project(":wire-protocol"))
|
||||||
|
implementation(project(":session-core"))
|
||||||
|
implementation(project(":api-client"))
|
||||||
|
implementation(project(":transport-okhttp"))
|
||||||
|
implementation(project(":client-tls"))
|
||||||
|
// A15: bridge the mTLS device identity (:client-tls-android) onto the shared client, and
|
||||||
|
// provide the DataStore-backed stores (:host-registry). :client-tls-android exposes
|
||||||
|
// :client-tls via `api`, but the explicit dep above is kept for clarity.
|
||||||
|
implementation(project(":client-tls-android"))
|
||||||
|
implementation(project(":host-registry"))
|
||||||
|
// Terminal render (A16): RemoteTerminalSession/RemoteTerminalView for the live terminal (A21) and
|
||||||
|
// the raw Termux TerminalEmulator for A18's off-screen thumbnail rasterisation (§6.7). :terminal-view
|
||||||
|
// scopes Termux as `implementation`, so :app names the emulator directly for the off-screen path.
|
||||||
|
implementation(project(":terminal-view"))
|
||||||
|
implementation(libs.termux.terminal.emulator)
|
||||||
|
// QR pairing (A19): CameraX + on-device ML Kit barcode scanning.
|
||||||
|
implementation(libs.bundles.camerax)
|
||||||
|
implementation(libs.mlkit.barcode.scanning)
|
||||||
|
// Push (A30) + nav/deep-links (A32).
|
||||||
|
implementation(platform(libs.firebase.bom))
|
||||||
|
implementation(libs.firebase.messaging)
|
||||||
|
implementation(libs.androidx.biometric)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
// OkHttp is `implementation` (not `api`) in :transport-okhttp, so :app names OkHttpClient /
|
||||||
|
// ConnectionPool directly in NetworkModule/TlsModule.
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
// Coroutines are used directly by the wiring (EventBus fan-out, engine confinement scope).
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
// Preferences DataStore is named in StorageModule's @Provides return types.
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
// AndroidX foundation
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
|
||||||
|
// Compose (the BOM governs every version below)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.bundles.compose)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
|
// Hilt (Dagger) — KSP annotation processing
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
|
|
||||||
|
// JVM unit tests (no device) — the frozen design-token spec, EventBus fan-out, and the
|
||||||
|
// RetainedSessionHolder lifecycle invariant driven by a fake transport under virtual time.
|
||||||
|
testImplementation(libs.bundles.unit.test)
|
||||||
|
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
|
||||||
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules.
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
4
android/app/proguard-rules.pro
vendored
Normal file
4
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# WebTerm :app — R8/ProGuard rules.
|
||||||
|
# Release is not minified yet (isMinifyEnabled = false); this file exists so the
|
||||||
|
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for
|
||||||
|
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later.
|
||||||
74
android/app/src/main/AndroidManifest.xml
Normal file
74
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Talk to the WebTerm server over WS/HTTP. No NEARBY_WIFI_DEVICES: Android
|
||||||
|
has no local-network permission prompt; plain WS to a LAN IP needs none
|
||||||
|
(plan §8 / R12). -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Push (A30/A31) — requested with rationale at runtime on API 33+. -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- QR pairing (A19) — CameraX preview; requested with rationale at runtime, denial
|
||||||
|
degrades to manual URL entry. android:required=false so non-camera devices install. -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".WebTermApp"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.WebTerm"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:usesCleartextTraffic="false">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.WebTerm">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- A32 deep links → DeepLinkRouter (v4-UUID whitelist; the manifest never validates). -->
|
||||||
|
<!-- Custom scheme: webterminal://open?host=<uuid>&join=<uuid> -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="webterminal" android:host="open" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- Verified App Link: https://terminal.yaojia.wang/open?host=&join= .
|
||||||
|
autoVerify needs assetlinks.json (release SHA-256) served at
|
||||||
|
/.well-known/assetlinks.json — a deploy artifact (§8), not built here. -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="terminal.yaojia.wang" android:pathPrefix="/open" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Push (A30) — FCM data-only entry point (server sends no notification block). -->
|
||||||
|
<service
|
||||||
|
android:name=".push.FcmService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<!-- Deny action: auth-free, no-UI, expedited POST (goAsync). -->
|
||||||
|
<receiver
|
||||||
|
android:name=".push.DenyBroadcastReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
<!-- Allow action: translucent, excluded-from-recents trampoline hosting BiometricPrompt
|
||||||
|
(a receiver/service cannot present it — R1). -->
|
||||||
|
<activity
|
||||||
|
android:name=".push.AllowTrampolineActivity"
|
||||||
|
android:theme="@style/Theme.WebTerm.Translucent"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
176
android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt
Normal file
176
android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package wang.yaojia.webterm
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.hostregistry.Host
|
||||||
|
import wang.yaojia.webterm.nav.DeepLinkRouter
|
||||||
|
import wang.yaojia.webterm.nav.NavRoutes
|
||||||
|
import wang.yaojia.webterm.nav.WebTermNavHost
|
||||||
|
import wang.yaojia.webterm.push.PushCoordinator
|
||||||
|
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single launcher Activity and composition root. A `@AndroidEntryPoint` [FragmentActivity]
|
||||||
|
* (FragmentActivity so the biometric/allow trampoline hierarchy is consistent) that:
|
||||||
|
*
|
||||||
|
* - **warms the mTLS/OkHttp stack off `Main`** ([AppEnvironment.warmUp]) before any terminal bind;
|
||||||
|
* - **registers this device's FCM token with every paired host on app start** ([PushCoordinator]);
|
||||||
|
* - **hands the launch/new intents to [DeepLinkRouter]** (the ONE whitelist parser) and navigates only a
|
||||||
|
* validated [NavRoutes.forDeepLink] route — the manifest `<intent-filter>`s deliver, never validate;
|
||||||
|
* - renders [WebTermNavHost] with the cold-start start destination ([AppEnvironment.coldStartPolicy]).
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
public class MainActivity : FragmentActivity() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public lateinit var appEnvironment: AppEnvironment
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public lateinit var pushCoordinator: PushCoordinator
|
||||||
|
|
||||||
|
/** The latest deep-link URI to route (null = nothing pending). Fed by onCreate + onNewIntent. */
|
||||||
|
private val pendingDeepLink = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Build the shared OkHttp/mTLS stack OFF-Main before the first bind (warmUp hops to IO itself);
|
||||||
|
// a real failure is swallowed (the terminal screen surfaces its own retry, FIX 5) but cancellation
|
||||||
|
// is rethrown so a destroyed Activity tears the warm-up down cleanly.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
appEnvironment.warmUp()
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// best-effort pre-warm; TerminalScreen re-runs warmUp with an actionable retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// App start: register the current FCM token with every paired host (best-effort self-heal).
|
||||||
|
pushCoordinator.registerAllHosts()
|
||||||
|
|
||||||
|
handleDeepLinkIntent(intent)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
WebTermTheme {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
NotificationPermissionGate()
|
||||||
|
ColdStartHost(
|
||||||
|
env = appEnvironment,
|
||||||
|
navController = navController,
|
||||||
|
pendingDeepLink = pendingDeepLink,
|
||||||
|
onHostPaired = { host -> pushCoordinator.registerHost(host) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
handleDeepLinkIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture a VIEW deep link's data URI; a launcher/push-body intent has none → nothing to route. */
|
||||||
|
private fun handleDeepLinkIntent(intent: Intent?) {
|
||||||
|
pendingDeepLink.value = intent?.data?.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the cold-start destination ([AppEnvironment.coldStartPolicy]) then render the graph. Shows a
|
||||||
|
* spinner until the (suspend) host-presence read completes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ColdStartHost(
|
||||||
|
env: AppEnvironment,
|
||||||
|
navController: NavHostController,
|
||||||
|
pendingDeepLink: MutableStateFlow<String?>,
|
||||||
|
onHostPaired: (Host) -> Unit,
|
||||||
|
) {
|
||||||
|
val startRoute by produceState<String?>(initialValue = null, key1 = env) {
|
||||||
|
value = NavRoutes.startRouteFor(env.coldStartPolicy.initialRoute())
|
||||||
|
}
|
||||||
|
val route = startRoute
|
||||||
|
if (route == null) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
WebTermNavHost(
|
||||||
|
env = env,
|
||||||
|
startRoute = route,
|
||||||
|
navController = navController,
|
||||||
|
onHostPaired = onHostPaired,
|
||||||
|
)
|
||||||
|
// Route pending deep links ONLY here — inside the `route != null` branch, so `WebTermNavHost`
|
||||||
|
// (which calls navController.setGraph) has composed and the graph is set before the effect body
|
||||||
|
// runs. On a cold launch the VIEW intent is captured before setContent, but its navigation is
|
||||||
|
// deferred to this point instead of racing an unset graph (which silently dropped the link).
|
||||||
|
DeepLinkEffect(pending = pendingDeepLink, navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route a pending deep-link URI through [DeepLinkRouter] (the ONE whitelist parser) and navigate only a
|
||||||
|
* validated route; invalid/ambiguous links are ignored (never partially applied). Clears the pending
|
||||||
|
* value once handled.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DeepLinkEffect(
|
||||||
|
pending: MutableStateFlow<String?>,
|
||||||
|
navController: NavHostController,
|
||||||
|
) {
|
||||||
|
val uri by pending.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(uri) {
|
||||||
|
val target = uri ?: return@LaunchedEffect
|
||||||
|
NavRoutes.forDeepLink(DeepLinkRouter.route(target))?.let { route ->
|
||||||
|
runCatching { navController.navigate(route) }
|
||||||
|
}
|
||||||
|
pending.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request POST_NOTIFICATIONS once on first launch (API 33+); a denial only stops push from being SHOWN. */
|
||||||
|
@Composable
|
||||||
|
private fun NotificationPermissionGate() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
android/app/src/main/java/wang/yaojia/webterm/WebTermApp.kt
Normal file
12
android/app/src/main/java/wang/yaojia/webterm/WebTermApp.kt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package wang.yaojia.webterm
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application entry point. `@HiltAndroidApp` triggers Hilt's code generation and
|
||||||
|
* creates the app-level `SingletonComponent` (see `di/AppModule`). The real
|
||||||
|
* composition root / wiring is A15 — this stays intentionally empty.
|
||||||
|
*/
|
||||||
|
@HiltAndroidApp
|
||||||
|
public class WebTermApp : Application()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermCard
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.session.AwayDigest
|
||||||
|
import wang.yaojia.webterm.wire.TimelineEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # AwayDigestView (A22) — the "what happened while I was away" reattach summary.
|
||||||
|
*
|
||||||
|
* The Android analogue of the iOS away-digest banner (plan §1): shown ABOVE the terminal on the
|
||||||
|
* first reconnect after a gap, summarising the [AwayDigest] reduced over the events since the user
|
||||||
|
* left — tool-run and waiting counts plus done/stuck flags. Its **展开** ("expand") affordance opens
|
||||||
|
* the A28 activity-timeline sheet via [onExpand] (this component only exposes the seam; A28 wires it).
|
||||||
|
*
|
||||||
|
* An [empty][AwayDigest.isEmpty] digest renders **nothing** (all-zero suppressed) — the caller
|
||||||
|
* ([GateViewModel] holds `null` for an empty digest) normally never passes one, but the guard makes
|
||||||
|
* the component safe in isolation. Recent-event labels are server-derived text rendered as **inert
|
||||||
|
* [Text]** (no linkify/markdown, §8).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun AwayDigestView(
|
||||||
|
digest: AwayDigest,
|
||||||
|
onExpand: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (digest.isEmpty) return // all-zero suppressed; render nothing.
|
||||||
|
WebTermCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
|
Text(
|
||||||
|
text = summaryLine(digest),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
digest.recent.lastOrNull()?.let { latest ->
|
||||||
|
// INERT: server-derived phrase rendered verbatim, single line (§8).
|
||||||
|
Text(
|
||||||
|
text = latest.label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
|
TextButton(onClick = onExpand) { Text(text = "展开") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one-line count summary, e.g. "离开期间:3 次工具调用 · 1 次等待 · 已完成". Pure presentation. */
|
||||||
|
internal fun summaryLine(digest: AwayDigest): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (digest.toolRuns > 0) parts += "${digest.toolRuns} 次工具调用"
|
||||||
|
if (digest.waitingCount > 0) parts += "${digest.waitingCount} 次等待"
|
||||||
|
if (digest.sawDone) parts += "已完成"
|
||||||
|
if (digest.sawStuck) parts += "疑似卡住"
|
||||||
|
val body = if (parts.isEmpty()) "有活动" else parts.joinToString(" · ")
|
||||||
|
return "离开期间:$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Preview(name = "AwayDigestView")
|
||||||
|
@Composable
|
||||||
|
private fun AwayDigestViewPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
AwayDigestView(
|
||||||
|
digest = AwayDigest(
|
||||||
|
toolRuns = 3,
|
||||||
|
waitingCount = 1,
|
||||||
|
sawDone = true,
|
||||||
|
sawStuck = false,
|
||||||
|
recent = listOf(TimelineEvent(at = 0L, eventClass = "tool", toolName = "Bash", label = "ran Bash")),
|
||||||
|
),
|
||||||
|
onExpand = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import wang.yaojia.webterm.designsystem.Radius
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # ContinueLastBanner (A29) — the "继续上次会话" re-entry affordance.
|
||||||
|
*
|
||||||
|
* The visible half of the continue-last-session cold-start UX (plan §1, §5 A29). When a host still has a
|
||||||
|
* live last session ([LastSessionStore][wang.yaojia.webterm.hostregistry.LastSessionStore], kept current
|
||||||
|
* by [SessionActivityBridge][wang.yaojia.webterm.wiring.SessionActivityBridge]), this banner is layered
|
||||||
|
* on top of the sessions landing; tapping it re-opens THAT session (full scrollback replay) instead of
|
||||||
|
* spawning a new one. It renders in two layouts:
|
||||||
|
* - [ContinueLastVariant.Stack] — a full-width strip pinned above a stacked (phone) session list;
|
||||||
|
* - [ContinueLastVariant.Sidebar] — a compact rounded card for the tablet nav sidebar.
|
||||||
|
*
|
||||||
|
* ### Shown IFF a last session exists
|
||||||
|
* The pure [continueLastModel] maps the persisted last-session id to a [ContinueLastModel] (or `null`),
|
||||||
|
* and the composable renders NOTHING for a `null` model — so "shown iff a last session exists" is the
|
||||||
|
* single JVM-tested rule, no device needed. Layout/gesture polish is device-QA (plan §7).
|
||||||
|
*
|
||||||
|
* ### Inert rendering (plan §8)
|
||||||
|
* The session id is a SERVER-issued (untrusted) string; it is shown as a plain, inert [Text] — no
|
||||||
|
* Markdown / autolink / `AnnotatedString` linkify.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Which layout the banner renders in — a top strip (phone) or a sidebar card (tablet). */
|
||||||
|
public enum class ContinueLastVariant { Stack, Sidebar }
|
||||||
|
|
||||||
|
/** The banner's derived state: the last session that can be re-opened. */
|
||||||
|
public data class ContinueLastModel(val sessionId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PURE derivation: a non-blank persisted [lastSessionId] yields a [ContinueLastModel] (banner shown);
|
||||||
|
* `null`/blank yields `null` (banner hidden). Blank is treated as absent so a corrupt persisted value
|
||||||
|
* never renders an un-openable banner.
|
||||||
|
*/
|
||||||
|
public fun continueLastModel(lastSessionId: String?): ContinueLastModel? =
|
||||||
|
lastSessionId?.takeIf { it.isNotBlank() }?.let { ContinueLastModel(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The banner. Renders NOTHING when [model] is `null` (no last session). Tapping the whole surface fires
|
||||||
|
* [onContinue] with the session id to re-open. [variant] picks the stack vs sidebar layout.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun ContinueLastBanner(
|
||||||
|
model: ContinueLastModel?,
|
||||||
|
onContinue: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
variant: ContinueLastVariant = ContinueLastVariant.Stack,
|
||||||
|
) {
|
||||||
|
if (model == null) return
|
||||||
|
|
||||||
|
val shape: Shape = when (variant) {
|
||||||
|
ContinueLastVariant.Stack -> RectangleShape
|
||||||
|
ContinueLastVariant.Sidebar -> RoundedCornerShape(Radius.md12)
|
||||||
|
}
|
||||||
|
val widthModifier =
|
||||||
|
if (variant == ContinueLastVariant.Stack) Modifier.fillMaxWidth() else Modifier
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
shape = shape,
|
||||||
|
modifier = modifier
|
||||||
|
.then(widthModifier)
|
||||||
|
.clickable { onContinue(model.sessionId) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||||
|
) {
|
||||||
|
Text(text = "↻", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Spacing.xs2),
|
||||||
|
) {
|
||||||
|
Text(text = "继续上次会话", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
// Untrusted server-issued id — inert Text, no autolink/markdown (§8).
|
||||||
|
Text(
|
||||||
|
text = shortSessionId(model.sessionId),
|
||||||
|
style = WebTermType.metaMono,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(text = "›", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short, inert label for the (untrusted) session id — the first id segment, capped so it never wraps. */
|
||||||
|
private fun shortSessionId(sessionId: String): String =
|
||||||
|
sessionId.substringBefore('-').take(SHORT_ID_MAX)
|
||||||
|
|
||||||
|
private const val SHORT_ID_MAX: Int = 12
|
||||||
|
|
||||||
|
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Preview(name = "ContinueLastBanner — stack")
|
||||||
|
@Composable
|
||||||
|
private fun ContinueLastBannerStackPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
ContinueLastBanner(
|
||||||
|
model = ContinueLastModel("a1b2c3d4-5678-4abc-9def-000000000000"),
|
||||||
|
onContinue = {},
|
||||||
|
variant = ContinueLastVariant.Stack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "ContinueLastBanner — sidebar", widthDp = 220)
|
||||||
|
@Composable
|
||||||
|
private fun ContinueLastBannerSidebarPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
ContinueLastBanner(
|
||||||
|
model = ContinueLastModel("a1b2c3d4-5678-4abc-9def-000000000000"),
|
||||||
|
onContinue = {},
|
||||||
|
variant = ContinueLastVariant.Sidebar,
|
||||||
|
modifier = Modifier.padding(Spacing.md12),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import wang.yaojia.webterm.designsystem.DisplayStatus
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.designsystem.StatusBadge
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermCard
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.session.GateState
|
||||||
|
import wang.yaojia.webterm.viewmodels.GateDecision
|
||||||
|
import wang.yaojia.webterm.wire.GateKind
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
|
||||||
|
*
|
||||||
|
* The Android analogue of the iOS tool-gate card (public/tabs.ts:334-350): a `WebTermCard` holding
|
||||||
|
* a "needs me" status badge, the server-supplied [detail][GateState.detail] rendered as **inert
|
||||||
|
* [Text]** (untrusted OSC/tool text — no Markdown / autolink, plan §8), and two actions: **批准**
|
||||||
|
* (approve → `Approve(mode=null)`) and **拒绝** (reject).
|
||||||
|
*
|
||||||
|
* ### Epoch capture (the stale-guard seam)
|
||||||
|
* Each button hands back the [epoch][GateState.epoch] of the gate IT rendered, so a tap is bound to
|
||||||
|
* the gate the user actually saw. The screen (A21) wires `onDecide = { d, e -> vm.decide(d, e) }`;
|
||||||
|
* [GateViewModel.decide] then drops the tap if that epoch is no longer the live gate.
|
||||||
|
*
|
||||||
|
* This card is for [GateKind.TOOL] gates only; plan gates use [PlanGateSheet] (three-way).
|
||||||
|
* Layout / colors are device-QA; the wired decision + epoch capture is the load-bearing contract.
|
||||||
|
*
|
||||||
|
* @param onDecide invoked with the tapped [GateDecision] and this gate's epoch.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun GateBanner(
|
||||||
|
gate: GateState,
|
||||||
|
onDecide: (GateDecision, Int) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
WebTermCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
|
) {
|
||||||
|
StatusBadge(status = DisplayStatus.PendingApproval, showsLabel = true)
|
||||||
|
gate.detail?.let { detail ->
|
||||||
|
// INERT: untrusted server string rendered verbatim, single-line, no linkify (§8).
|
||||||
|
Text(
|
||||||
|
text = detail,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
|
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
|
||||||
|
Text(text = "拒绝")
|
||||||
|
}
|
||||||
|
Button(onClick = { onDecide(GateDecision.APPROVE, gate.epoch) }) {
|
||||||
|
Text(text = "批准")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Preview(name = "GateBanner")
|
||||||
|
@Composable
|
||||||
|
private fun GateBannerPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
GateBanner(
|
||||||
|
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
|
||||||
|
onDecide = { _, _ -> },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import wang.yaojia.webterm.designsystem.LayoutTokens
|
||||||
|
import wang.yaojia.webterm.designsystem.Opacity
|
||||||
|
import wang.yaojia.webterm.designsystem.Radius
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.session.KeyByteMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # KeyBar (A17) — the mobile IME-bypass touch key-bar + hardware-chord router.
|
||||||
|
*
|
||||||
|
* The Android port of `public/keybar.ts`: a horizontal strip of the Claude Code
|
||||||
|
* high-frequency keys a phone soft keyboard can't easily produce (Esc / Esc² /
|
||||||
|
* ⇧Tab / arrows / Enter / Ctrl-chords / Tab / `/`), most-used first.
|
||||||
|
*
|
||||||
|
* ### Byte source of truth (plan §6.3, A6)
|
||||||
|
* Every label→bytes lookup — buttons AND hardware chords — resolves through
|
||||||
|
* [KeyByteMap] (byte-for-byte matched to the web `KEY_MAP`). NO escape sequence is
|
||||||
|
* ever hand-written here; hand-writing `ESC[A` etc. is a review finding.
|
||||||
|
*
|
||||||
|
* ### IME bypass (plan §6.3 source #2, R8)
|
||||||
|
* A key-bar tap calls [emitKey] → `onSend(bytes)` DIRECTLY, mirroring the web
|
||||||
|
* `ws.send` bypass. It never routes through a `BasicTextField` / the emulator's
|
||||||
|
* `InputConnection`, so tapping a key never pops the soft keyboard. `onSend` is
|
||||||
|
* wired by A21 to `TerminalSessionController.sendInput`.
|
||||||
|
*
|
||||||
|
* ### DECCKM split (plan §6.3 source #3)
|
||||||
|
* Hardware keys are routed by [HardwareKeyRouter]: Esc / Ctrl-<letter> / ⇧Tab are
|
||||||
|
* mapped app-side via [KeyByteMap]; **arrows / Enter / (plain) Tab are LEFT to
|
||||||
|
* Termux's `KeyHandler`** (via `RemoteTerminalView.onKeyCommand` returning false)
|
||||||
|
* so `cursorKeysApplication` (DECCKM) still emits `ESC O A` — never `ESC [ A` —
|
||||||
|
* for vim/htop.
|
||||||
|
*
|
||||||
|
* Compose layout / IME-inset behaviour / real key events are DEVICE-QA (plan §7);
|
||||||
|
* the byte contract + the routing decision are the JVM-tested core.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One key-bar button: fixed glyph + Chinese caption + the [KeyByteMap.Key] it emits. */
|
||||||
|
public data class KeyBarButton(
|
||||||
|
val label: String,
|
||||||
|
val caption: String,
|
||||||
|
val key: KeyByteMap.Key,
|
||||||
|
val title: String,
|
||||||
|
val isPrimary: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button layout — most-used Claude Code keys first, mirroring the web
|
||||||
|
* `KEYBAR_BUTTONS` (public/keybar.ts) order, glyphs and captions 1:1. The 🎤 voice
|
||||||
|
* button (web A2) is out of scope for A17.
|
||||||
|
*/
|
||||||
|
public val KEYBAR_LAYOUT: List<KeyBarButton> = listOf(
|
||||||
|
KeyBarButton("Esc", "中断", KeyByteMap.Key.ESC, "Esc — interrupt Claude / dismiss", isPrimary = true),
|
||||||
|
KeyBarButton("Esc²", "回溯", KeyByteMap.Key.ESC_ESC, "Esc Esc — rewind / clear draft"),
|
||||||
|
KeyBarButton("⇧Tab", "模式", KeyByteMap.Key.SHIFT_TAB, "Shift+Tab — cycle plan / auto-accept mode"),
|
||||||
|
KeyBarButton("↑", "上一个", KeyByteMap.Key.ARROW_UP, "Up — previous option / history"),
|
||||||
|
KeyBarButton("↓", "下一个", KeyByteMap.Key.ARROW_DOWN, "Down — next option / history"),
|
||||||
|
KeyBarButton("⏎", "确认", KeyByteMap.Key.ENTER, "Enter — confirm"),
|
||||||
|
KeyBarButton("^C", "取消", KeyByteMap.Key.CTRL_C, "Ctrl+C — cancel / quit"),
|
||||||
|
KeyBarButton("^R", "搜历史", KeyByteMap.Key.CTRL_R, "Ctrl+R — reverse-search command history"),
|
||||||
|
KeyBarButton("^O", "详情", KeyByteMap.Key.CTRL_O, "Ctrl+O — expand transcript / tool detail"),
|
||||||
|
KeyBarButton("^L", "重绘", KeyByteMap.Key.CTRL_L, "Ctrl+L — redraw screen"),
|
||||||
|
KeyBarButton("^T", "任务", KeyByteMap.Key.CTRL_T, "Ctrl+T — toggle task list"),
|
||||||
|
KeyBarButton("^B", "后台", KeyByteMap.Key.CTRL_B, "Ctrl+B — background running task"),
|
||||||
|
KeyBarButton("^D", "退出", KeyByteMap.Key.CTRL_D, "Ctrl+D — exit session (EOF)"),
|
||||||
|
KeyBarButton("Tab", "补全", KeyByteMap.Key.TAB, "Tab — complete / toggle"),
|
||||||
|
KeyBarButton("←", "左移", KeyByteMap.Key.ARROW_LEFT, "Left — move cursor left"),
|
||||||
|
KeyBarButton("→", "右移", KeyByteMap.Key.ARROW_RIGHT, "Right — move cursor right"),
|
||||||
|
KeyBarButton("/", "命令", KeyByteMap.Key.SLASH, "Slash — command launcher"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single tap→wire funnel: resolve [key]'s bytes through [KeyByteMap] and hand
|
||||||
|
* them VERBATIM to [sink] (invariant #9 — no content filtering). This is exactly
|
||||||
|
* what a button's `onClick` invokes, and what the JVM byte-contract test drives.
|
||||||
|
*/
|
||||||
|
internal fun emitKey(key: KeyByteMap.Key, sink: (String) -> Unit) {
|
||||||
|
sink(KeyByteMap.bytes(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Web parity: the key-bar is a mobile affordance, hidden on wide screens (web hides it >768px). */
|
||||||
|
internal const val WIDE_SCREEN_MAX_DP: Int = 768
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the key-bar should render: shown on narrow (phone-width) screens only,
|
||||||
|
* AND hidden when a hardware keyboard is present (the physical keys make the
|
||||||
|
* touch bar redundant — the iPad-equivalent auto-hide, plan §"iPad-equivalent").
|
||||||
|
*/
|
||||||
|
internal fun shouldShowKeyBar(screenWidthDp: Int, hardwareKeyboardPresent: Boolean): Boolean =
|
||||||
|
screenWidthDp <= WIDE_SCREEN_MAX_DP && !hardwareKeyboardPresent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort hardware-keyboard heuristic from the current [Configuration]
|
||||||
|
* (accepted-less-reliable, plan §"iPad-equivalent" — no `GCKeyboard` equivalent):
|
||||||
|
* an alphabetic keyboard that is currently exposed (lid open / dock attached).
|
||||||
|
*/
|
||||||
|
internal fun isHardwareKeyboardPresent(keyboard: Int, hardKeyboardHidden: Int): Boolean =
|
||||||
|
keyboard == Configuration.KEYBOARD_QWERTY && hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mobile key-bar overlay. Pinned ABOVE the IME via [WindowInsets.ime] (falling
|
||||||
|
* back to the navigation-bar inset when the keyboard is hidden), horizontally
|
||||||
|
* scrollable so every key stays reachable on a narrow phone. Renders nothing on
|
||||||
|
* wide screens or when a hardware keyboard is present ([shouldShowKeyBar]).
|
||||||
|
*
|
||||||
|
* @param onSend the IME-bypass sink — A21 wires it to `TerminalSessionController.sendInput`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun KeyBar(
|
||||||
|
onSend: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val config = LocalConfiguration.current
|
||||||
|
val hardwareKeyboard = isHardwareKeyboardPresent(config.keyboard, config.hardKeyboardHidden)
|
||||||
|
if (!shouldShowKeyBar(config.screenWidthDp, hardwareKeyboard)) return
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars))
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||||
|
) {
|
||||||
|
for (button in KEYBAR_LAYOUT) {
|
||||||
|
KeyBarKey(button = button, onSend = onSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One inert key button. A tap emits bytes directly via [emitKey] — never through a text field. */
|
||||||
|
@Composable
|
||||||
|
private fun KeyBarKey(button: KeyBarButton, onSend: (String) -> Unit) {
|
||||||
|
val container =
|
||||||
|
if (button.isPrimary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
val content =
|
||||||
|
if (button.isPrimary) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
onClick = { emitKey(button.key, onSend) },
|
||||||
|
shape = RoundedCornerShape(Radius.sm8),
|
||||||
|
color = container,
|
||||||
|
contentColor = content,
|
||||||
|
modifier = Modifier
|
||||||
|
.defaultMinSize(minWidth = LayoutTokens.minHitTarget, minHeight = LayoutTokens.minHitTarget)
|
||||||
|
.semantics { contentDescription = button.title },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
|
||||||
|
) {
|
||||||
|
Text(text = button.label, style = MaterialTheme.typography.labelLarge)
|
||||||
|
Text(
|
||||||
|
text = button.caption,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = content.copy(alpha = Opacity.stale),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DECCKM split router (plan §6.3 source #3): decides whether a hardware key is
|
||||||
|
* handled app-side (mapped through [KeyByteMap]) or LEFT to Termux's `KeyHandler`.
|
||||||
|
*
|
||||||
|
* Pure decision logic (primitive keyCode + modifier booleans, mirroring the Android
|
||||||
|
* `KeyEvent` constants) so it is JVM-unit-testable with no device / Robolectric.
|
||||||
|
* The Android glue lives in [handle].
|
||||||
|
*/
|
||||||
|
public object HardwareKeyRouter {
|
||||||
|
|
||||||
|
/** The ONLY Ctrl-<letter> chords the app claims — exactly the web key-bar's control keys. */
|
||||||
|
private val CTRL_LETTER_KEYS: Map<Int, KeyByteMap.Key> = mapOf(
|
||||||
|
KeyEvent.KEYCODE_C to KeyByteMap.Key.CTRL_C,
|
||||||
|
KeyEvent.KEYCODE_R to KeyByteMap.Key.CTRL_R,
|
||||||
|
KeyEvent.KEYCODE_O to KeyByteMap.Key.CTRL_O,
|
||||||
|
KeyEvent.KEYCODE_L to KeyByteMap.Key.CTRL_L,
|
||||||
|
KeyEvent.KEYCODE_T to KeyByteMap.Key.CTRL_T,
|
||||||
|
KeyEvent.KEYCODE_B to KeyByteMap.Key.CTRL_B,
|
||||||
|
KeyEvent.KEYCODE_D to KeyByteMap.Key.CTRL_D,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a hardware key to a routing decision.
|
||||||
|
*
|
||||||
|
* - **⇧Tab** → app-handled `ESC[Z`; **plain Tab** defers (completion / DECCKM nav).
|
||||||
|
* - **Esc** (unmodified) → app-handled `ESC`.
|
||||||
|
* - **Ctrl+{C,R,O,L,T,B,D}** → app-handled control byte via [KeyByteMap].
|
||||||
|
* - **Everything else** (arrows, Enter, plain Tab, plain letters, unmapped Ctrl
|
||||||
|
* chords) → [HardwareKeyResult.DeferToTerminal] so Termux's `KeyHandler`
|
||||||
|
* emits the DECCKM-correct sequence (`ESC O A` under application-cursor mode).
|
||||||
|
*/
|
||||||
|
public fun resolve(keyCode: Int, ctrl: Boolean, shift: Boolean, alt: Boolean): HardwareKeyResult {
|
||||||
|
// ⇧Tab is app-handled; plain Tab is left to Termux (do this BEFORE the generic defer).
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_TAB) {
|
||||||
|
return if (shift && !ctrl && !alt) app(KeyByteMap.Key.SHIFT_TAB) else HardwareKeyResult.DeferToTerminal
|
||||||
|
}
|
||||||
|
// Esc → interrupt Claude; a modified Escape is not our chord.
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_ESCAPE) {
|
||||||
|
return if (!ctrl && !shift && !alt) app(KeyByteMap.Key.ESC) else HardwareKeyResult.DeferToTerminal
|
||||||
|
}
|
||||||
|
// The mapped Ctrl-<letter> chords only; unmapped Ctrl letters defer to Termux.
|
||||||
|
if (ctrl && !alt) {
|
||||||
|
CTRL_LETTER_KEYS[keyCode]?.let { return app(it) }
|
||||||
|
}
|
||||||
|
// Arrows / Enter / plain Tab / plain keys → Termux KeyHandler (DECCKM-correct, §6.3).
|
||||||
|
return HardwareKeyResult.DeferToTerminal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android glue for `RemoteTerminalView.onKeyCommand`: extract modifiers from
|
||||||
|
* [event], route via [resolve], and on an app-handled DOWN emit the bytes to
|
||||||
|
* [onSend], returning true to CONSUME the event. Returns false otherwise so the
|
||||||
|
* stock Termux path (`KeyHandler`) handles arrows/Enter/Tab. Device-QA'd
|
||||||
|
* (touches `KeyEvent` accessor methods).
|
||||||
|
*/
|
||||||
|
public fun handle(keyCode: Int, event: KeyEvent, onSend: (String) -> Unit): Boolean {
|
||||||
|
if (event.action != KeyEvent.ACTION_DOWN) return false
|
||||||
|
return when (val result = resolve(keyCode, event.isCtrlPressed, event.isShiftPressed, event.isAltPressed)) {
|
||||||
|
is HardwareKeyResult.AppHandled -> {
|
||||||
|
onSend(result.bytes)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
HardwareKeyResult.DeferToTerminal -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun app(key: KeyByteMap.Key): HardwareKeyResult =
|
||||||
|
HardwareKeyResult.AppHandled(KeyByteMap.bytes(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The routing decision for one hardware key (the DECCKM split, plan §6.3). */
|
||||||
|
public sealed interface HardwareKeyResult {
|
||||||
|
/** The app consumes the key and sends these exact [bytes] (resolved via [KeyByteMap]). */
|
||||||
|
public data class AppHandled(val bytes: String) : HardwareKeyResult
|
||||||
|
|
||||||
|
/** The key is left to Termux's `KeyHandler` so DECCKM stays correct (arrows/Enter/Tab). */
|
||||||
|
public data object DeferToTerminal : HardwareKeyResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Preview(name = "KeyBar")
|
||||||
|
@Composable
|
||||||
|
private fun KeyBarPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
KeyBar(onSend = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.session.GateState
|
||||||
|
import wang.yaojia.webterm.viewmodels.GateDecision
|
||||||
|
import wang.yaojia.webterm.wire.GateKind
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # PlanGateSheet (A22) — the plan-gate three-way approval bottom sheet.
|
||||||
|
*
|
||||||
|
* The Android analogue of the iOS ExitPlanMode gate (public/tabs.ts:334-350): a Material 3
|
||||||
|
* [ModalBottomSheet] with three actions —
|
||||||
|
* - **批准** → approve + review (`Approve(mode="default")`, the default action),
|
||||||
|
* - **批准并自动接受** → approve + auto-accept edits (`Approve(mode="acceptEdits")`),
|
||||||
|
* - **继续计划** → keep planning (wires to `reject`).
|
||||||
|
*
|
||||||
|
* `mode` is the **TOP-LEVEL** `approve.mode` wire key (plan §1/§4.1) — carried by the resolved
|
||||||
|
* [GateState.Affordance.clientMessage] via [GateViewModel.decide], never hand-built here. The
|
||||||
|
* server-supplied [detail][GateState.detail] renders as **inert [Text]** (untrusted, §8).
|
||||||
|
*
|
||||||
|
* ### Epoch capture (stale-guard seam)
|
||||||
|
* Each action hands back this gate's [epoch][GateState.epoch] so the tap is bound to the gate on
|
||||||
|
* screen; [GateViewModel.decide] drops it if the live gate has moved on. Dismissing the sheet
|
||||||
|
* (scrim/back) resolves NOTHING — the gate stays held until an explicit decision.
|
||||||
|
*
|
||||||
|
* Sheet presentation / detents are device-QA; the wired decisions + epoch capture are the contract.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
public fun PlanGateSheet(
|
||||||
|
gate: GateState,
|
||||||
|
onDecide: (GateDecision, Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = Spacing.lg16, vertical = Spacing.md12),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
|
) {
|
||||||
|
Text(text = "批准执行计划", style = MaterialTheme.typography.titleMedium)
|
||||||
|
gate.detail?.let { detail ->
|
||||||
|
// INERT: untrusted plan text rendered verbatim, no linkify/markdown (§8).
|
||||||
|
Text(
|
||||||
|
text = detail,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(text = "批准") }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onDecide(GateDecision.ACCEPT_EDITS, gate.epoch) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(text = "批准并自动接受") }
|
||||||
|
TextButton(
|
||||||
|
onClick = { onDecide(GateDecision.REJECT, gate.epoch) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(text = "继续计划") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Preview(name = "PlanGateSheet")
|
||||||
|
@Composable
|
||||||
|
private fun PlanGateSheetPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
PlanGateSheet(
|
||||||
|
gate = GateState(kind = GateKind.PLAN, detail = "Refactor the auth module into 3 files.", epoch = 5),
|
||||||
|
onDecide = { _, _ -> },
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
|
import wang.yaojia.webterm.nav.LayoutMode
|
||||||
|
import wang.yaojia.webterm.nav.PointerMenuPolicy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
|
||||||
|
*
|
||||||
|
* The Android analogue of iOS's pointer context menu (open-in-cwd / kill / copy). A reusable wrapper
|
||||||
|
* that installs a `Modifier.pointerInput` detecting a **secondary** pointer button (mouse right-click,
|
||||||
|
* trackpad two-finger / secondary click) and anchors a Material `DropdownMenu` at the click position.
|
||||||
|
*
|
||||||
|
* ### Gating (plan §5 A26, §"iPad-equivalent", R13, §8)
|
||||||
|
* The whole affordance is gated by the pure [PointerMenuPolicy.enabled] predicate — enabled **only** in
|
||||||
|
* [LayoutMode.LIST_DETAIL] on a genuine tablet (`smallestScreenWidthDp >= 600`). When disabled the
|
||||||
|
* wrapper is a transparent pass-through (no pointer interception, no menu) so phones behave exactly as
|
||||||
|
* before. This is `Modifier.pointerInput` + an anchored `DropdownMenu` — deliberately NOT Compose
|
||||||
|
* Desktop's `ContextMenuArea`, which is not stable on Android (platform review).
|
||||||
|
*
|
||||||
|
* The pointer detection / menu placement / gesture behaviour is device-QA (plan §7); the gate is the
|
||||||
|
* JVM-tested [PointerMenuPolicy] core.
|
||||||
|
*
|
||||||
|
* @param mode the current [LayoutMode] from [wang.yaojia.webterm.nav.LayoutPolicy].
|
||||||
|
* @param actions the menu entries (e.g. 在当前目录开新会话 / 终止 / 复制), each a label + callback.
|
||||||
|
* @param smallestScreenWidthDp the device's smallest-width; defaults to the current configuration.
|
||||||
|
* @param content the wrapped content the secondary-click targets.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun PointerContextMenu(
|
||||||
|
mode: LayoutMode,
|
||||||
|
actions: List<ContextMenuAction>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
smallestScreenWidthDp: Int = LocalConfiguration.current.smallestScreenWidthDp,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
// Gate: on a phone / compact layout the menu never exists and the pointer is never intercepted.
|
||||||
|
if (!PointerMenuPolicy.enabled(mode, smallestScreenWidthDp)) {
|
||||||
|
Box(modifier = modifier) { content() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
var anchor by remember { mutableStateOf(DpOffset.Zero) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.pointerInput(actions) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
if (event.buttons.isSecondaryPressed) {
|
||||||
|
val position = event.changes.first().position
|
||||||
|
anchor = with(density) { DpOffset(position.x.toDp(), position.y.toDp()) }
|
||||||
|
expanded = true
|
||||||
|
// Consume so the underlying content doesn't also react to the secondary press.
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
offset = anchor,
|
||||||
|
) {
|
||||||
|
for (action in actions) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(action.label) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
action.onClick()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */
|
||||||
|
public data class ContextMenuAction(val label: String, val onClick: () -> Unit)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.SuggestionChip
|
||||||
|
import androidx.compose.material3.SuggestionChipDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import wang.yaojia.webterm.designsystem.Radius
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # QuickReply (A25) — the floating quick-reply chip row.
|
||||||
|
*
|
||||||
|
* A horizontal strip of the editable palette ([QuickReplyStore]). It **floats only
|
||||||
|
* while a gate is held** (plan §1 quick-reply row) — the moment Claude is waiting on
|
||||||
|
* the user is exactly when a one-tap canned reply is useful — and each chip, on tap,
|
||||||
|
* sends its [text][QuickReplyChip.text] VERBATIM via the injected [onSend].
|
||||||
|
*
|
||||||
|
* ### The load-bearing seam (JVM-tested; layout/float animation are device-QA §7)
|
||||||
|
* - **Visibility:** [shouldShowQuickReply] gates the row on `gateHeld && hasChips`.
|
||||||
|
* - **Send:** a tap calls [sendQuickReply] → `onSend(chip.text)` with NO added byte.
|
||||||
|
* A21 wires `onSend` to `TerminalSessionController.sendInput` (invariant #1 —
|
||||||
|
* input passed through verbatim), and `gateHeld` to the [GateViewModel]
|
||||||
|
* [wang.yaojia.webterm.viewmodels.GateViewModel] held-gate state.
|
||||||
|
*
|
||||||
|
* The chip label is the user's OWN palette text (not untrusted server data), rendered
|
||||||
|
* as a single-line, ellipsised [Text] — no Markdown / autolink.
|
||||||
|
*
|
||||||
|
* @param chips the palette to show, in display order.
|
||||||
|
* @param gateHeld whether a gate is currently held (drives the float).
|
||||||
|
* @param onSend receives the tapped chip's exact text.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun QuickReply(
|
||||||
|
chips: List<QuickReplyChip>,
|
||||||
|
gateHeld: Boolean,
|
||||||
|
onSend: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (!shouldShowQuickReply(gateHeld = gateHeld, hasChips = chips.isNotEmpty())) return
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(Radius.pill),
|
||||||
|
tonalElevation = Spacing.xs2,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
|
) {
|
||||||
|
chips.forEach { chip ->
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = { sendQuickReply(chip, onSend) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = chip.text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(Radius.pill),
|
||||||
|
colors = SuggestionChipDefaults.suggestionChipColors(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tap→emit seam (mirrors `KeyBar.emitKey`): send the chip's [text][QuickReplyChip.text]
|
||||||
|
* VERBATIM. Kept as a top-level function so the exact-byte contract is JVM-testable
|
||||||
|
* without driving Compose.
|
||||||
|
*/
|
||||||
|
internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) {
|
||||||
|
onSend(chip.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row floats only while a gate is held AND the palette is non-empty. */
|
||||||
|
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
|
||||||
|
gateHeld && hasChips
|
||||||
|
|
||||||
|
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Preview(name = "QuickReply (gate held)")
|
||||||
|
@Composable
|
||||||
|
private fun QuickReplyPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
QuickReply(
|
||||||
|
chips = QuickReplyDefaults.chips,
|
||||||
|
gateHeld = true,
|
||||||
|
onSend = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.add
|
||||||
|
import kotlinx.serialization.json.addJsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # QuickReplyChip (A25) — one entry of the editable quick-reply palette.
|
||||||
|
*
|
||||||
|
* A canned snippet the user can tap to send while a gate is held. [id] is a stable
|
||||||
|
* identity used for edit/remove/reorder (survives a [text] change); [text] is the
|
||||||
|
* exact string sent VERBATIM as input via `TerminalSessionController.sendInput`
|
||||||
|
* (plan §1 quick-reply row / §5 A25). No trailing byte is added — the palette owner
|
||||||
|
* types whatever they want submitted.
|
||||||
|
*/
|
||||||
|
public data class QuickReplyChip(
|
||||||
|
val id: String,
|
||||||
|
val text: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sensible starter palette, used the first time the store is read (no stored blob
|
||||||
|
* yet). Ids are stable so a default chip can still be edited/removed/reordered by id.
|
||||||
|
* Once the user mutates the palette (including deleting every chip → an empty list),
|
||||||
|
* their choice is persisted and these defaults are never re-seeded.
|
||||||
|
*/
|
||||||
|
public object QuickReplyDefaults {
|
||||||
|
public val chips: List<QuickReplyChip> = listOf(
|
||||||
|
QuickReplyChip("qr-continue", "continue"),
|
||||||
|
QuickReplyChip("qr-yes", "yes"),
|
||||||
|
QuickReplyChip("qr-no", "no"),
|
||||||
|
QuickReplyChip("qr-1", "1"),
|
||||||
|
QuickReplyChip("qr-2", "2"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frozen CRUD contract for the editable quick-reply palette (plan §5 A25). Every
|
||||||
|
* mutation returns the NEW list (immutable style, mirrors [HostStore]
|
||||||
|
* [wang.yaojia.webterm.hostregistry.HostStore]); nothing is mutated in place.
|
||||||
|
* Cross-device sync is explicitly OUT of scope — each device's store is independent
|
||||||
|
* (plan §1 "Cross-device palette sync" DEFERRED).
|
||||||
|
*
|
||||||
|
* Implementations: [DataStoreQuickReplyStore] (real, Preferences-DataStore-backed)
|
||||||
|
* and [InMemoryQuickReplyStore] (in-`main` double for JVM tests / previews).
|
||||||
|
*/
|
||||||
|
public interface QuickReplyStore {
|
||||||
|
/** The palette in display order (defaults on first read; the user's list thereafter). */
|
||||||
|
public suspend fun loadAll(): List<QuickReplyChip>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a new chip carrying [text] (a fresh id is minted). Blank [text] is an
|
||||||
|
* explicit no-op (returns the list unchanged) — a boundary guard so the palette
|
||||||
|
* never accumulates empty chips. Non-blank [text] is stored VERBATIM (leading /
|
||||||
|
* trailing whitespace preserved). Returns the new list.
|
||||||
|
*/
|
||||||
|
public suspend fun add(text: String): List<QuickReplyChip>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the [text] of the chip with [id] (position preserved). Unknown [id] or
|
||||||
|
* blank [text] → no-op (unchanged list). Returns the new list.
|
||||||
|
*/
|
||||||
|
public suspend fun edit(id: String, text: String): List<QuickReplyChip>
|
||||||
|
|
||||||
|
/** Remove the chip with [id]. Unknown [id] is a no-op. Returns the new list. */
|
||||||
|
public suspend fun remove(id: String): List<QuickReplyChip>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the chip at [fromIndex] to [toIndex] (drag-reorder). Out-of-range indices
|
||||||
|
* are a no-op (returns the unchanged list). Returns the new list.
|
||||||
|
*/
|
||||||
|
public suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pure collection transforms shared by all QuickReplyStore implementations (DRY) ──
|
||||||
|
// Never mutate the receiver — always return a fresh list. `internal` so both stores
|
||||||
|
// and the same-module unit tests use them without widening the public surface.
|
||||||
|
|
||||||
|
/** Append a chip with [id]/[text], unless [text] is blank (boundary guard → no-op). */
|
||||||
|
internal fun List<QuickReplyChip>.adding(text: String, id: String): List<QuickReplyChip> =
|
||||||
|
if (text.isBlank()) this else this + QuickReplyChip(id, text)
|
||||||
|
|
||||||
|
/** Replace the [text] of the chip with [id] (position kept). Blank text / unknown id → no-op. */
|
||||||
|
internal fun List<QuickReplyChip>.editing(id: String, text: String): List<QuickReplyChip> =
|
||||||
|
if (text.isBlank()) this else map { if (it.id == id) it.copy(text = text) else it }
|
||||||
|
|
||||||
|
/** A copy without the chip whose id equals [id] (unknown id → an unchanged copy). */
|
||||||
|
internal fun List<QuickReplyChip>.removingId(id: String): List<QuickReplyChip> =
|
||||||
|
filter { it.id != id }
|
||||||
|
|
||||||
|
/** Immutable move [from] → [to]; out-of-range (either end) is a no-op. */
|
||||||
|
internal fun List<QuickReplyChip>.moving(from: Int, to: Int): List<QuickReplyChip> {
|
||||||
|
if (from !in indices || to !in indices || from == to) return this
|
||||||
|
val next = toMutableList()
|
||||||
|
next.add(to, next.removeAt(from))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure JSON codec for the persisted palette (JVM-testable, no Context). A chip list
|
||||||
|
* ⇆ a JSON array of `{"id","text"}` objects. Encode is total; decode is defensive —
|
||||||
|
* the blob is untrusted at rest, so a corrupt array or a chip missing a string
|
||||||
|
* id/text is dropped rather than crashing. Uses the kotlinx JSON element API (no
|
||||||
|
* `@Serializable` codegen → no serialization compiler plugin needed on :app; the
|
||||||
|
* runtime lib is on the classpath via `:wire-protocol`'s `api` dependency).
|
||||||
|
*/
|
||||||
|
internal object QuickReplyCodec {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||||
|
|
||||||
|
fun encode(chips: List<QuickReplyChip>): String =
|
||||||
|
buildJsonArray {
|
||||||
|
chips.forEach { chip ->
|
||||||
|
addJsonObject {
|
||||||
|
put("id", chip.id)
|
||||||
|
put("text", chip.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
fun decode(raw: String?): List<QuickReplyChip> {
|
||||||
|
if (raw.isNullOrBlank()) return emptyList()
|
||||||
|
val array = try {
|
||||||
|
json.parseToJsonElement(raw) as? JsonArray ?: return emptyList()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return emptyList() // corrupt blob at rest → start clean, never crash
|
||||||
|
}
|
||||||
|
return array.mapNotNull { element ->
|
||||||
|
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||||
|
val id = obj["id"].asStringOrNull()
|
||||||
|
val text = obj["text"].asStringOrNull()
|
||||||
|
if (id.isNullOrEmpty() || text.isNullOrEmpty()) null else QuickReplyChip(id, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? =
|
||||||
|
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences-DataStore-backed [QuickReplyStore]. The whole palette is ONE JSON
|
||||||
|
* string under [PALETTE_KEY], serialized by [QuickReplyCodec]; writes are an atomic
|
||||||
|
* read-modify-write via [DataStore.edit], reusing the shared immutable transforms.
|
||||||
|
*
|
||||||
|
* Key-absent (first run) reads/seeds [QuickReplyDefaults]; once any write lands, the
|
||||||
|
* stored list wins — including an empty list, so "delete all" is respected. The
|
||||||
|
* [DataStore] is injected (constructed from a Context in :app DI), so this class has
|
||||||
|
* no Android-framework surface of its own and is exercised in JVM tests via an
|
||||||
|
* in-memory `DataStore<Preferences>` double.
|
||||||
|
*/
|
||||||
|
public class DataStoreQuickReplyStore(
|
||||||
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
private val newId: () -> String = { UUID.randomUUID().toString() },
|
||||||
|
) : QuickReplyStore {
|
||||||
|
|
||||||
|
override suspend fun loadAll(): List<QuickReplyChip> {
|
||||||
|
val raw = dataStore.data.first()[PALETTE_KEY]
|
||||||
|
return if (raw == null) QuickReplyDefaults.chips else QuickReplyCodec.decode(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun add(text: String): List<QuickReplyChip> =
|
||||||
|
writeTransform { it.adding(text, newId()) }
|
||||||
|
|
||||||
|
override suspend fun edit(id: String, text: String): List<QuickReplyChip> =
|
||||||
|
writeTransform { it.editing(id, text) }
|
||||||
|
|
||||||
|
override suspend fun remove(id: String): List<QuickReplyChip> =
|
||||||
|
writeTransform { it.removingId(id) }
|
||||||
|
|
||||||
|
override suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip> =
|
||||||
|
writeTransform { it.moving(fromIndex, toIndex) }
|
||||||
|
|
||||||
|
private suspend fun writeTransform(
|
||||||
|
transform: (List<QuickReplyChip>) -> List<QuickReplyChip>,
|
||||||
|
): List<QuickReplyChip> {
|
||||||
|
lateinit var updated: List<QuickReplyChip>
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
val raw = prefs[PALETTE_KEY]
|
||||||
|
val current = if (raw == null) QuickReplyDefaults.chips else QuickReplyCodec.decode(raw)
|
||||||
|
updated = transform(current)
|
||||||
|
prefs[PALETTE_KEY] = QuickReplyCodec.encode(updated)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val PALETTE_KEY = stringPreferencesKey("quickReplyPalette")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory [QuickReplyStore] — lives in `main` (not `test`) so it doubles for the
|
||||||
|
* DataStore store in JVM CRUD tests AND backs Compose previews. Seeded with
|
||||||
|
* [QuickReplyDefaults] by default. A [Mutex] serializes the read-modify-write so
|
||||||
|
* concurrent callers can't interleave; state is replaced wholesale on every change.
|
||||||
|
*/
|
||||||
|
public class InMemoryQuickReplyStore(
|
||||||
|
initial: List<QuickReplyChip> = QuickReplyDefaults.chips,
|
||||||
|
private val newId: () -> String = { UUID.randomUUID().toString() },
|
||||||
|
) : QuickReplyStore {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var chips: List<QuickReplyChip> = initial.toList()
|
||||||
|
|
||||||
|
override suspend fun loadAll(): List<QuickReplyChip> = mutex.withLock { chips }
|
||||||
|
|
||||||
|
override suspend fun add(text: String): List<QuickReplyChip> =
|
||||||
|
write { it.adding(text, newId()) }
|
||||||
|
|
||||||
|
override suspend fun edit(id: String, text: String): List<QuickReplyChip> =
|
||||||
|
write { it.editing(id, text) }
|
||||||
|
|
||||||
|
override suspend fun remove(id: String): List<QuickReplyChip> =
|
||||||
|
write { it.removingId(id) }
|
||||||
|
|
||||||
|
override suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip> =
|
||||||
|
write { it.moving(fromIndex, toIndex) }
|
||||||
|
|
||||||
|
private suspend fun write(
|
||||||
|
transform: (List<QuickReplyChip>) -> List<QuickReplyChip>,
|
||||||
|
): List<QuickReplyChip> = mutex.withLock {
|
||||||
|
val updated = transform(chips)
|
||||||
|
chips = updated
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.session.Connection
|
||||||
|
import wang.yaojia.webterm.session.ConnectionState
|
||||||
|
import wang.yaojia.webterm.session.Exited
|
||||||
|
import wang.yaojia.webterm.session.FailureReason
|
||||||
|
import wang.yaojia.webterm.session.SessionEvent
|
||||||
|
import wang.yaojia.webterm.wire.WireConstants
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # ReconnectBanner (A21) — the connection-status / EXIT banner row.
|
||||||
|
*
|
||||||
|
* The Android port of iOS `ReconnectBanner` + `TerminalViewModel.bannerModel` (plan §1, §5 A21). A
|
||||||
|
* single strip pinned above the terminal that reflects the engine's connection lifecycle:
|
||||||
|
* - `connecting` — first dial (spinner);
|
||||||
|
* - `reconnecting(attempt, countdown)` — the back-off ladder (spinner);
|
||||||
|
* - `failed(replayTooLarge)` — a NON-retryable wall: actionable copy, **NO spinner**, "新会话" action;
|
||||||
|
* - `exited(code, reason)` — the shell is gone: `code == -1` is a spawn-failure (reason required),
|
||||||
|
* otherwise a normal exit; both offer "开新会话".
|
||||||
|
*
|
||||||
|
* ### Precedence (the load-bearing rule — plan §1)
|
||||||
|
* **Terminal phases (`failed` / `exited`) OUTRANK the transient ones (`connecting` / `reconnecting`).**
|
||||||
|
* Once the session is terminally failed or exited, a stray transient connection event must never
|
||||||
|
* overwrite the banner — the reducer [bannerModel] enforces this. This mirrors the iOS `bannerModel`
|
||||||
|
* computed property where the exit/failure state is checked before the reconnect state.
|
||||||
|
*
|
||||||
|
* ### Inert rendering (plan §8)
|
||||||
|
* The exit `reason` is an untrusted server string — it is rendered as a plain, inert Compose [Text]
|
||||||
|
* with no Markdown / autolink / `AnnotatedString` linkify.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the banner shows, derived purely from the [SessionEvent] stream by [bannerModel]. A sealed model
|
||||||
|
* so the precedence rule and the "no-spinner on failure" rule are data, unit-testable without a device.
|
||||||
|
*/
|
||||||
|
public sealed interface BannerModel {
|
||||||
|
/** Whether a progress spinner is shown — true ONLY for the transient (retrying) phases. */
|
||||||
|
public val showsSpinner: Boolean
|
||||||
|
|
||||||
|
/** Whether the banner offers a "new session in cwd" action (`attach(null, cwd)`). */
|
||||||
|
public val offersNewSession: Boolean
|
||||||
|
|
||||||
|
/** Connected / idle — nothing to show. */
|
||||||
|
public data object Hidden : BannerModel {
|
||||||
|
override val showsSpinner: Boolean get() = false
|
||||||
|
override val offersNewSession: Boolean get() = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first connection is being established. */
|
||||||
|
public data object Connecting : BannerModel {
|
||||||
|
override val showsSpinner: Boolean get() = true
|
||||||
|
override val offersNewSession: Boolean get() = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The transport dropped; retry [attempt] fires after [countdown] (the [ConnectionState.Reconnecting] ladder). */
|
||||||
|
public data class Reconnecting(val attempt: Int, val countdown: Duration) : BannerModel {
|
||||||
|
override val showsSpinner: Boolean get() = true
|
||||||
|
override val offersNewSession: Boolean get() = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
|
||||||
|
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
|
||||||
|
*/
|
||||||
|
public data class Failed(val reason: FailureReason) : BannerModel {
|
||||||
|
override val showsSpinner: Boolean get() = false
|
||||||
|
override val offersNewSession: Boolean get() = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shell exited. [isSpawnFailure] (`code == -1`) means the PTY never spawned — [reason] is then
|
||||||
|
* required and drives the copy. Either way the session is over; offers "开新会话".
|
||||||
|
*/
|
||||||
|
public data class Exited(val code: Int, val reason: String?) : BannerModel {
|
||||||
|
override val showsSpinner: Boolean get() = false
|
||||||
|
override val offersNewSession: Boolean get() = true
|
||||||
|
|
||||||
|
/** `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) → the spawn itself failed. */
|
||||||
|
public val isSpawnFailure: Boolean get() = code == WireConstants.SPAWN_FAILED_EXIT_CODE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PURE reducer: fold one [SessionEvent] into the [current] [BannerModel]. Only connection-lifecycle and
|
||||||
|
* exit events affect the banner; every other event (`Output`/`Gate`/`Digest`/`Telemetry`/`Adopted`)
|
||||||
|
* leaves it unchanged.
|
||||||
|
*
|
||||||
|
* The precedence rule (plan §1): the terminal phases ([BannerModel.Failed] / [BannerModel.Exited]) are
|
||||||
|
* STICKY over the transient phases — a `connecting`/`reconnecting`/`connected`/`closed` event arriving
|
||||||
|
* after the session already failed or exited does NOT override the terminal banner. A fresh terminal
|
||||||
|
* event always wins (a later exit/failure replaces an earlier one).
|
||||||
|
*/
|
||||||
|
public fun bannerModel(current: BannerModel, event: SessionEvent): BannerModel = when (event) {
|
||||||
|
is Exited -> BannerModel.Exited(event.code, event.reason) // terminal — always wins
|
||||||
|
is Connection -> reduceConnection(current, event.state)
|
||||||
|
else -> current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reduceConnection(current: BannerModel, state: ConnectionState): BannerModel = when (state) {
|
||||||
|
is ConnectionState.Failed -> BannerModel.Failed(state.reason) // terminal — always wins
|
||||||
|
ConnectionState.Connecting -> current.orKeepTerminal(BannerModel.Connecting)
|
||||||
|
is ConnectionState.Reconnecting -> current.orKeepTerminal(BannerModel.Reconnecting(state.attempt, state.next))
|
||||||
|
ConnectionState.Connected -> current.orKeepTerminal(BannerModel.Hidden)
|
||||||
|
ConnectionState.Closed -> current.orKeepTerminal(BannerModel.Hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A terminal banner outranks any transient update; otherwise take the [transient] one. */
|
||||||
|
private fun BannerModel.orKeepTerminal(transient: BannerModel): BannerModel =
|
||||||
|
if (this is BannerModel.Failed || this is BannerModel.Exited) this else transient
|
||||||
|
|
||||||
|
// ── UI ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The banner row. Renders NOTHING for [BannerModel.Hidden]. [onNewSession] fires the "new session in
|
||||||
|
* cwd" action (the exit-banner and replay-too-large affordances both route to it).
|
||||||
|
*
|
||||||
|
* Compose layout / spinner animation is device-QA (plan §7); the reduced [model] + its copy/affordance
|
||||||
|
* mapping is the JVM-tested core.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun ReconnectBanner(
|
||||||
|
model: BannerModel,
|
||||||
|
onNewSession: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (model is BannerModel.Hidden) return
|
||||||
|
|
||||||
|
val container = when (model) {
|
||||||
|
is BannerModel.Failed, is BannerModel.Exited -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
val content = when (model) {
|
||||||
|
is BannerModel.Failed, is BannerModel.Exited -> MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(color = container, contentColor = content, modifier = modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||||
|
) {
|
||||||
|
if (model.showsSpinner) {
|
||||||
|
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
// Untrusted server strings (exit reason) render as inert Text — no autolink/markdown (§8).
|
||||||
|
Text(
|
||||||
|
text = bannerCopy(model),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (model.offersNewSession) {
|
||||||
|
TextButton(onClick = onNewSession) { Text(newSessionLabel(model)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Chinese status copy for [model]. Kept separate from the reducer so copy never leaks into logic. */
|
||||||
|
private fun bannerCopy(model: BannerModel): String = when (model) {
|
||||||
|
BannerModel.Hidden -> ""
|
||||||
|
BannerModel.Connecting -> "连接中…"
|
||||||
|
is BannerModel.Reconnecting ->
|
||||||
|
"连接断开,重连中(第 ${model.attempt} 次,${model.countdown.inWholeSeconds} 秒后重试)"
|
||||||
|
is BannerModel.Failed -> when (model.reason) {
|
||||||
|
FailureReason.REPLAY_TOO_LARGE ->
|
||||||
|
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
|
||||||
|
}
|
||||||
|
is BannerModel.Exited ->
|
||||||
|
if (model.isSpawnFailure) {
|
||||||
|
"会话启动失败:${model.reason ?: "未知原因"}"
|
||||||
|
} else {
|
||||||
|
"会话已结束(退出码 ${model.code})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "开新会话" for a clean exit, "新会话" for the replay-too-large wall — both call [ReconnectBanner]'s onNewSession. */
|
||||||
|
private fun newSessionLabel(model: BannerModel): String =
|
||||||
|
if (model is BannerModel.Failed) "新会话" else "开新会话"
|
||||||
|
|
||||||
|
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Preview(name = "ReconnectBanner — reconnecting")
|
||||||
|
@Composable
|
||||||
|
private fun ReconnectBannerReconnectingPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
ReconnectBanner(model = BannerModel.Reconnecting(attempt = 2, countdown = 4.seconds), onNewSession = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "ReconnectBanner — exited")
|
||||||
|
@Composable
|
||||||
|
private fun ReconnectBannerExitedPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
ReconnectBanner(model = BannerModel.Exited(code = 0, reason = null), onNewSession = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||||
|
import wang.yaojia.webterm.designsystem.DisplayStatus
|
||||||
|
import wang.yaojia.webterm.designsystem.Opacity
|
||||||
|
import wang.yaojia.webterm.designsystem.Radius
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.designsystem.StatusBadge
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermType
|
||||||
|
import wang.yaojia.webterm.viewmodels.SessionRow
|
||||||
|
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||||
|
import wang.yaojia.webterm.wire.StatusTelemetry
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # SessionListRow (A20) — one dashboard row for a running/just-exited session.
|
||||||
|
*
|
||||||
|
* Renders the parts the plan §1 session-list row enumerates: **preview thumbnail** (left) · **status
|
||||||
|
* badge** + sanitized **title** + **unread dot** (top line) · **cols×rows** geometry + cwd (meta line) ·
|
||||||
|
* **telemetry chips** (bottom line). Every server-influenced string ([SessionRow.title] — already run
|
||||||
|
* through `TitleSanitizer` in the ViewModel — plus cwd and the telemetry chip labels) renders as INERT
|
||||||
|
* [Text]: no linkify / Markdown / `AnnotatedString` autolink (plan §8). Exited rows dim to
|
||||||
|
* [Opacity.exited].
|
||||||
|
*
|
||||||
|
* The whole row is tap-to-open ([onOpen]); swipe-to-kill is owned by the enclosing `SwipeToDismissBox` in
|
||||||
|
* `SessionListScreen`. Layout/gesture behaviour is device-QA (plan §7); the row's DERIVED display values
|
||||||
|
* are computed by the JVM-tested `sessionRowsOf` in the ViewModel.
|
||||||
|
*
|
||||||
|
* @param thumbnails the off-screen preview seam — production wires it to the active host's
|
||||||
|
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (`(sessionId, lastOutputAt)`-keyed,
|
||||||
|
* §6.7); `null` renders the placeholder tile (the JVM/preview path draws no bitmap).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun SessionListRow(
|
||||||
|
row: SessionRow,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
thumbnails: SessionThumbnails? = null,
|
||||||
|
nowMs: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.alpha(if (row.displayStatus == DisplayStatus.Exited) Opacity.exited else 1f)
|
||||||
|
.clip(RoundedCornerShape(Radius.md12))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable(onClick = onOpen)
|
||||||
|
.padding(Spacing.md12),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ThumbnailTile(session = row.info, thumbnails = thumbnails)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||||
|
) {
|
||||||
|
TitleLine(row = row)
|
||||||
|
Text(
|
||||||
|
text = metaLine(row.info),
|
||||||
|
style = WebTermType.metaMono,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
row.info.telemetry?.let { telemetry ->
|
||||||
|
TelemetryChips(telemetry = telemetry, nowMs = nowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top line: status badge · unread dot · sanitized title (or an id/cwd fallback when the title is empty). */
|
||||||
|
@Composable
|
||||||
|
private fun TitleLine(row: SessionRow) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
StatusBadge(status = row.displayStatus)
|
||||||
|
if (row.isUnread) UnreadDot()
|
||||||
|
Text(
|
||||||
|
text = row.title.ifBlank { fallbackLabel(row.info) },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The unread dot (accent-filled), shown when server output is newer than the local seen-watermark (§1). */
|
||||||
|
@Composable
|
||||||
|
private fun UnreadDot() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(Spacing.sm8)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview thumbnail tile. Loads the bitmap once per `(id, lastOutputAt)` from the [thumbnails] seam
|
||||||
|
* (so an unchanged screen is served from the pipeline's cache) and draws it; while loading (or when no
|
||||||
|
* seam is wired) it shows a terminal-colored placeholder so the row height never jumps.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ThumbnailTile(session: LiveSessionInfo, thumbnails: SessionThumbnails?) {
|
||||||
|
var bitmap by remember(session.id, session.lastOutputAt) { mutableStateOf<Bitmap?>(null) }
|
||||||
|
LaunchedEffect(session.id, session.lastOutputAt, thumbnails) {
|
||||||
|
bitmap = thumbnails?.bitmapFor(session)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(THUMBNAIL_WIDTH)
|
||||||
|
.height(THUMBNAIL_HEIGHT)
|
||||||
|
.clip(RoundedCornerShape(Radius.sm8))
|
||||||
|
.background(WebTermColors.terminalBackground),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
bitmap?.let {
|
||||||
|
Image(
|
||||||
|
bitmap = it.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(THUMBNAIL_HEIGHT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "161×50 · ~/src/web-terminal" — tabular geometry + cwd (× is U+00D7). Both inert (§8). */
|
||||||
|
private fun metaLine(info: LiveSessionInfo): String {
|
||||||
|
val dims = "${info.cols}×${info.rows}"
|
||||||
|
val cwd = info.cwd?.takeIf { it.isNotBlank() }
|
||||||
|
return if (cwd != null) "$dims · $cwd" else dims
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When a session has no OSC title, label it by cwd (last segment) or the short id — never blank. */
|
||||||
|
private fun fallbackLabel(info: LiveSessionInfo): String {
|
||||||
|
val cwd = info.cwd?.takeIf { it.isNotBlank() }
|
||||||
|
if (cwd != null) return cwd.trimEnd('/').substringAfterLast('/').ifBlank { cwd }
|
||||||
|
return info.id.toString().substringBefore('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The off-screen thumbnail seam. Production is a thin adapter over the active host's
|
||||||
|
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline]
|
||||||
|
* (`{ session -> pipeline.thumbnail(session) }`); the default `null` seam renders the placeholder tile so
|
||||||
|
* the row composes with no `android.graphics` dependency under preview/JVM.
|
||||||
|
*/
|
||||||
|
public fun interface SessionThumbnails {
|
||||||
|
/** The cached/rendered preview bitmap for [session], or `null` when unavailable (placeholder shown). */
|
||||||
|
public suspend fun bitmapFor(session: LiveSessionInfo): Bitmap?
|
||||||
|
}
|
||||||
|
|
||||||
|
private val THUMBNAIL_WIDTH = Spacing.xxl24 * 4 // 96dp
|
||||||
|
private val THUMBNAIL_HEIGHT = Spacing.xxl24 * 2.5f // 60dp
|
||||||
|
|
||||||
|
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Preview(name = "SessionListRow")
|
||||||
|
@Composable
|
||||||
|
private fun SessionListRowPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
|
||||||
|
SessionListRow(
|
||||||
|
row = SessionRow(
|
||||||
|
info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L),
|
||||||
|
displayStatus = DisplayStatus.Working,
|
||||||
|
title = "web-terminal",
|
||||||
|
isUnread = true,
|
||||||
|
),
|
||||||
|
onOpen = {},
|
||||||
|
)
|
||||||
|
SessionListRow(
|
||||||
|
row = SessionRow(
|
||||||
|
info = previewInfo(status = ClaudeStatus.IDLE, title = "", cols = 80, rows = 24),
|
||||||
|
displayStatus = DisplayStatus.Exited,
|
||||||
|
title = "",
|
||||||
|
isUnread = false,
|
||||||
|
),
|
||||||
|
onOpen = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun previewInfo(
|
||||||
|
status: ClaudeStatus,
|
||||||
|
title: String,
|
||||||
|
cols: Int = 161,
|
||||||
|
rows: Int = 50,
|
||||||
|
lastOutputAt: Long? = null,
|
||||||
|
): LiveSessionInfo = LiveSessionInfo(
|
||||||
|
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
|
||||||
|
createdAt = 1L,
|
||||||
|
clientCount = 1,
|
||||||
|
status = status,
|
||||||
|
exited = false,
|
||||||
|
cwd = "/Users/dev/src/web-terminal",
|
||||||
|
title = title.ifBlank { null },
|
||||||
|
cols = cols,
|
||||||
|
rows = rows,
|
||||||
|
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
|
||||||
|
lastOutputAt = lastOutputAt,
|
||||||
|
)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package wang.yaojia.webterm.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.designsystem.TelemetryChip
|
||||||
|
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||||
|
import wang.yaojia.webterm.wire.PrInfo
|
||||||
|
import wang.yaojia.webterm.wire.RateInfo
|
||||||
|
import wang.yaojia.webterm.wire.StatusTelemetry
|
||||||
|
import wang.yaojia.webterm.wire.Tunables
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # TelemetryChips (A22) — the statusLine telemetry chip row.
|
||||||
|
*
|
||||||
|
* A horizontally-scrollable row of A13 [TelemetryChip] primitives — context %, $cost, model, PR,
|
||||||
|
* rate — derived from the latest [StatusTelemetry] frame (plan §1). Every chip **greys out together**
|
||||||
|
* when the frame is stale (its [at][StatusTelemetry.at] older than
|
||||||
|
* [TELEMETRY_STALE_TTL_MS][Tunables.TELEMETRY_STALE_TTL_MS]); a chip switches to the amber warning
|
||||||
|
* color when its metric crosses a threshold (near-full context, near-limit rate, PR changes-requested).
|
||||||
|
*
|
||||||
|
* Chip text is inert monospaced [TelemetryChip] output (no linkify, §8). The model string is
|
||||||
|
* server-controlled but renders as a plain chip label. Layout is device-QA; the staleness rule and
|
||||||
|
* chip derivation are the JVM-tested core ([isTelemetryStale] / [telemetryChipModels]).
|
||||||
|
*
|
||||||
|
* @param nowMs current wall clock (ms since epoch); the screen re-supplies it on a tick so chips grey
|
||||||
|
* as the frame ages.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun TelemetryChips(
|
||||||
|
telemetry: StatusTelemetry,
|
||||||
|
nowMs: Long,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val stale = isTelemetryStale(telemetry.at, nowMs)
|
||||||
|
val models = telemetryChipModels(telemetry)
|
||||||
|
Row(
|
||||||
|
modifier = modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||||
|
) {
|
||||||
|
for (model in models) {
|
||||||
|
TelemetryChip(text = model.text, isStale = stale, isWarning = model.isWarning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One derived chip: pre-formatted [text] plus whether it crossed a warning threshold. */
|
||||||
|
public data class TelemetryChipModel(val text: String, val isWarning: Boolean = false)
|
||||||
|
|
||||||
|
/** Context %, 5-hour and 7-day rate at/above which a chip turns amber (mirrors the web statusline). */
|
||||||
|
private const val CONTEXT_WARN_PCT: Double = 90.0
|
||||||
|
private const val RATE_WARN_PCT: Double = 90.0
|
||||||
|
private const val REVIEW_CHANGES_REQUESTED: String = "CHANGES_REQUESTED"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True iff [telemetry.at][StatusTelemetry.at] (server receive time) is older than the stale TTL as of
|
||||||
|
* [nowMs]. Boundary: exactly-TTL-old is NOT yet stale ("older than", [Tunables.TELEMETRY_STALE_TTL_MS]).
|
||||||
|
* Pure — the JVM-tested staleness threshold.
|
||||||
|
*/
|
||||||
|
public fun isTelemetryStale(atMs: Long, nowMs: Long): Boolean =
|
||||||
|
nowMs - atMs > Tunables.TELEMETRY_STALE_TTL_MS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the ordered chip list from a telemetry frame, skipping absent metrics. Pure + deterministic
|
||||||
|
* so the formatting + warning thresholds are unit-tested without Compose. Order mirrors the web
|
||||||
|
* statusLine: context · cost · model · PR · rate.
|
||||||
|
*/
|
||||||
|
public fun telemetryChipModels(telemetry: StatusTelemetry): List<TelemetryChipModel> {
|
||||||
|
val chips = mutableListOf<TelemetryChipModel>()
|
||||||
|
telemetry.contextUsedPct?.let {
|
||||||
|
chips += TelemetryChipModel("ctx ${it.roundToInt()}%", isWarning = it >= CONTEXT_WARN_PCT)
|
||||||
|
}
|
||||||
|
telemetry.costUsd?.let {
|
||||||
|
chips += TelemetryChipModel(String.format(Locale.US, "$%.2f", it))
|
||||||
|
}
|
||||||
|
telemetry.model?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
chips += TelemetryChipModel(it)
|
||||||
|
}
|
||||||
|
telemetry.pr?.let { chips += prChip(it) }
|
||||||
|
telemetry.rate?.fiveHourPct?.let {
|
||||||
|
chips += TelemetryChipModel("5h ${it.roundToInt()}%", isWarning = it >= RATE_WARN_PCT)
|
||||||
|
}
|
||||||
|
return chips
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prChip(pr: PrInfo): TelemetryChipModel =
|
||||||
|
TelemetryChipModel("PR #${pr.number}", isWarning = pr.reviewState == REVIEW_CHANGES_REQUESTED)
|
||||||
|
|
||||||
|
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Preview(name = "TelemetryChips")
|
||||||
|
@Composable
|
||||||
|
private fun TelemetryChipsPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
TelemetryChips(
|
||||||
|
telemetry = StatusTelemetry(
|
||||||
|
contextUsedPct = 92.0,
|
||||||
|
costUsd = 0.1234,
|
||||||
|
model = "opus-4.8",
|
||||||
|
pr = PrInfo(number = 7, url = "", reviewState = REVIEW_CHANGES_REQUESTED),
|
||||||
|
rate = RateInfo(fiveHourPct = 40.0),
|
||||||
|
at = 0L,
|
||||||
|
),
|
||||||
|
nowMs = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package wang.yaojia.webterm.designsystem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # WebTerm design system — the FROZEN numeric spec (pure Kotlin, JVM-testable).
|
||||||
|
*
|
||||||
|
* Mirrors the iOS `DS` token vocabulary
|
||||||
|
* (`ios/App/WebTerm/DesignSystem/Tokens.swift`). Every visual constant lives here
|
||||||
|
* as a raw ARGB [Long] / [Float] / [Int] with **no Compose import**, so it can be
|
||||||
|
* unit-tested on the plain JVM with no device (see `DesignSpecTest`). `Tokens.kt`
|
||||||
|
* builds Compose `Color`/`Dp` values from these; screens must reference the tokens,
|
||||||
|
* never inline a hex/gap.
|
||||||
|
*
|
||||||
|
* Direction: "精致原生" (refined native), dark-appearance-first, amber-gold accent
|
||||||
|
* matched to the desktop/web theme (`public/style.css` `--accent`). Color is NEVER
|
||||||
|
* the only status signal — pair it with the shape/label in `StatusStyle`.
|
||||||
|
*/
|
||||||
|
public object DesignSpec {
|
||||||
|
|
||||||
|
// ── Accent (amber gold — matches desktop --accent / --accent-2) ─────────────
|
||||||
|
// Adaptive: dark = #E3A64A (gold), light = #C9892F (deeper gold) for contrast
|
||||||
|
// on a light background. Gold needs DARK ink on top → use [ON_ACCENT].
|
||||||
|
public const val ACCENT_DARK: Long = 0xFFE3A64AL // #E3A64A (web --accent)
|
||||||
|
public const val ACCENT_LIGHT: Long = 0xFFC9892FL // #C9892F (web --accent-2)
|
||||||
|
public const val ON_ACCENT: Long = 0xFF1A1305L // #1A1305 (web --on-accent)
|
||||||
|
|
||||||
|
// ── Semantic status colors (match the desktop/web status palette) ───────────
|
||||||
|
// These are the ONLY status colors. `StatusStyle` pairs each with a distinct
|
||||||
|
// shape so status is never conveyed by color alone (color-blind safe).
|
||||||
|
public const val STATUS_WORKING: Long = 0xFF46D07FL // #46D07F (web --green)
|
||||||
|
public const val STATUS_WAITING: Long = 0xFFF5B14CL // #F5B14C (web --amber)
|
||||||
|
public const val STATUS_STUCK: Long = 0xFFFF6B6BL // #FF6B6B (web --red)
|
||||||
|
public const val STATUS_IDLE: Long = 0xFF8B8578L // warm secondary gray
|
||||||
|
public const val STATUS_UNKNOWN: Long = 0xFF6E6A61L // neutral gray (no signal yet)
|
||||||
|
|
||||||
|
// ── Timeline event classes (A28) ────────────────────────────────────────────
|
||||||
|
public const val TIMELINE_TOOL: Long = 0xFF5E9EFFL // indigo — a tool run
|
||||||
|
public const val TIMELINE_USER: Long = 0xFFAF7BFFL // violet — a user message
|
||||||
|
|
||||||
|
// ── Surfaces & text — DARK scheme (the default appearance) ──────────────────
|
||||||
|
public const val DARK_BACKGROUND: Long = 0xFF14130FL // warm near-black chrome
|
||||||
|
public const val DARK_SURFACE: Long = 0xFF1C1A15L
|
||||||
|
public const val DARK_CARD: Long = 0xFF24211AL // grouped-content step-up
|
||||||
|
public const val DARK_HAIRLINE: Long = 0xFF35322AL // separator/border
|
||||||
|
public const val DARK_TEXT_PRIMARY: Long = 0xFFECE9E3L // warm off-white (web --text)
|
||||||
|
public const val DARK_TEXT_SECONDARY: Long = 0xFFA8A296L
|
||||||
|
public const val DARK_TEXT_TERTIARY: Long = 0xFF6E6A61L
|
||||||
|
|
||||||
|
// ── Surfaces & text — LIGHT scheme ──────────────────────────────────────────
|
||||||
|
public const val LIGHT_BACKGROUND: Long = 0xFFFAF8F3L // warm off-white
|
||||||
|
public const val LIGHT_SURFACE: Long = 0xFFFFFFFFL
|
||||||
|
public const val LIGHT_CARD: Long = 0xFFF0EDE6L
|
||||||
|
public const val LIGHT_HAIRLINE: Long = 0xFFD8D3C8L
|
||||||
|
public const val LIGHT_TEXT_PRIMARY: Long = 0xFF1A1712L
|
||||||
|
public const val LIGHT_TEXT_SECONDARY: Long = 0xFF6B6559L
|
||||||
|
public const val LIGHT_TEXT_TERTIARY: Long = 0xFF9A9488L
|
||||||
|
|
||||||
|
// ── Terminal canvas (FIXED — independent of app theme) ──────────────────────
|
||||||
|
// A terminal reads as dark regardless of app appearance (like the desktop).
|
||||||
|
// Values mirror the web chrome: --bg #100F0D / --text #ECE9E3, gold caret.
|
||||||
|
public const val TERMINAL_BACKGROUND: Long = 0xFF100F0DL // web --bg
|
||||||
|
public const val TERMINAL_FOREGROUND: Long = 0xFFECE9E3L // web --text
|
||||||
|
public const val TERMINAL_CARET: Long = 0xFFE3A64AL // gold caret
|
||||||
|
|
||||||
|
// ── Spacing scale — 2·4·8·12·16·20·24 (dp). No off-scale gaps. ──────────────
|
||||||
|
public const val SPACE_XS2: Float = 2f
|
||||||
|
public const val SPACE_XS4: Float = 4f
|
||||||
|
public const val SPACE_SM8: Float = 8f
|
||||||
|
public const val SPACE_MD12: Float = 12f
|
||||||
|
public const val SPACE_LG16: Float = 16f
|
||||||
|
public const val SPACE_XL20: Float = 20f
|
||||||
|
public const val SPACE_XXL24: Float = 24f
|
||||||
|
|
||||||
|
// ── Corner radii (dp) ───────────────────────────────────────────────────────
|
||||||
|
public const val RADIUS_SM8: Float = 8f
|
||||||
|
public const val RADIUS_MD12: Float = 12f
|
||||||
|
public const val RADIUS_LG16: Float = 16f
|
||||||
|
public const val RADIUS_PILL: Float = 999f
|
||||||
|
|
||||||
|
// ── Stroke (dp) ──────────────────────────────────────────────────────────────
|
||||||
|
public const val STROKE_HAIRLINE: Float = 1f
|
||||||
|
|
||||||
|
// ── Opacity (dimming multipliers) ────────────────────────────────────────────
|
||||||
|
public const val OPACITY_STALE: Float = 0.45f // telemetry past its TTL
|
||||||
|
public const val OPACITY_EXITED: Float = 0.55f // a session that has exited
|
||||||
|
public const val OPACITY_PRESSED: Float = 0.72f // pressed-state feedback
|
||||||
|
public const val OPACITY_ACCENT_SOFT: Float = 0.15f // faint accent wash
|
||||||
|
|
||||||
|
// ── Layout (dp) ───────────────────────────────────────────────────────────────
|
||||||
|
public const val MIN_HIT_TARGET: Float = 44f // Material/HIG minimum touch target
|
||||||
|
|
||||||
|
// ── Motion (ms) — subtle, eased; ALWAYS gate through reduce-motion ──────────
|
||||||
|
public const val MOTION_FAST_MS: Int = 180
|
||||||
|
public const val MOTION_BASE_MS: Int = 250
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package wang.yaojia.webterm.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Primitives — reusable Compose building blocks (mirrors iOS `Primitives.swift`).
|
||||||
|
*
|
||||||
|
* `StatusBadge`, `TelemetryChip`, `WebTermCard`. Each pulls ALL constants from the
|
||||||
|
* design tokens — no inline magic. Untrusted server strings render as INERT
|
||||||
|
* [Text] (no Markdown / linkify / AnnotatedString autolink), per plan §8.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visual states a status indicator can show — a superset of the wire
|
||||||
|
* [ClaudeStatus] plus two App-layer emphasis states (mirrors iOS `DisplayStatus`):
|
||||||
|
* `PendingApproval` ("needs me", outranks status) and `Exited` (read-only).
|
||||||
|
*/
|
||||||
|
public enum class DisplayStatus {
|
||||||
|
Working, Waiting, Idle, Stuck, Unknown, PendingApproval, Exited;
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
/** Bridge from the wire enum (no pending/exited emphasis — callers add). */
|
||||||
|
public fun from(status: ClaudeStatus): DisplayStatus = when (status) {
|
||||||
|
ClaudeStatus.WORKING -> Working
|
||||||
|
ClaudeStatus.WAITING -> Waiting
|
||||||
|
ClaudeStatus.IDLE -> Idle
|
||||||
|
ClaudeStatus.STUCK -> Stuck
|
||||||
|
ClaudeStatus.UNKNOWN -> Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved visuals for one status: a semantic [color], a DISTINCT [symbol] glyph
|
||||||
|
* (so shape alone disambiguates — color-blind safe), and a Chinese [label] (also
|
||||||
|
* the accessibility description). Pure + deterministic (mirrors iOS `StatusStyle`).
|
||||||
|
*/
|
||||||
|
public data class StatusStyle(
|
||||||
|
val color: Color,
|
||||||
|
val symbol: String,
|
||||||
|
val label: String,
|
||||||
|
) {
|
||||||
|
public companion object {
|
||||||
|
public fun of(status: DisplayStatus): StatusStyle = when (status) {
|
||||||
|
DisplayStatus.Working -> StatusStyle(WebTermColors.statusWorking, "●", "运行中")
|
||||||
|
DisplayStatus.Waiting -> StatusStyle(WebTermColors.statusWaiting, "◔", "等待中")
|
||||||
|
DisplayStatus.Idle -> StatusStyle(WebTermColors.statusIdle, "○", "空闲")
|
||||||
|
DisplayStatus.Stuck -> StatusStyle(WebTermColors.statusStuck, "▲", "卡住")
|
||||||
|
DisplayStatus.Unknown -> StatusStyle(WebTermColors.statusUnknown, "?", "未知")
|
||||||
|
DisplayStatus.PendingApproval -> StatusStyle(WebTermColors.statusWaiting, "!", "等待审批")
|
||||||
|
DisplayStatus.Exited -> StatusStyle(WebTermColors.statusIdle, "⚑", "已退出")
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun of(status: ClaudeStatus): StatusStyle = of(DisplayStatus.from(status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color + distinct glyph (+ optional Chinese label) for one status. Status is
|
||||||
|
* conveyed by shape, color AND the semantics label — never color alone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun StatusBadge(
|
||||||
|
status: DisplayStatus,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showsLabel: Boolean = false,
|
||||||
|
) {
|
||||||
|
val style = StatusStyle.of(status)
|
||||||
|
Row(
|
||||||
|
modifier = modifier.semantics { contentDescription = style.label },
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = style.symbol, color = style.color, style = MaterialTheme.typography.labelMedium)
|
||||||
|
if (showsLabel) {
|
||||||
|
Text(
|
||||||
|
text = style.label,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pill of monospaced-tabular telemetry (context %, $cost, model, PR…). Greys
|
||||||
|
* out when [isStale]; switches to the waiting/amber color when [isWarning].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun TelemetryChip(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isStale: Boolean = false,
|
||||||
|
isWarning: Boolean = false,
|
||||||
|
) {
|
||||||
|
val fg = if (isWarning) WebTermColors.statusWaiting else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = WebTermType.metaMono,
|
||||||
|
color = fg,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = modifier
|
||||||
|
.alpha(if (isStale) Opacity.stale else 1f)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.pill))
|
||||||
|
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard card container: card surface + hairline border + `md12` radius +
|
||||||
|
* standard padding. The uniform card spec for rows, grid cells and panels.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun WebTermCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
padding: androidx.compose.ui.unit.Dp = Spacing.md12,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
androidx.compose.foundation.layout.Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.md12))
|
||||||
|
.border(Stroke.hairline, MaterialTheme.colorScheme.outline, RoundedCornerShape(Radius.md12))
|
||||||
|
.padding(padding),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Previews ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Preview(name = "StatusBadge")
|
||||||
|
@Composable
|
||||||
|
private fun StatusBadgePreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.md12)) {
|
||||||
|
DisplayStatus.entries.forEach { StatusBadge(status = it, showsLabel = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "TelemetryChip")
|
||||||
|
@Composable
|
||||||
|
private fun TelemetryChipPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
|
TelemetryChip(text = "ctx 92%", isWarning = true)
|
||||||
|
TelemetryChip(text = "$0.1234")
|
||||||
|
TelemetryChip(text = "PR #7", isStale = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Card")
|
||||||
|
@Composable
|
||||||
|
private fun CardPreview() {
|
||||||
|
WebTermTheme {
|
||||||
|
WebTermCard {
|
||||||
|
Text(text = "web-terminal · 161×50", style = WebTermType.metaMono)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package wang.yaojia.webterm.designsystem
|
||||||
|
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the OS "remove animations" accessibility setting is on. Motion tokens
|
||||||
|
* are ALWAYS routed through this (the analogue of iOS `DS.Motion.gated`): when
|
||||||
|
* true, callers collapse animations to instant. Provided by [WebTermTheme].
|
||||||
|
*/
|
||||||
|
public val LocalReduceMotion: androidx.compose.runtime.ProvidableCompositionLocal<Boolean> =
|
||||||
|
staticCompositionLocalOf { false }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # WebTermTheme — the app's Material 3 theme (mirrors the iOS `DS` appearance).
|
||||||
|
*
|
||||||
|
* Dark-appearance-first: the [darkColorScheme] carries the real design intent;
|
||||||
|
* the [lightColorScheme] is a faithful light counterpart. The amber-gold accent
|
||||||
|
* maps to `primary` (with dark `onPrimary` ink — gold needs dark text). Surface
|
||||||
|
* and text colors come from [WebTermColors]. The terminal canvas colors are NOT
|
||||||
|
* part of the scheme — they are fixed and read directly from [WebTermColors] by
|
||||||
|
* the terminal view, independent of app appearance.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun WebTermTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalReduceMotion provides reduceMotion) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = WebTermType.ramp,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the OS animator-duration-scale; 0 means "remove animations" is on. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberReduceMotion(): Boolean {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(context) {
|
||||||
|
val scale = Settings.Global.getFloat(
|
||||||
|
context.contentResolver,
|
||||||
|
Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||||
|
1f,
|
||||||
|
)
|
||||||
|
scale == 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scheme-independent status/accent colors stay in WebTermColors; the scheme
|
||||||
|
// here only maps surfaces/text/accent into Material 3's slots.
|
||||||
|
private val DarkColorScheme: ColorScheme = darkColorScheme(
|
||||||
|
primary = WebTermColors.dark.accent,
|
||||||
|
onPrimary = WebTermColors.onAccent,
|
||||||
|
secondary = WebTermColors.dark.accent,
|
||||||
|
onSecondary = WebTermColors.onAccent,
|
||||||
|
background = WebTermColors.dark.background,
|
||||||
|
onBackground = WebTermColors.dark.textPrimary,
|
||||||
|
surface = WebTermColors.dark.surface,
|
||||||
|
onSurface = WebTermColors.dark.textPrimary,
|
||||||
|
surfaceVariant = WebTermColors.dark.card,
|
||||||
|
onSurfaceVariant = WebTermColors.dark.textSecondary,
|
||||||
|
outline = WebTermColors.dark.hairline,
|
||||||
|
error = WebTermColors.statusStuck,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme: ColorScheme = lightColorScheme(
|
||||||
|
primary = WebTermColors.light.accent,
|
||||||
|
onPrimary = WebTermColors.onAccent,
|
||||||
|
secondary = WebTermColors.light.accent,
|
||||||
|
onSecondary = WebTermColors.onAccent,
|
||||||
|
background = WebTermColors.light.background,
|
||||||
|
onBackground = WebTermColors.light.textPrimary,
|
||||||
|
surface = WebTermColors.light.surface,
|
||||||
|
onSurface = WebTermColors.light.textPrimary,
|
||||||
|
surfaceVariant = WebTermColors.light.card,
|
||||||
|
onSurfaceVariant = WebTermColors.light.textSecondary,
|
||||||
|
outline = WebTermColors.light.hairline,
|
||||||
|
error = WebTermColors.statusStuck,
|
||||||
|
)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package wang.yaojia.webterm.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Tokens — the Compose-facing design vocabulary (mirrors iOS `DS`).
|
||||||
|
*
|
||||||
|
* Thin wrappers that lift [DesignSpec]'s raw numbers into Compose types
|
||||||
|
* (`Color`, `Dp`). Screens/components reference these — never an inline hex, gap
|
||||||
|
* or radius. Split into small objects that mirror the iOS `DS.*` groups.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two adaptive palettes (dark = default) plus the theme-independent tokens
|
||||||
|
* (accent, semantic status, terminal canvas). The scheme-specific surface/text
|
||||||
|
* colors are consumed by [WebTermTheme] to build the Material 3 `ColorScheme`.
|
||||||
|
*/
|
||||||
|
public object WebTermColors {
|
||||||
|
|
||||||
|
// Accent — theme-adaptive; injected once via the color scheme's `primary`.
|
||||||
|
public val accentDark: Color = Color(DesignSpec.ACCENT_DARK)
|
||||||
|
public val accentLight: Color = Color(DesignSpec.ACCENT_LIGHT)
|
||||||
|
public val onAccent: Color = Color(DesignSpec.ON_ACCENT)
|
||||||
|
/** Faint accent wash (selection/soft highlight) — web --accent-soft. */
|
||||||
|
public val accentSoft: Color = Color(DesignSpec.ACCENT_DARK).copy(alpha = DesignSpec.OPACITY_ACCENT_SOFT)
|
||||||
|
|
||||||
|
// Semantic status (theme-independent — same hex reads on desktop/iOS/Android).
|
||||||
|
public val statusWorking: Color = Color(DesignSpec.STATUS_WORKING)
|
||||||
|
public val statusWaiting: Color = Color(DesignSpec.STATUS_WAITING)
|
||||||
|
public val statusStuck: Color = Color(DesignSpec.STATUS_STUCK)
|
||||||
|
public val statusIdle: Color = Color(DesignSpec.STATUS_IDLE)
|
||||||
|
public val statusUnknown: Color = Color(DesignSpec.STATUS_UNKNOWN)
|
||||||
|
|
||||||
|
// Timeline event classes (A28).
|
||||||
|
public val timelineTool: Color = Color(DesignSpec.TIMELINE_TOOL)
|
||||||
|
public val timelineUser: Color = Color(DesignSpec.TIMELINE_USER)
|
||||||
|
|
||||||
|
// Terminal canvas — FIXED, independent of app theme.
|
||||||
|
public val terminalBackground: Color = Color(DesignSpec.TERMINAL_BACKGROUND)
|
||||||
|
public val terminalForeground: Color = Color(DesignSpec.TERMINAL_FOREGROUND)
|
||||||
|
public val terminalCaret: Color = Color(DesignSpec.TERMINAL_CARET)
|
||||||
|
|
||||||
|
/** One appearance's surface + text colors. Consumed by [WebTermTheme]. */
|
||||||
|
@Immutable
|
||||||
|
public data class Scheme(
|
||||||
|
val background: Color,
|
||||||
|
val surface: Color,
|
||||||
|
val card: Color,
|
||||||
|
val hairline: Color,
|
||||||
|
val textPrimary: Color,
|
||||||
|
val textSecondary: Color,
|
||||||
|
val textTertiary: Color,
|
||||||
|
val accent: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
public val dark: Scheme = Scheme(
|
||||||
|
background = Color(DesignSpec.DARK_BACKGROUND),
|
||||||
|
surface = Color(DesignSpec.DARK_SURFACE),
|
||||||
|
card = Color(DesignSpec.DARK_CARD),
|
||||||
|
hairline = Color(DesignSpec.DARK_HAIRLINE),
|
||||||
|
textPrimary = Color(DesignSpec.DARK_TEXT_PRIMARY),
|
||||||
|
textSecondary = Color(DesignSpec.DARK_TEXT_SECONDARY),
|
||||||
|
textTertiary = Color(DesignSpec.DARK_TEXT_TERTIARY),
|
||||||
|
accent = accentDark,
|
||||||
|
)
|
||||||
|
|
||||||
|
public val light: Scheme = Scheme(
|
||||||
|
background = Color(DesignSpec.LIGHT_BACKGROUND),
|
||||||
|
surface = Color(DesignSpec.LIGHT_SURFACE),
|
||||||
|
card = Color(DesignSpec.LIGHT_CARD),
|
||||||
|
hairline = Color(DesignSpec.LIGHT_HAIRLINE),
|
||||||
|
textPrimary = Color(DesignSpec.LIGHT_TEXT_PRIMARY),
|
||||||
|
textSecondary = Color(DesignSpec.LIGHT_TEXT_SECONDARY),
|
||||||
|
textTertiary = Color(DesignSpec.LIGHT_TEXT_TERTIARY),
|
||||||
|
accent = accentLight,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spacing scale — 2·4·8·12·16·20·24 (mirrors `DS.Space`). */
|
||||||
|
public object Spacing {
|
||||||
|
public val xs2: Dp = DesignSpec.SPACE_XS2.dp
|
||||||
|
public val xs4: Dp = DesignSpec.SPACE_XS4.dp
|
||||||
|
public val sm8: Dp = DesignSpec.SPACE_SM8.dp
|
||||||
|
public val md12: Dp = DesignSpec.SPACE_MD12.dp
|
||||||
|
public val lg16: Dp = DesignSpec.SPACE_LG16.dp
|
||||||
|
public val xl20: Dp = DesignSpec.SPACE_XL20.dp
|
||||||
|
public val xxl24: Dp = DesignSpec.SPACE_XXL24.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corner radii (mirrors `DS.Radius`). */
|
||||||
|
public object Radius {
|
||||||
|
public val sm8: Dp = DesignSpec.RADIUS_SM8.dp
|
||||||
|
public val md12: Dp = DesignSpec.RADIUS_MD12.dp
|
||||||
|
public val lg16: Dp = DesignSpec.RADIUS_LG16.dp
|
||||||
|
public val pill: Dp = DesignSpec.RADIUS_PILL.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Border widths (mirrors `DS.Stroke`). */
|
||||||
|
public object Stroke {
|
||||||
|
public val hairline: Dp = DesignSpec.STROKE_HAIRLINE.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dimming multipliers (mirrors `DS.Opacity`). */
|
||||||
|
public object Opacity {
|
||||||
|
public const val stale: Float = DesignSpec.OPACITY_STALE
|
||||||
|
public const val exited: Float = DesignSpec.OPACITY_EXITED
|
||||||
|
public const val pressed: Float = DesignSpec.OPACITY_PRESSED
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-spacing layout constants (mirrors `DS.Layout`). */
|
||||||
|
public object LayoutTokens {
|
||||||
|
public val minHitTarget: Dp = DesignSpec.MIN_HIT_TARGET.dp
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package wang.yaojia.webterm.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Typography — the type ramp (mirrors iOS `DS.Typography`).
|
||||||
|
*
|
||||||
|
* The proportional ramp is Material 3's default [Typography] (scales with the
|
||||||
|
* system font-size setting, the a11y "keep it scalable" point). Numbers /
|
||||||
|
* dimensions / cost / `cols×rows` / timestamps use [monoTabular]: a monospace
|
||||||
|
* family with **tabular figures** (`tnum`) so columns line up and digits don't
|
||||||
|
* jitter as values change — the analogue of iOS `DS.Typography.mono(_:)`.
|
||||||
|
*/
|
||||||
|
public object WebTermType {
|
||||||
|
|
||||||
|
/** Material 3 default ramp — proportional, system-scalable. */
|
||||||
|
public val ramp: Typography = Typography()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monospace + tabular-figures style at the given [size]. Use for anything
|
||||||
|
* numeric that must align or not jump: `cols×rows`, device/client counts,
|
||||||
|
* `$cost`, context %, relative timestamps.
|
||||||
|
*/
|
||||||
|
public fun monoTabular(size: Int = 13): TextStyle = TextStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontFeatureSettings = TABULAR_FIGURES,
|
||||||
|
fontSize = size.sp,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The canonical meta-number style: caption-sized mono + tabular. */
|
||||||
|
public val metaMono: TextStyle = monoTabular(size = 12)
|
||||||
|
|
||||||
|
/** The terminal-line style: mono at body size (the emulator overrides glyphs). */
|
||||||
|
public val terminal: TextStyle = TextStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontFeatureSettings = TABULAR_FIGURES,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** OpenType feature string enabling tabular (fixed-advance) numerals. */
|
||||||
|
private const val TABULAR_FIGURES: String = "tnum"
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package wang.yaojia.webterm.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.ConnectionPool
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import wang.yaojia.webterm.transport.ClientIdentity
|
||||||
|
import wang.yaojia.webterm.transport.ClientIdentityProvider
|
||||||
|
import wang.yaojia.webterm.transport.OkHttpClientFactory
|
||||||
|
import wang.yaojia.webterm.transport.OkHttpHttpTransport
|
||||||
|
import wang.yaojia.webterm.transport.OkHttpTermTransport
|
||||||
|
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||||
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.TermTransport
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network boundary (A15): the ONE shared [OkHttpClient] both transports use, the mTLS bridge, and the
|
||||||
|
* WS + REST transports. One client → the `SSLSocketFactory` + `cache(null)` posture apply uniformly to
|
||||||
|
* WS and REST, and the connection pool is shared (plan §2/§8).
|
||||||
|
*
|
||||||
|
* ### mTLS bridge (A11 [IdentityRepository] → A7 [ClientIdentityProvider])
|
||||||
|
* `:transport-okhttp` never depends on `:client-tls-android`; this module is the bridge. The provider
|
||||||
|
* reads the repository's stable `(SSLSocketFactory, X509TrustManager)` pair; the factory is backed by a
|
||||||
|
* re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no
|
||||||
|
* client rebuild (R4). The provider is read ONCE, when the client is built.
|
||||||
|
*
|
||||||
|
* ### Breaking the construction cycle (A11's frozen constructor)
|
||||||
|
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
|
||||||
|
* client needs the repository's SSL material — a cycle. It is broken with an explicit shared
|
||||||
|
* [ConnectionPool]: the repository is given a lightweight evict-only client that SHARES that pool
|
||||||
|
* ([TlsModule]), and the shared client + its WS variant both use the same pool, so `evictAll()` drops
|
||||||
|
* the real pooled/resumed connections. The graph is then acyclic (client → provider → repo → pool).
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
public object NetworkModule {
|
||||||
|
|
||||||
|
/** The one connection pool shared by the transports AND the repository's evict-only client. */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideConnectionPool(): ConnectionPool = ConnectionPool()
|
||||||
|
|
||||||
|
/** Bridge the mTLS device identity onto the shared client (read once, at client build). */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideClientIdentityProvider(
|
||||||
|
identityRepository: IdentityRepository,
|
||||||
|
): ClientIdentityProvider = ClientIdentityProvider {
|
||||||
|
val material = identityRepository.sslMaterial()
|
||||||
|
ClientIdentity(material.sslSocketFactory, material.trustManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideOkHttpClient(
|
||||||
|
identityProvider: ClientIdentityProvider,
|
||||||
|
connectionPool: ConnectionPool,
|
||||||
|
): OkHttpClient =
|
||||||
|
OkHttpClientFactory.create(identityProvider)
|
||||||
|
.newBuilder()
|
||||||
|
.connectionPool(connectionPool)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client)
|
||||||
|
|
||||||
|
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideHttpTransport(client: OkHttpClient): HttpTransport = OkHttpHttpTransport(client)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package wang.yaojia.webterm.di
|
||||||
|
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import wang.yaojia.webterm.push.PushRegistrarTokenSink
|
||||||
|
import wang.yaojia.webterm.push.PushTokenSink
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with
|
||||||
|
* `@BindsOptionalOf` (in `push/PushTokenSink.kt`). With a concrete `@Binds` present, the
|
||||||
|
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
|
||||||
|
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
|
||||||
|
* (self-heal). This is the intended optional-collaborator handoff (no mutable global).
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
public interface PushModule {
|
||||||
|
@Binds
|
||||||
|
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package wang.yaojia.webterm.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import wang.yaojia.webterm.hostregistry.HostStore
|
||||||
|
import wang.yaojia.webterm.wiring.ColdStartPolicy
|
||||||
|
import wang.yaojia.webterm.wiring.DefaultColdStartPolicy
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-wiring boundary (A15): the cold-start route seam.
|
||||||
|
*
|
||||||
|
* The per-host / per-session builders (`ApiClientFactory`, `SessionEngineFactory`) and the composition
|
||||||
|
* root (`AppEnvironment`) are constructor-injected (`@Inject`), so Hilt provides them without an entry
|
||||||
|
* here — this module only wires the [ColdStartPolicy] interface binding.
|
||||||
|
*
|
||||||
|
* NOTE (FIX 1): there is deliberately NO `EventBus` provider. The engine→UI fan-out is PER-SESSION —
|
||||||
|
* a fresh `EventBus` is minted inside `RetainedSessionHolder.bind()` alongside each engine's confined
|
||||||
|
* scope, so two sessions never share one bus (an app-wide singleton would cross-talk `Output`/`Gate`
|
||||||
|
* between sessions since `SessionEvent` carries no session id).
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
public object SessionModule {
|
||||||
|
|
||||||
|
/** Bind the cold-start seam A29 consumes to the host-presence policy. */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideColdStartPolicy(hostStore: HostStore): ColdStartPolicy =
|
||||||
|
DefaultColdStartPolicy(hostStore)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package wang.yaojia.webterm.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import wang.yaojia.webterm.hostregistry.DataStoreHostStore
|
||||||
|
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
|
||||||
|
import wang.yaojia.webterm.hostregistry.HostStore
|
||||||
|
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore.
|
||||||
|
* The host list ([HostStore], key `"hosts"`) and per-host last-session id ([LastSessionStore], key
|
||||||
|
* `"lastSessionId.<hostId>"`) use disjoint keys, so they safely share one DataStore file. Secret
|
||||||
|
* material (the device cert) never lives here — that is the Tink store in [TlsModule].
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
public object StorageModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideDataStore(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): DataStore<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile(DATASTORE_NAME) }
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideHostStore(dataStore: DataStore<Preferences>): HostStore =
|
||||||
|
DataStoreHostStore(dataStore)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
|
||||||
|
DataStoreLastSessionStore(dataStore)
|
||||||
|
|
||||||
|
private const val DATASTORE_NAME: String = "webterm"
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package wang.yaojia.webterm.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.ConnectionPool
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository
|
||||||
|
import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter
|
||||||
|
import wang.yaojia.webterm.tlsandroid.CertStore
|
||||||
|
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||||
|
import wang.yaojia.webterm.tlsandroid.TinkCertStore
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mTLS device-identity boundary (A15 → A11): the AndroidKeyStore importer (the ONE non-exportable key
|
||||||
|
* home), the Tink-AEAD-encrypted cert-chain store, and the [IdentityRepository] that ties them to the
|
||||||
|
* shared client for no-relaunch rotation (`connectionPool.evictAll()`).
|
||||||
|
*
|
||||||
|
* The repository's constructor is I/O-free (SSL material is built from standard JCA; keystore/Tink I/O
|
||||||
|
* is lazy). Per A11's KDoc, the FIRST identity touch (summary / mutation / first handshake) does
|
||||||
|
* synchronous AndroidKeyStore + Tink work and MUST be triggered off `Dispatchers.Main` — that warm-up
|
||||||
|
* is A21/A27's concern, not this wiring's.
|
||||||
|
*
|
||||||
|
* The repository is given an evict-only client that SHARES [NetworkModule]'s [ConnectionPool] (see the
|
||||||
|
* cycle-break note there), so `evictAll()` drops the real transports' pooled/resumed connections
|
||||||
|
* without this module depending on the shared client (which would reintroduce the cycle).
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
public object TlsModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideKeyStoreImporter(): AndroidKeyStoreImporter = AndroidKeyStoreImporter()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public fun provideIdentityRepository(
|
||||||
|
importer: AndroidKeyStoreImporter,
|
||||||
|
certStore: CertStore,
|
||||||
|
connectionPool: ConnectionPool,
|
||||||
|
): IdentityRepository {
|
||||||
|
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()'s
|
||||||
|
// `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8).
|
||||||
|
val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build()
|
||||||
|
return AndroidIdentityRepository(importer, certStore, evictClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package wang.yaojia.webterm.nav
|
||||||
|
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like [runCatching] but NEVER swallows structured-concurrency cancellation: a [CancellationException]
|
||||||
|
* is rethrown so a cancelled `produceState` / `LaunchedEffect` producer tears down cleanly, while any
|
||||||
|
* real failure (a DataStore/transport error) degrades to a [Result.failure] the caller maps to a null /
|
||||||
|
* fallback UI state. `inline` so the suspend calls in [block] inline into the caller's suspend context.
|
||||||
|
*/
|
||||||
|
internal inline fun <T> runCatchingCancellable(block: () -> T): Result<T> =
|
||||||
|
try {
|
||||||
|
Result.success(block())
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Result.failure(error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package wang.yaojia.webterm.nav
|
||||||
|
|
||||||
|
import wang.yaojia.webterm.wire.Validation
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # DeepLinkRouter (A32) — the ONE deep-link validation surface.
|
||||||
|
*
|
||||||
|
* Android analogue of iOS `DeepLinkRouter` (`ios/App/WebTerm/DeepLinkRouter.swift`). Three external
|
||||||
|
* entry points share ONE whitelist parser so there is never a second, drift-prone regex:
|
||||||
|
*
|
||||||
|
* - `webterminal://open?host=<uuid>&join=<uuid>` — the **custom scheme** (registered by the
|
||||||
|
* `<intent-filter>` on the launcher Activity), and
|
||||||
|
* - `https://<APP_LINK_HOST>/open?host=<uuid>&join=<uuid>` — the **verified App Link** (an
|
||||||
|
* `autoVerify=true` `<intent-filter>`, gated by a `assetlinks.json` deploy artifact), and
|
||||||
|
* - the FCM push-tap payload `{sessionId,…}` (A30 reuses [route] — same UUID rules, no second parser).
|
||||||
|
*
|
||||||
|
* ## Security contract (plan §8 "App Links", §1 deep-links row)
|
||||||
|
* A deep link is EXTERNAL, untrusted input. Every field is whitelist-validated: scheme / action /
|
||||||
|
* verified host / query keys, and BOTH ids as v4 UUIDs through the frozen [Validation.isValidSessionId]
|
||||||
|
* (never re-regexed here — M7). ANY invalid, ambiguous (duplicate key), or missing part collapses the
|
||||||
|
* whole link to [DeepLinkRoute.Ignore] — it is **never partially applied** (a valid host with a bad
|
||||||
|
* session id does NOT open the host). Ignores are tallied by [DeepLinkTally], never echoed (the URL is
|
||||||
|
* untrusted — log the counter only, never the link).
|
||||||
|
*
|
||||||
|
* ## Purity
|
||||||
|
* PURE Kotlin/JVM — parses with `java.net.URI` (stdlib, not an Android import), exactly like
|
||||||
|
* [wang.yaojia.webterm.wire.HostEndpoint]. The caller (the launcher Activity / the A30 push tap)
|
||||||
|
* converts the platform `android.net.Uri`/`RemoteMessage.data` to a `String`/`Map` and hands it here,
|
||||||
|
* so this whole surface is JVM-unit-testable with no device. Host EXISTENCE (is this a paired host?)
|
||||||
|
* is resolved LATER against the `HostStore`, never here — the router only proves syntactic validity.
|
||||||
|
*/
|
||||||
|
public object DeepLinkRouter {
|
||||||
|
|
||||||
|
/** Custom-scheme name — case-insensitive per RFC 3986, compared lowercased. */
|
||||||
|
public const val SCHEME: String = "webterminal"
|
||||||
|
|
||||||
|
/** Custom-scheme action (the URI "host" of `webterminal://open`). */
|
||||||
|
public const val ACTION: String = "open"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The verified App-Links domain. `assetlinks.json` must be served over HTTPS (no redirect) at
|
||||||
|
* `https://<APP_LINK_HOST>/.well-known/assetlinks.json` carrying the RELEASE signing-cert SHA-256
|
||||||
|
* (a deploy artifact, plan §8). The tunnel presents a real LE cert for this host.
|
||||||
|
*/
|
||||||
|
public const val APP_LINK_HOST: String = "terminal.yaojia.wang"
|
||||||
|
|
||||||
|
/** The App-Links path (the https analogue of the custom-scheme [ACTION]). */
|
||||||
|
public const val APP_LINK_PATH: String = "/open"
|
||||||
|
|
||||||
|
/** Query key carrying the paired-host id (a v4 UUID resolved against the store later). */
|
||||||
|
public const val QUERY_HOST: String = "host"
|
||||||
|
|
||||||
|
/** Query key carrying the session id to join (a v4 UUID). */
|
||||||
|
public const val QUERY_JOIN: String = "join"
|
||||||
|
|
||||||
|
/** Push-payload key carrying the gate's session id (payload minimized to `sessionId/cls/token`, §8). */
|
||||||
|
public const val PUSH_SESSION_ID: String = "sessionId"
|
||||||
|
|
||||||
|
/** URI paths accepted for the custom scheme (`webterminal://open` with an empty or root path). */
|
||||||
|
private val EMPTY_PATHS: Set<String> = setOf("", "/")
|
||||||
|
|
||||||
|
/** URI paths accepted for the App Link (`/open`, with or without a trailing slash). */
|
||||||
|
private val APP_LINK_PATHS: Set<String> = setOf(APP_LINK_PATH, "$APP_LINK_PATH/")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an incoming deep-link URI string. Handles BOTH the custom scheme and the verified App Link;
|
||||||
|
* returns [DeepLinkRoute.OpenSession] only when the shape is whitelisted AND both ids are v4 UUIDs,
|
||||||
|
* else [DeepLinkRoute.Ignore]. Never throws — a malformed URI is caught and ignored.
|
||||||
|
*/
|
||||||
|
public fun route(uri: String): DeepLinkRoute {
|
||||||
|
val parsed = try {
|
||||||
|
URI(uri)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return DeepLinkRoute.Ignore
|
||||||
|
}
|
||||||
|
if (!isWhitelistedShape(parsed)) return DeepLinkRoute.Ignore
|
||||||
|
|
||||||
|
val query = parseQuery(parsed.rawQuery)
|
||||||
|
val hostId = uniqueValidatedId(query, QUERY_HOST) ?: return DeepLinkRoute.Ignore
|
||||||
|
val sessionId = uniqueValidatedId(query, QUERY_JOIN) ?: return DeepLinkRoute.Ignore
|
||||||
|
return DeepLinkRoute.OpenSession(hostId = hostId, sessionId = sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an FCM push-tap payload (A30). The payload is minimized to `sessionId/cls/token`; only a
|
||||||
|
* v4-valid `sessionId` yields [DeepLinkRoute.GateSession] — there is no host id in the payload
|
||||||
|
* (A30's notification handler owns host resolution). Anything else → [DeepLinkRoute.Ignore].
|
||||||
|
*/
|
||||||
|
public fun route(pushData: Map<String, String>): DeepLinkRoute {
|
||||||
|
val sessionId = pushData[PUSH_SESSION_ID]?.let(::validatedId) ?: return DeepLinkRoute.Ignore
|
||||||
|
return DeepLinkRoute.GateSession(sessionId = sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [uri] matches the custom-scheme OR the verified-App-Link shape (scheme/host/path). */
|
||||||
|
private fun isWhitelistedShape(uri: URI): Boolean {
|
||||||
|
val scheme = uri.scheme?.lowercase() ?: return false
|
||||||
|
val host = uri.host?.lowercase() ?: return false
|
||||||
|
val path = uri.path ?: ""
|
||||||
|
return when (scheme) {
|
||||||
|
SCHEME -> host == ACTION && path in EMPTY_PATHS
|
||||||
|
"https" -> host == APP_LINK_HOST && path in APP_LINK_PATHS
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exactly ONE occurrence of [key], and its value is a v4 UUID. Zero occurrences (missing key) or
|
||||||
|
* duplicates (ambiguous input) → null, so the link is ignored rather than partially applied
|
||||||
|
* (mirrors iOS `uniqueValidatedId`).
|
||||||
|
*/
|
||||||
|
private fun uniqueValidatedId(query: Map<String, List<String>>, key: String): String? {
|
||||||
|
val values = query[key] ?: return null
|
||||||
|
if (values.size != 1) return null
|
||||||
|
return validatedId(values.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frozen-contract v4 check FIRST ([Validation.isValidSessionId], M7). Returns the canonical
|
||||||
|
* (unchanged) id string when valid, else null. UUIDs never need percent-decoding, so a value that
|
||||||
|
* is not a bare v4 UUID (encoded, empty, garbage) simply fails the whitelist → ignored.
|
||||||
|
*/
|
||||||
|
private fun validatedId(raw: String): String? = if (Validation.isValidSessionId(raw)) raw else null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a raw query string (`a=1&b=2&a=3`) into name → ordered values. Preserves duplicates so
|
||||||
|
* [uniqueValidatedId] can reject ambiguous links. A segment without `=` maps to an empty value; an
|
||||||
|
* empty/blank name is dropped. Values are NOT decoded (see [validatedId]).
|
||||||
|
*/
|
||||||
|
private fun parseQuery(rawQuery: String?): Map<String, List<String>> {
|
||||||
|
if (rawQuery.isNullOrEmpty()) return emptyMap()
|
||||||
|
val out = LinkedHashMap<String, MutableList<String>>()
|
||||||
|
for (segment in rawQuery.split("&")) {
|
||||||
|
if (segment.isEmpty()) continue
|
||||||
|
val eq = segment.indexOf('=')
|
||||||
|
val name = if (eq >= 0) segment.substring(0, eq) else segment
|
||||||
|
val value = if (eq >= 0) segment.substring(eq + 1) else ""
|
||||||
|
if (name.isEmpty()) continue
|
||||||
|
out.getOrPut(name) { mutableListOf() }.add(value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deep-link parse outcome carrying RAW, syntactically-validated ids (host existence is resolved later
|
||||||
|
* against the `HostStore`, never here). Mirrors iOS `DeepLinkRouter.Route`.
|
||||||
|
*/
|
||||||
|
public sealed interface DeepLinkRoute {
|
||||||
|
/** `webterminal://open` / verified App Link with both ids valid v4 → open [sessionId] on [hostId]. */
|
||||||
|
public data class OpenSession(val hostId: String, val sessionId: String) : DeepLinkRoute
|
||||||
|
|
||||||
|
/** Push-tap with a valid [sessionId] (no host id in the payload; A30 resolves the host). */
|
||||||
|
public data class GateSession(val sessionId: String) : DeepLinkRoute
|
||||||
|
|
||||||
|
/** Non-whitelisted / ambiguous / invalid / missing input. Never partially applied; tallied. */
|
||||||
|
public data object Ignore : DeepLinkRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable tally of invalid deep links dropped (the Android analogue of iOS
|
||||||
|
* `DeepLinkHandler.ignoredCount`). Kept as a pure reducer so counting stays side-effect-free and
|
||||||
|
* JVM-testable; the app holds one instance in its state and swaps in the [record] result. The dropped
|
||||||
|
* URL is NEVER stored — only the counter — because the link is untrusted external input (§8).
|
||||||
|
*/
|
||||||
|
public data class DeepLinkTally(val ignored: Int = 0) {
|
||||||
|
/** Return a new tally: +1 when [route] is [DeepLinkRoute.Ignore], otherwise unchanged. */
|
||||||
|
public fun record(route: DeepLinkRoute): DeepLinkTally =
|
||||||
|
if (route is DeepLinkRoute.Ignore) copy(ignored = ignored + 1) else this
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package wang.yaojia.webterm.nav
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # LayoutPolicy (A26) — the single adaptive-layout decision point.
|
||||||
|
*
|
||||||
|
* The Android mirror of iOS `LayoutPolicy` (`ios/App/WebTerm/Wiring/LayoutMode.swift`): the **only**
|
||||||
|
* place that turns a window size class into a layout decision. Views must never scatter
|
||||||
|
* `if windowSizeClass == …` — every branch reads this one pure predicate, so the decision is 100%
|
||||||
|
* JVM-unit-testable with plain enum inputs (no Compose, no device — plan §"iPad-equivalent", R13).
|
||||||
|
*
|
||||||
|
* The thin `WindowSizeClass → WindowWidth` extraction lives in the Compose layer
|
||||||
|
* ([wang.yaojia.webterm.screens.AdaptiveHome]); everything HERE is pure Kotlin so it stays in the
|
||||||
|
* JVM test path exactly like iOS's `LayoutPolicyTests`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain, Compose-free mirror of `androidx.window.core.layout.WindowWidthSizeClass` (the analogue of
|
||||||
|
* SwiftUI's `UserInterfaceSizeClass`). Kept as our own enum so [LayoutPolicy.mode] is a pure function
|
||||||
|
* unit-tested without pulling the window/Compose types onto the JVM test classpath.
|
||||||
|
*/
|
||||||
|
public enum class WindowWidth {
|
||||||
|
/** Phone portrait / small split / Slide-Over — the compact, single-pane path. */
|
||||||
|
COMPACT,
|
||||||
|
|
||||||
|
/** Large phone landscape / small foldable / medium split — wide enough for sidebar + detail. */
|
||||||
|
MEDIUM,
|
||||||
|
|
||||||
|
/** Tablet full-screen / large foldable / desktop-class window — sidebar + detail. */
|
||||||
|
EXPANDED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The adaptive root's layout mode (mirrors iOS `LayoutMode.stack` / `.split`).
|
||||||
|
*
|
||||||
|
* - [STACK] — the compact path: single-pane navigation (list → push terminal), byte-for-byte the
|
||||||
|
* existing phone flow.
|
||||||
|
* - [LIST_DETAIL] — the expanded/medium path: session list (list pane) + terminal (detail pane)
|
||||||
|
* side by side, driven by Material 3 Adaptive `ListDetailPaneScaffold`.
|
||||||
|
*/
|
||||||
|
public enum class LayoutMode {
|
||||||
|
/** Compact: single pane, stack navigation (iPhone / Slide-Over analogue). */
|
||||||
|
STACK,
|
||||||
|
|
||||||
|
/** Medium / expanded: sidebar + detail split (iPad analogue). */
|
||||||
|
LIST_DETAIL,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one size-class decision. [WindowWidth.COMPACT] → [LayoutMode.STACK]; [WindowWidth.MEDIUM] and
|
||||||
|
* [WindowWidth.EXPANDED] → [LayoutMode.LIST_DETAIL]. Pure; mirrors iOS
|
||||||
|
* `LayoutPolicy.mode(horizontalSizeClass:)`.
|
||||||
|
*/
|
||||||
|
public object LayoutPolicy {
|
||||||
|
public fun mode(width: WindowWidth): LayoutMode = when (width) {
|
||||||
|
WindowWidth.COMPACT -> LayoutMode.STACK
|
||||||
|
WindowWidth.MEDIUM, WindowWidth.EXPANDED -> LayoutMode.LIST_DETAIL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pointer-context-menu gating predicate (plan §5 A26, §"iPad-equivalent", R13, §8).
|
||||||
|
*
|
||||||
|
* A right-click / trackpad-secondary context menu is a large-screen affordance only — it is enabled
|
||||||
|
* **iff** the layout is [LayoutMode.LIST_DETAIL] AND the device is a genuine tablet
|
||||||
|
* (`smallestScreenWidthDp >= 600`, the Material tablet breakpoint). A large phone in landscape can
|
||||||
|
* momentarily report an expanded width but keeps `smallestScreenWidthDp < 600`, so the two-part gate
|
||||||
|
* blocks the desktop menu there. Pure so it is JVM-unit-tested with plain inputs.
|
||||||
|
*/
|
||||||
|
public object PointerMenuPolicy {
|
||||||
|
/** The Material tablet breakpoint (dp). `smallestScreenWidthDp` at/above this = a real large screen. */
|
||||||
|
public const val TABLET_MIN_WIDTH_DP: Int = 600
|
||||||
|
|
||||||
|
public fun enabled(mode: LayoutMode, smallestScreenWidthDp: Int): Boolean =
|
||||||
|
mode == LayoutMode.LIST_DETAIL && smallestScreenWidthDp >= TABLET_MIN_WIDTH_DP
|
||||||
|
}
|
||||||
266
android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt
Normal file
266
android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package wang.yaojia.webterm.nav
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import androidx.window.core.layout.WindowSizeClass
|
||||||
|
import wang.yaojia.webterm.hostregistry.Host
|
||||||
|
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||||
|
import wang.yaojia.webterm.wiring.ColdStartRoute
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # WebTerm navigation host (app-assembly of A19–A29).
|
||||||
|
*
|
||||||
|
* The single `NavHost` for the app: it maps each [NavRoutes] destination onto the real screen pane
|
||||||
|
* ([PairingPane] / [SessionsHome] / [ProjectsHome] / [TerminalHost] / [ProjectDetailPane] / [DiffPane] /
|
||||||
|
* [ClientCertPane] / [GatePane]), wires the inter-screen callbacks to `navController` navigations, and
|
||||||
|
* resolves every collaborator (ViewModels, per-host `ApiClient`, the terminal holder) off [env]. The
|
||||||
|
* start destination is chosen by [wang.yaojia.webterm.wiring.ColdStartPolicy] (surfaced through
|
||||||
|
* [NavRoutes.startRouteFor]) and passed in as [startRoute].
|
||||||
|
*
|
||||||
|
* ## Deep links go through [DeepLinkRouter], NOT `navDeepLink`
|
||||||
|
* A `navDeepLink { uriPattern = "webterminal://open?host={host}&join={join}" }` would let the OS navigate
|
||||||
|
* on ANY `{host}`/`{join}` value, bypassing the v4-UUID whitelist. Instead the launcher Activity (and the
|
||||||
|
* push tap) run [DeepLinkRouter.route] first and navigate only a whitelist-validated [NavRoutes.forDeepLink]
|
||||||
|
* route — the manifest `<intent-filter>`s deliver the intent; validation is never delegated to the graph.
|
||||||
|
* That is the single non-skippable check (plan §8 "App Links").
|
||||||
|
*
|
||||||
|
* The `NavHost` composition itself is device-QA (plan §7); the pure route mappers ([NavRoutes]) are
|
||||||
|
* JVM-unit-tested (`DeepLinkRouterTest`, `NavRoutesTest`).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun WebTermNavHost(
|
||||||
|
env: AppEnvironment,
|
||||||
|
startRoute: String,
|
||||||
|
navController: NavHostController = rememberNavController(),
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onHostPaired: (Host) -> Unit = {},
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startRoute,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
composable(NavRoutes.PAIRING) {
|
||||||
|
PairingPane(
|
||||||
|
env = env,
|
||||||
|
onPaired = { host ->
|
||||||
|
onHostPaired(host)
|
||||||
|
navController.navigate(NavRoutes.SESSIONS) {
|
||||||
|
popUpTo(NavRoutes.PAIRING) { inclusive = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onImportCert = { navController.navigate(NavRoutes.CERT) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(NavRoutes.SESSIONS) { SessionsHome(env = env, navController = navController) }
|
||||||
|
|
||||||
|
composable(NavRoutes.PROJECTS) { ProjectsHome(env = env, navController = navController) }
|
||||||
|
|
||||||
|
composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) }
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = TERMINAL_ROUTE,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
|
||||||
|
navArgument(NavArg.SESSION_ID) { type = NavType.StringType },
|
||||||
|
navArgument(NavArg.CWD) {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) { entry ->
|
||||||
|
val hostId = entry.arguments?.getString(NavArg.HOST_ID)
|
||||||
|
TerminalHost(
|
||||||
|
env = env,
|
||||||
|
hostId = hostId,
|
||||||
|
sessionArg = entry.arguments?.getString(NavArg.SESSION_ID),
|
||||||
|
cwd = entry.arguments?.getString(NavArg.CWD),
|
||||||
|
onNewSessionInCwd = { req ->
|
||||||
|
if (hostId != null) navController.navigate(newTerminalRoute(hostId, req.cwd))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = PROJECT_DETAIL_ROUTE,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
|
||||||
|
navArgument(NavArg.PATH) {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) { entry ->
|
||||||
|
ProjectDetailPane(
|
||||||
|
env = env,
|
||||||
|
hostId = entry.arguments?.getString(NavArg.HOST_ID),
|
||||||
|
path = entry.arguments?.getString(NavArg.PATH),
|
||||||
|
navController = navController,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = DIFF_ROUTE,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
|
||||||
|
navArgument(NavArg.PATH) {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) { entry ->
|
||||||
|
DiffPane(
|
||||||
|
env = env,
|
||||||
|
hostId = entry.arguments?.getString(NavArg.HOST_ID),
|
||||||
|
path = entry.arguments?.getString(NavArg.PATH),
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = NavRoutes.GATE_PATTERN,
|
||||||
|
arguments = listOf(navArgument(NavArg.SESSION_ID) { type = NavType.StringType }),
|
||||||
|
) { entry ->
|
||||||
|
GatePane(
|
||||||
|
env = env,
|
||||||
|
sessionArg = entry.arguments?.getString(NavArg.SESSION_ID),
|
||||||
|
navController = navController,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registered routes carrying an OPTIONAL/encoded arg (kept OUT of the pure [NavRoutes] object) ──────
|
||||||
|
|
||||||
|
/** The registered terminal route: [NavRoutes.TERMINAL_PATTERN] plus an optional `cwd` query arg. */
|
||||||
|
internal val TERMINAL_ROUTE: String = "${NavRoutes.TERMINAL_PATTERN}?${NavArg.CWD}={${NavArg.CWD}}"
|
||||||
|
|
||||||
|
/** The registered project-detail route: hostId path arg + an encoded `path` query arg. */
|
||||||
|
internal val PROJECT_DETAIL_ROUTE: String = "projectDetail/{${NavArg.HOST_ID}}?${NavArg.PATH}={${NavArg.PATH}}"
|
||||||
|
|
||||||
|
/** The registered diff route: hostId path arg + an encoded `path` query arg. */
|
||||||
|
internal val DIFF_ROUTE: String = "diff/{${NavArg.HOST_ID}}?${NavArg.PATH}={${NavArg.PATH}}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a concrete route that spawns a NEW session on [hostId] in [cwd] (`attach(null, cwd)`). The cwd
|
||||||
|
* is percent-encoded with [Uri.encode] here (the Android layer) so [NavRoutes] stays a pure,
|
||||||
|
* JVM-testable object with no `android.net.Uri` edge.
|
||||||
|
*/
|
||||||
|
internal fun newTerminalRoute(hostId: String, cwd: String?): String {
|
||||||
|
val base = NavRoutes.terminalNew(hostId)
|
||||||
|
return if (cwd.isNullOrEmpty()) base else "$base?${NavArg.CWD}=${Uri.encode(cwd)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a concrete project-detail route for [hostId] + [path] (path percent-encoded). */
|
||||||
|
internal fun projectDetailRoute(hostId: String, path: String): String =
|
||||||
|
"projectDetail/$hostId?${NavArg.PATH}=${Uri.encode(path)}"
|
||||||
|
|
||||||
|
/** Build a concrete diff route for [hostId] + [path] (path percent-encoded). */
|
||||||
|
internal fun diffRoute(hostId: String, path: String): String =
|
||||||
|
"diff/$hostId?${NavArg.PATH}=${Uri.encode(path)}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the layout [WindowWidth] from a Compose [WindowSizeClass] (the thin bridge kept out of the pure
|
||||||
|
* [LayoutPolicy]). Mirrors the Material width breakpoints: EXPANDED (≥840dp) → medium (≥600dp) → compact.
|
||||||
|
*/
|
||||||
|
internal fun windowWidthOf(sizeClass: WindowSizeClass): WindowWidth = when {
|
||||||
|
sizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> WindowWidth.EXPANDED
|
||||||
|
sizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> WindowWidth.MEDIUM
|
||||||
|
else -> WindowWidth.COMPACT
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nav-arg keys, shared between the route patterns and the argument readers. */
|
||||||
|
public object NavArg {
|
||||||
|
public const val HOST_ID: String = "hostId"
|
||||||
|
public const val SESSION_ID: String = "sessionId"
|
||||||
|
|
||||||
|
/** Optional new-session working directory (query arg on the terminal route). */
|
||||||
|
public const val CWD: String = "cwd"
|
||||||
|
|
||||||
|
/** Project/diff path (encoded query arg). */
|
||||||
|
public const val PATH: String = "path"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's navigation destinations and the pure route mappers. Route strings are the ONE source of
|
||||||
|
* truth for both [WebTermNavHost] and the callers that navigate; the mappers are pure (no Android edge)
|
||||||
|
* so the cold-start and deep-link seams stay JVM-unit-testable without a device.
|
||||||
|
*/
|
||||||
|
public object NavRoutes {
|
||||||
|
/** Pairing / add-host flow (A19) — the cold-start landing when no host is paired. */
|
||||||
|
public const val PAIRING: String = "pairing"
|
||||||
|
|
||||||
|
/** Session chooser / dashboard (A20) — the cold-start landing when a host is paired. */
|
||||||
|
public const val SESSIONS: String = "sessions"
|
||||||
|
|
||||||
|
/** Projects grid (A23). */
|
||||||
|
public const val PROJECTS: String = "projects"
|
||||||
|
|
||||||
|
/** Device-certificate management (A27). */
|
||||||
|
public const val CERT: String = "cert"
|
||||||
|
|
||||||
|
/** Terminal route pattern with the required host + session path args (A21). */
|
||||||
|
public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push-gate route pattern (A30): a session id with NO host id in the payload — the notification
|
||||||
|
* handler resolves the host, then this destination surfaces the gate for [sessionId].
|
||||||
|
*/
|
||||||
|
public const val GATE_PATTERN: String = "gate/{${NavArg.SESSION_ID}}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel session-arg meaning "spawn a NEW session" (`attach(null)`) — a fresh session has no
|
||||||
|
* server id yet, so the terminal route carries this in the [NavArg.SESSION_ID] slot and
|
||||||
|
* [attachSessionId] maps it back to `null` at the bind site.
|
||||||
|
*/
|
||||||
|
public const val NEW_SESSION_SENTINEL: String = "new"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cold-start start destination, keyed off [ColdStartRoute] (no host → pairing, else sessions).
|
||||||
|
* Pure; mirrors the [wang.yaojia.webterm.wiring.ColdStartPolicy] decision onto a nav route.
|
||||||
|
*/
|
||||||
|
public fun startRouteFor(route: ColdStartRoute): String = when (route) {
|
||||||
|
ColdStartRoute.PAIRING -> PAIRING
|
||||||
|
ColdStartRoute.SESSIONS -> SESSIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a concrete terminal route for a resolved (hostId, sessionId) pair. */
|
||||||
|
public fun terminal(hostId: String, sessionId: String): String = "terminal/$hostId/$sessionId"
|
||||||
|
|
||||||
|
/** Build a concrete terminal route that spawns a NEW session on [hostId] ([NEW_SESSION_SENTINEL]). */
|
||||||
|
public fun terminalNew(hostId: String): String = "terminal/$hostId/$NEW_SESSION_SENTINEL"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a terminal-route session arg to the id to bind: the [NEW_SESSION_SENTINEL] (a fresh spawn)
|
||||||
|
* becomes `null` (`attach(null)`), any other value passes through verbatim. Pure → JVM-tested.
|
||||||
|
*/
|
||||||
|
public fun attachSessionId(sessionArg: String?): String? =
|
||||||
|
sessionArg?.takeUnless { it == NEW_SESSION_SENTINEL }
|
||||||
|
|
||||||
|
/** Build a concrete push-gate route for a resolved sessionId. */
|
||||||
|
public fun gate(sessionId: String): String = "gate/$sessionId"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deep-link entry point: turn a [DeepLinkRouter] result into the concrete nav route to navigate
|
||||||
|
* to, or null for [DeepLinkRoute.Ignore] (the caller does nothing — the ignore is already tallied).
|
||||||
|
* This is the ONE place a validated deep link becomes a navigation, so the launcher Activity and the
|
||||||
|
* push tap share it. Pure → JVM-tested.
|
||||||
|
*/
|
||||||
|
public fun forDeepLink(route: DeepLinkRoute): String? = when (route) {
|
||||||
|
is DeepLinkRoute.OpenSession -> terminal(route.hostId, route.sessionId)
|
||||||
|
is DeepLinkRoute.GateSession -> gate(route.sessionId)
|
||||||
|
DeepLinkRoute.Ignore -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
236
android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt
Normal file
236
android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package wang.yaojia.webterm.nav
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import wang.yaojia.webterm.designsystem.Spacing
|
||||||
|
import wang.yaojia.webterm.hostregistry.Host
|
||||||
|
import wang.yaojia.webterm.screens.ClientCertScreen
|
||||||
|
import wang.yaojia.webterm.screens.DiffScreen
|
||||||
|
import wang.yaojia.webterm.screens.PairingScreen
|
||||||
|
import wang.yaojia.webterm.screens.ProjectDetailScreen
|
||||||
|
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
|
||||||
|
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
||||||
|
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
||||||
|
import wang.yaojia.webterm.viewmodels.HttpDiffFetcher
|
||||||
|
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
||||||
|
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
|
||||||
|
import wang.yaojia.webterm.viewmodels.ProjectsGateway
|
||||||
|
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
// ── Pairing (A19) ─────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts [PairingScreen] over a fresh [PairingViewModel]: the two-step probe uses the shared transports
|
||||||
|
* (resolved lazily off `Main` inside the probe), and the tunnel cert-gate reads the device identity off
|
||||||
|
* `Dispatchers.IO`. The screen does not self-bind, so this pane binds it to a lifecycle scope.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun PairingPane(
|
||||||
|
env: AppEnvironment,
|
||||||
|
onPaired: (Host) -> Unit,
|
||||||
|
onImportCert: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val viewModel = remember(env) {
|
||||||
|
PairingViewModel(
|
||||||
|
hostStore = env.hostStore,
|
||||||
|
prober = env.buildPairingProber(),
|
||||||
|
hasDeviceCert = {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||||
|
PairingScreen(viewModel = viewModel, onPaired = onPaired, onImportCert = onImportCert, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Device certificate (A27) ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Hosts [ClientCertScreen] over a fresh [ClientCertViewModel] on the shared mTLS device identity. */
|
||||||
|
@Composable
|
||||||
|
public fun ClientCertPane(
|
||||||
|
env: AppEnvironment,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val viewModel = remember(env) { ClientCertViewModel(repository = env.identityRepository) }
|
||||||
|
ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Project detail (A23) ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project-detail destination: resolves [hostId] → its per-host projects gateway, then renders
|
||||||
|
* [ProjectDetailContent]. "在此启动 Claude" spawns a new session in the project cwd.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun ProjectDetailPane(
|
||||||
|
env: AppEnvironment,
|
||||||
|
hostId: String?,
|
||||||
|
path: String?,
|
||||||
|
navController: NavHostController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (hostId == null || path == null) {
|
||||||
|
MessagePane("无效的项目。", modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
|
||||||
|
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
|
||||||
|
}
|
||||||
|
val resolved = host
|
||||||
|
if (resolved == null) {
|
||||||
|
MessagePane("正在打开项目…", modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val gateway = remember(resolved) { ApiClientProjectsGateway(env.apiClientFactory.create(resolved.endpoint)) }
|
||||||
|
ProjectDetailContent(
|
||||||
|
gateway = gateway,
|
||||||
|
path = path,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared project-detail body — reused by the stacked destination and the tablet detail pane. */
|
||||||
|
@Composable
|
||||||
|
public fun ProjectDetailContent(
|
||||||
|
gateway: ProjectsGateway,
|
||||||
|
path: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenClaude: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
|
||||||
|
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read-only git-diff destination (A24): resolves [hostId] → the RO diff fetcher over the shared
|
||||||
|
* [HttpTransport][wang.yaojia.webterm.wire.HttpTransport]. Composed for completeness; no inbound link is
|
||||||
|
* wired yet (the project-detail screen exposes no "view diff" callback to connect).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun DiffPane(
|
||||||
|
env: AppEnvironment,
|
||||||
|
hostId: String?,
|
||||||
|
path: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (hostId == null || path == null) {
|
||||||
|
MessagePane("无效的路径。", modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
|
||||||
|
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
|
||||||
|
}
|
||||||
|
val resolved = host
|
||||||
|
if (resolved == null) {
|
||||||
|
MessagePane("正在加载 diff…", modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val viewModel = remember(resolved, path) {
|
||||||
|
DiffViewModel(fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), path = path)
|
||||||
|
}
|
||||||
|
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Push-gate deep link (A30) ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The push-gate destination: a valid session id arrives with NO host (payload minimization, §4.5), so this
|
||||||
|
* best-effort scans the paired hosts' live sessions for a match and redirects to that host's terminal
|
||||||
|
* (which surfaces the held gate); if none matches, it falls back to the sessions chooser. All resolution
|
||||||
|
* runs off `Main`; device-QA.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun GatePane(
|
||||||
|
env: AppEnvironment,
|
||||||
|
sessionArg: String?,
|
||||||
|
navController: NavHostController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val sessionId = NavRoutes.attachSessionId(sessionArg)
|
||||||
|
LaunchedEffect(sessionId) {
|
||||||
|
val route = gateTargetRoute(env, sessionId)
|
||||||
|
runCatching {
|
||||||
|
navController.navigate(route) {
|
||||||
|
popUpTo(NavRoutes.SESSIONS) { inclusive = false }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessagePane("正在定位会话…", modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve where a push-gate deep link should land: the terminal of the host that currently owns
|
||||||
|
* [sessionId] (which surfaces the held gate), or the sessions chooser when the id is missing/invalid or
|
||||||
|
* no paired host has it.
|
||||||
|
*/
|
||||||
|
private suspend fun gateTargetRoute(env: AppEnvironment, sessionId: String?): String {
|
||||||
|
if (sessionId == null) return NavRoutes.SESSIONS
|
||||||
|
val uuid = runCatching { UUID.fromString(sessionId) }.getOrNull() ?: return NavRoutes.SESSIONS
|
||||||
|
val hostId = resolveHostForSession(env, uuid) ?: return NavRoutes.SESSIONS
|
||||||
|
return NavRoutes.terminal(hostId, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan paired hosts' `GET /live-sessions` for [id]; return the first host that has it (off `Main`). */
|
||||||
|
private suspend fun resolveHostForSession(env: AppEnvironment, id: UUID): String? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
for (host in runCatchingCancellable { env.hostStore.loadAll() }.getOrDefault(emptyList())) {
|
||||||
|
val hasIt = runCatchingCancellable {
|
||||||
|
env.apiClientFactory.create(host.endpoint).liveSessions().any { it.id == id }
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (hasIt) return@withContext host.id
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared placeholders ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Centered informational text for the tablet detail pane's empty state. */
|
||||||
|
@Composable
|
||||||
|
public fun DetailPlaceholder(text: String, modifier: Modifier = Modifier) {
|
||||||
|
CenteredMessage(text, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centered informational text for a loading / invalid-argument pane. */
|
||||||
|
@Composable
|
||||||
|
public fun MessagePane(text: String, modifier: Modifier = Modifier) {
|
||||||
|
CenteredMessage(text, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredMessage(text: String, modifier: Modifier) {
|
||||||
|
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(Spacing.xxl24),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
|
|
||||||
|
package wang.yaojia.webterm.nav
|
||||||
|
|
||||||
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import wang.yaojia.webterm.hostregistry.Host
|
||||||
|
import wang.yaojia.webterm.screens.AdaptiveHome
|
||||||
|
import wang.yaojia.webterm.screens.ProjectsScreen
|
||||||
|
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
|
||||||
|
import wang.yaojia.webterm.viewmodels.ProjectsViewModel
|
||||||
|
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Projects grid (A23) inside the A26 adaptive shell: [ProjectsScreen] (list pane) beside the selected
|
||||||
|
* [ProjectDetailContent] (detail pane) on large screens, or a single grid on compact where a card tap
|
||||||
|
* pushes the project-detail destination. "在此启动 Claude" (from a card or the detail) is routed through
|
||||||
|
* [ProjectsViewModel.requestOpenClaude] → a validated [ProjectOpenRequest], which this pane consumes into
|
||||||
|
* a `newTerminalRoute` navigation (`attach(null, cwd=projectPath)`).
|
||||||
|
*
|
||||||
|
* Projects run against the FIRST paired host (single-host is the common case; cross-host project selection
|
||||||
|
* is a device-QA refinement). The navigation-suite "会话" entry returns to [SessionsHome].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun ProjectsHome(
|
||||||
|
env: AppEnvironment,
|
||||||
|
navController: NavHostController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val host by produceState<Host?>(initialValue = null) {
|
||||||
|
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull() }.getOrNull()
|
||||||
|
}
|
||||||
|
val resolved = host
|
||||||
|
if (resolved == null) {
|
||||||
|
DetailPlaceholder("尚未配对主机。", modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val gateway = remember(resolved) { ApiClientProjectsGateway(env.apiClientFactory.create(resolved.endpoint)) }
|
||||||
|
val viewModel = remember(gateway) { ProjectsViewModel(gateway) }
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// "Open Claude here" → spawn a new session in the project cwd, then clear the one-shot signal.
|
||||||
|
LaunchedEffect(state.openRequest) {
|
||||||
|
val request = state.openRequest ?: return@LaunchedEffect
|
||||||
|
navController.navigate(newTerminalRoute(resolved.id, request.cwd))
|
||||||
|
viewModel.consumeOpenRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
val sizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||||
|
val mode = LayoutPolicy.mode(windowWidthOf(sizeClass))
|
||||||
|
var selectedPath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
AdaptiveHome(
|
||||||
|
listPane = {
|
||||||
|
ProjectsScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onOpenDetail = { path ->
|
||||||
|
if (mode == LayoutMode.LIST_DETAIL) {
|
||||||
|
selectedPath = path
|
||||||
|
} else {
|
||||||
|
navController.navigate(projectDetailRoute(resolved.id, path))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
detailPane = {
|
||||||
|
val path = selectedPath
|
||||||
|
if (path != null) {
|
||||||
|
ProjectDetailContent(
|
||||||
|
gateway = gateway,
|
||||||
|
path = path,
|
||||||
|
onBack = { selectedPath = null },
|
||||||
|
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DetailPlaceholder("选择一个项目查看详情。")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedDestination = 1,
|
||||||
|
onSelectDestination = { index ->
|
||||||
|
if (index == 0) {
|
||||||
|
navController.navigate(NavRoutes.SESSIONS) {
|
||||||
|
popUpTo(NavRoutes.SESSIONS) { inclusive = false }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
windowSizeClass = sizeClass,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
|
|
||||||
|
package wang.yaojia.webterm.nav
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import wang.yaojia.webterm.components.ContinueLastBanner
|
||||||
|
import wang.yaojia.webterm.components.continueLastModel
|
||||||
|
import wang.yaojia.webterm.screens.AdaptiveHome
|
||||||
|
import wang.yaojia.webterm.screens.SessionListScreen
|
||||||
|
import wang.yaojia.webterm.viewmodels.ApiClientSessionGateway
|
||||||
|
import wang.yaojia.webterm.viewmodels.SessionListViewModel
|
||||||
|
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sessions landing (A20) inside the A26 adaptive shell: a [SessionListScreen] (list pane) beside the
|
||||||
|
* live [TerminalHost] (detail pane) on large screens, or a single list on compact where a row tap pushes
|
||||||
|
* the terminal destination. The [ContinueLastBanner] (A29) is layered above the list. The navigation-suite
|
||||||
|
* "项目" entry navigates to [ProjectsHome].
|
||||||
|
*
|
||||||
|
* ### Adaptive open behaviour
|
||||||
|
* On [LayoutMode.LIST_DETAIL] a row tap fills the detail pane (a per-session holder keyed under THIS
|
||||||
|
* back-stack entry); on [LayoutMode.STACK] it pushes a full-screen terminal destination. New-session and
|
||||||
|
* continue-last always push, so a fresh spawn gets its own nav-scoped holder.
|
||||||
|
*
|
||||||
|
* The [SessionListViewModel] host menu drives its own active-host selection; this pane reads the resolved
|
||||||
|
* active host id from its state to build the terminal endpoint / continue-last pointer.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun SessionsHome(
|
||||||
|
env: AppEnvironment,
|
||||||
|
navController: NavHostController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val viewModel = remember(env) {
|
||||||
|
SessionListViewModel(
|
||||||
|
hostStore = env.hostStore,
|
||||||
|
gatewayFactory = { host -> ApiClientSessionGateway(env.apiClientFactory.create(host.endpoint)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val activeHostId = state.activeHostId
|
||||||
|
|
||||||
|
val sizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||||
|
val mode = LayoutPolicy.mode(windowWidthOf(sizeClass))
|
||||||
|
var selectedSessionId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val openSession: (UUID) -> Unit = { id ->
|
||||||
|
val sid = id.toString()
|
||||||
|
if (mode == LayoutMode.LIST_DETAIL) {
|
||||||
|
selectedSessionId = sid
|
||||||
|
} else {
|
||||||
|
activeHostId?.let { navController.navigate(NavRoutes.terminal(it, sid)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val openNewSession: () -> Unit = {
|
||||||
|
activeHostId?.let { navController.navigate(NavRoutes.terminalNew(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
AdaptiveHome(
|
||||||
|
listPane = {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val lastSessionId by produceState<String?>(initialValue = null, key1 = activeHostId) {
|
||||||
|
value = activeHostId?.let { runCatchingCancellable { env.lastSessionStore.lastSessionId(it) }.getOrNull() }
|
||||||
|
}
|
||||||
|
ContinueLastBanner(
|
||||||
|
model = continueLastModel(lastSessionId),
|
||||||
|
onContinue = { sid -> activeHostId?.let { navController.navigate(NavRoutes.terminal(it, sid)) } },
|
||||||
|
)
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
SessionListScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onOpenSession = openSession,
|
||||||
|
onNewSession = openNewSession,
|
||||||
|
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
|
||||||
|
onImportCert = { navController.navigate(NavRoutes.CERT) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
detailPane = {
|
||||||
|
val sid = selectedSessionId
|
||||||
|
if (sid != null && activeHostId != null) {
|
||||||
|
TerminalHost(
|
||||||
|
env = env,
|
||||||
|
hostId = activeHostId,
|
||||||
|
sessionArg = sid,
|
||||||
|
cwd = null,
|
||||||
|
onNewSessionInCwd = { req ->
|
||||||
|
navController.navigate(newTerminalRoute(activeHostId, req.cwd))
|
||||||
|
},
|
||||||
|
holderKey = "detail/$activeHostId/$sid",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DetailPlaceholder("选择一个会话查看终端。")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedDestination = 0,
|
||||||
|
onSelectDestination = { index ->
|
||||||
|
if (index == 1) navController.navigate(NavRoutes.PROJECTS) { launchSingleTop = true }
|
||||||
|
},
|
||||||
|
windowSizeClass = sizeClass,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package wang.yaojia.webterm.nav
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import wang.yaojia.webterm.hostregistry.Host
|
||||||
|
import wang.yaojia.webterm.screens.NewSessionRequest
|
||||||
|
import wang.yaojia.webterm.screens.TerminalScreen
|
||||||
|
import wang.yaojia.webterm.screens.TimelineSheet
|
||||||
|
import wang.yaojia.webterm.viewmodels.TimelineViewModel
|
||||||
|
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||||
|
import wang.yaojia.webterm.wiring.RetainedSessionHolder
|
||||||
|
import wang.yaojia.webterm.wiring.SessionActivityBridge
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** How long (steps × step-ms) to wait for the holder to publish its controller before giving up. */
|
||||||
|
private const val BRIDGE_WAIT_STEPS: Int = 200
|
||||||
|
private const val BRIDGE_WAIT_STEP_MS: Long = 50L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # TerminalHost — the composition-root host for one terminal destination (A21/A22/A28/A29).
|
||||||
|
*
|
||||||
|
* Resolves [hostId] → its paired [Host] (for the dial endpoint), obtains the config-surviving
|
||||||
|
* [RetainedSessionHolder] (per (host, session) via [hiltViewModel] — the nav back-stack entry is the
|
||||||
|
* `ViewModelStoreOwner`; [holderKey] scopes a distinct holder when several are hosted under ONE owner,
|
||||||
|
* e.g. the tablet detail pane), and renders [TerminalScreen]. It adds the two composition-root wirings
|
||||||
|
* A21 left as seams:
|
||||||
|
*
|
||||||
|
* - **A29 last-session lifecycle** — a per-session [SessionActivityBridge] folds the controller's
|
||||||
|
* control-event stream into [LastSessionStore][wang.yaojia.webterm.hostregistry.LastSessionStore]
|
||||||
|
* (set-on-`Adopted`, clear-on-`Exited`), so cold-start's「继续上次会话」points at the live session.
|
||||||
|
* - **A28 timeline** — the away-digest「展开」opens the [TimelineSheet] for the current session, its
|
||||||
|
* [TimelineViewModel] built over the per-host `ApiClient.events`.
|
||||||
|
*
|
||||||
|
* All Compose/lifecycle/emulator behaviour is device-QA (plan §7).
|
||||||
|
*
|
||||||
|
* @param sessionArg the raw route session arg ([NavRoutes.NEW_SESSION_SENTINEL] = spawn a new session).
|
||||||
|
* @param cwd new-session working directory (only meaningful when spawning; ignored on re-attach).
|
||||||
|
* @param onNewSessionInCwd nav callback for the toolbar/exit "在当前目录开新会话" action.
|
||||||
|
* @param holderKey optional [hiltViewModel] key so the tablet detail pane can host a holder distinct
|
||||||
|
* from the stacked terminal destination under the SAME owner (null = the owner's default holder).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
public fun TerminalHost(
|
||||||
|
env: AppEnvironment,
|
||||||
|
hostId: String?,
|
||||||
|
sessionArg: String?,
|
||||||
|
cwd: String?,
|
||||||
|
onNewSessionInCwd: (NewSessionRequest) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
holderKey: String? = null,
|
||||||
|
) {
|
||||||
|
if (hostId == null) {
|
||||||
|
MessagePane("无效的主机。", modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the paired host for its dial endpoint (untrusted at rest; a removed host stays "loading").
|
||||||
|
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
|
||||||
|
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
|
||||||
|
}
|
||||||
|
val resolved = host
|
||||||
|
if (resolved == null) {
|
||||||
|
MessagePane("正在打开会话…", modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val sessionId = NavRoutes.attachSessionId(sessionArg)
|
||||||
|
val holder: RetainedSessionHolder = hiltViewModel(key = holderKey ?: "$hostId/$sessionArg")
|
||||||
|
|
||||||
|
// A29: drive LastSessionStore off the session's control-event stream once the holder publishes its
|
||||||
|
// controller (bind() sets it during TerminalScreen's composition, after warm-up). A bounded wait
|
||||||
|
// avoids a busy-spin if warm-up fails; the collection is cancelled when this composable leaves.
|
||||||
|
LaunchedEffect(holder, hostId) {
|
||||||
|
var controller = holder.controller
|
||||||
|
var waited = 0
|
||||||
|
while (controller == null && waited < BRIDGE_WAIT_STEPS) {
|
||||||
|
delay(BRIDGE_WAIT_STEP_MS)
|
||||||
|
controller = holder.controller
|
||||||
|
waited++
|
||||||
|
}
|
||||||
|
controller?.let { live ->
|
||||||
|
SessionActivityBridge(lastSessionStore = env.lastSessionStore, hostId = hostId)
|
||||||
|
.observe(live.controlEvents())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showTimeline by remember(hostId, sessionArg) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
TerminalScreen(
|
||||||
|
holder = holder,
|
||||||
|
endpoint = resolved.endpoint,
|
||||||
|
sessionId = sessionId,
|
||||||
|
cwd = cwd,
|
||||||
|
onNewSessionInCwd = onNewSessionInCwd,
|
||||||
|
modifier = modifier,
|
||||||
|
onWarmUp = { env.warmUp() },
|
||||||
|
onDigestExpand = { if (sessionId != null) showTimeline = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showTimeline && sessionId != null) {
|
||||||
|
val sessionUuid = remember(sessionId) { runCatching { UUID.fromString(sessionId) }.getOrNull() }
|
||||||
|
val api = remember(resolved) { env.apiClientFactory.create(resolved.endpoint) }
|
||||||
|
// A fresh VM each time the sheet opens (this block re-enters composition) → exactly one fetch.
|
||||||
|
val timelineVm = remember { TimelineViewModel.forSession(sessionUuid) { id -> api.events(id) } }
|
||||||
|
if (timelineVm != null) {
|
||||||
|
TimelineSheet(viewModel = timelineVm, onDismiss = { showTimeline = false })
|
||||||
|
} else {
|
||||||
|
showTimeline = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user