| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609 |
- from __future__ import annotations
- import json
- from datetime import UTC, datetime
- from pathlib import Path
- from types import SimpleNamespace
- import httpx
- import pytest
- from fastapi.testclient import TestClient
- from starlette.websockets import WebSocketDisconnect
- from script_build_host.api import ApiSecurity, create_app
- from script_build_host.application.mission_service import (
- PhaseAdvanceResult,
- ScriptMissionStartResult,
- )
- from script_build_host.domain.artifacts import (
- ArtifactKind,
- ArtifactState,
- ArtifactVersion,
- EvidenceRecordV1,
- )
- from script_build_host.domain.errors import ArtifactNotFound
- from script_build_host.domain.records import BuildStatus, Principal
- from script_build_host.infrastructure.outbound import OutboundPolicy
- async def _public_resolver(_host: str, _port: int) -> tuple[str, ...]:
- return ("93.184.216.34",)
- class _PrincipalProvider:
- def __init__(self, principal: Principal | None) -> None:
- self.value = principal
- async def current(self, request_context: object | None = None) -> Principal:
- assert request_context is not None
- if self.value is None:
- raise PermissionError("missing")
- return self.value
- class _Authorizer:
- async def require_source_access(self, principal: Principal, **_source: int) -> None:
- if principal.subject != "owner":
- raise PermissionError("forbidden")
- async def require_access(self, principal: Principal, script_build_id: int) -> None:
- if principal.subject != "owner" or script_build_id != 7:
- raise PermissionError("forbidden")
- class _MissionService:
- def __init__(self) -> None:
- self.command = None
- self.advanced = False
- async def start(self, command: object) -> ScriptMissionStartResult:
- self.command = command
- return ScriptMissionStartResult(7, BuildStatus.RUNNING, "root-7", "11")
- async def stop(self, script_build_id: int, principal: Principal) -> object:
- del principal
- return SimpleNamespace(
- script_build_id=script_build_id,
- status=BuildStatus.STOPPED,
- stopped_operation_ids=(),
- )
- async def advance_to_phase_two(
- self, script_build_id: int, principal: Principal
- ) -> PhaseAdvanceResult:
- assert principal.subject == "owner"
- self.advanced = True
- return PhaseAdvanceResult(script_build_id, BuildStatus.RUNNING, "root-7", "11")
- class _Bindings:
- async def get_by_build(self, script_build_id: int) -> object:
- return SimpleNamespace(
- script_build_id=script_build_id,
- root_trace_id="root-7",
- input_snapshot_id=11,
- )
- _CONTRACT_REF = "script-build://task-contracts/sha256/" + "c" * 64
- class _TaskStore:
- def __init__(self) -> None:
- criterion = SimpleNamespace(criterion_id="criterion-1", description="safe", hard=True)
- current_spec = SimpleNamespace(
- version=1,
- objective="bounded task",
- acceptance_criteria=(criterion,),
- context_refs=("script-build://task-kinds/paragraph", _CONTRACT_REF),
- )
- self.task = SimpleNamespace(
- task_id="task-7",
- parent_task_id="root-task",
- display_path="Root/task-7",
- status="completed",
- current_spec=current_spec,
- current_spec_version=1,
- specs=[current_spec],
- child_task_ids=(),
- attempt_ids=(),
- validation_ids=(),
- decision_ids=(),
- blocked_reason=None,
- superseded_by=None,
- created_at="2026-07-19T00:00:00Z",
- updated_at="2026-07-19T00:00:00Z",
- )
- async def load(self, root_trace_id: str) -> object:
- del root_trace_id
- return SimpleNamespace(
- tasks={"task-7": self.task},
- to_dict=lambda: {
- "root_trace_id": "root-7",
- "protected_context": {"token": "must-not-leak"},
- "command_records": [{"arguments": "secret"}],
- },
- )
- class _Contract:
- def to_payload(self) -> dict[str, object]:
- return {
- "schema_version": "script-task-contract/v1",
- "task_kind": "paragraph",
- "scope_ref": "script-build://scopes/body",
- "intent_class": "explore",
- "objective": "write the evidence paragraph",
- "input_decision_refs": [],
- "base_artifact_ref": None,
- "write_scope": ["script-build://scopes/body"],
- "gap_ref": None,
- "output_schema": "paragraph-artifact/v1",
- "criteria": [
- {"criterion_id": "grounded", "description": "grounded", "hard": True}
- ],
- "budget": {"max_tokens": 1},
- "goal_ids": ["goal-1"],
- "supersedes_decision_ids": [],
- "candidate_closure_decision_refs": [],
- "adopted_decision_ids": [],
- "held_or_rejected_decision_ids": [],
- "compose_order": [],
- "comparison_decision_refs": [],
- }
- class _Contracts:
- async def read(self, root_trace_id: str, uri: str) -> object:
- assert root_trace_id == "root-7"
- assert uri == _CONTRACT_REF
- return SimpleNamespace(
- uri=uri,
- digest="sha256:" + "c" * 64,
- contract=_Contract(),
- )
- class _InputSnapshots:
- async def get(self, snapshot_id: str, *, script_build_id: int) -> object:
- assert snapshot_id == "11"
- assert script_build_id == 7
- return SimpleNamespace(
- snapshot_id="11",
- script_build_id=7,
- execution_id=1,
- topic_build_id=2,
- topic_id=3,
- topic={"topic": {"result": "How one reversal changes a belief"}},
- account={"account_name": "acct", "resolved_account_name": "acct"},
- persona_points=({"point_type": "voice"},),
- section_patterns=({}, {}),
- strategies=(
- {
- "id": 5,
- "name": "contrast",
- "description": "show the turn clearly",
- "content": "private strategy body",
- },
- ),
- canonical_sha256="sha256:" + "d" * 64,
- )
- def _app(
- principal: Principal | None,
- *,
- trace_store: object | None = None,
- uploaded_topics: object | None = None,
- business_artifacts: object | None = None,
- task_contract_store: object | None = None,
- input_snapshots: object | None = None,
- ) -> tuple[object, _MissionService]:
- mission = _MissionService()
- app = create_app(
- mission_service=mission,
- security=ApiSecurity(
- _PrincipalProvider(principal),
- _Authorizer(),
- websocket_allowed_origins=("https://ui.example",),
- ),
- bindings=_Bindings(),
- business_artifacts=business_artifacts or SimpleNamespace(),
- publications=SimpleNamespace(),
- coordinator=SimpleNamespace(task_store=_TaskStore()),
- trace_store=trace_store or SimpleNamespace(),
- uploaded_topics=uploaded_topics,
- outbound_policy=OutboundPolicy(
- frozenset({"data.example"}),
- resolver=_public_resolver,
- ),
- task_contract_store=task_contract_store,
- input_snapshots=input_snapshots,
- )
- return app, mission
- @pytest.mark.asyncio
- async def test_start_requires_auth_and_preserves_old_response_fields() -> None:
- unauthenticated, _ = _app(None)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=unauthenticated), base_url="http://test"
- ) as client:
- response = await client.post(
- "/api/pattern/script_builds",
- json={"execution_id": 1, "topic_build_id": 2, "topic_id": 3},
- )
- assert response.status_code == 401
- forbidden, _ = _app(Principal("intruder"))
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=forbidden), base_url="http://test"
- ) as client:
- response = await client.post(
- "/api/pattern/script_builds",
- json={"execution_id": 1, "topic_build_id": 2, "topic_id": 3},
- )
- assert response.status_code == 404
- authenticated, mission = _app(Principal("owner"))
- legacy_request = json.loads(
- (Path(__file__).parent / "fixtures" / "legacy_script_build_request.json").read_text(
- encoding="utf-8"
- )
- )
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=authenticated), base_url="http://test"
- ) as client:
- response = await client.post(
- "/api/pattern/script_builds",
- json=legacy_request,
- )
- assert response.status_code == 200, response.text
- body = response.json()
- assert body["success"] is True
- assert body["script_build_id"] == 7
- assert body["status"] == "running"
- assert mission.command.strategies_always_on == (13,)
- assert len(mission.command.prompt_requests) >= 8
- assert mission.command.runtime_prompt_manifest == ()
- secret_config = dict(legacy_request)
- secret_config["agent_config"] = {
- **legacy_request["agent_config"],
- "api_key": "must-not-persist",
- }
- secret_response = await client.post(
- "/api/pattern/script_builds",
- json=secret_config,
- )
- assert secret_response.status_code == 200
- assert "must-not-persist" not in str(mission.command.agent_config)
- safe = await client.post(
- "/api/pattern/script_builds",
- json={
- "execution_id": 1,
- "topic_build_id": 2,
- "topic_id": 3,
- "data_source_url": "https://data.example/input",
- },
- )
- assert safe.status_code == 200
- assert mission.command.data_source_url == "https://data.example/input"
- assert mission.command.datasource_manifest == {
- "data_source_host": "data.example",
- "scheme": "https",
- }
- @pytest.mark.asyncio
- async def test_observation_is_build_scoped_and_control_routes_are_not_mounted() -> None:
- app, _ = _app(Principal("owner"))
- paths = set(app.openapi()["paths"])
- assert not any(path.endswith("/operations") for path in paths)
- assert not any(path.startswith("/api/traces") for path in paths)
- assert (
- "patch" in app.openapi()["paths"]["/api/pattern/script_builds/{script_build_id}/favorite"]
- )
- assert "/api/pattern/script_builds/overview/{topic_build_id}" in paths
- assert "/api/pattern/script_builds/{script_build_id}/trace_messages" in paths
- assert "/script_build_detail/{script_build_id}" in paths
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as client:
- allowed = await client.get("/api/pattern/script_builds/7/mission")
- assert allowed.status_code == 200
- assert allowed.json() == {"root_trace_id": "root-7"}
- assert "must-not-leak" not in allowed.text
- denied = await client.get("/api/pattern/script_builds/8/mission")
- assert denied.status_code == 404
- unsafe = await client.post(
- "/api/pattern/script_builds",
- json={
- "execution_id": 1,
- "topic_build_id": 2,
- "topic_id": 3,
- "data_source_url": "http://127.0.0.1/private",
- },
- )
- assert unsafe.status_code == 400
- secret_query = await client.post(
- "/api/pattern/script_builds",
- json={
- "execution_id": 1,
- "topic_build_id": 2,
- "topic_id": 3,
- "data_source_url": "https://data.example/private?token=secret",
- },
- )
- assert secret_query.status_code == 400
- @pytest.mark.asyncio
- async def test_phase_two_advance_auth_and_hidden_not_found() -> None:
- unauthenticated, _ = _app(None)
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=unauthenticated), base_url="http://test"
- ) as client:
- response = await client.post("/api/pattern/script_builds/7/phase-two/advance")
- assert response.status_code == 401
- forbidden, forbidden_mission = _app(Principal("intruder"))
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=forbidden), base_url="http://test"
- ) as client:
- response = await client.post("/api/pattern/script_builds/7/phase-two/advance")
- assert response.status_code == 404
- assert response.json()["detail"]["error_code"] == "BUILD_NOT_FOUND"
- assert forbidden_mission.advanced is False
- allowed, mission = _app(Principal("owner"))
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=allowed), base_url="http://test"
- ) as client:
- response = await client.post("/api/pattern/script_builds/7/phase-two/advance")
- assert response.status_code == 200
- assert response.json() == {
- "success": True,
- "script_build_id": 7,
- "status": "running",
- "root_trace_id": "root-7",
- "input_snapshot_id": "11",
- }
- assert mission.advanced is True
- def test_websocket_rejects_unauthenticated_subscription_before_accept() -> None:
- app, _ = _app(None)
- with TestClient(app) as client:
- with pytest.raises(WebSocketDisconnect) as caught:
- with client.websocket_connect("/api/pattern/script_builds/7/traces/root-7/watch"):
- pass
- assert caught.value.code == 4404
- class _Trace:
- def __init__(self, trace_id: str, root_trace_id: str | None = None) -> None:
- self.trace_id = trace_id
- self.context = {"root_trace_id": root_trace_id} if root_trace_id is not None else {}
- def to_dict(self) -> dict[str, str]:
- return {"trace_id": self.trace_id}
- class _TraceStore:
- async def get_trace(self, trace_id: str) -> _Trace | None:
- values = {
- "root-7": _Trace("root-7"),
- "worker-7": _Trace("worker-7", "root-7"),
- "worker-8": _Trace("worker-8", "root-8"),
- }
- return values.get(trace_id)
- async def get_goal_tree(self, trace_id: str) -> None:
- del trace_id
- return None
- async def get_events(self, trace_id: str, since: int) -> list[dict[str, object]]:
- assert trace_id == "root-7"
- del since
- return []
- async def get_trace_messages(self, trace_id: str) -> list[object]:
- assert trace_id in {"root-7", "worker-7"}
- return [
- SimpleNamespace(sequence=value, to_dict=lambda value=value: {"sequence": value})
- for value in (1, 2, 3)
- ]
- class _BusinessArtifacts:
- def __init__(self) -> None:
- now = datetime.now(UTC)
- evidence = EvidenceRecordV1(
- evidence_id="evidence-api-1",
- source_type="decode",
- tool_name="retrieve_decode",
- query={"query": "safe query"},
- source_refs=("decode:item:1",),
- raw_artifact_ref=None,
- summary="authorized phase-two artifact content",
- supports=("scope:opening",),
- confidence="high",
- limitations=(),
- content_sha256="sha256:" + "a" * 64,
- created_at=now,
- )
- self.value = ArtifactVersion(
- artifact_version_id=71,
- script_build_id=7,
- task_id="task-7",
- attempt_id="attempt-7",
- spec_version=1,
- artifact_type=ArtifactKind.EVIDENCE,
- canonical_sha256="sha256:" + "b" * 64,
- state=ArtifactState.FROZEN,
- artifact=evidence,
- created_at=now,
- frozen_at=now,
- )
- async def get_by_id(self, artifact_version_id: int, *, script_build_id: int) -> ArtifactVersion:
- if artifact_version_id != 71 or script_build_id != 7:
- raise ArtifactNotFound()
- return self.value
- def test_websocket_requires_origin_and_accepts_authorized_build_trace() -> None:
- app, _ = _app(Principal("owner"), trace_store=_TraceStore())
- with TestClient(app) as client:
- with pytest.raises(WebSocketDisconnect) as caught:
- with client.websocket_connect(
- "/api/pattern/script_builds/7/traces/root-7/watch",
- headers={"origin": "https://evil.example"},
- ):
- pass
- assert caught.value.code == 4404
- with client.websocket_connect(
- "/api/pattern/script_builds/7/traces/root-7/watch",
- headers={"origin": "https://ui.example"},
- ) as websocket:
- websocket.send_text("ping")
- assert websocket.receive_json() == {"event": "pong"}
- for foreign_trace_id in ("worker-8", "root-8"):
- with pytest.raises(WebSocketDisconnect) as caught:
- with client.websocket_connect(
- f"/api/pattern/script_builds/7/traces/{foreign_trace_id}/watch",
- headers={"origin": "https://ui.example"},
- ):
- pass
- assert caught.value.code == 4404
- @pytest.mark.asyncio
- async def test_phase_two_observation_hides_foreign_resources_and_returns_owned_artifact() -> None:
- app, _ = _app(
- Principal("owner"),
- trace_store=_TraceStore(),
- business_artifacts=_BusinessArtifacts(),
- )
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as client:
- owned_task = await client.get("/api/pattern/script_builds/7/tasks/task-7")
- assert owned_task.status_code == 200
- assert owned_task.json()["task_id"] == "task-7"
- foreign_task = await client.get("/api/pattern/script_builds/7/tasks/task-8")
- assert foreign_task.status_code == 404
- assert foreign_task.json()["detail"]["error_code"] == "TASK_NOT_FOUND"
- owned_artifact = await client.get("/api/pattern/script_builds/7/artifacts/71")
- assert owned_artifact.status_code == 200
- assert (
- owned_artifact.json()["artifact"]["summary"] == "authorized phase-two artifact content"
- )
- foreign_artifact = await client.get("/api/pattern/script_builds/7/artifacts/81")
- assert foreign_artifact.status_code == 404
- assert foreign_artifact.json()["error_code"] == "ARTIFACT_NOT_FOUND"
- owned_trace = await client.get("/api/pattern/script_builds/7/traces/worker-7")
- assert owned_trace.status_code == 200
- assert owned_trace.json()["trace"]["trace_id"] == "worker-7"
- assert owned_trace.json()["goal_tree"] is None
- incremental_messages = await client.get(
- "/api/pattern/script_builds/7/traces/worker-7/messages?after_sequence=1"
- )
- assert incremental_messages.status_code == 200
- assert incremental_messages.json() == {
- "messages": [{"sequence": 2}, {"sequence": 3}]
- }
- foreign_trace = await client.get("/api/pattern/script_builds/7/traces/worker-8")
- assert foreign_trace.status_code == 404
- assert foreign_trace.json()["detail"]["error_code"] == "TRACE_NOT_FOUND"
- forged_root = await client.get("/api/v2/script-builds/7/roots/root-8/snapshot")
- assert forged_root.status_code == 404
- assert forged_root.json()["detail"]["error_code"] == "ROOT_NOT_FOUND"
- @pytest.mark.asyncio
- async def test_safe_contract_and_input_summaries_are_build_scoped() -> None:
- app, _ = _app(
- Principal("owner"),
- task_contract_store=_Contracts(),
- input_snapshots=_InputSnapshots(),
- )
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as client:
- contract = await client.get(
- "/api/pattern/script_builds/7/tasks/task-7/contract"
- )
- assert contract.status_code == 200, contract.text
- assert contract.json()["task_kind"] == "paragraph"
- assert contract.json()["goal_ids"] == ["goal-1"]
- assert contract.json()["current_spec_version"] == 1
- assert len(contract.json()["versions"]) == 1
- assert "budget" not in contract.json()
- summary = await client.get("/api/pattern/script_builds/7/input-summary")
- assert summary.status_code == 200, summary.text
- payload = summary.json()
- assert payload["topic"] == "How one reversal changes a belief"
- assert payload["persona"]["point_types"] == {"voice": 1}
- assert payload["strategies"][0]["name"] == "contrast"
- assert "private strategy body" not in summary.text
- denied = await client.get("/api/pattern/script_builds/8/input-summary")
- assert denied.status_code == 404
- class _UploadedTopics:
- async def parse(self, _value: object) -> dict[str, object]:
- return {
- "account_name": "acct",
- "topic_fusion": "topic",
- "points": [{"point_type": "关键点"}],
- "item_count": 1,
- }
- async def create(
- self, _parsed: dict[str, object], *, account_name: str | None
- ) -> dict[str, object]:
- return {
- "topic_build_id": 22,
- "topic_id": 33,
- "account_name": account_name or "acct",
- "item_count": 1,
- "point_count": 1,
- }
- @pytest.mark.asyncio
- async def test_upload_parse_and_start_routes_keep_legacy_response_contract() -> None:
- app, mission = _app(Principal("owner"), uploaded_topics=_UploadedTopics())
- payload = {"选题融合": "topic"}
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as client:
- parsed = await client.post(
- "/api/pattern/script_builds/parse_topic_json",
- json={"json_data": payload},
- )
- assert parsed.status_code == 200
- assert parsed.json()["point_type_stats"] == {"关键点": 1}
- started = await client.post(
- "/api/pattern/script_builds/from_topic_json",
- json={"json_data": payload, "account_name": "acct"},
- )
- assert started.status_code == 200
- assert started.json()["topic_build_id"] == 22
- assert started.json()["topic_id"] == 33
- assert mission.command.execution_id == 0
|