Commit Graph
9 Commits
Author SHA1 Message Date
Qin Haojie d47f2106ee refactor: remove unused and deprecated APIs (#3272)
Delete dead compatibility paths and test-only helpers so unsupported APIs do not remain as accidental contracts.
2026-07-16 10:49:56 +08:00
t0saki c99e65472b fix(server): default OAuth client scope for scope-less DCR (ChatGPT invalid_scope) (#3210)
#2921 persisted the DCR scope when the registrar sends one and started
advertising scopes_supported=["mcp"] in the PRM document. ChatGPT's DCR
omits the scope field, so its client registers scope-less, then requests
the advertised scope=mcp at /authorize and gets bounced back with
error=invalid_scope before any consent page renders.

- app.py: pass default_scopes=["mcp"] to ClientRegistrationOptions so
  scope-less registrations get the default grant; valid_scopes stays
  unset so clients that register their own scope strings are not
  rejected at DCR time
- provider.py: single-source the scope as MCP_SCOPE; get_client() falls
  back to it for NULL-scope rows, repairing already-registered clients
  without migration or re-registration
- router.py: reuse MCP_SCOPE in the PRM scopes_supported
- tests: provider fallback unit tests + end-to-end scope-less DCR and
  legacy NULL-scope client authorize regressions
2026-07-13 12:49:19 +08:00
xierfloatandwugj a6e72f929e fix(server): persist OAuth DCR client scope to fix invalid_scope for MCP clients (#2921)
OAuth Dynamic Client Registration silently dropped the client `scope`, so any
spec-compliant MCP client that requests a scope (e.g. scope=mcp) failed
/authorize with invalid_scope. The MCP SDK's client.validate_scope() requires
requested scopes to be a subset of the registered client.scope, which was always
None because register_client() never stored it.

- storage.py: add `scope` column to oauth_clients (+ idempotent migration) and
  persist it in register_client()
- provider.py: pass client_info.scope on registration; return scope from
  get_client() so validate_scope() sees the registered scope
- router.py: advertise scopes_supported=["mcp"] in the RFC 9728 PRM document
- tests: assert scope round-trips through register/get

Co-authored-by: wugj <wugj@g-bits.com>
2026-07-02 16:44:19 +08:00
87329714dd feat(grep): integrate VikingDB bm25 keyword search for grep engine (#2144)
* feat(grep): integrate VikingDB bm25 keyword search for grep engine

* fix(grep): address CI review feedback: max-size eviction to _count_cache, use Literal, Split regex alternation into individual keywords for bm25 (max 10)

* fix(schema): use dynamic __version__ for schema_version and handle dev suffixes in version comparison

* fix(schema): upsert data to vikingdb lack of content

* chore: add benchmark for retrieval

* fix(grep): vikingdb return 200 and no results means no matching content, not necessary to fallback to local fs

* fix(benchmark): sub uri args; add report

* refactor: code format by ruff

* optimize: move grep config (engine and switch_to_remote_threshold) to ov.conf

* optimize: auto adapt remote_return_limit by agg API; rm unnecessary params in keywords search

* fix: adjust benchmark scripts

* fix(grep): store full content for BM25; use PathScope depth; reduce redundant API calls

* refactor: new benchmark

* fix: step1 add resource by real code data

* feat(benchmark): split grep benchmark into effectiveness/performance suites with async reindex

* optimize (benchmark): adjust keywords and ground truth for testing

* fix: truncate 64KB for content field

* optimize: effectiveness add resource plainly

* optimize: change param use of SearchByKeywords from "keywords" to "query"

* optimize(benchmark): refactor effectiveness scripts

* optimize: ensure raw data for content field

* optimize: fulltext analyzer's stop-words only use symbols

* fix: adapt to new ov cli for benchmark

* optimize: reuse file content to avoid re-read AGFS file

* optimize: tune grep vikingdb defaults and refresh bm25 benchmark scripts

* optimize: benchmark client timeout

* update README

* fix: rm unused param

* fix: default values in docs

* optimize: increase truncate byte size to 1MB for content field for VikingDB

* fix(logger): harden queued stream logging (#2786)

* fix(logger): replace StreamHandler with QueueHandler+QueueListener to prevent thread deadlock

When log.output='stdout' (default) and the server is managed by systemd,
concurrent log writes can deadlock because logging.StreamHandler holds a
thread lock across stream.flush() which blocks on systemd-piped file I/O.

During session.commit() phase 2, multiple async coroutines (memory
extraction, summarization) concurrently call logger.info()/warning()
with large payloads. The first thread's flush() blocks on the pipe,
while all subsequent threads block on handler.acquire() forever.
This permanently silences the server log and prevents _write_done_file()
from executing, leaving phase 2 hanging without .done.

Fix: use QueueHandler + QueueListener from stdlib logging.handlers
(Python 3.2+). QueueHandler.emit() does queue.put(record) with no lock
or I/O, returning immediately. QueueListener has a dedicated single
thread as the sole consumer touching the real StreamHandler, making
lock contention impossible.

Changes in _create_log_handler(): stdout/stderr branches now create
a shared QueueListener with unbounded queue, returning QueueHandler
instances to callers. _build_standard_handler() delegates formatter
and filter setup to the real handler in the listener thread.

Closes: #2752

* fix(logger): harden queued stream logging

---------

Co-authored-by: njuboy11 <njuboy11@users.noreply.github.com>

---------

Co-authored-by: Qin Haojie <qinhaojie.exe@bytedance.com>
Co-authored-by: njuboy11 <njuboy11@users.noreply.github.com>
2026-06-24 18:46:02 +08:00
MaojiaShengandclaude-sonnet-4-6 ab656e240d refactor(auth): introduce plugin-based authentication architecture (#2709)
* chore: clear unused files

* fix(tests): fix unit test

* refactor(auth): introduce plugin-based authentication architecture

Replace the monolithic `openviking/server/auth.py` with an extensible
plugin-based auth system. This refactor extracts the three built-in modes
(`dev`, `api_key`, `trusted`) into separate `AuthPlugin` implementations,
adds a registry for third-party plugins, and preserves all existing behavior
while enabling custom authentication backends (e.g. LDAP, OIDC, mTLS).

Key changes:
- **New public API**: `AuthPlugin` (ABC) and `register_auth_plugin` decorator.
- **New registry**: `AuthPluginRegistry` supports runtime registration.
- **Built-in plugins**: `DevAuthPlugin`, `ApiKeyAuthPlugin`, `TrustedAuthPlugin`.
- **Config change**: `auth_mode` widened from `Literal` to `str` for custom modes.
- **Validation delegated**: `validate_server_config()` now delegates to the active
  plugin's `validate_config()`, preserving existing validation semantics.
- **Router compatibility**: All existing `require_*` decorators and `resolve_identity`
  / `get_request_context` dependencies remain unchanged. Routers import the same
  symbols from `openviking.server.auth`.
- **Tests**: `conftest.py` manually wires the DevAuthPlugin in ASGI tests (lifespan
  not triggered). `test_auth.py` expanded with plugin registration and validation tests.
- **Docs**: `04-authentication.md` (en/zh) updated with plugin registration examples.

Co-Authored-By: claude-sonnet-4-6 <noreply@anthropic.com>

* fix(tests): fix trusted mode test

* fix(tests): fix unit test

---------

Co-authored-by: claude-sonnet-4-6 <noreply@anthropic.com>
2026-06-18 20:05:17 +08:00
t0saki 8a3bfb68ff chore(oauth): remove dead push-OTP, repurpose footer to cross-device verify (#2538)
The push-OTP feature (mint an OTP in Studio to hand to an MCP client) was never
wired to a consumer: consume_otp had zero production callers and no endpoint or
grant ever redeemed an OTP. The 'full happy path' test actually exercised the
display_code flow, not OTP. So the sidebar footer's 'OAuth setup' entry minted a
code with nowhere to use it — dead, confusing UX.

Remove it end-to-end and repurpose the footer slot into an entry for the
cross-device verify page (enter the 6-char display_code), which previously had no
discoverable entry point in Studio.

Frontend:
- delete oauth-setup-dialog.tsx + /oauth/setup route (+ routeTree, i18n)
- extract CrossDeviceVerifyForm from verify.tsx; add CrossDeviceVerifyDialog
- footer 'OAuth verify' entry opens the verify dialog (desktop) / page (mobile)

Backend:
- drop issue_otp route + OTPRequest/OTPResponse, storage insert_otp/consume_otp,
  oauth_config.otp_ttl_seconds, and the OTP-specific tests
- keep otp.py generate_otp (cross-device display_code) + hash_secret, the shared
  _atomic_consume_code, and the oauth_codes.kind column
- convert the race/expiry/revoke/GC storage tests to auth-code rows

Docs: update 11-oauth, 06-mcp-integration, and the design doc to reflect removal.
2026-06-10 12:52:54 +08:00
Qin Haojie ff258768c2 feat(memory): 引入 User/Peer 记忆隔离模型 (#2236)
* feat(memory): introduce user and peer memory isolation

Unify agent-scoped memory behavior into user-owned memory spaces, add peer_id compatibility for session and retrieval paths, and wire memory_policy through session commit flows.

* feat(memory): align session identity around peer IDs

* feat(search): pass peer id through retrieval

* refactor(memory): remove agent identity from integrations

* fix(memory): isolate peer identity from self extraction

* fix(tau2): provision benchmark user configs

* fix(auth): allow admin keys to access data APIs

* fix(openclaw): enable peer memory policy for peer roles

* fix(openclaw): resolve sender for peer recall

* refactor(session): simplify memory extraction routing

* refactor(ov-cli): reduce formatting-only diff

* refactor(message): remove unused message helpers

* refactor(retrieval): simplify peer target resolution

* refactor(namespace): remove deprecated agent namespace policy

* fix(agent): propagate peer id through integrations

* fix(auth): align integration clients with api-key mode
2026-06-05 10:55:48 +08:00
t0saki da59289591 feat(oauth): move authorize UI into web-studio (#2160 follow-up) (#2170)
#2160 dropped the legacy `/console` standalone service but deliberately
left the OAuth authorize page's `/console` link and Quick-authorize panel
in place, calling out a follow-up to re-point them at web-studio. This
PR is that follow-up.

Backend
- `provider.authorize()` now defaults to redirecting to
  `/studio/oauth/consent` (same-origin SPA) instead of the server-rendered
  `/oauth/authorize/page`. New `FALLBACK_AUTHORIZE_PAGE` constant exposed
  for callers that need to opt into the legacy path.
- New public endpoint `GET /api/v1/auth/oauth/pending/{pending_id}` returns
  the minimum info the consent UI needs (client_name, redirect_host,
  scopes); deliberately does NOT expose display_code or full redirect_uri.
- `POST /api/v1/auth/oauth-verify` now accepts either `pending_id`
  (Studio consent path) or `code` (cross-device fallback).
- HTML `/oauth/authorize/page` template stripped of `/console` link, the
  `/console/api/v1/...` JS, and the Quick-authorize same-origin panel.
  It now serves as a pure cross-device fallback that points users at
  `/studio/oauth/verify` on another already-signed-in device.

Web Studio
- New `<IdentityPicker>` shared component: "current identity" or
  "use a different API key" — the temporary key is never persisted.
- New routes `/studio/oauth/consent` (same-device consent card) and
  `/studio/oauth/verify` (cross-device code entry).
- ConnectionDialog gains an "OAuth client OTP" section (same
  IdentityPicker), driving `POST /api/v1/auth/otp`.
- API key storage is unchanged: only sessionStorage. No new localStorage
  writes, no cross-tab channels — the consent UI runs inside Studio's own
  tab, so it reads the session-stored key directly.

Docs
- 11-oauth.md (zh/en): refreshed quickstart, How-it-works, Claude.ai
  walkthrough, curl example, and troubleshooting around the Studio
  consent / cross-device verify split.
- 12-public-access.md (zh/en): rewritten to lead with public HTTPS;
  the `:1934` Caddy block is now a one-paragraph compatibility note for
  deployments that already bookmarked it.
- mcp-oauth2-1.md: top-level "Studio migration" note explains the new
  default path; Phase 1 history retained.
- Caddyfile / docker-compose.yml comments reworded from "aggregated
  proxy" to "legacy fallback" to match the new docs.

Tests
- `tests/server/oauth/test_router.py` fixture pins to
  FALLBACK_AUTHORIZE_PAGE so existing end-to-end assertions keep working.
- 4 new tests cover the pending-info endpoint and pending_id verify path.
- 55 passed locally; ruff format+check, web-studio tsc/eslint/prettier
  all clean.

Security notes
- Consent UI requires explicit user click; client_name + redirect_host
  shown for phishing identification.
- Knowing a pending_id does not bypass Bearer auth.
- display_code is not returned by GET pending — the cross-device
  brute-force protection is preserved.
- `ctx.from_oauth` gate (router.py) untouched: OAuth bearer still
  cannot mint new OAuth state or OTPs.
2026-05-21 17:57:33 +08:00
t0saki 7d5fa62398 feat(oauth): native OAuth 2.1 authorization for MCP clients (#1870)
* feat(oauth): hand-sewn OAuth 2.1 M1+M2 (config, JWT, storage, /oauth/token, JWT discriminator)

Snapshot before evaluating migration to mcp.server.auth SDK provider. The
hand-rolled HS256 JWT implementation in openviking/server/oauth/jwt.py is
the main candidate for replacement: its surface area is small but it would
require careful crypto review by maintainers, while the official MCP SDK
already ships an OAuth provider wired into FastMCP.

Included so far:
- OAuthConfig + integration into OpenVikingConfig (default disabled)
- openviking/server/oauth/{jwt,storage,otp,router}.py
- POST /oauth/token (authorization_code + refresh_token, PKCE S256, RFC 6749 errors)
- JWT discriminator in resolve_identity (fail-closed; ResolvedIdentity.from_oauth)
- WWW-Authenticate Bearer hint on /mcp 401 (RFC 9728)
- 49 OAuth-specific unit/integration tests (all passing)

Not yet implemented (M3 / MVP gap):
- /oauth/register (DCR), /oauth/authorize (HTML + OTP submit), well-known metadata
- POST /api/v1/auth/otp REST endpoint

* refactor(oauth): switch to mcp.server.auth SDK provider, drop hand-sewn JWT

Replaces the hand-rolled HS256 JWT signer / token endpoint / DCR with
the OAuth 2.1 surface shipped in mcp.server.auth. We supply a Provider
that adapts the existing OAuthStore (SQLite) to the SDK Protocol, plus
two custom routes the SDK doesn't own: an OTP-entry HTML page (the URL
provider.authorize() returns) and POST /api/v1/auth/otp for issuing
OTPs against an existing API key.

Net result: all OAuth crypto is now the SDK's responsibility (PKCE
S256, redirect_uri matching, error formatting). The OpenViking-side code
contains zero cryptography — access tokens are opaque random strings
prefixed with `ovat_` and looked up in SQLite by SHA-256 hash. Refresh
tokens, auth codes, OTPs use the same scheme.

Highlights:
- openviking/server/oauth/provider.py: OpenVikingOAuthProvider implements
  the 8-method SDK Protocol, including subclassing AuthorizationCode /
  RefreshToken / AccessToken to pin (account_id, user_id, role) per
  token. Refresh-token replay triggers per-user chain revocation.
- openviking/server/oauth/storage.py: adds oauth_access_tokens and
  oauth_pending_authorizations tables; peek_auth_code / peek_refresh
  for non-destructive lookups; revoke_user_tokens cascades all OAuth
  state for an (account, user) pair when a key is rotated.
- openviking/server/oauth/router.py: minimal authorize page (inline
  HTML with frame-ancestors 'none') + OTP endpoint authenticated via
  existing get_request_context dependency.
- openviking/server/auth.py: replaces JWT discriminator with prefix
  match + provider.load_access_token; still fail-closed.
- openviking/server/app.py: mounts SDK routes via create_auth_routes
  alongside our authorize-page + OTP routes.
- Deletes openviking/server/oauth/jwt.py and tests/server/oauth/test_jwt.py.

Tests: 32 passing, including a full DCR -> OTP -> authorize page ->
token-exchange -> /mcp lookup happy path, refresh rotation, and replay
detection. Existing test_auth.py regression unchanged.

Phase 1 still missing for full Claude.ai connectivity:
- WWW-Authenticate hint already present on /mcp 401 (from M2)
- /.well-known/oauth-protected-resource (RFC 9728) — not currently
  emitted by the SDK; small custom route still TODO.

* docs(oauth): rewrite design doc to reflect mcp.server.auth SDK approach

The earlier draft described a hand-sewn HS256 JWT plan; the implementation
took a different route after discovering mcp.server.auth ships a complete
RFC 6749 / 7591 / 8414 server. Updated to reflect:

- SDK owns the protocol surface (DCR, /authorize parsing, /token, metadata,
  PKCE, redirect_uri matching, error codes).
- OpenViking only contributes a Provider implementation, the OTP-entry
  HTML page, and POST /api/v1/auth/otp.
- Tokens are opaque (ovat_ / ovrt_ / ovac_ prefixes) — no JWT, no crypto
  on our side.
- Implementation status: M1/M2/M3 done; only RFC 9728 protected-resource
  metadata + reverse-proxy issuer derivation remain for full Claude.ai
  end-to-end connectivity.

* feat(oauth): add /.well-known/oauth-protected-resource (RFC 9728)

The /mcp 401 path already advertises this URL via WWW-Authenticate
Bearer resource_metadata="...", but the endpoint itself didn't exist —
clients fetched it and got a 404, which silently broke the discovery
chain even though /.well-known/oauth-authorization-server worked. Wire
up the resource metadata document so the full RFC 9728 → RFC 8414
discovery chain works end-to-end.

Uses mcp.shared.auth.ProtectedResourceMetadata pydantic model. Reads
X-Forwarded-Proto/Host so the published resource URL matches what the
client used (matches our existing WWW-Authenticate behavior).

Cache-Control: max-age=3600 — metadata is stable across requests.

* feat(console): add OTP issuance button in Settings panel

Adds a "Get OTP" button under the Settings panel of the 8020 web
console. Clicking it issues an OAuth OTP via the user's existing API
key (already loaded into sessionStorage) and displays it inline with
a copy-to-clipboard button.

Replaces the previous workflow of users having to:
  curl -X POST -H "X-Api-Key: $KEY" http://1933/api/v1/auth/otp

…with a single button-click flow that the user can reach from any
machine with a browser.

Wires:
- console/app.py: new POST /console/api/v1/ov/auth/otp proxy route,
  forwarding to upstream /api/v1/auth/otp. Not gated by write_enabled
  since OTP issuance is an authentication artifact, not data mutation.
- index.html: new OAuth section in the Settings panel with otpBox
  (hidden until OTP is generated) and a Copy button.
- app.js: getOtpBtn click handler calls callConsole, otpCopyBtn copies
  to clipboard. Clear failure messages when the user has no API key
  loaded yet.

This is the lightweight half of the Console-OAuth integration. The
fuller "same-origin auto-authorize" flow (Phase 2) — where the
authorize page detects sessionStorage and submits the OTP form
automatically — is still TBD and will reuse this proxy route.

* feat(oauth): device-flow style authorize page + console verify form

Pivots the OTP flow direction so the UX matches OAuth 2.0 Device
Authorization Grant (RFC 8628) more closely:

  Old (push):  user goes to console -> Get OTP -> copy -> paste in
               client's authorize page -> submit -> redirect.
  New (pull):  client's authorize page DISPLAYS a 6-char code -> user
               types it into the console verify form -> page polls -> redirect.

This removes one tab switch and aligns with how users mentally model
authorization ("I'm approving the request shown over there from
where I'm already signed in"). The legacy POST /api/v1/auth/otp +
"Get OTP" button are kept under a collapsed details element for any
scripted/CLI flows that still drive the older pattern.

Also wires OPENVIKING_PUBLIC_BASE_URL env var as the highest-priority
public origin override, used consistently by:
  - /.well-known/oauth-protected-resource
  - WWW-Authenticate header
  - authorize page links
  - SDK issuer at app start.

Server changes:
- storage.py: oauth_pending_authorizations gains display_code,
  verified, verified_account_id/user_id/role columns; new
  find_pending_by_display_code + mark_pending_verified.
- provider.authorize() now generates display_code at pending creation
  and returns the page URL.
- router.py:
  * GET /oauth/authorize/page — renders the code + same-origin quick-
    authorize panel (sessionStorage detection, but click still required
    so authorization is never silent).
  * GET /oauth/authorize/page/status — polled by the page until verified;
    response carries the redirect_url with auth_code on approval.
  * POST /api/v1/auth/oauth-verify — authenticated; binds caller
    identity to a pending row (decision=approve|deny).

Console changes:
- Settings panel: new "Authorize an MCP client" section with code input
  and Authorize/Deny buttons. Legacy "Get OTP" still available under
  details.
- console proxy gains POST /console/api/v1/ov/auth/oauth-verify.

Tests: 38 OAuth tests passing, including a full device-flow happy path,
deny path, idempotency (one-shot pending), unknown-code rejection,
status-410 on consumed/expired, refresh rotation, OPENVIKING_PUBLIC_BASE_URL
override, and X-Forwarded-* fallback.

* docs(oauth): add 11-oauth guide + Caddy/nginx templates + .env-driven compose

Adds a top-level OAuth 2.1 guide (zh/en) covering the production path
end-to-end. Opens with a 5-step recommended setup so readers don't have
to wade through the rationale before they can deploy. Drops the "MCP"
qualifier from the doc name — OAuth 2.1 here is generic and serves any
OAuth client, not just MCP.

- docs/{en,zh}/guides/11-oauth.md: new. Recommended setup at the top,
  then background, full device flow, HTTP-local vs HTTPS-production
  deployment, Caddy + nginx templates, docker-compose with the shipped
  Caddy service, curl walkthrough, config reference, troubleshooting.
- docker-compose.yml: replace the prior PR's commented-out hint with a
  single OPENVIKING_PUBLIC_BASE_URL var (read by both the openviking
  service and an optional Caddy reverse-proxy service that's also
  shipped commented-out). Same env var drives Caddy via
  {$OPENVIKING_PUBLIC_BASE_URL}, so the public domain is configured
  once in .env.
- docs/{en,zh}/guides/06-mcp-integration.md: replace the "OAuth Proxy
  (planned, use community Cloudflare Worker)" section with a short
  pointer to the new 11-oauth guide. The community proxy is still
  mentioned as an alternative.

Same env-variable design also matches what the MCP add_resource tool
expects (it already reads OPENVIKING_PUBLIC_BASE_URL), so deployments
get a single source of truth for the public address.

* fix(oauth): read API key from localStorage on authorize page

The same-origin "Quick authorize" panel was reading sessionStorage,
which is per-tab. Since the OAuth authorize page opens in a different
tab from the console, the panel never showed up even when the user was
signed in.

The console persists the API key in localStorage as well (key
"ov_console_api_key" — see static/console_settings.js's
LEGACY_API_KEY_STORAGE_KEY) for cross-tab use, and that copy is what
the authorize page should consult.

Switch the page JS to localStorage first, fall back to sessionStorage
for resilience. No console-side change needed; the localStorage entry
has been written by the console all along.

* docs: add public access guide + default port 1934 aggregated proxy

- Add Caddyfile with :1934 HTTP aggregated proxy (merges 1933+8020)
- Enable Caddy service by default in docker-compose.yml on port 1934
- Add docs/{en,zh}/guides/12-public-access.md with full HTTPS setup guide
- Simplify 11-oauth.md: replace inline reverse proxy config with refs to 12
- Add HTTPS requirement callout to OAuth recommended setup
- Update 03-deployment.md to mention port 1934 as recommended entry point

* fix(oauth): address Copilot review + ruff format

- Update oauth_config.py docstrings to describe opaque tokens, not JWT
  (we switched away from JWT during implementation)
- Remove unused authorize_rate_limit_per_min config field — was never
  enforced anywhere in router/storage, dead config misled operators
- Wrap all OAuthStore read paths in self._lock (matching writes); the
  shared sqlite3.Connection with check_same_thread=False is not safe
  for concurrent cursor use across threads
- Clarify provider.exchange_refresh_token comment that replay revokes
  the entire (account, user) family, not just the (client, account,
  user) chain — broader blast radius is intentional
- ruff format: 8 files reformatted to satisfy CI lint

* perf(docker): add cargo + ccache cache mounts to py-builder stage

The two heavy RUN steps in py-builder (uv sync + maturin build) re-execute
on every Python source change because the upstream COPY layer for openviking/
invalidates the cache. Each rerun was ~510s + ~115s ≈ 10 min of wasted work
even though Rust/C++ source was unchanged.

Add BuildKit cache mounts so cargo and the C++ engine compilation can skip
work whose inputs are unchanged:

- Mount /cargo-target, cargo registry, and cargo git so cargo's incremental
  build artifacts persist across layer reruns. Pin CARGO_TARGET_DIR so the
  path stays stable when uv builds wheels in ephemeral isolated tempdirs.
- Install ccache and prepend /usr/lib/ccache to PATH so cmake (which calls
  shutil.which("gcc")) resolves the ccache wrapper. ccache is path-agnostic,
  so it benefits the cmake_build subdir even though setup.py recreates it
  in a fresh tempdir each wheel build.
- Mount /root/.ccache so the ccache hash store persists across reruns.

Expected: hot rebuilds on Python-only changes drop step 15 from ~510s to
~60-120s (uv wheel packaging overhead remains; cargo + g++ skip on cache hit).

* perf(docker): drop redundant second maturin build step

The second RUN step in py-builder built ragfs-python a second time and
extracted its .so into the installed openviking package. This was
redundant: setup.py's build_ragfs_python_artifact() already runs maturin
during step 15 (uv sync --no-editable), and because build_meta passes
'bdist_wheel' through PEP 517, _should_require_ragfs_artifact() returns
True and the build fails closed if maturin can't produce ragfs_python.so.
The .so is then bundled into the wheel via package_data and installed
into /app/.venv on wheel install. The second step's only effect was to
overwrite the same file, costing ~115s per build.

Verified after the fact by inspecting the installed venv and importing
ragfs_python in the runtime container.

* feat(oauth): bind OAuth token lifetime to authorizing API key

Previously OAuth tokens lived independently of the API key that authorized
them. Rotating a user's key did not invalidate already-issued OAuth access /
refresh tokens, so a compromised key remained dangerous even after rotation.

Tie every OAuth token to the SHA-256 fingerprint of the API key whose holder
authorized it:

- APIKeyManager grows get_user_key_fingerprint(account_id, user_id) ->
  sha256(stored_key_value). The stored value is whatever sits in
  user_info["key"] (plaintext key or argon2id hash), written once on
  create / regenerate and never mutated in place, so the fp is stable per
  key-generation and changes the moment regenerate_key runs.

- OAuth storage gains an authorizing_key_fp column on oauth_codes,
  oauth_pending_authorizations (verified_key_fp), oauth_refresh_tokens, and
  oauth_access_tokens. ALTER TABLE migration guarded by PRAGMA table_info
  for dev DBs that predate the field.

- Provider data classes thread the fp through authorize ->
  exchange_authorization_code -> _issue_token_pair, and refresh rotation
  preserves it from the consumed token's record.

- Router endpoints capture the caller's current fp at the only two
  identity-binding moments: /api/v1/auth/otp (caller) and
  /api/v1/auth/oauth-verify (verifier). If the manager returns None
  (ROOT key, trusted-mode identity, or removed user), refuse to issue
  OAuth state -- there is no key whose lifecycle we could honor.

- auth.py:_try_resolve_oauth_token recomputes the user's current fp on
  every OAuth bearer auth and demands strict equality via
  hmac.compare_digest. NULL / empty / mismatch all fail closed with a
  401 telling the client to re-authorize.

Crypto notes: sha256 over a 256-bit-random API key (or its argon2id hash)
is preimage-safe, so an oauth.db leak does not reveal the API key. No new
secret material introduced; the fp is derived deterministically from data
that already exists.

Tests: 3 new lifecycle tests in test_auth_integration (rotation rejected,
user-removed rejected, missing-fp fail-closed), 3 new router tests
(no-fp caller / verifier rejected, fp recorded on access + refresh), 2 new
APIKeyManager tests (fp changes on rotate / vanishes on remove).
Pre-existing inserts in test_storage updated to pass _FP. 82/82 OAuth +
APIKeyManager tests pass.

* docs(oauth): document OAuth lifetime ≤ authorizing key lifetime

The fingerprint binding landed in the previous commit; users need to know
that key rotation now auto-invalidates derived OAuth tokens (no separate
revoke step) and that ROOT / trusted-mode identities cannot issue OAuth.

Updates both en and zh under docs/guides/11-oauth.md, replacing the
"operator should also revoke ..." paragraph with the new automatic
behavior + brief note on the SHA-256 fingerprint scheme.

* fix(oauth): close 4 review findings on token lifecycle

External security review of #1870 surfaced four real gaps in the OAuth
implementation. All four directly affect the lifecycle / privilege model.

P1: role downgrade did not invalidate OAuth tokens
  set_role rewrites user_info["role"] without touching user_info["key"],
  so the SHA-256 fingerprint binding stays valid and an ADMIN demoted to
  USER continues to resolve as ADMIN. Refresh tokens keep minting fresh
  ADMIN access tokens. Fixed in two places:
  - auth.py:_try_resolve_oauth_token re-fetches Role.get_user_role and
    rejects when the embedded role outranks the current role.
  - provider.exchange_refresh_token gets a role_resolver callback (wired
    in app.py to api_key_manager.get_user_role) and applies the same
    gate before consuming a refresh.
  Promotion remains harmless — the embedded lower privilege is still
  authorized, only downgrades trigger rejection.

P1: confidential client secrets were never enforced
  provider.get_client returned client_secret=None regardless of the
  stored hash; the MCP SDK's ClientAuthenticator skips secret validation
  when the returned client has a falsy secret, silently allowing
  client_secret_basic / client_secret_post clients to authenticate with
  only client_id. Real MCP clients all use "none" + PKCE per RFC 8252
  §8.4 anyway, so register_client now rejects non-"none" auth methods at
  DCR. Native/desktop apps can't keep secrets — PKCE is the actual
  proof-of-possession.

P1: OAuth tokens could mint new OAuth grants
  /api/v1/auth/otp and /api/v1/auth/oauth-verify accepted any caller
  resolved through get_request_context, including identities resolved
  from OAuth bearers. A stolen 1h access token could call oauth_verify
  with its own pending row and walk away with a 30d refresh-token
  chain — privilege time-extension. RequestContext now carries
  from_oauth (mirroring ResolvedIdentity.from_oauth) and both endpoints
  reject from_oauth=True with 403, forcing primary auth.

P2: GC erased refresh-token replay tombstones
  gc_expired deleted "WHERE expires_at < ? OR consumed = 1" every
  minute. After GC, is_refresh_known_but_consumed could not distinguish
  a replay from an unknown token and exchange_refresh_token never fired
  revoke_chain — defeating RFC 9700 §4.14 family revocation for late
  replays. GC now keeps consumed refresh rows until their natural
  expires_at; storage cost bounded by the 30d max refresh TTL.

Also adds from_oauth field to RequestContext and propagates from
ResolvedIdentity in get_request_context.

Tests: 7 new (role downgrade rejection in bearer auth + refresh path,
role promotion is harmless, confidential DCR rejected, from_oauth
rejected at OTP and oauth-verify, refresh tombstone preserved across
GC). Pre-existing test_oauth_root_can_be_used and
test_dcr_registers_client updated to match the stricter contract.
89/89 OAuth + APIKeyManager tests pass.

* fix(oauth): downgrade confidential DCR to public instead of rejecting

The previous P1.2 fix rejected DCR when token_endpoint_auth_method was
not "none", reasoning that we never enforce client_secret server-side
so accepting confidential auth methods would be a silent security
downgrade. That is the right invariant — but the rejection broke real
clients: the OAuth 2.0 default for token_endpoint_auth_method is
"client_secret_basic", and at least Claude Desktop relies on the SDK to
fill in defaults rather than explicitly setting "none". DCR for those
clients started returning 400 even though they would work fine with PKCE
(which they all use anyway).

Soft-failure design instead: accept any registered auth method, but
overwrite the stored value to "none" and log a warning. The end-state
is identical to the rejection path — every client is treated as
public+PKCE, no secret is ever stored or enforced — but Claude Desktop's
DCR no longer blows up.

Updates the test from asserting 400 to asserting that a confidential
registration is silently downgraded: stored auth_method == "none",
client_secret_hash is None.

* delete(docs): remove error file

* docs(zh): sync 03-deployment.md with English version

c5cb241f only updated docs/en/guides/03-deployment.md when introducing
the 1934 aggregated entry point and the 12-public-access.md guide. This
backfills the same changes in the Chinese version:
  - Add port 1934 (Caddy aggregated entry point) to the access list
  - Link to 12-public-access.md for public HTTPS setup
  - Add 11-oauth.md and 12-public-access.md to "Related Documentation"
2026-05-08 20:21:21 +08:00