380 lines
21 KiB
Python
380 lines
21 KiB
Python
"""Behavioral regression for the independent agent coordination service."""
|
|
import concurrent.futures
|
|
import http.client
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
from agent_control.store import Store, Invalid, Conflict
|
|
from agent_control.server import Server, restricted_token
|
|
|
|
|
|
class StoreTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.path = Path(self.tmp.name) / 'db.sqlite3'
|
|
self.store = Store(self.path)
|
|
for sid in ('a','b'):
|
|
self.store.session(dict(id=sid,name=sid,wp='WP1'))
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def send(self, sid='a', key='one'):
|
|
return self.store.send(dict(session_id=sid,text='question',idempotency_key=key))
|
|
|
|
def claim(self, sid='a', worker='w'):
|
|
return self.store.claim(dict(session_id=sid,worker_id=worker))['message']
|
|
|
|
def completion(self, message, **overrides):
|
|
data=dict(session_id=message['session_id'],worker_id='w',message_id=message['id'],lease_id=message['lease_id'],text='real output',thread_id='thread-a')
|
|
data.update(overrides)
|
|
return data
|
|
|
|
def test_persistence_and_idempotency(self):
|
|
one = self.send()
|
|
self.assertEqual(one['id'], self.send()['id'])
|
|
reopened = Store(self.path)
|
|
self.assertEqual(reopened.messages('a')[0]['id'], one['id'])
|
|
with self.assertRaises(Conflict):
|
|
reopened.send(dict(session_id='a',text='different',idempotency_key='one'))
|
|
self.assertEqual(len(reopened.messages('a')),1)
|
|
|
|
def test_late_history_final_reconciles_without_duplicate_reply(self):
|
|
self.store.session(dict(id='a',name='a',thread_id='thread-a'))
|
|
original=self.send()
|
|
lease=self.claim()
|
|
owner=dict(session_id='a',worker_id='w',message_id=original['id'],lease_id=lease['lease_id'])
|
|
self.store.start(owner)
|
|
proof='exact submitted input'
|
|
self.store.submission(dict(owner,state='submitting',thread_id='thread-a',input_sha256=hashlib.sha256(proof.encode()).hexdigest()))
|
|
self.store.uncertain(owner)
|
|
batch=dict(session_id='a',thread_id='thread-a',items=[
|
|
dict(id='external-user',source='human',text='question',input_text=proof,related_message_id=original['id'],phase='input',turn_id='turn-a',turn_status='completed'),
|
|
dict(id='external-final',source='agent',text='real output',input_text=proof,related_message_id=original['id'],phase='final_answer',turn_id='turn-a',turn_status='completed')])
|
|
self.store.mirror(batch)
|
|
self.store.mirror(batch)
|
|
self.assertEqual(len(self.store.messages('a')),2)
|
|
self.assertEqual(self.store.messages('a')[0]['status'],'completed')
|
|
self.store.complete(self.completion(lease))
|
|
self.assertEqual(len(self.store.messages('a')),2)
|
|
with self.store.db() as db:
|
|
link=json.loads(db.execute('SELECT data FROM history_links WHERE external_id=?',('external-final',)).fetchone()['data'])
|
|
self.assertEqual(link['turn_id'],'turn-a')
|
|
|
|
def test_submission_is_bound_persistent_and_fenced(self):
|
|
self.store.session(dict(id='a',name='a',thread_id='thread-a'))
|
|
self.send()
|
|
lease=self.claim()
|
|
owner=self.completion(lease)
|
|
digest=hashlib.sha256(b'exact input').hexdigest()
|
|
data=dict(owner,state='submitting',input_sha256=digest)
|
|
with self.assertRaises(Conflict): self.store.submission(data)
|
|
self.store.start(owner)
|
|
self.store.submission(data)
|
|
self.store.submission(dict(data,state='accepted',receipt_id='receipt-a'))
|
|
self.store.submission(dict(data,state='observed',turn_id='turn-a'))
|
|
value=Store(self.path).messages('a')[0]['submission']
|
|
self.assertEqual(value['receipt_id'],'receipt-a')
|
|
self.assertEqual(value['turn_id'],'turn-a')
|
|
self.assertEqual(self.store.submission(data)['submission']['state'],'observed')
|
|
for change in ({'worker_id':'other'},{'thread_id':'wrong'},{'input_sha256':'b'*64},
|
|
{'state':'accepted','receipt_id':'other'},{'state':'observed','turn_id':'other'}):
|
|
with self.assertRaises(Conflict): self.store.submission(dict(data,**change))
|
|
with patch('agent_control.store.time.time',return_value=lease['lease_until']+1):
|
|
with self.assertRaises(Conflict): self.store.submission(data)
|
|
|
|
def test_mirror_requires_registered_full_input_and_turn(self):
|
|
self.store.session(dict(id='a',name='a',thread_id='thread-a'))
|
|
original=self.send(); lease=self.claim(); owner=self.completion(lease)
|
|
self.store.start(owner)
|
|
base=dict(session_id='a',thread_id='thread-a')
|
|
item=dict(id='f',source='agent',text='result',related_message_id=original['id'],
|
|
input_text='exact input',phase='final_answer',turn_id='turn-a',turn_status='completed')
|
|
with self.assertRaises(Conflict): self.store.mirror(dict(base,items=[item]))
|
|
self.store.submission(dict(owner,state='observed',turn_id='turn-a',input_sha256=hashlib.sha256(b'exact input').hexdigest()))
|
|
for change in ({'input_text':'forged exact input'},{'turn_id':'other-turn'}):
|
|
with self.assertRaises(Conflict): self.store.mirror(dict(base,items=[dict(item,**change)]))
|
|
self.assertIsNone(self.store.messages('a')[0]['response_id'])
|
|
|
|
def test_long_input_final_deferred_then_completed_without_duplicates(self):
|
|
self.store.session(dict(id='a',name='a',thread_id='thread-a'))
|
|
original=self.store.send(dict(session_id='a',text='q'*8001,idempotency_key='long'))
|
|
lease=self.claim(); owner=self.completion(lease); self.store.start(owner)
|
|
proof='envelope\n'+original['text']; answer='r'*8001
|
|
self.store.submission(dict(owner,state='accepted',receipt_id='r',input_sha256=hashlib.sha256(proof.encode()).hexdigest()))
|
|
items=[dict(id='u',source='human',text=original['text'],phase='input'),
|
|
dict(id='f',source='agent',text=answer,phase='final_answer')]
|
|
for i in items: i.update(related_message_id=original['id'],input_text=proof,turn_id='t',turn_status='completed')
|
|
batch=dict(session_id='a',thread_id='thread-a',items=items)
|
|
self.assertEqual(self.store.mirror(batch)['deferred'],['f'])
|
|
self.assertEqual(len(self.store.messages('a')),1)
|
|
self.store.complete(dict(owner,text=answer))
|
|
self.assertEqual(self.store.mirror(batch)['deferred'],[])
|
|
self.assertEqual(len(self.store.messages('a')),2)
|
|
self.assertEqual(self.store.messages('a')[1]['text'],answer)
|
|
|
|
def test_long_late_final_recovers_uncertain_and_rejects_oversize(self):
|
|
self.store.session(dict(id='a',name='a',thread_id='thread-a'))
|
|
original=self.send(); lease=self.claim(); owner=self.completion(lease); self.store.start(owner)
|
|
self.store.submission(dict(owner,state='submitting',input_sha256=hashlib.sha256(b'proof').hexdigest()))
|
|
self.store.uncertain(owner)
|
|
item=dict(id='f',source='agent',text='r'*8001,phase='final_answer',related_message_id=original['id'],
|
|
input_text='proof',turn_id='late',turn_status='completed')
|
|
batch=dict(session_id='a',thread_id='thread-a',items=[item])
|
|
self.store.mirror(batch); self.store.mirror(batch)
|
|
self.assertEqual(self.store.messages('a')[0]['status'],'completed')
|
|
self.assertEqual(len(self.store.messages('a')),2)
|
|
self.store.complete(dict(owner,text='r'*8001))
|
|
with self.assertRaises(Invalid): self.store.mirror(dict(batch,items=[dict(item,text='x'*262145)]))
|
|
with self.assertRaises(Invalid): self.store.complete(dict(owner,text='x'*262145))
|
|
self.assertEqual(len(self.store.messages('a')),2)
|
|
|
|
def test_live_visible_commentary_updates_same_item(self):
|
|
self.store.session(dict(id='a',name='a',thread_id='thread-a'))
|
|
payload=dict(session_id='a',thread_id='thread-a',items=[dict(id='comment',source='agent',text='first',phase='commentary',allow_update=True)])
|
|
self.store.mirror(payload)
|
|
payload['items'][0]['text']='updated'
|
|
self.store.mirror(payload)
|
|
self.assertEqual(len(self.store.messages('a')),1)
|
|
self.assertEqual(self.store.messages('a')[0]['text'],'updated')
|
|
|
|
def test_claim_is_atomic_and_serial_per_session(self):
|
|
self.send()
|
|
self.send(key='two')
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
|
results=list(pool.map(lambda n:self.claim(worker='w'+str(n)),range(8)))
|
|
self.assertEqual(sum(value is not None for value in results),1)
|
|
self.assertIsNone(self.claim('b'))
|
|
|
|
def test_cross_session_replies_and_completion_rejected(self):
|
|
one=self.send()
|
|
with self.assertRaises(Invalid):
|
|
self.store.send(dict(session_id='b',text='bad',source='agent',reply_to=one['id'],idempotency_key='reply'))
|
|
claimed=self.claim()
|
|
with self.assertRaises(Conflict):
|
|
self.store.complete(self.completion(claimed,session_id='b'))
|
|
self.assertEqual(self.store.messages('b'),[])
|
|
self.assertEqual(self.store.messages('a')[0]['status'],'claimed')
|
|
|
|
def test_expired_lease_fencing_and_renew(self):
|
|
self.send()
|
|
with patch('agent_control.store.time.time', return_value=100):
|
|
first=self.claim()
|
|
with patch('agent_control.store.time.time', return_value=200):
|
|
result=self.store.renew(dict(self.completion(first),lease_seconds=300))
|
|
self.assertEqual(result['lease_until'],500)
|
|
with patch('agent_control.store.time.time', return_value=501):
|
|
with self.assertRaises(Conflict):
|
|
self.store.renew(self.completion(first))
|
|
second=self.claim()
|
|
self.assertNotEqual(first['lease_id'],second['lease_id'])
|
|
with self.assertRaises(Conflict):
|
|
self.store.complete(self.completion(first))
|
|
self.store.complete(self.completion(second))
|
|
self.assertEqual(len(self.store.messages('a')),2)
|
|
|
|
def test_completion_is_atomic_idempotent_and_failure_terminal(self):
|
|
self.send()
|
|
claim=self.claim()
|
|
response=self.store.complete(self.completion(claim))
|
|
self.assertEqual(response['id'],self.store.complete(self.completion(claim))['id'])
|
|
self.assertEqual(len(Store(self.path).messages('a')),2)
|
|
self.assertIsNone(self.claim())
|
|
self.send(key='two')
|
|
next_claim=self.claim()
|
|
self.store.complete(self.completion(next_claim,outcome='failed',thread_id=None,text='authentication unavailable'))
|
|
self.assertEqual(self.store.messages('a')[-2]['status'],'failed')
|
|
self.assertIsNone(self.claim())
|
|
|
|
def test_task_validation_is_atomic(self):
|
|
self.store.task(dict(id='WP0',name='总控',status='running',phase='planning',eta_min_minutes=2,eta_max_minutes=5,eta_basis='remaining checks'))
|
|
before=self.store.state()['tasks']
|
|
for update in ({'phase':'imaginary'},{'eta_max_minutes':1},{'eta_min_minutes':float('nan')},{'eta_basis':''},{'status':'completed'}):
|
|
with self.assertRaises(Invalid):
|
|
self.store.task(update,'WP0')
|
|
self.assertEqual(before,self.store.state()['tasks'])
|
|
self.store.task(dict(status='completed',phase='done'),'WP0')
|
|
|
|
def test_started_expiry_blocks_session_until_reconciliation(self):
|
|
self.send()
|
|
self.send(key='next')
|
|
with patch('agent_control.store.time.time', return_value=100):
|
|
first=self.claim()
|
|
self.store.start(self.completion(first))
|
|
with patch('agent_control.store.time.time', return_value=401):
|
|
result=self.store.claim(dict(session_id='a',worker_id='other'))
|
|
self.assertIsNone(result['message'])
|
|
self.assertTrue(result['needs_reconciliation'])
|
|
with self.assertRaises(Conflict):
|
|
self.store.complete(self.completion(first))
|
|
self.assertEqual(self.store.messages('a')[0]['outcome'],'uncertain')
|
|
self.assertNotIn('lease_id',self.store.messages('a')[0])
|
|
self.store.reconcile(dict(session_id='a',message_id=first['id'],action='failed',resolution='人工确认已产生副作用,禁止重跑'))
|
|
second=self.claim()
|
|
self.assertNotEqual(first['id'],second['id'])
|
|
|
|
def test_uncertain_signal_fences_worker_and_requires_explicit_retry(self):
|
|
self.send()
|
|
first=self.claim()
|
|
self.store.start(self.completion(first))
|
|
with self.assertRaises(Conflict):
|
|
self.store.uncertain(self.completion(first,worker_id='other'))
|
|
self.store.uncertain(self.completion(first))
|
|
self.assertIsNone(self.claim())
|
|
with self.assertRaises(Invalid):
|
|
self.store.reconcile(dict(session_id='a',message_id=first['id'],action='retry',resolution=''))
|
|
self.store.reconcile(dict(session_id='a',message_id=first['id'],action='retry',resolution='人工确认未产生副作用,允许重试'))
|
|
second=self.claim()
|
|
self.assertEqual(first['id'],second['id'])
|
|
self.assertNotEqual(first['lease_id'],second['lease_id'])
|
|
with self.assertRaises(Conflict):
|
|
self.store.complete(self.completion(first))
|
|
|
|
def test_mirror_is_bound_idempotent_and_never_executable(self):
|
|
self.store.session(dict(id='a',name='bound',thread_id='actual-thread'))
|
|
batch=dict(session_id='a',thread_id='actual-thread',items=[
|
|
dict(id='external-1',source='human',text='historical question',created=10),
|
|
dict(id='external-1',source='agent',text='historical reply',created=11)])
|
|
self.assertEqual(self.store.mirror(batch)['imported'],2)
|
|
self.assertEqual(self.store.mirror(batch)['imported'],0)
|
|
self.assertEqual(self.store.state()['sessions'][0]['connection'],'waiting')
|
|
self.assertIsNone(self.claim())
|
|
rows=self.store.messages('a')
|
|
self.assertTrue(all(r['status']=='delivered' and r['origin']=='session_history' for r in rows))
|
|
self.assertEqual(rows[0]['created'],10)
|
|
self.assertEqual(self.store.messages('b'),[])
|
|
with self.assertRaises(Conflict):
|
|
self.store.mirror(dict(batch,thread_id='other-thread'))
|
|
with self.assertRaises(Conflict):
|
|
self.store.mirror(dict(batch,session_id='b'))
|
|
|
|
def test_mirror_batch_failure_rolls_back_and_rejects_changed_items(self):
|
|
self.store.session(dict(id='a',name='bound',thread_id='actual-thread'))
|
|
base=dict(session_id='a',thread_id='actual-thread')
|
|
one=dict(id='one',source='human',text='first')
|
|
self.store.mirror(dict(base,items=[one]))
|
|
with self.assertRaises(Conflict):
|
|
self.store.mirror(dict(base,items=[dict(one,id='new'),dict(one,text='changed')]))
|
|
self.assertEqual(len(self.store.messages('a')),1)
|
|
for items in ([dict(one,created=float('nan'))],[one]*101,[dict(one,source='tool')]):
|
|
with self.assertRaises(Invalid):
|
|
self.store.mirror(dict(base,items=items))
|
|
self.assertEqual(len(self.store.messages('a')),1)
|
|
|
|
def test_requested_is_not_real_session(self):
|
|
self.store.request(dict(name='new',prompt='work'))
|
|
state=self.store.state()
|
|
self.assertEqual(len(state['sessions']),2)
|
|
self.assertEqual(state['requests'][0]['status'],'requested')
|
|
self.assertTrue(all(s['connection']=='waiting' for s in state['sessions']))
|
|
|
|
|
|
class HTTPTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp=tempfile.TemporaryDirectory()
|
|
root=Path(self.tmp.name)
|
|
root.chmod(0o700)
|
|
self.secret='a'*48
|
|
self.token=root/'token'
|
|
self.token.write_text(self.secret)
|
|
self.token.chmod(0o600)
|
|
web=root/'web'
|
|
web.mkdir()
|
|
(web/'index.html').write_text('<title>Agent Workbench</title>')
|
|
(web/'app.js').write_text('console.log("test fixture")')
|
|
self.server=Server(dict(bind='127.0.0.1',port=0,runtime_root=str(root),token_file=str(self.token),web_root=str(web)))
|
|
self.thread=threading.Thread(target=self.server.serve_forever,daemon=True)
|
|
self.thread.start()
|
|
|
|
def tearDown(self):
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
self.thread.join()
|
|
self.tmp.cleanup()
|
|
|
|
def request(self,path='/api/state',method='GET',data=None,headers=None):
|
|
conn=http.client.HTTPConnection(*self.server.server_address,timeout=3)
|
|
base={'Authorization':'Bearer '+self.secret,'X-CSRF-Token':'1','Content-Type':'application/json'}
|
|
base.update(headers or {})
|
|
conn.request(method,path,body=json.dumps(data) if data is not None else None,headers=base)
|
|
response=conn.getresponse()
|
|
body=response.read()
|
|
result=(response.status,body,response.getheaders())
|
|
conn.close()
|
|
return result
|
|
|
|
def test_auth_host_origin_csrf_and_static_boundary(self):
|
|
self.assertEqual(self.request()[0],200)
|
|
self.assertEqual(self.request(headers={'Authorization':''})[0],401)
|
|
self.assertEqual(self.request(headers={'Host':'evil.example'})[0],403)
|
|
self.assertEqual(self.request(headers={'Origin':'https://evil.example'})[0],403)
|
|
self.assertEqual(self.request('/api/sessions','POST',{'name':'test'}, {'X-CSRF-Token':''})[0],403)
|
|
self.assertEqual(self.request('/../store.py')[0],404)
|
|
self.assertEqual(self.request('/%2e%2e/server.py')[0],404)
|
|
self.assertEqual(self.request('/?token=secret')[0],404)
|
|
code,body,headers=self.request('/',headers={'Authorization':''})
|
|
self.assertEqual(code,200)
|
|
self.assertIn(b'Agent Workbench',body)
|
|
self.assertIn('Content-Security-Policy',dict(headers))
|
|
|
|
def test_http_roundtrip_and_errors_do_not_leak(self):
|
|
code,body,_=self.request('/api/sessions','POST',{'id':'a','name':'session'})
|
|
self.assertEqual(code,200)
|
|
code,body,_=self.request('/api/messages','POST',{'session_id':'a','text':'hello','idempotency_key':'k'})
|
|
self.assertEqual(code,200)
|
|
code,body,_=self.request('/api/worker/claim','POST',{'session_id':'a','worker_id':'w'})
|
|
message=json.loads(body)['message']
|
|
self.assertEqual(code,200)
|
|
code,body,_=self.request('/api/worker/complete','POST',dict(session_id='a',worker_id='w',message_id=message['id'],lease_id=message['lease_id'],thread_id='real-thread',text='output'))
|
|
self.assertEqual(code,200)
|
|
code,body,_=self.request('/api/messages?session_id=a')
|
|
self.assertEqual(len(json.loads(body)['messages']),2)
|
|
with patch.object(self.server.store,'state',side_effect=RuntimeError(self.secret)):
|
|
code,body,_=self.request()
|
|
self.assertEqual(code,500)
|
|
self.assertNotIn(self.secret.encode(),body)
|
|
|
|
def test_http_mirror_accepts_bounded_long_history(self):
|
|
self.request('/api/sessions','POST',dict(id='history',name='history',thread_id='bound-thread'))
|
|
batch=dict(session_id='history',thread_id='bound-thread',items=[dict(id='long',source='agent',text='历史正文'*6000)])
|
|
self.assertGreater(len(json.dumps(batch).encode()),65536)
|
|
code,body,_=self.request('/api/mirror','POST',batch)
|
|
self.assertEqual(code,200)
|
|
self.assertEqual(json.loads(body)['imported'],1)
|
|
code,body,_=self.request('/api/worker/claim','POST',dict(session_id='history',worker_id='w'))
|
|
self.assertIsNone(json.loads(body)['message'])
|
|
|
|
def test_http_submission_and_large_completion(self):
|
|
self.request('/api/sessions','POST',dict(id='large',name='large',thread_id='thread-large'))
|
|
self.request('/api/messages','POST',dict(session_id='large',text='q',idempotency_key='one'))
|
|
_,body,_=self.request('/api/worker/claim','POST',dict(session_id='large',worker_id='w'))
|
|
claimed=json.loads(body)['message']
|
|
owner=dict(session_id='large',worker_id='w',message_id=claimed['id'],lease_id=claimed['lease_id'])
|
|
self.assertEqual(self.request('/api/worker/start','POST',owner)[0],200)
|
|
payload=dict(owner,state='submitting',thread_id='thread-large',input_sha256=hashlib.sha256(b'input').hexdigest())
|
|
self.assertEqual(self.request('/api/worker/submission','POST',payload)[0],200)
|
|
text='result'*20000
|
|
result=dict(owner,thread_id='thread-large',text=text)
|
|
self.assertGreater(len(json.dumps(result)),65536)
|
|
self.assertEqual(self.request('/api/worker/complete','POST',result)[0],200)
|
|
self.assertEqual(self.request('/api/worker/complete','POST',dict(result,text='x'*262145))[0],400)
|
|
|
|
@unittest.skipUnless(os.name=='posix','POSIX permissions')
|
|
def test_permissive_secret_file_rejected(self):
|
|
self.token.chmod(0o644)
|
|
with self.assertRaises(ValueError):
|
|
restricted_token(self.token)
|
|
|
|
|
|
if __name__=='__main__':
|
|
unittest.main()
|