Confirm a clip reached the vault before closing the tab - #19
Merged
Conversation
The extension cannot tell a completed obsidian:// handoff from a silently refused one. Measured on Firefox 134 with the scheme's protocol-handler prefs both set and unset, the ephemeral launch tab is byte-identical in each case: about:blank, status "complete", no dialog, no error. So tab_clip reported the path it meant to write, a dropped handoff read as success, and close: true closed the tab over a note that was never saved. Gullet can check what the browser cannot — it runs beside the vault and already reads Obsidian's registry for the vault-override check. clip-verify.ts resolves the vault directory and confirms the note before anything is closed. - The close moves out of the extension: clip with close: false, verify, then tabs_close, so the undo batch is still written by the method that owns it and batchId keeps its meaning. - Existence is not proof. Re-clipping an already-filed page would find the old note and vouch for a handoff that never happened, so the note must be newer than a timestamp taken before the request. - obsidian://new never overwrites: a taken name becomes "Note 1.md", then "Note 2.md". The extension only reports the name it asked for, so checking that one path called a landed clip missing on every re-clip. - Inability to check is never a failure. An unreadable registry, unknown vault, or unlistable folder is "unknown" and behaviour is unchanged — the same soft contract as the vault-override check. This does not fix the underlying launch bug (#18); it stops it costing the user the tab, and makes it visible instead of silent. The popup's Devour has no sidecar and is still affected. Refs #18
Follow-ups to 2c152d8, all found by review of that commit: - One note vouches for one clip. Two same-titled pages clipped concurrently ask for the same name and both timestamps precede both writes, so a single fresh note satisfied both verifications and the dropped handoff still closed its tab. The verifier now spends each note path+mtime once, pruning claims below the oldest freshness floor any in-flight verification can hold. Process-local, so two Gullets sharing a browser can still interleave. - A close that fails no longer discards a confirmed clip. tabs_close throws not-found for a tab that went away or was renumbered during verification, and that rejection failed the whole tab_clip over a note already on disk — which invites a re-clip, and Obsidian writes the duplicate. Reported as closed: false with the reason. - Unreadable is not absent. readDir/modifiedAt distinguish ENOENT from a permission or I/O failure instead of collapsing both to null, so a vault Gullet cannot traverse returns unknown rather than missing. This also drops the stat-on-directory probe that stood in for it. - parseObsidianVaultEntries keeps the registry's exact path and trims only the copy the vault name comes from; a trailing space is a legal directory name on macOS and Linux, and the trimmed path checked a directory next to the real vault. - clipAndVerify parses tab_clip's params before rewriting close, so a non-boolean close is a bad-request rather than a silent clip-only call — the MCP transport does not enforce the advertised schema. Verified: bun run check (438 tests).
The claim map added in de3b84a stops one note from vouching for two clips, but it does not decide *which* clip it belongs to: with A dropped and B filed, A can reach the note first, close its unsaved tab, and leave B reporting missing. Separate hub and peer processes are worse — each keeps its own map, so nothing correlates them at all. Every clip already writes an identifier to disk: the `source` property in its frontmatter. verifyClip now takes the clipped tab's URL and skips any note recording a different page, which correlates the handoff with its own write and needs no shared state, so it holds across processes. The claim map stays as the second line of defence, for two clips of the same page — where nothing on disk can tell them apart. Only positive disagreement disqualifies a note: one with no parseable source falls back to freshness, so a format change costs a defence rather than failing real clips. The source line is read to the last quote, because clip-format escapes `"` and nothing else and a URL ending in a backslash is otherwise unparseable. That format is duplicated rather than imported — clip-format reaches browser-typed code and Gullet's tsconfig has no DOM — so tests/clip-source.test.ts pins the two together from the side that owns it. Verified: bun run check (447 tests).
Two Gullet processes clipping one URL at the same moment, with one handoff dropped, both accept the single note: nothing on disk separates the requests and the claim maps are not shared. Documented rather than fixed — see the commit discussion — because the note then holds that same page clipped at that same moment, so what a wrong close costs is drift between two tabs of one URL, not a lost clip.
The tool description promised that "a failed handoff can never cost the user the tab", which is false in both directions: unknown is fail-open by design, so an unreadable registry or unlistable folder leaves the old behaviour — dropped handoffs included — and a landed verdict says a fresh note for the page exists, not that this invocation's extraction is what it holds. Says that instead, in the tool description agents read and in the two places the invariant is documented.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The extension cannot tell a completed
obsidian://handoff from a silently refused one. Measured on Firefox 134 with the scheme'snetwork.protocol-handler.*prefs both set and unset, the ephemeral launch tab is byte-identical either way:about:blank,status: "complete", no dialog, no error. Sotab_clipreported the path it meant to write, a dropped handoff read as success, andclose: trueclosed the tab over a note that was never saved — a closeundo_closereverses, but the clip itself is simply gone.Gullet can answer what the browser cannot: it runs beside the vault and already reads Obsidian's registry for the vault-override check.
clip-verify.tsresolves the vault directory and confirms the note before anything is closed.What the guarantee is, precisely
When the vault is verifiable, Gullet confirms a fresh note for the clipped page before closing. Two limits, both deliberate and both stated in the tool description agents read:
landedverdict means a fresh note for this page exists — not that this exact extraction is what it holds. Concurrent clips of the same URL from separate agent sessions can share one note as evidence (see Residual below).unknownis fail-open by design, and there the pre-verification behaviour stands, dropped handoffs included.What changed
clipAndVerifyclips withclose: false, verifies, then callstabs_close— so the undo batch is still written by the one method that owns it andbatchIdkeeps its meaning.CLIP_MTIME_SLACK_MSabsorbs clock-vs-filesystem skew).obsidian://newnever overwrites. A taken name becomesNote 1.md, thenNote 2.md(verified live), and the extension only reports the name it asked for — so checking that one path called a landed clip missing on every re-clip.isClipNoteNamematches the numbered variants by exact comparison, because a title may contain regex metacharacters.verifyCliptakes the clipped tab's URL and skips any note whose frontmattersourcerecords a different page — no shared state, so it holds across hub and peer processes. Behind that, a claim map spends each note path+mtime once.unknownand behaviour falls back to what it was. OnlyENOENTmeans "not written", which is whyreadDir/modifiedAtdistinguish absent from unreadable rather than collapsing both tonull.closed: falsewith a reason, never as a failedtab_clip— an agent told the clip failed re-clips it, and Obsidian writes the duplicate.parseObsidianVaultEntrieskeeps the registry's exact path and trims only the copy the vault name is derived from; a trailing space is a legal directory name on macOS and Linux, and the trimmed path checked the wrong directory.clipAndVerifyvalidatestab_clip's params before rewritingclose, so a non-booleancloseis abad-requestrather than a silent clip-only call — the MCP transport does not enforce the advertised schema.Residual — accepted, not fixed
Two Gullet processes clipping the same URL simultaneously, with one handoff dropped, both accept the single note: nothing on disk separates the requests and the claim maps are not shared. Closing it would take a per-request marker written into the user's notes — which permanently pollutes a format deliberately matched to Obsidian Web Clipper's — or cross-session serialization of the whole handoff through a new hub lock op. Neither is proportionate to what it costs: the note holds that same page clipped at that same moment, so only drift between two tabs of one URL is lost.
The one option that would restore a per-invocation guarantee without marking notes is a content hash returned by
tab_clipand compared against the file. It needs Obsidian's write fidelity (line endings, trailing newline) measured first, because a hash mismatch has no safe fallback. Not in this PR.Scope
This does not fix the underlying launch bug (#18). On a profile without
network.protocol-handler.external.obsidian=trueandnetwork.protocol-handler.warn-external.obsidian=false, Firefox still drops the handoff — this stops it costing the user the tab in the verifiable case, and makes it visible instead of silent. The popup's Devour has no sidecar and is still affected. Note thatbun run startandstart:firefoxboth pass those prefs, so dev profiles cannot reproduce the original bug.No manifest or permission changes; no UI changes. Sidecar-only, plus one extension-side test.
Verification
bun run check— typecheck, format:check, oxlint, web-ext lint, 447 tests passing.New coverage: attribution under concurrency (dropped clip reports
missing, filed clip reportslanded), one-note-one-clip, numbered variants, unreadable folder/note fail-open, trailing-space vault paths, close-failure preservation, malformedclose, andtests/clip-source.test.tspinning Gullet's frontmatter reader against the realmarkdownForClipoutput from the side that owns the format.Refs #18