| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419 |
- """自主模式的真实沙箱执行、交付归档和权限边界;不调用外部模型或网关。"""
- import asyncio
- import json
- import socket
- from pathlib import Path
- from types import SimpleNamespace
- import pytest
- from PIL import Image
- import config
- from agents.workspace import session_root
- from db import main_fan_store
- from extensions.main_fan import exploration, service
- from extensions.main_fan.schemas import AgentAssessment
- from extensions.registry import load_extensions
- from runtime import python_sandbox
- @pytest.fixture
- def workbench(monkeypatch, tmp_path):
- monkeypatch.setattr(config, 'ROOT', tmp_path)
- monkeypatch.setenv('MAIN_FAN_TARGETS_FILE', '')
- monkeypatch.setenv('MAIN_FAN_RULES_FILE', '')
- user, session = 'sandbox-user', 'sandbox-session'
- workspace = session_root(user, session) / 'workspace'
- uploads = workspace / 'uploads'
- uploads.mkdir(parents=True)
- source = uploads / 'scan.pdf'
- with Image.new('RGB', (120, 160), 'white') as image:
- image.save(source, 'PDF')
- request = {'mine_code': 'sandbox-mine', 'mine_name': '沙箱测试矿',
- 'fan_id': 'fan-1', 'fan_name': '1号风机', 'fan_pair': ['fan-1', 'fan-2'],
- 'materials': [{'path': '/workspace/uploads/scan.pdf'}],
- 'measurements': {'values': [10, 20, 30]}}
- prepared = exploration.prepare(request, user, session)
- return SimpleNamespace(user=user, session=session, workspace=workspace,
- request=request, manifest=prepared, source=source)
- def require_sandbox():
- if not python_sandbox.capability()['available']:
- pytest.skip(python_sandbox.capability()['reason'])
- def delivery_script(workbench, *, code='WARNING', summary='脚本实际计算均值20.0', status='completed'):
- """测试模型写出的程序:读原 PDF、渲染、计算并生成所有交付,不调用业务服务。"""
- identifier = workbench.manifest['workflow_id']
- return f'''
- from pathlib import Path
- import json
- import statistics
- import pypdfium2 as pdfium
- from PIL import Image
- import numpy as np
- import cv2
- base = Path('exploration/{identifier}')
- data = json.loads((base/'input.json').read_text())
- mean = statistics.mean(data['measurements']['values'])
- with pdfium.PdfDocument('uploads/scan.pdf') as pdf:
- pages = len(pdf)
- page = pdf[0]
- try:
- textpage = page.get_textpage()
- try:
- text = textpage.get_text_range()
- finally:
- textpage.close()
- bitmap = page.render(scale=1)
- image = bitmap.to_pil()
- image.save('page.png')
- image.close()
- bitmap.close()
- finally:
- page.close()
- facts = {{'page_count': pages, 'text': text, 'mean': mean}}
- Path('facts.json').write_text(json.dumps(facts))
- report = '# Agent 生成的测试报告\\n\\n均值:' + str(mean) + ';PDF页数:' + str(pages)
- Path('report.md').write_text(report)
- result = {{'status': {status!r}, 'result_code': {code!r}, 'summary': {summary!r},
- 'data_detail': '测试原件及本轮明确提供的数据;非现场结果',
- 'evidence': [{{'source': '/workspace/exploration/{identifier}/input.json',
- 'pointer': '/measurements/values', 'excerpt': '10、20、30'}}],
- 'details': {{'mean': mean, 'pages': pages, 'custom_method': 'statistics.mean'}}}}
- Path('assessment.json').write_text(json.dumps(result, ensure_ascii=False))
- print('computed', mean, 'pages', pages)
- '''
- async def execute_delivery(workbench, **kwargs):
- require_sandbox()
- script = workbench.workspace / 'analyze.py'
- script.write_text(delivery_script(workbench, **kwargs), encoding='utf-8')
- receipt = await python_sandbox.run(workbench.user, workbench.session, '/workspace/analyze.py',
- output_paths=['/workspace/report.md', '/workspace/assessment.json', '/workspace/facts.json', '/workspace/page.png'])
- assert receipt['exit_code'] == 0, receipt['stderr']
- assert not receipt['timed_out']
- exploration.record_python(workbench.manifest['workflow_id'], workbench.user, workbench.session, receipt)
- return receipt
- def submit_delivery(workbench, run_ids, **kwargs):
- return exploration.submit(workbench.manifest['workflow_id'], workbench.user, workbench.session,
- '/workspace/report.md', '/workspace/assessment.json', run_ids, **kwargs)
- def test_only_gateway_batch_and_infrastructure_tools_are_registered():
- catalog = load_extensions()
- names = set(catalog.agents['main_fan'].tools)
- assert names == {'prepare_main_fan_task', 'fetch_main_fan_data', 'query_fan_timeseries',
- 'query_fan_sensor_realtime', 'query_fan_equipment_realtime', 'query_fan_equipment_timeseries',
- 'run_python', 'submit_main_fan_report', 'submit_main_fan_mine_plan',
- 'query_main_fan_history', 'fetch_main_fan_reports', 'archive_main_fan_report'}
- assert names.isdisjoint({'inspect_main_fan_document', 'extract_main_fan_pages',
- 'register_main_fan_facts', 'compare_main_fan_data', 'analyze_main_fan'})
- assert catalog.business('main_fan').test_runner is None
- @pytest.mark.asyncio
- async def test_direct_pdf_read_returns_recoverable_feedback_without_sending_binary():
- from extensions.main_fan.middleware import MainFanSandboxMiddleware
- middleware = MainFanSandboxMiddleware()
- request = SimpleNamespace(tool_call={'name': 'read_file', 'id': 'pdf-read',
- 'args': {'file_path': '/workspace/uploads/report.pdf'}})
- async def forbidden(request):
- pytest.fail('不应让框架发送 PDF 二进制附件')
- result = await middleware.awrap_tool_call(request, forbidden)
- assert result.status == 'error' and isinstance(result.content, str)
- assert 'run_python' in result.content and 'PNG' in result.content
- def test_prepare_exports_raw_files_and_frozen_delivery_schema(workbench):
- manifest = workbench.manifest
- assert manifest['mode'] == 'sandbox_exploration'
- assert manifest['python_paths']['input'] == manifest['input_path'].removeprefix('/workspace/')
- assert manifest['python_paths']['materials'] == ['uploads/scan.pdf']
- assert 'facts_schema_path' not in manifest
- data = json.loads((workbench.workspace / manifest['data_path'].removeprefix('/workspace/')).read_text())
- assert data['reports'] == []
- schema = json.loads((workbench.workspace / manifest['submission_schema_path'].removeprefix('/workspace/')).read_text())
- assert {'result_code', 'summary'} <= set(schema['required'])
- assert exploration.prepare(workbench.request, workbench.user, workbench.session)['workflow_id'] == manifest['workflow_id']
- trusted = exploration.prepare({'mine_code': 'fake', 'mine_name': '伪造'}, workbench.user, workbench.session,
- trusted=workbench.request)
- assert trusted['mine_code'] == 'sandbox-mine'
- def test_test_lab_defaults_to_agent_and_cannot_use_legacy_pipeline(monkeypatch):
- from api.test_lab_routes import TestRequest as Request
- from runtime import business_tasks
- from runtime.test_lab import service as lab
- from db import business_store
- monkeypatch.setattr(business_tasks, '_controller', business_tasks.BusinessTaskController())
- request = Request(agent_name='main_fan', input={'mine_code': 'test', 'mine_name': '测试矿'})
- assert request.execution_mode == 'agent'
- result = lab.start_run(request, 'lab-default-test')
- assert business_store.get(result['id'])['source'] == 'test_agent'
- with pytest.raises(ValueError, match='完整智能体'):
- lab.start_run(request.model_copy(update={'execution_mode': 'pipeline'}), 'lab-default-test')
- @pytest.mark.asyncio
- async def test_real_sandbox_parses_pdf_computes_and_generates_report_without_legacy_pipeline(workbench, monkeypatch):
- def forbidden(*args, **kwargs):
- raise AssertionError('自主模式不能调用固定业务解析、计算或报告生成')
- from algorithms.main_fan import equivalence
- from extensions.main_fan import documents, pipeline, reporting
- for name in ('normalize_and_validate_fan_data', 'compare_basic_parameters', 'analyze_fan_switch_event',
- 'assess_fan_equivalent_capability'):
- monkeypatch.setattr(equivalence, name, forbidden)
- monkeypatch.setattr(documents, '_prepare', forbidden)
- monkeypatch.setattr(pipeline, 'run_pipeline', forbidden)
- monkeypatch.setattr(reporting, 'render_result', forbidden)
- monkeypatch.setattr(service, '_screen', forbidden)
- receipt = await execute_delivery(workbench)
- assert 'computed 20 pages 1' in receipt['stdout']
- assert (workbench.workspace / 'page.png').is_file()
- result = submit_delivery(workbench, [receipt['run_id']], artifacts=['/workspace/facts.json'])
- assert result['status'] == 'completed'
- assert result['analysis'] is None
- assert result['result'] == (workbench.workspace / 'report.md').read_text()
- assert result['assessment']['agent_result']['details']['mean'] == 20
- # 故意不生成旧七阶段结构;平台编码与摘要完全来自 Agent 交付,不被固定算法改成 UNDETERMINED。
- judgment = result['platform_payload']['judgment']
- assert judgment['resultCode'] == 'WARNING'
- assert judgment['result'] == '脚本实际计算均值20.0'
- assert result['assessment']['evidence'][0]['sha256']
- from runtime.test_lab.spreadsheets import result_tables, write_workbook, preview_workbook
- workbook = workbench.workspace / 'export.xlsx'
- write_workbook(workbook, result_tables(result))
- preview = preview_workbook(workbook)
- assert preview['sheets'][1]['name'] == 'Agent分析明细'
- assert ['mean', 20] in preview['sheets'][1]['rows']
- assert len(main_fan_store.workflow(workbench.manifest['workflow_id'], workbench.user, workbench.session)['stages']) == 3
- assert service.validate_result({**result, 'result': '篡改'}, workbench.user, workbench.session) == result
- assert submit_delivery(workbench, [receipt['run_id']]) == result
- with pytest.raises(ValueError, match='不属于'):
- service.validate_result(result, 'another-user', workbench.session)
- @pytest.mark.asyncio
- async def test_relative_evidence_path_binds_exact_same_trusted_source(workbench):
- require_sandbox()
- code = delivery_script(workbench).replace(
- '/workspace/exploration/' + workbench.manifest['workflow_id'] + '/input.json',
- 'exploration/' + workbench.manifest['workflow_id'] + '/input.json')
- (workbench.workspace / 'relative.py').write_text(code)
- receipt = await python_sandbox.run(workbench.user, workbench.session, '/workspace/relative.py',
- output_paths=['/workspace/report.md', '/workspace/assessment.json'])
- assert receipt['ok'], receipt['stderr']
- exploration.record_python(workbench.manifest['workflow_id'], workbench.user, workbench.session, receipt)
- result = submit_delivery(workbench, [receipt['run_id']])
- assert result['assessment']['evidence'][0]['source'] == workbench.manifest['input_path']
- @pytest.mark.asyncio
- @pytest.mark.parametrize('target', ['report.md', 'assessment.json', 'facts.json'])
- async def test_changed_script_outputs_cannot_be_submitted(workbench, target):
- receipt = await execute_delivery(workbench)
- (workbench.workspace / target).write_text('tampered')
- with pytest.raises(ValueError):
- submit_delivery(workbench, [receipt['run_id']], artifacts=['/workspace/facts.json'])
- @pytest.mark.asyncio
- async def test_delivery_requires_successful_execution_from_same_workflow(workbench):
- receipt = await execute_delivery(workbench)
- with pytest.raises(ValueError, match='成功执行记录'):
- submit_delivery(workbench, [])
- second = exploration.prepare({**workbench.request, 'task_description': '独立任务'}, workbench.user, workbench.session)
- with pytest.raises(ValueError, match='不属于本轮'):
- exploration.submit(second['workflow_id'], workbench.user, workbench.session,
- '/workspace/report.md', '/workspace/assessment.json', [receipt['run_id']])
- with pytest.raises(ValueError, match='不属于'):
- exploration._load(workbench.manifest['workflow_id'], 'someone-else', workbench.session)
- @pytest.mark.asyncio
- async def test_python_tool_returns_concrete_paths_when_agent_code_fails(workbench):
- require_sandbox()
- from extensions.main_fan.exploration_tools import run_python
- (workbench.workspace / 'wrong-path.py').write_text("from pathlib import Path\nPath('missing-input.json').read_text()")
- runtime = SimpleNamespace(config={'configurable': {'workspace_user': workbench.user,
- 'workspace_session': workbench.session}})
- result = await run_python('/workspace/wrong-path.py', runtime, workflow_id=workbench.manifest['workflow_id'])
- assert result['ok'] is False
- assert 'FileNotFoundError' in result['stderr']
- assert result['python_paths']['input'] == workbench.manifest['python_paths']['input']
- assert '不要' in result['next_action']
- @pytest.mark.asyncio
- async def test_preexisting_files_are_not_mislabeled_as_script_generated_outputs(workbench):
- require_sandbox()
- (workbench.workspace / 'existing.md').write_text('只是之前写入的文件')
- (workbench.workspace / 'noop.py').write_text("print('no output written')")
- receipt = await python_sandbox.run(workbench.user, workbench.session, '/workspace/noop.py',
- output_paths=['/workspace/existing.md'])
- assert receipt['ok']
- assert receipt['outputs'] == []
- assert receipt['unchanged_outputs'] == ['/workspace/existing.md']
- @pytest.mark.parametrize('number', ['NaN', 'Infinity', '1e999'])
- def test_delivery_json_rejects_nonfinite_numbers(tmp_path, number):
- path = tmp_path / 'result.json'
- path.write_text('{"details":{"value":' + number + '}}')
- with pytest.raises(ValueError, match='非有限'):
- exploration._read_json(path)
- @pytest.mark.asyncio
- async def test_raw_sources_and_frozen_inputs_cannot_be_replaced(workbench):
- receipt = await execute_delivery(workbench)
- original = workbench.source.read_bytes()
- workbench.source.write_bytes(b'changed pdf')
- with pytest.raises(ValueError, match='已被修改'):
- submit_delivery(workbench, [receipt['run_id']])
- workbench.source.write_bytes(original)
- rules = workbench.workspace / workbench.manifest['rules_path'].removeprefix('/workspace/')
- rules.write_text('{}')
- with pytest.raises(ValueError, match='已被修改'):
- submit_delivery(workbench, [receipt['run_id']])
- @pytest.mark.asyncio
- async def test_unknown_evidence_is_rejected_and_agent_can_correct_and_resubmit(workbench):
- receipt = await execute_delivery(workbench)
- # 修改生成脚本后重新执行,保证失败来自来源归属而非哈希变化。
- script = delivery_script(workbench).replace('/input.json', '/other-task.json')
- (workbench.workspace / 'analyze.py').write_text(script)
- bad = await python_sandbox.run(workbench.user, workbench.session, '/workspace/analyze.py',
- output_paths=['/workspace/report.md', '/workspace/assessment.json'])
- assert bad['exit_code'] == 0, bad['stderr']
- exploration.record_python(workbench.manifest['workflow_id'], workbench.user, workbench.session, bad)
- with pytest.raises(ValueError, match='证据来源'):
- submit_delivery(workbench, [bad['run_id']])
- corrected = await execute_delivery(workbench)
- assert submit_delivery(workbench, [corrected['run_id']])['analysis_id']
- @pytest.mark.asyncio
- async def test_gateway_fetch_still_uses_host_connector_and_updates_raw_files(workbench, monkeypatch):
- import hashlib
- from extensions.main_fan import data_source
- inputs = {**workbench.request, 'use_gateway': True, 'materials': []}
- prepared = exploration.prepare(inputs, workbench.user, workbench.session)
- calls = []
- class Gateway:
- async def download(self, agent_code, source, local):
- calls.append(('download', agent_code, source.file_id))
- local.write_bytes(workbench.source.read_bytes())
- return {'sha256': hashlib.sha256(local.read_bytes()).hexdigest()}
- gateway = Gateway()
- async def fetch(connector, agent_code, mine_code):
- assert connector is gateway
- calls.append(('fetch', agent_code, mine_code))
- return {'devices': [], 'points': [], 'operating_series': [], 'switch_records': [], 'fan_state_records': [],
- 'files': [{'file_id': 'file-1', 'version': 'v1', 'filename': 'remote.pdf', 'path': '/files/1'}]}
- monkeypatch.setattr(exploration, 'get_service', lambda name: gateway)
- monkeypatch.setattr(data_source, 'fetch', fetch)
- first = await exploration.fetch(prepared['workflow_id'], workbench.user, workbench.session)
- second = await exploration.fetch(prepared['workflow_id'], workbench.user, workbench.session)
- assert len(first['materials']) == len(second['materials']) == 1
- assert [call[0] for call in calls] == ['fetch', 'download']
- material = first['materials'][0]
- assert (workbench.workspace / material['path'].removeprefix('/workspace/')).is_file()
- raw = json.loads((workbench.workspace / first['input_path'].removeprefix('/workspace/')).read_text())
- assert raw['materials'][0]['sha256'] == material['sha256']
- @pytest.mark.asyncio
- async def test_sandbox_denies_host_secrets_network_and_process_execution(workbench, monkeypatch):
- require_sandbox()
- secret = workbench.workspace.parent / 'host-secret.txt'
- secret.write_text('host-only-value')
- monkeypatch.setenv('SANDBOX_TEST_SECRET', 'not-for-child')
- listener = socket.socket()
- listener.bind(('127.0.0.1', 0))
- listener.listen()
- port = listener.getsockname()[1]
- program = '''
- from pathlib import Path
- import os, socket, subprocess, sys
- assert os.getenv('SANDBOX_TEST_SECRET') is None
- try:
- Path('../host-secret.txt').read_text()
- except (PermissionError, FileNotFoundError):
- print('host-file-denied')
- else:
- raise AssertionError('host file readable')
- try:
- socket.create_connection(('127.0.0.1', TEST_PORT), timeout=1)
- except OSError:
- print('network-denied')
- else:
- raise AssertionError('network accessible')
- if sys.platform == 'darwin':
- try:
- subprocess.run(['/bin/sh', '-c', 'true'], check=True)
- except PermissionError:
- print('process-denied')
- else:
- raise AssertionError('arbitrary process allowed')
- else:
- print('processes-remain-in-namespace')
- '''
- (workbench.workspace / 'boundary.py').write_text(program.replace('TEST_PORT', str(port)))
- try:
- receipt = await python_sandbox.run(workbench.user, workbench.session, '/workspace/boundary.py')
- listener.settimeout(.1)
- with pytest.raises(TimeoutError):
- listener.accept()
- finally:
- listener.close()
- assert receipt['exit_code'] == 0, receipt['stderr']
- assert 'host-file-denied' in receipt['stdout']
- assert 'network-denied' in receipt['stdout']
- assert 'process-denied' in receipt['stdout'] or 'processes-remain-in-namespace' in receipt['stdout']
- @pytest.mark.asyncio
- async def test_sandbox_failure_timeout_and_path_escape_are_real(workbench):
- require_sandbox()
- script = workbench.workspace / 'failure.py'
- script.write_text("raise RuntimeError('real-script-failure')")
- failed = await python_sandbox.run(workbench.user, workbench.session, '/workspace/failure.py')
- assert failed['exit_code'] != 0 and 'real-script-failure' in failed['stderr']
- with pytest.raises(ValueError, match='失败或超时'):
- python_sandbox.execution_receipt(workbench.user, workbench.session, failed['run_id'])
- script.write_text('import time\ntime.sleep(10)')
- timed = await python_sandbox.run(workbench.user, workbench.session, '/workspace/failure.py', timeout_seconds=1)
- assert timed['timed_out']
- with pytest.raises(ValueError, match='越出'):
- python_sandbox.workspace_file(workbench.user, workbench.session, '/workspace/../../secret')
- (workbench.workspace / 'escape.py').symlink_to(workbench.workspace.parent / 'outside.py')
- with pytest.raises(ValueError, match='越出'):
- await python_sandbox.run(workbench.user, workbench.session, '/workspace/escape.py')
- @pytest.mark.asyncio
- async def test_missing_sandbox_never_falls_back_to_host_execution(workbench, monkeypatch):
- monkeypatch.setattr(python_sandbox.shutil, 'which', lambda name: None)
- (workbench.workspace / 'touch.py').write_text("from pathlib import Path\nPath('unsafe-marker').touch()")
- with pytest.raises(ValueError, match='沙箱执行'):
- await python_sandbox.run(workbench.user, workbench.session, '/workspace/touch.py')
- assert not (workbench.workspace / 'unsafe-marker').exists()
- @pytest.mark.parametrize('value', [
- {'result_code': 'NORMAL', 'summary': '无来源'},
- {'result_code': 'BAD', 'summary': '未知编码'},
- {'result_code': 'UNDETERMINED', 'summary': ' '},
- {'status': 'needs_materials', 'result_code': 'WARNING', 'summary': '矛盾状态',
- 'evidence': [{'source': '/workspace/input.json'}]},
- ])
- def test_delivery_contract_rejects_invalid_metadata(value):
- with pytest.raises(ValueError):
- AgentAssessment.model_validate(value)
|