Skip to content

chore(kaish): bump the read-only shell to kaish-kernel 0.17.1 - #174

Merged
tobert merged 2 commits into
mainfrom
kaish-0.17.1
Sep 2, 2026
Merged

chore(kaish): bump the read-only shell to kaish-kernel 0.17.1#174
tobert merged 2 commits into
mainfrom
kaish-0.17.1

Conversation

@tobert

@tobert tobert commented Sep 2, 2026

Copy link
Copy Markdown
Owner

0.17.1 carries the fix that has been blocking kaibo v0.4.0: readlink -f and
realpath failed on every operand on a rooted mount, with a message naming
neither the operand nor its target (readlink: o-exists: No such file or directory: /tmp). Both resolve now, and an escape refuses by name. We reported that shape
upstream during the 0.17.0 bump; it became two structural kaish PRs rather than a
patch — #435 (the router's mount-ancestor
synthesis was unreachable whenever / is mounted) and
#434 (Filesystem::canonicalize as a
defaulted trait method, moving containment out of the builtin into the VFS).

The compiler found nothing, so the shell was the check

No API break reached kaibo — Filesystem::canonicalize is defaulted and kaibo
implements no Filesystem or KernelBackend. The composed tool contract is
byte-identical under both pins (throwaway crate calling
compose(&Recipe::tool_description(), …)), and so are all eleven help surfaces
kaibo renders, 43941 bytes each side.

So the check was the shell, per the rule the 0.17.0 bump wrote into AGENTS.md. Every
battery in docs/sandbox-probes.md was run against both a 0.17.0 and a 0.17.1
binary and diffed. A, B, D, E, F and G came back identical. Two changes are the whole
delta a model can see.

1. The release blocker is fixed. readlink -f / realpath resolve an in-tree
path (exit 0) and refuse an escape by name. Battery G3 re-run on the new canonicalize
path: existing, missing, and unreadable targets still refuse byte-identically, so the
new code introduces no existence oracle.

2. The directories above the mount list again — new, and accepted. 0.17.0 answered
"not found" for every directory above the project; 0.17.1 restores them as synthesized
directories. It is synthesis from kaibo's own mount table, not a host read: each level
names only the next component down to the project, so a model walking up recovers the
root path string the caller already handed it and nothing else. Counted rather than
argued — ls /tmp returns one entry where the host /tmp holds 3575. Adjacent
secrets, real siblings, and the state db and media CAS all stay invisible (E2/F2
re-run against a non-empty store: 4 KiB db, 210 CAS objects, both not found).

That observable is pinned by a new containment test with a recorded positive
control
— move the mount up and the leak assertion fires — because a battery where
everything comes back empty proves nothing otherwise. Battery C's claim that /home
is "an inert stub that cannot be walked" is now false and is corrected in place, the
same way the 0.17.0 bump corrected three other criteria.

The probe caught itself once

E1 run without --root created a state db, because the fixture was then outside every
allowed tree and the guard correctly did not fire. Re-run with the root pointing at the
fixture, both E1 and F1 refuse loudly and create nothing. The §0 question — would this
read differently if the probe were broken, versus if the thing it audits were broken?

— is what found it.

Gates

  • cargo clippy --all-targets: clean.
  • cargo test: 1147 passed. The lone failure is the known tests/credentials.rs
    ETXTBSY exec race under parallelism (green serially, reproduces on unmodified code).
  • containment: 25 passed, one new.
  • cargo tree -i empty for aws-lc-rs, mimalloc, openssl-sys.

Cross-family review posted as a comment below.

🤖 Generated with Claude Code

0.17.1 carries the fix that has been blocking kaibo v0.4.0: `readlink -f` and
`realpath` failed on *every* operand on a rooted mount, with a message naming
neither the operand nor its target. Both resolve now, and an escape refuses by
name. We reported that shape upstream during the 0.17.0 bump; it became two
structural kaish PRs rather than a patch.

The compiler found nothing — no API break reached kaibo, and the composed tool
contract is byte-identical under both pins. So the check was the shell, per the
rule the last bump wrote: every battery in `docs/sandbox-probes.md` run against
both 0.17.0 and 0.17.1 binaries and diffed. A, B, D, E, F and G came back
identical; two changes are the whole delta a model can see.

The second one is new and we accept it: the directories *above* the project mount
list again, where 0.17.0 answered "not found". It is synthesis from kaibo's own
mount table, not a host read — each level names only the next component down to
the project, so a model walking up recovers the root path string the caller
already handed it. Counted rather than argued: `ls /tmp` returns one entry where
the host holds 3575. Adjacent secrets, real siblings, and the state db and media
CAS all stay invisible.

That observable is pinned by a new containment test with a recorded positive
control — move the mount up and the leak assertion fires — because a battery
where everything comes back empty proves nothing otherwise. Battery C's claim
that `/home` is an inert stub is now false and is corrected in place, the same
way the 0.17.0 bump corrected three others.

The probe caught itself once, worth recording: E1 run without `--root` created a
state db, because the fixture was then outside every allowed tree and the guard
correctly did not fire. The §0 question — would this read differently if the
probe were broken? — is what found it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tobert

tobert commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Cross-family review — kaibo cast crusoe (DeepSeek-V4-Flash explorer, GLM-5.2 synth)

Asked for a holistic read of the bump, with the safety argument for the new observable
called out as the finding I most wanted to be told about. It came back ship it, with
three real gaps. Every citation was checked against the code before acting; all three
were true and all three are fixed in e16c2db.

1. The test claimed the chain and probed one level. The doc comment said "each
ancestor lists exactly the one component," but the fixture only had outer/project — so
a synthesis bug one level higher would have fired nothing. The fixture is now two levels
deep (outer/mid/project), each level carrying a sibling that must never appear.

2. The gap that mattered: no existence probe. The test covered bytes (cat, grep)
and discovery (ls, find), but not existence. stat and realpath answer "does
this exist" without returning any bytes — and realpath resolves for the first time in
this very release, which makes that pair the most plausible place for an oracle to have
arrived. Measured: a real host file beside the chain and a path that was never created
refuse byte-identically once the operand is normalized away, and only the synthesized
components resolve. Now pinned in the shape Battery G3 uses for links, and recorded in
the runbook note.

3. A residual false claim in Battery C. Its opening pass criterion still read
"everything outside the single mount comes back not found," which the carve-out two
lines below now contradicts. Scoped to reads, which is what it always meant.

It also asked me to confirm the changelog's "kaish upgraded to 0.17.1 (from 0.14.1)"
baseline — correct: 0.17.0 was never cut, the last release is v0.3.0, so the cumulative
framing from the last released pin is right.

The positive control was re-run against the rewritten test: move the mount up and the
leak assertion fires with HOST LEAK: ls …/mid listed a real sibling.

Not taken: nothing. All three findings were real.

…e it could hide

Cross-family review (kaibo cast `crusoe`, DS4-Flash explorer + GLM-5.2 synth) found
three real gaps in the previous commit. Every citation checked before acting.

The test's doc comment claimed "each ancestor lists exactly the one component" and
then probed one level — the immediate parent. A synthesis bug a level higher would
have fired nothing. The fixture is now two levels deep, each with a sibling that must
never appear, so the test proves what it says.

The bigger miss: it covered bytes (`cat`, `grep`) and discovery (`ls`, `find`) but not
existence. `stat` and `realpath` answer "does this exist" without returning any bytes,
and `realpath` resolves for the first time in this very release — so that pair is
where an oracle would most plausibly have arrived. Measured: a real host file beside
the chain and a path that was never created refuse byte-identically, and only the
synthesized components resolve. Now pinned, in the shape Battery G3 uses for links.

Third, Battery C's opening pass criterion still read "everything outside the single
mount comes back `not found`", contradicting the carve-out two lines below it. Scoped
to reads, which is what it always meant.

The positive control was re-run against the rewritten test: move the mount up and the
leak assertion fires.

Reviewed-by: kaibo cast `crusoe` (DeepSeek-V4-Flash explorer, GLM-5.2 synth)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tobert

tobert commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

CI note: the tests/credentials.rs flake, measured

The first CI run on this branch passed; the second failed on
key_command_nonzero_exit_is_loud_and_never_leaks_stdout_or_stderr
(spawning key command .../stub-5). That is the known tests/credentials.rs ETXTBSY
race, not this diff — but it is worse than our notes said, so I measured it rather than
asserting it:

parallel serial
this branch 6 / 20 failed 0 / 10 failed
main @ f741a84, unmodified 3 / 15 failed

So: pre-existing, independent of the kaish bump, and a ~20–30% failure rate per run.

Shape. stub_script (tests/credentials.rs:208-215) writes an 0o755 #!/bin/sh
stub which resolve_key_from_cmd then execs. Rust opens files O_CLOEXEC, but in the
window between another test thread's fork and its exec the child holds a duplicate of
this thread's write fd — and execing a file that is open for writing is ETXTBSY. It is
the harness, not the credential code.

Fix shape, for its own PR: a process-wide mutex the stub-using tests hold across both
stub_script and the resolve_key_from_cmd call, so a fork can never happen while a stub
write fd is open. That serializes ~10 tests rather than the suite. Retrying on ETXTBSY
would also go green but would hide a real race behind a loop.

I re-ran the job rather than papering over it. Flagging it because a merge gate that fails
a fifth of the time is one we will start ignoring — worth closing before v0.4.0.

@tobert
tobert merged commit fc34233 into main Sep 2, 2026
1 of 2 checks passed
tobert added a commit that referenced this pull request Sep 3, 2026
…176)

The credentials suite failed CI on #174 and had been flaking for weeks.
Amy's call was to
fix it ahead of the cosmetic pre-release items — *"a merge gate that
fails a fifth of the
time is one we will start ignoring"* — so this goes first in the
pre-release queue.

## Measure before asserting

Our own note called this a rare race. It is not:

| | parallel | serial |
|---|---|---|
| the #174 branch | **6 / 20 failed** | 0 / 10 |
| `main` @ `f741a84`, unmodified | **3 / 15 failed** | — |

A fifth to a third of runs. That number is what moved this from "known
flake, re-run it"
to "fix it now."

## What it actually is

`ETXTBSY`. Writing a stub holds a write fd on it, and `fork` hands every
open fd to the
child; a sibling test forking in that window gives its child a
duplicate, which it holds
until it reaches `exec`. An `exec` of that stub in the meantime is "Text
file busy".
`O_CLOEXEC` does not save it — the fd closes *at* exec, and the entire
window is before
it. Nothing to do with the credential code: the harness owns this one.

## Three decisions

**Fix the race, not the symptom.** Retrying on `ETXTBSY` would go green
while leaving a
real fork/exec race in the tree, and a retry loop around a race teaches
the next reader
that the race is acceptable. This removes the overlap instead.

**A read/write lock, not a mutex** — the two operations are not
symmetric. Creation takes
the exclusive side (brief, and it must exclude every spawn); spawning
takes the shared
side, so resolves still run concurrently with each other. That matters
for one test
specifically: `a_blocking_key_resolve_does_not_stall_a_sibling_task`
exists to pin a
concurrency property, and a coarse mutex would have quietly serialized
it into a
tautology that passes without testing anything.

**Every resolve goes through the helper**, including the ones whose
command is not a stub
— a spawn that fails at `exec` has already forked, so it is in the race
too.

Poisoning is stepped over deliberately: otherwise one test's panicking
assertion fails
every later test with a lock error instead of its own message.

## The number, and the control that makes it mean something

**0 of 60** parallel runs fail with the fix.

A zero is only worth as much as its control, so: remove *only* the two
lock-acquisition
lines, keep the helper and every other change, and it goes back to **11
of 20 failing**.
The lock is doing the work, and the instrument still detects the race it
claims to have
closed.

Full suite 1328 passed / 0 failed. `cargo clippy --all-targets` clean.

## No changelog entry

The test harness is not a user-facing surface, and the house rule is
that the git log is
the record for an internal fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
tobert added a commit that referenced this pull request Sep 3, 2026
)

Pre-release item four. The house rule is that a changelog bullet is one
line — the change,
and at most one clause of reason — and that the whole unreleased section
gets shortened
*before* it is retitled for a release.

It had drifted a long way from that. Someone scanning for what changed
was reading the
reasoning behind it instead:

| | before | after |
|---|---|---|
| bullets | 47 | 46 |
| running past one line | **41** | 0 |
| lines | 120 | 95 |

The reasoning did not disappear — it lives in the pull requests, which
is where the rule
says it belongs.

## The better find

Shortening surfaced a wrong fact. The kaish bullet said *"upgraded to
0.17.1 (from
0.14.1)"* while a separate **Fixed** bullet announced *"Bump
`kaish-kernel` to 0.14.1"* —
the same cumulative jump stated twice, from a baseline that was not the
released one.

v0.3.0 shipped on kaish-kernel **0.14.0**:

```
$ git show v0.3.0:Cargo.toml | grep kaish-kernel
kaish-kernel = { version = "0.14.0", … }
```

So a reader upgrading from the last release was told the wrong starting
point. It now
reads "from 0.14.0", once — and 0.14.1's user-visible fix (the
explorer's shell dropping
piped stdin) stays in **Fixed** as the fix it is, rather than as a
second version bump.

This is the same class of error #174's review asked me to double-check
on the "from"
baseline. That check confirmed 0.17.0 had never shipped; it did not look
at whether
0.14.1 had. It hadn't.

## Compression was checked for loss

A shortening pass that quietly drops a change is worse than a long
changelog, so this was
verified rather than trusted: diffing the vocabulary of both versions
turned up four
things a user would act on that the first pass had dropped. All four are
back —

- a slow generation comes back as a **`job-N` handle** rather than
holding the call;
- base64 **`content`** is still the way in for an image that is not a
file;
- the four formats `write_cas` accepts **by signature** (png, jpeg, gif,
webp);
- the **twenty-fold** cost spread across operations — an exact number is
the whole point
  of publishing one.

Everything else that fell out was reasoning, an implementation detail
(`multipart`,
`init_image`/`style_image`, `ultra`/`sd3`), or a duplicate.

Changelog only — no code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant