happyskills 0.44.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.45.0] - 2026-05-13
11
+
12
+ ### Fixed
13
+ - `diff`, `pull`, `install`, and every other command that calls `repos_api.clone(owner, repo, ref, { commit })` now refuses any response whose top-level `commit` field does not match the requested commit. The pre-fix CLI silently trusted whatever the registry returned, which produced false-positive `local_only_modified` diffs (and would have produced silent base-content corruption on `pull` and `install`) whenever a CDN cache key collision served one commit's response for a different commit's request. The check lives at the single `clone()` chokepoint in `cli/src/api/repos.js` so all 8 call sites are covered automatically — no per-command opt-in. The error message names the requested vs returned commit, attributes the most likely cause (stale CDN cache or upstream cache-key misconfig), and tells the user to retry. Companion API fix shipped in `happyskills-api@2.11.5` closes the root cause by adding `commit` and `format` to the CloudFront cache-key whitelist for `/repos/*/clone`; this CLI check stays as defense in depth so the next cache/MITM/origin bug surfaces as a hard error instead of a quiet false positive. Reproduction: with the broken cache, `happyskills diff happyskillsai/happyskills-design` against linwong's `e65040…` returned a tree pinned to `6533f944…` — a different commit than the lock asked for; the new check stops on that exact mismatch.
14
+ - `repos_api.clone()` and `repos_api.get_blob()` additionally verify per-file content integrity: every file's bytes must hash to the SHA the server advertises (git-blob SHA-256 via `hash_blob` in `cli/src/utils/git_hash.js`). Catches the second failure mode where SHA and content are mismatched within a single response (MITM corruption, partial transfers, origin bugs that produce inconsistent pairs). Skipped cleanly for `format=archive` responses (no inline content) and for metadata-only entries that omit `content`. Test coverage in `cli/src/api/repos.test.js` (9 tests across commit-pin, content-pin, archive passthrough, and ref-based clone).
15
+ - `status <bare-skill-name>` now resolves the bare name to its locked `owner/skill` key by suffix match — same disambiguation that `validate`, `bump`, and `resolve_skill_owner` already use. Previously, running `happyskills status happyskills-design` (without the `happyskillsai/` prefix) returned `status: not_found` because `all_skills[target_skill]` is a direct lookup and the lock keys are fully qualified. `diff` still requires owner/name explicitly because its API path depends on the workspace; `status` reads only the lock and can always resolve unambiguously.
16
+
10
17
  ## [0.44.1] - 2026-05-13
11
18
 
12
19
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.44.1",
3
+ "version": "0.45.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
package/src/api/repos.js CHANGED
@@ -1,5 +1,40 @@
1
1
  const { error: { catch_errors } } = require('puffy-core')
2
2
  const client = require('./client')
3
+ const { hash_blob } = require('../utils/git_hash')
4
+
5
+ // Verify a clone response against the request that produced it. Two checks:
6
+ //
7
+ // 1. Commit pin (the structural one): when the caller passed `options.commit`,
8
+ // the response's top-level `commit` field MUST equal that value. If the CDN
9
+ // cache key drops the `commit` query param (or any future proxy/cache does
10
+ // the same), one commit's response can be returned for a different commit's
11
+ // request — internally self-consistent, but pinned to the wrong tree.
12
+ // This is exactly how the May 2026 `clone_cache_policy` whitelist bug
13
+ // surfaced as false-positive `local_only_modified` diffs.
14
+ //
15
+ // 2. Per-file content address (defense in depth): every file's bytes must
16
+ // hash to the SHA the server advertises. Catches MITM corruption, partial
17
+ // responses, and origin bugs that produce mismatched (sha, content) pairs.
18
+ //
19
+ // Returns an error message string on failure, or null when the response is
20
+ // trustworthy. Caller throws.
21
+ const verify_clone_integrity = (data, owner, repo, requested_commit) => {
22
+ if (!data) return null
23
+ if (requested_commit && typeof data.commit === 'string' && data.commit !== requested_commit) {
24
+ return `Clone commit mismatch for ${owner}/${repo}: requested commit ${requested_commit} but the response is pinned to ${data.commit}. This indicates a stale CDN cache entry serving a different commit's response. Retry the command; if it persists, the registry's clone-cache policy is misconfigured (the commit query param is missing from the CloudFront cache key).`
25
+ }
26
+ if (Array.isArray(data.files)) {
27
+ for (const f of data.files) {
28
+ if (!f || typeof f.sha !== 'string' || typeof f.content !== 'string') continue
29
+ const buf = Buffer.from(f.content, 'base64')
30
+ const computed = hash_blob(buf)
31
+ if (computed !== f.sha) {
32
+ return `Clone integrity check failed for ${owner}/${repo} file "${f.path}": server reported sha ${f.sha} but content hashes to ${computed}. This usually means a stale CDN cache entry is serving wrong content for the requested commit. Retry the command; if it persists, report to the registry maintainers.`
33
+ }
34
+ }
35
+ }
36
+ return null
37
+ }
3
38
 
4
39
  const search = (query, options = {}) => catch_errors('Search failed', async () => {
5
40
  const params = new URLSearchParams()
@@ -29,6 +64,8 @@ const clone = (owner, repo, ref, options = {}) => catch_errors(`Clone ${owner}/$
29
64
  const qs = params.toString() ? `?${params}` : ''
30
65
  const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${qs}`)
31
66
  if (errors) throw errors[errors.length - 1]
67
+ const integrity_msg = verify_clone_integrity(data, owner, repo, options.commit || null)
68
+ if (integrity_msg) throw new Error(integrity_msg)
32
69
  return data
33
70
  })
34
71
 
@@ -77,6 +114,12 @@ const compare = (owner, repo, base_commit) => catch_errors(`Compare ${owner}/${r
77
114
  const get_blob = (owner, repo, sha) => catch_errors(`Get blob ${owner}/${repo}/${sha} failed`, async () => {
78
115
  const [errors, data] = await client.get(`/repos/${owner}/${repo}/blob/${sha}`)
79
116
  if (errors) throw errors[errors.length - 1]
117
+ if (data && typeof data.content === 'string') {
118
+ const computed = hash_blob(Buffer.from(data.content, 'base64'))
119
+ if (computed !== sha) {
120
+ throw new Error(`Blob integrity check failed for ${owner}/${repo}@${sha}: content hashes to ${computed}. Possible stale CDN cache or registry corruption.`)
121
+ }
122
+ }
80
123
  return data
81
124
  })
82
125
 
@@ -0,0 +1,171 @@
1
+ const { describe, it, beforeEach, afterEach } = require('node:test')
2
+ const assert = require('node:assert')
3
+ const crypto = require('node:crypto')
4
+
5
+ // Compute the same git-blob SHA the registry advertises and the CLI verifies
6
+ // against. Mirrors cli/src/utils/git_hash.js so the test is self-contained.
7
+ const git_blob_sha = (buf) => {
8
+ const header = Buffer.from(`blob ${buf.length}\0`)
9
+ return crypto.createHash('sha256').update(header).update(buf).digest('hex')
10
+ }
11
+
12
+ // Stub the API client before requiring the module under test. We swap in a
13
+ // per-test fake `get` so we control exactly what `clone()` and `get_blob()` see.
14
+ const stub_client = (response) => {
15
+ const client_path = require.resolve('./client')
16
+ require.cache[client_path] = {
17
+ id: client_path,
18
+ filename: client_path,
19
+ loaded: true,
20
+ exports: {
21
+ get: async () => [null, response],
22
+ post: async () => [null, null],
23
+ put: async () => [null, null],
24
+ patch: async () => [null, null],
25
+ del: async () => [null, null],
26
+ request: async () => [null, null],
27
+ get_base_url: () => 'http://test'
28
+ }
29
+ }
30
+ delete require.cache[require.resolve('./repos')]
31
+ return require('./repos')
32
+ }
33
+
34
+ const restore = () => {
35
+ delete require.cache[require.resolve('./client')]
36
+ delete require.cache[require.resolve('./repos')]
37
+ }
38
+
39
+ describe('repos.clone — content integrity verification', () => {
40
+ afterEach(restore)
41
+
42
+ it('accepts a response whose file SHAs match the content', async () => {
43
+ const content = Buffer.from('hello world\n')
44
+ const sha = git_blob_sha(content)
45
+ const repos = stub_client({
46
+ ref: null,
47
+ commit: 'a'.repeat(64),
48
+ files: [{ path: 'README.md', content: content.toString('base64'), sha }]
49
+ })
50
+ const [err, data] = await repos.clone('acme', 'demo', null, { commit: 'a'.repeat(64) })
51
+ assert.strictEqual(err, null, 'clone should not error on a clean response')
52
+ assert.strictEqual(data.files.length, 1)
53
+ })
54
+
55
+ it('rejects a response whose file SHA does not match the bytes (cache pollution)', async () => {
56
+ // The exact failure mode of the CloudFront cache-key bug: server claims
57
+ // SHA X for a file whose bytes hash to Y. Pre-fix CLI silently trusted X
58
+ // and produced false-positive diffs. Post-fix CLI must throw here.
59
+ const content = Buffer.from('this is the REAL content of the file at commit B')
60
+ const wrong_sha = git_blob_sha(Buffer.from('STALE content cached under a colliding key'))
61
+ const repos = stub_client({
62
+ ref: null,
63
+ commit: 'b'.repeat(64),
64
+ files: [{ path: 'SKILL.md', content: content.toString('base64'), sha: wrong_sha }]
65
+ })
66
+ const [err] = await repos.clone('acme', 'demo', null, { commit: 'b'.repeat(64) })
67
+ assert.ok(err, 'clone must error when content does not hash to the advertised SHA')
68
+ const msg = err.map(e => e.message).join(' | ')
69
+ assert.match(msg, /integrity check failed/i, `error should mention integrity: ${msg}`)
70
+ assert.match(msg, /SKILL\.md/, `error should name the offending file: ${msg}`)
71
+ })
72
+
73
+ it('accepts an archive-format response with no per-file content', async () => {
74
+ // format=archive returns { format, ref, commit, url } — no `files` array
75
+ // to verify. The check must pass through cleanly so archive installs are
76
+ // not blocked.
77
+ const repos = stub_client({
78
+ format: 'archive',
79
+ ref: 'refs/tags/v1.0.0',
80
+ commit: 'c'.repeat(64),
81
+ url: 'https://s3.example.com/presigned'
82
+ })
83
+ const [err, data] = await repos.clone('acme', 'demo', 'refs/tags/v1.0.0', { format: 'archive' })
84
+ assert.strictEqual(err, null)
85
+ assert.strictEqual(data.format, 'archive')
86
+ })
87
+
88
+ it('rejects a response whose `commit` field does not match the requested commit (cache key collision)', async () => {
89
+ // The exact failure mode of the May 2026 CDN cache-key bug: CloudFront
90
+ // dropped `?commit=` from the cache key, so a clone response generated
91
+ // for commit X got served back for a request asking for commit Y. The
92
+ // response is internally consistent (SHAs match content), but pinned to
93
+ // the wrong tree. Pre-fix CLI silently used it as `base_commit`
94
+ // content and produced false-positive local_only_modified diffs.
95
+ const content = Buffer.from('// content of commit X\n')
96
+ const sha = git_blob_sha(content)
97
+ const repos = stub_client({
98
+ ref: null,
99
+ commit: 'x'.repeat(64), // server-pinned to X
100
+ files: [{ path: 'SKILL.md', content: content.toString('base64'), sha }]
101
+ })
102
+ const requested = 'y'.repeat(64)
103
+ const [err] = await repos.clone('acme', 'demo', null, { commit: requested })
104
+ assert.ok(err, 'clone must error when response.commit !== options.commit')
105
+ const msg = err.map(e => e.message).join(' | ')
106
+ assert.match(msg, /commit mismatch/i, `error should mention commit mismatch: ${msg}`)
107
+ assert.match(msg, new RegExp(requested.slice(0, 12)), 'error should include requested commit')
108
+ })
109
+
110
+ it('accepts a response when `commit` matches the requested commit', async () => {
111
+ const content = Buffer.from('matching content\n')
112
+ const sha = git_blob_sha(content)
113
+ const commit = 'a'.repeat(64)
114
+ const repos = stub_client({
115
+ ref: null,
116
+ commit,
117
+ files: [{ path: 'SKILL.md', content: content.toString('base64'), sha }]
118
+ })
119
+ const [err] = await repos.clone('acme', 'demo', null, { commit })
120
+ assert.strictEqual(err, null)
121
+ })
122
+
123
+ it('does not enforce commit pin when caller did not pass options.commit (ref-based clone)', async () => {
124
+ // When asking by `ref`, the caller does not know which commit will be
125
+ // resolved — that's the whole point. We can't pin in that direction;
126
+ // per-file SHA verification still applies.
127
+ const content = Buffer.from('content\n')
128
+ const sha = git_blob_sha(content)
129
+ const repos = stub_client({
130
+ ref: 'refs/tags/v1.0.0',
131
+ commit: 'z'.repeat(64),
132
+ files: [{ path: 'SKILL.md', content: content.toString('base64'), sha }]
133
+ })
134
+ const [err] = await repos.clone('acme', 'demo', 'refs/tags/v1.0.0')
135
+ assert.strictEqual(err, null)
136
+ })
137
+
138
+ it('skips entries that have no content (e.g. metadata-only responses)', async () => {
139
+ // Some endpoints return file metadata without inlined content; the check
140
+ // should not synthesize a false negative.
141
+ const repos = stub_client({
142
+ ref: null,
143
+ commit: 'd'.repeat(64),
144
+ files: [{ path: 'README.md', sha: 'whatever' }]
145
+ })
146
+ const [err] = await repos.clone('acme', 'demo', null, { commit: 'd'.repeat(64) })
147
+ assert.strictEqual(err, null)
148
+ })
149
+ })
150
+
151
+ describe('repos.get_blob — content integrity verification', () => {
152
+ afterEach(restore)
153
+
154
+ it('rejects a blob whose bytes do not hash to the requested SHA', async () => {
155
+ const content = Buffer.from('wrong bytes')
156
+ const requested_sha = git_blob_sha(Buffer.from('right bytes'))
157
+ const repos = stub_client({ content: content.toString('base64'), size: content.length })
158
+ const [err] = await repos.get_blob('acme', 'demo', requested_sha)
159
+ assert.ok(err, 'get_blob must error on hash mismatch')
160
+ assert.match(err.map(e => e.message).join(' | '), /integrity check failed/i)
161
+ })
162
+
163
+ it('accepts a blob whose bytes hash to the requested SHA', async () => {
164
+ const content = Buffer.from('hello')
165
+ const sha = git_blob_sha(content)
166
+ const repos = stub_client({ content: content.toString('base64'), size: content.length })
167
+ const [err, data] = await repos.get_blob('acme', 'demo', sha)
168
+ assert.strictEqual(err, null)
169
+ assert.strictEqual(data.size, content.length)
170
+ })
171
+ })
@@ -59,9 +59,27 @@ const run = (args) => catch_errors('Status failed', async () => {
59
59
  }
60
60
 
61
61
  const all_skills = get_all_locked_skills(lock_data)
62
- const entries = target_skill
63
- ? [[target_skill, all_skills[target_skill]]]
64
- : Object.entries(all_skills).filter(([, data]) => data !== null)
62
+ // Accept either fully-qualified "owner/skill" or bare "skill" — find by
63
+ // suffix when bare, matching how `validate`, `bump`, and other commands
64
+ // disambiguate from the lock. `diff` requires owner/name explicitly because
65
+ // its API calls need the workspace; status reads only the lock and can
66
+ // always resolve a bare name to its single locked owner.
67
+ let entries
68
+ if (target_skill) {
69
+ let key = target_skill
70
+ let data = all_skills[key] || null
71
+ if (!data && !target_skill.includes('/')) {
72
+ const suffix = `/${target_skill}`
73
+ const found = Object.keys(all_skills).find(k => k.endsWith(suffix))
74
+ if (found) {
75
+ key = found
76
+ data = all_skills[key] || null
77
+ }
78
+ }
79
+ entries = [[key, data]]
80
+ } else {
81
+ entries = Object.entries(all_skills).filter(([, data]) => data !== null)
82
+ }
65
83
 
66
84
  if (entries.length === 0) {
67
85
  if (args.flags.json) {