Skip to content

host: the state journal's own leaf test ignores nlink, so a hard link is read as history and appended to #2131

Closed

Description

@HugoXK

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.

Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: 2b732716ea8e4d7890f73a91a8ba55dc
Branch: dev/3-389-journal-hardlink
Base: e6dfffe
At: 2026-11-02T08:18:19Z

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.

Written-by: FlyBot Lead

state.jsonl asks two leaf questions of its own, and neither looks at nlink, so a hard link stands in for the journal.

Where. core/src/host/journal.js does not use the shared fileKind helper — :19 imports only dirKind — and writes its own predicate twice: :254 lstat → :259 if (!info.isFile()) throw (start-up read, before chmod(path, 0o600) and readFile), and :308 lstat → :312 if (info !== undefined && (info.isSymbolicLink() || !info.isFile())) throw (append). A hard link is isFile() === true and is not a symlink, so both let it through. The nlink === 1 that the temp-name writers got in #2114, and the one fileKind is getting, never reach this file.

What happens. stateJournal: true is opt-in, and the store then keeps the team snapshot — bots, sessions, routes — in journal/state.jsonl.

  • Start-up face: take a file outside the journal that already holds one valid base line (130 B, mode 664) and hard-link it at journal/state.jsonl (nlink 2). openJournal plus one record gives warnings [], the other name is chmoded to 600 (the shared inode, so the other name's mode changes too), it grows to 322 B, and two lines are appended to it. The host read someone else's file as its own history and continued it.
  • Swap face (the shape of the #2074 symlink case): record once normally (the file is 120 B), rm it and hard-link it to a 13 B file outside, then record again. Warnings [], and the outside file becomes 213 B with the two appended entries; isSymbolicLink() is false, nlink is 2.

Expected. Same shape as the symlink that :312 already refuses: stop() warns once (not recording), nothing is appended, the other name keeps its bytes and its mode, and the start-up face does not read the file and does not chmod it. A plain file this host owns, with nlink === 1, keeps recording exactly as today.

Scope. core/src/host/journal.js: the two predicates, one clause each — info.nlink !== 1 folded into the :259 rejection and into the :312 one. Tests in core/test/state-journal.test.js, in the shape of the #2074 symlink case at :370: one start-up hard link and one swap hard link (each: warns once, the other name's bytes and mode unchanged, nothing appended) plus the plain-file control that still records and replays. One line in docs/subsystems/core-host.md (the journal paragraph), and a changelog fragment. No new error type, no new configuration, no change to dirKind (the folder side already fails closed).

Deliberately not in scope: folding these two predicates into the shared fileKind. That needs fileKind to carry the plain-leaf rule first, and that change is not on main yet, so doing it here would tie this fix to an unmerged branch; the convergence to one predicate is left as its own follow-up.

Tier T1 (the journal is opt-in, the same level #2115 was accepted at), effort XS. No .github/workflows/ or policy/ file in the diff, so no Policy-sign:.

Dedupe (checked before opening): issue search nlink → 9 hits, all on the temp-name line or other entities (#2100, #2108, #2110, #2113, #2114, #2115, #2116, #2117, #2118); state.jsonl hard link → 0; journal + hard + link → 8, none about state.jsonl; the existing state-journal.test.js:370 case covers the symlink only. main still has if (!info.isFile()) at journal.js:259 and (info.isSymbolicLink() || !info.isFile()) at :312.

Activity

  1. HugoXK commented on Nov 2, 2026

    @HugoXK
    ContributorAuthor

    Release

    Written-by: FlyBot Lead
    Released: 2b732716ea8e4d7890f73a91a8ba55dc
    Reason: merged as de70481 (#2133, merged_at 2026-11-02T09:27:46Z)
    At: 2026-11-02T09:32:45Z

  2. added a commit that references this issue on Nov 2, 2026
    de70481
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions