voc.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """Required official VOC dependency: verify existing installation or install the pinned missing package."""
  2. import argparse
  3. import io
  4. import json
  5. import os
  6. from pathlib import Path
  7. import shutil
  8. import subprocess
  9. import sys
  10. import tarfile
  11. import tempfile
  12. import urllib.request
  13. import uuid
  14. sys.dont_write_bytecode = True
  15. from bootstrap import (HERE, Stopped, atomic_create, check_enterprise, encode, need, plain,
  16. private_json, safe_name, sealed, sha)
  17. def release(root):
  18. root = plain(root)
  19. cfg, _ = sealed(root)
  20. need(cfg['voc']['required'] is True and cfg['voc']['version'] == '0.3.44', 'VOC_REQUIREMENT_INVALID')
  21. meta = json.loads((root / cfg['voc']['releaseManifest']).read_bytes())
  22. need(meta['name'] == '@vocmarket/voc-skill' and meta['version'] == '0.3.44'
  23. and meta['url'] == 'https://registry.npmjs.org/@vocmarket/voc-skill/-/voc-skill-0.3.44.tgz',
  24. 'VOC_RELEASE_INVALID')
  25. need(sha((root / cfg['voc']['dependencyLock']).read_bytes()) == meta['dependencyLockSha256'],
  26. 'VOC_DEPENDENCY_LOCK_CHANGED')
  27. return cfg, meta
  28. def fragment(target):
  29. return {'mcpServers': {'voc': {'command': 'node', 'args': [str(target / 'mcp/src/server.js')],
  30. 'cwd': str(target), 'env': {'VOC_SKILL_NO_UPDATE_CHECK': '1'}}}}
  31. def public_hashes(target, meta):
  32. result = {}
  33. for name, digest in meta['files'].items():
  34. need(safe_name(name), 'INVALID_VOC_PATH')
  35. file = plain(target / name)
  36. need(file.is_file(), 'VOC_PUBLIC_FILE_MISSING')
  37. actual = sha(file.read_bytes())
  38. if actual != digest:
  39. need(name == '.mcp.json', 'EXISTING_VOC_FILES_CHANGED')
  40. # Preserve the host's existing paths, credentials and additional MCP configuration.
  41. value = json.loads(file.read_bytes()).get('mcpServers', {}).get('voc', {})
  42. need(value.get('command') == 'node' and value.get('args') == [str(target / 'mcp/src/server.js')]
  43. and value.get('cwd') == str(target), 'EXISTING_VOC_MCP_BINDING_DIFFERS')
  44. result[name] = actual
  45. return result
  46. def plan(root, data_dir):
  47. root, data_dir = plain(root), plain(data_dir)
  48. cfg, meta = release(root)
  49. check_enterprise(private_json(data_dir / '.fmode/config/enterprise/current.json'), cfg)
  50. options = [plain(data_dir / 'skills' / name) for name in ('voc-intelligence', 'voc-social-suite')]
  51. official = []
  52. for target in options:
  53. package = plain(target / 'package.json')
  54. if package.is_file():
  55. info = json.loads(package.read_bytes())
  56. if info.get('name') == meta['name']:
  57. need(info.get('version') == meta['version'], 'EXISTING_VOC_VERSION_DIFFERS')
  58. official.append(target)
  59. need(len(official) <= 1, 'DUPLICATE_OFFICIAL_VOC_INSTALLATION')
  60. target = official[0] if official else options[0]
  61. need(bool(official) or not target.exists(), 'UNKNOWN_EXISTING_VOC_DIRECTORY')
  62. hashes = public_hashes(target, meta) if official else None
  63. return {'schema': 'ksf.enterprise-voc-plan.v1', 'version': meta['version'], 'target': str(target),
  64. 'action': 'verify-existing' if official else 'install-missing', 'publicFiles': len(meta['files']),
  65. 'beforePublicHashes': hashes, 'archiveSha256': meta['sha256'],
  66. 'packageManifestSha256': sha((root / 'FILES.sha256').read_bytes()), 'businessWrites': 0}
  67. def unpack(raw, meta):
  68. need(len(raw) == meta['bytes'] and sha(raw) == meta['sha256'], 'VOC_ARCHIVE_HASH_MISMATCH')
  69. files = {}
  70. with tarfile.open(fileobj=io.BytesIO(raw), mode='r:gz') as z:
  71. for member in z.getmembers():
  72. need(member.isfile() and member.name.startswith('package/'), 'UNSAFE_VOC_ARCHIVE')
  73. name = member.name[len('package/'):]
  74. need(safe_name(name) and name not in files and name in meta['files'], 'UNSAFE_VOC_ARCHIVE_PATH')
  75. body = z.extractfile(member).read()
  76. need(sha(body) == meta['files'][name], 'VOC_FILE_HASH_MISMATCH')
  77. files[name] = body
  78. need(set(files) == set(meta['files']), 'VOC_ARCHIVE_INCOMPLETE')
  79. return files
  80. def runtime(root, target, runner=subprocess.run):
  81. node = shutil.which('node')
  82. need(node, 'NODE_REQUIRED_FOR_VOC')
  83. check = runner([node, str(root / 'check-voc.cjs'), str(target)], capture_output=True, timeout=45)
  84. need(check.returncode == 0, 'VOC_LOCAL_RUNTIME_CHECK_FAILED')
  85. value = json.loads(check.stdout)
  86. need(value.get('ok') is True and value.get('version') == '0.3.44'
  87. and value.get('collectionRequests') == 0, 'VOC_CHECK_RECEIPT_INVALID')
  88. return value
  89. def npm_command():
  90. node, npm = shutil.which('node'), shutil.which('npm')
  91. need(node and npm, 'NODE_AND_NPM_REQUIRED_FOR_VOC')
  92. version = subprocess.run([node, '-p', 'process.versions.node'], capture_output=True, timeout=15)
  93. need(version.returncode == 0 and int(version.stdout.decode().split('.')[0]) >= 18, 'NODE_18_REQUIRED')
  94. if os.name == 'nt':
  95. cli = Path(node).parent / 'node_modules/npm/bin/npm-cli.js'
  96. need(cli.is_file(), 'NPM_CLI_NOT_FOUND')
  97. return [node, str(cli)]
  98. return [npm]
  99. def apply(root, data_dir, expected_plan, output, archive=None, runner=subprocess.run):
  100. root, data_dir, output = plain(root), plain(data_dir), plain(output)
  101. observed = plan(root, data_dir)
  102. cfg, meta = release(root)
  103. target = plain(observed['target'])
  104. need(observed['packageManifestSha256'] == expected_plan['packageManifestSha256']
  105. and observed['target'] == expected_plan['target'], 'VOC_PLAN_CHANGED')
  106. if observed['action'] == 'verify-existing':
  107. check = runtime(root, target, runner)
  108. result = {'ok': True, 'version': meta['version'], 'action': 'verified-existing', 'target': str(target),
  109. 'vocModified': False, 'runtime': check, 'businessWrites': 0}
  110. else:
  111. need(observed == expected_plan, 'VOC_PLAN_CHANGED')
  112. if archive:
  113. raw = plain(archive).read_bytes()
  114. else:
  115. with urllib.request.urlopen(meta['url'], timeout=45) as response:
  116. raw = response.read(meta['bytes'] + 1)
  117. files = unpack(raw, meta)
  118. target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
  119. with tempfile.TemporaryDirectory(prefix='.ksf-voc-stage-', dir=target.parent) as temp:
  120. staged = Path(temp) / 'voc'
  121. for name, body in files.items():
  122. atomic_create(staged / name, body, 0o644)
  123. (staged / '.mcp.json').write_bytes(encode(fragment(target)))
  124. atomic_create(staged / 'package-lock.json', (root / cfg['voc']['dependencyLock']).read_bytes(), 0o644)
  125. command = npm_command() + ['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund',
  126. '--registry', 'https://registry.npmjs.org', '--cache', str(output / 'npm-cache')]
  127. installed = runner(command, cwd=staged, capture_output=True, timeout=240)
  128. need(installed.returncode == 0, 'VOC_DEPENDENCY_INSTALL_FAILED')
  129. runtime(root, staged, runner)
  130. need(plan(root, data_dir) == expected_plan, 'VOC_CONCURRENT_INSTALL')
  131. os.rename(staged, target)
  132. result = {**verify(root, data_dir, runner), 'action': 'installed-missing', 'vocModified': True}
  133. file = output / 'voc-mcp.fragment.json'
  134. body = encode(fragment(target))
  135. if file.exists():
  136. need(file.read_bytes() == body, 'MCP_FRAGMENT_CHANGED')
  137. else:
  138. atomic_create(file, body)
  139. result['mcpFragment'] = str(file)
  140. return result
  141. def verify(root, data_dir, runner=subprocess.run):
  142. observed = plan(root, data_dir)
  143. need(observed['action'] == 'verify-existing', 'VOC_NOT_INSTALLED')
  144. check = runtime(plain(root), Path(observed['target']), runner)
  145. return {'ok': True, 'version': observed['version'], 'target': observed['target'],
  146. 'publicFilesVerified': observed['publicFiles'], 'runtime': check,
  147. 'hostRegistrationVerified': False, 'liveAccessVerified': False, 'businessWrites': 0}
  148. def main():
  149. p = argparse.ArgumentParser(description=__doc__)
  150. p.add_argument('command', choices=['plan', 'apply', 'verify'])
  151. p.add_argument('--data-dir', type=Path, required=True)
  152. p.add_argument('--out', type=Path, required=True)
  153. p.add_argument('--archive', type=Path)
  154. a = p.parse_args()
  155. need(a.data_dir.is_absolute() and a.out.is_absolute(), 'ABSOLUTE_PATH_REQUIRED')
  156. data, output = plain(a.data_dir), plain(a.out)
  157. need(output != data and output not in data.parents and data / 'skills' not in output.parents
  158. and HERE not in output.parents and output != HERE, 'UNSAFE_RESULT_DIRECTORY')
  159. output.mkdir(parents=True, exist_ok=True, mode=0o700)
  160. if a.command == 'plan':
  161. result = plan(HERE, data)
  162. atomic_create(output / 'voc-plan.json', encode(result))
  163. result = {'ok': True, 'planOnly': True, **result}
  164. elif a.command == 'apply':
  165. result = apply(HERE, data, private_json(output / 'voc-plan.json'), output, a.archive)
  166. else:
  167. result = verify(HERE, data)
  168. atomic_create(output / ('voc-' + a.command + '-' + uuid.uuid4().hex + '.json'), encode(result))
  169. print(json.dumps(result, ensure_ascii=False, indent=2))
  170. if __name__ == '__main__':
  171. try:
  172. main()
  173. except (Stopped, OSError, ValueError, KeyError, subprocess.TimeoutExpired, tarfile.TarError) as error:
  174. print(json.dumps({'ok': False, 'error': {'code': str(error) if isinstance(error, Stopped)
  175. else 'VOC_INITIALIZATION_STOPPED'}, 'businessWrites': 0}), file=sys.stderr)
  176. sys.exit(1)