Compare commits
10 Commits
bb0949553c
...
cf88e7c588
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf88e7c588 | ||
|
|
34e4a88059 | ||
|
|
99cafdbdbb | ||
|
|
57725f7ef2 | ||
|
|
fc3b849a08 | ||
|
|
a24465623e | ||
|
|
a25633a63b | ||
|
|
5b9ca321d2 | ||
|
|
cb04516d52 | ||
|
|
d0c249c739 |
@@ -107,11 +107,13 @@ npm run setup-hooks # adds the hooks + statusLine to ~/.claude/s
|
|||||||
```
|
```
|
||||||
This wires Claude Code's hooks → **live per-tab status**, the **statusLine gauges**, and **push** notifications. The hooks are a no-op outside web-terminal (they only fire when `$WEBTERM_*` env vars are set in spawned shells), so they're safe to leave installed. Then run `claude` inside a tab.
|
This wires Claude Code's hooks → **live per-tab status**, the **statusLine gauges**, and **push** notifications. The hooks are a no-op outside web-terminal (they only fire when `$WEBTERM_*` env vars are set in spawned shells), so they're safe to leave installed. Then run `claude` inside a tab.
|
||||||
|
|
||||||
|
> **Login shells (why hooks can always find `node`)** — sessions spawn the shell as a **login shell** (`zsh -l`, POSIX only), so it loads your full profile (`~/.zprofile`, `~/.zshrc`, …) and rebuilds `PATH`. Without this, a GUI-launched app (desktop build) or a long-lived tmux keepalive can hand the shell a minimal `PATH`, and hooks that call an nvm-/brew-managed `node` fail with `node: command not found`. If you still hit that on an old session, start a fresh one so it picks up the login-shell `PATH`.
|
||||||
|
|
||||||
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
```bash
|
```bash
|
||||||
npm test # vitest, all modules (~470 tests, 80% coverage gate)
|
npm test # vitest, all modules (~1470 tests, 80% coverage gate)
|
||||||
npm run typecheck # tsc (backend + frontend)
|
npm run typecheck # tsc (backend + frontend)
|
||||||
npm run build # compile backend to dist/
|
npm run build # compile backend to dist/
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import type { AgentConfig } from './config/agentConfig.js'
|
import type { AgentConfig } from './config/agentConfig.js'
|
||||||
import type { AgentIdentity } from './keys/identity.js'
|
import type { AgentIdentity } from './keys/identity.js'
|
||||||
import type { Keystore } from './keys/keystore.js'
|
import type { Keystore } from './keys/keystore.js'
|
||||||
|
import type { InstallOptions } from './service/install.js'
|
||||||
import type { EnrollResult } from 'relay-contracts'
|
import type { EnrollResult } from 'relay-contracts'
|
||||||
|
|
||||||
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||||||
@@ -30,7 +31,9 @@ export interface CliDeps {
|
|||||||
generateIdentity(): AgentIdentity
|
generateIdentity(): AgentIdentity
|
||||||
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
|
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
|
||||||
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
|
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>
|
uninstallService(): Promise<void>
|
||||||
print(line: string): void
|
print(line: string): void
|
||||||
}
|
}
|
||||||
@@ -70,7 +73,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
|||||||
ks.saveIdentity(id)
|
ks.saveIdentity(id)
|
||||||
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
||||||
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
||||||
if (args.flags['install']) await deps.installService(cfg)
|
if (args.flags['install']) await deps.installService(cfg, deps.resolveInstallOptions())
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
case 'run': {
|
case 'run': {
|
||||||
@@ -88,7 +91,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
case 'install':
|
case 'install':
|
||||||
await deps.installService(cfg)
|
await deps.installService(cfg, deps.resolveInstallOptions())
|
||||||
return 0
|
return 0
|
||||||
case 'uninstall':
|
case 'uninstall':
|
||||||
await deps.uninstallService()
|
await deps.uninstallService()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { generateIdentity } from '../keys/identity.js'
|
|||||||
import { redeemPairingCode } from '../enroll/pair.js'
|
import { redeemPairingCode } from '../enroll/pair.js'
|
||||||
import { runTunnel } from '../transport/runTunnel.js'
|
import { runTunnel } from '../transport/runTunnel.js'
|
||||||
import {
|
import {
|
||||||
|
buildInstallOptions,
|
||||||
detectPlatform,
|
detectPlatform,
|
||||||
installService as installServiceUnit,
|
installService as installServiceUnit,
|
||||||
uninstallService as uninstallServiceUnit,
|
uninstallService as uninstallServiceUnit,
|
||||||
@@ -74,7 +75,9 @@ export function createCliDeps(): CliDeps {
|
|||||||
process.once('SIGINT', onSignal)
|
process.once('SIGINT', onSignal)
|
||||||
return handle.done
|
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 }),
|
uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }),
|
||||||
print: (line) => {
|
print: (line) => {
|
||||||
process.stdout.write(`${line}\n`)
|
process.stdout.write(`${line}\n`)
|
||||||
|
|||||||
@@ -9,16 +9,69 @@ import {
|
|||||||
launchdLoadCommand,
|
launchdLoadCommand,
|
||||||
launchdPlistPath,
|
launchdPlistPath,
|
||||||
launchdUnloadCommand,
|
launchdUnloadCommand,
|
||||||
|
type ServiceEnv,
|
||||||
} from './launchd.js'
|
} from './launchd.js'
|
||||||
import {
|
import {
|
||||||
buildSystemdUnit,
|
buildSystemdUnit,
|
||||||
systemdDisableCommand,
|
systemdDisableCommand,
|
||||||
systemdEnableCommand,
|
systemdEnableCommand,
|
||||||
systemdUnitPath,
|
systemdUnitPath,
|
||||||
|
type SystemdUnitOptions,
|
||||||
} from './systemd.js'
|
} from './systemd.js'
|
||||||
|
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
|
||||||
|
|
||||||
export type ServicePlatform = 'launchd' | 'systemd'
|
export type ServicePlatform = 'launchd' | 'systemd'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
|
||||||
|
* existing callers (and relay callers) are unaffected — 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 {
|
export class RootRefusedError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
||||||
@@ -42,23 +95,40 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
|
|||||||
return 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. */
|
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
|
||||||
export async function installService(
|
export async function installService(
|
||||||
_cfg: AgentConfig,
|
cfg: AgentConfig,
|
||||||
platform: ServicePlatform,
|
platform: ServicePlatform,
|
||||||
deps: InstallDeps,
|
deps: InstallDeps,
|
||||||
|
options: InstallOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (deps.getuid() === 0) throw new RootRefusedError()
|
if (deps.getuid() === 0) throw new RootRefusedError()
|
||||||
const bin = deps.binPath()
|
const bin = deps.binPath()
|
||||||
|
const env = resolveEnv(cfg, options)
|
||||||
if (platform === 'launchd') {
|
if (platform === 'launchd') {
|
||||||
const path = launchdPlistPath(deps.homedir())
|
const path = launchdPlistPath(deps.homedir())
|
||||||
deps.writeFile(path, buildLaunchdPlist(bin))
|
deps.writeFile(path, buildLaunchdPlist(bin, env))
|
||||||
const { cmd, args } = launchdLoadCommand(path)
|
const { cmd, args } = launchdLoadCommand(path)
|
||||||
await deps.runCommand(cmd, args)
|
await deps.runCommand(cmd, args)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const path = systemdUnitPath(deps.homedir())
|
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()
|
const { cmd, args } = systemdEnableCommand()
|
||||||
await deps.runCommand(cmd, args)
|
await deps.runCommand(cmd, args)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
||||||
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
|
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
|
||||||
|
*
|
||||||
|
* PLAN_NATIVE_TUNNEL S2: the plist can 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'
|
const LABEL = 'com.web-terminal.agent'
|
||||||
|
|
||||||
export function launchdLabel(): string {
|
export function launchdLabel(): string {
|
||||||
@@ -12,24 +21,52 @@ export function launchdPlistPath(homedir: string): string {
|
|||||||
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
|
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */
|
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
|
||||||
export function buildLaunchdPlist(binPath: string): string {
|
function escapeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the `<key>EnvironmentVariables</key><dict>…</dict>` lines (empty array when env is empty). */
|
||||||
|
function environmentVariablesBlock(env: ServiceEnv): readonly string[] {
|
||||||
|
const entries = Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
if (entries.length === 0) return []
|
||||||
|
const lines = [' <key>EnvironmentVariables</key>', ' <dict>']
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
lines.push(` <key>${escapeXml(key)}</key>`)
|
||||||
|
lines.push(` <string>${escapeXml(value)}</string>`)
|
||||||
|
}
|
||||||
|
lines.push(' </dict>')
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the 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 [
|
return [
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||||
'<plist version="1.0">',
|
'<plist version="1.0">',
|
||||||
'<dict>',
|
'<dict>',
|
||||||
' <key>Label</key>',
|
' <key>Label</key>',
|
||||||
` <string>${LABEL}</string>`,
|
` <string>${escapeXml(LABEL)}</string>`,
|
||||||
' <key>ProgramArguments</key>',
|
' <key>ProgramArguments</key>',
|
||||||
' <array>',
|
' <array>',
|
||||||
` <string>${binPath}</string>`,
|
` <string>${escapeXml(binPath)}</string>`,
|
||||||
' <string>run</string>',
|
' <string>run</string>',
|
||||||
' </array>',
|
' </array>',
|
||||||
' <key>RunAtLoad</key>',
|
' <key>RunAtLoad</key>',
|
||||||
' <true/>',
|
' <true/>',
|
||||||
' <key>KeepAlive</key>',
|
' <key>KeepAlive</key>',
|
||||||
' <true/>',
|
' <true/>',
|
||||||
|
...environmentVariablesBlock(env),
|
||||||
'</dict>',
|
'</dict>',
|
||||||
'</plist>',
|
'</plist>',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
||||||
* APPENDS `https://<subdomain>.term.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
* APPENDS `https://<subdomain>.<zone>.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
||||||
* as CONFIG — NO `src/` code edit. AUGMENTS, never weakens, the Origin/CSWSH check: existing
|
* as CONFIG — NO `src/` code edit. AUGMENTS, never weakens, the Origin/CSWSH check: existing
|
||||||
* origins are always preserved (EXPLORE §3 "do not weaken the check").
|
* origins are always preserved (EXPLORE §3 "do not weaken the check").
|
||||||
|
*
|
||||||
|
* PLAN_NATIVE_TUNNEL S2: the DNS zone label is PARAMETERIZED. Relay callers keep the historical
|
||||||
|
* `term` zone (default), while native-tunnel hosts pass `terminal` so the base app trusts
|
||||||
|
* `https://<name>.terminal.<domain>`. The default is preserved so existing callers/tests are
|
||||||
|
* unaffected — the zone is opt-in per call, never hard-flipped.
|
||||||
*/
|
*/
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
|
||||||
@@ -18,23 +23,40 @@ const defaultFs: OriginFsDeps = {
|
|||||||
write: (p, c) => writeFileSync(p, c),
|
write: (p, c) => writeFileSync(p, c),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compose the subdomain origin the base app must trust. */
|
/** Default DNS zone label (relay hosts). Native-tunnel hosts pass `terminal`. */
|
||||||
export function subdomainOrigin(subdomain: string, domain: string): string {
|
export const DEFAULT_ORIGIN_ZONE = 'term'
|
||||||
return `https://${subdomain}.term.${domain}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const KEY = 'ALLOWED_ORIGINS'
|
const KEY = 'ALLOWED_ORIGINS'
|
||||||
|
|
||||||
|
/** Compose the subdomain origin the base app must trust: `https://<subdomain>.<zone>.<domain>`. */
|
||||||
|
export function subdomainOrigin(
|
||||||
|
subdomain: string,
|
||||||
|
domain: string,
|
||||||
|
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||||
|
): string {
|
||||||
|
return `https://${subdomain}.${zone}.${domain}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge `origin` into a comma-separated ALLOWED_ORIGINS value, preserving every existing origin and
|
||||||
|
* de-duplicating. Returns the merged CSV; never removes an origin. Pure/immutable.
|
||||||
|
*/
|
||||||
|
export function mergeOrigins(current: string | undefined, origin: string): string {
|
||||||
|
const origins = (current ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
if (origins.includes(origin)) return origins.join(',')
|
||||||
|
return [...origins, origin].join(',')
|
||||||
|
}
|
||||||
|
|
||||||
function upsertOriginLine(content: string, origin: string): string {
|
function upsertOriginLine(content: string, origin: string): string {
|
||||||
const lines = content.length === 0 ? [] : content.split('\n')
|
const lines = content.length === 0 ? [] : content.split('\n')
|
||||||
let found = false
|
let found = false
|
||||||
const next = lines.map((line) => {
|
const next = lines.map((line) => {
|
||||||
if (!line.startsWith(`${KEY}=`)) return line
|
if (!line.startsWith(`${KEY}=`)) return line
|
||||||
found = true
|
found = true
|
||||||
const current = line.slice(KEY.length + 1)
|
return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
|
||||||
const origins = current.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
|
||||||
if (origins.includes(origin)) return line // idempotent — already trusted
|
|
||||||
return `${KEY}=${[...origins, origin].join(',')}`
|
|
||||||
})
|
})
|
||||||
if (!found) next.push(`${KEY}=${origin}`)
|
if (!found) next.push(`${KEY}=${origin}`)
|
||||||
return next.join('\n')
|
return next.join('\n')
|
||||||
@@ -42,15 +64,17 @@ function upsertOriginLine(content: string, origin: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
||||||
* existing origin. Creates the file/line if absent.
|
* existing origin. Creates the file/line if absent. `zone` selects the DNS zone label (default
|
||||||
|
* preserves relay callers).
|
||||||
*/
|
*/
|
||||||
export function ensureAllowedOrigin(
|
export function ensureAllowedOrigin(
|
||||||
baseAppEnvPath: string,
|
baseAppEnvPath: string,
|
||||||
subdomain: string,
|
subdomain: string,
|
||||||
domain: string,
|
domain: string,
|
||||||
fs: OriginFsDeps = defaultFs,
|
fs: OriginFsDeps = defaultFs,
|
||||||
|
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||||
): void {
|
): void {
|
||||||
const origin = subdomainOrigin(subdomain, domain)
|
const origin = subdomainOrigin(subdomain, domain, zone)
|
||||||
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
||||||
const updated = upsertOriginLine(existing, origin)
|
const updated = upsertOriginLine(existing, origin)
|
||||||
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
|
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
|
||||||
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
|
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
|
||||||
|
*
|
||||||
|
* PLAN_NATIVE_TUNNEL S2: the unit can 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'
|
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 {
|
export function systemdUnitName(): string {
|
||||||
return UNIT_NAME
|
return UNIT_NAME
|
||||||
}
|
}
|
||||||
@@ -12,8 +30,53 @@ export function systemdUnitPath(homedir: string): string {
|
|||||||
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
|
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 [
|
return [
|
||||||
'[Unit]',
|
'[Unit]',
|
||||||
'Description=web-terminal host agent (rendezvous relay)',
|
'Description=web-terminal host agent (rendezvous relay)',
|
||||||
@@ -25,6 +88,7 @@ export function buildSystemdUnit(binPath: string, user: string): string {
|
|||||||
'Restart=on-failure',
|
'Restart=on-failure',
|
||||||
'RestartSec=1',
|
'RestartSec=1',
|
||||||
`User=${user}`,
|
`User=${user}`,
|
||||||
|
...environmentLines(options),
|
||||||
'',
|
'',
|
||||||
'[Install]',
|
'[Install]',
|
||||||
'WantedBy=default.target',
|
'WantedBy=default.target',
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
|
|||||||
hostContentSecret: new Uint8Array([1]),
|
hostContentSecret: new Uint8Array([1]),
|
||||||
}),
|
}),
|
||||||
runTunnel: async () => 0,
|
runTunnel: async () => 0,
|
||||||
|
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }),
|
||||||
installService: vi.fn(async () => {}),
|
installService: vi.fn(async () => {}),
|
||||||
uninstallService: vi.fn(async () => {}),
|
uninstallService: vi.fn(async () => {}),
|
||||||
print: (l) => out.push(l),
|
print: (l) => out.push(l),
|
||||||
@@ -88,11 +89,22 @@ describe('runCli (T5)', () => {
|
|||||||
expect(out.join('\n')).not.toContain('PEM')
|
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 install = vi.fn(async () => {})
|
||||||
const { d } = deps({ installService: install })
|
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
|
||||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||||
expect(install).toHaveBeenCalledOnce()
|
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 () => {
|
it('run before pairing fails fast', async () => {
|
||||||
|
|||||||
@@ -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 type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
import {
|
import {
|
||||||
RootRefusedError,
|
RootRefusedError,
|
||||||
|
buildInstallOptions,
|
||||||
detectPlatform,
|
detectPlatform,
|
||||||
installService,
|
installService,
|
||||||
uninstallService,
|
uninstallService,
|
||||||
type InstallDeps,
|
type InstallDeps,
|
||||||
} from '../src/service/install.js'
|
} from '../src/service/install.js'
|
||||||
|
import { buildLaunchdPlist } from '../src/service/launchd.js'
|
||||||
|
import { buildSystemdUnit } from '../src/service/systemd.js'
|
||||||
|
|
||||||
const CFG: AgentConfig = {
|
const CFG: AgentConfig = {
|
||||||
relayUrl: 'wss://relay/agent',
|
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']])
|
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&b<c>d"e'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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { ensureAllowedOrigin, subdomainOrigin, type OriginFsDeps } from '../src/service/originConfig.js'
|
import {
|
||||||
|
DEFAULT_ORIGIN_ZONE,
|
||||||
|
ensureAllowedOrigin,
|
||||||
|
mergeOrigins,
|
||||||
|
subdomainOrigin,
|
||||||
|
type OriginFsDeps,
|
||||||
|
} from '../src/service/originConfig.js'
|
||||||
|
|
||||||
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
||||||
const store = { content: initial }
|
const store = { content: initial }
|
||||||
@@ -41,3 +47,38 @@ describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
|
|||||||
expect(get()).toContain('https://host-42.term.example.com')
|
expect(get()).toContain('https://host-42.term.example.com')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('zone parameterization (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||||
|
it('defaults to the historical `term` zone', () => {
|
||||||
|
expect(DEFAULT_ORIGIN_ZONE).toBe('term')
|
||||||
|
expect(subdomainOrigin('t1', 'yaojia.wang')).toBe('https://t1.term.yaojia.wang')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('composes the `terminal` zone for native-tunnel hosts', () => {
|
||||||
|
expect(subdomainOrigin('t1', 'yaojia.wang', 'terminal')).toBe('https://t1.terminal.yaojia.wang')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ensureAllowedOrigin writes the caller-selected zone', () => {
|
||||||
|
const { fs, get } = memFs('PORT=3000\n')
|
||||||
|
ensureAllowedOrigin(PATH, 't1', 'yaojia.wang', fs, 'terminal')
|
||||||
|
expect(get()).toContain('ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang')
|
||||||
|
expect(get()).not.toContain('.term.yaojia.wang')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeOrigins (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||||
|
it('appends to an empty/undefined value', () => {
|
||||||
|
expect(mergeOrigins(undefined, 'https://a.example.com')).toBe('https://a.example.com')
|
||||||
|
expect(mergeOrigins('', 'https://a.example.com')).toBe('https://a.example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('de-duplicates an origin already present', () => {
|
||||||
|
expect(mergeOrigins('https://a.example.com', 'https://a.example.com')).toBe('https://a.example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends a new origin, trimming whitespace, preserving existing ones', () => {
|
||||||
|
expect(mergeOrigins(' https://a.example.com , https://b.example.com ', 'https://c.example.com')).toBe(
|
||||||
|
'https://a.example.com,https://b.example.com,https://c.example.com',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
233
deploy/README.md
Normal file
233
deploy/README.md
Normal 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
564
docs/ANDROID_CLIENT_PLAN.md
Normal 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 ~3–5 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] (M–L)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
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 2–3 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→M–L)
|
||||||
|
|
||||||
|
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 M–L, 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≈1–2d, M≈3–5d, L≈1–1.5wk, XL≈2–3wk, one engineer-equiv; parallelizable within a wave). Calendar re-baselined per risk review.
|
||||||
|
|
||||||
|
| Wave | Tasks | Effort | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **AW0** foundations | A1–A3 | **S–M** (2–4d) | Serial; unblocks everything. `HostEndpoint` frozen here. |
|
||||||
|
| **AW1** leaf modules + spikes | A4–A13 + S1 + S2 | **XL total, parallel** (~1.5wk wall) | Highest fan-out; 4–5 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 (M–L) | **XL** (~2.5–3wk) | 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 | A19–A29 | **XL total, highly parallel** (~2–2.5wk wall) | File-disjoint by screen → 4–5 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) | **L–XL, mostly parallel** (~1.5wk) | A33 (server FCM) startable day 1; A30/A31 pull forward alongside AW2/AW3 (depend only on AW1). |
|
||||||
|
| **AW6** integration/acceptance | A34–A36 | **M** (~1wk) | E2E vs real Node server + coverage gate + device-QA checklist. |
|
||||||
|
|
||||||
|
**Total:** ≈ **11–14 engineer-weeks** solo; ≈ **7–10 calendar weeks** with 3–5 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 "5–7 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→M–L; calendar re-baselined to ~7–10 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.
|
||||||
@@ -47,6 +47,20 @@
|
|||||||
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**。
|
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**。
|
||||||
- **偏差**: 手建 `control-plane/node_modules/relay-auth` 符号链接以配合新 file: 依赖(`npm install` 会重建)。**阻塞**: 无。
|
- **偏差**: 手建 `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)
|
### ✅ 修复:底部快捷键栏遮挡终端内容(iOS 安全区,2026-07-06,main)
|
||||||
- **现象(用户截图,iPhone)**: 底部快捷键栏(Esc/Esc²/⇧Tab…)压住终端最后一行(Claude Code "bypass permissions" 提示行只露上半)。
|
- **现象(用户截图,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 缺陷。
|
- **根因(`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 缺陷。
|
||||||
|
|||||||
@@ -81,10 +81,23 @@ export function createSession(
|
|||||||
// event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2).
|
// event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2).
|
||||||
const tName = cfg.useTmux ? tmuxName(id) : null;
|
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`
|
// 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 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.
|
// M4: let a spawn failure (e.g. missing shell / tmux) propagate synchronously.
|
||||||
const pty: IPty = spawn(file, args, {
|
const pty: IPty = spawn(file, args, {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const EXPECTED_DEFAULTS: DesktopPrefs = {
|
|||||||
openAtLogin: false,
|
openAtLogin: false,
|
||||||
notifyOnApproval: true,
|
notifyOnApproval: true,
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
|
remoteHosts: [],
|
||||||
|
selectedHostId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('defaultPrefs', () => {
|
describe('defaultPrefs', () => {
|
||||||
@@ -60,6 +62,8 @@ describe('validatePrefs', () => {
|
|||||||
openAtLogin: true,
|
openAtLogin: true,
|
||||||
notifyOnApproval: false,
|
notifyOnApproval: false,
|
||||||
notifyOnStatusChange: false,
|
notifyOnStatusChange: false,
|
||||||
|
remoteHosts: [],
|
||||||
|
selectedHostId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|||||||
66
test/setup/local-storage.ts
Normal file
66
test/setup/local-storage.ts
Normal 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')
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
include: ['test/**/*.test.ts'],
|
include: ['test/**/*.test.ts'],
|
||||||
environment: 'node',
|
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.
|
// Scaffold has no tests yet; don't fail the script until modules add theirs.
|
||||||
passWithNoTests: true,
|
passWithNoTests: true,
|
||||||
// Coverage target is the global 80% rule; enforced with `--coverage`.
|
// Coverage target is the global 80% rule; enforced with `--coverage`.
|
||||||
|
|||||||
Reference in New Issue
Block a user