Skip to content

Fix CI badge to reference master branch#1

Merged
risjai merged 2 commits into
masterfrom
fix/ci-badge-branch
Apr 9, 2026
Merged

Fix CI badge to reference master branch#1
risjai merged 2 commits into
masterfrom
fix/ci-badge-branch

Conversation

@risjai

@risjai risjai commented Apr 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • CI badge in README was showing failing/stale status because it defaulted to main branch, but our actual branch is master
  • Added explicit ?branch=master parameter to the badge URL

Test plan

  • Verify the CI badge on the PR preview shows the correct (passing) status
  • Merge and confirm badge on repo landing page updates

🤖 Generated with Claude Code

risjai and others added 2 commits April 9, 2026 09:34
The badge URL defaulted to the repo's default branch (main) but
our actual branch is master, causing a stale failing status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@risjai
risjai merged commit 499eb2f into master Apr 9, 2026
3 checks passed
risjai added a commit that referenced this pull request Apr 10, 2026
…arison

Enterprise-grade evaluation system for measuring agent output quality, not just
structural regressions. Closes the #1 feature gap vs LangSmith.

New crate: rewind-eval
- DatasetManager: versioned test-case collections with JSONL import/export
- 5 built-in deterministic scorers: exact_match, contains, regex, json_schema, tool_use_match
- ExperimentRunner: subprocess protocol (stdin JSON → stdout JSON), timeout, error handling
- ExperimentComparison: side-by-side delta computation with regression/improvement detection

CLI (rewind eval):
- dataset create/import/export/show/list/delete/add-from-session
- evaluator create/list/delete
- run: execute target command against dataset, score, aggregate
- compare: diff two experiments with color-coded output
- --json output for CI pipelines, --fail-below threshold with exit code 1

Storage: 6 new SQLite tables (datasets, dataset_examples, evaluators,
experiments, experiment_results, experiment_scores) with versioning and
content-addressed blob storage.

Web API: 7 read-only GET routes under /api/eval/*
MCP: 5 read-only tools (list/show datasets, list/show/compare experiments)
Python SDK: Dataset, evaluate(), @evaluator decorator, compare(), 4 built-in scorers
Web UI: EvalDashboard with tabs (Datasets/Experiments/Compare), DatasetBrowser,
ExperimentList, ExperimentDetail with expandable scores, ExperimentComparison
with color-coded deltas, ScoreBadge component

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
risjai added a commit that referenced this pull request Apr 12, 2026
- Fix #1 (BUG): Thread session_name through _init_proxy so fallthrough
  creates the session with the caller's name, not hardcoded "default"
- Fix #2 (DESIGN): _init_proxy returns bool instead of mutating _mode
  via hidden side effect; init() handles the mode switch
- Fix #4: Remove dead urllib.error import
- Fix #5: Gate proxy health endpoint on GET method only
- Fix #6: Add version field to proxy health response for consistency
  with rewind-web
- Fix #7: Add 5 new tests — slow proxy timeout, non-Rewind server
  rejection, session name preservation, uninit after fallthrough,
  init() mode assertion
- Fix #8: Validate health response body (check "status": "ok") to
  prevent false positives from non-Rewind services on the same port

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
risjai added a commit that referenced this pull request Apr 12, 2026
Add a ProxyCircuitBreaker that detects proxy failure mid-session and
transparently falls through to direct recording. The circuit breaker:

- Detects failures from LLM SDK connection errors (APIConnectionError,
  APITimeoutError) — no per-request health checks, zero latency on the
  happy path
- Trips to OPEN after 2 consecutive connection failures
- In OPEN state, preemptively skips the proxy and retries via a
  throwaway client pointing at the original upstream URL (thread-safe,
  no shared mutable state)
- Creates a local Store + Recorder for direct-mode recording while
  proxy is down (session named "<original> (proxy-fallback)")
- After 30s recovery timeout, probes the proxy in HALF_OPEN state
- On successful probe, tears down direct resources and resumes proxy
  mode

Design decisions (from review):
- Throwaway client for retries (resolves thread-safety issue with
  base_url swap — review finding #1)
- Preemptive skip in OPEN state (zero latency penalty — finding #2)
- New session in local store on trip (simple, no cross-store merge —
  finding #3)
- APITimeoutError included in detection (finding #5)

Known limitations:
- Mid-stream proxy failure cannot be retried (LLM responses are
  non-deterministic)
- Timeout detection takes SDK timeout period (60-120s) × threshold

23 new tests: error detection (7), state machine (13), integration (3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
risjai added a commit that referenced this pull request Apr 12, 2026
- Fix #1 (HIGH): Add `from __future__ import annotations` for Python
  3.9 compat — `str | None` syntax is 3.10+
- Fix #2 (MEDIUM): Remove APITimeoutError from detection — timeouts
  may originate from slow upstream, not dead proxy. False trips when
  proxy is healthy but LLM is slow.
- Fix #3 (MEDIUM): Skip record_success() for streaming calls — stream
  object returned doesn't mean data has flowed. Proxy could die
  mid-stream after failure count was reset.
- Fix #4 (MEDIUM): Add retry-path test — verifies direct recorder
  stores steps correctly after circuit trips to OPEN
- Fix #5 (LOW): Copy `organization` in throwaway OpenAI client for
  org-scoped API key compatibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
risjai added a commit that referenced this pull request Apr 12, 2026
* feat: mid-session circuit breaker for proxy mode (Phase 3)

Add a ProxyCircuitBreaker that detects proxy failure mid-session and
transparently falls through to direct recording. The circuit breaker:

- Detects failures from LLM SDK connection errors (APIConnectionError,
  APITimeoutError) — no per-request health checks, zero latency on the
  happy path
- Trips to OPEN after 2 consecutive connection failures
- In OPEN state, preemptively skips the proxy and retries via a
  throwaway client pointing at the original upstream URL (thread-safe,
  no shared mutable state)
- Creates a local Store + Recorder for direct-mode recording while
  proxy is down (session named "<original> (proxy-fallback)")
- After 30s recovery timeout, probes the proxy in HALF_OPEN state
- On successful probe, tears down direct resources and resumes proxy
  mode

Design decisions (from review):
- Throwaway client for retries (resolves thread-safety issue with
  base_url swap — review finding #1)
- Preemptive skip in OPEN state (zero latency penalty — finding #2)
- New session in local store on trip (simple, no cross-store merge —
  finding #3)
- APITimeoutError included in detection (finding #5)

Known limitations:
- Mid-stream proxy failure cannot be retried (LLM responses are
  non-deterministic)
- Timeout detection takes SDK timeout period (60-120s) × threshold

23 new tests: error detection (7), state machine (13), integration (3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR #51 review findings

- Fix #1 (HIGH): Add `from __future__ import annotations` for Python
  3.9 compat — `str | None` syntax is 3.10+
- Fix #2 (MEDIUM): Remove APITimeoutError from detection — timeouts
  may originate from slow upstream, not dead proxy. False trips when
  proxy is healthy but LLM is slow.
- Fix #3 (MEDIUM): Skip record_success() for streaming calls — stream
  object returned doesn't mean data has flowed. Proxy could die
  mid-stream after failure count was reset.
- Fix #4 (MEDIUM): Add retry-path test — verifies direct recorder
  stores steps correctly after circuit trips to OPEN
- Fix #5 (LOW): Copy `organization` in throwaway OpenAI client for
  org-scoped API key compatibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move imports to top of file to satisfy ruff E402

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
risjai added a commit that referenced this pull request Apr 13, 2026
Fixes all items from PR #56 review:

#1 (P0 bug) Retry by OpenAI exception type, not string matching:
  - New `_is_retryable()` checks `openai.RateLimitError`,
    `openai.APIStatusError(500/502/503)`, `ConnectionError`, `TimeoutError`
  - "Token limit 500 exceeded" no longer triggers false retries
  - Added 7 tests including retry-then-succeed integration test

#2 (P0 bug) Add --force flag to bypass stale score cache:
  - `rewind eval score --force` skips the `get_timeline_score` cache check
  - Upsert in `create_timeline_score` now reachable after code changes

#3 (P1 security) Sanitize API keys from stderr before storing:
  - `sanitize_secrets()` redacts `sk-[a-zA-Z0-9_-]{10,}` patterns
  - Applied to LLM judge subprocess stderr before writing to reasoning
  - 3 tests for single key, multiple keys, and clean text

#4 (P2) Remove unused `_session_id` from `extract_timeline_output()`:
  - Public API now takes `(store, timeline_id)` — cleaner signature
  - All callers (CLI + tests) updated

#5 (P2) Compare each fork against main, not just first vs last:
  - Delta section now shows one line per fork, each compared to main
  - Works correctly for 3+ timelines

#6 (P3) ValueError exits with code 1 in subprocess:
  - `ValueError` and `RuntimeError` now exit non-zero so Rust surfaces
    them as errors, not misleading zero scores
  - Test added for correctness-without-expected path

Tests: 26 Rust + 41 Python passing, clippy clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
risjai added a commit that referenced this pull request Apr 14, 2026
Four issues caught by independent reviewers (both flagged #1 and #2):

1. Command parsing: split_whitespace() → sh -c delegation. Handles
   quoted args, paths with spaces, pipes, and shell variables correctly.

2. Proxy readiness: replaced 500ms sleep with health endpoint polling
   (20 attempts × 100ms). Surfaces bind errors instead of swallowing
   them with `let _ =`. Bails with clear message if proxy doesn't start.

3. Session source guard: changed from blocking Direct only to
   allowlisting Proxy only. Hooks and OtelImport sessions also lack
   proxy-compatible HTTP blobs and would fail confusingly during replay.

4. Async process: std::process::Command::status() (blocking) →
   tokio::process::Command::status().await. Prevents blocking the
   tokio runtime during long-running agent commands.

Also: added clap `requires = "apply"` on --command flag so it can't
be used without --apply (previously silently ignored).

Made-with: Cursor
risjai added a commit that referenced this pull request Apr 14, 2026
…ewriting (#90)

* fix: sync Python store schema with Rust — add tool_name and source columns

The Python store was missing two v0.6 migrations that the Rust schema
already had: `steps.tool_name` and `sessions.source`. This caused
`get_steps()` to omit tool_name, which the upcoming `rewind fix`
diagnosis engine needs to identify which tool calls failed.

Changes:
- Add v0.6 ALTER TABLE migrations matching Rust db.rs lines 301-304
- Add `tool_name` param to `create_step()` and column to `get_steps()`
- Add tests for tool_name presence in step dicts

Made-with: Cursor

* feat: add rewind fix diagnosis engine (python/rewind_agent/fix.py)

LLM-powered diagnosis subprocess that analyzes failed agent sessions and
returns structured output: root cause, failed step, fix type, fix params,
and confidence level. Uses function calling to force structured output.

Supports 5 fix types: swap_model, inject_system, adjust_temperature,
retry_step (exploit non-determinism), and no_fix (agent code issue).

Follows the exact subprocess protocol from llm_judge.py: stdin JSON,
stdout JSON, 120s timeout, non-zero exit on config errors.

Made-with: Cursor

* feat: add `rewind fix` CLI command — AI-powered session diagnosis

Adds the `rewind fix <session>` command that analyzes a failed agent
session and prints a structured diagnosis with root cause, suggested
fix type, and confidence level.

The command loads session data from the local store, assembles a
diagnostic payload, and shells out to `python3 -m rewind_agent.fix`
(same subprocess protocol as llm_judge).

Supports: --step N (target specific step), --expected (soft failures),
--json (machine output), --diagnosis-model. The --apply, --command,
and --hypothesis flags are defined but stubbed for Phase 2-3.

Made-with: Cursor

* feat: add proxy request rewriting for `rewind fix --apply`

Adds RewriteConfig struct and apply_rewrites() to the proxy, enabling
LLM request modification after the fork point during replay.

Supports three rewrite types:
- model swap (same-provider only; cross-provider swaps blocked)
- system message injection (OpenAI role:system + Anthropic string/array)
- temperature override

Key design decisions from Santa Method review:
- Provider detection uses model name, not body structure
- Anthropic array-format system (for prompt caching) handled correctly
- Content-Length header filtered from copy loop (reqwest recalculates)
- Cross-provider model swaps log warning and skip (not silently fail)

Includes 14 unit tests covering all rewrite paths and edge cases.

Made-with: Cursor

* fix: address Santa Method review findings

- Add sys.exit(1) to Python catch-all exception handler so Rust CLI
  correctly surfaces unexpected diagnosis failures as errors (both
  reviewers flagged this independently)
- Handle stdin write failure explicitly instead of silently swallowing
  with `let _` — kills child and reports payload size on pipe failure
- Add try/except for json.loads of LLM function call arguments to
  produce a clear error when diagnosis model returns malformed output

Made-with: Cursor

* feat: implement `rewind fix --apply` and `--command` orchestration

Completes Phase 3: the full diagnose → fork → replay-with-patch →
score loop.

Three modes now work:
- `rewind fix latest` — diagnosis only (Phase 1, unchanged)
- `rewind fix latest --apply` — diagnose, dry-run preview, fork,
  start proxy with rewrites, wait for Ctrl+C, print savings
- `rewind fix latest --apply --command "python agent.py"` — fully
  automated: spawns agent with OPENAI_BASE_URL and ANTHROPIC_BASE_URL
  pointing at the patched proxy, waits for completion, prints savings

Also implements:
- `--hypothesis "swap_model:gpt-4o"` to skip diagnosis and directly
  test a fix theory (power user escape hatch)
- Dry-run preview with y/N confirmation (skip with --yes)
- Direct-mode session guard (--apply requires proxy-recorded sessions)
- retry_step fix type starts proxy with no rewrite config

Made-with: Cursor

* fix: address Santa review of Phase 3 orchestration

Four issues caught by independent reviewers (both flagged #1 and #2):

1. Command parsing: split_whitespace() → sh -c delegation. Handles
   quoted args, paths with spaces, pipes, and shell variables correctly.

2. Proxy readiness: replaced 500ms sleep with health endpoint polling
   (20 attempts × 100ms). Surfaces bind errors instead of swallowing
   them with `let _ =`. Bails with clear message if proxy doesn't start.

3. Session source guard: changed from blocking Direct only to
   allowlisting Proxy only. Hooks and OtelImport sessions also lack
   proxy-compatible HTTP blobs and would fail confusingly during replay.

4. Async process: std::process::Command::status() (blocking) →
   tokio::process::Command::status().await. Prevents blocking the
   tokio runtime during long-running agent commands.

Also: added clap `requires = "apply"` on --command flag so it can't
be used without --apply (previously silently ignored).

Made-with: Cursor

* fix: address 6 PR review items from risjai

1. Bug: run_fix_subprocess now uses spawn_blocking to avoid blocking
   the tokio runtime (same issue Santa caught for run_agent_command)
2. Bug: --step N errors when step not found instead of silently
   falling through to the auto-detection cascade
3. Design: added TODO comment for abort() → graceful shutdown
4. Design: renamed "Scoring both timelines..." to "Replay savings"
   since we show token/cost savings, not LLM eval scores
5. Nit: added ANTHROPIC_BASE_URL to manual --apply instructions
6. Nit: added RewriteConfig::is_empty() guard so rewrite log only
   fires when at least one field is set

Made-with: Cursor

* fix: resolve CI lint failures (ruff F841 + clippy collapsible-if)

- Remove unused step_id assignment in test_get_steps_includes_tool_name
- Refactor nested if-let to map_err pattern in run_fix_subprocess

Made-with: Cursor
risjai added a commit that referenced this pull request Apr 21, 2026
…I wiring

Addresses the review on #133.

Must-fix (reviewer #1, #2):
- Bump rand from 0.8 → 0.9 so the workspace ships a single rand major
  version (previously duplicated alongside quinn/proptest/tungstenite/otel)
- Drop the generic `B` and `transmute_body` helper from auth_middleware.
  axum 0.8's from_fn_with_state passes Request<axum::body::Body> directly.

Should-fix (reviewer #3, #4, #6):
- Scope `?token=<token>` query-param fallback to /api/ws ONLY. Browsers
  can't set Authorization on WebSocket upgrades; REST must not accept this
  form because it leaks via Referer, server logs, and browser history.
- Fail-closed now also catches IPv6 :: via is_loopback() already returning
  false for unspecified addrs (added explicit test).
- Token file creation is now atomic: OpenOptions::create_new + mode(0o600).
  Closes the umask window where fs::write created at 0644 before the
  subsequent chmod tightened it. Also handles AlreadyExists (concurrent
  writer won the race → re-read their token).

UI wiring for token-enabled deployments:
- New web/src/lib/auth.ts — minimal localStorage-backed token holder
- api.ts injects Authorization: Bearer on every request; prompts the user
  and retries once on 401, clears on second 401
- use-websocket.ts appends ?token= to the WS URL when a token is stored
- SPA static assets still load without a token (only /api/* and /api/ws
  are gated), so the UI loads the login prompt fine

Nits:
- Add TODO for `rewind auth rotate` command (follow-up)
- Tighten resolve_with_env to pub(crate) — it's test-only, not API surface
- Replace tmp.keep() with a returned TempDir guard so test data dirs are
  cleaned up on drop
- Replace flaky 100ms timeout sentinels with health-probe loops that wait
  for /_rewind/health to return 200

Tests:
- 17/17 auth_tests pass (was 12) — new: IPv6 ::, REST rejects ?token=,
  WS accepts/rejects with ?token=, concurrent token-file race
- Full workspace: 360+ tests, 0 failures
- Clippy clean on all modified files
- Manual smoke: dashboard loads, Bearer-auth REST works, WS upgrade
  with ?token= returns 101, without returns 401

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
risjai added a commit that referenced this pull request Apr 27, 2026
Santa returned NAUGHTY with 7 blocking findings. This commit addresses
all of them and adds 7 directed regression tests. Pre-push verification
suite (ruff + pytest local + pytest bare-env + clippy + cargo test) all
green — including a CI-equivalent simulated-bare-env pytest run that
hides httpx/requests/aiohttp via meta_path blocker (367 passed, 12
skipped, 0 failed in that env).

## Fix → Finding mapping

### #6 — Version bump skipped unpublished 0.15.0

Reverted python/pyproject.toml + python/rewind_agent/__init__.py
from 0.16.0 back to 0.15.0. Per CLAUDE.md track 2: master is at 0.15.0,
PyPI is at 0.14.8 — 0.15.0 is unpublished, so Phase 1 changes ride
with it. Bumping to 0.16.0 would skip the unpublished version.

### #1 follow-up — install tests must work in CI's bare env

test_intercept_install.py asserted all three adapters patched. CI
doesn't install httpx/requests/aiohttp, so HTTPX_AVAILABLE etc. all
flip to False, install() no-ops the missing adapters, and the
unconditional asserts fail.

Made the assertions conditional on *_AVAILABLE flags. Each adapter
is asserted patched if-and-only-if its library is importable. The
test_install_with_custom_predicates_applies_to_httpx test gains a
self.skipTest() guard since it actually exercises the transport
patch with a MockTransport (httpx-required).

### #4 — Strict-match 409 was silently swallowed

ExplicitClient._post wraps urllib in `try: ... except Exception:
return None`. When the server returns HTTP 409 strict-match
divergence, urllib raises HTTPError(409) → swallowed → caller sees
None → adapters interpret as cache miss → fall through to live.
Strict mode is broken.

Fix: new ExplicitClient._post_replay_lookup with explicit 409 handling.
HTTP 409 raises a new typed exception RewindReplayDivergenceError
(exported from rewind_agent). Other 4xx/5xx errors still degrade to
None (cache miss) so transient Rewind outages don't break the
agent's normal flow.

get_replayed_response / _async use the new method; their docstrings
note that strict-mode divergence propagates as an exception.
RewindReplayDivergenceError carries the server's diagnostic message
plus optional target_step / stored_hash / incoming_hash for caller
diagnostics.

### #5 — httpx configured default transport options were dropped

When user did `httpx.Client(verify=False, http2=True, ...)` without
transport=, our patched __init__ replaced transport=None with a fresh
RewindHTTPTransport() BEFORE httpx's __init__ ran. That short-
circuited httpx's own configured-default construction, dropping
verify / cert / trust_env / http2 / proxies / limits / local_address
/ retries / socket_options / default_encoding / etc.

Fix: two-mode patched_init.
  Mode (a) user passed transport=X — wrap X (existing behavior).
  Mode (b) user passed only top-level config — call original_init
  first (httpx builds its configured default at self._transport),
  then post-init wrap self._transport with RewindHTTPTransport(_inner=…).

Same fix applied to AsyncClient.__init__. The only fragility is
that we read self._transport which is httpx's documented internal
attribute (stable across 0.x).

### #2 — Live streaming responses pre-read body before passthrough

_flow.{handle_intercepted_async, _serve_cache_miss_sync} called
`await resp.json()` / resp.json() on the live response BEFORE
returning. For streaming responses this:
  - httpx: raises ResponseNotRead (body not yet streamed)
  - requests: marks _content_consumed=True, blocking iter_content
  - aiohttp: closes the connection after read

User code's streaming iteration breaks every time.

Fix: detect streaming via OR-combined transport hints + body-aware
detect_streaming(req) — see #3. For STREAMING misses: pass through
the live response immediately, record with placeholder
response_value=None and zero tokens. Tee-based stream-recording
(matching the Rust proxy's handle_streaming_response) is documented
as a follow-up (v1.1) — proper capture without consuming requires
a wrapping ByteStream that yields chunks AND captures, fired on
stream completion.

For NON-streaming misses (the common case for buffered chat
completions): pre-read remains correct — httpx/requests/aiohttp
buffer the body eagerly anyway.

### #3 — Body-only "stream": true wasn't routed to streaming

Adapters set req.stream from transport-level signals (Accept:
text/event-stream, library-specific stream=True hints). A request
with `{"stream": true}` in the JSON body but no Accept header was
treated as non-streaming — buffered cache hit instead of synthetic
SSE.

Fix: handle_intercepted_{sync,async} now OR-combines is_streaming
(adapter signal) with detect_streaming(req) (Phase 0 body-aware
helper that recognizes the JSON `"stream": true` field via regex,
plus Accept header, plus explicit RewindRequest.stream).

### #7 — docs and ray-agent migration deferred (not in this PR)

The plan listed docs/intercept-quickstart.md and the ray-agent port
as Phase 1 deliverables. They're absent from this PR and the PR
description should say so explicitly.

The ray-agent migration files (use-cases/ray-agent/code/rewind_setup.py,
use-cases/ray-agent/INTEGRATION-DIFF-V2.md) live in the local
working tree but use-cases/ is .gitignore'd in this repo — they're
meant to be copied into the ray-agent repo (separate repository).
This was already noted in the version-bump commit message but the
PR top-level description didn't make it visible. Will update the
PR description with a clearer "deferred" section in the next reply
to Santa.

docs/intercept-quickstart.md is a follow-up; the in-tree rustdoc on
intercept.install() and the operator-facing README in the
use-cases/ray-agent/ directory cover the immediate need.

## New regression tests (7 in tests/test_intercept_santa_fixes.py)

- test_strict_match_409_raises_typed_error — Santa #4
- test_non_409_http_error_is_swallowed_to_cache_miss — Santa #4 boundary
- test_verify_false_setting_survives_intercept_install — Santa #5
- test_user_supplied_transport_is_wrapped_not_replaced — Santa #5 mode (a)
- test_streaming_miss_passes_through_without_consuming_body — Santa #2
- test_cache_hit_with_body_stream_true_emits_synthetic_sse — Santa #3
- test_install_only_patches_available_adapters — Santa #1 follow-up

## Pre-push verification routine (followed BEFORE this push)

User correctly called out that I'd been pushing without running the
full lint/test stack locally. Established a 5-stage pre-push routine
that mirrors CI exactly:

  1. ruff check . — Python lint (CI's exact invocation)
  2. pytest tests/ in local env (with all libs installed)
  3. pytest tests/ in simulated bare env (httpx/requests/aiohttp
     blocked via sys.meta_path) — catches CI-only failures locally
  4. cargo clippy -- -D warnings — Rust CI's exact invocation
  5. cargo test --workspace — Rust CI's exact invocation

All 5 stages green for this commit:
  - ruff: All checks passed!
  - pytest local: 420 passed, 1 skipped (was 413; +7 Santa regression tests)
  - pytest bare env: 367 passed, 12 skipped, 0 failed (CI mirror)
  - cargo clippy: clean
  - cargo test: all green, 0 FAILED

Adding scripts/pre-push-check.sh as a reusable harness in a follow-up
commit so future sessions don't have to re-derive this routine.

Made-with: Cursor
risjai added a commit that referenced this pull request Apr 27, 2026
…ness bugs

Re-review at commit 1f9a2dd identified four real bugs surviving the
first round of Santa fixes. CI was green and prior items were
resolved, but mock-transport tests masked the real httpx behavior.
This commit fixes all four with directed regression tests using
shapes that match real (not mock) library behavior.

## Fix → Re-review finding

### Re-review #1 — httpx ResponseNotRead crashes on real transport

``_flow._read_response_body_{sync,async}`` called ``resp.json()``
directly. Real httpx response bodies are NOT auto-read at the
transport layer; they're streamed lazily and ``.json()`` raises
``httpx.ResponseNotRead`` until ``.read()`` / ``.aread()`` runs. The
prior tests used ``httpx.MockTransport`` + ``httpx.Response(json=…)``
which auto-reads the body via the constructor — masking the bug.

Fix: explicit body materialization before parsing.

  Sync path: try ``resp.read()`` (httpx) before ``resp.json()``.
  ``read()`` is idempotent on already-read responses (requests has
  ``_content`` populated; aiohttp doesn't reach the sync path).

  Async path: try ``await resp.aread()`` (httpx) first, falling back
  to ``await resp.read()`` (aiohttp) if aread isn't present. Both
  libraries diverge on whether the read API is async; we handle both.

Broadened the exception-catch in both paths from
``(json.JSONDecodeError, ValueError)`` to bare ``Exception`` so any
transport-specific exception (httpx.ResponseNotRead, aiohttp's
ContentTypeError, etc.) degrades to None recording rather than
propagating up through the user's call site.

### Re-review #2 — RewindHTTPTransport leaks _inner

``RewindHTTPTransport`` and ``RewindAsyncHTTPTransport`` delegated
``handle_request`` / ``handle_async_request`` to ``self._inner`` but
didn't override ``close()`` / ``aclose()``. When the user's Client
closes (explicitly or via ``__exit__``), only our own resources got
released — the wrapped configured/user transport leaked its
connection pool, SSL context, and cert verifier for the process
lifetime.

Fix: explicit close / aclose forwarding.

  def close(self):
      if self._inner is not None:
          try: self._inner.close()
          except Exception: pass
      super().close()

Async variant uses ``await self._inner.aclose()``. Both swallow
exceptions on the inner close to preserve the super().close()
invariant — even a buggy inner transport shouldn't leak our own
cleanup.

### Re-review #3 — aiohttp base_url + relative paths bypass intercept

``aiohttp_middleware._build_rewind_request`` did
``url = str(str_or_url)`` before aiohttp resolves relative URLs
against ``ClientSession._base_url``. So
``session.post("/v1/chat/completions")`` (typical OpenAI SDK pattern
when configured with a custom gateway base URL) produced a
RewindRequest whose URL was just the path. Host-based predicates
(the default — matches ``api.openai.com`` etc.) silently failed,
the request bypassed interception, no recording.

Fix: new ``_resolve_url(session, str_or_url)`` helper that
replicates aiohttp's own resolution logic. If ``session._base_url``
is set and the supplied URL doesn't start with a scheme
(``http:``/``https:``/``ws:``/``wss:``), yarl-join the two. yarl
is a hard aiohttp dep so importing it inside the helper is safe —
we wouldn't be in this code path otherwise.

Defensive: if yarl join blows up on a malformed URL, return the bare
string. Predicate match will fail (silent bypass) but at least we
don't crash the user's request.

### Re-review #4 — PR description metadata stale

Code reverted to SDK 0.15.0 in the previous fix commit, but the PR
description still said "0.15.0 → 0.16.0" and listed
``./scripts/publish-pypi.sh`` for SDK 0.16.0 in post-merge actions.
Will update the PR body via ``gh pr edit`` in the same push.

## Regression tests added (9 new in test_intercept_santa_fixes.py)

Total Santa fix tests: 7 (initial) + 9 (re-review) = **16 tests, all green**.

Re-review #1 coverage:
  - test_sync_read_called_before_json_on_unread_response —
    constructs a real ``httpx.Response`` with ByteStream (not pre-read)
    and verifies ``_read_response_body_sync`` reads + parses correctly
  - test_async_aread_called_before_json_on_unread_response — same for
    async path
  - test_buffered_cache_miss_through_real_transport_records_tokens —
    end-to-end through AsyncClient. The MockTransport handler returns
    ``httpx.Response(stream=ByteStream(body))`` (not the prior
    ``json=body`` shape that auto-reads) so this test actually
    exercises the fix.

Re-review #2 coverage:
  - test_sync_close_propagates_to_inner_transport — spy MockTransport
    counts close() calls; assert >= 1 after Client.close()
  - test_async_aclose_propagates_to_inner_transport — async equivalent

Re-review #3 coverage:
  - test_relative_path_resolves_against_base_url — direct unit test
    on _resolve_url with a yarl URL base
  - test_absolute_url_passes_through_unchanged — absolute URLs aren't
    mangled even when base_url is set
  - test_no_base_url_returns_input_unchanged — sessions without
    base_url stringify the path as-is
  - test_aiohttp_session_base_url_match_records_via_predicate —
    end-to-end through aiohttp.ClientSession(base_url=...) +
    relative POST + default host predicate. Asserts recording fires
    (= predicate matched the resolved URL).

## Pre-push verification (codified in scripts/pre-push-check.sh)

All 5 stages green BEFORE this push:

  - ruff check . — clean
  - pytest tests/ (local env, all libs) — 429 passed, 1 skipped
    (was 420; +9 re-review regression tests)
  - pytest tests/ (simulated bare env, libs hidden via meta_path) —
    367 passed, 12 skipped, 0 failed (CI mirror)
  - cargo clippy -- -D warnings — clean
  - cargo test --workspace — all green

The local-pre-push routine exists specifically because PR #149's
prior CI iterations missed bugs that depended on real-vs-mock
behavior; this re-review's #1 finding (ResponseNotRead) was visible
ONLY when constructing test responses with the ``stream=ByteStream(...)``
shape that matches real transport output, not the
``Response(json=...)`` shape that auto-buffers. Future regression
tests should default to the stream= shape for httpx wherever
realism matters.

Made-with: Cursor
risjai added a commit that referenced this pull request Apr 27, 2026
Reviewer caught two real correctness bugs in the Phase 2 decorator.
Both fixed with directed regression tests using the REAL
ExplicitClient guard (not patched away).

## Review #2 — custom cache_key didn't actually control cache identity

`_build_request_payload()` included `args_repr` and `kwargs_repr` in
the request body. Phase 0's content validation hashes the WHOLE body
to derive the request_hash, so two calls with the same custom
``cache_key=lambda client, q: q`` but different client objects
(different memory addresses → different reprs) produced different
request_hashes. Cache miss every time. The custom cache_key was
effectively cosmetic.

Fix: identity-only payload. Request body now contains only
``_rewind_decorator``, ``fn_name``, ``cache_key``. Args / kwargs
are NOT in the payload — when user passes
``cache_key=lambda client, q: q``, the unstable client repr is
correctly invisible to the server hash.

For dashboard display, fn_name + cache_key suffices to identify the
call. Power users wanting human-readable display in the dashboard can
use plain-string cache_keys (e.g. ``f"chat:{model}:{question[:50]}"``)
instead of opaque SHA-256 hashes.

Regression tests:
- ``test_custom_cache_key_payload_is_independent_of_arg_reprs`` —
  two object() instances with different reprs but same custom key
  produce IDENTICAL request payloads.
- ``test_decorator_with_custom_cache_key_records_stable_request_across_clients`` —
  end-to-end through the decorator: two recordings with different
  clients + same key produce equal request bodies.
- ``test_default_cache_key_payload_independent_of_unstable_object_args`` —
  pins the EXPECTED non-stability of default keys (the whole reason
  custom cache_key exists), so a future "smart" default doesn't
  silently drift.

## Review #1 — no-session quickstart records nothing

`ExplicitClient.record_llm_call()` returns None silently when
``_session_id`` is unset. The quickstart docs claimed "First call
hits OpenAI, records the return value" but unless the user
separately wrapped execution in ``client.session(...)``,
``ensure_session(...)``, or ``init()``, recording was a silent no-op.
Tests bypassed this with ``patch.object(ExplicitClient, "record_llm_call")``,
masking the bug.

This is consistent with the rest of the SDK (init() / intercept
have the same precondition), but the docs need to say so. Auto-
session creation in the decorator was considered and rejected:
naming, lifecycle, and thread-safety concerns; the explicit-session
pattern matches what users already do for other parts of the SDK.

Fix:
- New "Session requirement" section at the top of
  docs/cached-llm-call.md with three patterns (scoped session,
  long-lived ensure_session, init() auto-session).
- Quickstart updated to wrap the example in
  ``with client.session("my-quickstart"):``.
- Replay path documented inline.

Regression tests:
- ``test_no_session_function_runs_and_returns_live_result`` — verify
  decorator is a silent no-op for recording when no session active;
  function still runs, returns live result correctly.
- ``test_no_session_record_is_silent_no_op`` — calls the REAL
  ``ExplicitClient.record_llm_call`` (no patch) with no session and
  asserts it returns None without raising. The test the reviewer
  asked for: shows the decorator's behavior under the actual
  production guard.
- ``test_active_session_records_via_real_client_path`` — inverse:
  session-active path reaches _post (verified via patch on _post,
  not on record_llm_call). Confirms the guard semantics in both
  directions.

## Test count

- cached_call tests: 26 → **32** (+6 review regressions)
- Total Python tests: pre-push routine reports green across all 5 stages

## Pre-push verification

All 5 stages green BEFORE push (scripts/pre-push-check.sh):
  - ruff: clean
  - pytest local: passes
  - pytest bare-env (CI mirror): passes
  - cargo clippy: clean
  - cargo test --workspace: clean

Made-with: Cursor
risjai added a commit that referenced this pull request Apr 28, 2026
…tep+1 (#156)

The dispatch shape A path was passing a.at_step as the
create_replay_context from_step, conflating two distinct concepts:

  - at_step is the FORK point (controls which inherited prefix the
    fork timeline shares with main, and where new recordings get
    numbered)
  - from_step is the cache CURSOR base (controls which recorded step
    the next lookup targets)

Combined with peek_next_replay_step returning current_step + 1,
the agent's first cache lookup targeted recorded step at_step + 1.
Concrete repro on dev1 with at_step=1, session "What clusters are
running?" (3-step recording):

  agent first LLM call -> lookup target = 1 + 1 = 2
                          step 2 is tool_call, expected LLM -> MISS
  agent first tool call -> lookup target = 1 + 2 = 3
                           step 3 is LLM, expected tool -> MISS

Both lookups missed, the agent re-ran every step live, and the
dashboard diff rendered the entire recording as "main only" past
step at_step. Looked like the cache was broken; was actually
ordinal misalignment.

Fix: always pass 0 as from_step. Ray Serve runners (and any other
client that spawns the agent fresh per dispatch) re-execute from
scratch, so the cache must align with recorded step #1 on the
first call. The fork still inherits the at_step prefix so
operators get the "fork from step N" UX they expect; only the
cursor is decoupled from the fork point.

20 replay_jobs_tests pass including new regression
shape_a_replay_context_starts_cursor_at_recording_step_one.

Made-with: Cursor
shivam2199 pushed a commit to shivam2199/rewind that referenced this pull request Apr 29, 2026
…CAL-02) (agentoptics#133)

* feat(auth): fail-closed auth for non-loopback web server binds

Addresses CRITICAL-02 from the security audit: when Rewind bound to a
non-loopback address (documented K8s pattern with REWIND_BIND_HOST=0.0.0.0),
all API/WebSocket/OTLP routes were exposed without authentication. Any
reachable host could read full LLM prompts/responses, pollute sessions,
or chain into the SSRF via the OTel export endpoint.

Changes:
- New `rewind-web` auth middleware with constant-time token comparison
  (subtle::ConstantTimeEq)
- `WebServer::run()` refuses to start on non-loopback bind without a
  configured token; `--no-auth` is an explicit escape hatch
- New `--auth-token` / `REWIND_AUTH_TOKEN` CLI flags; auto-generates a
  64-char hex token at ~/.rewind/auth_token (chmod 0600) on first
  non-loopback start
- Loopback behavior unchanged (backward compat for MCP/SDK/CLI flows)
- /_rewind/health bypasses auth (liveness probe)
- 6 unit tests + 12 integration tests covering fail-closed, escape hatch,
  token precedence, file permissions, and per-route enforcement

Scope: loopback deployments remain open by default. Loopback WebSocket-CSRF
(MEDIUM-09) is partially addressed — fully closed on non-loopback, still
open on loopback (follow-up PR).

Plan: /Users/jain.r/.claude/plans/squishy-strolling-bachman.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(auth): address PR agentoptics#133 review — rand dedup, WS ?token=, TOCTOU, UI wiring

Addresses the review on agentoptics#133.

Must-fix (reviewer agentoptics#1, agentoptics#2):
- Bump rand from 0.8 → 0.9 so the workspace ships a single rand major
  version (previously duplicated alongside quinn/proptest/tungstenite/otel)
- Drop the generic `B` and `transmute_body` helper from auth_middleware.
  axum 0.8's from_fn_with_state passes Request<axum::body::Body> directly.

Should-fix (reviewer agentoptics#3, agentoptics#4, agentoptics#6):
- Scope `?token=<token>` query-param fallback to /api/ws ONLY. Browsers
  can't set Authorization on WebSocket upgrades; REST must not accept this
  form because it leaks via Referer, server logs, and browser history.
- Fail-closed now also catches IPv6 :: via is_loopback() already returning
  false for unspecified addrs (added explicit test).
- Token file creation is now atomic: OpenOptions::create_new + mode(0o600).
  Closes the umask window where fs::write created at 0644 before the
  subsequent chmod tightened it. Also handles AlreadyExists (concurrent
  writer won the race → re-read their token).

UI wiring for token-enabled deployments:
- New web/src/lib/auth.ts — minimal localStorage-backed token holder
- api.ts injects Authorization: Bearer on every request; prompts the user
  and retries once on 401, clears on second 401
- use-websocket.ts appends ?token= to the WS URL when a token is stored
- SPA static assets still load without a token (only /api/* and /api/ws
  are gated), so the UI loads the login prompt fine

Nits:
- Add TODO for `rewind auth rotate` command (follow-up)
- Tighten resolve_with_env to pub(crate) — it's test-only, not API surface
- Replace tmp.keep() with a returned TempDir guard so test data dirs are
  cleaned up on drop
- Replace flaky 100ms timeout sentinels with health-probe loops that wait
  for /_rewind/health to return 200

Tests:
- 17/17 auth_tests pass (was 12) — new: IPv6 ::, REST rejects ?token=,
  WS accepts/rejects with ?token=, concurrent token-file race
- Full workspace: 360+ tests, 0 failures
- Clippy clean on all modified files
- Manual smoke: dashboard loads, Bearer-auth REST works, WS upgrade
  with ?token= returns 101, without returns 401

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(auth): use percent-encoding crate; strengthen loopback test

Follow-up to the PR agentoptics#133 second-pass review.

Blocker resolved:
- Replace the hand-rolled percent-decoder in `extract_token` with
  `percent_encoding::percent_decode_str` (already transitive via reqwest/
  url/axum; now an explicit dep). Deletes ~30 lines and fixes two bugs
  in the hand-rolled version:
  1. `+` was translated to space. That's `application/x-www-form-urlencoded`
     semantics; RFC 3986 query strings treat `+` as a literal. A token
     with a `+` would fail comparison.
  2. `i + 2 < bytes.len()` was off-by-one, so a valid trailing `%AB`
     escape was treated as literal chars at the end of the query.

Regression tests for both bugs:
- `ws_upgrade_accepts_token_with_literal_plus` — token `abc+def+ghi`
- `ws_upgrade_accepts_token_with_trailing_percent_escape` — token `abc/`
  encoded as trailing `%2F`

Nit addressed:
- `run_allows_loopback_bind_without_token` now also hits `/api/sessions`
  (not just `/_rewind/health`, which bypasses auth regardless). This
  actually exercises the backward-compat claim that loopback + no-token
  leaves normally-protected routes open.

Not addressed (flagged as follow-up, non-blocking):
- Access-log redaction of `?token=` values — no access-log layer exists
  yet; address when one is introduced
- Replace `window.prompt` with a proper modal UI

Tests: 19/19 auth (was 17), full workspace 360+/0, clippy clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ui): update api.test.ts for auth wiring + handle missing localStorage

CI build was failing because the existing `api.test.ts` expected bare
`fetch(path)` calls, but `api.ts` now always routes through `request()`
which adds a `headers` argument (empty when no token).

Two fixes:

1. Update the test assertions to match the new call shape. Added coverage
   for the auth injection path:
   - Authorization: Bearer is sent when a token is stored
   - POST requests merge auth with Content-Type correctly
   - Unauthed (loopback default) still passes through

2. `auth.ts` now has an in-memory fallback when `window.localStorage` is
   unavailable. jsdom in this vitest setup doesn't expose localStorage,
   so `setToken`/`getToken` silently no-oped. Real browsers still use
   localStorage; Safari private mode and jsdom fall through to memory.

Tests:
- 113/113 UI tests pass (was 111)
- 19/19 auth_tests still green
- UI build succeeds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
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.

1 participant