Skip to content

Confirm a clip reached the vault before closing the tab - #19

Merged
mlsimon734 merged 5 commits into
mainfrom
fix/obsidian-launch-confirmation
Aug 3, 2026
Merged

Confirm a clip reached the vault before closing the tab#19
mlsimon734 merged 5 commits into
mainfrom
fix/obsidian-launch-confirmation

Conversation

@mlsimon734

@mlsimon734 mlsimon734 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

The extension cannot tell a completed obsidian:// handoff from a silently refused one. Measured on Firefox 134 with the scheme's network.protocol-handler.* prefs both set and unset, the ephemeral launch tab is byte-identical either way: 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 — a close undo_close reverses, 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.ts resolves 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:

  • A landed verdict 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).
  • It is not a guarantee at all when the check cannot run. unknown is fail-open by design, and there the pre-verification behaviour stands, dropped handoffs included.

What changed

  • The close moves out of the extension. clipAndVerify clips with close: false, verifies, then calls tabs_close — so the undo batch is still written by the one 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 (CLIP_MTIME_SLACK_MS absorbs clock-vs-filesystem skew).
  • obsidian://new never overwrites. A taken name becomes Note 1.md, then Note 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. isClipNoteName matches the numbered variants by exact comparison, because a title may contain regex metacharacters.
  • A note is attributed, not merely counted. Two same-titled pages clipped concurrently ask for the same name and both timestamps precede both writes, so freshness alone let a dropped clip claim the note the other one wrote. verifyClip takes the clipped tab's URL and skips any note whose frontmatter source records 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.
  • Inability to check is never a failure. An unreadable registry, unknown vault, unlistable folder, unstattable note, or unparseable frontmatter returns unknown and behaviour falls back to what it was. Only ENOENT means "not written", which is why readDir/modifiedAt distinguish absent from unreadable rather than collapsing both to null.
  • A close that fails does not discard a confirmed clip. A tab that went away or was renumbered during verification comes back as closed: false with a reason, never as a failed tab_clip — an agent told the clip failed re-clips it, and Obsidian writes the duplicate.
  • parseObsidianVaultEntries keeps 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.
  • clipAndVerify validates 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.

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_clip and 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=true and network.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 that bun run start and start:firefox both 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 reports landed), one-note-one-clip, numbered variants, unreadable folder/note fail-open, trailing-space vault paths, close-failure preservation, malformed close, and tests/clip-source.test.ts pinning Gullet's frontmatter reader against the real markdownForClip output from the side that owns the format.

Refs #18

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.
@mlsimon734
mlsimon734 merged commit 583cb61 into main Aug 3, 2026
1 check passed
@mlsimon734
mlsimon734 deleted the fix/obsidian-launch-confirmation branch August 3, 2026 21:10
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