Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ dist-firefox/
dist-chrome/
.DS_Store
*.log
.dev-profile/
.dev-profile-firefox/
.dev-profile-chrome/
# Dev browser profiles. Wildcard rather than one line per profile: these carry
# the bridge token in extension storage, and a profile added later must not be
# one `git add -A` away from committing it.
.dev-profile*/
.claude/settings.local.json
.claude/skills/
.agents/
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ This is a Bun-powered TypeScript WebExtension for Zen Browser, Firefox, and Chro

Global Gullet settings live at `${XDG_CONFIG_HOME:-$HOME/.config}/tabglutton/config.json`, with the token in a separate `0600` file by default. The config is deliberately safe to commit: an inline `"token"` key is rejected even when a CLI or environment token would otherwise win. Keep the additive token precedence (`--token` → env → `./.env` → `tokenCommand` → `tokenFile` → default file). `tokenCommand` is bounded and lazy; its timeout/nonzero error, including stderr, goes through `Supervisor.fault()`, and the supervisor retries with backoff so unlocking a secret manager heals the existing MCP session. Do not move command execution ahead of that recoverable startup path.

**A clip is confirmed on disk before anything is closed, because the browser cannot confirm it.** A refused `obsidian://` launch is indistinguishable from a successful one from inside the extension: the ephemeral launch tab sits at `about:blank` with `status: "complete"` in both cases, raises no dialog and fires no error (measured on Firefox 134 with the scheme's `network.protocol-handler.*` prefs both set and unset — the tab samples are byte-identical). So `openObsidianUrl` reports the path it _intended_ to write, a dropped handoff read as success, and `tab_clip({ close: true })` would close the tab over a note that was never saved — a close `undo_close` can reverse, but the clip itself is simply gone. Gullet can answer what the browser cannot, because it runs beside the vault and already reads Obsidian's registry: `clip-verify.ts` resolves the vault directory and looks for the note. Six things are load-bearing. **The close is taken away from the extension**: `clipAndVerify` requests the clip with `close: false`, verifies, and only then calls `tabs_close`, so the undo batch is still written by the one method that owns it and `batchId` keeps its meaning — and that close is allowed to fail. The note is already on disk by then, so 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. **Existence is not proof** — re-clipping a page that was already filed would find the _old_ note and vouch for a handoff that never happened, so the note must have been written since a timestamp sampled before the request, with `CLIP_MTIME_SLACK_MS` absorbing the gap between our clock and the filesystem's. **`obsidian://new` never overwrites**: handed a name that is taken it writes `Note 1.md`, then `Note 2.md` (verified live), and since the extension only ever reports the name it _asked_ for, checking that one path called a landed clip missing the second time a page was filed — `isClipNoteName` matches the numbered variants, by exact string comparison because a note 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 the dropped clip claim the note the other one wrote — and close its tab over nothing. The note says which page it holds, in the `source` property `markdownForClip` writes, so `verifyClip` takes the clipped tab's URL and a note recording a different page never vouches. That needs no shared state, so it holds across the hub/peer processes a claim map cannot reach. `noteSourceUrl` reads the value to the _last_ quote on the line, because the writer escapes `"` and nothing else; the format is duplicated rather than imported (clip-format's import graph reaches browser-typed code, and Gullet has no DOM) and `tests/clip-source.test.ts` pins the two together. Only positive disagreement disqualifies — an unparseable note falls back to freshness, so drift costs a defence rather than failing real clips. Behind that, **one note still vouches for one clip**: the verifier spends each note path+mtime once, which is all that is left when two clips of the _same_ page race. That leaves one accepted residual — two _processes_ clipping one URL simultaneously with a dropped handoff, where nothing on disk separates the requests and the claim maps are not shared. Closing it needs a per-request marker in the user's note or cross-session serialization of the handoff, and neither is worth it for 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. **State the guarantee at its real strength**: a `landed` verdict means a fresh note for this page exists, not that this exact extraction is what it holds — and it means nothing at all when the check cannot run, because `unknown` is fail-open and the pre-verification behaviour stands there, dropped handoffs included. The tool description, this file, and any release note say that; "a failed handoff can never cost the user the tab" is false in both directions and has been removed. A content hash returned by `tab_clip` and compared against the file is the one option that would restore a per-invocation guarantee without marking the user's notes — it needs Obsidian's write fidelity (line endings, trailing newline) measured first, because a mismatch has no safe fallback. **Inability to check is never a failure**: an unreadable registry, an unknown vault name, an unlistable folder, or an unstattable note returns `unknown` and behaviour falls back to what it was, the same soft contract as the vault-override check — only `ENOENT` means "not written", which is why `readDir`/`modifiedAt` distinguish absent from unreadable rather than collapsing both to `null`. Only a folder that exists and provably lacks the note is `missing`. For the same reason `parseObsidianVaultEntries` stores 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 a trimmed path checks the wrong directory.
**A clip is confirmed on disk before anything is closed, because the browser cannot confirm it.** A refused `obsidian://` launch is indistinguishable from a successful one from inside the extension: the ephemeral launch tab sits at `about:blank` with `status: "complete"` in both cases, raises no dialog and fires no error (measured on Firefox 134 with the scheme's `network.protocol-handler.*` prefs both set and unset — the tab samples are byte-identical). So `openObsidianUrl` reports the path it _intended_ to write, a dropped handoff read as success, and `tab_clip({ close: true })` would close the tab over a note that was never saved — a close `undo_close` can reverse, but the clip itself is simply gone. Gullet can answer what the browser cannot, because it runs beside the vault and already reads Obsidian's registry: `clip-verify.ts` resolves the vault directory and looks for the note. Six things are load-bearing. **The close is taken away from the extension**: `clipAndVerify` requests the clip with `close: false`, verifies, and only then calls `tabs_close`, so the undo batch is still written by the one method that owns it and `batchId` keeps its meaning — and that close is allowed to fail. The note is already on disk by then, so 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. **Existence is not proof** — re-clipping a page that was already filed would find the _old_ note and vouch for a handoff that never happened, so the note must have been written since a timestamp sampled before the request, with `CLIP_MTIME_SLACK_MS` absorbing the gap between our clock and the filesystem's. **`obsidian://new` never overwrites**: handed a name that is taken it writes `Note 1.md`, then `Note 2.md` (verified live), and since the extension only ever reports the name it _asked_ for, checking that one path called a landed clip missing the second time a page was filed — `isClipNoteName` matches the numbered variants, by exact string comparison because a note 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 the dropped clip claim the note the other one wrote — and close its tab over nothing. The note says which page it holds, in the `source` property `markdownForClip` writes, so `verifyClip` takes the clipped tab's URL and a note recording a different page never vouches. That needs no shared state, so it holds across the hub/peer processes a claim map cannot reach. `noteSourceUrl` reads the value to the _last_ quote on the line, because the writer escapes `"` and nothing else; the format is duplicated rather than imported (clip-format's import graph reaches browser-typed code, and Gullet has no DOM) and `tests/clip-source.test.ts` pins the two together. Only positive disagreement disqualifies — an unparseable note falls back to freshness, so drift costs a defence rather than failing real clips. Behind that, **one note still vouches for one clip**: the verifier spends each note path+mtime once, which is all that is left when two clips of the _same_ page race. **The clip's own text is what closes the last gap.** Two _processes_ clipping one URL simultaneously defeat both of the above — same source, and the claim maps are not shared — so `tab_clip` returns `contentHash`, the SHA-256 of exactly what it handed Obsidian, and a note that does not hash to it was written by some other clip. That identifies the invocation rather than the destination, needs no marker in the user's notes, and holds across processes. It rests on a measurement, not an assumption: `obsidian://new` was handed known bytes and the file compared, in both clip modes, at 238 B and 50 kB, in the `test` vault and in both of the user's real (plugin-bearing) vaults — byte-identical every time, no trailing newline added, no line endings rewritten, no truncation. **That measurement is macOS-only, and one platform is known to break it**: in the default clipboard mode the text crosses the _OS clipboard_, and Windows carries plain text as `CF_UNICODETEXT` with CRLF, so the note on disk has endings the extension never composed. Both hashers therefore fold `\r\n` to `\n` first (`src/clip-hash.ts`, `clipContentHash` in `clip-verify.ts`) — line endings are transport, not content, and the cost is only the ability to tell apart two clips that differ in nothing else. **If byte-identity fails for any other reason the symptom is every clip reporting `mismatched`**, which is why that verdict exists as its own answer and says so in its message. 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 the two implementations together — importing the extension's real helper rather than restating it, since a restated copy would prove only that Bun agrees with the test — and nothing else would catch an encoding, digest, or normalization change, whose symptom would again be every clip unconfirmed. That test is also why the hash lives in its own pure module instead of in `bridge-methods.ts`, whose import graph reaches `browser`. **An empty note is never a landed clip**: Obsidian creates the file and fills it a beat later (measured live in both real vaults), and without that guard the empty file is exactly the one with no parseable `source`, which the fallback would have read as ours. **State the guarantee at its real strength**: hashed, a `landed` verdict means this exact text is on disk; unhashed, only that a fresh note for this page is; and it means nothing at all when the check cannot run, because `unknown` is fail-open and the pre-verification behaviour stands there, dropped handoffs included. "A failed handoff can never cost the user the tab" is false in both directions and must not come back. **Inability to check is never a failure**: an unreadable registry, an unknown vault name, an unlistable folder, or an unstattable note returns `unknown` and behaviour falls back to what it was, the same soft contract as the vault-override check — only `ENOENT` means "not written", which is why `readDir`/`modifiedAt` distinguish absent from unreadable rather than collapsing both to `null`. Only a folder that exists and provably lacks the note is `missing`. For the same reason `parseObsidianVaultEntries` stores 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 a trimmed path checks the wrong directory.

The underlying launch bug is [#18](https://github.com/mlsimon734/tabglutton/issues/18) and is **not fixed** by any of this — on a profile without `network.protocol-handler.external.obsidian=true` and `network.protocol-handler.warn-external.obsidian=false`, Firefox still drops the handoff, and the popup's Devour has no sidecar to check it. Note that `bun run start` and `start:firefox` both pass those prefs, so **dev profiles cannot reproduce it**; that is why it went unnoticed. Verification turns the silent failure into a reported one for the bridge path only.

Expand Down
Loading