Skip to content

Plugin state files sync with untranslated absolute paths (known_marketplaces.json, installed_plugins.json) #93

Description

@tawanorg

Found via #73, which diagnosed this correctly. Filing it separately so the bug is tracked independently of that PR's fate.

Problem

internal/config/config.go:91 includes plugins in full-scope SyncPaths, so these files already sync:

  • ~/.claude/plugins/known_marketplaces.json
  • ~/.claude/plugins/installed_plugins.json

Both contain absolute local paths. Neither is covered by content path translation: IsPortableContentPath only admits history.jsonl and text formats under projects/. So a device uploads its own absolute paths verbatim, and the other device pulls a plugin index pointing at directories that don't exist there — the exact class of breakage the ${HOME}/path_map token mechanism exists to prevent.

installed_plugins.json is the more awkward of the two, because it uses paths as object keys, not just values:

{ "/Users/alice/dev/my-plugin": [ ... ] }

Any fix has to translate keys as well as values. A value-only walk silently misses the main case.

Fix

Add both files to the allowlist in IsPortableContentPath. With #82 in place this is small: content kind is already derived from the extension, so .json escaping is handled, and no JSON decode/re-encode is needed — the regex substitution operates on bytes and preserves formatting, key order and unicode escapes.

Rough shape, in internal/sync/paths.go:

switch relPath {
case "history.jsonl",
     "plugins/known_marketplaces.json",
     "plugins/installed_plugins.json":
    return true
}

Approximately 30 lines with tests. #73 has a verified 81-line version including the conflict-path helpers below.

Do not fix this by decoding and re-encoding the JSON. #73 tried that, and reproduced against real ~/.claude data it sorts object keys, converts < to <, re-indents, and silently drops everything after the first JSON value in a .jsonl file. Byte-level substitution is the only approach that round-trips.

Related, worth doing at the same time

ConflictPath / SplitConflictPath from #73. Both IsPortableContentPath and pathContentKindFor currently find the conflict suffix with strings.Index(relPath, ".conflict."), which matches anywhere in the path — so a directory whose name contains .conflict. truncates the path and yields the wrong content kind. Parsing only the last segment fixes it.

Verification

A fix should assert, on the real plugin files:

  • normalize → resolve is byte-identical
  • resolve → normalize reproduces the remote bytes, so a pull doesn't leave the next push reporting a phantom modification
  • a path used as an object key is translated, not just path values
  • TestPosixContentMappingIsBackwardCompatible still passes

Note

MigratePaths only walks projects/, so plugin blobs already on a bucket stay untranslated until the local file next changes. Either extend the migration or accept it as self-healing — worth a deliberate decision rather than leaving it implicit.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions