Repository navigation
Troubleshooting
This page is a symptom-first guide: find what you are seeing in the table, jump to the section, and each section gives the cause, one diagnosis command, and the fix.
Two rules before you start. First, never test a fix against your live profile; every path in the applier is overridable with RAINDROP_SYNC_PROFILE and RAINDROP_SYNC_ROOT, and the throwaway-profile method is in RESEARCH.md section 12. Second, never print the token while debugging; a token that reached a terminal must be rotated (Operations). Healthy log output, for comparison, is also on the Operations page.
Since 1.1.0, run the doctor first:
~/.raindrop-sync/bin/doctor.shprints oneok,warnorFAILline per check (interpreter, tree, key, manifests, host ping through the wrapper, profile, Brave Sync, launchd, token presence, ledger tables, extension contact) and never prints the token. Most entries below start from aFAILline it gives you.
| What you see | Where | Most likely cause | Section |
|---|---|---|---|
Specified native messaging host not found |
worker console, or a native_host.log with no host start lines |
host manifest in Brave's directory instead of Chrome's, or a broken manifest field | Host not found |
| Card greyed out, toggle will not stay on | brave://extensions |
the extension id was poisoned by a CRX install | Extension greyed out |
| Bookmarks appear, then vanish | the bar; apply.out.log shows OK then to apply again |
the file writer lost a race with a running Brave | Appear then vanish |
| Nothing appears at all, no errors | everywhere | nothing staged, stale worker, agent not loaded, preflight abort, or the ledger says it is done | Nothing appears |
classified_count in the dozens or hundreds every run |
Phase A summary | hash over curated values, a timestamp watermark, a widened hash, or newest-first paging | Reclassifies every run |
| Two identical bookmarks, same second | the bar | the MV3 serialisation lock or the URL match was removed | Duplicates |
| Brave starts with an empty bar, like a fresh profile | Brave | a malformed Bookmarks file |
Empty bar after a write |
launchctl list shows nothing, or logs never advance |
apply.out.log stale |
agent never bootstrapped, bad absolute paths, moved interpreter, TCC denial | Agent never runs |
| Skill reports the Raindrop tools are missing | a Claude Code session | headless or scheduled session has no connector | Headless session |
pending[unknown] in the host log |
native_host.log |
a service worker running old code | Shorter ones |
ABORT: ... every tick |
apply.out.log |
a deliberate preflight refusal | Shorter ones |
No Raindrop token found |
Phase A, or the verify step | token never seeded, or seeded in a store the resolver does not consult | Shorter ones |
Symptom. The card looks healthy, nothing is ever applied, and native_host.log has no host start line in the last minute with Brave open. If you can reach the worker console it says Specified native messaging host not found.
Cause. Brave does not override Chromium's stock product path for native-messaging lookup on macOS, so a manifest under BraveSoftware/Brave-Browser/NativeMessagingHosts/ is silently ignored. It must be in Chrome's directory, ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/, whether or not Chrome is installed. The same generic error also covers four manifest mistakes, each silent: the filename does not equal the name field; path is not absolute or not executable; allowed_origins lacks the trailing slash; or the id in allowed_origins is not the id on the card (a regenerated key.pem, or a manifest without a pinned key after the folder moved).
Diagnose.
# is the manifest where Brave looks, and does it name the id on the card?
python3 - <<'EOF'
import json, os
p = os.path.expanduser("~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.raindrop_sync.host.json")
m = json.load(open(p)); print("name:", m["name"]); print("path:", m["path"], "executable:", os.access(m["path"], os.X_OK))
print("allowed_origins:", m["allowed_origins"])
EOF
cat ~/.raindrop-sync/.extension-identity.json # the id bootstrap derived from key.pemThen prove the host itself works, bypassing the browser:
python3 -c 'import struct,sys; m=b"{\"op\":\"ping\"}"; sys.stdout.buffer.write(struct.pack("@I",len(m))+m)' \
| ~/.raindrop-sync/bin/native_host.py | tail -c +5
# expect: {"ok": true, "version": "<VERSION from src/native_host.py>"}If that answers and the extension still does nothing, the problem is the manifest, not the host. The decisive evidence is Brave's own stderr from a throwaway profile:
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \
--user-data-dir=/tmp/lab --load-extension="$HOME/.raindrop-sync/extension" \
--enable-logging=stderr --v=0 about:blank 2>&1 | grep -iE "native|raindrop"
# decisive line: launch_context.cc:148 Can't find manifest for native messaging hostFix. Re-run bootstrap.sh. It is idempotent, reuses your key.pem, and rewrites both manifest copies with the current id and an absolute path. Then press reload on the card. If you prefer to hand-fix, follow SETUP.md step 4 and its four rules.
Read more. RESEARCH.md finding 2, verified by a single-variable experiment.
Symptom. The card shows the extension disabled. Clicking the toggle does nothing or it flips back. brave://extensions gives no reason.
Cause. At some point a locally signed CRX with this id was installed. Chromium's install verifier flagged the id DISABLE_NOT_VERIFIED (disable_reasons: [256]) against the signed allowlist in Preferences, and every attempt to re-enable is reverted. The flag follows the id, not the installation: loading the same folder unpacked inherits it. No UI, flag or profile edit clears it.
Diagnose. Read Preferences directly; the UI hides the field you need.
python3 - <<'EOF'
import json, os
p = json.load(open(json.load(open(os.path.expanduser("~/.raindrop-sync/config.json")))["profile"] + "/Preferences"))
for ext_id, s in p["extensions"]["settings"].items():
if s.get("disable_reasons"):
print(ext_id, "location:", s.get("location"), "disable_reasons:", s["disable_reasons"])
EOF256 next to your id means the identity is gone, not the code.
Fix. Mint a new id. bootstrap.sh treats a missing key.pem as a request for one:
rm ~/.raindrop-sync/key.pem
cd <your checkout> && ./scripts/bootstrap.sh # generates a key, rewrites manifest.json "key" and both host manifests, prints the new idThen in brave://extensions Remove the dead card (do not just reload it), Load unpacked ~/.raindrop-sync/extension again, and confirm the card shows the id bootstrap printed. Never pack a CRX again, not even to test.
Read more. RESEARCH.md finding 3, verified in Preferences and escaped only by a new id.
Symptom. apply.out.log says OK: N applied, the nodes are in the file, and a few seconds later, or after quitting Brave, they are gone. The next tick logs N to apply again.
Cause. The file writer wrote while Brave was running. Brave reads Bookmarks exactly once at startup and thereafter its in-memory model is authoritative; about 2.5 s after any bookmark change, and again at quit, it serialises that model over the file, which never contained your nodes. The writer already told you:
NOTE: Brave is running. It may overwrite this from memory; the next tick re-applies.
This is not a data-loss risk to anything you made. Both writers are atomic, so the file is never torn; the only casualty is the pending addition, and the ledger still wants it.
Diagnose.
grep -E "to apply|OK:|NOTE:|already present" ~/.raindrop-sync/log/apply.out.log | tail -8A pattern of to apply, OK, NOTE, then to apply again is the race, not a bug. It ends with all N promotions already present and marked on the first quiet tick or after the next Brave start.
Fix. Nothing. If you want it to land now rather than on the next quiet tick, that is what the extension is for: it writes through the model instead of around it, and the file writer then logs "already present". Two things not to do: do not kill Brave (the quit-time save is exactly what discards the write, and it is hostile besides), and do not add a Brave-is-running gate to the writer; the retry is the mechanism.
Read more. RESEARCH.md finding 4, verified with a probe extension. The debounce constant is a Chromium internal; do not depend on the number.
Symptom. No errors anywhere, no new bookmarks, both appliers apparently idle.
Causes and diagnosis, in the order worth checking.
-
Nothing is staged. The promotion gate is strict; classifying many and promoting none is normal.
python3 -c 'import json,os;print(len(json.load(open(os.path.expanduser("~/.raindrop-sync/desired.json")))), "staged")' -
The host is never invoked. With Brave open,
tail -3 ~/.raindrop-sync/log/native_host.log. Nohost startwithin the last minute means the extension is not reaching the host: go to Host not found. -
The worker is running old code. Unpacked extensions do not hot-reload. If you edited
sw.jsand did not bumpmanifest.json'sversionand press reload on the card, the old worker is still live. Compare the version on the card with~/.raindrop-sync/extension/manifest.json. This is the most common cause after a code change. -
The ledger says it is already done. The host only sends items not recorded for this client. After a profile reset, a new machine, or a folder you deleted by hand,
extension_appliedcan saycreatedfor something the browser no longer has.sqlite3 ~/.raindrop-sync/state.db "SELECT client, status, COUNT(*) FROM extension_applied GROUP BY 1,2;"
Fix: delete the rows for the affected items (or all of them); the extension re-checks by URL, so the worst outcome is one tick of
already_present. -
The launchd agent never fired.
launchctl list | grep raindrop. A missing line means it was never bootstrapped; a non-zero exit column means it ran and failed. See Agent never runs. -
Preflight aborted.
grep ABORT ~/.raindrop-sync/log/apply.out.log | tail -3. Each abort names its reason and is a deliberate refusal, repeated every tick until you clear the condition. See Shorter ones.
If the extension is off or Brave is closed, remember the fallback only becomes visible at the next Brave start; a file write made while Brave is running may take a few ticks to stick (Appear then vanish).
Symptom. The classified_count canary is huge on a library that barely changed: dozens or hundreds per run. The LLM cost is real and it never settles.
Cause. Something is re-selecting the library. In order of likelihood:
-
The ledger hashes curated values. This bug shipped once: the first run stored the cleaned title and link (emoji stripped, entities decoded, tracking parameters removed) and computed
content_hashfrom those. A hash of an edited record can never be reproduced from the raw source, so about a quarter of the library re-flagged as EDITED on the next run and would have forever. -
A timestamp watermark. Raindrop bumps
lastUpdateon every write including your own write-back, so anylastUpdate-based selection returns everything you just wrote, every run. -
A widened hash. Adding a field to the hash input invalidates every stored hash at once. That is a
taxonomy_version-class change and must be explicit, never a silent upgrade. - Newest-first paging. A bookmark saved mid-run shifts the window and items fall between pages; that produces phantom NEW and phantom missing items rather than a stable delta.
-
NULL hashes. Rows with no
content_hashare blind to edits and may be selected every run. A missing REST token is not a reason for a NULL: link and title come from the classifier's data source, and the emptyexcerpt|note|typeslots are empty by design.
Diagnose.
sqlite3 ~/.raindrop-sync/state.db "SELECT COUNT(*) AS null_hashes FROM bookmarks WHERE content_hash IS NULL;"Then pick one row that re-flagged, take its raw link and title from Raindrop (not from the ledger's display columns), and recompute. The documented format is sha256(raw_link|raw_title|||), three trailing pipes for the always-empty slots; confirm your reconstruction against a known-good row before trusting it:
python3 -c 'import hashlib,sys; print(hashlib.sha256(f"{sys.argv[1]}|{sys.argv[2]}|||".encode()).hexdigest())' '<raw link>' '<raw title>'A mismatch on a row whose Raindrop record did not change means the stored hash was computed from something other than the raw values.
Fix. Restore the invariant: two columns that intentionally disagree.
| column | holds | used for |
|---|---|---|
content_hash |
sha256(raw_link|raw_title|||), verbatim Raindrop values |
delta detection only |
title, link
|
the curated display values | browser naming, desired.json, reading |
Recompute the affected hashes from raw source values, validate against known-good rows, and only then run again. Never "repair" the two columns into agreement by recomputing the hash from the curated columns; that is precisely how the bug is reintroduced. Page ascending (sort=created). If you genuinely need a wider hash, do it as an explicit re-derivation that bumps taxonomy_version. Details of the ledger are in Ledger and File Formats; the classification rules are in Taxonomy and Classification.
Read more. RESEARCH.md finding 7. The curated-hash failure is marked verified the hard way; the changedBookmarksDate gate is marked untested, which is why Phase A does a full read every run.
Symptom. Two identical bookmarks, same title and URL, created within the same second.
Cause. There are two routes, and they leave different fingerprints.
-
The MV3 lifecycle race. Worker load and
onInstalledcan fire in the same millisecond; each ran the existence check, each saw the URL absent, each calledchrome.bookmarks.create().chrome.bookmarkshas no uniqueness constraint, no upsert and no transaction.sw.jsfunnels every entry point through a single promise chain,serialize(); if that lock was removed, or a second entry point was added that bypasses it, this race is back. -
The two appliers duplicating each other. The extension cannot write the file's
meta_infomarker, so a marker-only or GUID-only ownership check in the file writer does not see extension-created nodes and re-creates them.apply_brave.pytherefore also matches on URL; if that match was removed, every extension-applied item gets a marked twin on the next quiet tick.
Diagnose.
# route 1: the host saw the work twice in the same tick window
grep -E "applied\[|created folder" ~/.raindrop-sync/log/native_host.log | tail -6
grep -n "serialize" ~/.raindrop-sync/extension/sw.js # the lock must wrap every entry point
# route 2: the file writer created a node whose URL already existed
grep -E "to apply|OK:" ~/.raindrop-sync/log/apply.out.log | tail -4
grep -n "urls" ~/.raindrop-sync/bin/apply_brave.py # the URL index and the "or d[\"url\"] in urls" matchOne of the twins will carry meta_info and a deterministic uuid5 GUID (the file writer's), the other neither (the extension's). The read-only marker listing on the Operations page shows which is which.
Fix. Restore whichever guard is missing, reload the extension (bump the version), and delete the extra copy by hand in Brave; nothing in this tool deletes, including its own duplicates. Do not "fix" the race by adding a delay: that moves it, it does not close it.
Read more. RESEARCH.md finding 8 and finding 9, both verified. The ownership split is explained in Safety and Ownership Model.
Symptom. Brave starts with an empty bookmarks bar and behaves like a fresh profile. No dialog, no error, no warning.
Cause. A malformed Bookmarks file. On a parse failure, version != 1, or a missing permanent root, Chromium records a metric and loads three empty permanent nodes. That is the entire user-visible feedback. Worse, the first save of that session copies the broken file over Bookmarks.bak, destroying the last good copy; Chromium never reads .bak back and has no restore path anywhere.
The file writer's postflight (re-read from disk, verify parse, checksum, id and GUID uniqueness, restore the backup on any failure) exists so this cannot come from the writer. If you see it, something else wrote the file, or the writer was modified. Either way, the recovery is the same.
Diagnose, quickly, because the clock is running.
# 1. Quit Brave completely (Cmd-Q) BEFORE it saves. Confirm:
pgrep -x "Brave Browser" # expect no output
# 2. Confirm the live file is the problem
python3 -c 'import json,os,sys
p=json.load(open(os.path.expanduser("~/.raindrop-sync/config.json")))["profile"] + "/Bookmarks"
d=json.load(open(p)); print("version",d.get("version"),"roots",sorted(d.get("roots",{})))' \
|| echo "does not parse"
# 3. Find the newest backup the writer took
ls -t ~/.raindrop-sync/backups/ | headFix. Restore from the writer's own timestamped backups, after proving the backup parses:
B=~/.raindrop-sync/backups/Bookmarks.<TIMESTAMP>
python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print("version",d["version"],"roots",sorted(d["roots"]))' "$B"
cp "$B" "$(python3 -c 'import json,os;print(json.load(open(os.path.expanduser("~/.raindrop-sync/config.json")))["profile"])')/Bookmarks"
# start Brave and confirm the bar is backIf you are modifying the writer, keep every step of backup, atomic replace, re-read and verify. They are the only error surface this failure has.
Read more. RESEARCH.md finding 6 and finding 5. Both are source-derived and designed against, deliberately not reproduced on a populated profile; a reproduction on a throwaway profile is a welcome contribution (Contributing and Roadmap).
Symptom. launchctl list | grep raindrop prints nothing, or prints a non-zero exit status, or apply.out.log has not gained a line in more than 15 minutes.
Causes.
-
Never bootstrapped. The plist is on disk but was never loaded, or the session was rebooted after a
bootout. -
Paths in the plist are not absolute. launchd does not expand
~or$HOME. The template uses placeholders thatbootstrap.shfills in; a hand-written plist with~/.raindrop-sync/...silently never starts. -
The interpreter moved.
/opt/homebrew/bin/python3is a symlink that moves onbrew upgrade. The recommended interpreter is/usr/bin/python3; the scripts are pure stdlib. -
TCC denial. A runtime tree under
~/Documentsor~/Downloadscan be denied to launchd with a bareOperation not permittedand no dialog.~/.raindrop-syncand the Brave profile directory are not protected paths. -
The grep pattern. The label is
com.raindrop-sync.apply, with a hyphen. A pattern likeraindropsyncmatches nothing even when the agent is fine.
Diagnose.
launchctl list | grep raindrop
launchctl print gui/$(id -u)/com.raindrop-sync.apply | grep -E "state|last exit|program|arguments"
plutil -lint ~/Library/LaunchAgents/com.raindrop-sync.apply.plist
grep -A1 -E "<string>/" ~/Library/LaunchAgents/com.raindrop-sync.apply.plist | head # every path absolute?
ls -l /usr/bin/python3
cat ~/.raindrop-sync/log/apply.err.logFix. Re-run bootstrap.sh (it rewrites the plist from the template with absolute paths and reloads it), or load it by hand and force one run:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.raindrop-sync.apply.plist
launchctl kickstart -p gui/$(id -u)/com.raindrop-sync.apply
tail -2 ~/.raindrop-sync/log/apply.out.logExpected launchctl list line: - 0 com.raindrop-sync.apply (not running right now, last exit 0). If apply.err.log shows Operation not permitted, move the runtime tree back to ~/.raindrop-sync rather than granting anything. If you must use Homebrew's Python, name the versioned path, never the bin symlink.
Read more. SETUP.md step 6 and the TCC note in SETUP.md step 1. The plist template is launchd/com.raindrop-sync.apply.plist.template.
Symptom. /raindrop-sync runs from a scheduled, background or otherwise non-interactive Claude Code session and reports that find_bookmarks or update_bookmarks are unavailable, or it stalls at the fetch step.
Cause. Phase A reads and writes Raindrop through a Claude Code connector, and that connector is attached to an interactive session. A headless run has no connector, so the tools the skill's procedure names do not exist in that session. This is why classification is on demand rather than scheduled: the design describes a daily fetch agent, but as built it is not installed, because it would have nothing to read through.
Diagnose. In the session that failed, check whether the Raindrop tools are listed at all. If the session was started by a scheduler or with no interactive terminal, that is the cause; there is nothing to debug in the skill.
Fix. Run /raindrop-sync from an interactive Claude Code session with the Raindrop connector attached. Do not work around it by pasting the REST token into a prompt, and do not try to reimplement the fetch with curl inside a scheduled agent: the skill is told never to echo the token, and a token that reaches a transcript must be rotated (Operations). The REST token's job is reconciliation checks through get-token.sh piped into curl, never a substitute for the connector.
Phase B is unaffected: the extension and the launchd agent run unattended on whatever desired.json the last interactive run staged.
Read more. The limitation is stated in the README, Status and limitations and in SETUP.md step 7. Making Phase A schedulable is a roadmap item; see Contributing and Roadmap.
The worker did not send its client, which means it is running code from before the per-client ledger. It degrades safely (the host keys those rows under the sentinel unknown, costing at most one redundant already_present), but it also means any other fix you shipped to sw.js is not live either. Bump manifest.json's version and press reload on the card.
Each is a deliberate refusal, not a crash, and it repeats until the condition clears:
| Line | Meaning | What to do |
|---|---|---|
ABORT: Brave Sync is enabled |
top-level sync_metadata in the file; injected nodes would corrupt sync metadata and upload to every device |
turn Brave Sync off. Nothing was damaged. |
ABORT: unexpected version |
version != 1 |
stop; the format changed. Report it with your Brave version. |
ABORT: missing root ... |
one of bookmark_bar, other, synced absent |
as above |
ABORT: stored checksum does not match |
something else is editing the file, or a Brave upgrade changed the digest | run the checksum check in SETUP.md, Verify your install. If it fails on a file Brave itself just wrote, report it. |
ABORT: Bookmarks missing |
the file is absent while the profile exists | do not create one. A missing file is a symptom. Restore from backups/ only after finding out why. |
ABORT: AccountBookmarks present (or the encrypted variants) |
a storage regime the writer does not model | stop and report |
The preflight rules and why each exists are in Safety and Ownership Model.
The resolver found nothing in the environment, .env, or the keychain. Seed it per SETUP.md step 2, then verify with the fingerprint, never with echo. If you seeded a password manager only, note that the unattended resolver deliberately does not depend on one: two scheduled runs once died waiting for a Touch ID prompt with nobody at the machine (RESEARCH.md section 11).
The folder_path in desired.json is relative to the bar. A leading Bookmarks Bar component is tolerated and stripped by both appliers; a path that names the bar under a different title is not, and is created as a folder. This is the exact bug the dry run against a copy was built to catch, so keep running it before trusting a changed writer (SETUP.md, Verify your install, step 7).
Open an issue with the finding number if one applies, what you observed instead, the exact command, your Brave and macOS versions, and whether Brave was running. Include raw output; a launch_context.cc line can be acted on, "it didn't work" cannot. Never include real bookmarks, tokens, collection ids, extension ids, or absolute paths containing a username. Issues: https://github.com/rajatrv-fullstack/raindrop-brave-sync/issues. Questions: https://github.com/rajatrv-fullstack/raindrop-brave-sync/discussions.
MIT licence | raindrop-brave-sync on GitHub | Roadmap board
raindrop-brave-sync
Start here
How it works
Running it
Research
Community