test_main_fan_sandbox.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. """自主模式的真实沙箱执行、交付归档和权限边界;不调用外部模型或网关。"""
  2. import asyncio
  3. import json
  4. import socket
  5. from pathlib import Path
  6. from types import SimpleNamespace
  7. import pytest
  8. from PIL import Image
  9. import config
  10. from agents.workspace import session_root
  11. from db import main_fan_store
  12. from extensions.main_fan import exploration, service
  13. from extensions.main_fan.schemas import AgentAssessment
  14. from extensions.registry import load_extensions
  15. from runtime import python_sandbox
  16. @pytest.fixture
  17. def workbench(monkeypatch, tmp_path):
  18. monkeypatch.setattr(config, 'ROOT', tmp_path)
  19. monkeypatch.setenv('MAIN_FAN_TARGETS_FILE', '')
  20. monkeypatch.setenv('MAIN_FAN_RULES_FILE', '')
  21. user, session = 'sandbox-user', 'sandbox-session'
  22. workspace = session_root(user, session) / 'workspace'
  23. uploads = workspace / 'uploads'
  24. uploads.mkdir(parents=True)
  25. source = uploads / 'scan.pdf'
  26. with Image.new('RGB', (120, 160), 'white') as image:
  27. image.save(source, 'PDF')
  28. request = {'mine_code': 'sandbox-mine', 'mine_name': '沙箱测试矿',
  29. 'fan_id': 'fan-1', 'fan_name': '1号风机', 'fan_pair': ['fan-1', 'fan-2'],
  30. 'materials': [{'path': '/workspace/uploads/scan.pdf'}],
  31. 'measurements': {'values': [10, 20, 30]}}
  32. prepared = exploration.prepare(request, user, session)
  33. return SimpleNamespace(user=user, session=session, workspace=workspace,
  34. request=request, manifest=prepared, source=source)
  35. def require_sandbox():
  36. if not python_sandbox.capability()['available']:
  37. pytest.skip(python_sandbox.capability()['reason'])
  38. def delivery_script(workbench, *, code='WARNING', summary='脚本实际计算均值20.0', status='completed'):
  39. """测试模型写出的程序:读原 PDF、渲染、计算并生成所有交付,不调用业务服务。"""
  40. identifier = workbench.manifest['workflow_id']
  41. return f'''
  42. from pathlib import Path
  43. import json
  44. import statistics
  45. import pypdfium2 as pdfium
  46. from PIL import Image
  47. import numpy as np
  48. import cv2
  49. base = Path('exploration/{identifier}')
  50. data = json.loads((base/'input.json').read_text())
  51. mean = statistics.mean(data['measurements']['values'])
  52. with pdfium.PdfDocument('uploads/scan.pdf') as pdf:
  53. pages = len(pdf)
  54. page = pdf[0]
  55. try:
  56. textpage = page.get_textpage()
  57. try:
  58. text = textpage.get_text_range()
  59. finally:
  60. textpage.close()
  61. bitmap = page.render(scale=1)
  62. image = bitmap.to_pil()
  63. image.save('page.png')
  64. image.close()
  65. bitmap.close()
  66. finally:
  67. page.close()
  68. facts = {{'page_count': pages, 'text': text, 'mean': mean}}
  69. Path('facts.json').write_text(json.dumps(facts))
  70. report = '# Agent 生成的测试报告\\n\\n均值:' + str(mean) + ';PDF页数:' + str(pages)
  71. Path('report.md').write_text(report)
  72. result = {{'status': {status!r}, 'result_code': {code!r}, 'summary': {summary!r},
  73. 'data_detail': '测试原件及本轮明确提供的数据;非现场结果',
  74. 'evidence': [{{'source': '/workspace/exploration/{identifier}/input.json',
  75. 'pointer': '/measurements/values', 'excerpt': '10、20、30'}}],
  76. 'details': {{'mean': mean, 'pages': pages, 'custom_method': 'statistics.mean'}}}}
  77. Path('assessment.json').write_text(json.dumps(result, ensure_ascii=False))
  78. print('computed', mean, 'pages', pages)
  79. '''
  80. async def execute_delivery(workbench, **kwargs):
  81. require_sandbox()
  82. script = workbench.workspace / 'analyze.py'
  83. script.write_text(delivery_script(workbench, **kwargs), encoding='utf-8')
  84. receipt = await python_sandbox.run(workbench.user, workbench.session, '/workspace/analyze.py',
  85. output_paths=['/workspace/report.md', '/workspace/assessment.json', '/workspace/facts.json', '/workspace/page.png'])
  86. assert receipt['exit_code'] == 0, receipt['stderr']
  87. assert not receipt['timed_out']
  88. exploration.record_python(workbench.manifest['workflow_id'], workbench.user, workbench.session, receipt)
  89. return receipt
  90. def submit_delivery(workbench, run_ids, **kwargs):
  91. return exploration.submit(workbench.manifest['workflow_id'], workbench.user, workbench.session,
  92. '/workspace/report.md', '/workspace/assessment.json', run_ids, **kwargs)
  93. def test_only_gateway_batch_and_infrastructure_tools_are_registered():
  94. catalog = load_extensions()
  95. names = set(catalog.agents['main_fan'].tools)
  96. assert names == {'prepare_main_fan_task', 'fetch_main_fan_data', 'query_fan_timeseries',
  97. 'query_fan_sensor_realtime', 'query_fan_equipment_realtime', 'query_fan_equipment_timeseries',
  98. 'run_python', 'submit_main_fan_report', 'submit_main_fan_mine_plan',
  99. 'query_main_fan_history', 'fetch_main_fan_reports', 'archive_main_fan_report'}
  100. assert names.isdisjoint({'inspect_main_fan_document', 'extract_main_fan_pages',
  101. 'register_main_fan_facts', 'compare_main_fan_data', 'analyze_main_fan'})
  102. assert catalog.business('main_fan').test_runner is None
  103. @pytest.mark.asyncio
  104. async def test_direct_pdf_read_returns_recoverable_feedback_without_sending_binary():
  105. from extensions.main_fan.middleware import MainFanSandboxMiddleware
  106. middleware = MainFanSandboxMiddleware()
  107. request = SimpleNamespace(tool_call={'name': 'read_file', 'id': 'pdf-read',
  108. 'args': {'file_path': '/workspace/uploads/report.pdf'}})
  109. async def forbidden(request):
  110. pytest.fail('不应让框架发送 PDF 二进制附件')
  111. result = await middleware.awrap_tool_call(request, forbidden)
  112. assert result.status == 'error' and isinstance(result.content, str)
  113. assert 'run_python' in result.content and 'PNG' in result.content
  114. def test_prepare_exports_raw_files_and_frozen_delivery_schema(workbench):
  115. manifest = workbench.manifest
  116. assert manifest['mode'] == 'sandbox_exploration'
  117. assert manifest['python_paths']['input'] == manifest['input_path'].removeprefix('/workspace/')
  118. assert manifest['python_paths']['materials'] == ['uploads/scan.pdf']
  119. assert 'facts_schema_path' not in manifest
  120. data = json.loads((workbench.workspace / manifest['data_path'].removeprefix('/workspace/')).read_text())
  121. assert data['reports'] == []
  122. schema = json.loads((workbench.workspace / manifest['submission_schema_path'].removeprefix('/workspace/')).read_text())
  123. assert {'result_code', 'summary'} <= set(schema['required'])
  124. assert exploration.prepare(workbench.request, workbench.user, workbench.session)['workflow_id'] == manifest['workflow_id']
  125. trusted = exploration.prepare({'mine_code': 'fake', 'mine_name': '伪造'}, workbench.user, workbench.session,
  126. trusted=workbench.request)
  127. assert trusted['mine_code'] == 'sandbox-mine'
  128. def test_test_lab_defaults_to_agent_and_cannot_use_legacy_pipeline(monkeypatch):
  129. from api.test_lab_routes import TestRequest as Request
  130. from runtime import business_tasks
  131. from runtime.test_lab import service as lab
  132. from db import business_store
  133. monkeypatch.setattr(business_tasks, '_controller', business_tasks.BusinessTaskController())
  134. request = Request(agent_name='main_fan', input={'mine_code': 'test', 'mine_name': '测试矿'})
  135. assert request.execution_mode == 'agent'
  136. result = lab.start_run(request, 'lab-default-test')
  137. assert business_store.get(result['id'])['source'] == 'test_agent'
  138. with pytest.raises(ValueError, match='完整智能体'):
  139. lab.start_run(request.model_copy(update={'execution_mode': 'pipeline'}), 'lab-default-test')
  140. @pytest.mark.asyncio
  141. async def test_real_sandbox_parses_pdf_computes_and_generates_report_without_legacy_pipeline(workbench, monkeypatch):
  142. def forbidden(*args, **kwargs):
  143. raise AssertionError('自主模式不能调用固定业务解析、计算或报告生成')
  144. from algorithms.main_fan import equivalence
  145. from extensions.main_fan import documents, pipeline, reporting
  146. for name in ('normalize_and_validate_fan_data', 'compare_basic_parameters', 'analyze_fan_switch_event',
  147. 'assess_fan_equivalent_capability'):
  148. monkeypatch.setattr(equivalence, name, forbidden)
  149. monkeypatch.setattr(documents, '_prepare', forbidden)
  150. monkeypatch.setattr(pipeline, 'run_pipeline', forbidden)
  151. monkeypatch.setattr(reporting, 'render_result', forbidden)
  152. monkeypatch.setattr(service, '_screen', forbidden)
  153. receipt = await execute_delivery(workbench)
  154. assert 'computed 20 pages 1' in receipt['stdout']
  155. assert (workbench.workspace / 'page.png').is_file()
  156. result = submit_delivery(workbench, [receipt['run_id']], artifacts=['/workspace/facts.json'])
  157. assert result['status'] == 'completed'
  158. assert result['analysis'] is None
  159. assert result['result'] == (workbench.workspace / 'report.md').read_text()
  160. assert result['assessment']['agent_result']['details']['mean'] == 20
  161. # 故意不生成旧七阶段结构;平台编码与摘要完全来自 Agent 交付,不被固定算法改成 UNDETERMINED。
  162. judgment = result['platform_payload']['judgment']
  163. assert judgment['resultCode'] == 'WARNING'
  164. assert judgment['result'] == '脚本实际计算均值20.0'
  165. assert result['assessment']['evidence'][0]['sha256']
  166. from runtime.test_lab.spreadsheets import result_tables, write_workbook, preview_workbook
  167. workbook = workbench.workspace / 'export.xlsx'
  168. write_workbook(workbook, result_tables(result))
  169. preview = preview_workbook(workbook)
  170. assert preview['sheets'][1]['name'] == 'Agent分析明细'
  171. assert ['mean', 20] in preview['sheets'][1]['rows']
  172. assert len(main_fan_store.workflow(workbench.manifest['workflow_id'], workbench.user, workbench.session)['stages']) == 3
  173. assert service.validate_result({**result, 'result': '篡改'}, workbench.user, workbench.session) == result
  174. assert submit_delivery(workbench, [receipt['run_id']]) == result
  175. with pytest.raises(ValueError, match='不属于'):
  176. service.validate_result(result, 'another-user', workbench.session)
  177. @pytest.mark.asyncio
  178. async def test_relative_evidence_path_binds_exact_same_trusted_source(workbench):
  179. require_sandbox()
  180. code = delivery_script(workbench).replace(
  181. '/workspace/exploration/' + workbench.manifest['workflow_id'] + '/input.json',
  182. 'exploration/' + workbench.manifest['workflow_id'] + '/input.json')
  183. (workbench.workspace / 'relative.py').write_text(code)
  184. receipt = await python_sandbox.run(workbench.user, workbench.session, '/workspace/relative.py',
  185. output_paths=['/workspace/report.md', '/workspace/assessment.json'])
  186. assert receipt['ok'], receipt['stderr']
  187. exploration.record_python(workbench.manifest['workflow_id'], workbench.user, workbench.session, receipt)
  188. result = submit_delivery(workbench, [receipt['run_id']])
  189. assert result['assessment']['evidence'][0]['source'] == workbench.manifest['input_path']
  190. @pytest.mark.asyncio
  191. @pytest.mark.parametrize('target', ['report.md', 'assessment.json', 'facts.json'])
  192. async def test_changed_script_outputs_cannot_be_submitted(workbench, target):
  193. receipt = await execute_delivery(workbench)
  194. (workbench.workspace / target).write_text('tampered')
  195. with pytest.raises(ValueError):
  196. submit_delivery(workbench, [receipt['run_id']], artifacts=['/workspace/facts.json'])
  197. @pytest.mark.asyncio
  198. async def test_delivery_requires_successful_execution_from_same_workflow(workbench):
  199. receipt = await execute_delivery(workbench)
  200. with pytest.raises(ValueError, match='成功执行记录'):
  201. submit_delivery(workbench, [])
  202. second = exploration.prepare({**workbench.request, 'task_description': '独立任务'}, workbench.user, workbench.session)
  203. with pytest.raises(ValueError, match='不属于本轮'):
  204. exploration.submit(second['workflow_id'], workbench.user, workbench.session,
  205. '/workspace/report.md', '/workspace/assessment.json', [receipt['run_id']])
  206. with pytest.raises(ValueError, match='不属于'):
  207. exploration._load(workbench.manifest['workflow_id'], 'someone-else', workbench.session)
  208. @pytest.mark.asyncio
  209. async def test_python_tool_returns_concrete_paths_when_agent_code_fails(workbench):
  210. require_sandbox()
  211. from extensions.main_fan.exploration_tools import run_python
  212. (workbench.workspace / 'wrong-path.py').write_text("from pathlib import Path\nPath('missing-input.json').read_text()")
  213. runtime = SimpleNamespace(config={'configurable': {'workspace_user': workbench.user,
  214. 'workspace_session': workbench.session}})
  215. result = await run_python('/workspace/wrong-path.py', runtime, workflow_id=workbench.manifest['workflow_id'])
  216. assert result['ok'] is False
  217. assert 'FileNotFoundError' in result['stderr']
  218. assert result['python_paths']['input'] == workbench.manifest['python_paths']['input']
  219. assert '不要' in result['next_action']
  220. @pytest.mark.asyncio
  221. async def test_preexisting_files_are_not_mislabeled_as_script_generated_outputs(workbench):
  222. require_sandbox()
  223. (workbench.workspace / 'existing.md').write_text('只是之前写入的文件')
  224. (workbench.workspace / 'noop.py').write_text("print('no output written')")
  225. receipt = await python_sandbox.run(workbench.user, workbench.session, '/workspace/noop.py',
  226. output_paths=['/workspace/existing.md'])
  227. assert receipt['ok']
  228. assert receipt['outputs'] == []
  229. assert receipt['unchanged_outputs'] == ['/workspace/existing.md']
  230. @pytest.mark.parametrize('number', ['NaN', 'Infinity', '1e999'])
  231. def test_delivery_json_rejects_nonfinite_numbers(tmp_path, number):
  232. path = tmp_path / 'result.json'
  233. path.write_text('{"details":{"value":' + number + '}}')
  234. with pytest.raises(ValueError, match='非有限'):
  235. exploration._read_json(path)
  236. @pytest.mark.asyncio
  237. async def test_raw_sources_and_frozen_inputs_cannot_be_replaced(workbench):
  238. receipt = await execute_delivery(workbench)
  239. original = workbench.source.read_bytes()
  240. workbench.source.write_bytes(b'changed pdf')
  241. with pytest.raises(ValueError, match='已被修改'):
  242. submit_delivery(workbench, [receipt['run_id']])
  243. workbench.source.write_bytes(original)
  244. rules = workbench.workspace / workbench.manifest['rules_path'].removeprefix('/workspace/')
  245. rules.write_text('{}')
  246. with pytest.raises(ValueError, match='已被修改'):
  247. submit_delivery(workbench, [receipt['run_id']])
  248. @pytest.mark.asyncio
  249. async def test_unknown_evidence_is_rejected_and_agent_can_correct_and_resubmit(workbench):
  250. receipt = await execute_delivery(workbench)
  251. # 修改生成脚本后重新执行,保证失败来自来源归属而非哈希变化。
  252. script = delivery_script(workbench).replace('/input.json', '/other-task.json')
  253. (workbench.workspace / 'analyze.py').write_text(script)
  254. bad = await python_sandbox.run(workbench.user, workbench.session, '/workspace/analyze.py',
  255. output_paths=['/workspace/report.md', '/workspace/assessment.json'])
  256. assert bad['exit_code'] == 0, bad['stderr']
  257. exploration.record_python(workbench.manifest['workflow_id'], workbench.user, workbench.session, bad)
  258. with pytest.raises(ValueError, match='证据来源'):
  259. submit_delivery(workbench, [bad['run_id']])
  260. corrected = await execute_delivery(workbench)
  261. assert submit_delivery(workbench, [corrected['run_id']])['analysis_id']
  262. @pytest.mark.asyncio
  263. async def test_gateway_fetch_still_uses_host_connector_and_updates_raw_files(workbench, monkeypatch):
  264. import hashlib
  265. from extensions.main_fan import data_source
  266. inputs = {**workbench.request, 'use_gateway': True, 'materials': []}
  267. prepared = exploration.prepare(inputs, workbench.user, workbench.session)
  268. calls = []
  269. class Gateway:
  270. async def download(self, agent_code, source, local):
  271. calls.append(('download', agent_code, source.file_id))
  272. local.write_bytes(workbench.source.read_bytes())
  273. return {'sha256': hashlib.sha256(local.read_bytes()).hexdigest()}
  274. gateway = Gateway()
  275. async def fetch(connector, agent_code, mine_code):
  276. assert connector is gateway
  277. calls.append(('fetch', agent_code, mine_code))
  278. return {'devices': [], 'points': [], 'operating_series': [], 'switch_records': [], 'fan_state_records': [],
  279. 'files': [{'file_id': 'file-1', 'version': 'v1', 'filename': 'remote.pdf', 'path': '/files/1'}]}
  280. monkeypatch.setattr(exploration, 'get_service', lambda name: gateway)
  281. monkeypatch.setattr(data_source, 'fetch', fetch)
  282. first = await exploration.fetch(prepared['workflow_id'], workbench.user, workbench.session)
  283. second = await exploration.fetch(prepared['workflow_id'], workbench.user, workbench.session)
  284. assert len(first['materials']) == len(second['materials']) == 1
  285. assert [call[0] for call in calls] == ['fetch', 'download']
  286. material = first['materials'][0]
  287. assert (workbench.workspace / material['path'].removeprefix('/workspace/')).is_file()
  288. raw = json.loads((workbench.workspace / first['input_path'].removeprefix('/workspace/')).read_text())
  289. assert raw['materials'][0]['sha256'] == material['sha256']
  290. @pytest.mark.asyncio
  291. async def test_sandbox_denies_host_secrets_network_and_process_execution(workbench, monkeypatch):
  292. require_sandbox()
  293. secret = workbench.workspace.parent / 'host-secret.txt'
  294. secret.write_text('host-only-value')
  295. monkeypatch.setenv('SANDBOX_TEST_SECRET', 'not-for-child')
  296. listener = socket.socket()
  297. listener.bind(('127.0.0.1', 0))
  298. listener.listen()
  299. port = listener.getsockname()[1]
  300. program = '''
  301. from pathlib import Path
  302. import os, socket, subprocess, sys
  303. assert os.getenv('SANDBOX_TEST_SECRET') is None
  304. try:
  305. Path('../host-secret.txt').read_text()
  306. except (PermissionError, FileNotFoundError):
  307. print('host-file-denied')
  308. else:
  309. raise AssertionError('host file readable')
  310. try:
  311. socket.create_connection(('127.0.0.1', TEST_PORT), timeout=1)
  312. except OSError:
  313. print('network-denied')
  314. else:
  315. raise AssertionError('network accessible')
  316. if sys.platform == 'darwin':
  317. try:
  318. subprocess.run(['/bin/sh', '-c', 'true'], check=True)
  319. except PermissionError:
  320. print('process-denied')
  321. else:
  322. raise AssertionError('arbitrary process allowed')
  323. else:
  324. print('processes-remain-in-namespace')
  325. '''
  326. (workbench.workspace / 'boundary.py').write_text(program.replace('TEST_PORT', str(port)))
  327. try:
  328. receipt = await python_sandbox.run(workbench.user, workbench.session, '/workspace/boundary.py')
  329. listener.settimeout(.1)
  330. with pytest.raises(TimeoutError):
  331. listener.accept()
  332. finally:
  333. listener.close()
  334. assert receipt['exit_code'] == 0, receipt['stderr']
  335. assert 'host-file-denied' in receipt['stdout']
  336. assert 'network-denied' in receipt['stdout']
  337. assert 'process-denied' in receipt['stdout'] or 'processes-remain-in-namespace' in receipt['stdout']
  338. @pytest.mark.asyncio
  339. async def test_sandbox_failure_timeout_and_path_escape_are_real(workbench):
  340. require_sandbox()
  341. script = workbench.workspace / 'failure.py'
  342. script.write_text("raise RuntimeError('real-script-failure')")
  343. failed = await python_sandbox.run(workbench.user, workbench.session, '/workspace/failure.py')
  344. assert failed['exit_code'] != 0 and 'real-script-failure' in failed['stderr']
  345. with pytest.raises(ValueError, match='失败或超时'):
  346. python_sandbox.execution_receipt(workbench.user, workbench.session, failed['run_id'])
  347. script.write_text('import time\ntime.sleep(10)')
  348. timed = await python_sandbox.run(workbench.user, workbench.session, '/workspace/failure.py', timeout_seconds=1)
  349. assert timed['timed_out']
  350. with pytest.raises(ValueError, match='越出'):
  351. python_sandbox.workspace_file(workbench.user, workbench.session, '/workspace/../../secret')
  352. (workbench.workspace / 'escape.py').symlink_to(workbench.workspace.parent / 'outside.py')
  353. with pytest.raises(ValueError, match='越出'):
  354. await python_sandbox.run(workbench.user, workbench.session, '/workspace/escape.py')
  355. @pytest.mark.asyncio
  356. async def test_missing_sandbox_never_falls_back_to_host_execution(workbench, monkeypatch):
  357. monkeypatch.setattr(python_sandbox.shutil, 'which', lambda name: None)
  358. (workbench.workspace / 'touch.py').write_text("from pathlib import Path\nPath('unsafe-marker').touch()")
  359. with pytest.raises(ValueError, match='沙箱执行'):
  360. await python_sandbox.run(workbench.user, workbench.session, '/workspace/touch.py')
  361. assert not (workbench.workspace / 'unsafe-marker').exists()
  362. @pytest.mark.parametrize('value', [
  363. {'result_code': 'NORMAL', 'summary': '无来源'},
  364. {'result_code': 'BAD', 'summary': '未知编码'},
  365. {'result_code': 'UNDETERMINED', 'summary': ' '},
  366. {'status': 'needs_materials', 'result_code': 'WARNING', 'summary': '矛盾状态',
  367. 'evidence': [{'source': '/workspace/input.json'}]},
  368. ])
  369. def test_delivery_contract_rejects_invalid_metadata(value):
  370. with pytest.raises(ValueError):
  371. AgentAssessment.model_validate(value)