38274a527158882704a2be16e919b890a2a68405
251 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
38274a5271 |
fix(projects): style the diff panel — 52 classes had no CSS at all
The project-detail Git panel's diff viewer emitted 52 class names that style.css had no rule for, so it rendered as browser defaults: Working / Staged / the base picker / ✕ Close as native OS buttons on a dark warm panel, and a diff body with no monospace font and no +/- colouring at all. The user's screenshot showed the toolbar; the audit (literal AND template-literal class names) found the rest. Adds the B1 diff-viewer block and the proj-*/tl-* rules, all on :root tokens, following the file's existing two-button-role doctrine. Also fixes, in passing: - .df-commit-btn hardcoded #1a1712 where THEME.md mandates var(--on-accent) for text on a gold background - .proj-pr-hidden — a "hidden" state class with no rule, so it never hid - .proj-dot-stuck — ClaudeStatus has 'stuck' but only four of the five dots were styled, so a stuck session was the same colour as "no signal" - --warn-wash: a derived token for the deletion wash, documented in THEME.md, so a re-skin moves the wash with --warn Notable decisions, recorded in the CSS comments: - .proj-tl-container gets NO height cap. Every capping mechanism tried (overflow-y:auto, mask-image, an ::after fade, a :has() row gate) cost more than the height it saved — the 5s wipe-and-re-append render loop cannot host a scroll position, and mask-clip:border-box ate the well's own border. It grew freely before this CSS existed; maxEvents=50 bounds it. - The horizontal scroller stays per-hunk on .df-lines. Hoisting it to .df-file would drag the file path out of view when you scroll right. - .df-file > .df-file-header keeps a smaller unscoped padding: the terminal page's .approval-preview also calls renderDiffFile(). Verified against the real DOM in a browser at 900px and 390x844 via a throwaway harness (removed before commit), not from the CSS text alone. typecheck exit 0; 2216 unit + 27 e2e tests pass. |
||
|
|
576c033031 |
Merge orphan-parity-plan: schedule the orphan tmux session client work
Some checks failed
android / build-and-test (push) Has been cancelled
android / instrumented-tests (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (ClientTLS) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (iPad Pro 11-inch (M4), -skip-testing:WebTermTests/LiveServerSmokeTests, ipad, false) (push) Has been cancelled
ios / app-tests (iPhone 16, , iphone, true) (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (iPad Pro 11-inch (M4), ipad, -only-testing:WebTermUITests/ProjectsLayoutUITests) (push) Has been cancelled
ios / ui-test (iPhone 16, iphone, ) (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
|
||
|
|
f000a9d979 |
docs(plan): schedule the orphan tmux session client parity work
The server half shipped 2026-07-30 as feature A (4892fa7/f6ef19e/d39a0ab) and both native clients are at zero. This is NOT a regression — OrphanSessionInfo is deliberately a separate type rather than a flag on LiveSessionInfo, and src/server.ts:600 names the reason: the clients decode /live-sessions, so a variant shape there would have broken them. It is worth planning anyway because SessionManager.shutdown only detaches the tmux client for tmux-backed sessions (manager.ts:410-417), so a server restart or a desktop-app quit turns every session into an orphan. The web launcher lists them and re-adopts on tap; the phone still shows an empty list — which is the walk-away case this product exists for. The plan doc carries the eleven traps a client implementer will hit, each one a shipped review finding or a documented server subtlety — notably that preview returns cols/rows of literally 0 (both clients currently size thumbnails from that response), that a 429 must not collapse to "no orphans", and that the destructive confirmation must come from count-idle rather than card count (that one nearly ended 33 shells while the dialog said 24). Also corrects two now-false claims found while cross-referencing: the ROADMAP row for tmux discovery was half-stale, and ANDROID_CLIENT_PLAN's do-not-consume list still listed POST /projects/worktree (both clients consume it now) and GET /sessions (iOS consumes it, Android does not — so "match iOS" now argues for building it, not against). |
||
|
|
97e39949a8 |
Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
Closes the six remediation items from the 2026-07-29 iOS completion audit plus the whole P2 wave and Android access-token parity. The audit's headline was that the client was code-complete but stuck at the device door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware once, and it had fallen two months behind the server (Android had the git panel, iOS had none) while neither native client could connect at all once WEBTERM_TOKEN was set. Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49% and is now actually in the coverage gate, which it never was. Device build now succeeds on the free personal team. src/ and public/ are untouched — the git-panel endpoints already existed server-side; iOS simply never consumed them. # Conflicts: # android/.gitignore # android/README.md # android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt # android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt # android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt # android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt # android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt # docs/PROGRESS_LOG.md |
||
|
|
ddab77b337 |
test(ios): close T-iOS-32's end-to-end half — real worktree lifecycle against a real server
T-iOS-32's acceptance is "builder 测试 + 端到端一次". The builder half was green, but grep for projects/worktree across the whole test tree returned nothing — no test had ever created or removed a real git worktree against a real server. Six tests on a throwaway git fixture: create/remove/prune verified against both the filesystem and git's own `worktree list --porcelain` + .git/worktrees/, and asserting the values the SERVER derives rather than echoing our input (branch feature/e2e-worktree becomes directory feature-e2e-worktree; the raw branch name must never appear in the path). Includes a differential leg for the guarded-route rule — the same request without Origin is 403 with zero side effects, and with it is 200 — which needs raw URLSession, since APIClient structurally cannot emit an Origin-less guarded request. GET /sessions gets its own HOME-isolated server: history.ts reads os.homedir()/.claude/projects, so against the shared harness the assertions would land on the developer's real Claude history, and in CI the directory is absent so it degrades to [] and proves nothing. Integration tests 26 -> 32, independently re-run. |
||
|
|
5cc755b0b6 |
fix(ios,android): close the acceptance gaps the review found, add Android CI
- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped — recorded as a withKnownIssue rather than fixed. Height now derives from the content size category (and tracks live changes via registerForTraitChanges); the known-issue marker is gone, replaced by positive assertions including a 12-category no-clip sweep and a guard that stays red if anyone writes the constant back. Honest tradeoff: the keycap font is clamped at .accessibility2, the same policy the design system already applies to dense content, because an unclamped AX5 bar would eat the terminal. A test pins the clamp so the two cannot drift. - Thumbnails silently 401'd on a token-gated host: the pipeline built its own transport with no token source, and by design never throws, so every preview degraded to a placeholder with no signal. Assembled from AppEnvironment now. - Android had zero CI while the token wave shipped 24 files of secret-handling code. The instrumented leg is workflow_dispatch-only and says why in the file: no one has ever seen it green on a runner, and a required leg nobody trusts just produces a false green. - Android persisted a validated token before the host probe succeeded, stranding a secret for a host that never paired. App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691. |
||
|
|
822364d12b |
test(server): prove the H1 precondition instead of assuming it
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
`H1 shell state survives a server restart` failed in the full parallel suite
and passed in isolation, which reads like a timing flake in the assertion. It
was not. Polling for the marker instead of sleeping a fixed 900ms showed the
shell replying promptly with:
echo MARK-$WEBTERM_MARK
MARK-
An EMPTY variable. The post-restart shell was fine; the variable had never been
set in the PRE-restart shell, because a fixed 500ms is not enough for the shell
to become ready and run the assignment when 81 test files are competing for the
machine. The symptom then appears at the far end of the test and reads as "the
tmux session did not survive the restart", sending whoever debugs it after
entirely the wrong bug.
So the setup now proves itself: it echoes the variable back and waits for
`SET-tmuxlives` before restarting, and fails there with "the variable was never
set in the pre-restart shell" if it did not take. The final assertion likewise
waits for its marker with a bounded timeout rather than guessing a duration.
Neither wait can mask a genuine failure: if the tmux session really did not
survive, the shell is fresh, the variable is empty, and the marker never appears
no matter how long we wait.
Added a `waitUntil` helper for this. It carries its own sleep because the
`delay` the cases use is a local const inside a test body, not module scope.
Verified: full `npx vitest run test/` -> 81 files, 2243 tests, 0 failures.
NOT fixed, and not mine to guess at: `server.test.ts` has at least one more
real-PTY case that flakes under the same parallel load (⑤ attach → attached →
shell prompt output times out in `waitForMessage`, then its afterEach hook
times out too). It passes in isolation. Same class of fixed-delay assumption,
different case.
|
||
|
|
217206e9a9 |
Merge android-blocker-fixes: make the Android client work on a device
The client reported COMPLETE with 617 green tests and a buildable APK, but had
never run on hardware, and the first interaction on a device crashed it. Seven
real defects, four of them crashes:
- every key press NPE'd (no TerminalViewClient was ever installed)
- the PTY never learned the real grid (no resize was ever sent)
- bare-LAN ws:// could not connect (a STUB network-security-config)
- swipe-scrolling inside an alternate-screen TUI NPE'd, i.e. during ordinary
use of Claude Code, which is one
- any password manager crashed the app (unguarded autofill)
- onDraw tore the terminal buffer and killed the process — the defect the docs
had explicitly classified as 'Accepted (not a defect)', and which was dormant
only because resize never happened
- text selection's Copy button was an NPE, so copy-out was cut rather than
shipped broken
Also: the queue frame and status.preview were silently dropped (remote approvals
were blind), the v0.6 git panel was unrendered, ThumbnailPipeline/QuickReply/
PointerContextMenu were tested dead code with no call sites, the WEBTERM_TOKEN
gate was inert, release builds emitted unsigned APKs, and A34/A35 had no code at
all despite being marked done.
Then the audit's own leftovers: silent certificate rotation (which surfaced a bug
that would have failed every renewal, plus a latent iOS defect Android now
avoids), /device/:id/recover, the W2 inject-queue routes, unread-watermark
persistence, host removal, and /config/ui finally honoured fail-closed.
617 -> 1150 JVM tests, plus 67 instrumented tests that actually execute on an
emulator against this repo's own server. The device crashes are reproduced as
JVM tests where possible, so they are guarded without hardware.
Still open and recorded, not papered over: S2 needs two physical handsets under
Doze; the rest of DEVICE_QA_CHECKLIST has no automated cover.
# Conflicts:
# docs/PROGRESS_LOG.md
|
||
|
|
284cfd193a |
feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests. |
||
|
|
14f28e9d66 |
docs(android): record what the device run proved and what is still open
The correction section written earlier today said A34/A35 had no code and device QA was ~0%. Both were true when written and are not any more, so they are marked resolved rather than left to mislead the next reader — with the instrumented invocation recorded (the emulator needs ALLOWED_ORIGINS=http://10.0.2.2:<port>, because allowed origins are derived from NIC IPs and 10.0.2.2 is not one). What stays open is stated plainly: S2 needs two physical handsets under Doze, the rest of DEVICE_QA_CHECKLIST has no automated cover, and one instrumented test kills the process when driven without host arguments on a heavily-recycled emulator while passing reproducibly under the documented invocation — which is recorded as unresolved rather than explained away. |
||
|
|
390dd11202 |
feat(android): close the audit's remaining parity gaps
Seven items the earlier repair waves deliberately left alone, so that the
blocker fixes could land with a working safety net first.
CERTIFICATE LIFECYCLE (the audit's only "high"). iOS ships a
CertificateRotationScheduler; Android had no counterpart, so a device
certificate simply expired and needed a manual re-enroll. The decision is a
pure total function over five answers, with RE_ENROLL_REQUIRED checked first so
it outranks any backoff. Renewal is single-flight — a second trigger coalesces,
and a cancelled leader releases the slot so cancellation cannot wedge rotation
for the process lifetime. Failure leaves the prior identity fully live (the
existing atomic ping-pong flip already guaranteed that) and is published rather
than swallowed.
Two real defects surfaced while building it:
renew() decoded its 201 with EnrollResponseDto, whose deviceId is required —
but /device/:id/renew returns only {cert, caChain, notAfter}; only
/device/enroll echoes deviceId. Every silent renewal would have thrown
MalformedResponse. Fixed with a RenewResponseDto plus the deviceId from the
path segment we addressed.
And because no renew 201 carries renewAfter, a client that persisted it would
rotate exactly once and then report not-due until the cert died. That is why
renewAfter is re-derived from the live leaf as notBefore + 2/3·lifetime — the
control plane's own formula. This is a latent iOS defect that Android now
avoids rather than inherits.
/device/:id/recover was confirmed real (control-plane/src/api/renew.ts:443,
contract pinned by that suite's CP6e) and is now wired. It goes over a separate
NON-mTLS client that throws rather than falling back, because an expired client
certificate cannot authenticate its own recovery — the deadlock a previous
session already hit and recorded.
Asked explicitly about step-up: rotation is unaffected. stepUp appears nowhere
in control-plane/src; it lives only on the relay's WebSocket upgrade. On a
step-up host the SESSION is denied, not the rotation, so the certificate still
stays alive and that principal gap is orthogonal.
FOLLOW-UP QUEUE (W2). The queue frame decoded but the three routes managing it
were missing, so Android could see "N queued" and not queue anything. One
outcome union covers both guarded writes and distinguishes full / too-large /
disabled / session-gone / rate-limited, because they need different copy. 429 is
surfaced and never auto-retried — POST and DELETE share one 20/min bucket.
Queued text is raw shell input and travels verbatim, proven with control
characters and CJK.
UNREAD WATERMARK now persists, so the unread state survives process death —
which is exactly when it matters, since the product premise is walking away and
coming back. It stores a plain map rather than an UnreadLedger: the reducer
lives in :session-core, which :host-registry may not depend on, so the logic is
not restated anywhere.
COPY-OUT. Text selection was a recorded feature gap: the stock ActionMode's own
Copy handler dereferences the absent TerminalSession, so making it reachable
puts an NPE on the button — worse than no selection. The refusal was
re-verified from the bytecode, and the same disassembly showed the way out:
TerminalBuffer.getSelectedText touches only mLines/TerminalRow, no session and
no view. A first-party selection layer now builds on that, with a pure
row-major model so a backwards drag normalises correctly. Terminal content
reaches the clipboard only on an explicit user copy; OSC 52 stays declined.
HOST REMOVAL. PushRegistrar.unregisterHost had zero call sites, so a removed
host kept receiving pushes. Removal is now confirm-gated and cleans everything
keyed by host id — a half-removed host is worse than none.
/config/ui is finally honoured. It was implemented and never called, so
allowAutoMode was ignored and the plan gate offered "approve + auto" even where
the operator had disabled it. It fails CLOSED: an unfetchable config treats auto
as disabled, because the permissive default is the dangerous one.
Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 1150 JVM
tests, 0 failures (903 -> 1150). Instrumented suite re-run on emulator-5554
against live servers: 67 tests, 0 failures, 0 skipped — no device regression.
|
||
|
|
8075d2c671 |
test(android): the instrumented suite A34/A35 never had
The plan marked A34 (instrumented E2E against a real host) and A35
(macrobenchmark) DONE. Neither had a single line of code — :app had no
androidTest source set at all, and `testInstrumentationRunner` named a
HiltTestRunner class that did not exist. They had been converted into checklist
bullets. This adds 67 instrumented tests and 3 macrobenchmarks, and every one
of them has actually been executed on an emulator against this repo's own
server — not merely compiled.
Why this layer had to exist: four crash-on-first-interaction defects shipped
behind a green 900-test suite, for one structural reason — no JVM test
instantiates an android.view.View, creates an InputConnection, dispatches a
MotionEvent or runs a layout pass. So the rig builds the REAL RemoteTerminalView
in a REAL Activity and drives it through the REAL framework entry points,
faking only the wire so assertions can be about BYTES rather than "did not
crash". That distinction matters: a no-crash-only test would have passed against
the silent-black-hole variant of the IME bug, which was arguably the worse half
of it.
The four crash regressions (28 tests):
keys — Enter is \r not \n, Ctrl+C is 0x03, Backspace 0x7f, and arrows in BOTH
DECCKM modes driven off the live emulator bit, so ESC[A vs ESC OA is
asserted rather than assumed; BACK falls through and emits nothing; soft
keyboard commitText/sendKeyEvent/deleteSurroundingText and a CJK
composition pass through verbatim.
alternate-screen scroll — every test feeds ESC[?1049h FIRST and asserts the
buffer is active before touching anything, because that is the branch that
used to crash. Real dispatchTouchEvent drags both directions, the DECCKM
form, the mouse-tracking branch emitting a wheel report and NO arrows
(branch order), the wheel's 3-rows-per-notch, and the fling proven not to
start by 800 ms of silence after ACTION_UP.
autofill — the framework's own autofill-structure gate is closed on both the
frame and the stock child, before and after focus.
resize — including the draw-race counterpart of the AIOOBE fixed in
|
||
|
|
538c8ebf34 |
fix(android): move emulator mutation to the render thread
The instrumented suite was run on a device for the first time and the app died
on the first test:
ArrayIndexOutOfBoundsException: length=31; index=31
at com.termux.terminal.TerminalRow.getStyle
at com.termux.view.TerminalRenderer.render
at com.termux.view.TerminalView.onDraw
PROGRESS_ANDROID.md had classified exactly this as "Accepted (not a defect):
steady-state append runs concurrently with the UI-thread onDraw ... torn read
self-corrects next frame". Both halves of that were wrong.
It is not cosmetic. TerminalRenderer.render caches its column bound once from
TerminalEmulator.mColumns and then indexes TerminalRow.getStyle(column) into a
raw long[] sized when that row was allocated. TerminalEmulator.resize publishes
the new mColumns BEFORE resizeScreen() reallocates the rows, so a draw landing
in that window reads the new bound against old-width rows. The first
out-of-range column is exactly oldColumns, which is why the exception was
always length == index — the consistency was a deterministic signature of a
grow, not a rare interleaving. A shrink is harmless, which is probably why
"self-corrects" looked plausible. TerminalBuffer.resize is non-atomic in
several more ways, and TerminalRow.setChar swapping mText for a larger array is
the same hazard on the append path.
Nor does it match upstream. Upstream's background reader only fills a
ByteQueue; TerminalSession$MainThreadHandler is what calls append, and
TerminalView.updateSize resizes — both on the main thread. Upstream has no
concurrent reader at all. There is also no lock to lean on: monitorenter
appears nowhere in TerminalEmulator, TerminalBuffer, TerminalRow or
TerminalRenderer.
The race was dormant until this session. updateSize had no call sites, so
mColumns never changed after bind and there was never a width mismatch to tear
on. Making resize work is what made this reachable — the same lesson as the
rest of this pass: the green suite could not see any of it because no JVM test
instantiates a View.
Fix: every emulator mutation now runs on the render thread, from inside the
same single confined consumer. §6.2 is intact — still one writer draining one
ordered channel, suspending across the main hop, so a resize and an append
still cannot interleave and submission order still holds. They are now also
serialised against onDraw, because one Handler work item cannot run inside
another. Resize still reaches the wire and the §6.4 forced re-assert still
bypasses the dedup.
Chunking becomes load-bearing rather than incidental: each 4 KiB slice is its
own main-thread work item, so a multi-MB ring replay interleaves with frames
and input instead of blocking behind one long call — which is what §6.2's
no-ANR requirement actually needs, rather than "runs off the main thread".
Gating the child's dispatchDraw was considered and rejected: it would have to
cover appends too, leaving a choice between blanking the terminal and blocking
the UI thread on a full reflow. A read/write lock was rejected because append
calls back out through TerminalOutput (title, bell, DA/DSR reply), so holding a
write lock across it would bury a deadlock invariant in app-level callbacks.
The KDoc that made the false claims now states the mechanism with offsets, and
RemoteTerminalHostView records why dispatchDraw is deliberately not gated.
Verified: the device crash is reproduced as a JVM test first (red), so this
regression is now guarded without a device. ./gradlew test :app:assembleDebug
koverVerify -> 903 tests, 0 failures. On the emulator the previously-crashing
test now passes and 5 of 6 alternate-screen scroll tests pass.
|
||
|
|
53bee034ca | docs(progress): log the desktop USE_TMUX default fix | ||
|
|
2a24935b8d | Merge desktop-tmux-default: app-started sessions survive quitting the app | ||
|
|
5833529c0c |
fix(desktop): default USE_TMUX to auto so quitting the app stops killing sessions
The desktop shell pinned USE_TMUX=0 on every platform, not just Windows. Without tmux the shell is a direct child of the embedded server, so SessionManager.shutdown takes the kill branch and quitting the app — the tray Quit, a logout, an update — ends every session with it. That defeats the app's premise. The point is to hand Claude Code a task, walk away, and reconnect from another device; a session that cannot survive the window closing does not do that. With tmux the node-pty process is only a tmux CLIENT, so shutdown detaches and the shell keeps running, which is exactly what the standalone server has always done (USE_TMUX unset → 'auto' → is the binary present?). Non-Windows now defaults to 'auto'. Windows stays forced off — tmux is a *nix keepalive. An explicit ambient USE_TMUX still wins either way, so USE_TMUX=0 remains the opt-out. Tests assert the end-to-end result, not just the emitted string: buildServerEnv fed through loadConfig yields useTmux === true with tmux installed, and false for an explicit opt-out. The original bug was these two layers disagreeing about what a value meant, so checking the string alone would not have caught it. Sessions started in the app before this change are NOT retroactively protected — they have no tmux session behind them and will still die with the app. Only sessions created after the new build is installed survive. |
||
|
|
9114630c3a |
feat(android): access-token support — same frozen contract as iOS
Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written Cookie header on every request and on the WS upgrade (no CookieJar, matching the frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401 upgrade as a terminal state with no reconnect loop. |
||
|
|
a5fa843f00 |
test(ios): ClientTLS coverage 55.76% -> 89.49%, gate it, fix the three dead CI legs
ClientTLS was the most security-sensitive package in the tree and the least covered, and it was not in the coverage gate at all (the gate's 4-package set predates it). 48 -> 84 tests against the real macOS keychain, serialized with a custom Testing trait after a @globalActor proved insufficient (actors yield at await, so cross-await critical sections got interleaved by other cases' cleanup). CI: the app/ipad/ios17 legs ran a bundle containing LiveServerSmokeTests, which spawns tsx, with no npm ci -- a hard failure, not a skip, on a bare checkout. Adds the missing iPad UI-test leg, and makes a missing iOS 17 runtime fail loudly instead of silently reporting green. |
||
|
|
850531fd07 |
feat(ios): access-token support + git-panel endpoints across the package layer
APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four distinct outcomes from the frozen contract, Cookie/Accept landed at the same single header choke point that already enforces Origin-iff-G, plus the whole project-ops surface the server has had since late July and iOS consumed none of: /projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/ fetch, worktree create/remove/prune, GET /sessions, follow-up queue. HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain under the existing SecItemShim conventions (device-only, never synchronizable), charset/length validated at the boundary, old token-less records still decode. SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from the same point that writes Origin, and a 401 handshake is a terminal .unauthorized -- never entering the backoff loop, since retrying one wrong shared token is a brute-force generator against the server's 10/min limiter. |
||
|
|
c4f8b5b47f |
feat(ios): unblock device builds — dev team, push-entitlements env switch, mic/speech/background keys
DEVELOPMENT_TEAM=C738Z66SRW + target-level CODE_SIGN_IDENTITY (XcodeGen's
settingPreset injects the deprecated "iPhone Developer" at target level, so a
project-level override cannot win). Device build now BUILD SUCCEEDED on the free
personal team with an on-the-fly 7-day profile.
aps-environment lives in a new WebTerm.entitlements that is NOT attached by
default: CODE_SIGN_ENTITLEMENTS expands ${WEBTERM_PUSH_ENTITLEMENTS}, empty
unless set at xcodegen time. Proven both ways — attaching it on a free team
fails with "Personal development teams ... do not support the Push
Notifications capability".
Also pins SwiftTerm to exactVersion 1.13.0: the floating "from: 1.13.0" now
resolves to 1.15.0, whose new public hasActiveSelection collides with the local
one in TerminalScreen.swift:328 and broke every generate+build.
|
||
|
|
c1612d0145 |
build(android): make release buildable and instrumented testing possible
Four gaps that made "ship it" and "test it on hardware" impossible. RELEASE SIGNING. assembleRelease was silently producing app-release-unsigned.apk because there was no signingConfigs block at all — the failure mode where you only notice at install time. Credentials now resolve from keystore.properties, then local.properties, then WEBTERM_RELEASE_* env; nothing is created or committed, and keystore.properties.example is the template. The degradation is ENFORCED, not documented. The signingConfig is only created when credentials actually resolve — an empty config is precisely what makes AGP emit an unsigned artifact quietly — and a guard on packageRelease throws with an actionable message, distinguishing "not configured" from "credentials found but the keystore file is missing: <path>". It hooks the PACKAGE task on purpose: R8 has already run and reported by then, so a developer without a keystore still gets the full shrink result rather than an early abort. Verified: minifyReleaseWithR8 completes, packageRelease fails loudly, and no unsigned APK is written. VERSIONING. Was still the scaffold 1 / "0.1.0". Now 1 / "0.1.0-alpha01", where the suffix is a factual claim about device verification rather than decoration: alpha means device QA is essentially unstarted, beta means the A34/A35 checklist blocks pass on hardware, and plain 0.1.0 means the whole checklist is ticked. versionCode stays 1 because no artifact has ever left the build machine. MINIFICATION. release had isMinifyEnabled = false and an empty rules file — so nothing had ever exercised R8 on this app, and the first release build would have been the experiment. Now minify + resource shrinking, with a deliberately short rules file: the actual artifacts were checked, and kotlinx.serialization, Tink, Hilt, FCM, OkHttp, Compose and CameraX all ship their own consumer rules, so none are re-declared. The termux packages are kept whole because the JitPack artifacts ship no rules and :terminal-view binds to non-API internals — it writes the public TerminalView.mEmulator field and calls TerminalRenderer.getFontWidth()/getFontLineSpacing(). Getting that wrong yields a build that crashes only in production. lintVitalRelease also passes now, the two pre-existing lint ERRORS having been fixed earlier this session. ANDROID TEST ENABLEMENT. :app had no androidTest source set and no instrumentation runner, which is the mechanical reason A34/A35 have no code at all. The runner (Hilt-aware) and dependencies are wired, and a :macrobenchmark module exists. The tests themselves are a separate change. Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :app:assembleBenchmark :macrobenchmark:assembleBenchmark :app:lintVitalRelease koverVerify -> BUILD SUCCESSFUL; 901 JVM tests, 0 failures on a forced re-execution with test-results deleted and --no-build-cache. |
||
|
|
b09b90e5bb |
fix(android): the ThumbnailPipeline bugs its skipped review would have caught
This file's cross-review was explicitly skipped when it was written — PROGRESS_ANDROID.md says so — and it had no production call sites until this session, so nothing had ever reviewed OR executed it. Wiring it to the home screen finally made an adversarial concurrency review worthwhile. It confirmed the hard parts are right (the check-then-insert dedup critical section, withPermit's exactly-once release with the fallback outside it, the NonCancellable finally, and rethrowing CancellationException) and then found five real defects. Every premise was re-verified from the termux v0.118.0 bytecode before fixing. 1. Thumbnails drew DUPLICATED ROWS. TerminalBuffer.externalToInternalRow is `(mScreenFirstRow + row) % mTotalRows` and mTotalRows is the constructor's transcriptRows, so passing TERMINAL_TRANSCRIPT_ROWS_MIN (100) while rows went up to 200 aliased external rows 100..199 onto 0..99 — and left the emulator's scroll state invalid during append(). A pure ThumbnailGrid now derives transcriptRows = max(rows, MIN). 2. A legitimately-sized session was LMK bait. cols/rows are server-supplied and the protocol validates resize only to [1,1000], so a 1000x1000 session forced 2800x2800 ARGB_8888 = 31.4 MB, twice over for two permits, every poll tick. Rather than clamp the grid — which would CROP a real screen — the cell size is now derived from the target, shrinking the font's own cell by an exact integer fraction with the aspect preserved. Any geometry in 1..1000 now yields at most 480x480 = 900 KiB; a real 161x50 goes from 3.1 MB to 322 KiB. 3. Two stalled fetches wedged the feature PERMANENTLY. There is no OkHttp callTimeout (the per-socket read timeout is reset by every trickled byte) and the render is deliberately uncancellable by any caller, so two slow-trickle previews held both permits until close() and no thumbnail rendered again. A withTimeout now sits inside withPermit, budgeting the work rather than the permit wait. The subtlety that makes this correct: TimeoutCancellationException IS a CancellationException, so it is caught BEFORE that branch and routed to the placeholder — otherwise a slow host would still kill the pipeline instead of degrading. 4. The in-flight entry was leakable, contradicting a comment that claimed it never was. `scope.async` on an already-cancelled scope never runs its body, so the finally never ran: a post-close() request left a dead Deferred that every later request awaited forever, and inFlight grew unbounded. Now guarded inside the mutex, and the comment names the one path that genuinely cannot release (where the map dies with the pipeline anyway). 5. Obsolete renders did guaranteed wasted work. lastOutputAt advances on every PTY byte and the list polls every 5s, so an active session mints a new key each tick; the row cancelled the old caller but the old render ran a full GET plus a full raster whose bitmap was cached under a key nothing would request again — occupying both permits ahead of the key actually on screen, and growing without bound once render latency exceeded arrival rate. A newer key now cancels the older in-flight render for that session. Also made two false statements true rather than leaving them: retainOnly now prunes under the same monitor publish() takes, so a render landing after the prune cannot resurrect a killed session's bitmap; and PreviewCap's KDoc no longer claims to prevent an oversized-body OOM (the body is already buffered and parsed by then) nor to "mirror the server's cap" (the server defaults to 24 KiB, not 256 KiB, and an operator can raise it unbounded). RED was observed for all four behavioural fixes before implementing. Verified: ./gradlew test :app:testDebugUnitTest :app:assembleDebug koverVerify -> BUILD SUCCESSFUL, 901 JVM tests, 0 failures (893 -> 901). The agent distrusted a FROM-CACHE green, forced a real execution, and confirmed the code reached the packaged APK by scanning its dex. |
||
|
|
3ad8d8beba | docs(progress): log the round-2 tmux target prefix-matching hole + dual clocks | ||
|
|
a6cf547f04 | Merge orphan-review-round2: exact tmux targets, dual activity clocks | ||
|
|
d39a0ab8d1 |
fix(sessions): tmux -t prefix-matching let orphan ops reach the user's own sessions
Round two of adversarial review. The skeptic confirmed the session_activity finding
with extra evidence from this host and raised three things the first pass missed.
SECURITY — `tmux -t <name>` resolves exact name, then NAME PREFIX, then fnmatch.
The whole module rests on "we only ever touch `web_` + a UUID v4, so a tmux session
the user made for their own work is never enumerated and never offered for
deletion". Prefix matching goes around that: a session named `web_<uuid>_mine` is
refused by parseSessionList (its suffix is not a UUID) and so never appears in the
listing — but `-t web_<uuid>` matches it. So a DELETE aimed at an id that does not
exist would end the user's session, and a preview would print its screen.
Verified on tmux 3.6a, isolated socket:
has-session -t web_<uuid> → succeeds against web_<uuid>_MINE
has-session -t =web_<uuid> → "can't find session"
capture-pane -p -t web_<uuid> → printed the DECOY's screen
capture-pane -p -t =web_<uuid>: → "can't find session"
Two target forms are needed, because `=` only qualifies the session part of a
target: session targets (has-session, kill-session) take `=name`, while
capture-pane takes a PANE and rejects a bare `=name` outright ("can't find pane"),
so it needs `=name:`. Both are now the only way a name reaches tmux, with an
integration test that stands up a real decoy and asserts the listing omits it and
both the preview and the DELETE report 404 while it stays alive.
CORRECTNESS — take max(window_activity, session_activity), not window_activity
alone. The previous commit swapped one clock for the other, but they are not
ordered: window_activity tracks pane output while an attach with no output bumps
only session_activity. On this host one session's session_activity was 4 s NEWER
than its window_activity (and another's window_activity was 25 days newer). The
question is "has anything happened here", so the answer is the later of the two.
Also documents the caveat that window_activity resolves against the session's
current window only — exact for the single-window sessions this app creates, and
bounded by the max for anything else.
CORRECTNESS — parseSessionList accepted an empty numeric field. Number('') is 0
and 0 is finite, so a blank clock parsed as "created at the epoch, idle ever
since": instantly eligible for the idle cleanup. Fields must now be actual digits.
SAFETY — bulk cleanup re-reads the world immediately before killing. The candidate
list is a snapshot and the kill loop takes time, so a session could be attached, or
adopted into the table, inside that window. This is the irreversible path; it now
verifies each candidate against a fresh listing rather than trusting the snapshot.
PERFORMANCE — captureOrphan no longer probes with hasSession first. That was a
second tmux spawn per thumbnail, and a SYNCHRONOUS one on the path a whole grid
refreshes, buying nothing: capture-pane already fails cleanly on a missing session
and we already turn that into null. The guard splits into a pure half
(mayActOnOrphan: UUID shape + not in the table) used by both paths, and the
existence probe, which only the destructive path needs.
Tests: 2213 unit, 9 orphan integration (incl. the decoy regression). Confirmed
`has-session -t =<name>` still resolves the 69 real sessions, so the Case 3.5
re-attach path is unaffected.
Note: test/integration/server.test.ts "H1 shell state survives a server restart"
flakes ~1 run in 3 when all 27 run together on a loaded machine, and passes 4/4 in
isolation. That is the pre-existing real-PTY timing flake already recorded in
PROGRESS_LOG, not this change — it exercises Case 3.5, which is verified above.
|
||
|
|
2bfc76b397 | docs(progress): log the orphan tmux session feature + the session_activity clock bug | ||
|
|
22a7929a7b | Merge orphan-tmux-sessions: surface and manage untracked tmux sessions | ||
|
|
f6ef19ebf6 |
fix(sessions): orphan cleanup was reading the wrong tmux clock
Adversarial review of the previous commit found ten defects. The critical one
would have destroyed exactly the work this feature exists to protect.
CRITICAL — `#{session_activity}` is a CLIENT clock, not an output clock.
It advances on attach and on keypresses, and never moves for a detached session
however much its shell is printing. Measured on tmux 3.6a: a detached session
running `while true; do echo tick; sleep 1; done` still reported
session_activity == session_created after 6 s, while `#{window_activity}` tracked
the output exactly. So "idle for 7 days" was really "nobody has typed for 7 days",
and `Clean up idle 7d+` would have killed long-running unattended sessions that
were busy working — the walk-away case the whole app is built around.
Measured against this host's 69 sessions: the old clock condemned 36, the new one
condemns 33, so three sessions doing real work were one click from deletion.
Now reads window_activity, with a test that fails if the format string regresses.
HIGH — no timeout on any tmux call. maxBuffer bounds a flood but nothing bounded
the wait, and a synchronous exec cannot be interrupted by the event loop, so a
tmux server stopped mid-syscall hung the whole server permanently. All five calls
(including the three that predate this feature) now carry one.
HIGH — listSessions and capturePane were synchronous on a POLLED path. Measured
73 ms and 58 ms per call on this host; every home screen on every device refreshes
on a 5 s timer, and each of those was 73 ms with the event loop stopped, i.e.
73 ms in which no PTY byte reached any terminal anywhere. Both are async now, so
the manager's listOrphans/captureOrphan/killOrphansIdleSince are too. The server
is a byte-shuttle first: nothing polled may block it.
HIGH — GET /orphan-sessions had no rate limiter although, unlike every other read
route, it spawns a subprocess. It shares the preview bucket now.
HIGH — the cleanup confirmation counted CARDS, and the grid is capped at 24 while
the cleanup matches across every session the server enumerates. On this host it
would have said "24" and ended 33 shells. New GET /orphan-sessions/count-idle
answers the real question, and the dialog states that number, or refuses to
proceed if it cannot be determined.
MEDIUM — fetchOrphanSessions collapsed every failure into [], so one 429 or 500
read as "all recovered sessions are gone", tearing down every mounted xterm and
rebuilding it on the next success. It returns null for failure now, and the
refresh leaves the section untouched.
MEDIUM — the preview latch was "card was created", not "preview succeeded", so a
card that lost its single request stayed blank forever. Latches on success.
MEDIUM — overlapping refreshes. refresh() is entered from a 5 s timer, from
killOrphan and from cleanup, with awaits inside and no guard, so a slow poll could
resurrect cards for sessions already killed or re-mount them into a root that
setVisible(false) had just cleared. A generation counter, bumped on teardown and
checked after every await, invalidates stale runs.
MEDIUM — renderPreview could write to a disposed xterm (it throws "Object has
been disposed"). PreviewCard carries an explicit `disposed` flag set by a new
disposeCard() helper, so no caller can dispose a terminal and forget the flag.
Deliberately NOT el.isConnected: a card not yet appended is alive, and two
existing preview-grid tests correctly caught that conflation.
LOW — kill and cleanup discarded their results, so a refusal (403 behind a proxy
whose Origin differs, 404 on a lost race) looked like a dead button. Both report.
Tests: 2209 unit, 27 e2e, 8 orphan integration. Verified live against all 69 real
sessions with none harmed; the window_activity fix confirmed on an isolated tmux
socket (-L) so the developer's sessions were never involved.
|
||
|
|
980dbaa928 |
feat(android): wire the dead code, render the git panel, make the token gate real
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.
|
||
|
|
4892fa7b49 |
feat(sessions): surface tmux sessions the server does not track
A `web_*` tmux session that outlives the process which created it was unreachable
from the UI, permanently. Nothing enumerated tmux — src/session/tmux.ts had
hasSession but no list — so recovery only worked if a client still had the id in
localStorage. list() walks the in-memory table, and so does reapIdle, so such a
session could not be listed, previewed, joined or killed, and never aged out.
On this host that was 69 tmux sessions with 5 clients: 64 unreachable, the oldest
from 26 Jun, one of them still running a Claude Code session with 7 agents.
This is the "catalogue" approach: make them visible and actionable WITHOUT
adopting them. Nothing is attached, because attaching makes tmux resize the window
to the new client's dimensions and SIGWINCH whatever is running inside — doing
that to dozens of live TUIs at startup is exactly the outcome to avoid. Adoption
stays an explicit user action: opening an orphan re-attaches by id through the
existing re-attach path, and it becomes an ordinary live session.
src/session/tmux.ts listSessions() + parseSessionList() (pure, so the filter
rules are unit-testable) and capturePane(), which reads a
session's current screen via `capture-pane -p -e` — no
client, no resize, colour preserved.
src/session/manager.ts listOrphans/captureOrphan/killOrphan/killOrphansIdleSince
src/server.ts GET /orphan-sessions, GET /orphan-sessions/:id/preview,
DELETE /orphan-sessions/:id, DELETE /orphan-sessions
?idleDays=N
public/ a "Recoverable" section under the home grid, reusing the
existing card/thumbnail machinery via orphanAsLive()
Guards, all enforced in the manager so no route can forget one:
- the tmux name must be `web_` + a UUID v4 (SESSION_ID_RE, the same gate the
attach protocol applies). This is what stops a hostile or malformed name from
reaching `tmux -t` — a literal `-t`, or path traversal — and it means a tmux
session the user created for their own work is never enumerated, never
previewed and never offered for deletion.
- an id the table already owns is refused everywhere: killing it via tmux would
end the shell behind a live PTY's back and leave a zombie table entry. Those go
through killById.
- every function is inert when cfg.useTmux is off, so the non-tmux deployment is
byte-for-byte unchanged.
- bulk cleanup skips ATTACHED sessions and requires idleDays >= 1: there is no
"delete everything" form of the route. "This server does not track it" is not
evidence that it is abandoned — a plain `tmux attach` and a second server
process both report as attached.
- reads carry no Origin guard (same threat model as /live-sessions); both DELETEs
do. Preview gets its own rate-limit bucket since each call spawns a process.
Two things the live run exposed and this fixes: previews were loading
sequentially (69 tmux spawns per 5 s refresh) — now once per card, fire-and-forget
— and the grid is capped at 24 cards because each thumbnail is an xterm instance.
The remainder is stated in the UI with the tmux command to reach it, never
silently truncated.
Separate wire type from LiveSessionInfo on purpose: an orphan supports none of the
live-session operations, and the Android/iOS clients decode /live-sessions, so a
variant shape there would break them.
Tests: 2203 unit (+42: parse/filter rules, all four manager guards, tmux-off
inertness) and 8 integration against real tmux — create a throwaway session,
list it, capture it while asserting it stays unattached, reject non-UUID ids,
reject a foreign Origin, kill it, 404 the second time. The integration file never
issues the bulk DELETE: it runs against the host's real tmux server and would end
the developer's own sessions. Verified live against all 69 with none harmed.
|
||
|
|
35d32f4670 |
feat(android): decode the v0.6 git-panel surface
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.
|
||
|
|
3fe719f874 |
feat(android): decode the two server frames the client was throwing away
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).
|
||
|
|
0de5557921 | docs(ios): freeze ios-completion coordination contracts (token/git-panel/signing) | ||
|
|
3e5cfdc1cf |
fix(android): make the terminal actually usable on a device
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.
|
||
|
|
ffc185aa2d |
fix(desktop): stop shipping node-gyp, which broke codesign
`npm run dist:mac` failed at the signing step:
codesign --timestamp .../node_modules/node-gyp/gyp/pylib/gyp/generator/\
__pycache__/make.cpython-313.pyc
→ "A timestamp was expected but was not found."
node-gyp only COMPILES native modules. Nothing loads it at runtime — node-pty
resolves its prebuilt .node directly, and a grep over the packaged tree finds no
require of it outside the package itself. But it vendors a Python tree, and
`npmRebuild: true` runs it and leaves __pycache__/*.pyc behind. Those got copied
into Contents/Resources and codesign then tried to sign Python bytecode, which
takes the entire build down.
Excluding it removes both the dead weight (5 MB) and the failure surface. Also
excludes **/__pycache__ outright, so no Python bytecode can reach the bundle by
another route.
Verified on a real build: signature verifies --strict, node-gyp absent, zero .pyc
in the bundle, public/style.css and build/main.js byte-identical to the repo,
express/ws/web-push/qrcode/google-auth-library all still resolve from the
packaged tree, and node-pty's Electron-ABI pty.node is in place.
Note: the immediate failure was transient — a plain retry of the same config
succeeded (Apple's timestamp server). So this is the durable fix for a landmine
that would otherwise re-arm on every npmRebuild, not the cause of that one run.
Deviation from the one-worktree-per-session rule: the desktop build packages from
desktop/node_modules and its script does `cd .. && npm run build`, so it can only
be exercised in the main checkout. Committed on develop directly.
|
||
|
|
029bec831a | docs(progress): log the design-fidelity pass + the stale-desktop-bundle diagnosis | ||
|
|
e506d0dbbf | Merge panel-fidelity-leftovers: last four design gaps + readable projects.ts diffs | ||
|
|
dba83559d3 |
fix(projects): close the last four git-panel design gaps
The low-severity leftovers from the panel audit, plus the tooling trap that made reviewing this work harder than it needed to be. .gitattributes (new) — `public/projects.ts diff` That file embeds literal NUL bytes on purpose: the namespace sentinels '\0active' / '\0other' are prefixed so they cannot collide with a real `First.Second` key. git therefore classified it as binary and printed "Binary files differ" for every diff, and `git diff --stat` reported "Bin 66815 -> 68701 bytes" instead of line counts — the file was effectively unreviewable. The `diff` attribute makes git diff it as text without setting `text`, so no line endings are normalised and the bytes on disk are untouched. (Same trap in the shell: grep prints only "Binary file ... matches", which reads as a no-match. The comment in the file says to use grep -a.) Detached HEAD now shows its short sha The Head cell held the "detached HEAD" chip and nothing else: it said what was wrong and nothing about where you were. SyncState gains an optional `head`, populated from the .git/HEAD text readSyncState ALREADY reads for the detached test — so this costs no extra I/O and no extra git call. Guarded by a hex regex and rendered as textContent. One geometry for the ghost button role The design defines two button roles. The outline one had drifted to three different sizes (.proj-wt-open at 11.5px/4px 12px/6px, .proj-diff-toggle at 12.5px/7px 15px/7px) and both used full-strength --text instead of the muted label. All four outline buttons are now declared once, together, at the design's geometry; the duplicate .proj-diff-toggle block further down the file is gone, since a second block at equal specificity is exactly how the role drifted in the first place. Header branch chip joins the chip language .proj-branch was the only chip with no border and no mono face, and 1px tighter all round, so the detail header's branch did not match the branch chips in the worktree list right below it. "Changes" heading over the View Diff toggle Every other block on the page is introduced by a section heading. Without one the toggle hung 4px off the bottom of the sync band and read as part of it. Tests: 2167 unit (4 new — detached sha present/absent on both the server and the panel) and 27 integration, twice. |
||
|
|
a25fe30f1a | Merge panel-design-fidelity: close the git-panel design gaps | ||
|
|
950d2298a1 |
fix(projects): close the design gaps in the git panel
An audit of the project-detail panel against docs/mockups/project-detail-git.html turned up 59 differences. The ones that changed what you see: Controls that had class names but no rules, so they rendered as raw OS widgets: - .proj-wt-form had no layout at all — the branch input and Create Worktree button sat flush together, and the button jumped to its own line the moment a validation error appeared between them. - .proj-wt-error / .proj-wt-actions-error had no rule, so a validation failure rendered as 16px near-white body text, indistinguishable from content. - .proj-wt-remove (the worktree ✕) and .proj-wt-prune had no rule either. - .proj-fanout-mode never reset `appearance`, so WebKit kept its own light dropdown — the one control in the panel that stayed a native grey widget. - --danger was never defined, so every validation message fell back to an off-palette red. Cascade bugs — a later rule at equal specificity was silently winning: - .proj-wt-row-current's amber wash and border were completely dead. "You are here" rendered identically to every other row. - .proj-wt-branch inherited the 16px user-agent default in the UI sans, making a branch name the largest text on the page. Now 13.5px mono. Layout: - .proj-syncband dropped flex-wrap. Wrapping produced ragged half-empty rows between 721px and 1000px — at 900px the Fetch button dropped alone onto a second row. One cell now absorbs the shrink so the band stays a single row down to the 720px column break. - Commit rows are a 4-column grid, not flex, and the ↑ mark cell is rendered on every row instead of only unpushed ones. Previously no two lines agreed on where the sha started, so the unpushed group did not read as a group. - Pushed commit subjects step down to --text-dim, which is what makes the upstream boundary mean anything. Honesty of the sync band — the design's two hard rules: - Green "✓ in sync" was reachable when ahead/behind were UNKNOWN, because `?? 0` folded undefined into zero. Unknown counts now render "↑ —" / "↓ —" and are marked unverified. Green requires real zeros and a fresh fetch. - A stale ↓ painted the digit warning-red. Since a missing FETCH_HEAD is the default state, the largest glyph in the band was almost always red and the panel read as "this repo is broken". The doubt now sits on the `unverified` chip and the footnote, never on the number. Also: semantic tokens (--sunk/--warn/--ok/--dirty/--mono-font) replacing hex literals, one chip language across the panel, section headings as tracked caps with the count as a separate lighter element, and a ≤720px rule that drops the age column — placed after the base rule, since a media query adds no specificity. Verified in a real browser at 1280/1000/900/780/721/600/420px: the sync band holds one row down to 721px and stacks below it, commit columns align on every row, and nothing overflows or scrolls the page horizontally. |
||
|
|
c3613c2fae | Merge wt-path-alignment: path sits under its branch, not across the card | ||
|
|
609e7cea69 |
fix(projects): stop stranding the worktree path across the card
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. |
||
|
|
2066631c0a | Merge projects-control-styling: dark-theme controls, real theme tokens | ||
|
|
3c0b2f6bae |
fix(projects): style the panel controls and use the real theme tokens
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. |
||
|
|
e004da1a55 | Merge git-panel-layout-fixes: right-pinned worktree actions, count on the heading | ||
|
|
042c4cd4a9 |
fix(projects): pin the worktree actions right and put the count on the heading
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. |
||
|
|
c8d12780b9 |
Merge git-panel-design-fidelity: match the approved design
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. |
||
|
|
f711db9315 |
fix(projects): build the git panel the way the design specifies
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). |
||
|
|
e81c426329 |
Merge w6-project-git-panel: ambient git state on the project detail page
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
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. |
||
|
|
cc811dd18d |
test: stop the suite flaking on real-git and real-PTY fixtures
`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). |