mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-13 03:32:21 +02:00
1241 lines
42 KiB
Python
1241 lines
42 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
from unittest.mock import AsyncMock
|
|
|
|
from routes.chat_routes import _agent_turn_cwd
|
|
import routes.chat_routes as chat_routes
|
|
from src import tool_execution as _te
|
|
from src.agent_loop import _is_explicit_local_network_request
|
|
|
|
# Hold module-object references (not just from-imported names): other test
|
|
# modules re-import src.tool_execution via sys.modules pops, so string-target
|
|
# patches can land on a DIFFERENT fresh module object than the one these
|
|
# tests execute from. patch.object on the held reference always matches.
|
|
execute_tool_block = _te.execute_tool_block
|
|
_bridge_post = _te._bridge_post
|
|
_client_bridge = _te._client_bridge
|
|
_ROUTED_BRIDGE_TOOLS = _te._ROUTED_BRIDGE_TOOLS
|
|
|
|
|
|
async def _execute_tool_block_for_unit_tests(*args, **kwargs):
|
|
"""Use the explicit non-security-context test mode for dispatch tests."""
|
|
kwargs.setdefault("security_context", _te.NO_TOOL_SECURITY_CONTEXT)
|
|
return await _te.execute_tool_block(*args, **kwargs)
|
|
|
|
|
|
execute_tool_block = _execute_tool_block_for_unit_tests
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
BRIDGE_CTX = {
|
|
"surface": "odysseus-tui",
|
|
"session_cwd": "/home/pewds/project",
|
|
"host_shell_bridge": {"url": "http://127.0.0.1:47475/run", "token": "secret"},
|
|
}
|
|
|
|
|
|
def test_client_bridge_extracts_valid_bridge_only():
|
|
assert _client_bridge(BRIDGE_CTX)["token"] == "secret"
|
|
assert _client_bridge({"host_shell_bridge": BRIDGE_CTX["host_shell_bridge"]}) is None
|
|
assert _client_bridge({
|
|
"surface": "odysseus-tui",
|
|
"host_shell_bridge": {"url": "http://8.8.8.8:47475/run", "token": "x"},
|
|
}) is None
|
|
assert _client_bridge({"host_shell_bridge": {"url": "", "token": "x"}}) is None
|
|
assert _client_bridge({"host_shell_bridge": {"url": "http://x/run"}}) is None
|
|
assert _client_bridge(None) is None
|
|
assert _client_bridge({"host_shell_bridge": "nope"}) is None
|
|
|
|
|
|
def test_local_network_requests_are_distinct_from_web_search():
|
|
assert _is_explicit_local_network_request(
|
|
"Show the local IPv4 interfaces and default route"
|
|
)
|
|
assert _is_explicit_local_network_request(
|
|
"Find the LAN IP for ajax so I can SSH to it"
|
|
)
|
|
assert not _is_explicit_local_network_request(
|
|
"Search the web for the latest local network troubleshooting guide"
|
|
)
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
class _admin_gate:
|
|
"""Bypass the admin/single-user gates in tool dispatch for tests.
|
|
|
|
Production TUI turns carry the owner (admin on this deployment), so the
|
|
public-tool gate passes there; unit tests have no auth manager.
|
|
"""
|
|
|
|
def __enter__(self):
|
|
self._patch = patch.object(_te, "_owner_is_admin", lambda owner: True)
|
|
self._patch.start()
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
self._patch.stop()
|
|
return False
|
|
|
|
|
|
def test_routed_bash_sends_command_to_run_endpoint():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload, kwargs))
|
|
return {"stdout": "host-side", "stderr": "", "exit_code": 0, "cwd": "/home/pewds/project"}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="bash", content="pwd && ls"),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
path, payload, kwargs = calls[0]
|
|
assert path == "/run"
|
|
assert payload["command"] == "pwd && ls"
|
|
assert isinstance(payload["request_id"], str) and payload["request_id"]
|
|
assert kwargs["err_prefix"] == "bash"
|
|
assert result["stdout"] == "host-side"
|
|
assert desc.startswith("bash:")
|
|
|
|
|
|
def test_routed_python_uses_exec_argv_not_shell():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload))
|
|
return {"stdout": "", "stderr": "", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
_run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="python", content='print("hi")'),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
path, payload = calls[0]
|
|
assert path == "/run"
|
|
assert payload["exec"] == ["python3", "-I", "-c", 'print("hi")']
|
|
assert isinstance(payload["request_id"], str) and payload["request_id"]
|
|
assert "command" not in payload
|
|
|
|
|
|
def test_routed_read_file_uses_read_endpoint():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload))
|
|
return {"output": "file body", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="read_file", content="/home/pewds/project/main.py"),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
path, payload = calls[0]
|
|
assert path == "/read"
|
|
assert payload == {"path": "/home/pewds/project/main.py"}
|
|
assert result["output"] == "file body"
|
|
assert "read_file" in desc
|
|
|
|
|
|
def test_routed_read_file_preserves_json_line_range():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload))
|
|
return {"output": "selected lines", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(
|
|
tool_type="read_file",
|
|
content='{"path":"odysseus_tui/client.py","offset":55,"limit":40}',
|
|
),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
assert calls == [
|
|
(
|
|
"/read",
|
|
{"path": "odysseus_tui/client.py", "offset": 55, "limit": 40},
|
|
)
|
|
]
|
|
assert desc == "read_file: odysseus_tui/client.py"
|
|
assert result["output"] == "selected lines"
|
|
|
|
|
|
def test_routed_read_file_rejects_malformed_json_without_host_call():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return {"output": "unexpected", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="read_file", content='{"path":"broken"'),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
assert desc == "read_file: invalid arguments"
|
|
assert result["exit_code"] == 1
|
|
assert calls == []
|
|
|
|
|
|
def test_routed_read_file_rejects_empty_path_without_host_call():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return {"output": "unexpected", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="read_file", content=""),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
assert desc == "read_file: invalid arguments"
|
|
assert result["exit_code"] == 1
|
|
assert calls == []
|
|
|
|
|
|
def test_routed_read_file_rejects_invalid_line_range_without_host_call():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return {"output": "unexpected", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
for offset, limit in ((-1, 1), (0, -1), ("1", 2), (True, 2)):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(
|
|
tool_type="read_file",
|
|
content=json.dumps({"path": "main.py", "offset": offset, "limit": limit}),
|
|
),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
assert desc == "read_file: invalid arguments"
|
|
assert result["exit_code"] == 1
|
|
|
|
assert calls == []
|
|
|
|
|
|
def test_routed_read_file_rejects_non_string_or_multiline_path():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return {"output": "unexpected", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
for path in (123, ["main.py"], "main.py\nother.py"):
|
|
desc, result = _run(execute_tool_block(
|
|
SimpleNamespace(
|
|
tool_type="read_file",
|
|
content=json.dumps({"path": path}),
|
|
),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
))
|
|
assert desc == "read_file: invalid arguments"
|
|
assert result["exit_code"] == 1
|
|
|
|
assert calls == []
|
|
|
|
|
|
def test_routed_write_file_sends_base64_body(tmp_path):
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload))
|
|
return {"output": "wrote", "exit_code": 0}
|
|
|
|
target = tmp_path / "out.txt"
|
|
body = "line one\nline two with 'quotes' and $vars\n"
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
_run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="write_file", content=f"{target}\n{body}"),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
path, payload = calls[0]
|
|
assert path == "/write"
|
|
assert payload["path"] == str(target)
|
|
assert base64.b64decode(payload["content_b64"]).decode("utf-8") == body
|
|
assert re.fullmatch(r"[A-Za-z0-9_-]+", payload["request_id"])
|
|
|
|
|
|
def test_routed_write_file_decodes_native_json_arguments():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload))
|
|
return {"output": "wrote", "exit_code": 0}
|
|
|
|
body = "print('héllo')\n"
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(
|
|
tool_type="write_file",
|
|
content=json.dumps({"path": "src/demo.py", "content": body}),
|
|
),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
assert desc == "write_file: src/demo.py"
|
|
assert result["exit_code"] == 0
|
|
assert calls[0][0] == "/write"
|
|
assert calls[0][1]["path"] == "src/demo.py"
|
|
assert base64.b64decode(calls[0][1]["content_b64"]).decode("utf-8") == body
|
|
|
|
|
|
def test_routed_write_file_rejects_malformed_native_json_without_host_call():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return {"output": "unexpected", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="write_file", content='{"path":"broken"'),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
assert desc == "write_file: invalid arguments"
|
|
assert result["exit_code"] == 1
|
|
assert calls == []
|
|
|
|
|
|
def test_routed_write_file_rejects_non_text_content_without_host_call():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return {"output": "unexpected", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(
|
|
tool_type="write_file",
|
|
content=json.dumps({"path": "data.json", "content": {"value": 1}}),
|
|
),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
assert desc == "write_file: invalid arguments"
|
|
assert result == {"error": "write_file: content must be a string", "exit_code": 1}
|
|
assert calls == []
|
|
|
|
|
|
def test_routed_file_mutations_reject_non_string_or_multiline_path():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return {"output": "unexpected", "exit_code": 0}
|
|
|
|
cases = (
|
|
("write_file", {"path": 123, "content": "body"}),
|
|
("write_file", {"path": "a.txt\nb.txt", "content": "body"}),
|
|
("edit_file", {"path": ["a.txt"], "old_string": "a", "new_string": "b"}),
|
|
("edit_file", {"path": "a.txt\nb.txt", "old_string": "a", "new_string": "b"}),
|
|
)
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
for tool, args in cases:
|
|
desc, result = _run(execute_tool_block(
|
|
SimpleNamespace(tool_type=tool, content=json.dumps(args)),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
))
|
|
assert desc == f"{tool}: invalid arguments"
|
|
assert result["exit_code"] == 1
|
|
|
|
assert calls == []
|
|
|
|
|
|
def test_routed_bash_background_marker_detaches_on_client():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload))
|
|
return {"output": "detached", "exit_code": 0, "detached": True}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="bash", content="#!bg\nsleep 300"),
|
|
session_id="sid",
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
path, payload = calls[0]
|
|
assert path == "/run"
|
|
assert payload["detach"] is True
|
|
assert payload["command"] == "sleep 300"
|
|
assert result["output"] == "detached"
|
|
|
|
|
|
def test_routed_implicit_long_bash_is_detached_and_polled():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append(payload)
|
|
if len(calls) == 1:
|
|
return {"status": "running", "job_id": "job-implicit", "output": "started"}
|
|
return {"status": "done", "job_id": "job-implicit", "output": "finished", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
_, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="bash", content="sleep 22; printf done"),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
assert calls[0]["detach"] is True
|
|
assert calls[0]["command"] == "sleep 22; printf done"
|
|
assert calls[1] == {"job_id": "job-implicit"}
|
|
assert result["output"] == "finished"
|
|
|
|
|
|
def test_no_bridge_falls_back_to_backend_execution():
|
|
async def fake_mcp(tool, content, progress_cb=None, **kwargs):
|
|
return {"output": f"backend-side {tool}", "exit_code": 0}
|
|
|
|
with patch.object(_te, "_owner_is_admin", lambda owner: True), \
|
|
patch.object(_te, "_call_mcp_tool", fake_mcp):
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="bash", content="pwd"),
|
|
client_runtime_context={"surface": "webui"},
|
|
)
|
|
)
|
|
|
|
assert result["output"] == "backend-side bash"
|
|
|
|
|
|
def test_disabled_tool_still_blocked_with_bridge_present():
|
|
with _admin_gate():
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="bash", content="pwd"),
|
|
disabled_tools={"bash"},
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
)
|
|
)
|
|
|
|
assert "BLOCKED" in desc
|
|
assert result["exit_code"] == 1
|
|
|
|
|
|
def test_routed_tools_set_matches_computer_tools():
|
|
assert _ROUTED_BRIDGE_TOOLS == {
|
|
"bash", "python", "grep", "ls", "glob", "list_dir", "find_files",
|
|
"read_file", "write_file", "edit_file",
|
|
}
|
|
|
|
|
|
def test_routed_list_dir_preserves_range_and_uses_list_endpoint():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload, kwargs))
|
|
return {"output": "src\ntests", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(execute_tool_block(
|
|
SimpleNamespace(
|
|
tool_type="list_dir",
|
|
content='{"path":"src","offset":2,"limit":10}',
|
|
),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
))
|
|
|
|
assert desc == "list_dir: src"
|
|
assert calls[0][0] == "/list"
|
|
assert calls[0][1] == {
|
|
"path": "src", "offset": 2, "limit": 10, "recursive": True,
|
|
}
|
|
assert result["exit_code"] == 0
|
|
|
|
|
|
def test_routed_find_files_uses_find_endpoint():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload, kwargs))
|
|
return {"output": "src/main.py", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(execute_tool_block(
|
|
SimpleNamespace(
|
|
tool_type="find_files",
|
|
content='{"pattern":"\\\\.py$","path":"src"}',
|
|
),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
))
|
|
|
|
assert desc == "find_files: \\.py$"
|
|
assert calls[0][0] == "/find"
|
|
assert calls[0][1] == {"pattern": "\\.py$", "path": "src"}
|
|
assert result["exit_code"] == 0
|
|
|
|
|
|
def test_canonical_ls_and_glob_route_to_tui_discovery_endpoints():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload))
|
|
return {"output": "ok", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
ls_desc, _ = _run(execute_tool_block(
|
|
SimpleNamespace(tool_type="ls", content='{"path":"src"}'),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
))
|
|
glob_desc, _ = _run(execute_tool_block(
|
|
SimpleNamespace(tool_type="glob", content='{"pattern":"**/*.py","path":"src"}'),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
))
|
|
|
|
assert ls_desc == "ls: src"
|
|
assert calls[0] == (
|
|
"/list",
|
|
{"path": "src", "offset": 0, "limit": 0, "recursive": False},
|
|
)
|
|
assert glob_desc == "glob: **/*.py"
|
|
assert calls[1][0] == "/find"
|
|
assert calls[1][1] == {"path": "src", "glob": "**/*.py"}
|
|
|
|
|
|
def test_canonical_grep_routes_structured_search_to_tui_host():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(bridge, path, payload, **kwargs):
|
|
calls.append((path, payload))
|
|
return {"output": "src/main.py:1:needle", "exit_code": 0}
|
|
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
desc, result = _run(execute_tool_block(
|
|
SimpleNamespace(
|
|
tool_type="grep",
|
|
content=(
|
|
'{"pattern":"needle","path":"src","glob":"**/*.py",'
|
|
'"ignore_case":true,"max_results":25}'
|
|
),
|
|
),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
))
|
|
|
|
assert desc == "grep: needle"
|
|
assert calls == [("/grep", {
|
|
"pattern": "needle", "path": "src", "glob": "**/*.py",
|
|
"ignore_case": True, "max_results": 25,
|
|
})]
|
|
assert result["exit_code"] == 0
|
|
|
|
|
|
def test_routed_discovery_tools_reject_malformed_arguments_without_host_call():
|
|
calls = []
|
|
|
|
async def fake_bridge_post(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return {"output": "unexpected", "exit_code": 0}
|
|
|
|
cases = (
|
|
("list_dir", {"path": 123}),
|
|
("list_dir", {"path": ".", "offset": "2"}),
|
|
("find_files", {"pattern": ""}),
|
|
("find_files", {"pattern": ["py"], "path": "."}),
|
|
("grep", {"pattern": "needle", "ignore_case": "yes"}),
|
|
("grep", {"pattern": "needle", "max_results": 0}),
|
|
("grep", {"pattern": ["needle"]}),
|
|
)
|
|
with _admin_gate(), patch.object(_te, "_bridge_post", fake_bridge_post):
|
|
for tool, args in cases:
|
|
desc, result = _run(execute_tool_block(
|
|
SimpleNamespace(tool_type=tool, content=json.dumps(args)),
|
|
client_runtime_context=dict(BRIDGE_CTX),
|
|
))
|
|
assert desc == f"{tool}: invalid arguments"
|
|
assert result["exit_code"] == 1
|
|
|
|
assert calls == []
|
|
|
|
|
|
def test_agent_turn_cwd_prefers_raw_host_cwd_when_bridge_advertised():
|
|
sess = SimpleNamespace(cwd="/workspace/project") # translated container path
|
|
assert _agent_turn_cwd(sess, dict(BRIDGE_CTX)) == "/home/pewds/project"
|
|
|
|
|
|
def test_agent_turn_cwd_keeps_session_cwd_without_bridge():
|
|
sess = SimpleNamespace(cwd="/workspace/project")
|
|
ctx = {"surface": "odysseus-tui", "session_cwd": "/home/pewds/project"}
|
|
assert _agent_turn_cwd(sess, ctx) == "/workspace/project"
|
|
assert _agent_turn_cwd(sess, {"surface": "webui"}) == "/workspace/project"
|
|
|
|
|
|
def test_bridge_post_targets_endpoint_paths():
|
|
class FakeResp:
|
|
status_code = 200
|
|
|
|
def json(self):
|
|
return {"stdout": "ok", "exit_code": 0}
|
|
|
|
text = ""
|
|
|
|
posted = {}
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout=None):
|
|
posted["timeout"] = timeout
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def post(self, url, headers=None, json=None):
|
|
posted["url"] = url
|
|
posted["headers"] = headers
|
|
posted["json"] = json
|
|
return FakeResp()
|
|
|
|
import httpx
|
|
|
|
with patch.object(httpx, "AsyncClient", FakeClient):
|
|
result = _run(
|
|
_bridge_post(
|
|
{"url": "http://127.0.0.1:47475/run", "token": "secret"},
|
|
"/write",
|
|
{"path": "/tmp/x", "content_b64": "aGk="},
|
|
timeout_s=30.0,
|
|
err_prefix="write_file",
|
|
)
|
|
)
|
|
|
|
assert posted["url"] == "http://127.0.0.1:47475/write"
|
|
assert posted["headers"] == {"x-odysseus-tui-bridge-token": "secret"}
|
|
assert result["exit_code"] == 0
|
|
assert posted["timeout"].connect == 5.0
|
|
assert posted["timeout"].write == 10.0
|
|
assert posted["timeout"].read == 35.0
|
|
|
|
|
|
def test_bridge_post_rejects_untrusted_url_without_network_call():
|
|
class UnexpectedClient:
|
|
def __init__(self, *args, **kwargs):
|
|
raise AssertionError("invalid bridge URL must not reach the network")
|
|
|
|
import httpx
|
|
|
|
with patch.object(httpx, "AsyncClient", UnexpectedClient):
|
|
result = _run(
|
|
_bridge_post(
|
|
{"url": "http://8.8.8.8:47475/run", "token": "secret"},
|
|
"/run",
|
|
{"command": "true"},
|
|
timeout_s=30.0,
|
|
err_prefix="bash",
|
|
)
|
|
)
|
|
|
|
assert result == {"error": "bash: invalid TUI host bridge", "exit_code": 1}
|
|
|
|
|
|
def test_bridge_post_treats_empty_or_error_payload_as_failure():
|
|
class FakeResp:
|
|
status_code = 200
|
|
text = ""
|
|
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
def json(self):
|
|
return self.payload
|
|
|
|
payloads = iter(({}, {"error": "token rejected"}))
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout=None):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def post(self, *args, **kwargs):
|
|
return FakeResp(next(payloads))
|
|
|
|
import httpx
|
|
|
|
with patch.object(httpx, "AsyncClient", FakeClient):
|
|
empty = _run(_bridge_post(BRIDGE_CTX["host_shell_bridge"], "/run", {}, timeout_s=1, err_prefix="bash"))
|
|
errored = _run(_bridge_post(BRIDGE_CTX["host_shell_bridge"], "/run", {}, timeout_s=1, err_prefix="bash"))
|
|
|
|
assert empty == {"error": "bash: bridge returned an empty response", "exit_code": 1}
|
|
assert errored == {"error": "token rejected", "exit_code": 1}
|
|
|
|
|
|
def test_bridge_post_http_or_payload_error_overrides_false_success_code():
|
|
class FakeResp:
|
|
text = ""
|
|
|
|
def __init__(self, status_code, payload):
|
|
self.status_code = status_code
|
|
self.payload = payload
|
|
|
|
def json(self):
|
|
return self.payload
|
|
|
|
responses = iter((
|
|
FakeResp(401, {"error": "token rejected", "exit_code": 0}),
|
|
FakeResp(200, {"error": "host failed", "exit_code": 0}),
|
|
))
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout=None):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def post(self, *args, **kwargs):
|
|
return next(responses)
|
|
|
|
import httpx
|
|
|
|
with patch.object(httpx, "AsyncClient", FakeClient):
|
|
unauthorized = _run(_bridge_post(
|
|
BRIDGE_CTX["host_shell_bridge"], "/run", {}, timeout_s=1, err_prefix="bash"
|
|
))
|
|
host_error = _run(_bridge_post(
|
|
BRIDGE_CTX["host_shell_bridge"], "/run", {}, timeout_s=1, err_prefix="bash"
|
|
))
|
|
|
|
assert unauthorized["exit_code"] == 1
|
|
assert host_error["exit_code"] == 1
|
|
|
|
|
|
def test_bridge_post_rejects_malformed_exit_code():
|
|
class FakeResp:
|
|
status_code = 200
|
|
text = ""
|
|
|
|
def json(self):
|
|
return {"output": "looks successful", "exit_code": "zero"}
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout=None):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def post(self, *args, **kwargs):
|
|
return FakeResp()
|
|
|
|
import httpx
|
|
|
|
with patch.object(httpx, "AsyncClient", FakeClient):
|
|
result = _run(_bridge_post(
|
|
BRIDGE_CTX["host_shell_bridge"], "/run", {}, timeout_s=1,
|
|
err_prefix="bash",
|
|
))
|
|
|
|
assert result == {
|
|
"error": "bash: bridge returned an invalid exit_code",
|
|
"exit_code": 1,
|
|
}
|
|
|
|
|
|
def test_bridge_post_reports_empty_invalid_json_response():
|
|
class FakeResp:
|
|
status_code = 200
|
|
text = ""
|
|
|
|
def json(self):
|
|
raise ValueError("not JSON")
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout=None):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def post(self, *args, **kwargs):
|
|
return FakeResp()
|
|
|
|
import httpx
|
|
|
|
with patch.object(httpx, "AsyncClient", FakeClient):
|
|
result = _run(_bridge_post(
|
|
BRIDGE_CTX["host_shell_bridge"], "/run", {}, timeout_s=1,
|
|
err_prefix="bash",
|
|
))
|
|
|
|
assert result == {
|
|
"error": "bash: bridge returned invalid JSON",
|
|
"exit_code": 1,
|
|
}
|
|
|
|
|
|
def test_bridge_post_cancellation_schedules_host_operation_cancel():
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout=None):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def post(self, *args, **kwargs):
|
|
self.post_entered.set()
|
|
await asyncio.Event().wait()
|
|
|
|
async def exercise(path):
|
|
import httpx
|
|
|
|
entered = asyncio.Event()
|
|
FakeClient.post_entered = entered
|
|
cancel_request = AsyncMock()
|
|
with patch.object(httpx, "AsyncClient", FakeClient), patch.object(
|
|
_te, "_cancel_bridge_request", cancel_request
|
|
):
|
|
task = asyncio.create_task(_bridge_post(
|
|
BRIDGE_CTX["host_shell_bridge"],
|
|
path,
|
|
{"command": "sleep 900", "request_id": "request-123"},
|
|
timeout_s=900,
|
|
err_prefix="bash",
|
|
))
|
|
await entered.wait()
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
await asyncio.sleep(0)
|
|
cancel_request.assert_awaited_once_with(
|
|
BRIDGE_CTX["host_shell_bridge"], "request-123"
|
|
)
|
|
|
|
for path in ("/run", "/write", "/edit"):
|
|
_run(exercise(path))
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# chat_routes wiring — source-inspection style (matches dev-tree tests)
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
def test_chat_stream_parses_and_injects_tui_runtime_context():
|
|
source = (ROOT / "routes" / "chat_routes.py").read_text(encoding="utf-8")
|
|
tree = ast.parse(source)
|
|
chat_stream = next(
|
|
node for node in ast.walk(tree)
|
|
if isinstance(node, ast.AsyncFunctionDef) and node.name == "chat_stream"
|
|
)
|
|
chat_stream_source = ast.get_source_segment(source, chat_stream)
|
|
|
|
assert "_parse_client_runtime_context(" in chat_stream_source
|
|
assert 'form_data.get("client_runtime_context")' in chat_stream_source
|
|
assert "_client_runtime_context_system_message(" in chat_stream_source
|
|
assert "disabled_tools=disabled_tools" in chat_stream_source
|
|
assert "ctx.messages.insert(0, runtime_msg)" in chat_stream_source
|
|
assert "cwd=_agent_turn_cwd(sess, client_runtime_context)" in chat_stream_source
|
|
assert "client_runtime_context=client_runtime_context" in chat_stream_source
|
|
assert "_stream_agent_with_execution_bridge(" in chat_stream_source
|
|
assert "_external_execution_bridge(client_runtime_context)" in chat_stream_source
|
|
|
|
|
|
def test_native_runtime_context_accepts_only_loopback_execution_bridge():
|
|
base = {
|
|
"surface": "odysseus-native",
|
|
"terminal_agent": True,
|
|
"interaction_mode": "cook",
|
|
"unattended_mode": True,
|
|
}
|
|
valid = chat_routes._parse_client_runtime_context({
|
|
**base,
|
|
"external_execution_bridge": {
|
|
"url": "http://127.0.0.1:47123/execute",
|
|
"token": "a-secure-request-token",
|
|
"supported_tools": ["send_email", "send_email", "bad tool"],
|
|
},
|
|
})
|
|
assert valid["external_execution_bridge"] == {
|
|
"url": "http://127.0.0.1:47123/execute",
|
|
"token": "a-secure-request-token",
|
|
"supported_tools": ["send_email"],
|
|
}
|
|
|
|
for url in (
|
|
"https://127.0.0.1:47123/execute",
|
|
"http://example.com:47123/execute",
|
|
"http://127.0.0.1:47123/",
|
|
):
|
|
parsed = chat_routes._parse_client_runtime_context({
|
|
**base,
|
|
"external_execution_bridge": {
|
|
"url": url,
|
|
"token": "a-secure-request-token",
|
|
"supported_tools": ["send_email"],
|
|
},
|
|
})
|
|
assert "external_execution_bridge" not in parsed
|
|
|
|
|
|
def test_chat_stream_restores_persisted_workspace_for_resumed_sessions():
|
|
source = Path(chat_routes.__file__).read_text()
|
|
tree = ast.parse(source)
|
|
chat_stream = next(
|
|
node for node in ast.walk(tree)
|
|
if isinstance(node, ast.AsyncFunctionDef) and node.name == "chat_stream"
|
|
)
|
|
chat_stream_source = ast.get_source_segment(source, chat_stream)
|
|
assert "_resolve_persisted_session_workspace(" in chat_stream_source
|
|
assert "current_workspace=workspace" in chat_stream_source
|
|
assert "current_rejected=workspace_rejected" in chat_stream_source
|
|
|
|
|
|
def test_context_info_advertises_workspace_and_tool_policy():
|
|
source = (ROOT / "routes" / "session_routes.py").read_text(encoding="utf-8")
|
|
tree = ast.parse(source)
|
|
context_info = next(
|
|
node for node in ast.walk(tree)
|
|
if isinstance(node, ast.AsyncFunctionDef) and node.name == "get_context_info"
|
|
)
|
|
fn_source = ast.get_source_segment(source, context_info)
|
|
|
|
assert "workspace_mount_pairs" in fn_source
|
|
assert "backend_workspace_path" in fn_source
|
|
assert '"tool_policy"' in fn_source
|
|
assert '"computer_tools"' in fn_source
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# runtime-context sanitization (helpers ported verbatim from dev tree)
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
def test_runtime_context_preserves_sanitized_active_skills():
|
|
from routes.chat_routes import (
|
|
_client_runtime_context_system_message,
|
|
_parse_legacy_client_runtime_context as _parse_client_runtime_context,
|
|
)
|
|
|
|
context = _parse_client_runtime_context(
|
|
{
|
|
"surface": "odysseus-tui",
|
|
"session_cwd": "/home/pewds/odysseus-tui",
|
|
"active_skills": ["tdd", "`openai-docs`", "../bad", "tdd"],
|
|
"active_skill_details": [
|
|
{
|
|
"name": "tdd",
|
|
"description": " Red-green-refactor\nworkflow ",
|
|
"source": "file: /home/pewds/.codex/skills/tdd/SKILL.md",
|
|
},
|
|
{"name": "../bad", "description": "drop me"},
|
|
],
|
|
}
|
|
)
|
|
|
|
assert context["active_skills"] == ["tdd", "openai-docs"]
|
|
assert context["session_cwd"] == "/home/pewds/odysseus-tui"
|
|
assert context["active_skill_details"] == [
|
|
{
|
|
"name": "tdd",
|
|
"description": "Red-green-refactor workflow",
|
|
"source": "file: /home/pewds/.codex/skills/tdd/SKILL.md",
|
|
}
|
|
]
|
|
message = _client_runtime_context_system_message(context)
|
|
assert message is not None
|
|
assert "session_cwd: /home/pewds/odysseus-tui" in message["content"]
|
|
assert "active_skills: tdd, openai-docs" in message["content"]
|
|
assert "tdd: Red-green-refactor workflow" in message["content"]
|
|
|
|
|
|
def test_tui_agent_round_cap_still_bounds_non_coding_turns():
|
|
from routes.chat_routes import _effective_agent_rounds
|
|
|
|
assert _effective_agent_rounds(100, {"surface": "odysseus-tui"}, 20) == 20
|
|
assert _effective_agent_rounds("bad", {"surface": "odysseus-tui"}, 20) == 20
|
|
assert _effective_agent_rounds(100, {"surface": "webui"}, 20) == 100
|
|
|
|
|
|
def test_runtime_context_rejects_non_tui_surface_and_accepts_valid_tui_bridge():
|
|
from routes.chat_routes import _parse_client_runtime_context
|
|
|
|
assert _parse_client_runtime_context({"surface": "webui", "host_shell_bridge": {"url": "http://127.0.0.1:1/run", "token": "x"}}) == {}
|
|
deceptive = _parse_client_runtime_context({
|
|
"surface": "odysseus-tui",
|
|
"host_shell_bridge": {
|
|
"url": "http://127.0.0.1:80@8.8.8.8:47475/run",
|
|
"token": "x",
|
|
},
|
|
})
|
|
assert "host_shell_bridge" not in deceptive
|
|
context = _parse_client_runtime_context(
|
|
{
|
|
"surface": "odysseus-tui",
|
|
"host_shell_bridge": {"url": "http://192.168.1.50:47475/run", "token": "x"},
|
|
"unattended_mode": True,
|
|
"active_skills": ["tdd"],
|
|
"client_tools": [{"name": "host_shell"}, {"name": "not_a_tool"}],
|
|
}
|
|
)
|
|
assert context["host_shell_bridge"] == {
|
|
"url": "http://192.168.1.50:47475/run",
|
|
"token": "x",
|
|
}
|
|
assert context["client_tools"] == [{"name": "host_shell"}]
|
|
assert context["active_skills"] == ["tdd"]
|
|
|
|
|
|
def test_agent_runtime_context_can_omit_directives_when_agent_loop_renders_them():
|
|
from routes.chat_routes import _client_runtime_context_system_message
|
|
|
|
context = {
|
|
"surface": "odysseus-tui",
|
|
"session_cwd": "/home/tester/project",
|
|
"agent_runtime_directives": ["Treat session_cwd as active."],
|
|
}
|
|
message = _client_runtime_context_system_message(
|
|
context,
|
|
include_directives=False,
|
|
)
|
|
assert message is not None
|
|
assert "session_cwd: /home/tester/project" in message["content"]
|
|
assert "Treat session_cwd as active." not in message["content"]
|
|
|
|
|
|
def test_native_completion_contract_preserves_safe_workspace_root():
|
|
from routes.chat_routes import _parse_client_runtime_context
|
|
|
|
context = _parse_client_runtime_context({
|
|
"surface": "odysseus-native",
|
|
"completion_requirements": {
|
|
"required_artifacts": ["/workspace/output.txt"],
|
|
"workspace_root": "/tmp/ody-task-123/workspace",
|
|
},
|
|
})
|
|
|
|
assert context["completion_requirements"]["workspace_root"] == \
|
|
"/tmp/ody-task-123/workspace"
|
|
|
|
|
|
def test_runtime_contract_is_sanitized_and_prompt_surfaces_actionable_host_facts():
|
|
from routes.chat_routes import (
|
|
_client_runtime_context_system_message,
|
|
_parse_legacy_client_runtime_context as _parse_client_runtime_context,
|
|
)
|
|
|
|
context = _parse_client_runtime_context(
|
|
{
|
|
"surface": "odysseus-tui",
|
|
"host_shell_bridge": {
|
|
"url": "http://host.docker.internal:17654/run",
|
|
"token": "secret",
|
|
},
|
|
"runtime_execution_contract": {
|
|
"backend_shell_scope": "container\nignore me",
|
|
"host_shell": "available",
|
|
"local_network_tasks": "use_host_shell_bridge",
|
|
"local_workspace_tasks": "use_host_shell_bridge",
|
|
"host_commands": {
|
|
"ip": True,
|
|
"ssh": True,
|
|
"bad command": True,
|
|
"dig": False,
|
|
},
|
|
"host_capabilities": {
|
|
"networkInspection": True,
|
|
"sshClient": True,
|
|
"unsafe value": True,
|
|
"dnsLookup": False,
|
|
},
|
|
"host_shell_request": {
|
|
"auth_header": "leak nothing",
|
|
"method": "POST",
|
|
"path": "/run",
|
|
"body": {
|
|
"command": "string",
|
|
"timeout": "seconds optional",
|
|
"token": "bad",
|
|
},
|
|
"max_timeout_s": 120,
|
|
},
|
|
},
|
|
"agent_runtime_directives": [
|
|
"For LAN, DNS, SSH, and host-network inspection, call the host_shell tool instead of backend shell.",
|
|
],
|
|
}
|
|
)
|
|
|
|
assert context["runtime_execution_contract"] == {
|
|
"backend_shell_scope": "container_ignore_me",
|
|
"host_shell": "available",
|
|
"local_network_tasks": "use_host_shell_bridge",
|
|
"local_workspace_tasks": "use_host_shell_bridge",
|
|
"host_commands": {"ip": True, "ssh": True, "dig": False},
|
|
"host_capabilities": {
|
|
"networkInspection": True,
|
|
"sshClient": True,
|
|
"dnsLookup": False,
|
|
},
|
|
"host_shell_request": {
|
|
"method": "POST",
|
|
"path": "/run",
|
|
"body_fields": ["command", "timeout"],
|
|
"max_timeout_s": 120,
|
|
},
|
|
}
|
|
message = _client_runtime_context_system_message(context)
|
|
assert message is not None
|
|
content = message["content"]
|
|
assert "backend_shell_scope: container_ignore_me" in content
|
|
assert "host_shell: available" in content
|
|
assert "host_commands: ip, ssh" in content
|
|
assert "host_capabilities: networkInspection, sshClient" in content
|
|
assert "host_shell_request: POST /run; body fields: command, timeout; max_timeout_s: 120" in content
|
|
assert "on the USER's machine at session_cwd" not in content
|
|
assert "secret" not in content
|
|
assert "auth_header" not in content
|
|
assert "bad command" not in content
|
|
|
|
|
|
def test_tui_runtime_context_cwd_is_sanitized_and_surface_scoped(monkeypatch):
|
|
from routes.chat_routes import (
|
|
_client_runtime_context_cwd,
|
|
_parse_client_runtime_context,
|
|
)
|
|
|
|
context = _parse_client_runtime_context(
|
|
{
|
|
"surface": "odysseus-tui",
|
|
"session_cwd": "/repo\nmalicious",
|
|
}
|
|
)
|
|
|
|
assert "session_cwd" not in context
|
|
assert _client_runtime_context_cwd(context) == ""
|
|
assert _client_runtime_context_cwd({"surface": "webui", "session_cwd": "/repo"}) == ""
|
|
|
|
|
|
def test_backend_workspace_path_maps_host_root_to_container_root(monkeypatch):
|
|
from src.workspace_paths import backend_workspace_path
|
|
|
|
monkeypatch.setenv("ODYSSEUS_WORKSPACE_HOST_ROOT", "/home/alice")
|
|
monkeypatch.setenv("ODYSSEUS_WORKSPACE_CONTAINER_ROOT", "/workspace")
|
|
|
|
assert backend_workspace_path("/home/alice/project") == "/workspace/project"
|
|
|
|
|
|
def test_backend_workspace_path_keeps_unmapped_paths(monkeypatch):
|
|
from src.workspace_paths import backend_workspace_path
|
|
|
|
monkeypatch.delenv("ODYSSEUS_WORKSPACE_HOST_ROOT", raising=False)
|
|
monkeypatch.delenv("ODYSSEUS_WORKSPACE_CONTAINER_ROOT", raising=False)
|
|
monkeypatch.delenv("ODYSSEUS_WORKSPACE_MOUNTS", raising=False)
|
|
|
|
assert backend_workspace_path("/already/container") == "/already/container"
|
|
|
|
|
|
def test_backend_workspace_path_uses_longest_mapping(monkeypatch):
|
|
from src.workspace_paths import backend_workspace_path
|
|
|
|
monkeypatch.setenv(
|
|
"ODYSSEUS_WORKSPACE_MOUNTS",
|
|
"/home/alice=/workspace,/home/alice/code=/code",
|
|
)
|
|
monkeypatch.delenv("ODYSSEUS_WORKSPACE_HOST_ROOT", raising=False)
|
|
|
|
assert backend_workspace_path("/home/alice/code/app") == "/code/app"
|
|
|
|
|
|
def test_host_shell_bridge_url_allowlist_accepts_private_lan_and_tailscale():
|
|
from src.agent_tools.subprocess_tools import is_host_shell_bridge_url_allowed
|
|
|
|
assert is_host_shell_bridge_url_allowed("http://127.0.0.1:47475/run")
|
|
assert is_host_shell_bridge_url_allowed("http://localhost:47475/run")
|
|
assert is_host_shell_bridge_url_allowed("http://10.1.2.3:47475/run")
|
|
assert is_host_shell_bridge_url_allowed("http://192.168.1.21:47475/run")
|
|
assert is_host_shell_bridge_url_allowed("http://100.101.102.103:47475/run")
|
|
# 172.16/12 is docker-bridge territory: only the actual default gateway
|
|
# is trusted (covered by _docker_default_gateway_ips), not neighbors.
|
|
assert not is_host_shell_bridge_url_allowed("http://172.20.0.2:47475/run")
|
|
# Rejections: public hosts, wrong scheme, credentials, wrong path, query.
|
|
assert not is_host_shell_bridge_url_allowed("http://8.8.8.8:47475/run")
|
|
assert not is_host_shell_bridge_url_allowed("https://127.0.0.1:47475/run")
|
|
assert not is_host_shell_bridge_url_allowed("http://user:pass@127.0.0.1:47475/run")
|
|
assert not is_host_shell_bridge_url_allowed("http://127.0.0.1:47475/evil")
|
|
assert not is_host_shell_bridge_url_allowed("http://127.0.0.1:47475/run?x=1")
|
|
assert not is_host_shell_bridge_url_allowed("")
|
|
|
|
|
|
def test_host_shell_requires_bridge_context():
|
|
with _admin_gate():
|
|
desc, result = _run(
|
|
execute_tool_block(
|
|
SimpleNamespace(tool_type="host_shell", content="ip neigh"),
|
|
client_runtime_context={},
|
|
)
|
|
)
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "bridge" in str(result.get("error", "")).lower()
|