{"record":{"id":"7776e768c43229d7","repo":"midudev/autoskills","slug":"no-recorded-hash-for-normalizedrel","errorCode":null,"errorMessage":"no recorded hash for ${normalizedRel}","messagePattern":"no recorded hash for (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/autoskills/installer.ts","lineNumber":286,"sourceCode":"function isDisallowedSkillFile(rel: string): boolean {\n  return rel.toLowerCase().endsWith(\".zip\");\n}\n\nasync function downloadRegistryFile(\n  skillName: string,\n  entry: RegistryEntry,\n  rel: string,\n  opts: InstallOptions,\n): Promise<{ buf: Buffer; url: string }> {\n  const normalizedRel = normalizeRegistryRelPath(rel);\n\n  if (isDisallowedSkillFile(normalizedRel)) {\n    throw new Error(`refusing to download disallowed skill archive: ${normalizedRel}`);\n  }\n\n  const expected = entry.sha256[rel] || entry.sha256[normalizedRel];\n  if (!expected) {\n    throw new Error(`no recorded hash for ${normalizedRel}`);\n  }\n\n  const fetchFile = opts.fetchImpl || fetch;\n  const errors = [];\n  for (const baseUrl of getRegistryRawBaseUrls(opts)) {\n    const url = `${baseUrl}/${encodeRawPath(skillName, normalizedRel)}`;\n    opts.onTrace?.(`GET ${url}`);\n    const res = await fetchFile(url, {\n      headers: githubDownloadHeaders(url),\n    });\n    if (!res.ok) {\n      const resetAt = Number(res.headers.get(\"x-ratelimit-reset\") || 0) * 1000;\n      const resetSuffix = resetAt ? ` (resets ${new Date(resetAt).toISOString()})` : \"\";\n      if (res.status === 403 && res.headers.get(\"x-ratelimit-remaining\") === \"0\") {\n        throw new Error(\n          `GitHub rate limit exceeded${resetSuffix}. Set GITHUB_TOKEN or GH_TOKEN to increase the limit.`,\n        );\n      }","sourceCodeStart":268,"sourceCodeEnd":304,"githubUrl":"https://github.com/midudev/autoskills/blob/0ec725320d2137253ab2e68e7ba8a072148e741a/packages/autoskills/installer.ts#L268-L304","documentation":"The installer enforces integrity by comparing every downloaded file's SHA-256 against a hash recorded in the registry entry (entry.sha256, keyed by relative path). If neither the original rel string nor the normalized path has a recorded hash, it refuses to download, because an unhashed file could not be verified after download.","triggerScenarios":"Calling downloadRegistryFile for a rel path that is absent from entry.sha256 — e.g. the skill's file list in the registry manifest and its sha256 map are out of sync, or a new file was added to the registry without regenerating hashes.","commonSituations":"Hand-editing skills-registry.json to add a file without updating sha256; a registry publishing bug that emitted files[] but skipped the hash map; stale local registry cache from an older release where the file didn't exist.","solutions":["Regenerate the registry manifest so every downloaded file has a sha256 entry (run the registry sync/publish script).","If hand-editing, add the missing key to entry.sha256 with the correct sha256 of the file content.","Re-sync your local skills-registry.json from the official source in case your copy is stale or corrupted.","Remove the unhashable file from the registry entry's file list if it isn't required."],"exampleFix":"// registry entry (before)\n{ \"files\": [\"skills/foo/NEW.md\"], \"sha256\": {} }\n// after\n{ \"files\": [\"skills/foo/NEW.md\"], \"sha256\": { \"skills/foo/NEW.md\": \"<sha256 of file>\" } }","handlingStrategy":"validation","validationCode":"import { createHash } from \"node:crypto\";\nfunction validateEntryHashes(entry) {\n  const files = entry.files ?? Object.keys(entry.sha256);\n  return files.every(rel => typeof entry.sha256[rel] === \"string\" && entry.sha256[rel].length === 64);\n}\n// before install: if (!validateEntryHashes(entry)) throw new Error(\"registry entry has files without sha256\");","typeGuard":"const hasHash = (entry, rel) => typeof entry.sha256?.[rel] === \"string\" && entry.sha256[rel].length === 64;","tryCatchPattern":"try {\n  await downloadRegistryEntry(name, entry, dest);\n} catch (e) {\n  if (e.message.startsWith(\"no recorded hash for\")) {\n    const missing = e.message.match(/no recorded hash for (.+)$/)?.[1];\n    await resyncRegistryManifest(); // refresh manifest, then retry once\n    return downloadRegistryEntry(name, freshEntry, dest);\n  } else throw e;\n}","preventionTips":["Always regenerate sha256 via the official publish script, never by hand.","Add a CI check that every file in the registry has a 64-char sha256 entry.","Re-sync the local registry manifest before installs in long-lived environments.","Treat a manifest/hash mismatch as a sign of a stale cache, not something to bypass."],"tags":["integrity","registry-manifest","checksum","missing-metadata"],"backgroundTag":"checksum-mismatch","analyzedSha":"0ec725320d2137253ab2e68e7ba8a072148e741a","analyzedAt":"2026-09-15T14:12:31.090Z","contentChangedAt":"2026-09-15T14:12:31.090Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}