Four agents' worth of :app wiring. Everything here existed as tested code with
zero production call sites, or as a screen rendering a shape the server stopped
sending months ago.
Dead code that shipped as features. All three were fully implemented and
unit-tested while being reachable from nothing:
- Session-list preview thumbnails. SessionsHome called SessionListScreen
without the `thumbnails` argument, so the `= null` default won and every
row rendered a placeholder forever — on the home session chooser, whose
entire purpose is seeing what each session is doing.
- Quick-reply chips and their palette editor.
- PointerContextMenu (large-screen secondary click).
ThumbnailPipeline's formal cross-review had been explicitly skipped when it was
written — PROGRESS_ANDROID.md said so in as many words, and it is concurrency
code, so nothing had ever reviewed OR executed it. It has now been reviewed and
kept: the LruCache key, the fair Semaphore(2), the Mutex-guarded in-flight
dedup and the NonCancellable in-flight release are all sound, and a retained
map entry would have poisoned a key forever.
The v0.6 project-detail git panel. Android rendered the pre-w6 shape: one bare
dirty dot, a bare hash+subject commit row, and a worktree heading that renamed
itself to "Branch" at n=1 — the exact behaviour G5 removed. Now the sync band,
unpushed marking with a single upstream boundary, and "Worktrees (n)". The rule
that drives it is enforced, not documented: exactly one state may render green,
and no-upstream is not that state.
Remote approvals are no longer blind. status.preview reaches GateBanner and
PlanGateSheet, so the user can see the command or diff they are approving
instead of a tool name. Absent preview stays an ordinary gate, not an error.
The WEBTERM_TOKEN gate is no longer inert. NetworkModule was calling
OkHttpClientFactory.create(identityProvider) with no cookieJar, so the shared
client ran on CookieJar.NO_COOKIES and none of the cookie plumbing did
anything. It now installs the real AuthCookieJar — one client, so the cookie
rides the WS upgrade too — backed by the Tink-sealed store, and binds
InMemoryAuthCookieStore if a cipher cannot be constructed rather than ever
persisting a shell credential in the clear. The persister runs on an IO scope
because OkHttp calls a CookieJar synchronously and must never block on
DataStore or Tink.
And the user-facing half that made the gate unusable: a token-gated host
answers the pairing probe's unauthenticated GET with 401, which the frozen
taxonomy could only report as "this port is running something else" — so such
a host was impossible to pair AND the copy was misleading. Pairing now routes
to a token prompt and resumes through the same cert-gate choke point. The
token is a parameter, never a field and never part of any UI state.
Docs corrected rather than left aspirational: the plan asserted a cleartext
allowlist "permitted only for private LAN/Tailscale CIDRs", which the platform
cannot express at all — the network-security-config format has no netmask or
prefix syntax. PROGRESS_ANDROID.md now opens with what "COMPLETE" actually
meant, and PROGRESS_LOG.md records the whole pass including what is still
undone: A34/A35 have no code, and device QA remains ~0%.
Verified: ./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify
-> BUILD SUCCESSFUL, 893 JVM tests, 0 failures (baseline was 617). The :app
run was re-measured with outputs deleted and --no-build-cache, and the APK
re-packaged, so Hilt graph validity is an observation rather than an
up-to-date claim.
The server shipped the project-detail git panel in 8fe1f52/f711db9/042c4cd,
all after the last Android commit, so the client silently ignored every new
field (they are additive and optional, so nothing crashed — it just rendered
the pre-w6 shape: one bare dirty dot and no sync information at all).
Added: SyncState + ProjectDetail.sync/dirtyCount, ProjectInfo.dirtyCount,
ProjectSessionRef.cwd, GitLogResult.upstream, CommitLogEntry.unpushed, plus
GET /projects/worktree/state (read) and POST /projects/git/fetch (write).
Two rules from the server's own commit message are encoded as behaviour
rather than left to each call site to remember:
`SyncState.isFullySynced(now)` is the only sanctioned way to ask whether
the green all-clear may be rendered, and it answers false for anything
unknown. ahead/behind compare against @{u}, a locally cached ref that only
a fetch moves, so `behind = 0` from a stale FETCH_HEAD proves nothing. Most
importantly NO UPSTREAM leaves both counts undefined — which is not zero —
and that is the normal state of a fresh worktree branch. The server calls
that fall-through the easiest bug to ship here; there is a test per way it
could regress, including a fetch timestamp in the future, which is clock
skew rather than evidence that anything was checked.
`GitLogResult.upstreamBoundaryIndex` finds the LAST unpushed commit rather
than assuming the unpushed ones come first. git log is date-ordered, so
merging an older branch interleaves unpushed commits below pushed ones, and
the row-count shortcut fails in the dangerous direction — it would label an
unpushed commit as pushed. It returns null, not -1, when no boundary may be
drawn, so "no upstream" cannot be mistaken for "boundary before everything".
Origin policy is asserted in both directions: worktree/state carries no
Origin, git/fetch carries a byte-equal one, and a test also asserts the fetch
body contains no remote or refspec — the remote is derived server-side
precisely so a client cannot aim a fetch at an arbitrary URL. Fetch has its
own rate-limit bucket, so a 429 is surfaced rather than retried, and a failed
fetch leaves lastFetchMs unknown so the UI keeps saying "stale" instead of
pretending it refreshed.
Verified: :api-client:test + koverVerify green; 22 new tests.
Both had shipped on the server for a while and Android silently discarded
them, which is worse than not supporting them — the UI looked correct while
missing information the user needed.
W2 `queue`: `ServerMessageType` had no member for it, so `decodeServer`
returned null and `SessionEngine.onFrame` counted it as undecodable and
dropped it. The "N queued" badge could therefore never appear on Android,
on any device, ever. Adding the branch made the compiler flag the
non-exhaustive `when` in the engine, which is the same gap seen from the
other side. A negative depth drops the frame rather than feeding nonsense
to a badge; the next broadcast carries the real value.
W1 `status.preview`: not modelled at all, so a remote approval was BLIND —
the gate could show that `Bash` wanted to run, but not what it wanted to
run, and the same for an Edit's diff. Approving blind defeats the point of
the remote gate, which exists so whoever is holding the phone can make an
informed call. `ApprovalPreview` now models both variants and
`GateState.preview` carries it to the UI.
Two decode rules are load-bearing and pinned by tests:
- A broken preview must never cost the FRAME. `pending` is what makes the
gate appear at all, so trading a missing preview for a missing gate
would be a bad bargain. Every malformed shape — null, non-object, no
kind, unknown kind, command without text, diff without a file — degrades
the preview to null and still yields the Status.
- Unknown values are kept, not coerced. A diff line kind or file status
this client has never seen renders as itself; dropping a line we cannot
classify would hide part of what is being approved. Only a diff file
missing its paths is dropped, because then the preview cannot be trusted
to describe the right file.
A sustained pending refreshes the preview without minting a new epoch: the
server's bounded render can arrive a frame after the gate rises, and a
decision tapped in between has to stay valid.
The preview's diff subset lives in :wire-protocol rather than reusing the
:app diff model — :session-core may not depend on :app, and new wire types
belong in the frozen contract module (CLAUDE.md).
Verified: :wire-protocol:test :session-core:test + both koverVerify green;
16 new tests (10 codec, 6 engine-level).
The client built to an APK and 617 JVM tests passed, but nothing had ever
run on hardware, and three defects made it unusable the moment it did. All
three were invisible to the JVM suite because none of those tests
instantiate an Android View.
1. Every key press crashed. `RemoteTerminalView` bound the forked emulator
but never installed a `TerminalViewClient`, and the stock Termux view
dereferences `mClient` with no null guard on the first line of the paths
a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
offset 0, `onKeyUp`, `onKeyPreIme`). Installing any client is not enough:
returning false continues into `mTermSession.write()` at offset 150, and
`mTermSession` is null forever because `TerminalSession` is final and
forking a process is what this app must not do. So the client consumes
the key itself and `KeyRouting` has no defer-to-stock branch at all —
the crash is unrepresentable, not merely avoided. Termux's `KeyHandler`
stays the authority for the key tables, so DECCKM still emits `ESC O A`.
2. The PTY never learned the real size. Nothing called
`RemoteTerminalSession.updateSize`, and the stock fallback is dead
(`TerminalView.updateSize` early-returns without a session), so every
full-screen TUI rendered into 80x24. `TerminalResizeDriver` now drives
it from real cell metrics — read through the public `getFontWidth()` /
`getFontLineSpacing()` accessors rather than reflection, which would
break silently under R8, or a re-measured Paint, which would drift from
what the renderer actually draws (plan risk R5).
3. Bare-LAN connections were impossible. `network_security_config.xml` was
a self-labelled STUB denying all cleartext while `HostEndpoint` accepts
http and derives ws://. The format has no CIDR syntax, so the allowlist
its own comment promised cannot be written; cleartext is permitted in
base-config, the tunnel domain keeps a TLS block, trust anchors are
pinned to system-only, and the guard moves to the §5.4 pairing tiers.
Adversarial review then found three more, and one it got wrong:
- Swipe-scrolling inside an alternate-screen TUI still crashed. The touch
path bypasses `TerminalViewClient` entirely: `doScroll` turns a scroll
into `handleKeyCode` whenever the alternate buffer is active, and that
dereferences `mTermSession`. Claude Code is an alternate-screen TUI, so
this was a crash during normal use on the app's main screen.
`TerminalScrollGesture` claims the vertical drag first and reproduces
all three `doScroll` branches, closing the fling runnable and
`onGenericMotionEvent` with it.
- `autofill()` dereferences `mTermSession` unguarded while the view
advertises itself autofillable, so any password manager crashed it.
The subtree is excluded from the autofill structure.
- The review asked for stock text selection to be restored. It cannot be:
the ActionMode's own Copy and Paste handlers dereference the absent
session, so a reachable selection UI has an NPE on its Copy button.
Long press stays consumed and copy-out is recorded as a feature gap.
Also: the WEBTERM_TOKEN cookie is carried on REST and the WS upgrade and
sealed with Tink AEAD under an AndroidKeyStore key (fail-closed — a seal
failure persists nothing rather than falling back to plaintext), and
`allowBackup=false` keeps a 30-day shell credential off Google Drive.
Verified: ./gradlew test :app:assembleDebug -> 757 JVM tests, 0 failures.
Nothing here is device-verified yet; that is the next step.
margin-left:auto was correct for the old single-line row, where it pushed the
path to the far right of a flex row. The two-line card stacks branch over path
in a column, so the same declaration shoved the path to the opposite edge from
the branch it labels. Removed, with a note saying why it must not come back.
Same shape as the .proj-wt-row duplicate earlier in this series: an element
reused in a new layout kept a positioning declaration that only made sense in
the old one. Caught by screenshot; the DOM assertions could not see it.
Two things, both invisible in code review and obvious on screen.
1. Eight controls in the project detail had class names but no CSS rule at
all, so they rendered as raw user-agent widgets — white input boxes and grey
buttons sitting inside a dark panel: View Diff, the new-branch input, Create
Worktree, and the five fan-out fields. Gave them the skin the panel already
established, with two button roles: filled accent for the control that
commits a form (matching Fetch), outline for one that only reveals or
toggles (matching Open). Layout stays in each element's existing rule; this
block sets only the shared skin, so no property is declared twice.
2. The w6 CSS referenced --fg, --fg-dim, --fg-faint, --bg-sunk and --mono,
none of which exist in :root. Every one of those 19 references was silently
falling through to its hardcoded fallback, which happened to match the
default amber theme — so it looked correct while being wired to nothing.
Under any other theme the sync band would have kept the default colours
while the rest of the app changed. Rewired to the real tokens: --text,
--text-dim, --text-faint, --surface-1, --on-accent, and the mono stack the
file already uses.
The fallback values are what hid this: a var() fallback turns a typo into a
silent success. Worth remembering when reaching for one.
Verified: tsc clean, npm test green (78 files / 2161, e2e 27), bundle rebuilt.
Two layout defects the screenshots caught that the DOM tests could not.
1. `.proj-wt-row` was declared twice — my two-line-card rule earlier in the
file, the original flex rule later. Same specificity, so the later copy won
and the Open button sat against the text instead of at the card's right
edge. Folded the grid into the ORIGINAL rule and left a note where the
duplicate was. One rule per selector; a second declaration of a layout
property is a silent override waiting to happen.
2. The unpushed count rendered as its own line under "Recent commits". The
design has it ON the heading, because it qualifies that heading rather than
making a separate statement. renderGitLog now takes the heading element and
writes the badge there, clearing any previous badge first so a re-render
cannot stack duplicates.
Both were invisible to the existing assertions: they checked that the elements
exist and carry the right text, which was true in each case. Added a test for
the no-duplicate-badge path; the right-edge alignment is a pure CSS outcome and
is verified by screenshot, not by the suite.
Closes the three deviations between the shipped git panel and
docs/mockups/project-detail-git.html: the four-cell sync band with its
explanatory footnotes, the dirty count on the title line, and worktree rows as
two-line cards with a working Open button. Adds structural assertions so
layout drift is caught by the suite, not by eye.
The shipped panel was functionally right — every number matched git — but it
was not the approved design. I implemented from the plan's semantics (which
state is green, when to flag stale) and let the mock's layout drift, and
nothing caught it: the tests asserted behaviour, and no assertion covered
structure. Three deviations, now closed against docs/mockups/project-detail-git.html.
1. The sync band was compressed into one row of chips. Restored to the four
labelled cells: Upstream / To push / To pull / Fetch, with display-size
numerals and a footnote under each count. The footnotes are load-bearing,
not decoration — "Last fetched 19d ago — this number cannot be trusted" is
what tells a reader WHY the zero is suspect. Compressed to
"fetched 19d ago", the causal link was left for the reader to infer, and
not having to infer it is the entire point of the panel. The unverified
state is a chip on the count again, so the doubt attaches to the digit.
2. The working-tree count sat inside the band. Moved to the title line beside
the branch, where the design puts it: the band is about the remote, and the
dirty count is not. Reads "● 3 uncommitted" instead of a bare dot; the dot
remains as the fallback when a server sends no count.
3. Worktree rows were the old single line with the path right-aligned against
the chips. Rebuilt as the two-line card the design calls for — identity and
state on top, path underneath — plus the Open button, which was specified
and simply missing, so a worktree row could be read but not entered. Open
reuses the detail view pointed at the worktree path: a linked worktree is a
project directory, so this needs no new concept and no new route.
Also added the unpushed count beside the commit-list heading, so the number is
readable before the eye walks the rows hunting for rails.
Tests now assert structure, not just behaviour: cell captions exist, the stale
footnote says why, counts render at display size, the path is outside the chip
row, and Open fires with its worktree. That is the gap that let the drift
through, so it is the gap that is now covered.
Verified: tsc and build clean; npm test green (unit 78 files / 2159, e2e 27).
Adds the sync band (upstream, ↑ to push, ↓ to pull, staleness), the unpushed
boundary in the commit list, a reworked worktree panel with per-row state and
session counts, and a read-only POST /projects/git/fetch so the numbers can be
refreshed without a pull.
The governing rule throughout: only one state may look reassuring — even with
upstream and a fetch inside the hour. behind is derived from a locally cached
ref, so a stale zero is a guess and is marked as one; no upstream renders as
'no upstream', never as synced.
Also fixes the test suite's long-standing flakiness (5-11 shifting failures per
run) so the feature could actually be gated on it.
`npm test` was failing 5-11 tests per run with the set shifting between runs,
which made it useless as a gate. Two independent causes, neither of them a
product defect.
1. Fixtures had outgrown vitest's 5 s default. The git ones spawn 6-12
sequential `git` processes (init/config/commit/clone/push); the real-server
ones boot a server and a shell. Alone they finish easily; under a full
parallel run they do not, and the failure reads `Test timed out in 5000ms`.
Gave those describes an explicit 30 s ceiling, and the real-PTY waits a
named PTY_WAIT_MS. The deliberate short bounds in the "must be rejected"
handshake tests are left alone — there a timeout IS the assertion.
(The same rebudgeting also touched the test files in the preceding commit.)
2. test/integration/server.test.ts cannot share the machine with the rest of
the suite. It asserts on real prompt output within seconds, which is not
achievable while ~8 workers saturate the box: it passed alone (27/27,
repeatedly) and flaked in the full run. Raising the numbers further only
moved the flake around, so this is fixed as a scheduling problem — `npm
test` now runs two passes, `test:unit` (everything else, parallel) then
`test:e2e` (that file on its own). `vitest run` still runs everything at
once for anyone who wants that.
Also bounded the srv.close() in H1's finally. Unbounded, it swallowed whatever
really went wrong in the body — the inner waits threw, control jumped to the
finally, close() blocked, and the case reported a bare timeout instead of its
own diagnosis. The root-causing above only became possible after making that
failure legible.
Note on the `[needs real PTY (sandbox-off)]` labels: those cases were NOT
skipping here. PTY_AVAILABLE is true on this machine, so they were running and
failing on timing, not being gated out by a sandbox.
Verified: npm test green end to end across repeated runs (unit 78 files /
2147 tests, e2e 27).
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.
One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:
- `ahead` needs only local refs and is never flagged.
- `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
- Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
"I checked, ignore this"; getting it wrong is lying to the user.
- No upstream (the normal state of a fresh worktree branch) leaves ahead and
behind undefined — it renders an explicit `no upstream`, never the green
path. That fall-through is the easiest bug to ship here.
What landed:
G1 SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
and .dirtyCount. All additive and optional — the Android and iOS clients
decode these shapes. The ahead/behind helper already existed for the list
view; buildProjectDetail had simply never called it.
Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
directly, so it returned nothing inside a LINKED worktree, where .git is a
file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
the shared common dir (FETCH_HEAD).
G2 POST /projects/git/fetch. Same discipline as push: the remote is derived
server-side and no remote or refspec is ever read from the body, so a
client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
working tree, no index, no merge; it is not a pull. Own rate-limit bucket
so refreshes cannot eat the budget a real push needs. On failure
lastFetchMs is left alone, so the UI keeps saying "stale" instead of
pretending it refreshed.
G3 makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
dirty count, Fetch button (disabled on a detached HEAD).
G4 The commit list marks unpushed commits and draws the upstream boundary
once, after the last of them. Marking is server-side from `rev-list`,
deliberately NOT "the first N rows": `git log` is date-ordered, so merging
an older branch interleaves unpushed commits BELOW pushed ones, and that
shortcut fails in the dangerous direction — calling an unpushed commit
pushed. A regression test builds exactly that backdated-merge shape.
G5 The worktree section is always "Worktrees (n)" (it used to rename itself
to "Branch" at n=1) and the current row carries its own state chips.
G6 Cost control. The plan called for a .git-mtime cache; that was dropped
during implementation because a fingerprint over HEAD/index/reflog does
NOT move when push updates a remote-tracking ref — the cached `ahead`
would still claim "9 to push" right after a successful push, which is the
exact lie the feature exists to prevent. Replaced with three measures that
cannot go stale: in-flight coalescing (N devices watching one repo cost
one probe, entry dropped as it settles, nothing cached across time),
skipping the re-render when the payload is byte-identical (this also stops
the 5 s re-mount of the commit log, two more git spawns per tick), and
pausing the timer while the document is hidden.
G7 Per-worktree state via GET /projects/worktree/state, kept narrower than
/projects/detail so N rows do not pay for worktree listing and CLAUDE.md
reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
carried no cwd, so sessions could not be attributed to a worktree. Added
it, plus countSessionsByWorktree, which matches DEEPEST-first because
.claude/worktrees/<name> lives INSIDE the main checkout and prefix
matching would count every worktree session against the parent repo too.
Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.
Verified: tsc and build clean; 46 new tests.
`worktree.baseRef: head` is the setting CLAUDE.md's session workflow depends on:
without it EnterWorktree defaults to `fresh` and branches from `origin/main`,
which trails `develop` by ~75 commits. Shared config, so it belongs in the repo —
personal overrides still go in the ignored `.claude/settings.local.json`.
Sessions that change files now work in their own git worktree and merge back to
`develop`, instead of developing directly in the main checkout. Documents the
tool's actual behaviour rather than the assumed one: `EnterWorktree` prefixes the
branch as `worktree-<name>`, branches from the last commit (so uncommitted edits
do not carry over), and `ExitWorktree({action: "remove"})` deletes the branch
with the directory — hence the merge must happen before the cleanup.
Base ref is pinned to `head` in `.claude/settings.json` because `develop` runs
well ahead of `origin/main`, and the default `fresh` would branch from the stale
release trunk.
Also ignores `.claude/worktrees/`, which lives on disk per session and is never
committed.
Closes the three leftovers called out after the renewal-deadlock fix: a source
file the blanket `dist/` ignore rule kept out of every checkout, tunnel logs
that named no host (including all 6380 warnings during the outage), and the
missing phone-track counterpart to /recover.
1. `.gitignore` swallowed a source file. `agent/src/dist/buildBinary.ts` is the
packaging config, not build output, but the blanket `dist/` rule meant it was
never committed — a fresh clone could neither typecheck `agent/src/index.ts`
nor import it from the committed `agent/test/buildBinary.test.ts`. Re-included
the DIRECTORY first (git does not descend into an excluded one, so un-ignoring
just the file would not have worked) and committed the file. Verified build
output under `agent/dist/`, `dist/`, `public/build/` is still ignored.
2. Tunnel logs named no host. `pair` learned hostId/subdomain from the enroll
response and threw them away, so the long-running `run` process logged
`{"subdomain":null,"hostId":null}` — including all 6380 warnings during the
8-day outage, at exactly the moment you want to know which host. New
`config/hostRecord.ts` persists them at enrol; `resolveHostIdentity` resolves
config > record > the subdomain embedded in the leaf's SPIFFE SAN, so hosts
enrolled before the record existed get an identifier back without re-pairing.
3. The phone track had no recovery path. Added `POST /device/:id/recover`,
mirroring the host route: cert in the body, device-CA path validation, SPIFFE
parse, `notBefore` never graced, and the full registry check (active + same
account + `:id` matches the cert + same key) — only `notAfter` is relaxed.
Verified: agent 300/300, control-plane 296/296, tsc clean on both.
NOTE: the iOS/Android clients are not wired to call /device/:id/recover yet —
the server capability exists, the client-side trigger does not.
The tunnel had been down 8 days because /renew is mTLS-authenticated by the very
leaf it renews, so a lapsed leaf could never renew itself. Recovery now runs over
plain HTTPS with the expired cert in the body (CSR self-signature still proves key
possession), bounded by a 30-day grace; past that the agent stops retrying and
names the re-pair. Verified live end-to-end: an 8-day-expired leaf self-healed and
the tunnel returned HTTP 200 with no human action.
They were local worktree conveniences (symlinks into the main checkout so vitest
could resolve the file: workspace deps), swept in by a broad `git add agent`.
Correction to the previous commit. Its recovery vhost could not work: nginx
will not forward an expired client certificate in ANY `ssl_verify_client` mode.
`optional` answers a bare `400 The SSL certificate error` before any location
runs, and `optional_no_ca` only tolerates CHAIN failures — see nginx's
`ngx_ssl_verify_error_optional()`, which covers self-signed / unknown-issuer /
unverifiable-leaf and NOT `X509_V_ERR_CERT_HAS_EXPIRED`. Verified live against
the deployed :8472 server, which rejected the real expired leaf.
So recovery drops mTLS instead of trying to bend it:
- agent: `buildTlsOptions` and the mTLS renew transport go back to being
strictly fail-closed on expiry — the relaxation is gone from the TLS layer
entirely. The rotator now decides per attempt: valid → mTLS `/renew`,
expired-inside-grace → plain `/recover`, expired-beyond-grace → terminal
`onExhausted` with no request issued at all.
- new `recoverCert` POSTs `{cert, csr}` with no client certificate. Possession
of the private key is still proven: the CSR is self-signed by it and the host
signer already enforces CSR PoP plus `CSR key == registered key`, so a
replayed (public) cert without the key yields at most a certificate the
attacker cannot authenticate with.
- control-plane: `/renew` is strict again (grace 0, matching the terminator).
The grace lives on the new `POST /recover`, which reads the cert from the BODY
and ignores the `x-client-cert` header, then runs the identical trust
pipeline: X.509 path validation to the frp-client-CA anchors, SPIFFE parse,
`notBefore` (never graced), registry `active` + account match. Revocation
still bites.
- deploy: no new vhost, no new SNI, no DNS. One `location = /recover` merged
into the existing enroll vhost, documented in
`deploy/nginx/enroll-recover-location.md` with the nginx source citation.
The load-bearing new test is "a self-signed cert with a FORGED SPIFFE SAN is
refused → 401": nginx no longer validates the chain on this path, so that
assertion is what keeps `/recover` from being a cert vending machine.
Verified: agent 289/289, control-plane 290/290, tsc clean on both.
`POST /renew` is authenticated by mTLS with the very leaf it renews, so once
that leaf lapsed the host could never renew it and the tunnel stayed down until
an operator re-paired by hand. Production hit exactly this: the Mac slept
through its 8h renewal window, the 24h leaf expired, and the agent then logged
`client certificate has expired; renew before dialling` 6380 times over 8 days
without recovering.
Three layers independently refused an expired leaf, so all three had to move:
- agent `buildTlsOptions` gains an opt-in `expiredGraceMs`. Absent/0 keeps the
historical fail-closed behaviour, and the TUNNEL dial never passes it — only
the renew transport does. Past the window it throws the new
`CertExpiredBeyondGraceError`.
- agent rotator routes an already-expired leaf to a separate recovery endpoint
and treats "beyond grace" as TERMINAL: report once via `onExhausted`, stop
retrying, and name the fix (re-pair) instead of spamming warnings forever.
- control-plane `assertPresentedCertTrusted` grants a bounded grace on
`notAfter` only. Chain validation, SPIFFE identity, `notBefore`, and the
registry `active`/account checks are all unchanged, so revocation still bites.
- new `deploy/nginx/recover-mtls.conf` (:8472). The strict enroll vhost cannot
host this: under `ssl_verify_client optional` nginx answers a bare 400 as soon
as a presented cert fails verification, so an expired leaf never reaches the
location — and the directive is server-level, not per-location.
Grace defaults to 30 days on both ends and is configurable (`RECOVER_URL`,
`expiredRenewGraceMs`). The honest trade is recorded in the code: a stale stolen
leaf stays reusable for the window, which widens an existing exposure (an
unexpired stolen leaf already renews indefinitely) rather than opening a new one.
Verified: agent 296/296, control-plane 286/286, tsc clean on both.
A launchd/Finder-launched server has no LANG/LC_* at all, so tmux — which
decides per client whether the terminal is UTF-8 capable purely from those
variables — fell back to its non-UTF-8 mode and rewrote the byte stream
server-side, before it ever reached xterm:
- every wide character became `_` (中文测试 → ________)
- `✓` / `⏺` / emoji became `_`
- `═║╔╗` was downgraded to DEC Special Graphics (ESC ( 0), so rounded
corners vanished and boxes rendered as loose horizontal lines
The `-l` login shell cannot fix this: tmux is the PTY's root process and has
already made its decision by the time the shell sources ~/.zprofile.
withUtf8Locale() fills a locale in on the spawn env, and only when needed —
an existing UTF-8 LC_ALL / LC_CTYPE / LANG (zh_CN.UTF-8, ja_JP.UTF-8, …) is
left untouched; only a missing or non-UTF-8 charset is replaced.
Existing tmux sessions do not need recreating: re-attaching with a UTF-8
client restores correct output (already-scrolled `_` stays mangled).
- wire the enroll flow into the app UI (EnrollmentScreen + ViewModel + Hilt DI +
host-menu "自动获取证书"), mirroring iOS — Android previously only had manual
.p12 import; the enroll library was built but unreachable.
- renew is now mTLS-only ({csr}-only body, no Authorization header) matching the
/device/:id/renew contract (the enroll bearer is minutes-lived → silent
rotation would have thrown weeks later).
- enroll refreshes the identity-repository cache so a mid-session-enrolled cert
is presented on the next mTLS handshake without a process restart.
gradle :app:assembleDebug + api-client/client-tls-android unit tests + koverVerify
green. On-device QA (keygen/enroll/present) is the operator's step.
launchd has no default log destination (unlike systemd's journald), so a unit
that fails to start (bare node, missing env) was silent — which hid the EX_CONFIG
+ loadConfig failures during this deploy. Route both units' stdout+stderr to
<stateDir>/{base-app,agent}.log. 281 tests pass.
Two real-deploy renew bugs (found running the live /renew):
- the /renew mTLS request pinned the enroll caChain as the server CA → TLS
'unable to get local issuer certificate' (the LE-fronted CP is publicly
trusted). Verify the server against SYSTEM roots (drop ca), keep the client
cert + rejectUnauthorized:true. (TlsClientOptions.ca now optional.)
- renewCert parsed {cert, caChain:string}, but the CP returns cert=base64(DER)
+ caChain=base64(DER)[]; normalize to PEM (shared certs/pem.ts, reused by
native enroll). Verified live: cert rotated 13:41→next-day, frpc restarted,
tunnel stayed up. 281 tests pass.
nginx has no njs deployed, so the mTLS terminator forwards the verified client
cert via $ssl_client_escaped_cert (URL-encoded PEM). headerPresentedCert now
normalizes base64-DER, PEM, and escaped-PEM alike to raw DER. 281 tests pass.
Fixes found deploying `pair --install` against the live control-plane:
- native enroll tolerates hostContentSecret:null and converts the base64-DER
cert + caChain[] the CP returns into PEM for frpc/keystore
- launchd/systemd units use an absolute node (process.execPath) + a PATH with
/usr/local/bin (bare `node` died with EX_CONFIG 78)
- the agent unit now carries ENROLL_URL/RELAY_URL/STATE_DIR/LOCAL_TARGET_URL
(`run` loadConfig()-validates them; missing → supervisor exited 1)
281 tests pass. Follow-up: add unit tests for the native-enroll tolerance +
agent-unit env (only install node-path is covered so far).
Wire the prod boot to issue frp-client + device leaves from the existing on-disk
P-256 CAs: NATIVE_* env vars, a file-backed KmsResolver (loads the PEM key,
validates prime256v1, never logs key material), buildFileBackedNativeCas threaded
into buildControlPlane via a nativeCas override. Fail-closed on partial NATIVE_*.
281 tests pass.
When the always-on tunnel daemon already serves :3000, the app reuses it
(close() is a no-op) rather than spawning a second server on another port, so
the GUI and the tunnel share one base app.
Wire the SecureEnclave enroll library into a real flow (login->bearer->CSR->
/device/enroll->keychain identity), presented on the existing mTLS path; add a
rotation scheduler. Atomic keychain replace (add-before-delete); renew body is
{csr}-only; renewal-failing surfaced in the UI. ClientTLS 48 tests pass.
Wire createCertRotator/renewCert into superviseNative so the frp-client cert
renews silently at ~2/3 TTL over mTLS /renew and frpc restarts onto the fresh
leaf; failures retry with backoff, never crash the supervisor. mTLS transport
has a 15s timeout + 64KB response cap. 281 tests pass.
Unblocks the phone-enrollment track: an operator-password login mints a
short-lived device:enroll capability token that POST /device/enroll requires.
Constant-time (SHA-256 fixed-length) compare, per-client rate-limit, fail-closed
when unset. 260 tests pass.
Execution plan grounded in current VPS reality: pre-flight checks, Track A
(host auto-enroll via the agent) and Track B (phone auto-enroll), risks, and
the honest one-bootstrap-tap constraint. R1 (CA material) verified present on VPS.
An OPTIONAL shared token so the app can be used off-LAN (via the relay/tunnel) more
safely than "anyone who reaches the port gets a shell". Sits IN FRONT OF the existing
Origin/CSRF model — never replacing it — and is fully inert when unset.
- src/http/auth.ts (new, pure): constantTimeEqual hashes both inputs to sha256 (32
bytes) then crypto.timingSafeEqual — no `===`, no length side-channel, never throws.
parseCookieHeader / cookieIsAuthed / buildSetCookie / isAuthEnabled.
- WEBTERM_TOKEN in config: 16–512 URL/cookie-safe chars or the server refuses to start.
- GET /?token=<t> or POST /auth (rate-limited 10/min) validates → sets
HttpOnly; SameSite=Strict; Secure-when-https cookie; public/login.html (no <script>).
- When enabled: the WS handshake (AFTER the Origin check, before handleUpgrade) + a
central authGate over all remote HTTP require the cookie. Open: /login, /auth.
Loopback bypass scoped to /hook* ONLY (tighter than the plan — gates the local
browser too).
- Unset ⇒ isAuthEnabled false ⇒ pure passthrough (LAN zero-config unchanged).
Honest tradeoff (in code + CLAUDE.md + login page): a bar-raiser, NOT a TLS/Tailscale
substitute — on bare ws:// the token is cleartext + replayable; only hardens the
TLS-terminated relay path. Verified: typecheck + build:web clean, 2118 pass at
--test-timeout=30000 (disabled-mode regression proven; only the known tmux flake red);
auth.ts 100% line coverage.
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.
- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
"grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
+ caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
+ extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
One-click merge deferred (winner session stays open for a manual merge).
Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).
- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
throws). Path containment: every files[] entry realpath-contained under the repo,
argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
single -m argv. Push: current branch only — has-upstream → plain `git push`;
no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
+ isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
(all text via textContent; re-loads the diff on success).
Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.