Skip to content

Attribute a clip by the text it handed Obsidian - #21

Merged
mlsimon734 merged 3 commits into
mainfrom
feat/clip-content-hash
Aug 8, 2026
Merged

Attribute a clip by the text it handed Obsidian#21
mlsimon734 merged 3 commits into
mainfrom
feat/clip-content-hash

Conversation

@mlsimon734

@mlsimon734 mlsimon734 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Summary

tab_clip accepts a vault override, and until now nothing could tell a caller that the name was wrong. The handoff is an obsidian:// URL given to the OS: an unrecognised vault fails inside Obsidian, where neither end of the bridge can observe it, and the call still reports success.

Gullet now checks an explicit vault name against Obsidian's local obsidian.json registry before forwarding it. A readable, understood registry that does not contain the name produces a bad-request listing the vaults that registry knows.

Deliberately a soft check

The registry is undocumented and incomplete, and its location varies outside a standard install. So absent, unreadable, malformed, unfamiliar, or empty (vaults: {}) all preserve the old pass-through behavior — only a registry that positively contradicts the name refuses the call. An empty registry proves nothing about a name the user read off their own vault switcher, so it belongs with the other soft cases rather than producing a rejection listing "(none)".

The error describes known vaults rather than claiming to enumerate every vault on disk. The extension still cannot validate the destination.

Parsed contents are cached by modification time and size (one stat() per call), so a vault added while Gullet is running appears on the next changed-file check.

Notes

  • knownObsidianVaults is optional on ToolContext, matching rivalHubs? — it can refuse a call it can prove wrong, but an absent lookup never changes one, so construction sites need no stub.
  • parseVaultOverride is exported so the vault check reads only the vault field. It previously ran the whole parseTabClipParams, meaning Gullet re-validated tabId and close — which the extension owns — and reported those errors from a function named for the vault.
  • The existing vaultWarningFor path-vs-name check is unchanged.

Verification

bun run typecheck, bun test (398 pass), bun run format:check, bun run lint:js.

No manifest or permission changes. No UI changes.


Live verification (added after opening)

Done against a plain Firefox 134 carrying this build, attached to the same hub as my
real Zen — the hub serves both connections at once, so nothing about my session was
disturbed. The verifier ran in freshly spawned Gullet processes built from this branch.

  1. The hash survives the round trip. Clipped a Wikipedia article; the extension
    reported contentHash: cb7e769…, and re-hashing the file Obsidian wrote gave the
    same digest. That is WebCrypto in the browser agreeing with Bun.CryptoHasher on the
    filesystem, through the OS clipboard and the obsidian:// handoff — the one link the
    unit tests cannot reach.
  2. A wrong hash is refused, against the real note on disk. Same file, same reader
    code, only the expected digest altered: landedmismatched. With no evidence at
    all the same note still verifies landed, which is the pre-hash behaviour and shows
    the hash is what does the discriminating.
  3. The residual scenario itself. Two separate Gullet processes clipping two tabs of
    the same URL simultaneously: both landed, on distinct notes, and each reported
    hash matched a different file (21f2810f…Crocodile 2.md, f1746a68…
    Crocodile 3.md). Before this branch there was nothing to tell those two apart.

Incidentally: two processes clipping the same tab at once left one clip fine and the
other extract-failed — the same family as #20, and not something this branch changes.


Also carries a one-line .gitignore fix: dev browser profiles were ignored three-by-name, so the .dev-profile-live this testing created was untracked but not ignored — and those profiles hold the bridge token in extension storage. Now .dev-profile*/.

The source-URL check tells two clips of different pages apart, but not two
clips of the SAME page racing from two Gullet processes: same source, and
the claim maps are per-process. tab_clip now returns contentHash — SHA-256
of exactly what it handed over — and a note that does not hash to it was
written by some other clip. That identifies the invocation rather than the
destination, so it needs no shared state and no marker in the user's notes.

Measured before built, in both clip modes and both of the user's real
vaults as well as a bare one, at 238 B and 50 kB: Obsidian writes back
byte-identical text — no trailing newline added, no line endings rewritten,
no truncation. That measurement is what makes a mismatch trustworthy enough
to disqualify a note, and if it ever stops holding the symptom is every
clip reporting mismatched, which is a verdict of its own precisely so the
message can name that possibility.

Also from the live run: Obsidian creates a note and fills it a beat later,
and an empty file has no parseable source, so the fallback read it as ours.
An empty note now never vouches for a clip.

The hash is never required — an older extension sends none and attribution
falls back to source. The extension hashes with WebCrypto and Gullet with
Bun's hasher, so tests/clip-source.test.ts pins them together; nothing else
would catch an encoding or digest change.

ClipVerifier takes an evidence object rather than four positional
arguments, which is the simplification pass deferred from #19.

Verified: bun run check (456 tests).
The content fingerprint was taken over the LF text the extension
composed, but in the default clipboard mode that text reaches Obsidian
through the OS clipboard, and Windows carries plain text as
CF_UNICODETEXT with CRLF. The note on disk therefore has endings the
extension never produced, so every landed clip on Windows would hash
differently, report `mismatched`, leave the requested close undone, and
send the agent back to re-clip a page Obsidian had already filed.

Both hashers now fold CRLF to LF first. Line endings are transport, not
content; the only thing given up is telling apart two clips that differ
in nothing else, and there is no such pair.

The cross-boundary test was proving less than it claimed: it hashed with
its own WebCrypto copy, so it only ever showed that Bun agrees with the
test. It imports the real extension helper now — which is why that
helper moves out of bridge-methods.ts, whose import graph reaches
`browser`, into its own pure module — and covers both the CRLF route and
a pair that must still hash apart.

`tab_clip`'s MCP description still told agents a verified clip is no
proof of the extraction and that concurrent same-URL sessions can share
evidence. With a contentHash present that has not been true since
c6a6a70; it now splits the guarantee by whether the result carries one.
@mlsimon734
mlsimon734 merged commit ddf6b94 into main Aug 8, 2026
1 check passed
@mlsimon734
mlsimon734 deleted the feat/clip-content-hash branch August 8, 2026 01:27
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