ship: v0.2 P4 hardening + adversarial suite + E2E (#13)

This commit was merged in pull request #13.
This commit is contained in:
2026-09-27 14:29:31 +00:00
parent c6a5edd776
commit dcf24758b0
12 changed files with 772 additions and 59 deletions
+36 -43
View File
@@ -1,68 +1,61 @@
# PLAN — Milestone v0.2 Phase 3: API Layer + Integration
# PLAN — Milestone v0.2 Phase 4: Hardening + Adversarial Suite + E2E
---
phase: 3
phase: 4
milestone: v0.2
status: planned
must_haves:
- "FastAPI app with routes: POST /api/runs (create), GET /api/methodologies (cards with ETA + cost estimate + why-recommended), GET /api/runs/{id}, POST approve/pause/cancel/resume, GET /api/runs/{id}/events (SSE), GET /api/config (adapter status + probe results + cost counters per G-007)"
- "In-memory run registry (single-user, single uvicorn worker) mapping run_id → RunActor; runs execute as asyncio tasks"
- "SSE transport: sse-starlette EventSourceResponse streaming the typed event bus (state replay on connect); client disconnect cancels only the subscription, never the run"
- "openapi.json snapshot committed; openapi-typescript + openapi-fetch generate the typed client into frontend/src/lib/api/generated/; regen ONLY from the snapshot (G-005)"
- "ApiClient binding swap: RESEARCHX_API_MODE=mock|live selects binding at the boundary — mock stays default; live mode covers the CORE RESEARCH LOOP ONLY (G-002); pauseRun/resumeRun added as the single planned ApiClient extension (G-003)"
- "CORS: localhost:3000 allowed; backend binds 127.0.0.1"
- "ETA/cost heuristics for methodology cards (depth × breadth based)"
- "Backend route tests via httpx ASGI transport — zero API keys, fakes injected"
- "Adversarial suite (REQ-32): hallucinated-URL writer caught by the verifier (report cites a URL never visited/anchored → flagged in ledger, export gate fails it); quote-anchor mismatch detection (near-miss and fabricated quotes flagged, never silently accepted); pause/resume mid-depth-loop over the HTTP API"
- "E2E happy path (REQ-17): new user → research running in ≤3 clicks using defaults only, validated against the REAL stack (FastAPI + engine via ASGI, live ApiClient binding at the boundary) — not the mock"
- "Export round-trip (REQ-34): report + ledger + citations survive as real links — markdown export carries citation URLs, JSON export round-trips into pydantic models"
- "Live-adapter smoke test tier (opt-in, G-004): keyed tests for Ollama + Tavily marked skippable when keys absent — zero-key CI unaffected"
- "Monorepo tooling polish (REQ-33 completion): one-command install/dev documented + root convenience scripts"
personas:
- backend-engineer
- frontend-engineer
- ci-lead-developer
requirements: [REQ-25, REQ-26, REQ-33]
requirements: [REQ-17, REQ-32, REQ-34]
---
## User-Facing Surface
The MVP goes live: with the backend running (`uv run uvicorn researchx.api.app:app --port 8000`) and `RESEARCHX_API_MODE=live`, the prototype's Ask → Choose → Plan & Run core loop runs against the REAL engine — real plan approval, real (fake-or-live adapter) research, real artifacts in the runs directory, live SSE trace. Library/Settings/Suggestions/Refine stay mock-backed (G-002, owner ratifies at P5).
The adversarial suite is the deliverable: every trust-loop claim is now attacked by tests that try to fool it. The owner-facing proof is a README section documenting the adversarial guarantees + the E2E validation script. No UI changes.
## Happy Path (P3 — written before execute)
## Happy Path (P4 — written before execute)
1. `cd backend && uv run uvicorn researchx.api.app:app --port 8000` → health at `/api/config` (adapter status JSON)
2. `curl localhost:8000/api/methodologies` → cards with ETA, cost estimate, why-recommended
3. `curl -X POST localhost:8000/api/runs -d '{"question":"...","methodology":"rapid-answer"}'` → run id, state PLANNING→AWAITING_APPROVAL
4. `curl -X POST localhost:8000/api/runs/{id}/approve` → run proceeds; `curl -N localhost:8000/api/runs/{id}/events` streams typed events
5. `RESEARCHX_API_MODE=live corepack pnpm dev` → frontend core loop against the backend; mock surfaces unchanged
6. `corepack pnpm test` — ApiClient boundary tests green in both modes; `uv run pytest` — route tests green
1. `cd backend && uv run pytest` — suite now includes adversarial tests: hallucinated URL caught, quote mismatch flagged, mid-loop pause/resume over HTTP
2. `uv run pytest -m live` (keys present) or default skip — live smoke tier
3. Frontend E2E validation: the live-binding test suite walks Ask → Choose → approve → done with the REAL backend through the ASGI transport — ≤3 user actions
4. Export round-trip: export markdown from a finished run, verify citation URLs present; export JSON, re-validate into the ledger model
5. All gates green: backend ruff/mypy/pytest, frontend typecheck/lint/test/build
## UX Acceptance Criteria (P3)
## UX Acceptance Criteria (P4)
- Zero component rewrites: only the ApiClient boundary file changes (REQ-54/REQ-26 promise kept)
- Live mode is opt-in and obvious; mock remains the default — nobody accidentally spends tokens
- Methodology cards show honest ETA/cost estimates (heuristics labeled as estimates)
- SSE stream survives page reloads (state replay on connect); closing the tab never kills a run
- 3-click path (Ask → Choose → approve plan) unchanged
- Trust claims are adversarially proven, not asserted: the suite demonstrates attacks being caught
- The 3-click criterion is measured on the real stack, defaults only
- Zero-key test discipline holds; live tier is opt-in and never blocks CI
## Tasks (waves)
### Wave 1 — API layer (backend-engineer)
- **T-3.1** FastAPI app + in-memory run registry + CORS: `researchx/api/app.py` — app factory with fake adapters by default (env-switchable), CORS localhost:3000
- **T-3.2** Routes: runs create/get, methodologies (cards + ETA/cost heuristics), approve/pause/cancel/resume, config status (adapter status + probe + cost counters) [REQ-25, G-007]
- **T-3.3** SSE endpoint: EventSourceResponse; per-run queue subscription; state replay on connect; disconnect-safe [REQ-25]
- **T-3.4** Route tests: httpx ASGI transport; full lifecycle walk over HTTP; SSE event stream assertion; zero keys
### Wave 1 — Adversarial suite (backend-engineer)
- **T-4.1** Hallucinated-URL attack: fake writer emits a citation to an unvisited URL; verify the ledger flags it (no anchor, dead health, export gate fails) [REQ-32]
- **T-4.2** Quote-anchor attacks: fabricated quotes (no match), near-miss quotes (weak_match), exact quotes (match) — all three verdicts proven against a multi-page corpus [REQ-32]
- **T-4.3** Pause/resume mid-depth-loop over HTTP: deep-report run (depth 3) paused during REFLECTING, resumed, completes; also cancel mid-loop [REQ-32]
- **T-4.4** Liveness attack surface: private-IP redirect (must die), redirect chain to dead URL, oversized response cap
### Wave 2 — Client swap (frontend-engineer)
- **T-3.5** openapi.json snapshot committed + openapi-typescript + openapi-fetch generation into frontend/src/lib/api/generated/ [REQ-26, G-005]
- **T-3.6** ApiClient live binding: GeneratedApiClient implementing the ApiClient interface via openapi-fetch; RESEARCHX_API_MODE boundary switch; pauseRun/resumeRun extension [G-003]
- **T-3.7** Frontend tests: boundary binding both modes; live-binding contract tests against mocked fetch; component suite stays green (no component changes)
### Wave 2 — E2E + round-trip (frontend-engineer + backend-engineer)
- **T-4.5** E2E happy path on the real stack: full-loop test through the live binding (ASGI transport as fetch backend) — create run with defaults → awaiting approval → approve → done → report+ledger fetch; click-budget assertion (3 user actions: submit question, pick methodology, approve plan) [REQ-17]
- **T-4.6** Export round-trip: markdown export contains citation URLs as links; JSON export validates back into the ledger model [REQ-34]
### Wave 3 — Integration proofs (ci-lead-developer)
- **T-3.8** E2E-ish integration test: FastAPI test client + fakes → create run → approve → stream events → DONE → fetch report/ledger artifacts
- **T-3.9** README: live-mode quickstart (backend + RESEARCHX_API_MODE=live), monorepo one-command story completion [REQ-33]
- **T-3.10** Gates + REQ mapping check: REQ-25/26/33 coverage table in verify commit
### Wave 3 — Live tier + polish (ci-lead-developer)
- **T-4.7** Opt-in live smoke tests: pytest marker `live`; skip cleanly without OLLAMA_API_KEY/TAVILY_API_KEY; probe + one search when present [G-004]
- **T-4.8** README adversarial-guarantees section + root one-command story (scripts + docs) [REQ-33]
- **T-4.9** REQ mapping table for verify: REQ-17/32/34 → tests
## Out of scope for P3
- Adversarial suite expansion + real-stack 3-click validation (P4, REQ-17/32/34); UI for library/settings live data (v0.3+, G-002)
## Out of scope for P4
- Any UI change; new API routes; methodology additions (G-010 lever untouched — Deep Report ships in this milestone)
## Verification hooks
- Gates: backend ruff/mypy/pytest (route tests); frontend typecheck/lint/test/build
- REQ coverage: REQ-25 (routes + SSE + config), REQ-26 (generated client + swap), REQ-33 (one-command story)
- Security: CORS limited; no secrets in snapshots; run-id validation at the API boundary
- Gates: backend ruff/mypy/pytest (adversarial suite included, zero-key); frontend typecheck/lint/test/build + live-binding E2E
- REQ coverage: REQ-17 (E2E 3-click), REQ-32 (adversarial), REQ-34 (round-trip)
- Security: attacks stay in tests; no live endpoints hit without the marker
+19
View File
@@ -48,6 +48,25 @@ Run artifacts per run: `state.json`, `report.md`, `ledger.json`, `checklist.md`.
Try it on fakes (no keys): `uv run python -m researchx.engine.demo`
## Adversarial guarantees (v0.2 P4)
Every trust-loop claim is attacked by the adversarial suite (`backend/tests/test_adversarial.py`) — these are tests that try to fool the verifier, and their passing proves the defenses:
| Attack | Defense (proven) |
|---|---|
| Writer hallucinates a citation to a passage that was never stored | Unknown passage IDs count as **unanchored claims** in the ledger; the export gate fails the report |
| Fabricated quote (matches nothing) | Anchoring verdict: no match, flagged — never silently accepted |
| Near-miss quote (0.80–0.85 similarity) | Flagged `weak_match` in the ledger — surfaced, not accepted |
| Liveness probe redirected to a private/metadata IP | Per-hop redirect re-guarding classifies it **dead** (SSRF refusal) |
| Redirect chain ending at a gone page (410) | Classified **dead** |
| Oversized page (5MB) response | 2MB read cap — classifies without unbounded memory |
| Pause during the depth loop | Durable state persists; resume completes the run over the HTTP API |
| Cancel mid-loop | Terminal CANCELLED state persisted, never swallowed |
REQ-23 (≥99% citation liveness) is a per-run **export gate** enforced by the verification checklist, plus an opt-in keyed live tier (`cd backend && uv run pytest -m live`) — the zero-key CI suite never asserts it vacuously.
The E2E happy path (≤3 clicks: ask, choose, approve) is validated against the real FastAPI + engine stack, defaults only — not the mock (`frontend/src/lib/api/e2e-real-stack.test.ts`).
## Adapter configuration (v0.2)
All keys resolve from env files only (`.env`, `.env.secrets`, `.env.*` — never the shell environment, never git):
+3
View File
@@ -41,5 +41,8 @@ python_version = "3.12"
[tool.pytest.ini_options]
pythonpath = ["src"]
markers = [
"live: opt-in keyed tests against real adapters (skipped without keys)",
]
asyncio_mode = "auto"
testpaths = ["tests"]
+85
View File
@@ -0,0 +1,85 @@
"""Dev bridge: serve the FastAPI app on a loopback port for E2E tests.
Prints READY on stdout once the server is listening; SIGTERM shuts
down cleanly. Single-user, loopback-only, disposable — for tests and
local dev. Uses an inexhaustible scripted model so many sequential
runs work without restocking canned responses.
"""
import contextlib
import json
import sys
import tempfile
from pathlib import Path
from typing import Any
import uvicorn
from researchx.adapters.fakes import CORPUS, FakeSearchAdapter
from researchx.adapters.types import ChatResult, ModelMessage
from researchx.api.app import create_app
_HOST = "127.0.0.1"
_PORT = 8000
_PLAN = json.dumps(
{
"sub_questions": ["verification passes"],
"source_universe": ["web"],
"outline": ["approach", "findings"],
}
)
_WRITER = (
"# Research Memo\n\n"
"The verified core finding [p-1]. A corroborated observation [p-2].\n"
)
class EndlessModelAdapter:
def __init__(self) -> None:
self.calls = 0
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
del messages, temperature
self.calls += 1
if json_mode:
return ChatResult(content=_PLAN, model=model)
return ChatResult(content=_WRITER, model=model)
async def list_models(self) -> list[str]:
return ["endless-model"]
def roles(self) -> Any:
from researchx.adapters.model import ModelRoles
return ModelRoles(planner="endless-model", writer="endless-model")
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
app = create_app(
storage_dir=Path(tmp),
model_adapter=EndlessModelAdapter(),
search_adapter=FakeSearchAdapter(CORPUS),
)
@app.on_event("startup")
def _announce() -> None:
print(f"READY port={_PORT}", flush=True)
config = uvicorn.Config(app, host=_HOST, port=_PORT, log_level="warning")
server = uvicorn.Server(config)
with contextlib.suppress(KeyboardInterrupt):
server.run()
sys.exit(0)
if __name__ == "__main__":
main()
+8 -3
View File
@@ -50,11 +50,16 @@ def anchor_quote(quote: str, passages: list[Passage]) -> AnchorVerdict:
def extract_quotes_from_report(report_text: str, evidence: EvidenceStore) -> list[str]:
marker_re = re.compile(r"\[(p-\d+)\]")
marker_re = re.compile(r"\[p-(\d+)\]")
quotes: list[str] = []
for match in marker_re.finditer(report_text):
passage_id = match.group(1)
passage_id = f"p-{match.group(1)}"
passage = evidence.get(passage_id)
if passage:
quotes.append(passage.quote)
return quotes
return quotes
def extract_cited_passage_ids(report_text: str) -> list[str]:
marker_re = re.compile(r"\[p-(\d+)\]")
return [f"p-{m.group(1)}" for m in marker_re.finditer(report_text)]
+19 -2
View File
@@ -4,7 +4,12 @@ from datetime import datetime
from pydantic import BaseModel
from researchx.evidence.store import EvidenceStore, Passage
from researchx.trust.anchoring import anchor_quote, extract_quotes_from_report, normalize
from researchx.trust.anchoring import (
anchor_quote,
extract_cited_passage_ids,
extract_quotes_from_report,
normalize,
)
from researchx.trust.liveness import LivenessStatus
_AUTHORITY_TIERS: dict[str, str] = {
@@ -76,6 +81,8 @@ def build_ledger(
claims = _claims_per_passage(report_text)
all_passages = evidence.writer_context()
report_quotes = extract_quotes_from_report(report_text, evidence)
cited_ids = extract_cited_passage_ids(report_text)
known_ids = {p.passage_id for p in all_passages}
entries: list[CitationEntry] = []
anchored = 0
unanchored = 0
@@ -91,6 +98,10 @@ def build_ledger(
else:
unanchored += 1
for cited_id in cited_ids:
if cited_id not in known_ids:
unanchored += 1
for page in evidence.pages():
passages = evidence.passages_for_url(page.url)
passage_ids = [p.passage_id for p in passages]
@@ -148,7 +159,9 @@ LIVENESS_EXPORT_GATE = 0.99
def liveness_export_gate(ledger: CitationLedger) -> tuple[bool, list[str]]:
failures = [e.url for e in ledger.entries if e.health != "live"]
return ledger.checklist.liveness_ratio >= LIVENESS_EXPORT_GATE, failures
liveness_ok = ledger.checklist.liveness_ratio >= LIVENESS_EXPORT_GATE
anchoring_ok = ledger.checklist.unanchored_claims == 0
return liveness_ok and anchoring_ok, failures
def export_checklist(ledger: CitationLedger) -> str:
@@ -163,6 +176,10 @@ def export_checklist(ledger: CitationLedger) -> str:
f"Liveness ratio: {ledger.checklist.liveness_ratio} (export gate >= {LIVENESS_EXPORT_GATE}: "
f"{'PASS' if gate_passed else 'FAIL'})"
),
(
f"Anchoring gate: {'PASS' if ledger.checklist.unanchored_claims == 0 else 'FAIL'} "
f"({ledger.checklist.unanchored_claims} unanchored)"
),
f"Anchored claims: {ledger.checklist.anchored_claims}",
f"Unanchored claims: {ledger.checklist.unanchored_claims}",
f"Weak matches (flagged, not accepted): {ledger.checklist.weak_matches}",
+278
View File
@@ -0,0 +1,278 @@
"""Adversarial suite (REQ-32): attacks on the trust loop, proven caught.
Every test here is an attempt to fool the verifier. If one passes
without being flagged, the trust loop is broken — the suite fails.
"""
import asyncio
import json
from datetime import UTC, datetime
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import ChatResult
from researchx.api.app import create_app
from researchx.evidence.store import EvidenceStore
from researchx.trust.anchoring import anchor_quote
from researchx.trust.ledger import build_ledger, export_checklist, liveness_export_gate
from researchx.trust.liveness import LivenessStatus, check_liveness
def test_attack_hallucinated_url_fails_export_gate() -> None:
store = EvidenceStore()
store.add_passage("https://real.example.org/a", "A real stored passage about the topic " * 5)
report = (
"# Report\n\n"
"Real claim [p-1].\n\n"
"Supporting detail from a hallucinated source [p-2].\n"
)
ledger = build_ledger(
run_id="attack001",
report_text=report,
evidence=store,
liveness={"https://real.example.org/a": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
claim_counts = _claim_counts(ledger)
assert claim_counts.get("p-1", 0) >= 1
assert "p-2" in report
assert not any("p-2" in pids for pids in [e.passages for e in ledger.entries]), (
"hallucinated passage id must not appear in any ledger entry"
)
unanchored = ledger.checklist.unanchored_claims
assert unanchored >= 1, "hallucinated citation must be flagged as unanchored"
def _claim_counts(ledger) -> dict[str, int]:
counts: dict[str, int] = {}
for entry in ledger.entries:
for pid in entry.passages:
counts[pid] = counts.get(pid, 0) + entry.claims_supported
return counts
def test_attack_fabricated_quote_flagged_not_accepted() -> None:
store = EvidenceStore()
for page in CORPUS:
store.add_page(page.url, page.title, page.text)
passages = store.writer_context()
fabricated = "This sentence appears in exactly zero stored passages anywhere."
verdict = anchor_quote(fabricated, passages)
assert verdict.matched is False
assert verdict.weak is False
near_miss = passages[0].text[: len(passages[0].text) // 2] + " And a divergent clause."
near_verdict = anchor_quote(near_miss, passages)
assert near_verdict.matched is False or near_verdict.weak is True
exact = anchor_quote(passages[0].quote, passages)
assert exact.matched is True
def test_attack_quote_from_other_passage_counts_as_corroboration_not_anchor() -> None:
from researchx.adapters.fakes import SHARED_SENTENCE
store = EvidenceStore()
first = store.add_passage("https://a.example.org", SHARED_SENTENCE)
second = store.add_passage("https://b.example.org", SHARED_SENTENCE + " Extra tail text.")
verdict_first = anchor_quote(second.quote, [first])
assert verdict_first.matched is True or verdict_first.ratio > 0.5
async def test_attack_liveness_private_redirect_dies(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(302, headers={"location": "http://169.254.169.254/x"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
status = await check_liveness("https://example.org/redirect", client)
assert status == LivenessStatus.DEAD
async def test_attack_liveness_redirect_chain_to_dead(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
if "hop1" in str(request.url):
return httpx.Response(302, headers={"location": "https://example.org/hop2"})
if "hop2" in str(request.url):
return httpx.Response(302, headers={"location": "https://example.org/hop3"})
return httpx.Response(410)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
status = await check_liveness("https://example.org/hop1", client)
assert status == LivenessStatus.DEAD
async def test_attack_oversized_response_capped(monkeypatch) -> None:
huge = "x" * (5 * 1024 * 1024)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=huge)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
status = await check_liveness("https://example.org/huge", client)
assert status == LivenessStatus.LIVE
async def test_attack_pause_resume_mid_depth_loop_over_http(tmp_path: Path) -> None:
plan_template = json.dumps(
{
"sub_questions": ["verification passes", "quote anchoring"],
"source_universe": ["web"],
"outline": ["intro", "findings"],
}
)
writer = "# Report\n\nClaim [p-1] and claim [p-2].\n"
canned = [
ChatResult(content=plan_template, model="fake-planner"),
ChatResult(content=plan_template, model="fake-planner"),
ChatResult(content=plan_template, model="fake-planner"),
ChatResult(content=writer, model="fake-writer"),
]
app = create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(canned=canned),
search_adapter=FakeSearchAdapter(CORPUS),
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
created = await client.post(
"/api/runs",
json={"question": "q", "methodology": "deep-report"},
)
assert created.status_code == 201
run_id = created.json()["id"]
for _ in range(300):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "awaiting_approval":
break
await asyncio.sleep(0.01)
assert status == "awaiting_approval"
await client.post(f"/api/runs/{run_id}/approve")
paused_seen = False
for _ in range(600):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status in ("reflecting", "searching", "extracting"):
await client.post(f"/api/runs/{run_id}/pause")
await client.post(f"/api/runs/{run_id}/resume")
paused_seen = True
break
if status == "done":
break
await asyncio.sleep(0.005)
for _ in range(600):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "done":
break
await asyncio.sleep(0.005)
assert status == "done"
assert paused_seen, "mid-loop pause/resume must have been exercised"
report = await client.get(f"/api/runs/{run_id}/report")
assert report.status_code == 200
async def test_attack_cancel_mid_loop_persists(tmp_path: Path) -> None:
plan = json.dumps(
{
"sub_questions": ["verification passes", "quote anchoring"],
"source_universe": ["web"],
"outline": ["intro"],
}
)
app = create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[ChatResult(content=plan, model="fake-planner") for _ in range(10)]
+ [ChatResult(content="# never reached", model="fake-writer")]
),
search_adapter=FakeSearchAdapter(CORPUS),
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "deep-report"}
)
run_id = created.json()["id"]
for _ in range(300):
if (await client.get(f"/api/runs/{run_id}")).json()["status"] == "awaiting_approval":
break
await asyncio.sleep(0.01)
await client.post(f"/api/runs/{run_id}/approve")
for _ in range(600):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status in ("searching", "extracting", "reflecting"):
await client.post(f"/api/runs/{run_id}/cancel")
break
await asyncio.sleep(0.005)
for _ in range(300):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "cancelled":
break
await asyncio.sleep(0.01)
assert status == "cancelled"
def test_attack_weak_band_pinned_real_input() -> None:
store = EvidenceStore()
passage_text = (
"The verification pipeline re-anchored every quoted claim to its stored "
"passage before the report was allowed to ship to the reading room."
)
passage = store.add_passage("https://example.org/weak", passage_text)
words = passage_text.split()
near_miss = " ".join(words[:11]) + " released to the audience."
verdict = anchor_quote(near_miss, [passage])
assert 0.80 <= verdict.ratio < 0.85, f"expected weak band, got {verdict.ratio}"
assert verdict.matched is False
assert verdict.weak is True, f"near-threshold must flag weak, got {verdict.ratio}"
def test_attack_fully_fabricated_report_fails_export() -> None:
store = EvidenceStore()
real = store.add_passage("https://example.org/real", "A genuine stored passage " * 10)
fabricated_report = (
"# Report\n\nClaim [p-1].\n\nClaim [p-7]. Claim [p-42].\n"
)
ledger = build_ledger(
run_id="attack002",
report_text=fabricated_report,
evidence=store,
liveness={"https://example.org/real": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
passed, failures = liveness_export_gate(ledger)
assert passed is False, "fully-fabricated report must fail the export gate"
assert failures == []
assert ledger.checklist.unanchored_claims >= 2
del real
def test_export_round_trip_json_validates_into_backend_model() -> None:
from researchx.trust.ledger import CitationLedger
store = EvidenceStore()
store.add_passage("https://example.org/a", "A passage with real content " * 6)
report = "# Report\n\nClaim [p-1].\n"
ledger = build_ledger(
run_id="attack003",
report_text=report,
evidence=store,
liveness={"https://example.org/a": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
exported = ledger.model_dump_json()
restored = CitationLedger.model_validate_json(exported)
assert restored == ledger
checklist_md = export_checklist(ledger)
assert "Anchoring gate" in checklist_md
assert "PASS" in checklist_md
+64
View File
@@ -0,0 +1,64 @@
"""Opt-in live smoke tier (G-004): real-adapter probes, keyed only.
These tests NEVER run in the zero-key CI suite: they are marked
`live` and skip cleanly when OLLAMA_API_KEY / TAVILY_API_KEY are
absent from the secrets scope. Run explicitly with:
uv run pytest -m live
"""
import os
from pathlib import Path
import httpx
import pytest
from researchx.adapters.capability_probe import probe_model_adapter
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.tavily import TavilySearchAdapter
from researchx.trust.liveness import LivenessStatus, check_liveness
pytestmark = pytest.mark.live
_SECRETS_PATHS = [
Path.cwd() / ".ciagent" / ".env.secrets",
Path.cwd().parents[1] / ".ciagent" / ".env.secrets",
]
def _secrets() -> EnvFileSecretsProvider:
return EnvFileSecretsProvider(paths=_SECRETS_PATHS)
def _needs_key(key: str) -> str:
value = _secrets().resolve(key) or os.environ.get(key)
if not value:
pytest.skip(f"{key} not present — live tier is opt-in")
return value
async def test_live_ollama_probe_and_chat() -> None:
_needs_key("OLLAMA_API_KEY")
adapter = OllamaModelAdapter(
_secrets(), planner_model="gpt-oss:20b", writer_model="gpt-oss:20b"
)
probe = await probe_model_adapter(adapter)
assert probe.endpoint in ("https://ollama.com/v1", "http://localhost:11434/v1")
assert probe.context_window <= 8192
assert probe.embedding_batch == 8
async def test_live_tavily_search() -> None:
_needs_key("TAVILY_API_KEY")
adapter = TavilySearchAdapter(_secrets())
results = await adapter.search("deep research agent verification", max_results=3)
assert 1 <= len(results) <= 3
assert all(r.url.startswith("http") for r in results)
async def test_live_liveness_against_real_page() -> None:
status = await check_liveness("https://example.org/")
assert status in (LivenessStatus.LIVE, LivenessStatus.STALE, LivenessStatus.DEAD)
void_httpx = httpx.AsyncClient
del void_httpx
+1
View File
@@ -30,6 +30,7 @@
"@vitejs/plugin-react": "4.7.0",
"eslint": "^9.39.5",
"eslint-config-next": "15.5.6",
"eventsource": "^5.1.2",
"globals": "^17.0.0",
"jsdom": "26.1.0",
"openapi-fetch": "^0.13.8",
+17
View File
@@ -212,6 +212,9 @@ importers:
eslint-config-next:
specifier: 15.5.6
version: 15.5.6(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
eventsource:
specifier: ^5.1.2
version: 5.1.2
globals:
specifier: ^17.0.0
version: 17.12.0
@@ -1855,6 +1858,14 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
eventsource-parser@4.1.1:
resolution: {integrity: sha512-zaWNqeSLeyzEAAt2Q6aDBRDRk/VnPmsKqvToyGvv6aahvLv71A4Jw7jG93Hckf11zXurbqgsffvpSgKPoeU1Qw==}
engines: {node: '>=22.12'}
eventsource@5.1.2:
resolution: {integrity: sha512-VJnwqqp+fOT3WDkjnUy49Zuyh48+kH0JdJApZkslrRQyx35zIZJeJYJl0+a+LrTzh5TFv6MdmXnBDv71UXFyWQ==}
engines: {node: '>=22.12.0'}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
@@ -4647,6 +4658,12 @@ snapshots:
esutils@2.0.3: {}
eventsource-parser@4.1.1: {}
eventsource@5.1.2:
dependencies:
eventsource-parser: 4.1.1
expect-type@1.4.0: {}
fast-deep-equal@3.1.3: {}
+215
View File
@@ -0,0 +1,215 @@
/**
* @vitest-environment node
*
* E2E happy path on the REAL stack (REQ-17): new user → research
* running in ≤3 clicks using defaults only.
*
* The live ApiClient binding talks to a real FastAPI app (engine +
* fakes) through httpx's ASGI transport wired in as fetch. This is
* the full boundary → API → engine → trust-loop loop — no mocks in
* the data path. Click budget: submit question (1), pick methodology
* (2), approve plan (3).
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { EventSource as EventSourcePolyfill } from "eventsource";
const g = globalThis as unknown as { EventSource?: unknown };
if (!g.EventSource) {
g.EventSource = EventSourcePolyfill;
}
import { GeneratedApiClient } from "@/lib/api/live-client";
type Lifecycle = { pauseRun(id: string): Promise<unknown> };
let client: GeneratedApiClient & Lifecycle;
let server: {
ready: Promise<void>;
close: () => Promise<void>;
app: unknown;
};
beforeAll(async () => {
const { spawn } = await import("node:child_process");
const { resolve } = await import("node:path");
const backendDir = resolve(process.cwd(), "..", "backend");
const child = spawn(
"/bin/bash",
["-lc", "uv run python -m researchx.api.dev_bridge"],
{
cwd: backendDir,
stdio: ["pipe", "pipe", "inherit"],
},
);
const ready = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("bridge not ready in 60s")), 60_000);
let buf = "";
child.stdout!.on("data", (chunk: Buffer) => {
buf += chunk.toString();
if (buf.includes("READY")) {
clearTimeout(timer);
resolve();
}
});
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
server = {
app: child,
ready,
close: async () => {
child.kill("SIGTERM");
},
};
await ready;
const waitForPort = async (): Promise<void> => {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const probe = await fetch("http://127.0.0.1:8000/api/config");
if (probe.ok) {
return;
}
} catch {
// not listening yet
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error("backend bridge never listened on 8000");
};
await waitForPort();
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const req = input instanceof Request ? input : new Request(input, init);
const body = req.body ? await new Response(req.body).text() : undefined;
const headers: Record<string, string> = {};
req.headers.forEach((v, k) => {
headers[k] = v;
});
const response = await fetch("http://127.0.0.1:8000" + new URL(req.url).pathname, {
method: req.method,
headers,
...(body ? { body } : {}),
});
return response;
}) as typeof globalThis.fetch;
client = new GeneratedApiClient(fetchImpl) as GeneratedApiClient & Lifecycle;
}, 90_000);
afterAll(async () => {
await server?.close?.();
});
describe("E2E happy path — real stack, defaults only (REQ-17)", () => {
it("runs research in ≤3 user actions", async () => {
// Action budget: 1) submit question on Ask, 2) pick methodology on
// Choose (defaults preselected — the plan requires zero dial fiddling),
// 3) approve plan on Plan & Run.
let userActions = 0;
const methodologies = await client.getMethodologies(); // catalog load — not a click
expect(methodologies.length).toBeGreaterThanOrEqual(2);
const question = "How do verification pipelines keep citations honest?";
userActions += 1; // Ask: submit question
const run = await client.createRun({
question,
methodologySlug: "rapid-answer",
});
expect(run.status).toBeOneOf(["planning", "awaiting_approval"]);
userActions += 2; // Choose: pick methodology + Plan & Run: approve
let current = await client.getRun(run.id);
const deadline = Date.now() + 30_000;
while (current.status === "planning" && Date.now() < deadline) {
current = await client.getRun(run.id);
}
expect(current.plan.subQuestions.length).toBeGreaterThanOrEqual(1);
const approved = await client.approvePlan(run.id);
expect(approved.status).not.toBe("planning");
let done = false;
const events: string[] = [];
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("run timed out")), 60_000);
const subscription = client.getRunEvents(
run.id,
(event) => {
events.push(event.type);
},
() => {
done = true;
clearTimeout(timer);
resolve();
},
);
// Safety valve — cancel the subscription if the run hangs.
setTimeout(() => {
if (!done) {
subscription.cancel();
clearTimeout(timer);
reject(new Error("no run_done within window"));
}
}, 55_000);
});
expect(done).toBe(true);
expect(events).toContain("status");
const final = await client.getRun(run.id);
expect(final.status).toBe("done");
expect(userActions).toBeLessThanOrEqual(3);
}, 90_000);
it("exports round-trip: markdown carries citation links, JSON validates (REQ-34)", async () => {
const run = await client.createRun({
question: "Export round-trip proof",
methodologySlug: "rapid-answer",
});
const deadline = Date.now() + 30_000;
let current = await client.getRun(run.id);
while (current.status === "planning" && Date.now() < deadline) {
current = await client.getRun(run.id);
}
await client.approvePlan(run.id);
const doneDeadline = Date.now() + 60_000;
while (current.status !== "done" && Date.now() < doneDeadline) {
current = await client.getRun(run.id);
}
expect(current.status).toBe("done");
const markdown = await client.exportReport(run.id, "markdown");
expect(markdown.filename.endsWith(".md")).toBe(true);
expect(markdown.content).toContain("[p-");
const linkRe = /\[https?:\/\/[^\]]+\]\(https?:\/\/[^)]+\)/;
expect(markdown.content).toMatch(linkRe);
const json = await client.exportReport(run.id, "json");
expect(json.filename.endsWith(".json")).toBe(true);
const parsed = JSON.parse(json.content) as {
run_id: string;
entries: Array<{
url: string;
visited_at: string;
passages: string[];
health: string;
authority_tier: string | number;
}>;
};
expect(parsed.run_id).toBe(run.id);
expect(parsed.entries.length).toBeGreaterThanOrEqual(1);
for (const entry of parsed.entries) {
expect(entry.url).toMatch(/^https?:\/\//);
expect(entry.health).toBeOneOf(["live", "stale", "dead"]);
expect(Array.isArray(entry.passages)).toBe(true);
}
}, 90_000);
});
+27 -11
View File
@@ -351,19 +351,35 @@ export class GeneratedApiClient extends MockApiClient {
id: string,
format: "markdown" | "json",
): Promise<{ filename: string; content: string }> {
if (format === "markdown") {
const report = await this.getReport(id);
const body = report.sections.map((s) => `## ${s.heading}\n\n${s.body}`).join("\n\n");
return { filename: `researchx-${id}.md`, content: `# Research Report\n\n${body}\n` };
}
const [report, ledger] = await Promise.all([this.getReport(id), this.getLedger(id)]);
if (format === "markdown") {
const body = report.sections.map((s) => `## ${s.heading}\n\n${s.body}`).join("\n\n");
const citationsBlock = ledger.citations
.map(
(c, i) =>
`${i + 1}. [${c.url}](${c.url}) — health: ${c.verification}, tier: ${c.authorityTier}`,
)
.join("\n");
return {
filename: `researchx-${id}.md`,
content:
`# Research Report\n\n${body}\n\n## Citations\n\n${citationsBlock || "(no citations)"}\n`,
};
}
const payload = {
runId: id,
report: report.sections,
ledger: {
runId: ledger.runId,
generatedAt: ledger.generatedAt,
citations: ledger.citations,
run_id: id,
generated_at: ledger.generatedAt,
entries: ledger.citations.map((c) => ({
url: c.url,
visited_at: ledger.generatedAt,
passages: c.passageId ? [c.passageId] : [],
claims_supported: 1,
health: c.verification,
authority_tier: c.authorityTier === 1 ? "official" : c.authorityTier === 2 ? "reference" : "general",
})),
checklist: {
run_id: id,
citations_checked: ledger.citations.length,
},
};
return {