Skip to content

Fix #1666: copyDir and hashTree disagree on symlinks, so a client-asset source holding one re-copies and rewrites the ledger every boot and can never heal - #1667

Merged
philcunliffe merged 4 commits into
masterfrom
fix/issue-1666
Sep 11, 2026
Merged

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Feature or issue

copyDir copies files and directories and skips every other entry (src/core/util/fs_copy.js), while hashTree folded any other entry into the digest as an o:<relpath> line (src/core/runtime/client_asset_ledger.js). A client-asset source tree holding a symlink could therefore never digest equal to the copy the materializer makes of it, so the boot refresh's sourceDigest === onDisk check failed every time: the whole tree was re-copied, the new digest was recorded, and the ledger was rewritten with byte-identical content on every daemon boot, forever, visible only as an endless refreshed line. No shipped skill source holds a symlink today, but local-dir plugin sources (src/core/plugin_install/resolver.js) can carry one.

Solution

  • hashTree now covers exactly the set copyDir copies: an entry that is neither a file nor a directory is skipped rather than hashed by name, so a source tree and its copy digest equal and the boot after a copy reports unchanged and writes nothing.
  • Chosen over the other direction (teach copyDir to recreate symlinks) on three counts. Security: materializing a link verbatim into ~/.claude/skills lets a plugin tree place a pointer to anywhere on disk, including outside the tree, into a directory the client loads as skill content; skipping adds no capability the installer did not already have. Completeness: copyDir can recreate a symlink but not a fifo, socket, or device, so that direction leaves the same bug for the other entry kinds. Migration: copying symlinks changes digest(copy), which would invalidate every ledger digest recorded for such a tree and freeze the asset as asset_edited; this direction leaves digest(copy) untouched. Verified: digests of src, src/core/cli, and the claude plugin tree are byte-identical before and after, so every recorded digest stays valid. The tradeoff accepted is that a special entry dropped into an installed copy after the fact no longer registers as a user edit; its files and directories still do.
  • A regression test stages a skill source holding a symlink, installs it, backdates the ledger, and asserts the next refresh reports the asset unchanged with the ledger mtime untouched. It fails on unmodified code (unchanged is 0, not 1) and passes after; reverting only the client_asset_ledger.js hunk fails it again and nothing else. CPU/memory: the per-boot re-copy and the per-boot ledger write are gone for such an asset, the hash does strictly less work per skipped entry, and no new per-asset write is introduced. npm test 6573 pass / 3 fail, all three pre-existing environmental failures in test/core/hyparquet-floor-pin.test.js that reproduce on clean origin/master; npm run typecheck clean; node scripts/llp-numbers.js check clean.

Code: +12 / -9 lines

Fixes #1666

philcunliffe and others added 3 commits September 11, 2026 05:14
… a symlink never settles (#1666)

`copyDir` copies files and directories and skips every other entry, while
`hashTree` folded one into the digest as an `o:<relpath>` line. A source tree
holding a symlink could therefore never digest equal to the copy made of it:
the refresh's unchanged check failed on every boot, the whole tree was
re-copied, and the ledger was rewritten with identical content forever.

The hasher now covers exactly the set the copier copies. Digests of trees
without a special entry are byte-identical to before, so every recorded ledger
digest stays valid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n decision doc

Skipping an entry the copier cannot carry does not only make a source tree
settle; it narrows what the ownership digest measures. A symlink dropped into
an installed copy no longer moves the digest, so the refresh overwrites that
copy instead of reporting `asset_edited` and a prune of the retired asset
removes it instead of withholding it. LLP 0219 #edited-assets-are-not-ours is
Accepted and reads the digest as the measure of "the user took this over", so
the repo's own mechanism applies: a new LLP, noted on the old doc's
`Extended-by:` line (the same move LLP 0400 made two commits ago).

LLP 0401 records the direction, the rejected copier-side alternative, the
migration evidence, and the narrowing. LLP 0226's supporting sentence that a
symlink inside the tree "hashes as an opaque entry by name" no longer describes
the code the `@ref` on `inspectClientAsset` points at, so it takes a forward-ref
rather than an edit to what it settled.

No behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch was cut before #1665 landed. Both changes touch LLP 0219's
`Extended-by:` line, so the merge is resolved to carry both forward-refs
(LLP 0400 for the healing evidence, LLP 0401 for the narrowing of what the
digest covers).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 1 - head 07265d7f

Verdict: approve with fixes landed. The bug is real, the direction is the right one, and the regression test is non-vacuous. Three findings, two of them documentation-honesty consequences of a behavior change the diff makes but does not record in the corpus. Both are fixed on this branch; the third is rejected with counter-evidence. No blocker.

Reviewed in a detached worktree at 07265d7f, plus a second worktree carrying the merge with origin/master (0bd00358), because the branch was cut before #1665 landed and the shipped behavior is the merged tree, not this head. That merge is now in the branch (see "What I pushed").

The claims, re-verified

The bug. Confirmed at the source. copyDir copies isDirectory()/isFile() and nothing else (src/core/util/fs_copy.js:22-27); hashTree folded every other entry in as o:<relpath> (src/core/runtime/client_asset_ledger.js:266 pre-fix). copyDir has exactly one consumer (src/core/runtime/client_assets.js:1036,1077), so the asymmetry is total for that path: digest(source) !== digest(copy) forever, the sourceDigest === onDisk check at client_assets.js:220 never holds, replaceAsset re-copies, and the single post-loop ledger write runs with byte-identical content on every boot.

Direction: correct. I agree with hasher-follows-copier over copier-follows-hasher, and with all three stated reasons. The strongest is the one the PR lists second: copyDir cannot recreate a fifo, socket, or device without a new dependency, so the copier direction closes one entry kind and leaves the identical never-settles bug for the rest. The invariant the refresh actually needs is one-directional (the hash may never cover what the copy cannot carry), and only the hasher side satisfies it for every kind at once. The security argument is real but secondary: skipping removes no capability the installer had.

Migration safety: verified, claim holds. I did not take the PR's word for it. I ran both implementations side by side (pre-fix from a worktree at origin/master, post-fix from the branch) over the shipped trees:

SAME src                                    0e1b8c4ab861
SAME src/core/cli                           4435930b7005
SAME hypaware-core/plugins-workspace/claude 15ddd86e86cf
SAME hypaware-core/plugins-workspace        924a3edb44d6
SAME test / llp / bin
SAME-SKILL .claude/skills
SAME-SKILL hypaware-core/plugins-workspace/claude/{agents,skills}
SAME-SKILL hypaware-core/plugins-workspace/{codex,gascity}/skills

Byte-identical under both versions for every shipped asset source. The only tree that differed was the repo root, and only because a git worktree's CLAUDE.md is a symlink to AGENTS.md (git ls-files -s CLAUDE.md -> mode 120000); that is not an asset source. No recorded ledger digest changes, so no installed copy is reported as edited because the hasher moved under it. Claim confirmed.

Regression test: non-vacuous. Redone three ways, not relayed:

  • Revert only the client_asset_ledger.js hunk in the merged tree, keep the test: not ok 1 - a source holding a symlink settles instead of re-copying on every boot / expected: 1, actual: 0. Restore the hunk: passes.
  • Apply only the new test on top of clean origin/master: # tests 46 / # pass 45 / # fail 1, and the one failure is the new test. So "nothing else fails" holds too.
  • Merged tree, all three asset suites: 48/48 pass.

The test is also well built for what it is pinning: it asserts readdir(dest) === ['SKILL.md'] (proving the copy really cannot hold the link), and it backdates the ledger file and asserts mtimeMs survives, which is the only way to see a byte-identical rewrite. That mtime assertion is what makes it a test of LLP 0400 #one-write and not just of the summary counters.

Other digest consumers. Checked all three. The materializer's record (client_assets.js:257), the prune's ownership evidence (client_assets.js:688, pruneOneAsset), and hyp skills install all go through the same digestClientAsset, so they move together; there is no consumer left comparing an old-format digest to a new one. Nothing ever parsed the o: line - the digest is opaque and only ever compared to another digest from the same function - so there is no stale reader (grep for o: framing across src/ test/ hypaware-core/ llp/ bin/ returns only the hasher itself). The d/f shape prefix still keeps the file and directory hash domains disjoint. But two trees differing only in skipped entries now digest identically, and that does change one consumer's behavior - see finding 1.

Interaction with #1665. The merge is clean and I ran it: test/core/client-assets-{refresh,prune}.test.js + daemon-client-assets-refresh.test.js = 48/48. The heal gate #1665 added is reachable for a symlink-bearing source for the first time, which is a strict improvement: such an asset could previously never heal a stale record because sourceDigest === onDisk was unreachable for it. No conflict in intent; the two changes are complementary (one makes the unchanged check reachable, the other makes the heal reachable).


Findings

1. src/core/runtime/client_asset_ledger.js:263 - medium - the change narrows Accepted LLP 0219, and that was recorded nowhere in the corpus. Disposition: fix (landed).

The PR states the tradeoff honestly in its body, and the tradeoff itself is the right call. What was missing is that it is not merely a tradeoff, it is a narrowing of a rule an Accepted doc settled. LLP 0219 #edited-assets-are-not-ours reads the digest as the measure of "the user took this copy over" ("taking it over is exactly what the digest measures"). After this change a symlink the user drops into an installed copy no longer moves the digest. Reproduced directly against both implementations:

old  digest unchanged after user added a symlink: false
new  digest unchanged after user added a symlink: true

Consequences traced through the code, both user-visible:

  • Prune: pruneOneAsset reaches recorded.has(digest) === true (client_assets.js:831) and calls removeClientAssets -> fs.rm(dest, {recursive:true, force:true}) (client_assets.js:481), taking the directory with the user's link in it. Pre-fix it returned withheld with warning: retired skill '...' changed since HypAware installed it; left in place. (The link is unlinked, never followed, so the target is untouched.)
  • Refresh: the copy now digests as unmodified, so replaceAsset overwrites it instead of reporting asset_edited.

Files and directories the user adds, and edits to bytes already there, still move the digest and still stop both. So the narrowing is correctly scoped and was honestly stated; it was just not recorded. The PR's reasoning that "LLP 0219 does not settle how special entries are hashed" is true about the mechanism and beside the point about the evidence: what moved is what the digest is evidence of. CLAUDE.md prescribes exactly one move for that ("Change the design by extending it: a new LLP, noted on the old doc's Extended-by: line"), and #1665 made that same move two commits ago with LLP 0400. The @ref still sitting on digestClientAsset ("a digest may only match what we actually wrote") had also gone quietly false.

Fixed by minting LLP 0401 (llp/0401-the-asset-digest-covers-what-the-copier-carries.decision.md, number from node scripts/llp-numbers.js next after git fetch --prune), recording the direction, the rejected copier-side alternative, the migration evidence, and the narrowing under #edit-detection-narrows; appending it to LLP 0219's Extended-by:; pointing LLP 0397's consequence bullet at it; and adding @ref LLP 0401#digest-covers-the-copy [implements] on the skip in hashTree and @ref LLP 0401#edit-detection-narrows [constrained-by] beside the now-partial 0219 ref.

2. llp/0226-prune-direct-children-and-unreadable-assets.decision.md:110 - low - stale mechanism in an Accepted doc the changed code's @ref points at. Disposition: fix (landed).

0226 still read "a symlink whose target is gone hashes as an opaque entry by name, same as one whose target exists". After this commit no symlink hashes at all. The PR correctly updated the mirrored sentence in the inspectClientAsset JSDoc (client_asset_ledger.js:212-216) but not the doc that JSDoc's @ref LLP 0226#unreadable-is-not-absent resolves to, so the annotation cited a paragraph describing code that no longer exists - the exact staleness CLAUDE.md's "keep refs honest" rule is about. What 0226 decided (unreadable is not absent) is untouched, and the conclusion that sentence supports still holds for a stronger reason: a skipped entry reaches neither try's error path at all. Fixed with a parenthetical forward-ref to LLP 0401 rather than an edit to what 0226 settled, which is the editorial-only latitude an Accepted doc allows.

3. test/core/client-assets-refresh.test.js:152 - low - fs.symlink with no win32 guard. Disposition: reject.

The suggestion was to follow const skipSymlinks = process.platform === 'win32' && ... as the repo convention. It is not one. Counter-evidence: grep -rl skipSymlinks test/ returns 3 files (capture-spool-containment, search-scratch-sweep-containment, vector-search-orphan-containment) out of ~20 that create symlinks, and all three are path-containment suites where the symlink is the subject. Tests that create a symlink incidentally do not guard: test/core/usage-policy-symlink.test.js (an entire symlink regression suite, zero win32 mentions), test/core/purge-command.test.js:135, test/core/global-install.test.js:230. There is also no Windows surface to protect: package.json declares no os field but ships launchd and systemd daemon installers only, and all three CI jobs are ubuntu-latest. Adding the guard here would import a convention the repo does not hold, against "smallest change that fixes the problem". Rejected, not deferred: there is nothing to come back to.


LLP verdict

"No new decision doc" was not right, and is now fixed. The PR's own reasoning about LLP 0219 was the load-bearing error: 0219 is Accepted and does settle that the digest is what measures user takeover, and this change moves that. The repo's rule for an Accepted doc is unambiguous, and the precedent is two commits old (LLP 0400, minted by #1665 for the symmetric widening of the same rule). Editing Draft LLP 0397's Consequences bullet was correct and stays; it just was not sufficient on its own, because 0397 is about the refresh riding the update, not about what the digest covers, and it cannot carry a narrowing of another doc's rule. node scripts/llp-numbers.js check: 1 LLP number minted against refs/remotes/origin/master, no collision. The in-tree duplicate-number check is also clean.

CPU and memory pass

  • hashTree, per entry. The continue is placed before path.join, so a skipped entry now costs two Dirent predicate calls and nothing else: no path.join, no path.relative, no hash.update. Strictly less work and strictly less allocation than the o: line it replaces. For an entry that is kept, the predicate count is unchanged (the old ternary chain already called isDirectory()/isFile() twice each). No new allocation, no new retained state, no recursion change.
  • The macro win is the point of the fix and it is real. For a symlink-bearing asset, every boot previously ran fs.rm(dest, -r) + a full copyDir of the tree + a re-hash of the copy + a client-assets.json write. All four are gone, permanently, replaced by the one source hash the unchanged path already pays. The regression test pins the write half by mtime, so it cannot silently come back.
  • No new per-asset write introduced. The diff touches hashTree and comments only; the ledger is still written once after the loop (LLP 0400 #one-write), and my round-1 fix adds no code path at all (one doc, three forward-refs, two @ref lines).
  • Unbounded growth / busy loops: none. Work stays bounded by the ledger, not by uptime, which was already 0397's stated bound and is now actually true for every source tree rather than only for symlink-free ones.

No CPU or memory concern. This change is a net reduction in both.

What I pushed

New head: 02d71118 (was 07265d7f).

  • 41fb9a9a - findings 1 and 2: new llp/0401-the-asset-digest-covers-what-the-copier-carries.decision.md; LLP 0219 Extended-by: gains 0401; LLP 0226 line 110 gains the forward-ref; LLP 0397's bullet points at 0401 and states what the narrowing gives up; two @ref lines in client_asset_ledger.js. No behavior change.
  • 02d71118 - merge of origin/master. Required, not cosmetic: my 0219 Extended-by: edit collides with Fix #1652: A kill mid-refresh-loop leaves every already-rewritten copy recorded under its old digest forever #1665's edit to the same line, and I verified the collision (CONFLICT (content) in llp/0219-...) before resolving it to carry both forward-refs, 0400 and 0401.

Verified the fixes landed by diffing the committed tree against the pre-fix head rather than trusting a green suite: git diff 07265d7f shows the two @ref additions in client_asset_ledger.js at lines 181-182 and 265-266, and grep -n 0401 llp/0219-*.md llp/0226-*.md llp/0397-*.md hits all three. Post-merge sed -n '9p' llp/0219-*.md carries LLP 0226, 0284, 0400 and 0401.

Checks (final head 02d71118)

check result
npm test 6580 tests / 6576 pass / 3 fail / 1 skipped
npm run typecheck clean
node scripts/llp-numbers.js check 1 LLP number minted against refs/remotes/origin/master, no collision
llp-check duplicate-numbers (run by hand) no duplicates

The 3 failures are pre-existing and environmental, all in test/core/hyparquet-floor-pin.test.js (icebird uses the root parquet pins..., every read-path dependency that carries hyparquet is held at the floor, the read path resolves the one root hyparquet...). Reproduced on clean origin/master in a separate worktree: # tests 9 / # pass 6 / # fail 3. Not introduced here. The 1 skip is the unrelated ZSTD-availability test, not the uid-0 refresh skip: this ran as uid 1001, so the refresh suite had full coverage.

Style: no em dashes, no NUL bytes, no semicolons, no new runtime dependencies, no invented config keys or schema fields in any changed file (checked with grep -P '\xe2\x80\x94|\x00' over every file in the diff).

One thing I did not land

The narrowing in finding 1 is recorded in LLP 0401 but not pinned by a test. A test asserting that a symlink added to an installed copy leaves the digest matching (and therefore that the prune removes rather than withholds) would stop it drifting back silently, in the way #1665 pinned its own steady state. Left out deliberately under "stop when tests pass" and "smallest change"; noting it here rather than filing an issue, since it is a test for behavior this PR documents rather than a concrete defect.

`hashTree` now mirrors `copyDir`'s predicate, but the invariant lived only in
the hasher: `fs_copy.js` said nothing about a second reader depending on what
it carries. Widening `copyDir` alone is exactly the edit that reintroduces
issue #1666, and its only symptom is an endless `refreshed` line.

Comment and `@ref` only, no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

philcunliffe commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Review round 2 (final) - head 02d71118

Verdict: approve. The fix is right, the direction is right, the LLP work round 1 added is right, and the merge resolution is correct. Two findings, both low: one landed as a one-line annotation, one deferred to a new issue with counter-evidence that it is pre-existing and byte-identical before and after this PR. No blocker. Round 1's three findings all re-verified independently; none inherited.

Reviewed in a detached worktree at 02d71118 as uid 1001 (not root), so the refresh suite had full coverage. Second worktree at origin/master (0bd00358) for every before/after comparison. code-review skill at effort high, then every finding checked against the tree by hand.


The merge conflict resolution: correct

02d71118 has parents 41fb9a9a (round 1's fixes) and 0bd00358 (origin/master, #1665). git merge-base origin/master HEAD is 0bd00358, so origin/master...HEAD is the whole of the PR and it touches exactly six files.

LLP 0401's scope: matches the code exactly

0401 #digest-covers-the-copy decides "files and directories, and nothing else". The code is if (!entry.isDirectory() && !entry.isFile()) continue (src/core/runtime/client_asset_ledger.js:265), predicate-for-predicate the same two calls copyDir makes (src/core/util/fs_copy.js:22-27), on the same Dirent API, without following the entry. The code does not narrow more than 0401 authorizes, and does not narrow less. The rewritten ternary ('d' : 'f') and the else hash.update(await fs.readFile(full)) branch are exactly equivalent for every entry that survives the continue; the file/directory domain separation 0219 asked for is untouched.

One thing I checked because it would have broken the invariant silently: readdir(..., { withFileTypes: true }) can return UV_DIRENT_UNKNOWN on filesystems with no d_type, and a Dirent of that type answers false to both predicates. Node resolves it - internal/fs/utils.js getDirents lstats any UV_DIRENT_UNKNOWN entry and rebuilds the Dirent from the stats before handing it back - so the predicates are reliable. And even if they were not, copier and hasher would be wrong together, which is the direction the invariant survives.

Statuses are as the corpus requires: 0219, 0226, 0284, 0400, 0401 Accepted, 0397 Draft. LLP 0226's 4+/1- is annotation, not amendment: the original sentence ("...a symlink whose target is gone hashes as an opaque entry by name, same as one whose target exists; nothing about it is unreadable in the first place.") survives verbatim to its final full stop, and a parenthetical forward-ref to 0401 follows it. Nothing 0226 settled changed; #unreadable-is-not-absent still decides what it decided, and the conclusion now rests on a stronger fact (a skipped entry reaches neither try's error path at all). That is the forward-ref latitude CLAUDE.md grants on the applicable parts of an Accepted doc.

The prune behavior change: real, correctly scoped, safe

Not taken on anyone's word. I drove the real materializer three ways on the head and on origin/master, retiring a skill whose copy had been touched:

what the user did to the installed copy origin/master head 02d71118
dropped a symlink in withheld 1, dir kept pruned 1, dir removed
dropped a file in withheld 1, dir kept withheld 1, dir kept
nothing pruned 1 pruned 1

The withheld line in both the unchanged rows is the full warning: retired skill 'alpha' at ... changed since HypAware installed it; left in place - remove it by hand if you no longer want it. So the change is exactly one row wide, and it is the row 0401 #edit-detection-narrows describes.

Whose files can be lost, and what the sequence is. The user drops a symlink (or a fifo, socket, or device node) into a directory HypAware installed under ~/.claude/skills, HypAware later retires that skill, and the prune removes the directory rather than naming it. What is lost is the entry itself, a pointer or a device node. Nothing it points at is touched: fs.rm(dest, { recursive: true, force: true }) lstats and unlinks a symlink rather than following it, which I confirmed directly for both a symlink to a file and a symlink to a populated directory, and again in the table above (the precious.txt outside the tree reads back intact in every row). Any file the user adds, any directory they add, and any edit to the bytes of a file already there all still move the digest and still stop both the prune and the refresh, as the middle row shows.

So the narrowing cannot silently destroy user content; it can destroy a user-made reference inside a directory HypAware installed and has since retired. 0401 states that plainly, including the "(the link with it, never its target)" clause, and states the price honestly against the alternative: covering the entry buys edit detection for one entry kind and pays with a tree that can never settle, is re-copied on every boot, and whose next copy overwrites the user's entry anyway. Correctly scoped and safe. Not a blocker.

The other claims, re-verified

Migration safety - confirmed, my own way. Rather than re-running round 1's spot list, I ran both hashers side by side (pre-fix lifted from origin/master, post-fix from the branch) over every directory in the tree at unbounded depth, 139 roots. Exactly one differs, and it is the repo root, because a git worktree materializes the tracked CLAUDE.md -> AGENTS.md symlink there (git ls-files -s CLAUDE.md -> mode 120000). Independently: find . ! -type f ! -type d over the whole tree returns that one path and nothing else, and git ls-files -s shows exactly one non-100644/100755 mode in the index. The repo root is not a client-asset source; every skills/ and agents/ tree under hypaware-core/plugins-workspace/{claude,codex,gascity} and .claude/skills is symlink-free and digests byte-identically. No recorded ledger digest changes.

The trigger is real, and more reachable than the issue claims. fetchLocalDir copies a local-dir plugin with copyArtifactTree, which recreates symlinks as symlinks (src/core/plugin_install/fetch.js:158-160). So an installed local-dir plugin's skills/ tree genuinely can hold one, and that tree is then the asset.source copyDir reads. This is not a hypothetical entry kind. (It also does not undercut 0401's security argument, which is specifically about materializing a pointer into ~/.claude/skills, a third-party client's content directory, not into HypAware's own plugin state.)

Regression test - non-vacuous, two independent ways.

  • The PR's test file dropped onto clean origin/master code: # tests 20 / # pass 19 / # fail 1, and the single failure is not ok 4 - a source holding a symlink settles instead of re-copying on every boot, expected: 1, actual: 0. So it fails on unfixed code and nothing else fails.
  • git checkout origin/master -- src/core/runtime/client_asset_ledger.js in the merged tree, keeping the test: all three asset suites give # tests 48 / # pass 47 / # fail 1, the same single failure. Restored: 48/48.

The mtime assertion is what makes it a test of LLP 0400 #one-write rather than of the counters: the post-loop write is gated on observed.size > 0 (client_assets.js:310), so a byte-identical rewrite is invisible except through the file's mtime.

Interaction with #1665's healing path - the heal does the right thing, and it now fires. sourceDigest === onDisk is newly reachable for a symlink-bearing source, so #1665's heal gate is too. Driven directly: install a source holding a symlink, corrupt the ledger digest to simulate a pass killed after replaceAsset's swap and before the single ledger write, then refresh. Result {"unchanged":1,"healed":1,"refreshed":0,"skipped":[]}, and the ledger comes back carrying digestClientAsset(dest). That is correct: the copy is ours, being exactly what copyDir produces from the current source. Before this PR that asset could never heal, so the two changes are complementary, not merely compatible. The new false-positive surface is the same one 0401 already documents (a user symlink in the copy makes it digest equal to the source, so the heal reads it as ours), and the heal is keyed by dest, so two sources that now digest alike cannot cross-claim each other's records.

Stale readers of o:<rel> - none. All five digest consumers go through the same digestClientAsset/inspectClientAsset (client_assets.js:171,182,207,289,720,817), nothing parses the hash input, and no test or fixture carries a literal digest. grep for o: framing and "opaque entry" across src/ test/ hypaware-core/ llp/ bin/ docs/ leaves exactly one prose hit outside 0401: llp/0226:110, which is the sentence this PR immediately annotates with the forward-ref. copyDir still has one consumer.


Findings

1. src/core/runtime/client_asset_ledger.js:258-276 - low - hashTree frames each entry's path but not the length of the bytes that follow, so two distinct trees can digest identically. Disposition: defer (issue #1669).

Reproduced, and reproduced identically on origin/master, so the PR neither introduces nor worsens it:

tree A: { a: "",  b: "hello" }    tree B: { a: "f:b\nhello" }
both -> 561a36c026fd225bfef889ab7b15796c46e4d8aba852f55615e7248d6b02573c

Consequence: a user who edits an installed skill's file to end with the exact framing line of the next sorted sibling and deletes that sibling keeps the recorded digest, so pruneOneAsset reaches recorded.has(digest) === true (client_assets.js:864) and fs.rms their copy, and the refresh calls it unchanged (client_assets.js:252). It needs a crafted edit, not an accident, but it is precisely the property the hasher's own JSDoc claims to defend, on a delete path.

Deferred rather than fixed here, for a reason specific to this PR: the obvious repair (frame the byte length) changes every digest the hasher produces, invalidating every digest already in client-assets.json and reporting every installed asset as asset_edited on the next boot. That is the exact migration hazard LLP 0401 #migration rejects the copier-side alternative for, so it needs a migration decision of its own (versioned digest prefix, a one-boot re-record, or an accepted asset_edited wave) rather than a hunk inside a symlink fix. Concrete, consequential, and clearly outside the current task, so filed: #1669.

2. src/core/util/fs_copy.js:12-21 - low - the invariant this PR creates lives only in the hasher; the copier it pins to says nothing. Disposition: fix (landed).

After this change hashTree is defined in terms of copyDir's predicate and carries @ref LLP 0401#digest-covers-the-copy plus a comment naming the copier as the authority. copyDir carried nothing. Its JSDoc said symlinks are skipped "which is what the skill/agent installers want", with no hint that a second reader is pinned to the same two predicates. Widening copyDir alone - teaching it to recreate symlinks, which is the alternative LLP 0401 explicitly contemplates and rejects, so it is a live future edit - makes the digest cover less than the copy carries and reintroduces #1666, with the only symptom an endless refreshed line. This is the "annotate non-obvious decisions" case, not a mechanical ref: it tells a future editor something neither the code nor the filename does.

Fixed in d3133d60 with four lines of JSDoc and one @ref LLP 0401#digest-covers-the-copy [constrained-by] on copyDir. Comment only, no behavior change.

Round 1's three findings, re-checked: finding 1 (mint LLP 0401) and finding 2 (0226 forward-ref) both landed and are correct, as detailed above. Finding 3 (win32 guard on fs.symlink) stays rejected on the same counter-evidence, which I re-ran: grep -rl skipSymlinks test/ returns 3 files, all path-containment suites where the symlink is the subject, out of the ~20 test files that create one; test/core/usage-policy-symlink.test.js is an entire symlink suite with zero win32 mentions; all CI jobs are ubuntu-latest.

Round 1's one deliberate omission (no test pinning the narrowing itself): I looked for a reason to overturn it and found the opposite. A drift back to hashing the skipped entry is already caught, hard, by the new refresh test - I proved that by reverting the file and getting exactly that one failure. A second test asserting the prune now removes rather than withholds would pin the consequence of a change the corpus documents, at the cost of a test whose failure mode is the same as one that already exists. Rejected, not deferred.


What I pushed

New head: d3133d60 (was 02d71118). One commit, src/core/util/fs_copy.js only, comment and @ref.

Verified it landed by diffing the committed tree against the pre-fix head rather than trusting a green suite: git diff 02d71118..HEAD is exactly the one file, +7/-0, showing the four JSDoc lines and the @ref LLP 0401#digest-covers-the-copy [constrained-by] tag. #digest-covers-the-copy resolves (llp/0401-...:43). Not a behavior change, so there is nothing to pin: npm test is unchanged at 6576 pass and typecheck is clean after it.

CPU and memory pass

  • hashTree, per entry. The continue is placed before path.join, so a skipped entry now costs two Dirent predicate calls and nothing else: no path.join, no path.relative, no hash.update, no Buffer. Strictly less work and strictly less allocation than the o: line it replaces. For a kept entry the predicate count is unchanged - the old ternary chain already called isDirectory()/isFile() twice each, and the new one calls them twice total plus the guard's two, so the arithmetic is a wash and the allocation is identical. entries.sort still sees skipped entries, which is one comparison each and no allocation. No recursion change, no new retained state.
  • The macro win is the point of the fix and it is real. For a symlink-bearing asset every boot previously ran fs.rm(dest, -r), a full copyDir of the tree, a re-hash of the copy, and a client-assets.json write. All four are gone permanently, replaced by the one source hash the unchanged path already pays. refreshClientAssets's single post-loop write stays gated on observed.size > 0 (LLP 0400 #one-write), and the new test pins the write half by mtime so it cannot silently come back.
  • Round 2's own change adds comment text and no code, so zero runtime cost.
  • Unbounded growth, busy loops, per-record work: none. Work stays bounded by the ledger rather than by uptime, which was 0397's stated bound and is now actually true for every source tree instead of only symlink-free ones. The one thing that grows with data volume, hashing a tree, is unchanged per kept entry and cheaper per skipped one.

No CPU or memory concern. This change is a net reduction in both.

Checks (final head d3133d60)

check result
npm test 6580 tests / 6576 pass / 3 fail / 1 skipped
npm run typecheck clean
node scripts/llp-numbers.js check 1 LLP number minted against refs/remotes/origin/master, no collision
llp-check duplicate-numbers (run by hand) no duplicates
GitHub CI on 02d71118 all 8 checks pass

The 3 failures are pre-existing and environmental, all in test/core/hyparquet-floor-pin.test.js. Reproduced on clean origin/master in a second worktree: # tests 9 / # pass 6 / # fail 3, the same three. They also pass in GitHub CI on this head, so they are this container, not the branch. The 1 skip is the ZSTD-availability test, not the uid-0 refresh skip: this ran as uid 1001, so the refresh suite had full coverage.

Style: no em dashes, no NUL bytes, no trailing semicolons, no new runtime dependencies, no invented config keys or schema fields, in every file the PR touches and in the file round 2 touched (checked with grep -P '\x{2014}|\x00' over each).

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Finding disposition - head d3133d60

Both review rounds are exhausted, so this is the disposition pass, not another review. All five findings from the two review records, in original order, each re-verified against the current head in a fresh worktree (uid 1001, not root). No blocker. One deferral, four closed as resolved-at-head or disproven.

# finding disposition
1 (R1) LLP 0219 narrowing unrecorded resolved at head - nothing remains
2 (R1) LLP 0226 stale mechanism sentence resolved at head - nothing remains
3 (R1) win32 guard on fs.symlink in the test rejected - counter-evidence re-verified
4 (R2) hashTree framing collision deferred - issue #1669
5 (R2) copyDir carries no invariant annotation resolved at head - nothing remains

1 - resolved. Round 1's repair stands in the tree: llp/0401-the-asset-digest-covers-what-the-copier-carries.decision.md exists, Status Accepted, and its anchors resolve (#digest-covers-the-copy :43, #migration :61, #edit-detection-narrows :69). git diff origin/master...HEAD on LLP 0219 is exactly the one-line Extended-by: append carrying 0401 alongside 0226/0284/0400, the only latitude an Accepted doc allows. client_asset_ledger.js carries @ref LLP 0401#edit-detection-narrows [constrained-by] at :181 and @ref LLP 0401#digest-covers-the-copy [implements] at :265. The narrowing the doc records is real and correctly scoped: I re-confirmed fs.rm unlinks a symlink without following it (targets of a link to a file and to a populated directory both intact after the rm), so the prune change can remove a user-made reference inside a retired HypAware directory, never user content, and file/directory additions and byte edits still withhold.

2 - resolved. LLP 0226 :108-113: the settled sentence survives verbatim to its final full stop, followed by the parenthetical forward-ref to LLP 0401 (4+/1-, annotation not amendment). The @ref LLP 0226#unreadable-is-not-absent at client_asset_ledger.js:223 again resolves to a paragraph matching the code.

3 - rejected, counter-evidence re-run. grep -rl skipSymlinks test/ returns exactly 3 files, all path-containment suites where the symlink is the subject, out of 21 test files that create symlinks; test/core/usage-policy-symlink.test.js (an entire symlink suite) and the PR's new test both have zero win32 mentions; all six CI jobs across ci.yml and llp-check.yml are runs-on: ubuntu-latest. The guard would import a convention the repo does not hold.

4 - deferred to #1669, and the deferral holds on adjudication. Reproduced myself on both heads: {a:"", b:"hello"} and {a:"f:b\nhello"} digest to the same 561a36c0... under digestClientAsset at d3133d60 and on clean origin/master (0bd00358), byte-identical, so this PR neither introduces nor worsens it. Pre-existence alone would not make it deferrable; what does is (a) the obvious repair (frame the byte length) changes every digest the hasher produces, invalidating every recorded client-assets.json digest and marking every installed asset asset_edited on the next boot - the exact migration hazard Accepted LLP 0401 #migration rejects the copier-side alternative for - so the repair needs a migration decision of its own (versioned digest prefix, one-boot re-record, or an accepted asset_edited wave), clearly outside a one-issue symlink fix; and (b) the delete-path consequence needs a deliberately crafted self-edit to a copy the user already owns, never an accident or a remote trigger. Evidenced defect, so it keeps neutral:fix.

Issue identity note: neutral defer-findings keys reuse to the head-keyed marker, so it could not match round 2's #1669 and created #1670. Reconciled to the single identity the record requires: #1670 is closed as a duplicate with its marker defused, #1669 now carries the d3133d60-keyed deferred-finding marker, and a re-run returns {"ordinal":4,"number":1669,"reused":true} creating nothing.

5 - resolved. The head commit itself (d3133d60) is exactly +7/-0 comment lines on src/core/util/fs_copy.js: the JSDoc names hashTree as mirroring the predicate, warns that widening copyDir alone reintroduces #1666, and carries @ref LLP 0401#digest-covers-the-copy [constrained-by], whose anchor resolves.

Checks at d3133d60: all four client-asset suites pass (48/48 across refresh/prune/daemon-refresh, install suite green), npm run typecheck clean. The three hyparquet-floor-pin failures reproduce on clean origin/master here and pass in GitHub CI, so they are environmental. Accepted LLPs 0219, 0226, 0284, 0400 are untouched beyond the forward-ref/annotation latitude; 0397 is Draft and its bullet rewrite is in order; 0401 is the one new doc.

Adjudication consumes no review round; no neutral-review marker is added.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Sep 11, 2026
@philcunliffe

philcunliffe commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Ship risk: low

Who could be affected: Almost nobody: only someone who has hand-placed a shortcut (a symbolic link) inside a skill folder HypAware installed for them.

What could happen:

  • If that skill is later retired or its content changes, HypAware may now clear the folder instead of leaving it alone and warning them, so the shortcut disappears.
  • Everything the shortcut pointed at stays untouched, including whole folders of personal files stored outside the skill folder.
  • Notes, documents, and folders a person adds, and edits to the files already there, are still protected exactly as before.

Why this level: The only thing that can be lost is a shortcut someone made by hand, and re-creating it takes seconds. Nothing is deleted, made inaccessible, exposed, or irreversible. In exchange it fixes a fault where affected setups quietly redid the same copy and rewrote the same record on every start, and could never settle.

What was checked: The real install, refresh, and cleanup paths were run before and after the change and compared, including with personal files outside the skill folder. Existing records were confirmed unchanged, so nothing already installed is misread as edited after upgrading.

@philcunliffe
philcunliffe added this pull request to the merge queue Sep 11, 2026
Merged via the queue into master with commit 0e13366 Sep 11, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-1666 branch September 11, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

copyDir and hashTree disagree on symlinks, so a client-asset source holding one re-copies and rewrites the ledger every boot and can never heal

1 participant