Skip to content

fix(client): Agent Skills — the multi-version store model - #62

Open
XieX wants to merge 2 commits into
xie/skills-09-contract-fixesfrom
xie/skills-10-multi-version-store
Open

XieX wants to merge 2 commits into
xie/skills-09-contract-fixesfrom
xie/skills-10-multi-version-store

Conversation

@XieX

@XieX XieX commented Sep 16, 2026

Copy link
Copy Markdown

Phase 2 of the TypeScript Agent Skills review remediation. Stacked on
#61 (xie/skills-09-contract-fixes) — review only the top commit.

Why this is one change

InMemorySkillStore implemented a single-version design the spec (TESTING.md §3.21/§3.22) and the
Python SDK have both moved past: one object per key (skills.ts:76, 94), getObject's version
argument ignored outright (:113–116), and allObjects spreading the key-indexed map (:120).

Porting the store without the two consumers would introduce a bug rather than fix one: once
allObjects returns one entry per (key, version), allSkills returns two Skills for one key and
writeSkills('*') writes the same <root>/<key>/SKILL.md twice in one run, resolved by iteration
order. The store, the collapse helper, and both call sites are one semantic change, and a test written
against only the in-memory store cannot exercise the consumers.

What changed

The store (skills.ts) — Python's _versions / _loose structure. getObject answers a pin with
exactly that version, an omitted version with the newest held, and — when nothing well-formed is filed
under the key — the version-less entry, so an object too malformed to carry a usable version still
reaches verification and is withheld with a signal. Reading it as absent would let a tampered object
look like a deleted one, and would let prune delete the last known-good copy on disk.

Prototype pollution, same lines. The backing maps are real Maps. put-ting __proto__ corrupted
what every later lookup resolved to, and getObject('skill', 'constructor') on an empty store
answered with the Object function — constructor satisfies the skill-key pattern, so it arrived as an
ordinary lookup. Nothing unverified escaped to user code either way (verification rejects a function as
a non-object, and __proto__ fails key validation), so this is the store's own semantics rather than a
content-integrity hole. allObjects builds a null-prototype record for the same reason: a loose object
filed under __proto__ would otherwise set the record's prototype and vanish from the listing.

newestByKey (skills-core.ts) — ported from skills_core.py:668. Keeps objects too malformed to
carry a usable key or version rather than filtering them; dropping them early shrinks the resolved set,
which on the materialization path is indistinguishable from a revocation.

Both consumersallSkills (a list holding two versions of one key is not a set of skills) and
writeSkills('*') (<root>/<key>/SKILL.md is a single path, so writing it twice is a write race
against itself).

One caller-visible behaviour change

A pin the store does not hold now reports absent rather than wrong_version. The store honours
the pin and answers null, which is an absence; wrong_version is for a store that answers and
answers wrongly
, which is what resolveFromStore's equality check defends against. That matches
Python, which drives wrong_version through a purpose-built answering store
(_WrongVersionAnsweringStore) rather than through InMemorySkillStore. The two tests that relied on
the bundled store ignoring its own argument now use a store of that shape, and a new test pins the
absent reading.

Tests

Several existing tests were vacuous by the spec's own definition, because a store holding one version
cannot distinguish "newest" from "the only one":

  • getSkill "an omitted version means the newest available" and the store's own getObject cases now
    seed two versions, newer first, so insertion order cannot pass for ordering.
  • The allObjects test asserted Object.keys(...).sort(); §3.21 explicitly disclaims the spelling of
    those keys as store-internal. It now asserts the objects' own key fields, plus the required
    two-versions-yields-two-entries case and the malformed-object-for-a-pinned-request case.
  • New: both prototype vectors; allSkills newest-per-key; good + unusable object yields one Skill
    and one recorded integrity signal (the signal is what proves it was not merely filtered);
    writeSkills('*') with two versions of one key produces one action at the higher version and the
    file on disk carries that version's content.

Nine of the new or repaired assertions fail against the previous implementation (verified by reverting
the three production files and re-running).

Close-out gate

tsc --noEmit clean, biome check packages/client/src clean, vitest run 1402 passed / 6 skipped.
The six skips are the capability-probe-gated TOCTOU swap tests, which only execute on Linux — a green
macOS run is not evidence about them, so the Linux CI result is the one that matters.

Docs: dropped the "holds one object per key … by design" claims from agents.md and README.md, and
the JSDoc that explained the single-object design.

🤖 Generated with Claude Code


Note

Overview
Aligns the TypeScript Agent Skills client with the multi-version store model (matching Python): InMemorySkillStore now keeps several versions per key, honors version pins in getObject, and lists one entry per (key, version) with opaque map keys. Backing storage uses Maps (and null-prototype listing records) so prototype-pollution keys like __proto__ cannot corrupt lookups.

Adds newestByKey in skills-core so whole-store paths collapse to the newest version per key before verification. allSkills and writeSkills('*') use it so one key does not appear twice in a list or race writing the same SKILL.md path. Malformed siblings are kept (or dropped when a good version already resolved the key) so failures surface through verification and pruning signals rather than looking like revocations.

Caller-visible: a version pin the store does not hold now reports absent, not wrong_version — only a store that answers with the wrong version still yields wrong_version. README/agents docs and tests were updated accordingly.

Reviewed by Cursor Bugbot for commit 8807c40. Bugbot is set up for automated code reviews on this repo. Configure here.

`InMemorySkillStore` implemented a single-version design the spec and the
Python SDK have both moved past: one object per key, `getObject`'s `version`
argument ignored outright, and `allObjects` spreading the key-indexed map.
Three sites depend on that model being right, and they are one change.

Port Python's `_versions` / `_loose` structure into the store. Several
versions of one key coexist, because they coexist in a real payload: the
newest version of every skill plus every version a variation pins.
`getObject` answers a pin with exactly that version, an omitted version with
the newest held, and — when nothing well-formed is filed under the key — the
version-less entry, so an object too malformed to carry a usable version
still reaches verification and is withheld *with a signal*. Reading it as
absent instead would let a tampered object look like a deleted one, and would
let prune delete the last known-good copy on disk.

The backing maps are real `Map`s, not plain objects. Skill keys come off the
wire and a plain object inherits names that are not keys: `put`-ting
`__proto__` corrupted what every later lookup resolved to, and
`getObject('skill', 'constructor')` on an *empty* store answered with the
`Object` function — `constructor` satisfies the skill-key pattern, so it
arrived as an ordinary lookup. Nothing unverified escaped to user code either
way, so this is the store's own semantics rather than a content-integrity
hole. `allObjects` builds a null-prototype record for the same reason: a
loose object filed under `__proto__` would otherwise set the record's
prototype and vanish from the listing.

`allObjects` now returns one entry per `(key, version)`, so both whole-store
consumers have to collapse. `newestByKey` does it, keeping objects too
malformed to carry a usable key or version rather than filtering them —
dropping them early shrinks the resolved set, which on the materialization
path is indistinguishable from a revocation. `allSkills` uses it because a
list holding two versions of one key is not a set of skills, and
`writeSkills('*')` uses it because `<root>/<key>/SKILL.md` is a single path:
writing it twice in one run is not a duplicate report but a write race
against itself, resolved by whichever version iteration reached last.

One caller-visible consequence, pinned by a test: a pin the store does not
hold now reports `absent` rather than `wrong_version`. The store honours the
pin and answers `null`, which is an absence. `wrong_version` is for a store
that answers *and answers wrongly*, which is what the equality check in
`resolveFromStore` defends against — so the tests for it now drive a store
shaped like Python's `_WrongVersionAnsweringStore` instead of leaning on the
bundled store ignoring its own argument.

Tests: several were vacuous by the spec's own definition because they seeded
a single version, which cannot distinguish "newest" from "the only one". The
`allObjects` test also asserted the spelling of keys the `SkillStore`
contract explicitly disclaims; it now asserts the objects' own `key` fields.
Nine of the new or repaired assertions fail against the previous
implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8c5dba4. Configure here.

Comment thread packages/client/src/skills-core.ts Outdated
@XieX XieX changed the title fix(client)!: Agent Skills — the multi-version store model fix(client): Agent Skills — the multi-version store model Sep 17, 2026
@XieX
XieX requested a review from andrewklatzke September 17, 2026 19:37
`newestByKey` kept every object too malformed to carry a usable key and
version, so verification rather than the collapse would withhold it. That is
right when nothing else resolves the key — the key stays in the requested set
and prune leaves the copy on disk alone — but wrong when another version of the
same key resolved anyway.

In that case `writeSkills('*')` reported both a `written` and an `error` action
for one key, flipping `report.ok` to false for a skill that is correctly on
disk, and claiming the copy there "was left alone" when the same run had just
written it. `allSkills` recorded an integrity failure for the same key.

Drop a malformed object only when its own skill key resolved from another
version; keep it otherwise, which is the case the protection exists for.

Ports the Python fix (python-ai-sdk 098455f) to TypeScript, so the two SDKs
agree on the collapse again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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