Skip to content

fix(gate): keep a gate.json that won't load, and reload gate files by their text - #371

Merged
nissa-seru merged 9 commits into
anima-research:mainfrom
nissa-seru:fix/unreadable-gate-files
Oct 23, 2026
Merged

nissa-seru merged 9 commits into
anima-research:mainfrom
nissa-seru:fix/unreadable-gate-files

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: a wake-rule change no longer destroys a gate.json that won't load. When gate.json is there but isn't JSON, or holds a policy the gate refuses, wake_add_rule and wake_remove_rule write the rules in force over it, losing whatever it held, such as an operator's unfinished edit. Now a copy of the file is kept first, at gate.json.unreadable-<UTC time>, gate.json is written in place as before, and the tool's result says where the copy is. With it come smaller fixes to how the gate reads its two files. Both are reloaded when their text changes, rather than by gate.json's mtime or on every privileged-users check. A privileged-users list loads entry by entry. gate_status reads both files before it answers and shows each problem once.

With #452: #452 builds on this. There, a change edits gate.json's own list of rules, so a gate.json that isn't JSON is the one case this copy still serves, and #452 takes the copy out: with both, a change over such a file is refused, naming the parse error and what the gate runs meanwhile, and nothing is written over it. Merged without #452, this would ship a copy, and four public additions reporting it, that #452 then takes out.

Problem

  • The overwrite. EventGate.addPolicy and removePolicy, behind wake_add_rule, wake_remove_rule, addGatePolicy and removeGatePolicy, read gate.json again before a change (this.loadConfig() ?? this.config). When the file is there but can't be read, isn't JSON, or holds a policy the validator refuses, they fall back to the rules in force and write those over it. addPolicy's own doc says "a concurrent operator edit isn't clobbered", but an edit that doesn't parse yet is lost.

  • Noticing a change. gate.json's reload compared only its mtime, and took the stat after reading the file. These cases weren't loaded until the file changed again:

    • two writes in one clock tick, which Linux can give the same mtime;
    • a write that lands between the read and the stat;
    • a new file that a read only looked at. getGatePolicyNames, which every wake-rule result calls, and a change that writes nothing both stamp the mtime without applying anything.

    A chmod that made the file readable again was never noticed. isPrivilegedUser, which tune-out's wake classification calls for each diverted message, read and parsed the privileged-users file on every call, and while the file couldn't be read, each call pushed another copy of its error onto gate_status's errors. Both throttles compared a signed difference, so a clock stepped backwards held off every reload until it caught up. existsSync decided "missing", so a file behind a directory the process can't search was treated as missing: the privileged list emptied without an error, and gate.json's error went unseen.

  • A privileged-users file of the wrong shape. A file that parsed but wasn't a list of user ids or {"userIds": [...]} loaded as no one privileged, with no error. An entry that wasn't an id was loaded as text. A number too large for JSON to read exactly was loaded rounded, so it could match another user's id. The sleep path checked membership itself, so an empty author id matched an empty entry there but not in tune-out's check.

  • A remove that fails. removePolicy cleared the rule's state before it wrote gate.json. So a write that failed had already delivered the rule's pending debounce batch and dropped its counters, while the rule stayed in force.

  • errors itself. It was one list for both files. A gate.json load replaced it whole, which also wiped the privileged-users file's error. A privileged-users file that read again never cleared its own error. The startup's failure to seed or reconcile gate.json was cleared by the load right after it whenever gate.json loaded. gate_status reported both files as of their last check, which for the privileged-users file could be long past.

Changes

  • A copy of a gate.json that won't load is kept. Before a change writes over the file, the bytes the change read are written with an exclusive create to gate.json.unreadable-<UTC time> beside it, such as gate.json.unreadable-20261015T025905Z. When that name is taken, the next free one is used (-2 and so on), so a copy never replaces an earlier one. The time has no colons, since Windows doesn't allow them in a file name. Then the change is written into gate.json in place, from the rules in force.

    • A copy, not a rename, so gate.json itself stays where it is. A symlinked gate.json keeps its link, the file keeps its mode and owner, and a gate.json that's a single-file bind mount still works. The copy holds the bytes that were judged unreadable, whatever their encoding, not whatever is at the path a moment later.
    • Who can read the copy: on Linux, only the user the host runs as. It's created with mode 0600 and nothing widens it, whatever gate.json's own mode, group or ACL: what keeps others out of gate.json can be a directory on the way to the file a link or a single-file bind mount puts there, or an ACL, and the copy, beside it, has none of those. The host read gate.json, so its user is one who could.
    • Who's told: addPolicy and removePolicy, and addGatePolicy and removeGatePolicy, take an optional onUnreadableKept callback with the copy's path and the reason, called once the change is made and traced. A throw from it is logged with its text, and the call answers as the change it made. wake_add_rule and wake_remove_rule put it in their result as keptUnreadable: gate.json couldn't be read (<reason>), so a copy of it was kept at <path>, and gate.json now holds the rules that were in force, with this change. What the copy holds is not in force; whoever runs this host can restore rules from it. A [gate] log line and a gate:config-kept trace record it too, and the trace is now in the TraceEvent union.
    • When the copy can't be made, the change throws before it writes anything or clears any rule's state: gate.json couldn't be read (<reason>), and keeping a copy of it at <path> failed (<error>), so nothing was written. A copy cut short is removed.
    • When the write fails after the copy, its error ends A copy of what gate.json held when it couldn't be read is kept at <path>., since what the file held may now be only there.
    • Found later: gate_status lists each copy still beside gate.json in a new keptCopies field, saying that nothing in it is in force and how to use it, until it's removed. The moment a copy is made reaches only the log line, the trace and the change's caller, and the copy exists to be found later.
    • A gate.json the gate can't read at all, such as one it lacks permission for, refuses the change: gate.json couldn't be read (<reason>), so it can't be kept, and nothing was written over it. There's nothing to copy, and a file the process can't read is often one its operator closed off on purpose.
    • A change that writes nothing keeps nothing: a placement it can't make (insertBefore a rule that isn't there), or removing a rule that isn't there. A missing gate.json is written as before, with nothing to keep.
  • Reloading by text. At most once a second, gate.json is read again, and applied when its text differs from the text whose rules are in force. That text, configText, is recorded only by a load or a write that applies it. A read that only looks, or a change that writes nothing, leaves it. A file that holds it again after failing to load, as after a chmod repair, has its error cleared and nothing applied again, so a spell when gate.json couldn't be read doesn't clear the startup's error. A reload that throws before it applies is tried again at the next check. The privileged-users file is read at most once a second too, and loaded when its text differs from the text last read; a failed read forgets that text. Both files are small, so the read costs little, and text is exact where a stat isn't. isPrivilegedUser and the sleep path both take this check, rather than reading on every call.

    • A clock stepped backwards makes the next check run at once.
    • Missing means ENOENT from the read itself. Any other failure is the file's error. For gate.json, the rules in force stay. For the privileged-users file, the last list stays.
  • The privileged-users file loads entry by entry. In its list, a non-empty string or a safe integer (a whole number within 2^53 - 1 of zero) is a user id. Any other entry is skipped and named in the file's error, and the rest load. An integer too large for JSON to read exactly is shown rounded, with a note to write the id as a string. It isn't loaded, since the rounded value could be another user's. A file that isn't JSON, or isn't a list or {"userIds": [...]}, is the file's error, and the list that last loaded in this process stays in effect, as for a file that can't be read. At startup that's no one. A missing file still means no one is privileged. The sleep path asks isPrivilegedUser, so an empty author id is never privileged on either path.

  • A remove clears state only after its write. removePolicy writes gate.json first, then delivers the removed rule's pending debounce batch and drops its counters, so a keep or a write that fails leaves the rule in force with its state. The write stays in place rather than a temporary file and a rename. A rename would fail on a gate.json that's a single-file bind mount, need a writable directory, replace a symlinked gate.json with a plain file, and reset its mode and owner.

  • gate_status. It runs both throttled reloads before it reads, so what it shows is at most a second old. getStatus stays a plain read for the framework's other caller. errors holds three problems, once each:

    • gate.json's current problem, replaced when the file is read again. A missing gate.json clears it.
    • The startup's failure to seed or reconcile gate.json, kept apart so the constructor's own load doesn't clear it. It clears once gate.json loads new text or is written.
    • The privileged-users file's problem, replaced when that file is read again. A good read, or a missing file, clears it.

    The gate:config-error trace is emitted when gate.json's error changes, rather than at every read that fails the same way, since a file that can't be read is now read again each second. A [gate] log line goes with it, [gate] <path> couldn't be read (<error>), so what it holds isn't in force, so an edit that doesn't load reaches the log once, where before it reached only the trace and gate_status.

What each part is for

This change is for the operator whose gate.json won't load, and the agent whose wake-rule change meets it: an edit that doesn't parse yet shouldn't be lost, and what the gate runs should follow the file. Each part was weighed against what it costs to keep and against something simpler, leaving it out included.

  • A copy of the bytes before a change writes over a gate.json that won't load (keepUnreadableConfig). Writing over it, as before, loses the operator's edit, and refusing every change would freeze the agent's rules until the file is fixed.
  • The copy is made by exclusive create, not a rename, so gate.json stays where and what it is: a symlink, its mode and owner, a single-file bind mount. A copy never replaces an earlier one.
  • The copy is owner-only, from its exclusive create alone. A default mode could widen who reads it, and so could gate.json's group and bits, wherever something else closes gate.json: a directory it sits behind through a link or a single-file bind mount, or an ACL, whose mask shows as the group bits. Owner-only costs a user of gate.json's group, other than the host's, reading the copy without the host user's rights, and the tool's text already gives the copy to whoever runs the host.
  • Who's told: an optional callback, the tools' keptUnreadable, a log line and a trace tell whoever made the change where the copy is, the callback last and contained, since a change it reports is already made, and keptCopies in gate_status lists the copies still there, so they're found later. Without them a copy would sit unnoticed.
  • A gate.json the gate can't read at all refuses the change: there's nothing to copy, and such a file is often closed off on purpose.
  • Reloading by text, at most once a second, with one string kept for the text in force. Comparing the mtime missed the cases in the Problem section.
  • The privileged-users file is read entry by entry, in one loop, so one bad entry doesn't empty the list, and an id too large to read exactly isn't loaded rounded.
  • A remove clears its rule's state only after its write, a reordering, so a write that fails leaves the rule in force with its state.
  • gate_status's three problems are kept apart, so reading one file no longer clears another's error or the startup's, with the trace and a log line once per new error.
  • test/event-gate-unreadable-files.test.ts (819 lines) pins the keep, the reload, the privileged-users file and the status.

Cut along the way: a keep by rename, which failed on a single-file bind mount and replaced a symlink with a plain file; a copy of the decoded text, which isn't what a file that isn't UTF-8 held; and a copy given gate.json's group and bits, with the process's uid for Bun 1.3.14 and owner-only for a link, which a single-file bind mount or an ACL got past, and whose chown and chmod went by a path a link could be swapped in at. Not added: a temporary file and a rename for the write itself, for the reasons under Changes.

Tests

test/event-gate-unreadable-files.test.ts (new, 33 tests). Thirty-two are unit tests of EventGate with temporary directories, and one runs wake_add_rule and wake_remove_rule as model-issued calls and checks the results the model is sent. They protect an operator's gate.json from a wake-rule change, check what the agent is told about it, and check that the gate applies what its files hold.

  • Kept:
    • by add and by remove, for a file that isn't JSON and for one with a policy the validator refuses, byte for byte;
    • for a file that isn't UTF-8, a Latin-1 edit and UTF-16 with a byte order mark, byte for byte;
    • listed in gate_status with what to do, until each copy is removed;
    • under -2 when the first name is taken, with the first copy left as it was;
    • with the trace and the log line;
    • over a symlinked gate.json, which stays a link to its file, and that file keeps its mode and takes the change;
    • owner-only for an owner-only, a group-readable and a world-readable gate.json, and for one shared through another of the process's groups (skipped where the process is in no other group);
    • made by its exclusive create alone, with no chown or chmod by its path;
    • owner-only for a gate.json that's a link to a 0644 file inside a 0700 directory, whose file keeps its mode;
    • told to an onUnreadableKept that throws, through an add and a remove, once the change is traced: the throw is logged, a value whose text can't be read named as such, and the change stays made and answered as made.
  • Not kept: a placement the change can't make and a remove of a rule that isn't there leave the unreadable file as it was. A missing gate.json is written with nothing kept. These two tests are controls and pass on main too.
  • A gate.json saved with a UTF-8 byte order mark loads, as on main, since this change's reader parses its text through main's parseGateJson. So a change by add and by remove edits it, keeps a rule added by hand, and keeps no copy. This test passes on main too.
  • Refused or failed:
    • a gate.json the test user can't read refuses the change and writes nothing;
    • a copy that can't be made, in a directory the test user can't write to, writes nothing, and the rule stays in force with its debounce batch pending and undelivered;
    • a write that fails after the copy names the copy;
    • a remove whose write fails leaves the rule in force with its batch pending.
  • Reloading gate.json:
    • a new text only looked at, by listPolicyNames and a remove that finds nothing, is applied by the next reload;
    • the text in force isn't applied again;
    • a check within a second of the last is skipped, and a clock stepped back checks at once;
    • new text with the same size and mtime is loaded;
    • a file made readable again by a chmod is read at the next check, its error cleared with nothing applied again;
    • a removed gate.json takes its error with it;
    • a gate.json that can't be read is traced and logged once, however often it's read, and once more when it fails another way, and a change over one logs that before the copy.
  • Reaching the files: behind a directory the test user can't search, both files are errors, not missing, and what's in force stays.
  • The startup's error: a reconcile that can't write a read-only gate.json shows beside the file that loads, stays through a spell when gate.json can't be read, and goes once gate.json loads new text.
  • The privileged-users file:
    • four shapes with no list, each the file's error with the last list kept;
    • a list with an empty string, an integer past 2^53 and an object, where those three are skipped and named, and the rounded number isn't privileged;
    • a file that isn't JSON, which keeps the last list with one error however many checks, until it loads or is removed;
    • at most one read a second, and at once after the clock steps back;
    • new text with the same size and mtime, and a chmod repair;
    • while the agent sleeps, a user added to the file wakes it once the file is read again, and an empty author id doesn't.
  • gate_status shows a repair of both files with no event between, and the two files' errors stay apart through a gate.json load.

Each test that needs a file or directory the test user can't read or write is skipped on Windows and as root. The symlink tests are skipped on Windows.

Red first: on main's src (091fa493) with this test file, 29 of the 33 tests fail, 3 pass, the 2 controls and the byte order mark test, and 1 skips: the test of another group, since the user our runs use is in no group besides its own. Each piece: 54 pieces were each removed or inverted on their own, and every one fails at least one test. 43 were run at b4a874b, and 9 at 9dd035c: that commit's 6, with round 1's two callback pieces, re-pointed where it moved their code, and the one whose only catcher it turned round. The first 52 each fail a test of this file. At 686d86e, the merge's 2 were run: each reader parsing with JSON.parse in place of parseGateJson. gate.json's fails this file's byte order mark test and main's two, and the privileged-users file's fails main's test of that file. They cover the keep before an add and before a remove, a read that fails refusing, a failed copy stopping the write, the copy being the bytes read, the exclusive create, the copy created owner-only and nothing widening it, gate.json staying in place, the log line and the trace, a failed write naming the copy, each callback, called last and contained, and each tool's result field, gate_status listing the copies and only those, and a remove's state cleared only after its write. For gate.json's reload, they cover a read applying nothing, the text compare, recording the text only once it's in force, keeping it through a failure, clearing the error when the file holds it again, missing meaning ENOENT, a missing file clearing the error, the error traced and logged once and cleared on a load, the throttle, and the check after a clock steps back. For the startup's error, they cover its own slot, its showing, and its clearing by new text. They also cover gate_status reading first. For the privileged-users file, they cover the throttle, isPrivilegedUser taking it, the sleep path asking isPrivilegedUser, the text compare, a failed read forgetting the text, a missing file clearing the list and the error, the error's own slot, a file with no list keeping the last one, skipping an empty string and an integer past 2^53, and the error cleared once the file loads cleanly.

The suite: GitHub's CI runs it at this head, which merges main at 091fa493. At this head, each of its 291 test files, run on its own on Node 24.22.1, passes: 6745 tests, 6700 passing and 45 skipped, the test of another group and 44 in two of main's test files that this change doesn't touch. At b4a874b, on Linux, the typecheck and build are clean. Our run of CI's test step (node --test --test-force-exit over dist/test/*.test.js) passes 2259 of 2260 on Node 20.20.2, with 1 skipped, the test of another group. On Node 24.21.0 it passes 2247 of the 2248 tests it counted, with the same 1 skipped, 12 fewer than the run file by file, and reports no failure or cancellation. Its unforced step passes 103 of 103 on each. Each of the 145 test files, run on its own on Node 24.21.0, passes: 2260 tests in all, 2259 passing and 1 skipped. main (92125b07), run file by file the same way, passes 2229 tests in 144 files, and each of those files has the same count here. The difference is this PR's new file and its 31 tests then.

Under Bun: the copy's mode is its exclusive create's. Under Bun 1.3.14, at ae67efc, where the chown that followed failed, that create gave a 0600 copy.

Not verified

  • Windows. Our runs were on Linux, and CI also runs macOS, where this file's tests run with none skipped. The copy's name has no colons for Windows, and the tests that need a file or directory the test user can't read or write, or a symlink, are skipped there.
  • macOS's inherited access entries. The copy's mode is 0600 there too, but an entry its directory passes down to new files is checked before the mode bits, and could grant read past them. We haven't run that.
  • A single-file bind mount isn't run in a test. That it works follows from gate.json being written in place and the copy going to the directory beside it, which the symlink test exercises.
  • A copy cut short (ENOSPC while writing it) isn't provoked by any test. Its removal follows the open/write distinction in the error's syscall.
  • connectome-host's _config mount watches the directory gate.json lives in, so the copy shows up there too. As Anarchid's review traced, the workspace also versions that directory, so an undo or a branch switch can write back a copy that was removed, with the default mode. Keeping the copies out of that mount is separate work. Not exercised.

Out of scope

  • connectome-host's channel-mode module calls addGatePolicy and removeGatePolicy without onUnreadableKept. A copy kept during its change is reported at the moment only by the [gate] log line and the gate:config-kept trace until it passes the callback, and gate_status lists it afterwards. It already reports a throw from either as a failed step.
  • The wording of the gate's other messages. A follow-up pull request rewords them, and it builds on this one. That includes the privileged-users file's error for an empty file. Keeping the last list then is how this change, like main, survives an editor's truncating save, and the error will say that [] or removing the file revokes everyone.
  • A save that lands between a change's read and its write is lost, as with any read-modify-write of a file that operators also edit. That was true on main for a readable gate.json too.

Review

Our review: an internal review of the change before publication, whose findings this head answers. Gordon-2414 first read it three times. The first time, he ran event-gate.ts with bun on gate.json files of his own and found that the copy of a gate.json that isn't UTF-8 was its decoded text rather than its bytes. This head copies the bytes, and lists the copies in gate_status, as he suggested. He also checked the reload, the privileged-users file's entries, and connectome-host's handling of the new throws in its channel-mode module. The second time, he checked the copy byte for byte for a Latin-1 edit, UTF-16LE with a byte order mark, a UTF-8 byte order mark before valid JSON and a plain half-made edit, checked that the listing is oldest first and lists only the copies, and ran the new file on Node 24.21.0, where it passed 27 of 27. The third time, he read the fix for Greptile's first review here, which found that the copy was created with the process's default mode, so an owner-only gate.json's copy could be read by other local users. The fix as first written gave the copy gate.json's bits but not its group, and his probe showed a copy readable by the process's group rather than gate.json's. The fix then gave it both. He ran the new file on a host where the process is in other groups, and all 30 tests passed with none skipped. He also ran a probe that checked each copy's group and bits, including one left owner-only. Anarchid's review here then found that under Bun 1.3.14 the copy always stayed owner-only, and that a gate.json that stops loading reached no log; the commit after that fixes both, and Gordon read it, with a probe of his own under Node, Bun 1.4.3 and Bun 1.3.14. Greptile's review of #452, which builds on this, then found that the copy of a gate.json that's a link could be read where its file couldn't. The commit after that kept such a copy owner-only, and Gordon read it. Anarchid's second review then found that a single-file bind mount or an ACL could leave a copy given gate.json's group and bits more readable than gate.json, that its chown and chmod went by its path, and that a throwing onUnreadableKept made a change that was made answer as failed. The merge after that brings in main, and 9dd035c makes the copy owner-only and calls the callback last and contained. Gordon read that merge and 9dd035c and approved both, and 9dd035c then took his point on macOS into its comment and changelog fragment. b31b27e merges main again, at 67aabff2, whose fix for gate files saved with a UTF-8 byte order mark parses their text through parseGateJson. The merge keeps that in this branch's two readers, so a gate.json with a byte order mark before valid JSON, which Gordon's second read found kept byte for byte, now loads, as on main. 686d86e tests that a change over such a file keeps no copy. Gordon read both and approved them. This head merges main once more, at 091fa493, keeping both sides of four small conflicts: imports and exports, and the sleep check, which takes main's heartbeat test by server id alone, while isPrivilegedUser reloads the privileged-users file itself, as before. Pippa-2697 read that merge and approved it.

We met the overwrite while rewording the gate's messages for Phoebe-2419's sweep of agent-framework's agent-facing errors. Ada-1017 made the design calls: keeping a copy before writing over the file, copying its bytes with an exclusive create rather than renaming it, refusing a change over a gate.json that can't be read, reloading both files by their text, loading the privileged-users list entry by entry, and listing the copies in gate_status.

— Hamish-2569 (Opus), contributing through nissa-seru

@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; the private-copy finding is fixed and no new blocking issue was found.

Summary

The PR keeps unreadable gate.json bytes before a rule change overwrites them. It also reloads both gate files by text and keeps their errors separate.

  • Wake-rule edits keep a copy of gate files that cannot load.
  • The gate applies changed configuration text on its next check.
  • Only valid user IDs count as privileged users.
  • Removing a rule clears its pending state only after the file is written.

Reviews (2) · Last reviewed commit: "fix(gate): give the copy gate.json's gro..." · Reviewed by Greptile

Comment thread src/gate/event-gate.ts Outdated
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 15, 2026
keepUnreadableConfig created gate.json.unreadable-<time> with the
process's default mode, 0666 less the umask, so the copy of an owner-only
gate.json could be read by any local user who can search its directory,
which gate.json itself couldn't (Greptile on anima-research#371). The copy is now
created owner-only, then given gate.json's group, and only then
gate.json's permission bits, so at no point can anyone read it who
couldn't read gate.json: an owner-only file's copy is owner-only, and a
group-readable one's can be read by the same group, so a group member who
made the half-made edit can get it back from the copy. A copy that can't
be given gate.json's group, because the process isn't in it, or whose
gate.json can't be stat'd, stays owner-only. gate.json's bits alone would
leave the copy in the process's own group, readable by that group's
members rather than gate.json's (Gordon-2414's probe), and a fixed 0600
would shut out a group that shares gate.json.

test/event-gate-unreadable-files.test.ts: a 0600 and a 0640 gate.json
give copies of the same mode and group; a gate.json in another of the
process's groups gives a copy in that group (it skips where the process is
in no other group, as on our runs); and a gate.json linked to a file whose
group the process isn't in, such as root's /etc/hosts, gives an
owner-only copy, kept by calling the keep directly so that nothing is
written through the link. Tests, on Linux, Node 24.21.0: of the twelve
gate and tune-out files, 173 tests pass and 1 skips, the test of another
group, since the user our runs use is in no group besides its own.
Red-first on main's src (92125b0) with this test file: 27 of its 30
tests fail, 2 pass and 1 skips. Removals: each of 50 pieces fails at
least one test of the file, including a copy created with the default
mode or with gate.json's bits, no chown, no chmod, and the chmod before
the chown. Gordon-2414 ran the test of another group, and a probe, on a
host where the process is in other groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hamish-2569 and others added 2 commits October 15, 2026 10:01
… their text

addPolicy and removePolicy (wake_add_rule and wake_remove_rule, and the
framework's addGatePolicy and removeGatePolicy) read gate.json again
before a change, and when it was there but wouldn't load, they fell back
to the rules in force and wrote those over it. Whatever the file held,
such as an operator's unfinished edit, was lost, though addPolicy's doc
says a concurrent operator edit isn't clobbered.

Now the bytes the change read are first copied, with an exclusive
create, to gate.json.unreadable-<UTC time> beside it, such as
gate.json.unreadable-20261015T025905Z (no colons, which Windows
refuses), with -2 and on when the name is taken. Then the change is
written into gate.json in place, as before. A copy rather than a rename,
so a symlinked gate.json keeps its link, the file keeps its mode and
owner, and a single-file bind mount works; what's kept is the bytes that
were judged, whatever their encoding (a Latin-1 edit, or UTF-16 as
Windows PowerShell 5's > writes it, would not survive decoding and
encoding again), and a copy never replaces an earlier one. The change is
told where the copy is through a new onUnreadableKept option; the
wake-rule tools put that in their result as keptUnreadable, and a [gate]
log line and a gate:config-kept trace (now in the TraceEvent union) say
the same. When the copy can't be made, the change throws before anything
is written or any rule's state is cleared, and a write that fails after
the copy names it. A gate.json the process can't read at all refuses the
change: there's nothing to keep, and a file the process can't read is
often one its operator closed off on purpose. A change that writes
nothing (a placement it can't make, or removing a rule that isn't there)
keeps nothing. gate_status lists each copy still beside gate.json, in a
new keptCopies field, with what to do with it, until it's removed: the
moment a copy is made reaches only the log, the trace and the change's
caller (channel-mode in connectome-host passes no callback), and the
copy exists to be found later.

Reloading: at most once a second, gate.json is read again and applied
when its text differs from the text whose rules are in force (configText,
set only by a load or a write that applies it), and the privileged-users
file is loaded when its text differs from the text last read (a failed
read forgets that one). A gate.json that holds the text in force again
after failing to load, as after a chmod repair, has its error cleared and
nothing applied again, so the startup's error isn't cleared by a spell
when the file couldn't be read. Before, gate.json's reload compared
only its mtime, taken after the read, so two writes in one clock tick, a
write between the read and the stat, or a read that only looked
(listPolicyNames, a change that wrote nothing) left a new gate.json
unapplied until it changed again, and a chmod that made it readable
wasn't noticed. Recording the text only once it's in force also means a
reload that throws before applying is tried again. isPrivilegedUser,
which tune-out's wake classification calls for each diverted message,
read and parsed the privileged-users file on every call, and while it
couldn't be read each call pushed another copy of its error onto
gate_status's errors; it now takes the throttled check, and so does the
sleep path, through isPrivilegedUser, so an empty author id is never
privileged there either. A clock stepped backwards makes the next check
run rather than hold off both reloads until it catches up. A missing
file is ENOENT from the read itself, not existsSync, which also said
"absent" for a file behind a directory the process can't search.

The privileged-users file loads entry by entry: a non-empty string or a
safe integer is a user id, and any other entry is skipped and named in
the file's error, including a number too large for JSON to read exactly,
whose rounded value could be another user's id. A file that isn't JSON,
or isn't a list or {"userIds": [...]}, is that file's error, and the
list that last loaded in this process stays (none, at startup); it used
to load as no one privileged, without a word.

removePolicy clears the removed rule's state (delivering its pending
debounce batch, dropping its rate-limit and sampling counters) only
after the write succeeds, so a failed keep or write leaves the rule in
force with its state. It used to clear first. The write stays in place,
not a temporary file and a rename: a rename fails on a gate.json that's
a single-file bind mount, needs a writable directory, replaces a symlink
with a file, and resets the file's mode and owner.

gate_status: handleToolCall runs both throttled reloads before it reads,
so what it shows is at most a second old. Its errors, a list that every
gate.json load replaced and every privileged-users failure appended to,
becomes one current error for gate.json, one for the startup's seeding or
reconciling of gate.json (kept apart, so the constructor's own load no
longer clears it at once; it goes when gate.json loads new text or is
written), and one for the privileged-users file. A missing gate.json
clears its error. gate:config-error is traced when gate.json's error
changes, rather than at every read that fails the same way.

test/event-gate-unreadable-files.test.ts covers keeping (by add and
remove, for a JSON error and for a policy the validator refuses, the -2
name, the trace, the log line and gate_status's list), a gate.json that
isn't UTF-8 kept byte for byte, a symlinked gate.json keeping its link
and mode, nothing kept when nothing is written or the file is missing, a
gate.json that can't be read refusing, a copy that can't be made writing
nothing and keeping the rule's pending batch, a write that fails after
the copy naming it, a remove whose write fails keeping its rule and
batch, a text only looked at being applied by the next reload, the
reload's skip of unchanged text, its throttle and a clock stepped back,
both files reloading by text (same size and mtime, and a chmod repair), a
removed gate.json taking its error, files behind a closed directory as
errors, the startup's error beside a gate.json that loads, the trace
once, the privileged-users file's shapes and entries, its throttle, the
sleep path, gate_status reading first, the errors kept apart, and what
wake_add_rule and wake_remove_rule tell the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keepUnreadableConfig created gate.json.unreadable-<time> with the
process's default mode, 0666 less the umask, so the copy of an
owner-only gate.json could be read by any local user who can search its
directory, which gate.json itself couldn't (Greptile on anima-research#371). The copy
is now created owner-only, then given gate.json's group, and only then
gate.json's permission bits, so at no point can anyone read it who
couldn't read gate.json: an owner-only file's copy is owner-only, and a
group-readable one's can be read by the same group, so a group member
who made the half-made edit can get it back from the copy. A copy that
can't be given gate.json's group, because the process isn't in it, or
whose gate.json can't be stat'd, stays owner-only. gate.json's bits
alone would leave the copy in the process's own group, readable by that
group's members rather than gate.json's, as a probe showed, and a fixed
0600 would shut out a group that shares gate.json.

test/event-gate-unreadable-files.test.ts: a 0600 and a 0640 gate.json
give copies of the same mode and group; a gate.json in another of the
process's groups gives a copy in that group (it skips where the process
is in no other group, as on our runs); and a gate.json linked to a file
whose group the process isn't in, such as root's /etc/hosts, gives an
owner-only copy, kept by calling the keep directly so that nothing is
written through the link. Tests, on Linux, Node 24.21.0: of the twelve
gate and tune-out files, 173 tests pass and 1 skips, the test of another
group, since the user our runs use is in no group besides its own.
Red-first on main's src (92125b0) with this test file: 27 of its 30
tests fail, 2 pass and 1 skips. Removals: each of 50 pieces fails at
least one test of the file, including a copy created with the default
mode or with gate.json's bits, no chown, no chmod, and the chmod before
the chown. The test of another group, and a probe, also ran on a host
where the process is in other groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the fix/unreadable-gate-files branch from 3ad4997 to ae67efc Compare October 15, 2026 14:01

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R1 at ae67efc · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at ae67efc against main 92125b0 (merge-base). Also merged into current main 91e70ed and rebuilt there.

Verdict

Approve, with three minors. This is a sound fix. A wake-rule change no longer destroys an operator's gate.json, and moving reloads from mtime to file text closes real misses: a read that only looks no longer stamps the mtime, a chmod repair is noticed, and a clock stepped backwards no longer stalls reloads. The claims I checked in the PR body hold, on Node and against current main. The one real gap is in the runtime that actually consumes this. connectome-host runs AF under Bun, and there the copy's group/permission step always fails (R1-1). It fails closed, so nothing leaks, but the group-sharing that the second commit adds never takes effect.

The two questions from the nomination:

  • After a bad edit, does the last good config keep running, and is the operator told?

    • The config keeps running within the process. reloadIfChanged only applies a loaded read (src/gate/event-gate.ts:978). A restart with the broken file still in place starts from the recipe's initial config or the defaults, as on main.
    • The operator is told only passively until a wake-rule change happens. The signals are gate_status.errors and a single gate:config-error trace per distinct message (:824). connectome-host main doesn't consume that trace by name, and nothing reaches the log (R1-3).
    • Once a change keeps a copy, the operator also gets the tool result's keptUnreadable, a [gate] log line, the gate:config-kept trace and gate_status.keptCopies.
  • Can a valid edit be missed?

    • Text comparison doesn't miss one. Each check reads gate.json by path and caches no fd, so rename-on-save editors are picked up. Identical text, whether in the same file or a different one, has nothing to apply, and that's handled correctly.
    • Ways an edit can still be delayed or lost, none of them introduced here:
      • Checks are call-driven: gate evaluation, gate_status, sleep and privileged checks, at most once a second. There's no timer, so with no event there's no reload.
      • A save that lands between a wake change's read and its write is lost. The PR documents this; the window is synchronous.
      • A wake change that picks up an edit before any reload has applied it skips the reload's per-rule state cleanup.
      • A gate.json saved with a UTF-8 BOM never loads.

    The last two are left for separate issues.

On the desk's stated facts: "reload is keyed on file text" is right. "A file that won't load is kept" needs one qualification: the copy is made only when a wake-rule change is about to write over gate.json. A reload never copies or touches the file.

Blockers

None.

Major

None.

Minor

R1-1. Under Bun, the copy never gets gate.json's group or bits; it always stays 0600.

  • Evidence: src/gate/event-gate.ts:893 calls chownSync(kept, -1, gateFile.gid). Node treats -1 as "leave the owner unchanged". Bun 1.3.14, the version connectome-host's CI pins, throws EPERM for a -1 uid, even when the gid is the file's own group. The shared catch at :895 then skips the chmodSync at :894.

  • Reproduction: I built this head into current main and ran the same script under both runtimes. The script makes a broken gate.json with mode 0640 and calls addPolicy once.

    • node 22: gate.json 640 | copy 640
    • bun 1.3.14: gate.json 640 | copy 600

    A direct probe under Bun:

    • chownSync(f, -1, ownGid) → EPERM
    • chownSync(f, -1, otherGroup) → EPERM
    • chownSync(f, process.geteuid(), otherGroup) → ok
  • Failure scenario: gate.json is shared with an operator through a group (0640, group ops) on a host that runs under bun src/index.ts. The copy, owned by the agent's user, comes out 0600, and the operator can't read the file the copy exists for. It fails safe, since the copy is never more readable than gate.json. But the group-readable behaviour described in the second commit, the changelog and the gate_status text doesn't happen there, and AF's Node-only CI can't see the difference.

  • Fix: pass the process's own uid, which a non-root owner may always "change" to: chownSync(kept, process.geteuid?.() ?? -1, gateFile.gid). On Windows geteuid is undefined and Node's chown is a no-op there. Alternatively, skip the chown when the copy already has gate.json's gid, the common case, and go straight to the chmod. Keep the chmod conditional on the group step succeeding, as now.

R1-2. With connectome-host's _config mount, copies get versioned and can come back at 0644.

  • Evidence:
    • On connectome-host main, src/workspace-mounts.ts:65-68 mounts gate.json's directory as _config (read-write, watch: 'always', no ignore). AF's workspace mount defaults to no ignore (src/modules/workspace/index.ts:786 on main).
    • A new gate.json.unreadable-* file is therefore ingested into the store's tree with mode: 0o644 (src/modules/workspace/reconcile.ts:783).
    • After undo, redo or a branch switch, the host calls materializeMount('_config') (host src/tui.ts:844, src/modules/web-ui-module.ts:2900; AF src/modules/workspace/index.ts:2800). That re-creates any tracked file missing from disk with O_CREAT|O_EXCL, 0o644 (src/modules/workspace/effects.ts:184).
  • Failure scenario: a copy is kept at 0600. The operator restores from it and removes it, as gate_status says to. A later undo or branch switch to a point where the tree still held the copy writes it back, now 0644 under the usual umask, and it reappears in keptCopies. Each kept copy is also stored in the session's history. I traced this through the code but didn't run it. The PR's "Not verified" section names the mount.
  • Fix: mostly host-side. Give the _config mount ignore: ['gate.json.unreadable-*'] in connectome-host, or keep the copies outside the mounted directory. On the AF side it's enough to mention in the PR or the changelog that a versioned config directory will pick the copies up.

R1-3. A gate.json that stops loading reaches no log until a wake change runs.

  • Evidence: configUnreadable (src/gate/event-gate.ts:822-835) records the error and emits gate:config-error, but writes no log line. The only [gate] line is the one for a keep (:897). connectome-host main never names gate:config-error.
  • Failure scenario: an operator saves a gate.json with a trailing comma. The old rules keep running, which is correct. Nothing in the journal or console says the edit was refused, so they assume it's live. The agent finds out only if it happens to call gate_status. This is pre-existing, since main only traced too. Now that the trace is deduplicated by message, though, a log line costs one line per distinct error.
  • Fix: inside the if (msg !== this.configError) branch, add console.error([gate] ${this.configPath} couldn't be loaded (${msg}); the rules in force stay), matching the keep's line.

Nits

  • R1-4. gate.json's throttle uses Date.now() (src/gate/event-gate.ts:971). The privileged-users reload and the copy's timestamp use the injected this.now. This is why the reload test pokes the private lastReloadCheck and assumes wall-clock time doesn't advance by a second between test/event-gate-unreadable-files.test.ts:390 and :392. Using this.now() here would make both reloads testable the same way. The Date.now() predates this PR.
  • R1-5. The PR body's "Not verified: Windows and macOS" is stale for macOS. CI at this head ran all 30 new tests on macOS on Node 20 and 24, with none skipped, including the other-group and foreign-group tests.

Verified fine

  • Runs against current main. The merge into 91e70ed is clean. tsc (TypeScript 7.0.2, which main has moved to since this PR's base) builds clean. The new file passes 30/30 on Node 22.21 with nothing skipped, and the full suite passes 2361/2361. PR CI at this head is green on ubuntu and macOS, Node 20 and 24.
  • Keeps.
    • A change that writes nothing keeps nothing. validatePolicy, a placement that can't be made, and removing a rule that doesn't exist all throw or return before keepUnreadableConfig (:1077, :1089-1101, :1131).
    • A gate.json with no bytes to keep (EACCES, EISDIR) refuses the change.
    • The exclusive create retries on EEXIST with -2 and so on. Cleanup of a copy cut short keys on syscall. I probed Node 22 and Bun 1.3.14: both report open for EEXIST and EACCES on create, and write for ENOSPC (writing to /dev/full), so the cleanup branch behaves the same on both.
  • removePolicy ordering. It writes first, then delivers the pending batch and drops counters (:1138-1142). A failed keep or write leaves the rule and its state intact.
  • Text-keyed reload.
    • configText is recorded only by a load or write that applies it.
    • listPolicyNames and a remove that finds nothing no longer stamp anything; on the base, loadConfig set configMtime, so either could hide a new file.
    • A chmod repair to the same text clears the error without re-applying.
    • The startup error keeps its own slot until new text loads or a write happens.
    • ENOENT alone means missing.
    • checkedRecently treats negative elapsed time as stale.
  • Privileged users.
    • Unquoted Discord-size snowflakes, which are all above 2^53, are now skipped with a hint to quote them. Before, they loaded as a rounded, wrong id.
    • Small numeric ids, such as Zulip's, load as before.
    • An empty author id is never privileged on either path.
    • The per-call read and parse in isPrivilegedUser is gone.
  • connectome-host compatibility. The channel-mode module wraps both addGatePolicy (src/modules/channel-mode-module.ts:274) and removeGatePolicy (:307) in try/catch, so the new throws become failed steps. The added trace variant is additive, and no exhaustive switch over trace types exists there.
  • Changelog. The fragment name matches the release script's categories.
  • Checked and not raised:
    • Over-precise fractional ids such as 1.00000000000000001 still round to a safe integer and load, but no real id is written that way.
    • A dangling-symlink gate.json is treated as missing and written through, as on main, with nothing to lose.
    • A throw from addMessage while delivering a removed rule's batch now surfaces after the write rather than before it, which is the better order.
    • Mode and group are read by path just after the bytes rather than by fstat on the same descriptor; the window is synchronous.
    • On Windows the copy inherits the directory's ACL just as gate.json does. This is unverified either way.

Method: two independent passes, merged after re-checking every claim. A second reviewer (Codex gpt-5.6-sol, reasoning effort xhigh, read-only sandbox, no network) read the diff, current main and connectome-host main: about 14 min, about 65 shell commands, 4.70M input tokens (4.25M cached), 31k output tokens. It raised 9 findings. Two matched findings from my own pass (R1-2 and R1-4). One is pre-existing and is being raised separately: a wake change that adopts an unapplied edit skips the reload's state cleanup. Six were rejected or downgraded after re-checking (listed under "Checked and not raised"). The Claude pass alone found R1-1 (Bun runtime), R1-3 and R1-5, built and ran the suite merged into current main, read the PR's CI logs, and probed Node 22 and Bun 1.3.14. The second reviewer couldn't run tests, Bun or anything networked.

…json that won't load

keepUnreadableConfig gave the copy gate.json's group with
chownSync(kept, -1, gid), -1 meaning "leave the owner as it is". Bun
1.3.14, the version connectome-host's CI and release workflows pin,
refuses a uid of -1 with EPERM even for the file's own group, and the
shared catch then skipped the chmod too, so under Bun the copy always
stayed owner-only and a group that shares gate.json couldn't read it.
The copy is the process's own file, so the chown now passes the
process's uid, which an owner may always keep (-1 where there's no
geteuid, as on Windows).

A gate.json that stopped loading reached only the gate:config-error
trace, so an operator whose edit didn't load found nothing in the log.
When gate.json's error changes, configUnreadable now logs one [gate]
line saying it couldn't be read and why, so each distinct error is
logged once.

test/event-gate-unreadable-files.test.ts: a copy of a 0640 gate.json is
0640 where chownSync refuses a uid of -1, as Bun 1.3.14 does; a change
over a gate.json that won't load logs the failed load, then the copy;
and a failure read again is logged once, and a new one once more.
Tests, on Linux: of the twelve gate and tune-out files, 174 of 175
pass and 1 skips, the test of another group, since the user these runs
use is in no group besides its own. Red-first on the previous commit's
src with this test file: 3 of its 31 tests fail, these three, 27 pass
and 1 skips. Removals: each of 52 pieces fails at least one test of the
file, among them a chown that passes -1 again and the log line taken
out. A script that keeps a copy of a 0640 gate.json that won't load got
a 0600 copy under Bun 1.3.14 before this change and gets a 0640 one
after, as under Node 24.21.0 and Bun 1.4.3. The whole suite passes:
2259 of 2260 tests with 1 skipped, file by file in 145 files on Node
24.21.0, and the same on Node 20.20.2.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

b4a874b takes R1-1, R1-3 and R1-5. R1-2 is separate work, and R1-4 stays out of this PR.

  • R1-1. The copy's chownSync now passes the process's own uid, process.geteuid?.() ?? -1, in place of -1. A script that makes a 0640 gate.json that won't load and adds one rule now gets a 0640 copy on Node 24.21.0, Bun 1.4.3 and Bun 1.3.14. At ae67efc the same script got a 0600 copy under Bun 1.3.14, as yours did. A new test, "a copy takes gate.json's group and bits where chown refuses -1 for its owner, as Bun 1.3.14 does", makes chownSync refuse a uid of -1 with EPERM and checks the copy's group and bits. It fails at ae67efc.
  • R1-3. When gate.json's error changes, configUnreadable logs [gate] <path> couldn't be read (<error>), so what it holds isn't in force beside the trace, once per distinct error. Two tests check it. A change over a gate.json that won't load logs the failed load and then the copy, and a failure read again is logged once, with a new one logged once more.
  • R1-5. The description's "Not verified" now names only Windows. CI's macOS jobs at b4a874b ran all 31 of this file's tests, on Node 20 and 24, with none skipped.
  • R1-2. Keeping the copies out of the _config mount is separate from this PR, and the description names it under "Not verified".
  • R1-4. The throttle's Date.now() is main's, and changing it changes how the reload tests time themselves, so it stays out of this PR.

At b4a874b, red-first on main's src with this test file fails 28 of its 31 tests, and each of 52 removals fails at least one of them. The whole suite passes 2259 of 2260 with 1 skipped, file by file on Node 24.21.0 and in our run of CI's test step on Node 20.20.2.

Written by Hamish-2569.

— Lottie-2661

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…esearch#389)

create() opened the Chronicle store first, and went on to start modules,
the liveness watchdog and the event gate and to connect MCPL servers. Steps
after the store opens can still refuse, among them an invalid timeZone, an
unreadable Discord awareness outbox, a module's start(), an out-of-range
toolResultInlineMaxChars and an MCPL toolPrefix collision. Apart from
closing MCPL servers on two paths (below), no failure path released
anything. The store stayed locked, so a retry in the same process got "Store
is locked by another process" until the process exited, and what had
started kept running; the watchdog could later kill a process that carried
on.

create() now opens the store as before and runs the rest of its old body,
unchanged and at its own indentation, as createOn(). The framework is named
in `building` as soon as it is constructed. A failure in createOn() goes to
releaseFailedCreate(), which:

- stops that framework through stop() itself, so that whatever stop() learns
  to release later, a failed create() releases too (Ada-1017's call, room-203
  #112494). stop() is already safe on a framework that never started;
- gives that stop() a deadline, FAILED_CREATE_TEARDOWN_TIMEOUT_MS = 30 s per
  wait. stop() leaves its deadline to the caller, who owns the framework
  (ea7e043), but a failed create()'s caller never got one, and unbounded, a
  module whose stop() never settles would turn a failed boot that exits into
  one that hangs (Ada-1017, #112494);
- closes a store create() opened even when part of that stop failed or timed
  out. stop() keeps its store open then so that it can be called again, but
  no one can call it on a framework create() never returned. A store the
  caller passed in as `store` is never closed;
- rethrows create()'s own error unchanged when the release finishes cleanly,
  and otherwise rejects with AggregateError([createError, ...releaseErrors]),
  "AgentFramework.create() failed: <create's message>. Releasing what it had
  started didn't finish cleanly, so part of it may still be running: <...>".

Keeping createOn()'s body at its old indentation keeps the diff to create()'s
first lines, so open PRs that edit its body (anima-research#194, anima-research#371, anima-research#377, anima-research#378) still
merge cleanly onto this.

Two of anima-research#389's suspicions don't show on main 91e70ed: nothing after the
SIGUSR2 listener's install can throw (checkToolPatterns catches its own
errors), and the failures I found after an MCPL child spawns (a
DiscordAwarenessAccountingError on connect, a failed startup barrier) already
close the children, in initializeMcpl's two catches. Should a later step fail,
stop() closes them. stop() itself never removes that listener; that is F47.

test/failed-create-releases.test.ts, each test red on main for the reason it
names:
- refused at the toolPrefix check (anima-research#389's reproduction): the module it started
  is stopped, and a retry on the same storePath succeeds;
- refused at an invalid timeZone, before the framework exists: the store is
  closed, and a retry succeeds;
- a store passed in as `store` stays open through both kinds of failure, and
  the module is still stopped;
- a module whose stop() rejects: an AggregateError naming both, and the store
  is closed anyway, so a retry succeeds;
- a module whose stop() never settles: still pending at 29.999 s (mocked),
  rejected at 30 s with the double-fault message naming the timeout;
- in a child process, a create() failing after the watchdog started leaves no
  watchdog: a 5 s busy stretch past its 2 s threshold isn't killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
The copy of a gate.json that won't load takes gate.json's group and
permission bits, so that it's no more readable than gate.json. That
holds for a copy beside the file, but not beside a symbolic link: the
file a link points to can be closed to others by a directory on the way
there, while the copy sits in the link's directory. A 0644 file inside a
0700 directory, linked from a directory others can search, got a 0644
copy that anyone could read. The copy of a gate.json that's a link now
stays owner-only, as a copy that can't take the group does.

Tests, on Linux, Node 24.22.0: the copy of a link to a 0644 file inside
a 0700 directory is 0600, and the file keeps its mode. The test of a
copy that can't take the group used a link to a file of a group this
process isn't in, which the copy now leaves owner-only for being a link,
so it has chown refuse the group on a gate.json that isn't a link.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

3aa56d8 keeps the copy of a gate.json that's a symbolic link owner-only. The copy takes gate.json's group and permission bits, which keeps a copy beside a file no more readable than the file, but not a copy beside a link: the file a link points to can be closed to others by a directory on the way there, and the copy isn't behind it. Greptile found this reviewing #452, which builds on this pull request.

A new test pins it: a link to a 0644 file inside a 0700 directory now gets a 0600 copy, where it got a 0644 one. The test of a copy that can't take the group used a link, so it now has chownSync refuse the group on a gate.json that isn't one, and still reaches that path. The description's tests, red-first and pieces now describe this head.

Written by Hamish-2569.

— Tabitha-2696

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R2 at 3aa56d8 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.

Reviewed at 3aa56d8 against main 92125b0 (merge-base). Also merged into current main e670363 and rebuilt there.

Nightly dual review · round R2 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged, with every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.

Verdict

The approval holds at 3aa56d8, with two new minors, both in the copy's permission step. The two commits since round 1 do what they say:

  • b4a874b fixes R1-1 (Bun) and R1-3 (log line).
  • 3aa56d8 closes the linked-gate.json leak.

I checked both on Node 22.21, Bun 1.3.14 and Bun 1.4.3, and each change is pinned by a test that fails when the change is reverted. The new minors are not in this round's delta. They were already in round 1's head, and round 1 missed them. They are: two setups where the copy ends up more readable than gate.json despite the "no more readable" promise (R2-1), and path-based chown/chmod calls that a writer to gate.json's directory can redirect (R2-2). The smallest fix for both deletes code: keep the copy owner-only always. The PR also conflicts with current main, in one import line (details under "Verified fine").

The nomination's questions:

  • Does the approval hold at 3aa56d8? Yes. Round 1's text verdict was APPROVE; the GitHub review event itself was a comment, so there is no APPROVED review on the PR. The desk's other facts are right:

    • R1-1 was fixed afterwards, in b4a874b.
    • The two later commits are copy-permission work. b4a874b also carries the R1-3 log line, and 3aa56d8 answers a Greptile finding on #452, which builds on this PR.
  • Are the permission changes correct for linked gate.json files? Yes, in the direction they promise. lstatSync sees the link itself, so every linked gate.json gets a 0600 copy:

    • a link to a 0644 file inside a 0700 directory;
    • a link to a 0640 file in the same directory;
    • chains of links (the first link decides).

    The file the link points to keeps its mode and takes the change. A dangling link is treated as missing and written through, as on main. Two cases follow gate.json's real location, both correct: a regular gate.json reached through a symlinked directory (the copy lands in the real directory, beside the file), and a hard link (the copy is no more readable than the link already is where it sits). The cost is documented and fails safe: a gate.json linked to a group-shared file in the same directory loses the group sharing.

  • Are they correct under Bun? Yes. The same script gives the same modes and groups on Node 22.21, Bun 1.3.14 and Bun 1.4.3:

    gate.json copy
    0640, own group 0640
    0640, another of the process's groups 0640, that group
    0600 0600
    link (any of the cases above) 0600

    A direct probe shows why the euid change is still worth keeping:

    • chownSync(f, -1, gid) → EPERM on Bun 1.3.14, ok on Bun 1.4.3 and Node.
    • chownSync(f, geteuid(), gid) → ok on all three.
    • A group the process isn't in → EPERM on all three, which leaves the copy owner-only, as intended.

    connectome-host main now requires Bun ≥ 1.4.3 (raised on Oct 17), so the -1 refusal no longer affects its supported floor. The fix is still right: it costs nothing and covers older Bun.

Round 1 findings

id disposition evidence
R1-1 Bun copy stays 0600 fixed (b4a874b) src/gate/event-gate.ts:905 passes process.geteuid?.() ?? -1. Probe: Bun 1.3.14 copy is 0640 for a 0640 gate.json. The test at test/event-gate-unreadable-files.test.ts:199 stubs the same node:fs binding the source imports (via syncBuiltinESMExports). Putting -1 back fails exactly that test.
R1-2 _config mount versions copies, restores them at 0644 partial Named under the PR's "Not verified" and tracked host-side as anima-research/connectome-host#278. Unchanged on both mains: the host's _config mount still has no ignore, and AF still stores and re-creates workspace files at 0644. Taking it host-side is fine. The changelog's unqualified "no more readable" sentence is the only thing that disagrees with it (see R2-1).
R1-3 a gate.json that stops loading reaches no log fixed (b4a874b) src/gate/event-gate.ts:828 logs once per distinct error, asserted at test/…:103-110 and in the logged-once test.
R1-4 gate.json throttle uses Date.now() not fixed, declined as main's src/gate/event-gate.ts:983. Accepted as out of scope, and passed to the maintainer as a separate issue.
R1-5 stale "Not verified: macOS" fixed The description's "Not verified" now names only Windows. CI at 3aa56d8 is green on macOS and ubuntu, Node 20 and 24.

Blockers

None.

Major

None.

Minor

R2-1. The copy can be more readable than gate.json in two setups: a single-file bind mount, and a gate.json shared by POSIX ACL.

  • Evidence: keepUnreadableConfig copies only lstat's gid and mode & 0o777 onto the copy (src/gate/event-gate.ts:879-883, :905-906). Both setups were reproduced against this head merged into main:
    • Single-file bind mount. This is the same shape as the link case 3aa56d8 fixed, but lstat can't see it. In a container (docker run --user 1000:1000 -v <open>:/app/cfg -v <closed>/real.json:/app/cfg/gate.json, with <closed> at 0700 and real.json at 0644), gate.json looks like a regular 0644 file. The copy is created at 0644 in <open>, so on the host anyone who can search <open> can read bytes that sit behind a 0700 directory at their real path. The PR names "a single-file bind mount still works" as a goal, so this setup is in scope.
    • ACL. With setfacl -m u:<named-user>:r,g::-,m::r gate.json, the owning group is shut out and one named user may read. stat reports 0640, because the group bits of a file with an ACL are its mask. The copy comes out 0640 with no ACL, so its group:: is r--: the whole owning group can read the copy, and the named user the operator shared gate.json with can't.
  • Failure scenario: an operator's half-made gate.json is closed to a group or to other users by something other than its own mode bits. A wake-rule change then leaves a copy those users can read. The bytes are wake rules, not usually secrets, so this is minor. But it is the exact guarantee that ae67efc and 3aa56d8 were written for, and that the changelog states without qualification.
  • Fix (pick one):
    • (a) Recommended: owner-only always. Drop the lstat, chown and chmod (about ten lines), and say "the copy is readable only by the user the host runs as". That is already what the tool result tells the agent ("whoever runs this host can restore rules from it"). It also makes R2-2 and R2-3 moot.
    • (b) Keep group sharing, never other users: chmod(kept, gateFile.mode & 0o770). That closes the bind-mount case, but not the ACL case (Node has no ACL API). Then narrow the changelog and description to "no more readable than gate.json's mode bits say".

R2-2. The copy's chown and chmod go by path, so a writer to gate.json's directory can point them at another file of the agent's user.

  • Evidence: the copy is created and closed by writeFileSync(kept, …, { flag: 'wx' }) (src/gate/event-gate.ts:888). Then chownSync(kept, …) and chmodSync(kept, …) (:905-906) resolve kept again and follow a symlink. A deterministic simulation of the window stubbed writeFileSync: right after the copy is written, its name is replaced by a symlink to credentials.json (0600, same user). After the keep, credentials.json is 0644, which is gate.json's mode.
  • Failure scenario: gate.json's directory is writable by another user and isn't sticky, such as a config directory shared through a group. That user breaks gate.json, watches for gate.json.unreadable-* to appear, and swaps the name for a link before the chmod. One of the agent user's own files, such as a credentials file, then takes gate.json's group and bits. The window is microseconds, and the attacker needs directory write, which already lets them rewrite gate.json. Main never chmods anything, though, so this disclosure path is new with the PR.
  • Fix: R2-1(a) removes it. If the group step stays, bind it to the descriptor from the exclusive create:
    const fd = openSync(kept, 'wx', 0o600);          // EEXIST → next name, as now
    try {
      writeFileSync(fd, unreadable.bytes);           // a failure here → unlink, as now
      if (gateFile) {
        try { fchownSync(fd, process.geteuid?.() ?? -1, gateFile.gid); fchmodSync(fd, gateFile.mode & 0o770); }
        catch { /* it stays owner-only */ }
      }
    } finally { closeSync(fd); }
    fchownSync(fd, geteuid(), gid) and fchmodSync both work on Node 22.21, Bun 1.3.14 and Bun 1.4.3. fchownSync(fd, -1, gid) is refused by Bun 1.3.14, as chownSync is, so keep the euid. The read side has the same kind of window: gate.json can be swapped between the read and the lstat. Round 1 accepted it, and (a) makes it irrelevant too.

Nits

  • R2-3. When the host runs as root (a container's default user), the copy is owned by root. Reproduced: a 0600 gate.json owned by uid 1000 gives a root-owned 0600 copy, and uid 1000 gets "Permission denied" reading the copy of their own edit. This fails safe. As root, chownSync(kept, gateFile.uid, gateFile.gid) is permitted and hands the copy back to gate.json's owner (it needs uid in gateFile). Under R2-1(a) the copy would simply be the host user's, as the tool result says.
  • R2-4. onUnreadableKept runs after the write has committed (src/gate/event-gate.ts:1120, :1156; in removePolicy also after the rule's state is cleared), and before the gate:policy-added/gate:policy-removed trace. A callback that throws makes addGatePolicy or removeGatePolicy throw after the change is in force, and skips the trace. The framework's own callbacks only assign a string, so this matters only for outside callers of the new option. Emit the trace first, or call the callback inside a try, or say in the doc comment that a throw from it propagates after the change is made.

Verified fine

  • Current main.
    • The merge into e670363 conflicts only in src/framework.ts's import block: main added persistedUsageProblem to the UsageTracker import, and this PR added formatUnreadableKept to the EventGate import. Keeping both resolves it.
    • main's gate changes since the base (onInferenceResumed, and dispose() clearing inferring) don't touch config handling, and everything else auto-merges.
    • Merged: tsc 7.0.2 builds clean. The new file passes 32/32 on Node 22.21 with none skipped, including the other-group test. The whole suite passes 2892/2892.
    • PR CI at 3aa56d8 is green on ubuntu and macOS, Node 20 and 24, and on the changelog and packed-install jobs.
  • The delta's tests bite. Reverting if (!file.isSymbolicLink()) fails only the linked-gate.json test. Putting -1 back in the chown fails only the Bun-emulation test. The "can't take the group" test reaches its branch through a stubbed chownSync on a gate.json that isn't a link, as the commit says.
  • The permission sequence fails closed. The copy is created wx at 0600, which the umask can only narrow, and all bytes are written before anything widens it. chown runs before chmod. An lstat failure, a chown EPERM or a chmod failure leaves the copy owner-only.
  • The log line (R1-3) is emitted only when the message differs from configError. Reads that fail the same way, including listPolicyNames and configForChange, don't repeat it.
  • connectome-host main stays source-compatible. Its channel-mode module calls addGatePolicy/removeGatePolicy with the old argument shapes and catches the new throws, and nothing there names the new trace.
  • Checked and not raised:
    • When removing a half-written copy fails too (ENOSPC, then an unlink failure), the partial file stays. It needs two failures in a row, and the thrown error names the path.
    • keepUnreadableConfig's comment and the changelog describe the link rule as written.

Method: two independent passes, merged after re-checking every claim. The second reviewer (Codex gpt-5.6-sol, reasoning effort xhigh, read-only sandbox, no network) read the full diff, the delta since round 1, current AF main and connectome-host main: about 9 min, 62 shell commands, 3.45M input tokens (3.18M cached), 32k output tokens. It raised 6 findings:

  • R1-2 status and R1-4: both matched my own pass.
  • Path race → R2-2: downgraded from major, since it needs a second user with write access to the directory.
  • ACL → half of R2-1: downgraded from major. It was Sol-only, and I confirmed it with setfacl.
  • Failed-cleanup report: rejected (see "Checked and not raised").
  • Callback throw → R2-4: kept as a nit.

The Claude pass alone found the bind-mount half of R2-1 and R2-3, both reproduced in a container. It also merged and rebuilt on current main, ran the suite, mutation-checked the delta's tests, read CI and the PR thread, and ran the permission probes on Node 22.21, Bun 1.3.14 and Bun 1.4.3. The second reviewer couldn't run tests, Bun, containers or anything networked.

Hamish-2569 and others added 2 commits October 17, 2026 22:53
Takes main at e670363. The one conflict was the framework's import block: main's persistedUsageProblem in the usage tracker's import, beside formatUnreadableKept in the gate's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a callback throws

The copy of a gate.json that won't load took gate.json's group and
permission bits, and stayed owner-only for a link. Two setups got past
that. A single-file bind mount looks to lstat like a regular file, so
a world-readable file behind a closed directory gave a world-readable
copy in the open one. A POSIX ACL's mask shows as the group bits, so a
copy, which has no ACL, opened to the whole owning group. The chown
and chmod also went by the copy's path after its exclusive create, so
a link swapped in for it in between took them to another of the host
user's files. The copy is now created owner-only and nothing widens
it: the lstat, the chown with its uid for Bun 1.3.14, the chmod and
the link case are gone. The host read gate.json, so its user is one
who could; on Linux, only that user can read the copy, a directory's
default ACL being masked by its mode. The changelog fragment says so.

onUnreadableKept ran once the write was made but before the
gate:policy-added or gate:policy-removed trace, and a throw from it
reached addPolicy's or removePolicy's caller, so a change that was made
answered as failed and went untraced. The trace now comes first, and
the callback runs contained: a throw from it is logged with its text,
or with "a value that could not be printed" where that can't be read,
and the call answers as the change it made.

Tests, on Linux, Node 24.22.0: a 0600, a 0640 and a 0644 gate.json
each give a 0600 copy, and so does one shared through another of the
process's groups, where the process is in one; nothing chowns or
chmods the copy by its path; a throwing onUnreadableKept, through
addPolicy and removePolicy, runs after the trace and is logged, and
the change stays made, saved and answered as made, with a thrown value
whose text can't be read named as such. The test of chown refusing -1
under Bun goes with the code it tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

9dd035c answers R2-1 to R2-4, and a8fbc68 merges main at e670363.

  • R2-1. Addressed in 9dd035c, as your (a). The copy is created with mode 0600 and nothing widens it. The lstat, the chown with the euid for Bun 1.3.14, the chmod and the link case are gone. The changelog fragment and the description now say that on Linux only the user the host runs as can read the copy, whatever gate.json's own mode, group or ACL. macOS's inherited access entries are named under "Not verified". Tests: copies of a 0600, a 0640 and a 0644 gate.json are each 0600, and so is the copy of one shared through another of the process's groups, a test skipped where the process is in no other group.
  • R2-2. Addressed in 9dd035c, by R2-1's change: nothing chowns or chmods the copy by its path once it's made. A test stubs both to throw and checks that neither is called.
  • R2-3. Addressed in 9dd035c, by R2-1's change: the copy is the host user's, as the tool result says ("whoever runs this host can restore rules from it"), so under root it's root's.
  • R2-4. Addressed in 9dd035c. addPolicy and removePolicy emit their trace first, then call onUnreadableKept contained. A throw from it is logged, as [gate] onUnreadableKept threw once the change was made, and the change stands: <text>, and the call returns as the change it made. A test throws from the callback through an add and a remove, with a thrown value whose toString throws included.
  • The merge. a8fbc68 merges main at e670363, keeping both names in src/framework.ts's import block, as you resolved it.

At 9dd035c:

  • Red-first: on main's src (e670363) with this test file, 29 of its 32 tests fail, with 1 skipped. On the merge's src, exactly the three new or changed tests fail.
  • Removals: each of the 9 removals run for it fails a test.
  • The gate's test files: they pass 175 of 176, with 1 skipped.
  • CI: GitHub's CI runs the suite on the push.

Written by Hamish-2569.

— Tabitha-2696

main's fix for gate files saved with a UTF-8 BOM (anima-research#402) changed the two
parse lines this branch rewrote, in loadConfig and loadPrivileged. The
resolution keeps this branch's readers, readConfigFile and
loadPrivileged, and parses through main's parseGateJson in both:
readConfigFile's parse, which the conflict holds, and loadPrivileged's,
which sits outside it, where the branch parses the text it read.
Without that, a gate.json saved with a BOM would read here as
unreadable, and the next change would keep a copy of it and write the
rules in force over it.

framework.ts's imports take both sides: main's toolArgsProblems and
this branch's formatUnreadableKept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A change through addPolicy or removePolicy reads gate.json fresh. Saved
as UTF-8 with a leading BOM, it loads, so the change edits it: the rule
its operator added by hand stays, and nothing is kept beside it. Read as
unreadable, it would be kept and written over with the rules in force.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

b31b27e merges main at 67aabff, after #402, and 686d86e adds one test.

Apart from the merge and this test, the branch is as it was at 9dd035c, which answered Anarchid's R2.

Written by Hamish-2569.

— Lottie-2661

Four conflicts, each kept from both sides:
- framework.ts's imports: this branch's formatUnreadableKept beside
  main's wakeLineText, WakeLine and EPHEMERAL_USAGE_NAME;
- gate/index.ts and index.ts export UnreadableConfigKept beside main's
  WakeLine and wakeLineText;
- evaluate's sleep branch takes main's heartbeat test, the server id
  alone, without the reloadPrivilegedIfChanged call before it, since on
  this branch isPrivilegedUser, which the branch calls next, reloads the
  file itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

29afb43 merges main at 091fa49, which had moved on since 67aabff.

The merge met four small conflicts, and each keeps both sides:

  • framework.ts, gate/index.ts and index.ts: their imports and exports take this branch's formatUnreadableKept and UnreadableConfigKept beside main's wakeLineText and WakeLine.
  • evaluate's sleep check takes main's heartbeat test, by the server id alone. It doesn't take the reloadPrivilegedIfChanged() call before it, since on this branch isPrivilegedUser, called next, reloads the privileged-users file itself.

This branch's own changes are otherwise as they were.

#452 builds on this branch, and now takes its copy out. There, a change edits gate.json's own list of rules, so a gate.json that isn't JSON is the one case this branch's copy still served: with both, a change over such a file is refused, naming the parse error and what the gate runs meanwhile, and nothing is written over it. Merged without #452, this branch would ship a copy, and four public additions reporting it, that #452 then takes out.

Written by Hamish-2569.

— Lottie-2661

@nissa-seru
nissa-seru merged commit 8f39763 into anima-research:main Oct 23, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…qualified channels meet the registry, tune-out and the reply anchor

What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and
0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755,
anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465,
anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781.
Most of the conflicts are where anima-research#710 met the branch: in main a channel the
framework acts on is a ChannelRef, { serverId, channelId }, since two
MCPL servers can register one id.

Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent;
where the two had different designs for one thing, the branch's stays,
moved onto main's types:

- src/framework.ts: main's conversationAgentHomes map stays gone. A fork's
  home is the registry record's homeChannel, which homeOf reads wherever
  main read the map (isForkBound, mayTakeGateRoute, canCloseChannels,
  holdsChannelTraffic, agentForPrimary, the drafts resend, the channel
  tools' guard, the registry's homeChannelResolver, stop()'s unbinding,
  releaseAgentRuntime's). The record still keeps a bare id, read as main
  reads a wake that names its channel without its server (channelNamed),
  or, where it names no one registered channel, as a home on no server
  ({ serverId: '' }), which a path reading the pair finds unregistered and
  one reading the name finds no channel or two for. main's
  dropContinuations goes into releaseAgentRuntime, so every way a live
  non-ephemeral agent ends drops a continuation of its turn, and beside
  the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's
  addressed; the addressed-elsewhere notice names channels with main's
  channelShown and keys its once-per-channel set by destinationKey.
  isAddressedMessage stays tag-only, with main's ambient marks.
  publishInputWithTurnLocus gives the locus with its server, and only to
  a call made inside the turn (callingTurnOf); outside it, a conversation
  fork's call takes its home, as its dispatched calls do, and anyone
  else's the registry's default. The tune_out handler keeps the branch's
  shape (serverId, scope, check, peek) and resolves through
  ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the
  calling turn's own channels, check's peek included; its answers name
  the channel with nameShown. holdForProviderWait holds nothing for an
  agent a listener of the failure it reports ended (stopAgent), where
  main's report-before-hold (anima-research#774) meets the branch's stopAgent at a
  conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the
  branch's caller.
- src/tune-out/coordinator.ts: main's shown() names beside the branch's
  reader helpers; deliver_summary, cancel_tuneout and speak_in_channel
  resolve as main's do, for the subconscious and a reader fork alike;
  cancel_tuneout keeps the branch's serverId and all-except fallback, and
  a name that picks no channel (one two servers share, or a turn's own
  channel ownChannelFor refuses) doesn't keep a call naming the scope
  from ending it;
  findByChannel goes, as on main. focusChannel() returns a ref.
- src/mcpl/channel-registry.ts: markAccepted records no anchor (admission
  does), and handleIncoming's arrival count of removals, which main's
  guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing
  for a channel its server no longer registers, read at admission where
  main's guard compared removal counts at arrival and acceptance (a
  message admitted after its server registers the channel again sets the
  anchor here). buildChannelContext is
  main's, per server, with the branch's sharedFallback; incomingFor reads
  the anchor itself, not the speech fallback. globalFallbackFor,
  sharedFallback, fallbackLocus and focusChannelResolver are on refs.
- src/index.ts: main's DefaultErrorPolicy beside the branch's exports.
- Tests: the branch's registry stubs take main's shapes; main's
  appendInTwoParts in batched-wake-locus's rig.

Beyond the conflicts: the all-except announcement goes to the excepted
channel on its server (publishForAgent refuses a shared bare id since
anima-research#710), and tune_out's all-except answer names its channel with nameShown;
the tune_out schema's serverId description says channelId takes
serverId:channelId too; main's refusals for a tuned-out channel no name
picks alone give the tune_out cancel by id and server, since this
branch's tune_out takes a serverId; a stale comment naming
conversationAgentHomes in channel-registry.ts names the registry;
docs/dendrite.md says where a publish naming no channel goes outside the
turn, and no longer lists ChannelRef as unbuilt.

Test adaptations: main's tests that set conversationAgentHomes give the
fork its home through the registry. main's tests written to the
latest-inbound rule run under it (reply-server-routes: speech LEGACY;
shared-channel-id's registry fixture: latestInboundLocus, and
noteDelivered as admission records). Two reply-server-routes tests of an
in-turn publish naming no channel now check the branch's rule, the turn's
locus through its server. tune_out's shared-channel-id tests wait for the
handler's answer, and its shared-id refusal offers serverId. all-except-
unit's stub registry has nameShown; present-while-acting and
deferred-window-only build routing state in main's shapes. main's
marker and removed-server anchor tests record the anchor at admission
(noteDelivered), where this branch records it.

At this commit alone, the tests where a fork's home has to name its
server fail (two servers' channels with one id, or a home the registry
doesn't register at that moment); the pull request lists them. The next
commit stores the home with its server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants