"""Local administration and exact Cookbook caller-to-route regressions.""" import asyncio import json from dataclasses import replace from types import SimpleNamespace from unittest.mock import MagicMock import httpx import pytest from fastapi import FastAPI, HTTPException from starlette.requests import Request from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER from routes import cookbook_routes, shell_routes from src import builtin_actions, tool_execution from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority, seal_task_authority, restore_task_authority from src.agent_runtime.remote_resources import bind_backend_for_operation, bind_backend_operation from src.agent_runtime.local_model_control import CAPABILITY_HEADER, model_control_headers from src.agent_runtime.resources import ResourceIdentityError from src.tools import cookbook from src.tool_capabilities import ToolRunSecurityContext from src.tool_types import ToolBlock def request(host='127.0.0.1', headers=None, user=None): req = Request({'type': 'http', 'method': 'POST', 'scheme': 'http', 'path': '/api/shell/exec', 'server': ('127.0.0.1', 7000), 'client': (host, 1234), 'headers': [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()], 'app': SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(is_admin=lambda u: u == 'alice')))}) req.state.current_user = user return req @pytest.mark.parametrize('host,headers,allowed', [ ('127.0.0.1', {}, True), ('::1', {}, True), ('192.0.2.1', {}, False), ('127.0.0.1', {'x-forwarded-for': '192.0.2.1'}, False), ('127.0.0.1', {'forwarded': 'for=192.0.2.1'}, False), ('127.0.0.1', {'cf-ray': 'proxy'}, False), ('127.0.0.1', {'x-forwarded-proto': 'https'}, False), ('127.0.0.1', {'sec-fetch-site': 'cross-site'}, False), ('127.0.0.1', {'origin': 'https://evil.example'}, False), ('127.0.0.1', {INTERNAL_TOOL_HEADER: 'forged'}, False), ('127.0.0.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}, False), ]) def test_auth_disabled_operator_transport(monkeypatch, host, headers, allowed): monkeypatch.setenv('AUTH_ENABLED', 'false') req = request(host, headers) if allowed: shell_routes._require_admin(req) else: with pytest.raises(HTTPException) as error: shell_routes._require_admin(req) assert error.value.status_code == 403 @pytest.mark.parametrize('user,allowed', [('alice', True), ('bob', False), (None, False), ('api', False), (INTERNAL_TOOL_USER, False)]) def test_auth_enabled_administration(monkeypatch, user, allowed): monkeypatch.setenv('AUTH_ENABLED', 'true') if allowed: shell_routes._require_admin(request('192.0.2.1', user=user)) else: with pytest.raises(HTTPException): shell_routes._require_admin(request(user=user)) @pytest.fixture def control_app(tmp_path, monkeypatch): monkeypatch.setenv('AUTH_ENABLED', 'true') manager = SimpleNamespace(is_configured=True, users={'alice': {}}, is_admin=lambda u: u == 'alice') import core.auth monkeypatch.setattr(core.auth, 'AuthManager', lambda: manager) monkeypatch.setattr(tool_execution, '_owner_is_admin', lambda u: u == 'alice') monkeypatch.setattr(cookbook_routes, 'TMUX_LOG_DIR', tmp_path / 'tmux') state = tmp_path / 'cookbook.json' state.write_text(json.dumps({'presets': [{'name': 'preset', 'model': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}]})) monkeypatch.setattr(cookbook_routes, 'COOKBOOK_STATE_FILE', str(state)) monkeypatch.setattr(builtin_actions, 'COOKBOOK_STATE_FILE', str(state)) spawned = [] async def spawn(command, **kwargs): spawned.append(command) async def wait(): return 0 async def read(): return b'' return SimpleNamespace(returncode=0, wait=wait, stderr=SimpleNamespace(read=read)) monkeypatch.setattr(asyncio, 'create_subprocess_shell', spawn) async def remote_probe(*args, **kwargs): async def communicate(): return b'tmux', b'' return SimpleNamespace(returncode=0, communicate=communicate) monkeypatch.setattr(asyncio, 'create_subprocess_exec', remote_probe) import src.assistant_log monkeypatch.setattr(src.assistant_log, 'log_to_assistant', lambda *a, **k: None) async def endpoint(**kwargs): return {'added': True, 'endpoint_id': 'endpoint'} monkeypatch.setattr(cookbook, '_ensure_served_endpoint', endpoint) app = FastAPI() app.state.auth_manager = manager @app.middleware('http') async def attribution(req, next): if req.headers.get(INTERNAL_TOOL_HEADER) == INTERNAL_TOOL_TOKEN: req.state.current_user = req.headers.get('X-Odysseus-Owner') or INTERNAL_TOOL_USER return await next(req) app.include_router(cookbook_routes.setup_cookbook_routes()) app.include_router(shell_routes.setup_shell_routes()) real_client = httpx.AsyncClient def client_factory(*args, **kwargs): kwargs.setdefault('transport', httpx.ASGITransport(app=app)) return real_client(*args, **kwargs) monkeypatch.setattr(httpx, 'AsyncClient', client_factory) return app, spawned, tmp_path @pytest.mark.parametrize('tool,args', [ ('download_model', {'repo_id': 'org/model', 'local': True}), ('serve_model', {'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg', 'local': True}), ('serve_preset', {'name': 'preset'}), ]) async def test_real_local_tool_dispatch_reaches_real_model_route(control_app, tool, args): app, spawned, work = control_app authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant(tool),)) _, result = await tool_execution.execute_tool_block(ToolBlock(tool, json.dumps(args)), owner='alice', session_id='thread', workspace=str(work), request_authority=authority, security_context=ToolRunSecurityContext()) assert result['exit_code'] == 0, result assert result['session_id'].startswith('cookbook-' if tool == 'download_model' else 'serve-') assert len(spawned) == 1 and 'tmux new-session' in spawned[0] async def test_real_scheduled_local_action_uses_restored_exact_authority(control_app): app, spawned, work = control_app command = json.dumps({'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg', 'set_default': False}) snapshot = seal_task_authority(command, 'action', 'cookbook_serve', owner='alice') authority = restore_task_authority(snapshot, command, 'action', 'cookbook_serve', owner='alice') with bind_request_authority(authority): message, ok = await builtin_actions.action_cookbook_serve('alice', command=command) assert ok, message assert len(spawned) == 1 with bind_request_authority(authority): _, ok = await builtin_actions.action_cookbook_serve('alice', command=command.replace('samplepkg', 'changedpkg')) assert not ok and len(spawned) == 1 @pytest.mark.parametrize('host,headers', [ ('127.0.0.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}), ('192.0.2.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}), ('127.0.0.1', {INTERNAL_TOOL_HEADER: 'forged'}), ('127.0.0.1', {CAPABILITY_HEADER: 'forged', INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}), ]) async def test_header_only_cannot_launch(control_app, host, headers): app, spawned, _ = control_app async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client: for path in ('/api/model/download', '/api/model/serve', '/api/shell/exec'): r = await client.post(path, json={'repo_id': 'org/model', 'cmd': 'printf nope', 'command': 'printf nope'}, headers=headers) assert r.status_code == 403 assert not spawned @pytest.mark.parametrize('substitute', ['body', 'route', 'owner', 'remote', 'proxy', 'replay']) async def test_capability_exact_transport_binding(control_app, substitute): app, spawned, work = control_app content = json.dumps({'repo_id': 'org/model', 'local': True}) authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('download_model'),)) operation = ExactOperation.normalize('download_model', content) backend = bind_backend_for_operation(authority, operation) body = {'repo_id': 'org/model'} with bind_request_authority(authority), bind_backend_operation(backend), model_control_headers('download_model', content, 'alice', body) as headers: changed = dict(headers); payload = dict(body); path = '/api/model/download'; host = '127.0.0.1' if substitute == 'body': payload['repo_id'] = 'org/changed' if substitute == 'route': path = '/api/model/serve' if substitute == 'owner': changed['X-Odysseus-Owner'] = 'bob' if substitute == 'remote': host = '192.0.2.1' if substitute == 'proxy': changed['x-forwarded-for'] = '192.0.2.1' async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client: if substitute == 'replay': assert (await client.post(path, json=payload, headers=changed)).status_code == 200 r = await client.post(path, json=payload, headers=changed) assert r.status_code == 403 assert len(spawned) == (1 if substitute == 'replay' else 0) @pytest.mark.parametrize('field', ['owner', 'request_id', 'session_id']) def test_producer_wrong_application_binding(control_app, field): _, _, work = control_app content = '{"repo_id":"org/model","local":true}' authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('download_model'),)) backend = bind_backend_for_operation(authority, ExactOperation.normalize('download_model', content)) changed = replace(authority, **{field: 'replacement'}, resource_roots=None, backend_resources=None, owned_scopes=None) with bind_request_authority(changed), bind_backend_operation(backend), pytest.raises(ResourceIdentityError): with model_control_headers('download_model', content, 'alice', {'repo_id': 'org/model'}): pytest.fail('Substituted producer obtained a capability') async def test_remote_route_semantics_remain_unchanged(control_app): app, spawned, _ = control_app async with httpx.AsyncClient(base_url='http://127.0.0.1') as client: r = await client.post('/api/model/download', json={'repo_id': 'org/model', 'remote_host': 'gpu.example'}, headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}) assert r.status_code == 200 and r.json()['ok'], r.text assert len(spawned) == 1 and 'ssh ' in spawned[0] async def test_auth_disabled_local_route_usage(control_app, monkeypatch): app, spawned, _ = control_app monkeypatch.setenv('AUTH_ENABLED', 'false') async with httpx.AsyncClient(base_url='http://127.0.0.1') as client: shell = await client.post('/api/shell/exec', json={'command': ''}) state = await client.post('/api/cookbook/state', json={'tasks': []}) launch = await client.post('/api/model/download', json={'repo_id': 'org/model'}) assert shell.status_code == state.status_code == launch.status_code == 200 assert launch.json()['ok'] and len(spawned) == 1 async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=('192.0.2.1', 1)), base_url='http://127.0.0.1') as client: for path, body in [('/api/shell/exec', {'command': ''}), ('/api/cookbook/state', {'tasks': []}), ('/api/model/download', {'repo_id': 'org/model'})]: assert (await client.post(path, json=body)).status_code == 403