Skip to content

fix(skills): read run values through vcn.sh get, check the agent-tty socket dir, run the static proofs in CI - #545

Merged
ThomasK33 merged 15 commits into
mainfrom
fix/vcn-checked-get
Nov 12, 2026
Merged

ThomasK33 merged 15 commits into
mainfrom
fix/vcn-checked-get

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Why

#506 and #508 left six loose ends in the verify-claudecode-nvim skill (their verifiers' findings N3, m1 to m5, F1 and F2, and an advisor lead):

  1. The five journey-*.sh scripts and the dashboard command still ran source "$VCN_STATE/run.env" in the caller's shell. No check of the state dir covers that, so a stale or foreign VCN_STATE ran an unchecked file. Earlier attempts cut values out of printed text, and the docs(skills): stop telling callers to source run.env, and make the bare-tool self-check strict #508 round-5 verifier showed that text is forgeable (a VERIFY_OUT with OUT= in its name sent a journey's evidence to another folder).
  2. The doc-vars and no-bare-tools self-checks had test gaps (indented and ~~~ fences, a comment that names source run.env, a self-test of the wrong pattern copy, "${JQ_BIN:-jq}").
  3. att() puts NODE_DIR in front of PATH, and nothing checked NODE_DIR. Neither static check ran in CI.
  4. Deleting the mkdir in att() left both proofs green, though it decides whether record works on a run that an older vcn.sh started.
  5. SKILL.md said the refusal proof takes about 60 s (it takes 2 to 11 minutes), and one sentence did not say that root is fine above the state dir.
  6. agent-tty keeps its sockets under a fixed /tmp/agent-tty and never checks who owns it.

What changed

All changes are in .agents/skills/verify-claudecode-nvim/ and mise.toml.

  1. vcn.sh get [-z] <NAME> prints one value of run.env after the check that load makes of the state dir. It answers only a name that run.env holds (not PATH, not a name that is absent). -z ends the value with a NUL, so IFS= read -r -d "" OUT < <(vcn.sh get -z OUT) keeps a newline at the end of a path. The five journeys read OUT and WORK this way: nothing is sourced, no printed text is parsed, and an OUT that the caller exported cannot redirect them. vcn.sh dashboard replaces the dashboard recipe: it runs agent-tty dashboard through att(), so it gets the same NODE_DIR, TMPDIR and state check.
  2. Static proofs. scripts/proof-static.sh holds no-bare-tools and doc-vars (moved out of the resolution proof, so its count drops by two), no-source-run-env, and (round 2) doc-get-exact and doc-recipe. It reads files only. mise run check runs it, so CI does. The gaps are closed: fences are three or more backticks or tildes with any indent and a closing fence of the same character (m1), a whole-line comment that names a source is not a source (m2), units_missing and the self-test call the same two pattern functions, and a sample block that sets a variable to a literal must still be flagged (m3), and ${JQ_BIN:-jq}, ${SETSID_BIN=setsid} and ${JQ_BIN-jq} count as bare tools (m4).
  3. NODE_DIR guard. load refuses a run.env whose NODE_DIR is empty, relative or has a colon, and launch refuses a node whose directory is one of those (no state dir is made). A NODE_DIR that is absolute but has lost its node is not refused, so cleanup still runs. When load refuses, its message now says how to stop the run by hand: stop its nvim and agent-tty processes, then rm -rf the state dir (round 2: cleanup refuses too, and the verifier asked what a user does then).
  4. tmp-removed (F1): launch, remove $VCN_STATE/tmp, then record must exit 0 with a non-empty WebM and tmp back at mode 0700.
  5. SKILL.md: the refusal proof's time is the measured one, and "no dir above it may be owned by another user" now says that root is fine.
  6. Socket root guard. launch makes /tmp/agent-tty with mode 0700 when it is missing, and refuses one that is a symlink, is owned by another user or is group- or world-writable, before it makes anything. It also refuses a /tmp (or another dir above /tmp/agent-tty) that others can write and that has no sticky bit.

Round 2 (after the verifiers of 397414f2)

Finding Change
gpt A, Opus F11: features/README.md read OUT=$($V get OUT), which drops a newline at the end of the path README and SKILL.md give getz() { IFS= read -r -d '' "$1" < <($V get -z "$1"); }; getz OUT; getz WORK. The full-run block of SKILL.md no longer exports OUT. New static cases doc-get-exact (no recipe reads a path of the run through $(... get NAME)) and doc-recipe (it runs the documented recipe on a hand-made run.env with a newline at the end of OUT and WORK, and a control shows that a command substitution cuts it)
gpt B: att-root-own-755 proved only that launch did not refuse att_root_case now needs the next step to show: no agent-tty passes agent-tty doctor (the stand-in), the mode of /tmp/agent-tty after launch, and no refusal text. New cases att-root-own-700 and att-root-nosticky (a 0700 dir under a world-writable /tmp with no sticky bit: launch refuses)
Opus F3 to F6: the static scanner A comment sets no variable and hides no source. A trailing comment that names a source is still reported (fails closed). builtin source, command ., eval, backticks and quotes count as sourcing. A span split over two lines is joined. $((PORT+1)) is a use of PORT. need_docs fails when SKILL.md or features/*.md is missing. The header says what the scanner does not see
Opus F7: nothing walked above /tmp att-root-nosticky (above)
Opus F8, F10, F12 F8: kept both name checks of get (the regex and the run.env grep): without the regex, a name like O.T matches OUT= in the grep and then fails in ${!name} with a bash error instead of a refusal, and get-values now holds O.T. F10: SKILL.md line 154. F12: kept as intended, and SKILL.md says so
Opus F1, F2: this body The time quote in the table below is the one in SKILL.md (it was 2 to 6 minutes, 149 s to 338 s, and a gate run then took 626 s), and each gate number names the head it was measured on

Threat model for item 6

agent-tty 0.5.0 puts sockets at /tmp/agent-tty/<sha256(home)[:8]>/<sha256(session id)[:12]> whatever TMPDIR says (dist/storage/sessionPaths.js). dist/host/hostMain.js makes the per-home dir with mkdir recursive and chmod 0700, and nothing checks who owns /tmp/agent-tty. The client connects to the path and accepts any answer that echoes the request id (dist/host/rpcClient.js).

Who: another local user who owns /tmp/agent-tty (a squatter who made it first and left it writable) or who can rename entries in it. The owner of a directory can rename its entries even with the sticky bit. What: move the per-home dir away, make a new one with the same name, listen on a socket of the same file name (both names are readable with ls). Every later agent-tty call of the run then talks to the squatter: forged screens and forged ok answers, and the keys that vcn.sh sends. Not covered: the same uid, and root. agent-tty itself is not changed.

I reproduced this on main in a bwrap user namespace that I own, with /tmp/agent-tty a host-root-owned 1777 dir (it shows as owned by nobody, and I can write in it): agent-tty create made its per-home dir and socket in it without complaint, and after the per-home dir was swapped agent-tty snapshot printed FORGED-BY-SQUATTER. No second account exists here, so the swap was done by my own uid, standing in for the owner of the root. That the owner may rename is POSIX, not something I tested.

Evidence

Before is main a9f8d0e7 (the merge of #506). After is this branch. Every guard and every case below has a mutant that turns it red (the -r and V= parts of the documented recipe are the exceptions; see Not covered), and each mutant ran against the case that names it. The mutants and their uncut output are in the run's scratch dir.

Item Before (main) After Case that holds it Mutant that turns it red
1 get, journeys Each of the five journeys sources the run.env of a state dir that others can write (journey-state-probe.txt: run.env-sourced=YES five times) Each refuses (refusing state dir ...: it is group- or world-writable, run.env-sourced=no) get-state (also get itself) get placed before load (sources it): red. A journey that sources again: red here and in no-source-run-env
1 get, values A caller parses printed text or sources get -z OUT returns a VERIFY_OUT with a space, an equals sign, a tab, quotes and a newline (also at its end) exactly. get PATH, a name that run.env lacks and a bad name are refused get-values No name check: red. No name regex (only the grep): red on O.T. -z ends with a newline: red. No -z branch: red
1 stale OUT A caller's exported OUT is replaced by the source Same result with get: a journey run with OUT exported to a stale dir writes all its files into the OUT of its run, for a VERIFY_OUT with OUT= in it, a newline in it and a newline at its end (and none in the stale dir or in the dir named inside VERIFY_OUT) journey-out (real launches) A journey that does not read OUT: red (3 of its files land in the stale and the other dir). A journey that reads it through $(...): red for the newline at its end
1 docs recipe (round 2) features/README.md: OUT=$($V get OUT) cuts a newline at the end of the path, so the next documented write fails README and SKILL.md use getz (get -z and read -d '') doc-get-exact, doc-recipe Old recipe back in the README: red. Line read: red. Command substitution in SKILL.md: red. No recipe in the README: red
1 dashboard source "$VCN_STATE/run.env"; PATH=... "$ATT_BIN" --home ... dashboard in the caller's shell vcn.sh dashboard renders the running session (alpha, gamma of hello.txt) in a script(1) terminal and leaves no process dashboard cmd_dashboard that does nothing: red
2 m1 to m4 doc-vars passes an indented or ~~~ fence that expands $SID, a block whose comment names source run.env, a self-test of a copy of the pattern, and ${JQ_BIN:-jq} All four are caught doc-vars, no-bare-tools in proof-static.sh 21 mutants of the checks and of the docs and vcn.sh they read, all red (old fence rule, no comment strip, trailing comment, weak set patterns, a default in vcn.sh, an indented doc, a tilde doc, the dropped default pattern, no if or ! in the source pattern, a second source in vcn.sh)
2 F3 to F6 (round 2) A comment that names a source counted as one, or hid one. builtin source, eval and backticks were not seen. A span split over two lines was missed. $((PORT+1)) was not a use. A missing doc passed The 15 listed shapes are caught (no-source-run-env, doc-vars) proof-static.sh (5 cases) 15 mutants (s1 to s9c, d2 to d5), all red, and the control green
3 NODE_DIR An empty NODE_DIR leaves an empty PATH entry: a node in the current dir ran as the interpreter of agent-tty (nodedir-probe.txt: planted-node-ran=YES) load refuses an empty, relative or colon NODE_DIR, and launch refuses such a node before it makes a state dir. An absolute NODE_DIR without a node and a run.env without NODE_DIR are not refused, so cleanup runs. The refusal says how to stop the run by hand and keeps the state dir node-dir, node-relative, and the cleanup-no-agent-tty and jq-old-run cases that hold the exceptions Guard removed: red. Colon branch removed: red. Refusing a dir without a node: red. Checking an unset value: red. Early check in launch removed: red (and a leaked state dir). No hint: red. Hint names another dir: red
3 CI The two static checks run only inside the resolution proof, which needs agent-tty and Chromium mise run check runs proof-static.sh, so the unit-tests job does proof-static.sh (5 cases) n/a
4 F1 Deleting the mkdir in att() keeps both proofs green record on a run without $VCN_STATE/tmp makes it (0700) and writes a WebM tmp-removed mkdir removed: red (record exits 1)
5 F2, m2 "It takes about 60 s"; "no dir above it may be owned by another user" "2 to 11 minutes (149 s to 626 s measured, depending on the load of the host)"; "(root is fine)" n/a n/a
6 socket root agent-tty create made its per-home dir in a foreign-owned 1777 /tmp/agent-tty, and after a swap snapshot printed FORGED-BY-SQUATTER (att-root-repro-main.txt, att-root-swap-main.txt) Launch refuses a symlink, another user's dir, a group- or world-writable dir, and a non-sticky world-writable /tmp above it. It makes a missing one 0700 and accepts a 0700 or 0755 dir of the user, and then runs agent-tty doctor (round 2) att-root-foreign, -loose, -link, -made, -own-700, -own-755, -nosticky (bwrap, a /tmp of their own) Guard removed: 5 red (-foreign, -loose, -link, -made, -nosticky). mkdir removed: -made red. Refusing any mode but 0700 with another message: -own-755 red (it must see agent-tty doctor run next). Refusing a 0700 dir: -made and -own-700 red. Guard after mktemp: 4 red (-foreign, -loose, -link, -nosticky), and a state dir is left. Owner test removed: -foreign red. Walk above the dir removed: -nosticky red

Gate on the head of this branch

The head is 98653693: the docs commit that gives the slowest measured time of the refusal proof, on top of 2ae7ec86 (the merge of main, #549 and #532, over the docs commit 76088711, the round-2 commit 30e86876 and the pushed round 1). 98653693 changes one line of SKILL.md. The runs below are on 2ae7ec86 unless a line says otherwise. The host ran at a load of 50 to 90 (other workspaces), so the times are slow ones.

Check Result
mise run clean && mise run all exit 0 in 1688 s. 1706 successes, 0 failures, 0 errors, 25 pending. luacheck: 0 warnings, 0 errors in 181 files. Its check task runs proof-static.sh
check.sh of verify-diff-lifecycle == RESULT: PASS
proof-launch-refusals.sh exit 0 in 626 s. 42 PASS. The three ACL cases print SKIP (no default ACL can be set here)
proof-agent-tty-resolution.sh exit 0 in 1381 s. CASES 49, no SKIP, no FAIL (the bwrap cases ran)
proof-static.sh, mise run format-check on 98653693 (the docs edit is in the tree for both): 5 cases ok, exit 0, and 359 files formatted with 0 changed, exit 0
The five journeys, six runs (opendiff-accept, diff-close-save for an existing and a new file, new-tab-diff-mode, snacks-float-diff, snacks-float-reload) on 8df19bb1 (the first of the two merges of main in this round), TMPDIR a symlink of mine to a private dir: all six pass with launch 0, journey 0 and cleanup 0, and the state dir is under the resolved path. The snacks journeys ran on a snacks.nvim clone. The second merge changed only docs, the integration runner script and its spec, and no file the journeys read
The same full gate on 8df19bb1 mise run all exit 0 (1702 successes), check.sh PASS, refusals 42 PASS in 242 s, resolution CASES 49 in 1069 s
The same full gate on the round-2 commit 30e86876 mise run all exit 0 (1702 successes), check.sh PASS, refusals 42 PASS in 149 s, resolution CASES 49 in 722 s
CI on 2ae7ec86 and on 98653693 all 6 checks green on each (conventional-title, unit-tests, covered-lock, integration-tests (stable), integration-tests (v0.8.0), Required)
Codex review and security review on 2ae7ec86 and on 98653693 both clean on each ("Reviewed commit 98653693c2"), 0 review threads

The slowest time of the refusal proof was 626 s, so SKILL.md now says "2 to 11 minutes (149 s to 626 s measured, depending on the load of the host)". That is the sentence in SKILL.md line 134. A verifier's run on 98653693 took 966 s (exit 0, 42 PASS), so that range is already exceeded. The follow-up PR fixes the sentence.

Not covered

  • The 25 pending specs of mise run all need a real Neovim, so the Busted gate skips them. The integration-tests jobs of CI run them (scripts/run_integration_tests_individually.sh). This PR changes no Lua under lua/.
  • proof-static.sh does not see indented code blocks, <pre> blocks, a command held in a variable, or a source in a .lua, .py or .mjs file.
  • proof-static.sh also does not see a backslash-continued source \, a quoted "source", BASH_ENV or let. doc-get-exact does not see $(... get -z NAME) or a line read. doc-recipe uses a value with no backslash, and the test supplies V. The follow-up PR fixes or lists these.
  • A failed get inside getz prints the refusal but leaves OUT or WORK empty, and a shell without set -e goes on. The follow-up PR makes getz fail loudly.
  • Only launch checks /tmp/agent-tty. A tmp cleaner that deletes it during a run is not seen by the later commands.
  • The same uid and root can still swap the socket dir. agent-tty itself is not changed, and nothing is filed upstream. That the owner of a directory can rename its entries is POSIX reasoning, not a live test with a second account.
  • A reader who sources run.env in a shell of their own is not stopped. SKILL.md says not to, and no-source-run-env checks the skill's own scripts and recipes.
  • The two snacks journeys ran on a snacks.nvim clone, as before: they need one.
  • proof-static.sh takes about a minute on a host at a load of 35. It is about 17 s of CPU (1.8 s user, 15.0 s sys), measured by a verifier on 98653693.

One mutant is equivalent, not green by accident: mkdir -m 700 instead of mkdir in ensure_att_root changes nothing, because vcn.sh runs under umask 077.

Refs #506 and #508.


Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh

agent-tty keeps every session socket under the fixed dir /tmp/agent-tty, whatever
TMPDIR says, and never checks who owns it. Reproduced on main in a bwrap user
namespace that this user owns, with /tmp/agent-tty a host-root-owned 1777 dir (it
shows as owned by nobody and this user can still write in it): agent-tty create
made its per-home dir and socket inside it without complaint. The agent-tty client
connects to the path and accepts any answer that echoes the request id, so a party
that can rename entries in that dir can move the per-home dir away, listen on a
socket of the same name, and agent-tty snapshot prints what it answers (the repro
printed FORGED-BY-SQUATTER).

Launch now makes /tmp/agent-tty with mode 0700 when it is missing and refuses one
that is a symlink, is owned by another user or is group- or world-writable, through
the check that already covers VERIFY_OUT (unsafe_dir). The check runs before launch
makes the state dir, so a refusal leaves nothing behind.

Proof: five cases in proof-launch-refusals.sh in a bwrap sandbox with a /tmp of its
own (att-root-foreign, att-root-loose, att-root-link refuse; att-root-made and
att-root-own-755 accept). The tmp-link-foreign case shares its sandbox setup with
them. SKILL.md says what launch does, and that root is fine above the state dir.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
…s none

Round-5 verifiers of #506 (F1): deleting `[[ -d $VCN_STATE/tmp ]] || mkdir -m 700 ...`
from att() left the refusal proof at 34 PASS and the resolution proof at CASES 45. The
line decides whether `record` works on a run that an older vcn.sh started, because
that run has no tmp dir and agent-tty needs one for its WebM export.

tmp-removed launches, removes $VCN_STATE/tmp, and requires `record` to exit 0 with a
non-empty r2.webm and tmp back at mode 0700. Without the mkdir in att() the case
fails: record exits 1 (agent-tty EXPORT_ERROR).

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
… run the static proofs in CI

Follow-up of #506 and #508 (their verifiers' index items N3, m1-m5, F1 and F2).

get and dashboard. The five journey-*.sh scripts and the dashboard command still ran
`source "$VCN_STATE/run.env"` in the caller's own shell, with no check of the state
dir. `vcn.sh get [-z] <NAME>` answers one value of run.env after load's check, and
only a name that run.env holds. A journey reads OUT and WORK through it (`-z` keeps a
newline at the end of a path), so a stale exported OUT cannot redirect its evidence,
and `vcn.sh dashboard` runs the agent-tty dashboard through att(). Nothing parses
printed text, which the #508 round-5 verifier showed to be forgeable.

NODE_DIR. att() puts NODE_DIR in front of PATH. load and launch now refuse an empty or
relative NODE_DIR (the current dir would go on PATH) and one with a colon (a second
dir). A dir that lost its node is not refused, so cleanup still runs.

Static proofs. proof-static.sh holds no-bare-tools and doc-vars, moved out of the
resolution proof, and `mise run check` runs it, so CI does. It closes the gaps of the
#508 round-6 index: indented and ~~~ fences (m1), a comment that names `source
run.env` (m2), a self-test that checked a copy of the set pattern (m3), and
`"${JQ_BIN:-jq}"` (m4). A new case, no-source-run-env, fails when any script or
recipe of the skill sources a run.env except load.

Proof cases: get-values, get-state, journey-out (VERIFY_OUT with ` OUT=` in it, a
newline in it and at its end, and a stale exported OUT), dashboard, node-dir in the
resolution proof, node-relative in the refusal proof.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
… checks

An independent review of the previous commit found, by reading, that load refused a
run.env that has no NODE_DIR at all. The hand-made run.env files of cleanup-no-agent-tty
and jq-old-run have none, so a full run of the resolution proof stopped at the first
of them. load now checks NODE_DIR when it is set (an empty value is set, and refused).
A run.env that launch left half written stays cleanable, and nothing runs agent-tty
for it, because SID and ATT_BIN are written after NODE_DIR.

- SKILL.md no longer says that the dashboard command runs agent-tty with the caller's
  TMPDIR: it goes through att(). It counts three proofs in proof-static.sh, not two.
- unsafe_dir takes the label for the dirs above, so a /tmp that others can write says
  "above the agent-tty socket dir". The refusal names `chmod go-w /tmp/agent-tty` for a
  mode that agent-tty made under umask 002.
- no-source-run-env finds a source after if, then, do, else, elif, while, until and !,
  not only after ; & | ( {. A self-test holds lines that must and must not match.
- no-bare-tools runs the scan of a file (bare_hits) on a sample, so dropping the default
  pattern from the scan fails the case, not only a pattern test.
- node-relative puts a failing mise first on PATH: a mise in /usr/bin would otherwise
  answer with an absolute path and the case would not reach its refusal.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
Round-5 verifier of #506 (F2): SKILL.md said that proof-launch-refusals.sh takes about
60 s. It ran for 163 s (the last gate on #506), about 190 s (a verifier) and 287 s (this
branch, with 40 PASS lines, on a host at a load of about 20), because most cases start
a launch. The sentence now says 3 to 5 minutes and why.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
The final gate of this branch ran proof-launch-refusals.sh in 338 s (40 PASS lines) on a
host at a load of 35. SKILL.md now says 3 to 6 minutes, from 163 s to 338 s.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-12T08:46:36.079634Z 9865369 Manual request
🔒 Security Review ✅ Completed 2026-11-12T08:46:16.778925Z 9865369 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 397414f22a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 397414f22a

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

…ks that guard them

Round 1 of the verifiers of #545 (Opus F3 to F12, gpt A and B).

features/README.md read OUT as `OUT=$($V get OUT)`. A command substitution cuts a
newline at the end of the value, and VERIFY_OUT may end in one, so the next documented
write went to the twin of the evidence dir. SKILL.md and the README now give one
recipe, `getz() { IFS= read -r -d '' "$1" < <($V get -z "$1"); }; getz OUT; getz WORK`,
and the full-run block of SKILL.md no longer exports OUT. proof-static.sh gets two
cases: doc-get-exact (no recipe reads a path through `$(... get NAME)`) and doc-recipe
(the documented line runs on a hand-made run.env with a trailing newline in OUT and
WORK, with a control that shows a command substitution cuts it).

proof-static.sh, the points of Opus F3 to F6: a comment sets and sources nothing, so
`# PORT=$(x)` no longer satisfies doc-vars and `echo x # source run.env` no longer
exempts a block. A source after `builtin`, `command` or `eval`, in backticks or in
quotes, is found, and so is a span that a line break splits. `$((PORT + 1))` is a use.
A missing SKILL.md or features/*.md fails. The header says what the checks do not see
(an indented code block, <pre>, a command that a variable holds, a source in a .lua,
.py or .mjs file). Each change has a sample in the case and a mutant that turns it red.

proof-launch-refusals.sh, gpt B and Opus F7: a case that accepts /tmp/agent-tty
requires the launch to go on to `agent-tty doctor`, so a guard that refuses a safe dir
with any message fails it (the strict-0700 mutant with another message was green). New
cases: att-root-own-700 and att-root-nosticky (a 0700 dir under a /tmp that is
world-writable without the sticky bit: only the walk above the dir can refuse it).

Opus F9: load names the manual cleanup for a run whose NODE_DIR it refuses: stop the
nvim and agent-tty processes and `rm -rf` the state dir. cleanup refuses too, because it
would run agent-tty with that PATH. node-dir tests the hint and that the dir stays.
Opus F8: get keeps both name checks, and get-values holds a name (`O.T`) that only the
pattern check refuses. SKILL.md says that a 0775 /tmp/agent-tty (umask 002) is refused
and that the message names `chmod go-w`.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
…gate measured

The gate of the previous commit ran proof-launch-refusals.sh (42 PASS lines) in 149 s on
a host at a load of about 20. Earlier runs took 163 s, 287 s and 338 s. SKILL.md now says
2 to 6 minutes (149 s to 338 s).

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 2ae7ec8619

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 2ae7ec8619

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

The last gate ran proof-launch-refusals.sh in 626 s on a host at a load
of 50 to 90. SKILL.md said 149 s to 338 s. Name the range that the
gates measured: 149 s to 626 s.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking:
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 98653693c2

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 98653693c2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 12, 2026
@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 12, 2026
@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 12, 2026
@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit 523ee20 Nov 12, 2026
7 checks passed
@ThomasK33
ThomasK33 deleted the fix/vcn-checked-get branch November 12, 2026 11:33
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
…pecs there (coder#564)

## Why

The README says the `unified` diff layout needs Neovim 0.9. It does not.
The layout uses `vim.diff`, and Neovim 0.8.0 has that function. The 0.9
claim made eight integration specs skip every `unified` case on Neovim
0.8.0, so the 0.8.0 CI job ran no accept, reject, `:q`, window-size or
trailing-line-break check for that layout. A regression there would have
passed CI.

## What changed

1. **Both READMEs drop the 0.9 claim.** `README.md` (the
`diff_opts.layout` comment) no longer says "Requires Neovim >= 0.9.0".
`lua/claudecode/README.md` (the `unified` bullet) now says that Neovim
0.8.0 has `vim.diff`, so the layout runs on every Neovim the plugin
supports.
2. **Eight specs lose their 0.9 skip.** In each, the `unified` cases now
run on every Neovim: `diff_quit_reject_spec.lua`,
`diff_window_choice_spec.lua`, `diff_window_sizes_spec.lua`,
`diff_close_save_spec.lua`, `diff_hand_over_spec.lua`,
`diff_accept_trailing_newlines_spec.lua`,
`diff_accept_missing_final_newline_spec.lua`,
`diff_accept_emptied_buffer_spec.lua`. In `diff_quit_reject_spec.lua`
and `diff_window_choice_spec.lua` the skip covered the whole file.
3. **The error message tells the truth.** `setup_inline_diff` in
`lua/claudecode/diff_inline.lua` raised `-32000` with "Inline diff
requires Neovim >= 0.9.0" when Neovim has no diff function. The guard
checks for the function (`vim.text.diff` or `vim.diff`) and never for a
version, so the message now says what is missing: "The unified layout
needs vim.text.diff or vim.diff, and this Neovim has neither". A unit
test drives the guard with both functions removed. It fails on the old
message (50 successes, 1 failure) and passes on the new one (51
successes).
4. **One 0.9 gate stays.** `diff_hand_over_spec.lua` picks `:w ++p` only
when Neovim has it (0.9 and later) and a `mkdir` plus `:w` on 0.8.0.
That is a real difference between the versions, and it is not about the
`unified` layout.
5. **One case asserts the error it expects.** In
`diff_window_sizes_spec.lua`, the case "gives the sizes back when the
unified diff's setup fails" checked only that some error came back. With
the unified path broken on Neovim 0.8.0 (a diff function that ignores
`vim.diff`), it stayed green, because the guard raised an error too. It
now asserts the `Diff setup failed` message and that the error data
carries the failure of the user's autocmd. With that broken path it
fails on 0.8.0, and it passes on 0.8.0 and 0.12.3.

The tree holds no other 0.9 claim about the `unified` layout. I checked
every `0.9` and `nvim-0.9` line in code, docs, comments, error strings,
specs, scripts and the CI workflow. The ones that remain are about
something else: `nvim -l` (0.9 and later only), `statuscolumn` and `:w
++p` gates in `diff.lua` that are correct, the LuaJIT bug in Neovim 0.8
and 0.9, visual mode in 0.8 and 0.9, and the RPC process model.
`CHANGELOG.md` and its fixture copy keep their old lines, because they
describe what the release did when it shipped.

`diff_inline.lua` is covered by the diff lifecycle model. The change
touches only the `setup_inline` block, and only a comment and an error
string in it, so no behavior changed. The model passes (89205 states,
every fact TRUE) and `covered.lock` is recorded for the new file.

## How I checked it

The specs ran on a real Neovim 0.8.0 (`v0.8.0-1210-gd367ed9b2`) before
and after the change:

| spec (Neovim 0.8.0) | successes before | successes after |
| --- | --- | --- |
| `diff_quit_reject_spec.lua` | 0 (skipped) | 26 |
| `diff_window_choice_spec.lua` | 0 (skipped) | 14 |
| `diff_window_sizes_spec.lua` | 19 | 24 |
| `diff_close_save_spec.lua` | 40 | 77 |
| `diff_hand_over_spec.lua` | 12 | 13 |
| `diff_accept_trailing_newlines_spec.lua` | 14 | 28 |
| `diff_accept_missing_final_newline_spec.lua` | 64 | 94 |
| `diff_accept_emptied_buffer_spec.lua` | 34 | 40 |

That is 133 more tests on Neovim 0.8.0, with 0 failures and 0 errors. 13
of the 133 are vertical-layout cases of `diff_quit_reject_spec.lua`,
which the file's 0.9 skip also hid. Three other specs
(`diff_new_tab_bufenter_spec.lua`, `diff_new_tab_mode_spec.lua`,
`diff_terminal_float_spec.lua`) already ran their `unified` cases on
0.8.0 without a 0.9 skip, and they pass.

I also ran a unified diff by hand in the real Neovim 0.8.0 terminal UI
(tmux, 110 columns). The proposal changed `two` to `TWO` and added
`four`. The screen showed a red `- ` sign and a red-tinted line for the
deleted row, and a green `+ ` sign and a green-tinted line for each
added row. `:w` answered `FILE_SAVED` with the exact proposal text
`one\nTWO\nthree\nfour\n`. `:q` answered `DIFF_REJECTED`.

Gates on the final commit `598b0acc`, which merges `origin/main`
523ee20 into the branch (private XDG folders). Since coder#545, `mise run
all` also runs the static proofs of the `verify-claudecode-nvim` skill,
and they pass:

- `mise run all`: native exit 0. luacheck: 0 warnings / 0 errors in 185
files. busted: 1738 successes / 0 failures / 0 errors / 28 pending.
treefmt: 0 files changed.
- `mise run format-check`: native exit 0 (427 files, 0 changed).
- `scripts/run_integration_tests_individually.sh` on Neovim 0.12.3: 527
successes, 0 failed, 0 errors, native exit 0. On Neovim 0.8.0: 523
successes, 0 failed, 0 errors, native exit 0 (390 on `origin/main`, 133
more now).
- Full `verify-diff-lifecycle/check.sh`, then `--record`, then all four
`.agents/skills/verify-*/check.sh --hash-only`: every one matches its
`covered.lock`.

## Left as it is

`CHANGELOG.md` keeps its old lines about 0.9 and about the 0.8.0 job
skipping `unified`. They describe what the release did when it shipped.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
… other path characters (coder#561)

## Why

`cwd.git_root` must find the repository of a folder whatever its name
is. A repository in a folder with a space or a comma in its path was not
found when git could not run: the fallback gave nil, or the repository
of Neovim's current folder. A folder named `[x] {y}` or `$HOME-like` was
lost or changed even when git answered. A verifier of F12 found the
first case (finding F12-P1, a space or comma in the path makes the
`.git` fallback return nil).

## What changed

Four problems in `lua/claudecode/cwd.lua`. The first two took a path as
a pattern. The third stepped one folder down at a trailing `..`. The
fourth is a flaw in my first version of the second step.

1. **The `.git` fallback** called `vim.fn.finddir(".git", start_dir ..
";")`. `finddir` reads its path as a list in `'path'` option syntax. A
space or a comma split the path, and a `;` ended it. The fallback now
walks up from the full path of `start_dir` and tests each folder for a
`.git` folder with `vim.fn.isdirectory`. A relative `start_dir` now
reaches the folders above the current one too. Before, a relative start
gave a wrong answer or nil in some cases (for example
`git_root("sub/deeper")` in the repository root).
2. **`normalize_dir`** passed every path through `vim.fn.expand`.
`expand` reads `[`, `{`, `*`, `?`, `$` and `\` in a name as patterns or
variables. A folder named `[x] {y}` or `it's` came back with a trailing
slash. A folder named `$HOME-like` or `back\slash` came back nil. This
also happened when `git rev-parse` answered. `normalize_dir` now returns
a directory that exists as written, and expands any other string (`~`,
`$VAR`). See step 4 for text that starts with `~` or `$`.

3. **`..` before Neovim 0.11.** Before 0.11, `fnamemodify(path, ":p")`
keeps a last `..`, and `:h` of `a/b/..` is `a/b`. So a walk from a start
folder that ends in `..` stepped one folder down and returned the wrong
root. The walk now calls `simplify()` on the full path first. A verifier
found this.

4. **Text that starts with `~` or `$`.** The first version of step 2
took a path that names an existing folder as written, on the raw text.
`vim.fn.isdirectory` does not expand, so with `cwd = "~"` or `"$HOME"`
and a stray folder of that literal name in Neovim's current folder (for
example one that an unexpanded `mkdir "~/x"` made), `git_root` and
`resolve` returned the stray folder where main returned the home folder.
`normalize_dir` now sends text that starts with `~` or `$` through
`expand` first, as a shell and `:cd` do. A verifier of the terminal
change found this.

`lua/claudecode/README.md` gets one new bullet that states this, with
the rule for text that starts with `~` or `$`. It sits after the
"Terminal cwd precedence" bullet. It names `git_root` and `resolve`
only, and it says that the terminal still passes a static `cwd` and a
`cwd_provider` result through `vim.fn.expand` itself.

## Scope

- Only `cwd.lua` changes in code. Steps 1 and 2 are one change of idea:
a path is a literal, not a pattern. Step 3 is a quirk of `fnamemodify`
before Neovim 0.11. Step 4 closes a hole that step 2 opened.
- `normalize_dir` serves `cwd.git_root` and `cwd.resolve`. Nothing in
`lua/` calls `cwd.resolve`. The terminal calls `cwd.git_root` when
`git_repo_cwd` is set, so this PR fixes folder names with `$` and `\` on
that path: when git does not answer, the terminal now gets `$HOME-like`,
`back\slash`, `[x] {y}`, a space and a comma right, where main gave nil
or the repository of Neovim's current folder. A static `cwd` and a
`cwd_provider` result do not go through `normalize_dir`:
`terminal.lua:build_config` passes them through `vim.fn.expand` itself,
so this PR does not change what the terminal does with those. An
absolute start path that ends in `..` is fixed here, through the
`simplify()` call above.
- Two gaps stay for separate follow-ups. Main already has both, and this
PR does not reach them. First, the fallback finds a `.git` folder only,
not a `.git` file in a worktree or submodule. Second, the walk is
lexical, so a link into another repository answers with the repository
of the link's own folder, where git answers with the link target's
repository. The terminal's `expand` of a static `cwd` and of a provider
result is a third follow-up, and it has its own change.
- `~` and `$VAR` still expand in `cwd.resolve` (the spec checks both).
The spec's cases for `cwd.resolve` are named as `cwd.resolve` cases,
because they do not test the terminal.
- `git rev-parse` still runs first. Its result for a plain path is the
same string as before.

## Blast Radius

- User-visible: a repository in a folder with a space, a comma, a quote
or another path character is now found when git cannot run. With git,
`git_root` returns a folder with `[`, `{`, `*`, `?`, `$` or `\` in its
name exactly. It does not reach the terminal through a `cwd_provider`:
`build_config` still expands the provider's result, so the root README's
example still loses `$` and `\` there (follow-up). A plain path gives
the same result as before.
- The fallback result is an absolute path with no `..` part. Before it
could be relative. The one exception is a relative start whose name
begins with `~`: Vim reads `~` as the home folder and `~name` as a
user's home folder, so `:p` leaves `~name` relative and the result can
be the relative `~name`. That is a pathological case, and no caller in
`lua/` passes a relative path (the terminal passes the file's folder
from `expand("%:p")` or `getcwd()`).
- A string that names an existing folder is taken as written, except
text that starts with `~` or `$`. That text always goes through `expand`
first, so a folder named `~` or `$HOME` in Neovim's current folder does
not take the place of the home folder, as on main. The cost: a relative
folder name that starts with `$` and reads as a variable (`$HOME-like`,
`$odd/sub`) is expanded and not found, as on main. A lone relative
`$odd` that names no variable is left unchanged by Vim and is still
found. An absolute path never starts with `~` or `$`, so every folder
name this PR fixes keeps working. The change applies to `git_root`'s
`start_dir` and to `cwd.resolve`. The terminal's own `expand` of a
static `cwd` and a provider result is a separate change.
- Neovim 0.8: the code uses `vim.fn.isdirectory`, `vim.fn.fnamemodify`
and `vim.fn.simplify`, which exist there. I ran the new spec on Neovim
0.8.0, 0.10.0 and 0.12.3. The `..` case fails on 0.8.0 and 0.10.0
without the `simplify()` call, and passes on 0.12.3 either way.
- `covered.lock`: `cwd.lua` is the terminal's working-directory lookup.
`check.sh` printed STALE for the file hash of `cwd.lua` only, with no
changed block or call site. The change cannot reach diff state, an
openDiff answer, a disconnect or a diff command. The model passed
(`main`: 89205 distinct states, `live-DisconnectedCallsAnswered`: 5269).
`check.sh --record` wrote the new lock and `check.sh --hash-only`
reports that it matches.

### Merge with coder#539 (F12)

coder#539 landed first. This PR's merge commit with `origin/main` resolves
the interplay: it re-records `covered.lock` and changes the one stale
test in coder#539's `cwd_spec.lua` (see Verification). F11 (coder#535) merges with
this PR without a conflict.

## Verification

Red before the fix, green after (same spec, only
`lua/claudecode/cwd.lua` swapped):

| Spec | origin/main | with the fix |
| --- | --- | --- |
| `tests/integration/cwd_path_characters_spec.lua` (37 cases, real
Neovim, 0.8.0 and 0.12.3) | 20 successes, 17 failures | 37 successes, 0
failures |
| the new case "goes up from a start folder that ends in .." at
`ede2f733` (before the `simplify()` call) | fails on 0.8.0 and 0.10.0,
passes on 0.12.3 | passes on 0.8.0, 0.10.0 and 0.12.3 (35 successes when
added, 37 at the final head, 0 failures on each) |
| the two cases "expands a leading ~ / $ even when Neovim's cwd holds a
folder named ~ / $HOME", with `cwd.lua` from `ca9e61c0` (before the
guard) | 35 successes, 2 failures on 0.8.0 and 0.12.3 | 37 successes, 0
failures on each |

The spec makes real folders and runs `cwd.git_root` on them, in two
modes. In the first mode git is off `PATH` (the shell keeps its absolute
path), so `git` ends with "not found" and a list command raises E475, as
on a host without git. In the second mode a real `git init` makes the
repository. The folder names are: a space, a comma, a semicolon, single
and double quotes, brackets and braces, parentheses, non-ASCII letters,
a star and a question mark, a percent sign and a hash, a backslash, a
dollar sign, a leading tilde, and a plain name. Each name runs from the
repository folder and from a folder two levels below it, and the answer
must equal the repository path with no trailing slash. A folder that is
not a repository returns nil, whatever its name. Seven more cases cover
a relative start folder, a start folder that ends in `..`, `cwd.resolve`
with `git_repo_cwd`, a static `cwd` with brackets and a star in
`cwd.resolve`, `~` and `$VAR` in a setting of `cwd.resolve`, and two
cases where Neovim's current folder holds a folder named `~` or `$HOME`
and the setting must still expand. The 17 red cases are 10 of the no-git
names, 4 of the git names, the relative start, `resolve`, and the static
`cwd`.

Gates on the final head `f12368bd`, which contains a merge of
`origin/main` (05b9785, with coder#551, coder#535, coder#494, coder#545, coder#553, coder#555 and
coder#557) (private XDG folders). An independent verifier passed `c73ab044`.
Since then the branch has three changes of its own and two merges with
main. The changes are a docs commit (the README sentence about where the
walk starts), the fix for text that starts with `~` or `$` (step 4, with
two spec cases and the README clause), and the `covered.lock` record for
it. For the lock I ran the full `check.sh`: the model passed (89205
distinct states, 5269 live states, every fact TRUE), and only the file
hash of `cwd.lua` was stale, with no changed block or call site.

- `mise run all`: native exit 0. luacheck: 0 warnings / 0 errors in 186
files. busted: 1756 successes / 0 failures / 0 errors / 29 pending.
treefmt: 0 files changed. Since coder#545, `mise run all` also runs the
static proofs of the `verify-claudecode-nvim` skill, and they pass.
- `mise run format-check`: native exit 0 (428 files, 0 changed).
- `scripts/run_integration_tests_individually.sh` on Neovim 0.12.3: 564
successes, 0 failed, 0 errors, native exit 0. On Neovim 0.8.0: 427
successes, 0 failed, 0 errors, native exit 0 (the 0.8.0 run skips the
specs that need 0.9). The new spec gives 37 successes on both. It starts
about 100 processes (git and a shell), so it takes about 6 s on a quiet
host and can pass the runner's 40 s limit at a load average above 100. A
TIMEOUT line there means the host was slow, not that the spec hung.
- All four `.agents/skills/verify-*/check.sh --hash-only` match their
`covered.lock`.

The merge with `origin/main` had two parts to resolve in an earlier
commit. `covered.lock` conflicted on the hash line of `cwd.lua`, so I
ran the full `check.sh` and `check.sh --record`. coder#539's
`tests/unit/cwd_spec.lua` had a test that stubbed `vim.fn.finddir`,
which the fallback no longer calls, so that test now walks up real
folders. It does not depend on where `TMPDIR` is: it passes with
`TMPDIR` inside a git checkout too. `cwd.lua` itself merged without a
text conflict. The two latest merges needed no `covered.lock` change:
coder#551 conflicted only in `tests/mocks/vim.lua`, where the branch keeps
both its `simplify` entry and coder#551's `wordcount`, and coder#535 and coder#494
merged without a conflict.

An independent verifier found the `..` case, the README wording and the
other points that this body now states. The `terminal.lua` expand and
`.git` files in worktrees are gaps that main already has. They are
follow-ups, not part of this PR.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
…cipe fail loudly (coder#566)

## Why

coder#545's readiness advisor left five small follow-ups in the
`verify-claudecode-nvim` skill. Four are claims that said more than the
check behind them, and one is a recipe that failed without a word. Codex
and a second independent review found more gaps in the new checks, and
this PR closes all but the scanner gaps listed under "Not covered".

## What changed

All changes are in `.agents/skills/verify-claudecode-nvim/`.
`scripts/vcn.sh` changes one comment line (the usage header, see 7) and
no code. `bin()` and the `VCN_NVIM` branch are untouched, and so are
both launch proofs.

1. **Claims of the static scanner.** The `proof-static.sh` header, its
five `ok` lines and the `SKILL.md` bullet say only what the scanner
sees, except the `doc-get-exact` gaps under "Not covered". They list
what it does not see: an indented code block, `<pre>`, a command that a
variable holds, a source in a `.lua`, `.py` or `.mjs` file, a `read`
that takes `get` through a pipe or a file, and seven shapes that no code
block and no code line of a script has (a command substitution or `read
< <(...)` whose `get` is on the next line, a `read -d ''` with no
`IFS=`, a backtick substitution of `get`, a `source` whose `run.env` is
on the next line after a backslash, a quoted command word such as
`"source" run.env`, a `run.env` named through `BASH_ENV`, and a use of
`PORT`, `SID`, `NVIM_PID` or `NODE_DIR` without a `$`, as in `let` or
`(( ))`). I searched the skill for each of the seven and found none. The
scanner has no new rule for them: a stated limit is the fix for a shape
that no file has. The `doc-vars` line also says that the inline code of
a doc counts as one unit, and `SKILL.md` names the checks that skip a
block that sources a `run.env`.
2. **`doc-get-exact`** also flags `$(... get -z NAME)`, and a `read ...
< <(... get NAME)` that has no `-d ''` among its own words. Another
`read` on the same line does not hide it. A name in quotes (`get -z
"OUT"`) counts as the name. A new function `path_reads` holds both
rules, and the case's samples call it too.
3. **`doc-recipe`** reads a value that holds a backslash (`back\slash \\
end\n`, besides the newline and quotes), and runs each documented line
with no `V` in the environment, from the repo root. Before, it supplied
`V`, so a line that lost its own `V=` still passed. It builds its state
dirs inside a dir named `a:b`, so a path with a colon works.
4. **`getz` fails loudly.** `getz() { IFS= read -r -d '' "$1" < <("$V"
get -z "$1") || { echo "vcn.sh get $1 failed" >&2; return 1; }; }; getz
OUT && getz WORK || exit 1`. When `get` refuses, the call prints `vcn.sh
get <NAME> failed` and stops with status 1, so no later command runs
with an empty `OUT`. (The first head of this PR only returned 1 from the
function, and the call went on.) The `SKILL.md` line sets `V=` itself,
as the README line does. `doc-recipe` has three refused-get runs per
doc: a group-writable state dir (`load` refuses it), a `run.env` without
`OUT`, and one without `WORK`. Each must exit with status 1 exactly,
name the failed `get`, and run no later command.
5. **`SKILL.md` line 55:** the sentence "Keep it short: nvim's socket
..." is back after "the default passes.". coder#545 had put the `agent-tty`
sentences between them.
6. **`SKILL.md` line 134:** the refusal proof's time is "2 minutes or
more, 149 s to 966 s measured, depending on the load of the host", with
no maximum. My own runs measured 149 s to 626 s. The 966 s is the figure
from the issue brief.
7. **`vcn.sh` usage comment (line 19):** it said that `read -r -d ""`
keeps a newline at the end of a value. With the default `IFS`, `read`
trims it (a probe: `a\n` reads as `a`, and as `a\n` with `IFS=`). It now
says `IFS= read -r -d ""`. No line of code changes: the non-comment
lines of the file are identical.

## Evidence

Every row is a mutant of the proof or of a doc, run on a copy of the
final skill dir. Each red row fails at the case that the row names. The
last rows are the opposite: a break that the check cannot see stays
green, which shows what the check is for.

| Change | Mutant | Result |
| --- | --- | --- |
| Control | none | green, `CASES 5` |
| `-z` in the substitution rule | name pattern without the `-z` group |
red: `doc-get-exact: the patterns miss: x=$($V get -z OUT)` |
| The `read` rule | `path_reads` without it | red: `doc-get-exact: the
patterns miss: IFS= read -r OUT < <($V get -z OUT)` |
| `-d ''` only | any `-d` flag counts | red: `... read -r -d x OUT ...`
|
| `-d ''` bound to its own read | test on the whole line again | red:
`... read -r -d "" scratch <<< x; IFS= read -r OUT < <($V get -z OUT)` |
| Quoted names | quote groups removed | red: `... OUT=$($V get -z
"OUT")` |
| A doc with a `read` of a path and no `-d` | README span added (also
with two reads on a line) | red: `doc-get-exact: features/README.md
reads a path ...` |
| A doc with `$($V get -z OUT)` or a quoted name | SKILL.md span added |
red: `doc-get-exact: SKILL.md reads a path ...` |
| Backslash in the value | README `getz` without `-r` | red:
`doc-recipe: ... read OUT as '...'` |
| No `V` of its own | README or SKILL.md `getz` without `V=` | red:
`doc-recipe: ... failed: ... command not found` |
| The refused-get message | README or SKILL.md `getz` without it | red:
`doc-recipe: ... did not say 'vcn.sh get OUT failed'` |
| `&&` between the two `getz` | `;` in README or SKILL.md | red:
`doc-recipe: ... exited 0, wanted 1, after a refused get (OUT ...)` |
| `|| exit 1` | removed, or `exit 2` | red: `... exited 0, wanted 1
...`, `... exited 2, wanted 1 ...` |
| A swallowed `WORK` failure | `{ getz WORK \|\| :; }` in README or
SKILL.md | red: `doc-recipe: ... exited 0, wanted 1, after a refused get
(WORK ...)` |
| Directory and name in one string | colon-packed loop again | red:
`doc-recipe: did not say 'vcn.sh get b/vcn.XXXXXX:OUT failed': vcn: no
run at /tmp/.../a` |
| Gap: the exact status | status test `!= 0`, README `exit 2` | green:
with the exact test, the `exit 2` mutant is red |
| Gap: the no-`WORK` case | case removed, SKILL.md swallows `WORK` |
green: with the case, the same break is red |
| Gap: README `getz` without `-r`, or without `V=`, `$($V get -z OUT)`
in SKILL.md, `read -r OUT` of a path in the README | main's proof (from
coder#545) | green (four runs) |
| Gap: two reads on one line, a quoted name `get -z "OUT"` | the proof
of this PR's first head | green (two runs): Codex's findings 1 and 2 |

Codex's colon case, reproduced first: with a colon in `TMPDIR`, the old
proof fails `doc-recipe` with `did not say 'vcn.sh get
tmp/tmp.XXXXXXXXXX/vcn.XXXXXX:OUT failed'` and a truncated `VCN_STATE`.
The final proof passes the same run (`CASES 5`).

## Gate

On `858a482a`, the last head that changed code. The final head
`ca9c9a32` adds only comment lines and `SKILL.md` text; an independent
rerun of `mise run all` on `ca9c9a32` gave exit 0, 1756 successes, 0
failures, 0 errors, 28 pending, and `proof-static.sh` `CASES 5`.

| Check | Result |
| --- | --- |
| `mise run clean && mise run all` | exit 0 in 1584 s. 1756 successes, 0
failures, 0 errors, 28 pending. `luacheck`: 0 warnings, 0 errors in 185
files. Its `check` task runs `proof-static.sh` |
| `check.sh` of `verify-diff-lifecycle` | `== RESULT: PASS` |
| `proof-static.sh` | exit 0, `CASES 5` |
| `VCN_NVIM` = Neovim v0.8.0 (the official release binary), `vcn.sh
launch`, `doctor`, `journey-opendiff-accept.sh`, `cleanup` | all four
exit 0, the journey prints `PASS opendiff-accept`, the state dir and the
saved PIDs are gone. This ran because `vcn.sh` changes (one comment
line) |
| `proof-launch-refusals.sh`, `proof-agent-tty-resolution.sh` | on the
first head (`44d57979`): refusals exit 0 with 42 `PASS` in 360 s (3 ACL
`SKIP`), resolution exit 0 with `CASES 49` and no `SKIP` in 1765 s (a
restart of the host killed its first run, so this is its second). Both
proofs run `vcn.sh`, which differs from that head by one comment line,
and neither reads another file that this PR changed, so they did not run
again |

## Not covered

- The scanner misses a one-line `$(...)` with a `)` before its `get`
(for example `OUT=$(cd "$(dirname "$V")"; "$V" get -z OUT)`), a `read
... < <(...)` with `;`, `&` or `|` before its `get`, and `{ IFS= read -r
OUT; } < <(...)` or `while IFS= read -r OUT; do ...; done < <(...)`. No
doc or script has these shapes today. A follow-up PR fixes the scanner
for them.
- The scanner's stated limits stay limits. A new shape in a doc or
script that one of them hides is not seen.
- `getz OUT && getz WORK || exit 1` ends the shell of the call that runs
it. `SKILL.md` says that every bash call is a fresh shell, and the
README starts every bash call with this line, so no session is lost, but
a reader who pastes the line into a long-lived shell closes it.
- The 966 s of the refusal proof is not a measurement of mine.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 13, 2026
…HTTP retries (coder#560)

## Why

On coder#545's merge-group runs (43206505037 and 43206955049) the
`unit-tests` job failed in `Set up mise`, before it ran a test.
`www.lua.org` reset the connection for `lua-5.5.0.tar.gz`, and mise gave
up after its 3 retries (4 attempts, 10.6 s). Other PRs passed because
they hit the tool cache. coder#545 edited a task in `mise.toml`, so its runs
missed the cache (`mise cache not found for
mise-v1-linux-x64-2026.11.5-bd6141...`) and every tool downloaded again.

A `mise.toml` edit that does not change the tool set must not download
tools again.

## Cause

From `src/index.ts` of `jdx/mise-action` at the SHA that `test.yml` pins
(v4.0.1): the default key here is `mise-v1-<platform>-<mise
version>-<file_hash>`. The template adds `MISE_ENV` and a hash of the
tool names in `install_args` only when they are set. `--locked` names no
tool, so this key has neither. `file_hash` hashes every `mise.toml`,
`mise.lock`, `.mise.toml` and `.tool-versions` in the repo, so any
`mise.toml` edit, a task edit included, changes the key. Of the 10
commits on main that touched `mise.toml`, 5 left `mise.lock` alone. coder#545
was one of them: its `mise.lock` blob is the same before and after the
merge, and its `mise.toml` is not.

## What changed

Only `.github/workflows/test.yml`, in the `Set up mise` step of the
`unit-tests` job:

1. `cache_key: mise-v1-{{platform}}-{{version}}-${{
hashFiles('mise.lock') }}`. The key keeps the shape of the default key
(`mise-v1-linux-x64-2026.11.5-<hash>`) and hashes only `mise.lock`,
which pins the tool set. mise-action restores and saves from this exact
key, as before.
2. `cache_save: ${{ github.event_name == 'push' }}`. Only a push to main
saves the cache. A pull request, the merge queue and a manual run
restore it and never write it. mise runs the `postinstall` hooks of a
PR's own `mise.toml` during `mise install`, mise-action saves after
that, and the key ignores `mise.toml`. Without this line, a PR could
keep a tampered tool in its own cache after it removed the hook.
3. `MISE_HTTP_RETRIES: "6"`. mise retries a failed HTTP request 3 times
by default, with a backoff of about 0.2, 1 and 4 s (2.6 to 5.2 s in
total). 6 retries add three more backoffs of about 15 s each, so the
total is 25 to 50 s.

`release-notes.yaml` and `release-please.yaml` already set `cache:
false` (they publish), and `integration-tests` and `covered-lock` do not
install mise. They are unchanged. The pinned mise version and its
`sha256` are unchanged.

## What I did not do, and why

The first head of this PR used a separate `actions/cache` step with a
`restore-keys` prefix, so that a lock change would restore the previous
cache and download only the changed tools. Codex raised four valid
findings on it, and I dropped the design:

1. The step's path `~/.local/share/mise` gives another cache version
than the absolute path that mise-action uses (`e61abf9f359d` for
`~/.local/share/mise` and `7a8f38effbb6` for the absolute path: each is
the first 12 characters of the `sha256` of
`<path>|zstd-without-long|1.0`), so it could not restore the caches that
mise-action had saved. The first CI run of that head printed `Cache not
found for input keys: mise-v1-linux-x64-2026.11.5-cb7d66...,
mise-v1-linux-x64-2026.11.5-`.
2. A `restore-keys` fallback restores installed tools across lock
changes. mise does not check an installed tool against `mise.lock` (its
docs: "Restored caches and installed tools"), so a tool whose checksum
changed at the same version would stay as it was.
3. The fallback also keeps every old tool version in the next cache.
4. The extra step saved the cache in its post step, after the PR's
scripts had run.

mise's own advice is to cache the download store (`downloads-cas`)
instead of the installs. That would not have prevented this failure.
With the pinned mise 2026.11.5, a `--locked` install of `lua@5.5.0`
downloads the tarball through the vfox plugin, unchecked, and neither my
run nor an independent rerun made a `downloads-cas` dir.

So a lock change still downloads every tool, as it does today, and a PR
that changes `mise.lock` downloads every tool on each of its runs,
because a PR does not save. The 6 retries cover a short reset of a
download host. They do not cover an outage of several minutes.

## Evidence

| Claim | Evidence |
| --- | --- |
| The failure is the cache miss plus a `www.lua.org` reset | Log of run
43206505037, job `unit-tests`: `mise cache not found for
mise-v1-linux-x64-2026.11.5-bd6141...`, three `HTTP
https://www.lua.org/ftp/lua-5.5.0.tar.gz attempt N failed (transient)`
warnings with retries in 184 ms, 780 ms and 3.56 s, then `lua@5.5.0 ...
failed: ... Connection reset by peer`. Run 43206955049 fails the same
way, 3 minutes later, with the same key |
| The action hashes every mise file | `src/index.ts` at the pinned SHA:
`processCacheKeyTemplate` hashes `MISE_CONFIG_FILE_PATTERNS` |
| A task edit leaves the new key alone | `git diff --stat` of coder#545's
merge shows `mise.toml` changed and `mise.lock` not. The `mise.lock`
blob is `f3df30fd` on both sides, so `hashFiles('mise.lock')` is the
same |
| The template gives the key above | Handlebars 4 (the engine that
mise-action bundles) renders `mise-v1-{{platform}}-{{version}}-<hash>`
with `linux-x64` and `2026.11.5` as `mise-v1-linux-x64-2026.11.5-<hash>`
|
| The saved cache has the version of main's caches | The repo's cache
list: the entry that this PR's second head saved
(`mise-v1-linux-x64-2026.11.5-cb7d66...`) has version `7a8f38effbb6`,
the same as the caches of main. The first head's entry has
`e61abf9f359d` |
| mise's retry setting works as the comment says | Source of mise
v2026.11.5 (`crates/mise-util/src/http.rs`): the schedule is about 200
ms, 1 s, 4 s, then 15 s, each with a random factor of 0.5 to 1. A local
probe against a refused URL with a scratch mise dir: default
`http_retries` made 4 attempts in 4 s. `MISE_HTTP_RETRIES=5` made 6
attempts in 29 s |
| The retries rescue a run | Run 43220955113 (second head), job
`unit-tests`, step `Set up mise`: `www.lua.org` failed 4 times (resets
and a 500). The 5th attempt, after a 10.5 s backoff, downloaded Lua and
the job passed. With mise's default of 3 retries it would have stopped
after the 4th failed attempt, as on coder#545's runs |
| The cache key resolves and the cache saves | Same run and step: the
input prints as `cache_key: mise-v1-{{platform}}-{{version}}-cb7d66...`,
the action prints `mise cache not found for
mise-v1-linux-x64-2026.11.5-cb7d66...` (the first run with this key),
then `Cache saved from /home/runner/.local/share/mise with key:
mise-v1-linux-x64-2026.11.5-cb7d66...` |
| The workflow is valid | `actionlint` 1.7.12 on every workflow: exit 0.
`zizmor` 1.25.2 (`--offline`) on every workflow: no findings (25
suppressed), the same as main. `mise run format-check`: exit 0. Each of
these ran on every head |
| Gate | `mise run clean && mise run all` exits 0 in 848 s on the second
head: 1737 successes, 0 failures, 0 errors, 28 pending, `luacheck` 0
warnings in 185 files. No test reads `test.yml`, and the later heads
change only comments and the `cache_save` line, so the lint checks above
cover them |
| CI | On the final head, all 6 checks are green: `conventional-title`,
`unit-tests`, `covered-lock`, `integration-tests (stable)`,
`integration-tests (v0.8.0)` and `Required` |
| Review | Codex found 6 issues: 4 on the first head (all fixed: the
design above was dropped), 1 security issue on the third head `d08f3953`
(fixed in `61d7ea13`: the cache now saves only on push), and 1 on the
final head (rotate the cache key, because an earlier head could have
saved a cache under it), declined with evidence. That head ran with the
same `mise.toml` and `mise.lock` as main, and `mise.toml` has no hook. 0
threads are unresolved. This PR used 8 Codex review requests, past the
cap of 6. The maintainer's record is in the PR conversation |

## Not covered

- No test or lint checks the three settings. A later edit that removes
`cache_key`, `cache_save` or `MISE_HTTP_RETRIES` keeps CI green. A
follow-up PR adds a workflow guard.
- Main has no cache entry with this key yet. Run 43227198398 on the
final head printed `mise cache restored from key:
mise-v1-linux-x64-2026.11.5-cb7d661d...`, but from an entry that run
43220955113 saved under `refs/pull/560/merge`, which only this PR can
read. The merge-queue run of this PR and every run until the first push
to main whose `Set up mise` step succeeds miss and download all 15
tools, with the 6 retries. The first push to main saves, and the next
run with the same `mise.lock` restores. The saved entry has the cache
version of main's caches (see above).
- A `mise.lock` edit that leaves the 15 tools of CI alone (an entry for
macOS only, or for a tool that only a task installs) changes the hash
and misses the cache.
- The key ignores `mise.toml`, `install_args` and `MISE_ENV`. A change
that alters an install without changing `mise.lock` (a tool option in
`mise.toml`) keeps the key. The comment in `test.yml` says to bump `v1`
then.
- A `mise.lock` change downloads every tool again. A download host that
is down for longer than the 25 to 50 s of backoff still fails that run.
- The luarocks cache keeps its key on `hashFiles('mise.toml')` on
purpose: the rock list is in the `setup` task of `mise.toml`.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
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