readiness.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. """Check the agent's own research and encrypted business entries; never save business records."""
  2. import argparse
  3. import json
  4. import os
  5. from pathlib import Path
  6. import re
  7. import subprocess
  8. import sys
  9. import tempfile
  10. import uuid
  11. sys.dont_write_bytecode = True
  12. from bootstrap import HERE, Stopped, atomic_create, encode, need, plain, private_json, sha, verify
  13. import voc
  14. def activate_research(data_dir):
  15. """Persist one non-secret variable, preserving all other .env bytes and a private backup."""
  16. file = plain(data_dir / '.env')
  17. before = file.read_bytes() if file.exists() else None
  18. expected = str(data_dir / 'ksf-library-channel/channel.json')
  19. lines = (before or b'').decode('utf-8-sig').splitlines()
  20. matches = [re.fullmatch(r'\s*(?:export\s+)?KSF_LIBRARY_CHANNEL\s*=\s*(.*?)\s*', x) for x in lines]
  21. values = [m.group(1).strip('\"\'') for m in matches if m]
  22. need(len(values) <= 1 and (not values or values[0] == expected), 'EXISTING_RESEARCH_BINDING_DIFFERS')
  23. if values:
  24. return False
  25. newline = b'\r\n' if b'\r\n' in (before or b'') else b'\n'
  26. raw = before or b''
  27. if raw and not raw.endswith(b'\n'):
  28. raw += newline
  29. raw += ('KSF_LIBRARY_CHANNEL=' + expected).encode() + newline
  30. if before is not None:
  31. backup = data_dir / 'ksf-enterprise-workshop' / ('env-before-' + uuid.uuid4().hex)
  32. atomic_create(backup, before)
  33. temp = file.with_name('.env.ksf-' + uuid.uuid4().hex)
  34. try:
  35. atomic_create(temp, raw)
  36. need((file.read_bytes() if file.exists() else None) == before, 'ENV_CONCURRENT_CHANGE')
  37. os.replace(temp, file)
  38. finally:
  39. if temp.exists():
  40. temp.unlink()
  41. return True
  42. def run_json(argv, env, runner=subprocess.run):
  43. result = runner(argv, env=env, capture_output=True, timeout=180)
  44. try:
  45. value = json.loads(result.stdout if result.returncode == 0 else result.stderr)
  46. except (ValueError, UnicodeError):
  47. raise Stopped('ENTRY_RESPONSE_INVALID') from None
  48. if result.returncode != 0 or value.get('ok') is not True:
  49. code = value.get('error', {}).get('code', '')
  50. raise Stopped(code if re.fullmatch('[A-Z][A-Z0-9_]{1,90}', str(code)) else 'ENTRY_FAILED')
  51. return value
  52. def check(data_dir, output, activate=False, runner=subprocess.run):
  53. result = verify(HERE, data_dir)
  54. need(sys.platform == 'linux', 'TARGET_LINUX_RUNTIME_REQUIRED')
  55. voc_result = voc.verify(HERE, data_dir, runner)
  56. env = dict(os.environ)
  57. env['KSF_LIBRARY_CHANNEL'] = str(data_dir / 'ksf-library-channel/channel.json')
  58. env['PYTHONDONTWRITEBYTECODE'] = '1'
  59. channel = private_json(Path(env['KSF_LIBRARY_CHANNEL']))
  60. need(channel.get('schema') == 'ksf.library-channel.v1', 'INVALID_CHANNEL_CONFIG')
  61. # The installed library enforces key/known_hosts ownership and pinned host-key checking.
  62. library = run_json([sys.executable, '-B', str(data_dir / 'skills/ksf-voc-library/scripts/query_voc.py'),
  63. 'contract'], env, runner)
  64. binding = library.get('publicationBinding', {})
  65. generation = library.get('generation')
  66. need(binding.get('mode') == 'ssh-channel' and binding.get('backend') == 'parse-postgresql'
  67. and re.fullmatch('[a-f0-9]{64}', str(generation)) is not None
  68. and binding.get('currentGeneration') == generation and library.get('readOnly') is True
  69. and library.get('databaseWrites') == 0, 'RESEARCH_CONTRACT_MISMATCH')
  70. need(channel.get('host') and channel.get('user'), 'INVALID_CHANNEL_CONFIG')
  71. request_file = output / ('contract-input-' + uuid.uuid4().hex + '.json')
  72. atomic_create(request_file, b'{}\n')
  73. workshop = run_json(['node', str(data_dir / 'skills/ksf-workshop-runtime/scripts/ksf-secure.mjs'),
  74. 'workshop-contract', '--input', str(request_file), '--request-id',
  75. 'enterprise-contract-' + uuid.uuid4().hex], env, runner)
  76. inner = workshop.get('entryResult', {})
  77. need(workshop.get('credentialLoadedForChild') is True
  78. and workshop.get('credentialWrittenToDisk') is False
  79. and inner.get('readOnly') is True and inner.get('result', {}).get('workshop', {}).get('version')
  80. == 'ksf.workshop.v1', 'WORKSHOP_CONTRACT_MISMATCH')
  81. changed = activate_research(data_dir) if activate else False
  82. return {**result, 'researchAuthorized': True, 'businessAuthorized': True,
  83. 'voc': voc_result, 'vocCredentialAvailable': voc_result['runtime']['credentialAvailable'],
  84. 'research': {'mode': binding['mode'], 'backend': binding['backend'], 'generation': generation,
  85. 'readOnly': True, 'databaseWrites': 0},
  86. 'workshop': {'version': 'ksf.workshop.v1', 'readOnly': True},
  87. 'defaultResearchEnvironmentUpdated': changed, 'hostEnvironmentReloadVerified': False,
  88. 'realSaveReadbackVerified': False, 'dashboardAttributionVerified': False}
  89. def main():
  90. parser = argparse.ArgumentParser(description=__doc__)
  91. parser.add_argument('--data-dir', type=Path, required=True)
  92. parser.add_argument('--out', type=Path, required=True)
  93. parser.add_argument('--activate-research', action='store_true')
  94. args = parser.parse_args()
  95. need(args.data_dir.is_absolute() and args.out.is_absolute(), 'ABSOLUTE_PATH_REQUIRED')
  96. data_dir, output = plain(args.data_dir), plain(args.out)
  97. need(output not in data_dir.parents and output != data_dir
  98. and data_dir / 'skills' not in output.parents, 'UNSAFE_RESULT_DIRECTORY')
  99. output.mkdir(parents=True, exist_ok=True, mode=0o700)
  100. result = check(data_dir, output, args.activate_research)
  101. atomic_create(output / ('readiness-' + uuid.uuid4().hex + '.json'), encode(result))
  102. print(json.dumps(result, ensure_ascii=False, indent=2))
  103. if __name__ == '__main__':
  104. try:
  105. main()
  106. except (Stopped, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
  107. print(json.dumps({'ok': False, 'error': {'code': str(error) if isinstance(error, Stopped)
  108. else 'READINESS_STOPPED'}, 'businessWrites': 0}), file=sys.stderr)
  109. sys.exit(1)