fix(workspace): scope the binding cache and skill snapshot to the account - #1377
Merged
Merged
Conversation
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 bug
The binding cache and the workspace skill snapshot were both keyed on
tenant|apiUrl. Two accounts on one tenant therefore shared them.User A links a project to their private workspace, and its skills land in
.altimate-code/skill/_workspace. The credentials are then switched to user B on the same tenant — a shared analytics box, a service account, or/connectwith a colleague's key while pairing. Within the validation window, B's sessions read A's cached binding and load A's private workspace's skills, with no visibility check of their own. On the server a private workspace is visible only to its owner, so B should never have received any of it.The identity memo was already per account, and the pin validation in
state.tsalready scopes on a credential digest for exactly this reason. The binding cache and the skill snapshot were the parts still keyed on the tenant alone — which is howaccountScopedKeyandaccountKeyOfboth came to be named for an account neither of them included.The fix
state.ts— the scope carries the credential digest, the cache file records which account wrote it, and a file belonging to another account is neither read nor appended to.CACHE_VERSION1 → 2: a v1 file cannot be attributed to anyone, so it is discarded rather than migrated. The cache is an offline fallback, so the cost is one re-validation per project.skill-sync.ts— the manifest and the in-memory sync stamp carry the account, and a snapshot written by another account is deleted. Ignoring it would leave another user's private skill content sitting readable in a shared checkout.src/skill/index.ts— and the read side does not depend on that deletion having happened. Discovery serves a match under.altimate-code/skill/_workspaceonly when that project's manifest carries the current credential's digest. See below: the purge alone was not enough.identity.tswas rebuilding that string by hand, so it compared a two-part value against a three-part one and rendered every cached binding as unknown — caught by an existing test, and the reason the builder now lives in one place.Why the purge alone was not enough
An earlier revision of this description said a foreign snapshot "is deleted before anything loads it". That was wrong, and review caught it. Three paths get around the purge:
session/prompt.ts), so a stale snapshot is loaded on the very turn that goes on to delete it._workspaceafter B's purge.inFlightis keyed on the directory, so a caller who switched credentials mid-sync joins the other account's run.A later re-sync repairs the disk but cannot retract what already reached a prompt. So discovery asks the question itself — one manifest read per project, not per skill file — and fails closed: unattributable, unreadable, and unreadable-credentials all withhold. Withholding costs a poll interval; serving another account's private skill cannot be taken back. A project that never used the feature has no managed path among its matches, so the module is not even loaded — the same shape as the opt-out gate in
session/prompt.ts.inFlightstays keyed on the directory rather than on directory + account. Keying it per account would let two credentials write one tree concurrently — the thing the gate exists to prevent — and it cannot help the cross-process case anyway. With reading gated on its own, a joined run's stalechangedflag is no longer a disclosure, only a wasted poll; the comment at the join says so.The trade-off, stated plainly
A credential change re-validates instead of inheriting. That includes rotating your own key, which cannot be told from a different user by the key alone. The cost is one server round trip per project, and no cached binding while offline until it succeeds.
Stated precisely, because an earlier draft understated it: there is one bindings file, and it records the single account that wrote it. So a write by another account does not merely shadow the previous one's row for that project — it replaces the file, evicting every project's row. Alternating between two accounts therefore re-validates each project on each switch, not once.
This is worth calling out because the repo has been bitten by the other side of it:
skill-publish.tsdeliberately moved the published-id ledger away from key-digest scoping, because a rotation orphaned every published skill — "the user is the identity". That reasoning is right for the ledger, where the id belongs to a person. It is the wrong way round for the cache, where the question is whether this credential may see that workspace. Re-homing the cache after a server confirmation (the pattern the ledger uses) would remove the re-download and the offline window; it is a follow-up, not a blocker.Verification
bun test test/altimate/workspace/ test/skill/skill.test.ts— 791 pass, 1 fail. The one failure isflushPendingSyncs waits for a sync a short-lived process would abandon, which fails identically withmain's sources in place (measured, not assumed). Typecheck clean for every file touched.Full suite, since this change now touches
src/skill/index.ts: 14,251 pass of 15,121 across 717 files, with an identical set of 11 pre-existing failures before and after this commit — the samemcp.headers,HttpApiandflushPendingSyncstimeouts, name for name.New tests, in
state-account-scope.test.tsandskill-sync.test.ts:Mutation testing
foreign) account comparison removedTwo survive, and both are honest rather than gaps. The validator's field check is shape validation that keeps the
raw is CacheFilepredicate truthful for a hand-edited v2 file; the version check is what rejects older files, and the account comparison inreadCachedBindingis the security control. Theforeigncomparison's account clause is a second layer behind the purge that already fired — itsdatamateIdclause is separately covered.My own tests also caught a bug I introduced: writes were appending to a file owned by another account, so B's row landed in A's file and then failed B's own read.
Not in this change
The ticket's comments name two more instances of the same class, both needing a credential threaded through a call chain rather than a scope key: an account switch during an in-flight memory backfill, and
/workspace→ "Open in browser" resolving the workspace id and the URL under different credentials. Left as follow-ups so this stays reviewable.🤖 Generated with Claude Code
Summary by cubic
Fixes a bug where the binding cache and workspace skill snapshot were keyed only on
tenant|apiUrl, letting a second account on the same tenant read the first account's cached binding and private workspace skills with no visibility check.CACHE_VERSIONbumps to 2 and the manifest to version 2; v1 files are discarded rather than migrated, while an upgraded v1 snapshot is replaced so skills refresh instead of being stuck.registryStale, the sync stamp, and the sidebar's synced age all carry the account.main,snapshotProjectOfnow lives in the sharedsnapshot-path.tsmodule instead of duplicating the segment walk, keeping the collection and filter paths from drifting apart.Trade-off: switching credentials — including rotating your own key — now re-validates instead of inheriting, at the cost of one server round trip and no cached binding while offline.
Written for commit 5a02c4c. Summary will update on new commits.
Summary by CodeRabbit
Self-review (
ef4d253)Reading the change as a whole rather than the last diff. This bug existed because two helpers were named for an account neither of them included, and the fix had left three more ways to be misled:
tenantKey()now returned an account-scoped key — the same misnomer, just moved. RenamedaccountKey().credentialDigest's doc had been orphaned ontoscopeStringOfby the edit that introduced it, leaving the digest undocumented and the scope string described as a digest.CacheFile.accountwas documented as a "short digest of the API key" — it is the whole credential, unshortened, and the file header said the same.state.tsalso still computed a second, different digest inline for the pin validation: two answers in one file to the question this change is about, and the reason the cache and the validation beside it were scoped differently to begin with. The pin memo is in-memory, so its key format is free to change; it now usescredentialDigestlike everything else, andcreateHashappears once in the file instead of twice.Also confirmed during the pass:
memory-index.tswas already account-scoped, so it is not another instance of this bug.identity.ts,skill-publish.ts) are their own memo keys, not comparisons againstreadLocalBindingScoped's scope, so they are not at risk of the drift that brokeidentity.ts.765 pass / 1 pre-existing fail, typecheck clean, tracker-leak check clean.
Self-review (
71ebd25)Read as a whole feature rather than as the last diff, the question this round was: the PR says the snapshot is deleted before anything loads it — is that true? It is not, and every claim that rested on it has been corrected above rather than quietly dropped.
session/prompt.tscallsrefreshRegistry()beforerecentlySynced/syncSkills, so on the flag-on path a foreign snapshot is loaded first and deleted second.src/skill/index.tscontains no manifest reference at all. The reviewer was right on both counts.awaitto a turn-ordering-sensitive path that has already regressed a test once (documented in that file). Gating the read closes all three.Skill.dirstoo, not justall(). The withheld set is rebuilt from the surviving matches rather than filtered out ofstate.dirs—dirsfeeds the prompt, so a withheld skill leaving its directory behind would have been a half-fix. There is a test for it.a workspace-synced bundle layout is discovered as a real skillhad no manifest and would have started failing — it now writes one, which is itself evidence the gate bites. No other test in 717 files changed behaviour.src/skill/index.tsis on every session's path: the 11 failures are the same 11, name for name, before and after.Honest residue:
inFlightis still keyed on the directory, deliberately, and the reasoning is in the code at the join rather than left implicit — a per-account key would let two credentials write one tree at once and still would not help two processes.Self-review (
1ce2878)The gate added in
71ebd25was reviewed and three ways around it were found. All three were real; each is fixed with a test, and each test was verified by a mutation that was confirmed to have applied before its result was trusted.scanfollows them, so a link from any other scanned skill directory into_workspaceproduced a match with no managed component in its own path. Attribution is now decided on the real path. Two tests, deliberately a pair: a link into a foreign snapshot is withheld, a link into our own is still served — so this withholds what is foreign rather than everything it cannot recognise at a glance./.at > 0treated index 0 as "no project". Both the collection loop and the filter now call onesnapshotProjectOf, so the two cannot drift apart; it is@internal-exported because a project at the filesystem root cannot be staged as a fixture.registryStalekeyed on the manifest mtime, which an account switch does not move, so discovery never re-ran and the gate was never asked. The registry identity now carries the account. The test also asserts that switching back is not stale, so this refreshes on a real change rather than refusing to settle.Regression evidence, since this commit touches
session/prompt.tsas well: full suite before and after, same flaky clusters and nothing new. Themcp HttpApiandsession/loopfailures reproduce identically at the parent commit in isolation (3–5 of them, varying per run, all 5s timeouts), andflushPendingSyncsreproduces withmain's sources in place.Honest note on what is not closed: the check-then-load window cubic raises is still a window —
snapshotIsOursanswers, then the files are read. It is far narrower than the unchecked read it replaces, and closing it properly means reading a snapshot and its manifest as one immutable unit, which is a bigger change than this PR. It belongs with the other in-flight credential-switch items under "Not in this change".Self-review (
2291a2ea) — the gate, reviewed as its own featureThe discovery gate was added in
71ebd25and then reviewed as hard as the rest of the PR. Five ways around it were found, all real, all fixed, each with a test whose mutation was confirmed to have applied:_workspacefrom another scanned skill directory_workspaceto a file elsewhererealpathcould not resolve/at > 0read index 0 as "no project"snapshotProjectOf, which returns/registryStalekeyed on mtime, so discovery never re-ranThe third and fourth of those were mine, introduced by the fix for the first. That is the honest shape of this round: each narrowing moved the boundary and the next review found what the move exposed. The symlink pair is now pinned from both directions independently — gating on either end alone fails the other end's test — which is the property that says the boundary is checked rather than merely moved again.
One more, unrelated to the gate and found in the same round: the pre-binding purge compared the manifest's account against a digest that is
nullwhenever no API key resolves, so every valid snapshot looked foreign; the sync then failed for the same missing key and put nothing back. It is guarded on an account it could actually compute, the rule the disconnect and lookup-failure branches beside it already follow.And one test that was not testing what it claimed:
a snapshot from another user is dropped even when the new user is boundbound the second user to a different workspace id and served a different skill set, so an ordinary "not up to date" re-sync produced the same observable outcome — it passed with every account comparison removed. Same workspace id, identical listing, and it asserts the manifest's account changed hands.