workshop-quality-install.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. """Install two pinned, public Skills into a host-supplied Skills directory.
  2. No guessed host directories, credential discovery, account creation or business writes.
  3. The official VOC artifact is downloaded from npm and verified before extraction.
  4. """
  5. import argparse
  6. import hashlib
  7. import io
  8. import json
  9. import os
  10. from pathlib import Path, PurePosixPath
  11. import re
  12. import shutil
  13. import subprocess
  14. import sys
  15. import tarfile
  16. import tempfile
  17. import urllib.request
  18. class InstallError(Exception):
  19. pass
  20. def require(condition, code):
  21. if not condition:
  22. raise InstallError(code)
  23. def digest(raw):
  24. return hashlib.sha256(raw).hexdigest()
  25. def encode(value):
  26. return (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
  27. def safe_name(name):
  28. parts = PurePosixPath(name).parts
  29. return (isinstance(name, str) and bool(name) and '\\' not in name and ':' not in name
  30. and not name.startswith('/') and all(p not in ('..', '.') for p in parts)
  31. and name == '/'.join(parts))
  32. def plain_path(path):
  33. path = Path(os.path.abspath(path))
  34. for part in [*reversed(path.parents), path]:
  35. require(not part.is_symlink(), 'SYMLINK_PATH')
  36. if part.exists():
  37. require(not hasattr(part, 'is_junction') or not part.is_junction(), 'JUNCTION_PATH')
  38. return path
  39. def inside(parent, child):
  40. return child != parent and child.is_relative_to(parent)
  41. def verify_package(package):
  42. package = plain_path(package)
  43. manifest = package / 'FILES.sha256'
  44. require(manifest.is_file(), 'PACKAGE_MANIFEST_MISSING')
  45. names = set()
  46. for line in manifest.read_text(encoding='utf-8').splitlines():
  47. match = re.fullmatch(r'([a-f0-9]{64}) (.+)', line)
  48. require(match is not None, 'INVALID_PACKAGE_MANIFEST')
  49. h, name = match.groups()
  50. require(safe_name(name) and name not in names, 'INVALID_PACKAGE_PATH')
  51. names.add(name)
  52. p = plain_path(package / name)
  53. require(p.is_file() and digest(p.read_bytes()) == h, 'PACKAGE_HASH_MISMATCH')
  54. meta = json.loads((package / 'release.json').read_bytes())
  55. require(meta['schema'] == 'ksf.workshop-onboarding.v1', 'INVALID_RELEASE')
  56. return meta
  57. def voc_archive_files(raw, expected):
  58. require(len(raw) == expected['bytes'] and digest(raw) == expected['sha256'], 'VOC_ARCHIVE_HASH_MISMATCH')
  59. result = {}
  60. with tarfile.open(fileobj=io.BytesIO(raw), mode='r:gz') as archive:
  61. for member in archive.getmembers():
  62. require(member.name.startswith('package/'), 'INVALID_VOC_ARCHIVE_PATH')
  63. name = member.name[len('package/'):]
  64. require(safe_name(name), 'INVALID_VOC_ARCHIVE_PATH')
  65. require(member.isfile(), 'NON_FILE_VOC_ARCHIVE_MEMBER')
  66. require(name not in result and name in expected['files'], 'UNEXPECTED_VOC_ARCHIVE_FILE')
  67. require(member.size <= 8_000_000, 'VOC_ARCHIVE_MEMBER_TOO_LARGE')
  68. raw_file = archive.extractfile(member).read()
  69. require(digest(raw_file) == expected['files'][name], 'VOC_FILE_HASH_MISMATCH')
  70. result[name] = raw_file
  71. require(set(result) == set(expected['files']), 'VOC_ARCHIVE_INCOMPLETE')
  72. return result
  73. def source_files(package, meta, archive=None):
  74. expected = meta['voc']
  75. if archive:
  76. raw = plain_path(archive).read_bytes()
  77. else:
  78. require(expected['url'] == 'https://registry.npmjs.org/@vocmarket/voc-skill/-/voc-skill-0.3.42.tgz', 'UNEXPECTED_VOC_SOURCE')
  79. request = urllib.request.Request(expected['url'], headers={'User-Agent': 'ksf-workshop-onboarding/1'})
  80. with urllib.request.urlopen(request, timeout=30) as response:
  81. require(response.status == 200, 'VOC_DOWNLOAD_FAILED')
  82. raw = response.read(expected['bytes'] + 1)
  83. voc = voc_archive_files(raw, expected)
  84. workshop = {}
  85. for name, h in meta['workshop']['files'].items():
  86. require(safe_name(name), 'INVALID_WORKSHOP_PATH')
  87. raw_file = plain_path(package / 'skills/ksf-workshop-runtime' / name).read_bytes()
  88. require(digest(raw_file) == h, 'WORKSHOP_FILE_HASH_MISMATCH')
  89. workshop[name] = raw_file
  90. return {'ksf-workshop-runtime': workshop, 'voc-intelligence': voc}
  91. def mcp_fragment(voc_dir):
  92. return {'mcpServers': {'voc': {'command': 'node',
  93. 'args': [str(voc_dir / 'mcp/src/server.js')], 'cwd': str(voc_dir),
  94. 'env': {'VOC_SKILL_NO_UPDATE_CHECK': '1'}}}}
  95. def verify_component(target, files):
  96. require(target.is_dir(), 'EXISTING_SKILL_NOT_DIRECTORY')
  97. for name, raw in files.items():
  98. p = plain_path(target / name)
  99. require(p.is_file() and digest(p.read_bytes()) == digest(raw), 'EXISTING_SKILL_DIFFERS')
  100. def verify_hashes(target, hashes):
  101. for name, expected in hashes.items():
  102. require(safe_name(name), 'INVALID_UPGRADE_PATH')
  103. p = plain_path(target / name)
  104. require(p.is_file() and digest(p.read_bytes()) == expected, 'UPGRADE_BASELINE_CHANGED')
  105. def plan(skills_dir, files, upgrade_from=None):
  106. actions = []
  107. for name, content in files.items():
  108. target = plain_path(skills_dir / name)
  109. if target.exists():
  110. try:
  111. verify_component(target, content)
  112. action = 'verify-only'
  113. except InstallError:
  114. require(name == 'ksf-workshop-runtime' and upgrade_from, 'EXISTING_SKILL_DIFFERS')
  115. baseline = None
  116. for version, hashes in upgrade_from.items():
  117. try:
  118. verify_hashes(target, hashes)
  119. baseline = (version, hashes)
  120. break
  121. except InstallError:
  122. continue
  123. require(baseline is not None, 'UPGRADE_BASELINE_CHANGED')
  124. # Code, locks and unknown local files are never rewritten by this upgrade.
  125. for path, raw in content.items():
  126. if path.startswith('scripts/') or path == 'runtime-lock.json':
  127. require(digest(raw) == baseline[1].get(path), 'PROTECTED_RUNTIME_CHANGED')
  128. require(set(baseline[1]) <= set(content), 'UPGRADE_FILE_SET_CHANGED')
  129. additions = sorted(set(content) - set(baseline[1]))
  130. for path in additions:
  131. require(safe_name(path) and path.startswith('references/') and path.endswith('.md'),
  132. 'UPGRADE_NEW_FILE_NOT_REFERENCE')
  133. require(not plain_path(target / path).exists(), 'UPGRADE_NEW_FILE_COLLISION')
  134. actions.append({'skill': name, 'path': str(target), 'action': 'upgrade',
  135. 'files': len(content), 'fromVersion': baseline[0],
  136. 'before': baseline[1], 'additions': additions})
  137. continue
  138. else:
  139. action = 'create'
  140. actions.append({'skill': name, 'path': str(target), 'action': action, 'files': len(content)})
  141. return actions
  142. def remove_created(path, intended_parent):
  143. """Only remove our own newly created directory, after checking its final location."""
  144. path = plain_path(path)
  145. intended_parent = plain_path(intended_parent)
  146. require(inside(intended_parent, path), 'CLEANUP_PATH_OUTSIDE_TARGET')
  147. require(path.name.startswith('.ksf-init-') or path.name in ('ksf-workshop-runtime', 'voc-intelligence'),
  148. 'CLEANUP_TARGET_NOT_OWNED')
  149. shutil.rmtree(path)
  150. def install_files(skills_dir, files, actions, backup_dir=None):
  151. skills_dir.mkdir(parents=True, exist_ok=True)
  152. staging = Path(tempfile.mkdtemp(prefix='.ksf-init-', dir=skills_dir))
  153. created = []
  154. upgraded = []
  155. try:
  156. for row in actions:
  157. if row['action'] != 'create':
  158. continue
  159. staged = staging / row['skill']
  160. for name, raw in files[row['skill']].items():
  161. p = staged / name
  162. p.parent.mkdir(parents=True, exist_ok=True)
  163. p.write_bytes(raw)
  164. verify_component(staged, files[row['skill']])
  165. # Recheck both targets before committing either component.
  166. for row in actions:
  167. target = skills_dir / row['skill']
  168. if row['action'] == 'create':
  169. require(not target.exists(), 'CONCURRENT_INSTALL_CHANGE')
  170. elif row['action'] == 'upgrade':
  171. verify_hashes(target, row['before'])
  172. for name in row.get('additions', []):
  173. require(not plain_path(target / name).exists(), 'CONCURRENT_INSTALL_CHANGE')
  174. else:
  175. verify_component(target, files[row['skill']])
  176. # Save every changed public file before replacing any file. Extra files stay untouched.
  177. for row in actions:
  178. if row['action'] != 'upgrade':
  179. continue
  180. require(backup_dir is not None, 'UPGRADE_BACKUP_REQUIRED')
  181. for name, raw in files[row['skill']].items():
  182. if name not in row['before'] or digest(raw) == row['before'][name]:
  183. continue
  184. dest = backup_dir / row['skill'] / name
  185. dest.parent.mkdir(parents=True, exist_ok=True)
  186. dest.write_bytes((skills_dir / row['skill'] / name).read_bytes())
  187. (backup_dir / 'upgrade-plan.json').write_bytes(encode(row))
  188. for row in actions:
  189. if row['action'] == 'create':
  190. target = skills_dir / row['skill']
  191. require(not target.exists(), 'CONCURRENT_INSTALL_CHANGE')
  192. (staging / row['skill']).rename(target)
  193. created.append(target)
  194. elif row['action'] == 'upgrade':
  195. target = skills_dir / row['skill']
  196. verify_hashes(target, row['before'])
  197. names = sorted(files[row['skill']], key=lambda n: n == 'SKILL.md')
  198. for name in names:
  199. raw = files[row['skill']][name]
  200. if name not in row['before']:
  201. dest = plain_path(target / name)
  202. require(not dest.exists(), 'CONCURRENT_INSTALL_CHANGE')
  203. dest.parent.mkdir(parents=True, exist_ok=True)
  204. # Exclusive creation preserves an unknown file that arrives after plan.
  205. with dest.open('xb') as stream:
  206. stream.write(raw)
  207. stream.flush()
  208. os.fsync(stream.fileno())
  209. upgraded.append((dest, row['skill'], name, digest(raw)))
  210. continue
  211. if digest(raw) == row['before'][name]:
  212. continue
  213. dest = plain_path(target / name)
  214. require(digest(dest.read_bytes()) == row['before'][name], 'CONCURRENT_INSTALL_CHANGE')
  215. temporary = dest.with_name(dest.name + '.ksf-upgrade-new')
  216. require(not temporary.exists(), 'UPGRADE_TEMP_EXISTS')
  217. try:
  218. with temporary.open('xb') as stream:
  219. stream.write(raw)
  220. stream.flush()
  221. os.fsync(stream.fileno())
  222. temporary.chmod(dest.stat().st_mode & 0o777)
  223. os.replace(temporary, dest)
  224. upgraded.append((dest, row['skill'], name, digest(raw)))
  225. finally:
  226. if temporary.exists():
  227. temporary.unlink()
  228. for row in actions:
  229. verify_component(skills_dir / row['skill'], files[row['skill']])
  230. except Exception:
  231. for dest, skill, name, after_hash in reversed(upgraded):
  232. require(digest(dest.read_bytes()) == after_hash, 'CONCURRENT_CHANGE_PREVENTS_RESTORE')
  233. prior = backup_dir / skill / name
  234. if prior.is_file():
  235. dest.write_bytes(prior.read_bytes())
  236. else:
  237. # Only the newly added public reference has no before-file.
  238. require(any(name in r.get('additions', []) and r['skill'] == skill for r in actions),
  239. 'MISSING_UPGRADE_BACKUP')
  240. dest.unlink()
  241. for target in reversed(created):
  242. verify_component(target, files[target.name])
  243. remove_created(target, skills_dir)
  244. raise
  245. finally:
  246. if staging.exists():
  247. remove_created(staging, skills_dir)
  248. def node_and_npm():
  249. node = shutil.which('node')
  250. npm = shutil.which('npm')
  251. if not node or not npm:
  252. return None
  253. version = subprocess.run([node, '-p', 'process.versions.node'], capture_output=True, timeout=15, check=False)
  254. require(version.returncode == 0 and int(version.stdout.decode().strip().split('.')[0]) >= 18,
  255. 'NODE_18_REQUIRED')
  256. if os.name == 'nt':
  257. cli = Path(npm).parent / 'node_modules/npm/bin/npm-cli.js'
  258. require(cli.is_file(), 'NPM_CLI_NOT_FOUND')
  259. return [node, str(cli)]
  260. return [npm]
  261. def prepare_runtime(package, voc_dir, output_dir):
  262. npm = node_and_npm()
  263. if not npm:
  264. return {'status': 'pending', 'reason': 'NODE_OR_NPM_NOT_AVAILABLE'}
  265. # Dependency resolution affects this newly installed VOC directory only.
  266. command = npm + ['install', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund',
  267. '--registry', 'https://registry.npmjs.org', '--cache', str(output_dir / 'npm-cache')]
  268. result = subprocess.run(command, cwd=voc_dir, capture_output=True, check=False, timeout=240)
  269. # Raw process logs are intentionally not copied into the user-facing receipt.
  270. if result.returncode:
  271. return {'status': 'pending', 'reason': 'VOC_DEPENDENCY_INSTALL_FAILED', 'exitCode': result.returncode}
  272. check = subprocess.run([shutil.which('node'), str(package / 'check-voc.cjs'), str(voc_dir)],
  273. cwd=package, capture_output=True, check=False, timeout=40)
  274. if check.returncode:
  275. return {'status': 'pending', 'reason': 'VOC_LOCAL_CHECK_FAILED', 'exitCode': check.returncode}
  276. value = json.loads(check.stdout)
  277. require(value.get('ok') is True, 'INVALID_VOC_CHECK_RESULT')
  278. return {'status': 'verified', 'check': value}
  279. def run(args, package=None):
  280. package = Path(package).resolve() if package else Path(__file__).resolve().parent
  281. require(Path(args.skills_dir).is_absolute(), 'ABSOLUTE_HOST_SKILLS_DIRECTORY_REQUIRED')
  282. skills_dir = plain_path(args.skills_dir)
  283. require(skills_dir != Path(skills_dir.anchor), 'FILESYSTEM_ROOT_NOT_ALLOWED')
  284. require(not skills_dir.exists() or skills_dir.is_dir(), 'SKILLS_DIRECTORY_REQUIRED')
  285. require(not package.is_relative_to(skills_dir) and not skills_dir.is_relative_to(package), 'PACKAGE_TARGET_OVERLAP')
  286. require(not args.prepare_runtime or args.apply, 'PREPARE_RUNTIME_REQUIRES_APPLY')
  287. if args.apply:
  288. require(args.out and Path(args.out).is_absolute(), 'ABSOLUTE_RESULT_DIRECTORY_REQUIRED')
  289. output = plain_path(args.out)
  290. require(not output.exists(), 'RESULT_DIRECTORY_EXISTS')
  291. require(not output.is_relative_to(skills_dir) and not skills_dir.is_relative_to(output), 'RESULT_TARGET_OVERLAP')
  292. meta = verify_package(package)
  293. files = source_files(package, meta, args.voc_archive)
  294. # The npm artifact contains the author's machine-specific MCP paths.
  295. # Adapt that one generated configuration after verifying the original artifact.
  296. original_mcp_sha = digest(files['voc-intelligence']['.mcp.json'])
  297. files['voc-intelligence']['.mcp.json'] = encode(mcp_fragment(skills_dir / 'voc-intelligence'))
  298. actions = plan(skills_dir, files, meta.get('upgradeFrom'))
  299. if args.apply and any(a['action'] == 'upgrade' for a in actions):
  300. require(getattr(args, 'upgrade', False), 'UPGRADE_FLAG_REQUIRED')
  301. receipt = {'schema': 'ksf.workshop-onboarding-result.v1', 'ok': True,
  302. 'releaseId': meta['releaseId'], 'readOnly': not args.apply, 'actions': actions,
  303. 'filesInstalled': False, 'hostSkillsLoaded': 'not-verified',
  304. 'vocRuntime': {'status': 'not-checked'}, 'hostToolRegistration': 'not-verified',
  305. 'vocLiveAccess': 'not-verified', 'researchLibraryAccess': 'not-verified',
  306. 'businessSaveAccess': 'not-verified', 'accountOrEnterpriseCreated': False,
  307. 'researchOrBusinessDataWritten': False, 'remoteInstalled': False,
  308. 'adaptedVocConfig': {'path': '.mcp.json', 'originalSha256': original_mcp_sha,
  309. 'installedSha256': digest(files['voc-intelligence']['.mcp.json'])},
  310. 'note': 'Files, local runtime, host discovery and live access have separate acceptance.'}
  311. if not args.apply:
  312. return receipt
  313. output.mkdir(parents=True, exist_ok=False)
  314. try:
  315. install_files(skills_dir, files, actions, output / 'before')
  316. receipt['filesInstalled'] = True
  317. voc_dir = skills_dir / 'voc-intelligence'
  318. # A neutral fragment is written to the result directory only; the host owns registration.
  319. (output / 'voc-mcp.fragment.json').write_bytes(encode(mcp_fragment(voc_dir)))
  320. receipt['mcpFragment'] = str(output / 'voc-mcp.fragment.json')
  321. if args.prepare_runtime:
  322. try:
  323. receipt['vocRuntime'] = prepare_runtime(package, voc_dir, output)
  324. except subprocess.TimeoutExpired:
  325. receipt['vocRuntime'] = {'status': 'pending', 'reason': 'RUNTIME_PREPARATION_TIMEOUT'}
  326. else:
  327. receipt['vocRuntime'] = {'status': 'pending', 'reason': 'DEPENDENCIES_NOT_PREPARED'}
  328. except Exception as exc:
  329. receipt['ok'] = False
  330. receipt['errorCode'] = str(exc) if isinstance(exc, InstallError) else 'INSTALL_FAILED'
  331. raise
  332. finally:
  333. (output / 'install-receipt.json').write_bytes(encode(receipt))
  334. return receipt
  335. def main():
  336. parser = argparse.ArgumentParser(description=__doc__)
  337. parser.add_argument('--skills-dir', required=True, help='Actual Skills directory provided by the host')
  338. parser.add_argument('--out', help='New absolute result directory outside the Skills directory')
  339. parser.add_argument('--voc-archive', help='Optional local copy of the exact official npm tarball')
  340. parser.add_argument('--apply', action='store_true')
  341. parser.add_argument('--prepare-runtime', action='store_true')
  342. parser.add_argument('--upgrade', action='store_true', help='Upgrade an integrity-checked supported prior workshop version')
  343. args = parser.parse_args()
  344. try:
  345. receipt = run(args)
  346. print(json.dumps(receipt, ensure_ascii=False, indent=2))
  347. return 0
  348. except Exception as exc:
  349. code = str(exc) if isinstance(exc, InstallError) else 'INITIALIZATION_FAILED'
  350. print(json.dumps({'ok': False, 'errorCode': code, 'note': 'No credentials or raw environment were printed.'}))
  351. return 1
  352. if __name__ == '__main__':
  353. sys.exit(main())