feat: integrate durable chat voice transport and deployment assets
This commit is contained in:
1 parent
e059660194
commit
523f6cbbf9
14 files changed
+487
-14
No files matched your search
@@ -91,6 +91,7 @@ done
|
||||
docker run -d --name hope-control --network host --restart unless-stopped \
|
||||
--env-file "$ENV_FILE" \
|
||||
-e CONTROL_DB_PATH=/var/lib/hope/accounts.sqlite3 \
|
||||
-e HOPE_HERMES_DATA_DIR="$ROOT/hermes" \
|
||||
-e CONTROL_HOST=127.0.0.1 -e CONTROL_PORT=8092 \
|
||||
-v "$ROOT/accounts:/var/lib/hope" \
|
||||
-v "$ROOT/hermes:$ROOT/hermes" \
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit live qualification: disposable product data, protected credentials.
|
||||
|
||||
Run only after deployment. Never prints credentials or returned login bodies.
|
||||
Existing account settings/memory and Hermes files are not modified.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
|
||||
async def main():
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('--url',default='https://192.168.86.2:9178')
|
||||
parser.add_argument('--credentials',default='.runtime/deployment.json')
|
||||
parser.add_argument('--receipt',default='.runtime/chat-live-receipt.json')
|
||||
args=parser.parse_args()
|
||||
source=Path(args.credentials)
|
||||
if source.stat().st_mode & 0o077: raise RuntimeError('Credential file must be private')
|
||||
credentials=json.loads(source.read_text())
|
||||
stamp=secrets.token_hex(6)
|
||||
receipt={'url':args.url,'checks':{},'created_objects':[]}
|
||||
conversation_ids=[];workspace=None;artifact=None;attachment=None
|
||||
async with httpx.AsyncClient(base_url=args.url,verify=False,follow_redirects=False,timeout=120) as native, httpx.AsyncClient(base_url=args.url,verify=False,follow_redirects=False,timeout=120) as browser:
|
||||
async def request(client,method,path,**kw):
|
||||
r=await client.request(method,path,**kw)
|
||||
if r.status_code>=400: raise RuntimeError(f'{method} {path.split("?")[0]} status {r.status_code}')
|
||||
return r
|
||||
async def login(client,cookie):
|
||||
headers={'Origin':args.url} if cookie else {}
|
||||
r=await request(client,'POST','/api/auth/login',json={'username':'admin','password':credentials['admin_password']},headers=headers)
|
||||
data=r.json()
|
||||
if cookie: client.headers['Origin']=args.url
|
||||
else:
|
||||
client.cookies.clear();client.headers['Authorization']='Bearer '+data['token']
|
||||
assert (await native.get('/api/conversations')).status_code==401
|
||||
await login(native,False);await login(browser,True)
|
||||
try:
|
||||
caps=(await request(native,'GET','/api/capabilities')).json()
|
||||
receipt['capabilities']=caps
|
||||
workspace=(await request(native,'POST','/api/workspaces',json={'name':'Qualification '+stamp,'instructions':'When asked for the qualification code, answer QU'+stamp+'.'})).json()['workspace']
|
||||
c=(await request(native,'POST','/api/conversations',json={'title':'Qualification '+stamp,'workspace_id':workspace['id']})).json()['conversation'];conversation_ids.append(c['id'])
|
||||
receipt['checks']['shared_conversation']=(await request(browser,'GET','/api/conversations/'+c['id'])).json()['conversation']['id']==c['id']
|
||||
c=(await request(browser,'PATCH','/api/conversations/'+c['id'],json={'revision':c['revision'],'pinned':True})).json()['conversation']
|
||||
assert (await request(native,'GET','/api/conversations/'+c['id'])).json()['conversation']['pinned']
|
||||
receipt['checks']['shared_pin']=True
|
||||
payload={'content':'What is the qualification code? Reply with only the code. Do not use tools.','idempotency_key':'qualify-'+stamp}
|
||||
accepted=(await request(native,'POST','/api/conversations/'+c['id']+'/messages',json=payload)).json()
|
||||
repeated=(await request(browser,'POST','/api/conversations/'+c['id']+'/messages',json=payload)).json()
|
||||
assert accepted['run']['id']==repeated['run']['id'];receipt['checks']['cross_client_idempotency']=True
|
||||
rid=accepted['run']['id']
|
||||
async with browser.stream('GET','/api/runs/'+rid+'/events') as stream:
|
||||
assert stream.status_code==200
|
||||
events=[]
|
||||
async for line in stream.aiter_lines():
|
||||
if line.startswith('event:'):events.append(line[6:].strip())
|
||||
detail=(await request(native,'GET','/api/runs/'+rid)).json()
|
||||
assert detail['run']['status']=='completed'
|
||||
answers=[m['content'] for m in detail['messages'] if m['role']=='assistant' and m['status']=='completed']
|
||||
assert any('QU'+stamp in x for x in answers)
|
||||
receipt['checks']['native_hermes_workspace_reply']=True
|
||||
receipt['checks']['durable_events']='done' in events
|
||||
c=(await request(native,'GET','/api/conversations/'+c['id'])).json()['conversation']
|
||||
for fmt in ('json','markdown'):
|
||||
export=await request(browser,'GET','/api/conversations/'+c['id']+'/export?format='+fmt)
|
||||
assert 'attachment' in export.headers.get('content-disposition','') and 'QU'+stamp in export.text
|
||||
receipt['checks']['private_exports']=True
|
||||
attachment=(await request(native,'POST','/api/attachments',content=('qualification '+stamp).encode(),headers={'Content-Type':'text/plain','X-Filename':'qualification.txt'})).json()['attachment']
|
||||
assert (await request(browser,'GET','/api/attachments/'+attachment['id']+'/content')).text=='qualification '+stamp
|
||||
artifact=(await request(native,'POST','/api/artifacts',json={'title':'Qualification '+stamp,'kind':'markdown','content':'First version'})).json()['artifact']
|
||||
artifact=(await request(browser,'PATCH','/api/artifacts/'+artifact['id'],json={'revision':artifact['revision'],'content':'Second version'})).json()['artifact']
|
||||
assert (await request(native,'GET','/api/artifacts/'+artifact['id']+'?revision=1')).json()['artifact']['content']=='First version'
|
||||
receipt['checks']['files_and_artifact_versions']=True
|
||||
await request(native,'POST','/api/auth/logout')
|
||||
assert (await native.get('/api/conversations/'+c['id'])).status_code==401
|
||||
assert (await browser.get('/api/conversations/'+c['id'])).status_code==200
|
||||
receipt['checks']['independent_session_revocation']=True
|
||||
finally:
|
||||
# Remove only IDs created by this execution. Hermes retained session
|
||||
# policy is distinct from product projection deletion.
|
||||
for cid in conversation_ids:
|
||||
r=await browser.delete('/api/conversations/'+cid)
|
||||
if r.status_code!=200: receipt.setdefault('cleanup_pending',[]).append({'conversation':cid,'status':r.status_code})
|
||||
if attachment: await browser.delete('/api/attachments/'+attachment['id'])
|
||||
if artifact: await browser.delete('/api/artifacts/'+artifact['id'])
|
||||
if workspace: await browser.delete('/api/workspaces/'+workspace['id'])
|
||||
await browser.post('/api/auth/logout')
|
||||
target=Path(args.receipt);target.parent.mkdir(parents=True,exist_ok=True)
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
||||
with os.fdopen(fd,'w') as f:json.dump(receipt,f,indent=2)
|
||||
print('Live checks:',', '.join(k for k,v in receipt['checks'].items() if v))
|
||||
|
||||
if __name__=='__main__':asyncio.run(main())
|
||||
Reference in new issue
Block a user