Compare commits
41 Commits
feat/tunne
...
7c1d43376d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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.
|
||||
|
||||
12
README.md
12
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).
|
||||
@@ -113,7 +121,7 @@ This wires Claude Code's hooks → **live per-tab status**, the **statusLine gau
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
npm test # vitest, all modules (~1470 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/
|
||||
```
|
||||
@@ -200,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).
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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,
|
||||
@@ -98,7 +99,7 @@ async function enrollNative(
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
): Promise<NativeEnrollResult> {
|
||||
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks)
|
||||
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true })
|
||||
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
|
||||
}
|
||||
|
||||
@@ -156,9 +157,12 @@ export function readFrpcLog(stateDir: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Native run-loop (B4/H4): supervise the pinned frpc child with restart-on-exit backoff while a
|
||||
* 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). Resolves when the supervisor stops (SIGTERM/SIGINT).
|
||||
* 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')
|
||||
@@ -184,12 +188,28 @@ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
||||
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())
|
||||
return handle.done.finally(() => {
|
||||
monitor.stop()
|
||||
autoRenew?.stop()
|
||||
})
|
||||
}
|
||||
|
||||
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
||||
|
||||
@@ -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,10 +70,32 @@ 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') {
|
||||
throw new EnrollError('enroll response missing hostContentSecret')
|
||||
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,
|
||||
@@ -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).
|
||||
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||
ks.saveContentSecret(unwrapped)
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* - 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,
|
||||
@@ -202,13 +203,37 @@ export async function installService(
|
||||
// so nothing is ever written for a rejected install.
|
||||
const baseAppEnv = resolveBaseAppEnv(cfg, options)
|
||||
const bin = deps.binPath()
|
||||
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
|
||||
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, baseAppEnv, baseAppLabel()))
|
||||
deps.writeFile(
|
||||
baseAppPath,
|
||||
buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')),
|
||||
)
|
||||
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
|
||||
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, 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)
|
||||
@@ -217,8 +242,8 @@ export async function installService(
|
||||
}
|
||||
|
||||
const baseAppOptions: SystemdUnitOptions = options.envFile
|
||||
? { env: baseAppEnv, envFile: options.envFile }
|
||||
: { env: baseAppEnv }
|
||||
? { env: baseAppEnvWithPath, envFile: options.envFile }
|
||||
: { env: baseAppEnvWithPath }
|
||||
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
|
||||
deps.writeFile(
|
||||
baseAppPath,
|
||||
@@ -227,7 +252,7 @@ export async function installService(
|
||||
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
|
||||
deps.writeFile(
|
||||
agentPath,
|
||||
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'),
|
||||
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)
|
||||
|
||||
@@ -78,6 +78,7 @@ export function buildLaunchdPlist(
|
||||
programArguments: readonly string[],
|
||||
env: ServiceEnv = {},
|
||||
label: string = AGENT_LABEL,
|
||||
logPath?: string,
|
||||
): string {
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
@@ -91,6 +92,16 @@ export function buildLaunchdPlist(
|
||||
' <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>',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,12 @@ export interface FrpSuperviseHandle {
|
||||
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. */
|
||||
@@ -196,5 +202,8 @@ export function superviseFrpc(
|
||||
},
|
||||
done,
|
||||
isChildAlive: () => child?.isAlive() ?? false,
|
||||
restartChild(): void {
|
||||
if (!stopped) child?.kill()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,4 +145,44 @@ describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,8 +81,10 @@ describe('installService — two distinct units (FIX M-host-2service)', () => {
|
||||
expect(d.writes).toHaveLength(2)
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
const agent = unitWith(d, agentUnitName())
|
||||
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback)
|
||||
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
|
||||
// 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')
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -66,6 +66,25 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
|
||||
- [ ] 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.
|
||||
|
||||
@@ -9,22 +9,20 @@ This directory is a **Gradle multi-module** project. The module set mirrors the
|
||||
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
||||
upward* (ARCHITECTURE §1).
|
||||
|
||||
## ⚠️ No-SDK constraint (why only 5 modules build here)
|
||||
## Build environment (SDK installed — all modules build)
|
||||
|
||||
The current build environment has **no Android SDK**. Everything that can be pure
|
||||
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the
|
||||
Android framework (`com.android.*` plugins) is **scaffolded but disabled**.
|
||||
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.
|
||||
|
||||
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):**
|
||||
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`.
|
||||
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts)
|
||||
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
|
||||
- **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`.
|
||||
|
||||
To bring the Android modules online later: install an SDK, add
|
||||
`local.properties` → `sdk.dir`, add the Android Gradle Plugin + `google()` to
|
||||
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in
|
||||
each stub.
|
||||
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)
|
||||
|
||||
@@ -35,10 +33,10 @@ each stub.
|
||||
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
|
||||
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
|
||||
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
|
||||
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated |
|
||||
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated |
|
||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated |
|
||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated |
|
||||
| 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
|
||||
@@ -47,16 +45,16 @@ each stub.
|
||||
### Dependency graph (arrows = "depends on")
|
||||
|
||||
```
|
||||
:app (SDK-gated)
|
||||
:app
|
||||
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
:terminal-view :session-core :api-client :host-registry :client-tls-android
|
||||
(SDK-gated) │ │ (SDK-gated) │
|
||||
│ │ │ │
|
||||
│ │ │ ▼
|
||||
│ │ │ :client-tls (pure)
|
||||
└──────┬───────┴──────────┴──────────────┬────────────────┘
|
||||
▼ ▼
|
||||
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
|
||||
:wire-protocol ◀──────────── :transport-okhttp
|
||||
▲
|
||||
└──────── :test-support → test source sets only
|
||||
```
|
||||
|
||||
@@ -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,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,
|
||||
)
|
||||
@@ -34,6 +34,12 @@ public data class ProjectInfo(
|
||||
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(),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
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
|
||||
@@ -87,6 +98,43 @@ public class ApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `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 {
|
||||
@@ -132,6 +180,50 @@ public class ApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
|
||||
@@ -44,6 +44,9 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
|
||||
/** 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。")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ internal object HttpStatus {
|
||||
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). */
|
||||
|
||||
@@ -57,6 +57,28 @@ internal object Endpoints {
|
||||
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 =
|
||||
@@ -92,6 +114,58 @@ internal object Endpoints {
|
||||
|
||||
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
|
||||
@@ -124,4 +198,22 @@ internal object Endpoints {
|
||||
|
||||
@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,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,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,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) })
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@ import okhttp3.OkHttpClient
|
||||
import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository
|
||||
import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter
|
||||
import wang.yaojia.webterm.tlsandroid.CertStore
|
||||
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.tlsandroid.TinkCertStore
|
||||
import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
@@ -41,16 +44,38 @@ public object TlsModule {
|
||||
@Singleton
|
||||
public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context)
|
||||
|
||||
/** The Tink-AEAD-encrypted enrollment record (deviceId + key alias) the zero-`.p12` renew path reads. */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideIdentityRepository(
|
||||
public fun provideEnrollmentRecordStore(
|
||||
@ApplicationContext context: Context,
|
||||
): EnrollmentRecordStore = TinkEnrollmentRecordStore(context)
|
||||
|
||||
/**
|
||||
* The ONE mTLS device-identity repository, provided as the concrete type so BOTH the
|
||||
* [IdentityRepository] surface (import/rotate/remove/summary) and the narrow [IdentityCacheRefresher]
|
||||
* seam (B4 enroll cache-freshness) resolve to the SAME singleton instance.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideAndroidIdentityRepository(
|
||||
importer: AndroidKeyStoreImporter,
|
||||
certStore: CertStore,
|
||||
connectionPool: ConnectionPool,
|
||||
): IdentityRepository {
|
||||
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()'s
|
||||
): AndroidIdentityRepository {
|
||||
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()/refresh's
|
||||
// `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8).
|
||||
val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build()
|
||||
return AndroidIdentityRepository(importer, certStore, evictClient)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideIdentityRepository(repository: AndroidIdentityRepository): IdentityRepository = repository
|
||||
|
||||
/** FIX 3: the enroll/renew commit refreshes THIS same repository's in-memory cache (no restart). */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher =
|
||||
repository
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ public fun WebTermNavHost(
|
||||
|
||||
composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) }
|
||||
|
||||
composable(NavRoutes.ENROLL) { EnrollmentPane(env = env, onBack = { navController.popBackStack() }) }
|
||||
|
||||
composable(
|
||||
route = TERMINAL_ROUTE,
|
||||
arguments = listOf(
|
||||
@@ -211,6 +213,9 @@ public object NavRoutes {
|
||||
/** Device-certificate management (A27). */
|
||||
public const val CERT: String = "cert"
|
||||
|
||||
/** Zero-`.p12` device enrollment (B4) — obtains a hardware-bound cert with no file. */
|
||||
public const val ENROLL: String = "enroll"
|
||||
|
||||
/** Terminal route pattern with the required host + session path args (A21). */
|
||||
public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}"
|
||||
|
||||
|
||||
@@ -20,10 +20,13 @@ import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.screens.ClientCertScreen
|
||||
import wang.yaojia.webterm.screens.DiffScreen
|
||||
import wang.yaojia.webterm.screens.EnrollmentScreen
|
||||
import wang.yaojia.webterm.screens.PairingScreen
|
||||
import wang.yaojia.webterm.screens.ProjectDetailScreen
|
||||
import wang.yaojia.webterm.viewmodels.ApiClientGitWriteGateway
|
||||
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
|
||||
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
||||
import wang.yaojia.webterm.viewmodels.EnrollmentViewModel
|
||||
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
||||
import wang.yaojia.webterm.viewmodels.HttpDiffFetcher
|
||||
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
||||
@@ -74,6 +77,38 @@ public fun ClientCertPane(
|
||||
ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
|
||||
}
|
||||
|
||||
// ── Device enrollment (B4, zero-.p12 auto-cert) ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hosts [EnrollmentScreen] over a fresh [EnrollmentViewModel] (mirrors iOS `AppCoordinator`'s
|
||||
* `makeEnrollmentViewModel`). The enroll flow and the installed-summary read run OFF `Main`
|
||||
* (`Dispatchers.IO`) — resolving the enroller resolves the lazy shared client (keystore/TLS I/O) and the
|
||||
* enroll itself does hardware-keygen + network. The typed control-plane URL builds the enroller per attempt.
|
||||
*/
|
||||
@Composable
|
||||
public fun EnrollmentPane(
|
||||
env: AppEnvironment,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = remember(env) {
|
||||
EnrollmentViewModel(
|
||||
enrollOperation = { password, subdomain, deviceName, controlPlaneUrl ->
|
||||
withContext(Dispatchers.IO) {
|
||||
env.enrollmentFlowFactory.create(controlPlaneUrl).enroll(password, subdomain, deviceName)
|
||||
}
|
||||
},
|
||||
loadSummary = {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { env.identityRepository.currentSummary() }.getOrNull()
|
||||
}
|
||||
},
|
||||
defaultDeviceName = android.os.Build.MODEL ?: "",
|
||||
)
|
||||
}
|
||||
EnrollmentScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
|
||||
}
|
||||
|
||||
// ── Project detail (A23) ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -106,6 +141,7 @@ public fun ProjectDetailPane(
|
||||
path = path,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
||||
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -118,9 +154,10 @@ public fun ProjectDetailContent(
|
||||
onBack: () -> Unit,
|
||||
onOpenClaude: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onViewDiff: (String) -> Unit = {},
|
||||
) {
|
||||
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
|
||||
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier)
|
||||
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier, onViewDiff = onViewDiff)
|
||||
}
|
||||
|
||||
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -151,7 +188,12 @@ public fun DiffPane(
|
||||
return
|
||||
}
|
||||
val viewModel = remember(resolved, path) {
|
||||
DiffViewModel(fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), path = path)
|
||||
DiffViewModel(
|
||||
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport),
|
||||
path = path,
|
||||
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
|
||||
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
|
||||
)
|
||||
}
|
||||
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ public fun ProjectsHome(
|
||||
path = path,
|
||||
onBack = { selectedPath = null },
|
||||
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
||||
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
|
||||
)
|
||||
} else {
|
||||
DetailPlaceholder("选择一个项目查看详情。")
|
||||
|
||||
@@ -87,6 +87,7 @@ public fun SessionsHome(
|
||||
onOpenSession = openSession,
|
||||
onNewSession = openNewSession,
|
||||
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
|
||||
onEnroll = { navController.navigate(NavRoutes.ENROLL) },
|
||||
onImportCert = { navController.navigate(NavRoutes.CERT) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,13 +15,17 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -44,15 +48,17 @@ import wang.yaojia.webterm.viewmodels.DiffPhase
|
||||
import wang.yaojia.webterm.viewmodels.DiffRow
|
||||
import wang.yaojia.webterm.viewmodels.DiffUiState
|
||||
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
||||
import wang.yaojia.webterm.viewmodels.DiffWriteBanner
|
||||
|
||||
/**
|
||||
* # DiffScreen (A24) — the read-only staged/unstaged git-diff viewer.
|
||||
* # DiffScreen (A24 + W5) — the git-diff viewer with base-compare + git-write.
|
||||
*
|
||||
* Renders the presenter's flattened files→hunks→lines list in a `LazyColumn`, with a Working/Staged
|
||||
* toggle in the header. Every server-derived string (paths, hunk headers, code lines) is rendered as
|
||||
* **inert monospaced [Text]** — plain `Text`, never `ClickableText`/`LinkAnnotation`/autolink/markdown
|
||||
* — so a hostile diff cannot inject a tappable link or markup (plan §8). Line kinds carry the A13
|
||||
* colour tokens (added → green, removed → red). Layout/interaction is device-QA (plan §7).
|
||||
* toggle (hidden in base mode), a **base-rev** input (a third mode), per-file **Stage/Unstage** buttons
|
||||
* (working/staged mode only), a **commit** message field + **Commit** / **Push** buttons, and a result
|
||||
* **banner**. Every server-derived string (paths, hunk headers, code lines, git error messages) is
|
||||
* rendered as **inert [Text]** — never `ClickableText`/autolink/markdown (plan §8). Interaction is
|
||||
* device-QA (plan §7).
|
||||
*/
|
||||
@Composable
|
||||
public fun DiffScreen(
|
||||
@@ -61,29 +67,37 @@ public fun DiffScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onRefresh: () -> Unit = {},
|
||||
onBack: (() -> Unit)? = null,
|
||||
onSetBase: (String?) -> Unit = {},
|
||||
onToggleStage: (String, Boolean) -> Unit = { _, _ -> },
|
||||
onCommit: (String) -> Unit = {},
|
||||
onPush: () -> Unit = {},
|
||||
onDismissBanner: () -> Unit = {},
|
||||
) {
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
DiffHeader(staged = state.staged, onSelectStaged = onSelectStaged, onBack = onBack)
|
||||
if (state.truncated) {
|
||||
DiffNotice("Diff truncated — too large to display fully.")
|
||||
}
|
||||
DiffHeader(state = state, onSelectStaged = onSelectStaged, onBack = onBack, onSetBase = onSetBase)
|
||||
if (state.truncated) DiffNotice("Diff truncated — too large to display fully.")
|
||||
state.writeBanner?.let { WriteBanner(it, onDismissBanner) }
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (state.phase) {
|
||||
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
|
||||
DiffPhase.EMPTY -> CenteredMessage("No changes")
|
||||
DiffPhase.ERROR -> DiffError(onRetry = onRefresh)
|
||||
DiffPhase.LOADED -> DiffList(rows = state.rows)
|
||||
DiffPhase.LOADED -> DiffList(rows = state.rows, writeEnabled = state.writeEnabled, staged = state.staged, onToggleStage = onToggleStage)
|
||||
}
|
||||
}
|
||||
if (state.writeEnabled) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
|
||||
CommitBar(writing = state.writing, onCommit = onCommit, onPush = onPush)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful entry point: binds [viewModel] to a lifecycle scope, collects its state, and wires the
|
||||
* toggle/refresh callbacks. The nav layer supplies the already-constructed presenter (host + path).
|
||||
* toggle/refresh/base/git-write callbacks. The nav layer supplies the already-constructed presenter.
|
||||
*/
|
||||
@Composable
|
||||
public fun DiffScreen(
|
||||
@@ -92,7 +106,6 @@ public fun DiffScreen(
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
// Bind to the LaunchedEffect scope (cancelled when this screen leaves composition), then load.
|
||||
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||
DiffScreen(
|
||||
state = state,
|
||||
@@ -100,50 +113,60 @@ public fun DiffScreen(
|
||||
modifier = modifier,
|
||||
onRefresh = viewModel::refresh,
|
||||
onBack = onBack,
|
||||
onSetBase = viewModel::setBase,
|
||||
onToggleStage = viewModel::toggleStage,
|
||||
onCommit = viewModel::commit,
|
||||
onPush = viewModel::push,
|
||||
onDismissBanner = viewModel::clearWriteBanner,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffHeader(
|
||||
staged: Boolean,
|
||||
state: DiffUiState,
|
||||
onSelectStaged: (Boolean) -> Unit,
|
||||
onBack: (() -> Unit)?,
|
||||
onSetBase: (String?) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
if (onBack != null) {
|
||||
TextButton(onClick = onBack) { Text("Back") }
|
||||
var baseInput by remember(state.base) { mutableStateOf(state.base ?: "") }
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
if (onBack != null) TextButton(onClick = onBack) { Text("Back") }
|
||||
Text(text = "Diff", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
|
||||
Spacer(modifier = Modifier.width(Spacing.sm8))
|
||||
if (state.base == null) {
|
||||
// Working/Staged toggle is suppressed in base mode (server ignores staged then).
|
||||
FilterChip(selected = !state.staged, onClick = { onSelectStaged(false) }, label = { Text("Working") })
|
||||
FilterChip(selected = state.staged, onClick = { onSelectStaged(true) }, label = { Text("Staged") })
|
||||
} else {
|
||||
Text(text = "vs ${state.base}", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(top = Spacing.xs4)) {
|
||||
OutlinedTextField(
|
||||
value = baseInput,
|
||||
onValueChange = { baseInput = it },
|
||||
label = { Text("对比基点 (base rev)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedButton(onClick = { onSetBase(baseInput.takeIf { it.isNotBlank() }) }) { Text("对比") }
|
||||
if (state.base != null) OutlinedButton(onClick = { baseInput = ""; onSetBase(null) }) { Text("清除") }
|
||||
}
|
||||
Text(
|
||||
text = "Diff",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(Spacing.sm8))
|
||||
FilterChip(
|
||||
selected = !staged,
|
||||
onClick = { onSelectStaged(false) },
|
||||
label = { Text("Working") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = staged,
|
||||
onClick = { onSelectStaged(true) },
|
||||
label = { Text("Staged") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffList(rows: List<DiffRow>) {
|
||||
private fun DiffList(
|
||||
rows: List<DiffRow>,
|
||||
writeEnabled: Boolean,
|
||||
staged: Boolean,
|
||||
onToggleStage: (String, Boolean) -> Unit,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(items = rows, key = { it.id }) { row ->
|
||||
when (row) {
|
||||
is DiffFileHeaderRow -> FileHeader(row)
|
||||
is DiffFileHeaderRow -> FileHeader(row, writeEnabled = writeEnabled, staged = staged, onToggleStage = onToggleStage)
|
||||
is DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary)
|
||||
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
|
||||
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -153,11 +176,14 @@ private fun DiffList(rows: List<DiffRow>) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileHeader(row: DiffFileHeaderRow) {
|
||||
private fun FileHeader(
|
||||
row: DiffFileHeaderRow,
|
||||
writeEnabled: Boolean,
|
||||
staged: Boolean,
|
||||
onToggleStage: (String, Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -172,6 +198,42 @@ private fun FileHeader(row: DiffFileHeaderRow) {
|
||||
)
|
||||
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
|
||||
Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck)
|
||||
if (writeEnabled) {
|
||||
// In staged view we offer Unstage; in working view we offer Stage.
|
||||
TextButton(onClick = { onToggleStage(row.stagePath, !staged) }) { Text(if (staged) "取消暂存" else "暂存") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitBar(writing: Boolean, onCommit: (String) -> Unit, onPush: () -> Unit) {
|
||||
var message by remember { mutableStateOf("") }
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
OutlinedTextField(
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
label = { Text("提交信息") },
|
||||
singleLine = true,
|
||||
enabled = !writing,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(enabled = !writing, onClick = { onCommit(message); message = "" }) { Text("提交") }
|
||||
OutlinedButton(enabled = !writing, onClick = onPush) { Text("推送") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WriteBanner(banner: DiffWriteBanner, onDismiss: () -> Unit) {
|
||||
val color = if (banner.isError) WebTermColors.statusStuck else WebTermColors.statusWorking
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
Text(text = banner.message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onDismiss) { Text("×") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,9 +247,7 @@ private fun DiffText(text: String, color: Color) {
|
||||
softWrap = false,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Clip,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = 1.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -207,17 +267,13 @@ private fun DiffNotice(message: String) {
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(message: String) {
|
||||
CenteredContent {
|
||||
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
CenteredContent { Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -246,17 +302,15 @@ private fun markerFor(kind: DiffLineKind): String = when (kind) {
|
||||
@Composable
|
||||
private fun DiffScreenPreview() {
|
||||
val rows = listOf<DiffRow>(
|
||||
DiffFileHeaderRow(0, "src/app/Main.kt", "modified", added = 2, removed = 1),
|
||||
DiffFileHeaderRow(0, "src/app/Main.kt", "src/app/Main.kt", "modified", added = 2, removed = 1),
|
||||
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
|
||||
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
|
||||
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
|
||||
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
|
||||
DiffLineRow(5, DiffLineKind.ADDED, " println(\"added\")"),
|
||||
DiffLineRow(6, DiffLineKind.CONTEXT, "}"),
|
||||
)
|
||||
WebTermTheme {
|
||||
DiffScreen(
|
||||
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true),
|
||||
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true, canWrite = true),
|
||||
onSelectStaged = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package wang.yaojia.webterm.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.designsystem.WebTermColors
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
import wang.yaojia.webterm.viewmodels.CertSummaryView
|
||||
import wang.yaojia.webterm.viewmodels.EnrollError
|
||||
import wang.yaojia.webterm.viewmodels.EnrollPhase
|
||||
import wang.yaojia.webterm.viewmodels.EnrollmentUiState
|
||||
import wang.yaojia.webterm.viewmodels.EnrollmentViewModel
|
||||
|
||||
/**
|
||||
* # EnrollmentScreen (B4) — zero-`.p12` device enrollment (the phone half of zero-touch).
|
||||
*
|
||||
* The Compose shell over [EnrollmentViewModel], reached from the session-list host menu "自动获取证书"
|
||||
* (mirrors iOS `EnrollmentScreen` presented from `SessionListScreen`'s host menu). One operator login
|
||||
* generates a NON-EXPORTABLE hardware key + CSR and obtains a device cert with no file at all; the cert is
|
||||
* committed to the shared store and presented automatically on the existing mTLS path (cache-refreshed, so
|
||||
* no restart). The manual `.p12` path ([ClientCertScreen]) remains available alongside this one.
|
||||
*
|
||||
* ### Secrets & trust discipline (plan §8)
|
||||
* - The operator password is a masked field bound straight to the ViewModel and cleared after every
|
||||
* attempt — never logged, persisted, or echoed. The private key is generated non-exportably in secure
|
||||
* hardware inside the library; this screen never sees it.
|
||||
* - Every cert-derived string (CNs, expiry) and every error message is inert [Text] — no autolink/markdown.
|
||||
* Error copy is app-authored, never a server/exception string.
|
||||
*
|
||||
* The keystore/network I/O it drives is device-QA (plan §7); the state machine is JVM-tested in
|
||||
* `EnrollmentViewModelTest`.
|
||||
*/
|
||||
@Composable
|
||||
public fun EnrollmentScreen(
|
||||
viewModel: EnrollmentViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||
|
||||
EnrollmentScreen(
|
||||
state = state,
|
||||
onControlPlaneUrlChange = viewModel::onControlPlaneUrlChange,
|
||||
onSubdomainChange = viewModel::onSubdomainChange,
|
||||
onDeviceNameChange = viewModel::onDeviceNameChange,
|
||||
onPasswordChange = viewModel::onPasswordChange,
|
||||
onEnroll = viewModel::enroll,
|
||||
onDismissError = viewModel::clearError,
|
||||
modifier = modifier,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stateless body — pure inputs so a [Preview] and any host can drive it without the VM machinery. */
|
||||
@Composable
|
||||
public fun EnrollmentScreen(
|
||||
state: EnrollmentUiState,
|
||||
onControlPlaneUrlChange: (String) -> Unit,
|
||||
onSubdomainChange: (String) -> Unit,
|
||||
onDeviceNameChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onEnroll: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(Spacing.lg16),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.md12),
|
||||
) {
|
||||
Header(onBack = onBack)
|
||||
|
||||
if (state.phase == EnrollPhase.LOADING) {
|
||||
LoadingRow()
|
||||
return@Column
|
||||
}
|
||||
|
||||
InstalledSection(summary = state.summary)
|
||||
|
||||
state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) }
|
||||
if (state.didSucceed && state.error == null) SuccessCard()
|
||||
|
||||
EnrollForm(
|
||||
state = state,
|
||||
onControlPlaneUrlChange = onControlPlaneUrlChange,
|
||||
onSubdomainChange = onSubdomainChange,
|
||||
onDeviceNameChange = onDeviceNameChange,
|
||||
onPasswordChange = onPasswordChange,
|
||||
onEnroll = onEnroll,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = EnrollCopy.FOOTER,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(onBack: (() -> Unit)?) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
if (onBack != null) {
|
||||
TextButton(onClick = onBack) { Text("返回") }
|
||||
}
|
||||
Text(text = EnrollCopy.TITLE, style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
|
||||
/** The currently-installed identity summary (or "none" copy) — the "已安装证书" section. */
|
||||
@Composable
|
||||
private fun InstalledSection(summary: CertSummaryView?) {
|
||||
if (summary == null) {
|
||||
Text(
|
||||
text = EnrollCopy.NONE_INSTALLED,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Spacing.lg16),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||
) {
|
||||
SummaryRow(label = "设备(CN)", value = summary.subjectCommonName)
|
||||
SummaryRow(label = "签发方(CN)", value = summary.issuerCommonName)
|
||||
SummaryRow(label = "到期", value = summary.expiry)
|
||||
if (summary.isExpired) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Text(
|
||||
text = "⚠ 证书已过期,请重新注册。",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = WebTermColors.statusStuck,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Cert-derived value: inert monospaced Text — no linkify/markdown (plan §8).
|
||||
Text(text = value, style = WebTermType.monoTabular(13), color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnrollForm(
|
||||
state: EnrollmentUiState,
|
||||
onControlPlaneUrlChange: (String) -> Unit,
|
||||
onSubdomainChange: (String) -> Unit,
|
||||
onDeviceNameChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onEnroll: () -> Unit,
|
||||
) {
|
||||
val enrolling = state.phase == EnrollPhase.ENROLLING
|
||||
Text(
|
||||
text = if (state.summary == null) EnrollCopy.ENROLL_HEADER else EnrollCopy.ROTATE_HEADER,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.controlPlaneUrl,
|
||||
onValueChange = onControlPlaneUrlChange,
|
||||
label = { Text(EnrollCopy.CONTROL_PLANE_URL) },
|
||||
singleLine = true,
|
||||
enabled = !enrolling,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.subdomain,
|
||||
onValueChange = onSubdomainChange,
|
||||
label = { Text(EnrollCopy.SUBDOMAIN) },
|
||||
singleLine = true,
|
||||
enabled = !enrolling,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.deviceName,
|
||||
onValueChange = onDeviceNameChange,
|
||||
label = { Text(EnrollCopy.DEVICE_NAME) },
|
||||
singleLine = true,
|
||||
enabled = !enrolling,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = { Text(EnrollCopy.PASSWORD) },
|
||||
singleLine = true,
|
||||
enabled = !enrolling,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = onEnroll,
|
||||
enabled = state.canEnroll,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (enrolling) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(Spacing.xs4))
|
||||
} else {
|
||||
Text(if (state.summary == null) EnrollCopy.ENROLL_ACTION else EnrollCopy.ROTATE_ACTION)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorCard(error: EnrollError, onDismiss: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Spacing.md12),
|
||||
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
|
||||
) {
|
||||
Text(
|
||||
text = errorCopy(error),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("知道了") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuccessCard() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Text(
|
||||
text = EnrollCopy.SUCCESS,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Spacing.md12),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingRow() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps the coarse [EnrollError] to inert, app-authored copy (never an exception/server string, §8). */
|
||||
private fun errorCopy(error: EnrollError): String = when (error) {
|
||||
EnrollError.INVALID_URL -> "控制面地址无效,请输入 https:// 开头的完整地址。"
|
||||
EnrollError.MISSING_FIELDS -> "请填写子域名、设备名称和操作口令。"
|
||||
EnrollError.BAD_CREDENTIAL -> "操作口令错误,请重试。"
|
||||
EnrollError.SUBDOMAIN_NOT_OWNED -> "该账号未拥有此子域名,无法签发证书。"
|
||||
EnrollError.RATE_LIMITED -> "请求过于频繁,请稍后再试。"
|
||||
EnrollError.REJECTED -> "请求被拒绝:子域名或证书请求无效。"
|
||||
EnrollError.ENROLL_FAILED -> "注册失败,请稍后重试。"
|
||||
EnrollError.SERVER -> "服务器返回异常,请稍后重试。"
|
||||
EnrollError.KEYGEN -> "生成硬件密钥失败(本设备可能不支持安全硬件)。"
|
||||
EnrollError.UNKNOWN -> "注册失败,请重试。"
|
||||
}
|
||||
|
||||
/** User-facing copy (Chinese), mirroring iOS `EnrollmentCopy`. */
|
||||
private object EnrollCopy {
|
||||
const val TITLE = "自动获取证书"
|
||||
const val NONE_INSTALLED = "尚未安装设备证书。"
|
||||
const val ENROLL_HEADER = "注册本设备"
|
||||
const val ROTATE_HEADER = "重新注册"
|
||||
const val CONTROL_PLANE_URL = "控制面地址"
|
||||
const val SUBDOMAIN = "子域名(你拥有的隧道名)"
|
||||
const val DEVICE_NAME = "设备名称"
|
||||
const val PASSWORD = "操作口令"
|
||||
const val ENROLL_ACTION = "注册本设备"
|
||||
const val ROTATE_ACTION = "重新注册"
|
||||
const val SUCCESS = "已注册,证书已保存到本设备安全硬件,连接隧道主机时将自动出示。"
|
||||
const val FOOTER =
|
||||
"首次登录一次即可:本设备在安全硬件(StrongBox / TEE)生成不可导出的私钥,向控制面申请证书并自动保存;" +
|
||||
"之后连接隧道主机时自动出示,无需再手动导入 .p12。"
|
||||
}
|
||||
|
||||
@Preview(name = "EnrollmentScreen — none installed")
|
||||
@Composable
|
||||
private fun EnrollmentScreenNonePreview() {
|
||||
WebTermTheme {
|
||||
EnrollmentScreen(
|
||||
state = EnrollmentUiState(
|
||||
controlPlaneUrl = "https://cp.terminal.yaojia.wang",
|
||||
subdomain = "alice",
|
||||
deviceName = "Pixel 8",
|
||||
phase = EnrollPhase.IDLE,
|
||||
),
|
||||
onControlPlaneUrlChange = {},
|
||||
onSubdomainChange = {},
|
||||
onDeviceNameChange = {},
|
||||
onPasswordChange = {},
|
||||
onEnroll = {},
|
||||
onDismissError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "EnrollmentScreen — installed + error")
|
||||
@Composable
|
||||
private fun EnrollmentScreenInstalledPreview() {
|
||||
WebTermTheme {
|
||||
EnrollmentScreen(
|
||||
state = EnrollmentUiState(
|
||||
controlPlaneUrl = "https://cp.terminal.yaojia.wang",
|
||||
subdomain = "alice",
|
||||
deviceName = "Pixel 8",
|
||||
summary = CertSummaryView("alice-pixel", "webterm-device-ca", "2027年1月8日", isExpired = false),
|
||||
phase = EnrollPhase.IDLE,
|
||||
error = EnrollError.SUBDOMAIN_NOT_OWNED,
|
||||
),
|
||||
onControlPlaneUrlChange = {},
|
||||
onSubdomainChange = {},
|
||||
onDeviceNameChange = {},
|
||||
onPasswordChange = {},
|
||||
onEnroll = {},
|
||||
onDismissError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,23 +9,36 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.api.models.CommitLogEntry
|
||||
import wang.yaojia.webterm.api.models.PrAvailability
|
||||
import wang.yaojia.webterm.api.models.PrStatus
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.models.ProjectSessionRef
|
||||
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||
@@ -36,19 +49,19 @@ import wang.yaojia.webterm.designsystem.WebTermColors
|
||||
import wang.yaojia.webterm.designsystem.WebTermTheme
|
||||
import wang.yaojia.webterm.designsystem.WebTermType
|
||||
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
|
||||
import wang.yaojia.webterm.viewmodels.ProjectsCopy
|
||||
import wang.yaojia.webterm.viewmodels.WorktreeViewModel
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* # ProjectDetailScreen (A23) — one project's detail (branch · worktrees · sessions · CLAUDE.md) plus
|
||||
* "open Claude here". Mirrors web `renderProjectDetail` / iOS `ProjectDetailScreen`.
|
||||
* # ProjectDetailScreen (A23 + W5) — one project's detail (branch · worktrees · sessions · CLAUDE.md),
|
||||
* plus the W5 additions: a **PR + CI chip** (tappable only when the PR url parses as https), a
|
||||
* **recent-commits** section, and guarded **worktree create / remove / prune** actions.
|
||||
*
|
||||
* Every server string (name/path/branch/worktree/CLAUDE.md body) is rendered as **inert [Text]** — no
|
||||
* autolink/markdown (plan §8); the CLAUDE.md body is shown verbatim in a monospaced block. The three
|
||||
* failure buckets ([ProjectDetailViewModel.Failure]) map to copy + a retry action.
|
||||
*
|
||||
* @param onBack pop back to the projects grid.
|
||||
* @param onOpenClaude open a new session in the project cwd (`attach(null, cwd)`); the nav layer routes
|
||||
* it through [wang.yaojia.webterm.viewmodels.ProjectsViewModel.requestOpenClaude] (path re-validated).
|
||||
* Every server string (name/path/branch/worktree/CLAUDE.md/commit subject/PR title/error) is rendered
|
||||
* as **inert [Text]** — no autolink/markdown (plan §8). The single exception is the PR chip, which is a
|
||||
* link ONLY when its url is a valid https URL (scheme-validated before it is made clickable). The
|
||||
* worktree actions drive [ProjectDetailViewModel.worktree]; a remove force-confirms in a dialog and a
|
||||
* main worktree is never removable.
|
||||
*/
|
||||
@Composable
|
||||
public fun ProjectDetailScreen(
|
||||
@@ -56,8 +69,11 @@ public fun ProjectDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenClaude: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onViewDiff: (String) -> Unit = {},
|
||||
) {
|
||||
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
||||
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
|
||||
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(viewModel) { viewModel.load() }
|
||||
|
||||
@@ -71,7 +87,14 @@ public fun ProjectDetailScreen(
|
||||
is ProjectDetailViewModel.Phase.Failed ->
|
||||
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
|
||||
is ProjectDetailViewModel.Phase.Loaded ->
|
||||
DetailBody(detail = current.detail, onOpenClaude = onOpenClaude)
|
||||
DetailBody(
|
||||
detail = current.detail,
|
||||
prChip = prChip,
|
||||
recent = recent,
|
||||
worktree = viewModel.worktree,
|
||||
onOpenClaude = onOpenClaude,
|
||||
onViewDiff = onViewDiff,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +113,14 @@ private fun DetailHeaderBar(onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
|
||||
private fun DetailBody(
|
||||
detail: ProjectDetail,
|
||||
prChip: ProjectDetailViewModel.PrChip,
|
||||
recent: ProjectDetailViewModel.RecentCommits,
|
||||
worktree: WorktreeViewModel?,
|
||||
onOpenClaude: (String) -> Unit,
|
||||
onViewDiff: (String) -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -113,37 +143,236 @@ private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
|
||||
}
|
||||
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
||||
if (!detail.isGit) {
|
||||
EmptyLine("不是 git 仓库。")
|
||||
} else if (detail.worktrees.isEmpty()) {
|
||||
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
||||
PrChipRow(prChip)
|
||||
|
||||
if (detail.isGit && worktree != null) {
|
||||
WorktreeSection(detail = detail, worktree = worktree)
|
||||
} else {
|
||||
for (worktree in detail.worktrees) WorktreeRow(worktree)
|
||||
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
||||
if (!detail.isGit) EmptyLine("不是 git 仓库。")
|
||||
else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
||||
else for (w in detail.worktrees) WorktreeRow(w, onRemove = null)
|
||||
}
|
||||
|
||||
val running = detail.sessions.filter { !it.exited }
|
||||
SectionTitle("运行中的会话(${running.size})")
|
||||
if (running.isEmpty()) {
|
||||
EmptyLine("没有运行中的会话 —— 在下方开一个。")
|
||||
} else {
|
||||
for (session in running) SessionRow(session)
|
||||
}
|
||||
if (running.isEmpty()) EmptyLine("没有运行中的会话 —— 在下方开一个。")
|
||||
else for (session in running) SessionRow(session)
|
||||
|
||||
RecentCommitsSection(recent)
|
||||
|
||||
SectionTitle("CLAUDE.md")
|
||||
val claudeMd = detail.claudeMd
|
||||
if (detail.hasClaudeMd && claudeMd != null) {
|
||||
ClaudeMdBlock(claudeMd)
|
||||
} else {
|
||||
EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
|
||||
}
|
||||
if (detail.hasClaudeMd && claudeMd != null) ClaudeMdBlock(claudeMd)
|
||||
else EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
|
||||
|
||||
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
|
||||
if (detail.isGit) TextButton(onClick = { onViewDiff(detail.path) }) { Text("查看改动 (diff)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PR + CI chip (link only when https) ──────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PrChipRow(prChip: ProjectDetailViewModel.PrChip) {
|
||||
when (prChip) {
|
||||
ProjectDetailViewModel.PrChip.Hidden -> Unit
|
||||
ProjectDetailViewModel.PrChip.Loading -> EmptyLine("正在读取 PR 状态…")
|
||||
ProjectDetailViewModel.PrChip.Unavailable -> EmptyLine("PR 状态不可用。")
|
||||
is ProjectDetailViewModel.PrChip.Loaded -> PrChipContent(prChip.status)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorktreeRow(worktree: WorktreeInfo) {
|
||||
private fun PrChipContent(pr: PrStatus) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val httpsUrl = pr.url?.let { if (isHttpsUrl(it)) it else null } // link ONLY when https (plan §Security)
|
||||
val label = prChipLabel(pr)
|
||||
val color = prChipColor(pr)
|
||||
if (httpsUrl != null && pr.availability == PrAvailability.OK) {
|
||||
AssistChip(
|
||||
onClick = { runCatching { uriHandler.openUri(httpsUrl) } },
|
||||
label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
colors = AssistChipDefaults.assistChipColors(labelColor = color),
|
||||
)
|
||||
} else {
|
||||
// Non-ok / non-https → an INERT, non-clickable line (never make a hostile url tappable).
|
||||
Text(text = label, style = WebTermType.metaMono, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prChipLabel(pr: PrStatus): String = when (pr.availability) {
|
||||
PrAvailability.OK -> {
|
||||
val num = pr.number?.let { "#$it " } ?: ""
|
||||
val checks = pr.checks?.let { " (${it.passing}/${it.total})" } ?: ""
|
||||
"PR $num${pr.title ?: ""}$checks".trim()
|
||||
}
|
||||
PrAvailability.NO_PR -> "当前分支没有 PR"
|
||||
PrAvailability.NOT_INSTALLED -> "未安装 gh,无法读取 PR"
|
||||
PrAvailability.UNAUTHENTICATED -> "gh 未登录,无法读取 PR"
|
||||
PrAvailability.DISABLED -> "PR 集成已禁用"
|
||||
PrAvailability.ERROR -> "PR 状态读取失败"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun prChipColor(pr: PrStatus): androidx.compose.ui.graphics.Color = when {
|
||||
pr.availability != PrAvailability.OK -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
(pr.checks?.failing ?: 0) > 0 -> WebTermColors.statusStuck
|
||||
(pr.checks?.pending ?: 0) > 0 -> WebTermColors.statusWaiting
|
||||
else -> WebTermColors.statusWorking
|
||||
}
|
||||
|
||||
private fun isHttpsUrl(url: String): Boolean =
|
||||
runCatching { URI(url.trim()).scheme?.lowercase() == "https" }.getOrDefault(false)
|
||||
|
||||
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val phase by worktree.phase.collectAsStateWithLifecycle()
|
||||
var branch by remember { mutableStateOf("") }
|
||||
var base by remember { mutableStateOf("") }
|
||||
var removeTarget by remember { mutableStateOf<WorktreeInfo?>(null) }
|
||||
|
||||
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
||||
if (detail.worktrees.isEmpty()) {
|
||||
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
||||
} else {
|
||||
for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w })
|
||||
}
|
||||
|
||||
// New-worktree inline form.
|
||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
OutlinedTextField(
|
||||
value = branch,
|
||||
onValueChange = { branch = it },
|
||||
label = { Text("新工作树分支名") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = base,
|
||||
onValueChange = { base = it },
|
||||
label = { Text("基点(可选)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(
|
||||
enabled = phase != WorktreeViewModel.Phase.Working,
|
||||
onClick = { scope.launch { worktree.create(branch, base) } },
|
||||
) { Text("新建工作树") }
|
||||
OutlinedButton(
|
||||
enabled = phase != WorktreeViewModel.Phase.Working,
|
||||
onClick = { scope.launch { worktree.prune() } },
|
||||
) { Text("清理") }
|
||||
}
|
||||
WorktreePhaseBanner(phase, onDismiss = { worktree.reset() })
|
||||
}
|
||||
}
|
||||
|
||||
val target = removeTarget
|
||||
if (target != null) {
|
||||
RemoveWorktreeDialog(
|
||||
worktree = target,
|
||||
onConfirm = { force ->
|
||||
removeTarget = null
|
||||
scope.launch { worktree.remove(target, force) }
|
||||
},
|
||||
onDismiss = { removeTarget = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorktreePhaseBanner(phase: WorktreeViewModel.Phase, onDismiss: () -> Unit) {
|
||||
when (phase) {
|
||||
WorktreeViewModel.Phase.Idle -> Unit
|
||||
WorktreeViewModel.Phase.Working -> Text("处理中…", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
is WorktreeViewModel.Phase.Done -> BannerLine(phase.message, WebTermColors.statusWorking, onDismiss)
|
||||
is WorktreeViewModel.Phase.Failed -> BannerLine(phase.message, WebTermColors.statusStuck, onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerLine(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
Text(text = message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onDismiss) { Text("知道了") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoveWorktreeDialog(
|
||||
worktree: WorktreeInfo,
|
||||
onConfirm: (force: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var force by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("删除工作树") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = force, onCheckedChange = { force = it })
|
||||
Text("强制删除(丢弃未提交改动)")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(force) }) { Text("删除") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Recent commits ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
|
||||
when (recent) {
|
||||
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
|
||||
ProjectDetailViewModel.RecentCommits.Loading -> {
|
||||
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
|
||||
}
|
||||
ProjectDetailViewModel.RecentCommits.Unavailable -> {
|
||||
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
|
||||
}
|
||||
is ProjectDetailViewModel.RecentCommits.Loaded -> {
|
||||
SectionTitle("最近提交")
|
||||
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
|
||||
else for (commit in recent.result.commits) CommitRow(commit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitRow(commit: CommitLogEntry) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = commit.hash.take(7),
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = commit.subject,
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) {
|
||||
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
|
||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
@@ -152,6 +381,7 @@ private fun WorktreeRow(worktree: WorktreeInfo) {
|
||||
if (worktree.isMain) Tag("main")
|
||||
if (worktree.isCurrent) Tag("current")
|
||||
if (worktree.locked == true) Tag("locked")
|
||||
if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") }
|
||||
}
|
||||
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
@@ -178,7 +408,6 @@ private fun SessionRow(session: ProjectSessionRef) {
|
||||
@Composable
|
||||
private fun ClaudeMdBlock(text: String) {
|
||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||
// Inert monospaced block — CLAUDE.md is server content; never linkify/markdown (§8).
|
||||
Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
@@ -238,6 +467,17 @@ private fun ProjectDetailScreenPreview() {
|
||||
claudeMd = "# CLAUDE.md\n\nProject instructions…",
|
||||
)
|
||||
WebTermTheme {
|
||||
DetailBody(detail = detail, onOpenClaude = {})
|
||||
DetailBody(
|
||||
detail = detail,
|
||||
prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)),
|
||||
recent = ProjectDetailViewModel.RecentCommits.Loaded(
|
||||
wang.yaojia.webterm.api.models.GitLogResult(
|
||||
commits = listOf(CommitLogEntry("abc1234", 1, "Initial commit")),
|
||||
truncated = false,
|
||||
),
|
||||
),
|
||||
worktree = null,
|
||||
onOpenClaude = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,13 +222,21 @@ private fun ProjectCard(
|
||||
}
|
||||
}
|
||||
project.branch?.let { branch ->
|
||||
Text(
|
||||
text = branch,
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||
Text(
|
||||
text = branch,
|
||||
style = WebTermType.metaMono,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
// W3 sync chip: commits ahead/behind upstream (best-effort; only shown when non-zero).
|
||||
val ahead = project.ahead ?: 0
|
||||
val behind = project.behind ?: 0
|
||||
if (ahead > 0) Text(text = "↑$ahead", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
|
||||
if (behind > 0) Text(text = "↓$behind", style = WebTermType.metaMono, color = WebTermColors.statusWaiting)
|
||||
}
|
||||
}
|
||||
val running = project.sessions.count { !it.exited }
|
||||
if (running > 0) {
|
||||
|
||||
@@ -77,6 +77,7 @@ import java.util.UUID
|
||||
* @param onOpenSession open the terminal (A21) for a tapped session id (its dot is cleared first).
|
||||
* @param onNewSession start a new session on the active host (`attach(null)`; A21 owns the terminal).
|
||||
* @param onPairHost host-menu **配对新主机** → the pairing flow (A19).
|
||||
* @param onEnroll host-menu **自动获取证书** → the zero-`.p12` enrollment screen (B4).
|
||||
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
|
||||
* @param thumbnails off-screen preview seam; production wires it to the active host's
|
||||
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles.
|
||||
@@ -87,6 +88,7 @@ public fun SessionListScreen(
|
||||
onOpenSession: (UUID) -> Unit,
|
||||
onNewSession: () -> Unit,
|
||||
onPairHost: () -> Unit,
|
||||
onEnroll: () -> Unit,
|
||||
onImportCert: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
thumbnails: SessionThumbnails? = null,
|
||||
@@ -110,6 +112,7 @@ public fun SessionListScreen(
|
||||
onSelectHost = { id -> scope.launch { viewModel.selectHost(id) } },
|
||||
onNewSession = onNewSession,
|
||||
onPairHost = onPairHost,
|
||||
onEnroll = onEnroll,
|
||||
onImportCert = onImportCert,
|
||||
)
|
||||
},
|
||||
@@ -138,6 +141,7 @@ private fun SessionListTopBar(
|
||||
onSelectHost: (String) -> Unit,
|
||||
onNewSession: () -> Unit,
|
||||
onPairHost: () -> Unit,
|
||||
onEnroll: () -> Unit,
|
||||
onImportCert: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
@@ -156,6 +160,7 @@ private fun SessionListTopBar(
|
||||
onDismiss = { menuOpen = false },
|
||||
onSelectHost = { menuOpen = false; onSelectHost(it) },
|
||||
onPairHost = { menuOpen = false; onPairHost() },
|
||||
onEnroll = { menuOpen = false; onEnroll() },
|
||||
onImportCert = { menuOpen = false; onImportCert() },
|
||||
)
|
||||
}
|
||||
@@ -163,7 +168,7 @@ private fun SessionListTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the two actions. */
|
||||
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */
|
||||
@Composable
|
||||
private fun HostMenu(
|
||||
expanded: Boolean,
|
||||
@@ -171,6 +176,7 @@ private fun HostMenu(
|
||||
onDismiss: () -> Unit,
|
||||
onSelectHost: (String) -> Unit,
|
||||
onPairHost: () -> Unit,
|
||||
onEnroll: () -> Unit,
|
||||
onImportCert: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
@@ -184,6 +190,8 @@ private fun HostMenu(
|
||||
}
|
||||
if (hosts.isNotEmpty()) HorizontalDivider()
|
||||
DropdownMenuItem(text = { Text("配对新主机") }, onClick = onPairHost)
|
||||
// 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS.
|
||||
DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll)
|
||||
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import wang.yaojia.webterm.api.models.CommitResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PushResult
|
||||
import wang.yaojia.webterm.api.models.StageResult
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
@@ -47,14 +52,17 @@ import java.net.URI
|
||||
public class DiffViewModel(
|
||||
private val fetcher: DiffFetcher,
|
||||
private val path: String,
|
||||
/** Guarded git-write seam (stage/commit/push). Null → the diff is inert read-only (no buttons). */
|
||||
private val writer: GitWriteGateway? = null,
|
||||
) {
|
||||
private val _uiState = MutableStateFlow(DiffUiState())
|
||||
private val _uiState = MutableStateFlow(DiffUiState(canWrite = writer != null))
|
||||
|
||||
/** The single snapshot `DiffScreen` renders from. */
|
||||
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
private var job: Job? = null
|
||||
private var writeJob: Job? = null
|
||||
|
||||
/** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
|
||||
public fun bind(scope: CoroutineScope) {
|
||||
@@ -62,27 +70,46 @@ public class DiffViewModel(
|
||||
reload()
|
||||
}
|
||||
|
||||
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches. */
|
||||
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches.
|
||||
* No-op in base mode (the toggle is hidden there — the server ignores `staged` when `base` is set). */
|
||||
public fun selectStaged(staged: Boolean) {
|
||||
if (_uiState.value.base != null) return
|
||||
if (_uiState.value.staged == staged) return
|
||||
_uiState.value = _uiState.value.copy(staged = staged)
|
||||
reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter/leave base mode: a non-blank [rev] diffs HEAD against that base (staged toggle suppressed,
|
||||
* git-write disabled — parity with public/diff.ts); null/blank returns to the working/staged view.
|
||||
*/
|
||||
public fun setBase(rev: String?) {
|
||||
val next = rev?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (_uiState.value.base == next) return
|
||||
_uiState.value = _uiState.value.copy(base = next, writeBanner = null)
|
||||
reload()
|
||||
}
|
||||
|
||||
/** Re-fetch the current view (pull-to-refresh / retry after an error). */
|
||||
public fun refresh() {
|
||||
reload()
|
||||
}
|
||||
|
||||
/** Dismiss the git-write result banner. */
|
||||
public fun clearWriteBanner() {
|
||||
_uiState.value = _uiState.value.copy(writeBanner = null)
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
val scope = scope ?: return
|
||||
job?.cancel()
|
||||
val staged = _uiState.value.staged
|
||||
val base = _uiState.value.base
|
||||
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
|
||||
job = scope.launch {
|
||||
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
|
||||
val outcome = try {
|
||||
Result.success(fetcher.fetch(path, staged))
|
||||
Result.success(fetcher.fetch(path, staged, base))
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
@@ -101,6 +128,102 @@ public class DiffViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Git write (working/staged mode only — never in base mode, plan §Security/Edge cases) ──────
|
||||
|
||||
/** Stage (`staged=true`) or unstage a single file, then re-fetch so the view reflects the index. */
|
||||
public fun toggleStage(newPath: String, staged: Boolean) {
|
||||
runWrite { writer!!.gitStage(path, listOf(newPath), staged) }
|
||||
}
|
||||
|
||||
/** Commit the staged changes with [message]. An empty message is rejected client-side (no I/O). */
|
||||
public fun commit(message: String) {
|
||||
if (message.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(writeBanner = DiffWriteBanner(DiffCopy.COMMIT_EMPTY, isError = true))
|
||||
return
|
||||
}
|
||||
runWrite { writer!!.gitCommit(path, message) }
|
||||
}
|
||||
|
||||
/** Push the current branch to its upstream (tighter server-side rate limit; never auto-retried). */
|
||||
public fun push() {
|
||||
runWrite { writer!!.gitPush(path) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared guarded-write runner: guards on write availability + base mode, sets [DiffUiState.writing],
|
||||
* maps the [GitWriteOutcome] to a banner, and re-fetches the diff on success. Serialized via a single
|
||||
* [writeJob] so a rapid double-tap never races.
|
||||
*/
|
||||
private fun <T> runWrite(op: suspend () -> GitWriteOutcome<T>) {
|
||||
val scope = scope ?: return
|
||||
if (writer == null || _uiState.value.base != null || _uiState.value.writing) return
|
||||
writeJob?.cancel()
|
||||
_uiState.value = _uiState.value.copy(writing = true, writeBanner = null)
|
||||
writeJob = scope.launch {
|
||||
val outcome = try {
|
||||
Result.success(op())
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
val banner = outcome.fold(
|
||||
onSuccess = { bannerFor(it) },
|
||||
onFailure = { DiffWriteBanner(DiffCopy.writeFailed(errorDetail(it)), isError = true) },
|
||||
)
|
||||
_uiState.value = _uiState.value.copy(writing = false, writeBanner = banner)
|
||||
if (!banner.isError) reload() // refresh the diff after a successful write
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> bannerFor(outcome: GitWriteOutcome<T>): DiffWriteBanner = when (outcome) {
|
||||
is GitWriteOutcome.Ok -> DiffWriteBanner(DiffCopy.okBanner(outcome.payload), isError = false)
|
||||
is GitWriteOutcome.Rejected -> DiffWriteBanner(outcome.message ?: DiffCopy.WRITE_REJECTED, isError = true)
|
||||
GitWriteOutcome.RateLimited -> DiffWriteBanner(DiffCopy.RATE_LIMITED, isError = true)
|
||||
}
|
||||
|
||||
// A thrown ApiClientError's message IS its userMessage (super(userMessage)); transport errors carry
|
||||
// their own message — so message is already the display copy.
|
||||
private fun errorDetail(error: Throwable): String = error.message ?: error.toString()
|
||||
}
|
||||
|
||||
/** The guarded git-write seam DiffViewModel drives (stage/commit/push). Prod: [ApiClientGitWriteGateway]. */
|
||||
public interface GitWriteGateway {
|
||||
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult>
|
||||
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult>
|
||||
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult>
|
||||
}
|
||||
|
||||
/** Production [GitWriteGateway] delegating to a per-host [ApiClient] (Origin stamped in :api-client). */
|
||||
public class ApiClientGitWriteGateway(private val api: ApiClient) : GitWriteGateway {
|
||||
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> =
|
||||
api.gitStage(path, files, stage)
|
||||
|
||||
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
|
||||
api.gitCommit(path, message)
|
||||
|
||||
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> = api.gitPush(path)
|
||||
}
|
||||
|
||||
/** A one-line git-write result banner. [isError] drives the colour token (green ok / red failure). */
|
||||
public data class DiffWriteBanner(val message: String, val isError: Boolean)
|
||||
|
||||
/** User-visible git-write copy (Chinese named constants; server strings are surfaced verbatim/inert). */
|
||||
public object DiffCopy {
|
||||
public const val COMMIT_EMPTY: String = "请填写提交信息。"
|
||||
public const val WRITE_REJECTED: String = "操作被服务器拒绝。"
|
||||
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
|
||||
|
||||
public fun writeFailed(detail: String): String = "Git 操作失败:$detail"
|
||||
|
||||
/** Success banner keyed off the payload type (short sha / branch→remote / staged count). */
|
||||
public fun okBanner(payload: Any?): String = when (payload) {
|
||||
is StageResult -> if (payload.staged) "已暂存 ${payload.count} 个文件" else "已取消暂存 ${payload.count} 个文件"
|
||||
is CommitResult -> if (payload.commit.isEmpty()) "已提交" else "已提交 ${payload.commit}"
|
||||
is PushResult -> "已推送 ${payload.branch ?: "分支"} → ${payload.remote ?: "远端"}"
|
||||
else -> "操作完成"
|
||||
}
|
||||
}
|
||||
|
||||
/** The load phase the screen renders (loading spinner / empty / error / list). */
|
||||
@@ -108,14 +231,25 @@ public enum class DiffPhase { IDLE, LOADING, LOADED, EMPTY, ERROR }
|
||||
|
||||
/** The immutable snapshot the diff screen renders. */
|
||||
public data class DiffUiState(
|
||||
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. */
|
||||
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. Ignored in base mode. */
|
||||
val staged: Boolean = false,
|
||||
/** Non-null = base mode: diff HEAD against this revision (staged toggle + git-write suppressed). */
|
||||
val base: String? = null,
|
||||
val phase: DiffPhase = DiffPhase.IDLE,
|
||||
/** files→hunks→lines flattened into one ordered list (empty until loaded). */
|
||||
val rows: List<DiffRow> = emptyList(),
|
||||
/** Server capped the diff (too large) — the screen shows a truncation notice. */
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
/** True once a git-write is in flight — the screen disables the write controls. */
|
||||
val writing: Boolean = false,
|
||||
/** The last git-write result (ok/failure), or null. Dismissed via [DiffViewModel.clearWriteBanner]. */
|
||||
val writeBanner: DiffWriteBanner? = null,
|
||||
/** Whether git-write controls are offered at all (a writer gateway was supplied). */
|
||||
val canWrite: Boolean = false,
|
||||
) {
|
||||
/** Stage/commit/push are offered only in working/staged mode with a writer bound (never base mode). */
|
||||
val writeEnabled: Boolean get() = canWrite && base == null
|
||||
}
|
||||
|
||||
// ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────
|
||||
|
||||
@@ -125,10 +259,12 @@ public sealed interface DiffRow {
|
||||
public val id: Long
|
||||
}
|
||||
|
||||
/** A per-file header: the display path plus its `+added/-removed` numstat and status. */
|
||||
/** A per-file header: the display path plus its `+added/-removed` numstat and status. [stagePath] is the
|
||||
* file's `newPath` used verbatim for `git add`/`restore` (the display [path] may be an `old → new` rename). */
|
||||
public data class DiffFileHeaderRow(
|
||||
override val id: Long,
|
||||
val path: String,
|
||||
val stagePath: String,
|
||||
val status: String,
|
||||
val added: Int,
|
||||
val removed: Int,
|
||||
@@ -153,7 +289,7 @@ public fun flattenDiff(result: DiffResult): List<DiffRow> {
|
||||
val rows = ArrayList<DiffRow>()
|
||||
var id = 0L
|
||||
for (file in result.files) {
|
||||
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.status, file.added, file.removed))
|
||||
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.newPath, file.status, file.added, file.removed))
|
||||
if (file.binary) {
|
||||
rows.add(DiffBinaryRow(id++))
|
||||
continue
|
||||
@@ -212,7 +348,13 @@ public data class DiffFile(
|
||||
val hunks: List<DiffHunk>,
|
||||
)
|
||||
|
||||
public data class DiffResult(val files: List<DiffFile>, val staged: Boolean, val truncated: Boolean)
|
||||
public data class DiffResult(
|
||||
val files: List<DiffFile>,
|
||||
val staged: Boolean,
|
||||
val truncated: Boolean,
|
||||
/** Echoed by the server when the diff was against a base revision (`?base=<rev>`); null otherwise. */
|
||||
val base: String? = null,
|
||||
)
|
||||
|
||||
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
|
||||
private val DiffJson: Json = Json {
|
||||
@@ -230,7 +372,12 @@ internal fun decodeDiffResult(bytes: ByteArray): DiffResult {
|
||||
.getOrNull() as? JsonObject
|
||||
?: return DiffResult(emptyList(), staged = false, truncated = false)
|
||||
val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
|
||||
return DiffResult(files = files, staged = root.bool("staged", false), truncated = root.bool("truncated", false))
|
||||
return DiffResult(
|
||||
files = files,
|
||||
staged = root.bool("staged", false),
|
||||
truncated = root.bool("truncated", false),
|
||||
base = root.str("base"),
|
||||
)
|
||||
}
|
||||
|
||||
/** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */
|
||||
@@ -278,8 +425,12 @@ private fun JsonObject.bool(key: String, default: Boolean): Boolean =
|
||||
|
||||
/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
|
||||
public interface DiffFetcher {
|
||||
/** @throws DiffUnavailable on a non-200 status; transport errors propagate. */
|
||||
public suspend fun fetch(path: String, staged: Boolean): DiffResult
|
||||
/**
|
||||
* @param base when non-null, diff HEAD against this base revision — the server IGNORES [staged]
|
||||
* in base mode (server.ts:831), so callers omit it and the screen hides the Working/Staged toggle.
|
||||
* @throws DiffUnavailable on a non-200 status; transport errors propagate.
|
||||
*/
|
||||
public suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult
|
||||
}
|
||||
|
||||
/** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */
|
||||
@@ -293,8 +444,8 @@ public class HttpDiffFetcher(
|
||||
private val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
) : DiffFetcher {
|
||||
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
|
||||
val url = diffUrl(endpoint.baseUrl, path, staged) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
|
||||
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
|
||||
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
|
||||
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
|
||||
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
|
||||
return decodeDiffResult(response.body)
|
||||
@@ -305,20 +456,28 @@ private const val HTTP_OK = 200
|
||||
private const val HTTP_BAD_REQUEST = 400
|
||||
|
||||
/**
|
||||
* Build `<scheme>://host[:port]/projects/diff?path=<enc>&staged=1|0` from the dialed base URL,
|
||||
* keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). `staged` serializes as the
|
||||
* literal `"1"`/`"0"` string the server matches with `=== '1'` (NOT a boolean). Returns null if the
|
||||
* base URL cannot be parsed. `internal` so the JVM test asserts the exact query value.
|
||||
* Build `<scheme>://host[:port]/projects/diff?path=<enc>[&staged=1|0][&base=<enc>]` from the dialed
|
||||
* base URL, keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). In **base mode**
|
||||
* ([base] non-null/non-blank) the server ignores `staged` (server.ts:831), so `staged` is OMITTED and
|
||||
* `&base=<enc>` is appended (percent-encoded; the server's `isPlausibleRev` rejects junk with a 400).
|
||||
* Otherwise `staged` serializes as the literal `"1"`/`"0"` string the server matches with `=== '1'`
|
||||
* (NOT a boolean). Returns null if the base URL cannot be parsed. `internal` so the JVM test asserts
|
||||
* the exact query value.
|
||||
*/
|
||||
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean): String? {
|
||||
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean, base: String? = null): 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 stagedValue = if (staged) "1" else "0"
|
||||
return "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}&staged=$stagedValue"
|
||||
val prefix = "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}"
|
||||
val trimmedBase = base?.trim()
|
||||
return if (!trimmedBase.isNullOrEmpty()) {
|
||||
"$prefix&base=${percentEncode(trimmedBase)}" // base mode: no staged (server ignores it)
|
||||
} else {
|
||||
"$prefix&staged=${if (staged) "1" else "0"}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
|
||||
import wang.yaojia.webterm.clienttls.CertificateSummary
|
||||
import java.net.URI
|
||||
import java.security.GeneralSecurityException
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* # EnrollmentViewModel (B4) — the zero-`.p12` device-enrollment presenter.
|
||||
*
|
||||
* The Android port of iOS `EnrollmentViewModel` (`EnrollmentScreen.swift`). Drives the "自动获取证书"
|
||||
* screen reached from the session-list host menu: one operator login (password) → a short-lived
|
||||
* `device:enroll` bearer → a NON-EXPORTABLE hardware key + PKCS#10 CSR → `POST /device/enroll` → the
|
||||
* returned leaf is committed to the shared cert store and presented AUTOMATICALLY on the existing mTLS
|
||||
* path (no manual `.p12` import). Cache freshness (FIX 3) means the enrolled cert is live without a restart.
|
||||
*
|
||||
* A **plain presenter** (mirrors [ClientCertViewModel] / [PairingViewModel]), NOT an
|
||||
* `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no `Dispatchers.Main`. The
|
||||
* enroll flow and the installed-summary read are injected as suspend closures ([enrollOperation] /
|
||||
* [loadSummary]) so the VM is unit-testable without a keystore or network; production wires them (in the
|
||||
* enrollment pane) to [wang.yaojia.webterm.wiring.EnrollmentFlowFactory] over a `Dispatchers.IO` hop.
|
||||
*
|
||||
* ### Secrets discipline (plan §8)
|
||||
* The operator password lives only in [EnrollmentUiState] and is **cleared after every attempt** (success
|
||||
* OR failure) so it never lingers in memory; it is never logged and never placed in error copy. Errors are
|
||||
* a coarse [EnrollError] enum the screen maps to app-authored, inert copy — never a server/exception string.
|
||||
*
|
||||
* @param enrollOperation login → hardware keygen+CSR → enroll → commit, yielding the installed leaf summary.
|
||||
* @param loadSummary the currently-installed device-cert summary (for the "已安装证书" section), or null.
|
||||
* @param defaultControlPlaneUrl prefilled control-plane URL (the operator can edit it).
|
||||
* @param defaultDeviceName prefilled device name (the Android device/model in production).
|
||||
* @param zone / now formatting + expiry clock for the installed-cert summary (fixed in tests).
|
||||
*/
|
||||
public class EnrollmentViewModel(
|
||||
private val enrollOperation: suspend (password: String, subdomain: String, deviceName: String, controlPlaneUrl: String) -> CertificateSummary,
|
||||
private val loadSummary: suspend () -> CertificateSummary?,
|
||||
defaultControlPlaneUrl: String = DEFAULT_CONTROL_PLANE_URL,
|
||||
defaultDeviceName: String = "",
|
||||
private val zone: ZoneId = ZoneId.systemDefault(),
|
||||
private val now: () -> Instant = Instant::now,
|
||||
) {
|
||||
private val _uiState = MutableStateFlow(
|
||||
EnrollmentUiState(controlPlaneUrl = defaultControlPlaneUrl, deviceName = defaultDeviceName),
|
||||
)
|
||||
|
||||
/** The single snapshot the enrollment screen renders from. */
|
||||
public val uiState: StateFlow<EnrollmentUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
private var job: Job? = null
|
||||
|
||||
/** Bind the scope actions launch into (the screen passes a lifecycle scope) and load any installed cert. */
|
||||
public fun bind(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
refresh()
|
||||
}
|
||||
|
||||
// ── Field edits (two-way bound from the Compose form) ─────────────────────────────────────────────
|
||||
|
||||
public fun onControlPlaneUrlChange(value: String) {
|
||||
_uiState.value = _uiState.value.copy(controlPlaneUrl = value)
|
||||
}
|
||||
|
||||
public fun onSubdomainChange(value: String) {
|
||||
_uiState.value = _uiState.value.copy(subdomain = value)
|
||||
}
|
||||
|
||||
public fun onDeviceNameChange(value: String) {
|
||||
_uiState.value = _uiState.value.copy(deviceName = value)
|
||||
}
|
||||
|
||||
public fun onPasswordChange(value: String) {
|
||||
_uiState.value = _uiState.value.copy(password = value)
|
||||
}
|
||||
|
||||
/** Dismiss the current error banner. */
|
||||
public fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
val scope = scope ?: return
|
||||
job?.cancel()
|
||||
_uiState.value = _uiState.value.copy(phase = EnrollPhase.LOADING)
|
||||
job = scope.launch {
|
||||
// loadSummary is designed to degrade to null on a storage fault (never throw); guard anyway.
|
||||
val summary = runCatching { loadSummary() }.getOrNull()
|
||||
_uiState.value = _uiState.value.copy(phase = EnrollPhase.IDLE, summary = summary?.toView())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run enrollment: validate the control-plane URL (must be `https` with a host) and the fields BEFORE
|
||||
* any network, then drive [enrollOperation]. The password is cleared after every attempt so it never
|
||||
* lingers. A double-tap while an enroll is in flight is ignored.
|
||||
*/
|
||||
public fun enroll() {
|
||||
val scope = scope ?: return
|
||||
val snapshot = _uiState.value
|
||||
if (snapshot.phase == EnrollPhase.ENROLLING) return
|
||||
|
||||
val url = validControlPlaneUrl(snapshot.controlPlaneUrl)
|
||||
if (url == null) {
|
||||
_uiState.value = snapshot.copy(error = EnrollError.INVALID_URL, didSucceed = false)
|
||||
return
|
||||
}
|
||||
val subdomain = snapshot.subdomain.trim()
|
||||
val deviceName = snapshot.deviceName.trim()
|
||||
val password = snapshot.password
|
||||
if (subdomain.isEmpty() || deviceName.isEmpty() || password.isEmpty()) {
|
||||
_uiState.value = snapshot.copy(error = EnrollError.MISSING_FIELDS, didSucceed = false)
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.value = snapshot.copy(phase = EnrollPhase.ENROLLING, error = null, didSucceed = false)
|
||||
job?.cancel()
|
||||
job = scope.launch {
|
||||
val outcome = try {
|
||||
Result.success(enrollOperation(password, subdomain, deviceName, url))
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
// Clear the password whatever the outcome — never linger after an attempt.
|
||||
_uiState.value = outcome.fold(
|
||||
onSuccess = { summary ->
|
||||
_uiState.value.copy(
|
||||
phase = EnrollPhase.IDLE,
|
||||
summary = summary.toView(),
|
||||
password = "",
|
||||
didSucceed = true,
|
||||
error = null,
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.value.copy(
|
||||
phase = EnrollPhase.IDLE,
|
||||
password = "",
|
||||
didSucceed = false,
|
||||
error = classifyEnroll(e),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fields all present + not already enrolling → the button is enabled (deeper URL check on tap). */
|
||||
private fun CertificateSummary.toView() = toSummaryView(now(), zone)
|
||||
|
||||
public companion object {
|
||||
/** The default control-plane URL (matches iOS `EnrollmentCopy.defaultControlPlaneURL`). */
|
||||
public const val DEFAULT_CONTROL_PLANE_URL: String = "https://cp.terminal.yaojia.wang"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a control-plane URL exactly as iOS does before any network: it must parse, be `https`, and
|
||||
* carry a non-blank host. Returns the trimmed URL on success, or null (→ [EnrollError.INVALID_URL]).
|
||||
*/
|
||||
internal fun validControlPlaneUrl(raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
if (!"https".equals(uri.scheme, ignoreCase = true)) return null
|
||||
if (uri.host.isNullOrBlank()) return null
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an enroll throwable to the coarse, non-secret [EnrollError] the screen renders inertly. Android's
|
||||
* login + enroll share one [DeviceEnrollmentError.Http] type, so the HTTP status disambiguates (401 login
|
||||
* vs 403 subdomain-not-owned vs 429 vs 400). Keystore/hardware faults (incl. StrongBox-unavailable, a
|
||||
* [GeneralSecurityException] subclass) map to KEYGEN. Never carries the password/bearer.
|
||||
*/
|
||||
internal fun classifyEnroll(error: Throwable): EnrollError = when (error) {
|
||||
is DeviceEnrollmentError.Http -> when (error.status) {
|
||||
401 -> EnrollError.BAD_CREDENTIAL
|
||||
403 -> EnrollError.SUBDOMAIN_NOT_OWNED
|
||||
429 -> EnrollError.RATE_LIMITED
|
||||
400 -> EnrollError.REJECTED
|
||||
else -> EnrollError.ENROLL_FAILED
|
||||
}
|
||||
is DeviceEnrollmentError.MalformedResponse -> EnrollError.SERVER
|
||||
is DeviceEnrollmentError -> EnrollError.ENROLL_FAILED // InvalidRequest etc. (post-validation, rare)
|
||||
is GeneralSecurityException -> EnrollError.KEYGEN // hardware key / StrongBox / keystore fault
|
||||
else -> EnrollError.UNKNOWN
|
||||
}
|
||||
|
||||
/** The load/action phase the enrollment screen renders. */
|
||||
public enum class EnrollPhase {
|
||||
/** The initial installed-summary read is in flight. */
|
||||
LOADING,
|
||||
|
||||
/** Idle — the form is editable and [EnrollmentUiState.summary] shows any installed cert. */
|
||||
IDLE,
|
||||
|
||||
/** An enroll is in flight (login → keygen+CSR → enroll → commit). */
|
||||
ENROLLING,
|
||||
}
|
||||
|
||||
/** The coarse, non-secret enrollment failure taxonomy — the screen maps each to app-authored inert copy (§8). */
|
||||
public enum class EnrollError {
|
||||
/** The control-plane URL is not a valid `https://…` URL with a host (set before any network). */
|
||||
INVALID_URL,
|
||||
|
||||
/** A required field (subdomain / device name / password) was empty (set before any network). */
|
||||
MISSING_FIELDS,
|
||||
|
||||
/** Operator login rejected (401) — the password is wrong. */
|
||||
BAD_CREDENTIAL,
|
||||
|
||||
/** The account does not own the requested subdomain (403) — no cert can be issued. */
|
||||
SUBDOMAIN_NOT_OWNED,
|
||||
|
||||
/** Too many requests (429) — back off and retry. */
|
||||
RATE_LIMITED,
|
||||
|
||||
/** The subdomain or CSR was rejected (400). */
|
||||
REJECTED,
|
||||
|
||||
/** Any other enroll failure (server-side or transport). */
|
||||
ENROLL_FAILED,
|
||||
|
||||
/** The server returned a malformed response. */
|
||||
SERVER,
|
||||
|
||||
/** Hardware key generation failed (no StrongBox/TEE, or a keystore fault). */
|
||||
KEYGEN,
|
||||
|
||||
/** An unclassified failure. */
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
/** The immutable snapshot the enrollment screen renders. */
|
||||
public data class EnrollmentUiState(
|
||||
val controlPlaneUrl: String = "",
|
||||
val subdomain: String = "",
|
||||
val deviceName: String = "",
|
||||
val password: String = "",
|
||||
/** The currently-installed device identity's display summary, or `null` when none is installed. */
|
||||
val summary: CertSummaryView? = null,
|
||||
val phase: EnrollPhase = EnrollPhase.LOADING,
|
||||
/** The last enroll failure, or `null`. Surfaced as inert, app-authored copy. */
|
||||
val error: EnrollError? = null,
|
||||
/** Whether the most recent enroll succeeded (drives the success affordance). */
|
||||
val didSucceed: Boolean = false,
|
||||
) {
|
||||
/** Every field present and no enroll in flight → the submit button is enabled. */
|
||||
val canEnroll: Boolean
|
||||
get() = phase != EnrollPhase.ENROLLING &&
|
||||
controlPlaneUrl.trim().isNotEmpty() &&
|
||||
subdomain.trim().isNotEmpty() &&
|
||||
deviceName.trim().isNotEmpty() &&
|
||||
password.isNotEmpty()
|
||||
}
|
||||
@@ -4,26 +4,32 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import wang.yaojia.webterm.api.models.GitLogResult
|
||||
import wang.yaojia.webterm.api.models.PrStatus
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
|
||||
/**
|
||||
* # ProjectDetailViewModel (A23) — one project's detail (`GET /projects/detail?path=`), a phase state
|
||||
* machine (same discipline as [DiffViewModel]/iOS `ProjectDetailViewModel`).
|
||||
* # ProjectDetailViewModel (A23 + W5) — one project's detail (`GET /projects/detail?path=`), a phase
|
||||
* state machine, PLUS two failure-ISOLATED side fetches: the PR + CI chip (`GET /projects/pr`) and the
|
||||
* recent-commits list (`GET /projects/log`). A failure of either side fetch NEVER fails the detail load
|
||||
* (each has its own StateFlow) — the chip/list simply render an unavailable state.
|
||||
*
|
||||
* The [fetch] closure is injected — production wraps [ProjectsGateway.projectDetail] (the builder's
|
||||
* percent-encoding + 400/404/500 → typed [ApiClientError] mapping lives in `:api-client`), tests inject a
|
||||
* fake. This VM only reduces the three user-visible outcomes:
|
||||
* - success → [Phase.Loaded] (sessions/worktrees/hasClaudeMd/claudeMd passed through, rendered INERT);
|
||||
* - 400 / [ApiClientError.InvalidRequest] → [Failure.PATH_INVALID];
|
||||
* - 404 → [Failure.NOT_FOUND]; 500 / decode / transport → [Failure.UNAVAILABLE] — all retryable via [load].
|
||||
* The main [fetch] closure is injected (production wraps [ProjectsGateway.projectDetail]); [fetchPr] /
|
||||
* [fetchLog] are optional side fetches (null → the chip/list stay [PrChip.Hidden] / [RecentCommits.Hidden]).
|
||||
* [worktree] (when wired) drives the guarded create/remove/prune actions and re-fetches this detail on
|
||||
* success (via [load]).
|
||||
*
|
||||
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest` with no
|
||||
* `Dispatchers.Main`. The screen calls [load] in a lifecycle scope; the retry action re-calls it.
|
||||
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
|
||||
* [load] in a lifecycle scope; the retry action re-calls it.
|
||||
*/
|
||||
public class ProjectDetailViewModel(
|
||||
public val path: String,
|
||||
private val fetch: suspend () -> ProjectDetail,
|
||||
private val fetchPr: (suspend () -> PrStatus)? = null,
|
||||
private val fetchLog: (suspend () -> GitLogResult)? = null,
|
||||
/** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */
|
||||
public val worktree: WorktreeViewModel? = null,
|
||||
) {
|
||||
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
|
||||
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
|
||||
@@ -35,12 +41,40 @@ public class ProjectDetailViewModel(
|
||||
public data class Failed(val failure: Failure) : Phase
|
||||
}
|
||||
|
||||
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
|
||||
/** The PR + CI chip's own state (isolated from the detail load). */
|
||||
public sealed interface PrChip {
|
||||
public data object Hidden : PrChip
|
||||
public data object Loading : PrChip
|
||||
public data class Loaded(val status: PrStatus) : PrChip
|
||||
public data object Unavailable : PrChip
|
||||
}
|
||||
|
||||
/** The single snapshot `ProjectDetailScreen` renders from. */
|
||||
/** The recent-commits section's own state (isolated from the detail load). */
|
||||
public sealed interface RecentCommits {
|
||||
public data object Hidden : RecentCommits
|
||||
public data object Loading : RecentCommits
|
||||
public data class Loaded(val result: GitLogResult) : RecentCommits
|
||||
public data object Unavailable : RecentCommits
|
||||
}
|
||||
|
||||
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
|
||||
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
|
||||
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.Hidden)
|
||||
|
||||
/** The main detail snapshot `ProjectDetailScreen` renders from. */
|
||||
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||
|
||||
/** Fetch and present. Also the retry path: callable again after a [Phase.Failed]. */
|
||||
/** The PR chip snapshot (renders one chip from [PrStatus.availability]). */
|
||||
public val prChip: StateFlow<PrChip> = _prChip.asStateFlow()
|
||||
|
||||
/** The recent-commits snapshot. */
|
||||
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
|
||||
|
||||
/**
|
||||
* Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful
|
||||
* detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail
|
||||
* the detail load nor each other).
|
||||
*/
|
||||
public suspend fun load() {
|
||||
_phase.value = Phase.Loading
|
||||
_phase.value = try {
|
||||
@@ -53,6 +87,34 @@ public class ProjectDetailViewModel(
|
||||
// Transport/decode etc. — a retryable catch-all.
|
||||
Phase.Failed(Failure.UNAVAILABLE)
|
||||
}
|
||||
if (_phase.value is Phase.Loaded) {
|
||||
loadPr()
|
||||
loadRecentCommits()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPr() {
|
||||
val fetcher = fetchPr ?: return
|
||||
_prChip.value = PrChip.Loading
|
||||
_prChip.value = try {
|
||||
PrChip.Loaded(fetcher())
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
PrChip.Unavailable // isolated: a PR fetch failure never touches the detail phase
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadRecentCommits() {
|
||||
val fetcher = fetchLog ?: return
|
||||
_recentCommits.value = RecentCommits.Loading
|
||||
_recentCommits.value = try {
|
||||
RecentCommits.Loaded(fetcher())
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
RecentCommits.Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
private fun failureFor(error: ApiClientError): Failure = when (error) {
|
||||
@@ -62,8 +124,22 @@ public class ProjectDetailViewModel(
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/** Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). */
|
||||
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel =
|
||||
ProjectDetailViewModel(path) { gateway.projectDetail(path) }
|
||||
/**
|
||||
* Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). Wires the
|
||||
* detail + PR + log fetches and a [WorktreeViewModel] whose successes re-fetch this detail.
|
||||
*/
|
||||
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel {
|
||||
var self: ProjectDetailViewModel? = null
|
||||
val worktree = WorktreeViewModel(gateway, path, onChanged = { self?.load() })
|
||||
val vm = ProjectDetailViewModel(
|
||||
path = path,
|
||||
fetch = { gateway.projectDetail(path) },
|
||||
fetchPr = { gateway.projectPr(path) },
|
||||
fetchLog = { gateway.projectLog(path, null) },
|
||||
worktree = worktree,
|
||||
)
|
||||
self = vm
|
||||
return vm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
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.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.RemoveWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||
@@ -396,12 +402,26 @@ public data class ProjectsUiState(
|
||||
|
||||
// ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ─────────────────────
|
||||
|
||||
/** Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses. */
|
||||
/**
|
||||
* Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses.
|
||||
* The W5 additions (PR / recent commits / worktree create-remove-prune) let the detail page and the
|
||||
* [WorktreeViewModel] stay JVM-tested against a fake; the guarded worktree writes flow through the
|
||||
* :api-client Origin-stamping point (plan §Security).
|
||||
*/
|
||||
public interface ProjectsGateway {
|
||||
public suspend fun projects(): List<ProjectInfo>
|
||||
public suspend fun prefs(): UiPrefs
|
||||
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
|
||||
public suspend fun projectDetail(path: String): ProjectDetail
|
||||
|
||||
// ── W5: read-only PR + recent commits ──────────────────────────────────────────────────
|
||||
public suspend fun projectPr(path: String): PrStatus
|
||||
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
|
||||
|
||||
// ── W5: guarded worktree write ─────────────────────────────────────────────────────────
|
||||
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
|
||||
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
|
||||
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult>
|
||||
}
|
||||
|
||||
/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
|
||||
@@ -410,6 +430,15 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate
|
||||
override suspend fun prefs(): UiPrefs = api.prefs()
|
||||
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
|
||||
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
|
||||
override suspend fun projectPr(path: String): PrStatus = api.projectPr(path)
|
||||
override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n)
|
||||
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
|
||||
api.createWorktree(path, branch, base)
|
||||
|
||||
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> =
|
||||
api.removeWorktree(path, worktreePath, force)
|
||||
|
||||
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> = api.pruneWorktrees(path)
|
||||
}
|
||||
|
||||
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||
|
||||
/**
|
||||
* # WorktreeViewModel (W5) — the guarded worktree write actions for one project.
|
||||
*
|
||||
* A phase machine (`Idle → Working → Done | Failed`) over the three guarded routes
|
||||
* (`POST /projects/worktree`, `DELETE /projects/worktree`, `POST /projects/worktree/prune`), all
|
||||
* flowing through the :api-client Origin-stamping point (plan §Security). On a successful op it invokes
|
||||
* [onChanged] so the detail screen re-fetches and the worktree list refreshes.
|
||||
*
|
||||
* ### Defense in depth (UX, not the security boundary)
|
||||
* The branch name is pre-validated client-side ([isValidBranchName], a mirror of the server's
|
||||
* `validateBranchName`) so an obviously bad name fails with NO network I/O; a **main** worktree removal
|
||||
* is blocked client-side ([WorktreeInfo.isMain]) — the server re-validates + realpath-contains
|
||||
* regardless. Server `error` strings (disabled kill-switch, "uncommitted changes; force required") are
|
||||
* surfaced INERT (plain text; never linkified).
|
||||
*
|
||||
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
|
||||
* the suspend actions from a lifecycle scope; [reset] clears a settled banner back to [Phase.Idle].
|
||||
*/
|
||||
public class WorktreeViewModel(
|
||||
private val gateway: ProjectsGateway,
|
||||
private val repoPath: String,
|
||||
/** Invoked after any successful write so the detail page re-fetches (list refresh). */
|
||||
private val onChanged: suspend () -> Unit = {},
|
||||
) {
|
||||
/** The action phase the screen renders (idle / spinner / success banner / failure banner). */
|
||||
public sealed interface Phase {
|
||||
public data object Idle : Phase
|
||||
public data object Working : Phase
|
||||
public data class Done(val message: String) : Phase
|
||||
public data class Failed(val message: String) : Phase
|
||||
}
|
||||
|
||||
private val _phase = MutableStateFlow<Phase>(Phase.Idle)
|
||||
|
||||
/** The single snapshot the worktree sheet/dialog renders from. */
|
||||
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||
|
||||
/** Create a worktree for [branch] (off optional [base]). Invalid branch → [Phase.Failed], no I/O. */
|
||||
public suspend fun create(branch: String, base: String? = null) {
|
||||
val trimmed = branch.trim()
|
||||
if (!isValidBranchName(trimmed)) {
|
||||
_phase.value = Phase.Failed(WorktreeCopy.INVALID_BRANCH)
|
||||
return
|
||||
}
|
||||
if (_phase.value == Phase.Working) return
|
||||
_phase.value = Phase.Working
|
||||
val cleanBase = base?.trim()?.takeIf { it.isNotEmpty() }
|
||||
_phase.value = runOp { gateway.createWorktree(repoPath, trimmed, cleanBase) }
|
||||
}
|
||||
|
||||
/** Remove [worktree] ([force] to discard uncommitted changes). A **main** worktree is blocked here. */
|
||||
public suspend fun remove(worktree: WorktreeInfo, force: Boolean) {
|
||||
if (worktree.isMain) {
|
||||
_phase.value = Phase.Failed(WorktreeCopy.CANNOT_REMOVE_MAIN)
|
||||
return
|
||||
}
|
||||
if (_phase.value == Phase.Working) return
|
||||
_phase.value = Phase.Working
|
||||
_phase.value = runOp { gateway.removeWorktree(repoPath, worktree.path, force) }
|
||||
}
|
||||
|
||||
/** Reclaim stale worktree admin dirs (idempotent). */
|
||||
public suspend fun prune() {
|
||||
if (_phase.value == Phase.Working) return
|
||||
_phase.value = Phase.Working
|
||||
_phase.value = runOp { gateway.pruneWorktrees(repoPath) }
|
||||
}
|
||||
|
||||
/** Clear a settled banner (Done/Failed) back to Idle after the user dismisses it. */
|
||||
public fun reset() {
|
||||
_phase.value = Phase.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one guarded write, mapping its [GitWriteOutcome] to a phase. On success it re-fetches the
|
||||
* detail (via [onChanged]) BEFORE settling to [Phase.Done] so the list is fresh when the banner shows.
|
||||
*/
|
||||
private suspend fun <T> runOp(op: suspend () -> GitWriteOutcome<T>): Phase {
|
||||
val outcome = try {
|
||||
op()
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
return Phase.Failed(WorktreeCopy.failed(error.message ?: error.toString()))
|
||||
}
|
||||
return when (outcome) {
|
||||
is GitWriteOutcome.Ok -> {
|
||||
runCatching { onChanged() } // a refresh failure must not turn a successful write into a failure
|
||||
Phase.Done(WorktreeCopy.okMessage(outcome.payload))
|
||||
}
|
||||
is GitWriteOutcome.Rejected -> Phase.Failed(outcome.message ?: WorktreeCopy.REJECTED)
|
||||
GitWriteOutcome.RateLimited -> Phase.Failed(WorktreeCopy.RATE_LIMITED)
|
||||
}
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/** Longest branch name the server accepts (`src/http/worktrees.ts` `MAX_BRANCH_LEN`). */
|
||||
private const val MAX_BRANCH_LEN = 250
|
||||
|
||||
/** Mirror of the server's `FORBIDDEN_BRANCH_CHARS`: control/DEL, whitespace, `~^:?*[\`. */
|
||||
private val FORBIDDEN_BRANCH_CHARS = Regex("[\\u0000-\\u001f\\u007f\\s~^:?*\\[\\\\]")
|
||||
|
||||
/**
|
||||
* Client-side mirror of `validateBranchName` (worktrees.ts:95) — a fast UX pre-check ONLY; the
|
||||
* server re-validates. Rejects empty/overlong, leading `-`, bad slashes, `..`, `.lock`/trailing
|
||||
* `.`, `@{`, and any forbidden char.
|
||||
*/
|
||||
public fun isValidBranchName(branch: String): Boolean {
|
||||
if (branch.isEmpty() || branch.length > MAX_BRANCH_LEN) return false
|
||||
if (branch.startsWith("-")) return false
|
||||
if (branch.startsWith("/") || branch.endsWith("/") || branch.contains("//")) return false
|
||||
if (branch.contains("..")) return false
|
||||
if (branch.endsWith(".lock") || branch.endsWith(".")) return false
|
||||
if (branch.contains("@{")) return false
|
||||
if (FORBIDDEN_BRANCH_CHARS.containsMatchIn(branch)) return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** User-visible worktree-action copy (Chinese named constants; server strings surfaced inert). */
|
||||
public object WorktreeCopy {
|
||||
public const val INVALID_BRANCH: String = "分支名不合法(含非法字符或格式)。"
|
||||
public const val CANNOT_REMOVE_MAIN: String = "不能删除主工作树。"
|
||||
public const val REJECTED: String = "操作被服务器拒绝。"
|
||||
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
|
||||
|
||||
public fun failed(detail: String): String = "工作树操作失败:$detail"
|
||||
|
||||
public fun okMessage(payload: Any?): String = when (payload) {
|
||||
is CreateWorktreeResult -> "已创建工作树 ${payload.branch ?: ""}".trim()
|
||||
is RemoveWorktreeResult -> "已删除工作树"
|
||||
is PruneWorktreesResult ->
|
||||
if (payload.pruned.isEmpty()) "没有可清理的工作树" else "已清理 ${payload.pruned.size} 个工作树"
|
||||
else -> "操作完成"
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,11 @@ public class AppEnvironment @Inject constructor(
|
||||
public val apiClientFactory: ApiClientFactory,
|
||||
public val sessionEngineFactory: SessionEngineFactory,
|
||||
public val coldStartPolicy: ColdStartPolicy,
|
||||
/**
|
||||
* B4 · Builds a [DeviceEnroller][wang.yaojia.webterm.tlsandroid.DeviceEnroller] per control-plane URL
|
||||
* for the zero-`.p12` enrollment screen (A-enroll). App-scoped; the screen calls it off `Main`.
|
||||
*/
|
||||
public val enrollmentFlowFactory: EnrollmentFlowFactory,
|
||||
private val identityRepositoryLazy: Lazy<IdentityRepository>,
|
||||
private val httpTransportLazy: Lazy<HttpTransport>,
|
||||
private val termTransportLazy: Lazy<TermTransport>,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package wang.yaojia.webterm.wiring
|
||||
|
||||
import dagger.Lazy
|
||||
import okhttp3.OkHttpClient
|
||||
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
|
||||
import wang.yaojia.webterm.tlsandroid.CertStore
|
||||
import wang.yaojia.webterm.tlsandroid.DeviceEnroller
|
||||
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* B4 · Builds a [DeviceEnroller] bound to a user-supplied control-plane URL, over the app's frozen object
|
||||
* graph — the Android analogue of iOS `makeDeviceEnrollmentFlow` (`DeviceEnrollmentWiring.swift`).
|
||||
*
|
||||
* The control-plane base URL is a RUNTIME value (the operator types it on the enrollment screen), so the
|
||||
* enroller cannot be a plain singleton; this factory is the singleton and mints one enroller per enroll
|
||||
* attempt with the typed URL, wiring in the app-scoped collaborators:
|
||||
* - the SAME shared [HttpTransport] / [OkHttpClient] the mTLS transports use, so the renew ride presents
|
||||
* the current device cert and the pool eviction drops stale connections,
|
||||
* - the shared [CertStore] + [EnrollmentRecordStore] the running [IdentityRepository] resolves from, and
|
||||
* - the [IdentityCacheRefresher] (FIX 3) so a freshly enrolled leaf is presented with no restart.
|
||||
*
|
||||
* ### Off-`Main` discipline
|
||||
* [httpTransport] and [sharedClient] are behind `dagger.Lazy` because resolving either builds the shared
|
||||
* `OkHttpClient` (mTLS/keystore I/O). [create] therefore MUST be called off the UI thread — the enrollment
|
||||
* ViewModel invokes it inside a `Dispatchers.IO` hop (mirroring `AppEnvironment.warmUp`).
|
||||
*/
|
||||
@Singleton
|
||||
public class EnrollmentFlowFactory @Inject constructor(
|
||||
private val httpTransport: Lazy<HttpTransport>,
|
||||
private val sharedClient: Lazy<OkHttpClient>,
|
||||
private val certStore: CertStore,
|
||||
private val recordStore: EnrollmentRecordStore,
|
||||
private val cacheRefresher: IdentityCacheRefresher,
|
||||
) {
|
||||
/**
|
||||
* Mint a [DeviceEnroller] targeting [controlPlaneBaseUrl] (any trailing slash is trimmed by the
|
||||
* client). Call OFF `Main` — resolving the lazy transport/client does keystore/TLS I/O.
|
||||
*/
|
||||
public fun create(controlPlaneBaseUrl: String): DeviceEnroller =
|
||||
DeviceEnroller(
|
||||
client = DeviceEnrollmentClient(controlPlaneBaseUrl, httpTransport.get()),
|
||||
certStore = certStore,
|
||||
recordStore = recordStore,
|
||||
sharedClient = sharedClient.get(),
|
||||
cacheRefresher = cacheRefresher,
|
||||
)
|
||||
}
|
||||
@@ -6,8 +6,14 @@ import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
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.CommitResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PushResult
|
||||
import wang.yaojia.webterm.api.models.StageResult
|
||||
|
||||
/**
|
||||
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
|
||||
@@ -101,8 +107,10 @@ class DiffViewModelTest {
|
||||
// ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
|
||||
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
|
||||
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
|
||||
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
|
||||
val bases = mutableListOf<String?>() // records the base arg of each fetch
|
||||
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
|
||||
calls += staged
|
||||
bases += base
|
||||
error?.let { throw it }
|
||||
return result!!
|
||||
}
|
||||
@@ -154,4 +162,133 @@ class DiffViewModelTest {
|
||||
assertTrue(vm.uiState.value.staged)
|
||||
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
|
||||
}
|
||||
|
||||
// ── diffUrl base mode (Phase B) ───────────────────────────────────────────────────────────────
|
||||
@Test
|
||||
fun `diffUrl appends staged in working mode and base (omitting staged) in base mode`() {
|
||||
assertEquals(
|
||||
"http://h:3000/projects/diff?path=%2Frepo&staged=1",
|
||||
diffUrl("http://h:3000", "/repo", staged = true, base = null),
|
||||
)
|
||||
// base mode: no staged param, base percent-encoded.
|
||||
assertEquals(
|
||||
"http://h:3000/projects/diff?path=%2Frepo&base=feature%2Fx",
|
||||
diffUrl("http://h:3000", "/repo", staged = true, base = "feature/x"),
|
||||
)
|
||||
// a blank base is treated as working mode.
|
||||
assertEquals(
|
||||
"http://h:3000/projects/diff?path=%2Frepo&staged=0",
|
||||
diffUrl("http://h:3000", "/repo", staged = false, base = " "),
|
||||
)
|
||||
}
|
||||
|
||||
// ── DiffViewModel base mode (Phase B) ─────────────────────────────────────────────────────────
|
||||
@Test
|
||||
fun `setBase enters base mode, threads base to the fetcher, and suppresses the staged toggle`() = runTest {
|
||||
val fetcher = FakeFetcher(oneFileResult(false))
|
||||
val vm = DiffViewModel(fetcher, "/repo")
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
|
||||
vm.bind(scope); advanceUntilIdle()
|
||||
vm.setBase("main"); advanceUntilIdle()
|
||||
|
||||
assertEquals("main", vm.uiState.value.base)
|
||||
assertEquals(listOf(null, "main"), fetcher.bases) // base threaded on the re-fetch
|
||||
|
||||
// In base mode the staged toggle is a no-op (server ignores staged when base is set).
|
||||
vm.selectStaged(true); advanceUntilIdle()
|
||||
assertFalse(vm.uiState.value.staged)
|
||||
assertEquals(2, fetcher.calls.size, "selectStaged must not re-fetch in base mode")
|
||||
|
||||
// Leaving base mode returns to the working/staged view.
|
||||
vm.setBase(null); advanceUntilIdle()
|
||||
assertNull(vm.uiState.value.base)
|
||||
assertEquals(listOf(null, "main", null), fetcher.bases)
|
||||
}
|
||||
|
||||
// ── DiffViewModel git-write (Phase C) ─────────────────────────────────────────────────────────
|
||||
private class FakeWriter(
|
||||
var stage: GitWriteOutcome<StageResult> = GitWriteOutcome.Ok(StageResult(staged = true, count = 1)),
|
||||
var commit: GitWriteOutcome<CommitResult> = GitWriteOutcome.Ok(CommitResult(commit = "abc123")),
|
||||
var push: GitWriteOutcome<PushResult> = GitWriteOutcome.Ok(PushResult(branch = "main", remote = "origin")),
|
||||
) : GitWriteGateway {
|
||||
val stageCalls = mutableListOf<Triple<String, List<String>, Boolean>>()
|
||||
var commitCalls = 0; var pushCalls = 0
|
||||
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> {
|
||||
stageCalls += Triple(path, files, stage); return this.stage
|
||||
}
|
||||
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> { commitCalls++; return commit }
|
||||
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> { pushCalls++; return push }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleStage posts the file and refreshes the diff`() = runTest {
|
||||
val fetcher = FakeFetcher(oneFileResult(false))
|
||||
val writer = FakeWriter()
|
||||
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
|
||||
vm.bind(scope); advanceUntilIdle()
|
||||
vm.toggleStage("src/A.kt", staged = true); advanceUntilIdle()
|
||||
|
||||
assertEquals(Triple("/repo", listOf("src/A.kt"), true), writer.stageCalls.single())
|
||||
assertEquals(2, fetcher.calls.size, "a successful stage must refresh the diff")
|
||||
assertEquals(false, vm.uiState.value.writeBanner?.isError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit surfaces an Ok banner and an empty message is rejected client-side with no I O`() = runTest {
|
||||
val fetcher = FakeFetcher(oneFileResult(false))
|
||||
val writer = FakeWriter()
|
||||
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
vm.bind(scope); advanceUntilIdle()
|
||||
|
||||
vm.commit(" "); advanceUntilIdle() // blank → client-side reject
|
||||
assertEquals(0, writer.commitCalls, "a blank commit message must not hit the network")
|
||||
assertEquals(true, vm.uiState.value.writeBanner?.isError)
|
||||
|
||||
vm.commit("real message"); advanceUntilIdle()
|
||||
assertEquals(1, writer.commitCalls)
|
||||
assertEquals(false, vm.uiState.value.writeBanner?.isError)
|
||||
assertTrue(vm.uiState.value.writeBanner!!.message.contains("abc123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push maps a 409 rejection to the inert server message and does not refresh`() = runTest {
|
||||
val fetcher = FakeFetcher(oneFileResult(false))
|
||||
val writer = FakeWriter(push = GitWriteOutcome.Rejected(409, "Push rejected: remote has diverged."))
|
||||
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
vm.bind(scope); advanceUntilIdle()
|
||||
|
||||
vm.push(); advanceUntilIdle()
|
||||
|
||||
assertEquals(1, writer.pushCalls)
|
||||
assertEquals(true, vm.uiState.value.writeBanner?.isError)
|
||||
assertEquals("Push rejected: remote has diverged.", vm.uiState.value.writeBanner?.message)
|
||||
assertEquals(1, fetcher.calls.size, "a failed push must NOT refresh the diff")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `git-write is disabled in base mode`() = runTest {
|
||||
val fetcher = FakeFetcher(oneFileResult(false))
|
||||
val writer = FakeWriter()
|
||||
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
vm.bind(scope); advanceUntilIdle()
|
||||
vm.setBase("main"); advanceUntilIdle()
|
||||
|
||||
vm.toggleStage("a.kt", true); vm.commit("m"); vm.push(); advanceUntilIdle()
|
||||
|
||||
assertTrue(writer.stageCalls.isEmpty() && writer.commitCalls == 0 && writer.pushCalls == 0)
|
||||
assertFalse(vm.uiState.value.writeEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writeEnabled is false without a writer and true with one in working mode`() {
|
||||
assertFalse(DiffUiState(canWrite = false).writeEnabled)
|
||||
assertTrue(DiffUiState(canWrite = true).writeEnabled)
|
||||
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
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.enroll.DeviceEnrollmentError
|
||||
import wang.yaojia.webterm.clienttls.CertificateSummary
|
||||
import java.security.KeyStoreException
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* [EnrollmentViewModel] / [validControlPlaneUrl] / [classifyEnroll] (B4) — the JVM-tested zero-`.p12`
|
||||
* enrollment core, mirroring iOS `EnrollmentViewModelTests`. The enroll flow itself is covered headlessly
|
||||
* in `DeviceEnrollerTest`; these cover the VM's boundary validation, success bookkeeping (summary +
|
||||
* password-clear), and the error→[EnrollError] mapping — including that the password never lingers after
|
||||
* an attempt. The keystore / network / Compose shell is device-QA (plan §7); THIS is the pure core.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EnrollmentViewModelTest {
|
||||
|
||||
/** Records enroll invocations and replays a scripted result/throwable (mirrors iOS EnrollScript). */
|
||||
private class EnrollScript(private val result: Result<CertificateSummary>) {
|
||||
val calls = mutableListOf<Call>()
|
||||
data class Call(val password: String, val subdomain: String, val deviceName: String, val url: String)
|
||||
|
||||
suspend fun run(password: String, subdomain: String, deviceName: String, url: String): CertificateSummary {
|
||||
calls.add(Call(password, subdomain, deviceName, url))
|
||||
return result.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
private val fixedNow = Instant.parse("2026-01-01T00:00:00Z")
|
||||
private val utc = ZoneId.of("UTC")
|
||||
|
||||
private fun summary(
|
||||
subject: String? = "alice-pixel",
|
||||
issuer: String? = "webterm-device-ca",
|
||||
notAfter: Instant? = Instant.parse("2026-10-06T00:00:00Z"),
|
||||
) = CertificateSummary(subjectCommonName = subject, issuerCommonName = issuer, notAfter = notAfter)
|
||||
|
||||
private fun newVm(
|
||||
script: EnrollScript,
|
||||
installed: CertificateSummary? = null,
|
||||
controlPlaneUrl: String = "https://cp.terminal.yaojia.wang",
|
||||
subdomain: String = "alice",
|
||||
deviceName: String = "Pixel 8",
|
||||
): EnrollmentViewModel {
|
||||
val vm = EnrollmentViewModel(
|
||||
enrollOperation = { p, s, d, u -> script.run(p, s, d, u) },
|
||||
loadSummary = { installed },
|
||||
defaultControlPlaneUrl = controlPlaneUrl,
|
||||
defaultDeviceName = deviceName,
|
||||
zone = utc,
|
||||
now = { fixedNow },
|
||||
)
|
||||
vm.onSubdomainChange(subdomain)
|
||||
return vm
|
||||
}
|
||||
|
||||
// ── Success ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a successful enroll sets the summary, flags success and clears the password`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val script = EnrollScript(Result.success(summary(subject = "alice-pixel")))
|
||||
val vm = newVm(script)
|
||||
vm.bind(backgroundScope)
|
||||
vm.onPasswordChange("operator-secret")
|
||||
|
||||
vm.enroll()
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertEquals(1, script.calls.size)
|
||||
assertEquals("operator-secret", script.calls.single().password, "the entered password reaches the flow")
|
||||
assertEquals("alice-pixel", state.summary?.subjectCommonName)
|
||||
assertTrue(state.didSucceed)
|
||||
assertNull(state.error)
|
||||
assertEquals("", state.password, "the password must never linger after a successful enroll")
|
||||
assertEquals(EnrollPhase.IDLE, state.phase)
|
||||
}
|
||||
|
||||
// ── Boundary validation (no network) ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a non-https control-plane URL is rejected before any network`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val script = EnrollScript(Result.success(summary()))
|
||||
val vm = newVm(script, controlPlaneUrl = "http://cp.terminal.yaojia.wang")
|
||||
vm.bind(backgroundScope)
|
||||
vm.onPasswordChange("pw")
|
||||
|
||||
vm.enroll()
|
||||
|
||||
assertTrue(script.calls.isEmpty(), "an insecure URL must never hit the network")
|
||||
assertEquals(EnrollError.INVALID_URL, vm.uiState.value.error)
|
||||
assertFalse(vm.uiState.value.didSucceed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing fields are rejected before any network`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val script = EnrollScript(Result.success(summary()))
|
||||
val vm = newVm(script, subdomain = " ") // whitespace-only subdomain
|
||||
vm.bind(backgroundScope)
|
||||
vm.onPasswordChange("pw")
|
||||
|
||||
vm.enroll()
|
||||
|
||||
assertTrue(script.calls.isEmpty())
|
||||
assertEquals(EnrollError.MISSING_FIELDS, vm.uiState.value.error)
|
||||
}
|
||||
|
||||
// ── Error → copy mapping (password still cleared) ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a 403 subdomain-not-owned maps to actionable copy and clears the password`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(403, "rejected")))
|
||||
val vm = newVm(script)
|
||||
vm.bind(backgroundScope)
|
||||
vm.onPasswordChange("operator-secret")
|
||||
|
||||
vm.enroll()
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, state.error)
|
||||
assertFalse(state.didSucceed)
|
||||
assertEquals("", state.password, "the password is cleared even after a failed enroll")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 login maps to the bad-credential copy`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(401, "rejected")))
|
||||
val vm = newVm(script)
|
||||
vm.bind(backgroundScope)
|
||||
vm.onPasswordChange("wrong")
|
||||
|
||||
vm.enroll()
|
||||
|
||||
assertEquals(EnrollError.BAD_CREDENTIAL, vm.uiState.value.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hardware keystore fault maps to the keygen copy`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val script = EnrollScript(Result.failure(KeyStoreException("no StrongBox / TEE")))
|
||||
val vm = newVm(script)
|
||||
vm.bind(backgroundScope)
|
||||
vm.onPasswordChange("operator-secret")
|
||||
|
||||
vm.enroll()
|
||||
|
||||
assertEquals(EnrollError.KEYGEN, vm.uiState.value.error)
|
||||
}
|
||||
|
||||
// ── canEnroll gating ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `canEnroll requires every field including a non-empty password`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val vm = newVm(EnrollScript(Result.success(summary())))
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
assertFalse(vm.uiState.value.canEnroll, "password empty → disabled")
|
||||
vm.onPasswordChange("pw")
|
||||
assertTrue(vm.uiState.value.canEnroll)
|
||||
vm.onSubdomainChange(" ")
|
||||
assertFalse(vm.uiState.value.canEnroll, "whitespace-only subdomain → disabled")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearError dismisses a surfaced error`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val vm = newVm(newFailing())
|
||||
vm.bind(backgroundScope)
|
||||
vm.onPasswordChange("pw")
|
||||
vm.enroll()
|
||||
assertEquals(EnrollError.REJECTED, vm.uiState.value.error)
|
||||
|
||||
vm.clearError()
|
||||
|
||||
assertNull(vm.uiState.value.error)
|
||||
}
|
||||
|
||||
private fun newFailing() = EnrollScript(Result.failure(DeviceEnrollmentError.Http(400, "rejected")))
|
||||
|
||||
// ── Pure helpers ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `validControlPlaneUrl accepts https with a host and rejects everything else`() {
|
||||
assertEquals("https://cp.terminal.yaojia.wang", validControlPlaneUrl(" https://cp.terminal.yaojia.wang "))
|
||||
assertNull(validControlPlaneUrl("http://cp.terminal.yaojia.wang"), "http is rejected")
|
||||
assertNull(validControlPlaneUrl("https://"), "no host is rejected")
|
||||
assertNull(validControlPlaneUrl("not a url"), "unparseable is rejected")
|
||||
assertNull(validControlPlaneUrl(""), "empty is rejected")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classifyEnroll maps each throwable to its coarse EnrollError`() {
|
||||
assertEquals(EnrollError.BAD_CREDENTIAL, classifyEnroll(DeviceEnrollmentError.Http(401, null)))
|
||||
assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, classifyEnroll(DeviceEnrollmentError.Http(403, null)))
|
||||
assertEquals(EnrollError.RATE_LIMITED, classifyEnroll(DeviceEnrollmentError.Http(429, null)))
|
||||
assertEquals(EnrollError.REJECTED, classifyEnroll(DeviceEnrollmentError.Http(400, null)))
|
||||
assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.Http(500, null)))
|
||||
assertEquals(EnrollError.SERVER, classifyEnroll(DeviceEnrollmentError.MalformedResponse))
|
||||
assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.InvalidRequest))
|
||||
assertEquals(EnrollError.KEYGEN, classifyEnroll(KeyStoreException("x")))
|
||||
assertEquals(EnrollError.UNKNOWN, classifyEnroll(IllegalStateException("x")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
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.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.RemoveWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
|
||||
/**
|
||||
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
|
||||
* ProjectDetailViewModel PR/log). Records the guarded-write call args and returns canned outcomes;
|
||||
* PR/log return canned values or throw to exercise failure-isolation. The list-page methods
|
||||
* (projects/prefs) are unused here and throw if called.
|
||||
*/
|
||||
class FakeWorktreeGateway(
|
||||
private val detail: ProjectDetail? = null,
|
||||
private val prResult: PrStatus? = null,
|
||||
private val prThrows: Boolean = false,
|
||||
private val logResult: GitLogResult? = null,
|
||||
private val logThrows: Boolean = false,
|
||||
private val createOutcome: GitWriteOutcome<CreateWorktreeResult> = GitWriteOutcome.Ok(CreateWorktreeResult()),
|
||||
private val removeOutcome: GitWriteOutcome<RemoveWorktreeResult> = GitWriteOutcome.Ok(RemoveWorktreeResult()),
|
||||
private val pruneOutcome: GitWriteOutcome<PruneWorktreesResult> = GitWriteOutcome.Ok(PruneWorktreesResult()),
|
||||
) : ProjectsGateway {
|
||||
val createCalls = mutableListOf<Triple<String, String, String?>>()
|
||||
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
|
||||
val pruneCalls = mutableListOf<String>()
|
||||
var detailCalls = 0
|
||||
private set
|
||||
|
||||
override suspend fun projects(): List<ProjectInfo> = throw NotImplementedError()
|
||||
override suspend fun prefs(): UiPrefs = throw NotImplementedError()
|
||||
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = throw NotImplementedError()
|
||||
|
||||
override suspend fun projectDetail(path: String): ProjectDetail {
|
||||
detailCalls++
|
||||
return detail ?: throw NotImplementedError("no detail configured")
|
||||
}
|
||||
|
||||
override suspend fun projectPr(path: String): PrStatus {
|
||||
if (prThrows) throw RuntimeException("pr unavailable")
|
||||
return prResult ?: throw NotImplementedError("no pr configured")
|
||||
}
|
||||
|
||||
override suspend fun projectLog(path: String, n: Int?): GitLogResult {
|
||||
if (logThrows) throw RuntimeException("log unavailable")
|
||||
return logResult ?: throw NotImplementedError("no log configured")
|
||||
}
|
||||
|
||||
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
|
||||
createCalls += Triple(path, branch, base)
|
||||
return createOutcome
|
||||
}
|
||||
|
||||
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> {
|
||||
removeCalls += Triple(path, worktreePath, force)
|
||||
return removeOutcome
|
||||
}
|
||||
|
||||
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> {
|
||||
pruneCalls += path
|
||||
return pruneOutcome
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
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.CommitLogEntry
|
||||
import wang.yaojia.webterm.api.models.GitLogResult
|
||||
import wang.yaojia.webterm.api.models.PrAvailability
|
||||
import wang.yaojia.webterm.api.models.PrStatus
|
||||
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||
|
||||
/**
|
||||
* W5 ProjectDetailViewModel side fetches (JVM). The PR chip and recent-commits list are failure-
|
||||
* ISOLATED: a failure of either NEVER fails the detail load nor the other; a non-`ok` availability
|
||||
* renders a degraded (but Loaded) chip; the commit list decodes into its own state.
|
||||
*/
|
||||
class ProjectDetailPrLogTest {
|
||||
|
||||
private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "main")
|
||||
|
||||
@Test
|
||||
fun `detail plus PR plus log all load`() = runTest {
|
||||
val gateway = FakeWorktreeGateway(
|
||||
detail = detail,
|
||||
prResult = PrStatus(availability = PrAvailability.OK, number = 7, title = "A PR"),
|
||||
logResult = GitLogResult(commits = listOf(CommitLogEntry("h", 1, "s")), truncated = false),
|
||||
)
|
||||
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
|
||||
|
||||
vm.load()
|
||||
|
||||
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
|
||||
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
|
||||
assertEquals(PrAvailability.OK, chip.status.availability)
|
||||
val commits = vm.recentCommits.value as ProjectDetailViewModel.RecentCommits.Loaded
|
||||
assertEquals(1, commits.result.commits.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a PR fetch failure does not fail the detail load nor the log`() = runTest {
|
||||
val gateway = FakeWorktreeGateway(
|
||||
detail = detail,
|
||||
prThrows = true,
|
||||
logResult = GitLogResult(commits = emptyList(), truncated = false),
|
||||
)
|
||||
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
|
||||
|
||||
vm.load()
|
||||
|
||||
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "detail must still load")
|
||||
assertEquals(ProjectDetailViewModel.PrChip.Unavailable, vm.prChip.value)
|
||||
assertTrue(vm.recentCommits.value is ProjectDetailViewModel.RecentCommits.Loaded, "log stays isolated")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a log fetch failure isolates to the recent-commits state only`() = runTest {
|
||||
val gateway = FakeWorktreeGateway(
|
||||
detail = detail,
|
||||
prResult = PrStatus(availability = PrAvailability.NO_PR),
|
||||
logThrows = true,
|
||||
)
|
||||
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
|
||||
|
||||
vm.load()
|
||||
|
||||
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
|
||||
assertEquals(ProjectDetailViewModel.RecentCommits.Unavailable, vm.recentCommits.value)
|
||||
// A non-ok availability is still a Loaded chip (degraded copy is a render concern).
|
||||
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
|
||||
assertEquals(PrAvailability.NO_PR, chip.status.availability)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wired worktree VM shares the repo path and refreshes the detail on a successful create`() = runTest {
|
||||
val gateway = FakeWorktreeGateway(
|
||||
detail = detail,
|
||||
prResult = PrStatus(availability = PrAvailability.DISABLED),
|
||||
logResult = GitLogResult(),
|
||||
)
|
||||
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
|
||||
vm.load()
|
||||
val detailCallsAfterLoad = gateway.detailCalls
|
||||
|
||||
vm.worktree!!.create("feat/x")
|
||||
|
||||
assertTrue(gateway.detailCalls > detailCallsAfterLoad, "create success re-fetches the detail")
|
||||
assertEquals("/repo", gateway.createCalls.single().first)
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,11 @@ class ProjectsViewModelTest {
|
||||
}
|
||||
|
||||
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
|
||||
override suspend fun projectPr(path: String) = throw NotImplementedError()
|
||||
override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError()
|
||||
override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError()
|
||||
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError()
|
||||
override suspend fun pruneWorktrees(path: String) = throw NotImplementedError()
|
||||
}
|
||||
|
||||
private fun proj(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
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.CreateWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||
|
||||
/**
|
||||
* W5 WorktreeViewModel (JVM). The guarded worktree write phase machine: client-side branch validation
|
||||
* (no I/O on a bad name), main-worktree removal blocked client-side, the force flag threaded, and the
|
||||
* server's SAFE error strings (disabled 403 / 429) surfaced inertly. On success it re-fetches the detail.
|
||||
*/
|
||||
class WorktreeViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `an invalid branch name fails with no network I O`() = runTest {
|
||||
val gateway = FakeWorktreeGateway()
|
||||
val vm = WorktreeViewModel(gateway, "/repo")
|
||||
|
||||
vm.create("bad branch~name") // whitespace + '~' are forbidden
|
||||
|
||||
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Failed)
|
||||
assertEquals(WorktreeCopy.INVALID_BRANCH, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
|
||||
assertEquals(0, gateway.createCalls.size, "an invalid branch must never hit the network")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leading dash and dotdot and trailing dot are all rejected client-side`() {
|
||||
assertTrue(WorktreeViewModel.isValidBranchName("feat/ok-name"))
|
||||
assertTrue(WorktreeViewModel.isValidBranchName("release/1.2.x"))
|
||||
listOf("-flag", "a..b", "ends.", "has space", "a~b", "a:b", "@{now}", "", "//x", "/lead", "trail/").forEach {
|
||||
assertTrue(!WorktreeViewModel.isValidBranchName(it), "should reject '$it'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create success settles Done and re-fetches the detail`() = runTest {
|
||||
val gateway = FakeWorktreeGateway(
|
||||
createOutcome = GitWriteOutcome.Ok(CreateWorktreeResult(path = "/repo-worktrees/feat", branch = "feat/x")),
|
||||
)
|
||||
var refreshes = 0
|
||||
val vm = WorktreeViewModel(gateway, "/repo", onChanged = { refreshes++ })
|
||||
|
||||
vm.create("feat/x", base = "main")
|
||||
|
||||
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
|
||||
assertEquals(1, refreshes, "a successful create must re-fetch the detail")
|
||||
assertEquals(Triple("/repo", "feat/x", "main"), gateway.createCalls.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing a main worktree is blocked client-side with no I O`() = runTest {
|
||||
val gateway = FakeWorktreeGateway()
|
||||
val vm = WorktreeViewModel(gateway, "/repo")
|
||||
|
||||
vm.remove(WorktreeInfo(path = "/repo", branch = "main", isMain = true), force = false)
|
||||
|
||||
assertEquals(WorktreeCopy.CANNOT_REMOVE_MAIN, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
|
||||
assertEquals(0, gateway.removeCalls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove threads the force flag`() = runTest {
|
||||
val gateway = FakeWorktreeGateway(removeOutcome = GitWriteOutcome.Ok(RemoveWorktreeResult(path = "/wt/x")))
|
||||
val vm = WorktreeViewModel(gateway, "/repo")
|
||||
|
||||
vm.remove(WorktreeInfo(path = "/wt/x", branch = "feat", isMain = false), force = true)
|
||||
|
||||
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
|
||||
assertEquals(Triple("/repo", "/wt/x", true), gateway.removeCalls.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 403 disabled rejection surfaces the safe server message inertly`() = runTest {
|
||||
val gateway = FakeWorktreeGateway(
|
||||
createOutcome = GitWriteOutcome.Rejected(403, "Worktree creation is disabled."),
|
||||
)
|
||||
val vm = WorktreeViewModel(gateway, "/repo")
|
||||
|
||||
vm.create("feat/x")
|
||||
|
||||
assertEquals("Worktree creation is disabled.", (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 429 rate-limit surfaces the rate-limited copy`() = runTest {
|
||||
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.RateLimited)
|
||||
val vm = WorktreeViewModel(gateway, "/repo")
|
||||
|
||||
vm.prune()
|
||||
|
||||
assertEquals(WorktreeCopy.RATE_LIMITED, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prune with nothing to reclaim reports an empty result`() = runTest {
|
||||
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.Ok(PruneWorktreesResult(pruned = emptyList())))
|
||||
val vm = WorktreeViewModel(gateway, "/repo")
|
||||
|
||||
vm.prune()
|
||||
|
||||
val done = vm.phase.value as WorktreeViewModel.Phase.Done
|
||||
assertTrue(done.message.contains("没有"))
|
||||
}
|
||||
}
|
||||
@@ -29,17 +29,34 @@ android {
|
||||
minSdk = 29
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
testOptions {
|
||||
unitTests {
|
||||
// The device-enroll orchestration commit logs via android.util.Log — let the JVM unit
|
||||
// tests stub it (return 0) instead of throwing "not mocked". The security-critical paths
|
||||
// (commit sequencing, error handling) run on the JVM with a software key double.
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
// JVM (local) unit tests use JUnit 5 (matching the pure modules); AGP's testDebug/ReleaseUnitTest
|
||||
// tasks are `Test` tasks, so opt them into the JUnit Platform.
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table),
|
||||
// CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types.
|
||||
// (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.)
|
||||
api(project(":client-tls"))
|
||||
// B4 device-enroll: the pure CSR encoder + login/enroll/renew client + HttpTransport seam live in
|
||||
// :api-client (JVM-unit-tested); the framework HardwareBackedKey/DeviceEnroller drive them.
|
||||
implementation(project(":api-client"))
|
||||
implementation(libs.tink.android)
|
||||
implementation(libs.okhttp)
|
||||
// Mutex serializes the two-store rotation commit (single-commit invariant, A11).
|
||||
@@ -50,4 +67,10 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.test.core)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators
|
||||
|
||||
// Local JVM unit tests (src/test) — the DeviceEnroller enroll/commit orchestration driven with a
|
||||
// software P-256 key double + the shared FakeHttpTransport (no emulator, no AndroidKeyStore).
|
||||
testImplementation(project(":test-support"))
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import java.security.Signature
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import wang.yaojia.webterm.api.enroll.CertificateSigningRequest
|
||||
|
||||
/**
|
||||
* B4 · Instrumented (real AndroidKeyStore — NOT Robolectric, plan §7) proof that the generated
|
||||
* device key is hardware-backed, NON-EXPORTABLE, and produces a self-signed P-256 CSR the
|
||||
* control-plane accepts. COMPILES in CI here; RUNS on a device/emulator during device QA
|
||||
* (StrongBox availability is device-dependent — [HardwareKeyStore.generate] falls back to the TEE).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class HardwareBackedKeyTest {
|
||||
private val alias = "test-device-enroll-key"
|
||||
|
||||
@Before
|
||||
fun clean() = HardwareKeyStore.delete(alias)
|
||||
|
||||
@After
|
||||
fun tearDown() = HardwareKeyStore.delete(alias)
|
||||
|
||||
@Test
|
||||
fun generate_producesA65ByteX963PublicPoint() {
|
||||
val key = HardwareKeyStore.generate(alias)
|
||||
val point = key.publicKeyX963()
|
||||
assertEquals(65, point.size)
|
||||
assertEquals(0x04, point[0].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun generatedKeyIsNonExportable() {
|
||||
HardwareKeyStore.generate(alias)
|
||||
val loaded = HardwareKeyStore.load(alias)
|
||||
assertNotNull(loaded)
|
||||
// AndroidKeyStore private keys have no exportable encoding — the material never leaves HW.
|
||||
assertNull("AndroidKeyStore key must expose no encoded form", loaded!!.keyHandle.encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun csrSignedByHardwareKeySelfVerifies() {
|
||||
val key = HardwareKeyStore.generate(alias)
|
||||
|
||||
val der = CertificateSigningRequest.der("t1-android", key)
|
||||
|
||||
// Re-parse the CertificationRequestInfo + signature and verify with the embedded public key.
|
||||
val outer = TestDer.read(der, 0)!!
|
||||
val parts = TestDer.children(der, outer)
|
||||
val info = der.copyOfRange(parts[0].start, parts[0].end)
|
||||
val bitString = parts[2]
|
||||
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
|
||||
|
||||
// Rebuild a JCA public key from the X9.63 point to run the same crypto check the server does.
|
||||
val point = key.publicKeyX963()
|
||||
val pub = X963PublicKeys.p256(point)
|
||||
val ok = Signature.getInstance("SHA256withECDSA").apply {
|
||||
initVerify(pub)
|
||||
update(info)
|
||||
}.verify(signature)
|
||||
assertTrue("hardware-signed CSR must self-verify", ok)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadAfterGenerateReturnsAKeyWithTheSamePublicPoint() {
|
||||
val generated = HardwareKeyStore.generate(alias)
|
||||
val reloaded = HardwareKeyStore.load(alias)
|
||||
assertNotNull(reloaded)
|
||||
assertArrayEquals(generated.publicKeyX963(), reloaded!!.publicKeyX963())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadReturnsNullWhenNoKeyExists() {
|
||||
assertNull(HardwareKeyStore.load("absent-alias-xyz"))
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconstruct a P-256 public key from an X9.63 uncompressed point, for on-device signature checks. */
|
||||
private object X963PublicKeys {
|
||||
fun p256(point: ByteArray): java.security.PublicKey {
|
||||
val params = java.security.AlgorithmParameters.getInstance("EC").apply {
|
||||
init(java.security.spec.ECGenParameterSpec("secp256r1"))
|
||||
}
|
||||
val spec = params.getParameterSpec(java.security.spec.ECParameterSpec::class.java)
|
||||
val x = java.math.BigInteger(1, point.copyOfRange(1, 33))
|
||||
val y = java.math.BigInteger(1, point.copyOfRange(33, 65))
|
||||
val pubSpec = java.security.spec.ECPublicKeySpec(java.security.spec.ECPoint(x, y), spec)
|
||||
return java.security.KeyFactory.getInstance("EC").generatePublic(pubSpec)
|
||||
}
|
||||
}
|
||||
|
||||
/** A throwaway canonical-DER reader for structural assertions (device-side mirror of the JVM test). */
|
||||
private 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, start, index, 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
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,31 @@ class IdentityRepositoryTest {
|
||||
assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias)
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX 3 (cache freshness): a device cert committed OUT OF BAND of a running repository (the zero-`.p12`
|
||||
* [DeviceEnroller] writes the leaf straight into the shared [CertStore] + AndroidKeyStore) is picked up
|
||||
* by [AndroidIdentityRepository.refreshFromStore] WITHOUT a process restart — the cached "no identity"
|
||||
* flips to the freshly-committed leaf and is presented on the next handshake.
|
||||
*/
|
||||
@Test
|
||||
fun refreshFromStore_publishesAnOutOfBandCommittedIdentity_withoutRestart() = runBlocking {
|
||||
val running = newRepository()
|
||||
// Touch it while nothing is installed — caches the (null) initial identity.
|
||||
assertFalse(running.hasInstalledIdentity())
|
||||
|
||||
// Simulate DeviceEnroller committing an identity out of band (a second repo over the SAME stores).
|
||||
newRepository().importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
|
||||
|
||||
// The running repo still shows its stale cache (no restart yet).
|
||||
assertFalse("stale cache still reports no identity before a refresh", running.hasInstalledIdentity())
|
||||
|
||||
running.refreshFromStore()
|
||||
|
||||
// The refresh re-read the committed live-pointer → the enrolled leaf is now live.
|
||||
assertTrue("refreshFromStore must publish the out-of-band committed identity", running.hasInstalledIdentity())
|
||||
assertEquals(Fixtures.LEAF_SUBJECT_CN, running.currentSummary()?.subjectCommonName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking {
|
||||
val repository = newRepository()
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import android.util.Log
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.OkHttpClient
|
||||
import wang.yaojia.webterm.api.enroll.CertificateSigningRequest
|
||||
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
|
||||
import wang.yaojia.webterm.api.enroll.EnrollmentResult
|
||||
import wang.yaojia.webterm.clienttls.CertificateSummary
|
||||
import wang.yaojia.webterm.clienttls.CertificateSummaryReader
|
||||
|
||||
/**
|
||||
* B4 · The Android device-enroll orchestrator — the `.p12`-free path that mirrors iOS
|
||||
* `KeychainClientIdentityStore.enroll/renew`. It composes the five B4 pieces:
|
||||
*
|
||||
* 1. generate a NON-EXPORTABLE hardware key ([HardwareKeyStore]: StrongBox → TEE),
|
||||
* 2. self-sign a P-256 PKCS#10 CSR with it ([CertificateSigningRequest], `:api-client`),
|
||||
* 3. run the login → `POST /device/enroll` flow ([DeviceEnrollmentClient], `:api-client`),
|
||||
* 4. store the returned leaf + issuer chain into the SAME [CertStore] + AndroidKeyStore slot the
|
||||
* existing [AndroidIdentityRepository] resolves from — so it is presented on the EXISTING
|
||||
* re-reading `X509KeyManager` mTLS path with no change to that module, and
|
||||
* 5. expose a silent [renew] against `/device/:id/renew` using the SAME hardware key.
|
||||
*
|
||||
* The mutating methods are serialized by a [Mutex] so an enroll and a rotation can never interleave
|
||||
* the two-store commit (cert live-pointer + enrollment record).
|
||||
*
|
||||
* ### The commit
|
||||
* The cert-store save is THE durable live-pointer flip (identical to the import/rotation path). It is
|
||||
* written LAST, after the enrollment record, so a successful cert-store save always means the mTLS
|
||||
* identity is fully live; the pool is then evicted so the next handshake presents the new leaf.
|
||||
*/
|
||||
public class DeviceEnroller(
|
||||
private val client: DeviceEnrollmentClient,
|
||||
private val certStore: CertStore,
|
||||
private val recordStore: EnrollmentRecordStore,
|
||||
private val sharedClient: OkHttpClient,
|
||||
private val keyAlias: String = AndroidKeyStoreImporter.DEFAULT_ALIAS,
|
||||
private val keyProvider: DeviceKeyProvider = HardwareDeviceKeyProvider,
|
||||
// FIX 3 (cache freshness): the in-memory identity cache (AndroidIdentityRepository) is refreshed
|
||||
// AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no
|
||||
// process restart. Optional so the JVM orchestration tests can construct the enroller without it.
|
||||
private val cacheRefresher: IdentityCacheRefresher? = null,
|
||||
) {
|
||||
private val commitMutex = Mutex()
|
||||
|
||||
/** Raised when a state-changing enroll/renew precondition is not met. Never leaks a secret. */
|
||||
public class EnrollmentStateException(message: String) : Exception(message)
|
||||
|
||||
/**
|
||||
* One-time enrollment: login (operator password → short-lived `device:enroll` bearer) → generate
|
||||
* a non-exportable hardware key → CSR → `POST /device/enroll` → store the leaf + present it.
|
||||
* Returns the installed leaf's display summary. The bearer is held only for this call, never
|
||||
* persisted or logged.
|
||||
*/
|
||||
public suspend fun enroll(
|
||||
password: String,
|
||||
subdomain: String,
|
||||
deviceName: String,
|
||||
): CertificateSummary = commitMutex.withLock {
|
||||
val login = client.login(password)
|
||||
// Generate the hardware key ONLY after a successful login, so a rejected credential never
|
||||
// burns a fresh key slot; overwrites any stale key at the alias.
|
||||
val key = keyProvider.generate(keyAlias)
|
||||
try {
|
||||
val csr = CertificateSigningRequest.der(deviceName, key)
|
||||
val result = client.enroll(login.enrollToken, csr, subdomain, deviceName)
|
||||
commitIdentity(result, deviceName, key.alias)
|
||||
summaryOf(result)
|
||||
} catch (e: Exception) {
|
||||
// The enroll failed AFTER keygen: drop the orphan key so a retry starts clean and no
|
||||
// unreferenced key lingers in secure hardware.
|
||||
runCatching { keyProvider.delete(key.alias) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent rotation: re-CSR from the SAME hardware key and replace the leaf via
|
||||
* `POST /device/:id/renew`. The renew endpoint authenticates by the CURRENT device certificate over
|
||||
* mTLS (the presented client cert), so [bearerToken] is OPTIONAL and defaults to absent — the
|
||||
* production caller passes none (mirrors iOS, which renews with `bearerToken: nil`). The seam still
|
||||
* accepts a bearer for a hypothetical bearer-authenticated renew, but bakes in no credential policy;
|
||||
* it only re-signs and re-commits. Throws [EnrollmentStateException] if there is nothing enrolled to
|
||||
* renew or the key is gone.
|
||||
*/
|
||||
public suspend fun renew(bearerToken: String? = null): CertificateSummary = commitMutex.withLock {
|
||||
val record = recordStore.load()
|
||||
?: throw EnrollmentStateException("no enrollment record — nothing to renew")
|
||||
val key = keyProvider.load(record.keyStoreAlias)
|
||||
?: throw EnrollmentStateException("device key missing — a fresh enroll is required")
|
||||
val csr = CertificateSigningRequest.der(record.deviceName, key)
|
||||
val result = client.renew(record.deviceId, csr, bearerToken)
|
||||
commitIdentity(result, record.deviceName, record.keyStoreAlias)
|
||||
summaryOf(result)
|
||||
}
|
||||
|
||||
/** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */
|
||||
public suspend fun remove(): Unit = commitMutex.withLock {
|
||||
certStore.clear()
|
||||
recordStore.clear()
|
||||
keyProvider.delete(keyAlias)
|
||||
sharedClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the enrollment record (deviceId → renew), THEN commit the cert live-pointer (the mTLS
|
||||
* flip), THEN evict pooled/resumed connections so the next handshake presents the new leaf via
|
||||
* the existing re-reading `X509KeyManager`. The key already lives in AndroidKeyStore at [alias];
|
||||
* the private key never enters storage.
|
||||
*/
|
||||
private fun commitIdentity(result: EnrollmentResult, deviceName: String, alias: String) {
|
||||
val leaf = parseCertificate(result.certificate)
|
||||
val issuers = result.caChain.map { parseCertificate(it) }
|
||||
|
||||
recordStore.save(
|
||||
EnrollmentRecord(
|
||||
deviceId = result.deviceId,
|
||||
deviceName = deviceName,
|
||||
keyStoreAlias = alias,
|
||||
renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L,
|
||||
),
|
||||
)
|
||||
certStore.save(
|
||||
StoredIdentityMetadata(
|
||||
alias = alias,
|
||||
keyAlgorithm = KEY_ALGORITHM_EC,
|
||||
keyStoreAlias = alias,
|
||||
certificateChain = listOf(leaf) + issuers,
|
||||
),
|
||||
)
|
||||
sharedClient.connectionPool.evictAll()
|
||||
// FIX 3: re-read the just-committed live-pointer into the in-memory identity cache so the
|
||||
// newly enrolled/renewed leaf is presented on the NEXT mTLS handshake without a restart. Done
|
||||
// AFTER the durable commit + pool eviction so the cache can never publish an un-committed leaf.
|
||||
cacheRefresher?.refreshFromStore()
|
||||
Log.i(TAG, "Device identity enrolled/renewed and committed for alias '$alias'")
|
||||
}
|
||||
|
||||
private fun summaryOf(result: EnrollmentResult): CertificateSummary =
|
||||
CertificateSummaryReader.summarize(parseCertificate(result.certificate))
|
||||
|
||||
private fun parseCertificate(der: ByteArray): X509Certificate =
|
||||
CertificateFactory.getInstance(X509).generateCertificate(der.inputStream()) as X509Certificate
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DeviceEnroller"
|
||||
const val X509 = "X.509"
|
||||
|
||||
/** AndroidKeyStore EC keys report algorithm "EC" — matched by `ClientKeyManagerLogic`. */
|
||||
const val KEY_ALGORITHM_EC = "EC"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
/**
|
||||
* B4 · A seam over the three non-exportable hardware-key operations [DeviceEnroller]'s
|
||||
* enroll/renew orchestration needs. Production wires the real AndroidKeyStore-backed
|
||||
* [HardwareKeyStore] (StrongBox → TEE); a JVM unit test wires a software P-256 double, so the
|
||||
* enroll/commit orchestration (request shaping, error handling, the two-store commit sequencing)
|
||||
* can be exercised without an emulator. NOTHING about the hardware-key policy leaks through this
|
||||
* seam beyond generate/load/delete — the key stays non-exportable in the real implementation.
|
||||
*/
|
||||
public interface DeviceKeyProvider {
|
||||
/** Generate a fresh non-exportable key at [alias], overwriting any prior entry there. */
|
||||
public fun generate(alias: String): HardwareBackedKey
|
||||
|
||||
/** Load a previously-generated key by [alias], or null if no entry exists (pre-enroll state). */
|
||||
public fun load(alias: String): HardwareBackedKey?
|
||||
|
||||
/** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */
|
||||
public fun delete(alias: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* The production [DeviceKeyProvider] — a thin delegate to the AndroidKeyStore-backed
|
||||
* [HardwareKeyStore]. Kept as a stateless object so it can be the [DeviceEnroller] constructor
|
||||
* default without any wiring.
|
||||
*/
|
||||
public object HardwareDeviceKeyProvider : DeviceKeyProvider {
|
||||
override fun generate(alias: String): HardwareBackedKey = HardwareKeyStore.generate(alias)
|
||||
|
||||
override fun load(alias: String): HardwareBackedKey? = HardwareKeyStore.load(alias)
|
||||
|
||||
override fun delete(alias: String): Unit = HardwareKeyStore.delete(alias)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import com.google.crypto.tink.Aead
|
||||
import com.google.crypto.tink.KeyTemplates
|
||||
import com.google.crypto.tink.RegistryConfiguration
|
||||
import com.google.crypto.tink.aead.AeadConfig
|
||||
import com.google.crypto.tink.integration.android.AndroidKeysetManager
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
|
||||
/**
|
||||
* B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted
|
||||
* [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR
|
||||
* subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign
|
||||
* with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler.
|
||||
*
|
||||
* This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert
|
||||
* identity is what the handshake presents; this record only exists so renew can find the device and
|
||||
* its key. The private key is never here — it stays non-exportable in AndroidKeyStore.
|
||||
*/
|
||||
public data class EnrollmentRecord(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val keyStoreAlias: String,
|
||||
val renewAfterEpochSeconds: Long,
|
||||
) {
|
||||
init {
|
||||
require(deviceId.isNotBlank()) { "deviceId must not be blank" }
|
||||
require(keyStoreAlias.isNotBlank()) { "keyStoreAlias must not be blank" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */
|
||||
public object EnrollmentRecordCodec {
|
||||
public fun encode(record: EnrollmentRecord): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
DataOutputStream(out).use { data ->
|
||||
data.writeUTF(record.deviceId)
|
||||
data.writeUTF(record.deviceName)
|
||||
data.writeUTF(record.keyStoreAlias)
|
||||
data.writeLong(record.renewAfterEpochSeconds)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/** Decode [bytes]; any structural failure → [CorruptStoredIdentityException]. */
|
||||
public fun decode(bytes: ByteArray): EnrollmentRecord =
|
||||
try {
|
||||
DataInputStream(ByteArrayInputStream(bytes)).use { data ->
|
||||
EnrollmentRecord(
|
||||
deviceId = data.readUTF(),
|
||||
deviceName = data.readUTF(),
|
||||
keyStoreAlias = data.readUTF(),
|
||||
renewAfterEpochSeconds = data.readLong(),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage contract for the [EnrollmentRecord] — repository pattern so [DeviceEnroller] depends on
|
||||
* the operation set and a fault/blank can be injected in tests. Idempotent [clear].
|
||||
*/
|
||||
public interface EnrollmentRecordStore {
|
||||
public fun save(record: EnrollmentRecord)
|
||||
|
||||
public fun load(): EnrollmentRecord?
|
||||
|
||||
public fun clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tink-AEAD-encrypted [EnrollmentRecordStore] over an app-private `SharedPreferences` file, mirroring
|
||||
* [TinkCertStore]'s custody model (AndroidKeystore-wrapped master key; uninstall-wiped; useless off
|
||||
* this device). Kept in its own key/file namespace so it never collides with the cert live-pointer.
|
||||
*/
|
||||
public class TinkEnrollmentRecordStore(
|
||||
context: Context,
|
||||
private val keysetName: String = DEFAULT_KEYSET_NAME,
|
||||
private val prefFileName: String = DEFAULT_PREF_FILE,
|
||||
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
|
||||
) : EnrollmentRecordStore {
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val aead: Aead by lazy { buildAead() }
|
||||
|
||||
override fun save(record: EnrollmentRecord) {
|
||||
val ciphertext = aead.encrypt(EnrollmentRecordCodec.encode(record), ASSOCIATED_DATA)
|
||||
val committed = prefs().edit()
|
||||
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
|
||||
.commit()
|
||||
if (!committed) throw java.io.IOException("Failed to durably persist the device enrollment record")
|
||||
}
|
||||
|
||||
override fun load(): EnrollmentRecord? {
|
||||
val encoded = prefs().getString(BLOB_KEY, null) ?: return null
|
||||
val ciphertext = try {
|
||||
Base64.decode(encoded, Base64.NO_WRAP)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw CorruptStoredIdentityException("Enrollment record blob was not valid base64", e)
|
||||
}
|
||||
val plaintext = try {
|
||||
aead.decrypt(ciphertext, ASSOCIATED_DATA)
|
||||
} catch (e: java.security.GeneralSecurityException) {
|
||||
throw CorruptStoredIdentityException("Enrollment record blob failed AEAD decryption", e)
|
||||
}
|
||||
return EnrollmentRecordCodec.decode(plaintext)
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
val committed = prefs().edit().remove(BLOB_KEY).commit()
|
||||
if (!committed) throw java.io.IOException("Failed to durably clear the device enrollment record")
|
||||
}
|
||||
|
||||
private fun buildAead(): Aead {
|
||||
AeadConfig.register()
|
||||
val keysetHandle = AndroidKeysetManager.Builder()
|
||||
.withSharedPref(appContext, keysetName, prefFileName)
|
||||
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
|
||||
.withMasterKeyUri(masterKeyUri)
|
||||
.build()
|
||||
.keysetHandle
|
||||
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
|
||||
}
|
||||
|
||||
private fun prefs(): SharedPreferences =
|
||||
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
|
||||
|
||||
public companion object {
|
||||
private const val DEFAULT_KEYSET_NAME = "webterm_enroll_keyset"
|
||||
private const val DEFAULT_PREF_FILE = "webterm_enroll_record_prefs"
|
||||
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_enroll_master_key"
|
||||
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
|
||||
private const val BLOB_KEY = "enrollment_record_blob"
|
||||
private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.enrollment-record".toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.security.keystore.StrongBoxUnavailableException
|
||||
import android.util.Log
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.Signature
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.interfaces.ECPublicKey
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
import wang.yaojia.webterm.api.enroll.CsrSigner
|
||||
import wang.yaojia.webterm.api.enroll.EcPointEncoding
|
||||
|
||||
/**
|
||||
* B4 · A P-256 signing key that lives ENTIRELY inside AndroidKeyStore and is NON-EXPORTABLE by
|
||||
* construction (AndroidKeyStore has no key-material getter). It is the Android analogue of the iOS
|
||||
* `SecureEnclaveKey`: `sign` runs inside secure hardware (StrongBox → TEE) and drives the same
|
||||
* `Signature("SHA256withECDSA")` path the JVM-unit-test software key uses, so [CsrSigner] callers
|
||||
* (`CertificateSigningRequest`) are exercised identically.
|
||||
*
|
||||
* The wrapped [privateKey] is the opaque AndroidKeyStore handle — presented to the re-reading
|
||||
* `X509KeyManager` for the TLS `CertificateVerify` and never exported. [publicKey] is only used to
|
||||
* emit the CSR's `SubjectPublicKeyInfo`.
|
||||
*/
|
||||
public class HardwareBackedKey internal constructor(
|
||||
public val alias: String,
|
||||
private val privateKey: PrivateKey,
|
||||
private val publicKey: ECPublicKey,
|
||||
) : CsrSigner {
|
||||
|
||||
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(publicKey)
|
||||
|
||||
override fun sign(message: ByteArray): ByteArray =
|
||||
Signature.getInstance(SIGNATURE_ALGORITHM).apply {
|
||||
initSign(privateKey)
|
||||
update(message)
|
||||
}.sign()
|
||||
|
||||
/** The opaque, non-exportable AndroidKeyStore private-key handle presented on the mTLS path. */
|
||||
public val keyHandle: PrivateKey get() = privateKey
|
||||
|
||||
public companion object {
|
||||
private const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates / loads / deletes the device's non-exportable P-256 key in AndroidKeyStore.
|
||||
*
|
||||
* Generation prefers **StrongBox** (dedicated secure element) and falls back to the **TEE** when the
|
||||
* device has no StrongBox — the security posture (non-exportable, hardware-backed, silent-signing)
|
||||
* is identical either way; StrongBox is a hardening bonus, not a requirement. The key is
|
||||
* `PURPOSE_SIGN` only with a broad digest set so TLS 1.2/1.3 signature negotiation for the client
|
||||
* `CertificateVerify` works, and NO user-authentication is required so silent enroll/renew never
|
||||
* blocks on a biometric prompt.
|
||||
*/
|
||||
public object HardwareKeyStore {
|
||||
private const val TAG = "HardwareKeyStore"
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
private const val CURVE = "secp256r1"
|
||||
|
||||
/**
|
||||
* Generate a fresh non-exportable P-256 key at [alias], overwriting any prior entry there.
|
||||
* StrongBox-backed when available, else TEE-backed. Throws the underlying keystore exception if
|
||||
* BOTH paths fail (never returns a half-generated key).
|
||||
*/
|
||||
public fun generate(alias: String): HardwareBackedKey {
|
||||
val keyPair = try {
|
||||
generateKeyPair(alias, strongBox = true)
|
||||
} catch (_: StrongBoxUnavailableException) {
|
||||
Log.i(TAG, "StrongBox unavailable; generating a TEE-backed device key (non-exportable)")
|
||||
delete(alias) // clear any partial StrongBox entry before the TEE retry
|
||||
generateKeyPair(alias, strongBox = false)
|
||||
}
|
||||
return HardwareBackedKey(alias, keyPair.private, keyPair.public as ECPublicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a previously-generated key by [alias] (renew path, after relaunch). The public key is
|
||||
* recovered from the self-signed placeholder certificate AndroidKeyStore stored at generation.
|
||||
* Returns null if no key entry exists (the normal pre-enroll state).
|
||||
*/
|
||||
public fun load(alias: String): HardwareBackedKey? {
|
||||
val keyStore = androidKeyStore()
|
||||
val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: return null
|
||||
val publicKey = (keyStore.getCertificate(alias) as? X509Certificate)?.publicKey as? ECPublicKey
|
||||
?: return null
|
||||
return HardwareBackedKey(alias, privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */
|
||||
public fun delete(alias: String) {
|
||||
val keyStore = androidKeyStore()
|
||||
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
|
||||
}
|
||||
|
||||
/** Cheap existence check (does NOT read key material). */
|
||||
public fun exists(alias: String): Boolean = androidKeyStore().containsAlias(alias)
|
||||
|
||||
private fun generateKeyPair(alias: String, strongBox: Boolean): KeyPair {
|
||||
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
|
||||
.setAlgorithmParameterSpec(ECGenParameterSpec(CURVE))
|
||||
.setDigests(
|
||||
KeyProperties.DIGEST_NONE,
|
||||
KeyProperties.DIGEST_SHA256,
|
||||
KeyProperties.DIGEST_SHA384,
|
||||
KeyProperties.DIGEST_SHA512,
|
||||
)
|
||||
.setIsStrongBoxBacked(strongBox)
|
||||
.build()
|
||||
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE)
|
||||
generator.initialize(spec)
|
||||
return generator.generateKeyPair()
|
||||
}
|
||||
|
||||
private fun androidKeyStore(): KeyStore =
|
||||
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
}
|
||||
@@ -56,6 +56,18 @@ public interface IdentityRepository {
|
||||
public suspend fun remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* B4 · A narrow seam the zero-`.p12` enroll/renew commit ([DeviceEnroller]) fires so an in-memory
|
||||
* identity cache re-reads the freshly-committed live-pointer and presents the new leaf on the NEXT
|
||||
* mTLS handshake WITHOUT a process restart. Kept separate from [IdentityRepository] so the enroller
|
||||
* depends only on this one operation (it never needs the import/rotate/remove surface). The production
|
||||
* implementation is [AndroidIdentityRepository]; a JVM test uses a recording double.
|
||||
*/
|
||||
public fun interface IdentityCacheRefresher {
|
||||
/** Reload the persisted live identity into the in-memory cache and drop stale pooled connections. */
|
||||
public fun refreshFromStore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted
|
||||
* live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`).
|
||||
@@ -90,7 +102,7 @@ public class AndroidIdentityRepository(
|
||||
private val importer: AndroidKeyStoreImporter,
|
||||
private val certStore: CertStore,
|
||||
private val sharedClient: OkHttpClient,
|
||||
) : IdentityRepository {
|
||||
) : IdentityRepository, IdentityCacheRefresher {
|
||||
|
||||
/** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */
|
||||
private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String)
|
||||
@@ -150,6 +162,20 @@ public class AndroidIdentityRepository(
|
||||
sharedClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX 3 (cache freshness) · Re-read the persisted live-pointer into the in-memory cache. Used when a
|
||||
* device certificate is committed OUT OF BAND of this repository — the zero-`.p12` [DeviceEnroller]
|
||||
* writes the leaf straight into the shared [CertStore] + AndroidKeyStore, so without this the running
|
||||
* repo would keep presenting its cached (pre-enroll) identity until process restart. Publishing the
|
||||
* freshly-loaded snapshot as [liveOverride] and evicting pooled connections makes the enrolled leaf
|
||||
* present on the NEXT handshake. Reloading to `null` (a fault/absent pointer) is a valid outcome and
|
||||
* simply reports "no identity". Not `suspend` — the enroller already runs this off the UI thread.
|
||||
*/
|
||||
override fun refreshFromStore() {
|
||||
liveOverride = Box(loadInstalledOrNull())
|
||||
sharedClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-commit install/rotation (see the class KDoc). Validation throws before any mutation;
|
||||
* the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
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.enroll.DeviceEnrollmentClient
|
||||
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.interfaces.ECPublicKey
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
|
||||
/**
|
||||
* B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs
|
||||
* the security-critical two-store commit. Driven with a software P-256 key ([DeviceKeyProvider]
|
||||
* double) + the shared [FakeHttpTransport], so request shaping, error handling, and — most
|
||||
* importantly — the commit SEQUENCING run without an emulator or a real AndroidKeyStore.
|
||||
*
|
||||
* The security-critical invariant under test: the enrollment record is persisted BEFORE the cert
|
||||
* live-pointer flip (the mTLS commit), so a successful cert-store save always means the identity is
|
||||
* fully live (see [DeviceEnroller.commitIdentity]).
|
||||
*/
|
||||
class DeviceEnrollerTest {
|
||||
private companion object {
|
||||
const val BASE = "https://cp.terminal.yaojia.wang"
|
||||
const val ALIAS = "test-device-key"
|
||||
|
||||
// Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory /
|
||||
// CertificateSummaryReader parse them exactly as they parse a server-issued leaf.
|
||||
const val LEAF_CN = "t1-device"
|
||||
const val CA_CN = "webterm-device-ca"
|
||||
const val LEAF_B64 =
|
||||
"MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" +
|
||||
"ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" +
|
||||
"WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" +
|
||||
"cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" +
|
||||
"HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" +
|
||||
"ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" +
|
||||
"cdoleWDlqcMKU"
|
||||
const val CA_B64 =
|
||||
"MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" +
|
||||
"dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" +
|
||||
"YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" +
|
||||
"e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" +
|
||||
"4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" +
|
||||
"Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" +
|
||||
"YloHuEAg+kngzA33m52aWtublai4L+eybg=="
|
||||
|
||||
fun loginBody(): ByteArray =
|
||||
"""{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()
|
||||
|
||||
fun enrollBody(deviceId: String = "dev-1"): ByteArray =
|
||||
"""
|
||||
{"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"],
|
||||
"notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z",
|
||||
"renewAfter":"2026-09-05T00:00:00.000Z"}
|
||||
""".trimIndent().toByteArray()
|
||||
|
||||
fun softwareKey(alias: String): HardwareBackedKey {
|
||||
val kpg = KeyPairGenerator.getInstance("EC")
|
||||
kpg.initialize(ECGenParameterSpec("secp256r1"))
|
||||
val kp = kpg.generateKeyPair()
|
||||
return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
private val events = mutableListOf<String>()
|
||||
private val transport = FakeHttpTransport()
|
||||
private val certStore = RecordingCertStore(events)
|
||||
private val recordStore = RecordingRecordStore(events)
|
||||
private val keyProvider = RecordingKeyProvider(events)
|
||||
private val refresher = RecordingRefresher(events)
|
||||
|
||||
private fun enroller(): DeviceEnroller =
|
||||
DeviceEnroller(
|
||||
client = DeviceEnrollmentClient(BASE, transport),
|
||||
certStore = certStore,
|
||||
recordStore = recordStore,
|
||||
sharedClient = OkHttpClient(),
|
||||
keyAlias = ALIAS,
|
||||
keyProvider = keyProvider,
|
||||
cacheRefresher = refresher,
|
||||
)
|
||||
|
||||
// ── enroll: commit sequencing (the security-critical invariant) ────────────────────────────
|
||||
|
||||
@Test
|
||||
fun enrollPersistsTheRecordBeforeTheCertLivePointerFlip() = runTest {
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
|
||||
|
||||
val summary = enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
|
||||
|
||||
// Record.save strictly precedes cert.save — the mTLS pointer flip is written LAST.
|
||||
assertTrue(events.contains("record.save") && events.contains("cert.save"))
|
||||
assertTrue(
|
||||
events.indexOf("record.save") < events.indexOf("cert.save"),
|
||||
"the enrollment record must be committed BEFORE the cert live-pointer flip",
|
||||
)
|
||||
// Both stores received the issued identity; the stored chain is leaf + issuer (from caChain).
|
||||
assertEquals("dev-1", recordStore.saved!!.deviceId)
|
||||
assertEquals(ALIAS, recordStore.saved!!.keyStoreAlias)
|
||||
val chain = certStore.saved!!.certificateChain
|
||||
assertEquals(2, chain.size, "stored chain = leaf + one caChain issuer")
|
||||
assertTrue(chain[0].subjectX500Principal.name.contains(LEAF_CN), "chain[0] is the leaf")
|
||||
assertTrue(chain[1].subjectX500Principal.name.contains(CA_CN), "chain[1] is the device-CA issuer")
|
||||
// The install summary is read off the leaf via the production CertificateSummaryReader.
|
||||
assertEquals(LEAF_CN, summary.subjectCommonName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollRefreshesTheIdentityCacheAfterTheCommit() = runTest {
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
|
||||
|
||||
enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
|
||||
|
||||
// FIX 3: the in-memory identity cache is refreshed AFTER the durable cert live-pointer flip, so
|
||||
// the newly enrolled leaf is presented on the next handshake with no restart.
|
||||
assertTrue(events.contains("cache.refresh"), "the identity cache must be refreshed on enroll")
|
||||
assertTrue(
|
||||
events.indexOf("cert.save") < events.indexOf("cache.refresh"),
|
||||
"the cache refresh must run AFTER the cert live-pointer commit (never publish an un-committed leaf)",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollShapesTheLoginAndEnrollRequests() = runTest {
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
|
||||
|
||||
enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
|
||||
|
||||
val login = transport.recordedRequests[0]
|
||||
assertEquals("$BASE/auth/login", login.url)
|
||||
assertTrue(login.body!!.decodeToString().contains("\"password\":\"hunter2\""))
|
||||
|
||||
val enroll = transport.recordedRequests[1]
|
||||
assertEquals("$BASE/device/enroll", enroll.url)
|
||||
assertEquals("Bearer tok-xyz", enroll.headers["Authorization"], "enroll rides the login bearer")
|
||||
val enrollBodyStr = enroll.body!!.decodeToString()
|
||||
assertTrue(enrollBodyStr.contains("\"subdomain\":\"alice\""))
|
||||
assertTrue(enrollBodyStr.contains("\"deviceName\":\"Alice Pixel\""))
|
||||
}
|
||||
|
||||
// ── enroll: error handling ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun enrollDropsTheOrphanKeyWhenEnrollFailsAfterKeygen() = runTest {
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 403, body = """{"error":"rejected"}""".toByteArray())
|
||||
|
||||
val error = runCatching {
|
||||
enroller().enroll(password = "hunter2", subdomain = "bob", deviceName = "Bob Pixel")
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertEquals(DeviceEnrollmentError.Http(403, "rejected"), error)
|
||||
assertEquals(listOf(ALIAS), keyProvider.generatedAliases, "the key was generated after login")
|
||||
assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the orphan key is dropped on enroll failure")
|
||||
assertNull(recordStore.saved, "no record is committed when enroll fails")
|
||||
assertNull(certStore.saved, "the cert live-pointer is never flipped when enroll fails")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enrollNeverBurnsAKeyWhenLoginIsRejected() = runTest {
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 401, body = """{"error":"rejected"}""".toByteArray())
|
||||
|
||||
val error = runCatching {
|
||||
enroller().enroll(password = "wrong", subdomain = "alice", deviceName = "Alice Pixel")
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
|
||||
assertTrue(keyProvider.generatedAliases.isEmpty(), "a rejected credential must not burn a key slot")
|
||||
assertNull(recordStore.saved)
|
||||
assertNull(certStore.saved)
|
||||
}
|
||||
|
||||
// ── renew: preconditions + request shaping + commit ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun renewThrowsWhenNothingIsEnrolled() = runTest {
|
||||
val error = runCatching { enroller().renew() }.exceptionOrNull()
|
||||
assertTrue(error is DeviceEnroller.EnrollmentStateException)
|
||||
assertTrue(transport.recordedRequests.isEmpty(), "no network I/O when there is nothing to renew")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renewThrowsWhenTheDeviceKeyIsMissing() = runTest {
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
|
||||
// keyProvider has no key at ALIAS → load() returns null.
|
||||
val error = runCatching { enroller().renew() }.exceptionOrNull()
|
||||
assertTrue(error is DeviceEnroller.EnrollmentStateException)
|
||||
assertTrue(transport.recordedRequests.isEmpty(), "no renew call when the hardware key is gone")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renewReCsrsFromTheSameKeyOverMtlsWithNoBearerAndACsrOnlyBody() = runTest {
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
|
||||
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||
transport.queueSuccess(HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = enrollBody())
|
||||
|
||||
// FIX 2: production renew passes NO bearer — the endpoint authenticates by the current cert (mTLS).
|
||||
enroller().renew()
|
||||
|
||||
val renew = transport.recordedRequests.single()
|
||||
assertEquals("$BASE/device/dev-1/renew", renew.url)
|
||||
assertNull(renew.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
|
||||
val body = renew.body!!.decodeToString()
|
||||
// The renew body is {csr}-only — the server's .strict() schema rejects any enroll-only extra.
|
||||
assertTrue(body.contains("\"csr\":"), "renew sends the fresh CSR")
|
||||
assertFalse(body.contains("keyAlg"), "renew must not send the enroll-only keyAlg")
|
||||
assertFalse(body.contains("subdomain"), "renew must not send subdomain")
|
||||
assertFalse(body.contains("deviceName"), "renew must not send deviceName")
|
||||
// Same commit sequencing on the rotation path: record before cert, then cache refresh last.
|
||||
assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"))
|
||||
assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit")
|
||||
}
|
||||
|
||||
// ── remove: full teardown ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun removeClearsBothStoresAndDeletesTheKey() = runTest {
|
||||
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
|
||||
keyProvider.seed(ALIAS, softwareKey(ALIAS))
|
||||
|
||||
enroller().remove()
|
||||
|
||||
assertTrue(certStore.cleared)
|
||||
assertTrue(recordStore.cleared)
|
||||
assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the hardware key is deleted on remove")
|
||||
}
|
||||
|
||||
// ── recording doubles ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
private class RecordingCertStore(private val events: MutableList<String>) : CertStore {
|
||||
var saved: StoredIdentityMetadata? = null
|
||||
var cleared = false
|
||||
|
||||
override fun save(metadata: StoredIdentityMetadata) {
|
||||
saved = metadata
|
||||
events += "cert.save"
|
||||
}
|
||||
|
||||
override fun load(): StoredIdentityMetadata? = saved
|
||||
|
||||
override fun clear() {
|
||||
cleared = true
|
||||
saved = null
|
||||
events += "cert.clear"
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingRecordStore(private val events: MutableList<String>) : EnrollmentRecordStore {
|
||||
var saved: EnrollmentRecord? = null
|
||||
var cleared = false
|
||||
private var current: EnrollmentRecord? = null
|
||||
|
||||
fun seed(record: EnrollmentRecord) {
|
||||
current = record
|
||||
}
|
||||
|
||||
override fun save(record: EnrollmentRecord) {
|
||||
saved = record
|
||||
current = record
|
||||
events += "record.save"
|
||||
}
|
||||
|
||||
override fun load(): EnrollmentRecord? = current
|
||||
|
||||
override fun clear() {
|
||||
cleared = true
|
||||
current = null
|
||||
events += "record.clear"
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingRefresher(private val events: MutableList<String>) : IdentityCacheRefresher {
|
||||
override fun refreshFromStore() {
|
||||
events += "cache.refresh"
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingKeyProvider(private val events: MutableList<String>) : DeviceKeyProvider {
|
||||
private val keys = mutableMapOf<String, HardwareBackedKey>()
|
||||
val generatedAliases = mutableListOf<String>()
|
||||
val deletedAliases = mutableListOf<String>()
|
||||
|
||||
fun seed(alias: String, key: HardwareBackedKey) {
|
||||
keys[alias] = key
|
||||
}
|
||||
|
||||
override fun generate(alias: String): HardwareBackedKey {
|
||||
val key = softwareKey(alias)
|
||||
keys[alias] = key
|
||||
generatedAliases += alias
|
||||
events += "generate:$alias"
|
||||
return key
|
||||
}
|
||||
|
||||
override fun load(alias: String): HardwareBackedKey? = keys[alias]
|
||||
|
||||
override fun delete(alias: String) {
|
||||
keys.remove(alias)
|
||||
deletedAliases += alias
|
||||
events += "delete:$alias"
|
||||
}
|
||||
}
|
||||
}
|
||||
142
control-plane/src/api/auth-login.ts
Normal file
142
control-plane/src/api/auth-login.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* B1 — operator login → `device:enroll` bearer mint (registerable Fastify plugin; wired in `main.ts`).
|
||||
*
|
||||
* POST /auth/login { "password": "<operator secret>" }
|
||||
* -> 201 { "enrollToken": "<v4.public PASETO>", "accountId": "<uuid>", "expiresIn": <seconds> }
|
||||
*
|
||||
* The `enrollToken` is the short-lived §4.3 `device:enroll` capability token that `POST /device/enroll`
|
||||
* requires as `Authorization: Bearer <enrollToken>`. This is the ONE authenticated human action that
|
||||
* bootstraps the phone track (the honest "one bootstrap tap" constraint) — everything after (CSR →
|
||||
* cert → silent renew) is certificate-authenticated.
|
||||
*
|
||||
* SECURITY (this path ultimately mints device certs):
|
||||
* - credential compare is CONSTANT-TIME (delegated to `loginToAccountId` → `timingSafeEqualBytes`);
|
||||
* - login attempts are RATE-LIMITED per client (sliding window, mirrors registry/devices.ts);
|
||||
* - DENY-BY-DEFAULT + FAIL-CLOSED: unset operator credential ⇒ 503 (never mints); wrong ⇒ 401;
|
||||
* - the operator secret and the minted token are NEVER logged (INV9);
|
||||
* - input is Zod-validated at the boundary; `accountId` is server-config, never a client field.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import {
|
||||
loginToAccountId,
|
||||
mintDeviceEnrollToken,
|
||||
DeviceEnrollAuthError,
|
||||
DEFAULT_DEVICE_ENROLL_TTL_SEC,
|
||||
MIN_DEVICE_ENROLL_TTL_SEC,
|
||||
MAX_DEVICE_ENROLL_TTL_SEC,
|
||||
type LoginSeamConfig,
|
||||
} from '../auth/session.js'
|
||||
|
||||
/** Per-client login attempts allowed within the window (brute-force throttle). */
|
||||
export const DEFAULT_LOGIN_RATE_MAX = 10
|
||||
/** Login rate window (ms): 15 minutes. */
|
||||
export const DEFAULT_LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000
|
||||
|
||||
export interface AuthLoginDeps {
|
||||
/** Ed25519 signing key for the enroll bearer (PRIVATE half of `CAPABILITY_SIGN_PUBKEY_B64`). */
|
||||
readonly signingKey: CryptoKey | null
|
||||
/** How a credential resolves to an accountId (single-operator MVP or an injected resolver). */
|
||||
readonly loginConfig: LoginSeamConfig
|
||||
/** Minted bearer TTL (seconds); clamped to the enroll-token bounds. Defaults to 10 min. */
|
||||
readonly enrollTtlSec?: number
|
||||
readonly rateMax?: number
|
||||
readonly rateWindowMs?: number
|
||||
/** Clock (ms) — injectable for tests. */
|
||||
readonly now?: () => number
|
||||
/** Client-bucket key for rate-limiting (defaults to the socket IP). Injectable for tests. */
|
||||
readonly clientKey?: (req: FastifyRequest) => string
|
||||
}
|
||||
|
||||
const LoginBodySchema = z.object({ password: z.string().min(1).max(512) }).strict()
|
||||
|
||||
/** Uniform rate-limit reject → 429. */
|
||||
class LoginRateError extends Error {
|
||||
constructor() {
|
||||
super('login rate limited')
|
||||
this.name = 'LoginRateError'
|
||||
}
|
||||
}
|
||||
|
||||
/** In-process sliding-window limiter keyed by client bucket (mirrors registry/devices.ts). */
|
||||
function createLoginLimiter(max: number, windowMs: number, now: () => number) {
|
||||
const hits = new Map<string, number[]>()
|
||||
return {
|
||||
check(key: string): void {
|
||||
const ts = now()
|
||||
const cutoff = ts - windowMs
|
||||
const arr = (hits.get(key) ?? []).filter((t) => t > cutoff)
|
||||
if (arr.length >= max) {
|
||||
hits.set(key, arr) // persist the pruned window; do NOT record this rejected attempt
|
||||
throw new LoginRateError()
|
||||
}
|
||||
arr.push(ts)
|
||||
hits.set(key, arr)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function clampEnrollTtl(ttl: number): number {
|
||||
return Math.min(Math.max(ttl, MIN_DEVICE_ENROLL_TTL_SEC), MAX_DEVICE_ENROLL_TTL_SEC)
|
||||
}
|
||||
|
||||
/** The feature is live only when a signing key AND a resolvable credential seam are BOTH present. */
|
||||
function isConfigured(deps: AuthLoginDeps): deps is AuthLoginDeps & { signingKey: CryptoKey } {
|
||||
if (deps.signingKey === null) return false
|
||||
const c = deps.loginConfig
|
||||
return c.resolve !== undefined || (c.operatorCredential !== undefined && c.accountId !== undefined)
|
||||
}
|
||||
|
||||
/** Map any thrown error to a uniform HTTP reject — never leak which internal check failed (no oracle). */
|
||||
function sendLoginError(reply: FastifyReply, err: unknown): void {
|
||||
if (err instanceof LoginRateError) {
|
||||
void reply.code(429).send({ error: 'rate_limited' })
|
||||
return
|
||||
}
|
||||
if (err instanceof DeviceEnrollAuthError) {
|
||||
void reply.code(err.status).send({ error: 'rejected' })
|
||||
return
|
||||
}
|
||||
if (err instanceof z.ZodError) {
|
||||
void reply.code(400).send({ error: 'invalid request' })
|
||||
return
|
||||
}
|
||||
void reply.code(400).send({ error: 'rejected' })
|
||||
}
|
||||
|
||||
export function buildAuthLoginRouter(deps: AuthLoginDeps): FastifyPluginAsync {
|
||||
const now = deps.now ?? (() => Date.now())
|
||||
const clientKey = deps.clientKey ?? ((req: FastifyRequest) => req.ip || 'unknown')
|
||||
const limiter = createLoginLimiter(deps.rateMax ?? DEFAULT_LOGIN_RATE_MAX, deps.rateWindowMs ?? DEFAULT_LOGIN_RATE_WINDOW_MS, now)
|
||||
const ttl = clampEnrollTtl(deps.enrollTtlSec ?? DEFAULT_DEVICE_ENROLL_TTL_SEC)
|
||||
|
||||
return async (app) => {
|
||||
app.post('/auth/login', async (req, reply) => {
|
||||
try {
|
||||
// Rate-limit BEFORE any credential work so brute force is throttled regardless of outcome.
|
||||
limiter.check(clientKey(req))
|
||||
const { password } = LoginBodySchema.parse(req.body)
|
||||
|
||||
// Fail-closed: an unconfigured login mints nothing (503, distinct from a wrong-credential 401).
|
||||
if (!isConfigured(deps)) {
|
||||
void reply.code(503).send({ error: 'login unavailable' })
|
||||
return
|
||||
}
|
||||
|
||||
// Constant-time credential → accountId (deny-by-default inside loginToAccountId). Any failure
|
||||
// becomes a uniform 401 — never distinguish wrong-password from unknown-credential.
|
||||
let accountId: string
|
||||
try {
|
||||
accountId = loginToAccountId(password, deps.loginConfig)
|
||||
} catch {
|
||||
throw new DeviceEnrollAuthError(401, 'login rejected')
|
||||
}
|
||||
|
||||
const enrollToken = await mintDeviceEnrollToken(accountId, { signingKey: deps.signingKey, ttlSeconds: ttl })
|
||||
await reply.code(201).send({ enrollToken, accountId, expiresIn: ttl })
|
||||
} catch (err) {
|
||||
sendLoginError(reply, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,15 @@ export function headerPresentedCert(headerName: string = DEFAULT_CLIENT_CERT_HEA
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
if (typeof value !== 'string' || value.length === 0) return null
|
||||
try {
|
||||
return new Uint8Array(Buffer.from(value, 'base64'))
|
||||
// The terminator may forward the verified cert as base64 DER, OR as PEM — including nginx's
|
||||
// header-safe `$ssl_client_escaped_cert` (URL-encoded PEM). Normalize all three to raw DER:
|
||||
// URL-decode if escaped, then strip PEM armor + whitespace to recover the base64 DER body.
|
||||
let s = value.includes('%') ? safeDecodeUri(value) : value
|
||||
if (s.includes('BEGIN CERTIFICATE')) {
|
||||
s = s.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '')
|
||||
}
|
||||
const der = Buffer.from(s, 'base64')
|
||||
return der.length > 0 ? new Uint8Array(der) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -179,6 +187,15 @@ export function headerPresentedCert(headerName: string = DEFAULT_CLIENT_CERT_HEA
|
||||
}
|
||||
}
|
||||
|
||||
/** URL-decode `value`, returning it unchanged if it is not valid percent-encoding. */
|
||||
function safeDecodeUri(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust-anchor verification of the presented current cert (defense in depth: the mTLS terminator has
|
||||
* already verified it, but this route must not trust a cert the terminator would never have accepted).
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import type { CapabilityRight, CapabilityToken } from 'relay-contracts'
|
||||
import { verifyCapabilityToken } from 'relay-auth'
|
||||
import { signPaseto } from 'relay-auth/src/crypto/paseto.js'
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
||||
import { timingSafeEqualBytes } from '../util/bytes.js'
|
||||
|
||||
/** Distinct audience for device enrollment (Host-confusion guard — never a subdomain aud). */
|
||||
@@ -157,8 +157,11 @@ export function loginToAccountId(credential: string, config: LoginSeamConfig): s
|
||||
return acct
|
||||
}
|
||||
if (config.operatorCredential !== undefined && config.accountId !== undefined) {
|
||||
const a = new TextEncoder().encode(credential)
|
||||
const b = new TextEncoder().encode(config.operatorCredential)
|
||||
// SHA-256 both to a fixed 32 bytes BEFORE comparing, so the compare is
|
||||
// length-independent — timingSafeEqualBytes short-circuits on unequal length,
|
||||
// which would otherwise leak the operator credential's length via timing.
|
||||
const a = createHash('sha256').update(credential, 'utf8').digest()
|
||||
const b = createHash('sha256').update(config.operatorCredential, 'utf8').digest()
|
||||
if (timingSafeEqualBytes(a, b)) return config.accountId
|
||||
throw new DeviceEnrollAuthError(401, 'login rejected')
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
* `x509-assembler` normalizes to DER; both expose the same `sign()` so callers stay KMS-shaped.
|
||||
*/
|
||||
import { createPublicKey, generateKeyPairSync, sign as nodeSign } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { ControlPlaneEnv } from '../env.js'
|
||||
import { ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js'
|
||||
import { createPrivateKey, ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js'
|
||||
|
||||
/** Wraps KMS `sign()`; no raw key ever crosses this boundary. */
|
||||
export interface CaSigner {
|
||||
@@ -98,3 +99,56 @@ export function inProcessP256CaSigner(privateKey?: KeyObject): CaSigner {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefix marking a KMS key ref that is actually an on-disk PEM key path (`file:<path>`). */
|
||||
const FILE_KEY_REF_PREFIX = 'file:'
|
||||
/** OpenSSL's name for the P-256 (secp256r1) curve — the ONLY curve the native-tunnel CAs use. */
|
||||
const P256_CURVE = 'prime256v1'
|
||||
|
||||
/** Build a `file:<path>` KMS key ref from an on-disk native-CA private key path. */
|
||||
export function fileKeyRef(path: string): string {
|
||||
return `${FILE_KEY_REF_PREFIX}${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a PKCS#8 PEM P-256 private key from disk and wrap it in the SAME `CaSigner` surface as
|
||||
* `inProcessP256CaSigner` (raw P1363 `r||s` over the DER TBS). FAIL-FAST on an unreadable file,
|
||||
* malformed PEM, or a non-P-256 key. The raw key stays in-process — NEVER logged/serialised (INV9).
|
||||
*/
|
||||
function loadP256FileSigner(path: string): CaSigner {
|
||||
let pem: string
|
||||
try {
|
||||
pem = readFileSync(path, 'utf8')
|
||||
} catch {
|
||||
// Never echo the path contents — only that the file was unreadable (INV9).
|
||||
throw new Error('native-CA private key file is unreadable')
|
||||
}
|
||||
let key: KeyObject
|
||||
try {
|
||||
key = createPrivateKey(pem)
|
||||
} catch {
|
||||
throw new Error('native-CA private key is not a valid PEM private key')
|
||||
}
|
||||
if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== P256_CURVE) {
|
||||
throw new Error('native-CA private key must be a P-256 (prime256v1) EC key')
|
||||
}
|
||||
return inProcessP256CaSigner(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* FILE-BACKED KmsResolver — the production native-tunnel key custody for the single-owner VPS deploy.
|
||||
* Resolves a key ref of the form `file:<path>` (or a bare path) by loading the on-disk PEM P-256 key
|
||||
* into an in-process `CaSigner`. `policyRestrictedToServicePrincipal` is TRUE because the on-disk key
|
||||
* IS the control-plane's sole custody (documented file-custody reality; a real non-exportable KMS —
|
||||
* `KmsResolver` in front of KMS/HSM — is the future upgrade). Throws if the ref cannot be resolved so
|
||||
* `buildCaSigner` fails fast at boot. NEVER logs key material (INV9).
|
||||
*/
|
||||
export function fileKmsResolver(): KmsResolver {
|
||||
return {
|
||||
async resolve(keyRef: string) {
|
||||
const path = keyRef.startsWith(FILE_KEY_REF_PREFIX) ? keyRef.slice(FILE_KEY_REF_PREFIX.length) : keyRef
|
||||
if (path.length === 0) throw new Error('file KMS key ref has an empty path')
|
||||
return { signer: loadP256FileSigner(path), policyRestrictedToServicePrincipal: true }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,17 @@
|
||||
import 'reflect-metadata'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto, generateKeyPairSync } from 'node:crypto'
|
||||
import type { ControlPlaneEnv } from '../env.js'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { ControlPlaneEnv, NativeCaEnvConfig } from '../env.js'
|
||||
import { assembleCertificate } from '../ca/x509-assembler.js'
|
||||
import { buildCaSigner, inProcessP256CaSigner, type CaSigner, type KmsResolver } from './ca-wiring.js'
|
||||
import {
|
||||
buildCaSigner,
|
||||
fileKeyRef,
|
||||
fileKmsResolver,
|
||||
inProcessP256CaSigner,
|
||||
type CaSigner,
|
||||
type KmsResolver,
|
||||
} from './ca-wiring.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
@@ -153,3 +161,50 @@ export async function buildNativeCas(env: ControlPlaneEnv, opts: BuildNativeCasO
|
||||
])
|
||||
return { frpClientCa, deviceCa }
|
||||
}
|
||||
|
||||
/** Load one CA's (public) cert PEM from disk as anchor DER, failing loud on unreadable/unparseable material (INV9). */
|
||||
function loadCaCertDer(certPath: string): Uint8Array {
|
||||
let pem: string
|
||||
try {
|
||||
pem = readFileSync(certPath, 'utf8')
|
||||
} catch {
|
||||
// Never echo the file contents — only that it was unreadable (INV9).
|
||||
throw new Error('native-CA certificate file is unreadable — refusing to boot')
|
||||
}
|
||||
try {
|
||||
return new Uint8Array(new x509.X509Certificate(pem).rawData)
|
||||
} catch {
|
||||
throw new Error('native-CA certificate material is not a parseable X.509 certificate — refusing to boot')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PRODUCTION wiring from ON-DISK material — the A2-prep prerequisite for deploying the control-plane.
|
||||
* Builds both native-tunnel CAs from the VPS `gen-device-ca.sh` output: each CA's (public) cert PEM
|
||||
* becomes its trust-anchor DER and its PKCS#8 PEM private key path becomes a `file:` KMS ref resolved
|
||||
* by `fileKmsResolver` (single-owner file-custody; a non-exportable KMS is the upgrade). Delegates to
|
||||
* `buildNativeCas` in production mode so the SAME INV9 fail-fast applies — any unreadable/unparseable
|
||||
* cert or non-P-256 key refuses to boot. Raw key bytes are never loaded/logged at this layer.
|
||||
*/
|
||||
export async function buildFileBackedNativeCas(
|
||||
env: ControlPlaneEnv,
|
||||
config: NativeCaEnvConfig,
|
||||
opts: { readonly now?: () => number } = {},
|
||||
): Promise<NativeCas> {
|
||||
const material: NativeCaMaterial = {
|
||||
frpClientCa: {
|
||||
kmsKeyRef: fileKeyRef(config.frpClientCaKeyPath),
|
||||
caCertDer: loadCaCertDer(config.frpClientCaCertPath),
|
||||
},
|
||||
deviceCa: {
|
||||
kmsKeyRef: fileKeyRef(config.deviceCaKeyPath),
|
||||
caCertDer: loadCaCertDer(config.deviceCaCertPath),
|
||||
},
|
||||
}
|
||||
return buildNativeCas(env, {
|
||||
production: true,
|
||||
kmsResolver: fileKmsResolver(),
|
||||
material,
|
||||
...(opts.now !== undefined ? { now: opts.now } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
26
control-plane/src/boot/session-signing.ts
Normal file
26
control-plane/src/boot/session-signing.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* B1 — load the `device:enroll` bearer SIGNING key (Ed25519) from the CP env's PKCS#8 DER.
|
||||
*
|
||||
* The enroll bearer must verify on the SAME §4.3 path the admin API uses (boot/verifier.ts, keyed off
|
||||
* `CAPABILITY_SIGN_PUBKEY_B64`), so the login route signs with the PRIVATE half of that same keypair.
|
||||
* The key is imported NON-EXPORTABLE + `sign`-only (INV9: raw key material is never held as bytes and
|
||||
* never logged). A malformed / non-Ed25519 key FAILS CLOSED (throws) — the CP refuses to serve a login
|
||||
* route it cannot mint from.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Import the Ed25519 PKCS#8 private key as a non-exportable, sign-only `CryptoKey`. Throws (fail-closed)
|
||||
* on any malformed key; the error message never echoes key material (INV9).
|
||||
*/
|
||||
export async function loadEnrollSigningKey(pkcs8Der: Uint8Array): Promise<CryptoKey> {
|
||||
// Copy into a fresh ArrayBuffer-backed view (WebCrypto BufferSource typing / no shared pool).
|
||||
const bytes = new Uint8Array(pkcs8Der.length)
|
||||
bytes.set(pkcs8Der)
|
||||
try {
|
||||
return await globalThis.crypto.subtle.importKey('pkcs8', bytes, { name: 'Ed25519' }, false, ['sign'])
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
`failed to load device:enroll signing key: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,41 @@ export interface ControlPlaneEnv {
|
||||
readonly heartbeatTtlSec: number
|
||||
readonly pairingTtlSec: number
|
||||
readonly pairingMaxRedeemAttempts: number
|
||||
/**
|
||||
* B1 operator-login seam (single-tenant MVP). ALL THREE are set together or NONE — a half-configured
|
||||
* login is a boot error (fail-fast). Unset ⇒ the `/auth/login` route is fail-closed (503). When set:
|
||||
* - `operatorPassword` — the operator login secret (mirrors WEBTERM_TOKEN / relay OPERATOR_PASSWORD;
|
||||
* 16–512 URL/cookie-safe chars). Constant-time compared, NEVER logged (INV9).
|
||||
* - `operatorAccountId` — the account the minted `device:enroll` bearer is scoped to (`sub`).
|
||||
* - `capabilitySignPrivkey` — Ed25519 PKCS#8 DER private key whose PUBLIC half is
|
||||
* `capabilitySignPubkey`; used ONLY to sign the enroll bearer so it verifies on the same §4.3 path.
|
||||
*/
|
||||
readonly operatorPassword?: string
|
||||
readonly operatorAccountId?: string
|
||||
readonly capabilitySignPrivkey?: Uint8Array
|
||||
/**
|
||||
* A2-prep — the native-tunnel PKI's on-disk P-256 CA material (the VPS `gen-device-ca.sh` output:
|
||||
* `frp-client-CA` for host frp-client leaves + `device-CA` for device leaves). Set together or NOT
|
||||
* at all — a partial set is a boot error (fail-closed). Undefined ⇒ the dev/test injection path
|
||||
* (self-signed in-process CAs) is used. The private KEY paths mirror `CA_INTERMEDIATE_KEY_PATH`
|
||||
* (optional; derived from the cert path when unset). The raw keys are custody-on-disk (single-owner
|
||||
* file-custody reality; a non-exportable KMS is the future upgrade).
|
||||
*/
|
||||
readonly nativeCa?: NativeCaEnvConfig
|
||||
}
|
||||
|
||||
/** Resolved on-disk material for the two native-tunnel P-256 CAs + the DNS zone their leaves live under. */
|
||||
export interface NativeCaEnvConfig {
|
||||
/** frp-client-CA (public) cert PEM path — the host-leaf trust anchor. */
|
||||
readonly frpClientCaCertPath: string
|
||||
/** frp-client-CA PKCS#8 PEM private key path (derived from the cert path when unset). */
|
||||
readonly frpClientCaKeyPath: string
|
||||
/** device-CA (public) cert PEM path — the device-leaf trust anchor. */
|
||||
readonly deviceCaCertPath: string
|
||||
/** device-CA PKCS#8 PEM private key path (derived from the cert path when unset). */
|
||||
readonly deviceCaKeyPath: string
|
||||
/** DNS zone the leaves' `dNSName <sub>.<zone>` SAN is stamped under (the nginx :8470 tenant key). */
|
||||
readonly dnsZone: string
|
||||
}
|
||||
|
||||
/** A positive integer parsed from an env string, or a default when unset/empty. */
|
||||
@@ -77,8 +112,101 @@ const EnvSchema = z.object({
|
||||
HEARTBEAT_TTL_SEC: intWithDefault(15),
|
||||
PAIRING_TTL_SEC: intWithDefault(DEFAULT_PAIRING_TTL_SEC),
|
||||
PAIRING_MAX_REDEEM_ATTEMPTS: intWithDefault(DEFAULT_PAIRING_MAX_REDEEM_ATTEMPTS),
|
||||
// B1 operator-login seam — all optional; cross-validated below (set together or not at all).
|
||||
OPERATOR_PASSWORD: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[A-Za-z0-9._~+/=-]{16,512}$/, 'OPERATOR_PASSWORD must be 16–512 URL/cookie-safe chars')
|
||||
.optional(),
|
||||
OPERATOR_ACCOUNT_ID: z.string().trim().uuid('OPERATOR_ACCOUNT_ID must be a UUID').optional(),
|
||||
CAPABILITY_SIGN_PRIVKEY_B64: z.string().trim().min(1).optional(),
|
||||
// A2-prep native-tunnel CA material — all optional; cross-validated below (set together or not at all).
|
||||
NATIVE_FRP_CLIENT_CA_CERT_PATH: z.string().trim().optional(),
|
||||
NATIVE_FRP_CLIENT_CA_KEY_PATH: z.string().trim().optional(),
|
||||
NATIVE_DEVICE_CA_CERT_PATH: z.string().trim().optional(),
|
||||
NATIVE_DEVICE_CA_KEY_PATH: z.string().trim().optional(),
|
||||
NATIVE_DNS_ZONE: z.string().trim().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the optional operator-login triplet. Deny-by-default + fail-fast: if the operator password
|
||||
* is set, the account id and the Ed25519 signing key MUST also be set (a half-configured login is a
|
||||
* boot error, never a silent half-open). Returns `{}` when the feature is unconfigured. NEVER echoes
|
||||
* secret VALUES — only key names on failure (INV9).
|
||||
*/
|
||||
function resolveOperatorLogin(e: {
|
||||
OPERATOR_PASSWORD: string | undefined
|
||||
OPERATOR_ACCOUNT_ID: string | undefined
|
||||
CAPABILITY_SIGN_PRIVKEY_B64: string | undefined
|
||||
}): { operatorPassword?: string; operatorAccountId?: string; capabilitySignPrivkey?: Uint8Array } {
|
||||
const has = (v: string | undefined): v is string => v !== undefined && v.length > 0
|
||||
const pw = e.OPERATOR_PASSWORD
|
||||
const acct = e.OPERATOR_ACCOUNT_ID
|
||||
const priv = e.CAPABILITY_SIGN_PRIVKEY_B64
|
||||
if (!has(pw) && !has(acct) && !has(priv)) {
|
||||
return {} // feature off — /auth/login is fail-closed at runtime
|
||||
}
|
||||
if (!has(pw) || !has(acct) || !has(priv)) {
|
||||
const missing = [
|
||||
has(pw) ? null : 'OPERATOR_PASSWORD',
|
||||
has(acct) ? null : 'OPERATOR_ACCOUNT_ID',
|
||||
has(priv) ? null : 'CAPABILITY_SIGN_PRIVKEY_B64',
|
||||
].filter((x): x is string => x !== null)
|
||||
throw new Error(`Invalid control-plane env: operator login requires all of ${missing.join(', ')} to be set`)
|
||||
}
|
||||
let capabilitySignPrivkey: Uint8Array
|
||||
try {
|
||||
capabilitySignPrivkey = base64ToBytes(priv)
|
||||
} catch {
|
||||
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PRIVKEY_B64 is not valid base64')
|
||||
}
|
||||
return { operatorPassword: pw, operatorAccountId: acct, capabilitySignPrivkey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the optional native-tunnel CA material. Deny-by-default + fail-fast: the two (public) CA
|
||||
* cert paths and the DNS zone are the mandatory triad; if ANY native field is supplied (including a
|
||||
* lone KEY_PATH), all three MUST be present or boot fails (a partial set is never a silent half-open,
|
||||
* mirroring `resolveOperatorLogin`). The private KEY paths are optional and derived from their cert
|
||||
* path when unset (mirrors `CA_INTERMEDIATE_KEY_PATH`). Returns `{}` when the feature is unconfigured.
|
||||
* NEVER echoes path VALUES — only field NAMES on failure (INV9).
|
||||
*/
|
||||
function resolveNativeCa(e: {
|
||||
NATIVE_FRP_CLIENT_CA_CERT_PATH: string | undefined
|
||||
NATIVE_FRP_CLIENT_CA_KEY_PATH: string | undefined
|
||||
NATIVE_DEVICE_CA_CERT_PATH: string | undefined
|
||||
NATIVE_DEVICE_CA_KEY_PATH: string | undefined
|
||||
NATIVE_DNS_ZONE: string | undefined
|
||||
}): { nativeCa?: NativeCaEnvConfig } {
|
||||
const has = (v: string | undefined): v is string => v !== undefined && v.length > 0
|
||||
const frpCert = e.NATIVE_FRP_CLIENT_CA_CERT_PATH
|
||||
const frpKey = e.NATIVE_FRP_CLIENT_CA_KEY_PATH
|
||||
const devCert = e.NATIVE_DEVICE_CA_CERT_PATH
|
||||
const devKey = e.NATIVE_DEVICE_CA_KEY_PATH
|
||||
const zone = e.NATIVE_DNS_ZONE
|
||||
const anyPresent = [frpCert, frpKey, devCert, devKey, zone].some(has)
|
||||
if (!anyPresent) return {} // feature off — dev/test injection path (self-signed in-process CAs)
|
||||
if (!has(frpCert) || !has(devCert) || !has(zone)) {
|
||||
const missing = [
|
||||
has(frpCert) ? null : 'NATIVE_FRP_CLIENT_CA_CERT_PATH',
|
||||
has(devCert) ? null : 'NATIVE_DEVICE_CA_CERT_PATH',
|
||||
has(zone) ? null : 'NATIVE_DNS_ZONE',
|
||||
].filter((x): x is string => x !== null)
|
||||
throw new Error(
|
||||
`Invalid control-plane env: native-tunnel CA material requires all of ${missing.join(', ')} to be set`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
nativeCa: {
|
||||
frpClientCaCertPath: frpCert,
|
||||
frpClientCaKeyPath: has(frpKey) ? frpKey : deriveKeyPath(frpCert),
|
||||
deviceCaCertPath: devCert,
|
||||
deviceCaKeyPath: has(devKey) ? devKey : deriveKeyPath(devCert),
|
||||
dnsZone: zone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + validate control-plane config. THROWS (fail-fast) listing every missing/invalid
|
||||
* key by NAME. Never echoes secret VALUES (INV9).
|
||||
@@ -125,5 +253,17 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
|
||||
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
|
||||
pairingTtlSec: e.PAIRING_TTL_SEC,
|
||||
pairingMaxRedeemAttempts: e.PAIRING_MAX_REDEEM_ATTEMPTS,
|
||||
...resolveOperatorLogin({
|
||||
OPERATOR_PASSWORD: e.OPERATOR_PASSWORD,
|
||||
OPERATOR_ACCOUNT_ID: e.OPERATOR_ACCOUNT_ID,
|
||||
CAPABILITY_SIGN_PRIVKEY_B64: e.CAPABILITY_SIGN_PRIVKEY_B64,
|
||||
}),
|
||||
...resolveNativeCa({
|
||||
NATIVE_FRP_CLIENT_CA_CERT_PATH: e.NATIVE_FRP_CLIENT_CA_CERT_PATH,
|
||||
NATIVE_FRP_CLIENT_CA_KEY_PATH: e.NATIVE_FRP_CLIENT_CA_KEY_PATH,
|
||||
NATIVE_DEVICE_CA_CERT_PATH: e.NATIVE_DEVICE_CA_CERT_PATH,
|
||||
NATIVE_DEVICE_CA_KEY_PATH: e.NATIVE_DEVICE_CA_KEY_PATH,
|
||||
NATIVE_DNS_ZONE: e.NATIVE_DNS_ZONE,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ import { createFrpClientLeafSigner } from './ca/frpclient-issue.js'
|
||||
import { createDeviceLeafSigner, type DeviceLeafSigner } from './ca/device-issue.js'
|
||||
import { createLeafRenewer } from './ca/rotate.js'
|
||||
import { buildDeviceEnrollRouter, type SubdomainOwnershipResolver } from './api/device-enroll.js'
|
||||
import { buildAuthLoginRouter } from './api/auth-login.js'
|
||||
import { loadEnrollSigningKey } from './boot/session-signing.js'
|
||||
import type { LoginSeamConfig } from './auth/session.js'
|
||||
import { buildRenewRouter } from './api/renew.js'
|
||||
import { buildNativeCas, DEFAULT_NATIVE_DNS_ZONE, type NativeCas, type NativeCaMaterial } from './boot/native-ca.js'
|
||||
import type { RevocationBus } from 'relay-contracts'
|
||||
@@ -55,6 +58,13 @@ export interface ControlPlaneOverrides {
|
||||
readonly caChainDer?: readonly Uint8Array[]
|
||||
/** Production mode → native-CA + renew-anchor material is fail-closed (INV9). Defaults to NODE_ENV. */
|
||||
readonly production?: boolean
|
||||
/**
|
||||
* Pre-built native-tunnel CAs (A2-prep production path). When supplied, `buildControlPlane` uses
|
||||
* these verbatim and does NOT call `buildNativeCas` — this is how `server.ts` threads the two
|
||||
* on-disk P-256 CAs (`buildFileBackedNativeCas`) in without coupling them to the intermediate
|
||||
* `kmsResolver`. Takes precedence over `nativeCaMaterial`.
|
||||
*/
|
||||
readonly nativeCas?: NativeCas
|
||||
/** Production-loaded native-tunnel CA material (per-CA KMS ref + public cert DER). */
|
||||
readonly nativeCaMaterial?: NativeCaMaterial
|
||||
/** DNS zone the native-tunnel leaves are stamped under (defaults to `terminal.yaojia.wang`). */
|
||||
@@ -182,11 +192,15 @@ export async function buildControlPlane(
|
||||
// the renew route MUST validate presented certs against non-empty anchors (renew.ts). DEV generates
|
||||
// self-signed in-process P-256 CAs so leaves chain to a real, re-parseable CA.
|
||||
const production = overrides.production ?? process.env.NODE_ENV === 'production'
|
||||
const nativeCas = await buildNativeCas(env, {
|
||||
production,
|
||||
...(overrides.kmsResolver !== undefined ? { kmsResolver: overrides.kmsResolver } : {}),
|
||||
...(overrides.nativeCaMaterial !== undefined ? { material: overrides.nativeCaMaterial } : {}),
|
||||
})
|
||||
// `server.ts` builds the two on-disk P-256 CAs itself (buildFileBackedNativeCas, with the file
|
||||
// resolver) and threads them in as `nativeCas` — decoupled from the intermediate `kmsResolver`.
|
||||
const nativeCas =
|
||||
overrides.nativeCas ??
|
||||
(await buildNativeCas(env, {
|
||||
production,
|
||||
...(overrides.kmsResolver !== undefined ? { kmsResolver: overrides.kmsResolver } : {}),
|
||||
...(overrides.nativeCaMaterial !== undefined ? { material: overrides.nativeCaMaterial } : {}),
|
||||
}))
|
||||
const nativeDnsZone = overrides.nativeDnsZone ?? DEFAULT_NATIVE_DNS_ZONE
|
||||
|
||||
// ONE DeviceStore shared across the device registry, the device signer, and the renew path so the
|
||||
@@ -243,6 +257,18 @@ export async function buildControlPlane(
|
||||
await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner, nativeEnroller }))
|
||||
// Device enrollment is bearer-gated by the SAME capability verifier seam the admin API uses.
|
||||
await app.register(buildDeviceEnrollRouter({ verifier, devices: deviceRegistry, signer: deviceSigner, ownership }))
|
||||
// B1 — operator login → device:enroll bearer mint. The route ALWAYS registers (so a phone client
|
||||
// gets a coherent response), but is FAIL-CLOSED (503) unless the operator triplet is env-configured
|
||||
// (env.ts cross-validates set-together-or-none). The bearer is signed with the PRIVATE half of
|
||||
// `CAPABILITY_SIGN_PUBKEY_B64` so it verifies on the same §4.3 path /device/enroll checks. INV9: the
|
||||
// signing key is imported non-exportable + sign-only; the operator secret is never logged.
|
||||
const enrollSigningKey =
|
||||
env.capabilitySignPrivkey !== undefined ? await loadEnrollSigningKey(env.capabilitySignPrivkey) : null
|
||||
const loginConfig: LoginSeamConfig =
|
||||
env.operatorPassword !== undefined && env.operatorAccountId !== undefined
|
||||
? { operatorCredential: env.operatorPassword, accountId: env.operatorAccountId }
|
||||
: {}
|
||||
await app.register(buildAuthLoginRouter({ signingKey: enrollSigningKey, loginConfig }))
|
||||
// Leaf renewal is mTLS-authenticated (current client cert) — anchors chain-validate the presented cert.
|
||||
await app.register(
|
||||
buildRenewRouter({
|
||||
|
||||
@@ -16,7 +16,8 @@ import { runMigrations } from './db/migrate.js'
|
||||
import { createRedisRevocationBus } from './routing/bus.js'
|
||||
import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js'
|
||||
import { createRedisClient, createRedisPublisher } from './boot/redis.js'
|
||||
import { buildControlPlane } from './main.js'
|
||||
import { buildControlPlane, type ControlPlaneOverrides } from './main.js'
|
||||
import { buildFileBackedNativeCas } from './boot/native-ca.js'
|
||||
|
||||
/** Admin API binds to loopback by default — never expose the provisioning API on a public interface. */
|
||||
const DEFAULT_CP_BIND_HOST = '127.0.0.1'
|
||||
@@ -57,7 +58,21 @@ async function main(): Promise<void> {
|
||||
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
|
||||
const verifier = createCapabilityVerifier()
|
||||
|
||||
const { app } = await buildControlPlane(env, { stores, bus, verifier })
|
||||
// A2-prep: when NATIVE_* on-disk CA material is configured, load the two REAL P-256 CAs (frp-client
|
||||
// + device) from disk and thread them into the app so /enroll + /device/enroll issue leaves from the
|
||||
// production CAs. `buildFileBackedNativeCas` fails fast (INV9) on unreadable/non-P-256 material. When
|
||||
// unset, `buildControlPlane` keeps its existing behaviour (dev self-signed CAs, or fail-closed if
|
||||
// NODE_ENV=production) — the dev/test injection path is untouched.
|
||||
const nativeOverrides: Pick<ControlPlaneOverrides, 'production' | 'nativeCas' | 'nativeDnsZone'> =
|
||||
env.nativeCa !== undefined
|
||||
? {
|
||||
production: true,
|
||||
nativeCas: await buildFileBackedNativeCas(env, env.nativeCa),
|
||||
nativeDnsZone: env.nativeCa.dnsZone,
|
||||
}
|
||||
: {}
|
||||
|
||||
const { app } = await buildControlPlane(env, { stores, bus, verifier, ...nativeOverrides })
|
||||
|
||||
const host = resolveBindHost(process.env)
|
||||
const port = resolveBindPort(process.env)
|
||||
|
||||
184
control-plane/test/auth-login.test.ts
Normal file
184
control-plane/test/auth-login.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* B1 — operator login → device:enroll bearer mint. Proves the PINNED contract:
|
||||
* POST /auth/login { password } -> 201 { enrollToken, accountId, expiresIn }
|
||||
* then POST /device/enroll Authorization: Bearer <enrollToken> is reachable (201) and rejected
|
||||
* WITHOUT the bearer (401).
|
||||
*
|
||||
* Security surface under test: constant-time credential compare (via loginToAccountId), rate-limited
|
||||
* login attempts, deny-by-default / fail-closed when the operator credential is unset, and that the
|
||||
* minted bearer carries the correct right ('enroll') + audience ('device-enroll') + TTL — verified
|
||||
* through relay-auth's REAL §4.3 verify path (verifyDeviceEnrollToken), the same one production uses.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect, beforeEach } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import Fastify, { type FastifyInstance } from 'fastify'
|
||||
import { configureVerifyKey } from 'relay-auth'
|
||||
import { resetVerifyKeyForTest } from 'relay-auth/src/config/keys.js'
|
||||
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
|
||||
import { buildAuthLoginRouter, type AuthLoginDeps } from '../src/api/auth-login.js'
|
||||
import {
|
||||
verifyDeviceEnrollToken,
|
||||
DEVICE_ENROLL_AUD,
|
||||
DEFAULT_DEVICE_ENROLL_TTL_SEC,
|
||||
} from '../src/auth/session.js'
|
||||
import { buildControlPlane } from '../src/main.js'
|
||||
import { createCapabilityVerifier } from '../src/boot/verifier.js'
|
||||
import { loadEnv } from '../src/env.js'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { generateEd25519 } from '../src/util/crypto.js'
|
||||
import { buildCsrEc } from '../src/ca/csr-ec.js'
|
||||
import { bytesToBase64 } from '../src/util/bytes.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||
const OPERATOR_PASSWORD = 'operator-secret-abcdef123456'
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Block A — the login route in isolation (fast; verify-key configured so the mint round-trips)
|
||||
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
describe('B1 POST /auth/login (route in isolation)', () => {
|
||||
let signingKey: CryptoKey
|
||||
|
||||
beforeEach(async () => {
|
||||
resetVerifyKeyForTest()
|
||||
const pair = await generateEd25519KeyPair()
|
||||
await configureVerifyKey(pair.publicKey)
|
||||
signingKey = pair.privateKey
|
||||
})
|
||||
|
||||
function buildApp(overrides: Partial<AuthLoginDeps> = {}): FastifyInstance {
|
||||
const deps: AuthLoginDeps = {
|
||||
signingKey,
|
||||
loginConfig: { operatorCredential: OPERATOR_PASSWORD, accountId: ACCOUNT_A },
|
||||
clientKey: () => 'test-client', // deterministic rate-limit bucket
|
||||
...overrides,
|
||||
}
|
||||
const app = Fastify({ logger: false })
|
||||
void app.register(buildAuthLoginRouter(deps))
|
||||
return app
|
||||
}
|
||||
|
||||
test('valid password → 201 { enrollToken, accountId, expiresIn }', async () => {
|
||||
const app = buildApp()
|
||||
await app.ready()
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(body.accountId).toBe(ACCOUNT_A)
|
||||
expect(body.expiresIn).toBe(DEFAULT_DEVICE_ENROLL_TTL_SEC)
|
||||
expect(typeof body.enrollToken).toBe('string')
|
||||
expect(body.enrollToken.startsWith('v4.public.')).toBe(true)
|
||||
})
|
||||
|
||||
test('the minted bearer carries the enroll right + device-enroll aud + minutes TTL', async () => {
|
||||
const app = buildApp()
|
||||
await app.ready()
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
|
||||
const { enrollToken, expiresIn } = JSON.parse(res.body)
|
||||
// A successful verify through the REAL §4.3 path asserts aud === device-enroll AND the enroll right.
|
||||
const { accountId } = await verifyDeviceEnrollToken(enrollToken, { aud: DEVICE_ENROLL_AUD })
|
||||
expect(accountId).toBe(ACCOUNT_A)
|
||||
expect(expiresIn).toBeGreaterThanOrEqual(60) // minutes-scale, separate from the 30–60s connect clamp
|
||||
})
|
||||
|
||||
test('wrong password → 401, no token minted', async () => {
|
||||
const app = buildApp()
|
||||
await app.ready()
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'wrong-but-long-enough' } })
|
||||
expect(res.statusCode).toBe(401)
|
||||
expect(JSON.parse(res.body).enrollToken).toBeUndefined()
|
||||
})
|
||||
|
||||
test('missing password field → 400 (Zod at the boundary)', async () => {
|
||||
const app = buildApp()
|
||||
await app.ready()
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: {} })
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
test('fail-closed when the operator credential is unset → 503, never mints', async () => {
|
||||
const app = buildApp({ signingKey: null, loginConfig: {} })
|
||||
await app.ready()
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
|
||||
expect(res.statusCode).toBe(503)
|
||||
expect(JSON.parse(res.body).enrollToken).toBeUndefined()
|
||||
})
|
||||
|
||||
test('login attempts are rate-limited → 429 after the threshold', async () => {
|
||||
const app = buildApp({ rateMax: 2 })
|
||||
await app.ready()
|
||||
const attempt = () =>
|
||||
app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'wrong-but-long-enough' } })
|
||||
expect((await attempt()).statusCode).toBe(401)
|
||||
expect((await attempt()).statusCode).toBe(401)
|
||||
expect((await attempt()).statusCode).toBe(429) // over the per-client window
|
||||
})
|
||||
})
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Block B — end-to-end through the WIRED control-plane (login → bearer → /device/enroll)
|
||||
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
describe('B1 login → device:enroll e2e (wired app)', () => {
|
||||
let app: FastifyInstance
|
||||
|
||||
beforeEach(async () => {
|
||||
resetVerifyKeyForTest()
|
||||
// Capability keypair: raw public → boot verify key; PKCS#8 private → the enroll signing key env.
|
||||
const pair = await generateEd25519KeyPair()
|
||||
const rawPub = await exportEd25519PublicRaw(pair.publicKey)
|
||||
const pkcs8 = new Uint8Array(await webcrypto.subtle.exportKey('pkcs8', pair.privateKey))
|
||||
const env = loadEnv({
|
||||
PG_URL: 'postgres://u:p@localhost:5432/cp',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(rawPub),
|
||||
CAPABILITY_SIGN_PRIVKEY_B64: bytesToBase64(pkcs8),
|
||||
OPERATOR_PASSWORD,
|
||||
OPERATOR_ACCOUNT_ID: ACCOUNT_A,
|
||||
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
|
||||
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
|
||||
RELAY_TRUST_DOMAIN: 'terminal.yaojia.wang',
|
||||
BASE_DOMAIN: 'term.example.com',
|
||||
})
|
||||
const stores = createMemoryStores()
|
||||
const built = await buildControlPlane(env, { stores, verifier: createCapabilityVerifier() })
|
||||
app = built.app
|
||||
await app.ready()
|
||||
// Onboard a host so ACCOUNT_A OWNS 'alice' (device-enroll ownership gate reads this registry).
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { publicKeyRaw } = generateEd25519()
|
||||
await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||
})
|
||||
|
||||
test('login mints a bearer that /device/enroll accepts (201); without it → 401', async () => {
|
||||
const login = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
|
||||
expect(login.statusCode).toBe(201)
|
||||
const { enrollToken, accountId } = JSON.parse(login.body)
|
||||
expect(accountId).toBe(ACCOUNT_A)
|
||||
|
||||
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
|
||||
const payload = { csr: Buffer.from(csr).toString('base64'), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'iphone' }
|
||||
|
||||
const enrolled = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: `Bearer ${enrollToken}` },
|
||||
payload,
|
||||
})
|
||||
expect(enrolled.statusCode).toBe(201)
|
||||
expect(JSON.parse(enrolled.body).deviceId.length).toBeGreaterThan(0)
|
||||
|
||||
const noBearer = await app.inject({ method: 'POST', url: '/device/enroll', payload })
|
||||
expect(noBearer.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
test('wrong operator password on the wired app → 401', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'definitely-the-wrong-secret' } })
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
99
control-plane/test/ca-wiring.test.ts
Normal file
99
control-plane/test/ca-wiring.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* A2-prep — file-backed KMS resolver unit tests. Proves `fileKmsResolver` loads an on-disk PKCS#8
|
||||
* PEM P-256 CA key and exposes it as a `CaSigner` whose signature verifies against the key's public
|
||||
* half (the single-owner file-custody reality of the VPS deploy), and fails loud on bad material
|
||||
* (missing file, malformed PEM, wrong curve) — NEVER leaking key bytes (INV9).
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { generateKeyPairSync, createPublicKey, verify as nodeVerify, randomBytes } from 'node:crypto'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileKmsResolver, fileKeyRef } from '../src/boot/ca-wiring.js'
|
||||
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'cp-cawiring-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Write a fresh P-256 PKCS#8 PEM key to disk; return its path + the matching public KeyObject. */
|
||||
function writeP256Key(name: string): { path: string; publicKey: ReturnType<typeof createPublicKey> } {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string
|
||||
const path = join(dir, name)
|
||||
writeFileSync(path, pem)
|
||||
return { path, publicKey }
|
||||
}
|
||||
|
||||
describe('fileKmsResolver — P-256 on-disk key custody', () => {
|
||||
test('resolves a file: ref, loads the P-256 key, and signs verifiably', async () => {
|
||||
const { path, publicKey } = writeP256Key('frp.key.pem')
|
||||
const resolver = fileKmsResolver()
|
||||
|
||||
const { signer, policyRestrictedToServicePrincipal } = await resolver.resolve(fileKeyRef(path))
|
||||
expect(policyRestrictedToServicePrincipal).toBe(true)
|
||||
|
||||
const tbs = randomBytes(48)
|
||||
const sig = await signer.sign(new Uint8Array(tbs))
|
||||
// signer emits raw P1363 r||s (64 bytes) — the same shape hardware/`inProcessP256CaSigner` produce.
|
||||
expect(sig.length).toBe(64)
|
||||
const ok = nodeVerify('sha256', tbs, { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig))
|
||||
expect(ok).toBe(true)
|
||||
// the signer's public key equals the on-disk key's SPKI DER.
|
||||
const spki = new Uint8Array(publicKey.export({ format: 'der', type: 'spki' }) as Buffer)
|
||||
expect(Buffer.from(signer.publicKeyRaw).equals(Buffer.from(spki))).toBe(true)
|
||||
})
|
||||
|
||||
test('resolves a bare path (no file: prefix)', async () => {
|
||||
const { path, publicKey } = writeP256Key('bare.key.pem')
|
||||
const { signer } = await fileKmsResolver().resolve(path)
|
||||
const tbs = randomBytes(32)
|
||||
const sig = await signer.sign(new Uint8Array(tbs))
|
||||
expect(nodeVerify('sha256', tbs, { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig))).toBe(true)
|
||||
})
|
||||
|
||||
test('missing file → rejects, never echoing the path contents', async () => {
|
||||
await expect(fileKmsResolver().resolve(fileKeyRef(join(dir, 'nope.pem')))).rejects.toThrow(/unreadable/)
|
||||
})
|
||||
|
||||
test('malformed PEM → rejects', async () => {
|
||||
const path = join(dir, 'bad.pem')
|
||||
writeFileSync(path, 'not a pem key')
|
||||
await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/not a valid PEM private key/)
|
||||
})
|
||||
|
||||
test('non-P-256 key (Ed25519) → rejected (curve mismatch, fail-closed)', async () => {
|
||||
const { privateKey } = generateKeyPairSync('ed25519')
|
||||
const path = join(dir, 'ed.key.pem')
|
||||
writeFileSync(path, privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
|
||||
await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/P-256/)
|
||||
})
|
||||
|
||||
test('wrong-curve EC key (P-384) → rejected', async () => {
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-384' })
|
||||
const path = join(dir, 'p384.key.pem')
|
||||
writeFileSync(path, privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
|
||||
await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/P-256/)
|
||||
})
|
||||
|
||||
test('empty file: ref path → rejected', async () => {
|
||||
await expect(fileKmsResolver().resolve('file:')).rejects.toThrow(/empty path/)
|
||||
})
|
||||
|
||||
test('never leaks key material in the error message (INV9)', async () => {
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-384' })
|
||||
const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string
|
||||
const path = join(dir, 'secret.key.pem')
|
||||
writeFileSync(path, pem)
|
||||
try {
|
||||
await fileKmsResolver().resolve(fileKeyRef(path))
|
||||
throw new Error('expected rejection')
|
||||
} catch (e) {
|
||||
expect(e instanceof Error ? e.message : '').not.toContain(pem.slice(40, 80))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -66,3 +66,110 @@ describe('T1 loadEnv (INV9 fail-fast)', () => {
|
||||
expect(() => loadEnv({ ...base(), CAPABILITY_SIGN_PUBKEY_B64: short })).toThrow(/32 bytes/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('B1 operator-login env (set-together-or-none, fail-closed)', () => {
|
||||
const OPERATOR_PASSWORD = 'operator-secret-abcdef123456'
|
||||
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
|
||||
const PRIVKEY = bytesToBase64(new Uint8Array(48).fill(3)) // shape-valid base64; import validated at boot
|
||||
|
||||
test('none set → login fields undefined (feature off, route fail-closed at runtime)', () => {
|
||||
const env = loadEnv(base())
|
||||
expect(env.operatorPassword).toBeUndefined()
|
||||
expect(env.operatorAccountId).toBeUndefined()
|
||||
expect(env.capabilitySignPrivkey).toBeUndefined()
|
||||
})
|
||||
|
||||
test('all three set → parsed together', () => {
|
||||
const env = loadEnv({
|
||||
...base(),
|
||||
OPERATOR_PASSWORD,
|
||||
OPERATOR_ACCOUNT_ID: ACCOUNT,
|
||||
CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY,
|
||||
})
|
||||
expect(env.operatorPassword).toBe(OPERATOR_PASSWORD)
|
||||
expect(env.operatorAccountId).toBe(ACCOUNT)
|
||||
expect(env.capabilitySignPrivkey?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('password without account/key → fail-fast (no silent half-open)', () => {
|
||||
expect(() => loadEnv({ ...base(), OPERATOR_PASSWORD })).toThrow(/OPERATOR_ACCOUNT_ID|CAPABILITY_SIGN_PRIVKEY_B64/)
|
||||
})
|
||||
|
||||
test('too-short operator password rejected (16–512 charset rule)', () => {
|
||||
expect(() =>
|
||||
loadEnv({ ...base(), OPERATOR_PASSWORD: 'short', OPERATOR_ACCOUNT_ID: ACCOUNT, CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY }),
|
||||
).toThrow(/OPERATOR_PASSWORD/)
|
||||
})
|
||||
|
||||
test('non-UUID operator account rejected', () => {
|
||||
expect(() =>
|
||||
loadEnv({ ...base(), OPERATOR_PASSWORD, OPERATOR_ACCOUNT_ID: 'not-a-uuid', CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY }),
|
||||
).toThrow(/OPERATOR_ACCOUNT_ID/)
|
||||
})
|
||||
|
||||
test('never echoes the operator secret on a partial-config failure (INV9)', () => {
|
||||
try {
|
||||
loadEnv({ ...base(), OPERATOR_PASSWORD })
|
||||
} catch (e) {
|
||||
expect(e instanceof Error ? e.message : '').not.toContain(OPERATOR_PASSWORD)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('A2-prep native-tunnel CA env (set-together-or-none, fail-closed)', () => {
|
||||
const FRP_CERT = '/etc/cp/frp-client-ca.cert.pem'
|
||||
const DEV_CERT = '/etc/cp/device-ca.cert.pem'
|
||||
const ZONE = 'native.example.test'
|
||||
|
||||
test('none set → nativeCa undefined (injection/dev path)', () => {
|
||||
const env = loadEnv(base())
|
||||
expect(env.nativeCa).toBeUndefined()
|
||||
})
|
||||
|
||||
test('cert paths + dns zone set → parsed, key paths DERIVED from cert paths', () => {
|
||||
const env = loadEnv({
|
||||
...base(),
|
||||
NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT,
|
||||
NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT,
|
||||
NATIVE_DNS_ZONE: ZONE,
|
||||
})
|
||||
expect(env.nativeCa).toEqual({
|
||||
frpClientCaCertPath: FRP_CERT,
|
||||
frpClientCaKeyPath: '/etc/cp/frp-client-ca.key.pem',
|
||||
deviceCaCertPath: DEV_CERT,
|
||||
deviceCaKeyPath: '/etc/cp/device-ca.key.pem',
|
||||
dnsZone: ZONE,
|
||||
})
|
||||
})
|
||||
|
||||
test('explicit key paths are used verbatim (not derived)', () => {
|
||||
const env = loadEnv({
|
||||
...base(),
|
||||
NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT,
|
||||
NATIVE_FRP_CLIENT_CA_KEY_PATH: '/keys/frp.pem',
|
||||
NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT,
|
||||
NATIVE_DEVICE_CA_KEY_PATH: '/keys/dev.pem',
|
||||
NATIVE_DNS_ZONE: ZONE,
|
||||
})
|
||||
expect(env.nativeCa?.frpClientCaKeyPath).toBe('/keys/frp.pem')
|
||||
expect(env.nativeCa?.deviceCaKeyPath).toBe('/keys/dev.pem')
|
||||
})
|
||||
|
||||
test('partial set — only frp cert path → fail-closed listing the missing keys', () => {
|
||||
expect(() => loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT })).toThrow(
|
||||
/NATIVE_DEVICE_CA_CERT_PATH|NATIVE_DNS_ZONE/,
|
||||
)
|
||||
})
|
||||
|
||||
test('partial set — cert paths without a dns zone → fail-closed', () => {
|
||||
expect(() =>
|
||||
loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT, NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT }),
|
||||
).toThrow(/NATIVE_DNS_ZONE/)
|
||||
})
|
||||
|
||||
test('partial set — a key path supplied WITHOUT its cert path → fail-closed', () => {
|
||||
expect(() => loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_KEY_PATH: '/keys/frp.pem' })).toThrow(
|
||||
/NATIVE_FRP_CLIENT_CA_CERT_PATH|NATIVE_DEVICE_CA_CERT_PATH|NATIVE_DNS_ZONE/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
215
control-plane/test/native-ca.test.ts
Normal file
215
control-plane/test/native-ca.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* A2-prep — PRODUCTION native-tunnel CA wiring from ON-DISK P-256 material. Generates throwaway
|
||||
* P-256 CAs (key + cert PEM) in a tmp dir — standing in for the VPS `gen-device-ca.sh` output — then
|
||||
* proves `buildFileBackedNativeCas` binds each CA's signer to its on-disk key and its anchor to its
|
||||
* on-disk cert, that a leaf issued by the wired signer CHAINS to that on-disk cert, that a WIRED
|
||||
* control-plane `/enroll` mints an frp-client leaf whose dNSName SAN = `<sub>.<zone>` from the real
|
||||
* on-disk CA, and that partial/missing/malformed material FAILS CLOSED (INV9).
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto, generateKeyPairSync } from 'node:crypto'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { buildNativeCas, buildFileBackedNativeCas } from '../src/boot/native-ca.js'
|
||||
import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js'
|
||||
import { assembleCertificate } from '../src/ca/x509-assembler.js'
|
||||
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
|
||||
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { buildCsrEc } from '../src/ca/csr-ec.js'
|
||||
import { createPairingIssuer } from '../src/pairing/issue.js'
|
||||
import { buildControlPlane } from '../src/main.js'
|
||||
import { loadEnv, type ControlPlaneEnv, type NativeCaEnvConfig } from '../src/env.js'
|
||||
import { bytesToBase64 } from '../src/util/bytes.js'
|
||||
import type { Stores } from '../src/store/ports.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||
const ZONE = 'native.example.test'
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
let dir: string
|
||||
let env: ControlPlaneEnv
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'cp-nativeca-'))
|
||||
env = loadEnv({
|
||||
PG_URL: 'postgres://u:p@localhost:5432/cp',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(new Uint8Array(32).fill(7)),
|
||||
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
|
||||
CA_INTERMEDIATE_CERT_PATH: join(dir, 'int.cert.pem'),
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH: join(dir, 'node-ca.pem'),
|
||||
RELAY_TRUST_DOMAIN: ZONE,
|
||||
BASE_DOMAIN: 'term.example.com',
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Write a self-signed throwaway P-256 CA (key + cert PEM) to disk — mirrors the VPS gen-device-ca output. */
|
||||
async function writeThrowawayP256Ca(cn: string): Promise<{ keyPath: string; certPath: string; caDer: Uint8Array }> {
|
||||
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
const keyPem = caKeys.privateKey.export({ format: 'pem', type: 'pkcs8' }) as string
|
||||
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
|
||||
const signer = inProcessP256CaSigner(caKeys.privateKey)
|
||||
const now = Date.now()
|
||||
const caDer = await assembleCertificate({
|
||||
subjectPublicKey: caSpki,
|
||||
subject: `CN=${cn}`,
|
||||
issuer: `CN=${cn}`,
|
||||
serialNumber: Uint8Array.from([0x01]),
|
||||
notBefore: new Date(now - DAY_MS),
|
||||
notAfter: new Date(now + 365 * DAY_MS),
|
||||
extensions: [
|
||||
new x509.BasicConstraintsExtension(true, undefined, true),
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
|
||||
],
|
||||
signer,
|
||||
sigAlg: 'ecdsa-p256',
|
||||
})
|
||||
const certPem = new x509.X509Certificate(caDer).toString('pem')
|
||||
const keyPath = join(dir, `${cn}.key.pem`)
|
||||
const certPath = join(dir, `${cn}.cert.pem`)
|
||||
writeFileSync(keyPath, keyPem)
|
||||
writeFileSync(certPath, certPem)
|
||||
return { keyPath, certPath, caDer }
|
||||
}
|
||||
|
||||
async function writeNativeCaConfig(): Promise<{ config: NativeCaEnvConfig; frpCaDer: Uint8Array; deviceCaDer: Uint8Array }> {
|
||||
const frp = await writeThrowawayP256Ca('frp-client-CA')
|
||||
const dev = await writeThrowawayP256Ca('device-CA')
|
||||
return {
|
||||
config: {
|
||||
frpClientCaCertPath: frp.certPath,
|
||||
frpClientCaKeyPath: frp.keyPath,
|
||||
deviceCaCertPath: dev.certPath,
|
||||
deviceCaKeyPath: dev.keyPath,
|
||||
dnsZone: ZONE,
|
||||
},
|
||||
frpCaDer: frp.caDer,
|
||||
deviceCaDer: dev.caDer,
|
||||
}
|
||||
}
|
||||
|
||||
function b64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
function sanNames(der: Uint8Array): ReturnType<x509.GeneralNames['toJSON']> {
|
||||
return new x509.X509Certificate(der).getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
|
||||
}
|
||||
|
||||
describe('buildFileBackedNativeCas — anchors + signer from on-disk P-256 material', () => {
|
||||
test('each CA anchor DER == its on-disk cert DER; issuer names parse', async () => {
|
||||
const { config, frpCaDer, deviceCaDer } = await writeNativeCaConfig()
|
||||
const cas = await buildFileBackedNativeCas(env, config)
|
||||
|
||||
expect(b64(cas.frpClientCa.caCertDer)).toBe(b64(frpCaDer))
|
||||
expect(b64(cas.deviceCa.caCertDer)).toBe(b64(deviceCaDer))
|
||||
expect(cas.frpClientCa.anchorsDer).toEqual([cas.frpClientCa.caCertDer])
|
||||
expect(cas.deviceCa.anchorsDer).toEqual([cas.deviceCa.caCertDer])
|
||||
expect(cas.frpClientCa.issuerName.toString()).toContain('frp-client-CA')
|
||||
expect(cas.deviceCa.issuerName.toString()).toContain('device-CA')
|
||||
})
|
||||
|
||||
test('a leaf minted by the wired frp-client signer CHAINS to the on-disk CA cert', async () => {
|
||||
const { config, frpCaDer } = await writeNativeCaConfig()
|
||||
const cas = await buildFileBackedNativeCas(env, config)
|
||||
|
||||
// Bind a P-256 host so the signer will issue for it.
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { der: csr, keys } = await buildCsrEc('CN=alice')
|
||||
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
|
||||
const host = await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: spki, enrollFpr: fingerprint(spki) })
|
||||
|
||||
const signer = createFrpClientLeafSigner({
|
||||
hosts: stores.hosts,
|
||||
signer: cas.frpClientCa.signer,
|
||||
issuerName: cas.frpClientCa.issuerName,
|
||||
caChainDer: cas.frpClientCa.anchorsDer,
|
||||
trustDomain: ZONE,
|
||||
dnsZone: ZONE,
|
||||
})
|
||||
const { cert } = await signer.signHostLeaf(host.hostId, spki, csr)
|
||||
|
||||
// The leaf verifies against the ON-DISK CA cert's public key (real chain) …
|
||||
const onDiskCa = new x509.X509Certificate(frpCaDer)
|
||||
expect(await new x509.X509Certificate(cert).verify({ publicKey: onDiskCa.publicKey, signatureOnly: true })).toBe(true)
|
||||
// … and carries the enforcement dNSName SAN under the configured zone.
|
||||
expect(sanNames(cert)).toContainEqual({ type: 'dns', value: `alice.${ZONE}` })
|
||||
})
|
||||
})
|
||||
|
||||
describe('WIRED control-plane /enroll from on-disk native CAs', () => {
|
||||
async function ownedApp(): Promise<{ app: Awaited<ReturnType<typeof buildControlPlane>>['app']; stores: Stores; frpCaDer: Uint8Array }> {
|
||||
const { config, frpCaDer } = await writeNativeCaConfig()
|
||||
const nativeCas = await buildFileBackedNativeCas(env, config)
|
||||
const stores = createMemoryStores()
|
||||
const built = await buildControlPlane(env, { stores, production: true, nativeCas, nativeDnsZone: ZONE })
|
||||
await built.app.ready()
|
||||
return { app: built.app, stores, frpCaDer }
|
||||
}
|
||||
|
||||
test('POST /enroll (native EC arm) → 201 frp-client leaf that chains to the on-disk CA + dNSName SAN <sub>.<zone>', async () => {
|
||||
const { app, stores, frpCaDer } = await ownedApp()
|
||||
const issuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: 3600 })
|
||||
const { code } = await issuer.issuePairingCode(ACCOUNT_A)
|
||||
const { der: csr, keys } = await buildCsrEc('CN=web-terminal-host')
|
||||
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
payload: { code, machineId: 'MB-1', agentPubkey: b64(spki), csr: b64(csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(typeof body.subdomain).toBe('string')
|
||||
expect(body.hostContentSecret).toBeNull()
|
||||
|
||||
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
|
||||
// chains to the REAL on-disk frp-client-CA …
|
||||
const onDiskCa = new x509.X509Certificate(frpCaDer)
|
||||
expect(await new x509.X509Certificate(leafDer).verify({ publicKey: onDiskCa.publicKey, signatureOnly: true })).toBe(true)
|
||||
// … the caChain the server returned IS that on-disk anchor …
|
||||
expect(b64(new Uint8Array(Buffer.from(body.caChain[0], 'base64')))).toBe(b64(frpCaDer))
|
||||
// … and the leaf carries the server-assigned dNSName SAN under the env-configured zone.
|
||||
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${ZONE}` })
|
||||
})
|
||||
})
|
||||
|
||||
describe('native-tunnel CA fail-closed (INV9)', () => {
|
||||
test('buildNativeCas production without material → refuses to boot', async () => {
|
||||
await expect(buildNativeCas(env, { production: true })).rejects.toThrow(/unresolvable in production/)
|
||||
})
|
||||
|
||||
test('buildFileBackedNativeCas with an unreadable cert path → refuses to boot', async () => {
|
||||
const { config } = await writeNativeCaConfig()
|
||||
const broken: NativeCaEnvConfig = { ...config, frpClientCaCertPath: join(dir, 'missing.cert.pem') }
|
||||
await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/certificate file is unreadable/)
|
||||
})
|
||||
|
||||
test('buildFileBackedNativeCas with a NON-P256 key file → refuses to boot (fail-closed)', async () => {
|
||||
const { config } = await writeNativeCaConfig()
|
||||
const edPath = join(dir, 'ed.key.pem')
|
||||
writeFileSync(edPath, generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
|
||||
const broken: NativeCaEnvConfig = { ...config, frpClientCaKeyPath: edPath }
|
||||
await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/P-256|could not be resolved/)
|
||||
})
|
||||
|
||||
test('buildFileBackedNativeCas with a malformed cert file → refuses to boot', async () => {
|
||||
const { config } = await writeNativeCaConfig()
|
||||
const badCert = join(dir, 'bad.cert.pem')
|
||||
writeFileSync(badCert, 'not a certificate')
|
||||
const broken: NativeCaEnvConfig = { ...config, deviceCaCertPath: badCert }
|
||||
await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/parseable X.509 certificate/)
|
||||
})
|
||||
})
|
||||
@@ -41,6 +41,10 @@ extraResources:
|
||||
to: node_modules
|
||||
filter:
|
||||
- "**/*"
|
||||
# .bin holds dev-tool shims (asar/tsc/esbuild/…) symlinked into packages we
|
||||
# exclude below — copying them leaves DANGLING symlinks that electron-builder
|
||||
# stat()s and dies on (ENOENT .bin/asar). Runtime deps load by path, not .bin.
|
||||
- "!.bin/**"
|
||||
# build-only tooling — never needed at runtime by express/ws/web-push/node-pty:
|
||||
- "!electron/**"
|
||||
- "!electron-builder/**"
|
||||
|
||||
386
desktop/package-lock.json
generated
386
desktop/package-lock.json
generated
@@ -9,7 +9,9 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"google-auth-library": "^10.9.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"web-push": "3.6.7",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
@@ -1093,7 +1095,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -1103,7 +1104,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -1367,7 +1367,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1384,6 +1383,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
@@ -1586,6 +1594,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -1668,7 +1685,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -1681,7 +1697,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
@@ -1818,6 +1833,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1835,6 +1859,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@@ -1939,6 +1972,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dir-compare": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
|
||||
@@ -2218,7 +2257,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
@@ -2470,6 +2508,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2512,6 +2556,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||
@@ -2573,6 +2640,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
@@ -2590,6 +2670,18 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -2639,11 +2731,38 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.2.0.tgz",
|
||||
"integrity": "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -2792,6 +2911,32 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz",
|
||||
"integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.1.4",
|
||||
"gcp-metadata": "8.1.2",
|
||||
"google-logging-utils": "1.1.3",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/google-logging-utils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -3038,7 +3183,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3131,6 +3275,15 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@@ -3217,6 +3370,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -3449,6 +3614,44 @@
|
||||
"semver": "^7.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp": {
|
||||
"version": "12.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
||||
@@ -3636,6 +3839,42 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -3645,6 +3884,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -3756,6 +4004,15 @@
|
||||
"node": ">=10.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postject": {
|
||||
"version": "1.0.0-alpha.6",
|
||||
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
|
||||
@@ -3883,6 +4140,89 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
@@ -3973,7 +4313,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3989,6 +4328,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resedit": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||
@@ -4227,6 +4572,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -4410,7 +4761,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -4425,7 +4775,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -4788,6 +5137,15 @@
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webcrypto-core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
|
||||
@@ -4818,6 +5176,12 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"google-auth-library": "^10.9.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"web-push": "3.6.7",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
|
||||
@@ -23,6 +23,8 @@ import { buildServerEnv } from './server-config.js'
|
||||
|
||||
/** Default listen port when the user hasn't pinned one (free-port fallback still applies). */
|
||||
const DEFAULT_PORT = 3000
|
||||
/** How long to wait for the probe of an already-running base app before giving up. */
|
||||
const PROBE_TIMEOUT_MS = 1500
|
||||
|
||||
export interface EmbeddedServerDeps {
|
||||
readonly prefs: DesktopPrefs
|
||||
@@ -34,6 +36,26 @@ interface ServerHandle {
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether a web-terminal base app is ALREADY serving on `port` (typically
|
||||
* the always-on tunnel daemon that owns :3000 + frpc). `GET /config/ui` returns a
|
||||
* small JSON object with `allowAutoMode` only from our backend, so it doubles as a
|
||||
* cheap "is this one of ours?" fingerprint. Any failure (nothing listening, wrong
|
||||
* shape, timeout) → false, and we spawn our own server as before.
|
||||
*/
|
||||
async function probeExistingBaseApp(port: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/config/ui`, {
|
||||
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const body: unknown = await res.json()
|
||||
return typeof body === 'object' && body !== null && 'allowAutoMode' in body
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the embedded backend and return a lifecycle handle. Throws (after
|
||||
* logging) if the compiled server can't be imported or fails to start — the app
|
||||
@@ -41,7 +63,23 @@ interface ServerHandle {
|
||||
*/
|
||||
export async function startEmbeddedServer(deps: EmbeddedServerDeps): Promise<EmbeddedServer> {
|
||||
const { prefs, logger } = deps
|
||||
const port = await pickFreePort(prefs.port ?? DEFAULT_PORT)
|
||||
const preferred = prefs.port ?? DEFAULT_PORT
|
||||
|
||||
// If a base app is already up on the preferred port (the always-on tunnel
|
||||
// daemon), RIDE it instead of spawning a duplicate on another port: one base app
|
||||
// then serves the desktop UI, LAN, and the frpc tunnel alike. close() is a no-op
|
||||
// because we don't own that process — quitting the window must not kill it.
|
||||
if (await probeExistingBaseApp(preferred)) {
|
||||
logger.info(`reusing base app already on http://127.0.0.1:${preferred} (tunnel daemon) — not spawning a duplicate`)
|
||||
return {
|
||||
port: preferred,
|
||||
bindHost: '127.0.0.1',
|
||||
allowedOrigins: [],
|
||||
close: async () => {},
|
||||
}
|
||||
}
|
||||
|
||||
const port = await pickFreePort(preferred)
|
||||
const env = buildServerEnv({ prefs, port, platform: process.platform, env: process.env })
|
||||
|
||||
// dist/ is a sibling of the desktop dir in dev; under resourcesPath when packaged.
|
||||
|
||||
165
docs/PLAN_ZERO_TOUCH_ROLLOUT.md
Normal file
165
docs/PLAN_ZERO_TOUCH_ROLLOUT.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Zero-Touch Enrollment — Rollout & Integration Plan
|
||||
|
||||
> **This is the EXECUTION plan** (deploy + wire + fill the gaps), grounded in the *current*
|
||||
> deployment reality. The DESIGN blueprint is `docs/PLAN_TUNNEL_AUTOMATION.md`; this doc says
|
||||
> what to actually do next, in order, to reach the product requirement.
|
||||
>
|
||||
> **Product requirement (operator, 2026-07-18):** ① 主机端启动即自动连服务器 + 自动管理证书(零手动装证书);② 客户端(手机)连接时自动处理好证书(零手动 .p12)。
|
||||
|
||||
---
|
||||
|
||||
## 0. The honest constraint (read first)
|
||||
|
||||
The **first** enrollment of each identity cannot be zero *human* actions: issuing a tunnel cert to
|
||||
an unknown machine/phone with no auth = anyone who reaches the endpoint gets a cert. So exactly **one
|
||||
authenticated human action at bootstrap is unavoidable**: paste a one-time pairing code (host) / log
|
||||
in once (phone). **Everything after** — renew, rotate, reboot-survival — is genuinely silent.
|
||||
|
||||
> "Zero-touch" here honestly means **"one bootstrap tap, then never again"**, not "no human step ever."
|
||||
> Every step below is designed so that single tap is the *only* manual action for the product lifetime.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current state (verified 2026-07-18)
|
||||
|
||||
| Piece | State |
|
||||
|---|---|
|
||||
| `control-plane/` (PKI/CA + `/enroll` + `/device/enroll` + `/renew`) | **Built, 246 tests pass.** Loopback `127.0.0.1:8080`, fail-closed prod boot (needs real KMS CA material). |
|
||||
| `agent/` host agent (`pair <CODE> --install`) | **Built, 267 tests pass.** launchd user-agent; keygen→CSR→enroll→frpc.toml→2 units→supervised frpc. |
|
||||
| iOS device-enroll library (SecureEnclave→CSR→cert) | **Built + unit-tested**, but **zero app callers** (no UI). |
|
||||
| Android / Desktop device-enroll | **None** (Android from scratch; Desktop deferred). |
|
||||
| Server **login route + `device:enroll` bearer mint** | **MISSING — hard blocker for the phone track.** `loginToAccountId` is a deny-all stub. |
|
||||
| VPS control-plane | **NOT running** (`:8080` down); code on VPS is the **older `feat/relay-phase1`** branch. |
|
||||
| VPS tunnel data path (frps + nginx:8470 mTLS + LE wildcard cert) | **UP** (from M1). LE `*.terminal.yaojia.wang` valid → 2026-10-05. |
|
||||
| My manual `mac1` host tunnel | **Live**, but static hand-issued certs — bypasses the control-plane. Control cert correctly from **frp-client-CA**, so CA lineage already matches the agent path (low migration risk). |
|
||||
|
||||
**Bottom line:** the automated system is ~built but **not deployed and not wired**; the phone track has a
|
||||
real server-code hole (login/bearer). My `mac1` is a manual stand-in to be retired.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture (target flows)
|
||||
|
||||
**Host** (`web-terminal-agent pair <CODE> --install`): generate P-256 key (never leaves host, 0600) →
|
||||
PKCS#10 CSR → `POST /enroll {code, machineId, agentPubkey, csr}` → CA returns frp-client leaf
|
||||
(`dNSName SAN = <sub>.terminal.yaojia.wang`) + caChain → SHA-256-verify-download pinned `frpc` v0.61.1 →
|
||||
write `frpc.toml` (mTLS control channel, `serverName=frp.terminal.yaojia.wang`, one http proxy →
|
||||
`127.0.0.1:PORT`) + base-app `ALLOWED_ORIGINS` → install **two launchd units** (base app + supervised
|
||||
agent) → reboot-durable. **Renew** silently at ~2/3 TTL via `POST /renew` (mTLS w/ current cert).
|
||||
|
||||
**Phone**: one-time **account login** → short-lived `device:enroll` bearer → app generates a
|
||||
**non-exportable hardware key** (Secure Enclave / StrongBox) → hardware-signed CSR →
|
||||
`POST /device/enroll {csr, subdomain, deviceName, attestation?}` (Bearer) → CA verifies token +
|
||||
account-owns-subdomain (deny-by-default) + PoP + cap/rate-limit → returns device leaf + caChain →
|
||||
app stores identity against the hardware key, presents on the **already-wired mTLS path**. Silent renew
|
||||
via `POST /device/:id/renew`.
|
||||
|
||||
**Three PKI roots (do not conflate):** frp-client-CA (P-256, host control channel, frps:7000) ·
|
||||
device-CA (P-256, device data path, nginx:8470 `ssl_verify_client`) · relay agent-CA (Ed25519, legacy).
|
||||
|
||||
---
|
||||
|
||||
## 3. PRE-FLIGHT: deployment unknowns to verify (blockers before any build)
|
||||
|
||||
Run against VPS `8.138.1.192`. **These gate feasibility — resolve before committing to the tracks.**
|
||||
|
||||
1. **CP CA material** — does production KMS/CA material exist for the *native* CAs the new CP needs
|
||||
(`NATIVE_FRP_CLIENT_CA_*`, `NATIVE_DEVICE_CA_*`, `CA_INTERMEDIATE_*`)? CP is **fail-closed**: no real
|
||||
material ⇒ it won't boot (or falls back to dev placeholder leaves = unusable). **This is the #1 risk.**
|
||||
The relay-phase1 deploy had `/etc/relay/ca/{root,intermediate}` + a P-256 device-CA + frp-client-CA
|
||||
on disk — confirm these can back the new CP's signer (KMS refs vs on-disk keys).
|
||||
2. **CP branch/deploy** — the newer CP (with the enroll/renew endpoints as-tested) must be checked out
|
||||
& built on the VPS (currently `feat/relay-phase1`). Confirm PG + Redis reachable + migrated.
|
||||
3. **Public `/enroll` reachability** — CP is loopback; an nginx vhost must proxy `/enroll`,`/device/enroll`,
|
||||
`/renew` over public TLS. `curl -i https://<enroll-host>/enroll` should give a 4xx (validation), not 502.
|
||||
4. **frps trusts `frp-client-CA`** on the `:7000` control channel (NOT device-CA). Inspect frps `trustedCaFile`.
|
||||
5. **`manage` bearer + `CAPABILITY_SIGN_PUBKEY_B64`** set (else the verifier is `refuseAll` and no pairing
|
||||
code can be minted).
|
||||
6. **`FRP_AUTH_TOKEN`** identical on agent side and frps.
|
||||
7. **nginx :8470 dNSName→Host binding** deployed (njs `getCertSub` + `map` → 403 on mismatch) — the
|
||||
load-bearing isolation control for the device path.
|
||||
8. **Login/`device:enroll` route** — expected: **absent today**; confirm (defines phone-track start).
|
||||
|
||||
---
|
||||
|
||||
## 4. TRACK A — Host auto-enroll (small–medium; mostly deploy + wire)
|
||||
|
||||
**Goal:** fresh Mac → one pairing code → `pair --install` → auto-connect + self-managing cert, reboot-durable.
|
||||
|
||||
| # | Task | Where | Effort | Notes / risk |
|
||||
|---|---|---|---|---|
|
||||
| A1 | Resolve pre-flight §3.1–3.6 (CA material, CP build, public `/enroll`, frps CA, manage bearer, token) | VPS | S–M | Gated by §3.1 KMS/CA material — the real unknown. |
|
||||
| A2 | Deploy + run the new control-plane in **production mode** (real CA material, PG/Redis, systemd unit) | VPS `control-plane/` | M | Replace the old relay-phase1 CP; keep loopback:8080. |
|
||||
| A3 | nginx vhost proxying `/enroll` `/device/enroll` `/renew` → 127.0.0.1:8080 over the LE wildcard TLS | `deploy/nginx` + VPS | S | Add to existing :443 SNI / a dedicated enroll host. |
|
||||
| A4 | Mint one **pairing code** (`POST /accounts/:id/pairing-codes`, `manage` bearer) | VPS | S | MVP = manually pasted code (RFC 8628 device-grant deferred). |
|
||||
| A5 | **Wire auto-renew into the native run-loop** — call `createCertRotator`/`renewCert` from `superviseNative` | `agent/src` (**only real host code gap**) | S | Logic already built+tested; today `superviseNative` only *monitors* freshness → cert dies at 24h. TDD it. |
|
||||
| A6 | Build the agent (`dist/cli.js`) + set env (`ENROLL_URL`,`TUNNEL_DOMAIN`,`TUNNEL_ZONE=terminal`,`FRP_AUTH_TOKEN`,`PORT`,`BIND_HOST=127.0.0.1`, dummy `RELAY_URL`) | Mac | S | |
|
||||
| A7 | Run `web-terminal-agent pair <CODE> --install`; verify 2 launchd units + `https://<sub>.terminal.yaojia.wang` live via device cert | Mac | S | |
|
||||
| A8 | **Retire manual `mac1`** — bootout `com.webterm.tunnel.{baseapp,frpc}`, remove `frpc.toml`/certs | Mac | S | Only after A7 green. |
|
||||
| A9 | (optional) `machineId` dedup so reinstall keeps the same subdomain | `agent/src`, `control-plane/registry` | S | Deferred; cosmetic (`status` output). |
|
||||
|
||||
**Deliverable A:** computer start → auto-connect + auto-managed/renewing cert; one pairing code at first
|
||||
setup, silent thereafter. **Blocking risk = §3.1 (production CA material).**
|
||||
|
||||
---
|
||||
|
||||
## 5. TRACK B — Phone auto-enroll (build-heavy; server hole first)
|
||||
|
||||
**Goal:** phone → one login → device cert auto-issued to hardware key → connect, no `.p12`, ever.
|
||||
|
||||
| # | Task | Where | Effort | Notes / risk |
|
||||
|---|---|---|---|---|
|
||||
| B1 | **Build + deploy the CP login route + `device:enroll` bearer mint** (`auth/session.ts mintDeviceEnrollToken`, add `/login` or `/auth`, add `enroll` to `CapabilityRightSchema`) | `control-plane/src/auth` | M | **HARD BLOCKER — do first; nothing in the phone flow is reachable without a bearer.** Even a single-operator credential seam unblocks it (full OIDC later). |
|
||||
| B2 | Bridge: after login, mint bearer + scope to the account's subdomain(s) (deny-by-default already enforced by CP) | `control-plane` | S | |
|
||||
| B3 | **iOS**: wire `KeychainClientIdentityStore.enroll` into an enrollment screen (login → bearer → CSR → `/device/enroll` → store SE identity → present on existing mTLS path) + rotation scheduler | `ios/` | M | Library exists + unit-tested; this is UI + flow wiring. |
|
||||
| B4 | **Android**: build the enroll library from scratch (StrongBox keygen + CSR + `/device/enroll` client) + present via existing `X509KeyManager` | `android/` | L | Mirror the iOS ClientTLS package; no existing code. |
|
||||
| B5 | (optional) scan-to-enroll: make the pairing QR carry a `device:enroll` credential (QR & device-enroll are disjoint today) | app + CP `pairing` | M | UX nicety; not required for correctness. |
|
||||
| B6 | Desktop (Electron) programmatic OS-keychain install | `desktop/src` | M | **Deferred** — Chromium only reads certs from the OS store; today assumes pre-install. |
|
||||
|
||||
**Deliverable B:** phone → login once → connects with an auto-issued, hardware-bound, auto-renewing cert.
|
||||
|
||||
---
|
||||
|
||||
## 6. Deliberately deferred (layer on later, no rework)
|
||||
|
||||
Attestation verifiers (Apple App Attest / Android Key-Attestation) — mandatory only before a 2nd
|
||||
distrusting tenant · signed X.509 **CRL** + VPS reload webhook (MVP relies on 24h passive revocation) ·
|
||||
**B2** per-tenant name-constrained intermediates (gate for a 2nd customer) · RFC 8628 device-grant
|
||||
(approve-in-app) wrapping the same `/enroll` · Windows service · agent autoupdate · legacy `.p12`
|
||||
dual-trust migration window.
|
||||
|
||||
---
|
||||
|
||||
## 7. Suggested order & milestones
|
||||
|
||||
1. **Pre-flight §3** (esp. §3.1 CA material) — a spike; decides whether Track A is "deploy" or "also build a signer."
|
||||
2. **Track A (A1–A8)** → **M-Host**: fresh Mac, one command, reboot-durable, self-renewing. *Ships the host half of the requirement.*
|
||||
3. **B1 (login/bearer)** → unblocks everything phone-side.
|
||||
4. **B3 (iOS)** → **M-Device-iOS**: iPhone auto-enrolls, no `.p12`.
|
||||
5. **B4 (Android)** → **M-Device-Android**.
|
||||
6. Later: attestation → CRL → B2 (per-tenant) = gate for a 2nd distrusting customer.
|
||||
|
||||
## 8. Top risks
|
||||
|
||||
- **[R1] Production native-CA wiring in the new CP (§3.1)** — **RE-ASSESSED 2026-07-18: a real (small–medium)
|
||||
CODE task, the hard prerequisite for the VPS deploy.** The P-256 CA keys + certs exist on the VPS
|
||||
(`/etc/relay/{device-ca,frp-client-ca}/*.key.pem`), BUT: (1) `env.ts` does **not** define
|
||||
`NATIVE_FRP_CLIENT_CA_*` / `NATIVE_DEVICE_CA_*` (native-ca.ts:16 flags this as the "concrete follow-up");
|
||||
(2) `buildNativeCas` exists but is **never called from `server.ts`** — the prod boot doesn't wire the
|
||||
native CAs at all (the 246 tests inject dev `inProcessP256CaSigner`s); (3) production wants a KMS resolver
|
||||
and there is no file-backed signer to load the on-disk PEM keys. **Task A2-prep (do before deploy):** add
|
||||
the `NATIVE_*` env vars + a file-backed `KmsResolver` (load PEM P-256 key → sign) + call `buildNativeCas`
|
||||
in `server.ts` and thread the CAs into the enroll services, TDD + reviewed. File-key custody (not KMS) is
|
||||
acceptable for the single-owner VPS (mirrors the existing `CA_INTERMEDIATE_KEY_PATH` file fallback);
|
||||
document the KMS upgrade as future.
|
||||
- **[R2] frps control-channel CA mismatch** — must be `frp-client-CA`, not `device-CA`; crossed = silent
|
||||
1006/handshake failure (this class of bug already cost days in M1).
|
||||
- **[R3] nginx dNSName→Host binding** — the load-bearing isolation control; needs positive **and**
|
||||
negative tests (leaf-A must be refused for subdomain-B) even in MVP.
|
||||
- **[R4] Phone login trust seam (B1)** — the whole phone track hinges on a credential mechanism; a weak
|
||||
seam here is a security hole (it mints certs). Scope it deliberately even for single-operator.
|
||||
- **[R5] BIND_HOST loopback** — mandatory on every host; `0.0.0.0` re-opens an unauth shell on the LAN
|
||||
bypassing mTLS. The agent enforces it (S-GATE) — keep it enforced.
|
||||
|
||||
---
|
||||
_Grounded against `develop` + VPS `8.138.1.192` as of 2026-07-18. Design source: `docs/PLAN_TUNNEL_AUTOMATION.md`._
|
||||
@@ -24,6 +24,40 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### 🗺️ ROADMAP 落地 — Wave 1-4 八个功能(2026-07-12,当前活跃;多 agent 并行计划 + 逐个 builder 实施)
|
||||
- **产物**: [docs/ROADMAP.md](./ROADMAP.md)(分层 backlog)+ [docs/plans/](./plans/)(8 份可直接照写的实施计划,并行生成)。全在 `develop`。
|
||||
- **编排**: 主线洞察"产品把一切都**采集**了却**没拿去行动**",两个原语(PTY-inject、审批预览)解锁一片。流程 = **并行生成 8 份计划**(1 个 workflow / 8 agent)→ **逐个功能派 builder 在主工作树实现**(一次一个,避免踩 server.ts 等共享文件)→ **orchestrator 独立复验**(typecheck 两 config + full suite + build:web + 安全审)→ 提交 → 下一个。
|
||||
- **[x] 八个全绿(独立复验,非仅采信 builder)**,每个单独 commit:
|
||||
1. **W1 可点链接/文件路径** `debf47d` — `public/link-paths.ts` 纯 matcher + xterm link provider;URL scheme 白名单 + noopener;附加 `openFileInEditor`(`--goto file:line`,现有路由只能开目录)。
|
||||
2. **W1 审批预览** `e062065` — 手机上 Approve 前先看到 Bash 命令/Edit diff。`src/http/approval-preview.ts` 有界 sanitize(40 行/200 字/4KB),走 gate 同款晚加入者重发;渲染只走 textContent/renderDiffFile。
|
||||
3. **W2 PTY-inject + idle 队列** `3076843` — 地基原语。`POST /live-sessions/:id/queue`(Origin+限流+SESSION_ID_RE),idle 时 drain 一条(去抖 timer + pop-one + settle 复检 三重防重复),注入复用 writeInput 字节原样进 PTY。
|
||||
4. **W3 diff-vs-base** `b119c31` — `?base=<rev>` 审整条分支。三层防选项注入:isPlausibleRev → `rev-parse --verify --end-of-options` → 只有解析出的 sha 进 `git diff <sha>... --`。
|
||||
5. **W3 PR/CI chip** `7551f8a` — `src/http/gh.ts` 单次 `gh pr view --json`;缺 gh/未登录/无 PR 全降级不抛;PR title 走 textContent。
|
||||
6. **W3 quick wins** `1dd12b0` — 项目卡 ahead/behind + 最近提交时间;成本预算告警(`COST_BUDGET_USD` 单次 latch + push);`/digest` 重连摘要;`/projects/log` 最近提交。
|
||||
7. **W4 worktree 删除/prune** `552f35c` — 破坏性,护栏:必须在 `git worktree list`(realpath 匹配)、拒主 worktree、容器内、脏树要 force、locked 拒、错误归类、`git worktree remove` 不用 rm -rf。
|
||||
8. **W4 stage/commit/push** `19f241d` — 手机审完直接落地。MVP 只 stage/commit/push 当前分支(砍 discard/checkout);push 的 remote+branch 从 repo 读、绝不 `--force`/`+refspec`;三路由 Origin+GIT_OPS_ENABLED+限流;路径 realpath 容器内 + `--` 后作 argv;错误归类不泄露。
|
||||
- **已知测试抖动(非回归)**: 两个真-PTY/tmux 集成测试(`ring buffer` 重放、`H1 tmux`)在沙箱满负载下撞默认 5s / 自身 20s 超时;**单独跑或 `--test-timeout=30000` 全绿**(full suite @30s = **2005/2006**,唯一 red 是 H1 tmux 撞自身上限)。逻辑无回归。
|
||||
|
||||
- **未 push**: 全部本地 `develop`,领先 origin/develop 一批。
|
||||
|
||||
### 🖥️ SPLIT-GRID 看板 — 桌面多 session 分屏(2026-07-11)
|
||||
- **需求**: web/Mac 大屏、开多个 tab 时,把 `#term` 大窗切成 1×2 / 2×2 宫格,多个 **live 可交互**终端同屏,方便"vibe coding"时盯多个 Claude session。手机不做(<1024px 强制 single)。
|
||||
- **分支**: `feat/split-grid-view`(自 `feat/tunnel-automation`)。**用户决策(AskUserQuestion)**:全部阶段(v1→v2→v3)用多 agent + loop 完成;审批用**每格内联 ✓/✗**;成员=**前 N 个 tab(拖拽换序控制)**;布局=**single + 1×2 + 2×2**。
|
||||
- **编排**: orchestrator 亲写互锁的 5 文件(并行 builder 会互相踩),每阶段后**并行对抗式 review workflow**(4 lens → 逐条 verify)→ 修 confirmed → 复验绿 → commit → loop 下一阶段。
|
||||
- **[x] v1 DONE(2026-07-11)** — orchestrator 独立复验全绿:`npm run typecheck`(前后端两 config)干净、`build:web` 干净、**全套 1566 测试通过**(+33 新增),覆盖率 grid-layout.ts 95%/tabs.ts 94%(≥80 门槛)。
|
||||
- **核心设计**: `activeIndex` 语义**不变**=聚焦格(keybar/voice/approval 全部零改动);新增 `gridLayout` + 派生 `visibleIndices`;每个 pane 包进 `.term-cell`(header + 终端 + 可选内联审批 footer);`applyLayout()` 独占 pane 显隐/grid class/cell 排序/占位符;`#term` 变 CSS grid。**服务端/WS 协议零改动**。
|
||||
- **文件**: 新 `public/grid-layout.ts`(纯逻辑 + matchMedia 门 + 持久化 + toolbar 分段控件)、`tabs.ts`(applyLayout/renderCell/renderInlineApprove/setFocused/setGridLayout/refitVisible + `activate()` 改为**board-aware**)、`terminal-session.ts`(`show({focus})` 防 4 格抢焦点 + `onFocus` 回调)、`main.ts`(挂 toggle + refitVisible)、`style.css`(cell/grid/焦点环/pending 脉冲/内联审批/占位符/toggle + `.term-pane` 改 position:relative flex 子)。新测 `test/grid-layout.test.ts` + `tabs.test.ts` 追加 split-grid 块。
|
||||
- **交叉验证抓修 3 真缺陷**:**HIGH** — `activate()` 原先非 board-aware:满格(4/4)时点 "+" 会让新 tab 成为 activeIndex 但 `display:none`,用户对着看不见的 session 打字(空最常见路径触发)。修:把 off-board→moveTab 上板逻辑折进 `activate()`,`setFocused`/`setGridLayout` 委托之。**LOW** — 通知抑制未算 `homeForced`(⌂ 覆盖时 pane 实际不在屏);修:`onScreen = !homeForced && isVisible`。**LOW** — toggle 触控尺寸(coarse-pointer 平板);修:`@media (pointer:coarse)` 加大。全部补了回归测试。
|
||||
- **[x] v2 DONE(2026-07-11)** — orchestrator 独立复验全绿:typecheck 两 config 干净、build:web 干净、**全套 1579 测试**(+13),覆盖率 grid-layout 95%/tabs 93%。加了:**1×3(row-3)/2×3(grid-6)布局**、**Ctrl+`/Ctrl+Shift+` 循环焦点**(main.ts capture keydown → cycleFocus,单格模式不吞键)、**每格最大化 ⛶**(`.maximized` 覆盖层)、**拖 tab 到格**(wireCellDropTarget 复用 dragIndex)。用单个 `grid` 标记类承载共享 cell 样式(不再枚举每个 lay-*)。
|
||||
- **交叉验证抓修 3 真缺陷**:**HIGH** — 最大化原用 `grid-column/row: 1/-1` **span 网格**是错的:CSS Grid 把兄弟格挤进隐式行→"最大化"格变成细条(非全屏)+ 兄弟格 box 变化触发 ResizeObserver→**给后台 live PTY 发错误 resize(伪 SIGWINCH)**。评审在**真 headless Chrome 里实测复现**。修:`.maximized` 改 `position:absolute; inset:0; z-index:4` 脱离网格流做覆盖层。**独立 Playwright 复验**:最大化格填满 #term(1000×700)、兄弟格尺寸 0px 变化,对照组证实旧规则确会 891×572 细条+兄弟塌成 52px。**MED** — 最大化时被盖住的 pending 格 amber 脉冲外溢;修:`(!maximized || focused)` 门。**LOW** — `.cell-max` 缺 coarse-pointer 触控尺寸。均补回归测试;并把 Ctrl+` 键匹配抽成 `matchFocusCycleKey` 纯函数单测。
|
||||
- **[x] v3 DONE(2026-07-11)** — 三个特性,orchestrator 独立复验全绿(typecheck 两 config + build:web 干净、**全套 1615 测试**、全局覆盖率 89% stmts/82% branch 过 80 门槛)。分三 commit:
|
||||
- **v3a 只读 monitor 格(cd97114)**:每格 👁 切换 live↔read-only。`public/cell-monitor.ts` 轮询 `GET /live-sessions/:id/preview` 写只读 xterm,**不 attach WS、不发 resize**,故不驱动共享 PTY 尺寸——解决"小格盯 session 会缩掉别的设备全屏"的跨设备 shrink。monitor 时 live pane 保持 hidden;toggle-off/关 tab/离开 grid 皆清理。
|
||||
- **v3b 可拖拽分隔条(007e598)**:格间 gutter 拖拽调每布局 col/row 的 fr 比例(`adjustSplit` 纯函数:相邻轨道互让、clamp 0.3、守恒),内联 grid-template + 持久化 `web-terminal:grid-splits`;拖拽中 reposition 不重建 handle。
|
||||
- **v3c 布局预设(007e598)**:`public/grid-presets.ts` toolbar 下拉,存/应用/删命名的 布局+split(`web-terminal:grid-presets`);外点/Esc 关、同名替换、XSS-safe(textContent)。
|
||||
- **交叉验证抓修 4 类真缺陷(6 confirmed,2 dup)**:**HIGH** — `splitForLayout({'grid-4':null})` 抛 TypeError(`null!==undefined` 过守卫后读 `null.cols`),每次渲染都炸→整个 tab UI 砖掉;修:守卫加 `!==null && typeof==='object'`。**MED** — monitor 在 attach 前(id=null)切换:按钮显 active 但格仍 live 且发 resize,且不自愈;修:`onSessionId` 到达时 reconcile(re-render 使 startMonitor 生效)。**MED** — `renderGutters` 拖拽中被并发 applyLayout 销毁重建→丢拖拽监听;修:`draggingGutter` 标志跳过重建。**LOW** — grid-presets 硬编码 1024→改用 `GRID_MIN_WIDTH`。均补回归测试(含 null-split 不抛、reconnect-reconcile、拖拽中重渲染 handle 存活)。
|
||||
- **✅ 全部阶段(v1+v2+v3)完成**:5 commit(06814ba/5475b66/cd97114/007e598 + fix)。每阶段 = 实现→typecheck/test/build 绿→**并行多 lens 对抗式 review workflow + 逐条 verify**→修 confirmed→复验→commit。maximize 的 HIGH 几何缺陷经**真 headless Chrome(Playwright)实测**证实修复。服务端/WS 协议零改动;单格模式行为不变。
|
||||
- **探索产物**: 交互式原型 artifact(single/1×2/2×2 切换、点击移焦、内联审批)已给用户看过并据此拍板方向。
|
||||
|
||||
### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08)
|
||||
- **计划**: [PLAN_TUNNEL_AUTOMATION.md](./PLAN_TUNNEL_AUTOMATION.md)(design-locked)。目标:host 一条命令 onboard、device 登录一次即在硬件里生成不可导出密钥→CSR→拿证书,**无 .p12/AirDrop,私钥永不离设备**。三轨:A 控制面/PKI、B host agent、C 原生客户端(iOS/Android/desktop)。基础设施(frps/device-CA/frp-client-CA/nginx:8470 mTLS)已在 VPS M1 上线;本工作流建的是**自动化**(签发端点、njs cert→Host 绑定、硬件 keygen、host onboard)。
|
||||
- **分支**: `feat/tunnel-automation`(自 develop)。走 **MVP fast-path §7**(10 个 tracked task,见任务表 #1–#10),依赖图强制:A1 crypto → A2 契约 → A3 nginx 绑定为顺序地基,之后按职责(backend/host/iOS)扇出。
|
||||
|
||||
160
docs/ROADMAP.md
Normal file
160
docs/ROADMAP.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Roadmap
|
||||
|
||||
> **Status (2026-07-13):** ALL of Wave 1-5 (11 items) implemented + committed on `develop` — see docs/PROGRESS_LOG.md for per-feature commits. Remaining: only the deferred backlog table (smaller, several now cheap on the shipped PTY-inject + approval-preview primitives).
|
||||
|
||||
Prioritized backlog of features to build next, derived from a grounded analysis of
|
||||
the codebase + the existing planning docs (5 exploration lenses → 24 candidates →
|
||||
this synthesis). Each item keeps **what it touches** (real files/subsystems) and a
|
||||
rough **effort** so it's actionable, not aspirational.
|
||||
|
||||
**The throughline:** the product already *captures* everything — Claude Code hooks,
|
||||
statusLine telemetry, the activity timeline, git diff, live-sessions — but under-*acts*
|
||||
on it. The highest-leverage work turns passive capture into a **trustworthy remote
|
||||
review-and-drive surface**, without touching the byte-shuttle. Two small primitives
|
||||
unlock a whole family of features: a **server-side PTY-inject** call and a **preview on
|
||||
the approval bar**.
|
||||
|
||||
Effort key: **S** ≈ 1–2 days · **M** ≈ 3–4 days · **L** ≈ 1–2 weeks. Order = suggested
|
||||
build sequence (dependencies noted).
|
||||
|
||||
---
|
||||
|
||||
## Recently shipped (context)
|
||||
- **Split-grid watch board (v0.8, desktop)** — 1×2/1×3/2×2/2×3 layouts, click-to-focus,
|
||||
per-quadrant inline approve / maximize / read-only monitor, drag-to-quadrant,
|
||||
resizable splitters, saved presets. (`public/grid-layout.ts`, `grid-presets.ts`,
|
||||
`cell-monitor.ts`, `tabs.ts`.)
|
||||
- **Fixed:** browser worktree-create was 400ing (frontend sent `repoPath`, server reads
|
||||
`path`); now aligned + regression-tested (`public/projects.ts`, `test/worktree-form.test.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — ship first (high-trust / high-delight, independent)
|
||||
|
||||
- [x] **Approval preview — see-what-you-approve** ⭐ _(strongest)_
|
||||
Show the pending `Bash` command or `Edit`/`Write` diff *above* Approve/Reject, so remote
|
||||
one-tap approval stops being blind (today you could tap Approve on `rm -rf build`).
|
||||
*The core walk-away trust gap.*
|
||||
**Touches:** `src/http/hook.ts` (`tool_input` is already parsed at `:104` — derive a
|
||||
bounded preview for Bash/Edit/Write/MultiEdit) → `/hook/permission` + `pendingApprovals`
|
||||
in `src/server.ts:423` (attach + re-send to late joiners, like `gate` already does) →
|
||||
optional bounded `preview` on the `status` ServerMessage in `src/types.ts` → `manager`
|
||||
→ approval bar in `public/tabs.ts` (reuse `public/diff.ts` renderer + `sanitizeField`).
|
||||
Pure side-channel. **Effort: M.** Risk: truncate + strip control chars + cap bytes,
|
||||
`textContent`/diff-render only, unknown tools fall back to today's name-only bar.
|
||||
|
||||
- [x] **Clickable URLs & file paths in the terminal**
|
||||
Tap the dev-server URL or file path Claude prints instead of soft-keyboard copy gymnastics
|
||||
(paths reuse `POST /open-in-editor`). TECH_DOC named `@xterm/addon-web-links` in v0.2; never built.
|
||||
**Touches:** frontend only — `public/terminal-session.ts` (add the addon + a path-matcher
|
||||
regex → `/open-in-editor`). Zero server change. **Effort: S.**
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — the unlocking primitive
|
||||
|
||||
- [x] **Server-side PTY-inject + queued follow-up prompt** _(unlocks a family)_
|
||||
A thin Origin/loopback-guarded route that writes text into a session's PTY, plus a
|
||||
per-session queue that fires one entry when Claude goes idle ("now run the tests", then
|
||||
"now open a PR"). Walk-away = give a task and leave; queuing lets the session advance itself.
|
||||
**Touches:** new `POST /live-sessions/:id/queue` calling the existing `writeInput`
|
||||
(`src/session/session.ts:201`); a small queue in `manager.ts`; dequeue in the existing
|
||||
Stop/SessionEnd hook branch (`src/server.ts:414`); FE near `public/quick-reply.ts`.
|
||||
Injection is identical to a keystroke → byte-shuttle preserved, broadcasts to mirrors.
|
||||
**Effort: M.** Risk: gate firing on `claudeStatus==='idle'` + settle delay; surface the
|
||||
queue in UI. **Foundation for** templated launches, auto-continue, issue-intake (backlog).
|
||||
|
||||
---
|
||||
|
||||
## Wave 3 — read-only side-channel batch (review from the phone)
|
||||
|
||||
- [x] **Diff against a base branch** (`?base=<rev>`)
|
||||
Review a whole agent branch vs `main` before landing, not just uncommitted changes.
|
||||
`src/http/diff.ts` already deferred this (FR-B1.9) and named its mitigation:
|
||||
`git rev-parse --verify` allow-list before any revision reaches the CLI.
|
||||
**Touches:** optional `base` on `getDiff()` (guarded `git rev-parse --verify <base>` +
|
||||
trailing `--`, then `git diff <base>...`); branch-picker in `public/diff.ts`. **Effort: S/M.**
|
||||
|
||||
- [x] **PR + CI/checks status via `gh`** (read-only)
|
||||
Per-project/session chip: PR state · checks passing · mergeable — glance from the phone,
|
||||
re-engage only when red. No `gh` usage exists in `src/` yet; `gh` emits JSON (no parsing pain).
|
||||
**Touches:** new `src/http/gh.ts` (mirror `diff.ts`'s `runGit`); `GET /projects/pr?path=`
|
||||
guarded by `isValidGitDir`; `PrStatus` in `src/types.ts`; FE chip in project detail.
|
||||
Capability-probe + empty-degrade when `gh` absent/unauthed. **Effort: M.**
|
||||
|
||||
- [x] **Quick wins** (small, cheap, high-delight)
|
||||
- [ ] **Sync chip on project cards** — ahead/behind + last-commit, folded into the existing
|
||||
per-repo metadata pass in `src/http/projects.ts` (no new route). **S.**
|
||||
- [ ] **Cost budget guard + push alert** — `costUsd` already flows via statusLine
|
||||
(`handleStatusLine`); add `COST_BUDGET_USD` + a one-shot latch (like `stuckNotified`) +
|
||||
warn styling in `public/preview-grid.ts`. The rail that makes unattended auto-continue safe. **S–M.**
|
||||
- [ ] **"While you were away" reconnect digest** — read-side aggregate over `manager.list()`
|
||||
+ telemetry/timeline/status → "3 done, 1 waiting, $6, 2 PRs" on reconnect. **S–M.**
|
||||
- [ ] **Recent-commits log per project** — `git log --oneline -n N` (NUL-delimited) via a
|
||||
guarded `GET /projects/log`; inert-text render. **S–M.**
|
||||
|
||||
---
|
||||
|
||||
## Wave 4 — close the git loop
|
||||
|
||||
- [x] **Worktree lifecycle: remove / prune** (create is now fixed)
|
||||
Delete losing worktrees + land the winner from any device — closes the create-only loop.
|
||||
**Touches:** `removeWorktree`/`pruneWorktrees` in `src/http/worktrees.ts` (same execFile
|
||||
no-shell + timeout, validate target in `git worktree list` & not main, reuse realpath
|
||||
containment); `DELETE /projects/worktree` + `POST /projects/worktree/prune` (Origin-guarded);
|
||||
make the existing `locked`/`prunable` tags actionable. **Effort: S–M.** Risk: destructive —
|
||||
require `--force` + confirm for dirty trees, reject the main worktree, safe error messages.
|
||||
|
||||
- [x] **Stage / commit / push from the diff viewer**
|
||||
Claude's done, you reviewed on the phone — now commit + push without typing git into a
|
||||
mobile terminal. Highest-risk git write; bound the MVP to per-file stage-toggle + commit +
|
||||
push-current-branch only; **defer discard/checkout**; realpath-contain paths, cap msg length,
|
||||
push only to existing upstream or `-u`, CSRF-guarded. **Effort: M–L.**
|
||||
|
||||
---
|
||||
|
||||
## Wave 5 — bigger bets
|
||||
|
||||
- [x] **Worktree fan-out board** _(the north star: 真并行不互踩)_
|
||||
Fan one task across N branch/agent lanes of one repo, watch them race, approve/kill per lane,
|
||||
keep the winner. **Mostly composition of shipped parts** — `createWorktree` + live-sessions +
|
||||
the split-grid watch board + statusLine gauges + per-quadrant inline approve; server side is a
|
||||
thin "sessions grouped by repo/worktree" endpoint. Cost is UI. **Effort: L.** Depends on Wave 4.
|
||||
|
||||
- [x] **App-level access token** (leave-the-LAN bar-raiser) — shipped on `develop`
|
||||
A constant-time-compared (`crypto.timingSafeEqual` over SHA-256, fixed-length guard)
|
||||
`WEBTERM_TOKEN` checked on the WS handshake (alongside, not replacing, Origin) + a central
|
||||
gate over every remote HTTP route, set as an `HttpOnly; SameSite=Strict; Secure-when-https`
|
||||
cookie after one-time `GET /?token=` or `POST /auth` (rate-limited 10/min/IP), disabled when
|
||||
unset (keeps LAN zero-config byte-identical). Charset/length-validated at load; loopback
|
||||
`/hook*` ingest exempt. `src/http/auth.ts` + wiring in `src/server.ts`.
|
||||
**Honest tradeoff:** a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the
|
||||
token is cleartext and replayable; only hardens the TLS-terminated relay/tunnel path. **Effort: M.**
|
||||
|
||||
- [x] **Android Projects / Diff / Worktree screens** (client parity)
|
||||
Android's whole v0.6/v0.7 projects-git UI is SDK-gated/off in `settings.gradle.kts`.
|
||||
Zero server change (the `:api-client` module already speaks the endpoints), but the largest
|
||||
scope. Sequence *after* the server-side git features so it's one parity pass. **Effort: L.**
|
||||
|
||||
---
|
||||
|
||||
## Deferred backlog (your own recorded intent, surfaced from the docs)
|
||||
|
||||
Consciously punted in the planning docs; several become cheap once the two unlocking primitives
|
||||
(PTY-inject, approval-preview) land.
|
||||
|
||||
| Feature | Deferred in | Note |
|
||||
|---|---|---|
|
||||
| Line-level review comments → agent | `FEATURE_WALKAWAY_WORKBENCH.md §B1.6 / US-B1.4` | Tap a diff line, type feedback, composed with `file:line` and sent via `TerminalSession.send`. No new route. Pairs with diff-vs-base. **M.** |
|
||||
| GitHub issue → new session/worktree | `FEATURE_PROJECT_MANAGER.md §9` | Pick an issue → spawn a session (optionally fresh worktree) with title+body as the prompt. Rides PTY-inject + host `gh`. Treat issue text as untrusted bytes. **M.** |
|
||||
| Templated one-tap launches (repo + prompt + mode) | implied by `attach.cwd` + Projects launchers | "Triage repo X in plan mode" as one tap. Rides PTY-inject + a template store. **M.** |
|
||||
| Auto-continue on idle (bounded) | no idle automation today | Opt-in auto-inject "continue" up to N times under a cost ceiling. Rides PTY-inject + budget guard. Build last, low default cap, never auto-approve. **S–M.** |
|
||||
| Cross-session cost rollup / trends | `FEATURE_WALKAWAY_WORKBENCH.md §B2.6` | Bounded ring beside the latest-only field + `GET /telemetry/summary` + dashboard panel. Overlaps the budget-guard quick win. **M.** |
|
||||
| Mission Control: cross-session feed + durable run log | (no cockpit persistence today) | Merged reverse-chron feed + append-only on-disk tail (reuse `subscription-store.ts` JSONL/atomic write, byte-capped) so overnight runs survive restart. The "while you were away" digest is its first slice. **M–L.** |
|
||||
| Per-project task backlog | task tracking unbuilt | Per-repo TODO store mirroring `prefs-store.ts`; one tap spawns Claude pre-filled. Local state, **not** a GitHub Issues sync (YAGNI). **M.** |
|
||||
| Host tmux session discovery + attach | user request (this is not raw-iTerm; needs tmux) | We already run as a tmux client on the user's default tmux server (sessions `web_<id>`), so `tmux ls` already sees manual/iTerm tmux sessions. Add a discovery list + "attach external tmux session" entry in the launcher → spawn a client PTY `tmux attach -t <name>`, wrapped in the session model. **S–M.** |
|
||||
|
||||
---
|
||||
|
||||
_Kept in sync by the maintainer. Ideas are grounded against the code as of `develop` — verify
|
||||
file/line references before starting (they drift)._
|
||||
155
docs/plans/w1-approval-preview.md
Normal file
155
docs/plans/w1-approval-preview.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Approval preview — command/diff on the approval bar
|
||||
|
||||
## Summary & grounding
|
||||
|
||||
Today `/hook/permission` (`src/server.ts:423`) reads the hook body, extracts only `tool_name` (`server.ts:430`), derives a `gate` (`server.ts:454`), parks the held `res` in `pendingApprovals` (`server.ts:464`), and broadcasts a bare `waiting` status via `manager.handleHookEvent(sessionId, 'waiting', tool, true, gate)` (`server.ts:471`). The rich `tool_input` — which `parseHookEvent` already knows how to pass through verbatim (`src/http/hook.ts:104-105`) — is dropped on this route. The status `ServerMessage` (`src/types.ts:113-119`) has no field to carry a preview, so the approval bar in `public/tabs.ts:360-377` can only say *"Claude wants to use Bash"*.
|
||||
|
||||
This feature derives a **bounded, sanitized preview** from `tool_input` server-side, threads it through the same broadcast + late-joiner re-send paths the `gate` already uses, and renders it above the Approve/Reject buttons — reusing the render-only diff renderer (`public/diff.ts:139` `renderDiffFile`) and the `sanitizeField` pattern (`src/session/timeline.ts:50`).
|
||||
|
||||
Design decision: the derive logic lives in a **new pure module** `src/http/approval-preview.ts` (not inlined in `hook.ts`) so it is unit-testable in isolation and keeps `hook.ts` focused; `hook.ts` is cited only as the proof that `tool_input` is already available on the hook body. The `/hook/permission` route calls it directly (it does not go through `parseHookEvent`).
|
||||
|
||||
---
|
||||
|
||||
## Contract
|
||||
|
||||
### New message field — `src/types.ts` (coordination edit)
|
||||
|
||||
Add a new exported type and extend the `status` variant of `ServerMessage` (currently `src/types.ts:113-119`). Additive + optional → older clients ignore it; the frontend exhaustiveness check (`terminal-session.ts:349-355`) is unaffected.
|
||||
|
||||
```ts
|
||||
/** A5-adjacent: compact, BOUNDED preview of what a held approval will run.
|
||||
* Derived server-side from the hook tool_input, sanitized + byte-capped.
|
||||
* Discriminated on `kind`:
|
||||
* - 'command' → a shell command string (Bash). Newlines PRESERVED; all other
|
||||
* control/ANSI chars stripped. Rendered in a <pre> via textContent.
|
||||
* - 'diff' → ONE synthetic DiffFile (Edit/Write/MultiEdit/NotebookEdit),
|
||||
* rendered by public/diff.ts renderDiffFile (textContent-only, SEC-H4).
|
||||
* `truncated` = the source exceeded the line/byte cap and was clipped. */
|
||||
export type ApprovalPreview =
|
||||
| { kind: 'command'; text: string; truncated?: boolean }
|
||||
| { kind: 'diff'; file: DiffFile; truncated?: boolean };
|
||||
```
|
||||
|
||||
Extend the status variant:
|
||||
|
||||
```ts
|
||||
| {
|
||||
type: 'status';
|
||||
status: ClaudeStatus;
|
||||
detail?: string;
|
||||
pending?: boolean;
|
||||
gate?: PermissionGate;
|
||||
preview?: ApprovalPreview; // NEW — present only on a held (pending) waiting status
|
||||
}
|
||||
```
|
||||
|
||||
Extend `SessionManager.handleHookEvent` (currently `src/types.ts:331-339`) with a trailing optional param (appended last so the existing positional `/hook` call at `server.ts:412` is untouched):
|
||||
|
||||
```ts
|
||||
handleHookEvent(
|
||||
sessionId: string,
|
||||
status: ClaudeStatus,
|
||||
detail?: string,
|
||||
pending?: boolean,
|
||||
gate?: PermissionGate,
|
||||
eventClass?: string,
|
||||
toolName?: string,
|
||||
preview?: ApprovalPreview, // NEW
|
||||
): void;
|
||||
```
|
||||
|
||||
### New route behavior
|
||||
|
||||
No new route. `POST /hook/permission` (`server.ts:423`) gains: derive a preview from `body['tool_input']` + `tool`, store it on the `PendingApproval`, pass it into `handleHookEvent`. `GET`/other routes unchanged.
|
||||
|
||||
### New env vars — `src/config.ts`
|
||||
|
||||
**None.** The bounds are security limits, not user knobs (loosening them is a DoS/broadcast-bloat vector), so they are module constants in `approval-preview.ts`, not config. (Documented as a deliberate choice; if a knob is later wanted, `APPROVAL_PREVIEW_BYTES` slots into `Config` next to the existing `previewBytes` field.)
|
||||
|
||||
### Bounds (module constants in `src/http/approval-preview.ts`)
|
||||
|
||||
| Constant | Value | Purpose |
|
||||
|---|---|---|
|
||||
| `PREVIEW_MAX_LINES` | 40 | max diff/command lines emitted |
|
||||
| `PREVIEW_MAX_LINE_LEN` | 200 | per-line char cap (= `sanitizeField` default) |
|
||||
| `PREVIEW_MAX_BYTES` | 4096 | hard total-byte cap on the serialized preview payload |
|
||||
| `EDIT_TOOLS` | `Edit`,`Write`,`MultiEdit`,`NotebookEdit` | reuse the set semantics from `timeline.ts:17` |
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `src/types.ts` | **Coordination edit.** Add `ApprovalPreview` type; add `preview?` to the `status` `ServerMessage` variant (`:113-119`); add trailing `preview?` param to `SessionManager.handleHookEvent` (`:331-339`). |
|
||||
| `src/http/approval-preview.ts` | **New file (pure, no DOM, never throws).** `deriveApprovalPreview(toolName: string \| undefined, toolInput: unknown): ApprovalPreview \| null`. Bash→`{kind:'command'}`; Edit/Write/MultiEdit/NotebookEdit→`{kind:'diff', file}`; unknown tool / malformed input→`null`. Imports `sanitizeField` from `../session/timeline.js`; defines a `sanitizeLine` wrapper and per-line splitting so `\n`/`\t` survive but ANSI/control chars don't. |
|
||||
| `src/server.ts` | In `/hook/permission` (`:423`): after computing `tool` (`:430`), call `deriveApprovalPreview(tool, body['tool_input'])`. Add `preview?: ApprovalPreview` to the `PendingApproval` interface (`:225-231`); store it at creation (`:464`). Pass `preview` into `handleHookEvent(...)` (`:471`). In the late-joiner re-send (`:858-864`) include `preview: heldApproval.preview`. Import `deriveApprovalPreview` + `ApprovalPreview`. |
|
||||
| `src/session/manager.ts` | `handleHookEvent` (`:223-249`): accept trailing `preview?` param; set `msg.preview = preview` when defined, before `broadcast(session, msg)` (`:245-249`). (The Case-2 late-join re-send at `:142` stays bare — the server layer owns pending/preview re-send, per the comment at `:137-138`.) |
|
||||
| `public/terminal-session.ts` | Add `private pendingPreviewValue: ApprovalPreview \| null = null` near `:98-106`; getter `get pendingPreview()` near `:185-207`; in `case 'status'` (`:313-322`) set `this.pendingPreviewValue = nextPending ? (msg.preview ?? null) : null`; clear it in the two disconnect resets near `:385-389`. Import `ApprovalPreview` from `../src/types.js`. |
|
||||
| `public/tabs.ts` | In `updateApprovalBar` (`:360-377`): after the `label`, if `session.pendingPreview` is set, build and insert a preview node **before** the buttons. Add `private renderApprovalPreview(p: ApprovalPreview): HTMLElement` — `kind:'command'`→`<pre class="approval-cmd">` via `textContent`; `kind:'diff'`→`renderDiffFile(p.file)` wrapped in a scroll container; append a "… truncated" note when `p.truncated`. Import `renderDiffFile` from `./diff.js` and `ApprovalPreview` from `../src/types.js`. |
|
||||
| `public/style.css` | Add `.approval-preview` (scroll container: `max-height`, `overflow:auto`, `overflow-x:auto`), `.approval-cmd` (`white-space:pre-wrap`, monospace), `.approval-truncated` under the existing `#approvalbar` block (`:1084`). Reuse existing `.df-*` styling for the diff. |
|
||||
| `test/http/approval-preview.test.ts` | **New (node).** Unit tests for `deriveApprovalPreview`. |
|
||||
| `test/manager.test.ts` | Extend: `handleHookEvent` with a `preview` arg puts it on the broadcast status msg. |
|
||||
| `test/terminal-session.test.ts` | Extend (jsdom): status frame with `preview` sets `pendingPreview`; cleared when `pending` false / on disconnect. |
|
||||
| `test/tabs.test.ts` | Extend (jsdom): `FakeTerminalSession` gains `pendingPreview`; `updateApprovalBar` renders command / diff / truncated note; no-preview → name-only bar (regression). |
|
||||
| `test/integration/server.test.ts` | Extend: `POST /hook/permission` with `tool_input` → broadcast `waiting` status carries `preview`; a late-joining WS gets the preview on attach. |
|
||||
|
||||
---
|
||||
|
||||
## TDD steps (ordered)
|
||||
|
||||
Repo style: pure/back-end tests are plain vitest (node) — see `test/hook.test.ts` (`import { describe, it, expect }`, `expect.objectContaining`). Front-end tests carry `// @vitest-environment jsdom` and mock `TerminalSession` (`test/tabs.test.ts`) or mock `WebSocket` + stub xterm (`test/terminal-session.test.ts`). Diff render tests run in jsdom asserting `textContent` (`test/diff.test.ts`).
|
||||
|
||||
1. **RED — `test/http/approval-preview.test.ts` (node).** Write, before any impl:
|
||||
- Bash: `deriveApprovalPreview('Bash', { command: 'ls -la', description: 'x' })` → `{ kind:'command', text:'ls -la' }`.
|
||||
- Bash multi-line: `command:'a\nb'` → `text` still contains the `\n` (newlines preserved).
|
||||
- Bash with ANSI/control injection: `command:'\x1b[31mrm\x1b[0m\x07'` → ESC/BEL stripped, no `\x1b`/`\x07` in `text`.
|
||||
- Edit: `{ file_path:'/p/f.ts', old_string:'a\nb', new_string:'c' }` → `{ kind:'diff', file }` where `file.newPath` sanitized, hunk has `removed` lines `a`,`b` and `added` line `c`, `file.removed===2`, `file.added===1`.
|
||||
- Write: `{ file_path, content:'x\ny' }` → all-added hunk, `removed===0`.
|
||||
- MultiEdit: `{ file_path, edits:[{old_string,new_string},{...}] }` → one hunk per edit.
|
||||
- Truncation: `content` with >`PREVIEW_MAX_LINES` lines → `truncated:true`, ≤ cap lines emitted; a >`PREVIEW_MAX_BYTES` blob → `truncated:true` and serialized size ≤ cap.
|
||||
- Unknown tool (`'WebFetch'`) → `null`; missing/`null`/array/number `toolInput` → `null` and **never throws** (mirrors `hook.ts` SEC-M7 style).
|
||||
2. **GREEN — implement `src/http/approval-preview.ts`.** Pure, `unknown`-narrowed, `sanitizeField`-per-line, byte-clamped. Run the suite to green.
|
||||
3. **RED — `test/manager.test.ts`.** Add: calling `handleHookEvent(id,'waiting','Bash',true,'tool',undefined,undefined,{kind:'command',text:'ls'})` broadcasts a status msg whose `preview` deep-equals the arg (assert via the fake WS `send` capture already used in this file). Add: omitting `preview` → no `preview` key on the msg.
|
||||
4. **GREEN — `src/types.ts` param + `src/session/manager.ts`.** Add the trailing param and `msg.preview` assignment.
|
||||
5. **RED — `test/integration/server.test.ts`.** Add a test (follow the `itPty` pattern of ⑧ at `:608-655`): attach a WS, `POST /hook/permission` with `{ tool_name:'Bash', tool_input:{ command:'echo hi' } }`, assert the broadcast `pending===true` status has `preview.kind==='command'` and `preview.text` containing `echo hi`. Add a **late-joiner** assertion: open a 2nd WS to the same `sessionId` while held → its first `waiting/pending` status includes `preview`.
|
||||
6. **GREEN — `src/server.ts`.** Wire `deriveApprovalPreview` into `/hook/permission`, store on `PendingApproval`, pass to `handleHookEvent`, include in the `:858-864` re-send.
|
||||
7. **RED — `test/terminal-session.test.ts` (jsdom).** Feed a `status` frame with `pending:true, gate:'tool', preview:{kind:'command',text:'ls'}` → `session.pendingPreview` set; a follow-up `status` with `pending:false` clears it to `null`; disconnect clears it.
|
||||
8. **GREEN — `public/terminal-session.ts`.** Add field, getter, set/clear.
|
||||
9. **RED — `test/tabs.test.ts` (jsdom).** Extend `FakeTerminalSession` with `pendingPreview`. Assert `updateApprovalBar`: command preview → a `.approval-cmd` node whose `textContent` equals the command; diff preview → a `.df-file` present (renderDiffFile output); `truncated:true` → `.approval-truncated` present; `pendingPreview:null` → bar shows label + buttons only (regression, matches `:373-374`). Assert **zero `innerHTML`** — content via `textContent` only.
|
||||
10. **GREEN — `public/tabs.ts` + `public/style.css`.** Add `renderApprovalPreview`, insert before buttons, style the container.
|
||||
11. **Coverage gate.** The new pure module is branch-dense and fully unit-covered (helps the 80% gate); FE branches covered by jsdom tests. Run full `npm test` + coverage; confirm no regression in `hook.test.ts`/`diff.test.ts`/`manager.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
- **Unknown / non-preview tool** (WebSearch, Task, MCP tools, `ExitPlanMode`): `deriveApprovalPreview` returns `null` → status carries no `preview` → `updateApprovalBar` falls back to today's name-only bar (`:373`). Plan-gate (`gate:'plan'`, `:369-371`) also gets no preview (ExitPlanMode has no reviewable command/diff) — unchanged.
|
||||
- **Missing/partial `tool_input`**: Bash without `command`, Edit without `old_string`, `tool_input` present but `null` (the `'tool_input' in b` passthrough at `hook.ts:104` can yield `null`) → return `null`, never throw.
|
||||
- **Huge command / whole-file Write**: clipped at `PREVIEW_MAX_LINES` then `PREVIEW_MAX_BYTES`; `truncated:true` shows the "… truncated" note. Prevents a multi-MB status frame from being broadcast to every client and retained in `pendingApprovals`.
|
||||
- **Newlines vs control chars**: `sanitizeField` (`timeline.ts:50`) strips `\x00-\x1f` which includes `\n`/`\t`/`\r` — so it is applied **per line after splitting**, never to the whole multi-line blob, preserving structure while still killing ANSI ESC (`\x1b`) and BEL.
|
||||
- **MultiEdit with many edits**: hunks accumulate until the line/byte cap, then stop + `truncated:true`.
|
||||
- **Late joiner after approval already resolved**: `pendingApprovals.get()` returns `undefined` (`server.ts:858`) → no preview re-sent (correct; nothing is held).
|
||||
- **Approve/reject clears preview**: `handleHookEvent(...,'working',...,false)` (`server.ts:887,890`) broadcasts a non-pending status → `terminal-session` sets `pendingPreview = null` → bar hides (existing `:362` guard).
|
||||
- **Multi-device**: preview broadcasts to all clients via `broadcast` and re-sends to each new attach — every mirror shows the same preview.
|
||||
- **Binary/odd content in Write**: rendered as literal text via `textContent` (no interpretation), `binary:false` on the synthetic file (we don't attempt binary detection — out of scope).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **Trust boundary**: `tool_input` is Claude-controlled content arriving on the loopback-only `/hook/permission` route (`isLoopback` guard at `server.ts:424`). It is untrusted for *content*: treat as `unknown`, narrow every field (`typeof x === 'string'`), never index without a guard. `deriveApprovalPreview` must **never throw** (SEC-M7 discipline, mirroring `parseHookEvent`).
|
||||
- **Sanitization (SEC-H6 reuse)**: every emitted string passes through `sanitizeField`/`sanitizeLine` — strips `\x00-\x1f` (incl. ANSI ESC `\x1b`), truncates to `PREVIEW_MAX_LINE_LEN`. This neutralizes terminal-escape / cursor-hijack payloads in a filename or command before they reach the DOM.
|
||||
- **XSS (SEC-H4 reuse)**: the frontend renders **only** via `textContent` / `el()` / `renderDiffFile` (which is already innerHTML-free — `diff.ts:9`, `:191-194`). No `innerHTML` anywhere in the new FE code; asserted in tests. `<script>`, `&`, `<img onerror>` appear as literal characters.
|
||||
- **DoS / resource containment**: `PREVIEW_MAX_LINES` + `PREVIEW_MAX_BYTES` cap the payload that is (a) broadcast to N clients and (b) retained in `pendingApprovals` for the held-decision lifetime. No unbounded growth from a hostile/huge `tool_input`.
|
||||
- **No new route, no new capability token**: the existing per-decision token (`server.ts:457`), Origin/loopback guards, and rate limiters are untouched. Preview is pure display data attached to an already-authorized held decision.
|
||||
- **No path egress / no argv**: file paths from `tool_input` are only sanitized + displayed as text; they are never passed to `execFile`, `fs`, or a shell. No path-traversal surface is added.
|
||||
|
||||
---
|
||||
|
||||
## Effort & dependencies
|
||||
|
||||
- **Effort**: ~1.5–2 days. Breakdown: pure `approval-preview.ts` + its tests ~0.5d (the bulk of logic + coverage); server/manager/types threading ~0.25d; FE (terminal-session field + tabs render + CSS) + jsdom tests ~0.5d; integration test + polish ~0.25d.
|
||||
- **Depends on** (all already shipped): H3 held-approval gate + `pendingApprovals` (`server.ts:423-472`); B4 `gate` re-send plumbing to late joiners (`server.ts:858-864`, `manager.ts:137-142`) — this feature rides the exact same rails; B1 `public/diff.ts` `renderDiffFile` + the `DiffFile`/`DiffLine` types (reused, not modified); A4 `sanitizeField` (`timeline.ts:50`, reused).
|
||||
- **Unlocks / synergizes**: the diff-render path here is the same one W3 "Diff against a base branch" and W4 "Stage/commit/push from the diff viewer" extend — a shared, security-reviewed `DiffFile` render surface. Also complements A1 lock-screen approvals: a future enhancement can put a one-line preview summary into the push `detail` (`PushPayload.detail`, `types.ts:381`) so the phone shows *what* is being approved (not planned here, but the derive function is the reusable source).
|
||||
- **Isolation**: `src/http/approval-preview.ts` is a new owned file (no conflict); `src/types.ts` is the one coordination edit (additive-optional, low collision risk); `server.ts`/`manager.ts`/`terminal-session.ts`/`tabs.ts` edits are localized to the cited line ranges.
|
||||
143
docs/plans/w1-clickable-links.md
Normal file
143
docs/plans/w1-clickable-links.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Clickable URLs & file paths in the terminal
|
||||
|
||||
**Status of premise (read first).** Two independent capabilities are bundled here:
|
||||
|
||||
1. **URLs** — already *90% shipped.* `public/terminal-session.ts:148` already does `this.term.loadAddon(new WebLinksAddon())`, and `@xterm/addon-web-links@^0.12.0` is already in `package.json`. Remaining work is a security hardening pass on link activation (scheme allowlist + `noopener`). Pure frontend.
|
||||
|
||||
2. **File paths → editor** — needs a **custom link provider** *and* a **small, additive server change.** The task brief says "POST to the existing `/open-in-editor` … no server change," but the existing route **cannot** open a file at a line: `openInEditor` (`src/http/editor.ts:31`) rejects anything that is not an **absolute existing _directory_** (lines 35, 45–47) and spawns `code <dir>` with **no `--goto`/line** (line 51). Feeding it `src/app.ts:42` fails three validators at once. See the **Decision** callout below — I recommend a tiny additive `openFileInEditor` alongside the untouched `openInEditor`. A strict "frontend-only" fallback exists but degrades to "open the containing folder, no line jump."
|
||||
|
||||
`src/types.ts` is **not** touched — this is an HTTP JSON body, not a WS protocol message; no shared-contract change. The byte-shuttle terminal stream is untouched.
|
||||
|
||||
---
|
||||
|
||||
## Decision: how file-path clicks reach the editor
|
||||
|
||||
| Option | What clicking `src/app.ts:42` does | Server change | Recommendation |
|
||||
|---|---|---|---|
|
||||
| **A — additive `openFileInEditor` (recommended)** | Opens the file at line 42 (`code --goto /abs/src/app.ts:42`) | +~35 lines in `src/http/editor.ts`, +1 branch in the `server.ts:384` route. Backward-compatible; `openInEditor` + its tests untouched | **Yes.** Only option that delivers the actual feature (jump to file:line). Additive and low-risk. |
|
||||
| **B — strict frontend-only** | Resolves to the file's parent dir and calls existing `openInEditor` → opens the *repo/folder*, no file, no line | None | Fallback only. Poor UX; loses the whole point (line jump). Document but don't ship as primary. |
|
||||
|
||||
The rest of this plan assumes **Option A**. The frontend work is identical either way; only the POST body and server branch differ.
|
||||
|
||||
---
|
||||
|
||||
## Contract
|
||||
|
||||
### Route (extended, backward-compatible)
|
||||
`POST /open-in-editor` (`src/server.ts:384`) — unchanged guards: `express.json({ limit: '4kb' })`, `requireAllowedOrigin` (CSRF, `src/server.ts:352`). New branch on body shape:
|
||||
|
||||
- **Existing (directory)** — `{ "path": "<abs dir>" }` → `openInEditor(cfg, body.path)` (unchanged; Projects panel keeps working).
|
||||
- **New (file+line)** — `{ "file": "<abs file>", "line": <int?>, "column": <int?> }` → `openFileInEditor(cfg, body.file, body.line)`.
|
||||
- Response envelope unchanged: `204` on success; `{ error }` + `4xx/5xx` on failure (mirrors `src/server.ts:388–392`).
|
||||
|
||||
Body is a discriminated request: **`file` present ⇒ file mode; else path mode.** If neither present → `400 { error: 'path or file is required' }`.
|
||||
|
||||
### New server function (`src/http/editor.ts`)
|
||||
```
|
||||
export async function openFileInEditor(
|
||||
cfg: Config, rawFile: unknown, rawLine?: unknown
|
||||
): Promise<OpenEditorResult>
|
||||
```
|
||||
Reuses the existing `OpenEditorResult` type (`editor.ts:20`). Validates: string + non-empty (`400`), `path.isAbsolute` (`400`), `fs.stat` exists (`404`), `stat.isFile()` (`400 'path is not a file'`), and `line` (when present) is an integer in `1..1_000_000` else `400`. Spawns via `execFile(cfg.editorCmd, args)` (no shell, same as line 51) where:
|
||||
- `args = isGotoEditor(cfg.editorCmd) && line !== undefined ? ['--goto', `${file}:${line}`] : [file]`
|
||||
- `isGotoEditor` = basename ∈ `{code, code-insiders, codium, cursor, windsurf}` (the editors that accept `--goto`). Unknown editors open the bare file (never pass a bogus `--goto` argv).
|
||||
|
||||
### New frontend module (`public/link-paths.ts`) — pure, node-testable
|
||||
```
|
||||
export interface PathMatch {
|
||||
text: string // exact matched substring (e.g. "src/app.ts:42")
|
||||
path: string // "src/app.ts"
|
||||
line?: number
|
||||
column?: number
|
||||
startX: number // 1-based column of first char (xterm range.start.x)
|
||||
endX: number // 1-based column of last char (xterm range.end.x, inclusive)
|
||||
}
|
||||
export function findPathMatches(lineText: string): PathMatch[]
|
||||
```
|
||||
Matching rules (concrete): a token is a path candidate iff it has a filename with a dot-extension, optionally preceded by `./`, `../`, or `dir/…/` segments, optionally suffixed `:line` and `:line:col`. A candidate becomes a match iff **(has a `/` separator) OR (has a `:line` suffix) OR (extension ∈ `CODE_EXT` allowlist)** — this links `src/app.ts:42`, `README.md`, `main.rs:10` while rejecting `example.com`, `v1.2`, `foo.bar`. Reject candidates immediately preceded by `/` or `:` (avoids grabbing the tail of a `https://host/path.html` URL, which `WebLinksAddon` owns). `CODE_EXT` = a named const set (`ts tsx js jsx mjs cjs py go rs rb java kt c h cpp hpp cc cs php swift css scss html json yaml yml toml md txt sh sql vue svelte` …).
|
||||
|
||||
### Env vars
|
||||
**None new.** `EDITOR_CMD` (default `'code'`) already exists (`src/config.ts:49`, `src/types.ts:42`) and is reused.
|
||||
|
||||
### WS protocol / `src/types.ts`
|
||||
**No change.** No new client→server or server→client message types.
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `public/link-paths.ts` | **New.** Pure `findPathMatches` + `CODE_EXT` const + `PathMatch` type. No DOM. (Keeps matcher unit-testable in node and file <150 lines.) |
|
||||
| `public/terminal-session.ts` | Replace bare `new WebLinksAddon()` at **line 148** with a hardened handler (scheme allowlist + `window.open(uri,'_blank','noopener,noreferrer')`). After `term.open` (line 149) register a path link provider via `this.term.registerLinkProvider(...)`; store the returned `IDisposable`. Add `private openPath(m: PathMatch)` (resolve rel→abs via `this.cwdValue`, in-flight guard, `fetch('/open-in-editor', …)`, error→`statusLine` toast). Dispose the provider in `dispose()` (line 452). Small helpers `openWebLink`, `makePathLinkProvider`. |
|
||||
| `src/http/editor.ts` | **Add** `openFileInEditor` + `isGotoEditor` helper. **`openInEditor` unchanged** (Projects panel + its tests keep passing). |
|
||||
| `src/server.ts` | In the `/open-in-editor` handler (**line 384–393**) branch: `body.file` present → `openFileInEditor(cfg, body.file, body.line)`; else existing `openInEditor(cfg, body.path)`. |
|
||||
| `src/types.ts` | **Not touched** — noted here only to confirm no shared-contract edit is required. |
|
||||
|
||||
---
|
||||
|
||||
## TDD steps (ordered)
|
||||
|
||||
> Run with `npm test` (vitest). New frontend-logic tests are **node** (pure matcher); DOM-wiring tests reuse the existing **jsdom** harness in `test/terminal-session.test.ts`.
|
||||
|
||||
1. **`test/link-paths.test.ts` (new, node).** RED→GREEN for `findPathMatches`:
|
||||
- `'see src/app.ts:42 for'` → one match, `path:'src/app.ts', line:42`, `startX/endX` correct (1-based, `startX = index+1`, `endX = index+len`).
|
||||
- `'./a/b.tsx:10:5'` → `line:10, column:5`.
|
||||
- `'README.md'` (allowlisted ext, no slash) → matched; `'example.com'` and `'v1.2.3'` → **no** match.
|
||||
- URL guard: `'https://host/path.html'` → **no** path match (preceded-by-`/` rule).
|
||||
- Two paths on one line → two matches with disjoint ranges.
|
||||
Then implement `public/link-paths.ts` to green.
|
||||
|
||||
2. **`test/editor.test.ts` (extend, node — mirror existing spawn-a-harmless-process style, `editorCmd:'true'`).** Add a `describe('openFileInEditor')`:
|
||||
- relative → `400`; missing → `404`; **directory** → `400 'is not a file'`; non-int/`0`/`1e9+` line → `400`.
|
||||
- success on a real temp file (editorCmd `'true'`) → `status 204`.
|
||||
- **argv assertion:** write a tiny recorder script into the temp dir (`#!/bin/sh; printf '%s\n' "$@" > "$ARGS_OUT"`), set `editorCmd` to it, call with `line:42`, assert the recorded argv is `--goto`, `<file>:42`; call an unknown editor name → argv is just `<file>` (no `--goto`).
|
||||
Then implement `openFileInEditor` + `isGotoEditor` to green.
|
||||
|
||||
3. **`test/integration/server.test.ts` (extend; harness at line 229).** Boot the app, `POST /open-in-editor`:
|
||||
- `{file:<abs tmp file>, line:3}` with a valid `Origin` → `204`.
|
||||
- foreign/missing `Origin` → `403` (proves the CSRF guard still covers the new branch).
|
||||
- `{path:<abs tmp dir>}` still `204` (regression: directory mode intact).
|
||||
Wire the `server.ts` branch to green.
|
||||
|
||||
4. **`test/terminal-session.test.ts` (extend, jsdom).** Extend `FakeTerminal` (line 14) with `registerLinkProvider = vi.fn(p => { this.captured = p; return {dispose:vi.fn()} })` and a `buffer = { active: { getLine: (y)=>({ translateToString:()=> this.lineText }) } }`. Keep the `WebLinksAddon` mock (line 48) but assert the constructor **received a handler fn**. Tests:
|
||||
- after construct, a link provider is registered; feeding a line with `src/app.ts:42` → `provideLinks` callback yields one `ILink` whose `text/range` match `findPathMatches`.
|
||||
- calling `link.activate(mouseEvent, text)` when `cwd` is set (drive an OSC-7 via the captured handler, or set via a resolved path) → `fetch` (stub via `vi.stubGlobal('fetch', …)` as in `test/preview-grid.test.ts:140`) called once with `'/open-in-editor'`, method `POST`, body `{file:<abs>, line:42}`.
|
||||
- relative path **with null cwd** → **no** fetch; a `statusLine` is written to the terminal (assert `term.write`).
|
||||
- in-flight guard: two rapid `activate` calls → **one** fetch.
|
||||
- `openWebLink('javascript:alert(1)')` → `window.open` **not** called; `openWebLink('https://x')` → `window.open('https://x','_blank','noopener,noreferrer')` called.
|
||||
Wire `terminal-session.ts` to green.
|
||||
|
||||
**Coverage:** the matcher (branch-heavy) is fully covered by the node test; the DOM wiring by jsdom; the server branch + validators by editor/integration tests. This keeps the 80% gate comfortably.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
- **No cwd yet** (OSC-7 never fired, `this.cwdValue === null`, `terminal-session.ts:97`) → relative paths are unresolvable → **skip activation**, write a one-line `statusLine('cannot open <path>: working dir unknown')`. Absolute paths still work.
|
||||
- **Path doesn't exist / is a dir** → server returns `404`/`400`; frontend shows a non-blocking `statusLine` toast, no throw (mirror the existing `openProjectInEditor` catch that only `console.error`s).
|
||||
- **Wide (CJK) glyphs before a path** shift xterm columns vs JS string index. Paths are ASCII, but a preceding CJK run offsets `startX`. Acceptable v1 caveat; note it. (Fixable later by walking cells; YAGNI now.)
|
||||
- **URL/path overlap** (`https://host/a.ts:5`) — `WebLinksAddon` links the URL; the `/`-preceded guard stops the path provider from double-linking the tail. Verify the two providers don't both underline.
|
||||
- **`provideLinks` line indexing** — pass `terminal.buffer.active.getLine(bufferLineNumber - 1)` and set `range.{start,end}.y = bufferLineNumber` (xterm gives a 1-based buffer row). **Verify once in a real browser** — the single indexing footgun.
|
||||
- **Rapid clicks** spawn N detached GUI processes on the host → in-flight boolean guard (+ optional 500 ms cooldown) on the frontend.
|
||||
- **Non-`code` editor** without `--goto` support → `isGotoEditor` returns false → open bare file (never inject a stray `--goto` argv the editor would treat as a filename).
|
||||
- **Line-only false positives** like `12:34` (a timestamp) — filtered because the token needs a filename-with-extension before the `:line`.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **Origin/CSRF:** unchanged. `/open-in-editor` stays behind `requireAllowedOrigin` (`src/server.ts:385`, `:352`). The frontend POST is same-origin, so the browser sends `Origin` and passes; a foreign page's no-preflight POST is rejected `403`. Integration test #3 asserts this on the new branch.
|
||||
- **No shell / no injection:** `openFileInEditor` uses `execFile` with an **argv array** (as `editor.ts:51`); the file path and `<file>:<line>` are argv elements, never a command line. `line` is validated to an integer before interpolation, so `${file}:${line}` can't smuggle shell metacharacters via the line field.
|
||||
- **Path containment:** the resolved path is validated **absolute + existing + `isFile()`** server-side. Terminal output is attacker-influenced (a malicious repo could print `../../etc/hosts:1`), but opening a file the user could already `cat` in the shell this app *already grants* adds no privilege (threat model: LAN, no auth, full shell). Optional hardening (note, not required v1): reject resolved paths that escape the session `cwd` root.
|
||||
- **URL activation hardening (the real new surface):** custom `WebLinksAddon` handler (replacing the default at line 148) **allowlists schemes** to `http:/https:/mailto:` and opens with `window.open(uri, '_blank', 'noopener,noreferrer')` — blocks `javascript:`/`data:`/`file:` URIs and prevents reverse-tabnabbing. Activation is gated on the click (a genuine user gesture); no hover/auto-open.
|
||||
- **Rate-limit:** frontend in-flight guard caps editor-spawn fan-out; the route's `express.json({limit:'4kb'})` bounds body size. No new secrets, no logging of paths beyond the existing `console.error` on failure.
|
||||
|
||||
---
|
||||
|
||||
## Effort & dependencies
|
||||
|
||||
- **Effort:** ~**1.5–2 days.** Matcher + tests (0.5d), frontend wiring + jsdom tests + hardened URL handler (0.5d), server `openFileInEditor` + editor/integration tests (0.5d), manual browser verify of link indexing/overlap (0.25d).
|
||||
- **Depends on:** nothing — OSC-7 `cwd` capture (`terminal-session.ts:155–159`), the `/open-in-editor` route, and the `WebLinksAddon` dependency all already exist. Self-contained, W1.
|
||||
- **Unlocks / synergy:** the **Approval preview (W1, task #7)** and **diff viewer (W4, task #13)** can reuse `findPathMatches` + `openFileInEditor` to make paths in a diff/command preview clickable. `link-paths.ts` is deliberately a standalone pure module for that reuse.
|
||||
- **Scope flag for the orchestrator:** Option A adds a ~35-line server function (contradicting the brief's "no server change"). It is additive and backward-compatible, but it *is* a deviation — record it in `PROGRESS_LOG.md`. If the "no server change" constraint is hard, ship Option B (folder-open fallback) and defer file:line jump.
|
||||
138
docs/plans/w2-pty-inject-queue.md
Normal file
138
docs/plans/w2-pty-inject-queue.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Server-side PTY-inject + idle-queued follow-up prompt
|
||||
|
||||
**Feature id:** `w2-pty-inject-queue` · **Branch base:** `develop`
|
||||
|
||||
A thin, Origin/CSRF-guarded HTTP route writes text into a live session's PTY, plus a **bounded per-session queue** whose head entry fires **once** when Claude next goes idle (Stop/SessionEnd), after a short **settle delay**. This is the unlocking primitive for templated launches, auto-continue, and issue-intake.
|
||||
|
||||
Grounding facts from the code I read:
|
||||
- `writeInput(session, data)` — `src/session/session.ts:201` — no-op after PTY exit (L4); today called only from the WS input handler at `src/server.ts:876`.
|
||||
- Idle is observable in the hook side-channel: the Stop/SessionEnd branch is `src/server.ts:414` (`if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd')`), immediately after `manager.handleHookEvent(...)` at `:412`. `handleHookEvent` (`src/session/manager.ts:223`) sets `session.claudeStatus` and broadcasts.
|
||||
- `broadcast(session, msg)` — `src/session/session.ts:52` — fan-out to all `session.clients`.
|
||||
- CSRF helper `requireAllowedOrigin(req, res)` — `src/server.ts:352`; per-IP `createRateLimiter(max, windowMs)` — `src/server.ts:109`; `isLoopback` — `src/server.ts:151`; `SESSION_ID_RE` (UUID v4, M7) — `src/protocol.ts:22`.
|
||||
- Timer/hold precedent: `pendingApprovals` map + `setTimeout` live in **server.ts** (`:232`, `:459`), not the manager — the manager owns session state; the server owns wiring/timers. The queue follows the same split.
|
||||
- Session shape `src/types.ts:201`; `LiveSessionInfo` `:246`; `ServerMessage` union `:109`; `SessionManager` iface `:314`. `timeline`/`stuckNotified`/`telemetry` are the precedent for "mutable runtime handle on immutable meta, replaced wholesale".
|
||||
|
||||
---
|
||||
|
||||
## Contract
|
||||
|
||||
### New HTTP routes (all in `src/server.ts`, registered near the `/live-sessions/:id/preview` block ~`:334`)
|
||||
|
||||
| Method / path | Guard | Body | Success | Errors |
|
||||
|---|---|---|---|---|
|
||||
| `POST /live-sessions/:id/queue` | `requireAllowedOrigin` (CSRF) + per-IP rate limit + `SESSION_ID_RE` check | `{ text: string, appendEnter?: boolean }` (`express.json({limit:'16kb'})`) | `200 { length }` | `400` bad id / empty text / non-string; `413` text > `QUEUE_ITEM_MAX_BYTES`; `404` unknown or exited session; `409` queue at `QUEUE_MAX_ITEMS`; `429` rate; `503` when `QUEUE_ENABLED=false` |
|
||||
| `GET /live-sessions/:id/queue` | none (read-only, same threat model as `/live-sessions`) | — | `200 { length, items: string[] }` | `404` unknown |
|
||||
| `DELETE /live-sessions/:id/queue` | `requireAllowedOrigin` | — | `200 { length: 0 }` (escape hatch: cancel all pending) | `404` unknown |
|
||||
|
||||
Not loopback-gated (unlike `/hook`): these are LAN-device actions, so Origin-guarded like `/open-in-editor` (`:384`).
|
||||
|
||||
### `src/types.ts` (coordination edit — the frozen shared-contract source)
|
||||
|
||||
- **`ServerMessage`** — add a variant so all attached devices see pending count live:
|
||||
`| { type: 'queue'; length: number }`
|
||||
- **`Session`** — add a mutable runtime handle (precedent: `timeline`):
|
||||
`queue: readonly string[]` (verbatim byte strings, head fires first; replaced wholesale, never mutated in place).
|
||||
- **`LiveSessionInfo`** — add optional `readonly queueLength?: number;` (additive/optional like `lastOutputAt` at `:261`) so `/live-sessions` and the manage grid show depth.
|
||||
- **`SessionManager`** — add three methods:
|
||||
- `enqueueFollowup(id: string, text: string): EnqueueResult`
|
||||
- `drainOne(id: string): string | null` *(pops head, writes to PTY, broadcasts; null if none/exited)*
|
||||
- `clearQueue(id: string): boolean`
|
||||
- New result type:
|
||||
`export type EnqueueResult = { ok: true; length: number } | { ok: false; reason: 'unknown' | 'full' | 'exited' };`
|
||||
|
||||
### `src/config.ts` env vars (add to `Config` in types.ts `:21` block **and** `loadConfig`)
|
||||
|
||||
| Env | Field | Default | Parser (existing helper) |
|
||||
|---|---|---|---|
|
||||
| `QUEUE_ENABLED` | `queueEnabled: boolean` | `true` | `parseBool` (`config.ts:89`) |
|
||||
| `QUEUE_MAX_ITEMS` | `queueMaxItems: number` | `10` | `parseNonNegativeInt` (`:73`) |
|
||||
| `QUEUE_ITEM_MAX_BYTES` | `queueItemMaxBytes: number` | `4096` | `parseNonNegativeInt` |
|
||||
| `QUEUE_SETTLE_MS` | `queueSettleMs: number` | `1500` | `parseNonNegativeInt` |
|
||||
|
||||
### Client→server protocol
|
||||
|
||||
No new `ClientMessage`. Enqueue is HTTP POST (Origin-guarded), deliberately **not** a WS frame — it must survive "walked away, zero tabs open" and be usable from the manage page for any session, not just the WS-bound one. (Contrast: quick-reply chips send *immediately* over the active WS via `sendToActive`; the queue is *deferred* + cross-session, so HTTP is the right seam.)
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| Path | Concrete change |
|
||||
|---|---|
|
||||
| `src/types.ts` | **Coordination edit.** Add `queue` to `Session`; `queueLength?` to `LiveSessionInfo`; `{type:'queue';length}` to `ServerMessage`; 4 `Config` fields; 3 `SessionManager` methods; `EnqueueResult` type. |
|
||||
| `src/config.ts` | Parse the 4 new env vars in `loadConfig` (helpers already exist); include in returned `Config`. |
|
||||
| `src/session/session.ts` | Init `queue: Object.freeze([])` in the `createSession` session literal (~`:137`, beside `timeline`). No other logic here — reuse existing `writeInput` (`:201`) and `broadcast` (`:52`). |
|
||||
| `src/session/manager.ts` | Implement `enqueueFollowup` / `drainOne` / `clearQueue`; add `queueLength: s.queue.length` to `list()` map (`:184`); import `writeInput` from `./session.js` (add to the existing import at `:43`). Add the three names to the returned object (`:337`). |
|
||||
| `src/server.ts` | Register the 3 routes; add a `QUEUE_RATE_MAX` const + a `createRateLimiter` instance (beside `:218`); in the Stop/SessionEnd branch (`:414`) call a new local `scheduleDrain(sessionId)` that debounces a `setTimeout(cfg.queueSettleMs)` (map `drainTimers: Map<string, Timeout>`, `.unref()`); on fire, re-check idle + stable `lastOutputAt`, then `manager.drainOne(id)`. Clear all `drainTimers` in `doShutdown` (`:930`). |
|
||||
| `public/queue.ts` **(new)** | Tiny FE module: `enqueueFollowup(sessionId, text, appendEnter): Promise<Result>` — POSTs `/live-sessions/:id/queue` (same-origin), never throws; `clearQueue(sessionId)`; parse `{type:'queue'}` frames to update a badge. |
|
||||
| `public/tabs.ts` | Handle the incoming `{type:'queue'}` frame (near the existing status/telemetry frame handling) to show a "N queued" badge on the tab; add an "Queue…" affordance (long-press on the quick-reply `+`, or a small button) that calls `enqueueFollowup(this.activeSessionId(), text)` instead of `sendToActive` (`:875`). |
|
||||
| `public/manage.*` (grid) | Optional: render `queueLength` per card and a cancel (DELETE) button. |
|
||||
|
||||
---
|
||||
|
||||
## TDD steps (ordered — RED → GREEN, matching repo style)
|
||||
|
||||
1. **`src/types.ts`** — make the coordination edit first so everything compiles. Update **every** test `CFG` fixture that lists all Config fields (`test/manager.test.ts:47`, and the same literal in `test/session.test.ts`, plus any others — `grep -rl "worktreeTimeoutMs" test/`) to add the 4 new fields. (Compile gate, no assertion.)
|
||||
|
||||
2. **`test/config.test.ts`** (node) — RED: assert defaults (`queueEnabled=true`, `queueMaxItems=10`, `queueItemMaxBytes=4096`, `queueSettleMs=1500`), env overrides parse, and invalid (`QUEUE_MAX_ITEMS=-1`) throws (fail-fast, like existing `parseNonNegativeInt` tests). → GREEN in `src/config.ts`.
|
||||
|
||||
3. **`test/manager.test.ts`** (node, node-pty mocked via `createMockPty`) — RED then GREEN in `src/session/manager.ts`:
|
||||
- `enqueueFollowup` appends → returns `{ok:true,length:1}` and **broadcasts** `{type:'queue',length:1}` to a stub `WebSocketLike` (assert `ws.send` payload via `serialize`). Second call → `length:2`.
|
||||
- Cap: with `queueMaxItems:2`, third enqueue → `{ok:false,reason:'full'}`, no broadcast, queue unchanged (immutability).
|
||||
- Unknown id → `{ok:false,reason:'unknown'}`.
|
||||
- `drainOne` on a 2-item queue → returns head string, asserts **`mockPty.write` called with that exact string**, queue now length 1, broadcasts `{type:'queue',length:1}`.
|
||||
- `drainOne` empty queue → `null`, no write. Exited session (`session.exitedAt` set) → `null` (double-guards L4).
|
||||
- `clearQueue` → empties + broadcasts `length:0`, returns true.
|
||||
- `list()` includes `queueLength`.
|
||||
*(Use the existing mock-pty `write` spy pattern from `test/session.test.ts:364`.)*
|
||||
|
||||
4. **`test/integration/queue.test.ts`** (new, node, real `startServer` + `fetch`, PTY-gated with the `itPty` helper at `server.test.ts:51`) — RED then GREEN for the routes in `src/server.ts`:
|
||||
- `POST /live-sessions/:id/queue` → `403` foreign Origin; `403`/missing Origin default-deny (mirror `server.test.ts:770`).
|
||||
- Allowed Origin, malformed id → `400`; empty `text` → `400`; `text` of `queueItemMaxBytes+1` → `413`; unknown session → `404`; over rate → `429`.
|
||||
- Happy path on a **real attached** session (open WS, attach, capture `sessionId`): `200 {length:1}`, then `GET /live-sessions` shows `queueLength:1`; `DELETE …/queue` → `queueLength:0`.
|
||||
- **Idle-drain wiring** (`itPty` + `vi.useFakeTimers`): attach real session, enqueue `"echo QUEUED_MARKER\r"`, POST `/hook` (loopback) with `{hook_event_name:'Stop', ...}` and header `x-webterm-session`, advance timers past `queueSettleMs`, assert the client WS receives an `output` frame containing `QUEUED_MARKER` (the shell echoes it). Also assert a *second* enqueue does **not** fire until the next Stop (one-per-idle pacing).
|
||||
- Settle guard: enqueue, POST Stop, then before `queueSettleMs` push a `/hook` event that produces output (changes `lastOutputAt`) → advance timers → assert **no** drain (Claude still active). *(This targets the `scheduleDrain` cursor check.)*
|
||||
|
||||
5. **`test/queue.test.ts`** (new, **jsdom**, mocked `fetch`) — RED then GREEN in `public/queue.ts`:
|
||||
- `enqueueFollowup` POSTs to the right URL/body, returns parsed result; on non-2xx returns `{ok:false}` and **never throws**; on network reject returns `{ok:false}` (mirrors quick-reply's never-throw discipline, `quick-reply.ts:99`).
|
||||
- A `{type:'queue',length:3}` frame → badge helper returns/sets 3.
|
||||
|
||||
6. **`test/tabs.test.ts`** — extend: a `{type:'queue',length:N}` server frame updates the active tab's badge; the enqueue affordance calls `enqueueFollowup` with `activeSessionId()` (not `sendToActive`).
|
||||
|
||||
**Coverage:** manager + config + routes are node-testable deterministically (queue mutation, caps, broadcasts, drain, rate/Origin/validation all hit without a real PTY). The FE module is small and fully jsdom-mockable. Only the real echo-through-PTY assertion is `itPty`-gated (auto-skips in sandbox, runs in CI) — keeps the 80% gate.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
- **Idle flapping / repeated Stop:** `scheduleDrain` **debounces** — clears any existing `drainTimers` entry and restarts the settle timer on each Stop; only fires once the window elapses.
|
||||
- **New output during settle window:** capture `outputCursor = session.lastOutputAt` at schedule time; on timer fire, drain **only if** `session.lastOutputAt === outputCursor` **and** `claudeStatus === 'idle'` **and** `exitedAt === null`. Otherwise skip (a later genuine Stop reschedules). Prevents injecting mid-render.
|
||||
- **One-per-idle pacing (intended):** `drainOne` fires exactly one entry; the injected prompt makes Claude work again → its next Stop drains the next entry. If Claude errors and never emits Stop, remaining items **wait** (no spamming).
|
||||
- **Session exits with items queued:** `drainOne` guards on `exitedAt` (returns null); on session removal (`onSessionExit` L2 / `killById`) the queue dies with the session. Server clears its `drainTimers` entry in `doShutdown`; stale timers are harmless (drain returns null) and `.unref()`ed.
|
||||
- **Queue full → `409`** (never silently drop). **Oversized text → `413`.** Both actionable to the caller.
|
||||
- **Concurrent enqueue from two devices:** single-threaded, immutable array replace → both land; `{type:'queue'}` broadcast keeps every device's badge consistent.
|
||||
- **Enqueue to exited/unknown session → `404`** (checked before append).
|
||||
- **`QUEUE_ENABLED=false`** → routes `503` (graceful disable, like `/push/vapid-key:478`); Stop branch skips scheduling.
|
||||
- **Verbatim bytes / Enter:** queue stores the exact string (byte-shuttle invariant). FE decides `appendEnter` (append `\r`) at enqueue time, mirroring quick-reply's `appendEnter` (`quick-reply.ts:24`). No server-side text parsing.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **CSRF:** `POST`/`DELETE …/queue` are state-changing and cause **shell input**, so they carry `requireAllowedOrigin` (`:352`) — the same guard as the DELETE-session routes. Without it a foreign page could inject commands into a running Claude. `GET` is read-only (queue length + prompt text the user themselves queued) → no guard, consistent with `/live-sessions`.
|
||||
- **Not loopback-gated:** intentionally Origin-gated (LAN device), not `isLoopback` — `/hook` is loopback (host-only) but enqueue must work from the phone.
|
||||
- **Path/ID containment:** validate `:id` against `SESSION_ID_RE` (`protocol.ts:22`) before any Map lookup or PTY write; reject non-UUID with `400`. The id is only a Map key — never touches argv/fs.
|
||||
- **Input validation at the boundary:** `text` must be a non-empty `string`; byte length (`Buffer.byteLength`) ≤ `queueItemMaxBytes` → else `413`. `appendEnter` coerced to boolean. Body capped by `express.json({limit:'16kb'})`. Bytes are passed **verbatim** to the PTY (raw keyboard bytes — do not filter content, per the protocol rule), but bounded in size and count.
|
||||
- **Rate limit:** dedicated per-IP `createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS)` (e.g. 20/min) → `429`, matching the `DECISION_RATE_MAX`/`SUBSCRIBE_RATE_MAX` pattern (`:75`). Bounds injection-flood risk.
|
||||
- **DoS bounds:** `queueMaxItems` caps depth; `queueItemMaxBytes` caps size; drain writes one entry per idle → no unbounded PTY write burst.
|
||||
- **No capability tokens needed** — this reuses the app's existing LAN/Origin trust boundary (same as every other control route); it does **not** widen it. No secrets logged; sanitize any queued text before logging via existing `sanitizeForLog` (`:162`).
|
||||
- **Loopback drain source:** the drain trigger is the Stop hook, which is already loopback-gated at `/hook` (`:399`) — so the *timing* signal can't be forged remotely; only the *content* (Origin-gated) and it fires against the caller's own session.
|
||||
|
||||
---
|
||||
|
||||
## Effort & dependencies
|
||||
|
||||
- **Rough effort:** ~2–3 dev-days. Backend (types + config + manager methods + 3 routes + settle-timer wiring) ~1.5 d incl. tests; FE (`public/queue.ts` + tabs badge/affordance) ~0.5–1 d.
|
||||
- **Depends on:** nothing new — builds entirely on shipped primitives (`writeInput`, `broadcast`, `handleHookEvent` idle branch, `requireAllowedOrigin`, `createRateLimiter`, `LiveSessionInfo`). No schema migrations, no new deps.
|
||||
- **Coordination:** the `src/types.ts` edit is the only cross-cutting change — freeze it first (it touches `Config`, so every all-fields test `CFG` fixture must be updated in the same commit).
|
||||
- **Unlocks (roadmap):** templated launches / auto-continue (queue a follow-up prompt on kickoff), issue-intake (external POST that enqueues), and any "when Claude finishes, do X" automation. The manage-page `queueLength` surface also feeds the multi-session workbench view.
|
||||
131
docs/plans/w3-diff-vs-base.md
Normal file
131
docs/plans/w3-diff-vs-base.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Diff against a base branch (?base=<rev>)
|
||||
|
||||
Adds an optional `base` revision to the read-only git-diff side-channel so the viewer can compare a whole branch against `main` (or any commit-ish), not just the working tree / index. This lands the `FR-B1.9` deferral called out in `src/http/diff.ts:18-19`, using the exact mitigation named there: a `git rev-parse --verify` allow-list before any revision reaches the diff CLI. The diff **parsers and the render core stay untouched**; only a two-stage revision guard (backend), a reflected `base` field, and a toolbar picker (frontend) are added.
|
||||
|
||||
---
|
||||
|
||||
## Contract
|
||||
|
||||
### Route (unchanged path, one new optional query param)
|
||||
`GET /projects/diff` (`src/server.ts:661-678`)
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `path` | string (required) | absolute git dir; validated by `isValidGitDir` (`src/server.ts:122-133`) — unchanged |
|
||||
| `staged` | `0`\|`1` (optional) | current behavior; **ignored when `base` is present** |
|
||||
| `base` | string (optional) | a commit-ish (branch/tag/sha/`HEAD~N`). When present → three-dot diff `git diff <base>... --`; untracked files are not listed |
|
||||
|
||||
Response is the existing `DiffResult` JSON, now with an optional reflected `base`:
|
||||
- `200` structured `DiffResult` (with `base` echoed when supplied)
|
||||
- `400 {error}` — missing `path`, **or** a `base` that fails the syntactic pre-check (flag injection / junk)
|
||||
- `404 {error}` — path not a git dir (unchanged)
|
||||
- Best-effort: a syntactically-valid but **unknown/unrelated** `base` (rev-parse miss, no merge-base) yields `200` with `files: []` — consistent with the module's "git failure → empty, never throw" house style (`src/http/diff.ts:14`, `:346`).
|
||||
|
||||
### Message / data types — `src/types.ts` (coordination edit)
|
||||
Extend `DiffResult` (`src/types.ts:475-479`) with one **optional** field so the shape stays backward-compatible and the viewer's required-field validation is unaffected:
|
||||
```
|
||||
export interface DiffResult {
|
||||
files: DiffFile[];
|
||||
staged: boolean;
|
||||
truncated: boolean;
|
||||
base?: string; // NEW — echoed when the diff was against a base revision
|
||||
}
|
||||
```
|
||||
No other shared type changes. `GetDiffOptions` lives in `src/http/diff.ts:260-263` (not `types.ts`) and gains `base?: string`.
|
||||
|
||||
### Env vars — `src/config.ts`
|
||||
**None required.** rev-parse + diff reuse the existing `diffTimeoutMs` / `diffMaxBytes` bounds (`src/config.ts:347-362`). *(Optional kill-switch `DIFF_BASE_ENABLED` (default true) could be added mirroring `worktreeEnabled` at `src/config.ts:372` if a runtime disable is wanted — deferred, not needed for correctness.)*
|
||||
|
||||
### New/changed function signatures — `src/http/diff.ts`
|
||||
```
|
||||
export function isPlausibleRev(base: string): boolean // pure boundary check
|
||||
async function resolveBaseRev(cwd, base, timeoutMs, maxBytes): Promise<string | null> // rev-parse --verify → canonical sha | null
|
||||
export interface GetDiffOptions { staged: boolean; base?: string; cfg: Pick<Config,...> } // +base
|
||||
export async function getDiff(repoPath, opts): Promise<DiffResult> // branches on opts.base
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| Path | Concrete change |
|
||||
|---|---|
|
||||
| `src/types.ts` | **Coordination edit.** Add optional `base?: string` to `DiffResult` (`:475-479`). |
|
||||
| `src/http/diff.ts` | Add exported pure `isPlausibleRev` (charset + no-`..` + no-leading-`-` + length≤250). Add `resolveBaseRev` (runs `git rev-parse --verify --quiet --end-of-options <base>^{commit}` via `runGit` `:289-309`; return trimmed `/^[0-9a-f]{7,64}$/` sha or `null`). Add `base?` to `GetDiffOptions` (`:260-263`). In `getDiff` (`:347-365`): if `opts.base` set → `resolved = resolveBaseRev(...)`; `null` → `{files:[],staged:false,truncated:false,base:opts.base}`; else run `git diff --no-color <resolved>... --` and `git diff --numstat <resolved>... --`, **skip** `listUntracked` (`:322-340`), set `staged:false`, echo `base:opts.base`. Working-tree path unchanged. |
|
||||
| `src/server.ts` | Diff route (`:661-678`): read `base` (`typeof q==='string' && q!=='' ? q : undefined`); if present and `!isPlausibleRev(base)` → `400 {error:'invalid base revision'}`; else pass `base` into `getDiff(target,{staged,base,cfg})`. Import `isPlausibleRev` from `./http/diff.js`. Route stays no-Origin-guard (read-only, unchanged threat model). |
|
||||
| `public/diff.ts` | `fetchDiff` (`:110-120`): change signature to `fetchDiff(repoPath, opts:{staged?:boolean; base?:string})`; build URL with `&base=<enc>` (omit `staged`) when `base` set, else `&staged=`. `normalizeDiffResult` (`:38-51`): pass through optional `base` (`typeof o['base']==='string' ? o['base'] : undefined`; keep other fields required). `MountDiffViewerOpts` (`:240-243`): add `bases?: string[]`. `mountDiffViewer` (`:253-347`): add a `<select>` "compare-base" control to the toolbar (`:265-272`) — first option `Working tree` (base=null), then one option per `bases[]`; track `base: string \| null`; when a base is chosen disable/grey the Working/Staged tabs and `loadDiff` calls `fetchDiff(repoPath,{base})`; back on "Working tree" restores `fetchDiff(repoPath,{staged})`. **Render core (`renderDiff`/`renderDiffFile`/`renderLine`/`renderHunk`) untouched.** |
|
||||
| `public/projects.ts` | `buildDiffSection` (`:611-643`): add `bases: string[]` param; pass `{ bases, onClose }` into `mountDiffViewer` (`:625`). `renderProjectDetail` (`:672`, call site `:709`): derive `bases` = unique of `detail.worktrees.map(w=>w.branch)` (`WorktreeInfo.branch`, `src/types.ts:292`) ∪ `[detail.branch]`, filtered to defined strings; pass into `buildDiffSection(detail.path, diffRef, bases)`. This is the "reuse worktree/branch data" wiring. |
|
||||
| `test/http/diff.test.ts` | unit `isPlausibleRev` + `getDiff` base integration (below). |
|
||||
| `test/integration/worktree.test.ts` | route-level base tests (real `startServer`). |
|
||||
| `test/diff.test.ts` | jsdom `fetchDiff`/`normalizeDiffResult`/picker tests. |
|
||||
| `test/worktree-form.test.ts` | assert `bases` reach the `mountDiffViewer` mock. |
|
||||
|
||||
---
|
||||
|
||||
## TDD steps (ordered)
|
||||
|
||||
**1. Pure guard — `test/http/diff.test.ts` (node)** — add a `describe('isPlausibleRev')`:
|
||||
- ✅ accepts `main`, `feature/x`, `HEAD~3`, `v1.2.0`, a 40-hex sha, `main^`, `HEAD@{1}`.
|
||||
- ❌ rejects `''`, a 300-char string, `-rf`/`--output=x` (leading `-`), `a..b`, `x y` (whitespace), `` `id` `` / `$(x)` / `;` (metachars), `\x00`.
|
||||
- Implement `isPlausibleRev` → GREEN. (Matches the existing pure-parser layer at `:34-266`.)
|
||||
|
||||
**2. `getDiff` with base — `test/http/diff.test.ts` (node, real repo)** — extend the `describe('getDiff (real git repo)')` block (`:274`). In a `beforeAll`-style setup, commit on `main`, then `git(repo,'checkout','-b','feature')`, commit a change:
|
||||
- `getDiff(repo,{staged:false,base:'main',cfg:LIMITS})` → `result.base==='main'`, `result.staged===false`, the feature-only change present, **no `untracked` entries**, counts numstat-consistent (mirror `:292-302`).
|
||||
- `base:'main'` while HEAD===main → `files:[]` (empty, no changes).
|
||||
- `base:'no-such-branch'` → `{files:[], truncated:false}` (rev-parse miss → empty; assert never throws, like `:340-347`).
|
||||
- `base:'-x'` never reaches here (route-guarded) but assert `getDiff` still returns empty (defense-in-depth) — optional.
|
||||
- Implement `resolveBaseRev` + the base branch in `getDiff` → GREEN.
|
||||
|
||||
**3. Route — `test/integration/worktree.test.ts` (node, `startServer`)** — this file already covers `GET /projects/diff` (header comment `:2-11`); add, using its `itGit` + temp-repo + two-branch setup:
|
||||
- `GET /projects/diff?path=<repo>&base=feature` → `200`, `DiffResult` with `base:'feature'`, verbatim content.
|
||||
- `GET /projects/diff?path=<repo>&base=-rf` → `400`.
|
||||
- `GET /projects/diff?path=<repo>&base=ghost-branch` → `200` with `files:[]`.
|
||||
- Wire `base` parse + `isPlausibleRev` 400 into the route → GREEN.
|
||||
|
||||
**4. Frontend fetch/normalize — `test/diff.test.ts` (jsdom)** — the file mocks `fetch`; add:
|
||||
- `fetchDiff('/repo',{base:'main'})` builds `/projects/diff?path=%2Frepo&base=main` (no `staged=`); `fetchDiff('/repo',{staged:true})` builds the current `&staged=true` URL (update the existing signature-based tests).
|
||||
- `normalizeDiffResult({files:[],staged:false,truncated:false,base:'main'})` → `.base==='main'`; a payload without `base` → `.base===undefined` and still valid.
|
||||
- Implement `fetchDiff` opts + `normalizeDiffResult` pass-through → GREEN.
|
||||
|
||||
**5. Base picker — `test/diff.test.ts` (jsdom)** — extend `describe('mountDiffViewer')` (`:353`):
|
||||
- `mountDiffViewer(container,'/repo',{bases:['main','dev']})` renders a `<select>` with options `Working tree` + `main` + `dev`.
|
||||
- Selecting `main` (dispatch `change`) triggers a fetch whose URL contains `base=main` and disables the Working/Staged tabs; selecting `Working tree` restores a `staged`-mode fetch.
|
||||
- Empty/absent `bases` → no `<select>` (backward-compatible with existing tests that call `mountDiffViewer(container,'/repo',{})`).
|
||||
- Implement toolbar select + state → GREEN.
|
||||
|
||||
**6. Wiring — `test/worktree-form.test.ts` (jsdom)** — it already mocks `mountDiffViewer` (`:31`) and tests `renderProjectDetail`/`buildDiffSection` (`:402-421`). Add: give a `ProjectDetail` with `worktrees:[{branch:'main',...},{branch:'feat',...}]`, click "View Diff", assert the `mockMountDiffViewer` was called with `bases` containing `main` and `feat`. Implement `buildDiffSection` + `renderProjectDetail` derivation → GREEN.
|
||||
|
||||
**7. Refactor / coverage** — `npm test`; confirm the 80% gate holds (every new branch — `isPlausibleRev` both arms, `resolveBaseRev` hit/miss, `getDiff` base/no-base, route 400/200, picker on/off — is exercised above).
|
||||
|
||||
---
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
- **`base=''`** → treated as absent (route coerces to `undefined`) → normal working-tree diff.
|
||||
- **`base` + `staged=1` both set** → `base` wins; staged silently ignored; result `staged:false`. (Documented; the picker disables the staged tab in base mode so the UI can't send both.)
|
||||
- **Flag injection** (`base=-rf`, `--output=/etc/passwd`) → rejected by `isPlausibleRev` (leading `-`) → `400`; even if it slipped through, `--end-of-options` in rev-parse and the trailing `--` in `git diff` neutralize it.
|
||||
- **Range injection** (`base=a..b`, `base=a...b`) → `isPlausibleRev` rejects `..`; we construct the `...` ourselves from a single resolved sha.
|
||||
- **Unknown ref** (typo, deleted branch) → rev-parse `--verify` miss → `resolveBaseRev` returns `null` → empty `DiffResult` (no crash). Rare in practice since the picker only offers real worktree branches.
|
||||
- **Unrelated histories** (no merge-base for `<base>...HEAD`) → `git diff` errors → `runGit` returns empty (`:302-308`) → empty result.
|
||||
- **`base` peels to a tree/tag-of-tree, not a commit** → `^{commit}` peel fails → `null` → empty.
|
||||
- **Detached HEAD in the repo** → `HEAD` still resolves; three-dot works.
|
||||
- **Huge branch diff** → existing `diffMaxFiles`/`diffMaxBytes`/timeout truncation applies unchanged (`:360-364`).
|
||||
- **Rename/binary/new/deleted across the base range** → handled by the untouched `parseUnifiedDiff`/`parseNumstat` (numstat is authoritative for counts, `:187-205`).
|
||||
- **Old git without `--end-of-options`** (pre-2.24) → not a concern in 2026, but since `isPlausibleRev` already blocks leading `-`, the flag can be dropped without loss if a legacy git is hit.
|
||||
- **jsdom picker with `bases:[]` or omitted** → no select rendered; existing `mountDiffViewer(container,'/repo',{})` tests keep passing.
|
||||
|
||||
## Security
|
||||
|
||||
- **Revision allow-list (the core mitigation, `src/http/diff.ts:18-19`)** — two stages, both before the diff CLI: (1) `isPlausibleRev` — a pure boundary check (`/^[A-Za-z0-9][A-Za-z0-9._/@^~{}-]{0,249}$/`, reject `..`) rejecting flag-injection/junk fast with a `400`; (2) `git rev-parse --verify --quiet --end-of-options <base>^{commit}` — git itself is the authoritative allow-list, and its output (a canonical 40/64-hex sha) is what's passed to `git diff`, fully decoupling the raw user string from the diff invocation.
|
||||
- **No shell** — all git calls stay `execFile('git',[...])` (`:296`), args as an array; trailing `--` terminates options on every diff command (`:352-353`), matching the file's SEC note (`:11-12`).
|
||||
- **Read-only** — rev-parse and `git diff` are read-only; `base` introduces **no** write path, so the route keeps its no-Origin-guard status (same threat model as `/projects`, `:660`). No new state-changing surface → no `requireAllowedOrigin` / CSRF change needed.
|
||||
- **Path containment** — unchanged: `isValidGitDir` three-prong (`:122-133`) still gates `path`; `base` cannot escape the repo (rev-parse resolves inside `cwd`).
|
||||
- **DoS bounds** — the extra rev-parse spawn reuses `diffTimeoutMs`/`diffMaxBytes` via `runGit` (`:289-301`); no unbounded work added.
|
||||
- **Frontend XSS** — `base` is echoed and rendered only via `textContent`/`<option>.textContent`; the SEC-H4 "zero innerHTML" invariant of `public/diff.ts` (`:8-9`) is preserved (render core untouched).
|
||||
- **Rate-limit** — parity with the existing diff route (no per-route limiter today); base adds one bounded read-only spawn per request, no new amplification. If the route is later rate-limited, this feature needs no change.
|
||||
|
||||
## Effort & dependencies
|
||||
|
||||
- **Effort:** ~1.5–2 days. Backend guard + `getDiff` branch (~0.5d incl. tests), route wiring (~0.25d), FE picker + `projects.ts` wiring (~0.75d incl. jsdom tests), polish/coverage (~0.25d).
|
||||
- **Depends on:** the shipped B1 diff stack — `src/http/diff.ts`, `public/diff.ts`, the `/projects/diff` route, and B3 worktree/branch data in `ProjectDetail.worktrees` (`src/types.ts:290-312`) which the picker reuses. No new features required.
|
||||
- **Unlocks / adjacent:** W13 "Stage / commit / push from the diff viewer" (a base-vs-branch view is the natural surface for review-before-push) and W10 "PR + CI status chip" (comparing a feature branch against its PR base). Keeping the render core and parsers unchanged means those build on the same `DiffResult` without churn.
|
||||
181
docs/plans/w3-pr-ci-chip.md
Normal file
181
docs/plans/w3-pr-ci-chip.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# PR + CI/checks status chip via gh
|
||||
|
||||
A per-project chip in the project-detail view that shows, for the repo's current branch: **PR state** (open / draft / merged / closed / none), **N checks passing** (from `statusCheckRollup`), and **mergeable** (clean / conflicting). It is a read-only, out-of-band side-channel — exactly like `getDiff` (`src/http/diff.ts`): `execFile('gh', …)` (no shell), timeout + `maxBuffer` bound, parses `gh`'s `--json` output, and **capability-degrades** (chip explains itself) when `gh` is missing, unauthenticated, or the branch has no PR. Cached at module scope with a short TTL (reuses `cfg.projectScanTtlMs`) so opening/refreshing a project detail doesn't hammer the GitHub API.
|
||||
|
||||
Grounding: `buildProjectDetail` (`src/http/projects.ts:420`) surfaces branch/dirty/worktrees but nothing PR. `getDiff`/`runGit` (`src/http/diff.ts:289`) is the runner+degrade pattern to mirror. `isValidGitDir` (`src/server.ts:123`) + the `/projects/diff` route (`src/server.ts:661`) are the exact route mirror. The FE detail header is `renderProjectDetail` (`public/projects.ts:672`, header at lines 696-706); `buildDiffSection` (`public/projects.ts:612`) and `public/diff.ts` `fetchDiff`/`normalizeDiffResult` (lines 110/38) are the FE fetch+degrade+textContent pattern.
|
||||
|
||||
---
|
||||
|
||||
## Contract
|
||||
|
||||
### New route
|
||||
|
||||
`GET /projects/pr?path=<abs-repo-dir>` — read-only, **no** Origin guard (same threat model as `/projects` and `/projects/diff`; see `src/server.ts:660` comment).
|
||||
|
||||
- `400 {error}` — `path` missing/empty (mirror `src/server.ts:662-666`).
|
||||
- `404 {error:'project not found'}` — `!isValidGitDir(target)` (mirror `src/server.ts:667-670`, SEC-H7 three-prong).
|
||||
- `200 PrStatus` — **always** on a valid git dir, including all degrade cases (the availability lives in the body, not the HTTP status — so the FE renders one chip regardless). `500 {error}` only on an unexpected throw (mirror `src/server.ts:674-677`).
|
||||
|
||||
### New message type — `src/types.ts` (coordination edit, next to `DiffResult` at line 475)
|
||||
|
||||
```ts
|
||||
/* ── W3 PR + CI status chip (gh) ── */
|
||||
|
||||
/** Why a PrStatus has (or lacks) PR data. Drives the FE chip's degraded text. */
|
||||
export type PrAvailability =
|
||||
| 'ok' // a PR exists for the current branch; fields below are populated
|
||||
| 'no-pr' // gh works but the branch has no PR (or no remote/default repo)
|
||||
| 'not-installed' // `gh` binary not found on PATH (ENOENT)
|
||||
| 'unauthenticated' // gh present but not logged in (needs `gh auth login`)
|
||||
| 'disabled' // GH_ENABLED=0 — feature off, never spawns gh
|
||||
| 'error'; // gh spawned but failed for another reason (timeout, etc.)
|
||||
|
||||
/** Rolled-up CI check counts from gh's statusCheckRollup (CheckRun + StatusContext). */
|
||||
export interface PrCheckSummary {
|
||||
total: number;
|
||||
passing: number; // CheckRun conclusion SUCCESS/NEUTRAL/SKIPPED | StatusContext SUCCESS
|
||||
failing: number; // FAILURE/TIMED_OUT/CANCELLED/ACTION_REQUIRED | ERROR/FAILURE
|
||||
pending: number; // QUEUED/IN_PROGRESS/WAITING | PENDING/EXPECTED
|
||||
}
|
||||
|
||||
/** GET /projects/pr result. Only present-when-'ok' fields are optional. */
|
||||
export interface PrStatus {
|
||||
availability: PrAvailability;
|
||||
number?: number;
|
||||
title?: string;
|
||||
url?: string;
|
||||
state?: 'open' | 'closed' | 'merged'; // lower-cased from gh OPEN/CLOSED/MERGED
|
||||
isDraft?: boolean;
|
||||
mergeable?: 'mergeable' | 'conflicting' | 'unknown'; // lower-cased from gh
|
||||
headRefName?: string;
|
||||
baseRefName?: string;
|
||||
checks?: PrCheckSummary;
|
||||
}
|
||||
```
|
||||
|
||||
### `gh` invocation (single spawn — KISS)
|
||||
|
||||
One command; `statusCheckRollup` already carries per-check state, so no second `gh pr checks` spawn:
|
||||
|
||||
```
|
||||
gh pr view --json number,state,title,url,isDraft,mergeable,headRefName,baseRefName,statusCheckRollup
|
||||
```
|
||||
|
||||
Run with `cwd = repoPath`. gh resolves the PR from the current branch. `statusCheckRollup` items are a mix of `{__typename:'CheckRun', status, conclusion}` and `{__typename:'StatusContext', state}` — the pure parser handles both.
|
||||
|
||||
### New env vars — `src/config.ts` + `Config` in `src/types.ts` (coordination edit)
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `GH_ENABLED` | `true` (`parseBool`) | Feature flag. `false` → route returns `{availability:'disabled'}`, never spawns gh. |
|
||||
| `GH_TIMEOUT_MS` | `8000` (`parseNonNegativeInt`) | Hard-kill timeout for the gh spawn. Larger than `diffTimeoutMs` (2 s) because gh hits the network. |
|
||||
|
||||
Cache TTL **reuses** `cfg.projectScanTtlMs` (`src/config.ts:311`, default 10 000 ms) — no new TTL var. Add the two fields to the assembled object in `loadConfig` (`src/config.ts:390-438`) and to the `Config` interface.
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| Path | Concrete change |
|
||||
|---|---|
|
||||
| `src/types.ts` | **Coordination edit.** Add `PrAvailability`, `PrCheckSummary`, `PrStatus` near `DiffResult` (line 475). Add `ghEnabled: boolean` + `ghTimeoutMs: number` to the `Config` interface (find `Config` — used by `loadConfig`). |
|
||||
| `src/config.ts` | Add `const DEFAULT_GH_TIMEOUT_MS = 8000` near line 65; parse `GH_ENABLED` via `parseBool(env['GH_ENABLED'], true)` and `GH_TIMEOUT_MS` via `parseNonNegativeInt(...)`; add both to the frozen object (lines 413-438). |
|
||||
| `src/http/gh.ts` | **New file** (~180 lines), mirrors `diff.ts` structure: a `runGh` runner (`execFileAsync('gh', args, {cwd, timeout, maxBuffer})` that captures `stdout`/`stderr`/`code`/spawn-ENOENT), a **pure** `parsePrView(json): PrStatus`-core + `summarizeChecks(rollup): PrCheckSummary`, a `classifyGhFailure(exec): PrAvailability`, module-scope short-TTL cache mirroring `discoverCache` (`projects.ts:239-317`) with in-flight dedupe, `getPrStatus(repoPath, cfg): Promise<PrStatus>`, and a `_clearPrCache()` test hook (mirror `_clearProjectCache`, `projects.ts:314`). |
|
||||
| `src/server.ts` | Add `import { getPrStatus } from './http/gh.js'` (next to line 41). Add `GET /projects/pr` route immediately after `/projects/diff` (after line 678), copying the `path`-missing → 400 and `!isValidGitDir` → 404 guards, then `res.json(await getPrStatus(target, cfg))` in a try/catch → 500 (mirror lines 671-677). |
|
||||
| `public/gh-chip.ts` | **New file** (~120 lines), render-only, mirrors `public/diff.ts`: `normalizePrStatus(raw): PrStatus \| null`, `fetchPrStatus(repoPath): Promise<PrStatus \| null>` (mirror `fetchDiff`, `diff.ts:110`), `chipText(status): {label, cls, title}` (pure, unit-tested), `renderPrChip(status): HTMLElement` (all text via **`textContent`**, zero `innerHTML` — SEC-H4), `mountPrChip(container, repoPath): {destroy()}` that shows a loading placeholder then swaps in the resolved chip. |
|
||||
| `public/projects.ts` | Add `import { mountPrChip } from './gh-chip.js'` (near line 27). In `renderProjectDetail`, after the dirty indicator (line 704) inside the `if (detail.isGit)` guard, append the chip host and mount it; track the handle in a `prRef` and `destroy()` it in the `back` click handler (lines 683-688) alongside `diffRef.h?.destroy()`. |
|
||||
| `public/styles.css` (or the existing project-detail CSS file) | Add `.proj-pr-chip` + state modifier classes (`.proj-pr-open/.draft/.merged/.closed/.none/.unavailable`, `.proj-pr-checks-ok/.fail/.pending`, `.proj-pr-conflict`). Reuse the existing `.proj-branch` chip look (line 699) as the base. |
|
||||
| `test/http/gh.test.ts` | **New** (node) — pure parsers + `getPrStatus` classification. |
|
||||
| `test/integration/pr-status.test.ts` | **New** (node) — real `startServer`, `gh` stubbed via a PATH shim. |
|
||||
| `test/gh-chip.test.ts` | **New** (jsdom) — `normalizePrStatus`/`chipText`/`renderPrChip`/`mountPrChip`. |
|
||||
|
||||
---
|
||||
|
||||
## TDD steps
|
||||
|
||||
Ordered; each "write test → run RED → implement → GREEN". Backend-pure first (cheap, deterministic), then route, then FE. Keeps the 80 % gate because the pure parser + classifier are the bulk of the logic and are fully covered without spawning gh.
|
||||
|
||||
**Backend — `test/http/gh.test.ts`** (node; mirror `test/http/diff.test.ts:1-33`). Import from `../../src/http/gh.js`.
|
||||
|
||||
1. `summarizeChecks` — canned `statusCheckRollup` arrays:
|
||||
- CheckRun `{status:'COMPLETED',conclusion:'SUCCESS'}` → passing++. Implement `summarizeChecks`.
|
||||
- CheckRun `conclusion:'FAILURE'` → failing++; `TIMED_OUT`/`CANCELLED`/`ACTION_REQUIRED` → failing.
|
||||
- CheckRun `status:'IN_PROGRESS'`/`'QUEUED'` (null conclusion) → pending.
|
||||
- StatusContext `{state:'SUCCESS'}` → passing; `'PENDING'` → pending; `'FAILURE'/'ERROR'` → failing.
|
||||
- `NEUTRAL`/`SKIPPED` → passing (don't block). Empty/`undefined` rollup → all-zero. Unknown shape → counted in `total` only, treated as pending. Assert `total === passing+failing+pending`.
|
||||
2. `parsePrView` — feed a full canned JSON string:
|
||||
- Valid PR JSON → `availability:'ok'`, lower-cased `state`/`mergeable`, `number`/`title`/`url`/`isDraft`/`headRefName`/`baseRefName` mapped, `checks` from `summarizeChecks`. Implement `parsePrView` (never throws — `try/JSON.parse`; malformed → `{availability:'error'}`, mirroring `diff.ts` "never throws" house style).
|
||||
- `isDraft:true` still `availability:'ok'` (FE decides the "draft" label); `mergeable:'UNKNOWN'` → `'unknown'`.
|
||||
- Malformed / non-object JSON → `{availability:'error'}`.
|
||||
- **Security assert**: a PR `title` containing `<script>alert(1)</script>` survives verbatim in `PrStatus.title` (proves no parsing-side mangling; FE renders it inert).
|
||||
3. `classifyGhFailure` — given synthetic exec results:
|
||||
- spawn ENOENT (`code:'ENOENT'`) → `'not-installed'`.
|
||||
- stderr containing `gh auth login` / `not logged` / `authentication` / `HTTP 401` → `'unauthenticated'`.
|
||||
- stderr containing `no pull requests found` / `no default remote` / `no git remote` → `'no-pr'`.
|
||||
- other non-zero exit → `'error'`. Implement `classifyGhFailure` (regex on lower-cased stderr).
|
||||
4. `getPrStatus` cache/dedupe — inject a fake runner (or spy) so no real gh spawns:
|
||||
- `ghEnabled:false` in cfg → resolves `{availability:'disabled'}` **without** invoking the runner.
|
||||
- Two rapid calls for the same path share one in-flight run (assert runner called once); after `_clearPrCache()`, it runs again. Mirror `projects.ts:289-311`. Implement the module cache + `_clearPrCache`.
|
||||
- Cache key includes the current branch (cheap read of `.git/HEAD` like `readBranch`, `projects.ts:88`) so a branch switch busts the cache before TTL. Test: same path, different HEAD branch → runner re-invoked.
|
||||
|
||||
> To keep `getPrStatus` unit-testable without gh, factor the spawn into an injectable `runGh` (default real, overridable in tests) — same seam idea as `getDiff`'s `runGit`. `parsePrView`/`summarizeChecks`/`classifyGhFailure` stay pure and exported.
|
||||
|
||||
**Route — `test/integration/pr-status.test.ts`** (node; mirror `test/integration/projects-endpoint.test.ts:1-55`). Use `getFreePort` + a temp dir with a fake `.git` repo (reuse `makeFakeGitRepo` shape). Stub gh with a **PATH shim**: write an executable script named `gh` into a temp `bin/` that echoes canned JSON (or exits 1 with a canned stderr), then set `process.env.PATH = binDir + ':' + process.env.PATH` before `startServer` (execFile resolves `gh` via PATH). Restore PATH + `_clearPrCache()` in `afterEach`.
|
||||
|
||||
5. Missing `path` → **400**. Implement route guard 1.
|
||||
6. Non-git dir path → **404** (`isValidGitDir` fails). Implement guard 2.
|
||||
7. gh shim emits valid PR JSON → **200** with `availability:'ok'`, correct `checks`. Wire `res.json(await getPrStatus(...))`.
|
||||
8. gh shim exits 1 with `no pull requests found` on stderr → **200 `{availability:'no-pr'}`**.
|
||||
9. `GH_ENABLED='0'` env → **200 `{availability:'disabled'}`**, and (assert via a shim that writes a marker file) gh is never spawned.
|
||||
|
||||
**Frontend — `test/gh-chip.test.ts`** (jsdom; mirror `test/diff.test.ts:1-14`, `// @vitest-environment jsdom`, dynamic `await import('../public/gh-chip.js')`, `vi.stubGlobal('fetch', …)`).
|
||||
|
||||
10. `normalizePrStatus` — valid object round-trips; non-object / bad `availability` → `null` (mirror `normalizeDiffResult`, `diff.ts:38`). Implement.
|
||||
11. `chipText(status)` — pure map: `ok`+open → `"PR #12 ✓ 5/5"`; failing checks → `"PR #12 ✕ 3/5"`; `mergeable:'conflicting'` adds a `⚠ conflicts` marker/class; `no-pr` → `"No PR"`; `not-installed` → `"gh not installed"` + `title` link to cli.github.com; `unauthenticated` → `"gh auth login"`; `disabled` → chip hidden (returns null/`display:none`). Implement.
|
||||
12. `renderPrChip` — **SEC-H4 assert**: a `title` of `<img src=x onerror=...>` appears as literal text (`el.textContent` contains it, `el.querySelector('img')` is null). Zero `innerHTML`.
|
||||
13. `mountPrChip` — `fetch` stubbed to resolve `ok` JSON → placeholder replaced by the chip; `fetch` rejects → degrades to an `error` chip (no throw); `destroy()` removes the node. Mirror `mountDiffViewer` (`diff.ts:253-`).
|
||||
|
||||
**Frontend wiring — extend `test/projects.test.ts`** (jsdom; `renderProjectDetail` is already exported/tested there).
|
||||
|
||||
14. `renderProjectDetail` with `detail.isGit:true` → a `.proj-pr-chip` host is present in the header; with `isGit:false` → absent. (Mock `gh-chip`'s `mountPrChip` via `vi.mock` so the DOM assertion doesn't depend on fetch.) Implement the `renderProjectDetail` edit + `prRef.destroy()` in the back handler.
|
||||
|
||||
Run `npm test` after each GREEN. The pure backend parser tests (steps 1-3) carry most of the coverage weight for `gh.ts`; FE steps 10-13 cover `gh-chip.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
- **`gh` not installed** → spawn ENOENT → `'not-installed'`. Chip shows "gh not installed" (never a 500, never a stack trace).
|
||||
- **`gh` present, not authenticated** → stderr auth pattern → `'unauthenticated'` → "gh auth login".
|
||||
- **No PR for branch / no remote / detached HEAD** → `'no-pr'` → "No PR". (Detached HEAD: `.git/HEAD` isn't `ref:` → cache-key branch is `null`; gh itself errors → `no-pr`.)
|
||||
- **PR exists but checks not started / all pending** → `checks.total>0, passing=0, pending=total` → "⧗ 0/N".
|
||||
- **`mergeable:'UNKNOWN'`** (GitHub computes mergeability async right after a push) → `'unknown'` → neutral marker, not a red "conflict". Only `'conflicting'` shows the ⚠.
|
||||
- **Draft PR** → `isDraft:true` → "Draft" styling; still `availability:'ok'`.
|
||||
- **Merged/closed PR still on the branch** → `state:'merged'/'closed'` badge (gh returns the most recent PR).
|
||||
- **gh timeout** (network hang) → `execFileAsync` kills at `ghTimeoutMs` → `'error'` → generic "PR status unavailable". Bounded, never hangs the request.
|
||||
- **Huge `statusCheckRollup`** (100s of checks / monorepo) → bounded by `maxBuffer` (reuse `diffMaxBytes`); overflow → `'error'` (don't try to parse a truncated JSON). Counts are aggregate so the chip stays tiny.
|
||||
- **Malformed `--json` output** (gh version drift) → `parsePrView` `JSON.parse` throws → caught → `'error'`.
|
||||
- **Branch switch within TTL** → cache key includes HEAD branch, so it busts immediately rather than showing the previous branch's PR for up to 10 s.
|
||||
- **FE fetch/network error** → `fetchPrStatus` returns `null` → chip renders an `'error'` state, never throws (mirror `fetchDiff`, `diff.ts:117`).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **No shell**: `execFile('gh', [fixed argv])` — identical guarantee to `runGit` (`diff.ts:296`, SEC-M9). The **only** user-influenced input reaching gh is `cwd`, which is the already-validated `repoPath`. No untrusted string is ever placed in argv (gh derives the PR from the branch; we never pass a branch/base/rev). This sidesteps the `?base=` deferral rationale in `diff.ts:18-20`.
|
||||
- **Path containment**: route calls `isValidGitDir(target)` (`server.ts:123`) before spawning — absolute + is-dir + has `.git` (SEC-H7). Path traversal / arbitrary-cwd is blocked exactly as `/projects/diff`.
|
||||
- **DoS bounds**: `timeout: ghTimeoutMs` (hard kill) + `maxBuffer: cfg.diffMaxBytes` bound a slow/huge gh. Module-scope TTL cache + in-flight dedupe cap outbound GitHub-API calls to ≈1 per repo per `projectScanTtlMs` even under rapid detail-view refreshes (the panel auto-refreshes every 5 s, `projects.ts:33`).
|
||||
- **Network egress note**: unlike every other side-channel (all local), gh talks to GitHub's API using the host's existing `gh`/`GH_TOKEN` credential. The endpoint **never accepts or forwards a token** — it only triggers gh's own auth. Document this in the route comment and in TECH_DOC §7 (this is the first feature to make an outbound call on behalf of a LAN client; the `GH_ENABLED=0` kill-switch lets a cautious operator disable it entirely).
|
||||
- **No secret leakage**: never log gh **stdout** (may contain private PR titles) or the token. If logging a failure, log only `availability` + `sanitizeForLog(stderr.slice(0,200))` (reuse `server.ts:162`) — never raw stderr, mirroring the worktree audit line (`server.ts:714`) and SEC-M10 "never raw git stderr".
|
||||
- **Origin/CSRF**: GET is read-only and non-mutating → no Origin guard, consistent with `/projects` and `/projects/diff` (`server.ts:660`). It spawns a subprocess but performs no state change, so CSWH/CSRF risk is limited to triggering a cached, rate-bounded read.
|
||||
- **XSS**: PR `title` is attacker-controllable (anyone who can open a PR on a repo the host has access to). It is carried verbatim server-side and rendered **only via `textContent`** in `gh-chip.ts` (SEC-H4, same discipline as `diff.ts` line-rendering). Explicit jsdom test (step 12) asserts no element injection.
|
||||
- **Rate-limit**: no per-IP limiter needed (read-only, same as `/projects/diff`); the TTL cache is the effective throttle. If desired later, the `createRateLimiter` helper (`server.ts:109`) is available.
|
||||
|
||||
---
|
||||
|
||||
## Effort & dependencies
|
||||
|
||||
- **Effort**: ~1.5–2 days. Backend `gh.ts` + route ≈ 0.75 day (the runner/cache pattern is a near-copy of `diff.ts`/`projects.ts`; the pure `parsePrView`/`summarizeChecks` classifier is the real work). FE `gh-chip.ts` + wiring + CSS ≈ 0.5 day. Tests (3 files) ≈ 0.5 day, with the PATH-shim integration harness the only novel piece.
|
||||
- **Depends on**: nothing hard-blocking — `Config`/`src/types.ts`/`config.ts` coordination edits and `isValidGitDir` all exist today. Requires `gh` on the host for the live path, but the feature is designed to degrade cleanly without it (so it ships regardless).
|
||||
- **Unlocks**: the **W3 "Quick wins" chip** (task #11 — sync/ahead-behind chip, recent commits) can reuse the same `gh.ts`/`gh-chip.ts` side-channel + short-TTL-cache scaffold (e.g. `gh pr status`, `git rev-list --count @{u}...HEAD`). The PATH-shim gh-integration harness is reusable by any future gh-backed feature.
|
||||
- **Interacts with (no conflict)**: **W3 `?base=` diff** (task #9) touches `diff.ts`/`/projects/diff` only; this feature is an independent file (`gh.ts`) and route (`/projects/pr`). Both add a chip/section to the same `renderProjectDetail` header — coordinate the header layout (place the PR chip after the branch chip, before or beside the diff toggle) but they do not edit the same functions.
|
||||
174
docs/plans/w3-quick-wins.md
Normal file
174
docs/plans/w3-quick-wins.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Quick wins: sync chip + cost guard + reconnect digest + recent commits
|
||||
|
||||
Four small, independent features that ride existing seams. None touches the byte-shuttle WS stream; all new HTTP routes are side-channel and follow the established `/projects/*` / `/hook/*` patterns. Read-only routes get no Origin guard (same threat model as `/projects` — `src/server.ts:281`); the cost guard rides the already-injected `NotifyService` DI seam and the `stuckNotified` one-shot-latch pattern (`src/session/manager.ts:271-287`).
|
||||
|
||||
Shared coordination edit up front: **`src/types.ts`** gains `ProjectInfo.ahead/behind/lastCommitMs` (§a), `NotifyClass` `'budget'` + `Session.budgetNotified` + `Config.costBudgetUsd` + `UiConfig.costBudgetUsd` (§b), `DigestResult`/`DigestSession` (§c), `CommitLogEntry`/`GitLogResult` (§d). Do all type edits in one commit so the four sub-features can then proceed in parallel.
|
||||
|
||||
---
|
||||
|
||||
## Contract
|
||||
|
||||
### (a) Sync chip — no new route
|
||||
Folded into the existing `GET /projects` payload. `ProjectInfo` gains three optional fields, populated in the cached per-repo metadata pass:
|
||||
|
||||
```ts
|
||||
// src/types.ts — extend ProjectInfo (currently line 279-287)
|
||||
export interface ProjectInfo {
|
||||
name: string
|
||||
path: string
|
||||
isGit: boolean
|
||||
branch?: string
|
||||
dirty?: boolean
|
||||
lastActiveMs?: number
|
||||
ahead?: number // commits on HEAD not on @{u} (git rev-list, right count)
|
||||
behind?: number // commits on @{u} not on HEAD (git rev-list, left count)
|
||||
lastCommitMs?: number // git log -1 --format=%ct * 1000 (HEAD commit time)
|
||||
sessions: ProjectSessionRef[]
|
||||
}
|
||||
```
|
||||
`@{u}` with no upstream / non-git → all three `undefined` (best-effort, never throws). No new env var: gated by the existing `projectDirtyCheck` (which already means "spend git subprocess time per repo").
|
||||
|
||||
### (b) Cost budget guard + push alert
|
||||
- **New env** `COST_BUDGET_USD` (dollars, float ≥ 0; default `0` = disabled) → `Config.costBudgetUsd: number` (`src/types.ts` Config, alongside B2 `statuslineTtlMs` at line 65).
|
||||
- **New `NotifyClass` member** `'budget'` (`src/types.ts:376`): `'needs-input' | 'done' | 'stuck' | 'budget'`.
|
||||
- **New `Session` field** `budgetNotified: boolean` (`src/types.ts` Session, next to `stuckNotified` at line 227-228) — one-shot latch, **never re-armed** (cost is monotonic).
|
||||
- **`GET /config/ui`** payload gains `costBudgetUsd?: number` so the FE can derive warn-styling client-side:
|
||||
```ts
|
||||
export interface UiConfig { allowAutoMode: boolean; costBudgetUsd?: number }
|
||||
```
|
||||
- **No new ServerMessage variant.** The "warning broadcast" is the *existing* `{type:'telemetry', telemetry}` frame (already broadcast on every statusLine, `manager.ts:260`); the warn is derived on the client (`costUsd >= costBudgetUsd`). The distinct new server action on threshold crossing is a single `notifyService.notify(session, 'budget')` push. (Rationale: keeps the frozen `ServerMessage` contract in `types.ts:109-120` untouched — KISS/YAGNI. A dedicated frame was considered and rejected: cost overage is not a `ClaudeStatus`.)
|
||||
- `renderTelemetryGauge` (`public/preview-grid.ts:197`) gains a 4th param `costBudgetUsd?: number`; the cost chip (line 224-227) gets class `tg-cost-warn` when `telemetry.costUsd >= budget`, mirroring the ctx>80% path at line 219.
|
||||
|
||||
### (c) While-you-were-away reconnect digest
|
||||
**New read-only route** `GET /digest?since=<epochMs>` (no Origin guard; same as `/live-sessions`). Aggregates `manager.list()`:
|
||||
|
||||
```ts
|
||||
export interface DigestSession {
|
||||
id: string
|
||||
title?: string // last cwd segment
|
||||
status: ClaudeStatus
|
||||
costUsd?: number // telemetry.costUsd
|
||||
lastOutputAt?: number
|
||||
finished: boolean // status==='idle' && lastOutputAt > since
|
||||
needsInput: boolean // status==='waiting'
|
||||
stuck: boolean // status==='stuck'
|
||||
}
|
||||
export interface DigestResult {
|
||||
since: number
|
||||
generatedAt: number
|
||||
total: number
|
||||
finished: number
|
||||
needsInput: number
|
||||
stuck: number
|
||||
working: number
|
||||
totalCostUsd: number
|
||||
sessions: DigestSession[]
|
||||
}
|
||||
```
|
||||
Response: `200` + `DigestResult` (empty aggregate when no sessions). `since` clamped to a finite non-negative number (bad/absent → `0`, i.e. "everything is new").
|
||||
|
||||
### (d) Recent-commits log per project
|
||||
**New read-only route** `GET /projects/log?path=<abs>&n=<int>` (no Origin guard; guarded by `isValidGitDir`, `src/server.ts:123`, exactly like `/projects/diff` at line 661-678).
|
||||
|
||||
```ts
|
||||
export interface CommitLogEntry { hash: string; at: number; subject: string } // at = %ct*1000
|
||||
export interface GitLogResult { commits: CommitLogEntry[]; truncated: boolean }
|
||||
```
|
||||
- `path` missing/empty → `400`; not a valid git dir → `404` (three-prong `isValidGitDir`); git failure → `500`.
|
||||
- `n` parsed to int, clamped to `[1, GIT_LOG_MAX]` (const `50`; default `20`).
|
||||
- Git command (NUL-record, US-field delimited so subjects with tabs/newlines can't corrupt parsing):
|
||||
```
|
||||
git log --no-color -z -n <n> --format=%h%x1f%ct%x1f%s (cwd: repoPath, timeout, maxBuffer)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `src/types.ts` | **Coordination edit.** Add `ProjectInfo.ahead/behind/lastCommitMs`; `NotifyClass` `'budget'`; `Session.budgetNotified`; `Config.costBudgetUsd`; `UiConfig.costBudgetUsd`; new `DigestResult`/`DigestSession`, `CommitLogEntry`/`GitLogResult`. |
|
||||
| `src/config.ts` | Add `parseNonNegativeFloat(raw,label,fallback)` helper; parse `COST_BUDGET_USD`→`costBudgetUsd` (near B2 block line 365-369); add `costBudgetUsd` to the frozen object (spread block line 415-437). |
|
||||
| `src/http/projects.ts` | Add `readSync(repoPath)` helper (2 execFile calls); extend `MakeProjectArgs` (line 121) + `makeProject` (line 129) with `ahead/behind/lastCommitMs`; call `readSync` in `runDiscovery`'s per-repo `mapWithConcurrency` (line 276-280), gated by `cfg.projectDirtyCheck`. |
|
||||
| `src/http/git-log.ts` | **New.** `parseGitLog(stdout,max)` (pure) + `getGitLog(repoPath,{n,timeoutMs})` (async, execFile, no shell). Modelled on `src/http/diff.ts` / `readDirty` (`projects.ts:98`). |
|
||||
| `src/http/digest.ts` | **New.** `buildDigest(live: readonly LiveSessionInfo[], since: number): DigestResult` — pure, injected list (mirrors `buildProjects` injection, `projects.ts:387`). |
|
||||
| `src/session/manager.ts` | In `handleStatusLine` (line 256): after storing/broadcasting telemetry, run the budget-latch check → `notifyService?.notify(session,'budget')`. |
|
||||
| `src/session/session.ts` | Init `budgetNotified: false` in `createSession` object literal (~line 138). **Do not** re-arm (leave line 150 as-is). |
|
||||
| `src/server.ts` | Add `GET /projects/log` (after `/projects/diff`, line 678); add `GET /digest` (after `/live-sessions`, line 279); extend `GET /config/ui` (line 728-731) with `costBudgetUsd`. Import `getGitLog`, `buildDigest`. |
|
||||
| `public/preview-grid.ts` | `renderTelemetryGauge` gains `costBudgetUsd?` param; add `tg-cost-warn` class on the cost chip (line 224-227). |
|
||||
| `public/projects.ts` | `normalizeProject` (line 236): pass through numeric `ahead/behind/lastCommitMs`. `makeProjectCard` (line 454): add sync chip after branch chip. `renderProjectDetail` (line 709 area, git repos only): mount recent-commits section. |
|
||||
| `public/git-log.ts` | **New.** `mountGitLog(container, repoPath)` — fetch `GET /projects/log`, render inert rows via `textContent` only. |
|
||||
| `public/digest.ts` | **New.** `mountDigest(host)` — read `localStorage` last-seen, fetch `GET /digest?since=`, render dismissible banner, update last-seen. |
|
||||
| `public/tabs.ts` | In `loadUiConfig` (line 248-261): also read `costBudgetUsd`; store on the instance; pass to `renderTelemetryGauge` call (line 1391). |
|
||||
| `public/main.ts` | `mountDigest(...)` in the toolbar/app wiring block (~line 51-119). |
|
||||
| `public/style.css` | Add `.tg-cost-warn`, `.proj-sync`, `.proj-commitlog` rows, `.wya-banner` (mirror `.tg-ctx-warn`, `.proj-branch`). |
|
||||
|
||||
---
|
||||
|
||||
## TDD steps (ordered)
|
||||
|
||||
Repo style: backend unit tests are **node** env (default), FE tests declare `// @vitest-environment jsdom` at the top (`test/projects-panel.test.ts:1`) and mock `@xterm/xterm`; route tests spin a real server on a free port (`test/integration/projects-endpoint.test.ts`). `test/manager.test.ts` uses a mock `NotifyService` recording `notify(session,cls,token)` (line 100-107) and a `parseSent(ws)` helper.
|
||||
|
||||
**0. Types (RED→GREEN, compile-only)**
|
||||
- [ ] Edit `src/types.ts` with all shapes above. `npx tsc --noEmit` fails where callers/mocks lack new required fields → gives the worklist. `Session.budgetNotified` is required → `test/manager.test.ts` + `src/session/session.ts` must init it.
|
||||
|
||||
**(b) Cost guard — highest security value, do first**
|
||||
- [ ] `test/config.test.ts`: add defaults block — `loadConfig({}).costBudgetUsd === 0`; override `COST_BUDGET_USD:'5.50'`→`5.5`; `throw` for `'abc'` and `'-1'` (mirror line 156-181). Implement `parseNonNegativeFloat` + wire in `src/config.ts`.
|
||||
- [ ] `test/manager.test.ts` (extend `describe('handleStatusLine')` line 803): with `cfg.costBudgetUsd=1`, feed telemetry `costUsd=0.5` → `notify` **not** called, `session.budgetNotified===false`; feed `costUsd=1.2` → `notify` called once with `(s,'budget')`, latch `true`; feed `costUsd=2` again → **not** called again. With `costBudgetUsd=0` → never called. Implement the latch in `manager.handleStatusLine`.
|
||||
- [ ] `test/session.test.ts`: assert `createSession(...).budgetNotified === false`. Implement init in `session.ts`.
|
||||
- [ ] `test/preview-grid.test.ts` (jsdom): `renderTelemetryGauge(c, {costUsd:6,at:now}, ttl, 5)` → cost chip has class `tg-cost-warn`; with budget `0`/`undefined` or `costUsd<budget` → no warn class. Implement param.
|
||||
- [ ] Route: extend `test/integration` (or `test/http`) — `GET /config/ui` returns `costBudgetUsd`. Implement in `server.ts:728`.
|
||||
|
||||
**(d) Recent commits**
|
||||
- [ ] `test/http/git-log.test.ts` (new): `parseGitLog` — NUL-record + US-field splitting; empty stdout→`[]`; malformed record (missing field) skipped; subject truncated at cap; `truncated` flag when records===max. (Pure, no spawn.)
|
||||
- [ ] `test/http/git-log.test.ts`: `getGitLog` against a real temp repo made with `git init` + 3 commits (pattern from `test/http/diff.test.ts` / worktrees test) → 3 entries newest-first, `n=2`→2 + `truncated:true`.
|
||||
- [ ] `test/integration/projects-log-endpoint.test.ts` (new, model on `projects-endpoint.test.ts`): `GET /projects/log?path=<repo>` → 200 array; missing `path`→400; non-git temp dir→404; `?n=999`→clamped. Implement route in `server.ts`.
|
||||
- [ ] FE `test/git-log.test.ts` (jsdom): `mountGitLog` renders rows via `textContent`; a commit subject containing `<img onerror>` appears verbatim (no HTML injection); fetch failure → empty/error inert text. Implement `public/git-log.ts` + detail-section wiring in `public/projects.ts`.
|
||||
|
||||
**(a) Sync chip**
|
||||
- [ ] `test/projects.test.ts` (extend, node): real temp repo (uses `execFileP` already imported line ~20) with an upstream branch ahead/behind → `buildProjects` yields `ahead`/`behind`/`lastCommitMs`; repo with **no** upstream → those `undefined`, no throw; `projectDirtyCheck:false` → sync skipped (undefined). Implement `readSync` + fold into `runDiscovery`.
|
||||
- [ ] `test/projects-panel.test.ts` (jsdom): `normalizeProject` passes numeric `ahead/behind/lastCommitMs`, drops non-numbers; `makeProjectCard` renders `↑2 ↓1` chip when set, omits when `undefined`/`0`. Implement FE.
|
||||
|
||||
**(c) Reconnect digest**
|
||||
- [ ] `test/http/digest.test.ts` (new, node): `buildDigest([...LiveSessionInfo], since)` — counts finished (`idle` & `lastOutputAt>since`), needsInput (`waiting`), stuck, working; `totalCostUsd` sums `telemetry.costUsd`; empty list → zeroes; `since` in the future → 0 finished.
|
||||
- [ ] `test/integration/digest-endpoint.test.ts` (new): server up, `GET /digest?since=0` → 200 `DigestResult`; malformed `since` → treated as 0. Implement route.
|
||||
- [ ] FE `test/digest.test.ts` (jsdom): `mountDigest` fetches with the stored last-seen; renders banner only when `finished+needsInput+stuck>0`; dismiss updates localStorage last-seen and hides; fetch failure → no banner (best-effort). Implement `public/digest.ts` + `main.ts` mount.
|
||||
|
||||
**Coverage:** every new pure function (`parseGitLog`, `buildDigest`, `readSync` via `buildProjects`, budget latch, gauge warn) has a direct unit test; routes have integration tests. Keeps the ≥80% gate — the new code is mostly pure/tested; the thin `server.ts` wiring is exercised by the integration tests.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
- **(a)** No upstream (`@{u}` fatal) → catch → `ahead/behind` undefined; chip hidden. Detached HEAD → `git log -1 --format=%ct` still works (lastCommitMs set), `@{u}` fails (sync hidden). Empty repo (no commits) → both git calls fail → all undefined. Slow git → 2s timeout kill (reuse `GIT_STATUS_TIMEOUT_MS`, `projects.ts:36`). Adds ≤2 spawns/repo bounded by `GIT_CONCURRENCY=8`; gated off entirely when `projectDirtyCheck=false`. `ahead=behind=0` (in sync) → chip omitted (only render when >0).
|
||||
- **(b)** `costUsd` undefined in a telemetry frame → skip guard (no crossing). Budget `0` → disabled. Latch persists across statusLine frames; a **new** session gets its own latch (per-`Session`). DND/`notifyDone` interplay: `'budget'` is neither `'done'` nor gated, so `shouldSend` sends it unless global DND is on (matches stuck). Late-joining device: gets current telemetry via `manager.ts:139-140`, derives warn from `/config/ui` budget — no missed styling. Push disabled (no VAPID) → latch still flips, broadcast still happens, just no push (graceful, like stuck).
|
||||
- **(c)** `since` absent/NaN/negative → `0`. No sessions → all-zero `DigestResult` (banner suppressed client-side). Clock skew / `lastOutputAt` in future → still counted as finished if `idle` (acceptable; coarse "what happened" view). First-ever visit (no localStorage) → `since=0`, banner may list everything → set last-seen to `generatedAt` after first render so it doesn't re-nag.
|
||||
- **(d)** Binary/huge subjects: `maxBuffer` cap + subject truncation. Non-repo/deleted path → 404 (isValidGitDir). Repo with 0 commits → `[]`. Merge commits/unusual chars in subject → US-field + NUL-record delimiters immune to embedded whitespace. `n` non-numeric → clamp to default.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **Path containment:** `/projects/log` reuses `isValidGitDir` (`server.ts:123`) — absolute + isDirectory + has `.git` (SEC-H7 three-prong), identical to `/projects/diff`. `readSync` runs only against paths already discovered by the bounded BFS scan (`scanRepos`, symlink/dotdir/`node_modules`-skipping, `projects.ts:148`).
|
||||
- **No shell, ever:** all git calls use `execFile('git', [...])` with `timeout` + `maxBuffer` (mirrors `readDirty` line 100). `repoPath` is passed as `cwd`, never interpolated into argv. `n` is coerced to an int and clamped before reaching argv.
|
||||
- **Output is untrusted:** commit subjects and digest labels rendered via `textContent` only (SEC-H5, as in `preview-grid.ts` and `projects.ts` worktree/CLAUDE.md rendering) — zero `innerHTML`. FE `normalizeProject`-style narrowing for the new numeric fields (drop non-numbers) and for `/digest`/`/projects/log` responses (never trust the API shape).
|
||||
- **Origin/CSRF:** all four routes are **read-only GETs** → no Origin guard, consistent with `/projects`, `/live-sessions`, `/projects/diff`. `GET /config/ui` stays read-only. No state-changing surface is added, so no new `requireAllowedOrigin` / rate-limit needed. (The budget **push** goes out the existing `pushService` seam — no new inbound route.)
|
||||
- **Secrets:** cost budget is a non-secret number; `costBudgetUsd` is safe to expose in `/config/ui`. Push payload for `'budget'` carries only sessionId + cwd-basename label (no cost figure, no terminal bytes) via the existing `buildPayload` (`push-service.ts:88`) — SEC-C5 byte-shuttle boundary preserved.
|
||||
- **DoS:** sync adds bounded spawns (concurrency 8, 2s timeout, gated by `projectDirtyCheck`); `/projects/log` `n` clamped ≤50; `/digest` is O(sessions) over an already-capped table.
|
||||
|
||||
---
|
||||
|
||||
## Effort & dependencies
|
||||
|
||||
**~3–4 dev-days total** (four independent slices; can be parceled to parallel builders after the shared `src/types.ts` edit lands):
|
||||
|
||||
| Sub | Effort | Notes |
|
||||
|---|---|---|
|
||||
| (a) sync chip | ~0.5–0.75d | Backend `readSync` + fold-in + 2 FE renders. |
|
||||
| (b) cost guard | ~0.75d | Config float parser, manager latch, gauge warn, `/config/ui` + tabs.ts wiring. Highest test surface. |
|
||||
| (c) reconnect digest | ~1d | New pure aggregate + route + new FE banner module + main.ts mount + localStorage last-seen. |
|
||||
| (d) recent commits | ~1d | New backend git-log module + route + new FE render module + detail-section wiring. |
|
||||
|
||||
**Dependencies (into these):** all four build only on already-shipped infra — `buildProjects` cache pass (v0.6), `NotifyService` DI + `stuckNotified` latch (A5/A1), `renderTelemetryGauge` (B2), `isValidGitDir` + `getDiff` pattern (B1), `/config/ui` seam (review #4). No dependency on other roadmap items.
|
||||
|
||||
**Unlocks / synergy:** (d)'s `getGitLog` + NUL-parsing helper and (a)'s upstream detection are reusable by **#9 "diff against a base branch"** (upstream/`@{u}` resolution) and **#10 "PR + CI status chip"** (a per-repo git/`gh` metadata pass can fold into the same `runDiscovery` concurrency slot as the sync chip). (c)'s `buildDigest` gives **#8 idle-queued follow-up** a ready read-side "which sessions are idle/waiting" aggregate. `NotifyClass 'budget'` establishes the pattern for any future threshold alerts.
|
||||
135
docs/plans/w4-commit-push.md
Normal file
135
docs/plans/w4-commit-push.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Stage / commit / push from the diff viewer
|
||||
|
||||
`id: w4-commit-push` — the highest-risk git **write** set. Scope is bounded to the MVP the task defines: **per-file stage/unstage toggle + commit + push-current-branch**. Discard / checkout / restore-working-tree are explicitly **deferred** (they are the only destructive-to-working-tree ops; nothing in this feature ever touches file contents on disk).
|
||||
|
||||
This mirrors the existing read-only diff channel (`src/http/diff.ts`) and the one existing git-write channel (`src/http/worktrees.ts` + the `POST /projects/worktree` route at `src/server.ts:699–725`). The server stays a byte-shuttle; these are out-of-band side-channel routes.
|
||||
|
||||
---
|
||||
|
||||
## Contract
|
||||
|
||||
### New routes (all in `src/server.ts`, all `express.json`, all `requireAllowedOrigin` + `isValidGitDir`)
|
||||
|
||||
Every route: `requireAllowedOrigin(req, res)` (CSRF, `src/server.ts:352`) → `gitOpsEnabled` gate (403 if off) → per-IP rate limit → validate `path` with the in-file `isValidGitDir` (three-prong: absolute + dir + `.git`, `src/server.ts:123`) → delegate to `src/http/git-ops.ts`. The delegate re-validates and **realpath-contains** every path (defense in depth — the route’s `isValidGitDir` does not follow symlinks).
|
||||
|
||||
| Method + path | Body | Success | Notes |
|
||||
|---|---|---|---|
|
||||
| `POST /projects/git/stage` | `{ path: string, files: string[], stage?: boolean }` | `200 {ok:true, staged:boolean, count:number}` | `stage` default `true` → `git add`; `false` → unstage (`git restore --staged`). `stage` is an **addition** to the task’s bare `{path,files[]}` so one endpoint serves the toggle in both directions. |
|
||||
| `POST /projects/git/commit` | `{ path: string, message: string }` | `200 {ok:true, commit:string}` (short SHA) | Commits **staged** changes only. `message` capped at `commitMsgMaxLen`. |
|
||||
| `POST /projects/git/push` | `{ path: string }` | `200 {ok:true, branch:string, remote:string}` | Pushes the **current branch** to its existing upstream, else `-u <sole-remote> <branch>`. Remote/branch are **derived server-side**, never taken from the client. |
|
||||
|
||||
Error shape (mirrors the worktree route at `src/server.ts:724`): `res.status(result.status).json({ error: result.error })` where `error` is a **safe** message (never raw git stderr, SEC-M10 — see `classifyWorktreeError`, `src/http/worktrees.ts:193`).
|
||||
|
||||
Status codes: `400` invalid input / detached HEAD / no remote / identity unset; `403` disabled or bad Origin; `404` not a git dir; `409` nothing staged / non-fast-forward / index.lock held; `401` push auth required; `429` rate-limited; `500` unclassified; `200` success.
|
||||
|
||||
### `src/types.ts` (coordination edit — frozen shared-contract source)
|
||||
|
||||
Add, next to `CreateWorktreeResult` (`src/types.ts:485`):
|
||||
|
||||
```
|
||||
export interface GitOpResult {
|
||||
ok: boolean;
|
||||
status?: number; // HTTP status on failure
|
||||
error?: string; // SAFE message only (never raw git stderr)
|
||||
// success payloads (route-specific, all optional):
|
||||
staged?: boolean; // stage: direction applied
|
||||
count?: number; // stage: files affected
|
||||
commit?: string; // commit: short SHA
|
||||
branch?: string; // push: branch pushed
|
||||
remote?: string; // push: remote pushed to
|
||||
}
|
||||
```
|
||||
|
||||
One interface keeps the coordination churn minimal (like `CreateWorktreeResult`). The FE narrows with an `isGitOpResult` guard mirroring `isWorktreeResult` (`public/projects.ts:545`).
|
||||
|
||||
### `src/config.ts` env vars (4 new, mirroring the B3 worktree group at `src/config.ts:371–378`)
|
||||
|
||||
| Env | Field | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `GIT_OPS_ENABLED` | `gitOpsEnabled: boolean` | `true` | Master kill-switch (mirrors `worktreeEnabled`, `src/config.ts:372`). Off → all three routes 403. |
|
||||
| `GIT_OPS_TIMEOUT_MS` | `gitOpsTimeoutMs: number` | `10_000` | stage/commit exec timeout (mirrors `DEFAULT_WORKTREE_TIMEOUT_MS`, `src/config.ts:65`). |
|
||||
| `GIT_PUSH_TIMEOUT_MS` | `gitPushTimeoutMs: number` | `120_000` | push is network-bound → longer bound. |
|
||||
| `COMMIT_MSG_MAX_LEN` | `commitMsgMaxLen: number` | `5_000` | commit-message length cap. |
|
||||
|
||||
Parsed with the existing `parseBool` / `parseNonNegativeInt` helpers and appended to the frozen object (`src/config.ts:413–438`). File-count cap for `stage` reuses the existing `diffMaxFiles` (`src/config.ts:358`, default 300) — no new var. `src/types.ts` `Config` interface gains the 4 fields (same coordination note the file already carries at `src/config.ts:15`).
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| **`src/http/git-ops.ts`** (new) | The whole write engine. Mirrors `diff.ts`/`worktrees.ts` execFile pattern: no shell, `timeout`+`maxBuffer`, `--` terminator, `env:{...process.env, GIT_TERMINAL_PROMPT:'0'}`. Exports (all never-throw, return `GitOpResult`): `stageFiles(repoPath, files, stage, opts)`, `commit(repoPath, message, opts)`, `push(repoPath, opts)`, plus pure `classifyGitError(stderr): {status,error}` and `validateRepoFiles(repoRealPath, files): string[] \| null`. |
|
||||
| **`src/http/git-path.ts`** (new, small) | Extracts the containment helper so it isn’t duplicated: `resolveRealPath(target)` (copy of `worktrees.ts:118`) + `isContained(realBase, realCandidate): boolean`. Used by `git-ops.ts`. (Optional follow-up: refactor `worktrees.ts:141` `computeWorktreeDir` onto it — **deferred**, out of this task’s lane.) |
|
||||
| **`src/types.ts`** | Coordination edit: add `GitOpResult` (above) + 4 `Config` fields. |
|
||||
| **`src/config.ts`** | Parse the 4 new env vars; append to the frozen config object. |
|
||||
| **`src/server.ts`** | Register the 3 routes after the worktree route (`:725`). Add two module-level rate constants + two limiters via `createRateLimiter` (`:109`). Reuse in-file `isValidGitDir`, `requireAllowedOrigin`, `sanitizeForLog` (`:162`) for the commit/push audit log. Import from `./http/git-ops.js`. |
|
||||
| **`public/diff.ts`** | `mountDiffViewer` (`:253`) gains: per-file **Stage/Unstage** toggle button in each file row, and a bottom **commit/push bar** (message `<textarea>` + Commit + Push). Add optional `onToggleStage` to the render path; add POST helpers `postStage/postCommit/postPush`. All text via `textContent`/`el()` (SEC-H4, `:139` note). Re-`loadDiff()` after any successful write. |
|
||||
| **`public/projects.ts`** | No logic change required — `buildDiffSection` (`:612`) already mounts `mountDiffViewer` and gets the new UI for free. Only add CSS-class hooks if styling inline. |
|
||||
| **`public/style.css`** (or wherever `df-*` classes live) | Styles for `df-file-stage`, `df-commitbar`, `df-commit-msg`, `df-commit-btn`, `df-push-btn`, `df-op-error`, `df-op-busy`. |
|
||||
|
||||
---
|
||||
|
||||
## TDD steps (ordered)
|
||||
|
||||
Backend uses **node env + real throwaway git repos** in `os.tmpdir()` exactly like `test/http/diff.test.ts:277` and `test/http/worktrees-create.test.ts:42`. FE uses **jsdom + mocked `fetch`** like `test/diff.test.ts`.
|
||||
|
||||
**Pure classifier first (fast, deterministic):**
|
||||
1. `test/http/git-ops.test.ts` → `describe('classifyGitError')`: feed canned stderr strings, assert `{status,error}` and that `error` never contains `fatal:` (SEC-M10, mirror `worktrees-create.test.ts:193`). Cases: `nothing to commit`→409; `Please tell me who you are`→400; `! [rejected]`/`non-fast-forward`/`fetch first`→409; `could not read from Username`/`Authentication failed`/`terminal prompts disabled`/`Permission denied (publickey)`→401; `index.lock`→409; unknown→500. → then implement `classifyGitError`.
|
||||
2. `describe('validateRepoFiles')`: rejects `[]`, absolute paths, leading-`-`, `../escape`, > `diffMaxFiles` entries; accepts in-repo relative paths (incl. a **deleted** file whose path no longer exists on disk — `resolveRealPath` resolves the existing prefix). Symlink-escape: pre-plant a symlink inside the repo pointing outside, assert rejection (mirror `worktrees-create.test.ts:137`). → implement `validateRepoFiles` on `git-path.ts`.
|
||||
|
||||
**Integration against real repos:**
|
||||
3. `describe('stageFiles')`: init repo + commit; modify a tracked file + add an untracked file; `stageFiles(repo,[file],true)` → assert `git diff --cached --name-only` lists them; `stage:false` → assert unstaged. Assert non-git path → `{ok:false,status:404}`. → implement `stageFiles`.
|
||||
4. `describe('commit')`: stage a change → `commit(repo,'msg')` → `{ok:true, commit}` and `git log -1 --format=%H` starts with it. Empty index → `{ok:false,status:409}`. Repo with `user.name` unset → 400. Message length > cap → 400 (route-level; test the length guard where it lives). → implement `commit`.
|
||||
5. `describe('push')`: create a **bare** repo as `origin` (`git init --bare`), `git remote add origin`, first push sets upstream via `-u`; assert bare repo received the ref (`git --git-dir=<bare> rev-parse <branch>`). Detached HEAD → 400. Zero remotes → 400. Two remotes + no upstream → 409. Second push (upstream now set) → plain `git push`. → implement `push`.
|
||||
|
||||
**Config + route wiring:**
|
||||
6. `test/config.test.ts`: the 4 new vars default correctly and parse/validate (reuse existing patterns).
|
||||
7. `test/integration/server.test.ts` (extend, using the `startServer` + `fetch` + `Origin` pattern at `:783`): for each of the 3 routes — foreign Origin → 403, **no** Origin → 403 (default-deny, mirror `:778`), non-git path → 404, missing field → 400, `gitOpsEnabled:false` → 403, and a happy-path 200 against a temp repo (push against a temp bare remote). Confirm `error` bodies carry no `fatal:`.
|
||||
|
||||
**Frontend (jsdom):**
|
||||
8. `test/diff.test.ts` (extend): stub `global.fetch`. Assert each file row renders a Stage button on the Working view and Unstage on the Staged view; clicking POSTs to `/projects/git/stage` with the right `{path,files,stage}` body and re-fetches. Assert the commit bar disables Commit when the message is empty, POSTs `/projects/git/commit`, and shows a failure via `textContent` (SEC-H4 — assert zero `innerHTML`, mirror the file’s existing security assertions). Assert Push POSTs `/projects/git/push` and reflects busy/disabled state. → implement the `mountDiffViewer` changes to pass.
|
||||
|
||||
Coverage: the pure `classifyGitError`/`validateRepoFiles` + real-repo integration cover `git-ops.ts` branches; steps 7–8 cover the new server + `diff.ts` lines. Keeps the 80% gate.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
- **Nothing staged → commit**: git exits non-zero (`nothing to commit`) → 409 "Nothing staged to commit." (not a 500).
|
||||
- **Author identity unset**: `commit` fails (`Please tell me who you are`) → 400 with a safe hint. Do **not** auto-inject a fake identity.
|
||||
- **Unborn HEAD (no commits yet)**: unstage via `git restore --staged` errors → classify to a safe 409/400; commit still works (creates the first commit). Documented limitation.
|
||||
- **Detached HEAD on push**: `git rev-parse --abbrev-ref HEAD` → `HEAD` → refuse 400 "Cannot push a detached HEAD."
|
||||
- **No upstream, one remote**: `git push -u <remote> <branch>`. **No upstream, ≥2 remotes**: 409 "Set an upstream first." **Zero remotes**: 400.
|
||||
- **Non-fast-forward / remote ahead**: `! [rejected]` → 409 "Push rejected — pull/rebase first." (never force-push).
|
||||
- **Credential prompt hang**: `GIT_TERMINAL_PROMPT=0` (+ optional `GIT_SSH_COMMAND='ssh -o BatchMode=yes'`) makes auth fail fast → 401 instead of hanging; the exec `timeout` is the backstop.
|
||||
- **`index.lock` held (concurrent op)**: → 409 "Another git operation is in progress." (Optional hardening: a per-realpath in-memory promise-chain mutex in `server.ts` to serialize writes; git’s own lock is the correctness backstop, so this is MEDIUM, not required for MVP.)
|
||||
- **Deleted file staged**: path absent on disk → `resolveRealPath` resolves the existing prefix and re-appends the basename; `git add -- <path>` records the deletion. **Renamed file**: FE sends both `oldPath` and `newPath` so the deletion+addition both stage.
|
||||
- **Huge `files[]`**: capped at `diffMaxFiles` → 400.
|
||||
- **maxBuffer overflow / timeout**: caught, returned as a safe 500 (never a thrown rejection — house style, `diff.ts:302`).
|
||||
- **Commit message with newlines / leading `-` / emoji**: safe — passed as the single value of `-m` via argv (no shell), length-capped, never a pathspec.
|
||||
- **Write succeeds but re-fetch fails**: FE shows the diff error state but the write already happened; surface a non-blocking notice.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **Origin/CSRF**: all 3 routes call `requireAllowedOrigin` (`src/server.ts:352`) — this is a **write** channel, unlike read-only `/projects/diff` which has no guard. Tested for foreign-Origin **and** missing-Origin (default-deny).
|
||||
- **No shell, ever**: `execFile('git', argv, …)` only — mirrors `diff.ts:296` / `worktrees.ts:242`. Untrusted strings (message, file paths) are argv elements, never interpolated. `--` terminates options before any pathspec so a path can’t become a flag; file paths additionally rejected if they start with `-`.
|
||||
- **Path containment (highest-risk)**: `isValidGitDir` at the route (absolute+dir+`.git`) **plus** `realpath`-based containment of the repo and of **every** `files[]` entry inside `git-ops.ts` (`resolveRealPath` + `startsWith(realBase+sep)`, the M2 pattern from `worktrees.ts:141`). Defeats `../` traversal and pre-planted-symlink escapes even though the diff route only does the lighter three-prong check.
|
||||
- **Server-derived remote/branch**: push never accepts a remote or refspec from the client — both are read back from the repo — eliminating arg-injection and push-to-arbitrary-URL risk.
|
||||
- **Safe error classification**: `classifyGitError` maps stderr substrings to safe messages; raw git stderr is never returned (SEC-M10). Tested that responses contain no `fatal:`.
|
||||
- **Rate limiting**: reuse `createRateLimiter` (`src/server.ts:109`, 60 s window) — `gitWriteLimiter` (stage+commit) ≈ 30/min/IP, `gitPushLimiter` ≈ 6/min/IP; over-limit → 429. Fixed policy constants beside the existing ones (`src/server.ts:74–76`).
|
||||
- **Audit log**: commit/push log actor+path via `sanitizeForLog` (`src/server.ts:162`, control-char strip + truncate), like the worktree audit at `:714`.
|
||||
- **Kill-switch**: `gitOpsEnabled=false` disables all three (mirrors `worktreeEnabled`, `src/server.ts:701`) for locked-down deployments.
|
||||
- **Body size**: `express.json({ limit: '4kb' })` for commit/push, `'64kb'` for stage (large `files[]`) — matching the existing routes’ caps.
|
||||
- **FE injection**: all rendered git output/errors via `textContent`/`el()` — zero `innerHTML` (SEC-H4, asserted in `test/diff.test.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Effort & dependencies
|
||||
|
||||
- **Estimate**: ~3–4 days. Backend `git-ops.ts` + classifier + containment ≈ 1.5 d; route wiring + config + integration tests ≈ 0.75 d; FE toggles + commit/push bar + jsdom tests ≈ 1 d; polish/edge-cases ≈ 0.5 d.
|
||||
- **Depends on**: the existing B1 diff channel (`src/http/diff.ts`, `public/diff.ts:253` `mountDiffViewer`) and B3 worktree channel (`src/http/worktrees.ts`) — both shipped; this reuses their execFile + realpath-containment + error-classification patterns directly. No new dependency on other roadmap items.
|
||||
- **Shares infra with** `w4-worktree-remove` (task #12): both are git-write routes under `requireAllowedOrigin` + `classifyGitError`; landing the shared `git-path.ts` + `classifyGitError` here de-risks that one. Extractable-later: a generic `runGitWrite()` wrapper could later back `worktrees.ts` too (deferred — out of lane).
|
||||
- **Unlocks**: an in-browser commit loop for the walk-away workflow (stage → commit → push from a phone), and a natural home for a future "recent commits" chip (task #11).
|
||||
147
docs/plans/w4-worktree-lifecycle.md
Normal file
147
docs/plans/w4-worktree-lifecycle.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Worktree remove / prune
|
||||
|
||||
Delete losing worktrees and prune stale ones from any device. Backend adds `removeWorktree`/`pruneWorktrees` to `src/http/worktrees.ts` (same execFile-no-shell + timeout + containment machinery as the shipped `createWorktree`), two Origin-guarded routes in `src/server.ts`, and the FE makes the already-rendered `locked`/`prunable`/main tags in `public/projects.ts` `makeWorktreeRow` (lines 492–502) actionable.
|
||||
|
||||
## Contract
|
||||
|
||||
### New backend functions — `src/http/worktrees.ts`
|
||||
|
||||
Reuse the private helpers already in the file: `isGitRepo` (line 168), `listWorktrees`/`parseWorktrees` (lines 29, 69), `extractStderr` (line 180), `resolveRealPath` (line 118). Match `createWorktree`'s structured-result-never-throws contract (line 219).
|
||||
|
||||
```ts
|
||||
export interface RemoveWorktreeOptions { readonly force?: boolean; readonly timeoutMs: number }
|
||||
export async function removeWorktree(
|
||||
repoPath: string, targetPath: string, opts: RemoveWorktreeOptions,
|
||||
): Promise<RemoveWorktreeResult>
|
||||
|
||||
export interface PruneWorktreesOptions { readonly timeoutMs: number }
|
||||
export async function pruneWorktrees(
|
||||
repoPath: string, opts: PruneWorktreesOptions,
|
||||
): Promise<PruneWorktreesResult>
|
||||
```
|
||||
|
||||
`removeWorktree` algorithm (the security spine):
|
||||
1. `isGitRepo(repoPath)` false → `{ ok:false, status:404, error:'Not a git repository.' }`.
|
||||
2. `typeof targetPath !== 'string'` or empty → `{ ok:false, status:400, error:'Worktree path is required.' }`.
|
||||
3. `listWorktrees(repoPath)` → find the entry whose **realpath equals** `resolveRealPath(targetPath)` (canonical compare, not raw-string compare, to defeat symlink tricks). No match → `{ ok:false, status:404, error:'That path is not a worktree of this repository.' }`.
|
||||
4. Matched entry `.isMain === true` → `{ ok:false, status:400, error:'Cannot remove the main worktree.' }`.
|
||||
5. Matched entry `.locked` → `{ ok:false, status:409, error:'This worktree is locked; unlock it in a terminal first.' }` (never double-force `-f -f`).
|
||||
6. Run `git worktree remove` **with the canonical path from git's own list entry** (`match.path`), never the raw user string: `['worktree','remove', ...(force?['--force']:[]), '--', match.path]` via `execFileAsync` (cwd `repoPath`, `timeout: opts.timeoutMs`, `maxBuffer: WORKTREE_MAX_BUFFER`). On success `{ ok:true, path: match.path }`.
|
||||
7. On error → `classifyRemoveError(err)` (new; sibling of `classifyWorktreeError` line 193): stderr containing `contains modified or untracked files` / `use --force` / `is dirty` → `{ ok:false, status:409, error:'Worktree has uncommitted changes — force required.' }`; `not a working tree`/`is not a working tree` → `{ ok:false, status:404, ... }`; else `{ ok:false, status:500, error:'Failed to remove the worktree.' }`. Never leak raw stderr (SEC-M10).
|
||||
|
||||
`pruneWorktrees` algorithm: `isGitRepo` gate (404), then `git worktree prune -v` via execFile (timeout/maxBuffer). Parse verbose lines (`Removing worktrees/<name>: <reason>`, emitted on stdout/stderr — capture both) into `pruned: string[]` (best-effort; empty when nothing prunable — idempotent). Error → `{ ok:false, status:500, error:'Failed to prune worktrees.' }`.
|
||||
|
||||
### Changed message types — `src/types.ts` (coordination edit)
|
||||
|
||||
Add next to `CreateWorktreeResult` (line 485):
|
||||
|
||||
```ts
|
||||
export interface RemoveWorktreeResult { ok: boolean; path?: string; status?: number; error?: string }
|
||||
export interface PruneWorktreesResult { ok: boolean; pruned?: string[]; status?: number; error?: string }
|
||||
```
|
||||
|
||||
`WorktreeInfo` (line 290), `Config` worktree fields (lines 67–69) unchanged. Option interfaces stay local to `worktrees.ts` (mirrors `CreateWorktreeOptions`, line 207).
|
||||
|
||||
### New routes — `src/server.ts`
|
||||
|
||||
Insert both immediately after the create route (ends line 725), reusing `requireAllowedOrigin` (line 352), `sanitizeForLog` (line 162), `cfg.worktreeEnabled`, `cfg.worktreeTimeoutMs`. Add `removeWorktree, pruneWorktrees` to the import at line 43.
|
||||
|
||||
| Route | Body / gate | Response |
|
||||
|---|---|---|
|
||||
| `DELETE /projects/worktree` | `express.json({limit:'4kb'})`; `{ path, worktreePath, force? }`. Guard order: `requireAllowedOrigin` → `worktreeEnabled` (403) → `path`+`worktreePath` present (400). Audit-log via `sanitizeForLog`. | `result.ok` → `200 {ok:true,path}`; else `result.status ?? 500` + `{error}` |
|
||||
| `POST /projects/worktree/prune` | `express.json({limit:'4kb'})`; `{ path }`. Same guard order (path required → 400). | `200 {ok:true,pruned}` or `result.status` + `{error}` |
|
||||
|
||||
`force` coerced strictly: `const force = body['force'] === true`.
|
||||
|
||||
### Env vars — `src/config.ts`
|
||||
|
||||
**None new.** Reuse `worktreeEnabled` (line 372, gates all worktree writes), `worktreeTimeoutMs` (line 374) for remove/prune timeouts.
|
||||
|
||||
### FE — `public/projects.ts`
|
||||
|
||||
- New module-level fetch helpers (siblings of `killSession`, line 275): `removeWorktreeReq(repoPath, worktreePath, force)` → `DELETE /projects/worktree` with JSON body, returns `{ok, status, error}`; `pruneWorktreesReq(repoPath)` → `POST /projects/worktree/prune`, returns `{ok, pruned?, error?}`. Both same-origin (Origin guard passes), best-effort catch.
|
||||
- `makeWorktreeRow` (line 492) gains an optional `actions?: { onRemove:(wt)=>void }` param. When `actions` present and **not** `wt.isMain` and **not** `wt.locked`: append a `proj-wt-remove` `✕` button (`aria-label` "Remove worktree", `title` "Remove this worktree"). Locked rows keep the `locked` tag but no remove button (tooltip explains). Prunable rows still get remove.
|
||||
- `DetailCallbacks` (line 666) gains `onRemoveWorktree:(worktreePath:string)=>void` and `onPruneWorktrees:()=>void`. `renderProjectDetail` (line 672) threads `actions` into the worktree-list loop (line 721) and, when `detail.worktrees.some(w=>w.prunable)`, renders a section-level `Prune stale worktrees` button beside the "Worktrees" title (line 712) wired to `cb.onPruneWorktrees`.
|
||||
- `mountProjects` (line 820) implements the confirm→force→refresh flow (encapsulated here, like `killAndRefresh` line 897), passed into `renderDetail` (line 959):
|
||||
- `onRemoveWorktree`: `confirm("Remove worktree at <path>? This deletes the working tree.")` → `removeWorktreeReq(detailPath, wtPath, false)`; on `409` → second `confirm("Uncommitted changes will be lost. Force-remove?")` → retry with `force:true`; on other failure show error via `textContent` (never innerHTML, SEC-L3/H6); then `void refresh()`.
|
||||
- `onPruneWorktrees`: `confirm("Prune worktrees whose folders are gone?")` → `pruneWorktreesReq(detailPath)` → `refresh()`.
|
||||
- `public/diff.ts`: **no change** — `grep` confirms it renders no worktree rows/tags; the task's mention is covered entirely by `projects.ts`. (Note this deviation from the task wording in the log.)
|
||||
|
||||
## Files to change
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `src/types.ts` | **Coordination edit:** add `RemoveWorktreeResult`, `PruneWorktreesResult` after line 491. |
|
||||
| `src/http/worktrees.ts` | Add `removeWorktree`, `pruneWorktrees`, `RemoveWorktreeOptions`, `PruneWorktreesOptions`, `classifyRemoveError`; reuse existing `isGitRepo`/`listWorktrees`/`resolveRealPath`/`extractStderr`. |
|
||||
| `src/server.ts` | Import (line 43) + two routes after line 725 (`DELETE /projects/worktree`, `POST /projects/worktree/prune`). |
|
||||
| `public/projects.ts` | `removeWorktreeReq`/`pruneWorktreesReq` helpers; extend `makeWorktreeRow` (492), `DetailCallbacks` (666), `renderProjectDetail` (672), `mountProjects` (820). |
|
||||
| `test/http/worktrees-remove.test.ts` | **New** — node, real temp repos; unit + integration of the two functions. |
|
||||
| `test/integration/worktree.test.ts` | Extend with `DELETE`/prune route cases. |
|
||||
| `test/worktree-form.test.ts` | **New describe blocks** — jsdom FE row/confirm/prune tests. |
|
||||
|
||||
## TDD steps (ordered)
|
||||
|
||||
Backend pure/logic — `test/http/worktrees-remove.test.ts` (node env, mirror `worktrees-create.test.ts`: `makeRepo()` helper, `gitAvailable` guard, real temp repos, no network):
|
||||
|
||||
1. Test `removeWorktree` returns `{ok:false,status:404}` for a non-git dir → implement `isGitRepo` gate.
|
||||
2. Test empty/missing `targetPath` → `{ok:false,status:400}` → implement guard.
|
||||
3. Test a path not in `git worktree list` → `{ok:false,status:404}` → implement realpath-match against `listWorktrees`.
|
||||
4. Test main worktree (the repo root) → `{ok:false,status:400,error:/main/}` → implement `isMain` reject.
|
||||
5. Test happy path: create a worktree via `createWorktree`, then `removeWorktree` (clean tree) → `{ok:true}`; assert `git worktree list` no longer contains it.
|
||||
6. Test dirty worktree (write an untracked file into it) → `removeWorktree(force:false)` → `{ok:false,status:409}`; then `force:true` → `{ok:true}` → implement `classifyRemoveError` + force arg.
|
||||
7. Test error message never contains `fatal:`/`error:` (SEC-M10) → assert on the 409 case.
|
||||
8. Test locked worktree (`git worktree lock`) → `{ok:false,status:409,error:/locked/}` → implement locked reject.
|
||||
9. Test symlink alias: pass a symlink pointing at a real worktree as `targetPath` → still matches via realpath and removes git's canonical path → verify (M2-style containment).
|
||||
10. Test `pruneWorktrees` on a repo with a manually-`rm -rf`'d worktree dir → `{ok:true, pruned:[...]}` length ≥1; on a clean repo → `{ok:true, pruned:[]}` (idempotent). Non-git → `{ok:false,status:404}`.
|
||||
|
||||
Backend route — extend `test/integration/worktree.test.ts` (reuse `spawnServer`, `makeRealRepo`, `itGit`):
|
||||
|
||||
11. `DELETE /projects/worktree` foreign Origin → 403; missing Origin → 403.
|
||||
12. `WORKTREE_ENABLED=0` → 403.
|
||||
13. Missing `worktreePath` → 400.
|
||||
14. Attempt to remove main worktree → 400.
|
||||
15. `itGit`: create worktree via `POST /projects/worktree`, then `DELETE` it (clean) → 200 `{ok:true}`; `git worktree list` no longer lists it.
|
||||
16. `itGit`: dirty worktree → DELETE without force → 409; with `force:true` → 200.
|
||||
17. `POST /projects/worktree/prune`: Origin 403, disabled 403, and `itGit` prune-after-manual-delete → 200 with `pruned`.
|
||||
|
||||
FE — `test/worktree-form.test.ts` (jsdom; extend, reuse `makeDetail`, `makeHooks`, `makeCbs`, stubbed `fetch`):
|
||||
|
||||
18. `makeWorktreeRow` for a non-main non-locked wt with `actions` → contains a `.proj-wt-remove` button; main and locked rows → no button.
|
||||
19. `renderProjectDetail` with a prunable worktree → a `Prune stale worktrees` button exists; without → absent.
|
||||
20. Clicking Remove: stub `window.confirm=()=>true`, stub `fetch` → `{ok:true}`; assert `fetch` called `DELETE /projects/worktree` with `force:false` in body.
|
||||
21. Dirty retry: `fetch` first resolves `{ok:false,status:409}` then `{ok:true}`; `confirm` returns true twice → assert second `fetch` body has `force:true`.
|
||||
22. `confirm` returns false → assert `fetch` **not** called (no accidental deletion).
|
||||
23. Error render: `fetch` → `{ok:false,status:500,error:'boom'}` → error shown via `textContent` (assert `.textContent`, no HTML injection).
|
||||
|
||||
Run `npm test`; keep the 80% gate — the new functions and both routes carry direct + error-path coverage; FE branches (button presence, confirm true/false, force retry, error) are all exercised.
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
- **Main worktree** — always rejected (400); the repo root can never be deleted.
|
||||
- **Not-a-worktree path** — arbitrary FS path (e.g. `/etc`) never matches the list → 404, git never invoked against it.
|
||||
- **Dirty tree** (modified tracked or untracked files) — git refuses without `--force`; surfaced as 409 → explicit second confirm before force.
|
||||
- **Locked worktree** — 409 with a "unlock first" message; UI hides the remove button; never auto-escalate to `-f -f`.
|
||||
- **Removing the worktree you're viewing** (`isCurrent` but not `isMain`) — allowed; after refresh the detail path may 404 → detail shows "Project not found" (existing null branch, line 691). Acceptable.
|
||||
- **Already-removed / concurrent delete** — git errors "not a working tree" → 404 safe message; the follow-up `refresh()` reconciles the UI.
|
||||
- **Prune with nothing prunable** — `{ok:true, pruned:[]}`, no error (idempotent).
|
||||
- **git binary missing / timeout** — execFile rejects → 500 safe message; timeout bounded by `worktreeTimeoutMs`.
|
||||
- **Path with control chars / flag-like leading `-`** — never reaches argv as-is: we pass git's own canonical list path, and `--` terminates options; audit log runs through `sanitizeForLog`.
|
||||
- **DELETE-with-body stripped by an intermediary** — same-origin fetch, no proxy in the LAN threat model; body reliably delivered. (If ever a concern, mirror as query params — noted, not implemented.)
|
||||
|
||||
## Security
|
||||
|
||||
- **Origin/CSRF:** both routes call `requireAllowedOrigin` first (line 352) — destructive state change, mandatory (SEC-C3). Integration tests 11–12, 17 assert 403 for foreign/missing Origin.
|
||||
- **Feature gate:** `cfg.worktreeEnabled` (403 when off) governs remove/prune exactly as it governs create.
|
||||
- **No-shell exec:** `execFile('git', [...])` only; never a shell string. `timeout` + `maxBuffer` bound resource use.
|
||||
- **Path containment (the core defense):** the target is accepted **only** if its realpath matches an entry git itself reports in `worktree list`, and the command runs against **git's canonical path**, not the user string — so no traversal/symlink/arbitrary-path deletion is reachable (M2-consistent). `--` belt-and-suspenders before the path arg.
|
||||
- **Main-worktree protection:** `isMain` reject prevents deleting the repository itself.
|
||||
- **Safe error messages:** `classifyRemoveError`/prune mapping return fixed strings; raw git stderr never returned (SEC-M10) — asserted in test 7.
|
||||
- **FE injection:** error/label rendering uses `textContent` only (SEC-L3/H6), asserted in test 23.
|
||||
- **Destructive-intent confirmation:** browser `confirm()` before any delete, plus a **second** confirm before `force` on a dirty tree — no single-click data loss.
|
||||
- **Rate-limit:** inherits the app's per-connection posture; these are Origin-gated same-origin calls. (No new limiter added — consistent with the create route.)
|
||||
|
||||
## Effort & dependencies
|
||||
|
||||
- **Effort:** ~1.5–2 days (backend + routes ~0.75d, FE wiring + confirm flow ~0.5d, tests ~0.5d).
|
||||
- **Depends on:** W4 *create worktree* (shipped `createWorktree`, whose `isGitRepo`/`listWorktrees`/`resolveRealPath`/`classify*` are reused) and the v0.6 project-detail worktree list (`makeWorktreeRow`).
|
||||
- **Unlocks:** full worktree lifecycle from any device (create → work → **remove/prune losers**), and pairs naturally with W4 *stage/commit/push from the diff viewer* (issue #13) to close the "spin up a worktree, land the winner, delete the rest" loop.
|
||||
155
docs/plans/w5-access-token.md
Normal file
155
docs/plans/w5-access-token.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# App-level access token (leave-the-LAN bar-raiser)
|
||||
|
||||
> **Feature id:** `w5-access-token` · **Effort: M** · Branch: `develop`
|
||||
> Adds an **optional** shared secret (`WEBTERM_TOKEN`) that gates remote HTTP + the WS handshake with a constant-time-compared, `HttpOnly`/`SameSite=Strict` cookie. **Unset ⇒ auth disabled**, preserving today's LAN zero-config. The token is **additive** — it sits *in front of* the existing Origin/CSRF model (`isOriginAllowed` at `src/http/origin.ts:22`, `requireAllowedOrigin` at `src/server.ts:480`), never replaces it.
|
||||
|
||||
---
|
||||
|
||||
## Honest tradeoff (read first — belongs in the PR description too)
|
||||
|
||||
This is a **bar-raiser, not a TLS/Tailscale substitute.** On bare LAN the terminal stream is still `ws://` (plaintext) — see `buildWsUrl` at `public/terminal-session.ts:47`, which only upgrades to `wss` when the *page* is HTTPS. When the token travels over `ws://`/`http://`, **anyone sniffing the LAN sees the cookie/token in cleartext.** The token only meaningfully hardens the **relay/tunnel path**, where the edge terminates TLS and the browser speaks `wss://`/`https://`. It is a *single shared secret* (no per-user identity, no revocation except changing the env var + restart, no lockout beyond rate-limiting). Ship it with that framing; do not let it read as "now it's safe on the public internet."
|
||||
|
||||
---
|
||||
|
||||
## Contract (routes / messages / env / types)
|
||||
|
||||
### Env (new — `src/config.ts`)
|
||||
|
||||
| Env var | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `WEBTERM_TOKEN` | string \| undefined | **unset** | Shared access token. **Unset/empty ⇒ auth DISABLED** (everything open, exactly as today). When set: **validated at load** — must match `^[A-Za-z0-9._~+/=-]{16,512}$` (cookie/URL-safe charset, min 16 chars). Invalid ⇒ **throw** (fail-fast, like `parsePort`). |
|
||||
| `WEBTERM_TOKEN_TTL` | number (sec) | `2592000` (30d) | *(optional, YAGNI-dial)* Cookie `Max-Age`. Parsed via the existing `parseNonNegativeInt` helper (`src/config.ts:83`). Ship the constant first; only wire the env if trivial. |
|
||||
|
||||
Charset validation is **not cosmetic**: it blocks `Set-Cookie` header/response-splitting injection and query-string ambiguity, and the ≥16 floor multiplies brute-force cost against the rate limiter.
|
||||
|
||||
### Config type (`src/types.ts`)
|
||||
|
||||
Add one field to `Config` (interface at `src/types.ts:21`, alongside the other secret-ish fields near lines 44–47 / 82):
|
||||
|
||||
```
|
||||
readonly webtermToken: string | undefined; // WEBTERM_TOKEN; undefined ⇒ auth disabled (SECRET — never log/expose)
|
||||
```
|
||||
|
||||
Follow the `vapidPrivateKey` precedent (`src/config.ts:349`): read as `env['WEBTERM_TOKEN'] || undefined`, **never** log it, **never** return it over `/config/ui`.
|
||||
|
||||
### New pure module `src/http/auth.ts` (mirrors the shape/discipline of `src/http/origin.ts`)
|
||||
|
||||
```
|
||||
export const AUTH_COOKIE_NAME = 'webterm_auth'
|
||||
export function isAuthEnabled(cfg: Config): boolean // webtermToken != null && != ''
|
||||
export function parseCookieHeader(header: string | undefined): Record<string, string>
|
||||
export function constantTimeEqual(a: string, b: string): boolean // SHA-256 both → timingSafeEqual (fixed-length guard)
|
||||
export function cookieIsAuthed(cfg: Config, cookieHeader: string | undefined): boolean
|
||||
export function buildSetCookie(cfg: Config, opts: { secure: boolean }): string // the Set-Cookie value
|
||||
export function isHttpsRequest(req: IncomingMessage): boolean // x-forwarded-proto==='https' || socket.encrypted
|
||||
```
|
||||
|
||||
- **`constantTimeEqual`** hashes *both* inputs with `crypto.createHash('sha256')` to a fixed 32 bytes, then `crypto.timingSafeEqual`. Hashing-to-fixed-length is the "fixed-length guard": it removes the length side-channel **and** avoids `timingSafeEqual`'s throw-on-length-mismatch. (Present-vs-absent, i.e. undefined/empty cookie, short-circuits to `false` — a missing cookie is not a secret-comparison oracle.)
|
||||
- **`buildSetCookie`** returns:
|
||||
`webterm_auth=<token>; Path=/; Max-Age=<ttl>; HttpOnly; SameSite=Strict` **+ `; Secure`** only when `opts.secure` is true. Dynamic `Secure` is required: a `Secure` cookie is never sent over `ws://`/`http://`, so forcing it would silently break LAN-over-HTTP auth; over the relay (`x-forwarded-proto: https`) it must be present.
|
||||
|
||||
### New/changed HTTP routes (`src/server.ts`)
|
||||
|
||||
| Route | Guard | Behavior |
|
||||
|---|---|---|
|
||||
| `GET /login` | **always reachable** (registered *before* the gate) | Serves the self-contained `public/login.html`. |
|
||||
| `POST /auth` | **always reachable**, rate-limited (`authLimiter`, 10/min/IP) | Body `token` (accepts `urlencoded` *and* `json`, `limit:'1kb'`). Valid ⇒ `Set-Cookie` (`buildSetCookie`) + **302 → `/`** (native-form path) or **204** for XHR. Invalid ⇒ **401** (+ `302 → /login?e=1` for form navigations). Over rate limit ⇒ **429**. |
|
||||
| `GET /?token=<t>` | handled **inside the gate**, rate-limited | Bootstrap link. Valid ⇒ `Set-Cookie` + **302 → same path with `token` stripped** (no token left in history; `Referrer-Policy: no-referrer` already set at `src/server.ts:307`). Invalid ⇒ **302 → /login**. |
|
||||
| **The global auth gate** (`app.use`, new) | — | Runs after the security-headers middleware (`src/server.ts:304`) and **before** `express.static` (`src/server.ts:317`). See allow-list below. |
|
||||
|
||||
### WS handshake (`src/server.ts` upgrade handler, `:1130`)
|
||||
|
||||
After the existing Origin check passes (`:1141–1146`) and **before** `wss.handleUpgrade` (`:1149`), insert:
|
||||
|
||||
> if `isAuthEnabled(cfg)` and **not** `cookieIsAuthed(cfg, req.headers['cookie'])` → `socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return`.
|
||||
|
||||
The browser auto-sends the `HttpOnly` cookie on the same-origin WS handshake — **no frontend change to `buildWsUrl`/`connect()` is required** for the authed path.
|
||||
|
||||
### The gate's allow-list (single central policy point — the `origin.ts:13` idiom)
|
||||
|
||||
In order, the gate:
|
||||
1. `!isAuthEnabled(cfg)` → `next()` *(zero-config LAN unchanged)*.
|
||||
2. `isLoopback(req.socket.remoteAddress)` (`src/server.ts:160`) → `next()` *(loopback hook ingest — `POST /hook` `:533`, `/hook/permission` `:561`, `/hook/status` `:890` — has no cookie and must keep working; the token is about **remote** access).*
|
||||
3. `GET` with `?token=` → validate (rate-limited) → set-cookie+redirect, or → `/login`.
|
||||
4. `cookieIsAuthed(...)` → `next()`.
|
||||
5. else unauthed: `Accept: text/html` navigation → **302 → /login**; otherwise → **401** JSON `{ error: 'authentication required' }`.
|
||||
|
||||
**Scope note (decision to surface at review):** this gate is a **superset** of the ROADMAP's stated "WS + `requireAllowedOrigin` routes." It *also* gates the read-only GET side-channels (`/live-sessions`, `/projects`, **`/projects/diff` — which leaks source**, `/sessions` — which leaks prompts, `/config/ui`, etc.). That is deliberate: for a "don't-expose-a-shell-off-LAN" bar-raiser, leaving source/prompt reads open off-LAN is the bigger hole, and one central gate is simpler + DRYer than threading a token check through ~19 route handlers. `requireAllowedOrigin` is left **untouched** so the CSRF layer stays independent (defense in depth: gate = "are you authorized to be here", Origin = "is this request forged cross-site"). If a reviewer wants the strictly-minimal scope instead, the fallback is a `requireToken(req,res)` helper called only inside `requireAllowedOrigin` + the WS check — but the global gate is recommended.
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
| Path | Concrete change |
|
||||
|---|---|
|
||||
| `src/types.ts` | Add `readonly webtermToken: string \| undefined` to `Config` (interface `:21`, near the secret fields `:44–47`). **Coordination point** — this is the frozen shared contract; do it here, not locally. Optionally add `authRequired?: boolean` to `UiConfig` (`:656`) for the FE expiry hint. |
|
||||
| `src/config.ts` | In `loadConfig` (`:271`): read `WEBTERM_TOKEN` (`env['WEBTERM_TOKEN'] \|\| undefined`), validate charset+min-length when present (throw on bad, like `parsePort` `:170`), add `webtermToken` to the frozen return (`:474`). Never log it. *(Optional: parse `WEBTERM_TOKEN_TTL`.)* |
|
||||
| `src/http/auth.ts` | **NEW.** Pure, dependency-light helpers listed in Contract (`isAuthEnabled`, `parseCookieHeader`, `constantTimeEqual`, `cookieIsAuthed`, `buildSetCookie`, `isHttpsRequest`, `AUTH_COOKIE_NAME`). Imports `createHash`, `timingSafeEqual` from `node:crypto`. No Express/DOM types — keep it unit-testable like `origin.ts`. |
|
||||
| `src/server.ts` | (1) import the auth helpers (near `:36`). (2) Add `AUTH_RATE_MAX = 10` to the rate-limit constants (`:80–86`). (3) Instantiate `const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS)` (near `:227`). (4) Register `GET /login` + `POST /auth` **then** `app.use(authGate)` between `:313` and `:317`. (5) `authGate` closure implements the allow-list (reuses `isLoopback` `:160`, `requireAllowedOrigin` untouched). (6) WS upgrade: insert the cookie check after Origin (`:1146`, before `:1149`). Serve `login.html` from a startup-cached read of `path.join(publicDir,'login.html')`. |
|
||||
| `public/login.html` | **NEW, fully self-contained.** A `<form method="POST" action="/auth">` with a `type="password"` token field + submit. **Inline `<style>` only, NO inline `<script>`** — the CSP at `src/server.ts:310` is `script-src 'self'` (blocks inline JS) but `style-src 'self' 'unsafe-inline'` (allows inline CSS). Native form POST → server 302 → cookie set → app loads; **needs zero JS**. Show an error banner when `?e=1`. |
|
||||
| `public/terminal-session.ts` | *(OPTIONAL polish, low priority)* On WS close-before-`attached` while auth is enabled, print a `statusLine` (`:38`) hint like "Locked — reload to sign in" instead of silent reconnect loops. Covers the cookie-expired-mid-session case. |
|
||||
| `docs/ROADMAP.md`, `CLAUDE.md`, `.env`/README env list | Tick the ROADMAP item (`:124`); document `WEBTERM_TOKEN` in the env-var list (CLAUDE.md "Planned Commands" block) with the honest-tradeoff sentence. |
|
||||
| `docs/PROGRESS_LOG.md` | Orchestrator appends the completion entry (not the builder). |
|
||||
|
||||
---
|
||||
|
||||
## TDD steps (ordered; matches repo vitest style — pure-unit + `test/integration/*` real-server)
|
||||
|
||||
**Write each test RED first, then implement to GREEN.** Follow AAA and descriptive names, per the repo's `test/origin.test.ts` / `test/integration/server.test.ts` conventions.
|
||||
|
||||
1. **`test/auth.test.ts` (unit, like `origin.test.ts`)**
|
||||
- `constantTimeEqual`: equal strings → `true`; unequal same-length → `false`; **different length → `false`** (no throw); empty/`undefined` → `false`.
|
||||
- `parseCookieHeader`: `'a=1; webterm_auth=xyz; b=2'` → map; missing/empty header → `{}`; malformed pairs ignored.
|
||||
- `isAuthEnabled`: undefined/empty → `false`; set → `true`.
|
||||
- `cookieIsAuthed`: correct cookie → `true`; wrong value → `false`; absent cookie → `false`; disabled cfg → design choice (assert `false` and gate short-circuits before calling it).
|
||||
- `buildSetCookie`: asserts substrings `HttpOnly`, `SameSite=Strict`, `Path=/`, `Max-Age=`; **`Secure` present iff `opts.secure`**; token value present.
|
||||
|
||||
2. **`test/config.test.ts` (extend existing)**
|
||||
- `WEBTERM_TOKEN` unset → `cfg.webtermToken === undefined`.
|
||||
- Valid token (≥16, safe charset) → stored verbatim.
|
||||
- Too short (`'abc'`) → `loadConfig` **throws**.
|
||||
- Bad charset (contains `;`, space, control char) → **throws**.
|
||||
|
||||
3. **`test/integration/auth.test.ts` (NEW real-server, pattern from `server.test.ts`)** — extend `makeTestConfig` to accept `WEBTERM_TOKEN`.
|
||||
- **Regression / disabled:** no token set → WS connects with Origin only; `DELETE /live-sessions` with valid Origin works; `GET /live-sessions` open. *(Proves zero-config LAN is untouched.)*
|
||||
- **WS enabled:** valid Origin + **no cookie → 401** (`waitForOpen` rejects, mirroring the `:262` bad-Origin test); valid Origin + **valid `Cookie: webterm_auth=<t>` → connects → `attached`**; valid Origin + **wrong cookie → 401**.
|
||||
- **`POST /auth`:** wrong token → 401; correct token → 302 + `Set-Cookie` (assert flags; **no `Secure` over http**); `>10` bad attempts/min → **429**.
|
||||
- **`x-forwarded-proto: https`** on `POST /auth` → `Set-Cookie` **includes `Secure`**.
|
||||
- **`GET /?token=<valid>`** → 302 to `/` (Location has no `token`) + `Set-Cookie`; **`?token=<invalid>`** → 302 `/login`, no cookie.
|
||||
- **`GET /login`** → 200 HTML, reachable while unauthed.
|
||||
- **Unauthed HTML nav** (`GET /`, `Accept: text/html`, no cookie) → 302 `/login`; **unauthed XHR** (`GET /live-sessions`, no cookie) → 401 JSON.
|
||||
- **Gate + CSRF stacking:** `DELETE /live-sessions` valid Origin, **no cookie → 401** (gate); with cookie → passes gate, then Origin check as before.
|
||||
- **Loopback bypass:** `POST /hook` from 127.0.0.1 with **no cookie** still returns 204 (hooks unaffected). *(Guard against regressing the side-channel.)*
|
||||
|
||||
4. Run `npm test` (vitest) + `npm run typecheck`; confirm the 80% coverage gate holds for `src/http/auth.ts` and the new server branches.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Cookie-less non-browser clients (curl/scripts):** already rejected by the WS Origin check (undefined Origin → `false`, `origin.ts:26`); the token gate adds a second wall for HTTP. Intended.
|
||||
- **Cookie expiry mid-session:** loaded FE keeps a live WS but new WS reconnects/`/config/ui` fetches start 401ing → the OPTIONAL `terminal-session.ts` hint tells the user to reload. No crash.
|
||||
- **`SameSite=Strict` + bootstrap link:** the `?token=` response *sets* the cookie and 302-redirects; the follow-up top-level navigation to `/` carries it (Strict permits top-level same-site sends). Works.
|
||||
- **Token with URL-reserved chars:** prevented by the config-load charset validation (no `%`-encoding ambiguity, no cookie/header injection).
|
||||
- **`?token=` on a non-GET or with a body:** gate only intercepts `?token=` for GET; other methods fall through to the cookie check.
|
||||
- **PWA offline shell after expiry:** `sw.js` may serve the cached shell offline (same-origin, no data) but the WS still 401s — no data leak.
|
||||
- **Rate-limiter memory:** reuses the existing in-memory sliding-window `createRateLimiter` (`:118`); per-IP arrays are pruned per call — no unbounded growth for the auth endpoint beyond active IPs.
|
||||
- **Login page assets vs. gate:** `login.html` must reference **no** external CSS/JS (inline-styles-only) so the gate can 401 everything else including `/build/main.js` while unauthed.
|
||||
- **Trailing-slash / `/index.html`:** treat both `/` and `/index.html` navigations the same in the "HTML nav → /login" branch.
|
||||
|
||||
## Security
|
||||
|
||||
- **Constant-time compare** via SHA-256→`timingSafeEqual` (no length or content timing oracle; no throw). ✔ security.md secret-handling.
|
||||
- **Cookie flags:** `HttpOnly` (JS can't read the token → XSS can't exfiltrate it), `SameSite=Strict` (cross-site pages can't ride the cookie — complements the Origin/CSWSH defense and `requireAllowedOrigin`), `Secure`-when-https, `Path=/`.
|
||||
- **Rate limiting** on `/auth` and `?token=` (10/min/IP) raises brute-force cost; combined with the ≥16-char token floor this is not trivially guessable. No account lockout (single shared secret) — documented.
|
||||
- **Secret hygiene:** `webtermToken` never logged (follows `vapidPrivateKey` at `:349`), never returned by `/config/ui` (`:1099`). Charset validation blocks `Set-Cookie`/response-splitting injection.
|
||||
- **Additive, non-breaking:** Origin check (`origin.ts:22`) and `requireAllowedOrigin` (`:480`) are unchanged; the gate is a new earlier layer. Loopback hook ingest is explicitly bypassed so the smart-features side-channel keeps working.
|
||||
- **Honest boundary (repeat in code comments + PR):** plaintext on bare `ws://` — token is a relay/tunnel hardener, **not** a TLS/Tailscale replacement. Keep the "never port-forward this raw" guidance from TECH_DOC §7 intact.
|
||||
- **Security-review trigger:** this is auth + cookie + crypto code → run the `security-reviewer` before merge per code-review.md.
|
||||
|
||||
## Effort & dependencies
|
||||
|
||||
- **Effort: M** (matches ROADMAP `:129`). No new npm deps — `node:crypto` (`timingSafeEqual`/`createHash`) and Express's built-in `urlencoded`/`json` parsers cover it. `~1` new pure module + `~1` new HTML file + surgical server wiring.
|
||||
- **Hard dependencies:** none — self-contained; does not block on the fan-out board or Android parity.
|
||||
- **Coordination:** touches two shared files — `src/types.ts` (frozen contract; the `Config.webtermToken` add is the coordination point) and `src/server.ts` (shared wiring). If run in parallel with the other Wave-5 tasks, use `isolation: worktree` and land the `types.ts` bump first so the server change type-checks. `PROGRESS_LOG.md` is orchestrator-written.
|
||||
- **Cross-cutting risk to watch:** the CSP `script-src 'self'` constraint (`:310`) forces the login page to be JS-free (native form) — easy to get wrong by reaching for inline `<script>`; the integration test `GET /login → 200` plus a manual load catches it.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user