Compare commits
74 Commits
main
...
1dbed54581
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dbed54581 | ||
|
|
675de771c7 | ||
|
|
7c1d43376d | ||
|
|
0b35dc043f | ||
|
|
c98f5e6a1f | ||
|
|
1e398c7561 | ||
|
|
55d177e9ee | ||
|
|
9f7f5c0c54 | ||
|
|
6e04eb0661 | ||
|
|
af630143de | ||
|
|
10688b0dd1 | ||
|
|
5e427dcf98 | ||
|
|
07bcbf0c08 | ||
|
|
9a5909f672 | ||
|
|
fff011bb7f | ||
|
|
232ef22535 | ||
|
|
ca9eaa8f1f | ||
|
|
bc31de85dd | ||
|
|
469037cb94 | ||
|
|
9683a16f4f | ||
|
|
c81821b890 | ||
|
|
6541246fc9 | ||
|
|
a7eba2d43b | ||
|
|
19f241d7a3 | ||
|
|
552f35c690 | ||
|
|
1dd12b035a | ||
|
|
7551f8a4b2 | ||
|
|
b119c31019 | ||
|
|
3076843e9c | ||
|
|
e062065cd3 | ||
|
|
debf47d99e | ||
|
|
3e49e36806 | ||
|
|
09134e5001 | ||
|
|
8be2b06564 | ||
|
|
e7bfbe951d | ||
|
|
f8f82dce21 | ||
|
|
c6d819f85f | ||
|
|
afc22989d6 | ||
|
|
733c8a8318 | ||
|
|
007e598802 | ||
|
|
cd97114f87 | ||
|
|
5475b661ae | ||
|
|
06814ba276 | ||
|
|
e254918b1c | ||
|
|
542fde9580 | ||
|
|
e7f3bd05f0 | ||
|
|
31054450fc | ||
|
|
4fe1981997 | ||
|
|
7b3fe1b124 | ||
|
|
4ea8f7862a | ||
|
|
cf88e7c588 | ||
|
|
34e4a88059 | ||
|
|
99cafdbdbb | ||
|
|
57725f7ef2 | ||
|
|
fc3b849a08 | ||
|
|
a24465623e | ||
|
|
a25633a63b | ||
|
|
5b9ca321d2 | ||
|
|
cb04516d52 | ||
|
|
d0c249c739 | ||
|
|
bb0949553c | ||
|
|
e38e6d1689 | ||
|
|
5337281e85 | ||
|
|
6b8269c1c1 | ||
|
|
c1c837c54f | ||
|
|
89678c7949 | ||
|
|
7af4a68ef5 | ||
|
|
6efed9772e | ||
|
|
1a8984e851 | ||
|
|
d77f1ff62c | ||
|
|
5e7e4b22f2 | ||
|
|
bfe1be1dfe | ||
|
|
aa1912b962 | ||
|
|
95b9cccf07 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,3 +18,6 @@ npm-debug.log*
|
||||
# test coverage
|
||||
coverage/
|
||||
.gstack/
|
||||
|
||||
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
|
||||
deploy/.env
|
||||
|
||||
@@ -65,6 +65,8 @@ npm test # unit tests (vitest, all modules)
|
||||
|
||||
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
|
||||
|
||||
`WEBTERM_TOKEN` (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. **Unset ⇒ auth disabled**, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16–512 URL/cookie-safe chars (`[A-Za-z0-9._~+/=-]`) or the server refuses to start. Deliver it once via `GET /?token=<t>` (or `POST /auth`), which sets an `HttpOnly; SameSite=Strict; Secure-when-https` cookie the browser auto-sends thereafter; loopback hook ingest (`/hook*`) is exempt so the smart-features side-channel keeps working. **Honest tradeoff:** it is a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See `src/http/auth.ts` and `docs/plans/w5-access-token.md`.
|
||||
|
||||
## Architecture (the parts that span files)
|
||||
|
||||
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.
|
||||
|
||||
14
README.md
14
README.md
@@ -24,6 +24,14 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
|
||||
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
|
||||
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
|
||||
|
||||
### Split-grid watch board (v0.8, desktop)
|
||||
On a large screen (≥ 1024px) the terminal area can split into a grid so several **live, interactive** sessions show at once — built for watching multiple Claude Code runs in parallel. Desktop-only (a 2×2 of terminals is unusable on a phone); the server and wire protocol are untouched, and single-pane mode is unchanged.
|
||||
- **Layouts** — a toolbar toggle cycles **single / 1×2 / 1×3 / 2×2 / 2×3**. The board shows the first N tabs (drag-reorder the tab bar, or drag a tab straight onto a quadrant, to choose which); a dashed **+ New session** tile fills any empty slot. The choice persists.
|
||||
- **Click-to-focus** — the quadrant you click wears a focus ring and owns the keyboard, mobile key-bar, voice, and the approval bar; **Ctrl+`** cycles focus (⇧ reverses). Only the focused pane takes keyboard focus, so panes don't fight over it.
|
||||
- **Inline approve per quadrant** — a background quadrant waiting on a tool permission glows amber and shows its own **✓ / ✗** buttons, so you can clear approvals across several sessions without switching; its OS notification is suppressed while it's on screen.
|
||||
- **Maximize (⛶) / monitor (👁) per quadrant** — ⛶ expands one quadrant to fill the grid (the others stay live behind it); 👁 flips a quadrant to a **read-only preview** (polled screen snapshots — no WebSocket attach, no resize) so watching a session in a small quadrant never shrinks it for another device using it full-screen.
|
||||
- **Resizable splitters + saved presets** — drag the gutters between panes to re-balance column/row sizes (persisted per layout), and save a layout + its split as a named preset to re-apply in one click.
|
||||
|
||||
### Claude Code cockpit
|
||||
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
|
||||
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
|
||||
@@ -107,11 +115,13 @@ npm run setup-hooks # adds the hooks + statusLine to ~/.claude/s
|
||||
```
|
||||
This wires Claude Code's hooks → **live per-tab status**, the **statusLine gauges**, and **push** notifications. The hooks are a no-op outside web-terminal (they only fire when `$WEBTERM_*` env vars are set in spawned shells), so they're safe to leave installed. Then run `claude` inside a tab.
|
||||
|
||||
> **Login shells (why hooks can always find `node`)** — sessions spawn the shell as a **login shell** (`zsh -l`, POSIX only), so it loads your full profile (`~/.zprofile`, `~/.zshrc`, …) and rebuilds `PATH`. Without this, a GUI-launched app (desktop build) or a long-lived tmux keepalive can hand the shell a minimal `PATH`, and hooks that call an nvm-/brew-managed `node` fail with `node: command not found`. If you still hit that on an old session, start a fresh one so it picks up the login-shell `PATH`.
|
||||
|
||||
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
npm test # vitest, all modules (~470 tests, 80% coverage gate)
|
||||
npm test # vitest, all modules (~1600 tests, 80% coverage gate)
|
||||
npm run typecheck # tsc (backend + frontend)
|
||||
npm run build # compile backend to dist/
|
||||
```
|
||||
@@ -198,6 +208,6 @@ The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a r
|
||||
|
||||
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
|
||||
|
||||
Tested with **vitest** (~470 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
|
||||
Tested with **vitest** (~1600 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
|
||||
|
||||
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node\nimport{createRequire as __cjs}from\"node:module\";const require=__cjs(import.meta.url);'",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
@@ -26,6 +27,7 @@
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
|
||||
247
agent/src/certs/nativeRenew.ts
Normal file
247
agent/src/certs/nativeRenew.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Native-tunnel cert auto-renew wiring — TASK A5 (PLAN_ZERO_TOUCH_ROLLOUT).
|
||||
*
|
||||
* The native run-loop (`superviseNative`) used to only MONITOR the frp-client leaf's freshness; the
|
||||
* leaf therefore expired at ~24h and the tunnel dropped until a manual re-pair. This module closes
|
||||
* that gap by driving `createCertRotator`/`renewCert` (crypto is REUSED, never reimplemented):
|
||||
*
|
||||
* - `createMtlsFetch` — the injected `fetchImpl` the rotator hands to `renewCert`. It POSTs /renew
|
||||
* over mTLS presenting the CURRENT keystore leaf (re-read on every call, so the first renewal
|
||||
* after a rotation already authenticates with the freshly issued leaf). mTLS IS the auth — no
|
||||
* token, `rejectUnauthorized` always true (INV4/INV14). The private key stays in-process.
|
||||
* - `wireAutoRenew` — routes the rotator callbacks: rotated → restart frpc onto the new leaf and
|
||||
* log (non-secret); revoked (403) → tear the tunnel down (INV12); error → log + let the rotator
|
||||
* retry with backoff. A failed renewal NEVER crashes the supervisor.
|
||||
* - `startNativeAutoRenew` — the builder `superviseNative` calls: loads the identity, builds the
|
||||
* mTLS fetch + rotator at ~2/3-TTL, and starts it. Returns null (auto-renew disabled) if the host
|
||||
* is not enrolled (no identity), rather than throwing into the run-loop.
|
||||
*/
|
||||
import { request as httpsRequest } from 'node:https'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import type { Logger } from '../log/logger.js'
|
||||
import type { TimerLike } from '../transport/seams.js'
|
||||
import { createBackoff } from '../transport/backoff.js'
|
||||
import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js'
|
||||
import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js'
|
||||
import { createCertRotator, type CertRotator } from './rotation.js'
|
||||
|
||||
/** Non-secret message from an unknown thrown value (never serializes cert/key material). */
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket-idle timeout for a /renew request. A stalled or overloaded control-plane (or a NAT that
|
||||
* silently drops the connection after the TLS handshake) must NOT leave the renewal Promise pending
|
||||
* forever — that would starve the rotator's backoff-retry loop and let the leaf silently expire. On
|
||||
* timeout the request is destroyed and the rejection surfaces through the rotator's onError→backoff.
|
||||
*/
|
||||
export const RENEW_REQUEST_TIMEOUT_MS = 15_000
|
||||
/**
|
||||
* Hard cap on the buffered /renew response body. The reply is a small `{cert,caChain}` JSON; anything
|
||||
* beyond a few KB is malformed or hostile, so we destroy the stream and reject rather than buffer it.
|
||||
*/
|
||||
export const MAX_RENEW_RESPONSE_BYTES = 64 * 1024
|
||||
|
||||
// --- mTLS fetch --------------------------------------------------------------------------------
|
||||
|
||||
/** A single mTLS request the fetch shim delegates to (injectable so the shim is offline-testable). */
|
||||
export interface MtlsRequestInit {
|
||||
readonly method: string
|
||||
readonly headers: Record<string, string>
|
||||
readonly body?: string
|
||||
}
|
||||
export interface MtlsResponse {
|
||||
readonly status: number
|
||||
readonly body: string
|
||||
}
|
||||
export type MtlsRequest = (
|
||||
url: string,
|
||||
tls: TlsClientOptions,
|
||||
init: MtlsRequestInit,
|
||||
) => Promise<MtlsResponse>
|
||||
|
||||
/** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */
|
||||
const defaultMtlsRequest: MtlsRequest = (url, tls, init) =>
|
||||
new Promise<MtlsResponse>((resolve, reject) => {
|
||||
const req = httpsRequest(
|
||||
url,
|
||||
{
|
||||
method: init.method,
|
||||
headers: init.headers,
|
||||
cert: tls.cert,
|
||||
key: tls.key,
|
||||
// ca omitted ⇒ verify the server against the system roots (LE-fronted CP). Present only when a
|
||||
// private CA is pinned (not for /renew).
|
||||
...(tls.ca !== undefined ? { ca: tls.ca } : {}),
|
||||
rejectUnauthorized: tls.rejectUnauthorized, // always true (anti-MITM, INV14)
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
res.on('data', (c: Buffer) => {
|
||||
total += c.length
|
||||
if (total > MAX_RENEW_RESPONSE_BYTES) {
|
||||
res.destroy() // MEDIUM: refuse an unbounded body — a renew reply is a few-KB JSON
|
||||
reject(new Error(`renew response body exceeded ${MAX_RENEW_RESPONSE_BYTES} byte cap`))
|
||||
return
|
||||
}
|
||||
chunks.push(c)
|
||||
})
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
|
||||
)
|
||||
res.on('error', reject) // a mid-stream socket error must reject, not hang
|
||||
},
|
||||
)
|
||||
// HIGH: bound the request so a peer that accepts the connection but never replies rejects (and the
|
||||
// rotator re-enters backoff) instead of pending forever — destroy(err) emits 'error' → reject below.
|
||||
req.setTimeout(RENEW_REQUEST_TIMEOUT_MS, () => {
|
||||
req.destroy(new Error(`renew request timed out after ${RENEW_REQUEST_TIMEOUT_MS}ms`))
|
||||
})
|
||||
req.on('error', reject)
|
||||
if (init.body !== undefined) req.write(init.body)
|
||||
req.end()
|
||||
})
|
||||
|
||||
function toHeaderRecord(headers: RequestInit['headers']): Record<string, string> {
|
||||
if (!headers) return {}
|
||||
if (headers instanceof Headers) {
|
||||
const out: Record<string, string> = {}
|
||||
headers.forEach((v, k) => {
|
||||
out[k] = v
|
||||
})
|
||||
return out
|
||||
}
|
||||
if (Array.isArray(headers)) return Object.fromEntries(headers)
|
||||
return { ...(headers as Record<string, string>) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `fetch`-shaped shim `renewCert` uses. Each call re-reads the CURRENT keystore leaf via
|
||||
* `buildTlsOptions` (which fail-fast throws NotEnrolled/CertExpired — the rotator then logs + retries
|
||||
* with backoff, never crashing) and delegates to the mTLS transport, mapping the result to a real
|
||||
* `Response` (so `res.ok`/`res.status`/`res.json()` behave exactly as `renewCert` expects).
|
||||
*/
|
||||
export function createMtlsFetch(
|
||||
ks: Keystore,
|
||||
opts: { request?: MtlsRequest; certParser?: CertParser } = {},
|
||||
): typeof fetch {
|
||||
const request = opts.request ?? defaultMtlsRequest
|
||||
const shim = async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
// Present the current frp-client leaf (client auth), but verify the /renew SERVER cert against the
|
||||
// SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private
|
||||
// enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent →
|
||||
// node uses the default roots); rejectUnauthorized stays true.
|
||||
const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) })
|
||||
const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized }
|
||||
const reqInit: MtlsRequestInit = {
|
||||
method: init?.method ?? 'GET',
|
||||
headers: toHeaderRecord(init?.headers),
|
||||
...(typeof init?.body === 'string' ? { body: init.body } : {}),
|
||||
}
|
||||
const { status, body } = await request(url, tls, reqInit)
|
||||
return new Response(body, { status })
|
||||
}
|
||||
return shim as typeof fetch
|
||||
}
|
||||
|
||||
// --- rotator wiring ----------------------------------------------------------------------------
|
||||
|
||||
/** Non-secret identifiers logged alongside renew events (INV9). */
|
||||
export interface AutoRenewLogIds {
|
||||
readonly subdomain: string | null
|
||||
readonly hostId: string | null
|
||||
}
|
||||
|
||||
/** The two run-loop effects the rotator drives. */
|
||||
export interface AutoRenewHooks {
|
||||
/** Restart the supervised frpc so it re-reads the rotated cert (a leaf rotation only). */
|
||||
restartChild(): void
|
||||
/** Tear the tunnel down (host revoked ⇒ never reconnect, INV12). */
|
||||
stop(): void
|
||||
}
|
||||
|
||||
/** Handle for the wired auto-renew loop. */
|
||||
export interface AutoRenewController {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a rotator's callbacks to the run-loop and start it. Rotated → restart frpc; revoked → stop;
|
||||
* error → log (non-secret) and let the rotator retry with backoff. Returns a controller that stops
|
||||
* the rotator's scheduled timer.
|
||||
*/
|
||||
export function wireAutoRenew(
|
||||
rotator: CertRotator,
|
||||
hooks: AutoRenewHooks,
|
||||
logger: Logger,
|
||||
ids: AutoRenewLogIds,
|
||||
): AutoRenewController {
|
||||
const meta = { subdomain: ids.subdomain, hostId: ids.hostId }
|
||||
rotator.onRotated(() => {
|
||||
logger.log('info', 'frp-client cert rotated; restarting frpc onto the fresh leaf', meta)
|
||||
hooks.restartChild()
|
||||
})
|
||||
rotator.onRevoked(() => {
|
||||
logger.log('warn', 'frp-client cert renewal refused (host revoked); tearing down tunnel', meta)
|
||||
hooks.stop()
|
||||
})
|
||||
rotator.onError((err) => {
|
||||
logger.log('warn', 'frp-client cert renewal failed; will retry with backoff', {
|
||||
...meta,
|
||||
error: errorMessage(err),
|
||||
})
|
||||
})
|
||||
rotator.start()
|
||||
return { stop: () => rotator.stop() }
|
||||
}
|
||||
|
||||
// --- builder -----------------------------------------------------------------------------------
|
||||
|
||||
/** Injection seams for `startNativeAutoRenew` (all optional; unset ⇒ real transport/timers). */
|
||||
export interface NativeAutoRenewOpts {
|
||||
readonly mtlsRequest?: MtlsRequest
|
||||
readonly certParser?: CertParser
|
||||
readonly timer?: TimerLike
|
||||
readonly renewBeforeMs?: number
|
||||
readonly retryBaseMs?: number
|
||||
readonly now?: () => Date
|
||||
readonly parseCert?: (pem: string) => Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + start native cert auto-renew for `superviseNative`. Renews at ~2/3 of the leaf TTL
|
||||
* (default `DEFAULT_CERT_RENEW_WINDOW_MS`, the same window the health probe alarms on). Returns null
|
||||
* (auto-renew disabled, logged) when the host has no identity — an unenrolled run-loop must not throw.
|
||||
*/
|
||||
export function startNativeAutoRenew(
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
hooks: AutoRenewHooks,
|
||||
logger: Logger,
|
||||
opts: NativeAutoRenewOpts = {},
|
||||
): AutoRenewController | null {
|
||||
const id = ks.loadIdentity()
|
||||
if (id === null) {
|
||||
logger.log('warn', 'no identity in keystore — cert auto-renew disabled', {})
|
||||
return null
|
||||
}
|
||||
const fetchImpl = createMtlsFetch(ks, {
|
||||
...(opts.mtlsRequest ? { request: opts.mtlsRequest } : {}),
|
||||
...(opts.certParser ? { certParser: opts.certParser } : {}),
|
||||
})
|
||||
const rotator = createCertRotator(cfg, id, ks, {
|
||||
fetchImpl,
|
||||
renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS,
|
||||
...(opts.timer ? { timer: opts.timer } : {}),
|
||||
...(opts.now ? { now: opts.now } : {}),
|
||||
...(opts.parseCert ? { parseCert: opts.parseCert } : {}),
|
||||
...(opts.retryBaseMs !== undefined
|
||||
? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) }
|
||||
: {}),
|
||||
})
|
||||
return wireAutoRenew(rotator, hooks, logger, { subdomain: cfg.subdomain, hostId: cfg.hostId })
|
||||
}
|
||||
33
agent/src/certs/pem.ts
Normal file
33
agent/src/certs/pem.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* PEM helpers shared by the native enroll (enroll/pair.ts) and renew (certs/rotation.ts) paths.
|
||||
*
|
||||
* The control-plane returns the frp-client leaf + CA chain as base64-encoded DER (cert as a string,
|
||||
* caChain as a string[]); the keystore + frpc need PEM files. `derBase64ToPem` wraps a base64 DER body
|
||||
* back into CERTIFICATE armor at 64 columns.
|
||||
*/
|
||||
|
||||
/** base64(DER) → PEM (CERTIFICATE armor, 64-col wrapped). */
|
||||
export function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string {
|
||||
const body = derBase64.replace(/\s+/g, '')
|
||||
const lines = body.match(/.{1,64}/g) ?? []
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a control-plane cert response ({cert: base64 DER, caChain: base64 DER[]}) to PEM strings
|
||||
* for the keystore. Throws if the shape is wrong. Shared by enroll + renew so both stay in lockstep.
|
||||
*/
|
||||
export function certResponseToPem(cert: unknown, caChain: unknown): { certPem: string; caChainPem: string } {
|
||||
if (
|
||||
typeof cert !== 'string' ||
|
||||
!Array.isArray(caChain) ||
|
||||
caChain.length === 0 ||
|
||||
!caChain.every((c) => typeof c === 'string')
|
||||
) {
|
||||
throw new Error('cert response missing cert/caChain')
|
||||
}
|
||||
return {
|
||||
certPem: derBase64ToPem(cert),
|
||||
caChainPem: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import type { TimerLike } from '../transport/seams.js'
|
||||
import { createBackoff, type BackoffPolicy } from '../transport/backoff.js'
|
||||
import { buildCsr } from '../enroll/csr.js'
|
||||
import { certResponseToPem } from './pem.js'
|
||||
|
||||
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
|
||||
|
||||
@@ -22,6 +24,8 @@ export interface CertRotator {
|
||||
stop(): void
|
||||
onRotated(cb: () => void): void
|
||||
onRevoked(cb: () => void): void
|
||||
/** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */
|
||||
onError(cb: (err: unknown) => void): void
|
||||
}
|
||||
|
||||
export type RenewOutcome = 'rotated' | 'revoked'
|
||||
@@ -61,11 +65,11 @@ export async function renewCert(
|
||||
})
|
||||
if (res.status === 403) return 'revoked'
|
||||
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
||||
const json = (await res.json()) as { cert?: string; caChain?: string }
|
||||
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') {
|
||||
throw new Error('cert renewal response missing cert/caChain')
|
||||
}
|
||||
ks.saveCert(json.cert, json.caChain) // atomic whole-file install
|
||||
// The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the
|
||||
// keystore + frpc (same shape as native enroll).
|
||||
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
|
||||
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
|
||||
ks.saveCert(certPem, caChainPem) // atomic whole-file install
|
||||
return 'rotated'
|
||||
}
|
||||
|
||||
@@ -79,6 +83,8 @@ export function createCertRotator(
|
||||
fetchImpl?: typeof fetch
|
||||
now?: () => Date
|
||||
parseCert?: (pem: string) => Date
|
||||
/** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */
|
||||
retryBackoff?: BackoffPolicy
|
||||
} = {},
|
||||
): CertRotator {
|
||||
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
||||
@@ -91,9 +97,11 @@ export function createCertRotator(
|
||||
}
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const now = opts.now ?? (() => new Date())
|
||||
const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true })
|
||||
let handle: unknown = null
|
||||
let rotatedCb: (() => void) | null = null
|
||||
let revokedCb: (() => void) | null = null
|
||||
let errorCb: ((err: unknown) => void) | null = null
|
||||
|
||||
function schedule(): void {
|
||||
const certs = ks.loadCert()
|
||||
@@ -109,12 +117,16 @@ export function createCertRotator(
|
||||
revokedCb?.()
|
||||
return
|
||||
}
|
||||
retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle
|
||||
rotatedCb?.()
|
||||
schedule()
|
||||
})
|
||||
.catch(() => {
|
||||
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile.
|
||||
handle = timer.setTimeout(runRenewal, renewBeforeMs)
|
||||
.catch((err: unknown) => {
|
||||
// Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry
|
||||
// with backoff. The cert is still valid until expiry, so the tunnel stays up meanwhile — a
|
||||
// failed renewal must NEVER tear the supervisor down.
|
||||
errorCb?.(err)
|
||||
handle = timer.setTimeout(runRenewal, retryBackoff.nextDelayMs())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,5 +144,8 @@ export function createCertRotator(
|
||||
onRevoked(cb): void {
|
||||
revokedCb = cb
|
||||
},
|
||||
onError(cb): void {
|
||||
errorCb = cb
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
/**
|
||||
* CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`.
|
||||
* All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO.
|
||||
* CLI entrypoint — PLAN_RELAY_AGENT T5, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
|
||||
* `pair | run | status | install | uninstall`. All side effects (network/FS/tunnel/provision) are
|
||||
* injected via `CliDeps` so `runCli` stays pure and offline-testable.
|
||||
*
|
||||
* `pair <CODE> --install` is the NATIVE zero-touch onboard: P-256 keygen (FIX H-host-2) → CSR →
|
||||
* POST /enroll → provision the pinned frpc binary → write frpc.toml → install BOTH units (base-app
|
||||
* + agent, base-app env routed to the base-app unit; the agent unit supervises frpc — NOT the old
|
||||
* relay `runTunnel` rendezvous) → print `https://<sub>.terminal.<domain>`.
|
||||
*
|
||||
* `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
|
||||
*/
|
||||
import type { AgentConfig } from './config/agentConfig.js'
|
||||
import type { AgentIdentity } from './keys/identity.js'
|
||||
import type { Keystore } from './keys/keystore.js'
|
||||
import { assertNativeZone, type InstallOptions } from './service/install.js'
|
||||
import { subdomainOrigin } from './service/originConfig.js'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
|
||||
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||||
@@ -24,13 +33,41 @@ export class CliUsageError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The non-secret enrollment result the native onboard needs (host id + assigned subdomain). */
|
||||
export interface NativeEnrollResult {
|
||||
readonly hostId: string
|
||||
readonly subdomain: string
|
||||
}
|
||||
|
||||
export interface CliDeps {
|
||||
loadConfig(): AgentConfig
|
||||
openKeystore(stateDir: string): Keystore
|
||||
/** Ed25519 identity for the legacy relay path. */
|
||||
generateIdentity(): AgentIdentity
|
||||
/** P-256 identity for the native frp-client key (FIX H-host-2); private key never leaves the host. */
|
||||
generateP256Identity(): AgentIdentity
|
||||
/** Legacy relay redemption (Ed25519, E2E rendezvous). */
|
||||
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
|
||||
/** Native enroll: build the P-256 CSR, POST /enroll, store the returned cert; return ids only. */
|
||||
enrollNative(
|
||||
cfg: AgentConfig,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
): Promise<NativeEnrollResult>
|
||||
/** Download + verify + place the pinned frpc binary (B3); returns its path. */
|
||||
provisionFrpc(cfg: AgentConfig): Promise<string>
|
||||
/** Write the native `frpc.toml` for `subdomain` (base-app env is routed via installService). */
|
||||
writeFrpcConfig(cfg: AgentConfig, subdomain: string): void
|
||||
/** True iff a written native `frpc.toml` exists in `cfg.stateDir` (native-onboard signal). */
|
||||
nativeConfigExists(cfg: AgentConfig): boolean
|
||||
/** Legacy relay run-loop (Ed25519 WS rendezvous, T10 backoff + T9 heartbeat). */
|
||||
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||
installService(cfg: AgentConfig): Promise<void>
|
||||
/** Native run-loop: supervise the pinned frpc child (restart-on-exit backoff + health probe). */
|
||||
superviseFrpc(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||
/** Resolve per-host install inputs (S0 env incl. loopback BIND_HOST, tunnel origin) from env/flags. */
|
||||
resolveInstallOptions(): InstallOptions
|
||||
installService(cfg: AgentConfig, options: InstallOptions): Promise<void>
|
||||
uninstallService(): Promise<void>
|
||||
print(line: string): void
|
||||
}
|
||||
@@ -60,23 +97,60 @@ export function parseArgs(argv: readonly string[]): CliArgs {
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Native zero-touch onboard for `pair <CODE> --install` (B5). Order is load-bearing: keygen(P-256)
|
||||
* → enroll (CSR + POST /enroll) → provision frpc (so the binary exists before the agent unit
|
||||
* starts) → write frpc.toml → install BOTH units (which start them) → print the tunnel URL.
|
||||
*/
|
||||
async function pairInstallNative(
|
||||
code: string,
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
deps: CliDeps,
|
||||
): Promise<number> {
|
||||
const options = deps.resolveInstallOptions()
|
||||
if (!options.domain) {
|
||||
throw new CliUsageError(
|
||||
'native install requires TUNNEL_DOMAIN — the origin is https://<sub>.terminal.<domain>',
|
||||
)
|
||||
}
|
||||
assertNativeZone(options.zone) // FIX L-host-zone: native ⇒ `terminal`
|
||||
|
||||
const id = deps.generateP256Identity() // keygen (P-256, FIX H-host-2)
|
||||
ks.saveIdentity(id)
|
||||
const enroll = await deps.enrollNative(cfg, code, id, ks) // CSR → POST /enroll → store cert
|
||||
await deps.provisionFrpc(cfg) // pinned frpc binary on disk before the service starts (B3)
|
||||
deps.writeFrpcConfig(cfg, enroll.subdomain) // frpc.toml
|
||||
await deps.installService(cfg, options) // base-app + agent units; base-app env routed → started
|
||||
|
||||
deps.print(subdomainOrigin(enroll.subdomain, options.domain, options.zone))
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
|
||||
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
||||
const cfg = deps.loadConfig()
|
||||
const ks = deps.openKeystore(cfg.stateDir)
|
||||
switch (args.command) {
|
||||
case 'pair': {
|
||||
if (args.flags['install']) return pairInstallNative(args.code!, cfg, ks, deps)
|
||||
// Legacy relay pair (Ed25519 rendezvous, no install).
|
||||
const id = ks.loadIdentity() ?? deps.generateIdentity()
|
||||
ks.saveIdentity(id)
|
||||
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
||||
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
||||
if (args.flags['install']) await deps.installService(cfg)
|
||||
return 0
|
||||
}
|
||||
case 'run': {
|
||||
if (ks.loadIdentity() === null || ks.loadCert() === null) {
|
||||
const id = ks.loadIdentity()
|
||||
if (id === null || ks.loadCert() === null) {
|
||||
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
|
||||
}
|
||||
// Native onboard = a P-256 frp-client identity + a written frpc.toml. Supervise frpc as a
|
||||
// child (restart-on-exit backoff + health probe) instead of the legacy Ed25519 relay tunnel.
|
||||
if (id.alg === 'p256' && deps.nativeConfigExists(cfg)) {
|
||||
return deps.superviseFrpc(cfg, ks)
|
||||
}
|
||||
return deps.runTunnel(cfg, ks)
|
||||
}
|
||||
case 'status': {
|
||||
@@ -88,7 +162,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
case 'install':
|
||||
await deps.installService(cfg)
|
||||
await deps.installService(cfg, deps.resolveInstallOptions())
|
||||
return 0
|
||||
case 'uninstall':
|
||||
await deps.uninstallService()
|
||||
|
||||
252
agent/src/cli/deps.ts
Normal file
252
agent/src/cli/deps.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* CliDeps factory — PLAN_RELAY_PHASE1 C2, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
|
||||
* Wires the abstract `CliDeps` seams (consumed by `runCli`) to their real implementations: env-driven
|
||||
* config, the on-disk keystore, identity generation (Ed25519 + P-256), §4.5 pairing redemption, the
|
||||
* native enroll + frpc provisioning + frpc.toml writer, the supervised tunnel, and the two-unit OS
|
||||
* service install. All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable.
|
||||
*/
|
||||
import { execFile } from 'node:child_process'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, userInfo } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { CliDeps, NativeEnrollResult } from '../cli.js'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import { loadAgentConfig } from '../config/agentConfig.js'
|
||||
import { openKeystore } from '../keys/keystore.js'
|
||||
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { redeemPairingCode } from '../enroll/pair.js'
|
||||
import { runTunnel } from '../transport/runTunnel.js'
|
||||
import { buildNativeFrpcToml } from '../transport/frpcToml.js'
|
||||
import { superviseFrpc } from '../transport/frpSupervise.js'
|
||||
import { provisionFrpc } from '../provision/frpcBinary.js'
|
||||
import { startNativeAutoRenew } from '../certs/nativeRenew.js'
|
||||
import {
|
||||
probeLoopbackBaseApp,
|
||||
renderHealthStatus,
|
||||
runHealthProbe,
|
||||
startHealthMonitor,
|
||||
} from '../health/probe.js'
|
||||
import { createLogger } from '../log/logger.js'
|
||||
import { ensureAllowedOrigin } from '../service/originConfig.js'
|
||||
import {
|
||||
buildInstallOptions,
|
||||
detectPlatform,
|
||||
NATIVE_ORIGIN_ZONE,
|
||||
installService as installServiceUnit,
|
||||
uninstallService as uninstallServiceUnit,
|
||||
type InstallDeps,
|
||||
type ServicePlatform,
|
||||
} from '../service/install.js'
|
||||
|
||||
/** Keystore file names in `stateDir` (kept in lockstep with `keys/keystore.ts`). */
|
||||
const KEYSTORE_CERT = 'agent.cert.pem'
|
||||
const KEYSTORE_KEY = 'agent.key.pem'
|
||||
const KEYSTORE_CA = 'agent.ca.pem'
|
||||
const FRPC_TOML = 'frpc.toml'
|
||||
const FRPC_LOG = 'frpc.log'
|
||||
const BASE_APP_ENV_FILE = 'base-app.env'
|
||||
const DEFAULT_LOCAL_PORT = 3000
|
||||
|
||||
/** Resolve this process's own executable path (the bundled `dist/cli.js`) for the service unit. */
|
||||
function selfBinPath(): string {
|
||||
return fileURLToPath(import.meta.url)
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, args: readonly string[]): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
execFile(cmd, [...args], (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
function realInstallDeps(): InstallDeps {
|
||||
return {
|
||||
writeFile: (path, content) => {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content)
|
||||
},
|
||||
runCommand,
|
||||
getuid: () => (typeof process.getuid === 'function' ? process.getuid() : 0),
|
||||
homedir,
|
||||
username: () => userInfo().username,
|
||||
binPath: selfBinPath,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map the current OS to its service manager, or fail fast with a clear message. */
|
||||
function requirePlatform(): ServicePlatform {
|
||||
const platform = detectPlatform(process.platform)
|
||||
if (platform === null) {
|
||||
throw new Error(`service install/uninstall is unsupported on platform '${process.platform}'`)
|
||||
}
|
||||
return platform
|
||||
}
|
||||
|
||||
/** Parse a positive-integer PORT from the env (falls back to the base-app default 3000). */
|
||||
function resolveLocalPort(): number {
|
||||
const raw = process.env.PORT
|
||||
const port = raw ? Number.parseInt(raw, 10) : NaN
|
||||
return Number.isInteger(port) && port > 0 ? port : DEFAULT_LOCAL_PORT
|
||||
}
|
||||
|
||||
/** Native enroll: build the P-256 CSR + POST /enroll (via the frozen redeem flow), store the cert. */
|
||||
async function enrollNative(
|
||||
cfg: AgentConfig,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
): Promise<NativeEnrollResult> {
|
||||
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true })
|
||||
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the native `frpc.toml` into `stateDir`, pointing frpc at this host's keystore cert/key/CA and
|
||||
* the loopback base app. Also materializes the base-app ALLOWED_ORIGINS env file. The frps shared
|
||||
* token comes from `FRP_AUTH_TOKEN` (deploy secret; never logged). NOTE: the frpc binary run/e2e is
|
||||
* pending the B3 tar.gz extraction; this seam only emits the config.
|
||||
*/
|
||||
function writeFrpcConfig(cfg: AgentConfig, subdomain: string): void {
|
||||
const domain = process.env.TUNNEL_DOMAIN
|
||||
const toml = buildNativeFrpcToml({
|
||||
subdomain,
|
||||
localPort: resolveLocalPort(),
|
||||
authToken: process.env.FRP_AUTH_TOKEN ?? '',
|
||||
certFile: join(cfg.stateDir, KEYSTORE_CERT),
|
||||
keyFile: join(cfg.stateDir, KEYSTORE_KEY),
|
||||
trustedCaFile: join(cfg.stateDir, KEYSTORE_CA),
|
||||
})
|
||||
mkdirSync(cfg.stateDir, { recursive: true })
|
||||
writeFileSync(join(cfg.stateDir, FRPC_TOML), toml, { mode: 0o600 })
|
||||
if (domain) {
|
||||
ensureAllowedOrigin(join(cfg.stateDir, BASE_APP_ENV_FILE), subdomain, domain, undefined, NATIVE_ORIGIN_ZONE)
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored frp-client leaf's `notAfter`, or null if no cert/parse failure (non-secret metadata). */
|
||||
function certNotAfter(ks: Keystore): Date | null {
|
||||
const cert = ks.loadCert()
|
||||
if (cert === null) return null
|
||||
try {
|
||||
return new X509Certificate(cert.certPem).validToDate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single frpc log path in `stateDir`. Used by BOTH the supervisor's file-logging spawn (writer)
|
||||
* and `readFrpcLog` (reader) so the health probe can never scan a different file than frpc writes.
|
||||
*/
|
||||
export function frpcLogPath(stateDir: string): string {
|
||||
return join(stateDir, FRPC_LOG)
|
||||
}
|
||||
|
||||
/** Read the accumulated frpc log (empty string if not yet written) for the proxy-started scan. */
|
||||
export function readFrpcLog(stateDir: string): string {
|
||||
const path = frpcLogPath(stateDir)
|
||||
if (!existsSync(path)) return ''
|
||||
try {
|
||||
return readFileSync(path, 'utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native run-loop (B4/H4 + A5): supervise the pinned frpc child with restart-on-exit backoff while a
|
||||
* periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring)
|
||||
* logs NON-SECRET status only (INV9), AND auto-renew the frp-client leaf at ~2/3 TTL so the tunnel
|
||||
* never drops on cert expiry (A5): a successful renewal restarts frpc onto the fresh leaf, a 403
|
||||
* revoke tears the tunnel down, and a failed renewal retries with backoff without crashing the
|
||||
* supervisor. Resolves when the supervisor stops (SIGTERM/SIGINT).
|
||||
*/
|
||||
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
||||
const logger = createLogger('info')
|
||||
const binPath = join(cfg.stateDir, 'bin', 'frpc')
|
||||
const tomlPath = join(cfg.stateDir, FRPC_TOML)
|
||||
// Tee the frpc child's stdout/stderr into `<stateDir>/frpc.log` (the SAME path `readFrpcLog`
|
||||
// scans below) so the proxy-started health sub-check has real content — without this wiring the
|
||||
// log stays empty and `HealthReport.healthy` can never be true (B4/H4 goal).
|
||||
const handle = superviseFrpc(binPath, tomlPath, { logger, logFile: frpcLogPath(cfg.stateDir) })
|
||||
const port = resolveLocalPort()
|
||||
const monitor = startHealthMonitor(
|
||||
() =>
|
||||
runHealthProbe({
|
||||
isFrpcAlive: () => handle.isChildAlive(),
|
||||
probeBaseApp: () => probeLoopbackBaseApp(port, (url) => fetch(url)),
|
||||
readFrpcLog: () => readFrpcLog(cfg.stateDir),
|
||||
certNotAfter: () => certNotAfter(ks),
|
||||
now: () => new Date(),
|
||||
}),
|
||||
(report) => {
|
||||
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
|
||||
const ids = { subdomain: cfg.subdomain, hostId: cfg.hostId, certNotAfter: certNotAfter(ks) }
|
||||
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
|
||||
},
|
||||
)
|
||||
// A5: silently renew the leaf before it expires. Restart frpc onto the fresh cert on rotation;
|
||||
// stop the whole supervisor on a 403 revoke (INV12). Null ⇒ unenrolled (auto-renew disabled).
|
||||
const autoRenew = startNativeAutoRenew(
|
||||
cfg,
|
||||
ks,
|
||||
{
|
||||
restartChild: () => handle.restartChild(),
|
||||
stop: () => {
|
||||
void handle.stop()
|
||||
},
|
||||
},
|
||||
logger,
|
||||
)
|
||||
const onSignal = (): void => {
|
||||
void handle.stop()
|
||||
}
|
||||
process.once('SIGTERM', onSignal)
|
||||
process.once('SIGINT', onSignal)
|
||||
return handle.done.finally(() => {
|
||||
monitor.stop()
|
||||
autoRenew?.stop()
|
||||
})
|
||||
}
|
||||
|
||||
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
||||
export function createCliDeps(): CliDeps {
|
||||
return {
|
||||
loadConfig: () => loadAgentConfig(process.env),
|
||||
openKeystore: (stateDir) => openKeystore(stateDir),
|
||||
generateIdentity: () => generateIdentity(),
|
||||
generateP256Identity: () => generateP256Identity(),
|
||||
redeem: (cfg: AgentConfig, code, id, ks) => redeemPairingCode(cfg.enrollUrl, code, id, ks),
|
||||
enrollNative: (cfg, code, id, ks) => enrollNative(cfg, code, id, ks),
|
||||
provisionFrpc: async (cfg) => {
|
||||
const result = await provisionFrpc({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
binDir: join(cfg.stateDir, 'bin'),
|
||||
})
|
||||
return result.binPath
|
||||
},
|
||||
writeFrpcConfig: (cfg, subdomain) => writeFrpcConfig(cfg, subdomain),
|
||||
nativeConfigExists: (cfg) => existsSync(join(cfg.stateDir, FRPC_TOML)),
|
||||
superviseFrpc: (cfg, ks) => superviseNative(cfg, ks),
|
||||
runTunnel: async (cfg, ks) => {
|
||||
const handle = await runTunnel(cfg, ks)
|
||||
const onSignal = (): void => {
|
||||
void handle.stop()
|
||||
}
|
||||
process.once('SIGTERM', onSignal)
|
||||
process.once('SIGINT', onSignal)
|
||||
return handle.done
|
||||
},
|
||||
resolveInstallOptions: () => buildInstallOptions(process.env),
|
||||
installService: (cfg, options) =>
|
||||
installServiceUnit(cfg, requirePlatform(), realInstallDeps(), options),
|
||||
uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }),
|
||||
print: (line) => {
|
||||
process.stdout.write(`${line}\n`)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
||||
|
||||
export interface AgentConfig {
|
||||
readonly relayUrl: string
|
||||
@@ -19,9 +20,12 @@ export interface AgentConfig {
|
||||
readonly hostId: string | null
|
||||
}
|
||||
|
||||
const LOOPBACK_HOSTNAMES: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
|
||||
|
||||
/** True iff `url` is ws:// to a loopback host (127.0.0.0/8, localhost, or ::1). */
|
||||
/**
|
||||
* True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or
|
||||
* ::1). Uses the shared strict check so a crafted suffixed hostname such as
|
||||
* `ws://127.0.0.1.attacker.example.com:3000` — which the outbound dial would DNS-resolve and connect
|
||||
* to wherever it points — is REJECTED, closing the anti-SSRF bypass (not merely `startsWith('127.')`).
|
||||
*/
|
||||
export function isLoopbackWsUrl(url: string): boolean {
|
||||
let parsed: URL
|
||||
try {
|
||||
@@ -30,8 +34,7 @@ export function isLoopbackWsUrl(url: string): boolean {
|
||||
return false
|
||||
}
|
||||
if (parsed.protocol !== 'ws:') return false
|
||||
const host = parsed.hostname
|
||||
return LOOPBACK_HOSTNAMES.includes(host) || host.startsWith('127.')
|
||||
return isLoopbackHostLiteral(parsed.hostname)
|
||||
}
|
||||
|
||||
function hasScheme(url: string, scheme: string): boolean {
|
||||
|
||||
@@ -52,9 +52,11 @@ const OID = 0x06
|
||||
const UTF8_STRING = 0x0c
|
||||
const CONTEXT_0 = 0xa0
|
||||
|
||||
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
|
||||
// OID 2.5.4.3 (commonName), 1.3.101.112 (Ed25519), 1.2.840.10045.4.3.2 (ecdsa-with-SHA256) as
|
||||
// pre-encoded DER value bytes.
|
||||
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
|
||||
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
|
||||
const OID_ECDSA_WITH_SHA256 = Uint8Array.from([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
|
||||
|
||||
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
|
||||
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||
@@ -63,6 +65,24 @@ function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||
return tlv(SEQUENCE, concat([algId, pubBits]))
|
||||
}
|
||||
|
||||
/**
|
||||
* SubjectPublicKeyInfo bytes for the CSR. For P-256, `id.publicKey` IS already the full EC SPKI DER
|
||||
* (built by `keys/identity.ts`), so it is embedded verbatim; for Ed25519 the raw 32-byte key is
|
||||
* wrapped into the fixed SPKI structure.
|
||||
*/
|
||||
function spkiFor(id: AgentIdentity): Uint8Array {
|
||||
return id.alg === 'p256' ? id.publicKey : spkiFromRawEd25519(id.publicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* The signatureAlgorithm AlgorithmIdentifier: `SEQUENCE { OID }` (no parameters — RFC 5758 §3.2 for
|
||||
* ecdsa-with-SHA256, and Ed25519 likewise omits parameters).
|
||||
*/
|
||||
function sigAlgFor(id: AgentIdentity): Uint8Array {
|
||||
const oid = id.alg === 'p256' ? OID_ECDSA_WITH_SHA256 : OID_ED25519
|
||||
return tlv(SEQUENCE, tlv(OID, oid))
|
||||
}
|
||||
|
||||
/** X.501 Name with a single CN=<subject> RDN. */
|
||||
function nameFromCn(cn: string): Uint8Array {
|
||||
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||
@@ -77,18 +97,20 @@ function toPem(der: Uint8Array, label: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
|
||||
* The private key is used in-process only; never serialized into the output (INV4).
|
||||
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the identity's key. The
|
||||
* Ed25519 and P-256 (ecdsa-with-SHA256, FIX H-host-2) paths share this one encoder — only the SPKI
|
||||
* and the signatureAlgorithm differ. The private key is used in-process only; never serialized into
|
||||
* the output (INV4).
|
||||
*/
|
||||
export function buildCsr(id: AgentIdentity, subject: string): string {
|
||||
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||
const name = nameFromCn(subject)
|
||||
const spki = spkiFromRawEd25519(id.publicKey)
|
||||
const spki = spkiFor(id)
|
||||
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
||||
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
||||
|
||||
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
|
||||
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const signature = id.sign(requestInfo) // over CertificationRequestInfo, per id.alg
|
||||
const sigAlg = sigAlgFor(id)
|
||||
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||
|
||||
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { EnrollResult } from 'relay-contracts'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { buildCsr } from './csr.js'
|
||||
import { derBase64ToPem } from '../certs/pem.js'
|
||||
|
||||
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
||||
export type EnrollMode = 'token' | 'ed25519'
|
||||
@@ -53,6 +54,12 @@ export interface RedeemOptions {
|
||||
readonly agentToken?: string
|
||||
readonly unwrapContentSecret?: UnwrapContentSecret
|
||||
readonly subject?: string
|
||||
/**
|
||||
* Native frp-client enroll has NO E2E content secret — the control-plane returns
|
||||
* `hostContentSecret: null`. When true, tolerate its absence (skip unwrap + storage); the plain
|
||||
* frpc byte tunnel needs no content key. Defaults false so the legacy relay path still requires it.
|
||||
*/
|
||||
readonly allowMissingContentSecret?: boolean
|
||||
}
|
||||
|
||||
interface EnrollResponseJson {
|
||||
@@ -63,11 +70,33 @@ interface EnrollResponseJson {
|
||||
hostContentSecret: string // base64url over the wire
|
||||
}
|
||||
|
||||
function parseEnrollResult(json: unknown): EnrollResult {
|
||||
function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult {
|
||||
const j = json as Partial<EnrollResponseJson>
|
||||
if (typeof j.hostContentSecret !== 'string') {
|
||||
if (!allowMissingContentSecret) {
|
||||
throw new EnrollError('enroll response missing hostContentSecret')
|
||||
}
|
||||
// Native frp-client enroll: cert = base64(DER) string, caChain = base64(DER) string[], no content
|
||||
// key. The keystore + frpc need PEM, so convert here. Empty secret sentinel is never stored.
|
||||
const caChain: unknown = j.caChain
|
||||
if (
|
||||
typeof j.hostId !== 'string' ||
|
||||
typeof j.subdomain !== 'string' ||
|
||||
typeof j.cert !== 'string' ||
|
||||
!Array.isArray(caChain) ||
|
||||
caChain.length === 0 ||
|
||||
!caChain.every((c) => typeof c === 'string')
|
||||
) {
|
||||
throw new EnrollError('enroll response missing required fields')
|
||||
}
|
||||
return {
|
||||
hostId: j.hostId,
|
||||
subdomain: j.subdomain,
|
||||
cert: derBase64ToPem(j.cert),
|
||||
caChain: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
|
||||
hostContentSecret: new Uint8Array(0),
|
||||
}
|
||||
}
|
||||
const candidate = {
|
||||
hostId: j.hostId,
|
||||
subdomain: j.subdomain,
|
||||
@@ -128,10 +157,13 @@ export async function redeemPairingCode(
|
||||
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
const enroll = parseEnrollResult(json)
|
||||
const enroll = parseEnrollResult(json, opts.allowMissingContentSecret ?? false)
|
||||
ks.saveCert(enroll.cert, enroll.caChain)
|
||||
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
||||
// Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store.
|
||||
if (enroll.hostContentSecret.length > 0) {
|
||||
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||
ks.saveContentSecret(unwrapped)
|
||||
}
|
||||
return enroll
|
||||
}
|
||||
|
||||
182
agent/src/health/probe.ts
Normal file
182
agent/src/health/probe.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Native-tunnel health probe — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2 / §5, INV9).
|
||||
*
|
||||
* Reports four independent sub-checks that together say whether this host's native frp tunnel is
|
||||
* actually serving:
|
||||
* (a) frpcAlive — the supervised frpc child process is running;
|
||||
* (b) baseAppReachable — the loopback base app answers `GET http://127.0.0.1:PORT` (loopback ONLY);
|
||||
* (c) proxyStarted — frpc logged a "start proxy success" line (control channel + proxy up);
|
||||
* (d) certFresh — the frp-client leaf's `notAfter` is beyond the renewal window (not expiring).
|
||||
*
|
||||
* Every side effect is an INJECTABLE seam (process-liveness checker, loopback HTTP probe, log
|
||||
* scanner, clock) so the logic is pure and offline-testable. The status renderer emits ONLY
|
||||
* non-secret identifiers (subdomain, host id, cert expiry date, boolean flags) — NEVER keys, certs,
|
||||
* tokens, or CSRs (INV9). No `console.log`.
|
||||
*/
|
||||
|
||||
/** Default renewal window: 8h — one third of the 24h host frp-client TTL (renew at ~2/3 TTL). */
|
||||
export const DEFAULT_CERT_RENEW_WINDOW_MS = 8 * 60 * 60 * 1000
|
||||
|
||||
/** frpc emits this line on the control channel once a proxy is registered and forwarding. */
|
||||
export const FRPC_START_SUCCESS_RE = /start proxy success/i
|
||||
|
||||
/** Loopback host the base-app probe targets — hardcoded so the probe can never reach off-host. */
|
||||
export const LOOPBACK_PROBE_HOST = '127.0.0.1'
|
||||
|
||||
const MIN_PORT = 1
|
||||
const MAX_PORT = 65535
|
||||
|
||||
/** The four independent sub-checks plus the derived overall verdict. */
|
||||
export interface HealthReport {
|
||||
/** The supervised frpc child is running. */
|
||||
readonly frpcAlive: boolean
|
||||
/** `GET http://127.0.0.1:PORT` returned a response (base app is up on loopback). */
|
||||
readonly baseAppReachable: boolean
|
||||
/** frpc logged "start proxy success" (the tunnel proxy is registered). */
|
||||
readonly proxyStarted: boolean
|
||||
/** The frp-client cert's `notAfter` is beyond the renewal window (not near expiry). */
|
||||
readonly certFresh: boolean
|
||||
/** True iff all four sub-checks pass. */
|
||||
readonly healthy: boolean
|
||||
}
|
||||
|
||||
/** Injectable side effects the probe consumes; each is independently faked in tests. */
|
||||
export interface HealthProbeSeams {
|
||||
/** Process-liveness checker (the supervisor exposes whether its frpc child is alive). */
|
||||
readonly isFrpcAlive: () => boolean
|
||||
/** Loopback HTTP probe of the base app; resolves true iff it answered ok. */
|
||||
readonly probeBaseApp: () => Promise<boolean>
|
||||
/** Returns the accumulated frpc stdout/log text to scan for the success line. */
|
||||
readonly readFrpcLog: () => string
|
||||
/** The stored frp-client leaf's `notAfter`, or null if no cert is available. */
|
||||
readonly certNotAfter: () => Date | null
|
||||
/** Current time (injected for deterministic expiry tests). */
|
||||
readonly now: () => Date
|
||||
}
|
||||
|
||||
export interface HealthProbeConfig {
|
||||
/** Certs within this many ms of `notAfter` are "near expiry" (default 8h). */
|
||||
readonly renewWindowMs?: number
|
||||
}
|
||||
|
||||
/** Pure: true iff the frpc log contains a "start proxy success" line. */
|
||||
export function frpcProxyStarted(logText: string): boolean {
|
||||
return FRPC_START_SUCCESS_RE.test(logText)
|
||||
}
|
||||
|
||||
/** Pure: true iff `notAfter` is strictly beyond the renewal window from `now` (not near expiry). */
|
||||
export function certIsFresh(notAfter: Date | null, now: Date, renewWindowMs: number): boolean {
|
||||
if (notAfter === null) return false
|
||||
return notAfter.getTime() - now.getTime() > renewWindowMs
|
||||
}
|
||||
|
||||
/** Minimal shape of a fetch response the loopback probe cares about. */
|
||||
export interface LoopbackResponse {
|
||||
readonly ok: boolean
|
||||
}
|
||||
|
||||
/** Injectable loopback HTTP fetch (real wiring passes global `fetch`). */
|
||||
export type LoopbackFetch = (url: string) => Promise<LoopbackResponse>
|
||||
|
||||
/**
|
||||
* Probe the loopback base app with `GET http://127.0.0.1:PORT/`. The host is hardcoded loopback, so
|
||||
* the probe can never reach an off-host target (anti-SSRF). Any thrown/rejected fetch — or a
|
||||
* non-integer/out-of-range port — resolves to `false` rather than propagating (a probe never throws).
|
||||
*/
|
||||
export async function probeLoopbackBaseApp(port: number, fetchImpl: LoopbackFetch): Promise<boolean> {
|
||||
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) return false
|
||||
const url = `http://${LOOPBACK_PROBE_HOST}:${port}/`
|
||||
try {
|
||||
const res = await fetchImpl(url)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Run all four sub-checks and derive the overall verdict. Never throws (each seam is guarded). */
|
||||
export async function runHealthProbe(
|
||||
seams: HealthProbeSeams,
|
||||
config: HealthProbeConfig = {},
|
||||
): Promise<HealthReport> {
|
||||
const renewWindowMs = config.renewWindowMs ?? DEFAULT_CERT_RENEW_WINDOW_MS
|
||||
const frpcAlive = seams.isFrpcAlive()
|
||||
const baseAppReachable = await seams.probeBaseApp()
|
||||
const proxyStarted = frpcProxyStarted(seams.readFrpcLog())
|
||||
const certFresh = certIsFresh(seams.certNotAfter(), seams.now(), renewWindowMs)
|
||||
const healthy = frpcAlive && baseAppReachable && proxyStarted && certFresh
|
||||
return { frpcAlive, baseAppReachable, proxyStarted, certFresh, healthy }
|
||||
}
|
||||
|
||||
/** Non-secret identifiers safe to print in `status` (INV9): NO key/cert/token/CSR material. */
|
||||
export interface StatusIdentifiers {
|
||||
readonly subdomain: string | null
|
||||
readonly hostId: string | null
|
||||
readonly certNotAfter: Date | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `status` lines from non-secret identifiers + a health report (INV9). Emits the subdomain,
|
||||
* host id, cert EXPIRY DATE (never the cert bytes), and the boolean sub-check flags — never any key,
|
||||
* cert, token, or CSR material.
|
||||
*/
|
||||
export function renderHealthStatus(
|
||||
ids: StatusIdentifiers,
|
||||
report: HealthReport,
|
||||
): readonly string[] {
|
||||
return [
|
||||
`subdomain: ${ids.subdomain ?? '(none)'}`,
|
||||
`host_id: ${ids.hostId ?? '(none)'}`,
|
||||
`cert_expiry: ${ids.certNotAfter ? ids.certNotAfter.toISOString() : '(unknown)'}`,
|
||||
`frpc_alive: ${report.frpcAlive}`,
|
||||
`base_app_reachable: ${report.baseAppReachable}`,
|
||||
`proxy_started: ${report.proxyStarted}`,
|
||||
`cert_fresh: ${report.certFresh}`,
|
||||
`healthy: ${report.healthy}`,
|
||||
]
|
||||
}
|
||||
|
||||
/** Default periodic health-monitor interval (30s). */
|
||||
export const DEFAULT_HEALTH_INTERVAL_MS = 30_000
|
||||
|
||||
/** Minimal injectable interval timer (fake-timer-testable). */
|
||||
export interface IntervalTimer {
|
||||
setInterval(cb: () => void, ms: number): unknown
|
||||
clearInterval(handle: unknown): void
|
||||
}
|
||||
|
||||
const realIntervalTimer: IntervalTimer = {
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
|
||||
/** Handle to a running health monitor. */
|
||||
export interface HealthMonitor {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a periodic health monitor: every `intervalMs`, run `probe()` and hand the report to
|
||||
* `onReport` (the run-loop wires this to a redacting logger — non-secret lines only). A rejected
|
||||
* probe is swallowed (a monitor must never crash the supervisor). `stop()` clears the interval.
|
||||
*/
|
||||
export function startHealthMonitor(
|
||||
probe: () => Promise<HealthReport>,
|
||||
onReport: (report: HealthReport) => void,
|
||||
opts: { intervalMs?: number; timer?: IntervalTimer } = {},
|
||||
): HealthMonitor {
|
||||
const intervalMs = opts.intervalMs ?? DEFAULT_HEALTH_INTERVAL_MS
|
||||
const timer = opts.timer ?? realIntervalTimer
|
||||
const handle = timer.setInterval(() => {
|
||||
void probe()
|
||||
.then(onReport)
|
||||
.catch(() => {
|
||||
/* a probe failure is itself an unhealthy signal; never let it crash the monitor */
|
||||
})
|
||||
}, intervalMs)
|
||||
return {
|
||||
stop(): void {
|
||||
timer.clearInterval(handle)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,37 @@
|
||||
/**
|
||||
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
|
||||
*
|
||||
* Ed25519 keypair generated locally with node:crypto. `AgentIdentity` exposes ONLY the public
|
||||
* key, the §4.2 enroll fingerprint, and an in-process `sign()`; there is NO API that returns or
|
||||
* serializes the private key. The raw private key material is held in a module-private closure.
|
||||
* Two key algorithms share one `AgentIdentity` shape (discriminated by `alg`):
|
||||
* - `ed25519` — the relay E2E rendezvous path (unchanged).
|
||||
* - `p256` — the native-tunnel HOST frp-client key (FIX H-host-2): the `frp-client-CA` is P-256,
|
||||
* so the host key, its CSR (ECDSA-with-SHA256), and its leaf are all P-256.
|
||||
* `AgentIdentity` exposes ONLY the public key, the §4.2 enroll fingerprint, and an in-process
|
||||
* `sign()`; there is NO API that returns or serializes the private key. The raw private key material
|
||||
* is held in a module-private closure and, for P-256, NEVER leaves the host (only the pubkey + CSR do).
|
||||
*/
|
||||
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
|
||||
import type { KeyObject } from 'node:crypto'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
|
||||
/** Which key algorithm an identity carries. Drives CSR SPKI + signatureAlgorithm encoding. */
|
||||
export type KeyAlg = 'ed25519' | 'p256'
|
||||
|
||||
export interface AgentIdentity {
|
||||
/** Ed25519 raw 32-byte public key → stored in host registry (§4.2 agent_pubkey). */
|
||||
/** Which key algorithm this identity uses (`ed25519` = relay path, `p256` = native frp-client). */
|
||||
readonly alg: KeyAlg
|
||||
/**
|
||||
* The registry-stored public key bytes (§4.2 agent_pubkey):
|
||||
* - `ed25519`: the raw 32-byte public key;
|
||||
* - `p256`: the EC SubjectPublicKeyInfo DER (what the control-plane P-256 gate compares).
|
||||
*/
|
||||
readonly publicKey: Uint8Array
|
||||
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
|
||||
readonly enrollFpr: string
|
||||
/** Ed25519 signature over `message`, using the in-process private key. Never returns the key. */
|
||||
/**
|
||||
* Signature over `message` using the in-process private key (never returns the key):
|
||||
* - `ed25519`: a raw 64-byte Ed25519 signature;
|
||||
* - `p256`: a DER `ECDSA-Sig-Value` (ecdsa-with-SHA256) — the PKCS#10 signatureValue shape.
|
||||
*/
|
||||
sign(message: Uint8Array): Uint8Array
|
||||
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
|
||||
exportPrivatePkcs8Pem(): string
|
||||
@@ -39,6 +56,7 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
|
||||
const rawPub = rawPublicKey(publicKey)
|
||||
const enrollFpr = computeEnrollFpr(rawPub)
|
||||
return {
|
||||
alg: 'ed25519',
|
||||
publicKey: rawPub,
|
||||
enrollFpr,
|
||||
sign(message: Uint8Array): Uint8Array {
|
||||
@@ -53,19 +71,61 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P-256 identity (FIX H-host-2). `publicKey` is the EC SubjectPublicKeyInfo DER (the exact bytes the
|
||||
* control-plane frp-client gate compares against the registry); `sign` produces a DER `ECDSA-Sig-Value`
|
||||
* over the SHA-256 digest — the PKCS#10 / X.509 signatureValue shape. The private key never leaves
|
||||
* this closure (INV4); only the SPKI + CSR are emitted off-host.
|
||||
*/
|
||||
function buildP256Identity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
|
||||
const spkiDer = new Uint8Array(publicKey.export({ type: 'spki', format: 'der' }))
|
||||
const enrollFpr = computeEnrollFpr(spkiDer)
|
||||
return {
|
||||
alg: 'p256',
|
||||
publicKey: spkiDer,
|
||||
enrollFpr,
|
||||
sign(message: Uint8Array): Uint8Array {
|
||||
// ecdsa-with-SHA256 → DER ECDSA-Sig-Value (node's default dsaEncoding is 'der').
|
||||
return new Uint8Array(sign('sha256', message, privateKey))
|
||||
},
|
||||
exportPrivatePkcs8Pem(): string {
|
||||
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
},
|
||||
privateKeyObject(): KeyObject {
|
||||
return privateKey
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
|
||||
export function generateIdentity(): AgentIdentity {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
return buildIdentity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Reconstruct an identity from a stored PKCS#8 PEM private key (keystore load path). */
|
||||
/**
|
||||
* Generate a fresh EC P-256 identity for the native-tunnel host frp-client key (FIX H-host-2). The
|
||||
* private key is held in-memory only (INV4) and NEVER serialized off-host; only the pubkey + CSR leave.
|
||||
*/
|
||||
export function generateP256Identity(): AgentIdentity {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
return buildP256Identity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Reconstruct an Ed25519 identity from a stored PKCS#8 PEM private key (keystore load path). */
|
||||
export function identityFromPrivatePem(pem: string): AgentIdentity {
|
||||
const privateKey = createPrivateKey(pem)
|
||||
const publicKey = createPublicKey(privateKey)
|
||||
return buildIdentity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Reconstruct a P-256 identity from a stored PKCS#8 PEM private key (keystore load path). */
|
||||
export function p256IdentityFromPrivatePem(pem: string): AgentIdentity {
|
||||
const privateKey = createPrivateKey(pem)
|
||||
const publicKey = createPublicKey(privateKey)
|
||||
return buildP256Identity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
|
||||
export function verifySignature(
|
||||
publicKey: Uint8Array,
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createPrivateKey } from 'node:crypto'
|
||||
import type { AgentIdentity } from './identity.js'
|
||||
import { identityFromPrivatePem } from './identity.js'
|
||||
import { identityFromPrivatePem, p256IdentityFromPrivatePem } from './identity.js'
|
||||
|
||||
const SECRET_MODE = 0o600
|
||||
const DIR_MODE = 0o700
|
||||
@@ -54,6 +55,32 @@ function ensureDir(stateDir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct an `AgentIdentity` from a stored PKCS#8 PEM, branching on the key's algorithm
|
||||
* discriminant (the PKCS#8 AlgorithmIdentifier OID, surfaced as `asymmetricKeyType`): an Ed25519
|
||||
* key → the relay-path builder; an EC P-256 key → the native frp-client builder (EC SPKI publicKey).
|
||||
* A saved P-256 identity therefore round-trips as `alg: 'p256'` with a usable signing key, while
|
||||
* Ed25519 stays byte-identical. Any other algorithm is a hard error (never silently mislabeled).
|
||||
*/
|
||||
function identityFromStoredPem(pem: string): AgentIdentity {
|
||||
const key = createPrivateKey(pem)
|
||||
const alg = key.asymmetricKeyType
|
||||
if (alg === 'ed25519') return identityFromPrivatePem(pem)
|
||||
if (alg === 'ec') {
|
||||
// An `ec` key alone is not proof of P-256 — a P-384/secp256k1 key also reports `ec`. The native
|
||||
// frp-client path is P-256 ONLY, so assert the named curve before treating it as such; any other
|
||||
// curve is a hard error (never silently mislabeled as a usable P-256 identity).
|
||||
const curve = key.asymmetricKeyDetails?.namedCurve
|
||||
if (curve !== 'prime256v1') {
|
||||
throw new Error(
|
||||
`unsupported stored EC identity curve: ${curve ?? 'unknown'} (only prime256v1/P-256 is supported)`,
|
||||
)
|
||||
}
|
||||
return p256IdentityFromPrivatePem(pem)
|
||||
}
|
||||
throw new Error(`unsupported stored identity key algorithm: ${alg ?? 'unknown'}`)
|
||||
}
|
||||
|
||||
function writeSecret(path: string, data: string | Uint8Array): void {
|
||||
writeFileSync(path, data, { mode: SECRET_MODE })
|
||||
// Enforce 0600 even if a prior umask/file left it wider.
|
||||
@@ -80,7 +107,7 @@ export function openKeystore(stateDir: string): Keystore {
|
||||
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
|
||||
}
|
||||
try {
|
||||
return identityFromPrivatePem(pem)
|
||||
return identityFromStoredPem(pem)
|
||||
} catch (err) {
|
||||
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
30
agent/src/main.ts
Normal file
30
agent/src/main.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* CLI bootstrap — PLAN_RELAY_PHASE1 C2. The `dist/cli.js` entrypoint (the `#!/usr/bin/env node`
|
||||
* shebang is prepended at build time via esbuild `--banner`, NOT here). Reads argv, builds the
|
||||
* real CliDeps, dispatches through `runCli`, and maps any error to a clean stderr line + exit code
|
||||
* (usage errors ⇒ 2, everything else ⇒ 1) so no invocation ever crashes with a raw stack trace.
|
||||
*/
|
||||
import { parseArgs, runCli, CliUsageError } from './cli.js'
|
||||
import { createCliDeps } from './cli/deps.js'
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const argv = process.argv.slice(2)
|
||||
const deps = createCliDeps()
|
||||
try {
|
||||
return await runCli(parseArgs(argv), deps)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`web-terminal-agent: ${message}\n`)
|
||||
return err instanceof CliUsageError ? 2 : 1
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then((code) => {
|
||||
process.exitCode = code
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`web-terminal-agent: fatal ${message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
29
agent/src/net/loopbackLiteral.ts
Normal file
29
agent/src/net/loopbackLiteral.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Strict loopback-literal check — the single source of truth for both S-GATE-style guards:
|
||||
* - `service/install.ts` isLoopbackBindHost (BIND_HOST S-GATE, FIX C-host-1, CRITICAL)
|
||||
* - `config/agentConfig.ts` isLoopbackWsUrl (localTargetUrl anti-SSRF)
|
||||
*
|
||||
* Both previously used `value.startsWith('127.')`, which treats an arbitrary suffixed HOSTNAME
|
||||
* such as `127.0.0.1.attacker.example.com` or `127.evil.net` as loopback. Because Node resolves
|
||||
* non-literal hosts via DNS before bind()/dial, that prefix match let a crafted value defeat the
|
||||
* exact invariant the guard exists to enforce. This module fails closed: it accepts a value ONLY
|
||||
* when it is an EXACT loopback literal — `localhost`, `::1`/`[::1]`, or a fully-parsed IPv4 address
|
||||
* in 127.0.0.0/8 with NO trailing characters. Anything else (a hostname, a partial IP, `0.0.0.0`,
|
||||
* `::`) is rejected.
|
||||
*/
|
||||
import { isIPv4 } from 'node:net'
|
||||
|
||||
/** Non-IPv4 loopback literals accepted verbatim (localhost + the IPv6 loopback, with/without brackets). */
|
||||
const LOOPBACK_LITERALS: ReadonlySet<string> = new Set(['localhost', '::1', '[::1]'])
|
||||
|
||||
/**
|
||||
* True iff `host` is an EXACT loopback literal: `localhost`, `::1`, `[::1]`, or a well-formed IPv4
|
||||
* address in 127.0.0.0/8 (the whole string must parse as a dotted-quad — no trailing label). A
|
||||
* suffixed hostname like `127.0.0.1.attacker.example.com` is NOT loopback and returns false.
|
||||
*/
|
||||
export function isLoopbackHostLiteral(host: string): boolean {
|
||||
if (LOOPBACK_LITERALS.has(host)) return true
|
||||
// isIPv4 requires the FULL string to be a dotted-quad, so no trailing hostname label can slip
|
||||
// through; the first-octet check then confines it to the 127.0.0.0/8 loopback block.
|
||||
return isIPv4(host) && host.split('.')[0] === '127'
|
||||
}
|
||||
259
agent/src/provision/frpcBinary.ts
Normal file
259
agent/src/provision/frpcBinary.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Pinned frpc binary provisioner — TASK B3 (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
|
||||
*
|
||||
* Mirrors the `dist/buildBinary.ts` verify-download discipline: detect OS/arch, select a PINNED
|
||||
* release `{ version, url, sha256 }`, download the artifact TO DISK (a temp file), verify its
|
||||
* SHA-256 against the pin BEFORE the binary is placed or executed, then place it atomically
|
||||
* (temp -> rename) with the exec bit. On hash mismatch NOTHING is placed and the temp is removed.
|
||||
*
|
||||
* Non-negotiable (§5): never `curl | sh` a streamed secret, never disable TLS verification (no `-k`)
|
||||
* — the default fetch enforces `https:` and there is no insecure escape hatch.
|
||||
*
|
||||
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
|
||||
* a host cannot exec a `.tar.gz`. So AFTER the archive hash matches its pin (and only then) the bytes
|
||||
* are handed to the path-traversal-hardened extractor in `untar.ts`, which pulls out ONLY the inner
|
||||
* `frpc`; that verified binary is what gets placed. Extraction failure (no `frpc`, corrupt gzip/tar,
|
||||
* unsafe entry name, oversize) places nothing and throws `FrpcProvisionError`.
|
||||
*
|
||||
* The network fetch and the filesystem are INJECTABLE seams (`ProvisionFrpcDeps`) so tests exercise
|
||||
* URL/arch selection, hash-match extraction+placement, hash-mismatch rejection, extraction failures,
|
||||
* and unsupported-platform errors with no network access.
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { extractFrpcBinary } from './untar.js'
|
||||
|
||||
export type FrpcPlatform = 'darwin-arm64' | 'darwin-amd64' | 'linux-arm64' | 'linux-amd64'
|
||||
|
||||
export const FRPC_PLATFORMS: readonly FrpcPlatform[] = [
|
||||
'darwin-arm64',
|
||||
'darwin-amd64',
|
||||
'linux-arm64',
|
||||
'linux-amd64',
|
||||
]
|
||||
|
||||
/** A pinned frpc release artifact for one platform. `sha256` is lowercase hex over the artifact. */
|
||||
export interface FrpcReleaseRef {
|
||||
readonly version: string
|
||||
readonly url: string
|
||||
readonly sha256: string
|
||||
}
|
||||
|
||||
const FRPC_VERSION = '0.61.1'
|
||||
const RELEASE_BASE = `https://github.com/fatedier/frp/releases/download/v${FRPC_VERSION}`
|
||||
|
||||
/**
|
||||
* The pinned release map. URLs point at the real frp v0.61.1 assets and the `sha256` fields are the
|
||||
* REAL published digests from `frp_sha256_checksums.txt` (verified against the downloaded
|
||||
* darwin_arm64 archive on 2026-07-09). To bump frp: update `FRPC_VERSION` and paste the new digests
|
||||
* from that release's checksum file. Tests inject their own release map.
|
||||
*/
|
||||
export const FRPC_RELEASES: Readonly<Record<FrpcPlatform, FrpcReleaseRef>> = {
|
||||
'darwin-arm64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_arm64.tar.gz`,
|
||||
sha256: '3e65f13a17a284bd6013e6bb6856bc2720074cea6094cc446c1f4c3932154c2d',
|
||||
},
|
||||
'darwin-amd64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_amd64.tar.gz`,
|
||||
sha256: '403a0ee5e92f083a863d984b7af1e9d70ba2aaa28e87f42f1fe085adf76b8491',
|
||||
},
|
||||
'linux-arm64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_arm64.tar.gz`,
|
||||
sha256: 'af6366f2b43920ebfe6235dba6060770399ed1fb18601e5818552bd46a7621f8',
|
||||
},
|
||||
'linux-amd64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_amd64.tar.gz`,
|
||||
sha256: 'bff260b68ca7b1461182a46c4f34e9709ba32764eed30a15dd94ac97f50a2c40',
|
||||
},
|
||||
}
|
||||
|
||||
/** Node `os.arch()` values mapped to frp's arch tokens. */
|
||||
const ARCH_MAP: Readonly<Record<string, 'arm64' | 'amd64'>> = {
|
||||
arm64: 'arm64',
|
||||
x64: 'amd64',
|
||||
}
|
||||
const SUPPORTED_OS: ReadonlySet<string> = new Set(['darwin', 'linux'])
|
||||
|
||||
const BIN_NAME = 'frpc'
|
||||
const TMP_NAME = 'frpc.download.tmp'
|
||||
const EXEC_MODE = 0o755
|
||||
const DIR_MODE = 0o700
|
||||
|
||||
/** Map `os.platform()` + `os.arch()` to a supported `FrpcPlatform`, or `null` if unsupported. */
|
||||
export function detectFrpcPlatform(platform: string, arch: string): FrpcPlatform | null {
|
||||
const mappedArch = ARCH_MAP[arch]
|
||||
if (!SUPPORTED_OS.has(platform) || mappedArch === undefined) return null
|
||||
const key = `${platform}-${mappedArch}` as FrpcPlatform
|
||||
return FRPC_PLATFORMS.includes(key) ? key : null
|
||||
}
|
||||
|
||||
/** Injectable network fetch: returns the artifact bytes for `url`. */
|
||||
export type FrpcFetch = (url: string) => Promise<Uint8Array>
|
||||
|
||||
/** Injectable filesystem seam (all async, mirrors `node:fs/promises`). */
|
||||
export interface FrpcFsDeps {
|
||||
mkdir(dir: string): Promise<void>
|
||||
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||
rename(from: string, to: string): Promise<void>
|
||||
chmod(path: string, mode: number): Promise<void>
|
||||
rm(path: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface ProvisionFrpcDeps {
|
||||
readonly fetch: FrpcFetch
|
||||
readonly fs: FrpcFsDeps
|
||||
/** Override the digest function (default: node:crypto SHA-256, lowercase hex). */
|
||||
readonly computeSha256?: (data: Uint8Array) => string
|
||||
}
|
||||
|
||||
export interface ProvisionFrpcOptions {
|
||||
/** `os.platform()` (e.g. `'darwin'`, `'linux'`). */
|
||||
readonly platform: string
|
||||
/** `os.arch()` (e.g. `'arm64'`, `'x64'`). */
|
||||
readonly arch: string
|
||||
/** Directory the verified `frpc` binary is placed into. */
|
||||
readonly binDir: string
|
||||
/** Release map to select from; defaults to the pinned `FRPC_RELEASES`. */
|
||||
readonly releases?: Readonly<Record<FrpcPlatform, FrpcReleaseRef>>
|
||||
}
|
||||
|
||||
export interface ProvisionResult {
|
||||
readonly binPath: string
|
||||
readonly version: string
|
||||
readonly platform: FrpcPlatform
|
||||
}
|
||||
|
||||
/** A verify-download failure (unsupported platform, fetch error, or integrity mismatch). */
|
||||
export class FrpcProvisionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FrpcProvisionError'
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSha256(data: Uint8Array): string {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
/** Default HTTPS fetch — enforces `https:` (never `-k`, never plain http) and a 2xx status. */
|
||||
async function defaultFetch(url: string): Promise<Uint8Array> {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
throw new FrpcProvisionError(`invalid frpc download URL: ${url}`)
|
||||
}
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new FrpcProvisionError(`frpc download refuses non-https URL: ${url}`)
|
||||
}
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url)
|
||||
} catch (err: unknown) {
|
||||
// Surface transport failures (DNS, refused, TLS) through the SAME typed error as every other
|
||||
// failure path, so callers (e.g. the autoupdate rollback path) can pattern-match uniformly.
|
||||
throw new FrpcProvisionError(
|
||||
`frpc download failed: ${err instanceof Error ? err.message : 'network error'} for ${url}`,
|
||||
)
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new FrpcProvisionError(`frpc download failed: HTTP ${res.status} for ${url}`)
|
||||
}
|
||||
return new Uint8Array(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
const defaultFsDeps: FrpcFsDeps = {
|
||||
mkdir: async (dir) => {
|
||||
await mkdir(dir, { recursive: true, mode: DIR_MODE })
|
||||
},
|
||||
writeFile: async (path, data) => {
|
||||
await writeFile(path, data, { mode: 0o600 })
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
await rename(from, to)
|
||||
},
|
||||
chmod: async (path, mode) => {
|
||||
await chmod(path, mode)
|
||||
},
|
||||
rm: async (path) => {
|
||||
await rm(path, { force: true })
|
||||
},
|
||||
}
|
||||
|
||||
function defaultDeps(): ProvisionFrpcDeps {
|
||||
return { fetch: defaultFetch, fs: defaultFsDeps, computeSha256: defaultSha256 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Download + verify + extract + place the pinned frpc binary for the current platform.
|
||||
*
|
||||
* Order (verify-before-extract-before-exec): detect platform -> select pin -> fetch archive ->
|
||||
* write the unverified archive to a per-invocation temp -> SHA-256 verify against the pin. On
|
||||
* mismatch: remove the temp and throw (no extraction, nothing placed). On match: gunzip+untar to
|
||||
* pull ONLY the inner `frpc` (path-traversal-safe), overwrite the temp with that verified binary,
|
||||
* chmod exec, and atomic-rename into place. Any extraction failure removes the temp and throws.
|
||||
* Nothing is ever placed or made executable before the digest matches the pin.
|
||||
*/
|
||||
export async function provisionFrpc(
|
||||
opts: ProvisionFrpcOptions,
|
||||
deps: ProvisionFrpcDeps = defaultDeps(),
|
||||
): Promise<ProvisionResult> {
|
||||
const platform = detectFrpcPlatform(opts.platform, opts.arch)
|
||||
if (platform === null) {
|
||||
throw new FrpcProvisionError(
|
||||
`unsupported platform for frpc: ${opts.platform}/${opts.arch} (supported: ${FRPC_PLATFORMS.join(', ')})`,
|
||||
)
|
||||
}
|
||||
|
||||
const releases = opts.releases ?? FRPC_RELEASES
|
||||
const release = releases[platform]
|
||||
if (release === undefined) {
|
||||
throw new FrpcProvisionError(`no pinned frpc release for platform ${platform}`)
|
||||
}
|
||||
|
||||
const computeSha256 = deps.computeSha256 ?? defaultSha256
|
||||
// Per-invocation-unique temp path: two concurrent provisions (e.g. overlapping autoupdate polls)
|
||||
// must never write/rename through the same temp file (TOCTOU). Same path is used for write+rm+rename.
|
||||
const tmpPath = join(opts.binDir, `${TMP_NAME}.${process.pid}.${randomBytes(6).toString('hex')}`)
|
||||
const binPath = join(opts.binDir, BIN_NAME)
|
||||
|
||||
const bytes = await deps.fetch(release.url)
|
||||
|
||||
await deps.fs.mkdir(opts.binDir)
|
||||
await deps.fs.writeFile(tmpPath, bytes)
|
||||
|
||||
const digest = computeSha256(bytes).toLowerCase()
|
||||
const expected = release.sha256.toLowerCase()
|
||||
if (digest !== expected) {
|
||||
await deps.fs.rm(tmpPath)
|
||||
throw new FrpcProvisionError(
|
||||
`frpc SHA-256 mismatch for ${platform}: expected ${expected}, got ${digest} — nothing placed`,
|
||||
)
|
||||
}
|
||||
|
||||
// Verified — extract ONLY the inner `frpc` from the archive. The extractor selects by basename and
|
||||
// rejects any unsafe entry name, so nothing derived from the archive can escape binDir. On any
|
||||
// extraction failure the (still-archive) temp is removed and nothing is placed.
|
||||
let frpcBytes: Uint8Array
|
||||
try {
|
||||
frpcBytes = extractFrpcBinary(bytes)
|
||||
} catch (err: unknown) {
|
||||
await deps.fs.rm(tmpPath)
|
||||
const detail = err instanceof Error ? err.message : 'unknown error'
|
||||
throw new FrpcProvisionError(
|
||||
`frpc extraction failed for ${platform}: ${detail} — nothing placed`,
|
||||
)
|
||||
}
|
||||
|
||||
// Overwrite the temp with the verified extracted binary, grant exec, then promote atomically.
|
||||
await deps.fs.writeFile(tmpPath, frpcBytes)
|
||||
await deps.fs.chmod(tmpPath, EXEC_MODE)
|
||||
await deps.fs.rename(tmpPath, binPath)
|
||||
|
||||
return { binPath, version: release.version, platform }
|
||||
}
|
||||
159
agent/src/provision/untar.ts
Normal file
159
agent/src/provision/untar.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Minimal, path-traversal-hardened tar extractor for the frp release archive — TASK B3 extraction
|
||||
* step (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
|
||||
*
|
||||
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
|
||||
* the host cannot exec a `.tar.gz`, so after `frpcBinary.ts` has VERIFIED the archive's SHA-256
|
||||
* against its pin it hands the bytes here to pull out ONLY the inner `frpc`.
|
||||
*
|
||||
* SECURITY (§5): this does NOT reconstruct the archive's directory tree on disk. The caller writes
|
||||
* the returned bytes to a FIXED destination it owns; entries are selected purely by matching the
|
||||
* (sanitized) tar name's BASENAME. Any candidate entry whose name is absolute, contains a NUL, or
|
||||
* has a `..` path segment is REJECTED (never silently skipped, so a `../frpc` decoy cannot be used
|
||||
* to derive a path). A malicious tarball can therefore never cause a write outside the caller's dir.
|
||||
*
|
||||
* Format scope: real frp archives are plain USTAR (magic `ustar `) with 100-byte names (no PAX/GNU
|
||||
* long-name/sparse entries) — verified against frp v0.61.1. The parser reads only what USTAR needs:
|
||||
* name (0..100), size octal (124..136), typeflag (156); regular files are typeflag `0` or legacy NUL.
|
||||
*/
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
|
||||
const TAR_BLOCK = 512
|
||||
const NAME_OFFSET = 0
|
||||
const NAME_LEN = 100
|
||||
const SIZE_OFFSET = 124
|
||||
const SIZE_LEN = 12
|
||||
const TYPEFLAG_OFFSET = 156
|
||||
|
||||
// USTAR regular-file type flags: '0' (0x30) and the legacy NUL (0x00). Everything else (dir '5',
|
||||
// symlink '2', PAX 'x'/'g', GNU 'L', …) is not a plain file and is skipped when matching.
|
||||
const TYPE_REGULAR = 0x30
|
||||
const TYPE_LEGACY = 0x00
|
||||
|
||||
// Hard ceiling on a single extracted entry. frp's `frpc` is ~14 MB; 256 MB rejects a corrupt/hostile
|
||||
// size field before it can drive a huge allocation or an out-of-bounds read.
|
||||
const MAX_ENTRY_BYTES = 256 * 1024 * 1024
|
||||
|
||||
const FRPC_BASENAME = 'frpc'
|
||||
|
||||
/** A tar/gzip extraction failure (corrupt gzip, malformed/truncated tar, unsafe or missing entry). */
|
||||
export class TarExtractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'TarExtractError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `name` could escape a destination directory: contains a NUL, is an absolute path, or has
|
||||
* any `..` path segment. Both `/` and `\` are treated as separators (defense in depth).
|
||||
*/
|
||||
function isUnsafeEntryName(name: string): boolean {
|
||||
if (name.length === 0) return true
|
||||
if (name.includes('\0')) return true
|
||||
if (name.startsWith('/') || name.startsWith('\\')) return true
|
||||
return name.split(/[/\\]/).some((segment) => segment === '..')
|
||||
}
|
||||
|
||||
/** Last `/`- or `\`-separated segment of a tar entry name. */
|
||||
function basename(name: string): string {
|
||||
const parts = name.split(/[/\\]/)
|
||||
return parts[parts.length - 1] ?? name
|
||||
}
|
||||
|
||||
/** Decode the NUL-terminated USTAR name field of the header block at `off`. */
|
||||
function readName(tar: Uint8Array, off: number): string {
|
||||
const raw = tar.subarray(off + NAME_OFFSET, off + NAME_OFFSET + NAME_LEN)
|
||||
const nul = raw.indexOf(0)
|
||||
return new TextDecoder().decode(raw.subarray(0, nul < 0 ? NAME_LEN : nul))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a NUL/space-terminated octal numeric field. Leading spaces are tolerated (GNU pads them);
|
||||
* any non-octal digit yields `NaN` so the caller can reject a corrupt header.
|
||||
*/
|
||||
function readOctal(tar: Uint8Array, start: number, len: number): number {
|
||||
let value = 0
|
||||
let seenDigit = false
|
||||
for (let i = start; i < start + len; i++) {
|
||||
const c = tar[i]
|
||||
if (c === undefined || c === 0x00 || c === 0x20) {
|
||||
if (seenDigit) break
|
||||
continue
|
||||
}
|
||||
if (c < 0x30 || c > 0x37) return Number.NaN
|
||||
value = value * 8 + (c - 0x30)
|
||||
seenDigit = true
|
||||
}
|
||||
return seenDigit ? value : 0
|
||||
}
|
||||
|
||||
/** True if the 512-byte header block at `off` is entirely zero (the end-of-archive marker). */
|
||||
function isZeroBlock(tar: Uint8Array, off: number): boolean {
|
||||
for (let i = off; i < off + TAR_BLOCK; i++) {
|
||||
if (tar[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the bytes of the FIRST regular-file entry whose safe basename equals `targetBasename`.
|
||||
*
|
||||
* Selection is by basename only — the archive's directory structure is never mapped to a path. A
|
||||
* candidate whose name is unsafe (absolute / NUL / `..`) throws rather than being used. Missing
|
||||
* entry, a truncated/corrupt tar, or an over-size entry all throw `TarExtractError` (place nothing).
|
||||
*/
|
||||
export function extractTarFileByBasename(tar: Uint8Array, targetBasename: string): Uint8Array {
|
||||
let off = 0
|
||||
while (off + TAR_BLOCK <= tar.length) {
|
||||
if (isZeroBlock(tar, off)) break // end-of-archive
|
||||
|
||||
const size = readOctal(tar, off + SIZE_OFFSET, SIZE_LEN)
|
||||
if (!Number.isInteger(size) || size < 0) {
|
||||
throw new TarExtractError('corrupt tar: invalid entry size field')
|
||||
}
|
||||
if (size > MAX_ENTRY_BYTES) {
|
||||
throw new TarExtractError(`tar entry exceeds max size (${size} > ${MAX_ENTRY_BYTES})`)
|
||||
}
|
||||
|
||||
const contentStart = off + TAR_BLOCK
|
||||
const contentEnd = contentStart + size
|
||||
if (contentEnd > tar.length) {
|
||||
throw new TarExtractError('corrupt tar: entry content is truncated')
|
||||
}
|
||||
|
||||
const typeflag = tar[off + TYPEFLAG_OFFSET]
|
||||
const isRegular = typeflag === TYPE_REGULAR || typeflag === TYPE_LEGACY
|
||||
if (isRegular) {
|
||||
const name = readName(tar, off)
|
||||
if (basename(name) === targetBasename) {
|
||||
if (isUnsafeEntryName(name)) {
|
||||
throw new TarExtractError(`refusing unsafe tar entry name: ${JSON.stringify(name)}`)
|
||||
}
|
||||
return tar.slice(contentStart, contentEnd) // copy — never a view into the archive buffer
|
||||
}
|
||||
}
|
||||
|
||||
// Advance past this entry's content, padded up to the next 512-byte boundary.
|
||||
off = contentEnd + ((TAR_BLOCK - (size % TAR_BLOCK)) % TAR_BLOCK)
|
||||
}
|
||||
throw new TarExtractError(`no "${targetBasename}" file entry found in tar archive`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gunzip a verified frp `.tar.gz` and return the inner `frpc` binary's bytes. `gunzipSync` is used
|
||||
* on already-hash-verified input (the pin gate runs first), so there is no attacker-controlled
|
||||
* decompression-bomb surface here; a corrupt/non-gzip payload throws `TarExtractError`.
|
||||
*/
|
||||
export function extractFrpcBinary(archiveGz: Uint8Array): Uint8Array {
|
||||
return extractTarFileByBasename(gunzip(archiveGz), FRPC_BASENAME)
|
||||
}
|
||||
|
||||
function gunzip(archiveGz: Uint8Array): Uint8Array {
|
||||
try {
|
||||
return new Uint8Array(gunzipSync(archiveGz))
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : 'not a gzip stream'
|
||||
throw new TarExtractError(`corrupt frpc archive: gunzip failed (${detail})`)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,153 @@
|
||||
/**
|
||||
* Service install dispatcher — PLAN_RELAY_AGENT T17. Detects the platform, writes the unit, and
|
||||
* loads it. REFUSES to install as root (EXPLORE §4d least privilege). All IO is injected so the
|
||||
* logic is unit-testable without touching the real system.
|
||||
* Service install dispatcher — PLAN_RELAY_AGENT T17, re-targeted for the native tunnel
|
||||
* (PLAN_TUNNEL_AUTOMATION B5). Detects the platform and emits TWO durable units — the base-app and
|
||||
* the agent — then loads/enables both. REFUSES to install as root (EXPLORE §4d least privilege).
|
||||
* All IO is injected so the logic is unit-testable without touching the real system.
|
||||
*
|
||||
* Two safety controls are load-bearing here:
|
||||
* - FIX C-host-1 (S-GATE, CRITICAL): the base-app unit MUST bind loopback. A non-loopback
|
||||
* `BIND_HOST` (e.g. `0.0.0.0`) is REJECTED — the throw happens before any file is written, so a
|
||||
* rejected install emits nothing. An absent value is normalized to `127.0.0.1`.
|
||||
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
|
||||
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
|
||||
*/
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import {
|
||||
agentLabel,
|
||||
baseAppLabel,
|
||||
buildLaunchdPlist,
|
||||
launchdLoadCommand,
|
||||
launchdPlistPath,
|
||||
launchdUnloadCommand,
|
||||
type ServiceEnv,
|
||||
} from './launchd.js'
|
||||
import {
|
||||
agentUnitName,
|
||||
baseAppUnitName,
|
||||
buildSystemdUnit,
|
||||
systemdDisableCommand,
|
||||
systemdEnableCommand,
|
||||
systemdUnitPath,
|
||||
type SystemdUnitOptions,
|
||||
} from './systemd.js'
|
||||
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
|
||||
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
||||
|
||||
export type ServicePlatform = 'launchd' | 'systemd'
|
||||
|
||||
/**
|
||||
* Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
|
||||
* existing callers (and relay callers) are unaffected. `env` is the BASE-APP env (routed to the
|
||||
* base-app unit only); `baseAppExec` overrides the base-app `ExecStart` argv.
|
||||
*/
|
||||
export interface InstallOptions {
|
||||
/** Base-app env injected into the base-app unit (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
|
||||
readonly env?: ServiceEnv
|
||||
/** systemd `EnvironmentFile=` path (preferred over inline for values best kept off the unit). */
|
||||
readonly envFile?: string
|
||||
/** DNS zone label for the tunnel origin (`terminal` for native-tunnel hosts, default `term`). */
|
||||
readonly zone?: string
|
||||
/** Parent domain (e.g. `yaojia.wang`); with `cfg.subdomain` derives the tunnel ALLOWED_ORIGINS. */
|
||||
readonly domain?: string
|
||||
/** Base-app process argv (default `['node','dist/server.js']`). */
|
||||
readonly baseAppExec?: readonly string[]
|
||||
}
|
||||
|
||||
/** S0 base-app env vars the install CLI bakes into the base-app unit, passed through verbatim. */
|
||||
const BASE_APP_ENV_KEYS = [
|
||||
'ALLOWED_ORIGINS',
|
||||
'PORT',
|
||||
'SHELL_PATH',
|
||||
'IDLE_TTL',
|
||||
'USE_TMUX',
|
||||
'SCROLLBACK_BYTES',
|
||||
'MAX_PAYLOAD_BYTES',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Tunnel hosts MUST bind loopback. At the relay the device-cert mTLS is the ONLY auth gate, so a
|
||||
* default `0.0.0.0` bind (`src/config.ts`) would serve an unauth'd shell on the LAN, bypassing mTLS
|
||||
* entirely (PLAN_NATIVE_TUNNEL S0/R2, FIX C-host-1). This is the normalized loopback default.
|
||||
*/
|
||||
export const TUNNEL_DEFAULT_BIND_HOST = '127.0.0.1'
|
||||
|
||||
/** Native-tunnel origin zone → `https://<subdomain>.terminal.<domain>`; overridable via TUNNEL_ZONE. */
|
||||
export const TUNNEL_ORIGIN_ZONE = 'terminal'
|
||||
|
||||
/** Native-tunnel origin zone label; native installs MUST use this (FIX L-host-zone). */
|
||||
export const NATIVE_ORIGIN_ZONE = 'terminal'
|
||||
|
||||
/** Base-app process argv when the caller does not override it. */
|
||||
const DEFAULT_BASE_APP_EXEC: readonly string[] = ['node', 'dist/server.js']
|
||||
|
||||
/** A base-app BIND_HOST that is not loopback — the S-GATE fail-closed error (FIX C-host-1). */
|
||||
export class BindHostError extends Error {
|
||||
constructor(value: string) {
|
||||
super(
|
||||
`refusing to install: BIND_HOST="${value}" is not loopback. A tunnel host MUST bind ` +
|
||||
'127.0.0.1/::1/localhost — the device-cert mTLS at the relay is the only auth gate, so a ' +
|
||||
'0.0.0.0 (or LAN-IP) bind would serve an unauth\'d shell on the LAN. [FIX C-host-1 S-GATE]',
|
||||
)
|
||||
this.name = 'BindHostError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `value` is a loopback bind address (a well-formed 127.0.0.0/8 IPv4 literal, ::1, or
|
||||
* localhost). Delegates to the shared strict check so a suffixed hostname such as
|
||||
* `127.0.0.1.attacker.example.com` — which Node would DNS-resolve before bind() — is REJECTED,
|
||||
* not treated as loopback (FIX C-host-1 S-GATE).
|
||||
*/
|
||||
function isLoopbackBindHost(value: string): boolean {
|
||||
return isLoopbackHostLiteral(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* S-GATE (FIX C-host-1): normalize an absent/empty BIND_HOST to loopback; REJECT any non-loopback
|
||||
* value (throws `BindHostError`). The emitted base-app unit can therefore never bind `0.0.0.0`.
|
||||
*/
|
||||
export function normalizeBindHost(value: string | undefined): string {
|
||||
if (value === undefined || value.length === 0) return TUNNEL_DEFAULT_BIND_HOST
|
||||
if (!isLoopbackBindHost(value)) throw new BindHostError(value)
|
||||
return value
|
||||
}
|
||||
|
||||
/** Assert a native-tunnel install uses the `terminal` zone (FIX L-host-zone). Throws otherwise. */
|
||||
export function assertNativeZone(zone: string | undefined): void {
|
||||
if (zone !== NATIVE_ORIGIN_ZONE) {
|
||||
throw new Error(
|
||||
`native tunnel install requires zone="${NATIVE_ORIGIN_ZONE}" (got "${zone ?? '(default term)'}")` +
|
||||
' — the base-app origin must be https://<sub>.terminal.<domain> [FIX L-host-zone]',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2).
|
||||
* Sources the S0 base-app env — normalizing/gating `BIND_HOST` to loopback (S-GATE: throws on a
|
||||
* non-loopback value so no install can ever emit a LAN-exposed unit) — plus the tunnel-origin
|
||||
* `domain`/`zone` used to derive ALLOWED_ORIGINS. Pure/immutable (env is a parameter).
|
||||
*/
|
||||
export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
|
||||
const passthrough = Object.fromEntries(
|
||||
BASE_APP_ENV_KEYS.map((key) => [key, env[key]] as [string, string | undefined]).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0,
|
||||
),
|
||||
)
|
||||
// S-GATE at env-read time: a non-loopback BIND_HOST fails closed here (before any install).
|
||||
const serviceEnv: ServiceEnv = { BIND_HOST: normalizeBindHost(env.BIND_HOST), ...passthrough }
|
||||
const domain = env.TUNNEL_DOMAIN
|
||||
const envFile = env.AGENT_ENV_FILE
|
||||
const baseAppEntry = env.BASE_APP_ENTRY
|
||||
return {
|
||||
env: serviceEnv,
|
||||
...(domain ? { domain, zone: env.TUNNEL_ZONE || TUNNEL_ORIGIN_ZONE } : {}),
|
||||
...(envFile ? { envFile } : {}),
|
||||
...(baseAppEntry ? { baseAppExec: ['node', baseAppEntry] } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export class RootRefusedError extends Error {
|
||||
constructor() {
|
||||
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
||||
@@ -42,37 +171,109 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
|
||||
export async function installService(
|
||||
_cfg: AgentConfig,
|
||||
platform: ServicePlatform,
|
||||
deps: InstallDeps,
|
||||
): Promise<void> {
|
||||
if (deps.getuid() === 0) throw new RootRefusedError()
|
||||
const bin = deps.binPath()
|
||||
if (platform === 'launchd') {
|
||||
const path = launchdPlistPath(deps.homedir())
|
||||
deps.writeFile(path, buildLaunchdPlist(bin))
|
||||
const { cmd, args } = launchdLoadCommand(path)
|
||||
await deps.runCommand(cmd, args)
|
||||
return
|
||||
}
|
||||
const path = systemdUnitPath(deps.homedir())
|
||||
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
|
||||
const { cmd, args } = systemdEnableCommand()
|
||||
await deps.runCommand(cmd, args)
|
||||
/**
|
||||
* Resolve the BASE-APP env injected into the base-app unit. Runs the S-GATE on `BIND_HOST` (throws
|
||||
* `BindHostError` on a non-loopback value, before any write) and, when a `domain` (+ `cfg.subdomain`)
|
||||
* is supplied, merges the tunnel origin `https://<subdomain>.<zone>.<domain>` into ALLOWED_ORIGINS —
|
||||
* never weakening an origin the caller already provided. Immutable.
|
||||
*/
|
||||
function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
|
||||
const base = options.env ?? {}
|
||||
const bindHost = normalizeBindHost(base.BIND_HOST) // S-GATE — throws on non-loopback
|
||||
const withBind: ServiceEnv = { ...base, BIND_HOST: bindHost }
|
||||
if (!options.domain || !cfg.subdomain) return withBind
|
||||
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
|
||||
return { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) }
|
||||
}
|
||||
|
||||
/** Unload the service unit for `platform`. */
|
||||
/**
|
||||
* Write + load BOTH service units for `platform`: the base-app (`node dist/server.js` + base-app
|
||||
* env) and the agent (`<bin> run`, supervises frpc — no base-app env). Throws RootRefusedError if
|
||||
* running as root, or BindHostError (S-GATE) BEFORE any write if the base-app BIND_HOST is not
|
||||
* loopback (so a rejected install emits nothing).
|
||||
*/
|
||||
export async function installService(
|
||||
cfg: AgentConfig,
|
||||
platform: ServicePlatform,
|
||||
deps: InstallDeps,
|
||||
options: InstallOptions = {},
|
||||
): Promise<void> {
|
||||
if (deps.getuid() === 0) throw new RootRefusedError()
|
||||
// Resolve (and S-GATE) the base-app env BEFORE any IO — a non-loopback BIND_HOST throws here,
|
||||
// so nothing is ever written for a rejected install.
|
||||
const baseAppEnv = resolveBaseAppEnv(cfg, options)
|
||||
const bin = deps.binPath()
|
||||
const rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
|
||||
// launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node`
|
||||
// symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node
|
||||
// (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc
|
||||
// subprocesses — resolve their tools.
|
||||
const nodePath = process.execPath
|
||||
const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`
|
||||
const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec
|
||||
const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath }
|
||||
// The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front
|
||||
// (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the
|
||||
// agent's own runtime config (NOT the base-app env) alongside PATH.
|
||||
const agentEnv: Record<string, string> = {
|
||||
PATH: unitPath,
|
||||
ENROLL_URL: cfg.enrollUrl,
|
||||
RELAY_URL: cfg.relayUrl,
|
||||
STATE_DIR: cfg.stateDir,
|
||||
LOCAL_TARGET_URL: cfg.localTargetUrl,
|
||||
}
|
||||
|
||||
if (platform === 'launchd') {
|
||||
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
|
||||
deps.writeFile(
|
||||
baseAppPath,
|
||||
buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')),
|
||||
)
|
||||
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
|
||||
deps.writeFile(
|
||||
agentPath,
|
||||
buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel(), join(cfg.stateDir, 'agent.log')),
|
||||
)
|
||||
for (const path of [baseAppPath, agentPath]) {
|
||||
const { cmd, args } = launchdLoadCommand(path)
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const baseAppOptions: SystemdUnitOptions = options.envFile
|
||||
? { env: baseAppEnvWithPath, envFile: options.envFile }
|
||||
: { env: baseAppEnvWithPath }
|
||||
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
|
||||
deps.writeFile(
|
||||
baseAppPath,
|
||||
buildSystemdUnit(baseAppExec.join(' '), deps.username(), baseAppOptions, 'web-terminal base app (loopback)'),
|
||||
)
|
||||
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
|
||||
deps.writeFile(
|
||||
agentPath,
|
||||
buildSystemdUnit(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, 'web-terminal host agent (frpc supervisor)'),
|
||||
)
|
||||
for (const unit of [baseAppUnitName(), agentUnitName()]) {
|
||||
const { cmd, args } = systemdEnableCommand(unit)
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unload/disable BOTH service units for `platform` (agent first, then base-app). */
|
||||
export async function uninstallService(
|
||||
platform: ServicePlatform,
|
||||
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
|
||||
): Promise<void> {
|
||||
if (platform === 'launchd') {
|
||||
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir()))
|
||||
for (const label of [agentLabel(), baseAppLabel()]) {
|
||||
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
return
|
||||
}
|
||||
const { cmd, args } = systemdDisableCommand()
|
||||
for (const unit of [agentUnitName(), baseAppUnitName()]) {
|
||||
const { cmd, args } = systemdDisableCommand(unit)
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,108 @@
|
||||
/**
|
||||
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
||||
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the plist can inject a caller-supplied per-host env map
|
||||
* (BIND_HOST, ALLOWED_ORIGINS, PORT, SHELL_PATH, IDLE_TTL, USE_TMUX, …) as a launchd
|
||||
* `<key>EnvironmentVariables</key><dict>…</dict>` block. Values are XML-escaped and keys are
|
||||
* sorted for deterministic, immutable output.
|
||||
*
|
||||
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on `label` +
|
||||
* `programArguments`, so a native-tunnel install emits TWO distinct plists — the base-app
|
||||
* (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which supervises
|
||||
* frpc). Base-app env is routed to the base-app plist ONLY by the caller (`install.ts`).
|
||||
*/
|
||||
const LABEL = 'com.web-terminal.agent'
|
||||
|
||||
/** Shared env-map shape for the durable-service writers (launchd + systemd). Immutable. */
|
||||
export type ServiceEnv = Readonly<Record<string, string>>
|
||||
|
||||
/** The native-tunnel agent unit (supervises frpc). */
|
||||
const AGENT_LABEL = 'com.web-terminal.agent'
|
||||
/** The base-app unit (`node dist/server.js`, loopback-bound). */
|
||||
const BASE_APP_LABEL = 'com.web-terminal.base-app'
|
||||
|
||||
export function agentLabel(): string {
|
||||
return AGENT_LABEL
|
||||
}
|
||||
export function baseAppLabel(): string {
|
||||
return BASE_APP_LABEL
|
||||
}
|
||||
/** Back-compat alias for the agent label. */
|
||||
export function launchdLabel(): string {
|
||||
return LABEL
|
||||
return AGENT_LABEL
|
||||
}
|
||||
|
||||
export function launchdPlistPath(homedir: string): string {
|
||||
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
|
||||
export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
|
||||
return `${homedir}/Library/LaunchAgents/${label}.plist`
|
||||
}
|
||||
|
||||
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */
|
||||
export function buildLaunchdPlist(binPath: string): string {
|
||||
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** Build the `<key>EnvironmentVariables</key><dict>…</dict>` lines (empty array when env is empty). */
|
||||
function environmentVariablesBlock(env: ServiceEnv): readonly string[] {
|
||||
const entries = Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
|
||||
if (entries.length === 0) return []
|
||||
const lines = [' <key>EnvironmentVariables</key>', ' <dict>']
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(` <key>${escapeXml(key)}</key>`)
|
||||
lines.push(` <string>${escapeXml(value)}</string>`)
|
||||
}
|
||||
lines.push(' </dict>')
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Build the `<key>ProgramArguments</key><array>…</array>` lines. */
|
||||
function programArgumentsBlock(programArguments: readonly string[]): readonly string[] {
|
||||
const lines = [' <key>ProgramArguments</key>', ' <array>']
|
||||
for (const arg of programArguments) {
|
||||
lines.push(` <string>${escapeXml(arg)}</string>`)
|
||||
}
|
||||
lines.push(' </array>')
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a plist for `label` running `programArguments` (e.g. `[bin, 'run']` or `[node, serverJs]`);
|
||||
* RunAtLoad + KeepAlive (restart on failure). When `env` is non-empty, a launchd
|
||||
* `EnvironmentVariables` dict is injected. Pure/immutable — returns a fresh string.
|
||||
*/
|
||||
export function buildLaunchdPlist(
|
||||
programArguments: readonly string[],
|
||||
env: ServiceEnv = {},
|
||||
label: string = AGENT_LABEL,
|
||||
logPath?: string,
|
||||
): string {
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||
'<plist version="1.0">',
|
||||
'<dict>',
|
||||
' <key>Label</key>',
|
||||
` <string>${LABEL}</string>`,
|
||||
' <key>ProgramArguments</key>',
|
||||
' <array>',
|
||||
` <string>${binPath}</string>`,
|
||||
' <string>run</string>',
|
||||
' </array>',
|
||||
` <string>${escapeXml(label)}</string>`,
|
||||
...programArgumentsBlock(programArguments),
|
||||
' <key>RunAtLoad</key>',
|
||||
' <true/>',
|
||||
' <key>KeepAlive</key>',
|
||||
' <true/>',
|
||||
// launchd's default has no log sink (unlike systemd's journald), so a crashing unit is silent.
|
||||
// Route stdout+stderr to a file so `pair --install` failures are diagnosable out of the box.
|
||||
...(logPath !== undefined
|
||||
? [
|
||||
' <key>StandardOutPath</key>',
|
||||
` <string>${escapeXml(logPath)}</string>`,
|
||||
' <key>StandardErrorPath</key>',
|
||||
` <string>${escapeXml(logPath)}</string>`,
|
||||
]
|
||||
: []),
|
||||
...environmentVariablesBlock(env),
|
||||
'</dict>',
|
||||
'</plist>',
|
||||
'',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
||||
* APPENDS `https://<subdomain>.term.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
||||
* APPENDS `https://<subdomain>.<zone>.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
||||
* as CONFIG — NO `src/` code edit. AUGMENTS, never weakens, the Origin/CSWSH check: existing
|
||||
* origins are always preserved (EXPLORE §3 "do not weaken the check").
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the DNS zone label is PARAMETERIZED. Relay callers keep the historical
|
||||
* `term` zone (default), while native-tunnel hosts pass `terminal` so the base app trusts
|
||||
* `https://<name>.terminal.<domain>`. The default is preserved so existing callers/tests are
|
||||
* unaffected — the zone is opt-in per call, never hard-flipped.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
@@ -18,23 +23,40 @@ const defaultFs: OriginFsDeps = {
|
||||
write: (p, c) => writeFileSync(p, c),
|
||||
}
|
||||
|
||||
/** Compose the subdomain origin the base app must trust. */
|
||||
export function subdomainOrigin(subdomain: string, domain: string): string {
|
||||
return `https://${subdomain}.term.${domain}`
|
||||
}
|
||||
/** Default DNS zone label (relay hosts). Native-tunnel hosts pass `terminal`. */
|
||||
export const DEFAULT_ORIGIN_ZONE = 'term'
|
||||
|
||||
const KEY = 'ALLOWED_ORIGINS'
|
||||
|
||||
/** Compose the subdomain origin the base app must trust: `https://<subdomain>.<zone>.<domain>`. */
|
||||
export function subdomainOrigin(
|
||||
subdomain: string,
|
||||
domain: string,
|
||||
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||
): string {
|
||||
return `https://${subdomain}.${zone}.${domain}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `origin` into a comma-separated ALLOWED_ORIGINS value, preserving every existing origin and
|
||||
* de-duplicating. Returns the merged CSV; never removes an origin. Pure/immutable.
|
||||
*/
|
||||
export function mergeOrigins(current: string | undefined, origin: string): string {
|
||||
const origins = (current ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
if (origins.includes(origin)) return origins.join(',')
|
||||
return [...origins, origin].join(',')
|
||||
}
|
||||
|
||||
function upsertOriginLine(content: string, origin: string): string {
|
||||
const lines = content.length === 0 ? [] : content.split('\n')
|
||||
let found = false
|
||||
const next = lines.map((line) => {
|
||||
if (!line.startsWith(`${KEY}=`)) return line
|
||||
found = true
|
||||
const current = line.slice(KEY.length + 1)
|
||||
const origins = current.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
||||
if (origins.includes(origin)) return line // idempotent — already trusted
|
||||
return `${KEY}=${[...origins, origin].join(',')}`
|
||||
return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
|
||||
})
|
||||
if (!found) next.push(`${KEY}=${origin}`)
|
||||
return next.join('\n')
|
||||
@@ -42,15 +64,17 @@ function upsertOriginLine(content: string, origin: string): string {
|
||||
|
||||
/**
|
||||
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
||||
* existing origin. Creates the file/line if absent.
|
||||
* existing origin. Creates the file/line if absent. `zone` selects the DNS zone label (default
|
||||
* preserves relay callers).
|
||||
*/
|
||||
export function ensureAllowedOrigin(
|
||||
baseAppEnvPath: string,
|
||||
subdomain: string,
|
||||
domain: string,
|
||||
fs: OriginFsDeps = defaultFs,
|
||||
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||
): void {
|
||||
const origin = subdomainOrigin(subdomain, domain)
|
||||
const origin = subdomainOrigin(subdomain, domain, zone)
|
||||
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
||||
const updated = upsertOriginLine(existing, origin)
|
||||
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
||||
|
||||
@@ -1,30 +1,129 @@
|
||||
/**
|
||||
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
|
||||
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the unit can inject the per-host tunnel env via an `EnvironmentFile=`
|
||||
* line (preferred — keeps values out of the world-readable unit) and/or inline `Environment=`
|
||||
* lines from a caller-supplied env map. Inline `Environment=` is emitted after `EnvironmentFile=`
|
||||
* so an explicit value overrides the file on conflict.
|
||||
*
|
||||
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on the full
|
||||
* `ExecStart` command + `Description`, so a native-tunnel install emits TWO distinct units — the
|
||||
* base-app (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which
|
||||
* supervises frpc). Base-app env is routed to the base-app unit ONLY by the caller (`install.ts`).
|
||||
*/
|
||||
const UNIT_NAME = 'web-terminal-agent.service'
|
||||
import type { ServiceEnv } from './launchd.js'
|
||||
|
||||
/** The native-tunnel agent unit (supervises frpc). */
|
||||
const AGENT_UNIT = 'web-terminal-agent.service'
|
||||
/** The base-app unit (`node dist/server.js`, loopback-bound). */
|
||||
const BASE_APP_UNIT = 'web-terminal-base-app.service'
|
||||
|
||||
/** DEL (0x7F) and everything below the printable ASCII range are rejected in env values. */
|
||||
const FIRST_PRINTABLE_ASCII = 0x20
|
||||
const DEL_CODE = 0x7f
|
||||
|
||||
export interface SystemdUnitOptions {
|
||||
/** Inline env map → one `Environment="KEY=value"` line each (keys sorted, values escaped). */
|
||||
readonly env?: ServiceEnv
|
||||
/** Path referenced by a single `EnvironmentFile=` line (preferred over inline for secrets). */
|
||||
readonly envFile?: string
|
||||
}
|
||||
|
||||
export function agentUnitName(): string {
|
||||
return AGENT_UNIT
|
||||
}
|
||||
export function baseAppUnitName(): string {
|
||||
return BASE_APP_UNIT
|
||||
}
|
||||
/** Back-compat alias for the agent unit name. */
|
||||
export function systemdUnitName(): string {
|
||||
return UNIT_NAME
|
||||
return AGENT_UNIT
|
||||
}
|
||||
|
||||
export function systemdUnitPath(homedir: string): string {
|
||||
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
|
||||
export function systemdUnitPath(homedir: string, unitName: string = AGENT_UNIT): string {
|
||||
return `${homedir}/.config/systemd/user/${unitName}`
|
||||
}
|
||||
|
||||
/** Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). */
|
||||
export function buildSystemdUnit(binPath: string, user: string): string {
|
||||
/**
|
||||
* True if `value` contains any control character (C0 range below 0x20, or DEL 0x7F). A raw
|
||||
* newline/CR would terminate the current line and let the remainder inject arbitrary directives into
|
||||
* the `[Service]` section — so such values are rejected rather than escaped.
|
||||
*/
|
||||
function hasControlChar(value: string): boolean {
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0)
|
||||
if (code === undefined) continue
|
||||
if (code < FIRST_PRINTABLE_ASCII || code === DEL_CODE) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject ANY field interpolated into the unit that carries a control character. EVERY value written
|
||||
* into the unit (ExecStart, User, Description, EnvironmentFile path, and each Environment key+value)
|
||||
* flows through this one guard, so no field can smuggle a newline that starts a new `[Service]`
|
||||
* directive. Fail loud rather than escape — these fields are never legitimately multi-line.
|
||||
*/
|
||||
function assertNoControlChar(fieldName: string, value: string): void {
|
||||
if (hasControlChar(value)) {
|
||||
throw new Error(
|
||||
`refusing to write systemd unit: ${fieldName} contains a control character ` +
|
||||
'(newline/CR/etc.) that could corrupt the [Service] section',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Quote a systemd `Environment=` value: reject control chars (key AND value), then escape `\` and `"`. */
|
||||
function quoteEnvAssignment(key: string, value: string): string {
|
||||
assertNoControlChar(`Environment key '${key}'`, key)
|
||||
assertNoControlChar(`Environment value for '${key}'`, value)
|
||||
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
return `"${key}=${escaped}"`
|
||||
}
|
||||
|
||||
/** Build the `EnvironmentFile=` / `Environment=` lines (empty array when neither is supplied). */
|
||||
function environmentLines(options: SystemdUnitOptions): readonly string[] {
|
||||
const lines: string[] = []
|
||||
if (options.envFile) {
|
||||
assertNoControlChar('EnvironmentFile path', options.envFile)
|
||||
lines.push(`EnvironmentFile=${options.envFile}`)
|
||||
}
|
||||
const entries = Object.entries(options.env ?? {}).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(`Environment=${quoteEnvAssignment(key, value)}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a unit whose `ExecStart` is `execStart` (a full command line, e.g. `<bin> run` or
|
||||
* `node dist/server.js`); Restart=on-failure; User=<user> (never root). When `options.env`/
|
||||
* `options.envFile` are supplied, the per-host tunnel env is injected. Pure/immutable.
|
||||
*/
|
||||
export function buildSystemdUnit(
|
||||
execStart: string,
|
||||
user: string,
|
||||
options: SystemdUnitOptions = {},
|
||||
description: string = 'web-terminal host agent',
|
||||
): string {
|
||||
// Guard every non-env field the same way env values are guarded — a newline in ExecStart/User/
|
||||
// Description would otherwise inject a `[Service]` directive on the next line.
|
||||
assertNoControlChar('ExecStart', execStart)
|
||||
assertNoControlChar('User', user)
|
||||
assertNoControlChar('Description', description)
|
||||
return [
|
||||
'[Unit]',
|
||||
'Description=web-terminal host agent (rendezvous relay)',
|
||||
`Description=${description}`,
|
||||
'After=network-online.target',
|
||||
'',
|
||||
'[Service]',
|
||||
'Type=simple',
|
||||
`ExecStart=${binPath} run`,
|
||||
`ExecStart=${execStart}`,
|
||||
'Restart=on-failure',
|
||||
'RestartSec=1',
|
||||
`User=${user}`,
|
||||
...environmentLines(options),
|
||||
'',
|
||||
'[Install]',
|
||||
'WantedBy=default.target',
|
||||
@@ -32,9 +131,13 @@ export function buildSystemdUnit(binPath: string, user: string): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function systemdEnableCommand(): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', UNIT_NAME] }
|
||||
export function systemdEnableCommand(
|
||||
unitName: string = AGENT_UNIT,
|
||||
): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', unitName] }
|
||||
}
|
||||
export function systemdDisableCommand(): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', UNIT_NAME] }
|
||||
export function systemdDisableCommand(
|
||||
unitName: string = AGENT_UNIT,
|
||||
): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', unitName] }
|
||||
}
|
||||
|
||||
@@ -25,7 +25,13 @@ export class CertExpiredError extends Error {
|
||||
export interface TlsClientOptions {
|
||||
readonly cert: string
|
||||
readonly key: string
|
||||
readonly ca: string
|
||||
/**
|
||||
* CA(s) to verify the SERVER cert against. Set to the private tunnel CA for the relay dial; left
|
||||
* undefined for a request whose server is publicly trusted (the LE-fronted control-plane /renew) so
|
||||
* Node verifies against the system roots — pinning the private CA there fails with "unable to get
|
||||
* local issuer certificate".
|
||||
*/
|
||||
readonly ca?: string
|
||||
readonly rejectUnauthorized: true
|
||||
}
|
||||
|
||||
|
||||
209
agent/src/transport/frpSupervise.ts
Normal file
209
agent/src/transport/frpSupervise.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Native-tunnel frpc supervisor — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2).
|
||||
*
|
||||
* Spawns the pinned `frpc` child with the written `frpc.toml` and keeps it running: on every exit
|
||||
* it restarts the child after an exponential backoff (REUSES `transport/backoff.ts` — the same
|
||||
* 1s/2s/4s…cap-30s policy as the relay reconnect). A child that ran longer than a stability window
|
||||
* resets the backoff, so a healthy long-lived tunnel that finally dies restarts fast, while a
|
||||
* crash-loop stays capped at 30s. All IO — spawn, sleep, clock — is injected so the loop is
|
||||
* fully offline-testable (no real frpc / no real timers). No `console.log` (INV9 redacting logger).
|
||||
*
|
||||
* SCOPE (deferral #11): the real `frpc` binary run is pending B3 tar.gz extraction; the default
|
||||
* `spawn` shells out to the provisioned binary, but tests inject a fake child so nothing executes.
|
||||
*
|
||||
* LOG CAPTURE (B4/H4 wiring fix): when a `logFile` is supplied, the default spawn tees the frpc
|
||||
* child's stdout+stderr into that file (truncated per (re)spawn) so the health probe's log scanner
|
||||
* (`readFrpcLog` → `frpcProxyStarted`) has real content to match "start proxy success" against.
|
||||
* Truncate-per-spawn keeps the file bounded to the CURRENT child and free of a stale success line
|
||||
* from a dead one — a freshly connected frpc always re-logs the success line.
|
||||
*/
|
||||
import { spawn as nodeSpawn } from 'node:child_process'
|
||||
import { createWriteStream, mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { createBackoff, type BackoffPolicy, type Sleep } from './backoff.js'
|
||||
import { createLogger, type Logger } from '../log/logger.js'
|
||||
|
||||
/** A child process seam the supervisor drives (a fake child in tests; a real frpc in prod). */
|
||||
export interface FrpcChild {
|
||||
/** Register a one-shot exit handler (`null` code ⇒ killed by signal or spawn error). */
|
||||
onExit(cb: (code: number | null) => void): void
|
||||
/** Whether the child is still running (feeds the health probe's `isFrpcAlive`). */
|
||||
isAlive(): boolean
|
||||
/** Request termination. */
|
||||
kill(): void
|
||||
}
|
||||
|
||||
/** Spawn a frpc child running `frpc -c <tomlPath>`. */
|
||||
export type SpawnFrpc = (binPath: string, tomlPath: string) => FrpcChild
|
||||
|
||||
/** Injectable seams for the supervisor; unset fields default to real spawn/sleep/clock/logger. */
|
||||
export interface FrpSuperviseDeps {
|
||||
spawn: SpawnFrpc
|
||||
backoff: BackoffPolicy
|
||||
sleep: Sleep
|
||||
logger: Logger
|
||||
now: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervisor overrides: any `FrpSuperviseDeps` seam plus an optional `logFile`. When `logFile` is
|
||||
* set and no explicit `spawn` is given, the default spawn tees frpc's stdout/stderr into that file
|
||||
* so the health probe can scan it. An explicit `spawn` (tests) always wins over `logFile`.
|
||||
*/
|
||||
export interface FrpSuperviseOptions extends Partial<FrpSuperviseDeps> {
|
||||
readonly logFile?: string
|
||||
}
|
||||
|
||||
/** Handle returned by `superviseFrpc`: stop the loop, or await its terminal exit code. */
|
||||
export interface FrpSuperviseHandle {
|
||||
/** Request graceful shutdown (kills the child); resolves once the loop has fully stopped. */
|
||||
stop(): Promise<void>
|
||||
/** Resolves with an exit code (always 0 — a stopped supervisor is a clean shutdown). */
|
||||
readonly done: Promise<number>
|
||||
/** True while a frpc child is currently running (for the health probe). */
|
||||
isChildAlive(): boolean
|
||||
/**
|
||||
* Kill the current child WITHOUT stopping the supervisor (A5): the restart-on-exit loop respawns a
|
||||
* fresh frpc that re-reads the (now rotated) cert/key/CA files. A no-op once `stop()` was called —
|
||||
* it must never resurrect a supervisor that is shutting down.
|
||||
*/
|
||||
restartChild(): void
|
||||
}
|
||||
|
||||
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
|
||||
export const STABLE_RUN_MS = 60_000
|
||||
|
||||
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
/**
|
||||
* Default spawn (no log capture): launch the provisioned frpc binary, discarding its stdout/stderr.
|
||||
* `stdio: 'ignore'` (not `'pipe'`) is deliberate — an unconsumed pipe fills its OS buffer (~64KB) and
|
||||
* then BLOCKS the child. Log capture is opt-in via `createFileLoggingSpawn` (see `logFile`).
|
||||
*/
|
||||
const realSpawn: SpawnFrpc = (binPath, tomlPath) => {
|
||||
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'ignore', 'ignore'] })
|
||||
let alive = true
|
||||
return {
|
||||
onExit(cb: (code: number | null) => void): void {
|
||||
cp.once('exit', (code) => {
|
||||
alive = false
|
||||
cb(code)
|
||||
})
|
||||
cp.once('error', () => {
|
||||
alive = false
|
||||
cb(null)
|
||||
})
|
||||
},
|
||||
isAlive: () => alive,
|
||||
kill: () => {
|
||||
cp.kill()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn frpc and TEE its stdout+stderr into `logFile` (truncated per spawn) so the health probe's
|
||||
* `readFrpcLog` scanner has real content. A log-write failure is swallowed — persisting the log for
|
||||
* the probe must never crash the supervised tunnel.
|
||||
*/
|
||||
export function createFileLoggingSpawn(logFile: string): SpawnFrpc {
|
||||
return (binPath, tomlPath) => {
|
||||
mkdirSync(dirname(logFile), { recursive: true })
|
||||
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
// flags:'w' truncates so the file reflects only the current child (no stale success line).
|
||||
const log = createWriteStream(logFile, { flags: 'w' })
|
||||
log.on('error', () => {
|
||||
/* a log-write failure is non-fatal to the tunnel; the probe just sees no success line */
|
||||
})
|
||||
cp.stdout?.pipe(log, { end: false })
|
||||
cp.stderr?.pipe(log, { end: false })
|
||||
let alive = true
|
||||
const closeLog = (): void => {
|
||||
log.end()
|
||||
}
|
||||
return {
|
||||
onExit(cb: (code: number | null) => void): void {
|
||||
cp.once('exit', (code) => {
|
||||
alive = false
|
||||
closeLog()
|
||||
cb(code)
|
||||
})
|
||||
cp.once('error', () => {
|
||||
alive = false
|
||||
closeLog()
|
||||
cb(null)
|
||||
})
|
||||
},
|
||||
isAlive: () => alive,
|
||||
kill: () => {
|
||||
cp.kill()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeps(o?: FrpSuperviseOptions): FrpSuperviseDeps {
|
||||
const defaultSpawn = o?.logFile !== undefined ? createFileLoggingSpawn(o.logFile) : realSpawn
|
||||
return {
|
||||
spawn: o?.spawn ?? defaultSpawn,
|
||||
backoff: o?.backoff ?? createBackoff({ jitter: true }),
|
||||
sleep: o?.sleep ?? realSleep,
|
||||
logger: o?.logger ?? createLogger('info'),
|
||||
now: o?.now ?? Date.now,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start supervising frpc. Returns immediately with a handle; the restart loop runs in the
|
||||
* background. `stop()` sets the stop flag, kills the live child, and awaits loop termination.
|
||||
*/
|
||||
export function superviseFrpc(
|
||||
binPath: string,
|
||||
tomlPath: string,
|
||||
overrides?: FrpSuperviseOptions,
|
||||
): FrpSuperviseHandle {
|
||||
const { spawn, backoff, sleep, logger, now } = resolveDeps(overrides)
|
||||
|
||||
let stopped = false
|
||||
let child: FrpcChild | null = null
|
||||
|
||||
/** Spawn one frpc child and resolve when it exits. */
|
||||
function runOnce(): Promise<number | null> {
|
||||
return new Promise<number | null>((resolve) => {
|
||||
const c = spawn(binPath, tomlPath)
|
||||
child = c
|
||||
c.onExit((code) => {
|
||||
child = null
|
||||
resolve(code)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function loop(): Promise<number> {
|
||||
while (!stopped) {
|
||||
const startedAt = now()
|
||||
const exitCode = await runOnce()
|
||||
if (stopped) break
|
||||
if (now() - startedAt >= STABLE_RUN_MS) backoff.reset()
|
||||
const delayMs = backoff.nextDelayMs()
|
||||
logger.log('warn', 'frpc exited — restarting after backoff', { exitCode, delayMs })
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const done = loop()
|
||||
|
||||
return {
|
||||
async stop(): Promise<void> {
|
||||
stopped = true
|
||||
child?.kill()
|
||||
await done
|
||||
},
|
||||
done,
|
||||
isChildAlive: () => child?.isAlive() ?? false,
|
||||
restartChild(): void {
|
||||
if (!stopped) child?.kill()
|
||||
},
|
||||
}
|
||||
}
|
||||
155
agent/src/transport/frpcToml.ts
Normal file
155
agent/src/transport/frpcToml.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Native-tunnel frpc.toml writer — TASK B2h (PLAN_TUNNEL_AUTOMATION §3.2 / PLAN_NATIVE_TUNNEL §4).
|
||||
*
|
||||
* Emits the frp v0.61 TOML that a host's `frpc` presents to the VPS:
|
||||
* - dials `serverAddr:443`, SNI-routed by nginx `ssl_preread` to frps :7000;
|
||||
* - control-channel mTLS (frp-client cert/key + trusted CA) + shared token;
|
||||
* - one `[[proxies]]` of `type = "http"` exposing the loopback base app as `<sub>.terminal...`.
|
||||
*
|
||||
* SUPERSEDES the retired v0.8 `[common]/tls_enable` shape in `frpScaffold.ts` (do not reuse that
|
||||
* grammar — this is the native-tunnel writer).
|
||||
*
|
||||
* ANTI-SSRF (hard invariant): `localIP` MUST be loopback. frpc forwards ONLY to the local base app,
|
||||
* never an arbitrary target — a non-loopback `localIP` would turn the tunnel into an open proxy.
|
||||
* All inputs are validated at this boundary (fail-fast, clear messages); no `console.log`.
|
||||
*/
|
||||
|
||||
const DEFAULT_SERVER_ADDR = '8.138.1.192'
|
||||
const SERVER_PORT = 443
|
||||
const TLS_SERVER_NAME = 'frp.terminal.yaojia.wang'
|
||||
const DEFAULT_LOCAL_IP = '127.0.0.1'
|
||||
const MIN_PORT = 1
|
||||
const MAX_PORT = 65535
|
||||
const MAX_LABEL_LEN = 63
|
||||
const DEL_CHAR_CODE = 0x7f
|
||||
const FIRST_PRINTABLE_CODE = 0x20
|
||||
|
||||
/** Loopback forms accepted for `localIP` (anti-SSRF allowlist). */
|
||||
const LOOPBACK_IPS: readonly string[] = ['127.0.0.1', '::1', 'localhost']
|
||||
|
||||
/** RFC 1035 DNS label: 1-63 chars, alnum, internal hyphens only (no leading/trailing hyphen). */
|
||||
const LABEL_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
||||
|
||||
export interface NativeFrpcOptions {
|
||||
/** Subdomain label -> reachable at `https://<subdomain>.terminal.yaojia.wang`. Label-safe. */
|
||||
readonly subdomain: string
|
||||
/** Loopback port of the local base app frpc forwards to (1-65535). */
|
||||
readonly localPort: number
|
||||
/** Shared frps auth token (kept out of logs; the file itself is chmod 600 by the caller). */
|
||||
readonly authToken: string
|
||||
/** Keystore path to this host's frp-client leaf cert (control-channel mTLS). */
|
||||
readonly certFile: string
|
||||
/** Keystore path to the matching private key. */
|
||||
readonly keyFile: string
|
||||
/** Keystore path to the CA that signed the frps control cert (server verification). */
|
||||
readonly trustedCaFile: string
|
||||
/** VPS address; defaults to the deployed relay `8.138.1.192`. */
|
||||
readonly serverAddr?: string
|
||||
/** Local forward target IP; MUST be loopback. Defaults to `127.0.0.1`. */
|
||||
readonly localIP?: string
|
||||
}
|
||||
|
||||
/** A validation failure at the frpc.toml boundary. */
|
||||
export class FrpcTomlError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FrpcTomlError'
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff `value` contains an ASCII control character (C0 range or DEL). */
|
||||
function hasControlChar(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const code = value.charCodeAt(i)
|
||||
if (code < FIRST_PRINTABLE_CODE || code === DEL_CHAR_CODE) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Non-empty, control-char-free path/secret at the boundary. */
|
||||
function assertPresent(field: string, value: string): void {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new FrpcTomlError(`${field} is required`)
|
||||
}
|
||||
if (hasControlChar(value)) {
|
||||
throw new FrpcTomlError(`${field} contains control characters`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape a value for a TOML basic (double-quoted) string: backslash + quote (Windows paths). */
|
||||
function tomlBasicString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function assertLoopback(localIP: string): void {
|
||||
if (!LOOPBACK_IPS.includes(localIP)) {
|
||||
throw new FrpcTomlError(
|
||||
`localIP "${localIP}" is not loopback — frpc must forward only to the local base app (anti-SSRF)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertSubdomain(subdomain: string): void {
|
||||
if (typeof subdomain !== 'string' || subdomain.length === 0) {
|
||||
throw new FrpcTomlError('subdomain is required')
|
||||
}
|
||||
if (subdomain.length > MAX_LABEL_LEN || !LABEL_RE.test(subdomain)) {
|
||||
throw new FrpcTomlError(
|
||||
`subdomain "${subdomain}" is not a valid DNS label (alnum + internal hyphens, 1-63 chars)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertLocalPort(localPort: number): void {
|
||||
if (!Number.isInteger(localPort) || localPort < MIN_PORT || localPort > MAX_PORT) {
|
||||
throw new FrpcTomlError(
|
||||
`localPort must be an integer in ${MIN_PORT}-${MAX_PORT}, got ${localPort}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a validated frp v0.61 `frpc.toml` for the native mTLS tunnel. Throws `FrpcTomlError` on any
|
||||
* invalid input (fail-fast). The returned string is the full config file contents.
|
||||
*/
|
||||
export function buildNativeFrpcToml(opts: NativeFrpcOptions): string {
|
||||
assertSubdomain(opts.subdomain)
|
||||
assertLocalPort(opts.localPort)
|
||||
assertPresent('authToken', opts.authToken)
|
||||
assertPresent('certFile', opts.certFile)
|
||||
assertPresent('keyFile', opts.keyFile)
|
||||
assertPresent('trustedCaFile', opts.trustedCaFile)
|
||||
|
||||
const serverAddr = opts.serverAddr ?? DEFAULT_SERVER_ADDR
|
||||
assertPresent('serverAddr', serverAddr)
|
||||
|
||||
const localIP = opts.localIP ?? DEFAULT_LOCAL_IP
|
||||
assertLoopback(localIP)
|
||||
|
||||
const lines: readonly string[] = [
|
||||
`serverAddr = "${tomlBasicString(serverAddr)}"`,
|
||||
`serverPort = ${SERVER_PORT}`,
|
||||
'',
|
||||
'auth.method = "token"',
|
||||
`auth.token = "${tomlBasicString(opts.authToken)}"`,
|
||||
'',
|
||||
'# control-channel mTLS: present this host frp-client cert; verify the frps control cert',
|
||||
'transport.tls.enable = true',
|
||||
`transport.tls.serverName = "${TLS_SERVER_NAME}"`,
|
||||
'transport.tls.disableCustomTLSFirstByte = true',
|
||||
`transport.tls.certFile = "${tomlBasicString(opts.certFile)}"`,
|
||||
`transport.tls.keyFile = "${tomlBasicString(opts.keyFile)}"`,
|
||||
`transport.tls.trustedCaFile = "${tomlBasicString(opts.trustedCaFile)}"`,
|
||||
'',
|
||||
'loginFailExit = false',
|
||||
'',
|
||||
'[[proxies]]',
|
||||
`name = "${opts.subdomain}"`,
|
||||
'type = "http"',
|
||||
`localIP = "${localIP}"`,
|
||||
`localPort = ${opts.localPort}`,
|
||||
`subdomain = "${opts.subdomain}"`,
|
||||
'',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
152
agent/src/transport/runTunnel.ts
Normal file
152
agent/src/transport/runTunnel.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Long-running tunnel supervisor — PLAN_RELAY_PHASE1 C2. Ports the PROVEN cafeDemo assembly
|
||||
* (dialRelay → holdTunnel → createStreamRouter → dialLoopback, plus heartbeat) into a supervised
|
||||
* loop that survives disconnects: exponential backoff reconnect (T10 policy), heartbeat liveness
|
||||
* (T9), and GOAWAY/revocation-aware teardown (T14, INV12 — a revoked host NEVER reconnects).
|
||||
*
|
||||
* All IO is injectable via `RunTunnelDeps` so the loop is unit-testable with fakes (see cafeDemo);
|
||||
* the two-arg `runTunnel(cfg, ks)` default path wires the real `ws` sockets. INV2 is preserved: the
|
||||
* router splices OPAQUE bytes (identityTransform) — no terminal parsing happens here.
|
||||
*/
|
||||
import { WebSocket } from 'ws'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { createLogger, type Logger } from '../log/logger.js'
|
||||
import { createRevocationState, applyGoAway } from '../lifecycle/revocation.js'
|
||||
import { dialRelay, type TlsWsConstructor } from './dial.js'
|
||||
import { dialLoopback, type DialLoopback, type WsConstructor } from './loopback.js'
|
||||
import { holdTunnel, type Tunnel } from './tunnel.js'
|
||||
import { createStreamRouter, identityTransform } from './streamRouter.js'
|
||||
import { createHeartbeat } from './heartbeat.js'
|
||||
import { createBackoff, reconnectLoop, type BackoffPolicy, type Sleep } from './backoff.js'
|
||||
import type { TimerLike, WsLike } from './seams.js'
|
||||
|
||||
/** Handle returned by `runTunnel`: stop the supervisor, or await its terminal exit code. */
|
||||
export interface TunnelHandle {
|
||||
/** Request graceful shutdown; resolves once the supervisor loop has fully stopped. */
|
||||
stop(): Promise<void>
|
||||
/** Resolves with a process exit code when the loop ends (stopped or host revoked ⇒ 0). */
|
||||
readonly done: Promise<number>
|
||||
}
|
||||
|
||||
/** Injectable seams for the supervisor. All optional; unset fields default to real `ws` IO. */
|
||||
export interface RunTunnelDeps {
|
||||
connectRelay(): Promise<WsLike>
|
||||
dialLoopback: DialLoopback
|
||||
logger: Logger
|
||||
timer: TimerLike
|
||||
sleep: Sleep
|
||||
backoff: BackoffPolicy
|
||||
}
|
||||
|
||||
const realTimer: TimerLike = {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
function resolveDeps(cfg: AgentConfig, ks: Keystore, o?: Partial<RunTunnelDeps>): RunTunnelDeps {
|
||||
return {
|
||||
connectRelay:
|
||||
o?.connectRelay ?? (() => dialRelay(cfg, ks, { Ctor: WebSocket as unknown as TlsWsConstructor })),
|
||||
dialLoopback: o?.dialLoopback ?? dialLoopback(cfg.localTargetUrl, WebSocket as unknown as WsConstructor),
|
||||
logger: o?.logger ?? createLogger('info'),
|
||||
timer: o?.timer ?? realTimer,
|
||||
sleep: o?.sleep ?? realSleep,
|
||||
backoff: o?.backoff ?? createBackoff({ jitter: true }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the supervised tunnel. Returns immediately with a handle; the reconnect loop runs in the
|
||||
* background. Never awaits the first connection (an unreachable relay would otherwise hang the
|
||||
* caller with no way to `stop()`).
|
||||
*/
|
||||
export async function runTunnel(
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
overrides?: Partial<RunTunnelDeps>,
|
||||
): Promise<TunnelHandle> {
|
||||
const { connectRelay, dialLoopback: dialLb, logger, timer, sleep, backoff } = resolveDeps(cfg, ks, overrides)
|
||||
|
||||
let stopped = false
|
||||
let currentTunnel: Tunnel | null = null
|
||||
let currentSocket: WsLike | null = null
|
||||
|
||||
// INV12: revocation tears the live tunnel down immediately and suppresses all reconnects.
|
||||
const revocation = createRevocationState(() => currentTunnel?.close())
|
||||
const shouldStop = (): boolean => stopped || revocation.isRevoked()
|
||||
|
||||
async function dialTunnel(): Promise<Tunnel> {
|
||||
const socket = await connectRelay()
|
||||
currentSocket = socket
|
||||
return holdTunnel(socket)
|
||||
}
|
||||
|
||||
/** Run ONE tunnel session; resolves when this session ends (dead heartbeat / close / GOAWAY). */
|
||||
function runSession(tunnel: Tunnel, socket: WsLike): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const router = createStreamRouter(cfg, tunnel, dialLb, identityTransform, logger)
|
||||
const heartbeat = createHeartbeat(tunnel, { timer })
|
||||
tunnel.dispatchTo(router, heartbeat)
|
||||
|
||||
let settled = false
|
||||
const endSession = (): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
heartbeat.stop()
|
||||
resolve()
|
||||
}
|
||||
|
||||
heartbeat.onDead(() => {
|
||||
logger.log('warn', 'heartbeat missed — tunnel presumed down, will reconnect')
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
socket.on('close', () => endSession())
|
||||
socket.on('error', () => {
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
tunnel.onGoAway((reason) => {
|
||||
const action = applyGoAway(reason, revocation) // 'revoked' ⇒ no reconnect (INV12)
|
||||
logger.log('info', 'received GOAWAY', { action })
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
|
||||
heartbeat.start()
|
||||
})
|
||||
}
|
||||
|
||||
async function supervise(): Promise<number> {
|
||||
while (!shouldStop()) {
|
||||
const tunnel = await reconnectLoop(dialTunnel, backoff, shouldStop, sleep)
|
||||
if (tunnel === null || currentSocket === null) break
|
||||
if (shouldStop()) {
|
||||
// stop()/revoke raced with the in-flight dial — discard the fresh tunnel.
|
||||
tunnel.close()
|
||||
break
|
||||
}
|
||||
currentTunnel = tunnel
|
||||
logger.log('info', 'relay tunnel established')
|
||||
await runSession(tunnel, currentSocket)
|
||||
currentTunnel = null
|
||||
currentSocket = null
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const done = supervise()
|
||||
|
||||
return {
|
||||
async stop(): Promise<void> {
|
||||
stopped = true
|
||||
currentTunnel?.close()
|
||||
await done
|
||||
},
|
||||
done,
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,20 @@ describe('AgentConfig validation', () => {
|
||||
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://127.5.5.5:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://[::1]:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://10.0.0.5:3000')).toBe(false)
|
||||
expect(isLoopbackWsUrl('wss://127.0.0.1:3000')).toBe(false)
|
||||
})
|
||||
|
||||
it('REGRESSION: rejects a crafted suffixed-hostname target (anti-SSRF bypass)', () => {
|
||||
// Hostname, not a loopback literal — the outbound dial would DNS-resolve and connect out.
|
||||
expect(isLoopbackWsUrl('ws://127.0.0.1.attacker.example.com:3000/x')).toBe(false)
|
||||
expect(isLoopbackWsUrl('ws://127.evil.net:3000')).toBe(false)
|
||||
expect(() =>
|
||||
AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://127.0.0.1.attacker.example.com:3000' }),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('loadAgentConfig fails fast on a missing relayUrl', () => {
|
||||
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Keystore } from '../src/keys/keystore.js'
|
||||
import type { AgentIdentity } from '../src/keys/identity.js'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
|
||||
import type { InstallOptions } from '../src/service/install.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
@@ -14,8 +15,9 @@ const CFG: AgentConfig = {
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function fakeIdentity(): AgentIdentity {
|
||||
function fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
|
||||
return {
|
||||
alg,
|
||||
publicKey: new Uint8Array(32),
|
||||
enrollFpr: 'fpr',
|
||||
sign: () => new Uint8Array(64),
|
||||
@@ -24,10 +26,10 @@ function fakeIdentity(): AgentIdentity {
|
||||
}
|
||||
}
|
||||
|
||||
function fakeKeystore(enrolled: boolean): Keystore {
|
||||
function fakeKeystore(enrolled: boolean, alg: AgentIdentity['alg'] = 'ed25519'): Keystore {
|
||||
return {
|
||||
saveIdentity: vi.fn(),
|
||||
loadIdentity: () => (enrolled ? fakeIdentity() : null),
|
||||
loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
|
||||
saveCert: vi.fn(),
|
||||
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
|
||||
saveContentSecret: vi.fn(),
|
||||
@@ -35,12 +37,19 @@ function fakeKeystore(enrolled: boolean): Keystore {
|
||||
}
|
||||
}
|
||||
|
||||
const NATIVE_OPTIONS: InstallOptions = {
|
||||
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
|
||||
domain: 'yaojia.wang',
|
||||
zone: 'terminal',
|
||||
}
|
||||
|
||||
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
|
||||
const out: string[] = []
|
||||
const d: CliDeps = {
|
||||
loadConfig: () => CFG,
|
||||
openKeystore: () => fakeKeystore(enrolled),
|
||||
generateIdentity: fakeIdentity,
|
||||
generateIdentity: () => fakeIdentity('ed25519'),
|
||||
generateP256Identity: () => fakeIdentity('p256'),
|
||||
redeem: async (): Promise<EnrollResult> => ({
|
||||
hostId: 'h-1',
|
||||
subdomain: 'host-42',
|
||||
@@ -48,7 +57,13 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
|
||||
caChain: 'CA',
|
||||
hostContentSecret: new Uint8Array([1]),
|
||||
}),
|
||||
enrollNative: async () => ({ hostId: 'h-1', subdomain: 'host-42' }),
|
||||
provisionFrpc: async () => '/opt/frpc',
|
||||
writeFrpcConfig: vi.fn(),
|
||||
nativeConfigExists: () => false,
|
||||
runTunnel: async () => 0,
|
||||
superviseFrpc: async () => 0,
|
||||
resolveInstallOptions: () => NATIVE_OPTIONS,
|
||||
installService: vi.fn(async () => {}),
|
||||
uninstallService: vi.fn(async () => {}),
|
||||
print: (l) => out.push(l),
|
||||
@@ -79,7 +94,7 @@ describe('parseArgs (T5)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCli (T5)', () => {
|
||||
describe('runCli — legacy relay pair (no --install)', () => {
|
||||
it('pair happy path calls redeem and prints no secrets', async () => {
|
||||
const { d, out } = deps()
|
||||
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
|
||||
@@ -87,12 +102,97 @@ describe('runCli (T5)', () => {
|
||||
expect(out.join('\n')).toContain('host-42')
|
||||
expect(out.join('\n')).not.toContain('PEM')
|
||||
})
|
||||
})
|
||||
|
||||
it('pair --install installs the service', async () => {
|
||||
const install = vi.fn(async () => {})
|
||||
const { d } = deps({ installService: install })
|
||||
describe('runCli — native pair --install onboard (B5)', () => {
|
||||
it('wires keygen(P-256)→enroll→provisionFrpc→write toml+env→install both→print URL, in order', async () => {
|
||||
const calls: string[] = []
|
||||
const enrolledIds: AgentIdentity[] = []
|
||||
const generateP256Identity = vi.fn(() => {
|
||||
calls.push('keygen')
|
||||
return fakeIdentity('p256')
|
||||
})
|
||||
const enrollNative = vi.fn(async (_cfg: AgentConfig, _code: string, id: AgentIdentity) => {
|
||||
calls.push('enroll')
|
||||
enrolledIds.push(id)
|
||||
return { hostId: 'h-1', subdomain: 'host-42' }
|
||||
})
|
||||
const provisionFrpc = vi.fn(async () => {
|
||||
calls.push('provision')
|
||||
return '/opt/frpc'
|
||||
})
|
||||
const writeFrpcConfig = vi.fn(() => {
|
||||
calls.push('writeConfig')
|
||||
})
|
||||
const installService = vi.fn(async () => {
|
||||
calls.push('install')
|
||||
})
|
||||
const { d, out } = deps({
|
||||
generateP256Identity,
|
||||
enrollNative,
|
||||
provisionFrpc,
|
||||
writeFrpcConfig,
|
||||
installService,
|
||||
})
|
||||
|
||||
const code = await runCli(parseArgs(['pair', 'ABCD-1234', '--install']), d)
|
||||
|
||||
expect(code).toBe(0)
|
||||
expect(calls).toEqual(['keygen', 'enroll', 'provision', 'writeConfig', 'install'])
|
||||
// CSR is built from a P-256 identity (FIX H-host-2)
|
||||
expect(enrolledIds[0]!.alg).toBe('p256')
|
||||
// enroll seam received the pairing code
|
||||
expect(enrollNative).toHaveBeenCalledWith(CFG, 'ABCD-1234', expect.anything(), expect.anything())
|
||||
// both units installed with the resolved options (env routed to base-app by installService)
|
||||
expect(installService).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
|
||||
// frpc.toml written for the returned subdomain
|
||||
expect(writeFrpcConfig).toHaveBeenCalledWith(CFG, 'host-42')
|
||||
// prints the final tunnel URL
|
||||
expect(out.join('\n')).toContain('https://host-42.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('does NOT use the legacy relay redeem path on --install', async () => {
|
||||
const redeem = vi.fn()
|
||||
const { d } = deps({ redeem: redeem as unknown as CliDeps['redeem'] })
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
expect(install).toHaveBeenCalledOnce()
|
||||
expect(redeem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves the freshly generated P-256 identity before enrolling', async () => {
|
||||
const ks = fakeKeystore(false)
|
||||
const { d } = deps({ openKeystore: () => ks })
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
expect(ks.saveIdentity).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a native install whose zone is not `terminal` (FIX L-host-zone)', async () => {
|
||||
const { d } = deps({
|
||||
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'term' }),
|
||||
})
|
||||
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toThrow(/terminal/)
|
||||
})
|
||||
|
||||
it('rejects a native install with no TUNNEL_DOMAIN (cannot form the origin)', async () => {
|
||||
const { d } = deps({ resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }) })
|
||||
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toBeInstanceOf(CliUsageError)
|
||||
})
|
||||
|
||||
it('prints no key/cert material during --install (INV9)', async () => {
|
||||
const { d, out } = deps()
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
const joined = out.join('\n')
|
||||
expect(joined).not.toContain('PEM')
|
||||
expect(joined).not.toContain('CA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCli — install / run / status', () => {
|
||||
it('install threads the resolved InstallOptions into installService (S2 env injection)', async () => {
|
||||
const install = vi.fn(async () => {})
|
||||
const { d } = deps({ resolveInstallOptions: () => NATIVE_OPTIONS, installService: install })
|
||||
const code = await runCli(parseArgs(['install']), d)
|
||||
expect(code).toBe(0)
|
||||
expect(install).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
|
||||
})
|
||||
|
||||
it('run before pairing fails fast', async () => {
|
||||
@@ -100,6 +200,48 @@ describe('runCli (T5)', () => {
|
||||
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
|
||||
})
|
||||
|
||||
it('legacy run (Ed25519 identity, no frpc.toml) drives runTunnel — not frpc supervision', async () => {
|
||||
const runTunnel = vi.fn(async () => 0)
|
||||
const superviseFrpc = vi.fn(async () => 0)
|
||||
const { d } = deps(
|
||||
{ runTunnel, superviseFrpc, nativeConfigExists: () => false },
|
||||
true, // enrolled with the default Ed25519 identity
|
||||
)
|
||||
const code = await runCli({ command: 'run', flags: {} }, d)
|
||||
expect(code).toBe(0)
|
||||
expect(runTunnel).toHaveBeenCalledWith(CFG, expect.anything())
|
||||
expect(superviseFrpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('native run (P-256 identity + frpc.toml) supervises frpc — not the legacy relay', async () => {
|
||||
const runTunnel = vi.fn(async () => 0)
|
||||
const superviseFrpc = vi.fn(async () => 0)
|
||||
const { d } = deps({
|
||||
openKeystore: () => fakeKeystore(true, 'p256'),
|
||||
nativeConfigExists: () => true,
|
||||
runTunnel,
|
||||
superviseFrpc,
|
||||
})
|
||||
const code = await runCli({ command: 'run', flags: {} }, d)
|
||||
expect(code).toBe(0)
|
||||
expect(superviseFrpc).toHaveBeenCalledWith(CFG, expect.anything())
|
||||
expect(runTunnel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('native identity but missing frpc.toml falls back to the legacy relay path', async () => {
|
||||
const runTunnel = vi.fn(async () => 0)
|
||||
const superviseFrpc = vi.fn(async () => 0)
|
||||
const { d } = deps({
|
||||
openKeystore: () => fakeKeystore(true, 'p256'),
|
||||
nativeConfigExists: () => false,
|
||||
runTunnel,
|
||||
superviseFrpc,
|
||||
})
|
||||
await runCli({ command: 'run', flags: {} }, d)
|
||||
expect(runTunnel).toHaveBeenCalled()
|
||||
expect(superviseFrpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('status prints no key/cert material (INV9)', async () => {
|
||||
const { d, out } = deps({}, true)
|
||||
await runCli({ command: 'status', flags: {} }, d)
|
||||
|
||||
@@ -1,8 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { X509Certificate, createPublicKey, verify } from 'node:crypto'
|
||||
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
|
||||
import { buildCsr } from '../src/enroll/csr.js'
|
||||
|
||||
// --- minimal DER reader (test-only) — walks the PKCS#10 outer SEQUENCE into its 3 children -------
|
||||
interface Tlv {
|
||||
readonly tag: number
|
||||
/** the full tag+length+value bytes (what was signed, for the CertificationRequestInfo). */
|
||||
readonly tlv: Uint8Array
|
||||
readonly content: Uint8Array
|
||||
}
|
||||
|
||||
function readTlv(buf: Uint8Array, off: number): { node: Tlv; next: number } {
|
||||
const tag = buf[off]!
|
||||
let i = off + 1
|
||||
const first = buf[i]!
|
||||
let len: number
|
||||
if (first < 0x80) {
|
||||
len = first
|
||||
i += 1
|
||||
} else {
|
||||
const n = first & 0x7f
|
||||
len = 0
|
||||
for (let k = 0; k < n; k++) len = (len << 8) | buf[i + 1 + k]!
|
||||
i += 1 + n
|
||||
}
|
||||
return { node: { tag, tlv: buf.subarray(off, i + len), content: buf.subarray(i, i + len) }, next: i + len }
|
||||
}
|
||||
|
||||
function pemToDer(pem: string): Uint8Array {
|
||||
const b64 = pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '')
|
||||
return new Uint8Array(Buffer.from(b64, 'base64'))
|
||||
}
|
||||
|
||||
/** The three children of the PKCS#10 outer SEQUENCE: [certificationRequestInfo, sigAlg, signature]. */
|
||||
function csrChildren(pem: string): readonly Tlv[] {
|
||||
const der = pemToDer(pem)
|
||||
const outer = readTlv(der, 0).node
|
||||
const children: Tlv[] = []
|
||||
let p = 0
|
||||
while (p < outer.content.length) {
|
||||
const { node, next } = readTlv(outer.content, p)
|
||||
children.push(node)
|
||||
p = next
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
describe('PKCS#10 CSR (T4)', () => {
|
||||
it('emits a PEM CERTIFICATE REQUEST', () => {
|
||||
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
|
||||
@@ -30,3 +74,50 @@ describe('PKCS#10 CSR (T4)', () => {
|
||||
expect(typeof X509Certificate).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('P-256 PKCS#10 CSR (FIX H-host-2)', () => {
|
||||
it('emits a PEM CERTIFICATE REQUEST for a P-256 identity', () => {
|
||||
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
|
||||
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
|
||||
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
|
||||
expect(csr).not.toContain('PRIVATE KEY')
|
||||
})
|
||||
|
||||
it('round-trips as a valid PKCS#10: signatureAlgorithm is ecdsa-with-SHA256', () => {
|
||||
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
|
||||
const [, sigAlg] = csrChildren(csr)
|
||||
// sigAlg = SEQUENCE { OID 1.2.840.10045.4.3.2 } (no parameters, RFC 5758 §3.2)
|
||||
const oidTlv = readTlv(sigAlg!.content, 0).node
|
||||
expect(Array.from(oidTlv.content)).toEqual([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
|
||||
// no parameters: the sigAlg SEQUENCE holds ONLY the OID.
|
||||
expect(oidTlv.tlv.length).toBe(sigAlg!.content.length)
|
||||
})
|
||||
|
||||
it('self-signature verifies over the CertificationRequestInfo (verifyCsrPoPEc semantics)', () => {
|
||||
const id = generateP256Identity()
|
||||
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
|
||||
const [reqInfo, , sigVal] = csrChildren(csr)
|
||||
// signatureValue BIT STRING content = 0x00 (unused bits) || DER ECDSA-Sig-Value.
|
||||
expect(sigVal!.tag).toBe(0x03)
|
||||
const signature = sigVal!.content.subarray(1)
|
||||
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
|
||||
// Verify over the EXACT CertificationRequestInfo bytes that buildCsr signed.
|
||||
expect(verify('sha256', reqInfo!.tlv, pub, signature)).toBe(true)
|
||||
// Tampering with the signed body breaks PoP.
|
||||
const tampered = Uint8Array.from(reqInfo!.tlv)
|
||||
tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 0xff
|
||||
expect(verify('sha256', tampered, pub, signature)).toBe(false)
|
||||
})
|
||||
|
||||
it('embeds the identity EC SPKI verbatim as the CSR subjectPublicKeyInfo', () => {
|
||||
const id = generateP256Identity()
|
||||
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
|
||||
const [reqInfo] = csrChildren(csr)
|
||||
// requestInfo = SEQUENCE { version, name, spki, [0] attributes }; the spki is the 3rd child.
|
||||
const inner = reqInfo!.content
|
||||
const version = readTlv(inner, 0)
|
||||
const name = readTlv(inner, version.next)
|
||||
const spki = readTlv(inner, name.next).node
|
||||
expect(Buffer.from(spki.tlv).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
103
agent/test/deps.test.ts
Normal file
103
agent/test/deps.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* B4/H4 wiring test — closes the frpc-log capture loop that `HealthReport.healthy` depends on.
|
||||
*
|
||||
* Regression guarded: nothing used to WRITE `<stateDir>/frpc.log`, so `readFrpcLog` always returned
|
||||
* '' and `frpcProxyStarted` was permanently false — health could never be true. This exercises the
|
||||
* real production path end-to-end: `createFileLoggingSpawn` (the spawn `superviseNative` injects)
|
||||
* tees a child's stdout into the exact file `readFrpcLog` scans, and `frpcProxyStarted` detects the
|
||||
* "start proxy success" line. Writer path and reader path share `frpcLogPath`, so they can't diverge.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createFileLoggingSpawn, type FrpcChild } from '../src/transport/frpSupervise.js'
|
||||
import { frpcLogPath, readFrpcLog } from '../src/cli/deps.js'
|
||||
import { frpcProxyStarted } from '../src/health/probe.js'
|
||||
|
||||
const SUCCESS_LINE = '[web-terminal] start proxy success'
|
||||
|
||||
/** Poll `predicate` until true or `timeoutMs` elapses (real IO flush is async). */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true
|
||||
await new Promise((r) => setTimeout(r, 25))
|
||||
}
|
||||
return predicate()
|
||||
}
|
||||
|
||||
/** Write an executable fake `frpc` (node shebang) that repeatedly prints the success line to stdout. */
|
||||
function writeFakeFrpc(dir: string): string {
|
||||
const bin = join(dir, 'fake-frpc.mjs')
|
||||
const script =
|
||||
`#!${process.execPath}\n` +
|
||||
`process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)})\n` +
|
||||
`setInterval(() => process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)}), 100)\n`
|
||||
writeFileSync(bin, script, { mode: 0o755 })
|
||||
return bin
|
||||
}
|
||||
|
||||
describe('B4/H4 frpc-log capture wiring (createFileLoggingSpawn ↔ readFrpcLog)', () => {
|
||||
const dirs: string[] = []
|
||||
let child: FrpcChild | null = null
|
||||
|
||||
afterEach(() => {
|
||||
child?.kill()
|
||||
child = null
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('tees the frpc child stdout into the file readFrpcLog scans → proxyStarted becomes true', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
|
||||
dirs.push(dir)
|
||||
|
||||
// Before any child runs, the log is empty and the proxy is not started.
|
||||
expect(readFrpcLog(dir)).toBe('')
|
||||
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
|
||||
|
||||
// Spawn via the SAME factory superviseNative injects, pointed at the SAME path it reads.
|
||||
const bin = writeFakeFrpc(dir)
|
||||
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
|
||||
child = spawn(bin, join(dir, 'frpc.toml'))
|
||||
|
||||
const detected = await waitFor(() => frpcProxyStarted(readFrpcLog(dir)))
|
||||
expect(detected).toBe(true)
|
||||
expect(readFrpcLog(dir)).toContain('start proxy success')
|
||||
})
|
||||
|
||||
it('createFileLoggingSpawn truncates a stale log so a dead child’s success line is not reused', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
|
||||
dirs.push(dir)
|
||||
|
||||
// Simulate a leftover log from a previous (now dead) frpc that had succeeded.
|
||||
writeFileSync(frpcLogPath(dir), `${SUCCESS_LINE}\n`)
|
||||
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(true)
|
||||
|
||||
// A fresh spawn whose child never prints the line must NOT keep reporting the stale success.
|
||||
const bin = join(dir, 'silent-frpc.mjs')
|
||||
writeFileSync(bin, `#!${process.execPath}\nsetInterval(() => {}, 100)\n`, { mode: 0o755 })
|
||||
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
|
||||
child = spawn(bin, join(dir, 'frpc.toml'))
|
||||
|
||||
// Truncate-on-spawn clears the stale line; the silent child adds none.
|
||||
const cleared = await waitFor(() => readFrpcLog(dir) === '')
|
||||
expect(cleared).toBe(true)
|
||||
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpcLogPath / readFrpcLog (deterministic)', () => {
|
||||
it('frpcLogPath joins frpc.log under the state dir', () => {
|
||||
expect(frpcLogPath('/state/x')).toBe(join('/state/x', 'frpc.log'))
|
||||
})
|
||||
|
||||
it('readFrpcLog returns "" when the log file does not exist', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
|
||||
try {
|
||||
expect(readFrpcLog(dir)).toBe('')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
188
agent/test/frpSupervise.test.ts
Normal file
188
agent/test/frpSupervise.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
import { createBackoff } from '../src/transport/backoff.js'
|
||||
import {
|
||||
STABLE_RUN_MS,
|
||||
superviseFrpc,
|
||||
type FrpcChild,
|
||||
type SpawnFrpc,
|
||||
} from '../src/transport/frpSupervise.js'
|
||||
|
||||
const silentLogger = createLogger('error', () => {})
|
||||
|
||||
/**
|
||||
* A fake frpc child whose exit is driven from the test. `kill()` models a real process: it dies,
|
||||
* firing the exit handler (so the supervisor's `stop()` — which kills the live child — can unblock).
|
||||
* exit/kill fire the handler at most once.
|
||||
*/
|
||||
function makeChild(): { child: FrpcChild; exit: (code: number | null) => void; killed: boolean } {
|
||||
let onExit: ((code: number | null) => void) | null = null
|
||||
let alive = true
|
||||
const state = { killed: false }
|
||||
const fire = (code: number | null): void => {
|
||||
if (!alive) return
|
||||
alive = false
|
||||
onExit?.(code)
|
||||
}
|
||||
return {
|
||||
child: {
|
||||
onExit: (cb) => {
|
||||
onExit = cb
|
||||
},
|
||||
isAlive: () => alive,
|
||||
kill: () => {
|
||||
state.killed = true
|
||||
fire(null)
|
||||
},
|
||||
},
|
||||
exit: (code) => fire(code),
|
||||
get killed() {
|
||||
return state.killed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
|
||||
it('spawns the frpc binary with -c <toml> via the child seam', async () => {
|
||||
const spawn: SpawnFrpc = vi.fn(() => makeChild().child)
|
||||
superviseFrpc('/opt/agent/bin/frpc', '/state/frpc.toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(spawn).toHaveBeenCalledWith('/opt/agent/bin/frpc', '/state/frpc.toml')
|
||||
})
|
||||
|
||||
it('restarts the child on exit, backing off 1s → 2s → 4s', async () => {
|
||||
const children = [makeChild(), makeChild(), makeChild(), makeChild()]
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = () => children[n++]!.child
|
||||
const sleeps: number[] = []
|
||||
// now() fixed so no run counts as "stable" ⇒ backoff monotonically increases.
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
backoff: createBackoff(),
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms)
|
||||
},
|
||||
logger: silentLogger,
|
||||
now: () => 1000,
|
||||
})
|
||||
|
||||
// Crash three times; each crash schedules the next spawn after the growing backoff.
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
children[i]!.exit(1)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(sleeps).toEqual([1000, 2000, 4000])
|
||||
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('resets the backoff after a run that stayed up past the stability window', async () => {
|
||||
const children = [makeChild(), makeChild(), makeChild()]
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = () => children[n++]!.child
|
||||
const sleeps: number[] = []
|
||||
let clock = 0
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
backoff: createBackoff(),
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms)
|
||||
},
|
||||
logger: silentLogger,
|
||||
now: () => clock,
|
||||
})
|
||||
|
||||
// First run crashes instantly ⇒ backoff 1s.
|
||||
children[0]!.exit(1)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
// Second run stays up past STABLE_RUN_MS before dying ⇒ backoff resets to 1s (not 2s).
|
||||
clock += STABLE_RUN_MS + 1
|
||||
children[1]!.exit(1)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sleeps).toEqual([1000, 1000])
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('stop() halts the loop and kills the live child; done resolves 0', async () => {
|
||||
const c = makeChild()
|
||||
const spawn: SpawnFrpc = () => c.child
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(handle.isChildAlive()).toBe(true)
|
||||
|
||||
// stop() kills the child; that fires the exit handler and the loop observes `stopped` → breaks.
|
||||
const stopping = handle.stop()
|
||||
c.exit(null)
|
||||
await expect(stopping).resolves.toBeUndefined()
|
||||
await expect(handle.done).resolves.toBe(0)
|
||||
expect(c.killed).toBe(true)
|
||||
})
|
||||
|
||||
it('does not restart after stop (no spawn past shutdown)', async () => {
|
||||
const first = makeChild()
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
})
|
||||
await Promise.resolve()
|
||||
const stopping = handle.stop()
|
||||
first.exit(0)
|
||||
await stopping
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restartChild() kills the running child so the loop respawns onto the fresh cert files', async () => {
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||
const children = [makeChild(), makeChild()]
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = () => children[n++]!.child
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
now: () => 1_000_000, // fixed clock: run counts as unstable, but sleep is a no-op → respawn is immediate
|
||||
})
|
||||
await flush()
|
||||
expect(handle.isChildAlive()).toBe(true) // child[0]
|
||||
|
||||
handle.restartChild() // A5: rotator calls this after a cert rotation to reload the new leaf
|
||||
expect(children[0]!.killed).toBe(true)
|
||||
|
||||
await flush()
|
||||
expect(n).toBe(2) // child[1] spawned — frpc now reads the rotated cert on disk
|
||||
expect(handle.isChildAlive()).toBe(true)
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('restartChild() after stop is a no-op (no spawn past shutdown)', async () => {
|
||||
const first = makeChild()
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
})
|
||||
await Promise.resolve()
|
||||
const stopping = handle.stop()
|
||||
first.exit(0)
|
||||
await stopping
|
||||
handle.restartChild() // must not resurrect the supervisor
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
416
agent/test/frpcBinary.test.ts
Normal file
416
agent/test/frpcBinary.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import {
|
||||
detectFrpcPlatform,
|
||||
FRPC_PLATFORMS,
|
||||
FRPC_RELEASES,
|
||||
provisionFrpc,
|
||||
type FrpcPlatform,
|
||||
type FrpcReleaseRef,
|
||||
type ProvisionFrpcDeps,
|
||||
} from '../src/provision/frpcBinary.js'
|
||||
import {
|
||||
extractTarFileByBasename,
|
||||
extractFrpcBinary,
|
||||
TarExtractError,
|
||||
} from '../src/provision/untar.js'
|
||||
|
||||
function sha256Hex(data: Uint8Array): string {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-test tar/gzip fixture builder — hand-builds real USTAR blocks so the
|
||||
// extractor is exercised against the SAME on-disk layout frp ships (dir entry
|
||||
// + regular files), with zero network access.
|
||||
// ---------------------------------------------------------------------------
|
||||
const TAR_BLOCK = 512
|
||||
const TYPE_FILE = '0'
|
||||
const TYPE_DIR = '5'
|
||||
|
||||
interface TarEntrySpec {
|
||||
readonly name: string
|
||||
readonly content: Uint8Array
|
||||
readonly typeflag?: string
|
||||
}
|
||||
|
||||
function writeOctalField(block: Uint8Array, offset: number, len: number, value: number): void {
|
||||
const s = value.toString(8).padStart(len - 1, '0')
|
||||
block.set(new TextEncoder().encode(s), offset)
|
||||
block[offset + len - 1] = 0 // NUL terminator
|
||||
}
|
||||
|
||||
function tarHeader(name: string, size: number, typeflag: string): Uint8Array {
|
||||
const block = new Uint8Array(TAR_BLOCK)
|
||||
const enc = new TextEncoder()
|
||||
block.set(enc.encode(name).subarray(0, 100), 0)
|
||||
writeOctalField(block, 100, 8, 0o755) // mode
|
||||
writeOctalField(block, 108, 8, 0) // uid
|
||||
writeOctalField(block, 116, 8, 0) // gid
|
||||
writeOctalField(block, 124, 12, size) // size
|
||||
writeOctalField(block, 136, 12, 0) // mtime
|
||||
block[156] = typeflag.charCodeAt(0)
|
||||
block.set(enc.encode('ustar'), 257) // magic "ustar\0"
|
||||
block[263] = 0x30 // version "00"
|
||||
block[264] = 0x30
|
||||
// checksum: fields spaces during compute, then written as 6 octal + NUL + space
|
||||
for (let i = 148; i < 156; i++) block[i] = 0x20
|
||||
let sum = 0
|
||||
for (let i = 0; i < TAR_BLOCK; i++) sum += block[i] ?? 0
|
||||
block.set(enc.encode(sum.toString(8).padStart(6, '0')), 148)
|
||||
block[154] = 0
|
||||
block[155] = 0x20
|
||||
return block
|
||||
}
|
||||
|
||||
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((n, p) => n + p.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const p of parts) {
|
||||
out.set(p, off)
|
||||
off += p.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function makeTar(entries: readonly TarEntrySpec[]): Uint8Array {
|
||||
const parts: Uint8Array[] = []
|
||||
for (const e of entries) {
|
||||
parts.push(tarHeader(e.name, e.content.length, e.typeflag ?? TYPE_FILE))
|
||||
parts.push(e.content)
|
||||
const pad = (TAR_BLOCK - (e.content.length % TAR_BLOCK)) % TAR_BLOCK
|
||||
if (pad > 0) parts.push(new Uint8Array(pad))
|
||||
}
|
||||
parts.push(new Uint8Array(TAR_BLOCK * 2)) // end-of-archive: two zero blocks
|
||||
return concatBytes(parts)
|
||||
}
|
||||
|
||||
function makeTarGz(entries: readonly TarEntrySpec[]): Uint8Array {
|
||||
return new Uint8Array(gzipSync(makeTar(entries)))
|
||||
}
|
||||
|
||||
const FRPC_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0xde, 0xad, 0xbe, 0xef]) // fake "ELF" frpc
|
||||
const FRPS_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x11, 0x22, 0x33]) // decoy frps
|
||||
|
||||
/** A realistic frp archive layout: dir entry + frpc + decoy frps + LICENSE. */
|
||||
function realisticFrpTarGz(prefix = 'frp_0.61.1_darwin_arm64'): Uint8Array {
|
||||
return makeTarGz([
|
||||
{ name: `${prefix}/`, content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||||
{ name: `${prefix}/frps`, content: FRPS_BYTES },
|
||||
{ name: `${prefix}/frpc`, content: FRPC_BYTES },
|
||||
{ name: `${prefix}/LICENSE`, content: new TextEncoder().encode('MIT') },
|
||||
])
|
||||
}
|
||||
|
||||
interface FakeFsState {
|
||||
writes: Map<string, Uint8Array>
|
||||
renames: Array<{ from: string; to: string }>
|
||||
removed: string[]
|
||||
chmods: Array<{ path: string; mode: number }>
|
||||
mkdirs: string[]
|
||||
}
|
||||
|
||||
function makeFakeDeps(bytesByUrl: Record<string, Uint8Array>): {
|
||||
deps: ProvisionFrpcDeps
|
||||
state: FakeFsState
|
||||
fetchedUrls: string[]
|
||||
} {
|
||||
const state: FakeFsState = {
|
||||
writes: new Map(),
|
||||
renames: [],
|
||||
removed: [],
|
||||
chmods: [],
|
||||
mkdirs: [],
|
||||
}
|
||||
const fetchedUrls: string[] = []
|
||||
const deps: ProvisionFrpcDeps = {
|
||||
fetch: async (url) => {
|
||||
fetchedUrls.push(url)
|
||||
const bytes = bytesByUrl[url]
|
||||
if (!bytes) throw new Error(`test: no fixture bytes for ${url}`)
|
||||
return bytes
|
||||
},
|
||||
fs: {
|
||||
mkdir: async (dir) => {
|
||||
state.mkdirs.push(dir)
|
||||
},
|
||||
writeFile: async (path, data) => {
|
||||
state.writes.set(path, data)
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
state.renames.push({ from, to })
|
||||
const data = state.writes.get(from)
|
||||
if (data) {
|
||||
state.writes.delete(from)
|
||||
state.writes.set(to, data)
|
||||
}
|
||||
},
|
||||
chmod: async (path, mode) => {
|
||||
state.chmods.push({ path, mode })
|
||||
},
|
||||
rm: async (path) => {
|
||||
state.removed.push(path)
|
||||
state.writes.delete(path)
|
||||
},
|
||||
},
|
||||
}
|
||||
return { deps, state, fetchedUrls }
|
||||
}
|
||||
|
||||
function releaseOverride(
|
||||
platform: FrpcPlatform,
|
||||
ref: FrpcReleaseRef,
|
||||
): Record<FrpcPlatform, FrpcReleaseRef> {
|
||||
return { ...FRPC_RELEASES, [platform]: ref }
|
||||
}
|
||||
|
||||
const BIN_DIR = '/opt/wt/bin'
|
||||
const BIN_PATH = '/opt/wt/bin/frpc'
|
||||
|
||||
describe('detectFrpcPlatform (B3)', () => {
|
||||
it('maps darwin/linux × arm64/amd64 (node x64 → amd64)', () => {
|
||||
expect(detectFrpcPlatform('darwin', 'arm64')).toBe('darwin-arm64')
|
||||
expect(detectFrpcPlatform('darwin', 'x64')).toBe('darwin-amd64')
|
||||
expect(detectFrpcPlatform('linux', 'arm64')).toBe('linux-arm64')
|
||||
expect(detectFrpcPlatform('linux', 'x64')).toBe('linux-amd64')
|
||||
})
|
||||
|
||||
it('returns null for unsupported platform/arch', () => {
|
||||
expect(detectFrpcPlatform('win32', 'x64')).toBeNull()
|
||||
expect(detectFrpcPlatform('linux', 'ia32')).toBeNull()
|
||||
expect(detectFrpcPlatform('freebsd', 'arm64')).toBeNull()
|
||||
expect(detectFrpcPlatform('darwin', 'mips')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FRPC_RELEASES pinning', () => {
|
||||
it('pins a release per supported platform (https url, semver, 64-hex sha256)', () => {
|
||||
expect([...FRPC_PLATFORMS].sort()).toEqual([
|
||||
'darwin-amd64',
|
||||
'darwin-arm64',
|
||||
'linux-amd64',
|
||||
'linux-arm64',
|
||||
])
|
||||
for (const platform of FRPC_PLATFORMS) {
|
||||
const ref = FRPC_RELEASES[platform]
|
||||
expect(ref.url.startsWith('https://')).toBe(true)
|
||||
expect(ref.url.endsWith('.tar.gz')).toBe(true)
|
||||
expect(ref.version).toMatch(/^\d+\.\d+\.\d+$/)
|
||||
expect(ref.sha256).toMatch(/^[0-9a-f]{64}$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('pins REAL (non-placeholder) frp v0.61.1 checksums', () => {
|
||||
for (const platform of FRPC_PLATFORMS) {
|
||||
expect(FRPC_RELEASES[platform].sha256).not.toBe('0'.repeat(64))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractTarFileByBasename (tar path-traversal safe extractor)', () => {
|
||||
it('returns the bytes of the first regular file whose basename matches', () => {
|
||||
const tar = makeTar([
|
||||
{ name: 'frp_x/', content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
|
||||
expect(extractTarFileByBasename(tar, 'frps')).toEqual(FRPS_BYTES)
|
||||
})
|
||||
|
||||
it('does NOT match a directory entry that shares the basename', () => {
|
||||
const tar = makeTar([
|
||||
{ name: 'frpc/', content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
|
||||
})
|
||||
|
||||
it('throws when no matching file entry exists', () => {
|
||||
const tar = makeTar([{ name: 'frp_x/frps', content: FRPS_BYTES }])
|
||||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(TarExtractError)
|
||||
})
|
||||
|
||||
it('REJECTS a matching entry whose name contains a ".." traversal segment', () => {
|
||||
const tar = makeTar([{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES }])
|
||||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|traversal|\.\./i)
|
||||
})
|
||||
|
||||
it('REJECTS a matching entry with an absolute path name', () => {
|
||||
const tar = makeTar([{ name: '/etc/frpc', content: FRPC_BYTES }])
|
||||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|absolute/i)
|
||||
})
|
||||
|
||||
it('throws on a corrupt (truncated) tar rather than reading out of bounds', () => {
|
||||
const full = makeTar([{ name: 'frp_x/frpc', content: FRPC_BYTES }])
|
||||
const truncated = full.subarray(0, TAR_BLOCK + 4) // header + partial content
|
||||
expect(() => extractTarFileByBasename(truncated, 'frpc')).toThrow(TarExtractError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFrpcBinary (gunzip + extract)', () => {
|
||||
it('gunzips then extracts the inner frpc bytes', () => {
|
||||
expect(extractFrpcBinary(realisticFrpTarGz())).toEqual(FRPC_BYTES)
|
||||
})
|
||||
|
||||
it('throws on non-gzip input', () => {
|
||||
expect(() => extractFrpcBinary(new Uint8Array([1, 2, 3, 4]))).toThrow(TarExtractError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('provisionFrpc (B3 verify-download + extract discipline)', () => {
|
||||
it('selects the arch URL, VERIFIES the archive, and places the INNER frpc (not the archive)', async () => {
|
||||
const archive = realisticFrpTarGz()
|
||||
const url = 'https://example.test/frp-darwin-arm64.tar.gz'
|
||||
const releases = releaseOverride('darwin-arm64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state, fetchedUrls } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
const result = await provisionFrpc(
|
||||
{ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases },
|
||||
deps,
|
||||
)
|
||||
|
||||
expect(fetchedUrls).toEqual([url])
|
||||
expect(result.binPath).toBe(BIN_PATH)
|
||||
expect(result.version).toBe('0.61.1')
|
||||
expect(result.platform).toBe('darwin-arm64')
|
||||
// atomic place: renamed into the final path, made executable
|
||||
expect(state.renames.some((r) => r.to === BIN_PATH)).toBe(true)
|
||||
expect(state.chmods.some((c) => (c.mode & 0o111) !== 0)).toBe(true)
|
||||
// the placed file is the EXTRACTED frpc binary, NOT the .tar.gz archive
|
||||
const placed = state.writes.get(BIN_PATH)
|
||||
expect(placed).toEqual(FRPC_BYTES)
|
||||
expect(placed).not.toEqual(archive)
|
||||
})
|
||||
|
||||
it('ignores the decoy frps entry and places frpc even when frps precedes it', async () => {
|
||||
const archive = makeTarGz([
|
||||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
const url = 'https://example.test/frp.tar.gz'
|
||||
const releases = releaseOverride('linux-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps)
|
||||
|
||||
expect(state.writes.get(BIN_PATH)).toEqual(FRPC_BYTES)
|
||||
})
|
||||
|
||||
it('REJECTS on sha256 mismatch and places NO binary (temp cleaned up, no extraction)', async () => {
|
||||
const archive = realisticFrpTarGz()
|
||||
const url = 'https://example.test/frp-linux-amd64.tar.gz'
|
||||
const releases = releaseOverride('linux-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: 'f'.repeat(64), // deliberately wrong
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow(/sha-?256|hash|integrity|mismatch/i)
|
||||
|
||||
expect(state.renames).toEqual([])
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.removed.length).toBeGreaterThan(0) // unverified temp removed
|
||||
})
|
||||
|
||||
it('never places or execs before verifying (mismatch leaves nothing executable)', async () => {
|
||||
const archive = realisticFrpTarGz()
|
||||
const url = 'https://example.test/bad.tar.gz'
|
||||
const releases = releaseOverride('linux-arm64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: '0'.repeat(64),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'linux', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(state.chmods.every((c) => c.path !== BIN_PATH)).toBe(true)
|
||||
})
|
||||
|
||||
it('throws and places nothing when the verified archive has NO frpc entry', async () => {
|
||||
const archive = makeTarGz([
|
||||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||||
{ name: 'frp_x/LICENSE', content: new TextEncoder().encode('MIT') },
|
||||
])
|
||||
const url = 'https://example.test/no-frpc.tar.gz'
|
||||
const releases = releaseOverride('darwin-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'darwin', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow(/extract|frpc|tar/i)
|
||||
|
||||
expect(state.renames).toEqual([])
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.removed.length).toBeGreaterThan(0) // temp removed on extraction failure
|
||||
})
|
||||
|
||||
it('REJECTS a traversal frpc entry and never writes outside binDir', async () => {
|
||||
const archive = makeTarGz([
|
||||
{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
const url = 'https://example.test/evil.tar.gz'
|
||||
const releases = releaseOverride('linux-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow()
|
||||
|
||||
// nothing placed, and every write that ever happened stayed inside binDir
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.renames).toEqual([])
|
||||
expect([...state.writes.keys()].every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
|
||||
expect(state.removed.every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
|
||||
})
|
||||
|
||||
it('REJECTS a hash-matching but corrupt (non-gzip) archive after the gate', async () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) // hashes fine, not a gzip
|
||||
const url = 'https://example.test/corrupt.tar.gz'
|
||||
const releases = releaseOverride('darwin-arm64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(garbage),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: garbage })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow(/extract|gzip|tar/i)
|
||||
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.removed.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('throws a clear error on an unsupported platform (nothing fetched)', async () => {
|
||||
const { deps, fetchedUrls } = makeFakeDeps({})
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'win32', arch: 'x64', binDir: BIN_DIR }, deps),
|
||||
).rejects.toThrow(/unsupported|platform/i)
|
||||
expect(fetchedUrls).toEqual([])
|
||||
})
|
||||
})
|
||||
100
agent/test/frpcToml.test.ts
Normal file
100
agent/test/frpcToml.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildNativeFrpcToml, type NativeFrpcOptions } from '../src/transport/frpcToml.js'
|
||||
|
||||
const BASE: NativeFrpcOptions = {
|
||||
subdomain: 'alice',
|
||||
localPort: 3000,
|
||||
authToken: 'super-secret-token',
|
||||
certFile: '/home/alice/.web-terminal-agent/frpc.cert.pem',
|
||||
keyFile: '/home/alice/.web-terminal-agent/frpc.key.pem',
|
||||
trustedCaFile: '/home/alice/.web-terminal-agent/frps-ctrl-ca.pem',
|
||||
}
|
||||
|
||||
describe('buildNativeFrpcToml (B2h)', () => {
|
||||
it('emits the native-tunnel server/port/tls keys (PLAN_NATIVE_TUNNEL §4)', () => {
|
||||
const toml = buildNativeFrpcToml(BASE)
|
||||
expect(toml).toContain('serverAddr = "8.138.1.192"')
|
||||
expect(toml).toContain('serverPort = 443')
|
||||
expect(toml).toContain('transport.tls.enable = true')
|
||||
expect(toml).toContain('transport.tls.serverName = "frp.terminal.yaojia.wang"')
|
||||
expect(toml).toContain('transport.tls.disableCustomTLSFirstByte = true')
|
||||
expect(toml).toContain(`transport.tls.certFile = "${BASE.certFile}"`)
|
||||
expect(toml).toContain(`transport.tls.keyFile = "${BASE.keyFile}"`)
|
||||
expect(toml).toContain(`transport.tls.trustedCaFile = "${BASE.trustedCaFile}"`)
|
||||
expect(toml).toContain('auth.method = "token"')
|
||||
expect(toml).toContain('auth.token = "super-secret-token"')
|
||||
})
|
||||
|
||||
it('emits a well-formed [[proxies]] http block bound to loopback', () => {
|
||||
const toml = buildNativeFrpcToml(BASE)
|
||||
expect(toml).toContain('[[proxies]]')
|
||||
expect(toml).toContain('type = "http"')
|
||||
expect(toml).toContain('subdomain = "alice"')
|
||||
expect(toml).toContain('localIP = "127.0.0.1"')
|
||||
expect(toml).toContain('localPort = 3000')
|
||||
// the proxy block comes after the server/tls preamble
|
||||
expect(toml.indexOf('[[proxies]]')).toBeGreaterThan(toml.indexOf('serverAddr'))
|
||||
})
|
||||
|
||||
it('does NOT emit the retired v0.8 [common]/tls_enable shape', () => {
|
||||
const toml = buildNativeFrpcToml(BASE)
|
||||
expect(toml).not.toContain('[common]')
|
||||
expect(toml).not.toContain('tls_enable')
|
||||
})
|
||||
|
||||
it('honours a configurable serverAddr (default 8.138.1.192)', () => {
|
||||
expect(buildNativeFrpcToml(BASE)).toContain('serverAddr = "8.138.1.192"')
|
||||
expect(buildNativeFrpcToml({ ...BASE, serverAddr: '10.9.8.7' })).toContain(
|
||||
'serverAddr = "10.9.8.7"',
|
||||
)
|
||||
})
|
||||
|
||||
it('THROWS when localIP is non-loopback (anti-SSRF hard invariant)', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '10.0.0.5' })).toThrow(/loopback/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '0.0.0.0' })).toThrow(/loopback/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '192.168.1.9' })).toThrow(/loopback/i)
|
||||
})
|
||||
|
||||
it('accepts the three explicit loopback localIP forms', () => {
|
||||
for (const ip of ['127.0.0.1', '::1', 'localhost']) {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: ip })).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an empty or non-label-safe subdomain', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'has space' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '-bad' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'bad-' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a'.repeat(64) })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a/b' })).toThrow(/subdomain/i)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range or non-integer localPort', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 0 })).toThrow(/port/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 70000 })).toThrow(/port/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 3000.5 })).toThrow(/port/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: -1 })).toThrow(/port/i)
|
||||
})
|
||||
|
||||
it('rejects missing keystore paths and an empty auth token', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, certFile: '' })).toThrow(/certFile/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: '' })).toThrow(/keyFile/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, trustedCaFile: '' })).toThrow(/trustedCaFile/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, authToken: '' })).toThrow(/token/i)
|
||||
})
|
||||
|
||||
it('escapes backslashes and quotes in Windows-style paths (valid TOML basic string)', () => {
|
||||
const winCert = 'C:\\Users\\alice\\.web-terminal-agent\\frpc.cert.pem'
|
||||
const toml = buildNativeFrpcToml({ ...BASE, certFile: winCert })
|
||||
expect(toml).toContain(
|
||||
'transport.tls.certFile = "C:\\\\Users\\\\alice\\\\.web-terminal-agent\\\\frpc.cert.pem"',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects control characters in paths/token (TOML-injection guard)', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, authToken: 'a\nb' })).toThrow()
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, certFile: 'a\nb' })).toThrow()
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: 'a"b\nq' })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createPublicKey, verify } from 'node:crypto'
|
||||
import {
|
||||
computeEnrollFpr,
|
||||
generateIdentity,
|
||||
generateP256Identity,
|
||||
identityFromPrivatePem,
|
||||
p256IdentityFromPrivatePem,
|
||||
verifySignature,
|
||||
} from '../src/keys/identity.js'
|
||||
|
||||
@@ -50,4 +53,53 @@ describe('AgentIdentity (INV4)', () => {
|
||||
// No function exports raw private key bytes onto the network surface.
|
||||
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
|
||||
})
|
||||
|
||||
it('keeps the Ed25519 alg tag (no P-256 regression)', () => {
|
||||
expect(generateIdentity().alg).toBe('ed25519')
|
||||
})
|
||||
})
|
||||
|
||||
describe('P-256 AgentIdentity (FIX H-host-2 — native frp-client key)', () => {
|
||||
it('generates distinct EC P-256 keypairs tagged alg=p256', () => {
|
||||
const a = generateP256Identity()
|
||||
const b = generateP256Identity()
|
||||
expect(a.alg).toBe('p256')
|
||||
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
|
||||
// publicKey is the EC SubjectPublicKeyInfo DER (outer SEQUENCE), importable as a public key.
|
||||
expect(a.publicKey[0]).toBe(0x30)
|
||||
const pub = createPublicKey({ key: Buffer.from(a.publicKey), format: 'der', type: 'spki' })
|
||||
expect(pub.asymmetricKeyType).toBe('ec')
|
||||
expect(pub.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
|
||||
})
|
||||
|
||||
it('sign produces a DER ECDSA signature that verifies under SHA-256', () => {
|
||||
const id = generateP256Identity()
|
||||
const msg = new TextEncoder().encode('certificationRequestInfo-bytes')
|
||||
const sig = id.sign(msg)
|
||||
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
|
||||
expect(verify('sha256', msg, pub, sig)).toBe(true)
|
||||
expect(verify('sha256', new TextEncoder().encode('tampered'), pub, sig)).toBe(false)
|
||||
})
|
||||
|
||||
it('enrollFpr is deterministic base64url(SHA-256(spki))', () => {
|
||||
const id = generateP256Identity()
|
||||
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
|
||||
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
|
||||
})
|
||||
|
||||
it('reloads the same P-256 identity from PEM (keystore load path)', () => {
|
||||
const id = generateP256Identity()
|
||||
const pem = id.exportPrivatePkcs8Pem()
|
||||
const reloaded = p256IdentityFromPrivatePem(pem)
|
||||
expect(reloaded.alg).toBe('p256')
|
||||
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
|
||||
})
|
||||
|
||||
it('security: P-256 identity exposes no raw private-key getter', () => {
|
||||
const id = generateP256Identity()
|
||||
expect('privateKey' in id).toBe(false)
|
||||
// the PEM export is the only serialization surface; it is a PRIVATE key PEM (0600 keystore only).
|
||||
expect(id.exportPrivatePkcs8Pem()).toContain('PRIVATE KEY')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import {
|
||||
BindHostError,
|
||||
RootRefusedError,
|
||||
assertNativeZone,
|
||||
buildInstallOptions,
|
||||
detectPlatform,
|
||||
installService,
|
||||
normalizeBindHost,
|
||||
uninstallService,
|
||||
type InstallDeps,
|
||||
} from '../src/service/install.js'
|
||||
import { agentLabel, baseAppLabel, buildLaunchdPlist } from '../src/service/launchd.js'
|
||||
import { agentUnitName, baseAppUnitName, buildSystemdUnit } from '../src/service/systemd.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
@@ -17,7 +23,12 @@ const CFG: AgentConfig = {
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
|
||||
type FakeDeps = InstallDeps & {
|
||||
writes: Array<[string, string]>
|
||||
runs: Array<[string, readonly string[]]>
|
||||
}
|
||||
|
||||
function deps(uid = 501): FakeDeps {
|
||||
const writes: Array<[string, string]> = []
|
||||
const runs: Array<[string, readonly string[]]> = []
|
||||
return {
|
||||
@@ -34,6 +45,13 @@ function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs:
|
||||
}
|
||||
}
|
||||
|
||||
/** The unit content whose path contains `needle` (e.g. `'base-app'`, `'agent'`). */
|
||||
function unitWith(d: FakeDeps, needle: string): string {
|
||||
const hit = d.writes.find(([path]) => path.includes(needle))
|
||||
if (!hit) throw new Error(`no unit written whose path contains '${needle}'`)
|
||||
return hit[1]
|
||||
}
|
||||
|
||||
describe('detectPlatform (T17)', () => {
|
||||
it('maps darwin→launchd, linux→systemd, else null', () => {
|
||||
expect(detectPlatform('darwin')).toBe('launchd')
|
||||
@@ -42,35 +60,363 @@ describe('detectPlatform (T17)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('installService (T17)', () => {
|
||||
describe('installService — least privilege (T17)', () => {
|
||||
it('refuses to install as root (negative, least privilege)', async () => {
|
||||
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
|
||||
})
|
||||
|
||||
it('systemd: writes a run-as-user unit and enables it', async () => {
|
||||
it('root refusal emits nothing', async () => {
|
||||
const d = deps(0)
|
||||
await expect(installService(CFG, 'systemd', d)).rejects.toBeInstanceOf(RootRefusedError)
|
||||
expect(d.writes).toHaveLength(0)
|
||||
expect(d.runs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installService — two distinct units (FIX M-host-2service)', () => {
|
||||
it('systemd: writes a base-app unit AND an agent unit, both run-as-user, both enabled', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d)
|
||||
const [, unit] = d.writes[0]!
|
||||
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
|
||||
// exactly two units
|
||||
expect(d.writes).toHaveLength(2)
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
const agent = unitWith(d, agentUnitName())
|
||||
// agent unit supervises frpc via `<node> <bin> run` (absolute node so a minimal service PATH
|
||||
// that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback)
|
||||
expect(agent).toContain('/usr/local/bin/web-terminal-agent run')
|
||||
expect(agent).toMatch(/ExecStart=\S*node\S* \/usr\/local\/bin\/web-terminal-agent run/)
|
||||
expect(baseApp).toContain('ExecStart=')
|
||||
expect(baseApp).toContain('server.js')
|
||||
expect(baseApp).not.toContain('web-terminal-agent run')
|
||||
// both least-privilege + restart-on-failure
|
||||
for (const unit of [baseApp, agent]) {
|
||||
expect(unit).toContain('User=alice')
|
||||
expect(unit).not.toContain('User=root')
|
||||
expect(unit).toContain('Restart=on-failure')
|
||||
expect(d.runs[0]![0]).toBe('systemctl')
|
||||
}
|
||||
// both enabled
|
||||
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
|
||||
expect(d.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
|
||||
it('launchd: writes a base-app plist AND an agent plist, both with KeepAlive, both loaded', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d)
|
||||
const [path, plist] = d.writes[0]!
|
||||
expect(path).toContain('LaunchAgents')
|
||||
expect(plist).toContain('<string>run</string>')
|
||||
expect(d.writes).toHaveLength(2)
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
const agent = unitWith(d, agentLabel())
|
||||
expect(agent).toContain('<string>run</string>')
|
||||
expect(baseApp).toContain('server.js')
|
||||
expect(baseApp).not.toContain('<string>run</string>')
|
||||
for (const plist of [baseApp, agent]) {
|
||||
expect(plist).toContain('<key>KeepAlive</key>')
|
||||
expect(d.runs[0]![0]).toBe('launchctl')
|
||||
}
|
||||
expect(d.writes.every(([path]) => path.includes('LaunchAgents'))).toBe(true)
|
||||
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
|
||||
expect(d.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uninstall unloads cleanly', async () => {
|
||||
it('routes base-app env to the base-app unit ONLY (never onto the agent unit)', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } })
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
const agent = unitWith(d, agentUnitName())
|
||||
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(baseApp).toContain('Environment="PORT=3000"')
|
||||
// the agent unit must NOT carry the base-app env
|
||||
expect(agent).not.toContain('BIND_HOST')
|
||||
expect(agent).not.toContain('PORT=3000')
|
||||
})
|
||||
|
||||
it('uninstall tears down BOTH units (launchd unload)', async () => {
|
||||
const d = deps()
|
||||
await uninstallService('launchd', d)
|
||||
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
|
||||
const targets = d.runs.map(([, args]) => args[args.length - 1])
|
||||
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
|
||||
expect(targets.some((t) => t?.includes(baseAppLabel()))).toBe(true)
|
||||
expect(targets.some((t) => t?.includes(agentLabel()))).toBe(true)
|
||||
})
|
||||
|
||||
it('uninstall tears down BOTH units (systemd disable)', async () => {
|
||||
const d = deps()
|
||||
await uninstallService('systemd', d)
|
||||
const units = d.runs.map(([, args]) => args[args.length - 1])
|
||||
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
|
||||
expect(units).toContain(baseAppUnitName())
|
||||
expect(units).toContain(agentUnitName())
|
||||
})
|
||||
})
|
||||
|
||||
describe('BIND_HOST loopback S-GATE (FIX C-host-1, CRITICAL)', () => {
|
||||
it('normalizeBindHost defaults an absent value to loopback', () => {
|
||||
expect(normalizeBindHost(undefined)).toBe('127.0.0.1')
|
||||
expect(normalizeBindHost('')).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('normalizeBindHost accepts loopback forms (127.0.0.0/8, ::1, localhost)', () => {
|
||||
expect(normalizeBindHost('127.0.0.1')).toBe('127.0.0.1')
|
||||
expect(normalizeBindHost('127.0.0.2')).toBe('127.0.0.2')
|
||||
expect(normalizeBindHost('::1')).toBe('::1')
|
||||
expect(normalizeBindHost('localhost')).toBe('localhost')
|
||||
})
|
||||
|
||||
it('normalizeBindHost REJECTS 0.0.0.0 and other non-loopback values', () => {
|
||||
expect(() => normalizeBindHost('0.0.0.0')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('192.168.1.10')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('::')).toThrow(BindHostError)
|
||||
})
|
||||
|
||||
it('REGRESSION: rejects a suffixed hostname that merely starts with 127. (S-GATE bypass)', () => {
|
||||
// These are hostnames, not loopback literals — Node would DNS-resolve them before bind().
|
||||
expect(() => normalizeBindHost('127.0.0.1.attacker.example.com')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('127.evil.net')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('127.0.0.1x')).toThrow(BindHostError)
|
||||
expect(() => buildInstallOptions({ BIND_HOST: '127.0.0.1.attacker.example.com' })).toThrow(
|
||||
BindHostError,
|
||||
)
|
||||
})
|
||||
|
||||
it('buildInstallOptions throws on BIND_HOST=0.0.0.0 (fail-closed at env read)', () => {
|
||||
expect(() => buildInstallOptions({ BIND_HOST: '0.0.0.0' })).toThrow(BindHostError)
|
||||
})
|
||||
|
||||
it('NEGATIVE: installService with BIND_HOST=0.0.0.0 throws AND emits nothing', async () => {
|
||||
const d = deps()
|
||||
await expect(
|
||||
installService(CFG, 'systemd', d, { env: { BIND_HOST: '0.0.0.0', PORT: '3000' } }),
|
||||
).rejects.toBeInstanceOf(BindHostError)
|
||||
expect(d.writes).toHaveLength(0)
|
||||
expect(d.runs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('NEGATIVE (launchd): a 0.0.0.0 install emits no plist', async () => {
|
||||
const d = deps()
|
||||
await expect(
|
||||
installService(CFG, 'launchd', d, { env: { BIND_HOST: '0.0.0.0' } }),
|
||||
).rejects.toBeInstanceOf(BindHostError)
|
||||
expect(d.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('the emitted base-app unit can NEVER contain BIND_HOST=0.0.0.0 (normalized when absent)', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, { env: { PORT: '3000' } })
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(baseApp).not.toContain('0.0.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInstallOptions — env → InstallOptions (S0/S2 + S-GATE)', () => {
|
||||
it('defaults BIND_HOST to loopback so a tunnel install is never LAN-exposed (S0/R2)', () => {
|
||||
const options = buildInstallOptions({})
|
||||
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1' })
|
||||
})
|
||||
|
||||
it('honours an explicit loopback BIND_HOST and passes through the S0 base-app env vars', () => {
|
||||
const options = buildInstallOptions({
|
||||
BIND_HOST: '127.0.0.2',
|
||||
PORT: '3000',
|
||||
SHELL_PATH: '/bin/zsh',
|
||||
IDLE_TTL: '86400',
|
||||
USE_TMUX: '1',
|
||||
ALLOWED_ORIGINS: 'https://keep.me',
|
||||
SCROLLBACK_BYTES: '2097152',
|
||||
MAX_PAYLOAD_BYTES: '1048576',
|
||||
})
|
||||
expect(options.env).toEqual({
|
||||
BIND_HOST: '127.0.0.2',
|
||||
PORT: '3000',
|
||||
SHELL_PATH: '/bin/zsh',
|
||||
IDLE_TTL: '86400',
|
||||
USE_TMUX: '1',
|
||||
ALLOWED_ORIGINS: 'https://keep.me',
|
||||
SCROLLBACK_BYTES: '2097152',
|
||||
MAX_PAYLOAD_BYTES: '1048576',
|
||||
})
|
||||
})
|
||||
|
||||
it('AG3: passes SCROLLBACK_BYTES and MAX_PAYLOAD_BYTES through as base-app config', () => {
|
||||
const options = buildInstallOptions({ SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576' })
|
||||
expect(options.env).toEqual({
|
||||
BIND_HOST: '127.0.0.1',
|
||||
SCROLLBACK_BYTES: '2097152',
|
||||
MAX_PAYLOAD_BYTES: '1048576',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits unset/empty passthrough vars', () => {
|
||||
const options = buildInstallOptions({ PORT: '', SHELL_PATH: '/bin/bash' })
|
||||
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1', SHELL_PATH: '/bin/bash' })
|
||||
})
|
||||
|
||||
it('derives domain + default `terminal` zone from TUNNEL_DOMAIN', () => {
|
||||
const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang' })
|
||||
expect(options.domain).toBe('yaojia.wang')
|
||||
expect(options.zone).toBe('terminal')
|
||||
})
|
||||
|
||||
it('lets TUNNEL_ZONE override the origin zone and carries AGENT_ENV_FILE through', () => {
|
||||
const options = buildInstallOptions({
|
||||
TUNNEL_DOMAIN: 'yaojia.wang',
|
||||
TUNNEL_ZONE: 'term',
|
||||
AGENT_ENV_FILE: '/etc/wt.env',
|
||||
})
|
||||
expect(options.zone).toBe('term')
|
||||
expect(options.envFile).toBe('/etc/wt.env')
|
||||
})
|
||||
|
||||
it('omits domain/zone when no TUNNEL_DOMAIN is set', () => {
|
||||
const options = buildInstallOptions({})
|
||||
expect(options.domain).toBeUndefined()
|
||||
expect(options.zone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tunnel-origin derivation into the base-app unit (FIX L-host-zone)', () => {
|
||||
it('assertNativeZone accepts `terminal` and rejects `term`/undefined', () => {
|
||||
expect(() => assertNativeZone('terminal')).not.toThrow()
|
||||
expect(() => assertNativeZone('term')).toThrow(/terminal/)
|
||||
expect(() => assertNativeZone(undefined)).toThrow(/terminal/)
|
||||
})
|
||||
|
||||
it('merges https://<sub>.terminal.<domain> into the base-app ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
expect(baseApp).toContain('<key>ALLOWED_ORIGINS</key>')
|
||||
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
|
||||
})
|
||||
|
||||
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, {
|
||||
env: { ALLOWED_ORIGINS: 'https://keep.me' },
|
||||
domain: 'yaojia.wang',
|
||||
zone: 'terminal',
|
||||
})
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('does not derive an origin when the config has no subdomain', async () => {
|
||||
const d = deps()
|
||||
await installService({ ...CFG, subdomain: null }, 'launchd', d, {
|
||||
domain: 'yaojia.wang',
|
||||
zone: 'terminal',
|
||||
})
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
expect(baseApp).not.toContain('ALLOWED_ORIGINS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install CLI seam end-to-end — resolved env reaches the base-app unit (S2)', () => {
|
||||
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
|
||||
|
||||
it('launchd: the base-app plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
expect(baseApp).toContain('<key>BIND_HOST</key>')
|
||||
expect(baseApp).toContain('<string>127.0.0.1</string>')
|
||||
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
|
||||
expect(baseApp).toContain('<key>PORT</key>')
|
||||
expect(baseApp).not.toContain('0.0.0.0')
|
||||
})
|
||||
|
||||
it('systemd: the base-app unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(baseApp).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
|
||||
expect(baseApp).toContain('Environment="PORT=3000"')
|
||||
expect(baseApp).not.toContain('0.0.0.0')
|
||||
})
|
||||
|
||||
it('systemd: emits EnvironmentFile= (before inline Environment) on the base-app unit', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, {
|
||||
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
|
||||
envFile: '/etc/web-terminal.env',
|
||||
})
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('EnvironmentFile=/etc/web-terminal.env')
|
||||
expect(baseApp.indexOf('EnvironmentFile=')).toBeLessThan(baseApp.indexOf('Environment='))
|
||||
})
|
||||
})
|
||||
|
||||
describe('unit writers — escaping & control-char hardening', () => {
|
||||
it('launchd: escapes XML-significant characters in env values', () => {
|
||||
const plist = buildLaunchdPlist(['/bin/agent', 'run'], { X: `a&b<c>d"e'f` })
|
||||
expect(plist).toContain('<string>a&b<c>d"e'f</string>')
|
||||
expect(plist).not.toContain('a&b<c>d')
|
||||
})
|
||||
|
||||
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', () => {
|
||||
const plist = buildLaunchdPlist(['/bin/agent', 'run'], {
|
||||
BIND_HOST: '127.0.0.1',
|
||||
ALLOWED_ORIGINS: 'https://a',
|
||||
PORT: '3000',
|
||||
})
|
||||
expect(plist).toContain('<key>EnvironmentVariables</key>')
|
||||
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
|
||||
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
|
||||
})
|
||||
|
||||
it('launchd: no env → no EnvironmentVariables block', () => {
|
||||
const plist = buildLaunchdPlist(['/bin/agent', 'run'])
|
||||
expect(plist).not.toContain('EnvironmentVariables')
|
||||
})
|
||||
|
||||
it('systemd: escapes backslash and double-quote in Environment values', () => {
|
||||
const unit = buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a"b\\c' } })
|
||||
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
|
||||
})
|
||||
|
||||
it('systemd: rejects a newline in an env value (no [Service] directive injection)', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\nExecStartPre=/x' } }),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('systemd: rejects a carriage return in an env value', () => {
|
||||
expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\rb' } })).toThrow(
|
||||
/control character/,
|
||||
)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the ExecStart command (no [Service] directive injection)', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run\nExecStartPre=/x', 'alice'),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the User field', () => {
|
||||
expect(() => buildSystemdUnit('/bin/agent run', 'alice\nExecStartPre=/x')).toThrow(
|
||||
/control character/,
|
||||
)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the Description field', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', {}, 'desc\n[Service]\nExecStartPre=/x'),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the EnvironmentFile path', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', { envFile: '/etc/x.env\nExecStartPre=/y' }),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in an Environment KEY (not just the value)', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', { env: { 'X\nExecStartPre=/y': 'v' } }),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('systemd: default (no env) omits Environment lines', () => {
|
||||
const unit = buildSystemdUnit('/bin/agent run', 'alice')
|
||||
expect(unit).not.toContain('Environment')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'
|
||||
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
|
||||
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -67,4 +68,50 @@ describe('Keystore (INV4/INV5)', () => {
|
||||
mkdirSync(dir, { mode: 0o755 })
|
||||
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
|
||||
})
|
||||
|
||||
it('keeps the Ed25519 identity byte-identical across save/load (no P-256 regression)', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const id = generateIdentity()
|
||||
ks.saveIdentity(id)
|
||||
const reloaded = ks.loadIdentity()
|
||||
expect(reloaded!.alg).toBe('ed25519')
|
||||
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
|
||||
})
|
||||
|
||||
it('FIX H-host-2: round-trips a P-256 frp-client identity (alg + usable signing key)', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const id = generateP256Identity()
|
||||
ks.saveIdentity(id)
|
||||
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
|
||||
|
||||
const reloaded = ks.loadIdentity()
|
||||
expect(reloaded).not.toBeNull()
|
||||
// loadIdentity() branched on the stored key's alg discriminant → P-256, not Ed25519.
|
||||
expect(reloaded!.alg).toBe('p256')
|
||||
// EC SubjectPublicKeyInfo DER (outer SEQUENCE) preserved exactly.
|
||||
expect(reloaded!.publicKey[0]).toBe(0x30)
|
||||
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
|
||||
|
||||
// The reloaded key still signs: a DER ECDSA signature that verifies under the original pubkey.
|
||||
const msg = new TextEncoder().encode('csr-bytes')
|
||||
const sig = reloaded!.sign(msg)
|
||||
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
|
||||
expect(verify('sha256', msg, pub, sig)).toBe(true)
|
||||
})
|
||||
|
||||
it('AG1: rejects a stored EC key on a non-P256 curve (e.g. secp384r1) with a clear error', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
// Plant a valid PKCS#8 EC key on the WRONG curve directly at the key path — `ec` alone must not
|
||||
// be mistaken for P-256; loadIdentity must assert the named curve and fail closed.
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'secp384r1' })
|
||||
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
writeFileSync(join(dir, 'agent.key.pem'), pem, { mode: 0o600 })
|
||||
expect(() => ks.loadIdentity()).toThrow(KeystoreError)
|
||||
expect(() => ks.loadIdentity()).toThrow(/prime256v1|P-256|curve/)
|
||||
})
|
||||
})
|
||||
|
||||
39
agent/test/loopbackLiteral.test.ts
Normal file
39
agent/test/loopbackLiteral.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isLoopbackHostLiteral } from '../src/net/loopbackLiteral.js'
|
||||
|
||||
describe('isLoopbackHostLiteral — strict loopback-literal check (FIX C-host-1 / anti-SSRF)', () => {
|
||||
it('accepts exact loopback literals', () => {
|
||||
expect(isLoopbackHostLiteral('localhost')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('::1')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('[::1]')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts any well-formed IPv4 in 127.0.0.0/8', () => {
|
||||
expect(isLoopbackHostLiteral('127.0.0.1')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('127.0.0.2')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('127.5.5.5')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('127.255.255.255')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-loopback IPs and wildcards', () => {
|
||||
expect(isLoopbackHostLiteral('0.0.0.0')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('192.168.1.10')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('10.0.0.5')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('::')).toBe(false)
|
||||
})
|
||||
|
||||
it('REJECTS suffixed hostnames that merely start with 127. (the S-GATE bypass)', () => {
|
||||
expect(isLoopbackHostLiteral('127.0.0.1.attacker.example.com')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.evil.net')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.0.0.1.evil.example.com')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.0.0.1x')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed / non-dotted-quad partials and out-of-range octets', () => {
|
||||
expect(isLoopbackHostLiteral('127.1')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.0.0.256')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('0127.0.0.1')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127')).toBe(false)
|
||||
})
|
||||
})
|
||||
279
agent/test/nativeRenew.test.ts
Normal file
279
agent/test/nativeRenew.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* A5 native cert auto-renew wiring — TDD.
|
||||
*
|
||||
* Covers the host-side glue that was the "one real host code gap": the native run-loop must actually
|
||||
* RENEW the frp-client leaf (not merely monitor its freshness). Three units:
|
||||
* - `createMtlsFetch` — the injected `fetchImpl` `renewCert` uses: it POSTs /renew over mTLS
|
||||
* presenting the CURRENT keystore leaf (read fresh on every call, so a post-rotation renewal
|
||||
* authenticates with the new leaf) and maps the transport response to a `Response`.
|
||||
* - `wireAutoRenew` — routes the rotator's rotated→restartChild(+log), revoked→stop(+log),
|
||||
* error→log(no secret) callbacks and starts it.
|
||||
* - `startNativeAutoRenew` — the end-to-end builder `superviseNative` calls (identity + mTLS fetch
|
||||
* + rotator), returning null (disabled) when unenrolled.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import { generateP256Identity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
import type { CertRotator } from '../src/certs/rotation.js'
|
||||
import {
|
||||
createMtlsFetch,
|
||||
startNativeAutoRenew,
|
||||
wireAutoRenew,
|
||||
type MtlsRequest,
|
||||
} from '../src/certs/nativeRenew.js'
|
||||
import { FakeTimer } from './fixtures/fakes.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://cp.example.com/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function enrolledKs(): { dir: string; ks: ReturnType<typeof openKeystore> } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateP256Identity())
|
||||
ks.saveCert('LEAFCERT', 'CACHAIN')
|
||||
return { dir, ks }
|
||||
}
|
||||
|
||||
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||
|
||||
describe('createMtlsFetch (A5)', () => {
|
||||
it('presents the current keystore cert/key/CA over mTLS and maps the transport response', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const seen: Array<{ url: string; tls: Record<string, unknown>; init: Record<string, unknown> }> = []
|
||||
const request: MtlsRequest = async (url, tls, init) => {
|
||||
seen.push({ url, tls: tls as unknown as Record<string, unknown>, init: init as unknown as Record<string, unknown> })
|
||||
return { status: 200, body: JSON.stringify({ cert: 'NEW', caChain: 'NEWCA' }) }
|
||||
}
|
||||
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
||||
|
||||
const res = await f('https://cp.example.com/renew', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"csr":"x"}',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.ok).toBe(true)
|
||||
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
|
||||
expect(seen[0]!.url).toBe('https://cp.example.com/renew')
|
||||
expect(seen[0]!.tls.cert).toBe('LEAFCERT')
|
||||
// /renew verifies the LE-fronted control-plane against the SYSTEM roots, so no private CA is pinned
|
||||
// (pinning the enroll caChain here fails with "unable to get local issuer certificate").
|
||||
expect(seen[0]!.tls.ca).toBeUndefined()
|
||||
expect(seen[0]!.tls.rejectUnauthorized).toBe(true)
|
||||
expect(String(seen[0]!.tls.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key, mTLS only
|
||||
expect(seen[0]!.init.method).toBe('POST')
|
||||
expect(seen[0]!.init.body).toBe('{"csr":"x"}')
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads the current cert on every call so a post-rotation renewal uses the new leaf', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const certs: string[] = []
|
||||
const request: MtlsRequest = async (_url, tls) => {
|
||||
certs.push(tls.cert)
|
||||
return { status: 200, body: '{}' }
|
||||
}
|
||||
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
||||
|
||||
await f('https://x/renew', { method: 'POST' })
|
||||
ks.saveCert('ROTATEDLEAF', 'CACHAIN') // simulate a completed rotation
|
||||
await f('https://x/renew', { method: 'POST' })
|
||||
|
||||
expect(certs).toEqual(['LEAFCERT', 'ROTATEDLEAF'])
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('propagates a not-enrolled keystore as a throw (renewCert then retries with backoff)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
||||
const ks = openKeystore(dir)
|
||||
const request: MtlsRequest = async () => ({ status: 200, body: '{}' })
|
||||
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
||||
await expect(f('https://x/renew', { method: 'POST' })).rejects.toThrow()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('wireAutoRenew (A5)', () => {
|
||||
function fakeRotator(): {
|
||||
rotator: CertRotator
|
||||
fire: { rotated?: () => void; revoked?: () => void; error?: (e: unknown) => void }
|
||||
start: ReturnType<typeof vi.fn>
|
||||
stop: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const fire: { rotated?: () => void; revoked?: () => void; error?: (e: unknown) => void } = {}
|
||||
const start = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const rotator: CertRotator = {
|
||||
start,
|
||||
stop,
|
||||
onRotated: (cb) => {
|
||||
fire.rotated = cb
|
||||
},
|
||||
onRevoked: (cb) => {
|
||||
fire.revoked = cb
|
||||
},
|
||||
onError: (cb) => {
|
||||
fire.error = cb
|
||||
},
|
||||
}
|
||||
return { rotator, fire, start, stop }
|
||||
}
|
||||
|
||||
it('routes rotated→restartChild, revoked→stop, error→log; starts and stops the rotator', () => {
|
||||
const { rotator, fire, start, stop } = fakeRotator()
|
||||
const restartChild = vi.fn()
|
||||
const stopSupervisor = vi.fn()
|
||||
const lines: string[] = []
|
||||
const logger = createLogger('info', (l) => lines.push(l))
|
||||
|
||||
const controller = wireAutoRenew(rotator, { restartChild, stop: stopSupervisor }, logger, {
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
})
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
|
||||
fire.rotated!()
|
||||
expect(restartChild).toHaveBeenCalledTimes(1)
|
||||
|
||||
fire.revoked!()
|
||||
expect(stopSupervisor).toHaveBeenCalledTimes(1)
|
||||
|
||||
fire.error!(new Error('network down'))
|
||||
|
||||
controller.stop()
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).toContain('host-42') // non-secret identifier is logged
|
||||
expect(joined).not.toContain('LEAFCERT') // never a leaf/key/CSR
|
||||
})
|
||||
})
|
||||
|
||||
describe('startNativeAutoRenew (A5 end-to-end)', () => {
|
||||
it('a scheduled 200 renewal rotates the leaf on disk and restarts frpc with it', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
const request: MtlsRequest = async () => ({
|
||||
status: 200,
|
||||
body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }),
|
||||
})
|
||||
const restartChild = vi.fn()
|
||||
const stop = vi.fn()
|
||||
|
||||
const controller = startNativeAutoRenew(
|
||||
CFG,
|
||||
ks,
|
||||
{ restartChild, stop },
|
||||
createLogger('error', () => {}),
|
||||
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
|
||||
)!
|
||||
expect(controller).not.toBeNull()
|
||||
|
||||
timer.advance(1000) // renewal fires at ~2/3 TTL
|
||||
await flush()
|
||||
|
||||
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') // atomic persist
|
||||
expect(restartChild).toHaveBeenCalledTimes(1) // frpc restarted onto the fresh leaf
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
controller.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('a scheduled 403 renewal tears the tunnel down (revoked) and never rotates', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
const request: MtlsRequest = async () => ({ status: 403, body: '' })
|
||||
const restartChild = vi.fn()
|
||||
const stop = vi.fn()
|
||||
|
||||
const controller = startNativeAutoRenew(
|
||||
CFG,
|
||||
ks,
|
||||
{ restartChild, stop },
|
||||
createLogger('error', () => {}),
|
||||
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
|
||||
)!
|
||||
|
||||
timer.advance(1000)
|
||||
await flush()
|
||||
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
expect(restartChild).not.toHaveBeenCalled()
|
||||
expect(ks.loadCert()!.certPem).toBe('LEAFCERT') // untouched
|
||||
controller.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('a failing renewal is logged (no secret) and retried without crashing, then rotates', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
let calls = 0
|
||||
const request: MtlsRequest = async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('ECONNREFUSED')
|
||||
return { status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }) }
|
||||
}
|
||||
const restartChild = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const lines: string[] = []
|
||||
|
||||
const controller = startNativeAutoRenew(
|
||||
CFG,
|
||||
ks,
|
||||
{ restartChild, stop },
|
||||
createLogger('warn', (l) => lines.push(l)),
|
||||
{
|
||||
mtlsRequest: request,
|
||||
certParser: farFuture,
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
retryBaseMs: 500,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000),
|
||||
},
|
||||
)!
|
||||
|
||||
timer.advance(1000) // first attempt throws
|
||||
await flush()
|
||||
expect(restartChild).not.toHaveBeenCalled()
|
||||
expect(stop).not.toHaveBeenCalled() // a failure NEVER tears down
|
||||
expect(lines.join('\n')).toContain('retry')
|
||||
|
||||
timer.advance(500) // backoff retry fires and succeeds
|
||||
await flush()
|
||||
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF')
|
||||
expect(restartChild).toHaveBeenCalledTimes(1)
|
||||
expect(lines.join('\n')).not.toContain('LEAFCERT')
|
||||
controller.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns null (auto-renew disabled) when the keystore has no identity', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
||||
const ks = openKeystore(dir)
|
||||
const lines: string[] = []
|
||||
const controller = startNativeAutoRenew(
|
||||
CFG,
|
||||
ks,
|
||||
{ restartChild: () => {}, stop: () => {} },
|
||||
createLogger('warn', (l) => lines.push(l)),
|
||||
{},
|
||||
)
|
||||
expect(controller).toBeNull()
|
||||
expect(lines.join('\n')).toContain('auto-renew disabled')
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
169
agent/test/nativeRenewTransport.test.ts
Normal file
169
agent/test/nativeRenewTransport.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* A5 default mTLS transport (`defaultMtlsRequest`) — the ONE seam the other nativeRenew tests inject
|
||||
* past, so the real `node:https` transport had zero coverage. These tests mock `node:https` and drive
|
||||
* the actual transport to prove:
|
||||
* - the exact request options (rejectUnauthorized:true + client cert/key + pinned CA + method/body),
|
||||
* - the HIGH fix: a request timeout is armed and a stalled peer REJECTS (never hangs forever),
|
||||
* - the MEDIUM fix: an oversized response body is capped (destroyed + rejected, not buffered).
|
||||
*/
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }))
|
||||
vi.mock('node:https', () => ({ request: requestMock, default: { request: requestMock } }))
|
||||
|
||||
import { generateP256Identity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
createMtlsFetch,
|
||||
RENEW_REQUEST_TIMEOUT_MS,
|
||||
MAX_RENEW_RESPONSE_BYTES,
|
||||
} from '../src/certs/nativeRenew.js'
|
||||
|
||||
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||
|
||||
/** Fake `http.ClientRequest`: records setTimeout/write/end and emits 'error' on destroy(err). */
|
||||
class FakeClientRequest extends EventEmitter {
|
||||
readonly setTimeoutCalls: Array<{ ms: number; cb: () => void }> = []
|
||||
readonly written: string[] = []
|
||||
ended = false
|
||||
destroyedWith: Error | undefined
|
||||
setTimeout(ms: number, cb: () => void): this {
|
||||
this.setTimeoutCalls.push({ ms, cb })
|
||||
return this
|
||||
}
|
||||
write(chunk: string): boolean {
|
||||
this.written.push(chunk)
|
||||
return true
|
||||
}
|
||||
end(): this {
|
||||
this.ended = true
|
||||
return this
|
||||
}
|
||||
destroy(err?: Error): this {
|
||||
this.destroyedWith = err
|
||||
if (err) this.emit('error', err)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
/** Fake `http.IncomingMessage`: an EventEmitter with a statusCode and a real destroy(). */
|
||||
class FakeIncomingMessage extends EventEmitter {
|
||||
statusCode = 200
|
||||
destroyed = false
|
||||
destroy(): this {
|
||||
this.destroyed = true
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
type ReqCb = (res: FakeIncomingMessage) => void
|
||||
let dirs: string[] = []
|
||||
|
||||
function enrolledKs(): ReturnType<typeof openKeystore> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nrt-'))
|
||||
dirs.push(dir)
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateP256Identity())
|
||||
ks.saveCert('LEAFCERT', 'CACHAIN')
|
||||
return ks
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
requestMock.mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const d of dirs) rmSync(d, { recursive: true, force: true })
|
||||
dirs = []
|
||||
})
|
||||
|
||||
describe('defaultMtlsRequest transport (A5 — real node:https)', () => {
|
||||
it('passes rejectUnauthorized:true + the client cert/key/CA + method/body, and arms the timeout', async () => {
|
||||
const ks = enrolledKs()
|
||||
let seenUrl = ''
|
||||
let seenOpts: Record<string, unknown> = {}
|
||||
let seenReq: FakeClientRequest | undefined
|
||||
requestMock.mockImplementation((url: string, opts: Record<string, unknown>, cb: ReqCb) => {
|
||||
seenUrl = url
|
||||
seenOpts = opts
|
||||
const req = new FakeClientRequest()
|
||||
seenReq = req
|
||||
queueMicrotask(() => {
|
||||
const res = new FakeIncomingMessage()
|
||||
res.statusCode = 200
|
||||
cb(res)
|
||||
res.emit('data', Buffer.from('{"cert":"NEW",'))
|
||||
res.emit('data', Buffer.from('"caChain":"NEWCA"}'))
|
||||
res.emit('end')
|
||||
})
|
||||
return req
|
||||
})
|
||||
|
||||
const f = createMtlsFetch(ks, { certParser: farFuture }) // NO request seam → real defaultMtlsRequest
|
||||
const res = await f('https://cp.example.com/renew', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"csr":"x"}',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
|
||||
expect(seenUrl).toBe('https://cp.example.com/renew')
|
||||
expect(seenOpts.rejectUnauthorized).toBe(true) // anti-MITM (INV14)
|
||||
expect(seenOpts.method).toBe('POST')
|
||||
expect(seenOpts.cert).toBe('LEAFCERT')
|
||||
// ca omitted ⇒ verify the LE-fronted control-plane against the system roots (not the private CA).
|
||||
expect(seenOpts.ca).toBeUndefined()
|
||||
expect(String(seenOpts.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key
|
||||
// HIGH fix: a socket timeout is armed with the sane default so a stall can never hang forever.
|
||||
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
|
||||
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
|
||||
expect(seenReq!.written).toEqual(['{"csr":"x"}'])
|
||||
expect(seenReq!.ended).toBe(true)
|
||||
})
|
||||
|
||||
it('HIGH: a stalled peer (accepts TLS, never responds) REJECTS via the timeout, not hangs', async () => {
|
||||
const ks = enrolledKs()
|
||||
let seenReq: FakeClientRequest | undefined
|
||||
requestMock.mockImplementation(() => {
|
||||
const req = new FakeClientRequest()
|
||||
seenReq = req
|
||||
return req // never invokes the response callback — a stalled control-plane
|
||||
})
|
||||
|
||||
const f = createMtlsFetch(ks, { certParser: farFuture })
|
||||
const p = f('https://cp.example.com/renew', { method: 'POST', body: '{}' })
|
||||
|
||||
// Fire the armed socket-timeout callback (what node does when the socket idles past the limit).
|
||||
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
|
||||
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
|
||||
seenReq!.setTimeoutCalls[0]!.cb()
|
||||
|
||||
await expect(p).rejects.toThrow(/timed out/i)
|
||||
expect(seenReq!.destroyedWith).toBeInstanceOf(Error) // socket was torn down
|
||||
})
|
||||
|
||||
it('MEDIUM: an oversized response body is capped — res destroyed + promise rejected', async () => {
|
||||
const ks = enrolledKs()
|
||||
let seenRes: FakeIncomingMessage | undefined
|
||||
requestMock.mockImplementation((_url: string, _opts: unknown, cb: ReqCb) => {
|
||||
const req = new FakeClientRequest()
|
||||
queueMicrotask(() => {
|
||||
const res = new FakeIncomingMessage()
|
||||
res.statusCode = 200
|
||||
seenRes = res
|
||||
cb(res)
|
||||
res.emit('data', Buffer.alloc(MAX_RENEW_RESPONSE_BYTES + 1)) // one byte over the cap
|
||||
// deliberately NO 'end' — a capped stream must reject on its own, not wait for end
|
||||
})
|
||||
return req
|
||||
})
|
||||
|
||||
const f = createMtlsFetch(ks, { certParser: farFuture })
|
||||
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(/cap|exceed/i)
|
||||
expect(seenRes!.destroyed).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ensureAllowedOrigin, subdomainOrigin, type OriginFsDeps } from '../src/service/originConfig.js'
|
||||
import {
|
||||
DEFAULT_ORIGIN_ZONE,
|
||||
ensureAllowedOrigin,
|
||||
mergeOrigins,
|
||||
subdomainOrigin,
|
||||
type OriginFsDeps,
|
||||
} from '../src/service/originConfig.js'
|
||||
|
||||
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
||||
const store = { content: initial }
|
||||
@@ -41,3 +47,38 @@ describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
|
||||
expect(get()).toContain('https://host-42.term.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('zone parameterization (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
it('defaults to the historical `term` zone', () => {
|
||||
expect(DEFAULT_ORIGIN_ZONE).toBe('term')
|
||||
expect(subdomainOrigin('t1', 'yaojia.wang')).toBe('https://t1.term.yaojia.wang')
|
||||
})
|
||||
|
||||
it('composes the `terminal` zone for native-tunnel hosts', () => {
|
||||
expect(subdomainOrigin('t1', 'yaojia.wang', 'terminal')).toBe('https://t1.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('ensureAllowedOrigin writes the caller-selected zone', () => {
|
||||
const { fs, get } = memFs('PORT=3000\n')
|
||||
ensureAllowedOrigin(PATH, 't1', 'yaojia.wang', fs, 'terminal')
|
||||
expect(get()).toContain('ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang')
|
||||
expect(get()).not.toContain('.term.yaojia.wang')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeOrigins (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
it('appends to an empty/undefined value', () => {
|
||||
expect(mergeOrigins(undefined, 'https://a.example.com')).toBe('https://a.example.com')
|
||||
expect(mergeOrigins('', 'https://a.example.com')).toBe('https://a.example.com')
|
||||
})
|
||||
|
||||
it('de-duplicates an origin already present', () => {
|
||||
expect(mergeOrigins('https://a.example.com', 'https://a.example.com')).toBe('https://a.example.com')
|
||||
})
|
||||
|
||||
it('appends a new origin, trimming whitespace, preserving existing ones', () => {
|
||||
expect(mergeOrigins(' https://a.example.com , https://b.example.com ', 'https://c.example.com')).toBe(
|
||||
'https://a.example.com,https://b.example.com,https://c.example.com',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
216
agent/test/probe.test.ts
Normal file
216
agent/test/probe.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DEFAULT_CERT_RENEW_WINDOW_MS,
|
||||
certIsFresh,
|
||||
frpcProxyStarted,
|
||||
probeLoopbackBaseApp,
|
||||
renderHealthStatus,
|
||||
runHealthProbe,
|
||||
startHealthMonitor,
|
||||
type HealthProbeSeams,
|
||||
type HealthReport,
|
||||
type IntervalTimer,
|
||||
} from '../src/health/probe.js'
|
||||
|
||||
/** All-passing seams; each test overrides exactly one to prove sub-check independence. */
|
||||
function healthySeams(over: Partial<HealthProbeSeams> = {}): HealthProbeSeams {
|
||||
return {
|
||||
isFrpcAlive: () => true,
|
||||
probeBaseApp: async () => true,
|
||||
readFrpcLog: () => 'proxy [web-terminal] start proxy success',
|
||||
certNotAfter: () => new Date('2026-08-01T00:00:00Z'),
|
||||
now: () => new Date('2026-07-08T00:00:00Z'),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('frpcProxyStarted (log scan)', () => {
|
||||
it('detects the frpc start-proxy-success line', () => {
|
||||
expect(frpcProxyStarted('2026/07/08 [I] [proxy_manager] start proxy success')).toBe(true)
|
||||
})
|
||||
|
||||
it('is false before frpc reports success', () => {
|
||||
expect(frpcProxyStarted('login to server success\nstart proxy ...')).toBe(false)
|
||||
expect(frpcProxyStarted('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('certIsFresh (near-expiry check)', () => {
|
||||
const now = new Date('2026-07-08T00:00:00Z')
|
||||
|
||||
it('is fresh when notAfter is beyond the renewal window', () => {
|
||||
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS + 60_000)
|
||||
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(true)
|
||||
})
|
||||
|
||||
it('is NOT fresh when notAfter is inside the renewal window', () => {
|
||||
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS - 60_000)
|
||||
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a missing cert (null notAfter) as not fresh', () => {
|
||||
expect(certIsFresh(null, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeLoopbackBaseApp (loopback-only)', () => {
|
||||
it('targets 127.0.0.1:PORT and returns true on an ok response', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string) => ({ ok: url.includes('127.0.0.1:3000') }))
|
||||
await expect(probeLoopbackBaseApp(3000, fetchImpl)).resolves.toBe(true)
|
||||
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:3000/')
|
||||
})
|
||||
|
||||
it('returns false on a non-ok response', async () => {
|
||||
await expect(probeLoopbackBaseApp(3000, async () => ({ ok: false }))).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a rejected fetch (a probe never throws)', async () => {
|
||||
await expect(
|
||||
probeLoopbackBaseApp(3000, async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range port without fetching', async () => {
|
||||
const fetchImpl = vi.fn(async () => ({ ok: true }))
|
||||
await expect(probeLoopbackBaseApp(0, fetchImpl)).resolves.toBe(false)
|
||||
await expect(probeLoopbackBaseApp(70000, fetchImpl)).resolves.toBe(false)
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('runHealthProbe (aggregate verdict)', () => {
|
||||
it('is healthy when all four sub-checks pass', async () => {
|
||||
const report = await runHealthProbe(healthySeams())
|
||||
expect(report).toEqual<HealthReport>({
|
||||
frpcAlive: true,
|
||||
baseAppReachable: true,
|
||||
proxyStarted: true,
|
||||
certFresh: true,
|
||||
healthy: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('is unhealthy if frpc is dead', async () => {
|
||||
const report = await runHealthProbe(healthySeams({ isFrpcAlive: () => false }))
|
||||
expect(report.frpcAlive).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
|
||||
it('is unhealthy if the base app is unreachable', async () => {
|
||||
const report = await runHealthProbe(healthySeams({ probeBaseApp: async () => false }))
|
||||
expect(report.baseAppReachable).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
|
||||
it('is unhealthy if the proxy never started', async () => {
|
||||
const report = await runHealthProbe(healthySeams({ readFrpcLog: () => 'connecting...' }))
|
||||
expect(report.proxyStarted).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
|
||||
it('is unhealthy if the cert is near expiry', async () => {
|
||||
const report = await runHealthProbe(
|
||||
healthySeams({
|
||||
certNotAfter: () => new Date('2026-07-08T01:00:00Z'), // 1h out, inside 8h window
|
||||
}),
|
||||
)
|
||||
expect(report.certFresh).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderHealthStatus (INV9 — non-secret only)', () => {
|
||||
const report: HealthReport = {
|
||||
frpcAlive: true,
|
||||
baseAppReachable: true,
|
||||
proxyStarted: true,
|
||||
certFresh: true,
|
||||
healthy: true,
|
||||
}
|
||||
|
||||
it('prints subdomain, host id, expiry date, and flags', () => {
|
||||
const lines = renderHealthStatus(
|
||||
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
|
||||
report,
|
||||
)
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).toContain('subdomain: alice')
|
||||
expect(joined).toContain('host_id: h-1')
|
||||
expect(joined).toContain('cert_expiry: 2026-08-01T00:00:00.000Z')
|
||||
expect(joined).toContain('healthy: true')
|
||||
})
|
||||
|
||||
it('leaks NO key/cert/token/CSR material', () => {
|
||||
const lines = renderHealthStatus(
|
||||
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
|
||||
report,
|
||||
)
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).not.toMatch(/PRIVATE KEY|BEGIN CERTIFICATE|BEGIN CERTIFICATE REQUEST/)
|
||||
expect(joined.toLowerCase()).not.toMatch(/token|secret|csr|pem/)
|
||||
})
|
||||
|
||||
it('renders (none)/(unknown) placeholders when identifiers are absent', () => {
|
||||
const lines = renderHealthStatus({ subdomain: null, hostId: null, certNotAfter: null }, report)
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).toContain('subdomain: (none)')
|
||||
expect(joined).toContain('cert_expiry: (unknown)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('startHealthMonitor (periodic)', () => {
|
||||
function fakeTimer(): { timer: IntervalTimer; fire: () => void; cleared: boolean } {
|
||||
let cb: (() => void) | null = null
|
||||
const state = { cleared: false }
|
||||
return {
|
||||
timer: {
|
||||
setInterval: (fn) => {
|
||||
cb = fn
|
||||
return 1
|
||||
},
|
||||
clearInterval: () => {
|
||||
state.cleared = true
|
||||
},
|
||||
},
|
||||
fire: () => cb?.(),
|
||||
get cleared() {
|
||||
return state.cleared
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('runs the probe on each tick and reports it', async () => {
|
||||
const report: HealthReport = {
|
||||
frpcAlive: true,
|
||||
baseAppReachable: true,
|
||||
proxyStarted: true,
|
||||
certFresh: true,
|
||||
healthy: true,
|
||||
}
|
||||
const probe = vi.fn(async () => report)
|
||||
const seen: HealthReport[] = []
|
||||
const ft = fakeTimer()
|
||||
const monitor = startHealthMonitor(probe, (r) => seen.push(r), { timer: ft.timer })
|
||||
|
||||
ft.fire()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
expect(seen).toEqual([report])
|
||||
|
||||
monitor.stop()
|
||||
expect(ft.cleared).toBe(true)
|
||||
})
|
||||
|
||||
it('swallows a rejected probe (monitor never crashes)', async () => {
|
||||
const ft = fakeTimer()
|
||||
const onReport = vi.fn()
|
||||
startHealthMonitor(async () => Promise.reject(new Error('boom')), onReport, { timer: ft.timer })
|
||||
ft.fire()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(onReport).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
renewCert,
|
||||
renewalUrlFor,
|
||||
} from '../src/certs/rotation.js'
|
||||
import { createBackoff } from '../src/transport/backoff.js'
|
||||
import { FakeTimer } from './fixtures/fakes.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
@@ -52,10 +53,10 @@ describe('renewCert (T13)', () => {
|
||||
it('installs a fresh cert atomically on success (same key)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const before = ks.loadIdentity()!.publicKey
|
||||
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' }))
|
||||
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }))
|
||||
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||
expect(out).toBe('rotated')
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' })
|
||||
expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA')
|
||||
// pubkey unchanged — only the cert rotated
|
||||
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
@@ -101,7 +102,7 @@ describe('createCertRotator (T13)', () => {
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch,
|
||||
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000),
|
||||
})
|
||||
@@ -113,7 +114,47 @@ describe('createCertRotator (T13)', () => {
|
||||
timer.advance(1000)
|
||||
await flush()
|
||||
expect(rotated).toBe(1)
|
||||
expect(ks.loadCert()!.certPem).toBe('NEWCERT')
|
||||
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
|
||||
rotator.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('invokes onError and retries with backoff (not renewBeforeMs) when a renewal throws', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
let calls = 0
|
||||
const fetchImpl = (async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('network down')
|
||||
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
|
||||
}) as unknown as typeof fetch
|
||||
const errors: unknown[] = []
|
||||
let rotated = 0
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
|
||||
fetchImpl,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000), // initial renewal scheduled at ~1000ms
|
||||
})
|
||||
rotator.onError((e) => errors.push(e))
|
||||
rotator.onRotated(() => {
|
||||
rotated += 1
|
||||
})
|
||||
rotator.start()
|
||||
|
||||
timer.advance(1000) // first attempt fires → throws
|
||||
await flush()
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(rotated).toBe(0)
|
||||
|
||||
// The retry is armed at the 500ms backoff delay, NOT renewBeforeMs (1000): advancing only 500
|
||||
// must fire it. A crash-loop never escapes here (the supervisor keeps running).
|
||||
timer.advance(500)
|
||||
await flush()
|
||||
expect(rotated).toBe(1)
|
||||
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
|
||||
rotator.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
128
agent/test/runTunnel.test.ts
Normal file
128
agent/test/runTunnel.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import type { Keystore } from '../src/keys/keystore.js'
|
||||
import { createBackoff } from '../src/transport/backoff.js'
|
||||
import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js'
|
||||
import { FakeTimer, FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
/**
|
||||
* C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop —
|
||||
* bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12).
|
||||
*/
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
const KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||
|
||||
function emitOpen(upstream: FakeWs, open: MuxOpen): void {
|
||||
const payload = encodeOpen(open)
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
function emitGoAwayRevoked(upstream: FakeWs): void {
|
||||
const payload = encodeGoaway(0, 'revoked')
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function baseDeps(over: Partial<RunTunnelDeps>): Partial<RunTunnelDeps> {
|
||||
return {
|
||||
timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test
|
||||
sleep: async () => {},
|
||||
backoff: createBackoff(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('runTunnel supervisor (C2)', () => {
|
||||
it('splices bytes both ways through the loopback', async () => {
|
||||
const upstream = new FakeWs()
|
||||
const loopback = new FakeWs()
|
||||
const connectRelay = vi.fn(async () => upstream)
|
||||
const dialLoopback = vi.fn(async () => loopback)
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never }))
|
||||
await flush()
|
||||
|
||||
emitOpen(upstream, OPEN)
|
||||
await flush()
|
||||
expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
|
||||
upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10])))
|
||||
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
|
||||
|
||||
loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA
|
||||
const last = decodeMuxFrame(upstream.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('data')
|
||||
expect([...last.payload]).toEqual([79, 75])
|
||||
|
||||
await handle.stop()
|
||||
expect(await handle.done).toBe(0)
|
||||
})
|
||||
|
||||
it('reconnects after the tunnel dies', async () => {
|
||||
const sockets = [new FakeWs(), new FakeWs()]
|
||||
let i = 0
|
||||
const connectRelay = vi.fn(async () => sockets[i++]!)
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||
|
||||
sockets[0]!.emit('close') // first session dies ⇒ supervisor redials
|
||||
await flush()
|
||||
expect(connectRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => {
|
||||
const connectRelay = vi.fn(async () => new FakeWs())
|
||||
let socket: FakeWs | undefined
|
||||
const wrapped = vi.fn(async () => {
|
||||
socket = new FakeWs()
|
||||
return socket
|
||||
})
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
expect(wrapped).toHaveBeenCalledTimes(1)
|
||||
|
||||
emitGoAwayRevoked(socket!)
|
||||
await flush()
|
||||
|
||||
expect(await handle.done).toBe(0)
|
||||
expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation
|
||||
expect(connectRelay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stop() ends the loop with exit code 0 and does not reconnect', async () => {
|
||||
const connectRelay = vi.fn(async () => new FakeWs())
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
|
||||
await handle.stop()
|
||||
expect(await handle.done).toBe(0)
|
||||
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
42
android/.gitignore
vendored
Normal file
42
android/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
**/build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!src/**/build/
|
||||
|
||||
# Gradle caches / config-cache
|
||||
.gradle/configuration-cache/
|
||||
|
||||
# IDE — IntelliJ IDEA / Android Studio
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
captures/
|
||||
.navigation/
|
||||
|
||||
# Local machine config (never commit)
|
||||
local.properties
|
||||
|
||||
# Android build outputs (relevant once the SDK-gated modules are enabled)
|
||||
*.apk
|
||||
*.aab
|
||||
*.ap_
|
||||
*.dex
|
||||
release/
|
||||
proguard/
|
||||
|
||||
# Secrets — never commit
|
||||
google-services.json
|
||||
**/google-services.json
|
||||
*.keystore
|
||||
*.jks
|
||||
*.p12
|
||||
service-account*.json
|
||||
|
||||
# OS cruft
|
||||
.DS_Store
|
||||
|
||||
# Kover / test reports
|
||||
**/kover/
|
||||
95
android/DEVICE_QA_CHECKLIST.md
Normal file
95
android/DEVICE_QA_CHECKLIST.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Android client — device-QA checklist (A36 / plan §7)
|
||||
|
||||
Everything below is **device/emulator-only** — it could NOT run in the build environment (no emulator,
|
||||
no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests,
|
||||
Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping.
|
||||
|
||||
## Deploy artifacts to provide first (not built here)
|
||||
- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its
|
||||
absence with `runCatching`, so the app runs without it; push just won't register).
|
||||
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
|
||||
omitted it — applying it without the json fails the build).
|
||||
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
|
||||
SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the
|
||||
`FCM_*` env group (service-account key path + project id).
|
||||
|
||||
## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device)
|
||||
- [ ] `attach → attached → output` round-trip timing.
|
||||
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
|
||||
- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy.
|
||||
- [ ] kill via `DELETE /live-sessions/:id` (swipe-to-kill) removes the row (404 = already-gone = success).
|
||||
- [ ] `POST /hook/decision` resolves a held gate (from a push Allow/Deny).
|
||||
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
|
||||
|
||||
## A35 — one macrobenchmark / Espresso happy path
|
||||
- [ ] pair → attach → type → approve, on a real device.
|
||||
|
||||
## Terminal (A16/A17/A21)
|
||||
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
|
||||
WebView fallback ONLY if fidelity diverges — not expected).
|
||||
- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap.
|
||||
- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are
|
||||
package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); resize parity vs
|
||||
web/iOS on the same device sizes (R5).
|
||||
- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay
|
||||
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
|
||||
generation bump → fresh emulator replays.
|
||||
- [ ] key-bar above the IME (soft keyboard never pops on a key-bar tap); hardware chords; DECCKM arrows
|
||||
emit `ESC O A` under app-cursor mode (vim/htop).
|
||||
- [ ] `FLAG_SECURE` + privacy cover blanks the recents thumbnail on `ON_STOP`.
|
||||
- [ ] off-main chunked append does not ANR on a multi-MB replay.
|
||||
|
||||
## Push (A30/A31 · R1/S2)
|
||||
- [ ] **S2 spike:** data-only high-priority delivery latency/loss on ≥2 real handsets (incl. one
|
||||
Xiaomi/Huawei/Samsung) under Doze / OEM battery managers / force-stop — quantify loss, drive the
|
||||
"delivery not guaranteed while backgrounded" user-facing copy.
|
||||
- [ ] **Deny** from the lock screen (BroadcastReceiver, no app open, expedited POST).
|
||||
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
|
||||
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
|
||||
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
|
||||
- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals
|
||||
on rotation / new host / removal.
|
||||
|
||||
## Pairing / cert / storage (A19/A27/A11/A12)
|
||||
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry.
|
||||
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
|
||||
bypass); public host explicit-ack.
|
||||
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
|
||||
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
|
||||
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
|
||||
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
||||
|
||||
## Nav / deep links / adaptive (A32/A26/A29)
|
||||
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
||||
right destination; invalid UUID ignored.
|
||||
- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens.
|
||||
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
||||
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
||||
|
||||
## Projects / git parity (W5 — presenters JVM-tested, Compose device-QA)
|
||||
- [ ] Project card **sync chip**: `↑ahead` / `↓behind` render only when non-zero; no chip when there is
|
||||
no upstream (fields absent).
|
||||
- [ ] Project detail **PR chip**: `availability=ok` → tappable chip opens the PR in the browser ONLY when
|
||||
the url is https (a non-https / junk url is inert, non-clickable); `no-pr` / `not-installed` /
|
||||
`unauthenticated` / `disabled` / `error` each render the degraded copy inertly; check-count colour
|
||||
(fail=red / pending=amber / pass=green).
|
||||
- [ ] Project detail **recent commits**: list renders short-hash + subject inertly; unavailable state on a
|
||||
log failure does NOT hide the rest of the detail (failure-isolated).
|
||||
- [ ] **New worktree** inline form: valid `branch` (+optional `base`) → create → list refreshes; an invalid
|
||||
branch name is rejected with NO network call; a disabled-403 shows the server's safe message.
|
||||
- [ ] Per-worktree **remove**: the button is absent on the `main` worktree; the confirm dialog offers a
|
||||
**Force** checkbox; a dirty-worktree 409 surfaces "force required" inertly; **prune** button works.
|
||||
- [ ] Diff **base-rev** input: entering a rev enters base mode (Working/Staged toggle hidden, `vs <rev>`
|
||||
shown, git-write controls hidden); Clear returns to working/staged; junk rev → server 400 surfaced.
|
||||
- [ ] Diff **stage/unstage**: per-file button (Working→"暂存", Staged→"取消暂存") posts the file and
|
||||
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
|
||||
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
|
||||
|
||||
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
|
||||
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
|
||||
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
|
||||
- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
|
||||
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
|
||||
- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView`
|
||||
is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to
|
||||
`:app` and delete the shim.
|
||||
416
android/PROGRESS_ANDROID.md
Normal file
416
android/PROGRESS_ANDROID.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Android Client — Progress (this-session working log)
|
||||
|
||||
> **Why this file exists (temporary):** `docs/PROGRESS_LOG.md` (the canonical cross-session
|
||||
> memory) was being **concurrently edited by another live session** (the tunnel-automation
|
||||
> workstream on branch `feat/tunnel-automation`) while this Android work ran on the SAME working
|
||||
> tree. To avoid a read-modify-write clobber of that session's log, the Android orchestrator
|
||||
> records progress here instead. **FOLD THESE ENTRIES INTO `docs/PROGRESS_LOG.md` once the
|
||||
> concurrent session is done** (they belong under the `🤖 ANDROID CLIENT` section).
|
||||
>
|
||||
> **Git status note:** nothing below is committed. The Android lane (`android/**`,
|
||||
> `src/push/fcm.ts`, `test/push-fcm.test.ts`, `src/server.ts` FCM wiring, `package.json`
|
||||
> google-auth-library) is file-disjoint from the tunnel work, but the shared `.git`/index means
|
||||
> **no `git add -A`** — commit the Android lane by explicit path once branch strategy is settled.
|
||||
|
||||
Plan: [`../docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md). 36 tasks, waves AW0–AW6.
|
||||
Prior state (commit `4ea8f78`/`4fe1981`): AW0 + AW1 pure-Kotlin foundation (218 tests) + Android
|
||||
SDK/AGP 9.2.1 toolchain proven.
|
||||
|
||||
---
|
||||
|
||||
## [x] Wave R1 — A7 + A14 + A33 (DONE, orchestrator-verified 2026-07-08)
|
||||
|
||||
One implementation Workflow (3 TDD builders in parallel) → 6-agent adversarial cross-review (2
|
||||
independent lenses/task) → fix Workflow (must-fixes + regression tests) → adversarial re-verify →
|
||||
**orchestrator independent unified gate** (not just agent self-report).
|
||||
|
||||
**Unified verification (measured):**
|
||||
- `cd android && ./gradlew test` → **BUILD SUCCESSFUL, 250 tests, 0 failures** (wire-protocol 47 /
|
||||
session-core 75 / api-client 69 / client-tls 27 / test-support 15 / **transport-okhttp 17**).
|
||||
- `npx vitest run test/` → **54 files, 1533 tests, 0 failures** (incl. new `test/push-fcm.test.ts`
|
||||
60 tests; no regression of the existing server suite).
|
||||
- `npm run typecheck` (tsc main + web) → clean.
|
||||
|
||||
### [x] A7 `:transport-okhttp` — OkHttp WS + REST transport (pure JVM; MockWebServer-tested)
|
||||
- Files: `transport-okhttp/src/main/.../transport/{OkHttpClientFactory, OkHttpTermTransport,
|
||||
OkHttpWebSocketConnection, OkHttpHttpTransport}.kt` + 3 test files. Implements frozen
|
||||
`TermTransport`/`PingableTermTransport`/`HttpTransport` verbatim (contracts unmodified).
|
||||
- WS: `connect()` stamps `Origin = endpoint.originHeader` on the upgrade (CSWSH; asserted
|
||||
byte-equal); `frames` = `channelFlow` draining an unlimited listener mailbox — clean close →
|
||||
normal completion, `onFailure` → error (distinguishable); `send`→`webSocket.send`,
|
||||
`close`→`close(1000)` (**detach, never kill**). REST: non-2xx RETURNED, transport-failure THROWN,
|
||||
headers verbatim (no self-added Origin). Shared `OkHttpClient` `.cache(null)` (§8) + default
|
||||
system trust; mTLS via a LOCAL `ClientIdentityProvider` fun-interface seam (module depends only
|
||||
on `:wire-protocol`, no `:client-tls` edge).
|
||||
- **Cross-review fixes (regression-tested):** (1) HIGH — handshake socket leak on mid-dial cancel:
|
||||
`openConnection` now tears down the just-created WS on any throwable (incl. `CancellationException`)
|
||||
via an idempotent `cancel()` (`webSocket.cancel()`+`finish(null)`) before rethrowing — proven
|
||||
red→green by revert. (2) HIGH — the redundant transport-level 16 MiB frame cap
|
||||
(`TransportFrameTooLargeException`) was **deleted**: it was unreachable from `:session-core` (which
|
||||
may depend only on `:wire-protocol`), and `SessionEngine` already self-measures frames and emits
|
||||
`REPLAY_TOO_LARGE` — the single authoritative classifier. (3) MEDIUM — pump rethrows
|
||||
`CancellationException` (defensive; kept as a contract lock).
|
||||
|
||||
### [x] A14 `SessionEngine` — pure lifecycle state machine (`:session-core`, runTest virtual time)
|
||||
- Files: `session-core/src/main/.../session/SessionEngine.kt` + `SessionEngineTest.kt` (15 tests).
|
||||
Composes ReconnectMachine/PingScheduler/GateTracker/AwayDigest over an injected `TermTransport` +
|
||||
dispatcher/TimeSource; NO OkHttp/Android imports.
|
||||
- Verified: attach-first ordering, adopt-server-id, connect-now-on-foreground, **close≠kill**,
|
||||
oversized-replay→terminal `REPLAY_TOO_LARGE`, gate-decision epoch drop, backoff ladder
|
||||
1→2→4→8→16→30, ping 25s/2-miss — all under virtual time via the fakes.
|
||||
- **Cross-review fixes (regression-tested):** (1) HIGH (found independently by BOTH reviewers) —
|
||||
`close()` during the dial/attach window failed to detach the freshly-opened connection: engine now
|
||||
re-checks the `closed` flag after dial and after attach, closing + returning terminal with no
|
||||
further emits (2 new tests: close-during-dial, close-during-attach). (4) MEDIUM — gate double-send:
|
||||
`decideGate()` now locally retires the held gate after a successful send so a second same-epoch tap
|
||||
is dropped (new test). (2) MEDIUM — `started` flip moved onto the confined dispatcher (invariant
|
||||
#4). (3) MEDIUM — suspend-call `runCatching` replaced with cancel-rethrowing `ignoringNonCancel`.
|
||||
- **KNOWN follow-up routed to A15 (AW2):** the engine does not close its live WS when its *scope* is
|
||||
cancelled (only on explicit `close()`). Intended teardown is explicit `engine.close()` (per §6.6),
|
||||
so **the `RetainedSessionHolder` MUST call `engine.close()` before cancelling the engine scope**;
|
||||
additionally consider a `finally`-close in the engine for defense-in-depth. Non-blocking for R1
|
||||
(server PTY survives either way; only affects prompt-vs-TCP-timeout detach).
|
||||
|
||||
### [x] A33 server FCM — the ONE additive server change (§4.5)
|
||||
- Files: NEW `src/push/fcm.ts` (`NotifyService` impl behind a seam) + `POST/DELETE /push/fcm-token`
|
||||
route + `initFcm`/`normalizeFcmToken` wiring in `src/server.ts` (combined via
|
||||
`combineNotifyServices` beside web-push/APNs — zero new event paths) + `FCM_*` env
|
||||
(all-or-disabled) + `test/push-fcm.test.ts` (60 tests). Added `google-auth-library@^10` to
|
||||
`package.json` deps (R7 decision).
|
||||
- Data-only high-priority messages; payload minimized to `sessionId`/`cls`/`token` (no
|
||||
notification block, no cwd/command); loose FCM-token validator; token/key never logged; disabled
|
||||
cleanly when `FCM_*` incomplete.
|
||||
- **Cross-review fix (regression-tested):** HIGH — removed `validateStatus:()=>true`, which had
|
||||
silently defeated google-auth-library's built-in 401/403 refresh-and-retry (the whole reason R7
|
||||
chose the lib). Now wraps `client.request` in try/catch, reading `{status,body}` from
|
||||
`GaxiosError.response`; a 401 goes through the lib's refresh before surfacing; a no-response
|
||||
transport error is a send failure (token never falsely pruned). 3 new tests (401→refresh→200,
|
||||
404/UNREGISTERED→prune, no-response→keep).
|
||||
|
||||
**Not run here (deferred per plan §7):** instrumented/device tests (no emulator installed) and the
|
||||
S2 FCM real-handset delivery spike (needs ≥2 physical devices under Doze/OEM battery managers).
|
||||
|
||||
---
|
||||
|
||||
## [~] Wave R2 — A13 (done) · A11 + A12 (building) · S1 → moved to head of AW3
|
||||
|
||||
### [x] A13 `:app` Compose baseline (DONE, orchestrator-verified 2026-07-08)
|
||||
First real full Android app module — establishes the UI-stack version matrix every later Android
|
||||
task reuses. Workflow: TDD builder → kotlin + design cross-review.
|
||||
- Files: `app/build.gradle.kts`, `AndroidManifest.xml`, `WebTermApp.kt` (@HiltAndroidApp),
|
||||
`MainActivity.kt` (@AndroidEntryPoint), `di/AppModule.kt` (minimal Hilt), `designsystem/{DesignSpec,
|
||||
Tokens,Typography,Theme,Primitives}.kt`, `res/{values,xml}/*`, `DesignSpecTest.kt` (5 JVM tests).
|
||||
- **Version matrix PROVEN** (`:app:assembleDebug` → 32.7 MB APK, `:app:testDebugUnitTest` green):
|
||||
AGP 9.2.1 · Kotlin 2.3.21 · compose-compiler plugin 2.3.21 · Compose BOM 2025.11.01 (material3
|
||||
1.4.0, ui 1.9.5, material3.adaptive 1.2.0) · Hilt 2.60.1 via KSP 2.3.9 · **compileSdk 36**
|
||||
(installed `platforms;android-36`; SDK-37 unavailable — cmdline-tools too old), minSdk 29,
|
||||
targetSdk 35. Design system mirrors iOS DS 1:1 (amber-gold #E3A64A/#C9892F, semantic status
|
||||
colors, 8pt scale, tabular mono numerals, dark-first, fixed terminal-canvas #100F0D/#ECE9E3/gold).
|
||||
- **Cross-review fix (orchestrator applied + re-verified assemble green):** both reviewers flagged
|
||||
the primitives (StatusBadge/TelemetryChip/WebTermCard) hardcoding `WebTermColors.dark.*` → would
|
||||
render wrong in light theme. Routed through `MaterialTheme.colorScheme.{onSurfaceVariant,
|
||||
surfaceVariant,outline}` (Theme.kt already maps those). Also fixed preview `.dp` literals →
|
||||
`Spacing.*`. Docs synced: `android/README.md` + `settings.gradle.kts` recipe + `[[android-build-env]]`
|
||||
memory now say compileSdk 36 / android-36.
|
||||
- Deferred (per §7): instrumented/Compose-UI tests (no emulator). Follow-up for AW3: add a
|
||||
`Motion.gated()` helper (reduce-motion) to Tokens.kt when the first animation lands (LocalReduceMotion
|
||||
+ DesignSpec.MOTION_* already present); register a ContentObserver on ANIMATOR_DURATION_SCALE then.
|
||||
|
||||
### [x] A11 `:client-tls-android` — ClientTLS framework half (DONE, orchestrator-verified)
|
||||
Catalog pre-staged (tink 1.15.0, datastore-preferences 1.1.1, androidx-test); enabled in settings.
|
||||
Build workflow → security+kotlin cross-review → fix workflow → adversarial security re-verify →
|
||||
orchestrator applied 3 more fixes the re-verify caught + independent compile gate.
|
||||
- Files: `client-tls-android/src/main/.../tlsandroid/{AndroidKeyStoreImporter, TinkCertStore(+CertStore
|
||||
iface), ReReadingX509KeyManager, IdentityRepository(+ClientSslMaterial), StoredIdentityMetadata}.kt`
|
||||
+ androidTest `{Fixtures, AndroidKeyStoreImporterTest, IdentityRepositoryTest}.kt`.
|
||||
- ONE key home = AndroidKeyStore (non-exportable, per-handshake re-reading `X509KeyManager`); Tink AEAD
|
||||
encrypts ONLY the cert-chain+metadata blob; NO `.p12`/passphrase persisted; validate-before-persist;
|
||||
`connectionPool.evictAll()` on rotate/remove. Exposes `IdentityRepository`/`ClientSslMaterial` as the
|
||||
seam A15 bridges to `:transport-okhttp`'s `ClientIdentityProvider` (NO `:transport-okhttp` dep).
|
||||
- **Security must-fix (rotation safety) — resolved via single-commit + adversarial follow-through:**
|
||||
the re-verify caught the fixer's first attempt still lost the prior identity. Final design:
|
||||
**ping-pong staging slot → one atomic durable `SharedPreferences.commit()` live-pointer flip**
|
||||
(`StoredIdentityMetadata.keyStoreAlias`). Any failure before the flip → prior identity fully live;
|
||||
after → new identity fully live; stores never diverge. Orchestrator then fixed 3 residual bugs the
|
||||
security re-verify empirically proved: **(1 HIGH)** `currentLive()` used `liveOverride?.value ?:
|
||||
initialIdentity` which collapsed `Box(null)` (explicitly removed) into the stale cached identity →
|
||||
a *removed* cert stayed "live" and could be presented with a dangling key; now resolves via Box
|
||||
presence. **(3)** `TinkCertStore.save()/clear()` switched `apply()`→`commit()` (durable + throws on
|
||||
failure) so a crash-window can't leave the pointer naming a just-deleted key (both-identities-lost).
|
||||
**(2)** widened staging-key cleanup to cover the key-readback/encode steps. Added instrumented
|
||||
regression `remove_afterStartupTouch_reportsNoIdentity_notStaleCached`.
|
||||
- Verified (orchestrator): `./gradlew :client-tls-android:assembleDebug :client-tls-android:compileDebugAndroidTestKotlin`
|
||||
→ BUILD SUCCESSFUL (main + instrumented compile). All AndroidKeyStore/Tink tests run on device (§7).
|
||||
|
||||
### [x] A12 `:host-registry` — Host/HostStore + DataStore + LastSessionStore (DONE, both reviewers approved)
|
||||
- Files: `host-registry/src/main/.../hostregistry/{Host, HostStore(+immutable transforms),
|
||||
InMemoryHostStore, DataStoreHostStore, LastSessionStore, DataStoreLastSessionStore, HostCodec}.kt`
|
||||
+ tests (JVM unit: HostStoreTransforms/InMemoryHostStore/HostCodec/InMemoryLastSessionStore) + androidTest.
|
||||
- Immutable HostStore (upsert/remove return new lists, position-preserved, unknown-id no-op);
|
||||
DataStore read-modify-write; HostEndpoint re-validated on load; LastSessionStore set-on-adopted /
|
||||
clear-on-exited. **Orchestrator fix:** `DataStoreLastSessionStore` now guards the persisted id with
|
||||
the frozen v4 `Validation.isValidSessionId` (was `UUID.fromString` format-only).
|
||||
- Verified (orchestrator): `./gradlew :host-registry:assembleDebug :host-registry:testDebugUnitTest`
|
||||
→ BUILD SUCCESSFUL, **17 JVM unit tests green**; DataStore-backed tests are androidTest (device QA).
|
||||
|
||||
### S1 renderer spike — relocated to the head of AW3 (it gates A16, the XL renderer task).
|
||||
|
||||
**Wave R2 complete.** Android modules now: 6 pure-JVM (250 tests) + `:app` + `:host-registry` +
|
||||
`:client-tls-android` (framework, assemble+unit-verified here; instrumented deferred to device QA).
|
||||
|
||||
---
|
||||
|
||||
## [x] Wave AW2 — A15 `:app` wiring + DI FREEZE (DONE, orchestrator-verified 2026-07-09)
|
||||
|
||||
The convergence/contract task that gates all of AW4. Build → arch+coroutine cross-review (BOTH blocked)
|
||||
→ fix (6 must-fixes) → arch+coroutine re-verify (caught 1 more real bug) → residual fix → orchestrator
|
||||
added the final test lock + independent gate. `./gradlew :app:assembleDebug :app:testDebugUnitTest
|
||||
:session-core:test` all green (EventBus 6 tests, RetainedSessionHolder 4, DesignSpec 5, SessionEngine 15).
|
||||
|
||||
- Files: `app/src/main/.../wiring/{EventBus, TerminalSessionController, RetainedSessionHolder,
|
||||
AppEnvironment, ColdStartPolicy, ApiClientFactory, SessionEngineFactory}.kt` + `di/{NetworkModule,
|
||||
TlsModule, StorageModule, SessionModule}.kt` (replaced A13's placeholder AppModule) + tests.
|
||||
- **Frozen contracts (AW4 depends on these):** per-session `EventBus` (NOT @Singleton) with two typed
|
||||
sub-streams `outputBytes(): Flow<ByteArray>` (Output-only, UTF-8 decode off-Main via flowOn) +
|
||||
`controlEvents(): Flow<SessionEvent>` (non-Output); `TerminalSessionController` (start() decoupled
|
||||
from bind(), eager mailbox registration); `RetainedSessionHolder` (@HiltViewModel, config-survival,
|
||||
single-session-for-life BoundKey guard, close-join-before-cancel teardown); `AppEnvironment.warmUp()`
|
||||
(off-Main first identity touch); `ColdStartPolicy`; factories for per-host ApiClient + per-session
|
||||
SessionEngine. mTLS bridge: A11 `IdentityRepository` → A7 `ClientIdentityProvider` → ONE shared
|
||||
`OkHttpClient` (.cache(null), WS+REST) — construction cycle broken via a shared `ConnectionPool`.
|
||||
- **Coordination change (authorized):** `SessionEngine.close()` now returns its `Job` so teardown can
|
||||
`join()` the clean detach BEFORE cancelling the confinement scope (structural close-before-cancel).
|
||||
- **Cross-review caught + fixed:** (HIGH) EventBus was @Singleton → cross-session event bleed → now
|
||||
per-session; (HIGH) bind()-started-engine-before-UI-subscribe → lost `attached`+replay → decoupled
|
||||
start() + eager registration; (HIGH) eager DI did AndroidKeyStore/Tink I/O on Main → dagger.Lazy +
|
||||
warmUp() on IO; (HIGH, re-verify) `register()`'s finally-close made the reused controller's output/
|
||||
events flows DEAD after one config-change (blank terminal on rotation) → mailbox now lives for the
|
||||
session (receiveAsFlow consume=false), released only by `EventBus.close()` at teardown; + typed
|
||||
sub-streams, structural close, bind mis-route guard, @Volatile started. R10 (per-consumer UNLIMITED
|
||||
channels: slow consumer neither stalls nor drops) unit-tested; confinement invariant #4 intact.
|
||||
|
||||
## [~] Wave AW3 — A16 `:terminal-view` DONE · A17 KeyBar + A18 ThumbnailPipeline building
|
||||
|
||||
### [x] S1 + A16 `:terminal-view` (XL) — the renderer, DONE + orchestrator-verified 2026-07-09
|
||||
**S1 gate PASSED headless** (no device): a Termux `TerminalEmulator` (no JNI/subprocess) fed canned WS
|
||||
bytes renders into a readable `TerminalBuffer` ("hello", cursorCol 5); DSR reply routes out via
|
||||
`TerminalOutput.write`→engineSend; DECCKM `ESC[?1h` flips to `ESC OA`. **Termux resolved via JitPack**
|
||||
`com.github.termux.termux-app:{terminal-view,terminal-emulator}:v0.118.0` (only transitive dep
|
||||
androidx.annotation; no guava → R2 shim unneeded; Apache-2.0 scope held — never termux-shared/app).
|
||||
JitPack repo + coordinate added to settings/catalog; `:terminal-view` enabled.
|
||||
- Files: `terminal-view/src/main/.../terminalview/{RemoteTerminalSession, RemoteTerminalView,
|
||||
TerminalGridMath, NoOpTerminalSessionClient, LinkPolicy}.kt` + 5 test suites (17 unit tests).
|
||||
- `RemoteTerminalSession` = the FORK (no subprocess): a single confined-writer via an ordered
|
||||
`Channel<TerminalCommand>` (Feed/Resize/Bind/Unbind); append chunked 4 KiB + yield(); `onScreenUpdated`
|
||||
posted to an injected main dispatcher. pendingOutput queued-then-flushed in order (ESC[0m preserved).
|
||||
Resize math extracted as pure `TerminalGridMath` (R5), JVM-tested. Titles pass RAW (no :session-core
|
||||
edge — :app wires TitleSanitizer); OSC-52 declined; http/https LinkPolicy.
|
||||
- **Sound deviation (§6.1):** at v0.118.0 Termux `TerminalView`/`TerminalSession` are `final` →
|
||||
`RemoteTerminalView` is a COMPOSITION wrapper binding the forked emulator via the public `mEmulator`
|
||||
field (rendering stays 100% stock). Documented.
|
||||
- **Cross-review (correctness approved; threading blocked→fixed):** HIGH — bind published `mEmulator`
|
||||
to the renderer SYNC before the pendingOutput flush → bind-time UI-read-vs-confined-append race;
|
||||
fixed by routing the publish through the Bind command (flush on confined thread, THEN post
|
||||
publish+onScreenUpdated to Main together; test captures buffer state AT publish). MEDIUM — confined
|
||||
command loop now try/catches (rethrows Cancellation) so a hostile escape byte can't freeze output.
|
||||
LOW — added `kotlinx-coroutines-android` (else `Dispatchers.Main` crashes on-device). Verified:
|
||||
`:terminal-view:assembleDebug` + `testDebugUnitTest` 17/17 green.
|
||||
- **Accepted (not a defect):** steady-state append runs concurrently with the UI-thread `onDraw` — this
|
||||
is the intended §6.2 single-WRITER model (matches upstream Termux; torn read self-corrects next frame).
|
||||
- Deferred to device QA (§7): glyph/true-color rendering, IME/CJK, selection→clipboard, link-tap,
|
||||
rotation-rebind, real font-metric→grid (TerminalRenderer metrics are package-private → measured
|
||||
on-device, fed to the tested TerminalGridMath).
|
||||
|
||||
### [x] A17 KeyBar (DONE, green) — `app/.../components/KeyBar.kt` + test (12 tests)
|
||||
Compose mobile key-bar overlay porting `public/keybar.ts` (17 buttons, order/glyphs 1:1). Each tap →
|
||||
`KeyByteMap.bytes(key)` sent VERBATIM via an injected `onSend` (A21 wires `controller::sendInput`),
|
||||
bypassing IME/BasicTextField (soft keyboard never pops). Pinned above IME via
|
||||
`windowInsetsPadding(WindowInsets.ime.union(navigationBars))`, horizontally scrollable, hidden when
|
||||
`screenWidthDp>768` OR a hardware keyboard is present. **DECCKM split** = pure
|
||||
`HardwareKeyRouter.resolve()`: ⇧Tab→ESC[Z, Esc→ESC, Ctrl+{C,R,O,L,T,B,D}→control byte; everything else
|
||||
(arrows/Enter/Tab/unmapped) → `DeferToTerminal` so Termux `KeyHandler` emits DECCKM-correct `ESC OA`
|
||||
(never hardcoded `ESC[A`). Verified `:app` 27 tests green (KeyBar 12). Cross-reviewed: reviewers
|
||||
stalled (infra), but the byte contract is unit-locked. Device-QA: layout/IME-inset/real key delivery.
|
||||
A21 install: `remote.onKeyCommand = { kc,ev -> HardwareKeyRouter.handle(kc,ev,controller::sendInput) }`
|
||||
+ `KeyBar(onSend = controller::sendInput)`.
|
||||
|
||||
### [x] A18 ThumbnailPipeline (DONE, green — review deferred) — `app/.../wiring/ThumbnailPipeline.kt` + test (5 tests)
|
||||
Off-screen preview rasterisation (§6.7): `LruCache` keyed `(sessionId, lastOutputAt)` (unchanged ⇒
|
||||
cache hit, no re-render); fair `Semaphore(2)` FIFO fetch+render cap; in-flight dedup via
|
||||
`Map<Key,Deferred<Bitmap>>` under a `Mutex` (2nd same-key request awaits the 1st); failure caches a
|
||||
PLACEHOLDER (no retry storm); preview fetched over the shared **mTLS** `ApiClient` (`GET
|
||||
/live-sessions/:id/preview`, 256 KiB cap); off-screen `TerminalEmulator` (no view) → `Canvas` cell
|
||||
painter behind a `Rasterizer` seam (bitmap draw device-QA'd; concurrency/cache logic JVM-tested).
|
||||
**NOTE:** A18's build agent + all 4 AW3 reviewers stalled ~3.5h (infra degradation); the agent had
|
||||
written the file first. Orchestrator fixed a 1-char test-name error (illegal `;` in a backtick name),
|
||||
re-verified `:app:assembleDebug + testDebugUnitTest` → **32 tests green**. A18's formal cross-review was
|
||||
NOT run — flag it for the AW6 acceptance pass (concurrency code warrants a second look).
|
||||
|
||||
**Wave AW3 complete.** `:terminal-view` (17) + `:app` (32) + 6 pure-JVM (250) all green. The terminal
|
||||
render path — the plan's dominant risk — is proven and hardened.
|
||||
|
||||
---
|
||||
|
||||
## [~] Wave AW4 — 11 UI screens (A19–A29)
|
||||
|
||||
### [x] A21 Terminal screen + reconnect/EXIT banner + new-session-in-cwd (built, green — integration pass in flight)
|
||||
Files: `components/ReconnectBanner.kt` (pure `bannerModel` reducer: exited/failed OUTRANK
|
||||
connecting/reconnecting; exit -1 spawn-failure; REPLAY_TOO_LARGE no-spinner+new-session), `wiring/
|
||||
TerminalSessionControllerImpl.kt` (wraps the holder controller `by base`, wires output→feedRemote
|
||||
before start, OSC title→TitleSanitizer), `screens/TerminalScreen.kt` (generation-keyed AndroidView,
|
||||
KeyBar/HardwareKeyRouter, FLAG_SECURE privacy shade, new-session-in-cwd → attach(null,cwd)). `:app` 64
|
||||
tests (ReconnectBanner 8 + NewSessionInCwd 3).
|
||||
|
||||
### [x] A22 Gate/cockpit surfaces (built, green, reviewer approved)
|
||||
Files: `viewmodels/GateViewModel.kt` (two-line epoch stale-guard, decide via controller.decideGate,
|
||||
approve.mode top-level) + `components/{GateBanner (tool 2-btn), PlanGateSheet (plan 3-way sheet),
|
||||
TelemetryChips, AwayDigestView}.kt`. Built but not yet composed into the terminal screen (→ integration).
|
||||
|
||||
### [x] Terminal-path integration + A15 seam refinement (DONE, orchestrator-verified — `:app` 66 tests)
|
||||
Both re-verifiers confirmed (arch 7/0 broken cosmetic; kotlin 7/0). Frozen-contract changes:
|
||||
`TerminalSessionController.events` (single mailbox) → `controlEvents()` (per-consumer mailbox, no
|
||||
split); `RetainedSessionHolder.generation` → `mutableIntStateOf`; holder now owns
|
||||
`TerminalSessionControllerImpl` + `RemoteTerminalSession` (config-survival §6.6 — emulator+scrollback
|
||||
content survive rotation, no replay round-trip); GateViewModel + gate surfaces composed into
|
||||
TerminalScreen (haptic reduce-motion gated); warmUp error/retry pane. Confinement invariant #4 intact.
|
||||
Residual (cosmetic, tracked for AW6 doc pass): dangling `events` KDoc refs; scroll *offset* (not
|
||||
content) resets on rotation. Tests: TerminalSessionControllerTest (2, multi-consumer no-split),
|
||||
RetainedSessionHolder (4), GateViewModel (10).
|
||||
|
||||
### [x] AW4b — A19 Pairing + A20 Session list + A24 Diff + A25 Quick-reply (DONE, `:app` 111 tests green)
|
||||
Pre-staged CameraX (1.4.1) + ML Kit barcode (17.3.0) for A19 QR. All 4 reviewers approve/warn, 0 must-fix.
|
||||
- **A19 Pairing** — QR (CameraX+MLKit) / manual, both through the ONE `HostEndpoint.fromBaseUrl`
|
||||
validator; confirm-before-network; §5.4 tiers (public explicit-ack; tunnel cert-gated choke point
|
||||
retry can't bypass; fail-safe unknown→PUBLIC; tunnel-TLS→client-cert copy); no cert import; inert.
|
||||
- **A20 Session list** — STARTED-scoped poll, status/telemetry/thumbnail/cols×rows/sanitized-title/
|
||||
unread-dot rows, swipe-kill (optimistic, 404=success), multi-host switch, host menu (配对新主机/设备证书).
|
||||
SessionListViewModelTest 11.
|
||||
- **A24 Diff** — staged flag as STRING "1"/"0", files→hunks→lines flatten, lossy decode, inert read-only.
|
||||
A24's builder died mid-run (API drop) leaving a missing `LaunchedEffect` import (blocked :app compile)
|
||||
+ no test; orchestrator added the import + wrote DiffViewModelTest (8 tests: fromWire, flatten order,
|
||||
lossy decode, VM phase transitions + staged re-fetch).
|
||||
- **A25 Quick-reply** — DataStore CRUD palette (immutable), verbatim send, float-while-gate-held.
|
||||
|
||||
### [x] AW4c — A23 Projects + A27 CertScreen + A28 Timeline + A29 Cold-start (DONE, `:app` 164 tests green)
|
||||
Reviews: A27 approve; A23/A29 warn; all 0 must-fix except A28's "wired to away-digest expand" (a
|
||||
composition/nav wiring → tracked for the app-assembly pass below, not a code defect).
|
||||
- **A23 Projects** — ProjectGrouping BYTE-IDENTICAL group keys to web/iOS (" active"/" other" sentinels,
|
||||
first-seen-cased namespace keys, MIN_GROUP_SIZE=2); favourites/collapse via `/prefs` unknown-key-
|
||||
preserving (R11: never PUT if never loaded); detail page; open-Claude-here → attach(null,cwd,"claude\r");
|
||||
grid column seam (A26 owns full adaptive). ProjectsViewModelTest 12.
|
||||
- **A27 ClientCertScreen** — import(SAF .p12)/rotate/remove over `:client-tls-android` IdentityRepository;
|
||||
validate-before-persist keeps prior cert on bad passphrase; summary (issuer/subject-CN/expiry/EXPIRED);
|
||||
passphrase scrubbed, never logged; confirm-gated remove; inert. ClientCertViewModelTest 14.
|
||||
- **A28 Timeline** — TimelineSheet(ModalBottomSheet) + TimelineViewModel over `/events`, lossy decode,
|
||||
tokenized event colors. TimelineViewModelTest 16. (away-digest→sheet wiring → app-assembly.)
|
||||
- **A29 Cold-start** — SessionActivityBridge (set LastSessionStore on adopted / clear on exited),
|
||||
ContinueLastBanner (stack+sidebar), ColdStartPolicy route (no host→pairing). Tests: bridge 6 + policy 2.
|
||||
|
||||
### [x] A26 Adaptive large-screen + nav shell (DONE, review warn 0 must-fix)
|
||||
Pure `LayoutPolicy.mode(WindowWidth)` (compact→STACK, medium/expanded→LIST_DETAIL) + `PointerMenuPolicy.
|
||||
enabled(mode, sw>=600)` — both JVM-tested (LayoutPolicyTest 8). `AdaptiveHome` = NavigationSuiteScaffold +
|
||||
ListDetailPaneScaffold keyed on `currentWindowAdaptiveInfo()`. `PointerContextMenu` = `pointerInput`
|
||||
secondary-click → `DropdownMenu` (NOT ContextMenuArea), gated. Full NavGraph wiring → A32/app-assembly.
|
||||
|
||||
**Wave AW4 COMPLETE — all 11 screens (A19–A29) built + verified; `:app` green.**
|
||||
|
||||
---
|
||||
|
||||
## [~] Wave AW5 — push + deep-links (built; wiring → app-assembly)
|
||||
|
||||
### [x] A30 FCM client (green — `:app` 222 tests, PushDecisionTest 14)
|
||||
`push/{FcmService, NotificationBuilder, DenyBroadcastReceiver, AllowTrampolineActivity, PushPayload,
|
||||
PushDecisionSubmitter, PushTokenSink}.kt`. Data-only payload → notification built LOCALLY; **Deny** =
|
||||
BroadcastReceiver (goAsync + expedited POST, no UI, auth-free); **Allow** = translucent
|
||||
excludeFromRecents FragmentActivity hosting `BiometricPrompt` → POST (R1). Token single-use, only in
|
||||
FLAG_IMMUTABLE extras → ApiClient, never persisted/logged (test-asserted); payload minimized (no
|
||||
cwd/command/bytes). Multi-host: tries each paired host until 204 (foreign host 403s harmlessly).
|
||||
Biometric = BIOMETRIC_STRONG+negativeButton (minSdk-29 valid combo). Manifest (service/receiver/activity)
|
||||
+ `Theme.WebTerm.Translucent` **applied by orchestrator; `:app` assembles**.
|
||||
|
||||
### [x] A31 PushRegistrar (green — PushRegistrarTest 7)
|
||||
`push/PushRegistrar.kt` — POST /push/fcm-token to each paired host, self-heal (token rotation / new host
|
||||
/ removal→DELETE), token not logged. Review HIGH: not yet invoked in the app → **app-assembly** (DI:
|
||||
@Binds PushTokenSink + on-start/host-change invocation).
|
||||
|
||||
### [x] A32 DeepLinkRouter + NavGraph (green, review approve — DeepLinkRouterTest)
|
||||
`nav/{DeepLinkRouter, NavGraph}.kt` — pure `webterminal://open?host=&join=` + verified App-Links parser,
|
||||
v4-UUID whitelist on host+join (invalid ignored+counted, never partial), one router for cold/warm/push.
|
||||
Manifest intent-filters (custom scheme + `autoVerify` https) **applied by orchestrator; `:app` assembles**.
|
||||
|
||||
### [x] APP-ASSEMBLY — the app is a RUNNABLE whole (DONE, `:app` 227 tests, APK builds)
|
||||
`MainActivity` (@AndroidEntryPoint) → `WebTermNavHost` composing all screens; start destination from
|
||||
`ColdStartPolicy`; inter-screen callbacks wired (row/project/continue → terminal, host-menu →
|
||||
pairing/cert, away-digest → timeline); `AdaptiveHome` for large screens; PushRegistrar DI (@Binds
|
||||
PushTokenSink + on-start/host-change invocation); deep links via DeepLinkRouter (onCreate+onNewIntent);
|
||||
warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid+acyclic. Manifest
|
||||
(push components + deep-link intent-filters) applied by orchestrator.
|
||||
- **Review HIGH fixed by orchestrator:** cold-start deep links were dropped by a composition race
|
||||
(navigate before the NavHost graph was set) → moved `DeepLinkEffect` inside the `route != null`
|
||||
branch so it runs only after `WebTermNavHost` composed (graph set). Re-verified `:app` 227 green.
|
||||
- Minor gaps tracked (see DEVICE_QA_CHECKLIST): push-tap→specific-gate, diff inbound link, host-remove
|
||||
UI, the A21 Termux-view reflection shim.
|
||||
|
||||
**Wave AW5 COMPLETE.**
|
||||
|
||||
---
|
||||
|
||||
## [x] Wave AW6 — acceptance (DONE, orchestrator-verified 2026-07-09)
|
||||
- **[x] A36 coverage gate:** Kover 0.9.1 applied to the 4 pure modules with an enforced `koverVerify`
|
||||
`minBound(80)` rule. Measured line coverage: **wire-protocol 91.0%** (added a HostClassifier/TimelineEvent
|
||||
test — the type lives here but was only tested cross-module, so its own report read 77% → 91%),
|
||||
**session-core 95.2%, api-client 89.2%, client-tls 96.4%**. `./gradlew koverVerify` GREEN.
|
||||
- **[x] Device-QA checklist:** `android/DEVICE_QA_CHECKLIST.md` — captures **A34** (instrumented E2E vs
|
||||
real `npm start`: attach→attached→output, reconnect replay, kill, hook/decision, **bad-Origin reject
|
||||
F9**) + **A35** (pair→attach→type→approve macrobenchmark) + the **S2** FCM real-handset spike + every
|
||||
per-task device-deferred item + the deploy artifacts (google-services.json, assetlinks.json, FCM_* env).
|
||||
A34/A35 are device/instrumented by definition (plan §7) — deferred to real hardware, not run here.
|
||||
- **[x] FINAL UNIFIED GATE (orchestrator, measured):** `./gradlew test :app:assembleDebug
|
||||
:app:testDebugUnitTest koverVerify` → **BUILD SUCCESSFUL**. ~**484 JVM tests** (257 pure/transport +
|
||||
227 `:app`) + Kover ≥80% + the full `:app` APK + `:terminal-view`/`:host-registry`/`:client-tls-android`
|
||||
all assemble. Server side (A33 FCM) unchanged since R1 (1533 server tests green).
|
||||
|
||||
---
|
||||
|
||||
## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1–A36 + S1) landed
|
||||
|
||||
Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure
|
||||
JVM) + `:app :terminal-view :host-registry :client-tls-android` (framework). The Android app **builds to
|
||||
an APK** with functional parity to the iOS P0+P1 scope; the Termux renderer seam (the plan's dominant
|
||||
risk) is proven working headless. S2 (FCM real-device delivery) is the one spike inherently requiring
|
||||
≥2 physical handsets — documented in the checklist. Everything device-observable is deferred to
|
||||
`DEVICE_QA_CHECKLIST.md` per plan §7 (no emulator/Firebase/host in this env).
|
||||
|
||||
**Method:** every wave ran a Workflow (TDD builders → 2-lens adversarial cross-review → fix workflow →
|
||||
adversarial re-verify → orchestrator-independent gate). Cross-validation caught + fixed ~20 real defects
|
||||
before they reached a screen (transport socket leaks, engine close-during-dial + gate double-send, an
|
||||
FCM token-refresh regression, a security bug where a REMOVED client cert stayed live in TLS, cross-session
|
||||
event bleed, blank-terminal-on-rotation, terminal-freeze-on-hostile-bytes, cold-start deep-link drop, …).
|
||||
|
||||
**GIT / not committed:** all Android work is on-disk + test-verified but UNCOMMITTED — a concurrent
|
||||
session was running the tunnel-automation workstream on this same `feat/tunnel-automation` working tree,
|
||||
so the orchestrator held ALL git ops and never touched `docs/PROGRESS_LOG.md`. **TODO for the user:**
|
||||
reconcile the two sessions, then commit the Android lane by explicit path and fold this file into
|
||||
`docs/PROGRESS_LOG.md`.
|
||||
|
||||
### Tracked APP-ASSEMBLY items (a final wiring pass composes screens into the NavGraph + connects
|
||||
### callbacks): away-digest expand → TimelineSheet (A28); host menu → pairing/cert (A20→A19/A27);
|
||||
### project open → terminal (A23→A21); continue-last → terminal (A29); session row → terminal (A20→A21);
|
||||
### and the A21 reflection shim → `libs.termux.terminal.view` on `:app` (dep now available to add).
|
||||
Cross-review of A21/A22 surfaced interlocking frozen-seam gaps being fixed together: (1 HIGH)
|
||||
`controller.events` was a single mailbox but banner+gate both collect → event split → made
|
||||
multi-consumer (`controlEvents()` per consumer); (2 HIGH) `holder.generation` made Compose-observable
|
||||
(mutableIntStateOf) so a real-background bump re-keys the AndroidView; (3 HIGH) RemoteTerminalSession/
|
||||
controller moved into the config-surviving RetainedSessionHolder so §6.6 rotation keeps the emulator+
|
||||
scrollback; (4) A22 gate surfaces composed into TerminalScreen; (5) warmUp error handling. A21 also
|
||||
flagged for later: the reflection shim to reach the impl-scoped Termux `TerminalView` from `:app`
|
||||
(clean fix = add `libs.termux.terminal.view` to `:app`); adopted-session-id survival across
|
||||
real-background → A29's LastSessionStore.
|
||||
|
||||
### Remaining AW4 screens (independent of the terminal path, next batches):
|
||||
A19 Pairing (QR/confirm/tiers) · A20 Session list+dashboard+host menu · A23 Projects+grouping+detail ·
|
||||
A24 Diff viewer · A25 Quick-reply chips+palette · A27 ClientCertScreen · A28 Timeline sheet (wires to
|
||||
A22 away-digest expand) · A29 Cold-start UX (ColdStartPolicy + LastSessionStore lifecycle) · A26
|
||||
Adaptive large-screen (LAST — deps A20+A21).
|
||||
- **[ ] AW2** A15 wiring/DI freeze (carry the A14 scope-close follow-up above).
|
||||
- **[ ] AW3** A16/A17/A18 · **[ ] AW4** A19–A29 · **[ ] AW5** A30–A32 · **[ ] AW6** A34–A36.
|
||||
131
android/README.md
Normal file
131
android/README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# WebTerm — Android client
|
||||
|
||||
A native Android client for the WebTerm browser-terminal server, targeting functional
|
||||
parity with the shipped **iOS** client. See the full design in
|
||||
[`docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md) (stack §2, module
|
||||
architecture §3, server contract §4, task waves §5).
|
||||
|
||||
This directory is a **Gradle multi-module** project. The module set mirrors the iOS
|
||||
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
||||
upward* (ARCHITECTURE §1).
|
||||
|
||||
## Build environment (SDK installed — all modules build)
|
||||
|
||||
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
|
||||
alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build
|
||||
against SDK 35/36.
|
||||
|
||||
- **Pure Kotlin/JVM (`./gradlew test`):** `:wire-protocol`, `:session-core`, `:api-client`,
|
||||
`:client-tls`, `:test-support`, `:transport-okhttp`.
|
||||
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
|
||||
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
||||
|
||||
Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`;
|
||||
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
|
||||
`./gradlew test :app:assembleDebug koverVerify`.
|
||||
|
||||
## Module map (mirror of the iOS SPM packages — plan §3)
|
||||
|
||||
| iOS SPM package | Android module | Kind | Status |
|
||||
|------------------------|------------------------|-------------------------------|--------|
|
||||
| WireProtocol | `:wire-protocol` | pure Kotlin/JVM | ✅ built |
|
||||
| SessionCore (reducers) | `:session-core` | pure Kotlin/JVM | ✅ built |
|
||||
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
|
||||
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
|
||||
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
|
||||
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ✅ built |
|
||||
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
|
||||
|
||||
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
|
||||
> impls, JVM) is owned by task **A7** and will be added then. The iOS
|
||||
> `URLSession*Transport`s consolidate into it (plan §3 framing note).
|
||||
|
||||
### Dependency graph (arrows = "depends on")
|
||||
|
||||
```
|
||||
:app
|
||||
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
:terminal-view :session-core :api-client :host-registry :client-tls-android
|
||||
│ │ │ │
|
||||
│ │ │ ▼
|
||||
│ │ │ :client-tls (pure)
|
||||
└──────┬───────┴──────────┴──────────────┬────────────────┘
|
||||
▼ ▼
|
||||
:wire-protocol ◀──────────── :transport-okhttp
|
||||
▲
|
||||
└──────── :test-support → test source sets only
|
||||
```
|
||||
|
||||
`:wire-protocol` is the **frozen shared contract** (Android analogue of
|
||||
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
||||
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
||||
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
|
||||
interfaces. New wire types are added **only** here (a coordination point).
|
||||
|
||||
## Toolchain
|
||||
|
||||
- **Gradle** 9.6.1 (via the committed wrapper — always use `./gradlew`).
|
||||
- **Kotlin** 2.3.21 (matches the Kotlin embedded in Gradle 9.6.1).
|
||||
- **JVM toolchain** 17 (`jvmToolchain(17)` in every module).
|
||||
- Versions are pinned in the version catalog
|
||||
[`gradle/libs.versions.toml`](gradle/libs.versions.toml): kotlinx-serialization-json,
|
||||
kotlinx-coroutines-core/-test, JUnit5 (Jupiter), Turbine, MockK.
|
||||
|
||||
Pure modules apply `kotlin("jvm")` + `kotlin("plugin.serialization")`, wire the
|
||||
`libs.bundles.unit-test` bundle into `testImplementation`, and run tests on the
|
||||
JUnit Platform (`tasks.test { useJUnitPlatform() }`).
|
||||
|
||||
## Build & test
|
||||
|
||||
```bash
|
||||
# Use the committed wrapper for everything.
|
||||
./gradlew help # sanity: the build configures
|
||||
./gradlew projects # lists the 5 pure modules
|
||||
./gradlew build # compile all pure modules
|
||||
./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
|
||||
```
|
||||
|
||||
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
||||
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
|
||||
> small focused files — same discipline as the rest of the repo.
|
||||
|
||||
## Android SDK setup (proven working)
|
||||
|
||||
The pure JVM modules need only a JDK + Gradle. The **Android-framework** modules
|
||||
(`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android` — plan AW2+)
|
||||
need the Android SDK. This machine is set up and the toolchain is **proven** (an
|
||||
AGP library module compiled against SDK 35 and produced an AAR):
|
||||
|
||||
- **SDK location:** `/usr/local/share/android-commandlinetools`
|
||||
(installed via `brew install --cask android-commandlinetools`).
|
||||
- **Installed packages:** `platform-tools`, `platforms;android-35`, `platforms;android-36`,
|
||||
`build-tools;35.0.0`, `build-tools;36.0.0`. (`:app` compiles against SDK **36** — the
|
||||
Kotlin-2.3.21-contemporaneous androidx/Compose line refuses SDK 35; `platforms;android-37`
|
||||
is not fetchable here as the cmdline-tools are too old to parse the v4 repo XML.)
|
||||
- **`android/local.properties`** (gitignored) points Gradle at it:
|
||||
`sdk.dir=/usr/local/share/android-commandlinetools`.
|
||||
- **Shell env** (for `sdkmanager`/`adb`): `export ANDROID_HOME=/usr/local/share/android-commandlinetools`.
|
||||
|
||||
### Wiring an Android module (the working recipe)
|
||||
|
||||
- Repos: `google()` is in both `pluginManagement` and `dependencyResolutionManagement`
|
||||
in `settings.gradle.kts` (needed to resolve AGP + androidx).
|
||||
- Plugin: **AGP 9.2.1** (`libs.plugins.android.library` / `.android.application`),
|
||||
compatible with Gradle 9.6.1.
|
||||
- **Gotcha:** AGP 9 has **built-in Kotlin** — apply ONLY the android plugin. Adding
|
||||
`org.jetbrains.kotlin.android` errors with "no longer required since AGP 9.0".
|
||||
- Module block: `android { namespace = "…"; compileSdk = 36; defaultConfig { minSdk = 29 } }`.
|
||||
(Framework modules target `compileSdk = 36`; `targetSdk` stays `35` per plan §2.)
|
||||
- **`:app` UI-stack version matrix** (A13, proven `:app:assembleDebug` green): AGP 9.2.1 ·
|
||||
Kotlin 2.3.21 · Compose-compiler plugin `org.jetbrains.kotlin.plugin.compose` = 2.3.21 ·
|
||||
Compose BOM `2025.11.01` (→ material3 1.4.0, ui/foundation 1.9.5, material3.adaptive 1.2.0,
|
||||
material3-adaptive-navigation-suite 1.4.0) · Hilt (dagger) 2.60.1 via KSP `2.3.9` ·
|
||||
androidx core-ktx 1.17.0 / activity-compose 1.12.4 / lifecycle 2.10.0. Apply plugins:
|
||||
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
|
||||
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
|
||||
|
||||
To add more SDK pieces later (e.g. an emulator image for instrumented tests):
|
||||
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
||||
39
android/api-client/build.gradle.kts
Normal file
39
android/api-client/build.gradle.kts
Normal file
@@ -0,0 +1,39 @@
|
||||
// :api-client — pure REST client logic (12 routes, tolerant decode, Origin-iff-
|
||||
// guarded, strict query encoding, prefs unknown-key preservation, pairing probe /
|
||||
// PairingError / HostClassifier tiers). Consumes HttpTransport by interface only.
|
||||
// Depends only on :wire-protocol.
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kover)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":wire-protocol"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(project(":test-support"))
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// A36 acceptance gate: >=80% line coverage on this pure module (plan §7).
|
||||
kover {
|
||||
reports {
|
||||
verify {
|
||||
rule {
|
||||
minBound(80)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
/**
|
||||
* B4 · Manual, canonical-DER encoder for a P-256 PKCS#10 `CertificationRequest` — a byte-for-byte
|
||||
* port of the iOS `ClientTLS.CertificateSigningRequest`.
|
||||
*
|
||||
* Built by hand (no JCA CSR helper) so the exact bytes are under our control and the request is
|
||||
* signed by the [CsrSigner] (an AndroidKeyStore hardware key in production, a software P-256 key in
|
||||
* tests) via `SHA256withECDSA`. The output must satisfy the control-plane `verifyCsrPoPEc`: an EC
|
||||
* P-256 `SubjectPublicKeyInfo` (`id-ecPublicKey` + `prime256v1`), an `ecdsa-with-SHA256`
|
||||
* self-signature, and a valid PoP. Encoding is strictly canonical DER (minimal lengths) so the
|
||||
* server's re-serialization of `CertificationRequestInfo` matches the bytes we signed.
|
||||
*
|
||||
* ```
|
||||
* CertificationRequest ::= SEQUENCE {
|
||||
* certificationRequestInfo CertificationRequestInfo,
|
||||
* signatureAlgorithm AlgorithmIdentifier, -- ecdsa-with-SHA256
|
||||
* signature BIT STRING } -- X9.62 DER ECDSA-Sig
|
||||
*
|
||||
* CertificationRequestInfo ::= SEQUENCE {
|
||||
* version INTEGER { v1(0) },
|
||||
* subject Name,
|
||||
* subjectPKInfo SubjectPublicKeyInfo,
|
||||
* attributes [0] IMPLICIT SET OF Attribute } -- empty
|
||||
* ```
|
||||
*/
|
||||
public object CertificateSigningRequest {
|
||||
/** P-256 uncompressed public point is `0x04 || X(32) || Y(32)` = 65 bytes. */
|
||||
private const val UNCOMPRESSED_P256_POINT_LENGTH = 65
|
||||
|
||||
/**
|
||||
* Build and self-sign a P-256 PKCS#10 CSR DER for [signer]'s key.
|
||||
*
|
||||
* @param subjectCommonName the CSR subject CN. The device leaf's identity is driven server-side
|
||||
* by the ownership-verified subdomain SAN, so this is descriptive only; it must be non-empty.
|
||||
* @param signer the P-256 hardware key that provides the public key and signs the
|
||||
* `CertificationRequestInfo`.
|
||||
* @throws CsrException.InvalidSubject on an empty CN; [CsrException.InvalidPublicKey] if the
|
||||
* signer's public key is not a 65-byte X9.63 P-256 point.
|
||||
*/
|
||||
public fun der(subjectCommonName: String, signer: CsrSigner): ByteArray {
|
||||
if (subjectCommonName.isEmpty()) throw CsrException.InvalidSubject
|
||||
|
||||
val publicPoint = signer.publicKeyX963()
|
||||
if (publicPoint.size != UNCOMPRESSED_P256_POINT_LENGTH || publicPoint[0].toInt() != 0x04) {
|
||||
throw CsrException.InvalidPublicKey
|
||||
}
|
||||
|
||||
val requestInfo = certificationRequestInfo(subjectCommonName, publicPoint)
|
||||
val signature = signer.sign(requestInfo)
|
||||
|
||||
return DerWriter.sequence(
|
||||
listOf(
|
||||
requestInfo,
|
||||
ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER,
|
||||
DerWriter.bitString(signature),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── CertificationRequestInfo ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun certificationRequestInfo(subjectCommonName: String, publicPoint: ByteArray): ByteArray =
|
||||
DerWriter.sequence(
|
||||
listOf(
|
||||
DerWriter.INTEGER_0, // version v1(0)
|
||||
name(subjectCommonName),
|
||||
subjectPublicKeyInfo(publicPoint),
|
||||
DerWriter.EMPTY_ATTRIBUTES_CONTEXT0, // [0] IMPLICIT SET OF Attribute (empty)
|
||||
),
|
||||
)
|
||||
|
||||
/** `Name ::= SEQUENCE OF RelativeDistinguishedName` with a single CN RDN. */
|
||||
private fun name(commonName: String): ByteArray {
|
||||
val attribute = DerWriter.sequence(
|
||||
listOf(DerWriter.oid(Oid.COMMON_NAME), DerWriter.utf8String(commonName)),
|
||||
)
|
||||
val rdn = DerWriter.set(listOf(attribute))
|
||||
return DerWriter.sequence(listOf(rdn))
|
||||
}
|
||||
|
||||
/**
|
||||
* `SubjectPublicKeyInfo` for an EC P-256 key: `id-ecPublicKey` + `prime256v1` named curve, then
|
||||
* the uncompressed point as a BIT STRING.
|
||||
*/
|
||||
private fun subjectPublicKeyInfo(publicPoint: ByteArray): ByteArray {
|
||||
val algorithm = DerWriter.sequence(
|
||||
listOf(DerWriter.oid(Oid.EC_PUBLIC_KEY), DerWriter.oid(Oid.PRIME256V1)),
|
||||
)
|
||||
return DerWriter.sequence(listOf(algorithm, DerWriter.bitString(publicPoint)))
|
||||
}
|
||||
|
||||
/**
|
||||
* `AlgorithmIdentifier` for `ecdsa-with-SHA256` — no parameters (absent, per RFC 5758), which is
|
||||
* exactly what the server's verifier expects.
|
||||
*/
|
||||
private val ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER: ByteArray =
|
||||
DerWriter.sequence(listOf(DerWriter.oid(Oid.ECDSA_WITH_SHA256)))
|
||||
}
|
||||
|
||||
/** Object identifiers (DER content bytes; tag/length added by [DerWriter.oid]). */
|
||||
private object Oid {
|
||||
/** 1.2.840.10045.2.1 — id-ecPublicKey. */
|
||||
val EC_PUBLIC_KEY = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01)
|
||||
|
||||
/** 1.2.840.10045.3.1.7 — prime256v1 / secp256r1. */
|
||||
val PRIME256V1 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07)
|
||||
|
||||
/** 1.2.840.10045.4.3.2 — ecdsa-with-SHA256. */
|
||||
val ECDSA_WITH_SHA256 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02)
|
||||
|
||||
/** 2.5.4.3 — id-at-commonName. */
|
||||
val COMMON_NAME = byteArrayOf(0x55, 0x04, 0x03)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny canonical-DER encoder. Every helper returns a fully-formed TLV so callers just concatenate
|
||||
* children — canonical minimal-length encoding throughout. Internal so its byte layout is
|
||||
* unit-testable in isolation.
|
||||
*/
|
||||
internal object DerWriter {
|
||||
private const val TAG_INTEGER: Byte = 0x02
|
||||
private const val TAG_BIT_STRING: Byte = 0x03
|
||||
private const val TAG_OID: Byte = 0x06
|
||||
private const val TAG_UTF8_STRING: Byte = 0x0C
|
||||
private const val TAG_SEQUENCE: Byte = 0x30
|
||||
private const val TAG_SET: Byte = 0x31
|
||||
private const val TAG_CONTEXT0_CONSTRUCTED: Byte = 0xA0.toByte()
|
||||
|
||||
/** `INTEGER 0` — the fixed PKCS#10 version v1(0). */
|
||||
val INTEGER_0: ByteArray = byteArrayOf(TAG_INTEGER, 0x01, 0x00)
|
||||
|
||||
/** `[0] IMPLICIT SET OF Attribute`, empty — `A0 00`. */
|
||||
val EMPTY_ATTRIBUTES_CONTEXT0: ByteArray = byteArrayOf(TAG_CONTEXT0_CONSTRUCTED, 0x00)
|
||||
|
||||
fun sequence(children: List<ByteArray>): ByteArray = tlv(TAG_SEQUENCE, concat(children))
|
||||
|
||||
fun set(children: List<ByteArray>): ByteArray = tlv(TAG_SET, concat(children))
|
||||
|
||||
fun oid(content: ByteArray): ByteArray = tlv(TAG_OID, content)
|
||||
|
||||
fun utf8String(value: String): ByteArray = tlv(TAG_UTF8_STRING, value.encodeToByteArray())
|
||||
|
||||
/** BIT STRING with zero unused bits (all our bit strings are byte-aligned). */
|
||||
fun bitString(content: ByteArray): ByteArray = tlv(TAG_BIT_STRING, byteArrayOf(0x00) + content)
|
||||
|
||||
/** Tag-Length-Value with canonical DER length encoding. */
|
||||
private fun tlv(tag: Byte, value: ByteArray): ByteArray = byteArrayOf(tag) + length(value.size) + value
|
||||
|
||||
/** DER length: short form (<128) or long form (`0x80 | byteCount`, big-endian). */
|
||||
private fun length(count: Int): ByteArray {
|
||||
if (count < 0x80) return byteArrayOf(count.toByte())
|
||||
var value = count
|
||||
val bytes = ArrayDeque<Byte>()
|
||||
while (value > 0) {
|
||||
bytes.addFirst((value and 0xFF).toByte())
|
||||
value = value ushr 8
|
||||
}
|
||||
return byteArrayOf((0x80 or bytes.size).toByte()) + bytes.toByteArray()
|
||||
}
|
||||
|
||||
private fun concat(chunks: List<ByteArray>): ByteArray {
|
||||
val total = chunks.sumOf { it.size }
|
||||
val out = ByteArray(total)
|
||||
var offset = 0
|
||||
for (chunk in chunks) {
|
||||
System.arraycopy(chunk, 0, out, offset, chunk.size)
|
||||
offset += chunk.size
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
/**
|
||||
* B4 · The signing key abstraction the PKCS#10 CSR encoder ([CertificateSigningRequest]) drives —
|
||||
* the Android analogue of iOS `P256HardwareKey`.
|
||||
*
|
||||
* In production this is backed by a NON-EXPORTABLE P-256 key living inside AndroidKeyStore
|
||||
* (StrongBox → TEE), so `sign` runs inside secure hardware and the private key never leaves it
|
||||
* (`:client-tls-android` `HardwareBackedKey`). In JVM unit tests it is backed by a software P-256
|
||||
* key via the SAME `Signature("SHA256withECDSA")` path, so the CSR-encoding bytes are exercised
|
||||
* identically without an emulator.
|
||||
*/
|
||||
public interface CsrSigner {
|
||||
/**
|
||||
* The public key in ANSI X9.63 uncompressed form: `0x04 || X(32) || Y(32)` (65 bytes for
|
||||
* P-256). This is exactly what wraps into the CSR's `SubjectPublicKeyInfo` BIT STRING.
|
||||
*/
|
||||
public fun publicKeyX963(): ByteArray
|
||||
|
||||
/**
|
||||
* ECDSA-sign `message` over SHA-256, returning the X9.62 DER signature
|
||||
* (`SEQUENCE { r INTEGER, s INTEGER }`) — exactly the shape a PKCS#10 `signature` BIT STRING
|
||||
* and the server's `verifyCsrPoPEc` expect. The digest is computed by the algorithm
|
||||
* (`SHA256withECDSA`), so callers pass the raw message (the DER of `CertificationRequestInfo`),
|
||||
* NOT a pre-hash.
|
||||
*/
|
||||
public fun sign(message: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
/** Structural failures building a CSR — the client refuses to emit a malformed request. */
|
||||
public sealed class CsrException(message: String) : Exception(message) {
|
||||
/** The signer's public key was not the expected 65-byte X9.63 uncompressed P-256 point. */
|
||||
public data object InvalidPublicKey : CsrException("CSR public key is not a 65-byte X9.63 P-256 point")
|
||||
|
||||
/** The subject CN was empty (not encodable / rejected by the server). */
|
||||
public data object InvalidSubject : CsrException("CSR subject common name must not be empty")
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
|
||||
/**
|
||||
* B4 · Talks to the control-plane device-enrollment API over the shared [HttpTransport] seam (the
|
||||
* same seam `:transport-okhttp` implements and `:test-support` fakes), so the enroll flow rides the
|
||||
* app's normal HTTP stack. Android analogue of iOS `DeviceEnrollmentClient`, extended with the login
|
||||
* step (B4 pinned contract):
|
||||
*
|
||||
* `POST /auth/login` `{ password }` → 201 `{ enrollToken, accountId, expiresIn }`
|
||||
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
|
||||
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
|
||||
* notBefore, notAfter, renewAfter }`
|
||||
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The
|
||||
* server schema is `.strict()`; NO keyAlg/subdomain/deviceName.
|
||||
*
|
||||
* Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is
|
||||
* sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
|
||||
* are standard-base64 (`bytesToBase64` = Node `Buffer.toString('base64')`).
|
||||
*
|
||||
* Immutable: constructed once with a [baseUrl] + [http]; the short-lived enroll bearer is passed
|
||||
* per-call and never held/logged (leaked-bearer blast radius).
|
||||
*/
|
||||
public class DeviceEnrollmentClient(
|
||||
baseUrl: String,
|
||||
private val http: HttpTransport,
|
||||
) {
|
||||
/** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */
|
||||
private val base: String = baseUrl.trim().trimEnd('/')
|
||||
|
||||
/**
|
||||
* One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is
|
||||
* rejected client-side (`InvalidRequest`) before any network I/O — never send a blank credential.
|
||||
*/
|
||||
public suspend fun login(password: String): LoginResult {
|
||||
if (password.isEmpty()) throw DeviceEnrollmentError.InvalidRequest
|
||||
val body = EnrollJson.encodeToString(LoginRequestBody.serializer(), LoginRequestBody(password))
|
||||
val response = http.send(jsonRequest(HttpMethod.POST, PATH_LOGIN, body.encodeToByteArray(), bearer = null))
|
||||
val dto = decodeOn201(response, LoginResponseDto.serializer())
|
||||
return LoginResult(
|
||||
enrollToken = dto.enrollToken,
|
||||
accountId = dto.accountId,
|
||||
expiresInSeconds = dto.expiresIn,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll a freshly-generated hardware key: POST the [csrDer] under the enroll [bearerToken],
|
||||
* receive the leaf. Required fields are validated client-side (`InvalidRequest`) before any I/O.
|
||||
*/
|
||||
public suspend fun enroll(
|
||||
bearerToken: String,
|
||||
csrDer: ByteArray,
|
||||
subdomain: String,
|
||||
deviceName: String,
|
||||
attestation: String? = null,
|
||||
): EnrollmentResult {
|
||||
if (bearerToken.isEmpty() || csrDer.isEmpty() || subdomain.isEmpty() || deviceName.isEmpty()) {
|
||||
throw DeviceEnrollmentError.InvalidRequest
|
||||
}
|
||||
val body = EnrollJson.encodeToString(
|
||||
EnrollRequestBody.serializer(),
|
||||
EnrollRequestBody(
|
||||
csr = base64(csrDer),
|
||||
keyAlg = KEY_ALG_EC_P256,
|
||||
subdomain = subdomain,
|
||||
deviceName = deviceName,
|
||||
attestation = attestation,
|
||||
),
|
||||
)
|
||||
val response = http.send(jsonRequest(HttpMethod.POST, PATH_ENROLL, body.encodeToByteArray(), bearerToken))
|
||||
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` (silent-rotation seam).
|
||||
*
|
||||
* The renew endpoint is authenticated by the CURRENT device certificate over mTLS — the body is
|
||||
* `{ csr }` ONLY and NO bearer is sent (mirrors iOS, which passes `bearerToken: nil`). [bearerToken]
|
||||
* is therefore OPTIONAL and defaults to absent; the `Authorization` header is omitted when it is
|
||||
* null/blank. The seam still accepts a bearer for a hypothetical bearer-authenticated renew, but the
|
||||
* production caller passes none. [deviceId] and [csrDer] are validated client-side before any I/O.
|
||||
*/
|
||||
public suspend fun renew(deviceId: String, csrDer: ByteArray, bearerToken: String? = null): EnrollmentResult {
|
||||
if (deviceId.isEmpty() || csrDer.isEmpty()) {
|
||||
throw DeviceEnrollmentError.InvalidRequest
|
||||
}
|
||||
// Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert
|
||||
// and its schema is `.strict()`, so any enroll-only extra (keyAlg/subdomain/deviceName) is
|
||||
// rejected. Identity/key come from the current cert + registry record, never the body.
|
||||
val body = EnrollJson.encodeToString(
|
||||
RenewRequestBody.serializer(),
|
||||
RenewRequestBody(csr = base64(csrDer)),
|
||||
)
|
||||
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
|
||||
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
|
||||
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
|
||||
}
|
||||
|
||||
// ── Request/response plumbing ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun jsonRequest(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
jsonBody: ByteArray,
|
||||
bearer: String?,
|
||||
): HttpRequest {
|
||||
val headers = LinkedHashMap<String, String>()
|
||||
headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON
|
||||
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
|
||||
// string must never emit a bare "Bearer " header.
|
||||
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
|
||||
return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody)
|
||||
}
|
||||
|
||||
/** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
|
||||
* code (never the raw body); an undecodable 201 body → [DeviceEnrollmentError.MalformedResponse]. */
|
||||
private fun <T> decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer<T>): T {
|
||||
if (response.status != HTTP_CREATED) {
|
||||
throw DeviceEnrollmentError.Http(response.status, errorCode(response.body))
|
||||
}
|
||||
return runCatching { EnrollJson.decodeFromString(serializer, response.body.decodeToString()) }
|
||||
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
|
||||
}
|
||||
|
||||
private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
|
||||
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
|
||||
val chain = dto.caChain.map { entry ->
|
||||
decodeBase64OrNull(entry) ?: throw DeviceEnrollmentError.MalformedResponse
|
||||
}
|
||||
return EnrollmentResult(
|
||||
deviceId = dto.deviceId,
|
||||
certificate = certificate,
|
||||
caChain = chain,
|
||||
notBefore = parseInstantOrNull(dto.notBefore),
|
||||
notAfter = parseInstantOrNull(dto.notAfter),
|
||||
renewAfter = parseInstantOrNull(dto.renewAfter),
|
||||
)
|
||||
}
|
||||
|
||||
private fun errorCode(body: ByteArray): String? =
|
||||
runCatching { EnrollJson.decodeFromString(ErrorDto.serializer(), body.decodeToString()).error }.getOrNull()
|
||||
|
||||
private companion object {
|
||||
const val PATH_LOGIN = "/auth/login"
|
||||
const val PATH_ENROLL = "/device/enroll"
|
||||
const val PATH_DEVICE = "/device"
|
||||
const val KEY_ALG_EC_P256 = "ec-p256"
|
||||
const val HTTP_CREATED = 201
|
||||
|
||||
const val HEADER_CONTENT_TYPE = "Content-Type"
|
||||
const val HEADER_AUTHORIZATION = "Authorization"
|
||||
const val CONTENT_TYPE_JSON = "application/json"
|
||||
const val BEARER_PREFIX = "Bearer "
|
||||
|
||||
private val BASE64_ENCODER = java.util.Base64.getEncoder()
|
||||
private val BASE64_DECODER = java.util.Base64.getDecoder()
|
||||
|
||||
fun base64(bytes: ByteArray): String = BASE64_ENCODER.encodeToString(bytes)
|
||||
|
||||
fun decodeBase64OrNull(text: String): ByteArray? =
|
||||
runCatching { BASE64_DECODER.decode(text) }.getOrNull()
|
||||
|
||||
/** Percent-encode a `:id` path segment's non-unreserved bytes (defence: device ids are
|
||||
* server-minted UUIDs, but never build a URL from an unescaped field). */
|
||||
fun encodePathSegment(value: String): String {
|
||||
val sb = StringBuilder()
|
||||
for (byte in value.encodeToByteArray()) {
|
||||
val code = byte.toInt() and 0xFF
|
||||
val ch = code.toChar()
|
||||
if (ch in UNRESERVED) sb.append(ch) else sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private val UNRESERVED: Set<Char> =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
|
||||
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import java.math.BigInteger
|
||||
import java.security.interfaces.ECPublicKey
|
||||
|
||||
/**
|
||||
* B4 · Pure encoder from a JCA [ECPublicKey] to the ANSI X9.63 uncompressed point
|
||||
* `0x04 || X || Y` that a P-256 `SubjectPublicKeyInfo` BIT STRING carries.
|
||||
*
|
||||
* Kept in the pure `:api-client` module (no Android dependency) so it is reused by BOTH the
|
||||
* JVM-unit-test software signer AND the framework `HardwareBackedKey` (`:client-tls-android`),
|
||||
* and so this security-load-bearing byte layout is unit-tested at JVM speed.
|
||||
*/
|
||||
public object EcPointEncoding {
|
||||
/** P-256 field element width in bytes (256 bits). */
|
||||
public const val P256_COORDINATE_BYTES: Int = 32
|
||||
|
||||
/** Uncompressed-point prefix (`0x04`) per SEC 1 §2.3.3. */
|
||||
private const val UNCOMPRESSED_PREFIX: Byte = 0x04
|
||||
|
||||
/**
|
||||
* Encode [publicKey]'s affine (x, y) as `0x04 || X(32) || Y(32)` (65 bytes). Each coordinate is
|
||||
* an unsigned big-endian integer left-padded (or, defensively, high-byte-trimmed) to exactly
|
||||
* [P256_COORDINATE_BYTES]. Throws [IllegalArgumentException] if a coordinate genuinely does not
|
||||
* fit 32 bytes (i.e. the key is not on a 256-bit curve).
|
||||
*/
|
||||
public fun x963(publicKey: ECPublicKey): ByteArray {
|
||||
val point = publicKey.w
|
||||
val x = toFixedLengthUnsigned(point.affineX, P256_COORDINATE_BYTES)
|
||||
val y = toFixedLengthUnsigned(point.affineY, P256_COORDINATE_BYTES)
|
||||
val out = ByteArray(1 + P256_COORDINATE_BYTES * 2)
|
||||
out[0] = UNCOMPRESSED_PREFIX
|
||||
System.arraycopy(x, 0, out, 1, P256_COORDINATE_BYTES)
|
||||
System.arraycopy(y, 0, out, 1 + P256_COORDINATE_BYTES, P256_COORDINATE_BYTES)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a non-negative [value] to a big-endian byte array of exactly [length] bytes. A
|
||||
* `BigInteger` may carry a leading 0x00 sign byte (drop it) or be shorter than [length]
|
||||
* (left-pad with zeros). A value that needs MORE than [length] significant bytes is rejected —
|
||||
* silently truncating a coordinate would corrupt the key.
|
||||
*/
|
||||
internal fun toFixedLengthUnsigned(value: BigInteger, length: Int): ByteArray {
|
||||
require(value.signum() >= 0) { "EC coordinate must be non-negative" }
|
||||
val raw = value.toByteArray() // big-endian, possibly with a leading 0x00 sign byte
|
||||
val start = if (raw.size > length && raw[0].toInt() == 0) 1 else 0
|
||||
val significant = raw.size - start
|
||||
require(significant <= length) { "EC coordinate does not fit $length bytes (got $significant)" }
|
||||
val out = ByteArray(length)
|
||||
System.arraycopy(raw, start, out, length - significant, significant)
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* B4 · Typed result of the one-time operator login (`POST /auth/login`). The [enrollToken] is a
|
||||
* short-lived `device:enroll` bearer — hold it ONLY for the immediately-following enroll call and
|
||||
* NEVER persist or log it. [accountId] identifies the tenant the device will be scoped under.
|
||||
*/
|
||||
public data class LoginResult(
|
||||
val enrollToken: String,
|
||||
val accountId: String,
|
||||
val expiresInSeconds: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* B4 · Typed result of a successful `POST /device/enroll` (or `/device/:id/renew`): the issued leaf
|
||||
* plus its issuer chain and rotation timing. The private key is NOT here — it stays non-exportable
|
||||
* in AndroidKeyStore. Mirrors iOS `EnrollmentResult`.
|
||||
*
|
||||
* NOTE: [certificate]/[caChain] are `ByteArray`, so the generated `data class` equality is by
|
||||
* reference (transient DER carriers, not value-equality keys) — compare with `contentEquals`.
|
||||
*/
|
||||
public data class EnrollmentResult(
|
||||
val deviceId: String,
|
||||
/** Leaf certificate DER (decoded from the response's base64). */
|
||||
val certificate: ByteArray,
|
||||
/** Issuer chain DERs (device-CA etc.), leaf excluded. */
|
||||
val caChain: List<ByteArray>,
|
||||
val notBefore: Instant?,
|
||||
val notAfter: Instant?,
|
||||
/** When to renew from the same hardware key (~2/3 of the lifetime). */
|
||||
val renewAfter: Instant?,
|
||||
) {
|
||||
/**
|
||||
* The rotation seam: is the leaf due for renewal as of [now]? A missing [renewAfter] never
|
||||
* triggers (fail-safe — the TLS stack is the real gate; the scheduler only pre-empts expiry).
|
||||
*/
|
||||
public fun isRenewalDue(now: Instant = Instant.now()): Boolean {
|
||||
val due = renewAfter ?: return false
|
||||
return !now.isBefore(due) // now >= renewAfter
|
||||
}
|
||||
}
|
||||
|
||||
/** Typed failures for the device-enrollment surface. Transport-level errors propagate UNWRAPPED. */
|
||||
public sealed class DeviceEnrollmentError(message: String) : Exception(message) {
|
||||
/**
|
||||
* A non-success HTTP status with the server's uniform `{ error }` code, if any (401
|
||||
* missing/rejected token, 403 subdomain-not-owned, 429 rate_limited, 400 rejected
|
||||
* CSR/subdomain). Never leaks the response body.
|
||||
*/
|
||||
public data class Http(val status: Int, val code: String?) :
|
||||
DeviceEnrollmentError("device enrollment rejected: HTTP $status" + (code?.let { " ($it)" } ?: ""))
|
||||
|
||||
/** A success body that did not decode to the expected shape (or an undecodable base64 cert). */
|
||||
public data object MalformedResponse :
|
||||
DeviceEnrollmentError("device enrollment response was not the expected shape")
|
||||
|
||||
/** A required request field was empty — rejected client-side BEFORE any network I/O. */
|
||||
public data object InvalidRequest :
|
||||
DeviceEnrollmentError("device enrollment request was missing a required field")
|
||||
}
|
||||
|
||||
// ── Wire DTOs + JSON config (internal to the enroll package) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ENCODE omits absent optionals (`encodeDefaults = false` drops the default-null `attestation`;
|
||||
* `explicitNulls = false` never writes an explicit `null`) and DECODE is tolerant of unknown keys
|
||||
* (the server is untrusted at this boundary). `keyAlg` carries NO default, so it is ALWAYS encoded.
|
||||
*/
|
||||
internal val EnrollJson: Json = Json {
|
||||
encodeDefaults = false
|
||||
explicitNulls = false
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class LoginRequestBody(val password: String)
|
||||
|
||||
@Serializable
|
||||
internal data class EnrollRequestBody(
|
||||
val csr: String,
|
||||
val keyAlg: String,
|
||||
val subdomain: String,
|
||||
val deviceName: String,
|
||||
val attestation: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The `/device/:id/renew` request body. The endpoint authenticates by the presented mTLS device cert
|
||||
* and its server schema is `{ csr }` ONLY (`.strict()`), so it carries the single new CSR and NO
|
||||
* enroll-only fields (keyAlg/subdomain/deviceName) — an extra key would be rejected as a 400.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class RenewRequestBody(val csr: String)
|
||||
|
||||
@Serializable
|
||||
internal data class LoginResponseDto(
|
||||
val enrollToken: String,
|
||||
val accountId: String,
|
||||
val expiresIn: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class EnrollResponseDto(
|
||||
val deviceId: String,
|
||||
val cert: String,
|
||||
val caChain: List<String> = emptyList(),
|
||||
val notBefore: String? = null,
|
||||
val notAfter: String? = null,
|
||||
val renewAfter: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class ErrorDto(val error: String? = null)
|
||||
|
||||
/** Parse an ISO-8601 instant, degrading an absent/unparseable value to null (dates are advisory). */
|
||||
internal fun parseInstantOrNull(text: String?): Instant? {
|
||||
if (text == null) return null
|
||||
return runCatching { Instant.parse(text) }.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One commit from `git log` (`src/types.ts` `CommitLogEntry`). [hash] and [at] are REQUIRED — a
|
||||
* commit missing either is dropped by the list-lossy [CommitLogEntryListSerializer] (its siblings
|
||||
* survive). [subject] defaults to empty so a subject-less commit still decodes. `at` = `%ct * 1000`
|
||||
* (epoch millis). All fields are rendered INERT (plain text; no autolink) at the screen (plan §8).
|
||||
*/
|
||||
@Serializable
|
||||
public data class CommitLogEntry(
|
||||
val hash: String,
|
||||
val at: Long,
|
||||
val subject: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /projects/log` result (`src/types.ts` `GitLogResult`). [truncated] = more commits exist
|
||||
* beyond the server cap. The commit list decodes lossily (drop-one-keep-rest).
|
||||
*/
|
||||
@Serializable
|
||||
public data class GitLogResult(
|
||||
@Serializable(with = CommitLogEntryListSerializer::class)
|
||||
val commits: List<CommitLogEntry> = emptyList(),
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
|
||||
internal object CommitLogEntryListSerializer :
|
||||
KSerializer<List<CommitLogEntry>> by LossyListSerializer(CommitLogEntry.serializer())
|
||||
@@ -0,0 +1,71 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A client result union for the six guarded git-write ops (worktree create/remove/prune, git
|
||||
* stage/commit/push). It carries the server's SAFE body only — never raw git stderr (the server
|
||||
* classifies + sanitizes every failure, `src/http/git-ops.ts` / `worktrees.ts`, SEC-M10):
|
||||
*
|
||||
* - [Ok] — a 200 with the op's route-specific payload [T].
|
||||
* - [Rejected] — a 4xx/5xx with the server's inert `error` string ([message]) to display verbatim.
|
||||
* 403 is OVERLOADED (Origin-guard failure AND the disabled kill-switch both 403) so the client
|
||||
* cannot tell them apart by status — it surfaces [message] inertly rather than inventing a typed
|
||||
* variant (plan Edge cases / Security).
|
||||
* - [RateLimited] — a 429 (stage/commit share one limiter, push a tighter one). Do NOT auto-retry.
|
||||
*
|
||||
* Nothing here throws on a bad body: a missing/garbled payload degrades to defaults (empty sha,
|
||||
* empty pruned list) rather than crashing (tolerant-decode discipline, plan §8).
|
||||
*/
|
||||
public sealed interface GitWriteOutcome<out T> {
|
||||
/** 200 — the op succeeded; [payload] is the route-specific success body. */
|
||||
public data class Ok<out T>(val payload: T) : GitWriteOutcome<T>
|
||||
|
||||
/** A 4xx/5xx failure carrying the server's SAFE [message] (inert; may be null if unparseable). */
|
||||
public data class Rejected(val status: Int, val message: String?) : GitWriteOutcome<Nothing>
|
||||
|
||||
/** 429 — the server rate-limited this write. */
|
||||
public data object RateLimited : GitWriteOutcome<Nothing>
|
||||
}
|
||||
|
||||
// ── Per-op 200 payloads (all fields optional/defaulted → a garbled body degrades, never throws) ──
|
||||
|
||||
/** `POST /projects/git/stage` 200 → `{ ok, staged, count }`. */
|
||||
@Serializable
|
||||
public data class StageResult(val staged: Boolean = false, val count: Int = 0)
|
||||
|
||||
/** `POST /projects/git/commit` 200 → `{ ok, commit }` (short sha; may be `""` — empty is valid). */
|
||||
@Serializable
|
||||
public data class CommitResult(val commit: String = "")
|
||||
|
||||
/** `POST /projects/git/push` 200 → `{ ok, branch, remote }`. */
|
||||
@Serializable
|
||||
public data class PushResult(val branch: String? = null, val remote: String? = null)
|
||||
|
||||
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
|
||||
@Serializable
|
||||
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
|
||||
|
||||
/** `DELETE /projects/worktree` 200 → `{ ok, path }` (git's canonical removed path). */
|
||||
@Serializable
|
||||
public data class RemoveWorktreeResult(val path: String? = null)
|
||||
|
||||
/** `POST /projects/worktree/prune` 200 → `{ ok, pruned: [...] }` (empty = nothing to prune). */
|
||||
@Serializable
|
||||
public data class PruneWorktreesResult(val pruned: List<String> = emptyList())
|
||||
|
||||
/** Shape of a failure body — worktree routes emit `{ error }`, git-ops `{ ok:false, error }`; both
|
||||
* carry `error` as a SAFE string. Decoded to surface [error] inertly. */
|
||||
@Serializable
|
||||
internal data class GitErrorBody(val ok: Boolean = false, val error: String? = null)
|
||||
|
||||
/**
|
||||
* Decode a guarded 200 body into [T], degrading a missing/garbled body to the payload's defaults
|
||||
* (never throws — the caller already knows the status is 200).
|
||||
*/
|
||||
internal fun <T> decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer<T>): T =
|
||||
LossyDecode.objectOrNull(bytes, deserializer) ?: ModelJson.decodeFromString(deserializer, "{}")
|
||||
|
||||
/** Read the SAFE `error` string from a failure body; null when the body is empty/unparseable. */
|
||||
internal fun decodeGitError(bytes: ByteArray): String? =
|
||||
LossyDecode.objectOrNull(bytes, GitErrorBody.serializer())?.error
|
||||
@@ -0,0 +1,10 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
/**
|
||||
* The two verdicts `POST /hook/decision` accepts — anything else is a 400 server-side. The [wire]
|
||||
* value is what goes into the request body (`{ sessionId, decision, token }`).
|
||||
*/
|
||||
public enum class HookDecision(public val wire: String) {
|
||||
ALLOW("allow"),
|
||||
DENY("deny"),
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import wang.yaojia.webterm.wire.StatusTelemetry
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* One running (or just-exited) server session from `GET /live-sessions` (read-only discovery — NO
|
||||
* Origin header). Mirrors `src/types.ts` `LiveSessionInfo` and iOS `APIClient.LiveSessionInfo`
|
||||
* field-for-field.
|
||||
*
|
||||
* Tolerant decode at the untrusted boundary (plan §4):
|
||||
* - identity/geometry ([id]/[createdAt]/[clientCount]/[exited]/[cols]/[rows]) are REQUIRED — an
|
||||
* entry missing them is dropped by `LossyDecode.listOrNull`;
|
||||
* - an unrecognized `status` string maps to [ClaudeStatus.UNKNOWN] (a future server status must
|
||||
* not make a running session invisible);
|
||||
* - absent `cwd`/`telemetry`/`lastOutputAt` degrade to `null`.
|
||||
*
|
||||
* NOTE: ms timestamps ([createdAt]/[lastOutputAt]) are `Long` — epoch-ms overflows a 32-bit `Int`
|
||||
* (Swift's `Int` is 64-bit, so iOS used `Int`).
|
||||
*/
|
||||
@Serializable
|
||||
public data class LiveSessionInfo(
|
||||
@Serializable(with = UuidSerializer::class) val id: UUID,
|
||||
/** Server `Date.now()` at spawn (ms since epoch). */
|
||||
val createdAt: Long,
|
||||
/** Devices currently attached (JOIN/mirror semantics). */
|
||||
val clientCount: Int,
|
||||
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
|
||||
val exited: Boolean,
|
||||
val cwd: String? = null,
|
||||
/** Tab title / derived label (e.g. 'claude', 'shell'), OSC-title-derived server-side (`src/types.ts`
|
||||
* `title?`). HOST/ATTACKER-influenced — run through `TitleSanitizer` before any UI/list use (§8).
|
||||
* Additive optional field; absent → `null`. */
|
||||
val title: String? = null,
|
||||
/** Current PTY size (latest-writer-wins on the server). */
|
||||
val cols: Int,
|
||||
val rows: Int,
|
||||
/** Latest statusLine telemetry, if any (B2). */
|
||||
val telemetry: StatusTelemetry? = null,
|
||||
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
|
||||
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
|
||||
val lastOutputAt: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
|
||||
/**
|
||||
* Tolerant decode config for the UNTRUSTED server boundary (plan §4 / A8). The server is an
|
||||
* untrusted input source here: unknown keys are ignored, malformed list elements are dropped one
|
||||
* by one, and nothing crashes on bad input. The Android analogue of iOS's per-field `try?`
|
||||
* tolerance in `APIClient/Models.swift`.
|
||||
*
|
||||
* ENCODE is deterministic (`ignoreUnknownKeys`/`isLenient` only affect parsing) so the same
|
||||
* instance also encodes request bodies (`/hook/decision`, `/push/fcm-token`, `PUT /prefs`).
|
||||
*/
|
||||
internal val ModelJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-element / per-body tolerant decoders (mirror iOS `LiveSessionInfo.decodeList` /
|
||||
* `LossyBox` / `LossyList`).
|
||||
*
|
||||
* - [listOrNull]: a non-array top level yields `null` (the caller raises
|
||||
* `ApiClientError.InvalidResponseBody` — the pairing "port speaks HTTP but isn't web-terminal"
|
||||
* signal); malformed elements are dropped, good ones kept.
|
||||
* - [listOrEmpty]: a non-array top level yields `[]` (matches iOS `TimelineEvent.decodeList`, which
|
||||
* the server itself returns when timeline capture is disabled).
|
||||
* - [objectOrNull]: a single object that fails to decode yields `null` (caller raises
|
||||
* `InvalidResponseBody`).
|
||||
*/
|
||||
internal object LossyDecode {
|
||||
fun <T> listOrNull(bytes: ByteArray, element: KSerializer<T>): List<T>? {
|
||||
val array = parseArray(bytes) ?: return null
|
||||
return array.mapNotNull { el -> runCatching { ModelJson.decodeFromJsonElement(element, el) }.getOrNull() }
|
||||
}
|
||||
|
||||
fun <T> listOrEmpty(bytes: ByteArray, element: KSerializer<T>): List<T> =
|
||||
listOrNull(bytes, element) ?: emptyList()
|
||||
|
||||
fun <T> objectOrNull(bytes: ByteArray, deserializer: KSerializer<T>): T? =
|
||||
runCatching { ModelJson.decodeFromString(deserializer, bytes.decodeToString()) }.getOrNull()
|
||||
|
||||
private fun parseArray(bytes: ByteArray): JsonArray? =
|
||||
runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) as? JsonArray }.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
/**
|
||||
* Why a [PrStatus] has (or lacks) PR data (`src/types.ts` `PrAvailability`). Drives the detail
|
||||
* chip's copy. Decoded via [PrAvailabilitySerializer]: an unknown/future value **degrades to
|
||||
* [ERROR]** (never throws) — a new server availability must never make the chip crash.
|
||||
*/
|
||||
public enum class PrAvailability(public val wire: String) {
|
||||
/** A PR exists for the current branch; the sibling fields are populated. */
|
||||
OK("ok"),
|
||||
|
||||
/** gh works but the branch has no PR (or no remote/default repo). */
|
||||
NO_PR("no-pr"),
|
||||
|
||||
/** `gh` binary not found on PATH (ENOENT). */
|
||||
NOT_INSTALLED("not-installed"),
|
||||
|
||||
/** gh present but not logged in (needs `gh auth login`). */
|
||||
UNAUTHENTICATED("unauthenticated"),
|
||||
|
||||
/** `GH_ENABLED=0` — feature off, never spawns gh. */
|
||||
DISABLED("disabled"),
|
||||
|
||||
/** gh spawned but failed for another reason (timeout, etc.); also the unknown/missing fallback. */
|
||||
ERROR("error"),
|
||||
|
||||
;
|
||||
|
||||
public companion object {
|
||||
/** Map the wire string; unknown → [ERROR] (mirror of the FE never treating non-`ok` as fatal). */
|
||||
public fun fromWire(wire: String): PrAvailability =
|
||||
entries.firstOrNull { it.wire == wire } ?: ERROR
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode [PrAvailability] by its `wire` value; an unknown/future value maps to [PrAvailability.ERROR]
|
||||
* rather than throwing (mirror of [ClaudeStatusSerializer]). Serializes back the `wire` string.
|
||||
*/
|
||||
internal object PrAvailabilitySerializer : KSerializer<PrAvailability> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("PrAvailability", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): PrAvailability =
|
||||
PrAvailability.fromWire(decoder.decodeString())
|
||||
|
||||
override fun serialize(encoder: Encoder, value: PrAvailability) =
|
||||
encoder.encodeString(value.wire)
|
||||
}
|
||||
|
||||
/** Rolled-up CI check counts from gh's statusCheckRollup (`src/types.ts` `PrCheckSummary`). */
|
||||
@Serializable
|
||||
public data class PrCheckSummary(
|
||||
val total: Int = 0,
|
||||
val passing: Int = 0,
|
||||
val failing: Int = 0,
|
||||
val pending: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /projects/pr` result (`src/types.ts` `PrStatus`). Every field except [availability] is
|
||||
* optional (present only when `availability == ok`); [availability] itself defaults to
|
||||
* [PrAvailability.ERROR] so a body missing the field still decodes (never throws). `state` /
|
||||
* `mergeable` are lower-cased string unions on the wire — kept as raw INERT strings here (rendered
|
||||
* as plain text; no enum needed for display).
|
||||
*/
|
||||
@Serializable
|
||||
public data class PrStatus(
|
||||
@Serializable(with = PrAvailabilitySerializer::class)
|
||||
val availability: PrAvailability = PrAvailability.ERROR,
|
||||
val number: Int? = null,
|
||||
val title: String? = null,
|
||||
val url: String? = null,
|
||||
val state: String? = null,
|
||||
val isDraft: Boolean? = null,
|
||||
val mergeable: String? = null,
|
||||
val headRefName: String? = null,
|
||||
val baseRefName: String? = null,
|
||||
val checks: PrCheckSummary? = null,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* One running session belonging to a project (`src/types.ts` `ProjectSessionRef`). The server
|
||||
* mirrors [LiveSessionInfo] fields into this ref. Tolerant: unknown status → [ClaudeStatus.UNKNOWN],
|
||||
* absent title → `null`; missing id/clientCount/createdAt/exited drops the ref (nested lossy list).
|
||||
*/
|
||||
@Serializable
|
||||
public data class ProjectSessionRef(
|
||||
@Serializable(with = UuidSerializer::class) val id: UUID,
|
||||
val title: String? = null,
|
||||
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
|
||||
val clientCount: Int,
|
||||
val createdAt: Long,
|
||||
val exited: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* A discovered project (git repo or recently-used cwd) — `GET /projects` (`src/types.ts`
|
||||
* `ProjectInfo`). There is NO `namespace` field on the wire; namespace grouping is a client concept
|
||||
* surfaced only via `UiPrefs.collapsed` group-keys.
|
||||
*/
|
||||
@Serializable
|
||||
public data class ProjectInfo(
|
||||
val name: String,
|
||||
val path: String,
|
||||
val isGit: Boolean,
|
||||
val branch: String? = null,
|
||||
/** Uncommitted changes; only present when the server runs the dirty check. */
|
||||
val dirty: Boolean? = null,
|
||||
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
|
||||
val lastActiveMs: Long? = null,
|
||||
/** W3 sync chip — commits on HEAD not on `@{u}` (best-effort; absent when no upstream). */
|
||||
val ahead: Int? = null,
|
||||
/** W3 sync chip — commits on `@{u}` not on HEAD (best-effort; absent when no upstream). */
|
||||
val behind: Int? = null,
|
||||
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
|
||||
val lastCommitMs: Long? = null,
|
||||
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||
val sessions: List<ProjectSessionRef> = emptyList(),
|
||||
)
|
||||
|
||||
/** One entry from `git worktree list --porcelain` (`src/types.ts` `WorktreeInfo`). */
|
||||
@Serializable
|
||||
public data class WorktreeInfo(
|
||||
val path: String,
|
||||
/** Branch name; `null` on detached HEAD. */
|
||||
val branch: String? = null,
|
||||
val head: String? = null,
|
||||
val isMain: Boolean = false,
|
||||
val isCurrent: Boolean = false,
|
||||
val locked: Boolean? = null,
|
||||
val prunable: Boolean? = null,
|
||||
)
|
||||
|
||||
/** Detailed view of one project — `GET /projects/detail?path=` (`src/types.ts` `ProjectDetail`). */
|
||||
@Serializable
|
||||
public data class ProjectDetail(
|
||||
val name: String,
|
||||
val path: String,
|
||||
val isGit: Boolean,
|
||||
val branch: String? = null,
|
||||
val dirty: Boolean? = null,
|
||||
@Serializable(with = WorktreeInfoListSerializer::class)
|
||||
val worktrees: List<WorktreeInfo> = emptyList(),
|
||||
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||
val sessions: List<ProjectSessionRef> = emptyList(),
|
||||
val hasClaudeMd: Boolean = false,
|
||||
/** CLAUDE.md content (server-truncated for display) when present. */
|
||||
val claudeMd: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Decode a server session id as [UUID]. Server ids are lowercase `crypto.randomUUID()` strings;
|
||||
* a non-UUID string throws, so `LossyDecode` drops that list element (matches iOS decoding
|
||||
* `UUID.self` — a malformed id must not surface). Serializes back lowercase (`UUID.toString()`).
|
||||
*/
|
||||
internal object UuidSerializer : KSerializer<UUID> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
|
||||
override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode [ClaudeStatus] by its `wire` value; an unknown/future value maps to
|
||||
* [ClaudeStatus.UNKNOWN] rather than dropping the entry — a new server status must never make a
|
||||
* running session invisible (iOS `rawStatus.flatMap(...) ?? .unknown`).
|
||||
*/
|
||||
internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ClaudeStatus", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder): ClaudeStatus =
|
||||
ClaudeStatus.fromWire(decoder.decodeString()) ?: ClaudeStatus.UNKNOWN
|
||||
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
|
||||
}
|
||||
|
||||
/**
|
||||
* A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
|
||||
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,
|
||||
* `ProjectDetail.worktrees`). A bad nested element must not fail the whole parent object.
|
||||
*/
|
||||
internal class LossyListSerializer<T>(private val element: KSerializer<T>) : KSerializer<List<T>> {
|
||||
private val delegate = ListSerializer(element)
|
||||
override val descriptor: SerialDescriptor = delegate.descriptor
|
||||
override fun serialize(encoder: Encoder, value: List<T>) = delegate.serialize(encoder, value)
|
||||
override fun deserialize(decoder: Decoder): List<T> {
|
||||
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
|
||||
val array = json.decodeJsonElement() as? JsonArray ?: return emptyList()
|
||||
return array.mapNotNull { runCatching { json.json.decodeFromJsonElement(element, it) }.getOrNull() }
|
||||
}
|
||||
}
|
||||
|
||||
internal object ProjectSessionRefListSerializer :
|
||||
KSerializer<List<ProjectSessionRef>> by LossyListSerializer(ProjectSessionRef.serializer())
|
||||
|
||||
internal object WorktreeInfoListSerializer :
|
||||
KSerializer<List<WorktreeInfo>> by LossyListSerializer(WorktreeInfo.serializer())
|
||||
@@ -0,0 +1,17 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* `GET /live-sessions/:id/preview` response: the tail of the session's ring buffer for a
|
||||
* read-only thumbnail (no attach, no client registered). [data] is opaque ANSI/UTF-8 — feed it to
|
||||
* a terminal, never parse it. All fields required (a malformed body → `InvalidResponseBody`).
|
||||
*/
|
||||
@Serializable
|
||||
public data class SessionPreview(
|
||||
@Serializable(with = UuidSerializer::class) val id: UUID,
|
||||
val cols: Int,
|
||||
val rows: Int,
|
||||
val data: String,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /config/ui` response (`{ allowAutoMode }`). Reserved for a future permission-mode switcher
|
||||
* (filters the high-risk raw `auto` mode); the plan-gate three-way UI does not consume it.
|
||||
*/
|
||||
@Serializable
|
||||
public data class UiConfig(
|
||||
val allowAutoMode: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
|
||||
/**
|
||||
* The cross-device Projects-UI prefs blob (`GET /prefs` RO · `PUT /prefs` G) — an
|
||||
* OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web client
|
||||
* (`favourites: string[]`, `collapsed: { group-key: true }`); ALL OTHER top-level keys are
|
||||
* preserved verbatim across decode → mutate → encode, so an Android `PUT` can never clobber prefs
|
||||
* written by the web/iOS client or a future server (`PUT` replaces the WHOLE blob server-side, so
|
||||
* key preservation is correctness, not politeness).
|
||||
*
|
||||
* Immutable snapshot: [withFavourites]/[withCollapsed] return NEW copies that replace exactly one
|
||||
* known key and carry every other key through untouched. Numbers round-trip verbatim because a
|
||||
* parsed [JsonPrimitive] keeps its source token (`42` never re-encodes as `42.0`).
|
||||
*/
|
||||
public class UiPrefs private constructor(private val storage: JsonObject) {
|
||||
|
||||
/**
|
||||
* Favourited project paths (★): non-empty JSON strings, de-duplicated, original order kept.
|
||||
* Wrong-typed entries are dropped, not fatal (mirrors `public/prefs.ts sanitizePrefs`).
|
||||
*/
|
||||
public val favourites: List<String>
|
||||
get() {
|
||||
val array = storage[KEY_FAVOURITES] as? JsonArray ?: return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
for (element in array) {
|
||||
val primitive = element as? JsonPrimitive ?: continue
|
||||
if (!primitive.isString) continue
|
||||
val path = primitive.content
|
||||
if (path.isNotEmpty()) out.add(path)
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespace group-key → collapsed. Only literal `true` values count (expanded is the default;
|
||||
* both web and server sanitizers agree). A JSON string `"true"` does NOT count.
|
||||
*/
|
||||
public val collapsed: Map<String, Boolean>
|
||||
get() {
|
||||
val obj = storage[KEY_COLLAPSED] as? JsonObject ?: return emptyMap()
|
||||
val out = LinkedHashMap<String, Boolean>()
|
||||
for ((key, value) in obj) {
|
||||
if (key.isEmpty()) continue
|
||||
if (value is JsonPrimitive && !value.isString && value.booleanOrNull == true) {
|
||||
out[key] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** New snapshot with `favourites` replaced; every other key untouched (position preserved). */
|
||||
public fun withFavourites(favourites: List<String>): UiPrefs {
|
||||
val next = LinkedHashMap<String, JsonElement>(storage)
|
||||
next[KEY_FAVOURITES] = JsonArray(favourites.map { JsonPrimitive(it) })
|
||||
return UiPrefs(JsonObject(next))
|
||||
}
|
||||
|
||||
/** New snapshot with `collapsed` replaced; every other key untouched (position preserved). */
|
||||
public fun withCollapsed(collapsed: Map<String, Boolean>): UiPrefs {
|
||||
val next = LinkedHashMap<String, JsonElement>(storage)
|
||||
next[KEY_COLLAPSED] = JsonObject(collapsed.mapValues { JsonPrimitive(it.value) })
|
||||
return UiPrefs(JsonObject(next))
|
||||
}
|
||||
|
||||
/** Encode the FULL blob (known + unknown keys) for `PUT /prefs`. */
|
||||
public fun encodeBody(): ByteArray =
|
||||
ModelJson.encodeToString(JsonObject.serializer(), storage).encodeToByteArray()
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is UiPrefs && other.storage == storage
|
||||
override fun hashCode(): Int = storage.hashCode()
|
||||
override fun toString(): String = "UiPrefs(storage=$storage)"
|
||||
|
||||
public companion object {
|
||||
private const val KEY_FAVOURITES = "favourites"
|
||||
private const val KEY_COLLAPSED = "collapsed"
|
||||
|
||||
/** Fresh prefs (e.g. first write from a device that never fetched). */
|
||||
public fun create(
|
||||
favourites: List<String> = emptyList(),
|
||||
collapsed: Map<String, Boolean> = emptyMap(),
|
||||
): UiPrefs = UiPrefs(
|
||||
JsonObject(
|
||||
mapOf(
|
||||
KEY_FAVOURITES to JsonArray(favourites.map { JsonPrimitive(it) }),
|
||||
KEY_COLLAPSED to JsonObject(collapsed.mapValues { JsonPrimitive(it.value) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Decode a `/prefs` body. `null` = top level is not a JSON object — callers surface
|
||||
* `InvalidResponseBody` LOUDLY instead of degrading to empty prefs (an empty-based `PUT`
|
||||
* would wipe the server blob).
|
||||
*/
|
||||
public fun decode(bytes: ByteArray): UiPrefs? {
|
||||
val element = runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) }.getOrNull()
|
||||
return (element as? JsonObject)?.let { UiPrefs(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import wang.yaojia.webterm.wire.HostClassifier
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.PortUnreachableException
|
||||
import java.net.SocketException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.net.UnknownServiceException
|
||||
import java.security.cert.CertPathBuilderException
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import java.security.cert.CertificateException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Error taxonomy for the pairing probe (Android port of iOS `APIClient.PairingError`). Each case
|
||||
* maps one probe failure mode to actionable UI copy (`PairingViewModel`, A19, renders these).
|
||||
*
|
||||
* DEVIATION from iOS (plan R12): the iOS `localNetworkDenied` case is DROPPED — Android has no
|
||||
* iOS-style Local-Network permission prompt (a plain WS to a LAN IP needs no runtime permission),
|
||||
* so the ENETDOWN→localNetworkDenied diagnosis is dead weight here. iOS `atsBlocked` is kept as
|
||||
* [CleartextBlocked]: the genuine Android analogue is a `network_security_config` cleartext block
|
||||
* surfaced as [java.net.UnknownServiceException] (plan §6.9/§8 cleartext posture).
|
||||
*/
|
||||
public sealed interface PairingError {
|
||||
/** Nothing answered — connection refused / no route / DNS failure (`ConnectException`, `UnknownHostException`, …). */
|
||||
public data class HostUnreachable(val underlying: String) : PairingError
|
||||
|
||||
/**
|
||||
* The port speaks HTTP but `GET /live-sessions` did not return the web-terminal shape (a JSON
|
||||
* array) — "端口对吗?". Also used when probe ② connects but the socket never speaks our protocol.
|
||||
*/
|
||||
public data object HttpOkButNotWebTerminal : PairingError
|
||||
|
||||
/**
|
||||
* The WS upgrade (or the guarded kill round-trip) was rejected — the host's Origin whitelist
|
||||
* does not contain our dialed origin. [hint] carries the exact `ALLOWED_ORIGINS=<origin>` line,
|
||||
* always derived from [HostEndpoint.originHeader] (never hand-assembled; default ports omitted).
|
||||
*/
|
||||
public data class OriginRejected(val hint: String) : PairingError
|
||||
|
||||
/**
|
||||
* Cleartext (`ws://`/`http://`) to a host outside the app's `network_security_config` allowlist
|
||||
* was blocked by the platform ([java.net.UnknownServiceException]). Android analogue of iOS
|
||||
* `atsBlocked`. [host] is the dialed host for the actionable copy.
|
||||
*/
|
||||
public data class CleartextBlocked(val host: String) : PairingError
|
||||
|
||||
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
|
||||
public data object TlsFailure : PairingError
|
||||
|
||||
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
|
||||
public data object Timeout : PairingError
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* Actionable copy for [OriginRejected] — always derived from the SINGLE origin source
|
||||
* ([HostEndpoint.originHeader]), never hand-assembled. Ported verbatim from iOS.
|
||||
*/
|
||||
public fun originRejectedHint(endpoint: HostEndpoint): String =
|
||||
"服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=${endpoint.originHeader}" +
|
||||
"(与 App 连接的 URL 完全一致)后重启 web-terminal,再重试配对。"
|
||||
|
||||
/**
|
||||
* Classify a transport-level [Throwable] thrown by [wang.yaojia.webterm.wire.HttpTransport]
|
||||
* or [wang.yaojia.webterm.wire.TermTransport] into the probe taxonomy. Walks the bounded
|
||||
* `cause` chain (OkHttp wraps causes; never trust an error graph not to cycle) so wrapped
|
||||
* roots (e.g. an `IOException` wrapping a `ConnectException`) are seen.
|
||||
*
|
||||
* @param unrecognizedFallback used by probe step ② — after step ① proved the host reachable
|
||||
* AND web-terminal-shaped, an upgrade failure with no recognizable network cause is, by
|
||||
* elimination, the server's Origin 401 (its only upgrade-reject path).
|
||||
*/
|
||||
public fun classify(
|
||||
error: Throwable,
|
||||
endpoint: HostEndpoint,
|
||||
unrecognizedFallback: PairingError? = null,
|
||||
): PairingError {
|
||||
val chain = causeChain(error)
|
||||
if (chain.any { it is UnknownServiceException }) {
|
||||
return CleartextBlocked(host = HostClassifier.hostOf(endpoint))
|
||||
}
|
||||
if (chain.any { isTimeout(it) }) return Timeout
|
||||
if (chain.any { isTls(it) }) return TlsFailure
|
||||
if (chain.any { isConnectivity(it) }) {
|
||||
return HostUnreachable(underlying = describe(error))
|
||||
}
|
||||
return unrecognizedFallback ?: HostUnreachable(underlying = describe(error))
|
||||
}
|
||||
|
||||
private const val MAX_CAUSE_DEPTH = 8
|
||||
|
||||
/** Bounded walk of [Throwable.cause] with an identity cycle-guard (defensive). */
|
||||
private fun causeChain(error: Throwable): List<Throwable> {
|
||||
val chain = mutableListOf<Throwable>()
|
||||
var current: Throwable? = error
|
||||
while (current != null && chain.size < MAX_CAUSE_DEPTH) {
|
||||
if (chain.any { it === current }) break
|
||||
chain.add(current)
|
||||
current = current.cause
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
// `SocketTimeoutException` extends `InterruptedIOException`; OkHttp's overall call-timeout
|
||||
// also surfaces as a bare `InterruptedIOException` — both mean "timed out".
|
||||
private fun isTimeout(e: Throwable): Boolean = e is InterruptedIOException
|
||||
|
||||
private fun isTls(e: Throwable): Boolean =
|
||||
e is SSLException ||
|
||||
e is CertificateException ||
|
||||
e is CertPathValidatorException ||
|
||||
e is CertPathBuilderException
|
||||
|
||||
// `ConnectException` / `NoRouteToHostException` / `PortUnreachableException` all extend
|
||||
// `SocketException`; listed explicitly for readability. `UnknownHostException` is a DNS
|
||||
// failure (extends `IOException`, not `SocketException`).
|
||||
private fun isConnectivity(e: Throwable): Boolean =
|
||||
e is ConnectException ||
|
||||
e is NoRouteToHostException ||
|
||||
e is PortUnreachableException ||
|
||||
e is SocketException ||
|
||||
e is UnknownHostException
|
||||
|
||||
private fun describe(error: Throwable): String =
|
||||
error.message ?: error::class.simpleName ?: "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.MessageCodec
|
||||
import wang.yaojia.webterm.wire.ServerMessage
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import wang.yaojia.webterm.wire.TransportConnection
|
||||
import wang.yaojia.webterm.wire.Tunables
|
||||
import java.net.URI
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Result of a pairing probe — the validated [HostEndpoint] on success, a [PairingError] on failure.
|
||||
* Android analogue of iOS `Result<HostEndpoint, PairingError>`. The pairing UI (A19) constructs the
|
||||
* persisted `Host{id,name}` from the returned endpoint (id/name are not the probe's to know).
|
||||
*/
|
||||
public sealed interface PairingProbeResult {
|
||||
public data class Success(val endpoint: HostEndpoint) : PairingProbeResult
|
||||
|
||||
public data class Failure(val error: PairingError) : PairingProbeResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Public probe entry (Android port of iOS `runPairingProbe`). Two-step probe:
|
||||
* 1. `GET /live-sessions` (NO Origin) — reachability + web-terminal shape.
|
||||
* 2. WS `attach(null)` → adopt the server-issued `attached` id → close → **immediately
|
||||
* `DELETE /live-sessions/:id` WITH Origin** (verifies the Origin guard on both the upgrade and
|
||||
* the guarded-HTTP side, and never leaks the probe's orphan session).
|
||||
*
|
||||
* **Confirm-before-network contract:** callers MUST run this only after the user confirmed a
|
||||
* scanned/typed host (A19). Step ① already talks to the network and step ② spawns a PTY on the
|
||||
* target machine — nothing here is speculative, so the UI gate is the caller's responsibility.
|
||||
*
|
||||
* The wall-clock deadline is [Tunables.PAIRING_PROBE_TIMEOUT]; tests drive [runPairingProbeCore]
|
||||
* with an explicit (or `null`) timeout for deterministic virtual-time coverage.
|
||||
*/
|
||||
public suspend fun runPairingProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
): PairingProbeResult =
|
||||
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
|
||||
|
||||
/**
|
||||
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
|
||||
* still apply — fast, race-free tests); otherwise the whole probe is cancelled past the deadline and
|
||||
* resolves [PairingError.Timeout]. Cancellation is the coroutine analogue of iOS's `group.cancelAll`.
|
||||
*/
|
||||
internal suspend fun runPairingProbeCore(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
timeout: Duration?,
|
||||
): PairingProbeResult {
|
||||
if (timeout == null) return performProbe(endpoint, http, ws)
|
||||
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
|
||||
?: PairingProbeResult.Failure(PairingError.Timeout)
|
||||
}
|
||||
|
||||
// ── Probe body ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun performProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
): PairingProbeResult {
|
||||
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
|
||||
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
|
||||
probeReachability(endpoint, http)?.let { return it }
|
||||
|
||||
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
|
||||
// unrecognizable connect failure is classified as originRejected.
|
||||
val connection: TransportConnection = try {
|
||||
ws.connect(endpoint)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
return PairingProbeResult.Failure(
|
||||
PairingError.classify(
|
||||
error,
|
||||
endpoint,
|
||||
unrecognizedFallback = PairingError.OriginRejected(
|
||||
PairingError.originRejectedHint(endpoint),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Once connected, the connection MUST be closed on every exit path — including the timeout/cancel
|
||||
// path, where withTimeoutOrNull cancels us mid-`firstOrNull`. Without this finally, a cancel skips
|
||||
// close() and leaks the WS + its orphan PTY session on the host. NonCancellable so the close still
|
||||
// runs while we are already being cancelled.
|
||||
return try {
|
||||
when (val adoption = adoptAttachedSession(connection)) {
|
||||
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
|
||||
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) { runCatching { connection.close() } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe step ①. Returns a [PairingProbeResult.Failure] to short-circuit, or `null` to proceed.
|
||||
* Reachable + web-terminal-shaped = HTTP 200 with a JSON-array body (an HTML admin page, a 404, or
|
||||
* a non-array body are all "not web-terminal").
|
||||
*/
|
||||
private suspend fun probeReachability(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
): PairingProbeResult.Failure? {
|
||||
val response = try {
|
||||
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
||||
}
|
||||
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
|
||||
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Send `attach(null)` (explicit JSON `"sessionId":null` via [MessageCodec]) and wait for the
|
||||
* server-issued `attached` id, skipping any other or undecodable frame (untrusted server; only
|
||||
* `attached` matters here). A stream that ends or errors before speaking our protocol is NOT an
|
||||
* Origin problem — the upgrade already succeeded — so it maps to [PairingError.HttpOkButNotWebTerminal].
|
||||
*/
|
||||
private suspend fun adoptAttachedSession(connection: TransportConnection): Adoption =
|
||||
try {
|
||||
connection.send(MessageCodec.encode(ClientMessage.Attach(sessionId = null)))
|
||||
val sessionId = connection.frames
|
||||
.mapNotNull { frame -> (MessageCodec.decodeServer(frame) as? ServerMessage.Attached)?.sessionId }
|
||||
.firstOrNull()
|
||||
if (sessionId != null) Adoption.Success(sessionId) else Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||
}
|
||||
|
||||
/**
|
||||
* The guarded kill round-trip is part of pairing verification itself (`DELETE` exercises the
|
||||
* HTTP-side Origin guard the later `hookDecision` will need) AND guarantees the probe leaves no
|
||||
* orphan session. 204 = killed, 404 = already gone (both success), 403 = Origin guard rejected us.
|
||||
*/
|
||||
private suspend fun killProbeSession(
|
||||
sessionId: String,
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
): PairingProbeResult {
|
||||
val response = try {
|
||||
http.send(
|
||||
HttpRequest(
|
||||
method = HttpMethod.DELETE,
|
||||
url = killUrl(endpoint, sessionId),
|
||||
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
|
||||
),
|
||||
)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
||||
}
|
||||
return when (response.status) {
|
||||
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
|
||||
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
|
||||
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
|
||||
)
|
||||
else -> PairingProbeResult.Failure(
|
||||
PairingError.HostUnreachable(underlying = "HTTP ${response.status}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface Adoption {
|
||||
data class Success(val sessionId: String) : Adoption
|
||||
|
||||
data class Failure(val error: PairingError) : Adoption
|
||||
}
|
||||
|
||||
// ── URL derivation + shape check ────────────────────────────────────────────────────────────────
|
||||
|
||||
private val PROBE_JSON = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
|
||||
/** Web-terminal shape = the body parses as a JSON array (the server replies `[]` when idle). */
|
||||
private fun isJsonArray(body: ByteArray): Boolean =
|
||||
runCatching { PROBE_JSON.parseToJsonElement(body.decodeToString()) is JsonArray }.getOrDefault(false)
|
||||
|
||||
private fun liveSessionsUrl(endpoint: HostEndpoint): String = httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH
|
||||
|
||||
private fun killUrl(endpoint: HostEndpoint, sessionId: String): String =
|
||||
httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH + "/" + sessionId
|
||||
|
||||
/**
|
||||
* `scheme://host[:port]` from the endpoint's dialed URL — path/query/fragment/credentials dropped
|
||||
* (same derivation philosophy as [HostEndpoint.wsUrl]). The dialed port is kept verbatim; the host
|
||||
* is left as [java.net.URI] returns it (IPv6 literals are already bracketed).
|
||||
*/
|
||||
private fun httpBaseUrl(endpoint: HostEndpoint): String {
|
||||
val uri = URI(endpoint.baseUrl)
|
||||
val scheme = (uri.scheme ?: "http").lowercase()
|
||||
val host = uri.host ?: ""
|
||||
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||
return "$scheme://$host$portPart"
|
||||
}
|
||||
|
||||
private const val LIVE_SESSIONS_PATH = "/live-sessions"
|
||||
private const val ORIGIN_HEADER = "Origin"
|
||||
private const val HTTP_OK = 200
|
||||
private const val HTTP_NO_CONTENT = 204
|
||||
private const val HTTP_FORBIDDEN = 403
|
||||
private const val HTTP_NOT_FOUND = 404
|
||||
@@ -0,0 +1,265 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import wang.yaojia.webterm.api.models.CommitResult
|
||||
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.GitLogResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||
import wang.yaojia.webterm.api.models.LossyDecode
|
||||
import wang.yaojia.webterm.api.models.PrStatus
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||
import wang.yaojia.webterm.api.models.PushResult
|
||||
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.SessionPreview
|
||||
import wang.yaojia.webterm.api.models.StageResult
|
||||
import wang.yaojia.webterm.api.models.UiConfig
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.api.models.decodeGitError
|
||||
import wang.yaojia.webterm.api.models.decodeGitPayload
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TimelineEvent
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Typed client for the server's HTTP surface (12 frozen routes). Pure — consumes [HttpTransport]
|
||||
* by interface (no OkHttp/Android); `:transport-okhttp` (A7) provides the real impl, the
|
||||
* `:test-support` fake queues canned responses.
|
||||
*
|
||||
* **Origin 铁律 (CSWSH, plan §4.3):** only the guarded (state-changing) routes stamp
|
||||
* `Origin: endpoint.originHeader`; the read-only GETs never do. Stamping lives in ONE place
|
||||
* ([ApiRoute.toHttpRequest]) and the value is single-point-derived by [HostEndpoint].
|
||||
*
|
||||
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
|
||||
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
|
||||
*/
|
||||
public class ApiClient(
|
||||
public val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
) {
|
||||
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
||||
|
||||
/** `GET /live-sessions` — the discovery list every device polls. */
|
||||
public suspend fun liveSessions(): List<LiveSessionInfo> {
|
||||
val response = perform(Endpoints.liveSessions())
|
||||
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
|
||||
return LossyDecode.listOrNull(response.body, LiveSessionInfo.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
/** `GET /live-sessions/:id/preview` — ring-buffer tail for a read-only thumbnail (no attach). */
|
||||
public suspend fun preview(id: UUID): SessionPreview {
|
||||
val response = perform(Endpoints.preview(id))
|
||||
requireOk(response)
|
||||
return LossyDecode.objectOrNull(response.body, SessionPreview.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
/** `GET /live-sessions/:id/events` — the activity timeline. A non-array body (timeline capture
|
||||
* disabled) → `[]`; unknown-class entries survive shape-decode and are filtered downstream. */
|
||||
public suspend fun events(id: UUID): List<TimelineEvent> {
|
||||
val response = perform(Endpoints.events(id))
|
||||
requireOk(response)
|
||||
return LossyDecode.listOrEmpty(response.body, TimelineEvent.serializer())
|
||||
}
|
||||
|
||||
/** `GET /config/ui` — `{ allowAutoMode }`. */
|
||||
public suspend fun uiConfig(): UiConfig {
|
||||
val response = perform(Endpoints.uiConfig())
|
||||
requireOk(response)
|
||||
return LossyDecode.objectOrNull(response.body, UiConfig.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
/** `GET /projects` — discovered projects with their running sessions merged in. */
|
||||
public suspend fun projects(): List<ProjectInfo> {
|
||||
val response = perform(Endpoints.projects())
|
||||
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
|
||||
return LossyDecode.listOrNull(response.body, ProjectInfo.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
/** `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one project. An empty path is
|
||||
* rejected client-side (mirror of the server's 400) before any network I/O. */
|
||||
public suspend fun projectDetail(path: String): ProjectDetail {
|
||||
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
||||
val response = perform(Endpoints.projectDetail(path))
|
||||
return when (response.status) {
|
||||
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, ProjectDetail.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
||||
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
||||
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.ProjectDetailUnavailable
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /projects/pr?path=` — PR + CI status for the project's current branch. The PR *degrade*
|
||||
* (gh missing / unauth / no-PR / disabled) is `availability` inside a **200** body, NOT an HTTP
|
||||
* status — so every valid git dir returns 200 and the chip renders from [PrStatus.availability].
|
||||
* A garbled body degrades to `availability=ERROR` (tolerant decode). 400→path invalid; 404→not a
|
||||
* repo. Empty path rejected client-side before any I/O.
|
||||
*/
|
||||
public suspend fun projectPr(path: String): PrStatus {
|
||||
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
||||
val response = perform(Endpoints.projectPr(path))
|
||||
return when (response.status) {
|
||||
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, PrStatus.serializer())
|
||||
?: PrStatus() // availability defaults to ERROR — never throw on a bad PR body
|
||||
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
||||
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /projects/log?path=[&n=]` — recent commits (list-lossy: malformed commits dropped). 400→
|
||||
* path invalid; 404→not a repo; 500→[ApiClientError.GitLogUnavailable]. Empty path rejected
|
||||
* client-side before any I/O; `n` is clamped in the route builder.
|
||||
*/
|
||||
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult {
|
||||
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
||||
val response = perform(Endpoints.projectLog(path, n))
|
||||
return when (response.status) {
|
||||
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, GitLogResult.serializer())
|
||||
?: throw ApiClientError.InvalidResponseBody
|
||||
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
||||
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
||||
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.GitLogUnavailable
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
|
||||
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
|
||||
public suspend fun prefs(): UiPrefs {
|
||||
val response = perform(Endpoints.getPrefs())
|
||||
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
|
||||
return UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
|
||||
}
|
||||
|
||||
// ── G (state-changing — Origin required, byte-equal) ───────────────────────────────────
|
||||
|
||||
/** `DELETE /live-sessions/:id`. 204 = success; 404 = already gone (also success on the server,
|
||||
* but iOS surfaces it as `SessionNotFound` — matched here). */
|
||||
public suspend fun killSession(id: UUID) {
|
||||
val response = perform(Endpoints.killSession(id))
|
||||
when (response.status) {
|
||||
HttpStatus.NO_CONTENT -> Unit
|
||||
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
|
||||
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** `POST /hook/decision` — resolve a held remote approval with a single-use `token` (push
|
||||
* payload only; NEVER persist it). 403 → stale/mismatched token; 429 → rate-limited. */
|
||||
public suspend fun hookDecision(sessionId: UUID, decision: HookDecision, token: String) {
|
||||
val response = perform(Endpoints.hookDecision(sessionId, decision, token))
|
||||
when (response.status) {
|
||||
HttpStatus.NO_CONTENT -> Unit
|
||||
HttpStatus.FORBIDDEN -> throw ApiClientError.DecisionRejected
|
||||
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** `PUT /prefs` — replace the whole blob. Returns the server's sanitized echo — treat IT as the
|
||||
* new source of truth, not the input. 403 = Origin guard. */
|
||||
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs {
|
||||
val response = perform(Endpoints.putPrefs(prefs))
|
||||
return when (response.status) {
|
||||
HttpStatus.OK -> UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
|
||||
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
|
||||
|
||||
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
|
||||
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
|
||||
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
|
||||
|
||||
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
|
||||
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
|
||||
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
|
||||
|
||||
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
|
||||
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
|
||||
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
|
||||
|
||||
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
|
||||
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
|
||||
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
|
||||
|
||||
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
|
||||
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
|
||||
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
|
||||
|
||||
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
|
||||
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
|
||||
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
|
||||
|
||||
/**
|
||||
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
|
||||
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
|
||||
* carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
|
||||
* kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
|
||||
* (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
|
||||
*/
|
||||
private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
|
||||
val response = perform(route)
|
||||
return when (response.status) {
|
||||
HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
|
||||
HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
|
||||
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
|
||||
GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
|
||||
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
|
||||
public suspend fun registerFcmToken(token: String) {
|
||||
sendFcmToken(token, Endpoints::registerFcmToken)
|
||||
}
|
||||
|
||||
/** `DELETE /push/fcm-token` — unregister (idempotent → 204 even for an unknown token). */
|
||||
public suspend fun unregisterFcmToken(token: String) {
|
||||
sendFcmToken(token, Endpoints::unregisterFcmToken)
|
||||
}
|
||||
|
||||
private suspend fun sendFcmToken(token: String, build: (String) -> ApiRoute) {
|
||||
val normalized = FcmTokenRule.normalize(token) ?: throw ApiClientError.InvalidFcmToken
|
||||
val response = perform(build(normalized))
|
||||
when (response.status) {
|
||||
HttpStatus.NO_CONTENT -> Unit
|
||||
HttpStatus.BAD_REQUEST -> throw ApiClientError.InvalidFcmToken
|
||||
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
|
||||
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun perform(route: ApiRoute): HttpResponse {
|
||||
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
|
||||
return http.send(request)
|
||||
}
|
||||
|
||||
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
|
||||
private fun requireOk(response: HttpResponse) {
|
||||
when (response.status) {
|
||||
HttpStatus.OK -> Unit
|
||||
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
|
||||
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
/**
|
||||
* Typed failures for [ApiClient] calls (explicit error handling, plan §4). Transport-level errors
|
||||
* thrown by `HttpTransport.send` propagate UNWRAPPED (the pairing classifier reads their shape).
|
||||
*
|
||||
* [userMessage] is UI-ready copy (matches the iOS `APIClientError.message` 话术). The no-arg cases
|
||||
* are singletons (`object`) so tests can assert by identity/equality; [UnexpectedStatus] carries the
|
||||
* offending code.
|
||||
*/
|
||||
public sealed class ApiClientError(public val userMessage: String) : Exception(userMessage) {
|
||||
/** The request could not be built from the endpoint (malformed base URL — should not happen for
|
||||
* a validated `HostEndpoint`; surfaced instead of crashing). */
|
||||
public data object InvalidRequest : ApiClientError("无法构造请求(主机地址异常)。")
|
||||
|
||||
/** A 2xx arrived but the body is not the endpoint's shape. For `/live-sessions` this is the
|
||||
* pairing probe's "the port speaks HTTP but is not web-terminal" signal. */
|
||||
public data object InvalidResponseBody : ApiClientError("服务器响应不是预期格式——端口对吗?")
|
||||
|
||||
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
|
||||
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
|
||||
|
||||
/** 403 from a G route's Origin guard (CSWSH defence). */
|
||||
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
||||
|
||||
/** 403 from `POST /hook/decision`: the capability token is missing / mismatched / STALE —
|
||||
* tokens are single-use and expiring by design. */
|
||||
public data object DecisionRejected : ApiClientError("审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。")
|
||||
|
||||
/** 429: the endpoint is rate-limited per IP by fixed server policy. */
|
||||
public data object RateLimited : ApiClientError("操作过于频繁,服务器已限流,请稍后再试。")
|
||||
|
||||
/** The FCM registration token failed the client-side charset/length check, or the server echoed
|
||||
* a 400. */
|
||||
public data object InvalidFcmToken : ApiClientError("推送注册令牌格式异常,请重启 App 重新注册推送。")
|
||||
|
||||
/** 400 from `GET /projects/detail` — the `path` query parameter is missing/empty. Also raised
|
||||
* client-side for an empty path, before any network I/O. */
|
||||
public data object ProjectPathInvalid : ApiClientError("项目路径为空或不合法。")
|
||||
|
||||
/** 404 from `GET /projects/detail` — no project at that path (moved/deleted/not a directory). */
|
||||
public data object ProjectNotFound : ApiClientError("项目不存在(路径可能已移动或删除)。")
|
||||
|
||||
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
|
||||
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
|
||||
|
||||
/** 500 from `GET /projects/log` — the server failed reading the git log. */
|
||||
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
|
||||
|
||||
/** Any other non-success status code. */
|
||||
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status。")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import java.net.URI
|
||||
|
||||
/** Named HTTP status codes used by the client (no magic numbers, plan §4). */
|
||||
internal object HttpStatus {
|
||||
const val OK = 200
|
||||
const val NO_CONTENT = 204
|
||||
const val BAD_REQUEST = 400
|
||||
const val FORBIDDEN = 403
|
||||
const val NOT_FOUND = 404
|
||||
const val TOO_MANY_REQUESTS = 429
|
||||
const val INTERNAL_SERVER_ERROR = 500
|
||||
|
||||
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
|
||||
const val CLIENT_ERROR_MIN = 400
|
||||
const val SERVER_ERROR_MAX = 599
|
||||
}
|
||||
|
||||
/** Header / content-type names (no magic strings inline). */
|
||||
internal object HeaderName {
|
||||
const val ORIGIN = "Origin"
|
||||
const val CONTENT_TYPE = "Content-Type"
|
||||
}
|
||||
|
||||
internal object ContentType {
|
||||
const val JSON = "application/json"
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a route mutates server state — THE security split (plan §4.3): `Origin` is stamped
|
||||
* **iff** [GUARDED]. If the server ever reclassifies a RO route as guarded, tests go red instead of
|
||||
* passing by coincidence.
|
||||
*/
|
||||
internal enum class OriginPolicy {
|
||||
/** Read-only GET — MUST NOT carry Origin. */
|
||||
READ_ONLY,
|
||||
|
||||
/** State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal; server 403s a
|
||||
* missing/foreign Origin (CSWSH defence). */
|
||||
GUARDED,
|
||||
}
|
||||
|
||||
/**
|
||||
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
|
||||
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
|
||||
* sites). Origin stamping happens HERE and only here (single point).
|
||||
*/
|
||||
internal class ApiRoute(
|
||||
val method: HttpMethod,
|
||||
val path: String,
|
||||
val originPolicy: OriginPolicy,
|
||||
val body: ByteArray? = null,
|
||||
val percentEncodedQuery: String? = null,
|
||||
) {
|
||||
/**
|
||||
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
|
||||
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
|
||||
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
|
||||
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
|
||||
*/
|
||||
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
|
||||
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
|
||||
val headers = LinkedHashMap<String, String>()
|
||||
if (originPolicy == OriginPolicy.GUARDED) {
|
||||
headers[HeaderName.ORIGIN] = endpoint.originHeader
|
||||
}
|
||||
if (body != null) {
|
||||
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
|
||||
}
|
||||
return HttpRequest(method = method, url = url, headers = headers, body = body)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Rebuild `<scheme>://<host>[:<port>]<path>[?<query>]` from the dialed base URL, keeping the
|
||||
* dialed port verbatim (like `wsUrl`, unlike the default-port-dropping Origin). The path and
|
||||
* pre-encoded query are appended verbatim; any path/query/fragment/credentials the base URL
|
||||
* carried are dropped.
|
||||
*/
|
||||
fun buildUrl(baseUrl: String, path: String, query: String?): String? {
|
||||
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
val host = uri.host ?: return null
|
||||
if (host.isEmpty()) return null
|
||||
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||
val queryPart = if (query != null) "?$query" else ""
|
||||
return "$scheme://$serializedHost$portPart$path$queryPart"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.api.models.ModelJson
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Builders for the frozen endpoints (12 routes, verified against `src/http/…` + iOS `Endpoints` /
|
||||
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
|
||||
* pair (this client uses FCM, not APNs/VAPID).
|
||||
*
|
||||
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
|
||||
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
|
||||
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
|
||||
* · `POST|DELETE /push/fcm-token`
|
||||
*
|
||||
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
|
||||
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
|
||||
* `/push/fcm-token`), which is still PENDING — so FCM push is non-functional against the current
|
||||
* server until A33 lands. The client builders exist now so the token lifecycle is ready the moment
|
||||
* the server route ships; do not "fix" this as a mismatch.
|
||||
*/
|
||||
internal object Endpoints {
|
||||
// ── RO ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fun liveSessions(): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/live-sessions", OriginPolicy.READ_ONLY)
|
||||
|
||||
fun preview(id: UUID): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/preview", OriginPolicy.READ_ONLY)
|
||||
|
||||
fun events(id: UUID): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/events", OriginPolicy.READ_ONLY)
|
||||
|
||||
fun uiConfig(): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/config/ui", OriginPolicy.READ_ONLY)
|
||||
|
||||
fun projects(): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/projects", OriginPolicy.READ_ONLY)
|
||||
|
||||
/**
|
||||
* `GET /projects/detail?path=` — RO. The ONE place `path` gets percent-encoded, with a strict
|
||||
* RFC 3986 unreserved-only set (deliberately stricter than URL-query-allowed: a bare `+` decodes
|
||||
* to a SPACE in Express's qs parser, and `&`/`=` would split the parameter).
|
||||
*/
|
||||
fun projectDetail(path: String): ApiRoute =
|
||||
ApiRoute(
|
||||
HttpMethod.GET,
|
||||
"/projects/detail",
|
||||
OriginPolicy.READ_ONLY,
|
||||
percentEncodedQuery = "path=${percentEncode(path)}",
|
||||
)
|
||||
|
||||
fun getPrefs(): ApiRoute =
|
||||
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
||||
|
||||
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
|
||||
fun projectPr(path: String): ApiRoute =
|
||||
ApiRoute(
|
||||
HttpMethod.GET,
|
||||
"/projects/pr",
|
||||
OriginPolicy.READ_ONLY,
|
||||
percentEncodedQuery = "path=${percentEncode(path)}",
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
|
||||
* `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
|
||||
*/
|
||||
fun projectLog(path: String, n: Int?): ApiRoute {
|
||||
val query = StringBuilder("path=").append(percentEncode(path))
|
||||
if (n != null) {
|
||||
val clamped = n.coerceIn(1, GIT_LOG_MAX)
|
||||
query.append("&n=").append(clamped)
|
||||
}
|
||||
return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
|
||||
}
|
||||
|
||||
// ── G ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fun killSession(id: UUID): ApiRoute =
|
||||
ApiRoute(HttpMethod.DELETE, "/live-sessions/${pathId(id)}", OriginPolicy.GUARDED)
|
||||
|
||||
/** Body is exactly `{ sessionId, decision, token }`. `token` is single-use (push payload only) —
|
||||
* callers must never persist/log it. */
|
||||
fun hookDecision(sessionId: UUID, decision: HookDecision, token: String): ApiRoute {
|
||||
val body = ModelJson.encodeToString(
|
||||
HookDecisionBody.serializer(),
|
||||
HookDecisionBody(pathId(sessionId), decision.wire, token),
|
||||
).encodeToByteArray()
|
||||
return ApiRoute(HttpMethod.POST, "/hook/decision", OriginPolicy.GUARDED, body = body)
|
||||
}
|
||||
|
||||
/** `PUT /prefs` — G. Replaces the whole blob (server echoes a sanitized 200). */
|
||||
fun putPrefs(prefs: UiPrefs): ApiRoute =
|
||||
ApiRoute(HttpMethod.PUT, "/prefs", OriginPolicy.GUARDED, body = prefs.encodeBody())
|
||||
|
||||
fun registerFcmToken(normalized: String): ApiRoute =
|
||||
fcmTokenRoute(HttpMethod.POST, normalized)
|
||||
|
||||
fun unregisterFcmToken(normalized: String): ApiRoute =
|
||||
fcmTokenRoute(HttpMethod.DELETE, normalized)
|
||||
|
||||
private fun fcmTokenRoute(method: HttpMethod, normalized: String): ApiRoute {
|
||||
val body = ModelJson.encodeToString(
|
||||
FcmTokenBody.serializer(),
|
||||
FcmTokenBody(normalized),
|
||||
).encodeToByteArray()
|
||||
return ApiRoute(method, FCM_TOKEN_PATH, OriginPolicy.GUARDED, body = body)
|
||||
}
|
||||
|
||||
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
||||
|
||||
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
|
||||
|
||||
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
|
||||
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
|
||||
jsonBodyRoute(
|
||||
HttpMethod.POST,
|
||||
"/projects/worktree",
|
||||
CreateWorktreeBody.serializer(),
|
||||
CreateWorktreeBody(path, branch, base),
|
||||
)
|
||||
|
||||
/** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
|
||||
fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
|
||||
jsonBodyRoute(
|
||||
HttpMethod.DELETE,
|
||||
"/projects/worktree",
|
||||
RemoveWorktreeBody.serializer(),
|
||||
RemoveWorktreeBody(path, worktreePath, force),
|
||||
)
|
||||
|
||||
/** `POST /projects/worktree/prune` — `{ path }`. */
|
||||
fun pruneWorktrees(path: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))
|
||||
|
||||
// ── G: git write (stage / commit / push) ───────────────────────────────────────────────
|
||||
|
||||
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
|
||||
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
|
||||
|
||||
/** `POST /projects/git/commit` — `{ path, message }`. */
|
||||
fun gitCommit(path: String, message: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
|
||||
|
||||
/** `POST /projects/git/push` — `{ path }`. */
|
||||
fun gitPush(path: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
||||
|
||||
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
|
||||
private fun <T> jsonBodyRoute(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
serializer: kotlinx.serialization.KSerializer<T>,
|
||||
value: T,
|
||||
): ApiRoute {
|
||||
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
|
||||
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
|
||||
}
|
||||
|
||||
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
|
||||
private const val GIT_LOG_MAX = 50
|
||||
|
||||
/**
|
||||
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
|
||||
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
|
||||
* on the JVM (unlike Swift's uppercase `uuidString`).
|
||||
*/
|
||||
private fun pathId(id: UUID): String = id.toString()
|
||||
|
||||
/** Strict RFC 3986 unreserved set — everything else is percent-encoded over UTF-8 bytes. */
|
||||
private val UNRESERVED: Set<Char> =
|
||||
("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~").toSet()
|
||||
|
||||
private fun percentEncode(value: String): String {
|
||||
val sb = StringBuilder()
|
||||
for (byte in value.encodeToByteArray()) {
|
||||
val code = byte.toInt() and 0xFF
|
||||
val ch = code.toChar()
|
||||
if (ch in UNRESERVED) {
|
||||
sb.append(ch)
|
||||
} else {
|
||||
sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||
|
||||
@Serializable
|
||||
private data class HookDecisionBody(val sessionId: String, val decision: String, val token: String)
|
||||
|
||||
@Serializable
|
||||
private data class FcmTokenBody(val token: String)
|
||||
|
||||
@Serializable
|
||||
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
|
||||
|
||||
@Serializable
|
||||
private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)
|
||||
|
||||
@Serializable
|
||||
private data class PruneBody(val path: String)
|
||||
|
||||
@Serializable
|
||||
private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)
|
||||
|
||||
@Serializable
|
||||
private data class CommitBody(val path: String, val message: String)
|
||||
|
||||
@Serializable
|
||||
private data class PushBody(val path: String)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
/**
|
||||
* Client-side FCM registration-token validator (validate at the boundary, plan §4). Deliberately
|
||||
* LOOSE, per plan §4.5: non-empty, bounded length, `base64url` charset plus `:` — the FCM token
|
||||
* format/length is undocumented and changes, so a strict length regex risks rejecting valid tokens.
|
||||
*
|
||||
* Unlike iOS's APNs hex rule, FCM tokens are case-sensitive → NOT lowercased. [normalize] returns
|
||||
* the token unchanged when valid, or `null` (→ `ApiClientError.InvalidFcmToken` before any I/O).
|
||||
*/
|
||||
internal object FcmTokenRule {
|
||||
/** Generous headroom bound; real tokens are ~150–250 chars but the ceiling is undocumented. */
|
||||
private const val MAX_LENGTH = 4096
|
||||
|
||||
/** base64url alphabet (`A–Z a–z 0–9 - _`) plus the `:` that appears in FCM tokens. */
|
||||
private val ALLOWED: Set<Char> =
|
||||
(('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', ':')).toSet()
|
||||
|
||||
fun normalize(raw: String): String? {
|
||||
if (raw.isEmpty() || raw.length > MAX_LENGTH) return null
|
||||
if (!raw.all { it in ALLOWED }) return null
|
||||
return raw
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.Signature
|
||||
import java.security.interfaces.ECPublicKey
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
|
||||
/**
|
||||
* B4 · Proves the manual PKCS#10 encoder produces a well-formed, self-signed P-256 CSR that the
|
||||
* control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1 SPKI, ecdsa-with-SHA256
|
||||
* self-signature) accepts. Runs headless with a SOFTWARE P-256 key via the SAME
|
||||
* `Signature("SHA256withECDSA")` path the on-device AndroidKeyStore key uses — so the signing path
|
||||
* is byte-identical. Real StrongBox keygen is device-only (`:client-tls-android`).
|
||||
*/
|
||||
class CertificateSigningRequestTest {
|
||||
/** Software P-256 signer via the SAME JCA `SHA256withECDSA` path used on-device (no StrongBox). */
|
||||
private class SoftwareEcSigner : CsrSigner {
|
||||
val keyPair = KeyPairGenerator.getInstance("EC").apply {
|
||||
initialize(ECGenParameterSpec("secp256r1"))
|
||||
}.generateKeyPair()
|
||||
|
||||
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(keyPair.public as ECPublicKey)
|
||||
|
||||
override fun sign(message: ByteArray): ByteArray =
|
||||
Signature.getInstance("SHA256withECDSA").apply {
|
||||
initSign(keyPair.private)
|
||||
update(message)
|
||||
}.sign()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun csrIsCanonicalPkcs10SequenceOfExactlyThreeElements() {
|
||||
val signer = SoftwareEcSigner()
|
||||
|
||||
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||
|
||||
val outer = TestDer.read(der, 0)!!
|
||||
assertEquals(0x30, outer.tag, "outer CertificationRequest is a SEQUENCE")
|
||||
assertEquals(der.size, outer.end, "no trailing garbage after the CSR")
|
||||
val parts = TestDer.children(der, outer)
|
||||
assertEquals(3, parts.size)
|
||||
assertEquals(0x30, parts[0].tag) // certificationRequestInfo
|
||||
assertEquals(0x30, parts[1].tag) // signatureAlgorithm
|
||||
assertEquals(0x03, parts[2].tag) // signature BIT STRING
|
||||
}
|
||||
|
||||
@Test
|
||||
fun csrSelfSignatureVerifiesAgainstTheEmbeddedP256Key() {
|
||||
val signer = SoftwareEcSigner()
|
||||
|
||||
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||
|
||||
// Extract the exact CertificationRequestInfo bytes that were signed and the ECDSA signature
|
||||
// (the same crypto check verifyCsrPoPEc's req.verify() runs).
|
||||
val outer = TestDer.read(der, 0)!!
|
||||
val parts = TestDer.children(der, outer)
|
||||
val infoBytes = der.copyOfRange(parts[0].start, parts[0].end)
|
||||
val bitString = parts[2] // BIT STRING: first content byte is unused-bits (0x00)
|
||||
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
|
||||
|
||||
val ok = Signature.getInstance("SHA256withECDSA").apply {
|
||||
initVerify(signer.keyPair.public)
|
||||
update(infoBytes)
|
||||
}.verify(signature)
|
||||
assertTrue(ok, "the CSR self-signature must verify against its own SubjectPublicKeyInfo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun csrEmbedsAP256SubjectPublicKeyInfoTheServerVerifierAccepts() {
|
||||
val signer = SoftwareEcSigner()
|
||||
val point = signer.publicKeyX963()
|
||||
|
||||
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||
|
||||
val outer = TestDer.read(der, 0)!!
|
||||
val info = TestDer.children(der, outer)[0]
|
||||
val infoChildren = TestDer.children(der, info)
|
||||
assertEquals(4, infoChildren.size)
|
||||
// version v1(0)
|
||||
assertArrayEquals(byteArrayOf(0x02, 0x01, 0x00), der.copyOfRange(infoChildren[0].start, infoChildren[0].end))
|
||||
assertEquals(0xA0, infoChildren[3].tag) // [0] IMPLICIT attributes
|
||||
assertEquals(0, infoChildren[3].valueEnd - infoChildren[3].valueStart) // empty SET
|
||||
|
||||
// subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point }
|
||||
val spki = infoChildren[2]
|
||||
val spkiChildren = TestDer.children(der, spki)
|
||||
assertEquals(2, spkiChildren.size)
|
||||
val algIdChildren = TestDer.children(der, spkiChildren[0])
|
||||
// AlgorithmIdentifier { id-ecPublicKey, prime256v1 } — the exact OIDs verifyCsrPoPEc pins.
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x06, 0x07, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01),
|
||||
der.copyOfRange(algIdChildren[0].start, algIdChildren[0].end),
|
||||
)
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07),
|
||||
der.copyOfRange(algIdChildren[1].start, algIdChildren[1].end),
|
||||
)
|
||||
// BIT STRING content = 0x00 unused-bits + the exact 65-byte point.
|
||||
val bitString = spkiChildren[1]
|
||||
assertEquals(0x03, bitString.tag)
|
||||
assertEquals(0x00, der[bitString.valueStart].toInt() and 0xFF)
|
||||
assertArrayEquals(point, der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatureAlgorithmIsEcdsaWithSha256() {
|
||||
val signer = SoftwareEcSigner()
|
||||
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||
val outer = TestDer.read(der, 0)!!
|
||||
val algId = TestDer.children(der, outer)[1]
|
||||
val oid = TestDer.children(der, algId)[0]
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02),
|
||||
der.copyOfRange(oid.start, oid.end),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subjectCommonNameIsEncodedAsUtf8String() {
|
||||
val signer = SoftwareEcSigner()
|
||||
val der = CertificateSigningRequest.der("my-pixel", signer)
|
||||
val outer = TestDer.read(der, 0)!!
|
||||
val info = TestDer.children(der, outer)[0]
|
||||
val name = TestDer.children(der, info)[1] // subject Name
|
||||
val rdn = TestDer.children(der, name)[0] // SET
|
||||
val attr = TestDer.children(der, rdn)[0] // SEQUENCE { OID, value }
|
||||
val attrChildren = TestDer.children(der, attr)
|
||||
// OID 2.5.4.3 (commonName), then a UTF8String (tag 0x0C) carrying the CN bytes.
|
||||
assertArrayEquals(byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03), der.copyOfRange(attrChildren[0].start, attrChildren[0].end))
|
||||
assertEquals(0x0C, attrChildren[1].tag)
|
||||
assertArrayEquals("my-pixel".toByteArray(), der.copyOfRange(attrChildren[1].valueStart, attrChildren[1].valueEnd))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptySubjectCommonNameIsRejected() {
|
||||
val signer = SoftwareEcSigner()
|
||||
assertThrows(CsrException.InvalidSubject::class.java) {
|
||||
CertificateSigningRequest.der("", signer)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNon65BytePublicKeyIsRejected() {
|
||||
val badSigner = object : CsrSigner {
|
||||
override fun publicKeyX963(): ByteArray = ByteArray(64) { 0x04 } // wrong length
|
||||
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
|
||||
}
|
||||
assertThrows(CsrException.InvalidPublicKey::class.java) {
|
||||
CertificateSigningRequest.der("d", badSigner)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPublicKeyWithoutTheUncompressedPrefixIsRejected() {
|
||||
val badSigner = object : CsrSigner {
|
||||
override fun publicKeyX963(): ByteArray = ByteArray(65) { 0x02 } // right length, wrong prefix
|
||||
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
|
||||
}
|
||||
assertThrows(CsrException.InvalidPublicKey::class.java) {
|
||||
CertificateSigningRequest.der("d", badSigner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A throwaway canonical-DER reader for structural assertions (the enroll path itself does no DER
|
||||
* parsing — the server verifies; this mirrors the iOS `TestDER` test helper).
|
||||
*/
|
||||
internal object TestDer {
|
||||
data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) {
|
||||
val end: Int get() = valueEnd
|
||||
}
|
||||
|
||||
fun read(bytes: ByteArray, start: Int): Element? {
|
||||
if (start < 0 || start + 1 >= bytes.size) return null
|
||||
val tag = bytes[start].toInt() and 0xFF
|
||||
var index = start + 1
|
||||
val first = bytes[index].toInt() and 0xFF
|
||||
index += 1
|
||||
var length = 0
|
||||
if (first and 0x80 == 0) {
|
||||
length = first
|
||||
} else {
|
||||
val count = first and 0x7F
|
||||
if (count == 0 || count > 4 || index + count > bytes.size) return null
|
||||
repeat(count) {
|
||||
length = (length shl 8) or (bytes[index].toInt() and 0xFF)
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
val valueEnd = index + length
|
||||
if (valueEnd > bytes.size) return null
|
||||
return Element(tag = tag, start = start, valueStart = index, valueEnd = valueEnd)
|
||||
}
|
||||
|
||||
fun children(bytes: ByteArray, parent: Element): List<Element> {
|
||||
val elements = mutableListOf<Element>()
|
||||
var index = parent.valueStart
|
||||
while (index < parent.valueEnd) {
|
||||
val element = read(bytes, index) ?: break
|
||||
elements.add(element)
|
||||
index = element.valueEnd
|
||||
}
|
||||
return elements
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import java.time.Instant
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* B4 · DeviceEnrollmentClient request-building + response-mapping against the pinned login/enroll
|
||||
* contract, driven by the shared `FakeHttpTransport` (no network). Mirrors the iOS
|
||||
* `DeviceEnrollmentClientTests`, extended with the login step.
|
||||
*/
|
||||
class DeviceEnrollmentClientTest {
|
||||
private companion object {
|
||||
const val BASE = "https://cp.terminal.yaojia.wang"
|
||||
const val BEARER = "device-enroll-token-abc"
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = DeviceEnrollmentClient(BASE, transport)
|
||||
|
||||
private fun bodyObject(request: HttpRequest): JsonObject =
|
||||
Json.parseToJsonElement(request.body!!.decodeToString()) as JsonObject
|
||||
|
||||
private fun enrollResponse(
|
||||
deviceId: String = "dev-1",
|
||||
cert: ByteArray = byteArrayOf(0x30, 0x01, 0x02),
|
||||
caChain: List<ByteArray> = listOf(byteArrayOf(0x30, 0xAA.toByte())),
|
||||
notBefore: String = "2026-07-08T00:00:00.000Z",
|
||||
notAfter: String = "2026-10-06T00:00:00.000Z",
|
||||
renewAfter: String = "2026-09-05T00:00:00.000Z",
|
||||
): ByteArray {
|
||||
val b64 = Base64.getEncoder()
|
||||
val chainJson = caChain.joinToString(",") { "\"${b64.encodeToString(it)}\"" }
|
||||
return """
|
||||
{"deviceId":"$deviceId","cert":"${b64.encodeToString(cert)}","caChain":[$chainJson],
|
||||
"notBefore":"$notBefore","notAfter":"$notAfter","renewAfter":"$renewAfter"}
|
||||
""".trimIndent().toByteArray()
|
||||
}
|
||||
|
||||
// ── login ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun loginPostsPasswordAndMapsThe201Bearer() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST,
|
||||
url = "$BASE/auth/login",
|
||||
status = 201,
|
||||
body = """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray(),
|
||||
)
|
||||
|
||||
val result = client.login("hunter2")
|
||||
|
||||
val request = transport.recordedRequests.single()
|
||||
assertEquals(HttpMethod.POST, request.method)
|
||||
assertEquals("$BASE/auth/login", request.url)
|
||||
assertEquals("application/json", request.headers["Content-Type"])
|
||||
assertNull(request.headers["Authorization"], "login carries no bearer")
|
||||
assertEquals("hunter2", bodyObject(request)["password"]!!.jsonPrimitive.content)
|
||||
|
||||
assertEquals("tok-xyz", result.enrollToken)
|
||||
assertEquals("acct-1", result.accountId)
|
||||
assertEquals(600L, result.expiresInSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginRejectsAnEmptyPasswordBeforeAnyNetworkIo() = runTest {
|
||||
val error = runCatching { client.login("") }.exceptionOrNull()
|
||||
assertEquals(DeviceEnrollmentError.InvalidRequest, error)
|
||||
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty password")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginSurfacesA401AsHttpWithTheServerCode() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST,
|
||||
url = "$BASE/auth/login",
|
||||
status = 401,
|
||||
body = """{"error":"rejected"}""".toByteArray(),
|
||||
)
|
||||
val error = runCatching { client.login("wrong") }.exceptionOrNull()
|
||||
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
|
||||
}
|
||||
|
||||
// ── enroll ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun enrollBuildsABearerAuthenticatedPostWithTheContractBody() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse(),
|
||||
)
|
||||
val csr = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
|
||||
|
||||
client.enroll(BEARER, csr, subdomain = "alice", deviceName = "Alice Pixel")
|
||||
|
||||
val request = transport.recordedRequests.single()
|
||||
assertEquals(HttpMethod.POST, request.method)
|
||||
assertEquals("$BASE/device/enroll", request.url)
|
||||
assertEquals("Bearer $BEARER", request.headers["Authorization"])
|
||||
assertEquals("application/json", request.headers["Content-Type"])
|
||||
|
||||
val obj = bodyObject(request)
|
||||
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
||||
assertEquals("ec-p256", obj["keyAlg"]!!.jsonPrimitive.content)
|
||||
assertEquals("alice", obj["subdomain"]!!.jsonPrimitive.content)
|
||||
assertEquals("Alice Pixel", obj["deviceName"]!!.jsonPrimitive.content)
|
||||
assertFalse(obj.containsKey("attestation"), "attestation is omitted when not provided")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollForwardsAttestationWhenProvided() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
|
||||
client.enroll(BEARER, byteArrayOf(0x01), "a", "d", attestation = "attest-blob")
|
||||
assertEquals("attest-blob", bodyObject(transport.recordedRequests.single())["attestation"]!!.jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollMapsA201IntoATypedResult() = runTest {
|
||||
val cert = byteArrayOf(0x30, 0x82.toByte(), 0x01, 0x23)
|
||||
val ca = byteArrayOf(0x30, 0x82.toByte(), 0x02, 0x00)
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
||||
body = enrollResponse(deviceId = "dev-xyz", cert = cert, caChain = listOf(ca)),
|
||||
)
|
||||
|
||||
val result = client.enroll(BEARER, byteArrayOf(0x01), "alice", "Pixel")
|
||||
|
||||
assertEquals("dev-xyz", result.deviceId)
|
||||
assertArrayEquals(cert, result.certificate)
|
||||
assertEquals(1, result.caChain.size)
|
||||
assertArrayEquals(ca, result.caChain.single())
|
||||
assertTrue(result.renewAfter!!.isBefore(result.notAfter))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollRejectsEmptyRequiredFieldsBeforeAnyNetworkIo() = runTest {
|
||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll("", byteArrayOf(1), "a", "d") }.exceptionOrNull())
|
||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, ByteArray(0), "a", "d") }.exceptionOrNull())
|
||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "", "d") }.exceptionOrNull())
|
||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "") }.exceptionOrNull())
|
||||
assertTrue(transport.recordedRequests.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollSurfacesA403SubdomainNotOwnedWithTheServerCode() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 403,
|
||||
body = """{"error":"rejected"}""".toByteArray(),
|
||||
)
|
||||
assertEquals(
|
||||
DeviceEnrollmentError.Http(403, "rejected"),
|
||||
runCatching { client.enroll(BEARER, byteArrayOf(1), "bob", "d") }.exceptionOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollSurfacesA429RateLimited() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 429,
|
||||
body = """{"error":"rate_limited"}""".toByteArray(),
|
||||
)
|
||||
assertEquals(
|
||||
DeviceEnrollmentError.Http(429, "rate_limited"),
|
||||
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollThrowsMalformedResponseOnANonJson201Body() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = "not json".toByteArray())
|
||||
assertEquals(
|
||||
DeviceEnrollmentError.MalformedResponse,
|
||||
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollThrowsMalformedResponseWhenTheCertIsNotValidBase64() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
||||
body = """{"deviceId":"d","cert":"@@not-base64@@","caChain":[]}""".toByteArray(),
|
||||
)
|
||||
assertEquals(
|
||||
DeviceEnrollmentError.MalformedResponse,
|
||||
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollDegradesAbsentDatesToNull() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
||||
body = """{"deviceId":"d","cert":"MAEC","caChain":[]}""".toByteArray(),
|
||||
)
|
||||
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
|
||||
assertNull(result.notAfter)
|
||||
assertNull(result.renewAfter)
|
||||
assertFalse(result.isRenewalDue(Instant.parse("2030-01-01T00:00:00Z")), "absent renewAfter never triggers")
|
||||
}
|
||||
|
||||
// ── renew (silent rotation seam — mTLS-only, NO bearer) ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun renewTargetsDeviceIdRenewWithTheMinimalBodyAndNoAuthorizationHeader() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
|
||||
)
|
||||
val csr = byteArrayOf(0x02)
|
||||
|
||||
// Production renew passes NO bearer — the endpoint authenticates by the current device cert (mTLS).
|
||||
val result = client.renew("dev-9", csr)
|
||||
|
||||
val request = transport.recordedRequests.single()
|
||||
assertEquals("$BASE/device/dev-9/renew", request.url)
|
||||
assertNull(request.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
|
||||
val obj = bodyObject(request)
|
||||
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
||||
// The server's /device/:id/renew authenticates by the presented mTLS device cert and its body
|
||||
// schema is `{ csr }` ONLY (.strict()) — any enroll-only extra (keyAlg/subdomain/deviceName)
|
||||
// is rejected. The renew wire body must therefore carry the single `csr` key and nothing else.
|
||||
assertEquals(setOf("csr"), obj.keys, "renew body is {csr}-only — no keyAlg/subdomain/deviceName")
|
||||
assertFalse(obj.containsKey("keyAlg"), "renew must not send the enroll-only keyAlg field")
|
||||
assertFalse(obj.containsKey("subdomain"), "renew body carries no subdomain/deviceName")
|
||||
assertEquals("dev-9", result.deviceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renewForwardsAnExplicitBearerWhenTheOptionalSeamIsUsed() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
|
||||
)
|
||||
|
||||
// The bearer is optional/absent by default; when a caller DOES pass one it rides as a header.
|
||||
client.renew("dev-9", byteArrayOf(0x02), bearerToken = BEARER)
|
||||
|
||||
assertEquals("Bearer $BEARER", transport.recordedRequests.single().headers["Authorization"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renewRejectsEmptyDeviceIdOrCsrBeforeAnyNetworkIo() = runTest {
|
||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", byteArrayOf(1)) }.exceptionOrNull())
|
||||
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("d", ByteArray(0)) }.exceptionOrNull())
|
||||
assertTrue(transport.recordedRequests.isEmpty())
|
||||
}
|
||||
|
||||
// ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun isRenewalDueFlipsAtRenewAfter() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
|
||||
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
|
||||
assertFalse(result.isRenewalDue(Instant.parse("2026-09-04T00:00:00Z")))
|
||||
assertTrue(result.isRenewalDue(Instant.parse("2026-09-06T00:00:00Z")))
|
||||
}
|
||||
|
||||
private fun assertArrayEquals(expected: ByteArray, actual: ByteArray) =
|
||||
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.interfaces.ECPublicKey
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
|
||||
/** B4 · The X9.63 uncompressed-point encoder — the security-load-bearing SubjectPublicKeyInfo bytes. */
|
||||
class EcPointEncodingTest {
|
||||
@Test
|
||||
fun encodesAGeneratedP256KeyAs65UncompressedBytesRoundTrippingToTheCoordinates() {
|
||||
val kp = KeyPairGenerator.getInstance("EC").apply {
|
||||
initialize(ECGenParameterSpec("secp256r1"))
|
||||
}.generateKeyPair()
|
||||
val pub = kp.public as ECPublicKey
|
||||
|
||||
val encoded = EcPointEncoding.x963(pub)
|
||||
|
||||
assertEquals(65, encoded.size, "0x04 || X(32) || Y(32)")
|
||||
assertEquals(0x04, encoded[0].toInt() and 0xFF, "uncompressed-point prefix")
|
||||
// The 32-byte big-endian halves must be exactly the affine coordinates.
|
||||
val x = BigInteger(1, encoded.copyOfRange(1, 33))
|
||||
val y = BigInteger(1, encoded.copyOfRange(33, 65))
|
||||
assertEquals(pub.w.affineX, x)
|
||||
assertEquals(pub.w.affineY, y)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leftPadsAShortCoordinateToTheFixedWidth() {
|
||||
// A small value must be left-padded with leading zeros to exactly 32 bytes.
|
||||
val padded = EcPointEncoding.toFixedLengthUnsigned(BigInteger.valueOf(1), 32)
|
||||
assertEquals(32, padded.size)
|
||||
assertEquals(1, padded[31].toInt())
|
||||
assertEquals(0, padded[0].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsTheBigIntegerSignByteWhenPresent() {
|
||||
// A value whose top bit is set carries a leading 0x00 sign byte in BigInteger.toByteArray();
|
||||
// it must be dropped, not counted toward the width.
|
||||
val highBit = BigInteger(1, ByteArray(32) { 0xFF.toByte() })
|
||||
val encoded = EcPointEncoding.toFixedLengthUnsigned(highBit, 32)
|
||||
assertEquals(32, encoded.size)
|
||||
assertEquals(0xFF, encoded[0].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsACoordinateThatDoesNotFit() {
|
||||
val tooBig = BigInteger.ONE.shiftLeft(256) // needs 33 bytes
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
EcPointEncoding.toFixedLengthUnsigned(tooBig, 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* GitLogResult list-lossy decode (plan Phase A.2): a well-formed `{commits,truncated}` decodes; a
|
||||
* commit missing `hash`/`at` is dropped while its siblings survive; `truncated` passes through; a
|
||||
* subject-less commit still decodes (subject defaults to empty).
|
||||
*/
|
||||
class GitLogTest {
|
||||
|
||||
private fun decode(json: String): GitLogResult? =
|
||||
LossyDecode.objectOrNull(json.toByteArray(), GitLogResult.serializer())
|
||||
|
||||
@Test
|
||||
fun `decodes commits and truncated`() {
|
||||
val json = """
|
||||
{ "truncated": true, "commits": [
|
||||
{ "hash":"abc123", "at": 1710000000000, "subject":"first" },
|
||||
{ "hash":"def456", "at": 1710000005000, "subject":"second" }
|
||||
] }
|
||||
""".trimIndent()
|
||||
|
||||
val result = decode(json)!!
|
||||
assertTrue(result.truncated)
|
||||
assertEquals(2, result.commits.size)
|
||||
assertEquals("abc123", result.commits[0].hash)
|
||||
assertEquals(1710000000000L, result.commits[0].at)
|
||||
assertEquals("first", result.commits[0].subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops a commit missing hash or at, keeping the rest`() {
|
||||
val json = """
|
||||
{ "truncated": false, "commits": [
|
||||
{ "at": 1, "subject":"no hash" },
|
||||
{ "hash":"keep", "at": 2, "subject":"kept" },
|
||||
{ "hash":"noAt", "subject":"no at" }
|
||||
] }
|
||||
""".trimIndent()
|
||||
|
||||
val result = decode(json)!!
|
||||
assertFalse(result.truncated)
|
||||
assertEquals(1, result.commits.size)
|
||||
assertEquals("keep", result.commits.single().hash)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subject-less commit still decodes with an empty subject`() {
|
||||
val result = decode("""{ "commits":[ { "hash":"h", "at": 5 } ] }""")!!
|
||||
assertEquals(1, result.commits.size)
|
||||
assertEquals("", result.commits.single().subject)
|
||||
assertFalse(result.truncated) // default
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-object body degrades to null`() {
|
||||
org.junit.jupiter.api.Assertions.assertNull(decode("[]"))
|
||||
org.junit.jupiter.api.Assertions.assertNull(decode("garbage"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* GitWrite payload + error decode (plan Phase A.3): each 200 payload decodes; a failure body
|
||||
* `{ok:false,error:"…"}` (git-ops) and `{error:"…"}` (worktrees) both yield the SAFE `error` string;
|
||||
* a garbled 200 body degrades to the payload defaults (never throws). The empty-sha commit case is
|
||||
* exercised (server can return `{ok:true, commit:""}`).
|
||||
*/
|
||||
class GitWriteTest {
|
||||
|
||||
@Test
|
||||
fun `stage payload decodes staged and count`() {
|
||||
val r = decodeGitPayload("""{"ok":true,"staged":true,"count":3}""".toByteArray(), StageResult.serializer())
|
||||
assertEquals(StageResult(staged = true, count = 3), r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit payload decodes the sha and tolerates an empty sha`() {
|
||||
assertEquals("a1b2c3", decodeGitPayload("""{"ok":true,"commit":"a1b2c3"}""".toByteArray(), CommitResult.serializer()).commit)
|
||||
assertEquals("", decodeGitPayload("""{"ok":true,"commit":""}""".toByteArray(), CommitResult.serializer()).commit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push payload decodes branch and remote`() {
|
||||
val r = decodeGitPayload("""{"ok":true,"branch":"main","remote":"origin"}""".toByteArray(), PushResult.serializer())
|
||||
assertEquals("main", r.branch)
|
||||
assertEquals("origin", r.remote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worktree create and remove and prune payloads decode`() {
|
||||
val create = decodeGitPayload("""{"ok":true,"path":"/wt/x","branch":"feat"}""".toByteArray(), CreateWorktreeResult.serializer())
|
||||
assertEquals("/wt/x", create.path)
|
||||
assertEquals("feat", create.branch)
|
||||
|
||||
val remove = decodeGitPayload("""{"ok":true,"path":"/wt/x"}""".toByteArray(), RemoveWorktreeResult.serializer())
|
||||
assertEquals("/wt/x", remove.path)
|
||||
|
||||
val prune = decodeGitPayload("""{"ok":true,"pruned":["a","b"]}""".toByteArray(), PruneWorktreesResult.serializer())
|
||||
assertEquals(listOf("a", "b"), prune.pruned)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a garbled 200 body degrades to payload defaults, never throwing`() {
|
||||
assertEquals(StageResult(), decodeGitPayload("not json".toByteArray(), StageResult.serializer()))
|
||||
assertEquals(CommitResult(), decodeGitPayload("[]".toByteArray(), CommitResult.serializer()))
|
||||
assertTrue(decodeGitPayload("{}".toByteArray(), PruneWorktreesResult.serializer()).pruned.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a git-ops failure body yields the safe error string`() {
|
||||
assertEquals("Nothing to commit.", decodeGitError("""{"ok":false,"error":"Nothing to commit."}""".toByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a worktree failure body (no ok field) still yields the error string`() {
|
||||
assertEquals("Worktree creation is disabled.", decodeGitError("""{"error":"Worktree creation is disabled."}""".toByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty or errorless failure body yields null`() {
|
||||
assertNull(decodeGitError(ByteArray(0)))
|
||||
assertNull(decodeGitError("""{"ok":false}""".toByteArray()))
|
||||
assertNull(decodeGitError("not json".toByteArray()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* PrStatus tolerant decode (plan Phase A.1): a full `availability:"ok"` body decodes every field; an
|
||||
* unknown/missing `availability` degrades to [PrAvailability.ERROR] (never throws); `PrCheckSummary`
|
||||
* counts round-trip; a non-object body degrades rather than crashing. Mirrors the FE never treating a
|
||||
* non-`ok` availability as an HTTP error.
|
||||
*/
|
||||
class PrStatusTest {
|
||||
|
||||
private fun decode(json: String): PrStatus? =
|
||||
LossyDecode.objectOrNull(json.toByteArray(), PrStatus.serializer())
|
||||
|
||||
@Test
|
||||
fun `decodes a full ok body with all fields and check counts`() {
|
||||
val json = """
|
||||
{ "availability":"ok", "number":42, "title":"Add worktrees", "url":"https://x/pull/42",
|
||||
"state":"open", "isDraft":false, "mergeable":"mergeable",
|
||||
"headRefName":"feat/wt", "baseRefName":"main",
|
||||
"checks": { "total":5, "passing":3, "failing":1, "pending":1 } }
|
||||
""".trimIndent()
|
||||
|
||||
val pr = decode(json)!!
|
||||
assertEquals(PrAvailability.OK, pr.availability)
|
||||
assertEquals(42, pr.number)
|
||||
assertEquals("Add worktrees", pr.title)
|
||||
assertEquals("https://x/pull/42", pr.url)
|
||||
assertEquals("open", pr.state)
|
||||
assertEquals(false, pr.isDraft)
|
||||
assertEquals("mergeable", pr.mergeable)
|
||||
assertEquals("feat/wt", pr.headRefName)
|
||||
assertEquals("main", pr.baseRefName)
|
||||
assertEquals(PrCheckSummary(total = 5, passing = 3, failing = 1, pending = 1), pr.checks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown availability degrades to ERROR, never throwing`() {
|
||||
val pr = decode("""{ "availability":"quantum-flux" }""")!!
|
||||
assertEquals(PrAvailability.ERROR, pr.availability)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a body missing availability defaults to ERROR and leaves optional fields null`() {
|
||||
val pr = decode("""{ "number":7 }""")!!
|
||||
assertEquals(PrAvailability.ERROR, pr.availability)
|
||||
assertEquals(7, pr.number)
|
||||
assertNull(pr.title)
|
||||
assertNull(pr.checks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each known availability maps from its wire value`() {
|
||||
assertEquals(PrAvailability.NO_PR, PrAvailability.fromWire("no-pr"))
|
||||
assertEquals(PrAvailability.NOT_INSTALLED, PrAvailability.fromWire("not-installed"))
|
||||
assertEquals(PrAvailability.UNAUTHENTICATED, PrAvailability.fromWire("unauthenticated"))
|
||||
assertEquals(PrAvailability.DISABLED, PrAvailability.fromWire("disabled"))
|
||||
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("error"))
|
||||
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("")) // empty → ERROR
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-object body degrades to null rather than throwing`() {
|
||||
assertNull(decode("[]"))
|
||||
assertNull(decode("not json"))
|
||||
assertNull(LossyDecode.objectOrNull(ByteArray(0), PrStatus.serializer()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown top-level keys are ignored`() {
|
||||
val pr = decode("""{ "availability":"ok", "futureField":123, "nested":{"a":1} }""")!!
|
||||
assertEquals(PrAvailability.OK, pr.availability)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The prefs round-trip correctness trap: `PUT /prefs` replaces the WHOLE blob, so decode → mutate
|
||||
* one known key → encode MUST carry every unknown top-level key through verbatim (including exact
|
||||
* integer formatting), or an Android write clobbers web/iOS/future-server prefs.
|
||||
*/
|
||||
class UiPrefsTest {
|
||||
@Test
|
||||
fun sanitizesFavouritesAndCollapsedLikeTheWebClient() {
|
||||
val prefs = UiPrefs.decode(
|
||||
"""
|
||||
{"favourites":["/a","/b","/a","",123],"collapsed":{"g1":true,"g2":false,"g3":"true","":true}}
|
||||
""".trimIndent().toByteArray(),
|
||||
)!!
|
||||
|
||||
// favourites: non-empty strings only, de-duplicated, order preserved; the number 123 dropped.
|
||||
assertEquals(listOf("/a", "/b"), prefs.favourites)
|
||||
// collapsed: only literal `true`; false, string "true", and the empty key are all dropped.
|
||||
assertEquals(mapOf("g1" to true), prefs.collapsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mutatingOneKeyPreservesUnknownKeysAndIntegerFormatting() {
|
||||
val original = UiPrefs.decode(
|
||||
"""
|
||||
{"favourites":["/old"],"collapsed":{"g":true},"schemaVersion":7,"vendor":{"nested":42,"ratio":1.5}}
|
||||
""".trimIndent().toByteArray(),
|
||||
)!!
|
||||
|
||||
val mutated = original.withFavourites(listOf("/new"))
|
||||
val encoded = mutated.encodeBody().decodeToString()
|
||||
|
||||
// Known key was replaced...
|
||||
assertEquals(listOf("/new"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
|
||||
// ...collapsed (untouched known key) survived...
|
||||
assertEquals(mapOf("g" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
|
||||
// ...and every unknown key survived verbatim, integers still integers (42, not 42.0).
|
||||
assertTrue(encoded.contains("\"schemaVersion\":7"), "unknown scalar key must round-trip: $encoded")
|
||||
assertTrue(encoded.contains("\"nested\":42"), "nested integer must not become 42.0: $encoded")
|
||||
assertTrue(encoded.contains("\"ratio\":1.5"), "nested double must round-trip: $encoded")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withCollapsedReplacesOnlyThatKey() {
|
||||
val original = UiPrefs.decode("""{"favourites":["/keep"],"extra":"x"}""".toByteArray())!!
|
||||
val encoded = original.withCollapsed(mapOf("ns" to true)).encodeBody().decodeToString()
|
||||
|
||||
assertEquals(listOf("/keep"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
|
||||
assertEquals(mapOf("ns" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
|
||||
assertTrue(encoded.contains("\"extra\":\"x\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createBuildsAFreshBlobWithBothKnownKeys() {
|
||||
val encoded = UiPrefs.create(favourites = listOf("/a"), collapsed = mapOf("g" to true))
|
||||
.encodeBody().decodeToString()
|
||||
assertEquals("""{"favourites":["/a"],"collapsed":{"g":true}}""", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonObjectBodyDecodesToNull() {
|
||||
assertNull(UiPrefs.decode("[]".toByteArray()))
|
||||
assertNull(UiPrefs.decode("\"hi\"".toByteArray()))
|
||||
assertNull(UiPrefs.decode("not json".toByteArray()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.wire.HostClassifier
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HostNetworkTier
|
||||
|
||||
/** Ports the tier table of iOS `HostClassificationTests` (plan §5.4, fail-safe unknown→public). */
|
||||
class HostClassifierTest {
|
||||
@Test
|
||||
fun loopbackHostsClassifyAsLoopback() {
|
||||
val hosts = listOf("localhost", "LOCALHOST", "127.0.0.1", "127.5.9.200", "::1", "[::1]")
|
||||
hosts.forEach { assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(it), it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rfc1918AndLinkLocalAndMdnsClassifyAsPrivateLan() {
|
||||
val hosts = listOf(
|
||||
"10.0.0.5", "10.255.255.255",
|
||||
"172.16.0.1", "172.20.10.1", "172.31.255.255",
|
||||
"192.168.0.9", "192.168.1.1",
|
||||
"169.254.1.1",
|
||||
"mac-mini.local", "MAC-MINI.LOCAL", "printer.local",
|
||||
)
|
||||
hosts.forEach { assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(it), it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cgnatAndMagicDnsClassifyAsTailscale() {
|
||||
val hosts = listOf(
|
||||
"100.64.0.1", "100.100.1.1", "100.127.255.255",
|
||||
"mac.tailnet.ts.net", "MAC.TAILNET.TS.NET", "host.ts.net",
|
||||
)
|
||||
hosts.forEach { assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(it), it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everythingElseFailsSafeToPublic() {
|
||||
val hosts = listOf(
|
||||
// routable public IPs
|
||||
"8.8.8.8", "203.0.113.7",
|
||||
// hostnames
|
||||
"example.com", "claude.ai",
|
||||
// boundary-miss IPv4 (just outside the private/tailscale ranges)
|
||||
"172.15.0.1", "172.32.0.1", "100.63.0.1", "100.128.0.1",
|
||||
// malformed / out-of-range / wrong arity → fail-safe public
|
||||
"256.1.1.1", "1.2.3", "999.999.999.999", "not a host", "",
|
||||
// non-loopback IPv6 (ULA / documentation) → fail-safe public (iOS v1 scope)
|
||||
"fd00::1", "2001:db8::1",
|
||||
)
|
||||
hosts.forEach { assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(it), it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifyEndpointDelegatesToHost() {
|
||||
val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
|
||||
val tailscale = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
|
||||
val public = requireNotNull(HostEndpoint.fromBaseUrl("https://example.com"))
|
||||
|
||||
assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(lan))
|
||||
assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(tailscale))
|
||||
assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(public))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.net.UnknownServiceException
|
||||
import java.security.cert.CertificateException
|
||||
import javax.net.ssl.SSLException
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
/** Re-derives iOS `PairingError.classify` for JVM exceptions (plan R12: drop localNetworkDenied). */
|
||||
class PairingErrorTest {
|
||||
private val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
|
||||
|
||||
@Test
|
||||
fun connectionRefusedMapsToHostUnreachable() {
|
||||
val result = PairingError.classify(ConnectException("Connection refused"), lan)
|
||||
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dnsFailureMapsToHostUnreachable() {
|
||||
val result = PairingError.classify(UnknownHostException("no such host"), lan)
|
||||
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun socketTimeoutMapsToTimeout() {
|
||||
assertEquals(PairingError.Timeout, PairingError.classify(SocketTimeoutException("read timed out"), lan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interruptedIoMapsToTimeout() {
|
||||
// OkHttp's overall call-timeout surfaces as a bare InterruptedIOException.
|
||||
assertEquals(PairingError.Timeout, PairingError.classify(InterruptedIOException("timeout"), lan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sslHandshakeFailureMapsToTlsFailure() {
|
||||
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLHandshakeException("handshake_failure"), lan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genericSslAndCertFailuresMapToTlsFailure() {
|
||||
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLException("tls"), lan))
|
||||
assertEquals(PairingError.TlsFailure, PairingError.classify(CertificateException("bad cert"), lan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cleartextBlockMapsToCleartextBlockedWithHost() {
|
||||
val err = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted")
|
||||
val result = PairingError.classify(err, lan)
|
||||
assertInstanceOf(PairingError.CleartextBlocked::class.java, result)
|
||||
assertEquals("192.168.1.5", (result as PairingError.CleartextBlocked).host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrappedCauseIsWalked() {
|
||||
// OkHttp frequently wraps the real cause; the chain walk must see it.
|
||||
val wrappedConnect = IOException("unexpected end of stream", ConnectException("refused"))
|
||||
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(wrappedConnect, lan))
|
||||
|
||||
val wrappedTls = IOException("io", SSLHandshakeException("handshake"))
|
||||
assertEquals(PairingError.TlsFailure, PairingError.classify(wrappedTls, lan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unrecognizedErrorUsesFallbackWhenProvided() {
|
||||
// Probe step ②: after ① passed, an unclassifiable connect failure is the Origin 401.
|
||||
val fallback = PairingError.OriginRejected("hint")
|
||||
assertEquals(fallback, PairingError.classify(IllegalStateException("weird"), lan, unrecognizedFallback = fallback))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unrecognizedErrorWithoutFallbackIsHostUnreachable() {
|
||||
assertInstanceOf(
|
||||
PairingError.HostUnreachable::class.java,
|
||||
PairingError.classify(IllegalStateException("weird"), lan),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun causeCycleTerminates() {
|
||||
// A→B→A cycle must not loop forever; B is a ConnectException so it classifies as reachable-failure.
|
||||
val a = IOException("a")
|
||||
val b = ConnectException("b")
|
||||
a.initCause(b)
|
||||
b.initCause(a)
|
||||
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(a, lan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun originRejectedHintDerivesFromOriginHeaderWithoutDefaultPort() {
|
||||
val lanHint = PairingError.originRejectedHint(lan)
|
||||
assertTrue(lanHint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))
|
||||
|
||||
val tls = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
|
||||
val tlsHint = PairingError.originRejectedHint(tls)
|
||||
assertTrue(tlsHint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
|
||||
assertFalse(tlsHint.contains(":443"), "default https port must be omitted (no :443 迷信)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.testsupport.FakeTermTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.net.UnknownServiceException
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Ports iOS `PairingProbeTests`: the two-step probe (① GET reachability/shape, ② WS attach→adopt→
|
||||
* kill-with-Origin), each failure mode mapped to a [PairingError], full-success leaves no orphan
|
||||
* session, and the injected-deadline timeout — all under `runTest` virtual time (zero real waits).
|
||||
*/
|
||||
class PairingProbeTest {
|
||||
private data class Fixture(
|
||||
val endpoint: HostEndpoint,
|
||||
val http: FakeHttpTransport,
|
||||
val ws: FakeTermTransport,
|
||||
val liveSessionsUrl: String,
|
||||
val killUrl: String,
|
||||
)
|
||||
|
||||
private fun fixture(base: String = BASE): Fixture {
|
||||
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(base))
|
||||
return Fixture(
|
||||
endpoint = endpoint,
|
||||
http = FakeHttpTransport(),
|
||||
ws = FakeTermTransport(),
|
||||
liveSessionsUrl = "$base/live-sessions",
|
||||
killUrl = "$base/live-sessions/$SESSION_ID",
|
||||
)
|
||||
}
|
||||
|
||||
/** Non-timeout cases use `timeout = null` so the probe never enters the race — deterministic. */
|
||||
private suspend fun runProbe(f: Fixture, timeout: Duration? = null): PairingProbeResult =
|
||||
runPairingProbeCore(f.endpoint, f.http, f.ws, timeout)
|
||||
|
||||
private fun failureError(result: PairingProbeResult): PairingError {
|
||||
assertInstanceOf(PairingProbeResult.Failure::class.java, result)
|
||||
return (result as PairingProbeResult.Failure).error
|
||||
}
|
||||
|
||||
// ── Probe ① failure branches ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun stepOneConnectionRefusedMapsToHostUnreachableAndNeverTouchesWs() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueFailure(url = f.liveSessionsUrl, error = ConnectException("refused"))
|
||||
|
||||
val result = runProbe(f)
|
||||
|
||||
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(result))
|
||||
assertTrue(f.ws.connectAttempts.isEmpty(), "probe must not touch WS when ① fails")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stepOneHtmlBodyMapsToNotWebTerminal() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "<html><body>router admin</body></html>".toByteArray())
|
||||
|
||||
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stepOne404MapsToNotWebTerminal() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, status = 404)
|
||||
|
||||
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stepOneNonArrayJsonMapsToNotWebTerminal() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "{\"ok\":true}".toByteArray())
|
||||
|
||||
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stepOneTlsFailureMapsToTlsFailure() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueFailure(url = f.liveSessionsUrl, error = SSLHandshakeException("handshake_failure"))
|
||||
|
||||
assertEquals(PairingError.TlsFailure, failureError(runProbe(f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stepOneTransportTimeoutMapsToTimeout() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueFailure(url = f.liveSessionsUrl, error = SocketTimeoutException("read timed out"))
|
||||
|
||||
assertEquals(PairingError.Timeout, failureError(runProbe(f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stepOneCleartextBlockMapsToCleartextBlockedWithHost() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueFailure(
|
||||
url = f.liveSessionsUrl,
|
||||
error = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted"),
|
||||
)
|
||||
|
||||
val error = failureError(runProbe(f))
|
||||
assertInstanceOf(PairingError.CleartextBlocked::class.java, error)
|
||||
assertEquals("192.168.1.5", (error as PairingError.CleartextBlocked).host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stepOneDnsFailureMapsToHostUnreachable() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueFailure(url = f.liveSessionsUrl, error = UnknownHostException("nxdomain"))
|
||||
|
||||
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(runProbe(f)))
|
||||
}
|
||||
|
||||
// ── Probe ② failure branches ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() = runTest {
|
||||
// ① passes; ② upgrade fails (a 401 is a shapeless connect error at the transport layer).
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||
f.ws.scriptConnectFailure()
|
||||
|
||||
val error = failureError(runProbe(f))
|
||||
assertInstanceOf(PairingError.OriginRejected::class.java, error)
|
||||
val hint = (error as PairingError.OriginRejected).hint
|
||||
assertTrue(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"), hint)
|
||||
assertFalse(hint.contains(":443"), hint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun originRejectedHintOmitsDefaultPortForHttps() = runTest {
|
||||
val f = fixture(base = "https://mac.tailnet.ts.net")
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||
f.ws.scriptConnectFailure()
|
||||
|
||||
val error = failureError(runProbe(f))
|
||||
assertInstanceOf(PairingError.OriginRejected::class.java, error)
|
||||
val hint = (error as PairingError.OriginRejected).hint
|
||||
assertTrue(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"), hint)
|
||||
assertFalse(hint.contains(":443"), hint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() = runTest {
|
||||
// A queued finish is flushed into the connection at connect → stream closes before `attached`.
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||
f.ws.finishFrames()
|
||||
|
||||
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||
}
|
||||
|
||||
// ── Full pass + kill round-trip ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun fullProbeSuccessAttachesWithNullSessionIdThenKillsWithOrigin() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
|
||||
f.ws.emit(ATTACHED_FRAME)
|
||||
|
||||
val result = runProbe(f)
|
||||
|
||||
// Success payload is the probed endpoint.
|
||||
assertEquals(PairingProbeResult.Success(f.endpoint), result)
|
||||
|
||||
// attach(null) is the only WS frame, with an explicit JSON null sessionId key.
|
||||
assertEquals(1, f.ws.sentFrames.size)
|
||||
val attach = Json.parseToJsonElement(f.ws.sentFrames.single()).jsonObject
|
||||
assertEquals("attach", attach["type"]?.jsonPrimitive?.content)
|
||||
assertTrue(attach["sessionId"] is JsonNull, "sessionId key must be present and JSON null")
|
||||
|
||||
// Exactly two HTTP requests: RO GET (NO Origin) then guarded DELETE (byte-equal Origin).
|
||||
val requests = f.http.recordedRequests
|
||||
assertEquals(2, requests.size)
|
||||
assertFalse(requests.first().headers.containsKey("Origin"), "RO GET must not carry Origin")
|
||||
val kill = requests.last()
|
||||
assertEquals(HttpMethod.DELETE, kill.method)
|
||||
assertEquals(f.killUrl, kill.url)
|
||||
assertEquals(f.endpoint.originHeader, kill.headers["Origin"])
|
||||
|
||||
// The probe never holds the connection.
|
||||
assertEquals(1, f.ws.closeCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publicWrapperHappyPathReturnsEndpoint() = runTest {
|
||||
// The public entry uses the default Tunables deadline; immediate fakes never trip it.
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
|
||||
f.ws.emit(ATTACHED_FRAME)
|
||||
|
||||
val result = runPairingProbe(f.endpoint, f.http, f.ws)
|
||||
|
||||
assertEquals(PairingProbeResult.Success(f.endpoint), result)
|
||||
assertEquals(1, f.ws.closeCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
|
||||
f.ws.emit("""{"type":"output","data":"[0mreplay"}""")
|
||||
f.ws.emit(ATTACHED_FRAME)
|
||||
|
||||
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun killRejectedByOriginGuardMapsToOriginRejected() = runTest {
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 403)
|
||||
f.ws.emit(ATTACHED_FRAME)
|
||||
|
||||
assertInstanceOf(PairingError.OriginRejected::class.java, failureError(runProbe(f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun killReturning404StillCountsAsSuccess() = runTest {
|
||||
// Session exited between attach and kill — the goal state (no orphan) is already reached.
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 404)
|
||||
f.ws.emit(ATTACHED_FRAME)
|
||||
|
||||
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
|
||||
}
|
||||
|
||||
// ── Timeout (virtual time, zero real waits) ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun probeTimesOutWhenServerNeverSendsAttached() = runTest {
|
||||
// ① passes; ② connects but the server never replies `attached` → the probe hangs on the
|
||||
// frame stream until the injected deadline cancels it.
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||
|
||||
val result = runProbe(f, timeout = 10.seconds)
|
||||
|
||||
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeoutPathAlwaysClosesTheConnectionExactlyOnce() = runTest {
|
||||
// ① passes; ② connects but the server never sends `attached` → the probe hangs on the frame
|
||||
// stream until the injected deadline cancels it MID-adopt. The connection must still close
|
||||
// (try/finally + NonCancellable), or the probe leaks the WS + its orphan session.
|
||||
val f = fixture()
|
||||
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||
|
||||
val result = runProbe(f, timeout = 10.seconds)
|
||||
|
||||
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
|
||||
assertEquals(1, f.ws.closeCallCount, "the probe must close the WS even on the timeout path")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BASE = "http://192.168.1.5:3000"
|
||||
const val SESSION_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
const val ATTACHED_FRAME = """{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
/** Status-code → typed [ApiClientError] mapping per route, plus transport errors propagating raw. */
|
||||
class ApiClientErrorMappingTest {
|
||||
private companion object {
|
||||
const val BASE = "http://h:3000"
|
||||
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
const val ID_STR = "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||
|
||||
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
|
||||
|
||||
@Test
|
||||
fun killSessionMapsNotFoundAndForbidden() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 404)
|
||||
assertEquals(ApiClientError.SessionNotFound, errorOf { client.killSession(ID) })
|
||||
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 403)
|
||||
assertEquals(ApiClientError.Forbidden, errorOf { client.killSession(ID) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hookDecisionMapsForbiddenToRejectedAnd429ToRateLimited() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 403)
|
||||
assertEquals(ApiClientError.DecisionRejected, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") })
|
||||
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 429)
|
||||
assertEquals(ApiClientError.RateLimited, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun projectDetailMapsEmptyPathAndAllServerErrorStatuses() = runTest {
|
||||
// Empty path is rejected BEFORE any I/O.
|
||||
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("") })
|
||||
assertTrue(transport.recordedRequests.isEmpty(), "empty path must not hit the network")
|
||||
|
||||
val url = "$BASE/projects/detail?path=%2Fp"
|
||||
transport.queueSuccess(url = url, status = 400)
|
||||
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("/p") })
|
||||
transport.queueSuccess(url = url, status = 404)
|
||||
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectDetail("/p") })
|
||||
transport.queueSuccess(url = url, status = 500)
|
||||
assertEquals(ApiClientError.ProjectDetailUnavailable, errorOf { client.projectDetail("/p") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putPrefsMapsForbiddenAndUnexpected() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 403)
|
||||
assertEquals(ApiClientError.Forbidden, errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) })
|
||||
|
||||
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 418)
|
||||
assertEquals(ApiClientError.UnexpectedStatus(418), errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readOnlyRoutesMapNonOkToUnexpectedOrSessionNotFound() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/live-sessions", status = 500, body = "boom".toByteArray())
|
||||
assertEquals(ApiClientError.UnexpectedStatus(500), errorOf { client.liveSessions() })
|
||||
|
||||
// preview/events/uiConfig go through requireOk: 404 → SessionNotFound.
|
||||
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/preview", status = 404)
|
||||
assertEquals(ApiClientError.SessionNotFound, errorOf { client.preview(ID) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transportLevelErrorsPropagateUnwrapped() = runTest {
|
||||
transport.queueFailure(url = "$BASE/live-sessions", error = IOException("connection refused"))
|
||||
val error = errorOf { client.liveSessions() }
|
||||
assertTrue(error is IOException)
|
||||
assertEquals("connection refused", error?.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PrAvailability
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
|
||||
/**
|
||||
* Status-code → outcome mapping for the W5 git surface (plan Phase A.5): PR 200/400/404; log
|
||||
* decode + errors; each guarded write 200→Ok, 403→Rejected(body.error), 409→Rejected, 429→
|
||||
* RateLimited. Also asserts the transport RECEIVED an Origin on writes and NOT on reads.
|
||||
*/
|
||||
class ApiClientGitTest {
|
||||
private companion object {
|
||||
const val BASE = "http://h:3000"
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||
|
||||
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
|
||||
|
||||
// ── PR (RO; degrade lives in the 200 body, not the status) ───────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `projectPr decodes a 200 degrade body and maps 400 404`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"not-installed"}""".toByteArray())
|
||||
assertEquals(PrAvailability.NOT_INSTALLED, client.projectPr("/r").availability)
|
||||
|
||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 400)
|
||||
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("/r") })
|
||||
|
||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 404)
|
||||
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectPr("/r") })
|
||||
|
||||
// Empty path is rejected before any I/O.
|
||||
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projectPr never treats a garbled 200 body as an error (degrades to ERROR)`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = "not json".toByteArray())
|
||||
assertEquals(PrAvailability.ERROR, client.projectPr("/r").availability)
|
||||
}
|
||||
|
||||
// ── log ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `projectLog decodes 200 and maps 404 and 500`() = runTest {
|
||||
transport.queueSuccess(
|
||||
url = "$BASE/projects/log?path=%2Fr",
|
||||
body = """{"commits":[{"hash":"h","at":1,"subject":"s"}],"truncated":false}""".toByteArray(),
|
||||
)
|
||||
assertEquals(1, client.projectLog("/r").commits.size)
|
||||
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 404)
|
||||
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectLog("/r") })
|
||||
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 500)
|
||||
assertEquals(ApiClientError.GitLogUnavailable, errorOf { client.projectLog("/r") })
|
||||
}
|
||||
|
||||
// ── guarded writes: outcome mapping ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a guarded write 200 yields Ok with the decoded payload`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"abc"}""".toByteArray())
|
||||
val outcome = client.gitCommit("/r", "msg")
|
||||
assertTrue(outcome is GitWriteOutcome.Ok)
|
||||
assertEquals("abc", (outcome as GitWriteOutcome.Ok).payload.commit)
|
||||
// The write stamped an Origin.
|
||||
assertTrue(transport.recordedRequests.last().headers.containsKey(HeaderName.ORIGIN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `403 disabled and 409 both surface Rejected with the safe error string`() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST, url = "$BASE/projects/worktree",
|
||||
status = 403, body = """{"error":"Worktree creation is disabled."}""".toByteArray(),
|
||||
)
|
||||
val disabled = client.createWorktree("/r", "b", null)
|
||||
assertEquals(GitWriteOutcome.Rejected(403, "Worktree creation is disabled."), disabled)
|
||||
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.DELETE, url = "$BASE/projects/worktree",
|
||||
status = 409, body = """{"error":"Worktree has uncommitted changes; force required."}""".toByteArray(),
|
||||
)
|
||||
val dirty = client.removeWorktree("/r", "/r/x", false)
|
||||
assertEquals(GitWriteOutcome.Rejected(409, "Worktree has uncommitted changes; force required."), dirty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 yields RateLimited and never auto-retries`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 429, body = """{"error":"Too many requests."}""".toByteArray())
|
||||
assertEquals(GitWriteOutcome.RateLimited, client.gitPush("/r"))
|
||||
assertEquals(1, transport.recordedRequests.size) // exactly one attempt
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stage 200 decodes staged and count and threads the files body`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true,"staged":true,"count":2}""".toByteArray())
|
||||
val outcome = client.gitStage("/r", listOf("a", "b"), stage = true)
|
||||
assertTrue(outcome is GitWriteOutcome.Ok)
|
||||
assertEquals(2, (outcome as GitWriteOutcome.Ok).payload.count)
|
||||
assertEquals("""{"path":"/r","files":["a","b"],"stage":true}""", transport.recordedRequests.last().body?.decodeToString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) across all 12 routes. Verifies method, URL,
|
||||
* the presence/absence of the `Origin` header, and the exact JSON body for the mutating routes.
|
||||
*/
|
||||
class ApiRouteShapeTest {
|
||||
private companion object {
|
||||
const val BASE = "http://192.168.1.5:3000"
|
||||
const val ORIGIN = "http://192.168.1.5:3000"
|
||||
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
const val ID_STR = "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||
|
||||
private fun lastRequest(): HttpRequest = transport.recordedRequests.last()
|
||||
|
||||
private fun assertGuarded(request: HttpRequest) =
|
||||
assertEquals(ORIGIN, request.headers[HeaderName.ORIGIN], "guarded route must stamp byte-equal Origin")
|
||||
|
||||
private fun assertReadOnly(request: HttpRequest) =
|
||||
assertFalse(request.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
|
||||
|
||||
// ── read-only routes: correct verb+url, NO Origin ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun liveSessionsIsPlainReadOnlyGet() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
||||
client.liveSessions()
|
||||
|
||||
val request = lastRequest()
|
||||
assertEquals(HttpMethod.GET, request.method)
|
||||
assertEquals("$BASE/live-sessions", request.url)
|
||||
assertReadOnly(request)
|
||||
assertNull(request.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previewEventsUiConfigProjectsPrefsAreReadOnlyGets() = runTest {
|
||||
transport.queueSuccess(
|
||||
url = "$BASE/live-sessions/$ID_STR/preview",
|
||||
body = """{"id":"$ID_STR","cols":80,"rows":24,"data":""}""".toByteArray(),
|
||||
)
|
||||
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "[]".toByteArray())
|
||||
transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray())
|
||||
transport.queueSuccess(url = "$BASE/projects", body = "[]".toByteArray())
|
||||
transport.queueSuccess(url = "$BASE/prefs", body = "{}".toByteArray())
|
||||
|
||||
client.preview(ID)
|
||||
client.events(ID)
|
||||
client.uiConfig()
|
||||
client.projects()
|
||||
client.prefs()
|
||||
|
||||
val urls = transport.recordedRequests.map { it.url }
|
||||
assertEquals(
|
||||
listOf(
|
||||
"$BASE/live-sessions/$ID_STR/preview",
|
||||
"$BASE/live-sessions/$ID_STR/events",
|
||||
"$BASE/config/ui",
|
||||
"$BASE/projects",
|
||||
"$BASE/prefs",
|
||||
),
|
||||
urls,
|
||||
)
|
||||
transport.recordedRequests.forEach {
|
||||
assertEquals(HttpMethod.GET, it.method)
|
||||
assertReadOnly(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun projectDetailStrictlyPercentEncodesPathInTheQuery() = runTest {
|
||||
val path = "/home/me/my repo/a+b&c"
|
||||
transport.queueSuccess(
|
||||
url = "$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c",
|
||||
body = """{"name":"repo","path":"$path","isGit":true}""".toByteArray(),
|
||||
)
|
||||
|
||||
client.projectDetail(path)
|
||||
|
||||
val request = lastRequest()
|
||||
assertEquals(HttpMethod.GET, request.method)
|
||||
assertEquals("$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c", request.url)
|
||||
assertReadOnly(request)
|
||||
}
|
||||
|
||||
// ── guarded routes: correct verb+url, Origin stamped, exact body ────────────────────────
|
||||
|
||||
@Test
|
||||
fun killSessionIsGuardedDeleteWithNoBody() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
|
||||
client.killSession(ID)
|
||||
|
||||
val request = lastRequest()
|
||||
assertEquals(HttpMethod.DELETE, request.method)
|
||||
assertEquals("$BASE/live-sessions/$ID_STR", request.url)
|
||||
assertGuarded(request)
|
||||
assertNull(request.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hookDecisionIsGuardedPostWithExactBodyAndJsonContentType() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
|
||||
client.hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
|
||||
|
||||
val request = lastRequest()
|
||||
assertEquals(HttpMethod.POST, request.method)
|
||||
assertEquals("$BASE/hook/decision", request.url)
|
||||
assertGuarded(request)
|
||||
assertEquals(ContentType.JSON, request.headers[HeaderName.CONTENT_TYPE])
|
||||
assertEquals(
|
||||
"""{"sessionId":"$ID_STR","decision":"allow","token":"cap-tok-123"}""",
|
||||
request.body?.decodeToString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putPrefsIsGuardedPutEchoingTheFullBlob() = runTest {
|
||||
val body = """{"favourites":["/a"],"collapsed":{}}"""
|
||||
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = body.toByteArray())
|
||||
|
||||
client.putPrefs(UiPrefs.create(favourites = listOf("/a")))
|
||||
|
||||
val request = lastRequest()
|
||||
assertEquals(HttpMethod.PUT, request.method)
|
||||
assertEquals("$BASE/prefs", request.url)
|
||||
assertGuarded(request)
|
||||
assertEquals(body, request.body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fcmTokenRegisterAndUnregisterAreGuardedWithTokenBody() = runTest {
|
||||
val token = "fVoT9x-abc_DEF:api-901"
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/push/fcm-token", status = 204)
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/push/fcm-token", status = 204)
|
||||
|
||||
client.registerFcmToken(token)
|
||||
client.unregisterFcmToken(token)
|
||||
|
||||
val post = transport.recordedRequests[0]
|
||||
val delete = transport.recordedRequests[1]
|
||||
assertEquals(HttpMethod.POST, post.method)
|
||||
assertEquals(HttpMethod.DELETE, delete.method)
|
||||
assertTrue(post.url.endsWith("/push/fcm-token"))
|
||||
assertGuarded(post)
|
||||
assertGuarded(delete)
|
||||
assertEquals("""{"token":"$token"}""", post.body?.decodeToString())
|
||||
assertEquals("""{"token":"$token"}""", delete.body?.decodeToString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
|
||||
/** The intentionally-LOOSE FCM token validator (plan §4.5) + register/unregister error mapping. */
|
||||
class FcmTokenTest {
|
||||
private companion object {
|
||||
const val BASE = "http://h:3000"
|
||||
const val URL = "$BASE/push/fcm-token"
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||
|
||||
@Test
|
||||
fun acceptsBase64UrlPlusColonAndIsCaseSensitive() {
|
||||
// base64url chars + ':' , mixed case preserved (FCM tokens are case-sensitive → not lowered).
|
||||
val token = "cRZ7-x_Y9:APA91bH-Ab_CdEf"
|
||||
assertEquals(token, FcmTokenRule.normalize(token))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsEmptyOverlongAndOutOfCharsetTokens() {
|
||||
assertNull(FcmTokenRule.normalize(""))
|
||||
assertNull(FcmTokenRule.normalize("a".repeat(4097)))
|
||||
assertNull(FcmTokenRule.normalize("has space"))
|
||||
assertNull(FcmTokenRule.normalize("has/slash"))
|
||||
assertNull(FcmTokenRule.normalize("emoji😀"))
|
||||
assertNotNull(FcmTokenRule.normalize("a".repeat(4096))) // exactly at the bound is fine
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidTokenIsRejectedBeforeAnyNetworkIO() = runTest {
|
||||
val error = runCatching { client.registerFcmToken("bad token") }.exceptionOrNull()
|
||||
assertEquals(ApiClientError.InvalidFcmToken, error)
|
||||
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an invalid token")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun registerMaps400To403To429() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 400)
|
||||
assertEquals(ApiClientError.InvalidFcmToken, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
|
||||
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 403)
|
||||
assertEquals(ApiClientError.Forbidden, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
|
||||
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 429)
|
||||
assertEquals(ApiClientError.RateLimited, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun registerAndUnregisterSucceedOn204() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 204)
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = URL, status = 204)
|
||||
// No exception = success.
|
||||
client.registerFcmToken("okTok")
|
||||
client.unregisterFcmToken("okTok")
|
||||
assertEquals(2, transport.recordedRequests.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) for the W5 git surface: the two NEW reads
|
||||
* (`/projects/pr`, `/projects/log`) carry **no** Origin; the six writes (worktree×3, git×3) carry a
|
||||
* byte-equal Origin and a JSON body — including a `DELETE /projects/worktree` that carries a body
|
||||
* (the highest-risk integration gotcha). A route reclassified read↔write turns this red.
|
||||
*/
|
||||
class GitRouteShapeTest {
|
||||
private companion object {
|
||||
const val BASE = "http://192.168.1.5:3000"
|
||||
const val ORIGIN = "http://192.168.1.5:3000"
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||
|
||||
private fun last(): HttpRequest = transport.recordedRequests.last()
|
||||
|
||||
private fun assertGuarded(r: HttpRequest) =
|
||||
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
|
||||
|
||||
private fun assertReadOnly(r: HttpRequest) =
|
||||
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
|
||||
|
||||
// ── reads: no Origin, correct verb + strict-encoded query ────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `projectPr is a read-only GET with a strict-encoded path and no Origin`() = runTest {
|
||||
val path = "/home/me/my repo/a+b&c"
|
||||
val url = "$BASE/projects/pr?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c"
|
||||
transport.queueSuccess(url = url, body = """{"availability":"no-pr"}""".toByteArray())
|
||||
|
||||
client.projectPr(path)
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.GET, r.method)
|
||||
assertEquals(url, r.url)
|
||||
assertReadOnly(r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projectLog omits n when null and appends a clamped n when set`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||
client.projectLog("/p", n = null)
|
||||
assertEquals("$BASE/projects/log?path=%2Fp", last().url)
|
||||
assertReadOnly(last())
|
||||
|
||||
// n above GIT_LOG_MAX (50) clamps to 50; below 1 clamps to 1.
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=50", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||
client.projectLog("/p", n = 999)
|
||||
assertEquals("$BASE/projects/log?path=%2Fp&n=50", last().url)
|
||||
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=1", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||
client.projectLog("/p", n = 0)
|
||||
assertEquals("$BASE/projects/log?path=%2Fp&n=1", last().url)
|
||||
}
|
||||
|
||||
// ── writes: Origin stamped, correct verb, JSON body ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `createWorktree is a guarded POST with a path-branch-base body`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||
client.createWorktree("/repo", "feat/x", base = "main")
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.POST, r.method)
|
||||
assertEquals("$BASE/projects/worktree", r.url)
|
||||
assertGuarded(r)
|
||||
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
|
||||
assertEquals("""{"path":"/repo","branch":"feat/x","base":"main"}""", r.body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createWorktree omits base when null`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||
client.createWorktree("/repo", "feat/x", base = null)
|
||||
assertEquals("""{"path":"/repo","branch":"feat/x"}""", last().body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeWorktree is a guarded DELETE that CARRIES a JSON body`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||
client.removeWorktree("/repo", "/repo-worktrees/x", force = true)
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.DELETE, r.method)
|
||||
assertEquals("$BASE/projects/worktree", r.url)
|
||||
assertGuarded(r)
|
||||
assertNotNull(r.body, "DELETE /projects/worktree MUST carry a request body")
|
||||
assertEquals("""{"path":"/repo","worktreePath":"/repo-worktrees/x","force":true}""", r.body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pruneWorktrees is a guarded POST with a path body`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true,"pruned":[]}""".toByteArray())
|
||||
client.pruneWorktrees("/repo")
|
||||
|
||||
val r = last()
|
||||
assertEquals(HttpMethod.POST, r.method)
|
||||
assertEquals("$BASE/projects/worktree/prune", r.url)
|
||||
assertGuarded(r)
|
||||
assertEquals("""{"path":"/repo"}""", r.body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gitStage commit push are guarded POSTs with exact bodies`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
|
||||
client.gitStage("/repo", listOf("a.kt", "b.kt"), stage = true)
|
||||
assertEquals("$BASE/projects/git/stage", last().url)
|
||||
assertGuarded(last())
|
||||
assertEquals("""{"path":"/repo","files":["a.kt","b.kt"],"stage":true}""", last().body?.decodeToString())
|
||||
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"x"}""".toByteArray())
|
||||
client.gitCommit("/repo", "a message")
|
||||
assertEquals("""{"path":"/repo","message":"a message"}""", last().body?.decodeToString())
|
||||
assertGuarded(last())
|
||||
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
|
||||
client.gitPush("/repo")
|
||||
assertEquals("$BASE/projects/git/push", last().url)
|
||||
assertEquals("""{"path":"/repo"}""", last().body?.decodeToString())
|
||||
assertGuarded(last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every guarded write carries Origin and every read does not (batch invariant)`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"ok"}""".toByteArray())
|
||||
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", body = """{"commits":[],"truncated":false}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true}""".toByteArray())
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
|
||||
|
||||
client.projectPr("/r"); client.projectLog("/r", null)
|
||||
assertReadOnly(transport.recordedRequests[0])
|
||||
assertReadOnly(transport.recordedRequests[1])
|
||||
|
||||
client.createWorktree("/r", "b", null)
|
||||
client.removeWorktree("/r", "/r/x", false)
|
||||
client.pruneWorktrees("/r")
|
||||
client.gitStage("/r", listOf("f"), true)
|
||||
client.gitCommit("/r", "m")
|
||||
client.gitPush("/r")
|
||||
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import java.util.UUID
|
||||
|
||||
/** End-to-end decode of each model from a well-formed body (exercises the custom serializers). */
|
||||
class HappyPathDecodeTest {
|
||||
private companion object {
|
||||
const val BASE = "http://h:3000"
|
||||
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
const val ID_STR = "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||
|
||||
@Test
|
||||
fun previewDecodesGeometryAndOpaqueData() = runTest {
|
||||
transport.queueSuccess(
|
||||
url = "$BASE/live-sessions/$ID_STR/preview",
|
||||
body = """{"id":"$ID_STR","cols":100,"rows":30,"data":"[0mhello"}""".toByteArray(),
|
||||
)
|
||||
val preview = client.preview(ID)
|
||||
assertEquals(ID, preview.id)
|
||||
assertEquals(100, preview.cols)
|
||||
assertEquals("[0mhello", preview.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uiConfigDecodesAllowAutoMode() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray())
|
||||
assertTrue(client.uiConfig().allowAutoMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveSessionsDecodesTelemetry() = runTest {
|
||||
val body = """
|
||||
[{"id":"$ID_STR","createdAt":10,"clientCount":1,"status":"working","exited":false,"cols":80,"rows":24,
|
||||
"telemetry":{"at":99,"model":"opus","costUsd":0.42,"linesAdded":10,"pr":{"number":7,"url":"http://x"}}}]
|
||||
""".trimIndent()
|
||||
transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray())
|
||||
|
||||
val session = client.liveSessions().single()
|
||||
assertEquals("opus", session.telemetry?.model)
|
||||
assertEquals(0.42, session.telemetry?.costUsd)
|
||||
assertEquals(7, session.telemetry?.pr?.number)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun projectDetailDecodesWorktreesSessionsAndClaudeMd() = runTest {
|
||||
val body = """
|
||||
{"name":"repo","path":"/r","isGit":true,"branch":"main","dirty":true,
|
||||
"worktrees":[{"path":"/r","branch":"main","isMain":true,"isCurrent":true},
|
||||
{"path":"/r-wt","head":"abc123","isMain":false,"isCurrent":false}],
|
||||
"sessions":[{"id":"$ID_STR","title":"claude","status":"waiting","clientCount":1,"createdAt":5,"exited":false}],
|
||||
"hasClaudeMd":true,"claudeMd":"# Repo"}
|
||||
""".trimIndent()
|
||||
transport.queueSuccess(url = "$BASE/projects/detail?path=%2Fr", body = body.toByteArray())
|
||||
|
||||
val detail = client.projectDetail("/r")
|
||||
assertEquals("main", detail.branch)
|
||||
assertEquals(2, detail.worktrees.size)
|
||||
assertTrue(detail.worktrees[0].isMain)
|
||||
assertEquals("abc123", detail.worktrees[1].head)
|
||||
assertEquals("claude", detail.sessions.single().title)
|
||||
assertEquals("# Repo", detail.claudeMd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefsRoundTripsThroughGetAndPutEcho() = runTest {
|
||||
transport.queueSuccess(
|
||||
url = "$BASE/prefs",
|
||||
body = """{"favourites":["/a"],"collapsed":{"g":true},"vendor":1}""".toByteArray(),
|
||||
)
|
||||
val prefs = client.prefs()
|
||||
assertEquals(listOf("/a"), prefs.favourites)
|
||||
|
||||
// PUT returns the server's sanitized echo — treat IT as truth (server dropped "vendor").
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.PUT,
|
||||
url = "$BASE/prefs",
|
||||
body = """{"favourites":["/a","/b"],"collapsed":{}}""".toByteArray(),
|
||||
)
|
||||
val echoed = client.putPrefs(prefs.withFavourites(listOf("/a", "/b")))
|
||||
assertEquals(listOf("/a", "/b"), echoed.favourites)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefsNonObjectBodyThrowsInvalidResponseBody() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/prefs", body = "[]".toByteArray())
|
||||
assertEquals(
|
||||
ApiClientError.InvalidResponseBody,
|
||||
runCatching { client.prefs() }.exceptionOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.util.UUID
|
||||
|
||||
/** Tolerant decode at the UNTRUSTED server boundary (plan §4): drop bad elements, never crash. */
|
||||
class TolerantDecodeTest {
|
||||
private companion object {
|
||||
const val BASE = "http://h:3000"
|
||||
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
const val ID_STR = "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
|
||||
private val transport = FakeHttpTransport()
|
||||
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
|
||||
|
||||
@Test
|
||||
fun liveSessionsDropsMalformedEntriesAndMapsUnknownStatusToUnknown() = runTest {
|
||||
// Entry 1: valid, unknown status string. Entry 2: missing required clientCount → dropped.
|
||||
// Entry 3: non-UUID id → dropped. Entry 4: valid, big-ms createdAt (Long), no telemetry.
|
||||
val body = """
|
||||
[
|
||||
{"id":"$ID_STR","createdAt":1700000000000,"clientCount":2,"status":"reticulating","exited":false,"cols":80,"rows":24},
|
||||
{"id":"22222222-2222-4222-8222-222222222222","createdAt":1,"status":"idle","exited":false,"cols":80,"rows":24},
|
||||
{"id":"not-a-uuid","createdAt":1,"clientCount":1,"status":"idle","exited":false,"cols":80,"rows":24},
|
||||
{"id":"33333333-3333-4333-8333-333333333333","createdAt":1700000000001,"clientCount":0,"status":"working","exited":true,"cwd":"/w","cols":120,"rows":40,"lastOutputAt":1700000000005}
|
||||
]
|
||||
""".trimIndent()
|
||||
transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray())
|
||||
|
||||
val sessions = client.liveSessions()
|
||||
|
||||
assertEquals(2, sessions.size)
|
||||
assertEquals(ID, sessions[0].id)
|
||||
assertEquals(ClaudeStatus.UNKNOWN, sessions[0].status) // unknown wire value → UNKNOWN, not dropped
|
||||
assertEquals(1_700_000_000_000L, sessions[0].createdAt) // ms fits Long, would overflow Int
|
||||
assertEquals(ClaudeStatus.WORKING, sessions[1].status)
|
||||
assertEquals(1_700_000_000_005L, sessions[1].lastOutputAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveSessionsNonArrayBodyThrowsInvalidResponseBody() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/live-sessions", body = "{}".toByteArray())
|
||||
assertEquals(
|
||||
ApiClientError.InvalidResponseBody,
|
||||
runCatching { client.liveSessions() }.exceptionOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun projectsKeepsProjectButDropsOnlyItsMalformedNestedSession() = runTest {
|
||||
val body = """
|
||||
[
|
||||
{"name":"repo","path":"/r","isGit":true,"branch":"main","sessions":[
|
||||
{"id":"$ID_STR","status":"working","clientCount":1,"createdAt":1,"exited":false},
|
||||
{"id":"bad","status":"working","clientCount":1,"createdAt":1,"exited":false}
|
||||
]}
|
||||
]
|
||||
""".trimIndent()
|
||||
transport.queueSuccess(url = "$BASE/projects", body = body.toByteArray())
|
||||
|
||||
val projects = client.projects()
|
||||
|
||||
assertEquals(1, projects.size) // project survives its bad nested session
|
||||
assertEquals(1, projects[0].sessions.size)
|
||||
assertEquals(ID, projects[0].sessions[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventsNonArrayBodyDegradesToEmptyAndUnknownClassSurvives() = runTest {
|
||||
// A non-array body (timeline capture disabled) → [] rather than an error.
|
||||
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "{}".toByteArray())
|
||||
assertTrue(client.events(ID).isEmpty())
|
||||
|
||||
// Unknown class decodes fine (shape ok); hasKnownClass=false for downstream filtering.
|
||||
transport.queueSuccess(
|
||||
url = "$BASE/live-sessions/$ID_STR/events",
|
||||
body = """[{"at":5,"class":"weird","label":"did a thing"}]""".toByteArray(),
|
||||
)
|
||||
val events = client.events(ID)
|
||||
assertEquals(1, events.size)
|
||||
assertFalse(events[0].hasKnownClass)
|
||||
assertEquals("did a thing", events[0].label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleObjectRoutesThrowInvalidResponseBodyOnGarbage() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/config/ui", body = "not json".toByteArray())
|
||||
assertEquals(
|
||||
ApiClientError.InvalidResponseBody,
|
||||
runCatching { client.uiConfig() }.exceptionOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
122
android/app/build.gradle.kts
Normal file
122
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,122 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// :app — the Android application (Compose Material 3 + Adaptive design system,
|
||||
// Hilt DI skeleton, launcher MainActivity). Mirrors iOS App/WebTerm.
|
||||
//
|
||||
// A13 establishes the Android UI-stack version matrix for every later Android task
|
||||
// (the app-stack baseline, analogous to how A1 established the JVM catalog). All
|
||||
// UI versions are resolved in gradle/libs.versions.toml.
|
||||
//
|
||||
// Plugins (AGP 9 has BUILT-IN Kotlin → never apply org.jetbrains.kotlin.android):
|
||||
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "wang.yaojia.webterm"
|
||||
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
|
||||
// Kotlin 2.3.21 requires compiling against SDK 36+ (BOM 2025.11 → ui 1.9.5).
|
||||
// targetSdk stays 35 per plan §2 (compileSdk ≥ targetSdk is the normal rule).
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "wang.yaojia.webterm"
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// No applicationId suffix — keep it stable for deep-link testing (A32).
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Modules A15 wires into the DI graph.
|
||||
implementation(project(":wire-protocol"))
|
||||
implementation(project(":session-core"))
|
||||
implementation(project(":api-client"))
|
||||
implementation(project(":transport-okhttp"))
|
||||
implementation(project(":client-tls"))
|
||||
// A15: bridge the mTLS device identity (:client-tls-android) onto the shared client, and
|
||||
// provide the DataStore-backed stores (:host-registry). :client-tls-android exposes
|
||||
// :client-tls via `api`, but the explicit dep above is kept for clarity.
|
||||
implementation(project(":client-tls-android"))
|
||||
implementation(project(":host-registry"))
|
||||
// Terminal render (A16): RemoteTerminalSession/RemoteTerminalView for the live terminal (A21) and
|
||||
// the raw Termux TerminalEmulator for A18's off-screen thumbnail rasterisation (§6.7). :terminal-view
|
||||
// scopes Termux as `implementation`, so :app names the emulator directly for the off-screen path.
|
||||
implementation(project(":terminal-view"))
|
||||
implementation(libs.termux.terminal.emulator)
|
||||
// QR pairing (A19): CameraX + on-device ML Kit barcode scanning.
|
||||
implementation(libs.bundles.camerax)
|
||||
implementation(libs.mlkit.barcode.scanning)
|
||||
// Push (A30) + nav/deep-links (A32).
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.messaging)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
// OkHttp is `implementation` (not `api`) in :transport-okhttp, so :app names OkHttpClient /
|
||||
// ConnectionPool directly in NetworkModule/TlsModule.
|
||||
implementation(libs.okhttp)
|
||||
// Coroutines are used directly by the wiring (EventBus fan-out, engine confinement scope).
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
// Preferences DataStore is named in StorageModule's @Provides return types.
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
// AndroidX foundation
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// Compose (the BOM governs every version below)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.bundles.compose)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Hilt (Dagger) — KSP annotation processing
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
// JVM unit tests (no device) — the frozen design-token spec, EventBus fan-out, and the
|
||||
// RetainedSessionHolder lifecycle invariant driven by a fake transport under virtual time.
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules.
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
4
android/app/proguard-rules.pro
vendored
Normal file
4
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# WebTerm :app — R8/ProGuard rules.
|
||||
# Release is not minified yet (isMinifyEnabled = false); this file exists so the
|
||||
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for
|
||||
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later.
|
||||
74
android/app/src/main/AndroidManifest.xml
Normal file
74
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Talk to the WebTerm server over WS/HTTP. No NEARBY_WIFI_DEVICES: Android
|
||||
has no local-network permission prompt; plain WS to a LAN IP needs none
|
||||
(plan §8 / R12). -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Push (A30/A31) — requested with rationale at runtime on API 33+. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- QR pairing (A19) — CameraX preview; requested with rationale at runtime, denial
|
||||
degrades to manual URL entry. android:required=false so non-camera devices install. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".WebTermApp"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WebTerm"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="false">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.WebTerm">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- A32 deep links → DeepLinkRouter (v4-UUID whitelist; the manifest never validates). -->
|
||||
<!-- Custom scheme: webterminal://open?host=<uuid>&join=<uuid> -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="webterminal" android:host="open" />
|
||||
</intent-filter>
|
||||
<!-- Verified App Link: https://terminal.yaojia.wang/open?host=&join= .
|
||||
autoVerify needs assetlinks.json (release SHA-256) served at
|
||||
/.well-known/assetlinks.json — a deploy artifact (§8), not built here. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="terminal.yaojia.wang" android:pathPrefix="/open" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Push (A30) — FCM data-only entry point (server sends no notification block). -->
|
||||
<service
|
||||
android:name=".push.FcmService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<!-- Deny action: auth-free, no-UI, expedited POST (goAsync). -->
|
||||
<receiver
|
||||
android:name=".push.DenyBroadcastReceiver"
|
||||
android:exported="false" />
|
||||
<!-- Allow action: translucent, excluded-from-recents trampoline hosting BiometricPrompt
|
||||
(a receiver/service cannot present it — R1). -->
|
||||
<activity
|
||||
android:name=".push.AllowTrampolineActivity"
|
||||
android:theme="@style/Theme.WebTerm.Translucent"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
176
android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt
Normal file
176
android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt
Normal file
@@ -0,0 +1,176 @@
|
||||
package wang.yaojia.webterm
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.nav.DeepLinkRouter
|
||||
import wang.yaojia.webterm.nav.NavRoutes
|
||||
import wang.yaojia.webterm.nav.WebTermNavHost
|
||||
import wang.yaojia.webterm.push.PushCoordinator
|
||||
import wang.yaojia.webterm.wiring.AppEnvironment
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The single launcher Activity and composition root. A `@AndroidEntryPoint` [FragmentActivity]
|
||||
* (FragmentActivity so the biometric/allow trampoline hierarchy is consistent) that:
|
||||
*
|
||||
* - **warms the mTLS/OkHttp stack off `Main`** ([AppEnvironment.warmUp]) before any terminal bind;
|
||||
* - **registers this device's FCM token with every paired host on app start** ([PushCoordinator]);
|
||||
* - **hands the launch/new intents to [DeepLinkRouter]** (the ONE whitelist parser) and navigates only a
|
||||
* validated [NavRoutes.forDeepLink] route — the manifest `<intent-filter>`s deliver, never validate;
|
||||
* - renders [WebTermNavHost] with the cold-start start destination ([AppEnvironment.coldStartPolicy]).
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
public class MainActivity : FragmentActivity() {
|
||||
|
||||
@Inject
|
||||
public lateinit var appEnvironment: AppEnvironment
|
||||
|
||||
@Inject
|
||||
public lateinit var pushCoordinator: PushCoordinator
|
||||
|
||||
/** The latest deep-link URI to route (null = nothing pending). Fed by onCreate + onNewIntent. */
|
||||
private val pendingDeepLink = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Build the shared OkHttp/mTLS stack OFF-Main before the first bind (warmUp hops to IO itself);
|
||||
// a real failure is swallowed (the terminal screen surfaces its own retry, FIX 5) but cancellation
|
||||
// is rethrown so a destroyed Activity tears the warm-up down cleanly.
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
appEnvironment.warmUp()
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
// best-effort pre-warm; TerminalScreen re-runs warmUp with an actionable retry.
|
||||
}
|
||||
}
|
||||
// App start: register the current FCM token with every paired host (best-effort self-heal).
|
||||
pushCoordinator.registerAllHosts()
|
||||
|
||||
handleDeepLinkIntent(intent)
|
||||
|
||||
setContent {
|
||||
WebTermTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
val navController = rememberNavController()
|
||||
NotificationPermissionGate()
|
||||
ColdStartHost(
|
||||
env = appEnvironment,
|
||||
navController = navController,
|
||||
pendingDeepLink = pendingDeepLink,
|
||||
onHostPaired = { host -> pushCoordinator.registerHost(host) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleDeepLinkIntent(intent)
|
||||
}
|
||||
|
||||
/** Capture a VIEW deep link's data URI; a launcher/push-body intent has none → nothing to route. */
|
||||
private fun handleDeepLinkIntent(intent: Intent?) {
|
||||
pendingDeepLink.value = intent?.data?.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the cold-start destination ([AppEnvironment.coldStartPolicy]) then render the graph. Shows a
|
||||
* spinner until the (suspend) host-presence read completes.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColdStartHost(
|
||||
env: AppEnvironment,
|
||||
navController: NavHostController,
|
||||
pendingDeepLink: MutableStateFlow<String?>,
|
||||
onHostPaired: (Host) -> Unit,
|
||||
) {
|
||||
val startRoute by produceState<String?>(initialValue = null, key1 = env) {
|
||||
value = NavRoutes.startRouteFor(env.coldStartPolicy.initialRoute())
|
||||
}
|
||||
val route = startRoute
|
||||
if (route == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
WebTermNavHost(
|
||||
env = env,
|
||||
startRoute = route,
|
||||
navController = navController,
|
||||
onHostPaired = onHostPaired,
|
||||
)
|
||||
// Route pending deep links ONLY here — inside the `route != null` branch, so `WebTermNavHost`
|
||||
// (which calls navController.setGraph) has composed and the graph is set before the effect body
|
||||
// runs. On a cold launch the VIEW intent is captured before setContent, but its navigation is
|
||||
// deferred to this point instead of racing an unset graph (which silently dropped the link).
|
||||
DeepLinkEffect(pending = pendingDeepLink, navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a pending deep-link URI through [DeepLinkRouter] (the ONE whitelist parser) and navigate only a
|
||||
* validated route; invalid/ambiguous links are ignored (never partially applied). Clears the pending
|
||||
* value once handled.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeepLinkEffect(
|
||||
pending: MutableStateFlow<String?>,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
val uri by pending.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(uri) {
|
||||
val target = uri ?: return@LaunchedEffect
|
||||
NavRoutes.forDeepLink(DeepLinkRouter.route(target))?.let { route ->
|
||||
runCatching { navController.navigate(route) }
|
||||
}
|
||||
pending.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Request POST_NOTIFICATIONS once on first launch (API 33+); a denial only stops push from being SHOWN. */
|
||||
@Composable
|
||||
private fun NotificationPermissionGate() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
LaunchedEffect(Unit) {
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
12
android/app/src/main/java/wang/yaojia/webterm/WebTermApp.kt
Normal file
12
android/app/src/main/java/wang/yaojia/webterm/WebTermApp.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package wang.yaojia.webterm
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
/**
|
||||
* Application entry point. `@HiltAndroidApp` triggers Hilt's code generation and
|
||||
* creates the app-level `SingletonComponent` (see `di/AppModule`). The real
|
||||
* composition root / wiring is A15 — this stays intentionally empty.
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
public class WebTermApp : Application()
|
||||
@@ -0,0 +1,92 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermCard
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.session.AwayDigest
|
||||
import wang.yaojia.webterm.wire.TimelineEvent
|
||||
|
||||
/**
|
||||
* # AwayDigestView (A22) — the "what happened while I was away" reattach summary.
|
||||
*
|
||||
* The Android analogue of the iOS away-digest banner (plan §1): shown ABOVE the terminal on the
|
||||
* first reconnect after a gap, summarising the [AwayDigest] reduced over the events since the user
|
||||
* left — tool-run and waiting counts plus done/stuck flags. Its **展开** ("expand") affordance opens
|
||||
* the A28 activity-timeline sheet via [onExpand] (this component only exposes the seam; A28 wires it).
|
||||
*
|
||||
* An [empty][AwayDigest.isEmpty] digest renders **nothing** (all-zero suppressed) — the caller
|
||||
* ([GateViewModel] holds `null` for an empty digest) normally never passes one, but the guard makes
|
||||
* the component safe in isolation. Recent-event labels are server-derived text rendered as **inert
|
||||
* [Text]** (no linkify/markdown, §8).
|
||||
*/
|
||||
@Composable
|
||||
public fun AwayDigestView(
|
||||
digest: AwayDigest,
|
||||
onExpand: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (digest.isEmpty) return // all-zero suppressed; render nothing.
|
||||
WebTermCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
Text(
|
||||
text = summaryLine(digest),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
digest.recent.lastOrNull()?.let { latest ->
|
||||
// INERT: server-derived phrase rendered verbatim, single line (§8).
|
||||
Text(
|
||||
text = latest.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = onExpand) { Text(text = "展开") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The one-line count summary, e.g. "离开期间:3 次工具调用 · 1 次等待 · 已完成". Pure presentation. */
|
||||
internal fun summaryLine(digest: AwayDigest): String {
|
||||
val parts = mutableListOf<String>()
|
||||
if (digest.toolRuns > 0) parts += "${digest.toolRuns} 次工具调用"
|
||||
if (digest.waitingCount > 0) parts += "${digest.waitingCount} 次等待"
|
||||
if (digest.sawDone) parts += "已完成"
|
||||
if (digest.sawStuck) parts += "疑似卡住"
|
||||
val body = if (parts.isEmpty()) "有活动" else parts.joinToString(" · ")
|
||||
return "离开期间:$body"
|
||||
}
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "AwayDigestView")
|
||||
@Composable
|
||||
private fun AwayDigestViewPreview() {
|
||||
WebTermTheme {
|
||||
AwayDigestView(
|
||||
digest = AwayDigest(
|
||||
toolRuns = 3,
|
||||
waitingCount = 1,
|
||||
sawDone = true,
|
||||
sawStuck = false,
|
||||
recent = listOf(TimelineEvent(at = 0L, eventClass = "tool", toolName = "Bash", label = "ran Bash")),
|
||||
),
|
||||
onExpand = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.Radius
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
|
||||
/**
|
||||
* # ContinueLastBanner (A29) — the "继续上次会话" re-entry affordance.
|
||||
*
|
||||
* The visible half of the continue-last-session cold-start UX (plan §1, §5 A29). When a host still has a
|
||||
* live last session ([LastSessionStore][wang.yaojia.webterm.hostregistry.LastSessionStore], kept current
|
||||
* by [SessionActivityBridge][wang.yaojia.webterm.wiring.SessionActivityBridge]), this banner is layered
|
||||
* on top of the sessions landing; tapping it re-opens THAT session (full scrollback replay) instead of
|
||||
* spawning a new one. It renders in two layouts:
|
||||
* - [ContinueLastVariant.Stack] — a full-width strip pinned above a stacked (phone) session list;
|
||||
* - [ContinueLastVariant.Sidebar] — a compact rounded card for the tablet nav sidebar.
|
||||
*
|
||||
* ### Shown IFF a last session exists
|
||||
* The pure [continueLastModel] maps the persisted last-session id to a [ContinueLastModel] (or `null`),
|
||||
* and the composable renders NOTHING for a `null` model — so "shown iff a last session exists" is the
|
||||
* single JVM-tested rule, no device needed. Layout/gesture polish is device-QA (plan §7).
|
||||
*
|
||||
* ### Inert rendering (plan §8)
|
||||
* The session id is a SERVER-issued (untrusted) string; it is shown as a plain, inert [Text] — no
|
||||
* Markdown / autolink / `AnnotatedString` linkify.
|
||||
*/
|
||||
|
||||
/** Which layout the banner renders in — a top strip (phone) or a sidebar card (tablet). */
|
||||
public enum class ContinueLastVariant { Stack, Sidebar }
|
||||
|
||||
/** The banner's derived state: the last session that can be re-opened. */
|
||||
public data class ContinueLastModel(val sessionId: String)
|
||||
|
||||
/**
|
||||
* PURE derivation: a non-blank persisted [lastSessionId] yields a [ContinueLastModel] (banner shown);
|
||||
* `null`/blank yields `null` (banner hidden). Blank is treated as absent so a corrupt persisted value
|
||||
* never renders an un-openable banner.
|
||||
*/
|
||||
public fun continueLastModel(lastSessionId: String?): ContinueLastModel? =
|
||||
lastSessionId?.takeIf { it.isNotBlank() }?.let { ContinueLastModel(it) }
|
||||
|
||||
/**
|
||||
* The banner. Renders NOTHING when [model] is `null` (no last session). Tapping the whole surface fires
|
||||
* [onContinue] with the session id to re-open. [variant] picks the stack vs sidebar layout.
|
||||
*/
|
||||
@Composable
|
||||
public fun ContinueLastBanner(
|
||||
model: ContinueLastModel?,
|
||||
onContinue: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
variant: ContinueLastVariant = ContinueLastVariant.Stack,
|
||||
) {
|
||||
if (model == null) return
|
||||
|
||||
val shape: Shape = when (variant) {
|
||||
ContinueLastVariant.Stack -> RectangleShape
|
||||
ContinueLastVariant.Sidebar -> RoundedCornerShape(Radius.md12)
|
||||
}
|
||||
val widthModifier =
|
||||
if (variant == ContinueLastVariant.Stack) Modifier.fillMaxWidth() else Modifier
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
shape = shape,
|
||||
modifier = modifier
|
||||
.then(widthModifier)
|
||||
.clickable { onContinue(model.sessionId) },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||
) {
|
||||
Text(text = "↻", style = MaterialTheme.typography.titleMedium)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs2),
|
||||
) {
|
||||
Text(text = "继续上次会话", style = MaterialTheme.typography.bodyMedium)
|
||||
// Untrusted server-issued id — inert Text, no autolink/markdown (§8).
|
||||
Text(
|
||||
text = shortSessionId(model.sessionId),
|
||||
style = WebTermType.metaMono,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(text = "›", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A short, inert label for the (untrusted) session id — the first id segment, capped so it never wraps. */
|
||||
private fun shortSessionId(sessionId: String): String =
|
||||
sessionId.substringBefore('-').take(SHORT_ID_MAX)
|
||||
|
||||
private const val SHORT_ID_MAX: Int = 12
|
||||
|
||||
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "ContinueLastBanner — stack")
|
||||
@Composable
|
||||
private fun ContinueLastBannerStackPreview() {
|
||||
WebTermTheme {
|
||||
ContinueLastBanner(
|
||||
model = ContinueLastModel("a1b2c3d4-5678-4abc-9def-000000000000"),
|
||||
onContinue = {},
|
||||
variant = ContinueLastVariant.Stack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "ContinueLastBanner — sidebar", widthDp = 220)
|
||||
@Composable
|
||||
private fun ContinueLastBannerSidebarPreview() {
|
||||
WebTermTheme {
|
||||
ContinueLastBanner(
|
||||
model = ContinueLastModel("a1b2c3d4-5678-4abc-9def-000000000000"),
|
||||
onContinue = {},
|
||||
variant = ContinueLastVariant.Sidebar,
|
||||
modifier = Modifier.padding(Spacing.md12),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.DisplayStatus
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.StatusBadge
|
||||
import wang.yaojia.webterm.designsystem.WebTermCard
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.session.GateState
|
||||
import wang.yaojia.webterm.viewmodels.GateDecision
|
||||
import wang.yaojia.webterm.wire.GateKind
|
||||
|
||||
/**
|
||||
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
|
||||
*
|
||||
* The Android analogue of the iOS tool-gate card (public/tabs.ts:334-350): a `WebTermCard` holding
|
||||
* a "needs me" status badge, the server-supplied [detail][GateState.detail] rendered as **inert
|
||||
* [Text]** (untrusted OSC/tool text — no Markdown / autolink, plan §8), and two actions: **批准**
|
||||
* (approve → `Approve(mode=null)`) and **拒绝** (reject).
|
||||
*
|
||||
* ### Epoch capture (the stale-guard seam)
|
||||
* Each button hands back the [epoch][GateState.epoch] of the gate IT rendered, so a tap is bound to
|
||||
* the gate the user actually saw. The screen (A21) wires `onDecide = { d, e -> vm.decide(d, e) }`;
|
||||
* [GateViewModel.decide] then drops the tap if that epoch is no longer the live gate.
|
||||
*
|
||||
* This card is for [GateKind.TOOL] gates only; plan gates use [PlanGateSheet] (three-way).
|
||||
* Layout / colors are device-QA; the wired decision + epoch capture is the load-bearing contract.
|
||||
*
|
||||
* @param onDecide invoked with the tapped [GateDecision] and this gate's epoch.
|
||||
*/
|
||||
@Composable
|
||||
public fun GateBanner(
|
||||
gate: GateState,
|
||||
onDecide: (GateDecision, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
WebTermCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
StatusBadge(status = DisplayStatus.PendingApproval, showsLabel = true)
|
||||
gate.detail?.let { detail ->
|
||||
// INERT: untrusted server string rendered verbatim, single-line, no linkify (§8).
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
|
||||
Text(text = "拒绝")
|
||||
}
|
||||
Button(onClick = { onDecide(GateDecision.APPROVE, gate.epoch) }) {
|
||||
Text(text = "批准")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "GateBanner")
|
||||
@Composable
|
||||
private fun GateBannerPreview() {
|
||||
WebTermTheme {
|
||||
GateBanner(
|
||||
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
|
||||
onDecide = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package wang.yaojia.webterm.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import wang.yaojia.webterm.designsystem.LayoutTokens
|
||||
import wang.yaojia.webterm.designsystem.Opacity
|
||||
import wang.yaojia.webterm.designsystem.Radius
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.session.KeyByteMap
|
||||
|
||||
/**
|
||||
* # KeyBar (A17) — the mobile IME-bypass touch key-bar + hardware-chord router.
|
||||
*
|
||||
* The Android port of `public/keybar.ts`: a horizontal strip of the Claude Code
|
||||
* high-frequency keys a phone soft keyboard can't easily produce (Esc / Esc² /
|
||||
* ⇧Tab / arrows / Enter / Ctrl-chords / Tab / `/`), most-used first.
|
||||
*
|
||||
* ### Byte source of truth (plan §6.3, A6)
|
||||
* Every label→bytes lookup — buttons AND hardware chords — resolves through
|
||||
* [KeyByteMap] (byte-for-byte matched to the web `KEY_MAP`). NO escape sequence is
|
||||
* ever hand-written here; hand-writing `ESC[A` etc. is a review finding.
|
||||
*
|
||||
* ### IME bypass (plan §6.3 source #2, R8)
|
||||
* A key-bar tap calls [emitKey] → `onSend(bytes)` DIRECTLY, mirroring the web
|
||||
* `ws.send` bypass. It never routes through a `BasicTextField` / the emulator's
|
||||
* `InputConnection`, so tapping a key never pops the soft keyboard. `onSend` is
|
||||
* wired by A21 to `TerminalSessionController.sendInput`.
|
||||
*
|
||||
* ### DECCKM split (plan §6.3 source #3)
|
||||
* Hardware keys are routed by [HardwareKeyRouter]: Esc / Ctrl-<letter> / ⇧Tab are
|
||||
* mapped app-side via [KeyByteMap]; **arrows / Enter / (plain) Tab are LEFT to
|
||||
* Termux's `KeyHandler`** (via `RemoteTerminalView.onKeyCommand` returning false)
|
||||
* so `cursorKeysApplication` (DECCKM) still emits `ESC O A` — never `ESC [ A` —
|
||||
* for vim/htop.
|
||||
*
|
||||
* Compose layout / IME-inset behaviour / real key events are DEVICE-QA (plan §7);
|
||||
* the byte contract + the routing decision are the JVM-tested core.
|
||||
*/
|
||||
|
||||
/** One key-bar button: fixed glyph + Chinese caption + the [KeyByteMap.Key] it emits. */
|
||||
public data class KeyBarButton(
|
||||
val label: String,
|
||||
val caption: String,
|
||||
val key: KeyByteMap.Key,
|
||||
val title: String,
|
||||
val isPrimary: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Button layout — most-used Claude Code keys first, mirroring the web
|
||||
* `KEYBAR_BUTTONS` (public/keybar.ts) order, glyphs and captions 1:1. The 🎤 voice
|
||||
* button (web A2) is out of scope for A17.
|
||||
*/
|
||||
public val KEYBAR_LAYOUT: List<KeyBarButton> = listOf(
|
||||
KeyBarButton("Esc", "中断", KeyByteMap.Key.ESC, "Esc — interrupt Claude / dismiss", isPrimary = true),
|
||||
KeyBarButton("Esc²", "回溯", KeyByteMap.Key.ESC_ESC, "Esc Esc — rewind / clear draft"),
|
||||
KeyBarButton("⇧Tab", "模式", KeyByteMap.Key.SHIFT_TAB, "Shift+Tab — cycle plan / auto-accept mode"),
|
||||
KeyBarButton("↑", "上一个", KeyByteMap.Key.ARROW_UP, "Up — previous option / history"),
|
||||
KeyBarButton("↓", "下一个", KeyByteMap.Key.ARROW_DOWN, "Down — next option / history"),
|
||||
KeyBarButton("⏎", "确认", KeyByteMap.Key.ENTER, "Enter — confirm"),
|
||||
KeyBarButton("^C", "取消", KeyByteMap.Key.CTRL_C, "Ctrl+C — cancel / quit"),
|
||||
KeyBarButton("^R", "搜历史", KeyByteMap.Key.CTRL_R, "Ctrl+R — reverse-search command history"),
|
||||
KeyBarButton("^O", "详情", KeyByteMap.Key.CTRL_O, "Ctrl+O — expand transcript / tool detail"),
|
||||
KeyBarButton("^L", "重绘", KeyByteMap.Key.CTRL_L, "Ctrl+L — redraw screen"),
|
||||
KeyBarButton("^T", "任务", KeyByteMap.Key.CTRL_T, "Ctrl+T — toggle task list"),
|
||||
KeyBarButton("^B", "后台", KeyByteMap.Key.CTRL_B, "Ctrl+B — background running task"),
|
||||
KeyBarButton("^D", "退出", KeyByteMap.Key.CTRL_D, "Ctrl+D — exit session (EOF)"),
|
||||
KeyBarButton("Tab", "补全", KeyByteMap.Key.TAB, "Tab — complete / toggle"),
|
||||
KeyBarButton("←", "左移", KeyByteMap.Key.ARROW_LEFT, "Left — move cursor left"),
|
||||
KeyBarButton("→", "右移", KeyByteMap.Key.ARROW_RIGHT, "Right — move cursor right"),
|
||||
KeyBarButton("/", "命令", KeyByteMap.Key.SLASH, "Slash — command launcher"),
|
||||
)
|
||||
|
||||
/**
|
||||
* The single tap→wire funnel: resolve [key]'s bytes through [KeyByteMap] and hand
|
||||
* them VERBATIM to [sink] (invariant #9 — no content filtering). This is exactly
|
||||
* what a button's `onClick` invokes, and what the JVM byte-contract test drives.
|
||||
*/
|
||||
internal fun emitKey(key: KeyByteMap.Key, sink: (String) -> Unit) {
|
||||
sink(KeyByteMap.bytes(key))
|
||||
}
|
||||
|
||||
/** Web parity: the key-bar is a mobile affordance, hidden on wide screens (web hides it >768px). */
|
||||
internal const val WIDE_SCREEN_MAX_DP: Int = 768
|
||||
|
||||
/**
|
||||
* Whether the key-bar should render: shown on narrow (phone-width) screens only,
|
||||
* AND hidden when a hardware keyboard is present (the physical keys make the
|
||||
* touch bar redundant — the iPad-equivalent auto-hide, plan §"iPad-equivalent").
|
||||
*/
|
||||
internal fun shouldShowKeyBar(screenWidthDp: Int, hardwareKeyboardPresent: Boolean): Boolean =
|
||||
screenWidthDp <= WIDE_SCREEN_MAX_DP && !hardwareKeyboardPresent
|
||||
|
||||
/**
|
||||
* Best-effort hardware-keyboard heuristic from the current [Configuration]
|
||||
* (accepted-less-reliable, plan §"iPad-equivalent" — no `GCKeyboard` equivalent):
|
||||
* an alphabetic keyboard that is currently exposed (lid open / dock attached).
|
||||
*/
|
||||
internal fun isHardwareKeyboardPresent(keyboard: Int, hardKeyboardHidden: Int): Boolean =
|
||||
keyboard == Configuration.KEYBOARD_QWERTY && hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO
|
||||
|
||||
/**
|
||||
* The mobile key-bar overlay. Pinned ABOVE the IME via [WindowInsets.ime] (falling
|
||||
* back to the navigation-bar inset when the keyboard is hidden), horizontally
|
||||
* scrollable so every key stays reachable on a narrow phone. Renders nothing on
|
||||
* wide screens or when a hardware keyboard is present ([shouldShowKeyBar]).
|
||||
*
|
||||
* @param onSend the IME-bypass sink — A21 wires it to `TerminalSessionController.sendInput`.
|
||||
*/
|
||||
@Composable
|
||||
public fun KeyBar(
|
||||
onSend: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val config = LocalConfiguration.current
|
||||
val hardwareKeyboard = isHardwareKeyboardPresent(config.keyboard, config.hardKeyboardHidden)
|
||||
if (!shouldShowKeyBar(config.screenWidthDp, hardwareKeyboard)) return
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars))
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
for (button in KEYBAR_LAYOUT) {
|
||||
KeyBarKey(button = button, onSend = onSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One inert key button. A tap emits bytes directly via [emitKey] — never through a text field. */
|
||||
@Composable
|
||||
private fun KeyBarKey(button: KeyBarButton, onSend: (String) -> Unit) {
|
||||
val container =
|
||||
if (button.isPrimary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
|
||||
val content =
|
||||
if (button.isPrimary) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Surface(
|
||||
onClick = { emitKey(button.key, onSend) },
|
||||
shape = RoundedCornerShape(Radius.sm8),
|
||||
color = container,
|
||||
contentColor = content,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = LayoutTokens.minHitTarget, minHeight = LayoutTokens.minHitTarget)
|
||||
.semantics { contentDescription = button.title },
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
|
||||
) {
|
||||
Text(text = button.label, style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
text = button.caption,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = content.copy(alpha = Opacity.stale),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The DECCKM split router (plan §6.3 source #3): decides whether a hardware key is
|
||||
* handled app-side (mapped through [KeyByteMap]) or LEFT to Termux's `KeyHandler`.
|
||||
*
|
||||
* Pure decision logic (primitive keyCode + modifier booleans, mirroring the Android
|
||||
* `KeyEvent` constants) so it is JVM-unit-testable with no device / Robolectric.
|
||||
* The Android glue lives in [handle].
|
||||
*/
|
||||
public object HardwareKeyRouter {
|
||||
|
||||
/** The ONLY Ctrl-<letter> chords the app claims — exactly the web key-bar's control keys. */
|
||||
private val CTRL_LETTER_KEYS: Map<Int, KeyByteMap.Key> = mapOf(
|
||||
KeyEvent.KEYCODE_C to KeyByteMap.Key.CTRL_C,
|
||||
KeyEvent.KEYCODE_R to KeyByteMap.Key.CTRL_R,
|
||||
KeyEvent.KEYCODE_O to KeyByteMap.Key.CTRL_O,
|
||||
KeyEvent.KEYCODE_L to KeyByteMap.Key.CTRL_L,
|
||||
KeyEvent.KEYCODE_T to KeyByteMap.Key.CTRL_T,
|
||||
KeyEvent.KEYCODE_B to KeyByteMap.Key.CTRL_B,
|
||||
KeyEvent.KEYCODE_D to KeyByteMap.Key.CTRL_D,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a hardware key to a routing decision.
|
||||
*
|
||||
* - **⇧Tab** → app-handled `ESC[Z`; **plain Tab** defers (completion / DECCKM nav).
|
||||
* - **Esc** (unmodified) → app-handled `ESC`.
|
||||
* - **Ctrl+{C,R,O,L,T,B,D}** → app-handled control byte via [KeyByteMap].
|
||||
* - **Everything else** (arrows, Enter, plain Tab, plain letters, unmapped Ctrl
|
||||
* chords) → [HardwareKeyResult.DeferToTerminal] so Termux's `KeyHandler`
|
||||
* emits the DECCKM-correct sequence (`ESC O A` under application-cursor mode).
|
||||
*/
|
||||
public fun resolve(keyCode: Int, ctrl: Boolean, shift: Boolean, alt: Boolean): HardwareKeyResult {
|
||||
// ⇧Tab is app-handled; plain Tab is left to Termux (do this BEFORE the generic defer).
|
||||
if (keyCode == KeyEvent.KEYCODE_TAB) {
|
||||
return if (shift && !ctrl && !alt) app(KeyByteMap.Key.SHIFT_TAB) else HardwareKeyResult.DeferToTerminal
|
||||
}
|
||||
// Esc → interrupt Claude; a modified Escape is not our chord.
|
||||
if (keyCode == KeyEvent.KEYCODE_ESCAPE) {
|
||||
return if (!ctrl && !shift && !alt) app(KeyByteMap.Key.ESC) else HardwareKeyResult.DeferToTerminal
|
||||
}
|
||||
// The mapped Ctrl-<letter> chords only; unmapped Ctrl letters defer to Termux.
|
||||
if (ctrl && !alt) {
|
||||
CTRL_LETTER_KEYS[keyCode]?.let { return app(it) }
|
||||
}
|
||||
// Arrows / Enter / plain Tab / plain keys → Termux KeyHandler (DECCKM-correct, §6.3).
|
||||
return HardwareKeyResult.DeferToTerminal
|
||||
}
|
||||
|
||||
/**
|
||||
* Android glue for `RemoteTerminalView.onKeyCommand`: extract modifiers from
|
||||
* [event], route via [resolve], and on an app-handled DOWN emit the bytes to
|
||||
* [onSend], returning true to CONSUME the event. Returns false otherwise so the
|
||||
* stock Termux path (`KeyHandler`) handles arrows/Enter/Tab. Device-QA'd
|
||||
* (touches `KeyEvent` accessor methods).
|
||||
*/
|
||||
public fun handle(keyCode: Int, event: KeyEvent, onSend: (String) -> Unit): Boolean {
|
||||
if (event.action != KeyEvent.ACTION_DOWN) return false
|
||||
return when (val result = resolve(keyCode, event.isCtrlPressed, event.isShiftPressed, event.isAltPressed)) {
|
||||
is HardwareKeyResult.AppHandled -> {
|
||||
onSend(result.bytes)
|
||||
true
|
||||
}
|
||||
|
||||
HardwareKeyResult.DeferToTerminal -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun app(key: KeyByteMap.Key): HardwareKeyResult =
|
||||
HardwareKeyResult.AppHandled(KeyByteMap.bytes(key))
|
||||
}
|
||||
|
||||
/** The routing decision for one hardware key (the DECCKM split, plan §6.3). */
|
||||
public sealed interface HardwareKeyResult {
|
||||
/** The app consumes the key and sends these exact [bytes] (resolved via [KeyByteMap]). */
|
||||
public data class AppHandled(val bytes: String) : HardwareKeyResult
|
||||
|
||||
/** The key is left to Termux's `KeyHandler` so DECCKM stays correct (arrows/Enter/Tab). */
|
||||
public data object DeferToTerminal : HardwareKeyResult
|
||||
}
|
||||
|
||||
// ── Preview ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "KeyBar")
|
||||
@Composable
|
||||
private fun KeyBarPreview() {
|
||||
WebTermTheme {
|
||||
KeyBar(onSend = {})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user