Commit Graph
1363 Commits
Author SHA1 Message Date
l0ng-ai 1ccdaeaa2d fix(agent-hooks): quote the hook binary for the shell that re-reads it
Every agent takes the hook as a command *line*, so a shell parses it again
before anything runs. The path went in double-quoted, and inside double
quotes `sh` still expands `$`, a backtick and a backslash — so an install
under `/opt/build$stage` reached the shell as `/opt/build/tty7`. The hook
then never fired: no error, no log line, just an agent that quietly stopped
reporting its status for good.

Checked against a real `sh` first, because the failure is invisible from the
code: the double-quoted form resolves the program to `.../q/tool`, the
single-quoted one to `.../q$stage/tool`. Then checked again through the
generated OpenCode plugin under node with a stubbed `$`, which is the path
that actually ships — the assembled command re-parses to the full path,
dollar segment intact.

Which shell is the target's, not ours: a hook installed on a remote machine
runs there. Only a local Windows target keeps the double-quoted form, which
is what `cmd.exe` wants and what the PATH-resolvable case avoids needing.

`shell_quote` had been written out twice already, in `daemon::install` and
`daemon::shell_integration`. Rather than add a third copy this moves the one
implementation to `core::shells`, where a core module may reach it without
depending on the daemon, and both old homes now re-export it.
2026-08-23 10:18:28 +08:00
l0ng-ai 17942c0262 fix(file-tree): stop the delete prompt implying a trash that is not there
Deleting from the file tree calls `LocalHost::remove` — `remove_file` and
`remove_dir_all`. The file is gone. The confirmation said only that it "will
be deleted", which on a Mac or in Explorer reads as "moved to the trash",
because in every file manager the user has ever used it is.

The remote prompts made it worse rather than better. They have always said
"there is no trash on the far side", and read next to a local prompt that
says nothing, that plainly implies the local side has one.

So the local prompts now say what the remote ones do, in all three locales,
and a test holds every delete confirmation to naming the trash it is not
using. That sentence is only read on the day it matters, so nobody would
notice it going missing again.
2026-08-23 10:10:14 +08:00
l0ng-ai f42eec5e8d test(keymap): hold the tmux preset to the same no-collision rule
A preset rebinds only the actions it names, so every default it leaves alone
stays where it was — which is where a collision would come from, and it would
only show up for the people who chose that preset. It is clean today.
2026-08-23 09:57:23 +08:00
l0ng-ai 1d5c0ab2ef test(keymap): fail the build if two default bindings claim one key in one scope
Two actions on one key is not always wrong: `secondary-enter` is fullscreen
in the window and commit inside the commit box, which is what a scope is for.
It is wrong when the scopes match, because then one of the two simply never
fires, and nothing on screen says which — the winner is whichever
`rebuild_keymap` installed last, which is an ordering this file has already
had a bug about.

Nothing checked it. The 112 defaults are clean today; the test says so, and
says which pair if that stops being true. Actions with no default key are
exempt — an empty string is "palette only", not a claim on a key, and about
sixty actions are deliberately in that state.
2026-08-23 09:54:58 +08:00
l0ng-ai 8fcc076374 test(git): pin which warning each destructive operation raises
`destructive()` decides what the confirmation dialog tells the user they are
about to lose, and the choice is load-bearing: a hard reset onto an older
commit clobbers the worktree *and* drops commits, and the code deliberately
reports the worse of the two, because telling someone only about their edits
invites a yes to a different question than the one being asked.

The test asserted only that such an op warns about something. Downgrading a
hard reset from LosesCommits to LosesWorktreeEdits passed it unchanged —
checked, not assumed. Each operation is now paired with the warning it owes
the user, and the same downgrade fails.

No behaviour change; the classifier was already right.
2026-08-23 09:48:09 +08:00
l0ng-ai 951a08e6dd fix(daemon): stop one pane's panic from costing every pane on the machine
The daemon holds every shell on the box behind mutexes — the pty master, the
child handle, the writer, the pane state. Seventy-four of those locks were
taken with `.lock().unwrap()`, so a panic in any one critical section
poisoned the mutex and every later taker died on it too. One bug in one
pane's thread, and the daemon can no longer serve any of them.

Poisoning buys nothing here. Whatever inconsistency the panic left is there
either way; the flag only decides whether the next thread also dies. A
garbled write to one pane is recoverable, and losing every session on the
machine is not.

Most of the code already agreed — about seventy places carried on, spelled
out by hand — so this is mostly a drift fix. `Locked::locked` gives the
policy one name, the daemon uses it throughout, and a test walks
`daemon/` and fails with the file and line if a panicking lock comes back.
Somewhere that genuinely cannot tolerate the inconsistency can still take
the poison with `lock()`; the point is that it be a decision rather than
the default.
2026-08-23 09:42:42 +08:00
l0ng-ai 5b77b51a28 fix(tree-sync): collect the shells a window strands, from a census of its own panes
Closing a tab straight after making it stranded a live shell — six times out
of six, reproducibly. It holds a pty and an fd and runs until the machine
does not. A person cannot type that fast; an agent loop that opens a tab per
task and closes it when the task is done meets it on nearly every iteration,
which is the shape this product is built for.

The rule for judging a pane was already here and already right: one that no
window is showing and no workspace on the machine names is nobody's. Two
earlier attempts failed because of what was put in front of it. Both parked
panes at the *site* that dropped them, and the leak does not live at a site:
a hydration whose whole session is discarded never compares a before against
an after, so its panes were never offered for judgement at all.

So the window now keeps a census of every pane it has brought into existence,
taken at the one point both spawn routes meet, and judges the census rather
than a list of suspects.

The second half is when. The sweep ran only on a landing that rebuilt from a
full machine tree, and the failures do not lead there — a refused operation
calls `desync`, which primes, and a prime pulls this workspace's mirror
alone. Instrumented, the sweep did not run once across three cycles that
stranded three shells. It now also runs after a prime, and pays for the
machine-wide pull only when a censused pane is not on screen anywhere;
while everything the window made is still showing, that is one set
comparison and no request.

Measured against a live window, same reproducer, same machine:

  before   6 create-and-close cycles    6 stranded shells
  after   16 create-and-close cycles    0

and nothing else was harmed: four unrelated panes kept their ids and their
scrollback across ten churn cycles and a GUI restart, which is the failure
this has to be judged on — a leaked shell is recoverable with `pane close
--orphans`, a shell killed under a live window is not.
2026-08-23 09:29:33 +08:00
l0ng-ai 7d7f9a508f test(host): hold every host to keeping a script's executable bit
A save is an overwrite of a file that already exists, and the mode belongs
to the file rather than to the write. A host that wrote by creating a fresh
file, or by sending the far side an explicit set of attributes, would turn a
script into something the shell no longer runs — silently, on a save the
user made for an unrelated reason, with nothing in the UI to report it.

Both hosts already get this right; nothing here is a fix. It is the case
that keeps them right, and it runs against the remote link too, where the
write is a control request rather than a syscall. Checked by making the
local write a remove-then-create: the case fails, as it should.

The two sandbox hooks follow the existing symlink one — a host with no
notion of an executable bit returns None and the case skips.
2026-08-23 09:09:10 +08:00
l0ng-ai 5ce0fbdd5a fix(editor): give a saved file back the line endings it arrived with
Enter belongs to the input widget and inserts a bare \n whatever the file
around it does. Editing one line of a file checked out with CRLF therefore
mixed the two endings, and git reports that as a rewrite of lines the user
never visited.

The buffer still keeps the exact bytes it was given — that part was already
right, and there are now tests holding it there, along with hard tabs and a
missing final newline. What changed is the save: a file that arrived
uniformly CRLF goes back out uniformly CRLF. Deliberately unanimous, so a
file that already mixes its endings is still written exactly as found rather
than being handed a winner it never asked for.

Saving had no test at all before this; the new one writes a real file and
reads the bytes back.
2026-08-23 09:06:20 +08:00
l0ng-ai d6de373619 test(pane): assert awkward text survives the pty and the ring unchanged
The grid's own tests cover wide cells and combining marks, but they sit at
the end of the chain: a pty, a ring and a snapshot on a socket come first,
and nothing exercised those with anything but ASCII.

Both spellings of an accented letter go in on purpose. A terminal must not
normalise, so the test asserts not only that each form comes back, but that
neither turned into the other.
2026-08-23 08:58:50 +08:00
l0ng-ai ab8faf5877 test(sftp): drive the subsystem against a real server
`SftpManager` had no live coverage of any kind. Its tests cover the path
arithmetic — `remote_join`, `remote_parent`, `safe_local_name` — and the
transport-failure classifier, none of which needs a server, and all of which
sits downstream of a subsystem no test had ever opened. The whole panel is
built on two calls, `list` and `op`, and neither had been made against sshd.

Now both are, and I ran it: mkdir a scratch directory, create a file in it,
list it and check the entry comes back as a `File`, rename it and check both
that the new name is there and the old one is not, stat a path that was never
created and require the answer to be something other than `Done`, then remove
the file, remove the directory, and confirm listing it fails.

Everything lives under a directory named for the process on the far side and
is removed at the end; the far side being this same machine is what makes it
safe to assert about. Checked afterwards that `$HOME` holds no leftovers.

Takes its turn on the same mutex as the other two, for the reason that one
documents: they share `SshManager::global()`. All three pass together — the
fourth, GSSAPI, still wants a Kerberos realm this machine does not have.
2026-08-23 08:53:08 +08:00
l0ng-ai 510a42c2e5 test(ssh): put bytes through a real tunnel, and let the live tests take turns
`open_direct_tcpip` is what every local forward and the SOCKS proxy are built
on, and what a remote workspace's control link rides. Its existing tests are
about bookkeeping — which rule is registered, what a teardown reports — and
none of them puts a byte through a socket.

This one does, and I ran it: bind a loopback listener, open a direct-tcpip
channel to it *through a real sshd*, write a line in, have the listener answer
it uppercased, and assert both ends saw what the other sent. A listener of our
own rather than a well-known port, so the test needs nothing to be running and
cannot be fooled by something that is.

Adding it broke the key-auth test, which is the interesting part. Both share
`SshManager::global()`, and sharing is the whole point of it — one of them
asserts that a second `open_connection` is *reused*, and the other evicting the
same key underneath it makes that false. Under `--ignored` they are the only
tests running and they still collide with each other, so they now take turns on
a mutex. Verified: individually and together.

The spec both use is factored into `live_key_spec`, so the defaults that make
this run with no environment at all — localhost, `$USER`, port 22,
`~/.ssh/id_ed25519` — are written once.
2026-08-23 08:49:39 +08:00
l0ng-ai d7df1352c9 test(ssh): an end-to-end key-auth check against a real server, and run it
tty7 speaks SSH itself rather than shelling out, and remote workspaces, the
routed pane socket and SFTP all stand on the connection it makes. The only
live test beside this one needs Kerberos — the case almost nobody runs — so
the case almost everybody runs had no end-to-end check at all.

It has one now, and I ran it. Against this machine's own sshd:

    cargo test -p tty7-core --lib live_key_auth -- --ignored
    test daemon::ssh::tests::live_key_auth_connects_and_opens_a_channel ... ok

Key auth against a real server, a channel opened on it, `echo tty7-live-check`
executed, the reply read back and compared, the exit status collected, and
then a second `open_connection` for the same spec asserted to be *reused* —
sharing one transport across panes is the reason `SshManager` exists, and
nothing was checking it either.

Ignored like its neighbour, because it needs a server, but a much smaller one:
Remote Login on the machine running the test and a key it accepts, which is
`ssh localhost true` away. The defaults are `localhost`, `$USER`, port 22 and
`~/.ssh/id_ed25519`, so on such a machine the command above is the whole
setup. `verify_host_keys` is off for the same reason it is next door: the
point is the transport, and a first connection to an unapproved host would sit
on a prompt no test can answer.

One thing the first run taught, kept as a comment: openssh sends the exit
status *after* `Eof`, so breaking on `Eof` reads the output and then reports
no exit code. Waiting for `wait()` to return `None` is the only end worth
waiting for. The output assertion passed on that first run — it was the exit
code that was missing, and it was my loop that was wrong, not tty7.
2026-08-23 08:44:41 +08:00
l0ng-ai caf5c58022 test(git-log): round-trip the two calendars against each other
`civil_from_days` dates the panic log and lives in `core::crash`;
`days_from_civil` lives here. They were written separately, they are each
other's inverse, and each was checked alone — this one by the walk beside it,
that one against four known dates. Four dates leave the century years and
everything before 1970 untouched, which is exactly where era arithmetic earns
its keep.

So they now check each other: 73,049 consecutive days from 1900-01-01, each
converted both ways. Two separately written implementations can only agree
across two centuries if both are right. The walk starts before the epoch on
purpose — asserted, so a later edit cannot quietly move it after — because
negative day counts are the half neither test was exercising.

Verified against the epoch shift, which it catches and which moves every date
in the log by a day.

One mutation turned out not to be one: changing `doe / 146_096` to
`doe / 146_095` is *equivalent* — brute-forced over ±547 years, not a single
day differs — so no test failing on it is the right answer rather than a gap.
Worth writing down, because it looks exactly like a constant somebody typed
wrong.

My own expected day count was off by one on the first run (73,048 for
73,049); the implementations were right and the arithmetic in my head was not.
2026-08-23 08:37:58 +08:00
l0ng-ai 1659391dad test(git-log): walk the civil calendar the commit dates rest on
`days_from_civil` and `days_in_month` are pure arithmetic that no test named,
and every date this crate prints goes through them. Hinnant's algorithm is
correct as written here; what is easy is transcribing one constant wrong, and
a wrong `146097` or `719468` moves every commit date by a fixed amount that
still reads as a plausible date.

Walked rather than compared against a second implementation, so there is
nothing to get wrong twice: step a day at a time from 1900 to 2100 using
`days_in_month`, count as you go, and require `days_from_civil` to agree at
every step. Two centuries of agreement is only possible if both are right — a
leap rule wrong in either drifts them apart within four years and never
recovers — and the count of leap days is asserted at the end so a walk that
skipped February would not pass either.

Anchored first on the epoch, the day before it, and the two century years that
separate the naive leap rule from the real one, so a walk that is merely
self-consistent cannot pass.

Verified against three mistranscriptions: the era length, the epoch shift, and
dropping the divisible-by-400 clause. Each fails it.

Found by looking for pure helpers that no test in their own file names — 607
of them, most trivial or I/O-bound. This pair was the one where being wrong is
both easy and invisible.
2026-08-23 08:33:11 +08:00
l0ng-ai e91e45f129 docs(tree-sync): a second dead end, and what the two have in common
Tried the nearest of the two silent paths: parking the seeded panes of the
queued operations `desync` is about to discard. It looks the most tractable of
anything left, because an op that never left this process provably never filed
its pane, so those are stranded by construction.

It is not a fix. It fires rarely — 0 to 2 panes across ten create/close cycles
— and does not reliably reap even those, because the sweep needs a hydration
to settle and a parked pane can outlive the window's next few pulls. One run
parked two panes and one of them was still an orphan at the end. Totals: 4, 4,
5 against a baseline of 5, 5, 5, which is inside the noise.

Backed out, and recorded next to the first attempt, because what the two have
in common is the useful part. Both park at a *site*. The leak does not live at
one — this comment already said so, and two experiments now agree with it: a
sweep phrased against any particular failure keeps missing whichever path was
not instrumented. It has to be phrased against the end state, over every pane
this window spawned, which is what the paragraph above describes and what
nobody has built yet.

No behaviour change.
2026-08-23 08:27:41 +08:00
l0ng-ai a5589bd582 docs(changelog): tell users about the stray-shell leak under an agent loop
The measurement is in the code; this is the half a user needs. A tab created
and closed again before the window has finished reconciling leaves its shell
running — 5 of 6 iterations, reproducibly, with no pause between the two
commands. A second's pause, or creating without closing, leaks none, so
somebody working in the window by hand will never meet it and an orchestration
loop will meet it on nearly every pass.

Under Known rather than Fixed, because it is not fixed. What is true is that
the strays are now findable three ways and that one command ends them all;
what is not is that anything ends them for you.
2026-08-23 08:20:22 +08:00
l0ng-ai 6635f94f58 docs(tree-sync): bound the orphan leak — it is an agent loop, not a fuzz
The total was already recorded; the shape matters more, and it is not volume.
Measured against a live window:

    tab new / split / send, paced or as fast as the CLI will go   0 orphans
    tab new then tab close, 1s apart, x4                          0 orphans
    tab new then tab close, no pause,  x6                         5 orphans

The last reproduced exactly — 5 of 6, three runs in a row. What strands a
shell is closing something before the window has finished reconciling the
thing that made it, and the gap that matters is under a second.

That reframes the cost. A person cannot type that fast, so nobody driving the
window by hand meets this. An agent does it by default: a loop that opens a
tab per task and closes it when the task is done leaks a live shell on nearly
every iteration, and that is the shape this product is built for.

The strays are visible — `pane ls --all`, the switcher, and `doctor` since
this afternoon — and `pane close --orphans` ends them. Nothing ends them
automatically, which is what the end-state sweep this comment asks for would
do. Recorded in the changelog under Known, because a user running an
orchestration loop should be told rather than left to find it in `pane ls`.
2026-08-23 08:18:59 +08:00
l0ng-ai ac90f6c212 fix(doctor): say when the server is running panes nothing holds
An interrupted `tty7 run` strands a pane. So does a window reconciling its
layout while something else edits the tree — measured at 17 from 160
operations, every one a live `zsh` holding a pty and its descriptors, and
`tty7 pane close --orphans` ended all 17.

None of that was news to tty7: `pane ls --all` names them, the switcher lists
them, and the reaper works. What was missing is that nothing volunteered they
existed. `doctor` printed `status  pid …, up 6s, 2 panes` and counted the
stray among them, so the one verb somebody runs when something feels wrong had
the number and did not say what it meant.

A row and a `server.orphans` count. Deliberately not an exit code: one stray
after an interrupted `run` is ordinary and the reference documents it as such,
so `tty7 doctor || alert` firing on it would cry wolf. What was missing was
the sentence, not an alarm.

Counted inside the arm that already has the machine tree in hand for the
dangling-context row, so it costs no extra round trip, and under `server`
because it takes a server to have panes at all.

The first draft put the count at the top level of the JSON, and
`the_doctor_json_sections_are_the_ones_the_reference_names` — added earlier
today for exactly this — caught it as an undocumented section before it went
anywhere.
2026-08-23 08:13:50 +08:00
l0ng-ai 96b7597497 docs(tree-sync): record what the orphan leak measures, and one dead end
The comment here already has the right analysis: panes stranded by a refused
operation are the minority path, the other two throw their panes away without
a refusal, and the sweep belongs after a hydration settles rather than at any
one failure. Two things it did not say, both now measured.

The scale and the severity. 160 CLI operations against a live window left 17
orphans, and every one was a live `zsh` — not a stale record — each holding a
pty and its descriptors. `tty7 pane close --orphans` ended all 17, so the
documented recovery does work.

And a dead end, written down so nobody spends the evening on it twice. Parking
the pane here rather than only warning — handing it to the `sweep_parked` that
already exists, which judges against the whole machine's freshly pulled tree
and spares anything a live view shows — is safe, and does not measurably help.
Four runs: of the panes that reach this arm, 33–60% were still orphaned at the
end without it and 43–50% with it. They are mostly already caught by the sweep
the layout rewrite arms. The leak lives in the two paths that never refuse an
operation, exactly where this comment says the fix belongs.

No behaviour change; the experiment was backed out.
2026-08-23 08:05:58 +08:00
l0ng-ai 24bc48704a fix(keymap): register the fixed bindings before the config, not after
gpui resolves a keystroke by sorting the matches on context depth and then on
registration index, later winning:

    matched_bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| {
        depth_b.cmp(depth_a).then(ix_b.cmp(ix_a))
    });

`rebuild_keymap` added the config's bindings and then the fixed ones, so a
fixed binding won any tie. Six of the seven are scoped — `Terminal`,
`Switcher`, `Palette` — and win on depth whatever the order. The seventh is
global: `secondary-+`, the font-size step. A config that asked for that chord
got `IncreaseFontSize` instead, with no error, nothing in the keybindings UI,
and nothing in the log — which is the silence this tree has already gone out
of its way to remove from an unparseable chord, a clamped setting and an
unread config key.

Swapping the two lines fixes it and changes nothing else: depth is compared
before index, so the scoped six still win. And it matches what "fixed" was
ever for, which the comment on `fixed_bindings` states — they are not in
`effective_bindings`, so a rebuild replaying only the config would drop them.
That is an argument about existing, not about outranking.

Found by checking the reverse direction of the shortcuts page: every chord it
prints against every chord tty7 binds. Nothing was undocumented — `⌘ C`,
`⌘ V`, `⌃ R` and the arrow shorthands are keys tty7 answers without a keymap
action, which is why a strict guard there would be wrong — but `⌘ +` turned
out to be bound twice, and following that up is what surfaced the ordering.
2026-08-23 07:49:42 +08:00
l0ng-ai 5ff8011b24 test(settings): pin the docs page to the section names, not just the count
`the_settings_page_documents_every_section` checked two numbers: that the page
says how many sections there are, and that it shows that many `<Card>`s. Eight
and eight is just as true when one of them has been called something else for
a release, so a rename passed untouched — and the page is what someone reads
before they go looking in the window.

The names now have to match. They come from `SettingsSection::nav_label`, a
new method whose match is exhaustive: a section added to `ALL` has to be given
a name before this compiles, and the nav builder reads the same answer instead
of spelling eight `L10nKey`s inline beside eight variants. One list where
there were two.

All eight agree today. Verified by renaming a card on the page, which the test
now names.
2026-08-23 07:39:17 +08:00
l0ng-ai 46656455f6 docs(config): give a summary line back the test it was written for
`a_quarantined_tree_can_be_found_by_something_that_wants_to_report_it` carried
two summary lines:

    /// What a ninth corruption does to the eight kept copies.
    ///
    /// What `doctor` can find out about a tree that was set aside.

Only the second is about that test. The first belongs to
`a_ninth_corruption_overwrites_the_oldest_kept_copy` forty lines below, whose
own comment opens mid-thought — "`quarantine_path` ends in `unwrap_or(base)`,
which reads like a defensive branch and is the wrap" — with no summary at all.
Same displacement this tree has fixed once before in `daemon::pane`.

Moved back. Both now open with one sentence saying what they are for, which is
what every other test in this file does and what a reader scanning them needs.

Swept the rest of the tree for the same shape — a doc block whose first two
paragraphs are each a lone sentence — and found one other, which is a genuine
summary followed by a genuine second paragraph. This was the only one.
2026-08-23 07:33:56 +08:00
l0ng-ai 9e49b6a412 test(cli): walk the nested verbs when checking the reference for flags
`the_reference_documents_every_flag_of_the_verbs_it_covers` read one level:
`cli.get_subcommands()`, then that verb's own arguments. Two things followed.

It never descended, so every flag a level down was unchecked — `tab new --cwd`,
`pane ls --all`, `pane split --ratio`, `pane close --orphans`,
`server restart --hard`. And its gate for "is this verb documented" looked only
for ``` `tty7 <name> ```, which the page uses for leaf verbs; a group is headed
``` ## `pane` — panes ```. So `ws`, `tab`, `pane`, `machine` and `server` were
skipped whole, taking their children with them. Fifteen flags were checked
where there are twenty-two.

Both fixed: the walk recurses, and the gate accepts either heading shape. The
reference turns out to be complete — nothing was actually undocumented, which
is worth knowing rather than assuming.

The matching rule was too strict as well, and the extension is what showed it:
requiring a backtick immediately before the dashes reported
`server restart --hard` missing when it has its own table row, written as
``` `server restart --hard` ```. It now looks for the flag anywhere on the
page.

The floor is five named nested flags rather than a count, because a count
drifts with the surface and what has to hold is that the walk went down.
Verified both ways: dropping `--orphans` from the page is reported, and
stopping the recursion is reported by name.
2026-08-23 07:28:52 +08:00
l0ng-ai 76929eb8df docs(changelog): four more, closing the backlog's user-visible half
`doctor` now says when a workspace tree was set aside — a `machine.json` that
does not parse takes every tab and pane layout with it, and the only notice was
a log line a default install never writes. A panic in the updater is recorded:
it runs detached with its stderr attached to nothing, doing the one job that
can leave an install broken, and it was the one binary of the three without a
crash hook. The side panels accept the widths the reference publishes, instead
of accepting a documented number and quietly drawing something else. And log
timestamps say they are UTC, which they always were.

That brings the Unreleased section from the 51 bullets on main to 103. What is
left in the branch is genuinely minor — internal wording, an icon asset, test
scaffolding — and is the kind of thing this changelog has never listed.
2026-08-23 07:22:56 +08:00
l0ng-ai c929057126 fix(scm): fold a commit author's name onto the byline
git will not take a control character in a branch name:

    $ git branch $'featx\ry'
    fatal: 'featx?y' is not a valid branch name

and takes one in an author name without a word about it:

    $ git -c user.name=$'Bad\rName' -c user.email=b@b commit --allow-empty -m x
    $ git log --format=%an -1 | sed -n l
    Bad\rName$

So a cloned repository can carry one into the byline under a commit subject,
which sits in a bar exactly one row tall beside a subject that is truncated to
fit. Ninth site in this tree, and the third whose input is a repository rather
than the machine tty7 runs on.

Both bylines: the diff overlay's `label_byline` and the commit detail's
`byline` are separate functions that had drifted into the same shape, and
folding one would have left the other. The overlay's commit *subject* is
folded at the same site for the same reason — the graph row already does it,
and this bar is no taller.

An author that is only control characters now reads as no author, so the
middle dot goes with it, which is what the surrounding tests already say
should happen for an empty one. Both checked against injected regressions.
2026-08-23 07:19:51 +08:00
l0ng-ai 029aa3e988 fix(scm): fold a status path where the panel and the detail draw it
`git::status` asks for `--porcelain=v2 -z`, and its own comment says why:
without `-z` "any path with a space, a quote or a newline comes back
C-quoted". Raw is the right thing to read — it is what opens the file — and
the wrong thing to draw. A filename is bytes to the kernel, `touch $'a\nb'`
makes one, and the row it lands in has a fixed height that a mandatory break
grows past and over its neighbour.

The file tree met this and folds its own names. The source-control panel and
the commit detail read their paths from a different place and arrived at the
shared `split_display_path` unfolded, so the eighth site in this tree is the
one place two views share.

Folded there, which is why it now hands back owned strings instead of borrows
— and on both halves, because a directory carries a newline exactly as easily
as a file does. Checked against an injected regression, and the existing
assertions about how the split itself behaves are unchanged apart from their
types.
2026-08-23 07:11:48 +08:00
l0ng-ai 95b31cd64b fix(scm): fold a commit subject onto the row that draws it
A commit subject is a name nobody here composed, and git does not clean it:

    $ git commit -m "$(printf 'fix: something\rHIDDEN OVERWRITE')"
    $ git log --format=%s -1 | sed -n l
    fix: something\rHIDDEN OVERWRITE$

Tabs, vertical tabs and form feeds survive the same way. Any repository
somebody clones can carry one, and the graph draws whatever the clone has —
onto a row of fixed `GRAPH_ROW_H`, which a mandatory break inside it grows
past and over the row below. Seventh place in this tree to meet the same
hazard, and the first where the input comes from a repository rather than
from the machine tty7 runs on.

`\r` earns its own line in the comment: it is a carriage return, so the
visible half of that subject need not be the stored half. Folded, both halves
are on the row and neither is pretending to be the whole of it.

Folded before `split_conventional` rather than after, because the split
borrows from what it is handed — which is also why it goes through a named
`row_subject` the test can call, instead of being spelled inline.

The detail panel is deliberately left alone: it shows the whole subject across
several lines under a `line_clamp`, so a break there costs a line it has
already budgeted for. Checked against an injected regression.
2026-08-23 07:04:36 +08:00
l0ng-ai 74c4de7127 fix(ui): fold a workspace and tab label where it is drawn, too
Folding names in `normalize_name` covered the names this build stores. Two
kinds get past it.

A workspace with no name of its own is labelled by the directory its first
pane sits in, and nobody named that. `mkdir $'proj\nname'` is enough — checked
against a live daemon, which reports the pane's cwd as
`/tmp/…/proj\r\nname` — and the label grows a row and paints over what sits
below it, the same failure this tree has now fixed six times elsewhere.

And a name can arrive already stored: written by an older build, or read out
of a *remote* machine's tree, where the folding is only as good as the server
that wrote it. The dialect version does not separate those, correctly — the
wire shape never changed — so a peer mid-rollout is the ordinary case rather
than a contrived one.

So both are folded where they are drawn as well: `display_name_of`, which is
the one funnel for a workspace's label, and `tab_label`'s user-name branch.
That is the rule the file tree already follows and states — fold at the point
of drawing — and it composes with the storage-side fold rather than replacing
it: the stored value stays what someone typed, and what reaches a row is one
row's worth.
2026-08-23 07:00:02 +08:00
l0ng-ai 2cc5ffb568 fix(daemon): raise the open-file limit, and name it when it is still reached
A pane costs about three descriptors — the pty master and the pipes behind it
— and the daemon took whatever soft limit its launcher happened to have. On
the historic macOS default of 256 that is a ceiling around eighty panes,
against a `MAX_PANES` of 16,384, and nothing in the failure said so:

    tty7: spawning a shell: daemon refused Spawn: dup of fd 93 failed

That sentence does not contain the word "file". Measured with an inherited
soft limit of 96, the daemon stopped at 26 extra tabs.

Two halves. The soft limit is now taken up to the hard limit at startup, which
is the ordinary thing a long-lived server does, needs no privilege, and leaves
an administrator who lowered the *hard* limit in charge of the ceiling. Capped
at `OPEN_MAX` because macOS refuses `RLIM_INFINITY` outright and a refused
request would leave the low limit in place, which is the one outcome worth
avoiding. Same measurement afterwards: past 45 tabs and still going.

And when the hard limit genuinely is the ceiling, the refusal says what it is
and what to do, keeping the original in parentheses for whoever needs it. The
re-wording sits where the spawn handler turns the error into a reply, not at
`openpty`: several calls along that path can hit the limit, and libc's "Too
many open files" and portable-pty's "dup of fd N failed" both arrive there.
Matched on the text because portable-pty hands back a message, not a code —
so the test covers both spellings and, more importantly, that a missing
program and a permission error are left exactly as they were.

Verified against a live daemon in both configurations: a low soft limit is
raised past, and a low hard limit produces the new sentence with the daemon
still serving afterwards.
2026-08-23 06:37:12 +08:00
l0ng-ai fa5b901d8d fix(doctor): check the shell every new tab is going to launch
$ cat config.json
    { "shell": { "program": "/nonexistent/shell" } }
    $ tty7 new
    tty7: spawning a shell: daemon refused Spawn:
          no such program on this machine: /nonexistent/shell
    $ tty7 doctor | grep config
    config           ok

Nothing can open a tab, and the verb that checks the install says the config
is fine — which it is, in the only sense that row means: the file parses. The
same table already reports an unusable `custom_shells` entry, and that costs a
menu row. This costs every tab, every `tty7 new`, and the GUI's new-tab button.

The check is the daemon's own `shell_program_problem`, moved from
`daemon::pane` to `core::shells` beside `unusable_custom_shells` so both
callers share one definition and the row cannot drift from the refusal it is
predicting — it prints the same sentence the spawn will. Missing, a directory,
and not executable are told apart, because they are three different fixes.

A program given as a bare name is still not reported: the OS resolves it
through PATH and guessing at that would be worse than silence — the moved
comment says so and the moved tests pin it. Skipped under `-m` for the same
reason the hooks row is: a path checked here would answer about the wrong
machine.

Verified against a live daemon across all five cases — missing, directory,
non-executable, bare name, and no `shell` set — with exit 1 for the three that
break and 0 for the two that do not.
2026-08-23 06:11:12 +08:00
l0ng-ai 4e5699dc89 fix(config): say what is wrong with a config file, and where
Three different mistakes, one answer:

    { "shell": "/nonexistent/shell" }  ->  NOT VALID JSON
    { "font_size": 12,, }              ->  NOT VALID JSON
    { "font_size": "big" }             ->  NOT VALID JSON

Two of those *are* valid JSON. They are valid JSON in the wrong shape — a
string where a struct goes, a string where a number goes — which is a
different mistake with a different fix, and telling that reader their file is
not valid JSON sends them hunting for a missing comma that is not missing.

serde has already worked out the answer. It names the field, the type it
wanted, and the line and column. That went to `log::warn!` and nowhere else,
and there is no log unless `TTY7_LOG` is set, so on a default install it went
nowhere at all — which is the same shape as the keybinding faults and the
clamped settings this tree has already fixed.

Now:

    DOES NOT FIT — invalid type: string "big", expected f32 at line 1 column 20
    NOT VALID JSON — key must be a string at line 1 column 19
    NOT VALID JSON — EOF while parsing an object at line 1 column 17

`parse_fault` is a helper rather than a field on `LoadOutcome`, which is
`Copy` and crosses several call sites that only want the verdict; this is
asked once, by a diagnostic, about a file already on disk. The window's
notice appends the same detail on its own line — deliberately after the
translated sentence rather than inside it, because serde's message is English
that is not ours to translate and a placeholder would leave a raw parser
string in the middle of a localized one. The notice's own wording needed no
change: it says "could not be parsed", which was true of all three.
2026-08-23 06:05:50 +08:00
l0ng-ai 7d6923d1e3 fix(machine): fold a name to one line where every naming path passes
$ tty7 tab rename @1 $'one\ntwo'
    $ tty7 tab ls --json
    "name": "one\ntwo"

A workspace or tab name is only ever drawn as a label, and gpui breaks a label
on `\n` whatever its wrapping says — the tab strip, the tab sidebar and the
switcher would each have grown a row and painted its tail over whatever sits
below. This tree has fixed that four times already: the OSC title, a filename
in the file tree, a remote name in the SFTP panel, a completion description.
Names were the surface it did not reach.

The CLI's own table folds on the way out, which is the part that makes this
plainly wrong rather than merely unhandled: the daemon was storing, and
handing to every other reader, something one of its readers had already
decided could not be drawn.

Folded in `normalize_name`, which `workspace_create`, `workspace_rename` and
`tab_rename` all pass through, so one change covers the three ways in and the
tab strip, the sidebar, the switcher and a remote client cannot disagree about
it. Same rule and same reasoning as `parse_osc_title` — control character to
space, trim afterwards because folding can leave the edges blank.

Deliberately the opposite of the choice made for a *filename*, which is folded
only where it is drawn because it is also handed to `join`, `rename` and
`remove`. A name has no second life: what is stored is what is shown.

Verified against a live daemon before and after — `alpha\nbeta` comes back
`alpha beta`, a tab named with a tab character comes back spaced.
2026-08-23 05:56:44 +08:00
l0ng-ai 4805cb1765 fix(doctor): notice a config directory that cannot be written to
Made the config directory read-only and asked doctor about it:

    CHECK            RESULT
    TTY7_CONFIG_DIR  set (/tmp/…)
    config           none yet — the defaults are the config
    server           ok (build 26.8.3)
    …
    rc=0

Meanwhile, on the same directory:

    $ tty7 new
    tty7: could not write the machine tree at /tmp/…/machine.json:
          Permission denied (os error 13)

Every `new`, every `tab new`, and every settings save fails, and the verb
whose entire job is "check this install" called it healthy. The config row is
about *reading* — it is answering "does the file parse", and there is no file
— so nothing in the table was wrong, and nothing in it was the answer either.
`tty7 doctor || alert` is exactly the thing that should have fired.

Now: a row naming the directory and what stops working, a headline on stderr
so `-q` still says it, `config.dir_writable` in the JSON, and exit 1 alongside
the unparseable-config case it sits next to.

The check writes a probe file and removes it, rather than reading the mode
bits. A read-only mount, an ACL, an immutable flag or another user's directory
all leave `0700` on something that refuses every write, and what is being
diagnosed is whether the write succeeds. A directory that cannot be reached at
all counts as unwritable — an install nobody can open is not a healthy one.

Verified against a live daemon in all three states: healthy exits 0, read-only
exits 1 with the row and the headline, and fixing the permissions goes back to
0. The unit test covers the probe both ways and that it cleans up after
itself; replacing it with an `exists()` check fails it.
2026-08-23 05:47:16 +08:00
l0ng-ai c69a39f7a0 docs(changelog): five more, bringing the backlog to a third
A clamped setting said nothing, so a config file asking for `font_size: 999`
went on claiming it while the app ran at 256. `tty7 procs` on a pane that
never existed printed "nothing running in this pane" and exited 0, which an
agent cannot tell from an idle pane. A terminal title carrying a newline
painted its tail over the tab strip. The refusal to write the machine file
named neither the operation nor the file, on a machine where that directory
is what the reader has to go and fix. And the clipboard-read policy was an
upstream crate's default rather than tty7's own decision, one release away
from handing any program in a pane whatever is on the clipboard.

Thirty-eight of the branch's 114 user-visible fixes are now written up, which
is the share 26.8.3 shipped with. The rest are smaller — wording, log lines,
internal guards — and can go in as they are reviewed.
2026-08-23 05:38:56 +08:00
l0ng-ai ecbe43f5ab docs(changelog): four more, one of them the largest on the list
Every pane ran with SIGTERM blocked. The daemon blocks it on its main thread
so one waiter can run the shutdown save, and that mask crossed `fork` and
survived `execve` — so `kill`, `pkill`, `timeout`, a supervisor and a CI
cancellation could not stop anything running in a pane, and only `kill -9`
would. That is the biggest thing still missing from these notes.

With it: panes the window put down while squaring its layout were never hung
up, because that path dropped the daemon's reply; a killed server left its
throwaway shell directories behind, 3,850 of them on this machine; and
`send`, `capture` and `procs` reported a raw `os error 2` instead of saying
the server was down, unlike every verb that goes to the control socket.

Thirty-three written up. No code changes.
2026-08-23 05:37:23 +08:00
l0ng-ai 58145dace1 docs(changelog): four more, three of them things a user would see
A newline in a filename, a remote name, or a completion description broke the
row drawing it — the layout breaks on a newline whatever the row asks for, so
the row grew and everything below it slid. 122 of the shipped completion specs
carry one. A tab path starting at `~` was drawn with a leading ellipsis that
stood for nothing, while an absolute path of the same shape kept its root. An
internationalised domain came back as the bare scheme `https://`, offered as a
clickable link that opens nowhere. And the PNG decode ceiling on the
untrusted graphics path was the image library's 512 MiB default rather than
tty7's own, in the GUI process.

Twenty-nine written up. No code changes.
2026-08-23 05:35:50 +08:00
l0ng-ai 12ab786e64 docs(changelog): five more, including two nobody could have noticed
Search with case sensitivity off found nothing when the query carried a
capital, because the regex engine's own smart-case rule decided instead of the
toggle — so the toggle looked broken exactly when someone typed `Error`. The
SCM confirmations answered neither Escape nor Return outside English, and put
Cancel where every other dialog puts "do it"; those are the dialogs guarding
discard, reset and amend. Quick connect read a typed `-p 22` as silence and
connected to whatever the alias said instead. A workspace's port forwards
could stay bound for the life of the daemon with nothing logged, because an
unaddressable route and an unanswered request both reported a clean teardown.
And the published `tty7 wait && tty7 capture` idiom came back empty whenever
the pane exited, which is one of the three states `wait` waits for by default.

Twenty-five written up. No code changes.
2026-08-23 05:34:12 +08:00
l0ng-ai fa392a9558 docs(changelog): write up five more fixes, including two that lost work
Continuing the backlog. These five are the ones a reader of the release notes
would most want to have been told about:

macOS in-app updates failed 100% of the time, on every build and every
channel, because the updater read `codesign`'s designated requirement off the
wrong stream — the only way out was downloading the app by hand. Closing a tab
threw away unsaved editor edits with no dialog, by the everyday route: edit a
file, click back into the terminal, press ⌘W. A client opening a remote
workspace could rename somebody else's — in the reported case one holding
nineteen live panes. The ignore chain let a whitelist inside an excluded
directory undo the exclusion, which git does not, and did not read
`.git/info/exclude` at all. And `tty7 events` exited 0 when the server went
away, so a reader whose lines stopped had no code to branch on.

Twenty of the backlog written up so far; the list still runs longer than this.
No code changes.
2026-08-23 05:32:00 +08:00
l0ng-ai d2a894c4a1 docs(changelog): write up ten fixes that shipped without an entry
This branch carries 114 user-visible fix commits and had added six
changelog bullets. Recent releases run between a third and nearly all of
their fixes — 26.8.3 wrote up 85 of 251, 26.8.2 wrote up 71 of 74 — so five
percent is well under what this project does, and the ones missing are not
small.

Ten of the largest, in the order someone would care about them: a `kill -9`
leaving the server unable to start again and no way to find out why; SIGTERM
armed too late to save any pane's screen, so a shutdown or a logout cost every
one of them up to thirty seconds; an SFTP download renaming over a file it did
not pick, and a dangling symlink reading as a free name; a failed update
deleting the only backup and leaving nothing at the install path; `--cwd`
naming a directory that does not exist and running the command somewhere else
with exit 0; a probe timeout disabling shell integration for a whole host for
the life of the daemon; a signalled command reported to agents as an honest
exit 1; a refused split leaving a shell nothing holds; a window reshape
leaving its old shells running; and three secret-carrying types still
reaching the log.

No code changes. More to follow — the rest of the list is longer than one
sitting.
2026-08-23 05:29:52 +08:00
l0ng-ai 16370ed30a docs(cli): record what the capture settle timer actually costs
`tty7 capture` takes ~323ms against a local daemon and neither constant said
why. Measured: a pane that has printed nothing costs the same as one holding
22,000 lines, `--plain` costs the same as raw, and lowering the replay grid's
floor from 10,000 lines to 100 changes nothing. The grid work is ~20ms; the
rest is `REPLAY_SETTLE` running out. `tty7 pane ls` over the same socket is
~5ms.

It is paid because the replay has no end. The daemon sends `Size` then
`Snapshot` per ring segment and stops, and the only way to know it has stopped
is to wait. Live `Output` cuts the wait short, so a busy pane returns at once
— but the idiom the reference recommends is `tty7 wait … && tty7 capture …`,
and `wait` returns exactly when the pane has gone quiet. The recommended path
always pays in full.

No behaviour change; this is the note that was missing. It also records the
two wrong ways out, because both look attractive from the outside. Lowering
the timer trades a bounded delay for silently truncated output on a slow link
or a loaded machine. An end-of-replay message is the real fix and is *not*
additive — `DaemonMsg::read` rejects an unknown kind outright, so a daemon
sending one to an older client breaks it. That needs a `PROTOCOL_VERSION` bump
with the daemon gating on what the peer announced, which is a larger change
than a comment.
2026-08-23 05:22:47 +08:00
l0ng-ai a77d691dad fix(cli): send a bad tab address to the table that has the @ numbers
Five places tell someone where tab `@` numbers come from, and they had drifted
to three different answers. The three `--help` strings say `tty7 tab ls`. The
two runtime errors — the ones a person actually meets, because they only
appear once something has already gone wrong — said otherwise.

`'@deadbeef' is not a tab address — @7 as numbered by `tty7 ls`` was simply
wrong. `tty7 ls` lists workspaces: `WORKSPACE NAME TABS PANES ATTACHED`, where
TABS is a count. There is no `@` anywhere in it. Someone following that
instruction finds nothing and has to go looking.

`no tab @N — `tty7 pane ls` shows the @ numbers` was true but sideways.
`pane ls` does carry a TAB column, but the verb the person is already holding
is `tab`, and `tab ls` is the table with one row per tab and the number in the
first column.

Both now say `tty7 tab ls`. The guard reads all three sources for anything
teaching where the numbers come from and requires that answer, so a sixth
place — or a sixth answer — is caught rather than assumed. It skips its own
test module, which names the phrases it searches for.
2026-08-23 05:10:37 +08:00
l0ng-ai a67234266b docs(cli): name doctor's config section, and pin the shape
`tty7 doctor --json` has emitted a fourth top-level section, `config`, since
it started answering for a config file that parses but carries keys tty7 does
not read. The reference went on describing three. Checked the documented shape
of every `--json` verb against a live daemon; this was the one that disagreed.

It matters more here than elsewhere. `doctor` is one of the three verbs whose
JSON is printed even when the verb fails — an unreachable server is the
finding, and `tty7 doctor || alert` needs the rows as well as the code — so
something is always parsing this.

The page now names `config`, says what `ok` and `state` mean, and says which
of the table's rows are prose rather than fields, so the next reader is not
left looking for `config keys` in the JSON.

The guard compares doctor's top-level sections against the ones the reference
line names, both directions. Top level only: the fields inside vary with what
a server could be asked — `context` gains `workspace_gone` and `pane_gone`
only when one answered — and the page explains that in prose it would be wrong
to pin to a fixed list. Verified by dropping the section from the page, and by
dropping it from doctor.
2026-08-23 05:06:39 +08:00
l0ng-ai 719e35ec59 test(machine): pin the tty7 events kinds to the CLI reference
`docs/cli/reference.mdx` says of the event stream that "the kinds and payloads
are stable; the prose is not" — a promise to whoever is writing a loop against
it. The stream is externally tagged, so a reader matching on known kinds meets
an object it cannot place the moment a `LayoutDelta` variant is added without
the page being told.

All thirteen currently agree, in both directions. Nothing was holding them
there.

Names come from the enum rather than a list kept beside the test, so it cannot
quietly agree with itself, and only the sentence that enumerates the kinds is
read — a name appearing elsewhere on the page does not count. The scan asserts
it found more than ten variants first, so a change in how the file is written
fails loudly instead of passing for the wrong reason.

Checked both ways: adding a variant, and removing a kind from the page.
2026-08-23 05:02:58 +08:00
l0ng-ai 8390fa54e2 test(sftp): pin the one thing only the components walk refuses
`safe_local_name` checks a remote-supplied filename three ways: literal `.`
and `..`, any `/` or `\`, and finally that `Path::components` sees exactly one
`Normal`. Deleting either of the first two left every assertion passing —
they cover each other on Unix, which is defence in depth working rather than a
fault.

The components walk is different. On Windows it is the only one that refuses a
drive-relative name, and `Path::join` with one does not append, it replaces:
`…\Downloads`.join(`C:evil.txt`) is `C:evil.txt`, resolved against the
process's current directory on that drive. The name comes off a remote
directory listing, so the server picks it, and the download would land outside
the folder it was told to write into. Nothing asserted that.

Now something does, under `#[cfg(windows)]`, with a `#[cfg(not(windows))]`
counterpart asserting the opposite — because the answer genuinely differs: on
Unix `C:evil.txt` is an ordinary filename with a colon in it and refusing it
would be wrong. That difference is precisely why the decision goes through
`Path::components` instead of a list of forbidden characters, and the pair
says so.

Stated plainly: on Unix the components walk has no observable effect that the
other two checks do not already give, so no test running here can pin it. The
Windows half is where it earns its place, and CI is where that half runs.
2026-08-23 04:57:30 +08:00
l0ng-ai 3e1b8bd620 test(agent-hooks): pin that the hook is inert outside tty7
Removing the `TTY7` marker check from `run_agent_hook` left every test in the
repo green. That check is the whole of the promise the generated hooks make:
they are installed into the user's own Claude, Codex, Gemini and Copilot
config, so they run on *every* invocation of those agents — including the ones
in iTerm, Terminal, VS Code, or over plain ssh. What they emit is
`OSC 777;notify`, urxvt's desktop-notification convention, honoured as such by
more than one terminal. Without the check those terminals would get a
notification, or a mouthful of escape bytes, every turn of an agent that has
nothing to do with tty7.

The three plugin bridges carry their own `process.env["TTY7"]` gate and are
already tested for it. The nine config-driven agents have only this one.

Order is pinned as well as presence: the check sits ahead of the stdin read
because stdin is a pipe the caller may never write to, and a hook that blocks
is a hook that hangs the agent.

Checked at source rather than exercised. Exercising it means varying an
environment variable, and this suite runs in one process across many threads
where `set_var` is unsound; a three-line gate is not worth making the rest of
this file unsafe to test. Verified against both mutations — removing the check,
and moving it below the stdin read — each named precisely.
2026-08-23 04:31:25 +08:00
l0ng-ai a9faf7cf07 test(ssh): pin the two host-key decisions nothing was checking
Mutating the host-key policy table showed it is well covered: accepting a
revoked key with verification off, and silently accepting an unknown or a
changed key, each fail the suite. The carrying-out was not.

**Saying no had nothing holding it.** Deleting the `if !accept` guard in
`apply_decision` — so a person shown a changed host key declines and connects
anyway — left every test in the repo green. `accepted_and_remembered` is
tested on its own; nothing checked that the caller acts on its answer. Now
four responses are refused: no, no-with-the-checkbox-still-ticked, cancelled,
and a response of the wrong shape. It touches no filesystem, because none of
them reach the recording branch.

**Dropping the superseded line had nothing holding it either.** The order is
the point — `check` answers `Known` on any same-algorithm match, so appending
without dropping leaves the key this one replaces trusted for good, which is
how a host that rotated away from a compromised key goes on accepting the old
one. There was a test named after that bug, but it called the two halves
itself, so removing the `forget_superseded` call from `apply_decision` changed
nothing it could see.

The sequence is now one function, `record_trusted`, with the order and the
"if the drop fails, append nothing" rule stated where it happens. Production
and that test call the same thing, so neither can drop a half alone. Verified
by re-running both mutations against the new tests; each fails, naming what
went wrong.
2026-08-23 04:25:53 +08:00
l0ng-ai 69ac479c97 test(kitty): prove the temp-dir check is what saves a file, not the OS
`a_temp_file_transfer_outside_the_temp_dir_is_read_but_not_deleted` sent a
`t=t` transfer at `/etc/hosts` and asserted it survived. It survives whether
or not the check exists: the suite does not run as root, so `remove_file`
fails on its own and `let _ =` swallows that. Removing the temp-dir check
outright left the test green — and that check is the one standing between an
escape sequence and `remove_file` on any path the user can delete, which the
comment beside it names as `~/.ssh/id_ed25519`.

The `/etc/hosts` half stays: it is the case that is never under a temp
directory on any host we build for, and it proves reading still works. Added
beside it is a victim the test user demonstrably *can* delete — created under
`CARGO_MANIFEST_DIR`, with a probe file removed right next to it first,
because a survivor proves nothing unless deletion was possible. If the
checkout itself lives under a temp directory the second half returns early
rather than asserting the opposite of what it means.

Verified: dropping the temp-dir check now fails it, naming what was unlinked.
2026-08-23 04:18:04 +08:00
l0ng-ai bf68eb9f20 fix(kitty): open a file transfer non-blocking, so the fifo check can run
`read_file` refuses anything that is not a regular file, and the comment
above it says why: fifos and devices, "where the open itself can block". The
check sat one call after the open. `open(O_RDONLY)` on a fifo blocks until a
writer arrives — for a fifo nobody intends to write to, forever — so the
guard never got the chance to refuse the thing it was written to refuse.

Reachable the same way everything else in this path is: the name comes out of
an escape sequence, so `mkfifo` plus one line of output, or any file someone
`cat`s, is enough. `resolve` runs on the daemon's pane reader thread, so the
pane's output stops there and does not come back while the process lives.

The open now carries `O_NONBLOCK`, which returns immediately on a fifo and
lets the existing check refuse it. On a regular file the flag changes
nothing — it is not consulted by `read`, and reads of regular files do not
block regardless.

Found by mutation: removing this check, the shm-name check, and the temp-dir
unlink check each left the whole workspace suite green, so all three were
being asserted only as pure functions and never where they are wired in.
Testing the first one properly is what surfaced the ordering.

The guard resolves a transfer naming a real fifo on a worker with a ten-second
deadline, because without the fix this does not fail, it hangs — and a test
that hangs says much less than one that reports what happened. Verified: with
the blocking open restored it fails at ten seconds with that message.
2026-08-23 04:15:43 +08:00
l0ng-ai ba28a8224e test(kitty): hold the remote transfer gate for t=f and t=t, not only t=s
The gate that stops a remote pane resolving a file or shm transfer had one
test and it sent `t=s`. Narrowing that gate to shm alone — so `t=f` and `t=t`
resolved on a remote pane — left the entire workspace suite green. Checked,
not assumed: 43 kitty tests and every other test in the repo passed with the
guard weakened.

That is the gate that matters. `query_reply` is the polite half; it tells a
well-behaved sender not to bother, and its own test already probes all three
media with a comment saying why. This is the half that holds when the sender
ignores the refusal, and the sender on a remote pane is the far end of an ssh
link naming a path on *this* machine. `read_file` will open whatever it is
given — bounded and regular-files-only, but given.

Now all three media are sent at a remote parser and refused, and all three at
a local parser and accepted, so the guard is pinned to refusing on locality
rather than on the medium. The failure names the path that would have been
read. Checked against both directions: narrowing the gate to shm, and
refusing regardless of locality.

No behaviour change — the guard was already right. This is the test that
would have noticed if it stopped being.
2026-08-23 04:09:48 +08:00