bootstrap.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. """Install a sealed enterprise workshop without copying credentials or identities."""
  2. import argparse
  3. import hashlib
  4. import json
  5. import os
  6. from pathlib import Path, PurePosixPath
  7. import re
  8. import shutil
  9. import sys
  10. import tempfile
  11. import uuid
  12. sys.dont_write_bytecode = True
  13. HERE = Path(__file__).absolute().parent
  14. COMPANY = 'FKieRbIoGt'
  15. APP = 'FKieRbIoGt_main'
  16. class Stopped(Exception):
  17. pass
  18. def need(value, code):
  19. if not value:
  20. raise Stopped(code)
  21. def sha(raw):
  22. return hashlib.sha256(raw).hexdigest()
  23. def encode(value):
  24. return (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
  25. def plain(value):
  26. p = Path(os.path.abspath(value))
  27. for part in [*reversed(p.parents), p]:
  28. need(not part.is_symlink(), 'SYMLINK_PATH')
  29. need(not getattr(part, 'is_junction', lambda: False)(), 'JUNCTION_PATH')
  30. return p
  31. def safe_name(name):
  32. return (isinstance(name, str) and bool(name) and '\\' not in name and ':' not in name
  33. and not name.startswith('/') and name == '/'.join(PurePosixPath(name).parts)
  34. and all(x not in ('.', '..') for x in PurePosixPath(name).parts))
  35. def private_json(file):
  36. file = plain(file)
  37. need(file.is_file(), 'PRIVATE_FILE_MISSING')
  38. stat = file.stat()
  39. if os.name != 'nt':
  40. need(stat.st_uid == os.geteuid() and stat.st_mode & 0o077 == 0, 'PRIVATE_FILE_PERMISSIONS')
  41. return json.loads(file.read_bytes())
  42. def sealed(root):
  43. root = plain(root)
  44. listed = {}
  45. for line in (root / 'FILES.sha256').read_text('utf-8').splitlines():
  46. m = re.fullmatch(r'([a-f0-9]{64}) (.+)', line)
  47. need(m is not None, 'INVALID_MANIFEST')
  48. digest, name = m.groups()
  49. need(safe_name(name) and name not in listed, 'INVALID_MANIFEST_PATH')
  50. p = plain(root / name)
  51. need(p.is_file() and sha(p.read_bytes()) == digest, 'PACKAGE_HASH_MISMATCH')
  52. listed[name] = digest
  53. actual = set()
  54. for p in root.rglob('*'):
  55. if p.relative_to(root).parts[0] == '.git':
  56. continue
  57. plain(p)
  58. need(p.is_file() or p.is_dir(), 'SPECIAL_PACKAGE_FILE')
  59. if p.is_file():
  60. actual.add(p.relative_to(root).as_posix())
  61. need(actual == set(listed) | {'FILES.sha256'}, 'UNDECLARED_PACKAGE_FILE')
  62. cfg = json.loads((root / 'enterprise.json').read_bytes())
  63. need(cfg['schema'] == 'ksf.enterprise-workshop.v1' and cfg['companyObjectId'] == COMPANY
  64. and cfg['appId'] == APP, 'PACKAGE_ENTERPRISE_MISMATCH')
  65. need(cfg['functionsURL'] == 'https://kangshifu.fmode.cn/api/functions'
  66. and cfg['serverURL'] == 'https://kangshifu.fmode.cn/data', 'PACKAGE_TARGET_MISMATCH')
  67. entry = 'payload/skills/ksf-workshop-runtime/SKILL.md'
  68. need(listed.get(entry) == cfg['skill']['entrySha256'], 'SKILL_ENTRY_MISMATCH')
  69. return cfg, listed
  70. def candidate(root, listed):
  71. files = {}
  72. for name in listed:
  73. if name.startswith('payload/'):
  74. relative = name[len('payload/'):]
  75. need(relative.startswith(('skills/ksf-workshop-runtime/', 'skills/ksf-voc-library/scripts/')),
  76. 'UNEXPECTED_PAYLOAD')
  77. files[relative] = (root / name).read_bytes()
  78. skill = {n: b for n, b in files.items() if n.startswith('skills/ksf-workshop-runtime/')}
  79. need(len(skill) == 45 and len(files) > 45, 'INCOMPLETE_PAYLOAD')
  80. need(not any('credential-binding' in n or 'channel.json' in n for n in files), 'PRIVATE_PAYLOAD')
  81. return files
  82. def check_enterprise(current, expected):
  83. need(isinstance(current, dict), 'INVALID_ENTERPRISE_CONFIGURATION')
  84. need(current.get('companyObjectId') == expected['companyObjectId']
  85. and current.get('appId') == expected['appId'], 'ENTERPRISE_MISMATCH')
  86. need(all(isinstance(current.get(k), str) and current[k].rstrip('/') == expected[k]
  87. for k in ('serverURL', 'functionsURL')),
  88. 'ENTERPRISE_ENDPOINT_MISMATCH')
  89. need(current.get('provisionStatus') == 'success', 'ENTERPRISE_NOT_PROVISIONED')
  90. def tree_snapshot(directory):
  91. if not directory.exists():
  92. return None
  93. need(directory.is_dir(), 'NOT_DIRECTORY')
  94. result = {}
  95. for p in directory.rglob('*'):
  96. plain(p)
  97. need(p.is_file() or p.is_dir(), 'SPECIAL_TARGET_FILE')
  98. if p.is_file():
  99. result[p.relative_to(directory).as_posix()] = [sha(p.read_bytes()), p.stat().st_mode & 0o777]
  100. return result
  101. def plan(root, data_dir):
  102. root, data_dir = plain(root), plain(data_dir)
  103. need(data_dir.is_absolute() and data_dir != Path(data_dir.anchor), 'INVALID_DATA_DIR')
  104. need(root != data_dir and root not in data_dir.parents
  105. and not any(target == root or target in root.parents or root in target.parents
  106. for target in (data_dir / 'skills', data_dir / '.fmode',
  107. data_dir / 'ksf-enterprise-workshop')),
  108. 'PACKAGE_TARGET_OVERLAP')
  109. cfg, listed = sealed(root)
  110. current_path = data_dir / '.fmode/config/enterprise/current.json'
  111. current = private_json(current_path)
  112. check_enterprise(current, cfg)
  113. files = candidate(root, listed)
  114. targets = sorted({n.split('/')[1] for n in files})
  115. if (data_dir / 'skills/ksf-voc-library/SKILL.md').exists():
  116. raise Stopped('RESEARCH_SKILL_ENTRYPOINT_STILL_ACTIVE')
  117. snapshots = {n: tree_snapshot(plain(data_dir / 'skills' / n)) for n in targets}
  118. changes = []
  119. for name, raw in files.items():
  120. dest = plain(data_dir / name)
  121. need(not dest.exists() or dest.is_file(), 'TARGET_NOT_FILE')
  122. if dest.exists():
  123. need(sha(dest.read_bytes()) == sha(raw), 'EXISTING_COMPONENT_DIFFERS')
  124. else:
  125. # Existing directories may carry private local files, but cannot be a partial/unknown version.
  126. need(snapshots[name.split('/')[1]] is None, 'INCOMPLETE_EXISTING_COMPONENT')
  127. changes.append(name)
  128. binding = encode({'schema': 'ksf.enterprise-workshop-binding.v1', 'companyObjectId': COMPANY,
  129. 'appId': APP, 'configurationSha256': sha((root / 'enterprise.json').read_bytes()),
  130. 'packageManifestSha256': sha((root / 'FILES.sha256').read_bytes()),
  131. 'skill': 'ksf-workshop-runtime', 'version': 'r13'})
  132. binding_path = plain(data_dir / 'ksf-enterprise-workshop/binding.json')
  133. binding_before = sha(binding_path.read_bytes()) if binding_path.exists() else None
  134. if binding_path.exists():
  135. prior = json.loads((root / 'previous-release.json').read_bytes())
  136. need(binding_before in (sha(binding), prior['previousBindingSha256']), 'ENTERPRISE_BINDING_CHANGED')
  137. return {'schema': 'ksf.enterprise-workshop-plan.v1', 'companyObjectId': COMPANY, 'appId': APP,
  138. 'dataDir': str(data_dir), 'packageManifestSha256': sha((root / 'FILES.sha256').read_bytes()),
  139. 'currentPublicFields': {k: current[k] for k in ('companyObjectId', 'appId', 'serverURL', 'functionsURL')},
  140. 'snapshots': snapshots, 'changes': changes, 'bindingExists': binding_path.exists(),
  141. 'bindingSha256': sha(binding), 'bindingBeforeSha256': binding_before,
  142. 'bindingUpdateRequired': binding_before != sha(binding), 'businessWrites': 0}
  143. def atomic_create(file, raw, mode=0o600):
  144. plain(file)
  145. file.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
  146. with file.open('xb') as stream:
  147. os.chmod(file, mode)
  148. stream.write(raw)
  149. stream.flush()
  150. os.fsync(stream.fileno())
  151. def apply(root, data_dir, expected_plan):
  152. root, data_dir = plain(root), plain(data_dir)
  153. observed = plan(root, data_dir)
  154. if not observed['changes'] and observed['bindingExists'] and not observed['bindingUpdateRequired']:
  155. need(observed['packageManifestSha256'] == expected_plan['packageManifestSha256']
  156. and observed['dataDir'] == expected_plan['dataDir']
  157. and observed['currentPublicFields'] == expected_plan['currentPublicFields'], 'PLAN_CHANGED')
  158. return {**verify(root, data_dir), 'installedFiles': 0, 'applied': False}
  159. need(observed == expected_plan, 'PLAN_CHANGED')
  160. _, listed = sealed(root)
  161. files = candidate(root, listed)
  162. lock = plain(data_dir / '.ksf-enterprise-bootstrap.lock')
  163. created = []
  164. created_snapshots = {}
  165. replaced_binding = None
  166. descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
  167. os.close(descriptor)
  168. try:
  169. need(plan(root, data_dir) == expected_plan, 'PLAN_CHANGED')
  170. # Existing components are verify-only. All new component trees are staged before any rename.
  171. with tempfile.TemporaryDirectory(prefix='.ksf-enterprise-stage-', dir=data_dir) as tmp:
  172. staging = Path(tmp)
  173. for relative in expected_plan['changes']:
  174. atomic_create(staging / relative, files[relative], 0o644)
  175. for name, before in expected_plan['snapshots'].items():
  176. target = plain(data_dir / 'skills' / name)
  177. need(tree_snapshot(target) == before, 'CONCURRENT_INSTALL')
  178. if before is None:
  179. target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
  180. created_snapshots[target] = tree_snapshot(staging / 'skills' / name)
  181. os.rename(staging / 'skills' / name, target)
  182. created.append(target)
  183. bind = plain(data_dir / 'ksf-enterprise-workshop/binding.json')
  184. if expected_plan['bindingUpdateRequired']:
  185. raw = encode({'schema': 'ksf.enterprise-workshop-binding.v1', 'companyObjectId': COMPANY,
  186. 'appId': APP, 'configurationSha256': sha((root / 'enterprise.json').read_bytes()),
  187. 'packageManifestSha256': expected_plan['packageManifestSha256'],
  188. 'skill': 'ksf-workshop-runtime', 'version': 'r13'})
  189. if not bind.exists():
  190. atomic_create(bind, raw)
  191. created.append(bind)
  192. else:
  193. before = bind.read_bytes()
  194. need(sha(before) == expected_plan['bindingBeforeSha256'], 'CONCURRENT_INSTALL')
  195. atomic_create(bind.with_name('binding-before-' + uuid.uuid4().hex + '.json'), before)
  196. temp_bind = bind.with_name('.binding-' + uuid.uuid4().hex)
  197. atomic_create(temp_bind, raw)
  198. try:
  199. need(bind.read_bytes() == before, 'CONCURRENT_INSTALL')
  200. os.replace(temp_bind, bind)
  201. replaced_binding = (bind, before)
  202. finally:
  203. if temp_bind.exists():
  204. temp_bind.unlink()
  205. result = verify(root, data_dir)
  206. return {**result, 'installedFiles': len(expected_plan['changes']), 'applied': True,
  207. 'existingComponentsModified': False, 'identitiesCopied': False,
  208. 'credentialsCopied': False, 'vocModified': False}
  209. except Exception:
  210. if replaced_binding:
  211. bind, before = replaced_binding
  212. if sha(bind.read_bytes()) == expected_plan['bindingSha256']:
  213. rollback = bind.with_name('.binding-rollback-' + uuid.uuid4().hex)
  214. atomic_create(rollback, before)
  215. os.replace(rollback, bind)
  216. # Only remove newly created, still-identical payload files. Preserve concurrent changes.
  217. for p in reversed(created):
  218. if p.is_file():
  219. if sha(p.read_bytes()) == expected_plan['bindingSha256']:
  220. p.unlink()
  221. elif p.is_dir() and tree_snapshot(p) == created_snapshots[p]:
  222. need(p.parent == data_dir / 'skills', 'ROLLBACK_PATH_OUTSIDE_TARGET')
  223. shutil.rmtree(p)
  224. raise
  225. finally:
  226. lock.unlink()
  227. def verify(root, data_dir):
  228. result = plan(root, data_dir)
  229. need(not result['changes'] and result['bindingExists'] and not result['bindingUpdateRequired'],
  230. 'INSTALLATION_INCOMPLETE')
  231. return {'ok': True, 'companyObjectId': COMPANY, 'appId': APP, 'version': 'r13',
  232. 'filesVerified': len(candidate(plain(root), sealed(root)[1])), 'businessWrites': 0,
  233. 'agentLoaded': False, 'researchAuthorized': False, 'businessAuthorized': False,
  234. 'participantBound': False, 'automaticLifecycleHookVerified': False}
  235. def main():
  236. parser = argparse.ArgumentParser(description=__doc__)
  237. parser.add_argument('command', choices=['plan', 'apply', 'verify'])
  238. parser.add_argument('--data-dir', required=True, type=Path)
  239. parser.add_argument('--out', required=True, type=Path)
  240. args = parser.parse_args()
  241. need(args.data_dir.is_absolute() and args.out.is_absolute(), 'ABSOLUTE_PATH_REQUIRED')
  242. data_dir, out = plain(args.data_dir), plain(args.out)
  243. need(out != data_dir and out not in data_dir.parents and not (data_dir / 'skills') in out.parents,
  244. 'UNSAFE_RESULT_DIRECTORY')
  245. out.mkdir(parents=True, exist_ok=True, mode=0o700)
  246. if args.command == 'plan':
  247. record = plan(HERE, data_dir)
  248. atomic_create(out / 'plan.json', encode(record))
  249. result = {'ok': True, 'readOnly': True, 'changes': len(record['changes']), 'businessWrites': 0}
  250. elif args.command == 'apply':
  251. result = apply(HERE, data_dir, private_json(out / 'plan.json'))
  252. atomic_create(out / ('apply-' + uuid.uuid4().hex + '.json'), encode(result))
  253. else:
  254. result = verify(HERE, data_dir)
  255. atomic_create(out / ('verify-' + uuid.uuid4().hex + '.json'), encode(result))
  256. print(json.dumps(result, ensure_ascii=False, indent=2))
  257. if __name__ == '__main__':
  258. try:
  259. main()
  260. except (Stopped, OSError, ValueError, KeyError) as error:
  261. print(json.dumps({'ok': False, 'error': {'code': str(error) if isinstance(error, Stopped)
  262. else 'BOOTSTRAP_STOPPED'}, 'businessWrites': 0}), file=sys.stderr)
  263. sys.exit(1)