|
| 1 | +"""Published native bindings must not break the authorized chat catalog.""" |
| 2 | + |
| 3 | +import pytest |
| 4 | + |
| 5 | +from backend import ws_agent_manager |
| 6 | +from backend.main import app |
| 7 | +from backend.models.edge_node import EdgeNode |
| 8 | +from backend.models.identity import Team, User, Workspace, WorkspaceMembership, WorkspaceRole |
| 9 | +from backend.models.operations_agent import ( |
| 10 | + OperationsAgentIdentity, |
| 11 | + PublishedOperationsAgentVersion, |
| 12 | +) |
| 13 | +from backend.security.identity import RequestIdentity, get_request_identity |
| 14 | + |
| 15 | + |
| 16 | +async def seed_agent(db, *, slug="native", configuration=None): |
| 17 | + user = User(subject=f"{slug}@example.test") |
| 18 | + workspace = Workspace(name=slug, slug=slug) |
| 19 | + db.add_all([user, workspace]) |
| 20 | + await db.flush() |
| 21 | + team = Team(workspace_id=workspace.id, name="Agent owners", slug="owners") |
| 22 | + db.add_all( |
| 23 | + [ |
| 24 | + team, |
| 25 | + WorkspaceMembership( |
| 26 | + workspace_id=workspace.id, user_id=user.id, role=WorkspaceRole.ADMIN |
| 27 | + ), |
| 28 | + ] |
| 29 | + ) |
| 30 | + await db.flush() |
| 31 | + agent = OperationsAgentIdentity( |
| 32 | + workspace_id=workspace.id, |
| 33 | + owning_team_id=team.id, |
| 34 | + name=f"{slug} assistant", |
| 35 | + current_published_version=1, |
| 36 | + ) |
| 37 | + db.add(agent) |
| 38 | + await db.flush() |
| 39 | + version = PublishedOperationsAgentVersion( |
| 40 | + operations_agent_id=agent.id, |
| 41 | + version=1, |
| 42 | + draft_revision=1, |
| 43 | + instructions="Inspect authorized project information.", |
| 44 | + model_configuration=configuration |
| 45 | + if configuration is not None |
| 46 | + else { |
| 47 | + "runtime_binding": { |
| 48 | + "schema_version": "agent.runtime-binding.v2", |
| 49 | + "workflow": "assistant", |
| 50 | + "preferred_agent_urls": ["http://private-runner.example:19823"], |
| 51 | + "preferred_runtimes": ["codex"], |
| 52 | + }, |
| 53 | + "agent_contract": { |
| 54 | + "schema_version": "agent.contract.v2", |
| 55 | + "role": "assistant", |
| 56 | + "input_schema": {"type": "object"}, |
| 57 | + "output_schema": {"type": "object"}, |
| 58 | + "state_schema": {"type": "object"}, |
| 59 | + "required_capabilities": ["streaming", "workspace_read"], |
| 60 | + }, |
| 61 | + }, |
| 62 | + published_by_user_id=user.id, |
| 63 | + reason="Test published native configuration", |
| 64 | + ) |
| 65 | + db.add(version) |
| 66 | + await db.commit() |
| 67 | + return user, workspace, agent, version |
| 68 | + |
| 69 | + |
| 70 | +@pytest.mark.parametrize( |
| 71 | + ("connected", "capabilities", "reason"), |
| 72 | + [ |
| 73 | + (True, ["streaming", "workspace_read", "resumable"], "native_session_contract_unavailable"), |
| 74 | + (False, ["streaming", "workspace_read"], "runtime_node_unavailable"), |
| 75 | + (True, ["streaming"], "runtime_node_unavailable"), |
| 76 | + ], |
| 77 | +) |
| 78 | +async def test_native_v2_catalog_uses_capability_selection_without_claiming_chat_resume( |
| 79 | + client, db_session, monkeypatch, connected, capabilities, reason |
| 80 | +): |
| 81 | + user, workspace, agent, _version = await seed_agent(db_session) |
| 82 | + node = EdgeNode( |
| 83 | + url="http://private-runner.example:19823", |
| 84 | + protocol="ws", |
| 85 | + status="online", |
| 86 | + runtimes=["", " ", "codex"], |
| 87 | + runtime_capabilities={ |
| 88 | + "": capabilities, |
| 89 | + " ": capabilities, |
| 90 | + "codex": ["", " ", *capabilities], |
| 91 | + }, |
| 92 | + ) |
| 93 | + db_session.add(node) |
| 94 | + await db_session.commit() |
| 95 | + monkeypatch.setattr(ws_agent_manager, "is_connected", lambda _url: connected) |
| 96 | + |
| 97 | + async def identity(): |
| 98 | + return RequestIdentity(subject=user.subject) |
| 99 | + |
| 100 | + app.dependency_overrides[get_request_identity] = identity |
| 101 | + response = await client.get( |
| 102 | + "/api/v1/chat/execution-targets", params={"workspace_id": workspace.id} |
| 103 | + ) |
| 104 | + assert response.status_code == 200 |
| 105 | + (target,) = response.json()["data"]["targets"] |
| 106 | + assert target["id"] == f"native:{agent.id}" |
| 107 | + assert target["readiness"]["status"] == "blocked" |
| 108 | + assert target["readiness"]["reason_code"] == reason |
| 109 | + assert target["setup_url"] == ( |
| 110 | + "/providers" if reason == "native_session_contract_unavailable" else "/nodes" |
| 111 | + ) |
| 112 | + assert target["runtime"]["resume_by_id"] is False |
| 113 | + assert "" not in target["runtime"]["capabilities"] |
| 114 | + assert " " not in target["runtime"]["capabilities"] |
| 115 | + assert target["runtime"]["name"] == ( |
| 116 | + "codex" if reason == "native_session_contract_unavailable" else None |
| 117 | + ) |
| 118 | + assert "private-runner" not in response.text |
| 119 | + assert "agent_url" not in response.text |
| 120 | + assert node.id not in response.text |
| 121 | + creation = await client.post( |
| 122 | + "/api/v1/chat/sessions", |
| 123 | + json={"workspace_id": workspace.id, "execution_target_id": target["id"]}, |
| 124 | + ) |
| 125 | + assert creation.status_code == 409 |
| 126 | + assert creation.json()["detail"]["code"] == "native_runtime_blocked" |
| 127 | + |
| 128 | + |
| 129 | +@pytest.mark.parametrize("configuration", [{}, {"runtime_binding": {"schema_version": "old"}}]) |
| 130 | +async def test_incomplete_published_binding_has_repair_reason_and_workspace_isolation( |
| 131 | + client, db_session, configuration |
| 132 | +): |
| 133 | + user, workspace, agent, _version = await seed_agent(db_session, configuration=configuration) |
| 134 | + _other_user, other_workspace, other_agent, _other_version = await seed_agent( |
| 135 | + db_session, slug="private" |
| 136 | + ) |
| 137 | + |
| 138 | + async def identity(): |
| 139 | + return RequestIdentity(subject=user.subject) |
| 140 | + |
| 141 | + app.dependency_overrides[get_request_identity] = identity |
| 142 | + response = await client.get( |
| 143 | + "/api/v1/chat/execution-targets", params={"workspace_id": workspace.id} |
| 144 | + ) |
| 145 | + assert response.status_code == 200 |
| 146 | + (target,) = response.json()["data"]["targets"] |
| 147 | + assert target["id"] == f"native:{agent.id}" |
| 148 | + assert target["readiness"]["reason_code"] == "runtime_binding_invalid" |
| 149 | + assert target["setup_url"] == "/operations-agents" |
| 150 | + assert other_agent.id not in response.text |
| 151 | + forbidden = await client.get( |
| 152 | + "/api/v1/chat/execution-targets", params={"workspace_id": other_workspace.id} |
| 153 | + ) |
| 154 | + assert forbidden.status_code == 403 |
0 commit comments