Skip to content

feat(cli): worktrees serve — the engine behind an authenticated loopback WebSocket - #516

Merged
brethash merged 26 commits into
feat/server-editionfrom
feat/worktrees-serve
Oct 22, 2026
Merged

brethash merged 26 commits into
feat/server-editionfrom
feat/worktrees-serve

Conversation

@brethash

@brethash brethash commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

What

worktrees serve hosts the same commands the desktop app calls (the ones #508
moved into worktrees-api) behind a WebSocket on loopback. One engine process
per host is the plan: next, the desktop app becomes a client of a local serve,
and later other devices connect to the same process. host::boot / start /
stop is the engine's wiring, shared by the app and serve.

Wire

  • Request {id, cmd, args} → {id, ok} or {id, err}.
  • A channel streams {channel, seq, data}: base64 for byte streams, JSON for
    typed callbacks. It ends with {channel, seq, end: true}, which never
    overtakes its data.
  • Events {event, payload} go to authenticated sockets only.
  • Every command has an exposure class: Engine, HostAdmin, Stream or
    ClientLocal. ClientLocal commands (open editor, open terminal, copy, zoom…)
    answer err: "client-local". Anything unlisted is refused.

Security (the loopback rule set, plus the OWASP review of this server as built)

  • Host must be loopback, Origin must be on an allowlist, and a missing
    Origin is refused. There is no CORS header. Every refusal closes the socket
    with zero bytes written.
  • Handshake v1, a mutual HMAC proof. The token never crosses the wire.
    hello → challenge → auth → {ok, version, proof}, with different
    labels for each direction and a 3 s deadline for the whole exchange. This
    fixes review finding H1: a process that grabbed the port after serve died
    used to receive the live token as the client's first frame.
  • serve.json ({port, token, pid, version}) is created mode 0600, under a
    lock, with an atomic rename. The token is 256-bit from the OS CSPRNG,
    rotated every launch, and compared in constant time.
  • Limits (review finding M1):
    • at most 512 bytes before the proof verifies;
    • at most 16 MiB per message after it;
    • at most 64 connections, and at most 16 unauthenticated;
    • a 5 s HTTP deadline;
    • RLIMIT_NOFILE raised at boot.

Correctness fixes found by driving it

  • Requests to one stream run in the order they were sent. Before this, fast
    typing reached zsh scrambled (echo → ecohd).
  • A finished channel says so, so clients release its handler.
  • Each serve.log line is written with one write(2), so lines stay whole under
    concurrency.
  • A dock shell keeps its reader when the client that attached it goes away.
    Before this, a page reload left the shell with nobody reading it.

Verified

  • Every new test was shown failing first, including handshake cases (a)–(e).
  • 7 kinds of impostor were all refused, with the token on the wire in neither
    direction.
  • The review's flood scripts, before → after:
    • unauthenticated memory: 291 MB → 6 MB;
    • open files under 300 idle connections: 260 → 30;
    • a client authenticated before the flood keeps working.
  • Still possible: a reconnecting local flood can lock out new clients. It can
    no longer take memory, file descriptors, or a connected client.
  • app/scripts/server-stack.sh was driven end to end. The real frontend
    connects through the transport PR.
  • Platform claims were measured on macOS 27 and in Linux containers.
  • New crates: tungstenite, tokio-tungstenite, hmac 0.13, sha2 0.11 and their
    dependencies. All pinned, with no OSV advisories at the locked versions.
  • Gates: bats, lint, core/cli/api/app tests, tsc, all check scripts, 0 warnings.

Before merge (in progress on this branch)

Not in this PR

  • The desktop app as a client of serve (the next lane).
  • Remote access, TLS and enrollment (Phase 2).

Rehearsal fix (D2)

serve drained ~/.cache/worktrees/inbox with zero clients connected, so after
an app crash a worktrees show was consumed in under a second and emitted to
nobody; with two engines running, the client-less one took 4 of 6. 9fb1127:
a show waits while no client is connected and goes to the next one.

3950a58 (main through #523, via #508): servefile.rs now writes serve.json
through fsx::write_atomic(…, 0o600) (random O_EXCL|O_NOFOLLOW temp, still
0600 from creation) instead of its own .serve.json.<pid>.tmp, which fsx's
temp-name guard rejects.

… the app and serve

Every engine process needs the same preamble, the same background work and
the same exit sequence: the log sink before anything can want it, the panic
hook, the launchd-bare PATH and locale repairs, core's log, the startup line;
the viewer sweep, the pre-registry project merge, the 3 s poll that lights
the nav dots and flushes the dock rings; and on the way out the cwd and ring
flush, the shells, the docs server, in-flight clones, the derived trees. The
desktop app's run() had grown all of it one hard-won line at a time, and
`worktrees serve` needs every line — which is the shape of the bug AGENTS.md
records for pi: a correct core, and a host that forgot one wiring step.

So the wiring moves into `worktrees_api::host` as `boot(sink)`,
`start(ctx, hooks)` and `stop(&ctx)`, ported from the shim without
behavioural change (every comment travelled with its beat). The shim's edit
is mechanical: three calls, and the one window-only step — raising the app
when the inbox delivers a `worktrees show` — becomes the `on_inbox` hook,
which a server leaves empty. `hostenv` gains `APP_IDENT` and
`default_config_dir()`, Tauri's `app_config_dir()` computed without Tauri.

`api-boundary-check.mjs` now asserts the shim makes all three calls — boot
first, start exactly once on the managed Ctx, stop in the Exit arm — and
that nothing in run() installs a sink, a hook, a thread or an exit step of
its own. Shown to fail first: 13 failures against the pre-edit shim.

Rebased onto #508's one-`.setup` fix (bbfed64): tauri's Builder::setup
replaces rather than chains, so the shim's single `.setup` now holds IDENT,
the config dir, `hostpolicy::boot`, the managed Ctx and the window state,
and then `host::start`; `host::boot` replaces the preamble, core's log sink
included, so the `.setup` no longer installs it. The check keeps the base's
two new rules (exactly one `.setup`; Tauri state only for the Ctx and the
window tracker) and gains one: `host::start` must come after the Ctx's
`manage()` — shown failing with the call moved three lines up.
…ack WebSocket

Server Edition, Phase 1, slice 1 (the server half). `worktrees serve` hosts
`worktrees-api` on 127.0.0.1 and speaks the frontend's own shape: a request
`{id, cmd, args}` is answered `{id, ok}` or `{id, err}`; a `ByteSink` or a
progress callback becomes ordered channel frames `{channel, seq, data}` on
the connection that asked; `Host::emit` is `{event, payload}` to every
authenticated socket. One unbounded queue per connection and one writer
task make frame order a property of the code (AGENTS.md: a replay answered
as input came from an out-of-band payload). Requests dispatch concurrently
on the blocking pool, as Tauri's async commands do, so a clone cannot hold
up a keystroke.

The loopback rule set (`serve/policy.rs`), every refusal a closed socket
with nothing written: `Host` must be loopback on our port (the docs
server's rule, reused — it is what stops DNS rebinding); `Origin` must be
on the allow-list, and a MISSING Origin is refused — deliberately the
opposite of `docserver::origin_ok`, whose own page sends none, so the
divergence is written down where someone would "align" it; the first frame
must be `{"auth": "<token>"}` within 3 s. The token is 256 bits from
getrandom, rotated per launch, written to `<config_dir>/serve.json` with
`{port, pid, version}` at mode 0600 under flock + temp-with-mode + rename;
a second serve on a config dir whose file names a live pid that still
listens is refused (one engine per host). The auth reply carries the cargo
version for the app's mismatch check.

Dispatch is ONE macro table (`serve/dispatch.rs`): 150 rows, each
`name: Exposure => body`, generating both the exhaustive `(name, exposure)`
slice and the match arms, so neither can drift from the other. `ClientLocal`
rows (copy_text, open_editor, open_terminal, detect_editors,
open_docs_viewer, set_zoom, install_app_version) are refused with
`err: "client-local"` before their body is looked at — copy_text would write
the SERVER's clipboard. `HostAdmin` rows are tagged and logged, allowed on
loopback. Args are read by key as Tauri reads them: camelCase as the
frontend sends, snake_case accepted too, missing = null, the error naming
the key. `err` carries what the Tauri `Err` serializes to — a string for
149 commands, the `CloneError` object for clone_project, whose dialog
branches on `kind`. A client that disconnects takes its tmux attaches with
it; dock shells stay, because the next attach replays them.

Stack: hyper parses the upgrade (already compiled in for the docs server)
and a refusal is the service returning Err, which hyper answers with a
closed connection and no bytes — tungstenite's own accept_hdr writes an
error response, which is why it is not used; tokio-tungstenite 0.30
(default-features off, `handshake` for derive_accept_key only) owns the
frames over the upgraded socket. futures-util is a trait import, already in
the lock. Thirteen crates new in the lock, listed in Cargo.toml.

The log is `~/.cache/worktrees/serve.log`, the CLI's cache location, with
`host::boot`'s `startup v…` line first — a long-lived server outlives the
binary that replaced it on disk. SIGINT/SIGTERM run `host::stop` and remove
serve.json if it is still ours.

Tests, each shown to fail first by mutation (12 mutations, 12 red):
the connect-witness over raw sockets, served case FIRST (a 101 with the RFC
6455 worked-example accept value), then foreign Origin, missing Origin and
forged Host each closed with zero bytes; serve.json 0600 and rotated;
the version in the auth reply; a silent client cut at the deadline; requests
answered by id with Tauri's shapes; events to every authenticated socket
and none to an unauthenticated one; and `tests/serve_bin.rs`, which drives
the real binary under a sandbox HOME/XDG: serve.json, `list_workspace` over
an empty registry, the log's first line, a refused second serve, SIGTERM
cleanup, token rotation. The second-serve step is a bounded spawn/poll/kill
because its first fail-first run HUNG under the mutation rather than failing.
Mode assertions were measured on macOS 27.0 and in Linux 6.12 containers
(Alpine 3.24, Debian 12) under three umasks; a pid outside `pid_t` reads
dead without being cast into a process-group probe.

`app/scripts/serve-dispatch-check.mjs` holds the table against the shim's
generate_handler! in both directions and pins the client-local seven; fails
with a row removed, a handler removed, or a row retagged.
`app/scripts/server-stack.sh` runs a serve on an isolated config dir, tmux
prefix and port for the frontend lane and prints the serve.json path.

(143 rows at the extraction. The rebase onto #508's merges of main added
host policy's `get_codex_permissions` (Engine) and Kimi's six commands,
each classed as its pi or codex twin — kimi_status, kimi_version and
kimi_usage Engine; set_kimi_allowed, kimi_mcp_status and kimi_mcp_set
HostAdmin — and `set_codex_permissions` / `set_pi_trust` now take the Ctx.)
… whole

serve.log tore under concurrency. feat-serve-transport read
`…03:07:38serve: host-admin codex_mcp_statusZ [` with the rest on the next
line: `writeln!` on an unbuffered append handle issues one write per
formatted piece, and two threads' pieces interleave in the file.

Each line is now formatted into one String and written with a single
`write_all` to the O_APPEND handle, which lands a short write whole. The
append path moved into `append_line(dir, path, …)` so a test can aim it at
a temp file; the sink still writes to ~/.cache/worktrees/serve.log.

Test, shown red first: eight threads × 300 lines through `append_line`,
then every line must parse as one stamp, one level, one pid and one
writer's body. Against the shipped `writeln!` the first line read was
`2026-10-18 05:20:402026-10-18 05:20:40…Z [2026-10-18 05:20:40infoZ [Z [`.
Keystrokes were reordered. conn.rs gave every text frame its own
spawn_blocking, so consecutive term_write/shell_write requests raced on the
blocking pool. feat-serve-transport sent `echo 0123456789abcdefghij\r` as 26
frames with ascending ids and zsh received `0124356789abcdefghij`; three of
three fast-typed lines had at least one pair swapped.

`dispatch::lane` keys every Stream row that names an existing stream by
that stream — a tmux attach by `id`, a dock shell by (repo, slug, index) —
and the connection runs one serial lane per key: an unbounded queue drained
by one task that awaits each job on the blocking pool before taking the
next, so an idle lane holds no thread. Everything else still runs
concurrently, so a long clone_project cannot hold up a keystroke.
term_open stays concurrent: nothing can write to an attach before the reply
names it. The key is built from the RAW args, so a malformed request fails
in order with its own error instead of racing past its neighbours. The
request is now parsed on the reader task, which chooses the lane.

Tests, shown red first:
- writes_to_one_stream_reach_the_pty_in_the_order_they_were_sent: 60
  single-byte shell_writes to one dock shell (/bin/sh on a pty in a scratch
  repo), and the pty's echo off the channel must be the alphabet. Against
  495df95's conn.rs, 5 of 5 runs red, e.g.
  `0123456789bcadefghijmrqkpsltwACGuJozDKMORvQTynBLNFHPSUIVWXxE`. With the
  lane rule mutated to return None, red again (`0124673958…`).
- stream_requests_are_keyed_by_their_stream_and_nothing_else_is: the
  lane rule itself; red under the same mutation.

The Tauri shim has the same exposure in principle — its async commands are
spawned tasks — but serve makes it routine. The shim is not changed here.
serve never said a channel was finished, so a client had to keep every
channel handler, and the xterm it closes over, for the life of the page.
Tauri sends an end when its Rust `Channel` drops.

Wire addition, the LAST frame on a channel:

    {"channel": <id>, "seq": <n>, "end": true}

no `data`, `seq` one past the channel's last data frame, on the same
per-connection queue, so it cannot overtake that data. It is sent when the
engine lets the sink go:
- a ByteSink's last Arc drops (`impl Drop for FrameSink`): shell_detach,
  a re-attach replacing the old sink, term_close (the attach's reader
  thread exits on EOF and drops it), the client gone;
- a dock shell exits: the reader now releases the sink BEFORE emitting
  `shell:exit` (worktrees-api term.rs), so the end frame precedes the event;
- a typed progress callback is dropped: it is a local of the row's body, so
  it drops when clone_project/sync_apply returns, before the reply.
An `err` reply to the request that named a channel also finishes it; an
end frame precedes it only when a sink had been made. Documented in
serve/wire.rs and in serve-dispatch-check.mjs's header, which now carries
the whole wire for a client author.

Tests, shown red first:
- dropping_a_sink_ends_its_channel_after_its_last_byte and
  dropping_a_typed_callback_ends_its_channel (wire.rs): exact frames; red
  before, with only the data frames queued.
- a_channel_ends_when_its_sink_is_dropped_by_a_detach_or_an_exit (real
  dock shells over a socket): the end frame precedes the detach's reply, and
  precedes `shell:exit` for a shell that exits. Red before ("no end frame for
  channel 21 before the detach reply; saw []"); the exit half alone red with
  the term.rs line mutated out (data, then the event, no end frame).
… it goes

Found while adding the end-of-channel frame. A dock shell's reader thread
broke out of its loop on the first failed send to its sink ("webview
gone"). Under the Tauri app that meant the app was quitting. Under `serve`
it is routine: a page reload drops the socket while the shell still holds
that connection's sink, and the next thing the shell prints fails to send.
The shell then ran on with nobody reading its pty — no ring, no echo — and
the next client re-attached to a tab that replayed and never answered
again, although conn.rs's own design note says dock shells survive a
disconnect so the next attach can replay them.

The reader now lets that client's sink go — exactly what a detach does,
so the channel ends too — and keeps reading. A tmux attach is unchanged:
it belongs to its client, and the connection closes it on disconnect.

Test, shown red first:
a_dock_shell_keeps_its_reader_when_the_client_that_attached_it_goes —
client A attaches /bin/sh in a scratch repo, starts `sleep 1; echo …`,
disconnects; client B re-attaches and types `echo still-$((40+2))`. Before:
the replay held the line printed while detached and then nothing ("the shell
answered nothing after its first client left"). After: `still-42` arrives.
…wire

OWASP finding H1 (High, blocks Phase 1): the client authenticated no
server. Under v0 the token was the client's FIRST frame, so whatever held
the port after serve died received it — the review captured it with a
stand-in, and then a typed "password".

Server side of (main)'s handshake-v1.md, exactly:

  C→S {"hello": cn}
  S→C {"challenge": sn}                 sn fresh per connection, OS CSPRNG
  C→S {"auth": HMAC(K, "worktrees-serve/v1/client\n" + cn + "\n" + sn)}
  S→C {"ok": true, "version": "<CARGO_PKG_VERSION>",
       "proof": HMAC(K, "worktrees-serve/v1/server\n" + cn + "\n" + sn)}

K = serve.json's token as UTF-8; cn, sn and both proofs are 32 bytes as 64
lowercase hex (upper case is refused, not folded). HMAC-SHA256 from
RustCrypto's hmac 0.13 + sha2 0.11 on the already-locked digest 0.11.3; the
client proof is checked with `verify_slice` (constant time, via ctutils).
The server computes its own proof only after the client's verifies, so an
unproven peer receives nothing but a random challenge. Any other first
frame — the old {"auth": token} included — a frame with company, a wrong or
reflected or replayed proof, or a missed deadline closes the socket with
nothing written (D6). AUTH_DEADLINE (3 s) now covers the WHOLE exchange,
from the upgrade to `ok`, not each frame.

Cargo.lock gains exactly four crates and moves nothing else: hmac 0.13.0,
sha2 0.11.1 (beside the docs server's 0.10.9), ctutils 0.4.3, cmov 0.5.4.
OSV at those versions: no advisories (positive control: tungstenite 0.20.0
returns RUSTSEC-2023-0065). An offline resolution had bumped ~30 unrelated
crates; it was discarded and resolved again online.

Tests, written from the spec rather than from policy.rs — the labels are
spelled out again and the HMAC is RFC 2104 over bare SHA-256, itself checked
against RFC 4231 test cases 2 and 6 — each shown red first:
(a) the_raw_token_as_a_first_frame_is_closed_with_nothing_said: before, the
    v0 server answered the REAL token as a first frame with
    {"ok":true,"version":"0.42.0"}. Also: a request, a non-hex32 or
    upper-case hello, a hello with company, a binary hello, a ping.
(b) a_wrong_client_proof_is_closed_with_nothing_said_after_the_challenge:
    another token's proof, the SERVER-labelled (reflected) proof, cn/sn
    swapped, upper case, the raw token, a second hello, a proof with
    company, and a replay of the right proof for another connection's
    challenge. Before: no challenge at all (the v0 server reset the socket).
(c) the_server_proof_is_the_specs_hmac_computed_here_and_the_reply_names_the_version:
    the reply is exactly {ok, version, proof}; proof = the test's own HMAC.
(d) the_token_never_appears_in_any_byte_the_server_writes: everything read
    off the raw socket — the 101, challenge, ok and four replies — has the
    token neither as hex nor as its 32 bytes. Red with sn = token mutated in
    ("the token, in hex, went out on the wire").
(e) a_handshake_that_outlasts_the_deadline_is_refused_even_when_each_step_is_quick:
    two 1.71 s steps; a brisk control is served first. Red with a per-frame
    deadline mutated in (the server answered ok after 3.4 s).
Also policy.rs unit tests: the frame shapes, and a proof bound to its
direction, its nonces and the token.

impostor.py's idea from the other side, against a release build: a stand-in
WITHOUT the token answered a spec-following client seven ways (reflect the
client's proof, random, HMAC with an empty key / keyed with the client's
proof / keyed with cn, the client label under the client's proof, no proof
field); the client refused all seven, and the token was on the wire in
neither direction. The real serve's proof verified (positive control), and
the token is not in its serve.log. Not claimed: an impostor running as the
SAME user can read serve.json; nothing at this layer stops that.

wire.rs, serve-dispatch-check.mjs's wire header, AGENTS.md and the CHANGELOG
describe v1. tests/serve_bin.rs drives the binary with its own v1 client.
…OWASP M1)

OWASP finding M1 (Medium): nothing limited connections before auth. The
review measured six unauthenticated sockets, each part-way through a 60 MiB
message, taking RSS from 21 MB to 277 MB, and 300 idle TCP connections
spending the default 256 descriptors — after which the user's own client
was RST and an authenticated client's `tmux_check` failed.

- Connections are capped: 64 open at once, 16 of them unauthenticated (from
  accept until the client's proof verifies). Past either cap a NEW
  connection is accepted and dropped with nothing written. The
  unauthenticated slot is handed back the moment the proof verifies; the
  connection slot when the session ends. The log says "at the cap" once per
  episode, not once per connection: a 176k-connect flood wrote 15 lines.
- Before auth a peer may send 512 bytes in all (two handshake frames are
  under 200). tokio-tungstenite cannot change its limits after
  construction, so the pre-auth bound is a `Budget` on the upgraded socket
  UNDER the codec; past it the read fails and the connection ends where it
  stands, before any payload is buffered.
- After auth, a message (and a frame) may be 16 MiB — tungstenite's message
  default was 64 MiB. Measured: the editor saves at most what `read_file`
  could open (1 MB): 0.98 MiB of JSON for this repository's Rust, 5.72 MiB
  in the worst escaping case; a 1 MB paste is a JSON array of bytes, 3.35
  MiB of ASCII, 3.81 MiB of emoji. 16 MiB is the worst save ×2.8 and pastes
  to about 4 MB. A larger message ends the connection; the client reconnects.
- HANDSHAKE_DEADLINE (the HTTP phase) is 5 s, down from the docs server's 20.
- hyper's request buffer is 64 KiB (from ~400 KiB): room for the cookies a
  browser sends for every dev server on 127.0.0.1, ports being no boundary.
- serve raises its SOFT RLIMIT_NOFILE at boot to min(hard, 10240) — never
  down, never past hard — and logs "serve: open-file limit 256 → 10240".

Tests, each shown red first (against the limits plumbed in but unenforced):
- a_preauth_frame_past_the_byte_budget_is_closed_at_once: a header claiming
  1 MiB plus 4 KiB of it. Before: closed only by the auth deadline, 3.00 s.
- connections_past_the_unauthenticated_cap_are_closed_and_a_served_client_keeps_working
  (cap 4): before, connection 0 past the cap stayed open.
- connections_past_the_total_cap_are_closed (cap 3): before, the fourth
  stayed open.
- a_message_past_the_post_auth_cap_ends_the_connection_and_a_large_paste_is_served:
  cap+1 bytes as 1 MiB frames, because tungstenite already refused a single
  FRAME past 16 MiB and only its MESSAGE default was 64 MiB. Before: answered
  ({"id":2,"ok":null}). A 1 MB paste is answered either way.
- a_connection_that_never_speaks_is_closed_within_five_seconds: before,
  still open at 7 s.
- tests/serve_bin.rs serve_raises_a_low_soft_open_file_limit_at_boot: the
  binary launched at a soft limit of 256; before, no such line was logged.

The review's floods, rerun before/after on this machine, each against a
fresh release serve launched at a soft fd limit of 256:
  6 × unfinished 60 MiB message, pre-auth:  RSS 4 → 291 MB   |  4 → 6 MB
  300 idle connections:  260 open files, new client RST     |  30 open files, new client still turned away
  client authenticated BEFORE the flood:   tmux_check false  |  tmux_check true, probe_dir answered
And how hard NEW connections still are to get while a local process floods
(flood3.py): 300 connections opened once and held — a new client got in
after 5.08 s (the HTTP deadline freeing the held slots; before M1: not in
15 s); 32 attackers reopening each connection the instant it closes
(~176k connects in 10 s) — a new client got in on 0 of 49 tries. Not
claimed: a local flood can lock NEW clients out. It can no longer take the
memory, the descriptors, or a client already in.
feat/server-edition made Codex permissions and pi project trust HOST policy
(OWASP M2): `host-policy.json` in the config dir, written only by the
Settings act, and read only by the engine — `hostpolicy::boot` migrates it
and loads it into core's overrides "at startup, before any client can
connect". The app's `.setup` calls it. `worktrees serve` is the engine
under one-engine-per-host, and nothing in it called boot: a host that chose
`ask` for Codex and then restarted serve would launch every lane under
`auto-review`, the default the file is there to override.

`cmd_serve` now calls `hostpolicy::boot(&config_dir)` after the
one-engine refusal (boot may write the file, and a refused second serve
must write nothing) and before the listener exists. Deliberately not in
`host::start`: the app also calls that, and its one boot call stays inside
its `.setup`, where hostpolicy-check.mjs holds it. The check needed no
change. The binary test's launcher drops the two policy env vars, whose
tier would outrank a test's file.

Test, shown red first: tests/serve_bin.rs serve_loads_host_policy_at_boot
— host-policy.json {"codex_permissions": "ask"} in the config dir at
launch, then get_codex_permissions over the socket. Before:
{"id":1,"ok":"auto-review"}. After: "ask".
@brethash
brethash force-pushed the feat/worktrees-serve branch from 60b21b0 to be5bb71 Compare October 20, 2026 14:01
@brethash
brethash added this pull request to stack #517 October 20, 2026 14:54
… boundary (OWASP L1)

log_event kept a client's newlines, so an authenticated serve client wrote a
whole forged `[1] serve: host-admin …` line, timestamp and all, into
serve.log (the OWASP review's L1). It is cleaned in the API body, so app.log
and serve.log get the same line: C0/C1 controls, U+2028/2029 and the bidi
embeddings, overrides and isolates become visible escapes, then the result is
cut to 4000 bytes on a char boundary.

The old cut was `String::truncate(4000)`, which panics off a boundary: 3999
ASCII bytes and an `é` took the handler down.

Tests: three unit tests on `ui_line`, red against the old body (the cap one
as that panic), and one through the binary in serve_bin.rs, red with CR, ESC
and NUL in the file and the forged line standing on its own.
serve.log was created 0644 beside a 0600 serve.json, so any local user could
read the engine's PATH, the places opened and every client connect. It is
now created 0600, an existing file is tightened through its handle on every
write (every serve on the machine shares it, an older binary among them),
and serve.log.1 is tightened after the rotation, because a rename keeps the
mode the file had.

Tests, all red first under umask 022: three on append_line (a fresh file, a
0644 file already there, a 0644 file past the cap whose serve.log.1 came out
0644), and serve_log_is_owner_only_new_or_inherited through the binary. Its
second half, a relaunch over a file set back to 0644, was shown red on its
own by removing the per-write tightening and keeping the mode at creation.
…ing spawns

shell_open took the slug as given and spawned a login shell in
`<wt_root>/<slug>`: `--all`, `nope` and `lane/sub` each got a shell, and the
first open also ran the sidecar sweep for a session named after it. It now
holds the slug to the rule the other place commands share: core's safe_arg
(no leading `-`, no `..`, no CR/LF/NUL, not empty) up front, then "is it a
place" in place_session_cwd, against the same place_index drop_reference
reads, answering `no such place: <slug>`.

Test: shell_open_refuses_a_slug_that_is_not_a_place_before_anything_spawns,
red first (`--all` was given a shell), and a positive control that `(main)`
and a `.worktrees/` directory still resolve. The serve socket tests open
`(main)` shells end to end.
…r says which is missing

term_open answered ok for a tmux session that did not exist: the attach
client in the pty printed "can't find session" and quit, and the caller held
an id for nothing. And because a tmux target is a prefix match, a term_open
for a session that had just gone attached whichever session's name began with
it: another place's agent, typed into from this pane. It now checks with
core's session_exists (list-sessions, exact) before anything spawns, answers
`no tmux session named <name>`, and attaches with `-t =<name>` so the attach
is exact too.

Nothing relied on "ok, attach later": TerminalPane mounts only for a session
`ls` reports up and writes an open error into the pane, and the serve client
ends a channel on an err. A missing session could not attach later anyway,
since the tmux client exits at once.

Tests in serve_bin.rs against a private tmux server that serve is pointed at
through TMUX (this side passes -S on every call): a missing session is an
error naming it, and a prefix of a live session's name is refused while the
exact name attaches (tmux lists exactly one client, on that session). Both
were red first: `{"ok":1}` for the missing one, and the prefix attached
wtsb-lane.
a_handshake_that_outlasts_the_deadline_is_refused_even_when_each_step_is_quick
failed once in a loaded gate run with EINVAL from set_read_timeout. The
server had closed at the deadline, as it should; the test's late `auth` send
drew an RST, and macOS refuses SO_RCVTIMEO on a reset socket (measured, with
the next read answering ECONNRESET at once). Whether the RST landed before
the call was a race.

rest() now accepts EINVAL there, since a reset socket cannot block a read,
and asserts on any other error. Shown by forcing the race (a 200 ms sleep
after the send): red 3 of 3 with the gate's EINVAL before, green 3 of 3
after; the sleep removed again.
app.log sat at 0644 in a 0755 directory, readable by every local account,
with the login-shell PATH, the places opened and the engine's own errors in
it: serve.log's exposure, one level out. The sink's body moves into
`append_line` and takes serve.log's shape: 0600 at creation, an existing
file tightened through its handle on every write, app.log.1 tightened after
the rename, which keeps the mode it found.

The tightening is one helper now, `worktrees_api::log::owner_only`, which
serve.log's writer uses too, so the two sinks cannot drift apart.

Tests: a fresh file, a pre-existing 0644 file, and a 0644 file past the cap,
all red first under umask 022 (644, 644, "app.log.1 is 644"); serve's three
log tests still green on the shared helper.
tune_session set aggressive-resize and window-size with `-t <session>`, a
tmux PREFIX match: handed a name whose session had gone, it set them on
whichever session's name began with it. Its two callers (term_open, after
its exact check, and ops::launch, right after new-session) make that a race
rather than a path, but the same reason as term_open's attach applies. The
target is now `=<session>:`, exactly that session's current window.

Test: tune_session_tunes_only_the_session_named_exactly, against a private
tmux server. The call runs in a child (this test binary again, filtered to
the test, TMUX naming the private socket), because tmux() takes its server
from the environment and setting it in-process would move every other
test's tmux calls. Red first: tuning `wtt` set aggressive-resize on
wtt-lane. The exact name still gets both options. make test-real-tmux 5/5.
…argv) into worktrees serve

Brings in main's #520 (claude_usage hands the token to curl on stdin;
proc.rs) and 4067c33's CHANGELOG wording for the 150th command.

One conflict, CHANGELOG.md: #520's bullet and this branch's two new ones
all ended [Unreleased] ### Fixed. Every bullet is kept, #520's first; the
section still has one each of Changed, Added and Fixed.
Decision 6 says serve has a CI witness that its listener cannot be reached
from the LAN; only the docs server had one. This is the same shape, against
the binary: a connect to this machine's own LAN IPv4 on serve's port must
fail, after a loopback connect to the same serve has succeeded, because
without that first half a dead server passes.

A connect, not a bind, because no bind answers the same on both platforms.
Measured, a connect does: on macOS 27.0 and Linux 6.12 the LAN connect is
refused against a 127.0.0.1 listener and accepted against 0.0.0.0. The
after-accept peer check in conn.rs does not mask a wider bind, since the
kernel completes the handshake before accept returns. On a CI runner a
missing LAN address fails the test instead of skipping it.

Red with the listener bound to 0.0.0.0: "192.168.0.40:50706 answered, so
serve is reachable off loopback".
A dispatch body that panicked never answered its request. tokio keeps the
panic inside the blocking task, so the connection, its lane and the server
all carried on, and the client waited on that id forever. answer() now
catches the unwind and replies {id, err: "internal error"}, with nothing of
the panic in it. The log gets one error line naming the command, beside
the panic hook's own entry. The line's text is escaped with {:?} (C0, C1,
U+2028/9 and the bidi controls, measured) and capped at 500 chars, since a
body can panic over a client's value.

The seam is a test-build-only command, dispatch::TEST_PANIC, which is never
in TABLE. When it names an attach id it can run on that attach's lane, so
the test covers both the concurrent pool and a stream lane.

Test a_body_that_panics_is_answered_and_everything_after_it_is_served,
red on the old answer(): no frame within 5 s ("expected a frame: ...
os error 35"). It also checks the next request on the same socket, a
request queued behind the panic on the same lane, a new client, and one
log line per panic. With the log text left raw, it is red at "escaped onto
one line".
CI on #516 (run 39537958064, macOS rust job, which installs no tmux):
term_open_refuses_a_session_that_does_not_exist panicked at
serve_bin.rs:330 with "tmux: Os { code: 2, kind: NotFound }". The test
needs no tmux, but PrivateTmux's Drop ran kill-server through run(), and
run() expect()s. Cleanup now goes through try_run(), which never panics.
run() still expect()s, and only test bodies that have checked available()
call it. core's tune_session test returns at have_tmux() before it makes
its server, but its Drop had the same unwrap(), so it gets the same
try_run().

Red with tmux off PATH (PATH=~/.cargo/bin:/usr/bin:/bin:/usr/sbin:/sbin):
the CI panic exactly, at serve_bin.rs:330:95. Green after, with and without
tmux on PATH. The tune test skips without tmux and passes with it.
serve wrote serve.json and started the engine before it installed its
signal handlers, and even then installed them inside spawned tasks. A
SIGTERM in that window took the default action: the process was killed,
serve.json was left behind, and nothing logged "serve: stopped".
serve.json is how a client or a supervisor learns that serve is up. Two
serve_bin tests that stop serve straight after launching it lost this
race in a run with tmux off PATH (exit status None, not 0). The race is
not about tmux; tmux only changed the timing.

signals() now creates both Signal streams synchronously, first thing in
the runtime and before start(). Each handler is in place when signal()
returns, and the Notify's stored permit keeps a signal that arrives
before anyone waits.

Test a_sigterm_as_soon_as_serve_json_exists_is_a_clean_stop: ten rounds
of launch then SIGTERM, each exiting 0 with serve.json removed. The
launch helper now polls for serve.json every 5 ms instead of every 50 ms,
so the SIGTERM lands within a few ms of the file appearing. Red 6 of 6 on
the old code, with tmux on PATH and without ("signal: 15 (SIGTERM)" in
rounds 0 to 6). Green 3 of 3 under each PATH, and the full serve_bin is
11/11 with tmux and without.
… said

The panic hook host::boot installs, for the app and serve alike, logged
info.to_string(). That is two lines: "panicked at f:l:c:", then the
message raw on a line of its own. So grep '[panic]' found where a panic
happened and never what it said, and a message carrying a client's text
could start a line that read as the log's own (OWASP L1, as for
log_event).

It now logs one line, `panicked at <file:line:col>: "<message>"`. The
message goes through log::panic_text: {:?}-escaped (C0, C1, U+2028/9,
the bidi controls) and cut at 500 chars. serve's answer() was already
doing the same for a body that panicked, so its private copy moves into
worktrees_api::log and both use it.

Test a_panic_is_one_log_line_with_its_location_and_its_message re-execs
the api test binary as a child that runs the real boot() with a file
sink and panics with "the first line\n2026-01-01 … [info] forged
\u{202e}". The hook and the sink are process-wide, which is why it runs in
a child. Red on the old hook: the [panic] line ends at "host.rs:438:49:"
and the message follows on lines of its own. Green after.

AGENTS.md describes the format where it tells you to grep app.log for
[panic]. The CHANGELOG sentence goes in the L1 log bullet.
The app's sink wrote each line with writeln! on an unbuffered File. That
is one write(2) per piece of the format (stamp, "Z [", level, "] ", msg,
newline), so two threads logging at once interleaved their pieces inside
one line. serve.log had the same tearing until ecf6851. Now the line is
formatted into one String first and written with a single write_all.

Test app_log_lines_from_concurrent_writers_are_whole: 8 threads write
300 lines each through append_line, and every line must come back whole.
Red 3 of 3 on the old sink ("2026-10-21 05:13:202026-10-21 05:13:20Z
[2026-10-21 05:13:20infoZ [] infow2-0-..."). Green 3 of 3 after.
… next one

serve drained ~/.cache/worktrees/inbox on every 3 s tick whether or not
anyone was connected, so after an app crash the client-less serve the app
had started took each request within a tick and emitted it to nobody. With
the app down, v0.42.0 left the request for the next launch, inside the
inbox's 30 s life. In the rehearsal, with two engines up, the orphan won 4
of 6 (D2).

Host gains has_audience (default true; serve's Broadcast: an authenticated
client is in the map), and drain_inbox consumes nothing without one: it
sweeps instead (inbox::sweep), leaving fresh requests and deleting expired
ones, so a long client-less stretch leaves no backlog for a fresh request
to expire behind. The poll logs "inbox: N request(s) held until a client
connects" when that number moves.

The serve_bin test is decided by that log line, not by a sleep. On the old
code it failed within one tick with the request taken off the inbox. With
has_audience forced false it fails on the other half: the client that
connects never receives the request.
… host policy) into worktrees serve

Brings in #508's D6 fix: the app keeps a copy of the host policy it last
picked in ui-state.json (settings.ts, SettingsSheet.tsx, PiPanel.tsx), so an
earlier version keeps the user's choice rather than resetting Codex to
Auto-review. hostpolicy-check.mjs guards it, and hostpolicy.rs's doc
comments say so.

No conflicts. D6's CHANGELOG sentence extends the host-policy bullet under
[Unreleased] ### Fixed, where it sits on both sides; the section still has
one each of Changed, Added and Fixed.
…that never follow a link) into worktrees serve

Brings in main's #521-#523 by way of #508's ed65ed1. That includes #523's
fsx::write_atomic, which inbox.rs's request() now uses (merged cleanly
beside this branch's drain/sweep), and fsx's no_source_builds_its_own_temp_name
guard.

The guard flagged six lines on this branch; each is resolved here, as
#508 did for viewer.rs and status.rs:
- servefile.rs wrote serve.json through a temp of its own,
  `.serve.json.<pid>.tmp`. It now goes through fsx::write_atomic with mode
  0600, so the temp is still created 0600, under the same flock, with a
  random O_EXCL|O_NOFOLLOW name.
- serve/mod.rs's serve.log line carries the pid and names nothing; it is an
  OWN_TEMP_NAMES row with that reason.
- serve/tests.rs is test code. mod.rs declares it under #[cfg(test)], which
  a line scan cannot see, so it has one row for its pid-named scratch dirs.

One conflict, CHANGELOG.md: #523's bullet and this branch's two both ended
[Unreleased] ### Fixed. Every bullet is kept, #523's first after #520's,
and the section still has one each of Changed, Added and Fixed.
@brethash
brethash merged commit 80a9a77 into main Oct 22, 2026
18 checks passed
@brethash
brethash deleted the feat/worktrees-serve branch October 22, 2026 03:57
brethash added a commit that referenced this pull request Oct 22, 2026
…closed by #516

§4.2 named rustls 0.23.42 as already in the tree. cargo-deny reports
RUSTSEC-2026-0285, -0346, -0348 and -0349 against it (fixed by 0.23.46),
and RUSTSEC-2026-0382 against rustls-webpki 0.103.13 (fixed in 0.103.16).
State floors instead and name the real path in: tauri-plugin-updater ->
reqwest.

§8: #516 as merged closed gap 15 (meta::ui_line escapes and truncates on
a char boundary; serve.log is 0600 and re-tightened) and gap 11
(serve_bin.rs::serve_can_be_reached_on_loopback_and_not_off_it, in CI on
both OSes). The fadb2ce line refs predate both. §0, §3.4, §4.6, §5.4 and
slice 0 follow. Slice 0 keeps only the witness's release gate.
brethash added a commit that referenced this pull request Oct 22, 2026
Phase 1 shipped while the proposal was waiting, and several of its
statements described a branch that has since been squash-merged.

- A "Status at v0.43.0" note: what Phase 1 shipped, what this document
  still proposes, and what shipping changed here.
- §8: gap 9 is closed by #523. #516 closed the prefix match of gap 3
  (term_open/tune_session attach `=<name>`) and shell_open's `..` slug
  in gap 5; the scope check and list_branches remain. Gap 6's #519 is
  still open. Gap 16 is no longer latent.
- §3.3: safe_arg refuses a leading `-`, `..` and CR/LF/NUL, not `/` or
  every control; the resolver states its own single-component rule.
- §6, O15, slice 0: #516's conn::answer already answers a panicking body
  ("internal error"); only the in-flight drop guard is left, with the caps.
- §9.1: since #525 a reconnect to a serve the app restarted never
  re-pushes the three page-pushed host policies (found by reading), and a
  replaced pane closes its old terminal id on the new socket.
- §4.4: the remote door reuses reconnect.ts's policy, minus starting a
  serve.
- server-edition.md: the header status, §III.9's Phase 1 and Phase 2
  notes, the Queued list, and Appendix B's host-policy and term::NEXT_ID
  entries say what shipped. The index marks both documents' status.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant