mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-18 22:22:21 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
"""Task- and chat-scoped approval continuation coverage for issue #6112."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import src.tool_capabilities as tool_capabilities
|
||||
from core.models import ChatMessage, Session
|
||||
from src.tool_approval_scopes import (
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||
ToolApprovalScope,
|
||||
)
|
||||
from src.tool_approvals import ExactToolApproval, ToolApprovalStore
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_approval_gate_for_legacy_gate_tests(monkeypatch):
|
||||
monkeypatch.setattr(tool_capabilities, "TOOL_APPROVAL_GATE_ENABLED", True)
|
||||
|
||||
|
||||
def _pending(
|
||||
store: ToolApprovalStore,
|
||||
*,
|
||||
selected_tools=None,
|
||||
continuation_query="inspect the project using memory and skills",
|
||||
):
|
||||
content = "printf exact"
|
||||
return store.create(
|
||||
owner="Alice",
|
||||
session_id="session-1",
|
||||
origin_run_id="run-1",
|
||||
tool_name="bash",
|
||||
content=content,
|
||||
workspace=None,
|
||||
external_untrusted_context_seen=True,
|
||||
selected_tools=selected_tools,
|
||||
continuation_query=continuation_query,
|
||||
capabilities=capabilities_for_action("bash", content),
|
||||
)
|
||||
|
||||
|
||||
def test_card_offers_task_chat_session_and_deny_without_leaking_private_state():
|
||||
pending = _pending(
|
||||
ToolApprovalStore(),
|
||||
selected_tools=["manage_skills", "bash", "manage_skills"],
|
||||
)
|
||||
|
||||
payload = pending.public_payload()
|
||||
|
||||
assert payload["session_id"] == "session-1"
|
||||
assert [option["value"] for option in payload["options"]] == [
|
||||
"approve_task",
|
||||
"approve",
|
||||
"deny",
|
||||
]
|
||||
assert [option["label"] for option in payload["options"]] == [
|
||||
"Allow for this task",
|
||||
"Allow for this chat session",
|
||||
"Deny",
|
||||
]
|
||||
serialized = json.dumps(payload, sort_keys=True)
|
||||
assert "Allow once" not in serialized
|
||||
assert "selected_tools" not in serialized
|
||||
assert "continuation_query" not in serialized
|
||||
assert "manage_skills" not in serialized
|
||||
assert "inspect the project" not in serialized
|
||||
|
||||
|
||||
def test_allow_for_task_bypasses_only_the_resumed_run_gate():
|
||||
store = ToolApprovalStore()
|
||||
pending = _pending(store, selected_tools=["bash", "manage_skills"])
|
||||
grant = store.consume(
|
||||
pending.approval_id,
|
||||
decision="approve_task",
|
||||
owner="alice",
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
assert grant is not None
|
||||
assert grant.scope is ToolApprovalScope.TASK
|
||||
assert grant.allow_remaining_actions is True
|
||||
assert grant.grants_chat_session is False
|
||||
assert grant.pending.continuation_query == (
|
||||
"inspect the project using memory and skills"
|
||||
)
|
||||
|
||||
resumed = ToolRunSecurityContext(
|
||||
external_untrusted_context_seen=True,
|
||||
approval_gate_bypassed=grant.allow_remaining_actions,
|
||||
)
|
||||
assert resumed.decision_for("bash").allowed is True
|
||||
|
||||
# A new ordinary user turn constructs a fresh context and asks again.
|
||||
fresh = ToolRunSecurityContext(external_untrusted_context_seen=True)
|
||||
assert fresh.decision_for("bash").allowed is False
|
||||
|
||||
|
||||
def test_allow_for_chat_session_applies_to_later_turns_in_only_that_chat():
|
||||
store = ToolApprovalStore()
|
||||
pending = _pending(store, selected_tools=["bash", "manage_skills"])
|
||||
grant = store.consume(
|
||||
pending.approval_id,
|
||||
decision="approve",
|
||||
owner="alice",
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
assert grant is not None
|
||||
assert grant.scope is ToolApprovalScope.CHAT_SESSION
|
||||
assert grant.allow_remaining_actions is True
|
||||
assert grant.grants_chat_session is True
|
||||
assert grant.pending.selected_tools == ("bash", "manage_skills")
|
||||
assert grant.pending.continuation_query.startswith("inspect the project")
|
||||
|
||||
resolved_card = pending.public_payload()
|
||||
resolved_card["resolved"] = "approve"
|
||||
history = [
|
||||
ChatMessage(
|
||||
"assistant",
|
||||
"approval requested",
|
||||
{"tool_events": [{"ask_user": resolved_card}]},
|
||||
),
|
||||
ChatMessage("user", "continue the work"),
|
||||
]
|
||||
session = Session(
|
||||
id="session-1",
|
||||
name="Chat",
|
||||
endpoint_url="http://example.invalid",
|
||||
model="test",
|
||||
history=history,
|
||||
)
|
||||
|
||||
messages = session.get_context_messages()
|
||||
assert messages[-1]["metadata"][CHAT_SESSION_APPROVAL_CONTEXT_MARKER] is True
|
||||
assert history[-1].metadata is None
|
||||
|
||||
future_turn = ToolRunSecurityContext(external_untrusted_context_seen=True)
|
||||
future_turn.observe_messages(messages)
|
||||
assert future_turn.approval_gate_bypassed is True
|
||||
assert future_turn.decision_for("bash").allowed is True
|
||||
|
||||
# The persisted card is bound to its original chat id, so a fork/copy does
|
||||
# not inherit the grant merely by copying transcript metadata.
|
||||
other_session = Session(
|
||||
id="session-2",
|
||||
name="Fork",
|
||||
endpoint_url="http://example.invalid",
|
||||
model="test",
|
||||
history=history,
|
||||
)
|
||||
other_messages = other_session.get_context_messages()
|
||||
assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
|
||||
other_messages[-1].get("metadata") or {}
|
||||
)
|
||||
other_turn = ToolRunSecurityContext(external_untrusted_context_seen=True)
|
||||
other_turn.observe_messages(other_messages)
|
||||
assert other_turn.decision_for("bash").allowed is False
|
||||
|
||||
|
||||
def test_deny_executes_nothing_and_grants_no_task_or_chat_scope():
|
||||
store = ToolApprovalStore()
|
||||
pending = _pending(store)
|
||||
|
||||
assert store.consume(
|
||||
pending.approval_id,
|
||||
decision="deny",
|
||||
owner="alice",
|
||||
session_id="session-1",
|
||||
) is None
|
||||
assert store.peek(pending.approval_id) is None
|
||||
|
||||
denied_card = pending.public_payload()
|
||||
denied_card["resolved"] = "deny"
|
||||
session = Session(
|
||||
id="session-1",
|
||||
name="Chat",
|
||||
endpoint_url="http://example.invalid",
|
||||
model="test",
|
||||
history=[
|
||||
ChatMessage(
|
||||
"assistant",
|
||||
"approval requested",
|
||||
{"tool_events": [{"ask_user": denied_card}]},
|
||||
),
|
||||
ChatMessage("user", "another request"),
|
||||
],
|
||||
)
|
||||
messages = session.get_context_messages()
|
||||
assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
|
||||
messages[-1].get("metadata") or {}
|
||||
)
|
||||
|
||||
|
||||
def test_private_continuation_state_is_canonical_bounded_and_digest_bound():
|
||||
selected_tools = ["manage_skills", "bash", "manage_skills", "", 7]
|
||||
selected_tools.extend(f"tool_{index:04d}" for index in range(600))
|
||||
selected_tools.append("x" * 513)
|
||||
pending = _pending(
|
||||
ToolApprovalStore(),
|
||||
selected_tools=selected_tools,
|
||||
continuation_query=" " + ("original request " * 500),
|
||||
)
|
||||
assert pending.selected_tools[:2] == ("bash", "manage_skills")
|
||||
assert len(pending.selected_tools) == 512
|
||||
assert all(len(name) <= 512 for name in pending.selected_tools)
|
||||
assert "x" * 513 not in pending.selected_tools
|
||||
assert pending.continuation_query.startswith("original request")
|
||||
assert len(pending.continuation_query) == 4000
|
||||
|
||||
tampered = replace(
|
||||
pending,
|
||||
selected_tools=("bash", "manage_skills", "send_email"),
|
||||
continuation_query="different request",
|
||||
)
|
||||
grant = ExactToolApproval(tampered)
|
||||
assert grant.matches(
|
||||
owner="alice",
|
||||
session_id="session-1",
|
||||
tool_name="bash",
|
||||
content="printf exact",
|
||||
workspace=None,
|
||||
) is False
|
||||
|
||||
|
||||
def test_consumed_card_resolution_updates_memory_and_persisted_metadata(monkeypatch):
|
||||
from routes import chat_routes
|
||||
|
||||
ask_user = {
|
||||
"kind": "tool_approval",
|
||||
"approval_id": "approval-1",
|
||||
"session_id": "session-1",
|
||||
}
|
||||
metadata = {
|
||||
"_db_id": "message-1",
|
||||
"tool_events": [{"ask_user": ask_user}],
|
||||
}
|
||||
sess = SimpleNamespace(
|
||||
id="session-1",
|
||||
history=[SimpleNamespace(metadata=metadata)],
|
||||
)
|
||||
db_message = SimpleNamespace(meta_data=None)
|
||||
|
||||
class Column:
|
||||
def __eq__(self, value):
|
||||
return value
|
||||
|
||||
class FakeDBMessage:
|
||||
id = Column()
|
||||
session_id = Column()
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *conditions):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return db_message
|
||||
|
||||
class FakeDB:
|
||||
committed = False
|
||||
rolled_back = False
|
||||
closed = False
|
||||
|
||||
def query(self, model):
|
||||
assert model is FakeDBMessage
|
||||
return FakeQuery()
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def rollback(self):
|
||||
self.rolled_back = True
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
db = FakeDB()
|
||||
monkeypatch.setattr(chat_routes, "DBChatMessage", FakeDBMessage)
|
||||
monkeypatch.setattr(chat_routes, "SessionLocal", lambda: db)
|
||||
|
||||
assert chat_routes._mark_tool_approval_resolved(
|
||||
sess,
|
||||
"approval-1",
|
||||
"approve",
|
||||
) is True
|
||||
assert ask_user["resolved"] == "approve"
|
||||
persisted = json.loads(db_message.meta_data)
|
||||
assert persisted["tool_events"][0]["ask_user"]["resolved"] == "approve"
|
||||
assert "_db_id" not in persisted
|
||||
assert db.committed is True
|
||||
assert db.rolled_back is False
|
||||
assert db.closed is True
|
||||
|
||||
|
||||
def test_deny_resolution_stream_is_control_only():
|
||||
from routes.chat_routes import _tool_approval_resolution_stream
|
||||
|
||||
async def collect():
|
||||
return [chunk async for chunk in _tool_approval_resolution_stream("deny")]
|
||||
|
||||
chunks = asyncio.run(collect())
|
||||
assert chunks[-1] == "data: [DONE]\n\n"
|
||||
event = json.loads(chunks[0][len("data: "):])
|
||||
assert event == {"type": "tool_approval_resolved", "decision": "deny"}
|
||||
assert "Denied the" not in "".join(chunks)
|
||||
|
||||
|
||||
def test_route_context_agent_frontend_and_cache_bust_wire_the_contract():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
route = (root / "routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
helpers = (root / "routes/chat_helpers.py").read_text(encoding="utf-8")
|
||||
agent = (root / "src/agent_loop.py").read_text(encoding="utf-8")
|
||||
frontend = (root / "static/js/chat.js").read_text(encoding="utf-8")
|
||||
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||
app = (root / "static/app.js").read_text(encoding="utf-8")
|
||||
index = (root / "static/index.html").read_text(encoding="utf-8")
|
||||
approvals = (root / "src/tool_approvals.py").read_text(encoding="utf-8")
|
||||
capabilities = (root / "src/tool_capabilities.py").read_text(encoding="utf-8")
|
||||
models = (root / "core/models.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'decision not in {"approve", "approve_task", "deny"}' in route
|
||||
assert "set(pending_tool_approval.selected_tools)" in route
|
||||
assert "pending_tool_approval.continuation_query" in route
|
||||
assert "persist_user_message=not tool_approval_continuation" in route
|
||||
assert "_mark_tool_approval_resolved(" in route
|
||||
assert "_tool_approval_resolution_stream(decision)" in route
|
||||
assert "Approved the exact" not in route
|
||||
assert "Denied the" not in route
|
||||
assert "continuation_context_message: str | None = None" in helpers
|
||||
assert "persist_user_message: bool = True" in helpers
|
||||
assert "_without_latest_matching_user_message(" not in helpers
|
||||
assert "selected_tools=approval_selected_tools" in agent
|
||||
assert "continuation_query=_retrieval_query or _last_user" in agent
|
||||
assert "approval_gate_bypassed=bool(" in agent
|
||||
assert "['approve', 'approve_task', 'deny']" in frontend
|
||||
assert "input.value = label" not in frontend
|
||||
assert "const msg = approvalForSend ? '' : el('message').value;" in frontend
|
||||
assert "const skipBubble = _hideUserBubble || !!approvalForSend;" in frontend
|
||||
assert "fd.append('message', approvalForSend ? '' : _finalMsgWithInject);" in frontend
|
||||
assert "json.type === 'tool_approval_resolved'" in frontend
|
||||
assert "if (aq.resolved) return null;" in renderer
|
||||
assert "ev.ask_user && !ev.ask_user.resolved" in renderer
|
||||
assert '"label": "Allow once"' not in approvals
|
||||
assert '"label": "Allow for this task"' in approvals
|
||||
assert '"label": "Allow for this chat session"' in approvals
|
||||
assert "scope_for_decision(normalized_decision)" in approvals
|
||||
assert "CHAT_SESSION_APPROVAL_CONTEXT_MARKER" in capabilities
|
||||
assert "CHAT_SESSION_APPROVAL_CONTEXT_MARKER" in models
|
||||
|
||||
def version(source: str, module: str) -> str:
|
||||
match = re.search(rf"(?:\./js/|\./|/static/js/){module}\.js\?v=([^'\"<]+)", source)
|
||||
assert match, f"missing versioned {module}.js reference"
|
||||
return match.group(1)
|
||||
|
||||
chat_version = version(app, "chat")
|
||||
renderer_version = version(app, "chatRenderer")
|
||||
stream_version = version(frontend, "chatStream")
|
||||
assert version(index, "chat") == chat_version
|
||||
assert version(frontend, "chatRenderer") == renderer_version
|
||||
assert version(index, "chatRenderer") == renderer_version
|
||||
assert version(index, "chatStream") == stream_version
|
||||
Reference in New Issue
Block a user