ouroboros/tests/test_process_environment.py

317 lines
17 KiB
Python

"""Selected process configuration keeps values, source secrecy and actor authority."""
from __future__ import annotations
import gzip
import json
import sys
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import pytest
from ouroboros import mcp_client
from ouroboros.contracts.task_constraint import TaskConstraint
from ouroboros.tools import services
from ouroboros.tools.registry import ToolContext, ToolRegistry
from ouroboros.tools.tool_result import ToolResult
from ouroboros.utils import sanitize_tool_args_for_log
from ouroboros.workspace_executor import resolve_process_env
pytestmark = pytest.mark.serial
@pytest.fixture
def process_context(tmp_path, monkeypatch):
system, workspace, data = (tmp_path / name for name in ('system', 'workspace', 'data'))
for path in (system, workspace, data):
path.mkdir()
monkeypatch.setenv('OUROBOROS_RUNTIME_MODE', 'advanced')
monkeypatch.setattr('ouroboros.safety.check_safety', lambda *args, **kwargs: (True, ''))
context = ToolContext(repo_dir=system, drive_root=data, workspace_root=workspace,
workspace_mode='external', task_id='process-env')
registry = ToolRegistry(system, data)
registry.set_context(context)
yield registry, context, workspace, data
services.kill_all_services(data)
mcp_client.reset_manager_for_tests()
def test_environment_resolution_retains_settings_classification():
env, secrets = resolve_process_env(
{'PORT': '8080', 'DEBUG': '1', 'MODE': 'literal'},
{'PASSWORD': 'CUSTOM_KEY', 'MODE': 'OPENAI_BASE_URL'},
settings={'CUSTOM_KEY': '!', 'OPENAI_BASE_URL': 'info'},
)
assert env == {'PORT': '8080', 'DEBUG': '1', 'PASSWORD': '!', 'MODE': 'info'}
assert secrets == ('!',)
def test_mcp_literal_environment_and_unknown_extras_preserve_working_server():
raw = {'id': 'local', 'enabled': True, 'transport': 'stdio', 'command': 'unused',
'env': {'PORT': '8080', 'DEBUG': '1'}, 'future_option': {'retained': True}}
before = json.loads(json.dumps(raw))
manager = mcp_client.MCPManager()
async def listing(cfg, timeout):
assert cfg.env == raw['env']
return [{'name': 'probe', 'description': 'port=8080 attempt=1 /v1', 'input_schema': {}}]
async def call(cfg, name, args, timeout):
return ToolResult(status='ok', code='OK', text='port=8080 attempt=1 127.0.0.1 /v1')
manager._async_list_tools, manager._async_call_tool = listing, call
manager.reconfigure({'MCP_ENABLED': True, 'MCP_SERVERS': [raw]})
tested = manager.test_server(raw)
assert tested['ok'] and 'future_option' in tested['configuration_warnings'][0]
assert manager.refresh_server('local')['ok']
assert 'port=8080 attempt=1 /v1' in manager.list_tools_for_registry()[0]['description']
assert 'port=8080 attempt=1 127.0.0.1 /v1' in manager.call_tool('mcp_local__probe', {})
status = manager.status_payload()['servers'][0]
assert not status['last_error'] and status['configuration_warnings']
assert raw == before
def test_settings_wire_metadata_is_not_an_unknown_config_field():
from ouroboros.gateway.settings import _mask_mcp_servers_payload
raw = {'id': 'local', 'transport': 'stdio', 'command': 'unused', 'future_option': True}
wire = _mask_mcp_servers_payload([raw])[0]
assert wire['auth_configured'] is False
cfg = mcp_client.normalize_server_config(wire)
assert cfg is not None
assert cfg.configuration_warnings == ('Fields retained but not applied: future_option',)
@pytest.mark.parametrize('secret', ['!', 'synthetic-quote"\\tail\nvalue', 'synthetic-quote"\\tail\r\nvalue'])
@pytest.mark.parametrize('newline', ['\n', '\r\n'])
def test_mcp_reference_secrets_are_masked_without_a_length_floor(secret, newline):
raw = {'id': 'local', 'transport': 'stdio', 'command': 'unused',
'env': {'PORT': '8080'}, 'env_from_settings': {'PASSWORD': 'CUSTOM_KEY'}}
cfg = mcp_client.normalize_server_config(raw, settings={'CUSTOM_KEY': secret})
assert cfg is not None and cfg.env['PASSWORD'] == secret
echo = secret.replace('\n', newline)
diagnostic = 'port=8080 secret=' + echo + ' encoded=' + json.dumps(echo)[1:-1]
masked = mcp_client._redact_error_text(diagnostic, cfg)
assert echo not in masked and json.dumps(echo)[1:-1] not in masked
assert 'port=8080' in masked and '***' in masked
assert cfg.env['PASSWORD'] == secret and raw['env'] == {'PORT': '8080'}
@pytest.mark.parametrize('newline', ['\n', '\r\n'])
def test_known_secret_newline_echoes_preserve_values_and_one_pass(newline):
import io
from ouroboros.secret_masking import redact_known_values
secret = 'selected-"\\é' + newline + 'private-fragment'
echoes = [secret, secret.replace('\r\n', '\n'), secret.replace('\n', '\r\n')]
# Full-log text reads also normalize output that the child already expanded.
with io.TextIOWrapper(io.BytesIO(echoes[-1].encode('utf-8')), encoding='utf-8') as stream:
echoes.append(stream.read())
payload = {'messages': [echo for raw in echoes for echo in (
raw, json.dumps(raw, ensure_ascii=True)[1:-1], json.dumps(raw, ensure_ascii=False)[1:-1],
)], 'PORT': '8080', 'EMPTY': '', 'count': 7, 'ready': True}
before = json.loads(json.dumps(payload))
result = redact_known_values(payload, (secret, '*'))
assert result == {**payload, 'messages': ['***'] * len(payload['messages'])}
assert payload == before
def test_tool_argument_log_redacts_nested_keys_before_projection(tmp_path):
from ouroboros.loop_tool_execution import _execute_single_tool
secret = 'synthetic-password-without-provider-prefix'
args = {'env': {'DB_PASSWORD': secret, 'PORT': '8080', 'DEBUG': '1'},
'env_from_settings': {'SESSION': 'CUSTOM_KEY'}, 'items': [{'DB_PASSWORD': secret}]}
sanitized = sanitize_tool_args_for_log('start_service', args)
assert secret not in json.dumps(sanitized)
assert sanitized['env']['PORT'] == '8080' and sanitized['env']['DEBUG'] == '1'
assert sanitized['env_from_settings'] == {'SESSION': 'CUSTOM_KEY'}
assert args['env']['DB_PASSWORD'] == secret
received = []
def execute(name, values):
received.append(values)
return ToolResult(status='ok', code='OK', text='fixture result')
tools = SimpleNamespace(CODE_TOOLS={'start_service'}, _ctx=SimpleNamespace(task_metadata={}),
execute_result=execute)
logs = tmp_path / 'logs'
logs.mkdir()
_execute_single_tool(tools, {'id': 'env-log', 'function': {'name': 'start_service',
'arguments': json.dumps(args)}}, logs, task_id='env-log')
assert received == [args]
assert secret not in (logs / 'tools.jsonl').read_text()
@pytest.mark.parametrize('actor', ['acting_subagent', 'local_readonly_subagent', 'presence'])
def test_restricted_service_refs_fail_before_settings_lookup(process_context, monkeypatch, actor):
registry, ctx, workspace, _data = process_context
if actor == 'presence':
from ouroboros.presence_authority import build_presence_capability_ceiling, presence_ceiling_payload
from ouroboros.presence_capabilities import PresenceResourceTarget, PresenceToolTarget
from tests.test_presence_authority import _resolution
ceiling = build_presence_capability_ceiling(
skill_name='fixture', skill_content_hash='c' * 64, state_fingerprint='d' * 64,
resolution=_resolution(PresenceToolTarget('builtin', 'start_service'),
PresenceResourceTarget('active_workspace', ('service',), '.')),
)
ctx.task_contract = {'capability_ceiling': presence_ceiling_payload(ceiling)}
else:
ctx.task_constraint = TaskConstraint(mode=actor, surface='external_workspace', write_root=str(workspace))
monkeypatch.setattr(services, 'load_settings', lambda: pytest.fail('restricted actor reached Settings'))
monkeypatch.setattr('ouroboros.process_custody.spawn_supervised', lambda *a, **k: pytest.fail('must not spawn'))
result = registry.execute_result('start_service', {'cmd': [sys.executable], 'cwd': str(workspace),
'env_from_settings': {'TOKEN': 'CUSTOM_KEY'}})
assert result.status == 'blocked' and result.code == 'ACCESS_BLOCKED'
expected = 'LOCAL_READONLY_SUBAGENT_BLOCKED' if actor == 'local_readonly_subagent' else 'SERVICE_ENV_REFERENCE_BLOCKED'
assert expected in result.text
def test_acting_service_literal_environment_still_runs(process_context, monkeypatch):
registry, ctx, workspace, _data = process_context
ctx.task_constraint = TaskConstraint(mode='acting_subagent', surface='external_workspace', write_root=str(workspace))
monkeypatch.setattr(services, 'load_settings', lambda: pytest.fail('literal env must not read Settings'))
script = workspace / 'service.py'
script.write_text("import os,time; print('port='+os.environ['PORT']+' debug='+os.environ['DEBUG'],flush=True); time.sleep(30)")
command = [sys.executable, str(script)]
result = registry.execute_result('start_service', {'cmd': command, 'cwd': str(workspace),
'env': {'PORT': '8080', 'DEBUG': '1'}, 'readiness': {'log_contains': 'port=8080 debug=1', 'timeout_sec': 3}})
assert result.status == 'ok' and json.loads(result.text)['ready'], result.text
assert 'port=8080 debug=1' in registry.execute('service_logs', {})
def test_service_replacement_keeps_uncertain_record_and_returns_start_error(process_context, monkeypatch):
from ouroboros import workspace_executor
registry, ctx, workspace, _data = process_context
ctx.executor_ref = {'type': 'local', 'workspace_host_path': str(workspace), 'workspace_backend_path': '/workspace'}
key = workspace_executor.service_key(ctx, 'service')
record = object()
monkeypatch.setattr(workspace_executor, '_SERVICES', {key: record})
monkeypatch.setattr(workspace_executor, '_service_state', lambda item: 'unknown')
monkeypatch.setattr(workspace_executor, '_stop_service_record', lambda *a, **k: {'stop_failed': True, 'stop_error': 'unknown'})
monkeypatch.setattr('ouroboros.process_custody.spawn_supervised', lambda *a, **k: pytest.fail('must not spawn a duplicate'))
try:
result = registry.execute('start_service', {'cmd': [sys.executable], 'cwd': str(workspace)})
assert 'SERVICE_START_ERROR' in result and 'not confirmed' in result
assert workspace_executor._SERVICES[key] is record
finally:
workspace_executor._SERVICES.pop(key, None)
@pytest.mark.parametrize('cleanup', ['stop', 'task', 'global', 'global_nowait'])
def test_local_executor_finalizes_secret_log_before_forgetting_record(process_context, monkeypatch, cleanup):
registry, ctx, workspace, data = process_context
ctx.executor_ref = {'type': 'local', 'workspace_host_path': str(workspace), 'workspace_backend_path': '/workspace'}
secret = 'synthetic-finalization-canary'
monkeypatch.setattr(services, 'load_settings', lambda: {'CUSTOM_KEY': secret})
command = [sys.executable, '-c', "import os,time; print(os.environ['TOKEN'],flush=True); print('port='+os.environ['PORT'],flush=True); time.sleep(30)"]
started = registry.execute_result('start_service', {'cmd': command, 'cwd': str(workspace),
'env': {'PORT': '8080'}, 'env_from_settings': {'TOKEN': 'CUSTOM_KEY'},
'readiness': {'log_contains': secret, 'timeout_sec': 3}})
assert started.status == 'ok' and json.loads(started.text)['ready']
assert secret not in registry.execute('service_logs', {})
durable = data / 'state' / 'workspace_executor_processes'
assert all(secret not in path.read_text() for path in durable.glob('*.json'))
if cleanup == 'stop':
stopped = [json.loads(registry.execute('stop_service', {}))]
elif cleanup == 'task':
stopped = services.stop_task_services(ctx)
else:
stopped = services.kill_all_services(data, wait=cleanup != 'global_nowait')
payload = next(item for item in stopped if item.get('name') == 'service')
if payload.get('stop_failed'):
# wait=False dispatch can return before SIGKILL is reaped. It must
# retain env/custody rather than inventing completed log delivery.
from ouroboros import workspace_executor
assert cleanup == 'global_nowait'
record = next(record for record in workspace_executor._services_snapshot() if record.name == 'service')
assert record.secret_values == (secret,) and record.durable_record_path.exists()
record.local_proc.wait(timeout=5)
payload = next(item for item in services.kill_all_services(data) if item.get('name') == 'service')
assert payload['state'] == 'stopped' and not payload.get('stop_failed')
final = payload['log_finalization']
assert final['deleted_live_log'] and not final['errors']
text = gzip.decompress(Path(final['full_log_ref']['path']).read_bytes()).decode()
assert secret not in text and 'port=8080' in text and '***' in text
assert not list(durable.glob('*.json'))
assert services.prune_service_logs(data, retention_days=0)['archived_files'] == 0
@pytest.mark.parametrize('actor', ['acting_subagent', 'local_readonly_subagent'])
def test_children_keep_the_existing_owner_configured_mcp_identity(process_context, actor):
pytest.importorskip('mcp')
registry, ctx, workspace, _data = process_context
witness = workspace / 'mcp-witness.jsonl'
script = workspace / 'mcp.py'
script.write_text('''import json,os,sys
for line in sys.stdin:
request=json.loads(line)
if 'id' not in request: continue
method=request['method']
with open(sys.argv[1],'a') as f: f.write(json.dumps({'method':method,'token':os.environ['TOKEN']})+'\\n')
if method=='initialize': result={'protocolVersion':request['params']['protocolVersion'],'capabilities':{'tools':{}},'serverInfo':{'name':'fixture','version':'1'}}
elif method=='tools/list': result={'tools':[{'name':'probe','description':'Configured probe','inputSchema':{'type':'object'}}]}
else: result={'content':[{'type':'text','text':'configured='+os.environ['TOKEN']}],'isError':False}
print(json.dumps({'jsonrpc':'2.0','id':request['id'],'result':result}),flush=True)
''', encoding='utf-8')
secret = 'synthetic-owner-mcp-identity'
manager = mcp_client.get_manager()
manager.reconfigure({'MCP_ENABLED': True, 'MCP_TOOL_TIMEOUT_SEC': 5, 'CUSTOM_KEY': secret,
'MCP_SERVERS': [{'id': 'configured', 'enabled': True, 'transport': 'stdio', 'command': sys.executable,
'args': [str(script), str(witness)], 'cwd': str(workspace), 'env_from_settings': {'TOKEN': 'CUSTOM_KEY'}}]})
assert manager.refresh_server('configured')['ok']
tool = 'mcp_configured__probe'
ctx.task_constraint = TaskConstraint(mode=actor, surface='external_workspace', write_root=str(workspace),
external_tool_grants=[tool])
result = registry.execute_result(tool, {})
assert result.status == 'ok' and 'configured=***' in result.text and secret not in result.text
rows = [json.loads(line) for line in witness.read_text().splitlines()]
assert any(row['method'] == 'tools/call' for row in rows)
assert all(row['token'] == secret for row in rows)
if actor == 'acting_subagent':
ctx.task_constraint = replace(ctx.task_constraint, external_tool_grants=())
refused = registry.execute_result(tool, {})
assert refused.status == 'blocked' and refused.code == 'ACCESS_BLOCKED'
assert len(witness.read_text().splitlines()) == len(rows)
@pytest.mark.parametrize('local_executor', [False, True])
def test_replacing_exited_service_finalizes_each_environment_revision(process_context, monkeypatch, local_executor):
from ouroboros import workspace_executor
registry, ctx, workspace, _data = process_context
if local_executor:
ctx.executor_ref = {'type': 'local', 'workspace_host_path': str(workspace), 'workspace_backend_path': '/workspace'}
settings = {'CUSTOM_KEY': 'synthetic-first-environment'}
monkeypatch.setattr(services, 'load_settings', lambda: dict(settings))
finalized = []
finalize = services._finalize_service_log_for_drive
def observe(*args, **kwargs):
result = finalize(*args, **kwargs)
finalized.append(result)
return result
monkeypatch.setattr(services, '_finalize_service_log_for_drive', observe)
script = workspace / 'one_shot.py'
script.write_text("import os; print(os.environ['TOKEN'], flush=True)")
for secret in ('synthetic-first-environment', 'synthetic-second-environment'):
settings['CUSTOM_KEY'] = secret
result = registry.execute_result('start_service', {'cmd': [sys.executable, str(script)],
'cwd': str(workspace), 'env_from_settings': {'TOKEN': 'CUSTOM_KEY'}})
assert result.status == 'ok', result.text
records = workspace_executor._SERVICES if local_executor else services._SERVICES
record = records[workspace_executor.service_key(ctx, 'service')]
proc = record.local_proc if local_executor else record.proc
assert proc.wait(timeout=5) == 0
registry.execute('stop_service', {})
assert len(finalized) == 2
for result in finalized:
assert result['deleted_live_log'] and not result['errors']
text = gzip.decompress(Path(result['full_log_ref']['path']).read_bytes()).decode()
assert 'synthetic-first-environment' not in text and 'synthetic-second-environment' not in text
assert '***' in text