Skip to content

fix(cli): an attached agent can name itself, and find its own kild - #675

Merged
Wirasm merged 6 commits into
mainfrom
fix/attached-agent-identity
Jul 28, 2026
Merged

fix(cli): an attached agent can name itself, and find its own kild#675
Wirasm merged 6 commits into
mainfrom
fix/attached-agent-identity

Conversation

@Wirasm

@Wirasm Wirasm commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Two failures with one cause: the CLI knew nothing about the harness session it runs inside.

Both were found by dogfooding — two agents (@kild on the engine, @claude on the helm rewrite) working the same kild during the helm port. Neither is theoretical.

Attribution

attach mints a bearer token, and the engine writes that handle as from when it is presented. The CLI took the token, printed it under --json, and threw it awayengineFetch never set an Authorization header. So every send from an attached harness landed as the unattributed label human, indistinguishable from the operator typing and from every other attached agent in the kild.

Observed live: two agents' messages and the operator's smoke tests were all human → [...] on one log, while the operator was away.

send now mints through the idempotent attach at call time and presents it. The token is deliberately not stored — it lives in engine memory (AttachTokens) and dies with the engine, so a copy on disk would outlive the thing it names and be rejected as unknown attach token on the next send.

Discovery

An attached agent was reachable only if KILD_KILD_ID was in the environment, and a process's environment is fixed at exec time — so a session could not attach to a kild opened after it started, which is the normal case, since you decide to delegate mid-session. export in a tool call changes a child shell, never the harness.

A resumed or forked session is worse: its environment is rebuilt from a harness settings file no shell owns. The operator relaunched a session three times in one day trying to fix this from the shell, and could not.

attach now records {kild, handle} against the harness session id — an opaque string kild stores and hands back. inbox and send resolve it. kild attach <id> --as <handle> is now the entire setup.

The record outranks the environment

This is the opposite of the obvious ordering, and it is the interesting part of the change.

A harness settings file (.claude/settings.local.json) is re-read on every session start including resumes, outranks anything a shell exports, and cannot be cleared from the shell. A stale entry naming an archived kild is therefore invisible and unkillable — it would drain nothing forever while reporting success, which is precisely the failure hooks/claude-stop's own header warns about.

An attach is a deliberate act naming a kild that existed at the time; ambient config nobody re-reads loses to it. KILD_KILD_ID still works, as the fallback.

Resolution: explicit argument > this session's recorded attach > environment.

Supersession

Attaching supersedes any other session holding the same (kild, handle). A session id is not stable — --fork-session mints a new one for what the operator experiences as the same session — so without this, records accumulate one per relaunch. And a handle is one identity: two live sessions sharing it would race on one destructive inbox, each eating mail meant for the other.

Spawned agents are guarded out

The engine sets KILD_KILD_ID and KILD_HANDLE on the agents it spawns too. Without the KILD_AGENT_ID guard, kild send from an owned agent would have attached a second, shadow handle over the agent's own and spoken through it. Covered by a test.

Two things found by running it, not by testing it

  • harnessSession was a const arrow reached by dispatch() before initialisation, which broke kild attach outright with Cannot access 'harnessSession' before initialization. dispatch() runs at the top of the module, so anything it reaches must be hoisted. It is a function declaration now. No unit test would have caught this; running the binary did, immediately.
  • cli.attached.test.ts inherited the ambient environment (env: { ...process.env }). Harmless before — cli.ts read none of those vars. Now the suite resolved the operator's own attachment and passed or failed depending on whose machine it ran on. The harness scrubs the identity vars and redirects KILD_HOME to a temp dir; tests opt into an identity by name.

Gates

Gate Result
bun test 400 pass, 0 fail
bun run typecheck pass
bun run lint pass
./scripts/e2e.sh 70 checks, 0 fail

Verified live against the running engine on 4517: attach → record on disk → an attributed send landing as kild → [claude] instead of human → a drain resolved with KILD_KILD_ID and KILD_HANDLE explicitly unset.

Boundary

The engine learns nothing about any harness. It stores no session id and gains no new field; the record is written by the CLI, and only hooks/claude-stop knows where the id comes from. Vendor knowledge stays in the vendor file.

For an existing setup

docs/upgrading.md has the full list. In short: re-copy hooks/claude-stop, and check your harness settings file for a leftover KILD_ROOM/KILD_PARTICIPANT — renaming the variables did not remove the stale entry, and it is exactly the invisible trap described above.

Wirasm added 6 commits July 28, 2026 13:24
Two failures with one cause: the CLI knew nothing about the harness session
it runs inside.

Attribution. `attach` mints a bearer token and the engine writes that handle
as `from` when it is presented. The CLI took the token, printed it under
--json, and threw it away — so every send from an attached harness landed as
the unattributed label, indistinguishable from the operator typing and from
every other attached agent in the kild. `send` now mints through the
idempotent attach at call time and presents it. The token is deliberately not
stored: it lives in engine memory and dies with it, so a copy on disk would
outlive the thing it names and be rejected as unknown.

Discovery. An attached agent was reachable only if KILD_KILD_ID was in the
environment, and a process's environment is fixed at exec time — so a session
could not attach to a kild opened after it started, which is the normal case.
A resumed or forked session is worse: its environment is rebuilt from a
harness settings file no shell owns. `attach` now records {kild, handle}
against the session id, and inbox/send resolve it.

The record outranks the environment, which is the opposite of the obvious
ordering. A settings file is re-read on every start including resumes and
cannot be cleared from a shell, so a stale entry naming an archived kild is
invisible and drains nothing forever while reporting success. An attach is a
deliberate act naming a kild that existed; ambient config loses to it.
KILD_KILD_ID still works as the fallback.

Attaching supersedes any other session holding the same (kild, handle), so
forks cannot accumulate a record per relaunch and two sessions can never race
on one destructive inbox.

An agent the engine spawned is guarded out of all of it. The engine sets
KILD_KILD_ID and KILD_HANDLE on owned agents too, so without the guard a send
would have attached a shadow copy of the agent over its own handle.

Two things found by running it rather than testing it:

- `harnessSession` was a const arrow reached by `dispatch()` before
  initialisation, which broke `kild attach` outright. It is a function
  declaration now.
- cli.attached.test.ts inherited the ambient environment, so the suite
  resolved the operator's own attachment and passed or failed depending on
  whose machine it ran on. The harness scrubs identity vars and redirects
  KILD_HOME to a temp dir; tests opt into an identity by name.

Gates: 400 tests, typecheck, lint, e2e 70/70. Verified live against the
running engine: attach, an attributed send, and a drain resolved with
KILD_KILD_ID and KILD_HANDLE unset.
Three real defects, all in the new slice.

A concurrency bug that lost data. `recordAttachment` wrote its record and then
scanned the directory deleting every OTHER record for the same (kild, handle).
Two sessions attaching at once each saw the other's freshly written record as
somebody else's and deleted it, leaving NEITHER attached rather than the last
one — reproduced at 197 of 200 trials. That is exactly the fork case the record
exists to serve. The write and the scan now run under a lock (an atomic mkdir,
the smallest exclusion available without a dependency), with a stale-lock
break so a crashed attach cannot wedge every later one. Failing to lock skips
superseding and says so; the record itself is still written.

A silent failure that reintroduced the very bug this branch fixes. The
`.catch(() => null)` in resolveAttachment was written for the turn-end hook,
which must degrade to silence — but resolveAttachment is also what `kild send`
resolves through, so an unreadable record made a send post with no credential:
exit 0, "Sent to the kild.", and the message on the log as `human`.
Indistinguishable from a correctly attributed send. The swallow moves to the
one caller entitled to it, matching the shape kildInbox already used for
drainInbox.

Underneath it, readRecord caught every fs.readFile failure as "not attached",
so EACCES or an I/O error read as "this session never attached". Only ENOENT
means that now; unreadable and malformed throw, and each caller decides.

Docs that contradicted the code. The hook header and attached-agents.md still
said KILD_HANDLE "defaults to claude" after this branch removed the default.
Someone following them would have got a hook that silently did nothing.

forgetAttachment is deleted rather than wired up. Nothing called it: it was a
documented, tested function with no caller, and the cleanup story it implied
(what happens to records when a kild stops) is not designed — it would need to
drop every session's record for a kild, not one session's. Its tests go with
it. A stale record already degrades correctly to silence.

Gates: 405 tests, typecheck, lint, e2e 70/70. Verified live again after the
change: attach, no lock left behind, and a no-argument drain still resolving.
The lock did not hold, and verification broke it two ways.

Its staleness was measured from the waiter's own arrival rather than the
lock's real age, so a slow-but-alive holder had its lock stolen; and the
release was an unconditional rm with no ownership check, so that holder then
deleted a lock somebody else legitimately held. Two critical sections could
overlap — a narrower version of the bug the lock was added to fix.

Worse, the give-up path wrote nothing at all. Both the record write and the
supersede scan sat inside the lock callback, so failing to acquire skipped
both, while `kild attach` still printed success from the engine's reply. An
operator could be told "attached" with no local record and every later send
and drain would behave as if the session had never attached. The doc comment
and the previous commit message both claimed "the record itself is still
written". That was simply false.

Rather than add lock-age stat, an ownership token and a degraded write path,
this drops the lock. Who holds a handle is now one file written by atomic
rename:

  attached/<session>.json         what this session attached to
  attached/claims/<kild>/<handle> which session currently holds that handle

A session resolves only if the claim still names it. Concurrent attaches
produce exactly one winner because rename is atomic and has no observable
intermediate state — no timeout, no staleness heuristic, no acquisition that
can fail. Kild and handle are separate path segments so no separator inside
either value can make two pairs collide on one filename.

Bounded growth is kept by a sweep that removes records the claim no longer
names. It is safe under any race because it only deletes records that already
resolve to null, and it runs after the attachment is complete, so a failure
costs disk and never an answer.

Verified by stress rather than by argument: 200 trials of 8 sessions racing
one handle give exactly one attached, 200 times — no losses, no duplicates.
The scan-and-delete this replaced lost both records in 197 of 200.

Gates: 408 tests, typecheck, lint, e2e 70/70. Dogfooded live: a record whose
claim is missing correctly reads as not attached, re-attach writes the claim,
and a no-argument drain then resolves and delivers.
…ents

Two defects verification reproduced in the atomic-claim rewrite.

The sweep could delete a live record. It decided from a snapshot it had
already read but unlinked the live path, so an ordinary re-attach racing any
OTHER session's sweep removed a record whose claim still named it. That leaves
a session holding a claim with no record, which resolves as "not attached",
which makes the next `kild send` go out with no credential — the exact failure
this branch exists to prevent, reached a third way. Every attach swept the
whole directory, so the window was not rare.

The sweep is removed rather than repaired. It was never a correctness
mechanism — the claim is — and a race-free delete needs rename-aside plus a
link-back protocol to buy nothing but disk. Superseded records now stay:
one small inert file per forked session. Nothing scans this directory (every
read is a direct path lookup), so the cost is bytes, never latency and never a
wrong answer. Reclaiming them belongs in an explicit maintenance verb, not in
an implicit delete running on somebody else's attach.

`safeSegment` accepted `.` and `..`. The character class allows `.`, so the
traversal tokens passed the check whose whole job was blocking traversal, and
`path.join` collapses `..` against the preceding segment — so any (kild, `..`)
pair targets the shared `claims` directory itself. One such call turned that
directory into a plain file, after which every attach failed ENOTDIR for good.
Both tokens are now refused by name; dots inside a value still work, because
worktree names carry them.

Verified rather than argued: the reported attack is blocked at all four
positions, the claims directory is never created, and a normal attach still
works afterwards. 200 trials of 8 sessions racing one handle still give
exactly one attached.

One test changed meaning and is worth naming: "forks do not accumulate
records" asserted a file count, which is no longer the invariant. It is now
"only the newest fork resolves, however many came before it" — the property
that actually matters, and the one the claim provides.

Gates: 414 tests, typecheck, lint, e2e 70/70.
Final verification cleared the invariant — no interleaving or crash point
resolves a session to a kild it does not hold, and two sessions never both
resolve for one handle — and found one real gap that does not touch it.

On a case-insensitive filesystem, which is the macOS default, two handles
differing only in case are distinct agents to the roster but fold to one claim
file. The second attach overwrites the first, and the first session then reads
as not attached. It fails closed rather than to somebody else's identity, so it
cannot mis-attribute, but it does go quiet.

Not fixed here, and the reason matters: the durable fix is for the engine to
refuse case-variant handles when an agent joins the roster, since handles that
differ only in case are ambiguous to a human reader too. Encoding around the
collision in the storage layer would leave the ambiguity in place and hide it.
That is a change to agent admission, not to this file, and it does not belong
in a branch already rewritten three times.

Written down in both places a reader would look.
@Wirasm
Wirasm merged commit c2bc0d6 into main Jul 28, 2026
3 checks passed
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