Repository navigation
feat(cli): worktrees serve — the engine behind an authenticated loopback WebSocket - #516
Merged
Merged
Conversation
… 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
force-pushed
the
feat/worktrees-serve
branch
from
October 20, 2026 14:01
60b21b0 to
be5bb71
Compare
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".
3 of 5 tasks
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.
This was referenced Oct 22, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
worktrees servehosts the same commands the desktop app calls (the ones #508moved into
worktrees-api) behind a WebSocket on loopback. One engine processper 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/stopis the engine's wiring, shared by the app andserve.Wire
{id, cmd, args}→{id, ok}or{id, err}.{channel, seq, data}: base64 for byte streams, JSON fortyped callbacks. It ends with
{channel, seq, end: true}, which neverovertakes its data.
{event, payload}go to authenticated sockets only.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)
Hostmust be loopback,Originmust be on an allowlist, and a missingOrigin is refused. There is no CORS header. Every refusal closes the socket
with zero bytes written.
hello→challenge→auth→{ok, version, proof}, with differentlabels for each direction and a 3 s deadline for the whole exchange. This
fixes review finding H1: a process that grabbed the port after
servediedused to receive the live token as the client's first frame.
serve.json({port, token, pid, version}) is created mode 0600, under alock, with an atomic rename. The token is 256-bit from the OS CSPRNG,
rotated every launch, and compared in constant time.
RLIMIT_NOFILEraised at boot.Correctness fixes found by driving it
typing reached zsh scrambled (
echo→ecohd).serve.logline is written with one write(2), so lines stay whole underconcurrency.
Before this, a page reload left the shell with nobody reading it.
Verified
direction.
no longer take memory, file descriptors, or a connected client.
app/scripts/server-stack.shwas driven end to end. The real frontendconnects through the transport PR.
dependencies. All pinned, with no OSV advisories at the locked versions.
Before merge (in progress on this branch)
mainmerge. This adds dispatch rows for main'ssix new Kimi commands, keeps the app's single
.setup, and makesserveload
host-policy.jsonat boot, so a restarted server keeps your Codex andpi settings.
Not in this PR
serve(the next lane).Rehearsal fix (D2)
serve drained
~/.cache/worktrees/inboxwith zero clients connected, so afteran app crash a
worktrees showwas consumed in under a second and emitted tonobody; 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.rsnow writesserve.jsonthrough
fsx::write_atomic(…, 0o600)(randomO_EXCL|O_NOFOLLOWtemp, still0600 from creation) instead of its own
.serve.json.<pid>.tmp, which fsx'stemp-name guard rejects.