Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions src/vouch/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
import uvicorn
import yaml
from starlette.applications import Starlette
from starlette.concurrency import run_in_threadpool
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
Expand Down Expand Up @@ -201,18 +202,31 @@ async def _rpc(request: Request) -> JSONResponse:
))

agent = request.headers.get("X-Vouch-Agent")
reset = jsonl_server._actor.set(agent) if agent else None
bearer = trust_mod.matched_bearer_token(
request.headers.get("authorization"),
tuple(getattr(request.app.state, "vouch_bearer_tokens", ()) or ()),
)
trust = trust_mod.with_auth_subject(trust_mod.JSONL_HTTP, bearer)
try:
with trust_mod.trust_context(trust):
response = jsonl_server.handle_request(envelope)
finally:
if reset is not None:
jsonl_server._actor.reset(reset)

def _dispatch() -> dict[str, Any]:
# The actor and the trust marker are both ContextVars. They're set
# here, inside the worker, so each request mutates only its own copy
# of the context — set on the event loop instead, two concurrent
# calls would overwrite each other's caller identity.
reset = jsonl_server._actor.set(agent) if agent else None
try:
with trust_mod.trust_context(trust):
return jsonl_server.handle_request(envelope)
finally:
if reset is not None:
jsonl_server._actor.reset(reset)

# handle_request is synchronous and reads the whole KB off disk — seconds
# of work on a large one. Called inline it blocks the event loop, so every
# other request (health probes included) queues behind it and clients see
# the endpoint as hung. The /mcp surface already dispatches its sync tools
# through a worker thread; this keeps /rpc consistent with it.
response = await run_in_threadpool(_dispatch)
Comment on lines 205 to +229

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for existing locking/serialization around KB writes now that
# handle_request can run concurrently on real threads.
rg -n 'Lock\(|RLock\(|Semaphore\(|filelock|fcntl|with.*lock' src/vouch --type py -C2

Repository: vouchdev/vouch

Length of output: 10649


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== affected file outline =="
ast-grep outline src/vouch/http_server.py --view compact || true

echo "== http_server relevant lines =="
sed -n '170,245p' src/vouch/http_server.py

echo "== storage lock import/usages =="
sed -n '565,620p' src/vouch/storage.py
sed -n '480,510p' src/vouch/storage.py

echo "== audit lock definition/usages =="
sed -n '40,75p' src/vouch/audit.py
sed -n '140,165p' src/vouch/audit.py

echo "== storage write path candidates =="
rg -n 'def (create|update|delete|load|save|write|read|jsonl|append|replace|json)|open\(|Path\.|# lock|with .*lock|fsync|flush' src/vouch/storage.py src/vouch/audit.py src/vouch/jsonl_server.py src/vouch/proposals.py -g '*.py' -C2

Repository: vouchdev/vouch

Length of output: 20987


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== proposals lock/content =="
sed -n '1,260p' src/vouch/proposals.py
rg -n 'lock|with|open\("x"|Path\.read|Path\.write|mcp|lockfile|fcntl|LOCK_EX' src/vouch/proposals.py -C2

echo "== jsonl_server handle_request outline and file storage calls =="
ast-grep outline src/vouch/jsonl_server.py --view names | sed -n '1,220p' || true
rg -n 'def handle_request|proposals\.approve|storage\.update_|storage\.put_|storage\.delete_|storage\.' src/vouch/jsonl_server.py src/vouch/proposals.py src/vouch/storage.py -C3 | sed -n '1,260p'

echo "== anyio version/deps if present =="
if [ -f pyproject.toml ]; then rg -n 'anyio|uvicorn|starlette' pyproject.toml p -C1 || true; fi

Repository: vouchdev/vouch

Length of output: 20500


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== jsonl_server handle_request implementation =="
sed -n '945,1010p' src/vouch/jsonl_server.py

echo "== http imports for run_in_threadpool and thread pool settings =="
rg -n 'from stdlib|run_in_threadpool|threadpool|workers|limit_workers|_rpc|FastAPI' src/vouch/http_server.py src/vouch --type py -g '*.py' -C2

echo "== storage file operations around durable writes =="
sed -n '700,905p' src/vouch/storage.py
sed -n '1030,1145p' src/vouch/storage.py
sed -n '1,220p' src/vouch/storage.py | sed -n '160,215p'

echo "== deterministic shape from source =="
python3 - <<'PY'
from pathlib import Path
for path in ['src/vouch/storage.py','src/vouch/proposals.py','src/vouch/jsonl_server.py','src/vouch/http_server.py','src/vouch/audit.py']:
    p=Path(path)
    text=p.read_text()
    print(f"\n{path}: runs_on_event_loop_line={any('run in_threadpool' in line for line in text.splitlines())}, has_lock_words={any(w in text for w in ['Lock(', 'RLock(', 'Semaphore(', 'fcntl.flock', 'msvcrt.locking', 'with _audit_lock'])}")
PY

Repository: vouchdev/vouch

Length of output: 31179


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== approve implementation relevant sections =="
sed -n '760,970p' src/vouch/proposals.py
sed -n '995,1050p' src/vouch/proposals.py
sed -n '1220,1235p' src/vouch/proposals.py

echo "== storage delete/update path coverage =="
sed -n '1145,1235p' src/vouch/storage.py
sed -n '938,1038p' src/vouch/storage.py

echo "== candidate tests around MCP sync or /rpc concurrency =="
rg -n 'run_in_threadpool|handle_request|kb\.approve|proposals\.approve|thread|concurrent' tests src/tests tests --type py -C2 | sed -n '1,240p' || true

Repository: vouchdev/vouch

Length of output: 32128


Serialize or lock KB mutations before offloading /rpc to threadpool workers.

handle_request now calls synchronous MCP handlers from real worker threads, and /rpc dispatches the same handlers. The approval flow performs read-then-write checks around store.put_*/update_*, move_proposal_to_decided(), and audit indexing without a cross-thread/global guard, so concurrent /mcp or /rpc writes can interleave and leave artifacts/audit state inconsistent. The existing lock is only around log_event; it should cover the full mutating approval write path or the workers should be serialized at the request boundary before the storage layer changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/http_server.py` around lines 205 - 229, Serialize `/rpc` and `/mcp`
worker execution for mutating approval requests so concurrent handlers cannot
interleave read-then-write operations. Extend the existing synchronization
around `handle_request` and the approval flow to cover `store.put_*`/`update_*`,
`move_proposal_to_decided()`, and audit indexing, rather than only `log_event`;
preserve concurrent handling for non-mutating requests where possible.

Source: Path instructions

return _json(200, response)


Expand Down
19 changes: 17 additions & 2 deletions src/vouch/web/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ def resolve_console_dir(
return None


# The SPA shell names the hashed bundle, so it must be revalidated on every
# load. Served with only Last-Modified a browser applies heuristic freshness
# and keeps replaying the shell it cached before — after an upgrade that means
# the previous console build running against the new backend, which fails in
# whatever way the response shapes changed. `no-cache` still allows a 304 off
# the ETag, so the cost is one conditional request. Everything vite emits into
# assets/ is content-hashed and can be kept forever.
Comment on lines +69 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

keep the added comment prose lowercase.

The new comment block uses sentence case and uppercase prose. Please lowercase it while preserving literal protocol names where necessary.

As per path instructions, “use lowercase prose in comments and review notes.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/web/console.py` around lines 69 - 75, Update the added comment
block above the SPA shell caching logic to use lowercase prose throughout, while
preserving literal protocol and technical names such as SPA, Last-Modified,
browser, ETag, 304, and Vite as required.

Source: Path instructions

_SHELL_CACHE = {"cache-control": "no-cache"}
_ASSET_CACHE = {"cache-control": "public, max-age=31536000, immutable"}


def _cache_headers(rel: str) -> dict[str, str]:
return _ASSET_CACHE if rel.startswith("assets/") else _SHELL_CACHE
Comment on lines +80 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | grep -E '(^|/)console\.py$|AGENTS\.md|CLAUDE\.md|pytest|requirements|pyproject|README' | sed -n '1,200p'

echo "== console outline =="
ast-grep outline src/vouch/web/console.py --view expanded || true

echo "== relevant console sections =="
cat -n src/vouch/web/console.py | sed -n '1,120p'
printf '\n--- 180-215 ---\n'
cat -n src/vouch/web/console.py | sed -n '180,215p'

echo "== tests mentioning console/static/assets/cache =="
rg -n "_cache_headers|assets/|static|FileResponse|index.html|dot|\\./|\\.\\." tests src/vouch -S || true

Repository: vouchdev/vouch

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== test_console relevant sections =="
cat -n tests/test_console.py | sed -n '88,155p'
printf '\n--- tests mentioning console path params or console app ---\n'
rg -n "build_console_app|full_path|assets/|index\\.html|dot|\\./|\\.\\." tests/test_console.py -S

echo "== behavioral probe: path normalization in current logic =="
python3 - <<'PY'
from pathlib import Path
root = Path("/tmp/webapp/dist")
index = root / "index.html"
for rel in ("assets/app.js", "../index.html", "assets/../index.html"):
    candidate = (root / rel).resolve()
    try:
        resolved_rel = candidate.relative_to(root).as_posix()
    except ValueError:
        resolved_rel = "(rejected)"
    print(f"{rel:25} candidate={candidate} file={candidate.is_file()} rel_prefix={rel.startswith('assets/')} resolved_rel={resolved_rel}")
PY

Repository: vouchdev/vouch

Length of output: 5415


classify the resolved path, not the raw request path.

An in-root alias such as /assets/../index.html resolves to the SPA shell, but rel still starts with assets/, so the shell can receive immutable asset caching. Use candidate.relative_to(root).as_posix() for the cache header decision after the traversal check, and add a regression test for dot-segment paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/web/console.py` around lines 80 - 81, Update the cache-header
decision in the request path handling to classify the resolved candidate rather
than the raw rel value: after the traversal check, derive the root-relative
POSIX path with candidate.relative_to(root).as_posix() and pass that value to
_cache_headers. Add a regression test covering an in-root dot-segment path such
as /assets/../index.html, ensuring it receives shell caching.



def _err(status: int, code: str, message: str) -> JSONResponse:
"""The vouch-native error envelope the SPA already understands."""
return JSONResponse(
Expand Down Expand Up @@ -182,8 +197,8 @@ async def _spa(request: Request) -> Response:
except ValueError:
candidate = index
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(index)
return FileResponse(candidate, headers=_cache_headers(rel))
return FileResponse(index, headers=_SHELL_CACHE)

routes = [
Route("/proxy", _proxy, methods=_PROXY_METHODS),
Expand Down
23 changes: 23 additions & 0 deletions tests/test_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,29 @@ def test_real_static_asset_is_served(console_dir: Path) -> None:
assert "console.log" in res.text


def test_index_is_revalidated_on_every_load(console_dir: Path) -> None:
"""The SPA shell must never be served from cache without a revalidation.

index.html names the hashed bundle. With only Last-Modified on it a browser
applies heuristic freshness and keeps replaying the shell it saw before —
so after `pip install -U vouch-kb` a returning reviewer silently runs the
*previous* console build against the new backend, and any response shape
that changed in between crashes the render tree.
"""
client = _loopback_client(build_console_app(console_dir))
for path in ("/", "/review"):
res = client.get(path)
assert res.status_code == 200
assert "no-cache" in res.headers.get("cache-control", ""), path


def test_hashed_assets_stay_cacheable(console_dir: Path) -> None:
"""Only the shell is revalidated — content-hashed assets are immutable."""
res = _loopback_client(build_console_app(console_dir)).get("/assets/app.js")
assert res.status_code == 200
assert "immutable" in res.headers.get("cache-control", "")


# --- the /proxy bridge ------------------------------------------------------


Expand Down
45 changes: 45 additions & 0 deletions tests/test_http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import pytest

from vouch import http_server
from vouch.http_server import _VouchHTTPServer, make_server, run_http
from vouch.models import Claim, ProposalStatus
from vouch.storage import KBStore
Expand Down Expand Up @@ -231,3 +232,47 @@ def test_negative_content_length_rejected(base_url: str) -> None:
return
status_line = response.partition(b"\r\n")[0]
assert b" 4" in status_line, status_line


# --- event loop stays responsive ------------------------------------------


def test_slow_rpc_does_not_block_other_requests(
kb: KBStore, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A slow kb.* call must not stall every other request on the server.

``handle_request`` is synchronous and CPU/IO-bound — on a real KB a
``kb.status`` can run for seconds. Dispatched inline from the async
endpoint it blocks the event loop, so liveness probes and every other
in-flight request queue behind it and the console reports the endpoint
as unreachable. It has to run off the loop.
"""
import time

real = http_server.jsonl_server.handle_request

def slow(envelope: dict) -> dict:
time.sleep(1.0)
return real(envelope)

monkeypatch.setattr(http_server.jsonl_server, "handle_request", slow)

gen = _serve(make_server("127.0.0.1", 0))
url = next(gen)
try:
rpc = threading.Thread(
target=_post, args=(url, {"id": "slow", "method": "kb.status"}), daemon=True
)
rpc.start()
time.sleep(0.2) # let the slow call reach the handler
start = time.monotonic()
code, body = _get(url, "/health")
elapsed = time.monotonic() - start
rpc.join(timeout=10)
finally:
with pytest.raises(StopIteration):
next(gen)

assert code == 200 and body == {"ok": True}
assert elapsed < 0.5, f"/health waited {elapsed:.2f}s behind the slow rpc call"
Loading