Skip to content

feat(serve): add HTTP/SSE transport with bearer-token auth#154

Closed
greatjourney589 wants to merge 2 commits into
vouchdev:mainfrom
greatjourney589:feat/http-transport-bearer-auth-sse
Closed

feat(serve): add HTTP/SSE transport with bearer-token auth#154
greatjourney589 wants to merge 2 commits into
vouchdev:mainfrom
greatjourney589:feat/http-transport-bearer-auth-sse

Conversation

@greatjourney589

@greatjourney589 greatjourney589 commented Jun 3, 2026

Copy link
Copy Markdown

What changed

Closes #153

Adds an HTTP transport to vouch serve (--transport http) backed by a new
Starlette/uvicorn server (src/vouch/http_server.py) and a bearer-token auth
layer (src/vouch/auth.py). The full kb.* method surface is exposed over
POST /kb/<method> by reusing the existing HANDLERS dict from
jsonl_server.py. Audit events are streamed to subscribers via
GET /kb/events (Server-Sent Events, topic-filtered). A companion
--transport mcp-http flag runs FastMCP over its streamable-HTTP transport.
kb.capabilities now advertises available transports and auth modes.
New ServerConfig and TokenEntry Pydantic models back the optional
server: stanza in .vouch/config.yaml. The [http] optional-dependency
group (starlette, uvicorn[standard]) is added to pyproject.toml.

Why

The two existing transports (MCP stdio, JSONL stdio) require the agent and KB
server to share a process or pipe. This breaks cross-machine review workflows
(CI proposes on Machine A; human approves on Machine B), prevents running a
persistent shared sidecar with a warm FTS5 index, and is unavailable on
subprocess-hostile runtimes (Lambda, Cloud Run, Replit). Closes the feature
request: feat: HTTP transport with bearer-token auth and SSE streaming for
vouch serve
.

What might break

Nothing for existing users. Specifically:

  • vouch serve with no flags still starts MCP stdio — no change.
  • The stdio and jsonl transports are untouched.
  • .vouch/config.yaml gains an optional server: key; old configs without
    it load fine (defaults apply).
  • kb.capabilities gains two new fields (transports, auth_modes);
    old adapters that don't read them are unaffected.
  • Bundle format is unchanged.
  • No file moves, no field renames, no kb.* method behaviour changes.

VEP

VEP-0004: HTTP transport with bearer-token auth and SSE streaming — filed as draft in this PR; implementation follows the design in the VEP.

Tests

  • make check passes locally (lint + mypy + pytest)
  • New / changed behaviour has a test
    • tests/test_auth.py — 17 tests: all auth modes, timing-safe compare, actor resolution, loopback enforcement
    • tests/test_http_server.py — 11 tests: auth rejection (401), route dispatch, error mapping (404/422), propose→approve flow, per-token actor attribution
  • CHANGELOG.md updated under ## [Unreleased]

Summary by CodeRabbit

  • New Features

    • HTTP transport for the server with JSON request/response and SSE event streaming.
    • Configurable auth modes: bearer, token-file, none; per-token actor attribution and CLI auth options.
    • CLI: new bind and auth options to run the HTTP transport.
    • Capabilities and server config exposed to report transports and auth modes.
  • Documentation

    • Added formal HTTP transport specification/proposal.
  • Tests

    • New unit and integration tests covering auth, HTTP endpoints, and event streaming.
  • Chores

    • Optional HTTP runtime dependencies added.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3f88bbb1-da64-4214-8c70-56073d731b47

📥 Commits

Reviewing files that changed from the base of the PR and between 692c10f and ba01264.

📒 Files selected for processing (4)
  • src/vouch/auth.py
  • src/vouch/capabilities.py
  • src/vouch/cli.py
  • src/vouch/http_server.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/vouch/capabilities.py
  • src/vouch/cli.py
  • src/vouch/http_server.py
  • src/vouch/auth.py

📝 Walkthrough

Walkthrough

Adds VEP-0004 and implements an HTTP transport: spec and models, bearer-token auth and actor resolution, Starlette HTTP/SSE server with POST /kb/{method} and GET /kb/events, CLI flags, optional HTTP deps, and tests for auth and HTTP flows.

Changes

HTTP Transport Feature Implementation

Layer / File(s) Summary
Specification and Configuration Models
proposals/README.md, proposals/VEP-0004-http-transport.md, src/vouch/models.py
VEP-0004 documents the HTTP/JSON and SSE wire formats, auth modes, and config model. Capabilities adds auth_modes. New TokenEntry and ServerConfig models define server bind/auth/tls and named sha256 token entries.
Authentication Module & Tests
src/vouch/auth.py, tests/test_auth.py
Implements require_auth(), verify_token(), resolve_actor(), and loopback enforcement for auth=none. Token verification uses SHA-256 + hmac.compare_digest. Unit tests cover modes (none, bearer, token-file), header parsing, actor resolution, and loopback checks.
HTTP Server & Integration Tests
src/vouch/http_server.py, tests/test_http_server.py
build_app() defines POST /kb/{method} JSON dispatch with error-to-HTTP mapping and temporary actor injection, and GET /kb/events SSE streaming by tailing audit.log.jsonl with topic filtering. run_http() starts uvicorn with bind parsing and loopback enforcement. Integration tests exercise auth, routing, claim lifecycle, and audit actor attribution.
CLI Integration, Capabilities, Dependencies
pyproject.toml, src/vouch/cli.py, src/vouch/capabilities.py
vouch serve gains `--transport http

Sequence Diagram

sequenceDiagram
  participant Agent as Agent / Client
  participant HTTPServer as HTTP Server
  participant Auth as auth module
  participant ConfigFile as config.yaml
  participant KBStore as KBStore
  participant AuditLog as audit.log.jsonl

  Agent->>HTTPServer: POST /kb/propose_claim\nAuthorization: Bearer token123
  HTTPServer->>Auth: require_auth(header, mode, kb_dir)
  Auth->>ConfigFile: load server.tokens
  ConfigFile-->>Auth: token entries with sha256:hash
  Auth->>Auth: compute sha256(token123)
  Auth->>Auth: compare with stored hash
  Auth-->>HTTPServer: actor = "ci-agent"
  HTTPServer->>KBStore: dispatch handler
  KBStore->>AuditLog: log proposal_created(actor="ci-agent")
  KBStore-->>HTTPServer: proposal_id
  HTTPServer-->>Agent: 200 OK {proposal_id}

  Agent->>HTTPServer: GET /kb/events?topics=proposals\nAuthorization: Bearer token123
  HTTPServer->>Auth: require_auth(...)
  Auth-->>HTTPServer: actor (authenticated)
  HTTPServer->>AuditLog: tail file, filter by topics
  AuditLog-->>HTTPServer: JSON audit records
  HTTPServer-->>Agent: text/event-stream\nevent: proposal_created\ndata: {...}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Suggested reviewers

  • plind-junior

Poem

🐰 A rabbit's ode to distant KBs:

Bearer keys hum across the net,
SSE sings each audit set,
Loopback keeps the devs in place,
Tokens name the agent's face,
Remote reviews now hop in place. 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(serve): add HTTP/SSE transport with bearer-token auth' accurately reflects the main changes: adding HTTP/SSE transport with authentication to the serve command.
Linked Issues check ✅ Passed The PR comprehensively addresses all major objectives from issue #153: HTTP transport implementation (POST /kb/, GET /kb/events SSE), three auth modes with token-actor mapping, MCP-over-HTTP support, capabilities advertisement, server configuration models, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are scope-aligned to issue #153: HTTP server, auth layer, CLI updates, config models, capabilities, dependencies, and tests. No unrelated modifications detected in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (3)
tests/test_auth.py (1)

103-119: ⚡ Quick win

Add coverage for authenticating a config-only token.

The suite verifies resolve_actor against server.tokens but never asserts that such a token actually passes verify_token/require_auth. A test where a token exists only in config.yaml (not as the static VOUCH_SERVER_TOKEN) would surface the auth gap flagged in auth.py (verify_token/resolve_actor disconnect).

🤖 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 `@tests/test_auth.py` around lines 103 - 119, Add a test that covers
authenticating a config-only token by writing a token entry via
_write_token_config (e.g., token "configtoken" -> actor "config-agent"),
ensuring VOUCH_SERVER_TOKEN env is not set, then call the auth path used in
production (either verify_token or require_auth helper used by your routes) with
that token and assert it authenticates and resolves to "config-agent";
specifically reference resolve_actor, verify_token/require_auth, and
_write_token_config so the test verifies the token present only in server.tokens
(config.yaml) is accepted by the verification logic.
src/vouch/models.py (1)

390-396: 💤 Low value

Consider constraining auth to known modes.

auth: str accepts arbitrary values; a typo in config.yaml (e.g. auth: bearrer) loads silently and only surfaces as a behavioral mismatch later. A Literal["none", "bearer", "token-file"] gives validation at config-load time.

♻️ Proposed change
-    bind: str = "127.0.0.1:7749"
-    auth: str = "token-file"
+    bind: str = "127.0.0.1:7749"
+    auth: Literal["none", "bearer", "token-file"] = "token-file"

(Literal is already imported at line 13.)

🤖 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/models.py` around lines 390 - 396, The ServerConfig model's auth
field currently accepts any string; change its type annotation to a constrained
Literal to validate allowed modes at load time (use Literal["none", "bearer",
"token-file"]) while keeping the default value "token-file" so invalid values in
config.yaml raise a validation error; update the auth declaration in class
ServerConfig accordingly (the Literal import is already available).
src/vouch/auth.py (1)

84-91: 💤 Low value

mode parameter is unused in _read_static_token.

bearer and token-file resolve identically here (both re-read env/file on each call). The intended bearer-vs-token-file distinction (cache vs re-read) is not implemented. Either drop the mode parameter or implement the caching difference.

🤖 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/auth.py` around lines 84 - 91, The _read_static_token(mode: str)
function currently ignores the mode; either remove the unused mode parameter or
implement the intended difference between "bearer" (cache env token once) and
"token-file" (re-read file each call). To fix, choose one: (A) remove the mode
parameter from _read_static_token and update all callers to call it without
mode; or (B) implement caching by introducing a module-level variable (e.g.
_CACHED_STATIC_TOKEN) and, in _read_static_token, when mode == "bearer" read
VOUCH_SERVER_TOKEN once into that cache and return it on subsequent calls, while
when mode == "token-file" always read _DEFAULT_TOKEN_FILE.read_text().strip();
ensure callers still pass the same mode strings and handle None results
unchanged.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/vouch/auth.py`:
- Around line 55-81: verify_token currently only checks a single static token
via _read_static_token and therefore ignores per-token entries in config.yaml;
update verify_token(token: str, mode: str, kb_dir: Path) to first handle mode ==
"none" as now, then load the server config with _load_server_config(kb_dir) and
if cfg and cfg.tokens iterate those entries comparing _sha256_hex(token)
(stripping optional "sha256:" prefix from entry.token_hash) with each stored
hash using _safe_eq and return True on match; if no match in cfg.tokens fall
back to the existing static-token check (_read_static_token) so existing
VOUCH_SERVER_TOKEN / token-file behavior remains; also ensure _read_static_token
respects the mode parameter (or change verify_token to call the correct
static-token reader for different modes) so "bearer" vs "token-file" behave
correctly.

In `@src/vouch/capabilities.py`:
- Around line 73-87: The advertised transport token in capabilities() is
inconsistent with the CLI; update the transports list (the local variable
transports used before the Capabilities(...) return) to advertise the canonical
CLI token "http" instead of "http-jsonl" so clients can use the same token as
the vouch serve CLI; leave other transports (e.g., "mcp-http") unchanged and
ensure the Capabilities(...) call returns the updated transports list.

In `@src/vouch/cli.py`:
- Around line 796-811: The mcp-http branch is not forwarding the parsed bind
host/port and is not handling auth modes consistently: update the code around
the mcp.run call so mcp.run(transport="streamable-http") receives the host and
port parsed from bind (pass host and port args extracted from bind into
mcp.run), and unify auth handling by calling assert_loopback_for_no_auth(bind)
when auth_mode == "none" but otherwise either wire the chosen auth
(bearer/token-file) into the streamable-http server startup or explicitly raise
a ClickException for unsupported auth modes; adjust the logic in the try block
that imports .auth.assert_loopback_for_no_auth and calls mcp.run to forward
host/port and enforce/reject non-none auth modes accordingly.

In `@src/vouch/http_server.py`:
- Around line 111-112: The current exception handler returns
traceback.format_exc() to clients (in the except block that calls
_error_response), which leaks internal stack traces; change this to log the full
traceback server-side using a module logger (create or use a logger instance in
src/vouch/http_server.py), call logger.exception or logger.error with
traceback.format_exc() and the exception, and then call _error_response with a
generic error message like "internal_error" and a non-sensitive client-facing
message (no stack trace). Ensure the unique symbols referenced are the except
block surrounding the call to _error_response and the _error_response function
itself so you replace the response payload while preserving server-side logging.
- Around line 103-117: The dispatch() function currently mutates
os.environ["VOUCH_AGENT"] and calls HANDLERS[full_method](params) synchronously
which blocks the loop and risks clobbering agent attribution; instead remove any
global VOUCH_AGENT mutation and pass the actor explicitly into handlers (e.g.,
add an actor parameter to the jsonl_server handler functions named _h_* and the
helper _agent() should be changed to read the actor argument rather than
os.environ), and run handler execution off the event loop if they are CPU-bound
by using asyncio.get_running_loop().run_in_executor(...) or by making handlers
async and awaiting them; preserve the existing exception mapping (KeyError ->
missing_param, ValueError/ProposalError/ArtifactNotFoundError ->
invalid_request, others -> internal_error) and drop the finally block that
restores VOUCH_AGENT.
- Around line 190-197: The SSE endpoint currently returns
Response(content=generate()) where generate() is an async generator—replace
Response with starlette.responses.StreamingResponse and pass generate() as the
content so the async generator is streamed properly; change internal_error to
return a non-sensitive generic error message instead of traceback.format_exc()
to avoid leaking internals; update dispatch to avoid calling HANDLERS[...]
synchronously by either awaiting the handler if it is async or offloading sync
handlers with asyncio.to_thread/anyio.to_thread, and stop reading request-scoped
state from global os.environ["VOUCH_AGENT"]—accept the agent value from the
request context/parameters (or request.state) and pass it into dispatch/handlers
instead.

---

Nitpick comments:
In `@src/vouch/auth.py`:
- Around line 84-91: The _read_static_token(mode: str) function currently
ignores the mode; either remove the unused mode parameter or implement the
intended difference between "bearer" (cache env token once) and "token-file"
(re-read file each call). To fix, choose one: (A) remove the mode parameter from
_read_static_token and update all callers to call it without mode; or (B)
implement caching by introducing a module-level variable (e.g.
_CACHED_STATIC_TOKEN) and, in _read_static_token, when mode == "bearer" read
VOUCH_SERVER_TOKEN once into that cache and return it on subsequent calls, while
when mode == "token-file" always read _DEFAULT_TOKEN_FILE.read_text().strip();
ensure callers still pass the same mode strings and handle None results
unchanged.

In `@src/vouch/models.py`:
- Around line 390-396: The ServerConfig model's auth field currently accepts any
string; change its type annotation to a constrained Literal to validate allowed
modes at load time (use Literal["none", "bearer", "token-file"]) while keeping
the default value "token-file" so invalid values in config.yaml raise a
validation error; update the auth declaration in class ServerConfig accordingly
(the Literal import is already available).

In `@tests/test_auth.py`:
- Around line 103-119: Add a test that covers authenticating a config-only token
by writing a token entry via _write_token_config (e.g., token "configtoken" ->
actor "config-agent"), ensuring VOUCH_SERVER_TOKEN env is not set, then call the
auth path used in production (either verify_token or require_auth helper used by
your routes) with that token and assert it authenticates and resolves to
"config-agent"; specifically reference resolve_actor, verify_token/require_auth,
and _write_token_config so the test verifies the token present only in
server.tokens (config.yaml) is accepted by the verification logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9e4ac460-cfe8-4141-bbb4-e5a8f93cc230

📥 Commits

Reviewing files that changed from the base of the PR and between 3beb821 and 692c10f.

📒 Files selected for processing (10)
  • proposals/README.md
  • proposals/VEP-0004-http-transport.md
  • pyproject.toml
  • src/vouch/auth.py
  • src/vouch/capabilities.py
  • src/vouch/cli.py
  • src/vouch/http_server.py
  • src/vouch/models.py
  • tests/test_auth.py
  • tests/test_http_server.py

Comment thread src/vouch/auth.py Outdated
Comment thread src/vouch/capabilities.py
Comment thread src/vouch/cli.py
Comment thread src/vouch/http_server.py
Comment thread src/vouch/http_server.py Outdated
Comment thread src/vouch/http_server.py Outdated
@greatjourney589

Copy link
Copy Markdown
Author

Hi,@plind-junior
Could you please review this PR?

@plind-junior

Copy link
Copy Markdown
Member

Review

Summary: This PR adds a Starlette/uvicorn HTTP transport (--transport http) and a bearer-token auth layer to vouch serve, matching the design in issue #153 and VEP-0004. The architecture is clean: http_server.py reuses the HANDLERS dict from jsonl_server.py directly, auth.py is self-contained, and both new files have dedicated test suites.

What works

  • src/vouch/auth.py:313–314 — timing-safe comparison via hmac.compare_digest is correct; both sides are pre-hashed to equal-length strings, eliminating length-based side channels.
  • src/vouch/auth.py:406–423 — loopback enforcement for --auth none is sensible and matches what the issue requested. localhost is explicitly allowed as an alias for 127.0.0.1.
  • src/vouch/http_server.py:621–636VOUCH_AGENT env-var is saved and restored in a finally block, preventing actor bleed-through between concurrent requests in tests. (Caveat below.)
  • src/vouch/http_server.py:729–733 — the /kb/events route is registered before /kb/{method:path} so the specific path wins; correct ordering with Starlette's route precedence.
  • tests/test_http_server.py:1061–1088 — the propose→approve end-to-end flow including the trusted-agent config fixture is a solid regression guard.
  • tests/test_auth.py:847–852 — monkeypatching _DEFAULT_TOKEN_FILE to test token-file mode without touching ~/.vouch/server.token is the right isolation technique.

Suggestions

  • [blocking] src/vouch/http_server.py:621–636 — mutating os.environ["VOUCH_AGENT"] per-request is not safe under concurrent async requests. Uvicorn runs async handlers in the same thread but the env-var is process-global, so two concurrent requests can clobber each other's actor. Fix: pass actor as an explicit argument through the call chain or inject it into request.state and thread it down to HANDLERS (requires a small refactor of how handlers read actor, similar to how JSONL does it).

  • [blocking] src/vouch/http_server.py:682–684pos is captured once from audit_log.stat().st_size at stream open time, and then generate() reopens the file on every 0.5 s tick. If the log is rotated or truncated between open and seek (e.g., vouch doctor rewrites it), fh.seek(pos) silently reads stale or empty data. Additionally, pos is a local variable in generate() but pos += pos_delta reassigns it inside the loop — this works in Python 3 with closures that rebind, but only because pos starts as a simple int. No code bug, but the truncation race is real. Minimum fix: if fh.seek(pos) lands beyond EOF (i.e., fh.tell() > fh.seek(0, 2)), reset pos = 0 to re-read from start.

  • [blocking] src/vouch/cli.py:494–496mcp-http with --auth bearer or --auth token-file raises ClickException("not supported") and exits. The issue spec explicitly requires bearer-token auth on mcp-http as well (it is a network-exposed transport). Blocking for security: users who try vouch serve --transport mcp-http --auth bearer get an error and may fall back to --auth none, inadvertently exposing an unauthenticated MCP endpoint on the network. Either support bearer on mcp-http or document the limitation in --help and refuse non-loopback binds when auth is unsupported.

  • [non-blocking] src/vouch/auth.py:417–423 — the except ValueError re-raise dance for assert_loopback_for_no_auth is fragile: it catches the ValueError from ipaddress.ip_address(host) (invalid IP) and only allows "localhost" as an alias. Any other hostname (e.g., "my-machine.local") silently raises the generic "not allowed" error rather than "not a loopback address." Consider resolving the hostname to an IP (or at minimum documenting in --help that only 127.x.x.x and localhost are accepted for --auth none).

  • [non-blocking] src/vouch/http_server.py:596–600_get_store() is called on every request, which re-runs discover_root() (filesystem walk) each time. For a persistent sidecar this is wasteful. Consider building the KBStore once in build_app and capturing it in the handler closure, similar to how jsonl_server.py constructs the store at startup.

  • [non-blocking] Issue feat: HTTP transport with bearer-token auth and SSE streaming for vouch serve #153 calls for VOUCH_SERVER_URL CLI proxy mode (item 5 in the issue) — this is mentioned in VEP-0004 too but not implemented in the PR. That's fine as a follow-up, but the PR description doesn't note it as out-of-scope, which could create confusion about what was delivered.

  • [non-blocking] src/vouch/models.py:374auth_modes is added to Capabilities but the field is placed after the closing brace comment of the existing model body, outside the class indentation visually. Confirm it is actually part of Capabilities (the line numbers suggest it is at +368 inside the class), and consider adding it to the model_config json_schema_extra example block so it appears in schema docs.

  • [non-blocking] tests/test_http_server.py:951pytest.importorskip("starlette") at module level means the entire file is skipped when starlette is absent. This is correct for optional-dep tests, but CI should have a dedicated tox/nox env that installs vouch-kb[http] and runs these tests; otherwise the 11 HTTP tests never run in a base install.

Verdict

Request changes — the concurrent os.environ mutation (blocking #1) is a correctness bug under any async server load, and the mcp-http auth gap (blocking #3) creates a security footgun. Both are small, targeted fixes. The SSE truncation race (blocking #2) should also be addressed before merge. The rest of the implementation is solid.

@greatjourney589

Copy link
Copy Markdown
Author

Hi,@plind-junior
I understood your suggestion exactly.when you reopen this PR, I will resolve it perfectly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: HTTP transport with bearer-token auth and SSE streaming for vouch serve

2 participants