Compare commits

..

10 Commits

Author SHA1 Message Date
Yaojia Wang
cf88e7c588 docs(android-plan): lock user decisions R1 (accept best-effort FCM) + R7 (use google-auth-library)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
R1: accept best-effort FCM background delivery as a documented limitation.
R7: server FCM sender uses google-auth-library (not hand-rolled RS256).
All three open questions (incl. R2 license) now decided; nothing blocks AW3/AW5.
2026-07-08 11:38:38 +02:00
Yaojia Wang
34e4a88059 docs: Android client implementation plan (multi-agent explore + review)
Phased build spec for an Android client at functional parity with the iOS
client. Produced by a multi-agent workflow: 10 iOS-subsystem explorers + 2
Android-tech researchers -> architect synthesis -> 4-lens adversarial review
-> finalize. Mirrors the iOS SPM package set as Gradle modules; 36 tasks
across waves AW0-AW6 with Owns/Depends/Verify, two de-risking spikes.

R2 (Termux license) independently re-verified and downgraded Critical->Low:
terminal-view/terminal-emulator are Apache-2.0 and on JitPack, not GPL-vendor.

Open questions for the user: R1 (FCM background-delivery acceptance),
R7 (FCM OAuth2 dependency).
2026-07-08 11:31:30 +02:00
Yaojia Wang
99cafdbdbb docs(readme): update test count (~1470) + document login-shell PATH behavior
Explains why hooks reliably find nvm/brew-managed node (shells spawn as
login shells) and how to recover an old session with a stale PATH.
2026-07-07 21:10:40 +02:00
Yaojia Wang
57725f7ef2 test: fix pre-existing failures — localStorage polyfill + prefs shape drift
- jsdom 29's localStorage methods are undefined under this vitest config, so
  every frontend test calling localStorage.clear()/setItem() threw. Add a
  shared in-memory Web Storage polyfill via setupFiles (no-op when a working
  Storage exists / in node env). Fixes quick-reply, push, projects-panel (135 tests).
- Update desktop prefs test: defaultPrefs gained remoteHosts/selectedHostId
  (remote-host mTLS work); sync EXPECTED_DEFAULTS and the round-trip fixture.

Full suite now green: 1473 passed (was 142 failing).
2026-07-07 21:09:49 +02:00
Yaojia Wang
fc3b849a08 fix(session): spawn PTY as login shell so hooks find nvm node
Spawn the shell with -l (POSIX only) so it loads the full profile chain
and rebuilds PATH. Without it, a shell spawned by a GUI-launched app or a
long-lived tmux keepalive server inherits a minimal PATH, so nvm-managed
node (only on the login PATH) is missing and Claude Code hooks fail with
'node: command not found'. PowerShell (Windows) has no -l; skipped there.

Applies to both the tmux new-session command and the direct-spawn path.
2026-07-07 21:03:35 +02:00
Yaojia Wang
a24465623e docs: LE wildcard cert live — native tunnel M2-ready (public trust verified, no -k) 2026-07-07 20:57:27 +02:00
Yaojia Wang
a25633a63b docs: native tunnel M1 achieved on VPS (frps + device-CA mTLS + nginx :8470 + SNI merge) 2026-07-07 20:28:27 +02:00
Yaojia Wang
5b9ca321d2 docs(deploy): master deployment guide — today's work + full VPS→client mTLS-tunnel runbook 2026-07-07 16:16:24 +02:00
Yaojia Wang
cb04516d52 docs: log native mTLS reverse-tunnel — four tracks landed + two HIGH fixups 2026-07-07 09:43:35 +02:00
Yaojia Wang
d0c249c739 feat(agent): inject S0 env into launchd/systemd via install CLI (S2)
- launchd writer emits <EnvironmentVariables>; systemd writer emits EnvironmentFile/Environment;
  originConfig zone parameterized (default 'term' preserved for relay callers).
- InstallOptions threaded CLI install -> createCliDeps -> installService seam -> writers, so generated
  units carry BIND_HOST (defaults 127.0.0.1 for tunnel hosts), ALLOWED_ORIGINS, PORT, SHELL_PATH,
  IDLE_TTL, USE_TMUX. systemd rejects control chars in env values.
Verified: agent typecheck+build clean; vitest 166/166 (154 baseline + 12 new).
2026-07-07 09:42:12 +02:00
17 changed files with 1378 additions and 31 deletions

View File

@@ -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.
> **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.
### Tests
```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 build # compile backend to dist/
```

View File

@@ -6,6 +6,7 @@
import type { AgentConfig } from './config/agentConfig.js'
import type { AgentIdentity } from './keys/identity.js'
import type { Keystore } from './keys/keystore.js'
import type { InstallOptions } from './service/install.js'
import type { EnrollResult } from 'relay-contracts'
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
@@ -30,7 +31,9 @@ export interface CliDeps {
generateIdentity(): AgentIdentity
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
installService(cfg: AgentConfig): Promise<void>
/** Resolve per-host install inputs (S0 env incl. loopback BIND_HOST, tunnel origin) from env/flags. */
resolveInstallOptions(): InstallOptions
installService(cfg: AgentConfig, options: InstallOptions): Promise<void>
uninstallService(): Promise<void>
print(line: string): void
}
@@ -70,7 +73,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
ks.saveIdentity(id)
const enroll = await deps.redeem(cfg, args.code!, id, ks)
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
if (args.flags['install']) await deps.installService(cfg)
if (args.flags['install']) await deps.installService(cfg, deps.resolveInstallOptions())
return 0
}
case 'run': {
@@ -88,7 +91,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
return 0
}
case 'install':
await deps.installService(cfg)
await deps.installService(cfg, deps.resolveInstallOptions())
return 0
case 'uninstall':
await deps.uninstallService()

View File

@@ -17,6 +17,7 @@ import { generateIdentity } from '../keys/identity.js'
import { redeemPairingCode } from '../enroll/pair.js'
import { runTunnel } from '../transport/runTunnel.js'
import {
buildInstallOptions,
detectPlatform,
installService as installServiceUnit,
uninstallService as uninstallServiceUnit,
@@ -74,7 +75,9 @@ export function createCliDeps(): CliDeps {
process.once('SIGINT', onSignal)
return handle.done
},
installService: (cfg) => installServiceUnit(cfg, requirePlatform(), realInstallDeps()),
resolveInstallOptions: () => buildInstallOptions(process.env),
installService: (cfg, options) =>
installServiceUnit(cfg, requirePlatform(), realInstallDeps(), options),
uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }),
print: (line) => {
process.stdout.write(`${line}\n`)

View File

@@ -9,16 +9,69 @@ import {
launchdLoadCommand,
launchdPlistPath,
launchdUnloadCommand,
type ServiceEnv,
} from './launchd.js'
import {
buildSystemdUnit,
systemdDisableCommand,
systemdEnableCommand,
systemdUnitPath,
type SystemdUnitOptions,
} from './systemd.js'
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
export type ServicePlatform = 'launchd' | 'systemd'
/**
* Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
* existing callers (and relay callers) are unaffected — an empty object writes the historical unit.
*/
export interface InstallOptions {
/** S0 env injected into the supervised process (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
}
/** S0 base-app env vars the install CLI bakes into the per-host unit, passed through verbatim. */
const BASE_APP_ENV_KEYS = ['ALLOWED_ORIGINS', 'PORT', 'SHELL_PATH', 'IDLE_TTL', 'USE_TMUX'] 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). `buildInstallOptions` therefore defaults `BIND_HOST` here.
*/
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'
/**
* Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2).
* Sources the S0 base-app env — defaulting `BIND_HOST` to loopback so a tunnel install can never
* emit a LAN-exposed unit — plus the tunnel-origin `domain`/`zone` used to derive ALLOWED_ORIGINS.
* Pure/immutable: `env` is a parameter, so this is unit-testable without touching real process state.
*/
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,
),
)
const serviceEnv: ServiceEnv = { BIND_HOST: env.BIND_HOST || TUNNEL_DEFAULT_BIND_HOST, ...passthrough }
const domain = env.TUNNEL_DOMAIN
const envFile = env.AGENT_ENV_FILE
return {
env: serviceEnv,
...(domain ? { domain, zone: env.TUNNEL_ZONE || TUNNEL_ORIGIN_ZONE } : {}),
...(envFile ? { envFile } : {}),
}
}
export class RootRefusedError extends Error {
constructor() {
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
@@ -42,23 +95,40 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
return null
}
/**
* Resolve the env map injected into the unit. When a `domain` (+ `cfg.subdomain`) is supplied, the
* tunnel origin `https://<subdomain>.<zone>.<domain>` is merged into ALLOWED_ORIGINS so the base app
* trusts its own tunnel host — never weakening any origin the caller already provided. Immutable.
*/
function resolveEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
const base = options.env ?? {}
if (!options.domain || !cfg.subdomain) return base
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
return { ...base, ALLOWED_ORIGINS: mergeOrigins(base.ALLOWED_ORIGINS, origin) }
}
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
export async function installService(
_cfg: AgentConfig,
cfg: AgentConfig,
platform: ServicePlatform,
deps: InstallDeps,
options: InstallOptions = {},
): Promise<void> {
if (deps.getuid() === 0) throw new RootRefusedError()
const bin = deps.binPath()
const env = resolveEnv(cfg, options)
if (platform === 'launchd') {
const path = launchdPlistPath(deps.homedir())
deps.writeFile(path, buildLaunchdPlist(bin))
deps.writeFile(path, buildLaunchdPlist(bin, env))
const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args)
return
}
const path = systemdUnitPath(deps.homedir())
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
const systemdOptions: SystemdUnitOptions = options.envFile
? { env, envFile: options.envFile }
: { env }
deps.writeFile(path, buildSystemdUnit(bin, deps.username(), systemdOptions))
const { cmd, args } = systemdEnableCommand()
await deps.runCommand(cmd, args)
}

View File

@@ -1,7 +1,16 @@
/**
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
*
* PLAN_NATIVE_TUNNEL S2: the plist can now 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.
*/
/** Shared env-map shape for the durable-service writers (launchd + systemd). Immutable. */
export type ServiceEnv = Readonly<Record<string, string>>
const LABEL = 'com.web-terminal.agent'
export function launchdLabel(): string {
@@ -12,24 +21,52 @@ export function launchdPlistPath(homedir: string): string {
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
}
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */
export function buildLaunchdPlist(binPath: string): string {
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
function escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
/** 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 plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). When `env`
* is non-empty, a launchd `EnvironmentVariables` dict is injected so the supervised process
* receives the per-host tunnel config. Pure/immutable — returns a fresh string.
*/
export function buildLaunchdPlist(binPath: string, env: ServiceEnv = {}): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<dict>',
' <key>Label</key>',
` <string>${LABEL}</string>`,
` <string>${escapeXml(LABEL)}</string>`,
' <key>ProgramArguments</key>',
' <array>',
` <string>${binPath}</string>`,
` <string>${escapeXml(binPath)}</string>`,
' <string>run</string>',
' </array>',
' <key>RunAtLoad</key>',
' <true/>',
' <key>KeepAlive</key>',
' <true/>',
...environmentVariablesBlock(env),
'</dict>',
'</plist>',
'',

View File

@@ -1,8 +1,13 @@
/**
* 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
* 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'
@@ -18,23 +23,40 @@ const defaultFs: OriginFsDeps = {
write: (p, c) => writeFileSync(p, c),
}
/** Compose the subdomain origin the base app must trust. */
export function subdomainOrigin(subdomain: string, domain: string): string {
return `https://${subdomain}.term.${domain}`
}
/** Default DNS zone label (relay hosts). Native-tunnel hosts pass `terminal`. */
export const DEFAULT_ORIGIN_ZONE = 'term'
const KEY = 'ALLOWED_ORIGINS'
/** Compose the subdomain origin the base app must trust: `https://<subdomain>.<zone>.<domain>`. */
export function subdomainOrigin(
subdomain: string,
domain: string,
zone: string = DEFAULT_ORIGIN_ZONE,
): string {
return `https://${subdomain}.${zone}.${domain}`
}
/**
* Merge `origin` into a comma-separated ALLOWED_ORIGINS value, preserving every existing origin and
* de-duplicating. Returns the merged CSV; never removes an origin. Pure/immutable.
*/
export function mergeOrigins(current: string | undefined, origin: string): string {
const origins = (current ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (origins.includes(origin)) return origins.join(',')
return [...origins, origin].join(',')
}
function upsertOriginLine(content: string, origin: string): string {
const lines = content.length === 0 ? [] : content.split('\n')
let found = false
const next = lines.map((line) => {
if (!line.startsWith(`${KEY}=`)) return line
found = true
const current = line.slice(KEY.length + 1)
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(',')}`
return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
})
if (!found) next.push(`${KEY}=${origin}`)
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
* 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(
baseAppEnvPath: string,
subdomain: string,
domain: string,
fs: OriginFsDeps = defaultFs,
zone: string = DEFAULT_ORIGIN_ZONE,
): void {
const origin = subdomainOrigin(subdomain, domain)
const origin = subdomainOrigin(subdomain, domain, zone)
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
const updated = upsertOriginLine(existing, origin)
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)

View File

@@ -1,9 +1,27 @@
/**
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
*
* PLAN_NATIVE_TUNNEL S2: the unit can now 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.
*/
import type { ServiceEnv } from './launchd.js'
const UNIT_NAME = 'web-terminal-agent.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 systemdUnitName(): string {
return UNIT_NAME
}
@@ -12,8 +30,53 @@ export function systemdUnitPath(homedir: string): string {
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
}
/** 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 single `Environment=` 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
}
/** Quote a systemd `Environment=` value: reject control chars, then double-quote escaping `\` and `"`. */
function quoteEnvAssignment(key: string, value: string): string {
if (hasControlChar(value)) {
throw new Error(
`refusing to write systemd Environment for '${key}': value contains a control character ` +
'(newline/CR/etc.) that could corrupt the [Service] section',
)
}
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) 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 the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). When
* `options.env`/`options.envFile` are supplied, the per-host tunnel env is injected into the
* `[Service]` section. Pure/immutable — returns a fresh string.
*/
export function buildSystemdUnit(
binPath: string,
user: string,
options: SystemdUnitOptions = {},
): string {
return [
'[Unit]',
'Description=web-terminal host agent (rendezvous relay)',
@@ -25,6 +88,7 @@ export function buildSystemdUnit(binPath: string, user: string): string {
'Restart=on-failure',
'RestartSec=1',
`User=${user}`,
...environmentLines(options),
'',
'[Install]',
'WantedBy=default.target',

View File

@@ -49,6 +49,7 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
hostContentSecret: new Uint8Array([1]),
}),
runTunnel: async () => 0,
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }),
installService: vi.fn(async () => {}),
uninstallService: vi.fn(async () => {}),
print: (l) => out.push(l),
@@ -88,11 +89,22 @@ describe('runCli (T5)', () => {
expect(out.join('\n')).not.toContain('PEM')
})
it('pair --install installs the service', async () => {
it('pair --install installs the service with the resolved options', async () => {
const options = { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } }
const install = vi.fn(async () => {})
const { d } = deps({ installService: install })
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(install).toHaveBeenCalledOnce()
expect(install).toHaveBeenCalledWith(CFG, options)
})
it('install threads the resolved InstallOptions into installService (S2 env injection)', async () => {
const options = { env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'terminal' }
const install = vi.fn(async () => {})
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
const code = await runCli(parseArgs(['install']), d)
expect(code).toBe(0)
expect(install).toHaveBeenCalledWith(CFG, options)
})
it('run before pairing fails fast', async () => {

View File

@@ -1,12 +1,15 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { AgentConfig } from '../src/config/agentConfig.js'
import {
RootRefusedError,
buildInstallOptions,
detectPlatform,
installService,
uninstallService,
type InstallDeps,
} from '../src/service/install.js'
import { buildLaunchdPlist } from '../src/service/launchd.js'
import { buildSystemdUnit } from '../src/service/systemd.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
@@ -74,3 +77,193 @@ describe('installService (T17)', () => {
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
})
})
const TUNNEL_ENV = {
BIND_HOST: '127.0.0.1',
ALLOWED_ORIGINS: 'https://t1.terminal.yaojia.wang',
PORT: '3000',
} as const
describe('env injection into the writers (PLAN_NATIVE_TUNNEL S2)', () => {
it('launchd: default (no options) omits the EnvironmentVariables block', async () => {
const d = deps()
await installService(CFG, 'launchd', d)
const [, plist] = d.writes[0]!
expect(plist).not.toContain('EnvironmentVariables')
})
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { env: TUNNEL_ENV })
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist).toContain('<key>BIND_HOST</key>')
expect(plist).toContain('<string>127.0.0.1</string>')
// keys are sorted (ALLOWED_ORIGINS before BIND_HOST before PORT)
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
})
it('launchd: escapes XML-significant characters in env values', () => {
const plist = buildLaunchdPlist('/bin/agent', { X: `a&b<c>d"e'f` })
expect(plist).toContain('<string>a&amp;b&lt;c&gt;d&quot;e&apos;f</string>')
expect(plist).not.toContain('a&b<c>d')
})
it('systemd: default (no options) omits Environment lines', async () => {
const d = deps()
await installService(CFG, 'systemd', d)
const [, unit] = d.writes[0]!
expect(unit).not.toContain('Environment')
})
it('systemd: emits sorted, quoted Environment= lines from the env map', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV })
const [, unit] = d.writes[0]!
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(unit).toContain('Environment="PORT=3000"')
expect(unit.indexOf('ALLOWED_ORIGINS')).toBeLessThan(unit.indexOf('BIND_HOST'))
})
it('systemd: emits EnvironmentFile= (before inline Environment) when a path is given', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV, envFile: '/etc/web-terminal.env' })
const [, unit] = d.writes[0]!
expect(unit).toContain('EnvironmentFile=/etc/web-terminal.env')
expect(unit.indexOf('EnvironmentFile=')).toBeLessThan(unit.indexOf('Environment='))
})
it('systemd: escapes backslash and double-quote in Environment values', () => {
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
})
})
describe('tunnel-origin derivation (PLAN_NATIVE_TUNNEL S2)', () => {
it('merges https://<subdomain>.<zone>.<domain> into ALLOWED_ORIGINS when domain is given', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>ALLOWED_ORIGINS</key>')
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
})
it('defaults to the `term` zone when only a domain is supplied', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang' })
const [, plist] = d.writes[0]!
expect(plist).toContain('<string>https://host-42.term.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 [, unit] = d.writes[0]!
expect(unit).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' })
const [, plist] = d.writes[0]!
expect(plist).not.toContain('ALLOWED_ORIGINS')
})
})
describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/S2)', () => {
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 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',
})
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',
})
})
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('install CLI seam end-to-end — resolved env reaches the units (PLAN_NATIVE_TUNNEL S2)', () => {
// Env the operator would export before `web-terminal-agent install` on a tunnel host.
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
it('launchd: the plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist).toContain('<key>BIND_HOST</key>')
expect(plist).toContain('<string>127.0.0.1</string>')
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
expect(plist).toContain('<key>PORT</key>')
expect(plist).not.toContain('0.0.0.0')
})
it('systemd: the unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
const [, unit] = d.writes[0]!
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(unit).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
expect(unit).toContain('Environment="PORT=3000"')
expect(unit).not.toContain('0.0.0.0')
})
})
describe('systemd env value hardening (LOW: control-char injection)', () => {
it('rejects a newline in an env value so it cannot inject a [Service] directive', () => {
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\nExecStartPre=/x' } })).toThrow(
/control character/,
)
})
it('rejects a carriage return in an env value', () => {
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\rb' } })).toThrow(/control character/)
})
it('still accepts ordinary values with quotes and backslashes', () => {
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
})
})

View File

@@ -1,5 +1,11 @@
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 } {
const store = { content: initial }
@@ -41,3 +47,38 @@ describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
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',
)
})
})

233
deploy/README.md Normal file
View File

@@ -0,0 +1,233 @@
# Native mTLS Reverse-Tunnel — Deployment Guide
Master runbook for exposing native iOS / Android / desktop clients to a **local** web-terminal host
through the user's VPS (`8.138.1.192`), gated **only** by device-certificate mTLS.
- **Design / rationale:** [`docs/PLAN_NATIVE_TUNNEL.md`](../docs/PLAN_NATIVE_TUNNEL.md) (the *why*, risk register, milestones).
- **Per-host onboarding (repeatable):** [`frp/README.md`](./frp/README.md) (V7 checklist).
- **This file:** what shipped, and the **one-time VPS deploy** end-to-end.
> **Trust model A — single-owner fleet.** Every device cert and host belongs to one operator. The
> shared device-CA means "the closed set of *my* devices"; there is **no cross-tenant isolation**.
> Do **not** enroll distrusting third parties on this CA — that is Model B, out of scope for v1.
---
## 1. Architecture
Everything multiplexes onto the single open port `:443`, SNI-routed by the existing nginx `stream`
(`ssl_preread`, TLS **passthrough**), coexisting untouched with the browser relay and xray-Reality.
```
Public internet ── :443 (only open port) ──► nginx stream ssl_preread (no TLS term)
map $ssl_preread_server_name:
frp.terminal.yaojia.wang → 127.0.0.1:7000 frps control (mTLS + token)
*.terminal.yaojia.wang → 127.0.0.1:8470 nginx TLS-term + device-CA mTLS + CRL ─► :7080 frps vhost
*.term.yaojia.wang → 127.0.0.1:8443 E2E browser relay (EXISTING, preserved)
default → 127.0.0.1:10443 xray Reality (EXISTING, preserved)
frps vhost :7080 ──(frp tunnel; frpc dials OUT :443)──► LOCAL host: frpc → base app 127.0.0.1:3000
BIND_HOST=127.0.0.1 (loopback ONLY)
native client (iOS/Android/desktop) presents a DEVICE CERT → https://<name>.terminal.yaojia.wang
```
**Why mTLS is non-negotiable:** the base app has no login of its own. The device client-cert is the
one and only gate. Two invariants make it airtight: `ssl_verify_client on` (never `optional`) at
`:8470`, and `BIND_HOST=127.0.0.1` on every host (default `0.0.0.0` would serve an unauth'd shell on
the LAN, bypassing mTLS entirely).
---
## 2. What shipped — 2026-07-07 (branch `feat/relay-phase1`)
Four file-disjoint tracks implemented via multi-agent build→review, then two HIGH review findings
closed. **Zero `src/` (base-app core) changes** — a working tunnel needs none. All self-verified green.
| Track | Commit | What | Verified |
|---|---|---|---|
| **VPS scripts** (V2/V7) | `5337281` | device CA + issue + revoke scripts, frps/frpc templates, nginx `:8470` mTLS conf, SNI-merge doc | `gen→issue→openssl verify→revoke→CRL` green in tmp dir (OpenSSL 3.0.18) |
| **iOS client mTLS** (C-iOS) | `e38e6d1` | `ClientTLS` SPM package (identity / PKCS12 import / keychain / challenge-responder), both transports take a lazy identity provider (no relaunch after install), `设备证书` install screen | ClientTLS 14/14 · SessionCore 93/93 · app `BUILD SUCCEEDED` |
| **Desktop** (D-1/D-2) | `bb09495` | remote-host mode (origin-locked nav) + `select-client-certificate` from OS keychain | `npm run typecheck` clean |
| **Host packaging** (S2) | `d0c249c` | launchd/systemd env injection wired through the `install` CLI; `BIND_HOST` defaults `127.0.0.1`; systemd control-char rejection | agent build clean · vitest 166/166 |
Adversarial review: Desktop + VPS scripts passed clean; the iOS "install screen unreachable" and
"S2 env-injection not wired to the CLI" HIGH findings were both closed and re-verified.
**Not yet done:** the **live VPS deploy** below (touches production nginx/xray/relay — done serially by
an operator, not by an agent). The clients cannot reach a tunneled host until §3 is complete.
---
## 3. Deploy — one-time VPS setup (V0 → V5)
**Prerequisites**
- DNS: `*.terminal.yaojia.wang` and `frp.terminal.yaojia.wang``8.138.1.192` (confirmed resolving).
- Aliyun DNS API creds for Let's Encrypt DNS-01: `Ali_Key` / `Ali_Secret`.
- `frps` binary ≥ v0.52 (needs `disableCustomTLSFirstByte`, ≥ v0.50). Mainland fetch may be blocked →
`scp` from a laptop.
- `acme.sh` installed on the VPS.
- **Coexistence rule (every step):** never retype the existing `map` lines; merge additively; deploy
only via `nginx -t && systemctl reload nginx`; re-run the SNI oracle (§5) after each nginx change.
> Repo scripts referenced below are run **from the repo root**. Push the repo to the VPS first
> (`git push root@8.138.1.192:/root/web-terminal.git feat/relay-phase1`) or `scp` the `deploy/` tree.
### V0 — snapshot (reversible safety net)
```bash
cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)}
nginx -T > /root/nginx-dump.pre.txt
ss -ltnp | sort > /root/ports.pre.txt
nginx -v # expect 1.24 → do NOT add `http2 off;` (invalid on 1.24)
dig +short foo.terminal.yaojia.wang frp.terminal.yaojia.wang # both → 8.138.1.192
```
### V1 — frps: control `:7000` (mTLS) + vhost `:7080`
```bash
# dedicated unprivileged user (loopback ports need no root)
useradd -r -s /usr/sbin/nologin frp 2>/dev/null || true
install -d -o frp -g frp -m 750 /etc/relay/frp /var/log/frp
# config from the template — set the token, keep everything else
install -m 600 -o frp -g frp deploy/frp/frps.toml.example /etc/relay/frp/frps.toml
openssl rand -hex 32 >> /etc/relay/secrets.env # FRP_TOKEN — paste into frps.toml auth.token
# frps.toml already sets: bindAddr 127.0.0.1 / bindPort 7000 / vhostHTTPPort 7080 /
# subDomainHost terminal.yaojia.wang / transport.tls.force = true (control-channel mTLS)
# systemd unit: NoNewPrivileges / ProtectSystem=strict / ReadWritePaths=/var/log/frp, User=frp
systemctl enable --now frps
journalctl -u frps -n30 --no-pager # clean start
curl -s -H 'Host: probe.terminal.yaojia.wang' http://127.0.0.1:7080/ # → frp 404 "no proxy" = vhost up
```
### V1b + V2 — the two CAs (control-channel + device data-path)
Separate trust roots from Let's Encrypt and from the agent-enrollment CA.
```bash
bash deploy/scripts/gen-device-ca.sh --kind frp-client # → /etc/relay/frp-client-ca (frpc control mTLS)
bash deploy/scripts/gen-device-ca.sh # → /etc/relay/device-ca (KIND=device) + EMPTY crl.pem
```
- `frps.toml transport.tls.trustedCaFile``/etc/relay/frp-client-ca/frp-client-ca.cert.pem`.
- Issue the frps control **server** cert + each host's frp-client cert (V1b) from the frp-client CA:
```bash
CA_DIR=/etc/relay/frp-client-ca bash deploy/scripts/issue-device-cert.sh <hostname> ./out-<hostname>
# frpc uses out-<hostname>/<hostname>.{cert,key}.pem — ignore the .p12 for control mTLS
```
- **Effect:** a leaked `auth.token` alone can no longer register a subdomain (needs a valid frp-client
cert too) — closes the subdomain-takeover / MITM hole (R3).
### V3 — Let's Encrypt wildcard server cert (hard iOS prerequisite — self-signed fails iOS ATS)
```bash
export Ali_Key=... Ali_Secret=...
acme.sh --issue --dns dns_ali -d '*.terminal.yaojia.wang' -d terminal.yaojia.wang
install -d -m 700 /etc/relay/frp-tls
acme.sh --install-cert -d '*.terminal.yaojia.wang' \
--fullchain-file /etc/relay/frp-tls/fullchain.pem \
--key-file /etc/relay/frp-tls/privkey.pem \
--reloadcmd 'nginx -s reload'
openssl x509 -in /etc/relay/frp-tls/fullchain.pem -noout -text | grep -A1 'Subject Alternative Name'
# → *.terminal.yaojia.wang (separate cert from *.term — different parent label; don't try to cover both)
```
### V4 — nginx `:8470` = TLS-term + device-CA mTLS + CRL + WS proxy
```bash
cp deploy/nginx/frp-mtls.conf /etc/nginx/conf.d/frp-mtls.conf
nginx -t
# frp-mtls.conf enforces: ssl_verify_client ON (never optional), ssl_client_certificate = device-CA,
# ssl_crl = device-ca/crl.pem (revocation day one), WS upgrade, proxy_read/send_timeout 3600s
# (idle-WS kill fix), proxy_pass 127.0.0.1:7080. NO `http2 off;`.
```
**Loopback verify before touching the stream** (issue a throwaway device cert with V2's issue script):
```bash
curl -sI --resolve x.terminal.yaojia.wang:8470:127.0.0.1 https://x.terminal.yaojia.wang:8470/ # no cert → TLS handshake FAILURE (must fail)
curl -sI --cert dev.cert.pem --key dev.key.pem ... # → frp 404 (reaches frps) = gate works
```
### V5 — stream SNI additions (the only edit to the existing shared conf)
Edit `/etc/nginx/stream-relay.conf`, **merge** the two lines from
[`nginx/stream-sni-additions.md`](./nginx/stream-sni-additions.md) into the existing `map` body — do
**not** retype the existing `*.term`→8443 / `default`→10443 lines:
```nginx
frp.terminal.yaojia.wang 127.0.0.1:7000; # exact wins over wildcard
*.terminal.yaojia.wang 127.0.0.1:8470;
```
```bash
nginx -t && systemctl reload nginx
```
---
## 4. Deploy — hosts and clients
- **Per local host (S0/S1):** follow [`frp/README.md`](./frp/README.md) — base-app env
(`BIND_HOST=127.0.0.1`, `ALLOWED_ORIGINS=https://<name>.terminal.yaojia.wang`, `PORT=3000`), `frpc.toml`
from `frp/frpc.example.toml`, and a per-host frp-client cert. Durable service packaging (S2) via the
agent `install` CLI now injects that env into launchd/systemd units.
- **iOS:** deliver `<device>.p12` (AirDrop/Files) → app → Settings → **设备证书** → Import + passphrase →
pair `https://<name>.terminal.yaojia.wang`. (Cert takes effect on the next connect, no relaunch.)
- **Desktop:** double-click `<device>.p12` into the login keychain (macOS) / Personal store (Windows);
the app auto-selects it by issuer CN. Switch to the remote host in the tray picker.
- **Android:** WebView shell — not yet built (Track C-Android, deferred).
Issue a device cert (data-path mTLS) and deliver it **securely** (never email):
```bash
P12_PASSWORD='...' bash deploy/scripts/issue-device-cert.sh <person-or-device> ./out-dev
# → out-dev/<...>.p12 (iOS/Android) + <...>.cert.pem/.key.pem/.fullchain.pem (desktop/curl)
```
---
## 5. Verify — S-GATE + M1 acceptance
**S-GATE (BLOCKING — before any `frpc` starts on any host):** with **no** client cert,
`curl -sI https://<name>.terminal.yaojia.wang/live-sessions` must be a **TLS failure**. If it returns
`200`, **STOP — the shell is world-open.**
**M1 acceptance matrix** (`t1.terminal.yaojia.wang`, base app `BIND_HOST=127.0.0.1`, throwaway frpc):
1. no device cert → TLS handshake failure.
2. `curl --cert dev.cert.pem --key dev.key.pem -sI …/live-sessions` → `200` JSON.
3. WS `attach`→`attached` on `wss://t1.terminal.yaojia.wang/term`.
4. 2nd frpc with the token but no/other frp-client cert claiming `t1` → refused at control handshake.
5. revoked device cert (added to CRL) → rejected.
6. first byte to `:443` is `0x16` (`tcpdump` — proves `disableCustomTLSFirstByte`).
7. 4-zone SNI oracle green + V0 coexistence preserved; `journalctl -u xray -u nginx` clean.
**SNI oracle** (run after every nginx change — proves coexistence):
```bash
for sni in frp.terminal.yaojia.wang t1.terminal.yaojia.wang x.term.yaojia.wang www.microsoft.com; do
echo "== $sni =="; openssl s_client -connect 8.138.1.192:443 -servername "$sni" </dev/null 2>/dev/null \
| openssl x509 -noout -subject 2>/dev/null || echo "(passthrough / no cert at :443)"
done
```
---
## 6. Operational notes
- **Revoke a device:**
```bash
CA_DIR=/etc/relay/device-ca bash deploy/scripts/revoke-device.sh /path/<dev>.cert.pem
# copy the regenerated /etc/relay/device-ca/crl.pem to the VPS, then ON THE VPS: nginx -t && nginx -s reload
```
- **Never** port-forward this to the public internet beyond `:443` mTLS; frps `:7000`/`:7080` and
nginx `:8470` are **loopback-only** and must stay absent from the Aliyun security group (`nmap` them
closed).
- **`/live-sessions/:id/preview` leaks scrollback** to any valid cert-holder (no Origin guard). Accepted
under Model A (all certs are yours); it sets Model B's blast radius.
- **SSH to the mainland VPS is intermittently DPI-throttled** (banner-exchange timeout while bare TCP
`:22` still connects — the box and key are fine, the SSH *protocol path* is throttled). Workarounds:
retry later; route SSH through an overseas proxy node (e.g. flip ClashX to **Global** mode so the CN
IP isn't matched by a `GEOIP,CN → DIRECT` rule, then `ssh -o ProxyCommand="nc -X 5 -x 127.0.0.1:7890 %h %p"`);
or use the Aliyun web console. The `git push` remote (`root@8.138.1.192:/root/web-terminal.git`) uses
the same `:22` path and is affected identically.
## 7. File map (this directory)
| Path | Role |
|---|---|
| `scripts/gen-device-ca.sh` | EC P-256 CA (`--kind device` \| `frp-client`), openssl `ca` db, empty CRL |
| `scripts/issue-device-cert.sh` | P-256 clientAuth leaf → `.p12` (`-legacy`) + `.pem/.key/.fullchain` |
| `scripts/revoke-device.sh` | revoke by cert or `--serial` → regenerate `crl.pem` |
| `frp/frps.toml.example` | frps control `:7000` (mTLS+token) + vhost `:7080` |
| `frp/frpc.example.toml` | per-host: dials `:443`, `disableCustomTLSFirstByte=true`, exposes `127.0.0.1:3000` |
| `frp/README.md` | per-host onboarding checklist (V7) |
| `nginx/frp-mtls.conf` | `:8470` TLS-term + `ssl_verify_client on` + `ssl_crl` + WS proxy → `:7080` |
| `nginx/stream-sni-additions.md` | the two additive stream SNI `map` lines (merge-only) |

564
docs/ANDROID_CLIENT_PLAN.md Normal file
View File

@@ -0,0 +1,564 @@
# Android Client Implementation Plan
> Version: v1.0 (final) · Target: functional parity with the WebTerm **iOS** client
> Lives under a new `android/` directory at the repo root. The app does not exist yet.
> Companion to [`ios/README.md`](../ios/README.md), [`TECH_DOC.md`](./TECH_DOC.md) (§4 protocol, §5.2 session model, §7 security), [`ARCHITECTURE.md`](./ARCHITECTURE.md) (invariants §8), and the multi-agent [`PLAN.md`](./PLAN.md).
> Same working discipline as the rest of the repo: phased waves, file-disjoint tasks with stable IDs and `Owns:` lists for multi-agent parallelism, TDD, progress tracked in `PROGRESS_LOG.md`.
>
> **Framing note (per architecture review):** this plan mirrors the **iOS package *set*** and its "dependencies flow down" rule. It is **not** a file-for-file port. Two deliberate restructurings: (1) both transports (iOS keeps `URLSessionTermTransport` inside `SessionCore` and `URLSessionHTTPTransport` in `App/Wiring`) are **consolidated into `:transport-okhttp`** so `:session-core` becomes genuinely pure and runs under `runTest` virtual time; (2) a `:terminal-view` module is added for the Termux wrap. These are improvements, called out where they relocate iOS logic.
---
## 0. First principles (inherit verbatim from the server contract)
Four invariants govern every decision below. The first three are copied from the server contract, not reinterpreted; the fourth is the Android concurrency contract that replaces the Swift actor model.
1. **The client is a byte-shuttle half, not a terminal.** The app never parses ANSI/terminal semantics — the emulator library does. Output bytes are an opaque blob fed verbatim to the emulator; `input.data` is raw keyboard bytes passed through unfiltered (ARCHITECTURE invariant #1, #9).
2. **PTY lifecycle ≠ WebSocket lifecycle.** A WS disconnect must NEVER kill the server PTY; `close()` == detach. Sessions are keyed by `sessionId`; reconnect carries it so the server replays the same ring buffer (TECH_DOC §5.2). This is what makes the Android background story (§ push) work: the phone reconnects and replays on resume; push is the wake channel while away.
3. **Origin-header validation on the WS handshake is the one defense that cannot be skipped** (CSWSH, TECH_DOC §7). The `Origin` string must byte-match the browser's `new URL()` serialization or the server 401s the upgrade. There is exactly ONE place that derives it (`HostEndpoint`, frozen in `:wire-protocol`), stamped on the WS handshake and the guarded HTTP routes only.
4. **Confinement contract (the actor analogue).** Engine state lives only on the engine's `limitedParallelism(1)` dispatcher; emulator/scrollback state lives only on `Dispatchers.Main.immediate`. The **only** cross-boundary calls are `engine.send()` (UI→engine) and the event fan-out (engine→UI, per-consumer `Channel`s). This is written down so a future edit cannot touch emulator state off-Main or engine state off-confinement and reintroduce the data races the actor model prevented.
---
## 1. Goal & parity scope
Reach functional parity with the iOS client's shipped feature set (P0 + P1). Every iOS feature is enumerated below and marked **IN** (build now), **IN·adapted** (build, but the platform forces a different mechanism), or **DEFERRED** (post-parity), with rationale. Feature-parity review surfaced five user-facing surfaces missing from the draft; all are now **IN** with owning tasks (§5): the **reconnect/EXIT banner**, the **activity-timeline sheet**, the **device-certificate management screen**, **new-session-in-cwd** (phone toolbar + exit-banner action), and the **continue-last-session cold-start UX**.
### P0 — daily-usable pocket cockpit
| iOS feature | Android status | Rationale / adaptation |
|---|---|---|
| Native terminal (SwiftTerm), full ANSI/true-color, scrollback replay on attach | **IN·adapted** | No SwiftTerm on JVM. Use **Termux `terminal-emulator` + `terminal-view`** (the battle-tested analogue). The single hardest port — see §6. Approach validated by throwaway spike **S1** before AW3 commits; license gated by **R2**. |
| Pairing: QR scan / manual URL, confirm-before-network, two-step probe, secure save, §5.4 warning tiers | **IN·adapted** | CameraX + ML Kit for QR; Tink AEAD for storage. Confirm-gate + probe + tier logic port verbatim as pure Kotlin. **Cert import is NOT part of pairing** (matches iOS — pairing is URL/QR+probe only); it lives in the separate device-cert screen (A27). |
| Session list (chooser + dashboard): status badge, telemetry chips, preview thumbnail, cols×rows, swipe-to-kill, pull-to-refresh, multi-host + host menu | **IN** | MVVM/HTTP port; thumbnails are the hard sub-piece (§6.7). Host menu (the app's only settings surface) explicitly enumerated in A20. `lastOutputAt` is already serialized server-side (`src/session/manager.ts`), so unread dots are unblocked. |
| Reconnect / connection-status / EXIT banner | **IN** | ios `ReconnectBanner`: `connecting` / `reconnecting(attempt, countdown)` / `failed(replayTooLarge, actionable copy, no spinner)` / `exited(code, reason, spawn-failure 1)` + on-exit "new session" action. Terminal phases (failed/exited) outrank transient phases (connecting/reconnecting), mirroring `TerminalViewModel.bannerModel`. Owner: A21. |
| Remote approve/reject: tool 2-button card + plan 3-way sheet, haptic on arrival | **IN** | Compose `Card` + `ModalBottomSheet`; the two-line epoch stale-guard is security-load-bearing and ports 1:1. |
| Away digest on reattach | **IN** | Pure `AwayDigest` reducer ports directly. Its "expand" affordance opens the activity-timeline sheet (A28). |
| Sessions survive background/kill/network loss; 1 live WS + HTTP poll; 1s→30s backoff; 25s ping | **IN·adapted** | Foreground-only WS (STARTED-scoped); background wake via FCM. **No background foreground-service** (see §9 R9 decision). |
| Mobile key-bar (Esc/Esc²/⇧Tab/arrows/Enter=`\r`/^C^R^O^L^T^B^D/Tab/`/`), raw send, hardware chords | **IN** | `KeyByteMap` ports byte-for-byte; key-bar is a Compose overlay pinned above IME (`WindowInsets.ime`). |
| New session in current cwd (`attach(null, cwd)`) | **IN** | ios TerminalScreen toolbar "在当前目录开新会话" + the exit-banner "开新会话" action, both routing to `attach(null, cwd)`. Both the phone toolbar entry and the exit-banner action are owned by A21 (the iPad pointer-menu variant is A26). |
| Privacy shade when app inactive | **IN·adapted** | `FLAG_SECURE` + a cover Composable on `ON_STOP`; blanks the recents thumbnail. |
| Notifications P0 (existing ntfy bridge, zero new code, default-off) | **IN (free)** | ntfy is server-side + the ntfy Android app; nothing to build. The pre-FCM stopgap. |
### P1 — walk-away complete
| iOS feature | Android status | Rationale / adaptation |
|---|---|---|
| Push + lock-screen Allow/Deny (single-use token, no full app open on Deny) | **IN·adapted (FCM)** | FCM has no server-defined action buttons → **data-only high-priority** messages; the client builds the notification + buttons locally. **Deny → `BroadcastReceiver`** (`goAsync()` + expedited POST). **Allow → a translucent, `excludeFromRecents` trampoline `Activity`** that hosts `BiometricPrompt`, then POSTs — a `BroadcastReceiver`/`Service` **cannot** present `BiometricPrompt` (see R1 mustFix). Requires a new server sender (§4.5). |
| Deep links `webterminal://open?host=&join=`, UUID-whitelist, cold/warm, push taps share the router | **IN** | Custom scheme intent-filter + verified App Links; one ported `DeepLinkRouter`. |
| Device-certificate management screen (import / rotate / remove + issuer-CN/expiry/expired-warning summary) | **IN** | ios `ClientCertScreen` + `ClientCertViewModel`, reached from the session-list host menu "设备证书". A **distinct screen**, not folded into pairing. Owner: A27. |
| Projects: namespaced repos, favourites/collapse synced via `/prefs` (unknown-key preserving), dirty badge, detail page, "open Claude here", iPad multi-column grid | **IN** | `ProjectGrouping` must produce **byte-identical group keys** to web/iOS (shared `/prefs`). Regular-width grid + adaptive sheet detents (ios `ProjectsLayout`) included in A23. |
| Multi-session switcher: unread dots (`lastOutputAt`), sanitized OSC titles | **IN** | `UnreadLedger` + `TitleSanitizer` port 1:1 (both pure reducers in `:session-core`). |
| Activity timeline (`/events` drill-down) | **IN** | ios `TimelineSheet` + `TimelineViewModel` over `GET /live-sessions/:id/events`, reachable from the away-digest expand affordance. Owner: A28. |
| Diff viewer (read-only, staged/unstaged, per-file hunks) | **IN** | staged flag sent as `'1'/'0'` (server matches `=== '1'`), like iOS — not `true/false`. |
| Quick-reply chips + editable palette (floats while a gate is held) | **IN** | `QuickReplyPalette` CRUD ports 1:1; DataStore replaces UserDefaults. |
| Continue-last-session cold-start | **IN** | `ColdStartPolicy` route (no host → pairing, else sessions) + "继续上次会话" re-entry banner (stack + sidebar) + `LastSessionStore` lifecycle (`SessionActivityBridge` sets on `.adopted`, clears on `.exited`). Owner: A29. |
| Session thumbnails: off-screen render, concurrency-capped, cached `(sessionId, lastOutputAt)` | **IN·adapted** | Off-screen `TerminalEmulator` buffer + manual `Canvas` cell painter (avoids iOS's off-screen-view snapshot / `CADisplayLink` leak). |
### iPad-equivalent (large screen / foldable)
| iOS feature | Android status | Rationale / adaptation |
|---|---|---|
| Adaptive split (regular = sidebar+detail; compact = stack), one `LayoutPolicy` | **IN·adapted** | Material 3 Adaptive: `NavigationSuiteScaffold` + `ListDetailPaneScaffold`, keyed on `currentWindowAdaptiveInfo().windowSizeClass`. |
| Hardware-keyboard auto-hide of key-bar | **IN·adapted** | `InputDevice`/`onKeyEvent` heuristic (no `GCKeyboard` equivalent — less reliable, accepted). |
| Pointer context menu (open-in-cwd / kill / copy) | **IN·adapted** | `Modifier.pointerInput` detecting `PointerButton.Secondary` → anchored `DropdownMenu` (Compose `ContextMenuArea` is a Compose-Desktop construct, not stable on Android — corrected per platform review). Gated on `Expanded` + `smallestScreenWidthDp >= 600`. |
### Design system
| iOS feature | Android status |
|---|---|
| Frozen design system: amber-gold accent `#E3A64A` dark / `#C9892F` light, semantic status colors, 8pt scale, tabular monospace numerals, reduce-motion-gated animation/haptics, dark-first | **IN** — Compose Material 3 theme, one token file, dark-appearance-first. Terminal canvas fixed `#100F0D`/`#ECE9E3` gold caret independent of app theme (reads identically to desktop). |
### Explicitly DEFERRED (post-parity, called out so scope is closed)
- **`DELETE /live-sessions` kill-all / a "manage" page** — **iOS does NOT consume this route**; its swipe-to-kill and pointer-menu kill both use per-session `DELETE /live-sessions/:id`. Out of parity; do not build a kill-all surface iOS lacks.
- **On-device WebView/xterm.js fidelity fallback** — only if Termux emulation fails QA (§6.8). Not built by default.
- **System-`KeyChain` MDM cert path** — documented fallback only; wrong trust model for a per-device pinned identity.
- **Self-signed-LAN `wss://` custom trust / pinning** — bare LAN uses `ws://` cleartext (allowlisted CIDRs); `wss` is only for the tunnel/Tailscale, which present real CA-signed certs. A `CertificatePinner`/custom `X509TrustManager` path is documented but not built (§6.9, §8).
- **Cross-device palette sync** — matches current design (iOS/web/Android stores are independent).
- **Voice commands / editor-open / worktree-create** — server has routes (`/open-in-editor`, `/projects/worktree`) the iOS client does not consume; out of parity scope.
---
## 2. Tech stack decision (locked)
| Concern | Choice | One-line rationale |
|---|---|---|
| Language | **Kotlin 2.x** (K2), JVM target 17 | Coroutines/Flow map cleanly onto Swift actors/AsyncStreams; pure modules stay JVM-testable. |
| Min / target SDK | **minSdk 29** (Android 10) / **targetSdk 35** (Android 15) | 29 gives modern TLS + scoped storage without legacy branches; 35 for App Links + FGS/notification rules. |
| UI toolkit | **Jetpack Compose** + **Material 3 Adaptive** (`androidx.compose.material3.adaptive`) | Direct analogue of SwiftUI + size-class adaptive split. |
| Terminal renderer | **Termux `terminal-emulator` + `terminal-view`** (primary; **Apache-2.0**, consumable via **JitPack** `com.termux:termux-app:terminal-view:0.118.0` — vendoring optional for version-pinning) | Only battle-tested JVM VT100/xterm emulator with 24-bit true-color; the direct SwiftTerm analogue. **These two libs are Apache-2.0** (only `termux-shared`/app module are GPLv3 — do NOT depend on those). Actively maintained (0.119.0-beta.3, 2025-06). License de-risked → §9 R2; seam proof → S1 spike. |
| WebSocket + HTTP | **OkHttp 4.x** (`WebSocket` + `WebSocketListener`, and REST) — one shared `OkHttpClient` | One client → mTLS `SSLSocketFactory` applies to both WS and REST uniformly; supports a custom `Origin` header on the WS handshake. |
| JSON | **kotlinx.serialization** (`Json { ignoreUnknownKeys = true; isLenient = true }`) for **decode**; **hand-rolled `StringBuilder` codec** for **encode** | Decode tolerant; encode must be byte-identical to `JSON.stringify` (explicit `sessionId:null`, top-level `approve.mode`, JS control-char escaping) — defaults won't guarantee that (R3). |
| Concurrency | **kotlinx-coroutines**`Dispatchers.Default.limitedParallelism(1)` for engine confinement; **per-consumer `Channel`s** for the event bus (not a single `SharedFlow` — see R10) | Structured concurrency replaces the Swift actor; single-thread confinement replaces actor isolation. |
| DI | **Hilt** (Dagger) | One shared `OkHttpClient` singleton, testable seams, standard Android DI. |
| Persistence (non-secret) | **Jetpack DataStore (Preferences + Proto)** | Host list, last-session id, unread watermarks, quick-reply palette, prefs blob. |
| Persistence (secret / device cert) | **Google Tink AEAD + AndroidKeystore master key**; **the private key is imported non-exportable into `AndroidKeyStore`** (the runtime home). Tink AEAD encrypts only the **cert chain + metadata** blob at rest. **No `.p12` blob or passphrase is persisted.** | Non-exportable, device-bound = the `…AfterFirstUnlockThisDeviceOnly` analogue. **Not `EncryptedFile`/`security-crypto`** (that Jetpack Security API is deprecated — draft inconsistency resolved). |
| Push | **Firebase Cloud Messaging (FCM) v1**, data-only high-priority | Only way to reproduce the lock-screen two-tap Allow/Deny loop (§4.5, §9 R1). |
| mTLS | **`AndroidKeyStore`-imported non-exportable key + a custom re-reading `X509KeyManager`** (returns the AndroidKeyStore `PrivateKey`/chain per handshake) → `SSLContext` → OkHttp `sslSocketFactory`. **`KeyStore("PKCS12")` is used transiently only to *parse the import file*, never as the runtime key home.** | App-private identity (NOT system `KeyChain`); re-reading KeyManager + `connectionPool.evictAll()` gives the iOS "no-relaunch cert rotation" behavior. One key home, no contradiction (R4/mustFix). |
| Server-cert trust | **Default system trust** (tunnel `*.terminal.yaojia.wang` + Tailscale MagicDNS present real LE certs). Bare LAN uses **`ws://` cleartext** via a `network_security_config` CIDR allowlist. | Closes the "how do we trust the server cert" gap without a custom trust manager for the common case. |
| QR scan | **CameraX + ML Kit Barcode** (`com.google.mlkit:barcode-scanning`) | On-device, one validator shared with manual entry. |
| Biometric | **`androidx.biometric:BiometricPrompt`** hosted in a `FragmentActivity` (the Allow trampoline) | Gates the Allow action. |
| Testing | **JUnit5 + kotlinx-coroutines-test (virtual time) + Turbine + MockK** (unit); **Espresso + Compose UI Test** (instrumented; Robolectric only where it faithfully emulates); **Kover** (coverage) | Mirrors iOS's injected-clock/fake-transport discipline. `AndroidKeyStore`/Tink tests are **instrumented on a real emulator/device** — Robolectric does not provide the AndroidKeyStore provider (platform review). |
---
## 3. Module / package architecture (mirrors the iOS package set)
Gradle multi-module. Mapping to the iOS packages, with the two additions noted in the framing block.
```
iOS SPM package Android Gradle module Kind
──────────────────────────────────────────────────────────────────────────────
WireProtocol → :wire-protocol pure Kotlin/JVM (testable)
(HostEndpoint here) — Origin derivation frozen in the contract
SessionCore (reducers) → :session-core pure Kotlin/JVM (testable)
— incl. TitleSanitizer (pure, security-relevant)
URLSessionTermTransport→ :transport-okhttp JVM (OkHttp) — impls interfaces
URLSessionHTTPTransport→ :transport-okhttp (relocated here from iOS App/Wiring)
APIClient → :api-client pure Kotlin/JVM (testable)
HostRegistry → :host-registry Kotlin + DataStore (storage behind iface)
ClientTLS → :client-tls SPLIT: pure half (JVM) + framework half
(SwiftTerm host view) → :terminal-view Android-framework-bound (Termux wrap)
TestSupport → :test-support pure Kotlin/JVM (fakes)
App/WebTerm → :app Android app (Compose, Hilt, FCM)
```
### Dependency graph (arrows = "depends on"; nothing points upward)
```
:app (Compose UI, ViewModels, DI, FCM, DeepLinkRouter, DesignSystem, EventBus)
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls (framework half)
│ │ │ │ │
│ │ │ │ ▼
│ │ │ │ :client-tls (pure half)
└───────┬───────┴────────┴──────┬───────┴───────────────┘
▼ ▼
:wire-protocol ◀────── :transport-okhttp
(models, HostEndpoint, (impls TermTransport + HttpTransport,
TermTransport/HttpTransport, consumes ClientIdentityProvider)
PingableTermTransport)
└──────── :test-support (FakeTransport / FakeHttpTransport / FakeTimeSource) → test source sets only
```
**Boundary note (`:terminal-view`):** `:terminal-view` depends **only on `:wire-protocol`**`RemoteTerminalSession` consumes `ClientMessage` and raw `ByteArray`. The `SessionEvent → ByteArray` decode happens in `:app`'s `TerminalSessionController` (A21), **not** in the terminal module. There is **no** `:terminal-view → :session-core` edge (draft ambiguity resolved per architecture review — A16 depends on the wiring freeze, not on the engine module).
Rules (mirroring ARCHITECTURE §1 "dependencies only flow down"):
- **`:wire-protocol` is the frozen shared contract** — the Android analogue of `src/types.ts` + WireProtocol. It owns `ClientMessage`/`ServerMessage` sealed interfaces, `MessageCodec`, `Validation`, `WireConstants`, **`HostEndpoint` (Origin/wsURL derivation — moved here from the draft's A4 so both transport and api-client can depend on it without a same-wave sibling dependency)**, and the boundary interfaces `TermTransport` / `HttpTransport` / **`PingableTermTransport`** (a sub-interface so the pure `PingScheduler` drives a ping only through transports that support it, matching iOS's `transport as? any PingableTermTransport`). No Android imports. **New wire types are added here only** (coordination point).
- **`:session-core` is pure** — `SessionEngine`, `ReconnectMachine`, `PingScheduler`, `GateTracker`/`GateState`, `AwayDigest`, `UnreadLedger`, `KeyByteMap`, **`TitleSanitizer`**, `SessionEvent`. Depends only on `:wire-protocol`; consumes `TermTransport` by interface — never touches OkHttp/Android; runs entirely under `runTest` virtual time. `TitleSanitizer` lives here (not behind `:terminal-view`) because it is a pure, security-relevant reducer consumed by both the terminal and the session-list/switcher UIs.
- **`:transport-okhttp`** implements `TermTransport`/`PingableTermTransport` (WS) and `HttpTransport` (REST). It takes an injected `ClientIdentityProvider` (from `:client-tls`) so mTLS is wired without `:session-core` knowing about it.
- **`:client-tls` is split like `:host-registry`** — a **pure Kotlin/JVM half** (PKCS#12 structural parse via `KeyStore("PKCS12")`, `X509KeyManager` alias/`getPrivateKey` selection logic, `CertificateSummary` parsing, `PairingError`/`HostClassifier` warning-tier mapping) tested at JVM speed and **in the 80% Kover gate**; and a **thin framework half** (AndroidKeyStore import for device binding + Tink AEAD storage + `connectionPool.evictAll()` on rotation) tested instrumented. This restores the iOS test posture (iOS unit-tests `PKCS12Importer`/`CertificateSummary`/`MutualTLSChallengeResponder`) and avoids leaning on Robolectric for AndroidKeyStore.
**Pure-Kotlin (JVM-unit-testable, the 80%-coverage targets):** `:wire-protocol`, `:session-core`, `:api-client`, the logic half of `:host-registry`, **and the pure half of `:client-tls`**.
**Android-framework-bound (instrumented + device QA):** `:terminal-view`, the framework half of `:client-tls`, the storage half of `:host-registry`, and `:app`.
### `:app` internal package layout (mirrors `App/WebTerm/`)
```
android/app/src/main/java/wang/yaojia/webterm/
├── di/ Hilt modules (OkHttp singleton, engine factory, stores, per-feature boundaries)
├── designsystem/ Theme.kt, Tokens.kt, StatusBadge, TelemetryChip, Card…
├── screens/ PairingScreen, SessionListScreen, TerminalScreen, ProjectsScreen,
│ ProjectDetailScreen, DiffScreen, ClientCertScreen, TimelineSheet
├── viewmodels/ SessionListViewModel, GateViewModel, ProjectsViewModel, ProjectDetailViewModel,
│ DiffViewModel, PairingViewModel, ClientCertViewModel, TimelineViewModel
├── wiring/ TerminalSessionController, EventBus (per-consumer channels), SessionActivityBridge,
│ ThumbnailPipeline, ColdStartPolicy, AppEnvironment (composition root),
│ RetainedSessionHolder (@ActivityRetainedScoped / nav-scoped)
├── components/ KeyBar, QuickReply, QuickReplyStore, GateBanner, PlanGateSheet, ReconnectBanner,
│ AwayDigestView, TelemetryChips, TerminalContextMenu, ContinueLastBanner
├── push/ FcmService, DenyBroadcastReceiver, AllowTrampolineActivity, PushRegistrar, NotificationBuilder
└── nav/ DeepLinkRouter, NavGraph
```
---
## 4. The server contract (single reference for the Android side)
The complete surface the client uses, confirmed against `src/server.ts`. **The server is unchanged for everything except one additive P1 touch-point (§4.5).**
### 4.1 WebSocket — `wss://<host>/term` (path from `WireConstants.wsPath`)
Single connection, JSON text frames. `attach` MUST be the first frame on every (re)connect. `Origin` header stamped from `HostEndpoint.originHeader`.
**Client → server**
| type | shape | notes |
|---|---|---|
| `attach` | `{ "type":"attach", "sessionId": <uuid-v4-lowercase> \| null, "cwd"?: "/abs/path" }` | `sessionId` key **always present** (JSON `null` for new session — server rejects a missing key). `cwd` appended only when `sessionId` is null. |
| `input` | `{ "type":"input", "data": <string> }` | raw keyboard bytes, verbatim, never filtered. |
| `resize` | `{ "type":"resize", "cols": <int 1..1000>, "rows": <int 1..1000> }` | own message → `ioctl(TIOCSWINSZ)`→SIGWINCH. Validate range before send (server silently drops out-of-range). |
| `approve` | `{ "type":"approve", "mode"?: "acceptEdits" \| "default" }` | `mode` is a **TOP-LEVEL** key. No key = plain approve. |
| `reject` | `{ "type":"reject" }` | |
**Server → client** (every frame untrusted; undecodable → drop + count, never crash)
| type | shape | notes |
|---|---|---|
| `attached` | `{ "type":"attached", "sessionId": <uuid> }` | **always adopt** the server-issued id. Must pass v4 regex AND `UUID` parse. |
| `output` | `{ "type":"output", "data": <string> }` | opaque ANSI/UTF-8 → emulator verbatim. Ring-buffer replay arrives as one large frame right after attach (prefixed by `ESC[0m` — do NOT strip). |
| `exit` | `{ "type":"exit", "code": <int>, "reason"?: <string> }` | terminal; `code == -1` = spawn-failure, non-retryable, `reason` required. |
| `status` | `{ "type":"status", "status": <ClaudeStatus>, "detail"?, "pending"?: bool, "gate"?: "plan"\|"tool" }` | cockpit side-channel. `pending` absent → false; unknown/absent `gate` while pending → treat as tool. |
| `telemetry` | `{ "type":"telemetry", "at": <ms>, ...optional metrics }` | `at` is the only required field (missing → drop whole frame). |
Client-side frame max message size raised to **16 MiB** (guard with a client-side cap) to receive ring-buffer replay; oversized → non-retryable `replayTooLarge` (§9 R6). The large replay append is **chunked and off-main** (§6.2).
### 4.2 Read-only HTTP (GET, **no** `Origin` header)
| Route | Returns |
|---|---|
| `GET /live-sessions` | `[LiveSessionInfo]``{ id, cols, rows, status, cwd?, title?, telemetry?, lastOutputAt? }` (list-lossy; unknown `status``.unknown`; `lastOutputAt` already serialized server-side → unread dots unblocked) |
| `GET /live-sessions/:id/preview` | `SessionPreview { id, cols, rows, data }``data` ~24 KiB opaque ANSI (≤256 KiB cap client-side) |
| `GET /live-sessions/:id/events` | `[TimelineEvent]` — away-digest + activity-timeline source (consumed by A28) |
| `GET /config/ui` | `UiConfig { allowAutoMode }` |
| `GET /projects` | `[ProjectInfo]` (list-lossy) |
| `GET /projects/detail?path=` | `ProjectDetail { sessions, worktrees, claudeMd, … }` |
| `GET /projects/diff?path=&staged=1\|0` | `DiffResult` (files→hunks→lines; **`staged` must be `'1'`/`'0'`**) |
| `GET /prefs` | `UiPrefs` (opaque JSON object — **preserve unknown top-level keys**) |
### 4.3 Guarded HTTP (state-changing — MUST send `Origin == HostEndpoint.originHeader` byte-equal; foreign/missing → 403)
| Route | Body | Returns |
|---|---|---|
| `DELETE /live-sessions/:id` | — | 204 (404 = already gone = success) |
| `POST /hook/decision` | `{ sessionId, decision: "allow"\|"deny", token }` | 204 (400 malformed, **403 stale/used/mismatched token**, 429 ≤10/min). Token single-use, from push payload only — never persist/log. |
| `PUT /prefs` | full blob ≤64 KB | 200 echo (replaces whole blob → rewrite the unknown-key-preserving object) |
> `DELETE /live-sessions` (kill-all) is **NOT consumed** (iOS lacks it). Do not build it.
### 4.4 Endpoints the iOS client does NOT use (match iOS — do not consume unless expanding scope)
`GET /sessions`, `POST /hook`, `POST /hook/permission`, `POST /hook/status`, `POST /open-in-editor`, `POST /projects/worktree`, `DELETE /live-sessions` (kill-all). Push web-only: `GET /push/vapid-key`, `POST/DELETE /push/subscribe` (Android uses FCM, not VAPID).
### 4.5 The ONE additive server change Android requires (P1 push)
APNs routes (`POST/DELETE /push/apns-token`) do not transfer to FCM. Add, mirroring `src/push/apns.ts` behind the existing `combineNotifyServices(...)` seam (zero new event paths — `notify(session, cls, token?)` already carries needs-input/done):
- **NEW `src/push/fcm.ts`** — a `NotifyService` impl: OAuth2 bearer via **`google-auth-library`** (`GoogleAuth`/`JWT` with the service-account key + scope `https://www.googleapis.com/auth/firebase.messaging`; the lib handles token mint + ~1h refresh) → `POST fcm.googleapis.com/v1/projects/{PID}/messages:send` with `{message:{token, data:{sessionId,cls,token?}, android:{priority:"high"|"normal", ttl}}}`. **Data-only** (no `notification` block). Prune on `UNREGISTERED`/`INVALID_ARGUMENT`; the lib auto-refreshes on 401. **DECIDED (R7): use `google-auth-library`** (add as a server dep) rather than hand-rolling RS256 — less crypto to own/test than `apns.ts`'s hand-rolled JWT.
- **NEW route `POST/DELETE /push/fcm-token`** — mirrors the apns-token route but with an **FCM-token validator that is intentionally loose** (non-empty, bounded max length, `base64url` charset plus `:` — FCM token format/length is undocumented and changes; a strict `{140-170}` regex risks rejecting valid tokens). Same `Origin`-guard + rate limit.
- **NEW env group `FCM_*`** (project id + service-account key path), all-or-disabled like `loadApnsConfig`.
Payload minimization carried over verbatim: `data` holds only `sessionId`, `cls`, and (gate only) `token` — never cwd/command/terminal bytes.
---
## 5. Phased task plan (waves for multi-agent parallelism)
Same rules as `PLAN.md` §0: each task has a stable ID, an exclusive `Owns:` list, `Depends:` (interface, not implementation), and a `Verify:` step. `:wire-protocol` types **including `HostEndpoint`** are frozen by **A2** — other tasks only read them. **A15 freezes the `:app` wiring + DI contracts** before AW4 fans out. `PROGRESS_LOG.md` is orchestrator-only. Dispatch ~35 file-disjoint same-wave tasks concurrently; use `isolation: worktree` for concurrent Gradle/test runs.
Ordering follows the mandate: **protocol+transport → session core → terminal render → UI screens → cockpit/push/mTLS** — with two throwaway spikes (**S1** renderer, **S2** FCM) run in AW1 so both Critical risks are measured with schedule buffer remaining.
```
AW0 foundations (serial, blocks all)
A1 gradle scaffold → A2 :wire-protocol (FROZEN contract incl. HostEndpoint + PingableTermTransport) → A3 :test-support fakes
AW1 leaf modules + de-risking spikes (parallel — highest fan-out)
A4 MessageCodec + Validation (:wire-protocol, TDD golden vectors)
A5 :session-core timing/lifecycle reducers (ReconnectMachine, PingScheduler, SessionEvent)
A6 :session-core cockpit reducers (GateTracker/GateState, AwayDigest, UnreadLedger, KeyByteMap, TitleSanitizer)
A7 :transport-okhttp (TermTransport/PingableTermTransport WS + HttpTransport, server-cert trust config)
A8 :api-client routes (12 REST, tolerant decode, Origin-iff-guarded, strict query, prefs unknown-key)
A9 :api-client pairing (probe/PairingError/HostClassifier tiers)
A10 :client-tls pure half (PKCS12 parse, X509KeyManager alias logic, CertificateSummary, warning tiers)
A11 :client-tls framework half (AndroidKeyStore import, Tink AEAD, connectionPool.evictAll on rotation)
A12 :host-registry (Host, HostStore iface + DataStore, LastSessionStore)
A13 :app designsystem (theme tokens, primitives) + Hilt skeleton
S1 RENDERER SPIKE (throwaway): Termux emulator, no local process, canned WS bytes → one rendered screen
S2 FCM DELIVERY SPIKE (real device): data-only high-priority delivery under Doze / OEM battery managers
AW2 session engine + wiring freeze (convergence #1)
A14 SessionEngine lifecycle (generation-safe cancel, attach-first, backoff, ping, gate reduce, digest) [Dep A4,A5,A6,A7]
A15 FREEZE :app wiring + DI contracts (EventBus per-consumer channels, TerminalSessionController surface,
AppEnvironment, RetainedSessionHolder scope, ColdStartPolicy seam, per-feature Hilt boundaries, confinement invariant) [Dep A14]
AW3 terminal render (the XL piece — S1 must be green; R2 already resolved: Apache-2.0 via JitPack)
A16 :terminal-view — RemoteTerminalSession (Termux terminal-view via JitPack 0.118.0 — or vendor for pin; NO local process; off-main append, resize math) [Dep A15; consumes :wire-protocol only]
A17 KeyBar overlay (IME-bypass) + hardware chords + DECCKM split [Dep A16, A6 KeyByteMap]
A18 ThumbnailPipeline (off-screen emulator → Canvas cell painter, LRU, Semaphore(2), mTLS fetch) [Dep A16, A8, A11] (ML)
AW4 UI screens (parallel — file-disjoint by screen; A15 wiring frozen)
A19 Pairing flow (QR CameraX/MLKit + confirm-gate + warning tiers; NO cert import) [Dep A9, A12]
A20 Session list + dashboard + host menu (poll STARTED-scoped, rows, swipe-kill per-session, thumbnails,
multi-host switch + checkmark, host-menu: 配对新主机 / 设备证书) [Dep A8, A18]
A21 Terminal screen wiring + reconnect/EXIT banner + new-session-in-cwd toolbar
(TerminalSessionController, EventBus fan-out, privacy shade, RetainedSessionHolder, lifecycle rebuild) [Dep A14, A15, A16, A17]
A22 Gate/cockpit surfaces (GateViewModel, banner + plan sheet + telemetry chips + away digest, two-line stale-guard, haptics) [Dep A14]
A23 Projects + ProjectGrouping + detail + "open Claude here" + iPad projects grid/sheet detents [Dep A8]
A24 Diff viewer (flattened lazy list, staged '1'/'0', lossy decode) [Dep A8]
A25 Quick-reply chips + editable palette (DataStore) [Dep A6 KeyByteMap]
A26 Adaptive large-screen layout (ListDetail/NavigationSuite, pointer context menu via pointerInput) [Dep A20, A21]
A27 ClientCertScreen (device cert import/rotate/remove + summary; host-menu reachable) [Dep A9, A10, A11, A12]
A28 Activity-timeline sheet (TimelineSheet + TimelineViewModel over /events, wired to away-digest expand) [Dep A8, A22]
A29 Cold-start UX (ColdStartPolicy route + continue-last-session banner stack+sidebar + LastSessionStore lifecycle via SessionActivityBridge) [Dep A12, A14, A20]
AW5 cockpit / push / mTLS integration (parallel where disjoint — pull forward alongside AW2/AW3 where deps allow)
A30 FCM client (FcmService data-only → NotificationBuilder; Deny→BroadcastReceiver goAsync;
Allow→translucent trampoline Activity hosting BiometricPrompt→expedited POST) [Dep A8, A12]
A31 PushRegistrar (POST /push/fcm-token per host, POST_NOTIFICATIONS, self-heal) [Dep A8, A12]
A32 DeepLinkRouter + custom scheme + verified App Links (assetlinks) + push-tap routing [Dep A21]
A33 SERVER: src/push/fcm.ts + POST/DELETE /push/fcm-token + FCM_* env [server-side, no client dep] ← routed to server module owner; startable day 1
AW6 integration + acceptance
A34 :app instrumented E2E against real `npm start` (attach→attached→output, reconnect replay, kill, hook/decision, bad-Origin reject)
A35 One macrobenchmark/Espresso happy path: pair → attach → type → approve
A36 Coverage gate (Kover ≥80% on pure modules incl. :client-tls pure half) + README(android/) + PROGRESS_LOG finalize + device-QA checklist
```
### Task detail (Owns / Verify highlights)
- **A1** `Owns: android/settings.gradle.kts, android/build.gradle.kts, android/gradle/libs.versions.toml, android/**/build.gradle.kts stubs, android/README.md`. `Verify:` `./gradlew help` + empty modules assemble.
- **A2** `Owns: :wire-protocol/**` model + interfaces (sealed `ClientMessage`/`ServerMessage`, `TermTransport`, `HttpTransport`, `PingableTermTransport`, `WireConstants`, **`HostEndpoint`**, enums). **Freezes the shared contract incl. Origin derivation.** `Verify:` compiles; consumers can import.
- **A4** `Owns: :wire-protocol MessageCodec.kt, Validation.kt + tests`. `Verify:` golden-vector encode byte-equality vs captured web-client frames; `HostEndpoint` origin table vs `src/http/origin.ts` (in A2's tests but re-asserted here).
- **A5** `Owns: :session-core/{ReconnectMachine,PingScheduler,SessionEvent}.kt + tests`. `Verify:` backoff ladder 1→2→4→8→16→30; no-reset-on-foreground; ping 25s/2-miss under virtual time.
- **A6** `Owns: :session-core/{GateTracker,GateState,AwayDigest,UnreadLedger,KeyByteMap,TitleSanitizer,SessionEvent-cockpit}.kt + tests`. `Verify:` two-line epoch guard + `canDecide`; digest once-per-reconnect, all-zero suppressed; `KeyByteMap == public/keybar.ts`; `TitleSanitizer` strips bidi/zero-width + 256-char cap.
- **A7** `Owns: :transport-okhttp/**`. `Verify:` `Origin` header reaches server (integration); clean-finish vs error preserved via `channelFlow`; server-cert trust: default trust for tunnel/Tailscale, `ws://` allowlist for LAN.
- **A10** `Owns: :client-tls pure half (Pkcs12Parse.kt, ClientKeyManagerLogic.kt, CertificateSummary.kt, HostClassifier.kt) + tests`. `Verify:` wrong-passphrase → `UnrecoverableKeyException`; alias selection; classifier tiers (loopback/tailscale/privateLAN/public, fail-safe unknown→public). **In the Kover gate.**
- **A11** `Owns: :client-tls framework half (AndroidKeyStoreImporter.kt, TinkCertStore.kt, IdentityRepository.kt) + androidTest`. `Verify:` (instrumented) mid-run-imported cert presented on next handshake with no relaunch; `connectionPool.evictAll()` drops old-identity pooled/resumed connections.
- **A14** `Owns: :session-core/SessionEngine.kt + SessionEngineTest.kt`. `Verify:` FakeTransport drives attach-first ordering, adopt-server-id, connect-now-on-foreground, close≠kill, oversized-replay terminal, gate-decision epoch drop.
- **A15** `Owns: :app/wiring/{EventBus,TerminalSessionController(interface+skeleton),AppEnvironment,RetainedSessionHolder,ColdStartPolicy}.kt + di/*Module.kt boundaries`. `Verify:` per-consumer `Channel` fan-out (slow collector never drops a gate AND never stalls output); engine hosted in `@ActivityRetainedScoped`/nav-scoped holder; contract compiles for all AW4 consumers.
- **A16** `Owns: :terminal-view/**`. `Verify:` cols×rows from font metrics; replay never drops bytes (pendingOutput flush, chunked off-main append); DECCKM arrows still `ESC O A` under app-cursor mode; rotation re-binds surviving emulator without blank/replay.
- **A21** `Owns: :app screens/TerminalScreen.kt, components/ReconnectBanner.kt, wiring/TerminalSessionController(impl).kt`. `Verify:` banner precedence (exited/failed outrank connecting/reconnecting); exit `-1` spawn-failure copy; `replayTooLarge` no-spinner actionable copy + "new session"; toolbar "new session in cwd" → `attach(null, cwd)`.
- **A27** `Owns: :app screens/ClientCertScreen.kt, viewmodels/ClientCertViewModel.kt`. `Verify:` SAF `.p12` import → AndroidKeyStore (import validates before persist so a bad passphrase can't clobber a prior identity); rotate/remove; summary (issuer-CN/expiry/expired-warning); host-menu reachability.
- **A29** `Owns: :app wiring/SessionActivityBridge.kt, components/ContinueLastBanner.kt, nav/ColdStartPolicy-wiring`. `Verify:` no-host→pairing / host→sessions route; adopted→set / exited→clear `LastSessionStore`; banner in stack + sidebar.
- **A30** `Owns: :app push/{FcmService,NotificationBuilder,DenyBroadcastReceiver,AllowTrampolineActivity}.kt`. `Verify:` Deny via `BroadcastReceiver` (`goAsync()` + expedited POST, no UI); Allow via translucent `excludeFromRecents` Activity hosting `BiometricPrompt` then POST; token never logged/persisted; single-use token → retried-after-success POST returns 403 (idempotency guarantee).
- **S1** `Owns: throwaway sandbox module (deleted after)`. `Verify:` a Termux `TerminalEmulator` with the JNI process removed renders one canned screen from WS-shaped bytes; sizes the `TerminalSession` replacement surface. **Gate to AW3.**
- **S2** `Owns: throwaway push sandbox + a test Firebase project`. `Verify:` measure data-only high-priority delivery latency/loss on ≥2 real handsets (incl. one Xiaomi/Huawei/Samsung) under Doze/force-stop. **Feeds the R1 decision.**
---
## 6. Terminal rendering deep-dive (the single hardest piece)
No JVM port of SwiftTerm exists, so we adopt **Termux `terminal-emulator` (VT parser + `TerminalBuffer` scrollback ring) + `terminal-view` (`TerminalView` + `TerminalRenderer`)**. Do NOT hand-roll an ANSI parser. **This is a source vendor, not a Maven dependency** — there is no consumable `com.termux:terminal-emulator` artifact, which is why R2 (license) and S1 (seam spike) gate AW3.
### 6.1 The wiring problem and the chosen shape (this is a fork, not a subclass)
Termux's `TerminalView.attachSession(TerminalSession)` couples the view to a `TerminalSession` whose **constructor forks a local process over JNI** and pumps its stdout into the emulator, and whose `write()`/`getEmulator()`/`initializeEmulator()` the view drives. We have no local process. So the real work is **forking/replacing `TerminalSession`** — remove the JNI subprocess, override `write()` to send over the WS, and call `initializeEmulator()` manually — **not merely subclassing the view** (platform review correction). S1 proves this seam is mechanically achievable before A16 commits 23 weeks.
```
class RemoteTerminalSession(
private val engineSend: (ClientMessage) -> Unit // ordered send pump → engine.send
) {
val emulator = TerminalEmulator(termOutput, cols=80, rows=24, transcriptRows=10_000)
// termOutput: TerminalOutput whose write(bytes,off,len) forwards to engineSend(Input)
// — carries emulator-originated bytes: DA/DSR replies, mouse, bracketed-paste wrappers.
fun feedRemote(bytes: ByteArray) // remote output → emulator.append (off-main, chunked); post onScreenUpdated()
fun writeInput(data: String) // typed/keybar bytes → engineSend(Input(data))
fun updateSize(cols, rows) // emulator.resize(cols,rows); engineSend(Resize)
}
```
The Termux `TerminalView` is subclassed only to (a) expose an `onKeyCommand`-style outlet and (b) install the key-bar / pointer menu — glyph rendering, cursor, selection, IME, scroll stay stock, exactly as iOS uses stock SwiftTerm.
### 6.2 Inbound: output bytes → rendered cells (off-main append)
```
SessionEngine.events (engine confinement dispatcher)
→ SessionEvent.Output(String) decoded to ByteArray (UTF-8) in TerminalSessionController (A21)
→ RemoteTerminalSession.feedRemote(bytes)
→ append on a CONFINED single-thread dispatcher (mirrors Termux's background reader thread),
CHUNKED so a multi-MB ring-replay never blocks a single append
→ post terminalView.onScreenUpdated() to Dispatchers.Main.immediate (invalidate → TerminalRenderer.render())
```
**Why off-main (platform review mustFix):** `TerminalEmulator`/`TerminalBuffer` is single-writer, read by the renderer during the UI-thread draw. A synchronous multi-MB `append` on `Main.immediate` blocks the UI thread → ANR. Termux appends on a background reader and only posts `onScreenUpdated` to the UI thread; we replicate that (single-writer discipline enforced against the UI-thread renderer read).
**pendingOutput invariant (must replicate exactly):** output that arrives before the `TerminalView` binds (ring-buffer replay can land first) is queued in an `ArrayDeque<ByteArray>` and flushed **in submission order** the instant the view binds. The `ESC[0m` soft-reset prefix on replay is passed through, never stripped.
### 6.3 Outbound: input → bytes → wire (one ordered pump)
Three sources funnel into a **single ordered send pump** (a `Channel<ClientMessage>` consumed by one coroutine) so two fast taps never race onto the wire:
1. **Typing (IME):** `TerminalView``RemoteTerminalSession.writeInput` (stock Termux `InputConnection` path, redirected from process-write to WS-send).
2. **Key-bar taps:** Compose buttons → `KeyByteMap.bytes(key)``engine.send(Input(...))` **bypassing the IME entirely** (soft keyboard never pops), mirroring the web `ws.send` bypass.
3. **Hardware chords:** `onKeyEvent` → Esc/Ctrl-letter/⇧Tab map via `KeyByteMap`; **arrows/Enter/Tab are left to Termux's `KeyHandler`** so `cursorKeysApplication` (DECCKM) still emits `ESC O A` for vim/htop. Hardcoding `ESC [ A` would break TUIs — do not.
### 6.4 Resize math (cols×rows)
Termux `TerminalRenderer` exposes cell metrics: `mFontWidth = ceil(paint.measureText("X"))`, `mFontLineSpacing = ceil(descent - ascent) + lineSpacingAdd`. On every layout / font-size change:
```
cols = max(1, floor((viewWidth - 2*hPad) / mFontWidth))
rows = max(1, floor((viewHeight - 2*vPad) / mFontLineSpacing))
if (cols,rows) != lastSentDims && cols>0 && rows>0:
emulator.resize(cols, rows) // local buffer reflow
engine.send(Resize(cols, rows)) // → server SIGWINCH
lastSentDims = (cols, rows)
```
**Latest-writer-wins sizing (v0.4):** drop non-positive pre-layout dims; remember `lastSentDims`; on `Lifecycle.State.RESUMED` (pane-show/device-switch) and window-focus, re-send `lastSentDims` via `engine.notifyForegrounded(dims)` to reclaim full-screen. Attach/detach never resize. Feed **both** lifecycle-RESUMED and the view's size-changed callback into the same connect-now+resize path (missing either breaks device-switch reclaim). Font metrics differ from SwiftTerm, so QA resize parity against web/iOS on the same device sizes (R5).
### 6.5 Scrollback, selection, links, titles
- **Scrollback:** local scroll via `TerminalBuffer` `transcriptRows` (~10 000). Authoritative history is the server's ~2 MB ring replayed on attach — the local transcript is just for scroll-up while connected.
- **Selection/copy:** Termux `TextSelectionCursorController` + Android `ActionMode``ClipboardManager`.
- **Links:** URL detection → `Intent(ACTION_VIEW)` with an **http/https-only allowlist**. OSC 52 host-clipboard writes are declined.
- **OSC 0/2 titles:** delegate → **`TitleSanitizer` (in `:session-core`, JVM-tested)** — strip bidi/zero-width, 256-char cap. Titles are attacker-influenced; render inert. Same sanitizer feeds the session-list/switcher.
### 6.6 Lifecycle & config-change survival (retained holder — load-bearing for A14/A16/A21)
**Decision (architecture + platform mustFix):** `SessionEngine` + `RemoteTerminalSession` (emulator + scrollback) live in a **config-surviving `RetainedSessionHolder`** (`@ActivityRetainedScoped` / nav-scoped `ViewModel`, in `viewModelScope`), **not** `lifecycleScope` (which cancels at `ON_DESTROY` on every rotation). Rules:
- Distinguish **config change** (rotation/fold/multi-window/dark-mode — Activity recreated, Compose tree disposed, `AndroidView` factory re-run) from **real backgrounding** using `isChangingConfigurations()` / `onCleared()`.
- On **config change:** the holder survives; **re-bind the surviving emulator to a recreated `AndroidView`** — no detach, no ~2 MB replay round-trip, no scroll-position loss. (Prefer this over `android:configChanges`, which breaks resource re-resolution and posture changes.)
- On **real background** (`ON_STOP` that is not a config change): `engine.close()` (clean detach; server PTY survives). On real foreground return after a background stop: rebuild the stack, bump `generation`, key the `AndroidView` by `generation` so a fresh emulator replays the ring and re-fires resize. The generation bump is reserved for **genuine background detach only**, never config change.
- **Flow-collection scoping:** all UI-facing collectors use `collectAsStateWithLifecycle`/`repeatOnLifecycle(STARTED)`; the HTTP poll is a STARTED-scoped loop (matching iOS's foreground poll); the WS is gated to STARTED; the terminal output collector runs on `Main.immediate` but is cancelled with the **retained holder**, not the composition, so a recompose never drops a frame.
### 6.7 Preview thumbnails (cleaner than iOS, but more code — re-baselined M→ML)
Instantiate an **off-screen `TerminalEmulator`** (no `TerminalView`), `append(previewBytes)`, then rasterize `TerminalBuffer` cells directly with a `Canvas` + monospace `Paint` — port `TerminalRenderer.render` onto a `Bitmap`-backed `Canvas`, drawing fg/bg per cell from each cell's `TextStyle`. This avoids iOS's off-screen-`UIView` snapshot and the `CADisplayLink` leak, but the manual per-cell painter is **more work than iOS's cheap `SwiftTerm.feed` off-screen path** — sized ML, on the A16 critical path. Cache `Bitmap` in an `LruCache` keyed **`(sessionId, lastOutputAt)`** (unchanged `lastOutputAt` ⇒ never re-render); cap concurrency with `Semaphore(2)` (FIFO); dedup same-key via `Map<Key, Deferred<Bitmap>>` under a `Mutex`; any failure caches a placeholder under the same key. Fetch preview bytes over the **mTLS OkHttp client** (tunnel-host previews must pass nginx).
### 6.8 Fallback if the primary renderer fails
Two tiers, chosen by decision gate. **Note the blast radius:** the tier-2 WebView path also invalidates the §6.7 off-screen-emulator thumbnail approach, so it is a partial AW3 rebuild, not a drop-in swap.
1. **License-safe fallback (if Termux GPL/AGPL is rejected, §9 R2):** fork **jackpal `Android-Terminal-Emulator`** (Apache-2.0). Weaker true-color/OSC coverage — accept a fidelity QA pass. Same `RemoteTerminalSession` seam applies.
2. **Fidelity fallback (if JVM emulation diverges from web/iOS on real Claude TUIs):** render **xterm.js offscreen in a `WebView`** — byte-identical to the web client by construction; feed `output` via `evaluateJavascript("term.write(...)")`, read input via a JS bridge. Heavier (WebView per session), against the native goal — **last resort**, decided after A16 QA against live Claude Code sessions (and would require re-doing §6.7 thumbnails).
### 6.9 Server-cert trust (closes the platform-review gap)
OkHttp requires an `(SSLSocketFactory, X509TrustManager)` pair. Decision:
- **Tunnel (`*.terminal.yaojia.wang`) + Tailscale MagicDNS:** real CA-signed (LE) certs → **default system trust**. No custom trust manager.
- **Bare LAN:** use **`ws://` cleartext**, permitted only for private LAN/Tailscale CIDRs via `network_security_config` (global `usesCleartextTraffic=false`).
- **Self-signed LAN `wss`:** DEFERRED — a documented `CertificatePinner`/custom `X509TrustManager` path, not built by default.
---
## 7. Testing strategy (mirror iOS package tests; 80% coverage target)
### Unit (JVM, no device) — the 80% target modules
JUnit5 + `kotlinx-coroutines-test` (virtual time) + Turbine + MockK. Measured by **Kover**; gate ≥80% on `:wire-protocol`, `:session-core`, `:api-client`, `:host-registry` (logic), **and `:client-tls` pure half**.
- **`:wire-protocol`** — `MessageCodec` **golden-vector** byte-equality vs captured web-client frames (explicit `sessionId:null`, top-level `approve.mode`, JS control-char escaping, lowercase UUIDs); tolerant decode (bad JSON/unknown type/missing-required → null; wrong-typed optional → absent; `telemetry.at` mandatory); `Validation` (v4 regex; resize 1..1000; absolute cwd); `HostEndpoint` origin/wsURL vs `src/http/origin.ts` (default-port omission, lowercase, IPv6 brackets).
- **`:session-core`** — `ReconnectMachine` ladder + no-reset-on-foreground; `PingScheduler` 25s/2-miss (drives only `PingableTermTransport`); `GateTracker` rising-edge epoch + two-line stale-guard + `canDecide`; `AwayDigest` once-per-reconnect; `UnreadLedger` watermark/cap/tie-break; **`TitleSanitizer` bidi/zero-width strip + 256 cap**; `SessionEngine` (FakeTransport + FakeTimeSource): attach-first ordering, adopt-server-id, connect-now-on-foreground, close≠kill, oversized-replay terminal, gate-decision epoch drop.
- **`:api-client`** — FakeHttpTransport: **Origin-iff-guarded** (3 guarded routes carry byte-equal Origin; RO GETs must not); tolerant list decode (drop-one-keep-rest, unknown status→unknown, non-array→invalidResponseBody); **strict RFC3986 query encoding**; UiPrefs unknown-key round-trip (Int-vs-Double preserved); loose FCM-token validator; pairing probe leaves no orphan; `PairingError`/`HostClassifier` tiers (fail-safe unknown→public).
- **`:client-tls` pure half** — PKCS12 parse happy + wrong-passphrase (`UnrecoverableKeyException`) + corrupt (`EOFException`); `X509KeyManager` alias/`getPrivateKey` selection; `CertificateSummary` fields; classifier warning tiers.
### Instrumented (androidTest — device/emulator; Robolectric only where faithful)
- **`:terminal-view`** — resize→cols/rows for a known font size; keybar tap → exact bytes on a fake send pump; DECCKM arrow form; pendingOutput flush ordering; **off-main chunked append does not stall UI**; rotation re-binds surviving emulator (no blank); thumbnail rasterization non-null.
- **`:client-tls` framework half (real AndroidKeyStore — NOT Robolectric)** — import happy + no-identity mapping; **re-reading `X509KeyManager` presents a mid-run-imported cert on the next handshake with no relaunch**; **`connectionPool.evictAll()` — pooled/resumed connections do not reuse the old identity**; import validates before persist (bad passphrase can't clobber prior identity).
- **`:host-registry`** — DataStore read-modify-write list; secret split (cert never in plain store).
- **`:app`** — FCM data-only → `NotificationBuilder` Allow/Deny actions; **Deny `BroadcastReceiver` POSTs `/hook/decision` (token never logged)**; **Allow trampoline Activity hosts a mock `BiometricPrompt` then POSTs**; DeepLinkRouter v4-whitelist; Compose UI tests for gate banner/plan sheet/**reconnect banner precedence**/session-list rows/**continue-last banner**.
### E2E / integration (mirror `ios/IntegrationTests`, ~10 tests)
Instrumented suite against a **real `npm start` Node server**: `attach→attached→output` timing; reconnect replays ring buffer (F5/F6); spawn-failure → `exit(-1)`; kill via `DELETE /live-sessions/:id`; `hook/decision` resolves a held gate; bad Origin rejected (F9). Plus **one macrobenchmark/Espresso happy-path**: pair → attach → type → approve.
### Deferred to real device (matches iOS)
Gesture/IME/CJK composition, camera-QR, haptics, lock-screen Allow/Deny, FCM end-to-end (real Firebase + device), OEM battery-manager + **force-stop** delivery — a written manual checklist in A36 (informed by the S2 spike), not automated.
---
## 8. Security checklist (non-negotiable)
- [ ] **Origin header parity** — one `HostEndpoint`-derived `Origin` (frozen in `:wire-protocol`), byte-equal to `new URL()`; stamped on the WS handshake and the **3 guarded HTTP routes** only. The single non-skippable CSWSH defense (TECH_DOC §7). Verified vs `src/http/origin.ts`.
- [ ] **mTLS trust model (one key home)** — private key **imported non-exportable into `AndroidKeyStore`**; a **custom re-reading `X509KeyManager`** returns the AndroidKeyStore `PrivateKey`/chain per handshake; `KeyStore("PKCS12")` is import-parse-only (transient), never the runtime home; **NOT** system `KeyChain`. Client cert presented on both WS and REST via one shared `OkHttpClient`. On rotation, `connectionPool.evictAll()` so pooled/resumed connections drop the old identity.
- [ ] **Cert storage** — private key non-exportable/device-bound; only the **cert chain + metadata** blob is stored, encrypted with **Tink AEAD + AndroidKeystore master key**; app-private, uninstall-wiped, never in backups/cloud. **No `.p12` blob or passphrase persisted.** Import validates before persisting so a bad passphrase can't clobber a prior identity.
- [ ] **No system-wide credential leakage** — no ephemeral/disk HTTP cache (`OkHttpClient.cache(null)`) — preview/diff bodies can contain terminal secrets.
- [ ] **Cleartext posture**`usesCleartextTraffic=false` globally; a `network_security_config` allowlist permits `ws://` only for LAN/Tailscale CIDRs; `wss` (tunnel/Tailscale) uses default system trust; §5.4 warning tiers reproduced in-app (public-host blocking needs explicit acknowledge; tunnel host = cert-gated, warning softened, TLS failure re-mapped to "client cert invalid/revoked").
- [ ] **Tunnel-host cert gate**`runProbe` refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed; both confirm and retry funnel through the one choke point (retry can't bypass).
- [ ] **Push token discipline** — single-use `/hook/decision` capability token validated as v4 UUID, passed only to the POST, **never persisted, never logged, never in fallback copy**; payload minimized (`sessionId`/`cls`/`token` only).
- [ ] **Notification-action trust split****Deny → `BroadcastReceiver`** (`goAsync()`, no UI, expedited POST). **Allow → a translucent, `excludeFromRecents` trampoline `Activity`** that hosts `BiometricPrompt` then POSTs — a `BroadcastReceiver`/`Service` cannot present `BiometricPrompt` and `goAsync()`'s ~10s budget would blow. (Corrects the draft's "no app open" claim for Allow.) All `PendingIntent`s `FLAG_IMMUTABLE` (API 31+). Deny is auth-free (fail-safe). The decision POST is **expedited** (not deferrable `WorkManager`); the single-use token makes a retried-after-success POST return 403 → idempotent retry-safety.
- [ ] **App Links**`assetlinks.json` over HTTPS, no redirect, correct **release** signing-cert SHA-256; deep-link fields UUID-whitelisted, invalid → ignore+count (never partially applied).
- [ ] **Untrusted server strings** — titles/paths/branches/diff lines/telemetry/gate-detail rendered as **inert Compose `Text`**, no Markdown/`linkify`/`AnnotatedString` autolink; PR badge is a tappable link only when the URL parses `https`. OSC titles routed through `TitleSanitizer`.
- [ ] **No secrets in code** — FCM service-account JSON **server-side only**; `google-services.json` (client config, not a secret) is fine; no API keys hardcoded; all config via env/DI.
- [ ] **`POST_NOTIFICATIONS`** requested with rationale (API 33+); **`FLAG_SECURE` + privacy shade** so the recents snapshot never leaks terminal bytes. **No `NEARBY_WIFI_DEVICES`** — Android has no iOS-style local-network permission prompt; plain WS to a LAN IP needs no runtime permission (R12).
---
## 9. Risks & open questions (ranked)
| # | Risk / question | Severity | Mitigation / decision |
|---|---|---|---|
| **R1** | **FCM has no server-defined action buttons**; data-only high-priority is the only faithful Allow/Deny reproduction, and delivery is best-effort (Doze; OEM battery killers on Xiaomi/Huawei/Samsung; **a force-stopped/swiped-away app may get no FCM until next launch**; normal-priority "done" is Doze-batched). | **Accepted (was Critical)** | **DECIDED (user): accept best-effort background delivery** — documented as a known limitation; no background FGS (R9). Gate = `priority:"high"` data-only, notification built on-device; "done" = `normal`. S2 spike still runs to quantify real-device loss and inform user-facing copy ("delivery not guaranteed while backgrounded"). |
| **R2** | **Termux `terminal-emulator`/`terminal-view` licensing.** ~~GPL, must vendor~~ **CORRECTED (independently verified 2026):** these two libraries are **Apache-2.0** (per Termux `LICENSE.md` — they descend from jackpal's Apache-2.0 emulator; only `termux-shared` + the app module are GPLv3) and are **consumable via JitPack** (`com.termux:termux-app:terminal-view:0.118.0`), so no copyleft on the app and vendoring is optional. | **Low (was Critical)** | **No longer a blocking legal decision.** Mitigation = **scope the dependency to `terminal-view`(+`terminal-emulator`) only; never pull `termux-shared`/app module** (GPLv3). Add `com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava` to dodge a Guava conflict. When vendoring for version-pinning, spot-check per-file Apache headers. jackpal fork is a needless fallback (Apache-2.0 but archived 2022, VT-100-only). Sources: Termux `LICENSE.md`, Termux-Libraries wiki. |
| **R3** | **Byte-exact JSON encode parity** vs `JSON.stringify`. | High | Hand-rolled `StringBuilder` codec + golden-vector tests (A4). Non-negotiable gate. |
| **R4** | **OkHttp binds `SSLSocketFactory` at build time** + pooled/resumed connections keep the old identity → breaks "no-relaunch" rotation. | High | Custom re-reading `X509KeyManager` over the AndroidKeyStore identity + `connectionPool.evictAll()` on rotation. Instrumented test proves next-handshake cert + no old-identity reuse (A11). |
| **R5** | **cols×rows drift** — Android font metrics ≠ SwiftTerm. | High | Derive metrics from `TerminalRenderer` exactly; unit-test the formula; QA resize parity vs web/iOS. |
| **R6** | **Oversized-replay classification** — OkHttp has no default max message size. | Medium | Client-side 16 MiB frame cap → non-retryable `replayTooLarge`; spike a real large-scrollback session. |
| **R7** | **Server OAuth2 for FCM** — RS256 JWT + refresh vs the zero-new-dep rule. | Resolved | **DECIDED (user): use `google-auth-library`.** Add it as a server dependency (a deliberate exception to the zero-new-dep ethos) so A33 doesn't hand-roll RS256/JWKS. |
| **R8** | **Key-bar above IME** — no `inputAccessoryView`. | Medium | Compose overlay via `WindowInsets.ime`/`imePadding()`; QA multiple IMEs; taps never route through `BasicTextField`. |
| **R9** | **Background WS vs Android 12+ FGS-background-start block and the Android 14 `dataSync` / Android 15 `mediaProcessing` 6h-per-24h cap** (the draft's "6h FGS cap" was imprecise). No clean `foregroundServiceType` for "hold a WebSocket" (`dataSync` is capped; `specialUse` needs Play review; `connectedDevice` needs a companion). | Medium | **Decision: drop the background foreground-service entirely.** WS is foreground-only (STARTED-scoped); **FCM high-priority = background wake → foreground reconnect + replay**; server keeps the PTY alive. Avoids `specialUse` Play review and any `startForeground()` crash. |
| **R10** | **EventBus fan-out** — a single `SharedFlow` with `onBufferOverflow=SUSPEND` back-pressures the engine pump (head-of-line-blocks OUTPUT for every consumer); DROP could lose a gate. | Medium | **Per-consumer buffered `Channel`s** (fan-out coroutine offers to each) — the true analogue of iOS's per-subscriber `AsyncStream`; a slow collector neither drops a gate nor stalls output. Tested in A15. |
| **R11** | **`/prefs` unknown-key clobber**. | Medium | Store the raw `JsonObject`, rewrite only known keys; never PUT if `/prefs` never loaded; round-trip test (unknown key + Int-vs-Double). |
| **R12** | **Error-taxonomy mismatch** — Android exceptions don't map 1:1 to NSURLError/POSIX; **no local-network permission prompt on Android** (iOS `localNetworkDenied`/`NEARBY_WIFI_DEVICES` logic is dead weight). | Low | Re-derive `PairingError.classify` from `ConnectException`/`SSLHandshakeException`/`UnknownHostException`; **drop `NEARBY_WIFI_DEVICES` and `localNetworkDenied`** (they gate Wi-Fi scanning/Aware, not sockets). |
| **R13** | **Tablet detection fuzziness** — no clean iPad boolean. | Low | `currentWindowAdaptiveInfo().windowSizeClass`; pointer menu = `Modifier.pointerInput` secondary-click → `DropdownMenu`, gated on `Expanded` + `smallestScreenWidthDp>=600`. |
---
## 10. Effort estimate & suggested build order
Sizes carry over the iOS subsystem estimates, re-scaled for the port (S≈12d, M≈35d, L≈11.5wk, XL≈23wk, one engineer-equiv; parallelizable within a wave). Calendar re-baselined per risk review.
| Wave | Tasks | Effort | Notes |
|---|---|---|---|
| **AW0** foundations | A1A3 | **SM** (24d) | Serial; unblocks everything. `HostEndpoint` frozen here. |
| **AW1** leaf modules + spikes | A4A13 + S1 + S2 | **XL total, parallel** (~1.5wk wall) | Highest fan-out; 45 agents. A10+A11 (client-tls split) is the security-critical long pole. **S1/S2 de-risk the two Criticals now.** |
| **AW2** engine + wiring freeze | A14 (L), A15 (M) | **L** (~1.5wk) | Actor→structured-concurrency lifecycle + retained holder is the correctness risk; **A15 must freeze the wiring/DI seam before AW4.** |
| **AW3** terminal render | A16 (XL), A17 (M), A18 (ML) | **XL** (~2.53wk) | The dominant uncertainty; **S1 green first** (R2 license already resolved — Apache-2.0/JitPack). A17/A18 parallel after A16's seam lands. |
| **AW4** UI screens | A19A29 | **XL total, highly parallel** (~22.5wk wall) | File-disjoint by screen → 45 agents. A20+A18 thumbnail, A23 grouping-parity, A21 banner/lifecycle are the risky ones. |
| **AW5** push/mTLS/links | A30 (L), A31 (S), A32 (M), A33 server (M) | **LXL, mostly parallel** (~1.5wk) | A33 (server FCM) startable day 1; A30/A31 pull forward alongside AW2/AW3 (depend only on AW1). |
| **AW6** integration/acceptance | A34A36 | **M** (~1wk) | E2E vs real Node server + coverage gate + device-QA checklist. |
**Total:****1114 engineer-weeks** solo; ≈ **710 calendar weeks** with 35 parallel agents per wave. The serial spine AW0 → A14/A15 → A16 → A21 → AW6 floors wall-clock at ~7 weeks even with infinite agents — the A14→A16 chain cannot be parallel-compressed, so the draft's "57 weeks" low end is not achievable.
### Suggested build order (critical path bolded)
1. **AW0 A1→A2→A3** (serial). Simultaneously kick off **A33** (server FCM — independent) and stand up **S1** and **S2** sandboxes.
2. **AW1** in parallel: **A4** (codec golden vectors) and **A7** (transport) are on the critical path; A5, A6, A8, A9, A10, A11, A12, A13 fill the batch. **Run S1 (renderer seam) and S2 (FCM delivery) here.**
3. **All three open questions are now DECIDED** — R2 (Termux = Apache-2.0/JitPack), R7 (use `google-auth-library`), R1 (accept best-effort FCM delivery). Nothing blocks AW3 or AW5. S2 still runs to quantify FCM loss and drive user-facing "not guaranteed while backgrounded" copy.
4. **AW2 A14** (engine) then **A15** (wiring/DI freeze) — everything terminal/UI waits on the `SessionEvent` contract and the frozen wiring seam.
5. **AW3 A16** (renderer) — the XL spike; get it green against a live Claude Code session (decide R5 cols×rows fidelity) before A17/A18. Give it explicit schedule buffer. (No R2 license fallback needed — Termux terminal libs are Apache-2.0.)
6. **AW4** — fan out the screens: land **A21** (terminal wiring + reconnect banner) + **A22** (gate) first (full engine→UI path), then discovery/projects/diff/quick-reply/timeline/cold-start/cert, then A26 large-screen last. **Start A19/A23/A24/A25 during AW3** (they depend only on AW1 modules) to reclaim wall-clock lost to the serial A14→A16 spine.
7. **AW5** — pull A30/A31 forward alongside AW3; A32 deep links after A21; needs A33 on a test Firebase project for end-to-end.
8. **AW6** — integration, coverage gate, README, deferred device-QA checklist.
---
**Key file references (authoritative context):** `docs/TECH_DOC.md` §4/§5.2/§7, `docs/ARCHITECTURE.md` §1/§3.2/§8, `ios/README.md` + `ios/Packages/*`, and the confirmed server route surface in `src/server.ts` (`src/http/*`, `src/push/*`). The frozen wire contract lives in `src/types.ts` / `src/protocol.ts`, mirrored by the Android `:wire-protocol` module (A2).
---
## 11. Review resolutions
### Architecture review (7.5) — what changed
- **mustFix (Allow ≠ BroadcastReceiver):** Allow now routes through a translucent `excludeFromRecents` trampoline `Activity` hosting `BiometricPrompt`; Deny stays a `BroadcastReceiver`. §5 A30, §8, §1 P1 updated.
- **mustFix (engine ownership / config-change survival):** committed to a `RetainedSessionHolder` (`viewModelScope`/`@ActivityRetainedScoped`), distinguishing config change from real background via `isChangingConfigurations()`/`onCleared()`, re-binding the surviving emulator on rotation; generation-bump reserved for genuine background. §6.6 rewritten; A14/A15/A16/A21 gated on it.
- **Adopted:** `TitleSanitizer``:session-core` (A6); `:client-tls` split into pure + framework halves (A10/A11, pure half in the Kover gate); `:terminal-view` boundary pinned to `:wire-protocol` only (decode in A21); **per-consumer `Channel` EventBus** (R10, A15); expedited decision POST not `WorkManager` (A30, idempotency via single-use token); confinement contract written as invariant #4; "1:1 mirror" reframed; Flow scoping (`repeatOnLifecycle(STARTED)`) specified (§6.6).
### Feature-parity review (7.5) — gaps closed
- **Reconnect/EXIT banner** → A21 (with precedence + spawn-failure/`replayTooLarge` copy + "new session" action).
- **Activity-timeline sheet** → A28 (wired to away-digest expand).
- **Device-cert management screen** → A27 (split out of pairing; host-menu reachable).
- **New-session-in-cwd** (phone toolbar + exit-banner) → A21.
- **Continue-last-session cold-start** (`ColdStartPolicy` + banner + `LastSessionStore` lifecycle) → A29.
- **Adopted:** `TitleSanitizer` named in A6; `DELETE /live-sessions` kill-all marked out-of-parity (§1 DEFERRED, §4.3/§4.4); host-menu contents enumerated (A20); iPad Projects grid assigned (A23); `lastOutputAt` noted as already server-serialized (A20 unblocked).
### Kotlin/Compose platform review (7) — corrections applied
- **mustFix:** Allow-in-receiver contradiction resolved (as above); **single mTLS key home** chosen (AndroidKeyStore-imported + custom X509KeyManager; PKCS12 KeyStore is import-parse-only; no persisted `.p12`); **Termux is a source fork/vendor** (S1 spike + R2), not a subclass; **FGS dropped** — FCM-wake→foreground reconnect (R9), "6h cap" corrected to `dataSync`/`mediaProcessing` 6h-per-24h.
- **Adopted:** off-main chunked `emulator.append` (§6.2); `connectionPool.evictAll()` on rotation (§8, A11); server-cert trust specified (§6.9); per-consumer channels (R10); `NEARBY_WIFI_DEVICES`/`localNetworkDenied` removed (R12); `ContextMenuArea``pointerInput` `DropdownMenu` (R13, A26); `EncryptedFile` inconsistency removed (Tink only, §2); loose FCM-token validator (§4.5); force-stop FCM caveat (R1).
### Risk & sequencing review (7) — sequencing fixes
- **mustFix:** `HostEndpoint`/Origin moved to the AW0 freeze (A2); an explicit **wiring+DI freeze (A15)** inserted at the top of the AW2→AW4 boundary; a **throwaway renderer spike (S1)** added to AW1 before AW3 commits.
- **Adopted:** **S2** FCM delivery spike in AW1; A30/A31 pulled forward; A5/A7 split into finer tasks (A5/A6 and A8/A9); A18 thumbnail re-baselined M→ML; calendar re-baselined to ~710 weeks; A19/A23/A24/A25 started during AW3.
### Post-review correction (independent verification)
- **R2 downgraded Critical→Low.** A dedicated web-verification pass (after the two explorer agents for the terminal stack failed their structured-output step) established that Termux's `terminal-view` + `terminal-emulator` are **Apache-2.0** (not GPL) and **available on JitPack** (not vendor-only). The plan's worst-case "choose GPL / fork / open-source" framing was wrong; the renderer decision is de-risked and AW3 is no longer gated on a license call. §2, §9 R2, §5, §10, and open-question #1 updated accordingly. Only guardrail: keep the dependency scoped to those two libs (never `termux-shared`/app module, which are GPLv3).
### Decisions locked (no open questions remain)
1. ~~**R2 (BLOCKS A16 / AW3):** Termux is GPL and must be vendored.~~ **RESOLVED (verified 2026).** `terminal-view`/`terminal-emulator` are **Apache-2.0** on **JitPack**; no copyleft as long as the dependency is scoped to those two libs (not `termux-shared`/app). AW3/S1 not gated on a license call. Residual technical-only choice: JitPack-consume vs vendor-pin → default JitPack `0.118.0`.
2. **R1 (push reliability): ✅ ACCEPTED (user).** Best-effort FCM background delivery is accepted as a documented limitation (no background FGS). S2 spike still runs to quantify loss and drive user-facing "delivery not guaranteed while backgrounded" copy.
3. **R7 (FCM OAuth2): ✅ DECIDED (user) — use `google-auth-library`.** Added as a server dependency (deliberate exception to zero-new-dep); A33 does not hand-roll RS256.

View File

@@ -47,6 +47,20 @@
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**
- **偏差**: 手建 `control-plane/node_modules/relay-auth` 符号链接以配合新 file: 依赖(`npm install` 会重建)。**阻塞**: 无。
### ✅ NATIVE-TUNNEL — 原生 mTLS 反向隧道:四轨代码全部落地(2026-07-07,分支 `feat/relay-phase1`,commits 5337281/e38e6d1/bb09495/d0c249c)
- **背景**: 原生 iOS/Android/桌面客户端讲 base-app 协议(非 E2E relay),需经用户 VPS 反向隧道(frp)回到本机 `127.0.0.1:3000`。**base app 无鉴权 → mTLS 客户端证书是唯一闸门**。总计划见 [PLAN_NATIVE_TUNNEL.md](./PLAN_NATIVE_TUNNEL.md)(Model A 单主人机队;LE 泛证书;frps 控制通道 mTLS+token;CRL 第一天上;`BIND_HOST=127.0.0.1` 强制)。
- **执行**: 两个后台 Workflow —— (1) build→review 四轨并行(8 agents,0 error,860k tok);(2) 修复两个 HIGH(2 agents,0 error,308k tok)。四轨文件互不相交(`ios/`·`desktop/`·`agent/`·`deploy/`),**零 `src/` 改动**(计划承诺兑现)。
- **各轨(全部已验证)**:
- **C-iOS `[x]`**: 新 `ios/Packages/ClientTLS`(SecIdentity 包装、PKCS12 导入含类型化错误、keychain 存储 `AfterFirstUnlockThisDeviceOnly`、纯 `MutualTLSChallengeResponder` 真值表、跨平台 X.509 DER 摘要)。两个 transport + SessionThumbnail 改**惰性 `@Sendable ()->ClientIdentity?` provider**(WS 每连接、HTTP 每证书挑战解析 → 装证书后免重启生效)。`ClientCertScreen``hostMenu` 的「设备证书」入口可达;PairingViewModel 对 `*.terminal.yaojia.wang` 无证书时挡探测、mTLS 拒绝重映射为 clientCertRejected。**验证**: ClientTLS 14/14、SessionCore 93/93、xcodegen+xcodebuild BUILD SUCCEEDED。
- **C-Desktop `[x]`**(D-1/D-2): `remote-hosts.ts` + DesktopPrefs(不可变、向后兼容)+ 托盘选择器;`will-navigate` 动态锁定选中远端 origin、null 失败关闭(无开放重定向);`select-client-certificate` handler `event.preventDefault()` 优先、按 issuer CN 选证、缺证弹框不静默。**验证**: `npm run typecheck` 干净。
- **S2 host-packaging `[x]`**: launchd 写 `<EnvironmentVariables>`、systemd 写 `EnvironmentFile`、originConfig zone 参数化(默认 `term` 保留);`InstallOptions` 从 CLI install → createCliDeps → installService seam → writers 全程贯通,生成单元携带 `BIND_HOST`(隧道机默认 `127.0.0.1`)/`ALLOWED_ORIGINS`/`PORT`/`SHELL_PATH`/`IDLE_TTL`/`USE_TMUX`;systemd 拒绝控制字符。**验证**: agent typecheck+build 干净、vitest **166/166**(154+12 新)。
- **V2/V7 VPS 脚本(可提交件)`[x]`**: `gen-device-ca.sh`(EC P-256,`--kind frp-client`)、`issue-device-cert.sh`(P-256 leaf,EKU=clientAuth only,.p12 `-legacy`+.pem,防 CSR 注入 serverAuth/CA:TRUE)、`revoke-device.sh`(CRL 强制)、`frp/{frps,frpc}` 模板(`disableCustomTLSFirstByte=true`)、`nginx/frp-mtls.conf`(:8470 TLS 终止 + `ssl_verify_client on` + `ssl_crl`,WS 升级代理 →:7080)、`nginx/stream-sni-additions.md`(两条加性 SNI map,合并式共存)。**验证**: tmp 目录跑通 gen→issue→`openssl verify`→revoke→CRL(OpenSSL 3.0.18);无密钥/证书误入库。
- **对抗式评审结论**: Desktop/VPS 直接 PASS;iOS/S2 各命中 1 个 HIGH(ClientCertScreen 无入口 / 环境注入未接到 install CLI)—— 均属跨 lane 收尾接线,已由第二个 Workflow 补齐并复验。
- **实机 VPS 部署 V0→V6 完成,M1 达成 `[x]`(2026-07-07,8.138.1.192)**: frps 0.61.1(systemd `frps.service`,控制 :7000 + vhost :7080,控制通道 mTLS+token);P-256 device CA + frp-client CA(openssl ca db + CRL);nginx `:8470`(`ssl_verify_client on` + device-CA + `ssl_crl` → WS 代理 :7080);stream map **加性合并** `frp.terminal→7000`/`*.terminal→8470`(保留 `*.term→8443`/`writer→8444`/`default→10443`,`.bak` 已存)。**M1 全绿**:无证书→400 拒;设备证书→200 `/live-sessions` JSON(全链路 :443→SNI→:8470 mTLS→frps→frpc→base app);frpc 无 frp-client 证书→控制层拒(`error: EOF`);吊销设备证书→400、另一证书仍 200(CRL,`nginx -s reload` 后即时);4-zone SNI oracle 共存(xray/writer 未动)。base app **零改动**。详见 memory `native-tunnel-m1-live`
- **LE 泛证书已上线 `[x]`(2026-07-07)**: VPS→LE 被墙(curl err 28),改在**本机 Mac** 跑 `acme.sh` **手动 DNS-01**(用户不交 `Ali_Key`/`Ali_Secret`,手动加两条 `_acme-challenge.terminal` TXT,`dig` 验证),本机签出 **Let's Encrypt 泛证书 `*.terminal.yaojia.wang`(ECC,exp 2026-10-05)**,`scp` 到 VPS `/etc/relay/frp-tls/`,`systemctl restart nginx`(graceful reload 有 worker 竞态,restart 才稳)。**无 `-k` 公网信任验证全绿**:无证书→400、设备证书→200 `/live-sessions` JSON;共存 oracle 不变。**续期注意**:手动模式不自动续,2026-10-05 前需重跑本机手动流程(或补 Ali 凭证走 dns_ali 自动续)。
- **M2 现在只剩设备侧**: ① 把 `feat/relay-phase1` 的 iOS ClientTLS 版本 build 装到 iPhone;② 设置→设备证书 导入 `.p12`(测试证书 `/root/deploy-native/out-dev/test-device.p12`,口令 `testpass123`);③ 配对 `https://t1.terminal.yaojia.wang`。t1 现指向 VPS 自身的临时 base app;真机 iPhone→笔记本 用例需把笔记本按 `deploy/frp/README.md` 上线为一台 host。
- **旁注(非本次改动)**: E2E 浏览器 relay `:8443` 在部署前已 DOWN(只有 :8444 writer 在),故 `terminal.term` 无证书;`*.term→8443` 路由从未被动过。
### ✅ 修复:底部快捷键栏遮挡终端内容(iOS 安全区,2026-07-06,main)
- **现象(用户截图,iPhone)**: 底部快捷键栏(Esc/Esc²/⇧Tab…)压住终端最后一行(Claude Code "bypass permissions" 提示行只露上半)。
- **根因(`public/`)**: `index.html` 设了 `viewport-fit=cover`(布局铺满物理屏、延伸进刘海/Home 指示条),但 `style.css` **全程没有任何 `env(safe-area-inset-*)` 补偿**。于是 `#keybar`(`fixed; bottom:0; height:--keybar-h`)整条压进 Home 指示条区,而 `#term` 只预留了裸 `--keybar-h`,末行与键栏相撞。教科书级 cover-无-safe-area 缺陷。

View File

@@ -81,10 +81,23 @@ export function createSession(
// event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2).
const tName = cfg.useTmux ? tmuxName(id) : null;
// Spawn the shell as a LOGIN shell (POSIX only) so it loads the full profile
// chain (/etc/zprofile, ~/.zprofile, ~/.zshrc, …) and rebuilds PATH — exactly
// like Terminal.app / iTerm. Without `-l`, a shell spawned by a GUI-launched
// app (Finder / login-item) or a long-lived tmux keepalive server inherits a
// minimal PATH, so nvm-managed `node` (only on the login PATH) is missing and
// Claude Code hooks fail with "node: command not found". PowerShell (Windows)
// has no `-l`; skip it there (Windows also forces tmux off — server-config.ts).
const loginArgs = process.platform === 'win32' ? [] : ['-l'];
// H1: under tmux, the node-pty process is a tmux CLIENT; `new-session -A`
// attaches to web_<id> if it exists (restart survival) or creates it.
// attaches to web_<id> if it exists (restart survival) or creates it. The
// shell-command (shellPath + login flag) is the tmux session's root process.
const file = tName !== null ? 'tmux' : cfg.shellPath;
const args = tName !== null ? ['new-session', '-A', '-s', tName, cfg.shellPath] : [];
const args =
tName !== null
? ['new-session', '-A', '-s', tName, cfg.shellPath, ...loginArgs]
: loginArgs;
// M4: let a spawn failure (e.g. missing shell / tmux) propagate synchronously.
const pty: IPty = spawn(file, args, {

View File

@@ -18,6 +18,8 @@ const EXPECTED_DEFAULTS: DesktopPrefs = {
openAtLogin: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
remoteHosts: [],
selectedHostId: null,
}
describe('defaultPrefs', () => {
@@ -60,6 +62,8 @@ describe('validatePrefs', () => {
openAtLogin: true,
notifyOnApproval: false,
notifyOnStatusChange: false,
remoteHosts: [],
selectedHostId: null,
}
// Act

View File

@@ -0,0 +1,66 @@
/**
* test/setup/local-storage.ts — in-memory Web Storage polyfill for the test env.
*
* jsdom 29 (the `@vitest-environment jsdom` files) exposes a `localStorage`
* object whose Storage methods (getItem/setItem/clear/…) are `undefined` under
* this vitest config — so any test that calls `localStorage.clear()` throws
* "localStorage.clear is not a function". This registered setupFile installs a
* spec-compliant in-memory Storage whenever the ambient one is missing or
* non-functional, so frontend unit tests (quick-reply, push, projects-panel)
* get real persistence semantics. It is a no-op if a working Storage already
* exists, and harmless in the node environment (just adds an unused global).
*/
class MemoryStorage implements Storage {
private store = new Map<string, string>()
get length(): number {
return this.store.size
}
clear(): void {
this.store.clear()
}
getItem(key: string): string | null {
return this.store.has(key) ? (this.store.get(key) as string) : null
}
setItem(key: string, value: string): void {
this.store.set(String(key), String(value))
}
removeItem(key: string): void {
this.store.delete(key)
}
key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null
}
}
/** True when `s` is a usable Storage (has a callable `clear`). */
function isUsableStorage(s: unknown): s is Storage {
return s != null && typeof (s as Storage).clear === 'function'
}
/** Install an in-memory Storage on `target[name]` unless a working one exists. */
function ensureStorage(target: Record<string, unknown>, name: string): void {
if (isUsableStorage(target[name])) return
Object.defineProperty(target, name, {
value: new MemoryStorage(),
configurable: true,
writable: true,
})
}
const globalTarget = globalThis as unknown as Record<string, unknown>
ensureStorage(globalTarget, 'localStorage')
ensureStorage(globalTarget, 'sessionStorage')
// Mirror onto `window` (jsdom env) so `window.localStorage` resolves too.
const win = (globalThis as { window?: Record<string, unknown> }).window
if (win) {
ensureStorage(win, 'localStorage')
ensureStorage(win, 'sessionStorage')
}

View File

@@ -4,6 +4,10 @@ export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
// jsdom 29's localStorage methods are undefined under this config; a shared
// in-memory Web Storage polyfill fixes the frontend (@vitest-environment
// jsdom) tests that call localStorage.clear()/setItem(). No-op in node env.
setupFiles: ['test/setup/local-storage.ts'],
// Scaffold has no tests yet; don't fail the script until modules add theirs.
passWithNoTests: true,
// Coverage target is the global 80% rule; enforced with `--coverage`.