trajectory/track a opencode luthien bridge - #614
Conversation
PaoloC68
commented
Apr 28, 2026
- feat(telemetry): default OTEL exporter to HTTP/protobuf instead of gRPC
- chore: add .opencode symlink
- feat: role-based auth separation — reject CLIENT_API_KEY on admin endpoints
- feat: StringReplacementPolicy request-side filtering and dashboard observability
- fix(history): strip policy-context from session preview message
- chore: regenerate .env.example after PR feat(telemetry): default OTEL exporter to HTTP/protobuf instead of gRPC #562 adds OTEL_EXPORTER_OTLP_PROTOCOL field
- feat(health): report database connectivity in /health, add /ready endpoint
- feat: configurable upstream header injection via UPSTREAM_HEADERS env var
- review: add changelog fragment, document config bypass and security surface
- fix: case-insensitive collision check for reserved upstream headers
- feat: link inbound traceparent to Luthien spans for distributed trace correlation
- feat: extract and store user identity from X-Luthien-User-Id header or JWT sub claim
- chore: workspace init for issue 561
- chore: set objective to data retention policy (Feature: Data retention policy and archival for conversation storage #561)
- feat: add conversation data retention with configurable purge and S3 archival
- fix: add missing retention index migration (015) omitted from PR feat: data retention policy with configurable purge and optional S3 archival #571
- feat: add webhook event export for conversation completion
- feat(history): add server-side session search API with full-text content search
- fix(history): consolidate query paths, fix time filter and SQLite search bugs
- refactor(history): inline shared query impl, drop wrapper indirection
- fix(history): address review feedback on feat(history): server-side session search API (supersedes #578) #581
- fix: renumber session search migration from 014 to 016 (014 and 015 already taken)
- chore: add changelog fragments and clear objective for trajectory week 0
- fix: address PR review findings — KeyError→ValueError, JSONL error message, webhook exception callback, boto3 asyncio.to_thread, PolicyContext.for_testing user_id, migration 016 comment
- fix: generate_settings.py emits Field(validation_alias) when env_var != auto-derived name (fixes OTEL_EXPORTER_OTLP_PROTOCOL)
- chore: gitignore .sisyphus/ agent state and local business/meeting notes
- feat(security): gate X-Luthien-User-Id header behind TRUST_USER_ID_HEADER config flag and sanitize input (length limit + strip control chars)
- fix(history): pass client_api_key to check_auth_or_redirect in history_list_page for consistent role-based auth messaging
- chore(telemetry): use direct attribute access for settings.otel_exporter_protocol (field is now guaranteed to exist)
- feat(hooks): add staged-file safety check to pre-commit hook — blocks sensitive docs, agent state, and meeting transcripts
- fix(retention): use dialect-aware purge on SQLite — CTE DELETE RETURNING not supported in SQLite
- docs(processor): clarify non-streaming webhook fires only on success path
- docs(history): update SessionSearchParams.user docstring — user_id filter is live (PR Feature: User identity extraction and storage (who made each request) #554 merged)
- fix(webhook): store task references in _pending_tasks to prevent GC; narrow exception to httpx/asyncio/OSError; document type: ignore
- fix(migration): add comment explaining SQLite 014 lacks IF NOT EXISTS (SQLite limitation, idempotency via migration runner)
- fix(policy): validate StringReplacementPolicy replacement pairs have exactly 2 elements at config load time
- fix(security): strip CRLF from upstream header template expansion to prevent header injection
- fix(retention): make ConversationPurger.start() idempotent — guard against duplicate task creation
- fix(webhook): sanitize URL before logging to prevent credential leakage from embedded auth in WEBHOOK_URL
- fix(security): sanitize JWT sub claim — strip control chars and truncate to 256 chars (same as header extraction)
- fix(main): use _safe_url for webhook startup log to avoid logging embedded credentials
- fix(policy): reject empty from_str in StringReplacementPolicy config at validation time
- fix(security): validate upstream header names against RFC 7230 token spec — reject malformed names at load time
- fix(history): guard int() casts with 'or 0' fallback to prevent TypeError on None aggregates
- fix(history): validate from_time <= to_time in SessionSearchParams at parse time
- fix(search): extend tsvector search to response events (migration 017) — response text was never searchable
- fix(retention): archive all columns to prevent data loss on new columns (session_id, user_id)
- fix(processor): deep-copy initial_request to preserve original snapshot across in-place policy mutations
- fix(webhook): preserve port in _safe_url — debugging internal webhook endpoints
- fix(search): user filter matches user_id column (not session_id prefix)
- style(history): use lazy %-formatting for logger.warning calls instead of f-strings
- docs(service): add SECURITY INVARIANT comment on fetch_session_list f-string SQL construction
- feat(webhook): add WEBHOOK_MAX_PENDING_TASKS cap to prevent unbounded task accumulation when endpoint is slow or down
- fix(auth): check_auth_or_redirect redirects to /login?error=not_configured when ADMIN_API_KEY is unset — was silently granting access
- refactor(webhook): rename _safe_url to safe_url (public property — accessed from main.py)
- fix(migration): change _extract_event_search_text from IMMUTABLE to STABLE — PL/pgSQL functions calling STABLE functions should not be IMMUTABLE
- style(tests): replace inline import("datetime") with standard import at module level
- docs(history): update user query param description — filter now uses user_id (not session_id prefix)
- docs(changelog): document breaking change — admin UI requires ADMIN_API_KEY when LOCALHOST_AUTH_BYPASS=false
- feat(webhook): add WebhookSender.stop() for graceful shutdown — cancel in-flight tasks on gateway exit
- fix(history): skip corrupt turns in fetch_session_detail instead of raising — single bad event no longer makes entire session unviewable
- **feat(upstream-headers): warn at load time when templates reference potentially sensitive env vars (*KEY, SECRET, PASSWORD, DATABASE_URL, REDIS_URL)
- fix(emitter): move dropped_db_writes from class-level to instance-level attribute
- chore: remove customer-identifying information from committed files
- style(history): clarify is_error coercion — True if truthy else None is more explicit than 'or None'
- fix(settings): use uppercase env_var in validation_alias — resilient to case_sensitive=True if ever added
- fix(processor): clarify empty stream error message — 'No response from policy execution' instead of misleading 'blocked' message
- test(webhook): add stop() graceful shutdown tests
- test(history): add SQL injection regression tests for user filter (LIKE escaping)
- style: remove dead result=[] variable in _apply_capitalization_pattern; clarify callback ordering in fire_and_forget
- fix(history): strip tags from session preview messages
- fix(test): override get_api_key in ui/test_routes fixture
- fix(test): pass admin key in ui/test_routes fixture
- fix(main): remove duplicate _webhook_sender.stop() in shutdown path
- chore: set objective to Track A opencode-luthien plugin + gateway multi-provider bridge
gRPC fails behind HTTP load balancers (ALB, nginx) with StatusCode.UNAVAILABLE — a common deployment pattern. HTTP/protobuf works everywhere and is the safer default. Changes: - Add otel_exporter_protocol config field (OTEL_EXPORTER_OTLP_PROTOCOL) - Default to 'http/protobuf', 'grpc' available via env var override - Add opentelemetry-exporter-otlp-proto-http dependency - Import both exporters, select based on protocol config - Silence HTTP exporter logger alongside gRPC logger - Add tests: default uses HTTP, explicit grpc uses gRPC, unknown falls back to HTTP Resolves #556
…points Enforce role separation between proxy key (CLIENT_API_KEY) and admin key (ADMIN_API_KEY). Previously, a proxy key would fail admin endpoints with a generic 403; now it receives an explicit rejection message telling the operator they are using the wrong key type. Changes: - verify_admin_token: injects client_api_key via Depends(get_api_key); if presented token matches CLIENT_API_KEY but not ADMIN_API_KEY, raises 403 with 'Proxy API key cannot be used for admin access. Use ADMIN_API_KEY.' - check_auth_or_redirect: adds optional client_api_key parameter; if presented token matches CLIENT_API_KEY but not ADMIN_API_KEY, redirects to /login?error=proxy_key instead of generic required error - history/routes.py, ui/routes.py: inject client_api_key and pass to check_auth_or_redirect on all protected UI endpoints - 11 new unit tests covering: proxy key rejection (Bearer + x-api-key), admin key regression, same-key-for-both local dev case, no-client-key fallthrough Edge cases: - CLIENT_API_KEY == ADMIN_API_KEY: admin key check passes first → access granted (local dev convenience) - CLIENT_API_KEY not set: no change in behavior - Proxy auth (verify_token in gateway_routes.py) is untouched Closes #555
…servability
- Add apply_to config option ('request', 'response', 'both'; default 'response')
- Implement on_anthropic_request to strip patterns from user messages and tool results
- Use context.record_event for both request and response interventions (dashboard visible)
- 69 unit tests covering all content types, apply_to modes, and observability
- Changelog fragment added
Closes #557
When inject_policy_awareness_anthropic is enabled, the policy-context tag is prepended to the first user message. This caused the /history session list to show the policy boilerplate as the preview instead of the actual user message, making every session look identical. Fix: add _POLICY_CONTEXT_PATTERN regex (analogous to _SYSTEM_REMINDER_PATTERN) and strip it from content in _extract_preview_message before returning the preview. After stripping, the actual user content is returned. Tests: 5 new cases in TestExtractPreviewMessage covering string prefix, block prefix, fallthrough when content is only policy-context, multiple policies, and regression for normal messages. Closes #559
…PROTOCOL field
…point
/health (liveness probe):
- Pings database via SELECT 1 and reports status
- Returns {status: 'healthy', database: 'connected'} when DB is up
- Returns {status: 'degraded', database: 'unreachable', database_error: '...'}
when DB is down
- Always HTTP 200 (process is alive regardless of DB state)
/ready (readiness probe):
- Returns HTTP 200 only when fully ready to serve traffic
- Checks: database connected + policy loaded
- Returns HTTP 503 with {status: 'not_ready', reasons: [...]} otherwise
- Use for ALB/ECS/k8s readiness probes to avoid routing traffic before
the gateway can handle requests
Tests:
- test_health_reports_db_connected: DB ping succeeds → healthy + connected
- test_health_reports_db_unreachable: DB down → degraded + unreachable
- test_ready_returns_200_when_ready: DB + policy OK → 200
- test_ready_returns_503_when_db_unreachable: DB down → 503
Resolves #552, resolves #553
… var
Adds support for injecting custom headers into upstream (backend) API
requests. Headers are configured via the UPSTREAM_HEADERS environment
variable (JSON object) with template variable expansion:
- ${session_id} — Claude Code session UUID
- ${request_path} — HTTP request path
- ${env.VARNAME} — Any environment variable
Primary use case: chaining Luthien in front of Helicone or other
LLM observability proxies that require custom headers for session
tracking, user identity, and analytics integration.
Example:
UPSTREAM_HEADERS='{"Helicone-Auth":"Bearer ${env.HELICONE_API_KEY}","Helicone-Session-Id":"${session_id}"}'
Resolves #548
…urface Address review feedback on PR #549: - Add changelog fragment (changelog.d/upstream-headers.md) - Document intentional config system bypass in module docstring - Document env var expansion security surface in module docstring - Note restart-required behavior for lru_cache
If UPSTREAM_HEADERS contains a header that collides with a client-forwarded header (e.g. 'Anthropic-Beta' vs 'anthropic-beta'), the client header now always wins via case-insensitive dedup. Prevents sending duplicate logical headers to the upstream API.
… correlation Extract W3C traceparent header from incoming requests and use it as the parent context for Luthien's root span. This links Luthien spans into the caller's distributed trace (e.g. Claude Code → Luthien → Datadog shows as one connected trace instead of disjoint spans). Uses opentelemetry.propagate.extract() which handles W3C Trace Context (traceparent/tracestate) automatically.
…r JWT sub claim Add user identity extraction to the proxy pipeline. Identity is resolved from: 1. X-Luthien-User-Id custom header (explicit, highest priority) 2. JWT 'sub' claim from the Authorization Bearer token The resolved user_id is stored on conversation_calls and available in the history API for filtering sessions by user. Includes migrations (Postgres + SQLite) adding user_id column to conversation_calls table. Resolves #554
…archival - Add CONVERSATION_RETENTION_DAYS config field (int, default: disabled) - Add ARCHIVE_S3_BUCKET and ARCHIVE_S3_PREFIX config fields for optional S3 archival - Implement ConversationPurger background task (follows TelemetrySender pattern) - Implement S3ConversationArchiver for JSONL archival before purge - Wire purger into app lifespan (start on startup, stop on shutdown) - 18 unit tests covering purger and archiver behavior - Regenerate settings.py and .env.example from updated config_fields - Add changelog fragment Closes #561
…ent search Extend the /api/history/sessions endpoint with server-side filtering: - user: filter by user_id (prefix match) - model: filter by model name - from/to: filter by time range (ISO 8601) - q: full-text content search via Postgres tsvector index Adds migration 014_add_session_search_tsvector.sql for both Postgres and SQLite backends. Postgres uses a generated tsvector column with GIN index for efficient full-text search. SQLite falls back to LIKE. Includes 432-line test suite covering all filter combinations, edge cases, and both database backends. Resolves #558
…rch bugs Address review feedback from #578 (comment) Architecture: - Consolidate _fetch_session_list_pg and _fetch_session_list_sqlite into one shared implementation with dialect branches only for FTS, JSON access, and FILTER(WHERE). The SQLite 3-query pattern (stats → models → previews) works on both backends since follow-up queries use WHERE session_id IN (page_ids). - Eliminates ~320 lines of parallel query construction and the associated param-index fragility. Bug fixes: - Time filter (from_time/to_time) no longer mutilates session stats. Sessions are now qualified by aggregated MIN/MAX timestamps first, then full stats are computed in a separate step without the time predicate. - SQLite q parameter now escapes LIKE metacharacters (%, _, \) matching the existing user filter escaping, with ESCAPE '\\' clause. - SQLite content search now extracts message text via json_each/json_extract instead of matching raw JSON payload. Searches only message content values, not structural keys like 'role', 'type', 'final_request'. Tests: - Add 6 bug demonstration tests from upstream review (test_search_bugs.py) - All 6 now pass; 1962 total tests passing
Replaces the `_fetch_session_list_pg` / `_fetch_session_list_sqlite` wrappers with a direct call to the shared implementation inside `fetch_session_list`. The wrappers were set up to preserve mock targets in `test_search.py`, using a sentinel attribute on the function object (`_shared_impl_active`) to distinguish "entry point" from "shared impl" calls. This indirection is subtle and hard to read; the cleaner fix is to drop the dispatcher-only tests, since SQL behavior is already covered by `test_service_sqlite.py` and `test_search_bugs.py` against a real in-memory database. Co-Authored-By: Sami Jawhar <sami@thecybermonk.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix to_time semantics: use MAX(created_at) <= to_time so that a session whose last activity is after the upper bound is excluded (matches docstring; consistent with from_time's MAX >= from_time). - Add a model_expr_on(alias) helper instead of replacing 'payload' inside model_expr via string substitution. - Drop dead "models"/"request_payload" guards in the post-fetch merge — session_stats no longer surfaces those columns after consolidation. - Rename test_search_bugs.py to test_search_regressions.py and rename classes/prefixes away from "Bug" so the file reads as regression coverage of previously-fixed issues. - Add a positive to_time test (fully-contained session) and a negative test (session with last_ts after to_time is excluded). - Remove two DB-mocked tests in test_service.py whose row shape predates the unified query path; real SQL behavior is covered by test_service_sqlite.py and test_search_regressions.py.
…ssage, webhook exception callback, boto3 asyncio.to_thread, PolicyContext.for_testing user_id, migration 016 comment
…!= auto-derived name (fixes OTEL_EXPORTER_OTLP_PROTOCOL)
…ADER config flag and sanitize input (length limit + strip control chars)
…y_list_page for consistent role-based auth messaging
…ter_protocol (field is now guaranteed to exist)
… sensitive docs, agent state, and meeting transcripts
PR Review: trajectory/track a opencode luthien bridge (#614)This is a thorough review covering security, code quality, architecture, test coverage, and migrations across all 92 changed files and 86 commits. Process: "One PR = One Concern" ViolationThis PR bundles 10+ independent features into a single 7,052-addition PR. Per the project's own CLAUDE.md:
The bundled features (passthrough routes, retention system, webhook sender, session search, user identity extraction, upstream header injection, 5 database migrations, auth changes, etc.) could each be reviewed, tested, and merged independently. Bundling makes it impossible to revert a single feature without reverting everything, and bypasses the COE process for any bugs found later. Recommendation: At a minimum, the new passthrough routes (Track A feature) should be split from the "Trajectory Week 0" merges since they are clearly separate concerns with different risk profiles. Critical Issues1. Passthrough routes forward ALL upstream response headers to client
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
)The buffered passthrough response forwards every header from upstream (OpenAI, Gemini) back to the client unfiltered. This includes hop-by-hop headers ( Risk: Header injection from upstream, HTTP response smuggling if Fix: Apply an allowlist filter to 2. PASSTHROUGH auth mode creates an open proxy to OpenAI/Gemini
if auth_mode == AuthMode.PASSTHROUGH:
return token or ""When auth mode is Risk: Unauthorized consumption of server-side API keys. The gateway becomes an open proxy. Fix: At minimum, require a token even in 3. Streaming passthrough doesn't forward upstream status code
return StreamingResponse(stream_chunks(), media_type="text/event-stream")
Fix: Capture the upstream status code and content type before streaming begins and pass them to Medium Issues4. Unbounded memory in S3 archiver
rows = await db_conn.fetch(
"SELECT * FROM conversation_calls WHERE created_at < $1 ORDER BY created_at", cutoff)
jsonl_lines = [json.dumps(_row_to_dict(row)) for row in rows]
body = "\n".join(jsonl_lines).encode("utf-8")Loads all qualifying rows into memory, then builds the full JSONL string, then encodes it. For a deployment with millions of old records, this could exhaust memory. Also uses Fix: Batch into chunks (e.g., 1000 rows) with streaming S3 multipart upload. 5. Module-level httpx clients never closed
_streaming_client = httpx.AsyncClient(...)
_buffered_client = httpx.AsyncClient(...)These module-level async clients are created at import time and never cleaned up during shutdown. The Fix: Either manage them through the lifespan context, or add cleanup calls to the shutdown sequence. 6. Migration 016 backfill evaluates function twice per row
UPDATE conversation_events
SET search_vector = to_tsvector('english', COALESCE(_extract_event_search_text(payload), ''))
WHERE event_type = 'transaction.request_recorded'
AND _extract_event_search_text(payload) IS NOT NULL;Calls Fix: Use a subquery or CTE to evaluate the function once. 7. No S3 encryption-at-rest configuration
The Fix: Add an optional 8. Webhook sender creates new httpx client per delivery attempt
async with httpx.AsyncClient(timeout=SEND_TIMEOUT_SECONDS) as client:Each retry attempt creates a new connection pool and TLS handshake. With 3 retries, that's 4 TCP connections per webhook event. Fix: Create one client in Low Issues
What's Good
Summary
Blocking recommendations before merge:
|
PR #614 Code ReviewThis is a very large PR (7,244 additions across 92 files) bundling dozens of features, fixes, and migrations. The review below focuses on the highest-risk findings across security, correctness, and test coverage. CRITICAL1. SSRF via unvalidated upstream base URL env overrides (passthrough_routes.py:33-38) _upstream_base() trusts OPENAI_BASE_URL, GEMINI_BASE_URL, ANTHROPIC_BASE_URL env vars without validation. If an attacker can influence env vars (shared hosting, PaaS config injection, .env compromise), they can redirect traffic to internal hosts. Combined with the user-controlled path parameter being unsanitized, this is a potent SSRF vector for a proxy service. Recommendation: Validate resolved upstream URLs use https://, block RFC 1918 / link-local / loopback addresses, and sanitize path to reject .. or encoded traversal sequences. 2. No blocklist for security-sensitive header names (upstream_headers.py:52-53, 122-141) _HEADER_NAME_RE validates RFC 7230 token syntax, but nothing prevents UPSTREAM_HEADERS from overriding Authorization, x-api-key, Host, or Content-Length. A misconfigured UPSTREAM_HEADERS could override the credential-resolved auth header. Recommendation: Add a _BLOCKED_HEADER_NAMES frozenset and reject them in _load_header_templates(). HIGH3. Streaming passthrough returns hardcoded HTTP 200 (passthrough_routes.py:156) StreamingResponse always returns 200. By the time the generator reads the upstream status code, FastAPI has already committed the response line. An upstream 401 or 429 silently appears as 200. 4. Streaming media_type hardcoded to text/event-stream (passthrough_routes.py:156) The upstream actual Content-Type is ignored. Gemini streaming may use application/json (newline-delimited), breaking clients that check media type. 5. Response headers forwarded without filtering (passthrough_routes.py:174) The buffered response path forwards dict(response.headers) verbatim, including hop-by-hop headers (transfer-encoding, connection, keep-alive). The existing Anthropic route in gateway_routes.py:249-252 correctly filters to an allowlist. 6. JWT sub claim used without signature verification (session.py:114-153) extract_user_id_from_bearer_token() decodes JWT payload without signature verification. The extracted user_id flows into conversation events, PolicyContext, OTel spans, and webhook payloads. An attacker can craft a JWT with arbitrary sub to impersonate users in audit logs. 7. Non-atomic archive-then-delete risks data loss (purger.py:76-116, archiver.py:91) The archive+purge sequence is: SELECT rows, S3 upload, DELETE by timestamp. Between SELECT and DELETE, rows matching the cutoff could be inserted and then deleted without being archived. On SQLite, the COUNT + DELETE are separate statements without a transaction. Recommendation: Use SELECT ... FOR UPDATE on Postgres, or archive by row ID rather than timestamp. Wrap SQLite COUNT+DELETE in an explicit transaction. 8. _build_outbound_headers (core security function) is completely untested (test_passthrough_routes.py) This function controls credential stripping, auth header injection per provider, and x-luthien-* removal. Zero direct test coverage. The streaming path (lines 133-156) is also entirely untested. 9. CLIENT_KEY mode with api_key=None: silent full lockout (passthrough_auth.py:46-66) When auth_mode=CLIENT_KEY but CLIENT_API_KEY is not configured, all requests are silently rejected with 401. No log, no startup warning. BOTH mode with no key silently degrades to PASSTHROUGH. MEDIUM
TEST COVERAGE GAPS
POSITIVE OBSERVATIONS
OVERALL ASSESSMENTCode quality is generally good: parameterized queries, credential stripping, immutable policy state, solid regression tests. However, the passthrough routes module has the densest cluster of issues: SSRF risk, incorrect streaming error propagation, header forwarding without filtering, and critically low test coverage. I would recommend addressing the CRITICAL and HIGH items in passthrough routes before merging, or splitting that module into its own PR for focused review. The retention/archival system has a real data-loss risk from non-atomic archive+delete. The search and history changes look solid. Generated with Claude Code |
PR Review: trajectory/track-a-opencode-luthien-bridge (#614)Scope: 98 files, +7259/-419 lines. Bundles ~60 commits spanning retention system, webhook events, session search, passthrough proxy (OpenAI/Gemini), upstream headers, user identity extraction, auth hardening, and numerous security fixes.
High Severity1. Module-level httpx clients never closed — resource leak
2. Streaming passthrough always returns HTTP 200 regardless of upstream status
3. OTEL endpoint/protocol default mismatch The default protocol was changed to Medium Severity4. Unbounded
5. Archive-then-delete is not atomic If the process crashes between S3 upload completing and the DELETE executing, the next run re-archives the same rows (creating S3 duplicates). The archive is idempotent from a data-safety perspective (no data loss), but the reverse case (archive succeeds, delete fails) deserves at minimum a comment, and ideally a deduplication key in the S3 path. 6. Upstream response headers forwarded without filtering For buffered responses, 7. Migration number collisions These prefix numbers collide with existing migrations on 8. SQLite full-text search has no query timeout The SQLite fallback for full-text search uses Low Severity9. Webhook URLs may contain auth tokens in query strings or userinfo. The 10. Replacement counting logic duplicated 4 times The same counting pattern (iterate replacements, call 11. Plugin URL inconsistency 12. Broken link in 13. Unused Missing Test Coverage
Security Positives (good changes in this PR)
Architecture ObservationThis PR bundles ~60 independent commits across 6+ features. Per the repo's own guidance ("One PR = One Concern" / "Bug fixes bundled into feature PRs bypass the COE process"), the retention system, webhook system, session search, passthrough proxy, upstream headers, and the numerous security fixes could each be separate PRs. This would make review more tractable and allow independent merging. 🤖 Generated with Claude Code |
PR Review: #614 — Track A opencode-luthien bridgeThis is a very large PR (99 files, ~7,300 additions) that bundles 10+ feature PRs from the Trajectory Labs engagement plus the Track A opencode-luthien bridge. Given the scope, this review focuses on the most impactful areas: security, architectural concerns, code quality, and test coverage. 🔴 High-Priority Issues1. Passthrough auth + server-side key injection creates an open proxy in PASSTHROUGH modeFiles: In This means in PASSTHROUGH mode, any client who can reach the gateway can spend the operator's OpenAI/Gemini credits with zero authentication. The naming is also misleading: "passthrough" implies the client's own key is forwarded, but for OpenAI/Gemini the opposite happens — the server's key is injected. Similarly in BOTH mode (line 58): after the CLIENT_API_KEY check fails, any non-empty token is accepted. This makes BOTH mode effectively identical to PASSTHROUGH for passthrough routes. Recommendation: Either (a) require CLIENT_API_KEY validation for passthrough routes regardless of auth mode, or (b) document this prominently and add a startup warning when PASSTHROUGH mode + server-side keys are both configured. 2. No request body size limit on passthrough routesFile:
Recommendation: Add a content-length check mirroring the Anthropic pipeline's approach. 3. Module-level httpx clients are never closedFile:
Recommendation: Move client creation into the lifespan context manager and close them during shutdown. 4. Upstream response headers forwarded verbatim to clientFile: return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
)This forwards all upstream headers to the client, including Recommendation: Allowlist response headers or at least strip hop-by-hop headers ( 🟡 Medium-Priority Issues5. S3 archiver loads all rows into memory at onceFile:
Recommendation: Add batched fetching with a configurable batch size (e.g., 10,000 rows at a time). 6. Webhook sender creates a new httpx.AsyncClient per delivery attemptFile: async with httpx.AsyncClient(timeout=SEND_TIMEOUT_SECONDS) as client:Under high load with retries (up to 1000 pending × 4 attempts), this creates many short-lived connections. Consider a persistent client instance with connection pooling. 7.
|
|
Split into 3 focused PRs per .sisyphus/plans/pr614-split-and-fix.md:
All 4 security findings from the review are addressed in PR #758 with explicit test coverage. The 5 in-main findings are filed as separate GH issues (label: from-pr-614-review). Original branch state preserved at tag |
…dfe0ba29 PR #614 review follow-ups
The Postgres FTS backfill in 014_add_session_search_fts.sql called _extract_event_search_text(payload) twice per row -- once in the UPDATE SET clause and once in the WHERE filter -- doubling the per-row work over the whole conversation_events table. Compute it once in a subquery and reuse the result for both the tsvector value and the NULL filter. Results are identical: only rows with non-NULL extracted text get a search_vector. The SQLite 014 backfill already computes content once (subquery + outer WHERE content != ''), so this is a Postgres-only change. Added a regression test asserting the backfill UPDATE evaluates the helper exactly once. Card 6a0f94f1 (PR LuthienResearch#614 review follow-up, GH LuthienResearch#763). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…w-ups Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>