From 150883b9e172136bfb1625dacd7dbf02cbc42196 Mon Sep 17 00:00:00 2001 From: Paulo Date: Thu, 23 Jul 2026 22:44:04 +0200 Subject: [PATCH] Replace browser login with edge-asserted identity --- .env.example | 9 + backend/druks/accounts/constants.py | 3 - backend/druks/accounts/dependencies.py | 173 +++++--- backend/druks/accounts/exceptions.py | 6 + backend/druks/accounts/models.py | 21 +- backend/druks/accounts/routes.py | 115 +----- backend/druks/accounts/schemas.py | 9 + backend/druks/accounts/sessions.py | 25 -- backend/druks/api/app.py | 52 ++- backend/druks/extensions/base.py | 2 +- backend/druks/harnesses/base.py | 62 +-- backend/druks/harnesses/claude.py | 4 +- backend/druks/harnesses/codex.py | 4 +- backend/druks/harnesses/constants.py | 7 +- backend/druks/harnesses/datastructures.py | 6 +- backend/druks/harnesses/exceptions.py | 2 +- backend/druks/harnesses/models.py | 13 +- backend/druks/harnesses/routes.py | 110 ++++++ backend/druks/settings.py | 22 +- backend/druks/setup_env.py | 41 +- backend/druks/user_settings/routes.py | 22 +- backend/druks/user_settings/schemas.py | 17 +- backend/tests/conftest.py | 13 +- backend/tests/test_agent_routes.py | 6 +- backend/tests/test_api_settings.py | 10 +- backend/tests/test_auth.py | 253 ------------ backend/tests/test_auth_boundary.py | 53 ++- backend/tests/test_auth_pats.py | 123 ++++-- backend/tests/test_durable_sdk.py | 2 +- backend/tests/test_extensions.py | 2 +- backend/tests/test_harness_auth.py | 70 ++-- ...rness_login.py => test_harness_connect.py} | 80 ++-- backend/tests/test_identity.py | 370 ++++++++++++++++++ backend/tests/test_scaffolding.py | 2 +- backend/tests/test_settings.py | 18 + backend/tests/test_setup_env.py | 24 ++ deploy/README.md | 46 +-- deploy/caddy/Caddyfile | 24 +- deploy/compose.yaml | 13 +- docs/concepts.md | 26 +- docs/configuration.md | 76 ++-- docs/development.md | 10 +- docs/full-local.md | 12 +- docs/troubleshooting.md | 34 +- docs/writing-an-extension.md | 4 +- frontend/src/api/client.test.ts | 75 +++- frontend/src/api/client.ts | 61 +-- frontend/src/api/sse.test.tsx | 38 +- frontend/src/api/sse.ts | 23 +- frontend/src/api/types.ts | 19 +- frontend/src/components/AuthProvider.test.tsx | 68 ---- frontend/src/components/AuthProvider.tsx | 67 ---- frontend/src/components/AuthedApp.tsx | 9 +- .../src/components/HarnessConnect.test.tsx | 28 +- ...arnessLogin.tsx => HarnessConnectFlow.tsx} | 23 +- .../src/components/IdentityBootstrap.test.tsx | 136 +++++++ frontend/src/components/IdentityBootstrap.tsx | 89 +++++ frontend/src/components/Landing.test.tsx | 40 -- frontend/src/components/Onboarding.test.tsx | 48 +++ .../{Landing.tsx => Onboarding.tsx} | 38 +- frontend/src/components/SettingsModal.tsx | 13 +- frontend/src/main.tsx | 6 +- frontend/src/styles.css | 3 +- 63 files changed, 1720 insertions(+), 1060 deletions(-) delete mode 100644 backend/druks/accounts/sessions.py create mode 100644 backend/druks/harnesses/routes.py delete mode 100644 backend/tests/test_auth.py rename backend/tests/{test_harness_login.py => test_harness_connect.py} (62%) create mode 100644 backend/tests/test_identity.py delete mode 100644 frontend/src/components/AuthProvider.test.tsx delete mode 100644 frontend/src/components/AuthProvider.tsx rename frontend/src/components/{HarnessLogin.tsx => HarnessConnectFlow.tsx} (74%) create mode 100644 frontend/src/components/IdentityBootstrap.test.tsx create mode 100644 frontend/src/components/IdentityBootstrap.tsx delete mode 100644 frontend/src/components/Landing.test.tsx create mode 100644 frontend/src/components/Onboarding.test.tsx rename frontend/src/components/{Landing.tsx => Onboarding.tsx} (68%) diff --git a/.env.example b/.env.example index 3bdab5f..6d0f685 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,15 @@ DRUKS_LOG_LEVEL=INFO # python3 -c 'import base64, os; print(base64.b64encode(os.urandom(32)).decode())' DRUKS_SECRETS_KEY= +# Browser identity. `none` = no authentication (loopback dashboard, exactly one +# operator account, created by the first harness connect). Behind a trusted +# identity edge, switch to header mode and name the header your edge injects — +# there is no default (exe.dev: X-ExeDev-Email, Teleport: X-Forwarded-Email, +# Cloudflare Access: Cf-Access-Authenticated-User-Email): +# DRUKS_AUTH_MODE=header +# DRUKS_AUTH_HEADER= +DRUKS_AUTH_MODE=none + # Webhook authentication and public ingress. DRUKS_WEBHOOK_HOST is used only # for the doctor's external probe; leave it empty when no public ingress exists. DRUKS_WEBHOOK_SECRET=change-me diff --git a/backend/druks/accounts/constants.py b/backend/druks/accounts/constants.py index 6377fc2..7ffc5d5 100644 --- a/backend/druks/accounts/constants.py +++ b/backend/druks/accounts/constants.py @@ -5,9 +5,6 @@ # start; no provider email ever collides with it. SYSTEM_ACCOUNT_ID = "system" -# A signed-in session in Redis: token -> account_id. -SESSION_PREFIX = "druks:session:" - # A personal access token serializes as druks_pat__. The prefix # is the row's lookup key and the only part that may appear in errors, logs, or # lists; the secret exists only inside the copy-once plaintext. diff --git a/backend/druks/accounts/dependencies.py b/backend/druks/accounts/dependencies.py index bae574a..7061351 100644 --- a/backend/druks/accounts/dependencies.py +++ b/backend/druks/accounts/dependencies.py @@ -1,56 +1,137 @@ -from fastapi import HTTPException, Request +from collections.abc import AsyncIterator + +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from druks.accounts import sessions from druks.accounts.context import current_account_id -from druks.accounts.exceptions import InvalidPatError +from druks.accounts.exceptions import AuthConfigurationError, InvalidPatError from druks.accounts.models import Account, PersonalAccessToken _BEARER_CHALLENGE = 'Bearer realm="druks"' +# auto_error=False: absence and malformed both come back None — presence is +# checked separately so a malformed header hard-fails instead of sliding to +# the session identity. Registers the bearer scheme in the OpenAPI schema. +_bearer_scheme = HTTPBearer(auto_error=False, scheme_name="personalAccessToken") + + +def resolve_pat_account(credentials: HTTPAuthorizationCredentials | None) -> Account: + """A present Authorization must authenticate — never a fall-through.""" + if credentials: + try: + return PersonalAccessToken.authenticate(credentials.credentials).account + except InvalidPatError as error: + raise HTTPException( + status_code=401, + detail=str(error), + headers={"WWW-Authenticate": f'{_BEARER_CHALLENGE}, error="invalid_token"'}, + ) from error + raise HTTPException( + status_code=401, + detail="Authorization must be: Bearer .", + headers={"WWW-Authenticate": _BEARER_CHALLENGE}, + ) -async def resolve_session_account(request: Request) -> Account | None: - token = request.cookies.get(sessions.SESSION_COOKIE) - if not token: - return - account_id = await sessions.resolve_session(token) - if not account_id: - return - return Account.get(account_id) - - -async def current_session_account(request: Request) -> Account: - """The signed-in session's account, else 401 — the only door for PAT - management, so a token can never manage tokens.""" - account = await resolve_session_account(request) - if account: - current_account_id.set(account.id) - return account - raise HTTPException(status_code=401, detail="Sign in to use this API.") - - -async def current_account(request: Request) -> Account: - """The calling account: the Bearer personal access token when an - Authorization header is present — never falling back to the cookie — - else the signed-in session.""" - header = request.headers.get("Authorization") - # Present-but-empty is still present: it must be challenged, never slide - # to the cookie. - if header is not None: - scheme, _, credential = header.partition(" ") - if scheme == "Bearer" and credential and " " not in credential: - try: - pat = PersonalAccessToken.authenticate(credential) - except InvalidPatError as error: - raise HTTPException( - status_code=401, - detail=str(error), - headers={"WWW-Authenticate": f'{_BEARER_CHALLENGE}, error="invalid_token"'}, - ) from error - current_account_id.set(pat.account_id) - return pat.account +def resolve_single_operator() -> Account | None: + """None while zero accounts exist (setup); more than one refuses rather + than guesses.""" + operators = Account.list_non_system() + if len(operators) > 1: + raise AuthConfigurationError( + f"auth mode 'none' expects exactly one operator account, found " + f"{len(operators)} — remove the extras or switch to header mode" + ) + return operators[0] if operators else None + + +def _resolve_operator(request: Request) -> Account | None: + """None only during none/zero setup. Header mode open-enrolls the edge's + asserted email; none mode ignores the header entirely.""" + settings = request.app.state.settings + if settings.auth_mode == "none": + return resolve_single_operator() + values = request.headers.getlist(settings.auth_header) + if len(values) == 1 and (email := values[0].strip()): + return Account.get_or_create(email) + raise HTTPException( + status_code=401, + detail=f"The edge must assert exactly one nonblank {settings.auth_header} identity.", + ) + + +def _require_no_bearer(request: Request) -> None: + if "Authorization" in request.headers: raise HTTPException( status_code=401, - detail="Authorization must be exactly: Bearer .", - headers={"WWW-Authenticate": _BEARER_CHALLENGE}, + detail="This API accepts your edge or local operator identity only, " + "never a bearer token.", + ) + + +async def current_account( + request: Request, + bearer: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), +) -> AsyncIterator[Account]: + """The Bearer PAT when Authorization is present — present-but-empty still + challenges — else the session identity.""" + if "Authorization" in request.headers: + account = resolve_pat_account(bearer) + else: + account = _resolve_operator(request) + if not account: + raise HTTPException( + status_code=409, + detail="No operator account exists yet — connect a harness to finish setup.", + ) + token = current_account_id.set(account.id) + try: + yield account + finally: + # The actor must not leak into whatever runs on this task next. + current_account_id.reset(token) + + +async def current_session_account(request: Request) -> AsyncIterator[Account]: + """The signed-in human, never a bearer — a token cannot manage + capabilities. Identity re-asserts per request; no session state.""" + _require_no_bearer(request) + account = _resolve_operator(request) + if not account: + raise HTTPException( + status_code=409, + detail="No operator account exists yet — connect a harness to finish setup.", ) - return await current_session_account(request) + token = current_account_id.set(account.id) + try: + yield account + finally: + current_account_id.reset(token) + + +async def current_session_or_setup(request: Request) -> AsyncIterator[Account | None]: + """The signed-in human; None during none/zero setup, where the first + completed connection creates the operator.""" + _require_no_bearer(request) + account = _resolve_operator(request) + token = current_account_id.set(account.id if account else None) + try: + yield account + finally: + current_account_id.reset(token) + + +async def current_account_or_setup( + request: Request, + bearer: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), +) -> AsyncIterator[Account | None]: + """PAT-first identity that reads none/zero setup as None instead of + refusing — ``/api/auth/me`` only.""" + if "Authorization" in request.headers: + account = resolve_pat_account(bearer) + else: + account = _resolve_operator(request) + token = current_account_id.set(account.id if account else None) + try: + yield account + finally: + current_account_id.reset(token) diff --git a/backend/druks/accounts/exceptions.py b/backend/druks/accounts/exceptions.py index a411f00..798764f 100644 --- a/backend/druks/accounts/exceptions.py +++ b/backend/druks/accounts/exceptions.py @@ -1,3 +1,9 @@ class InvalidPatError(Exception): """A presented bearer credential that resolves to no live personal access token — unknown, mismatched, revoked, or expired.""" + + +class AuthConfigurationError(Exception): + """The configured auth mode cannot resolve a single operator identity — + e.g. ``none`` mode with more than one non-system account. Refuses the + request (and startup) instead of guessing which account is the operator.""" diff --git a/backend/druks/accounts/models.py b/backend/druks/accounts/models.py index 6fe6a48..3ba8806 100644 --- a/backend/druks/accounts/models.py +++ b/backend/druks/accounts/models.py @@ -5,7 +5,7 @@ from datetime import datetime from sqlalchemy import ForeignKey, Index, LargeBinary, String, select -from sqlalchemy.dialects.postgresql import CITEXT +from sqlalchemy.dialects.postgresql import CITEXT, insert from sqlalchemy.orm import Mapped, mapped_column, relationship from druks.accounts.constants import ( @@ -16,6 +16,7 @@ PAT_PREFIX_LENGTH, PAT_SECRET_BYTES, PAT_TOKEN_TAG, + SYSTEM_ACCOUNT_ID, ) from druks.accounts.exceptions import InvalidPatError from druks.core.models import Uuid7Pk @@ -46,14 +47,24 @@ def get_for_username(cls, username: str) -> "Account | None": @classmethod def get_or_create(cls, username: str) -> "Account": + """Concurrency-safe lookup-or-create: racing requests both INSERT with + ON CONFLICT DO NOTHING, then converge on the one row through the + canonical CITEXT lookup.""" account = cls.get_for_username(username) if account: return account - account = cls(username=username) session = db_session() - session.add(account) - session.flush() - return account + session.execute( + insert(cls) + .values(username=username) + .on_conflict_do_nothing(index_elements=["username"]) + ) + return session.scalars(select(cls).where(cls.username == username)).one() + + @classmethod + def list_non_system(cls) -> list["Account"]: + stmt = select(cls).where(cls.username != SYSTEM_ACCOUNT_ID).order_by(cls.created_at) + return list(db_session().scalars(stmt)) def _hash_token(token: str) -> bytes: diff --git a/backend/druks/accounts/routes.py b/backend/druks/accounts/routes.py index 8926f8b..be686ea 100644 --- a/backend/druks/accounts/routes.py +++ b/backend/druks/accounts/routes.py @@ -1,116 +1,27 @@ -from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response +from fastapi import APIRouter, Body, Depends, HTTPException, Request -from druks.accounts import sessions from druks.accounts.constants import PAT_NAME_LENGTH -from druks.accounts.dependencies import ( - current_account, - current_session_account, - resolve_session_account, -) +from druks.accounts.dependencies import current_account_or_setup, current_session_account from druks.accounts.models import Account, PersonalAccessToken -from druks.accounts.schemas import AccountResponse, PatResponse -from druks.harnesses.base import Harness -from druks.harnesses.exceptions import LoginError +from druks.accounts.schemas import AccountResponse, IdentityResponse, PatResponse from druks.harnesses.models import HarnessConnection -from druks.harnesses.registry import get_harness -from druks.user_settings.models import HarnessSettings, UserSettings router = APIRouter(prefix="/api/auth", tags=["auth"]) -def _resolve_harness(name: str) -> type[Harness]: - harness = get_harness(name) - if harness: - return harness - raise HTTPException(status_code=404, detail=f"Unknown harness: {name!r}") - - -def _set_session_cookie(request: Request, response: Response, token: str) -> None: - # The shipped edge terminates TLS and proxies loopback HTTP. - scheme = request.headers.get("x-forwarded-proto", request.url.scheme) - response.set_cookie( - sessions.SESSION_COOKIE, - token, - max_age=sessions.SESSION_TTL_SECONDS if token else 0, - httponly=True, - samesite="lax", - secure=scheme == "https", +@router.get("/me", response_model=IdentityResponse, response_model_by_alias=True) +async def get_identity( + request: Request, account: Account | None = Depends(current_account_or_setup) +) -> IdentityResponse: + return IdentityResponse( + auth_mode=request.app.state.settings.auth_mode, + account=AccountResponse.model_validate(account) if account else None, + # An account needs onboarding exactly while it has no harness + # connection; none/zero is onboarding before the account exists. + onboarding_required=not (account and HarnessConnection.list_for_account(account.id)), ) -@router.get("/session", response_model=AccountResponse, response_model_by_alias=True) -async def get_session( - request: Request, response: Response, account: Account = Depends(current_account) -) -> Account: - # Slide the cookie with the Redis TTL; a Bearer request carries none. - token = request.cookies.get(sessions.SESSION_COOKIE) - if token: - _set_session_cookie(request, response, token) - return account - - -@router.post("/harnesses/{name}/login/start") -async def start_login(name: str, request: Request) -> dict[str, str]: - harness = _resolve_harness(name) - account = await resolve_session_account(request) - url, login_id = await harness.login_start(account_id=account.id if account else None) - return {"authorizeUrl": url, "loginId": login_id} - - -@router.post( - "/harnesses/{name}/login/complete", - response_model=AccountResponse, - response_model_by_alias=True, -) -async def complete_login( - name: str, - request: Request, - response: Response, - code: str = Body(..., embed=True), - login_id: str = Body(..., embed=True, alias="loginId"), -) -> Account: - harness = _resolve_harness(name) - session_account = await resolve_session_account(request) - try: - completed = await harness.login_complete(flow_id=login_id, pasted=code) - except LoginError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - if completed.account_id and (not session_account or completed.account_id != session_account.id): - # A bound reconnect must never rebind the login by email fallback. - raise HTTPException( - status_code=422, - detail="This sign-in was started under a different session — start it again.", - ) - account = session_account or Account.get_or_create(completed.provider_email) - # Runs with no actor execute as the fallback account; claim the slot when - # none is set yet. - settings = UserSettings.get() - if not settings.fallback_account_id: - settings.set_fallback_account(account.id) - connection = HarnessConnection.connect( - harness=harness.name, - account=account, - payload=completed.payload, - expires_at=completed.expires_at, - provider_email=completed.provider_email, - ) - # Fresh picker right after login; failures are tagged inside, never raised. - await HarnessSettings.require(harness.name).refresh_models(connection) - old_token = request.cookies.get(sessions.SESSION_COOKIE) - if old_token: - await sessions.drop_session(old_token) # login rotates the token - _set_session_cookie(request, response, await sessions.mint_session(account.id)) - return account - - -@router.post("/logout", status_code=204) -async def logout(request: Request, response: Response) -> None: - token = request.cookies.get(sessions.SESSION_COOKIE) - if token: - await sessions.drop_session(token) - _set_session_cookie(request, response, "") - - @router.get("/personal-tokens", response_model=list[PatResponse], response_model_by_alias=True) async def list_pats( account: Account = Depends(current_session_account), diff --git a/backend/druks/accounts/schemas.py b/backend/druks/accounts/schemas.py index b9a26f2..a082e20 100644 --- a/backend/druks/accounts/schemas.py +++ b/backend/druks/accounts/schemas.py @@ -12,6 +12,15 @@ class AccountResponse(BaseResponse): username: str +class IdentityResponse(BaseResponse): + # What /api/auth/me answers: how this deployment authenticates, who the + # request resolved to (null in the none/zero setup state), and whether that + # identity still needs its first harness connection. + auth_mode: str + account: AccountResponse | None + onboarding_required: bool + + class PatResponse(BaseResponse): model_config = ConfigDict(from_attributes=True) diff --git a/backend/druks/accounts/sessions.py b/backend/druks/accounts/sessions.py deleted file mode 100644 index 96a6138..0000000 --- a/backend/druks/accounts/sessions.py +++ /dev/null @@ -1,25 +0,0 @@ -import secrets - -from druks.redis import get_client - -from .constants import SESSION_PREFIX - -SESSION_COOKIE = "druks_session" -SESSION_TTL_SECONDS = 30 * 24 * 3600 - - -async def mint_session(account_id: str) -> str: - token = secrets.token_urlsafe(32) - await get_client().set(f"{SESSION_PREFIX}{token}", account_id, ex=SESSION_TTL_SECONDS) - return token - - -async def resolve_session(token: str) -> str | None: - # GETEX reads and slides the 30-day TTL in one atomic hop. - value = await get_client().getex(f"{SESSION_PREFIX}{token}", ex=SESSION_TTL_SECONDS) - if value: - return value.decode() - - -async def drop_session(token: str) -> None: - await get_client().delete(f"{SESSION_PREFIX}{token}") diff --git a/backend/druks/api/app.py b/backend/druks/api/app.py index 896584d..4aedbe4 100644 --- a/backend/druks/api/app.py +++ b/backend/druks/api/app.py @@ -11,14 +11,16 @@ from starlette.datastructures import MutableHeaders from starlette.routing import Route -from druks.accounts.dependencies import current_account +from druks.accounts.dependencies import current_account, resolve_single_operator +from druks.accounts.exceptions import AuthConfigurationError from druks.accounts.routes import router as auth_router from druks.api.artifacts import router as artifacts_router from druks.api.runs import router as runs_router -from druks.database import configure_session, create_engine_from_url, db_session +from druks.database import configure_session, create_engine_from_url, db_session, session_scope from druks.durable.engine import init_dbos, launch, shutdown from druks.events.routes import router as events_router from druks.extensions.loader import iter_extensions, load +from druks.harnesses.routes import router as harness_connection_router from druks.mcp.catalog import load_mcp_catalog from druks.mcp.gateway.exceptions import AgentApiError from druks.mcp.gateway.routes import router as gateway_router @@ -59,6 +61,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # workflows may deliver MCP servers immediately. A bad catalog stops # boot here, loudly. (The suite loads it from a conftest fixture.) load_mcp_catalog(settings.mcp_catalog_path) + if settings.auth_mode == "none": + # A drifted none-mode install (more than one operator account) must + # refuse at boot, not per request; the per-request resolver repeats + # the check for drift that happens while running. + with session_scope(app.state.engine): + resolve_single_operator() # DBOS runs embedded here: this process both serves HTTP and executes # durable workflows. Tests pre-populate app.state.settings and never # reach here — they drive DBOS through their own fixtures. @@ -153,6 +161,16 @@ async def _agent_api_error_handler(request: Request, exc: AgentApiError) -> JSON ) +# Auth-mode drift (e.g. none mode grew a second operator account) is an +# operator problem, not a caller problem: log it loudly, answer 503. +@app.exception_handler(AuthConfigurationError) +async def _auth_configuration_handler( + request: Request, exc: AuthConfigurationError +) -> JSONResponse: + logging.getLogger(__name__).error("auth configuration failure: %s", exc) + return JSONResponse(status_code=503, content={"error": "HTTP_503", "detail": str(exc)}) + + @app.exception_handler(RequestValidationError) async def _validation_exception_handler( request: Request, @@ -195,23 +213,27 @@ async def _unhandled_exception_handler( # Platform-core routers, mounted by hand at their own prefixes. Extension routers # (core, build, usage, …) are discovered and mounted under /api/ by load(). -# /api sits behind the session gate except the login surface and the health -# probe; /_external routes carry their own authentication. The boundary test -# pins the split. -_session_gate = [Depends(current_account)] +# /api sits behind the identity gate except the identity/connection surface and +# the health probe; /_external routes carry their own authentication. The +# boundary test pins the split. The auth and harness-connection routers mount +# ungated because each of their routes carries its own resolver (/me and the +# connection flow must answer during none/zero setup; capability management +# admits only the session identity). +_identity_gate = [Depends(current_account)] app.include_router(health_router) # Before the webhook catch-all ({hook_path:path}): declaration order is match order. app.include_router(notifications_external_router) app.include_router(webhooks_router) app.include_router(auth_router) -app.include_router(settings_router, dependencies=_session_gate) -app.include_router(skills_router, dependencies=_session_gate) -app.include_router(mcp_router, dependencies=_session_gate) -app.include_router(notifications_router, dependencies=_session_gate) -app.include_router(events_router, dependencies=_session_gate) -app.include_router(runs_router, dependencies=_session_gate) -app.include_router(gateway_router, dependencies=_session_gate) -app.include_router(artifacts_router, dependencies=_session_gate) +app.include_router(harness_connection_router) +app.include_router(settings_router, dependencies=_identity_gate) +app.include_router(skills_router, dependencies=_identity_gate) +app.include_router(mcp_router, dependencies=_identity_gate) +app.include_router(notifications_router, dependencies=_identity_gate) +app.include_router(events_router, dependencies=_identity_gate) +app.include_router(runs_router, dependencies=_identity_gate) +app.include_router(gateway_router, dependencies=_identity_gate) +app.include_router(artifacts_router, dependencies=_identity_gate) load(app) # Tools derive from every route tagged "agent", so the endpoint composes @@ -219,7 +241,7 @@ async def _unhandled_exception_handler( from druks.mcp.app import create_mcp_app # noqa: E402 # A bare Route at exactly /mcp (a Mount would 307 the no-slash path); PATs -# authenticate it, so it sits outside the session gate. +# authenticate it, so it sits outside the identity gate. mcp_app = create_mcp_app(app) app.router.routes.append( Route("/mcp", mcp_app, methods=["POST", "DELETE"], include_in_schema=False) diff --git a/backend/druks/extensions/base.py b/backend/druks/extensions/base.py index 6627c66..c2c2ca5 100644 --- a/backend/druks/extensions/base.py +++ b/backend/druks/extensions/base.py @@ -227,7 +227,7 @@ def load(cls, app: "FastAPI") -> None: modules = cls.discover() # /api/ wraps the author's own prefix so extensions can't shadow - # the platform or each other; every route sits behind the session gate. + # the platform or each other; every route sits behind the identity gate. prefix = f"/api/{cls.name}" for router in cls.get_routers(modules): app.include_router(router, prefix=prefix, dependencies=[Depends(current_account)]) diff --git a/backend/druks/harnesses/base.py b/backend/druks/harnesses/base.py index 6c51edc..c8d7e72 100644 --- a/backend/druks/harnesses/base.py +++ b/backend/druks/harnesses/base.py @@ -20,11 +20,11 @@ from druks.skills.models import Skill from druks.usage.models import UsageScrape -from .constants import LOGIN_PENDING_PREFIX, REFRESH_LOCK_PREFIX +from .constants import CONNECT_PENDING_PREFIX, REFRESH_LOCK_PREFIX from .datastructures import ( AgentInvocation, CodexToken, - CompletedLogin, + CompletedConnect, HarnessRunResult, OAuthToken, ParsedModels, @@ -33,10 +33,10 @@ SandboxSettings, ) from .exceptions import ( + ConnectError, GrantError, HarnessError, HarnessNotConnectedError, - LoginError, OAuthTokenError, ) from .models import HarnessConnection @@ -48,9 +48,10 @@ _GRANT_TIMEOUT_SECONDS = 30.0 _USAGE_TIMEOUT_SECONDS = 20.0 -# The connect-flow pending state (PKCE verifier + state) lives in Redis this long -# — enough to sign in and paste, short enough that an abandoned attempt clears. -_LOGIN_PENDING_TTL_SECONDS = 600 +# The connect-flow pending state (PKCE verifier + state) lives in Redis this +# long — enough to authorize and paste, short enough that an abandoned attempt +# clears. +_CONNECT_PENDING_TTL_SECONDS = 600 # Per-row refresh lock: five minutes outlives the provider grant timeout and # expires before the next 15-minute cron tick if the holder dies mid-refresh. _REFRESH_LOCK_TTL_SECONDS = 300 @@ -212,14 +213,14 @@ def render_credentials_file(cls, connection_id: str | None = None) -> str: if row: return json.dumps(dict(row.payload)) raise HarnessNotConnectedError( - f"the selected {cls.name} login was disconnected — reconnect it in " + f"the selected {cls.name} connection was removed — reconnect it in " "Settings → Harnesses." ) @classmethod - async def login_start(cls, *, account_id: str | None = None) -> tuple[str, str]: + async def connect_start(cls, *, account_id: str | None = None) -> tuple[str, str]: """Mint PKCE state under a single-use flow id; return (authorize URL, - flow id). A reconnect under a live session binds ``account_id``.""" + flow id). A flow started by a resolved operator binds ``account_id``.""" verifier = _b64url(secrets.token_bytes(64)) challenge = _b64url(hashlib.sha256(verifier.encode()).digest()) url, state = cls.authorize_url(verifier=verifier, challenge=challenge) @@ -232,34 +233,34 @@ async def login_start(cls, *, account_id: str | None = None) -> tuple[str, str]: } ) await get_client().set( - f"{LOGIN_PENDING_PREFIX}{flow_id}", pending, ex=_LOGIN_PENDING_TTL_SECONDS + f"{CONNECT_PENDING_PREFIX}{flow_id}", pending, ex=_CONNECT_PENDING_TTL_SECONDS ) return url, flow_id @classmethod - async def login_complete(cls, *, flow_id: str, pasted: str) -> CompletedLogin: + async def connect_complete(cls, *, flow_id: str, pasted: str) -> CompletedConnect: """Pop the flow's single-use state, parse the paste, exchange the code. - Raises :class:`LoginError` on failure; the state is gone either way, + Raises :class:`ConnectError` on failure; the state is gone either way, so a retry re-starts cleanly.""" - pending = await get_client().getdel(f"{LOGIN_PENDING_PREFIX}{flow_id}") # single-use + pending = await get_client().getdel(f"{CONNECT_PENDING_PREFIX}{flow_id}") # single-use if not pending: - raise LoginError("This sign-in expired — start it again.") + raise ConnectError("This connect attempt expired — start it again.") expected = json.loads(pending) code, pasted_state = _parse_pasted(pasted) if not code: - raise LoginError("Couldn't find an authorization code in what you pasted.") + raise ConnectError("Couldn't find an authorization code in what you pasted.") if pasted_state and pasted_state != expected["state"]: - raise LoginError("That code is from a different sign-in — start it again.") + raise ConnectError("That code is from a different connect attempt — start it again.") payload, provider_email = await cls.exchange(code=code, verifier=expected["verifier"]) if not provider_email: - raise LoginError( - "The provider returned no account email — sign in with an account " + raise ConnectError( + "The provider returned no account email — authorize with an account " "that has one and try again." ) _, expires_at = cls._refresh_state(payload) - return CompletedLogin( + return CompletedConnect( payload=payload, provider_email=provider_email, expires_at=expires_at, @@ -270,8 +271,8 @@ async def login_complete(cls, *, flow_id: str, pasted: str) -> CompletedLogin: @abstractmethod def authorize_url(cls, *, verifier: str, challenge: str) -> tuple[str, str]: """Build this provider's PKCE authorize URL; return (url, state), where - ``state`` is what the provider echoes back so login_complete can verify - the round-trip.""" + ``state`` is what the provider echoes back so connect_complete can + verify the round-trip.""" @classmethod @abstractmethod @@ -305,7 +306,7 @@ async def rotate_token( now: datetime | None = None, margin: timedelta | None = None, ) -> RotationResult: - """Refresh one login row's token if it's within the expiry margin, + """Refresh one connection row's token if it's within the expiry margin, persisting the new token back to that row. Addressed by row id and read fresh — the caller's tick may span other rows' commits. One refresher per row: a Redis lock elects the winner, and the loser @@ -356,12 +357,13 @@ async def rotate_token( if exc.tag == "invalid_grant": # The provider revoked this row's refresh lineage; # presenting it again can never succeed. Drop only this - # credential so the login reads as disconnected — the UI - # shows Reconnect and the next tick has no row to hammer. + # credential so the connection reads as disconnected — the + # UI shows Reconnect and the next tick has no row to hammer. row.delete() db_session().commit() logger.warning( - "%s login %s auto-disconnected after invalid_grant; reconnect to restore", + "%s connection %s auto-disconnected after invalid_grant; " + "reconnect to restore", cls.name, row.id, ) @@ -653,8 +655,8 @@ def _parse_pasted(raw: str) -> tuple[str | None, str | None]: async def post_token(url: str, body: dict, *, form: bool) -> dict: """POST an authorization-code exchange (form- or JSON-encoded) and return the - parsed grant. Raises :class:`LoginError` with the provider's error text on any - failure, so the operator sees why the sign-in didn't take.""" + parsed grant. Raises :class:`ConnectError` with the provider's error text on + any failure, so the operator sees why the connect didn't take.""" try: async with httpx.AsyncClient(timeout=_GRANT_TIMEOUT_SECONDS) as client: if form: @@ -663,7 +665,7 @@ async def post_token(url: str, body: dict, *, form: bool) -> dict: response = await client.post(url, json=body) except httpx.HTTPError as exc: logger.warning("token exchange request failed (%s): %s", url, exc, exc_info=True) - raise LoginError("The request to the provider failed — try again.") from exc + raise ConnectError("The request to the provider failed — try again.") from exc if response.status_code != 200: logger.warning( "token exchange returned %s (%s): %s", @@ -672,11 +674,11 @@ async def post_token(url: str, body: dict, *, form: bool) -> dict: response.text[:300], ) detail = response.text.strip()[:300] or f"HTTP {response.status_code}" - raise LoginError(f"The provider rejected the sign-in: {detail}") + raise ConnectError(f"The provider rejected the connect: {detail}") try: return response.json() except ValueError as exc: - raise LoginError("The provider returned an unreadable response.") from exc + raise ConnectError("The provider returned an unreadable response.") from exc def check_returncode(result: HarnessRunResult, *, name: str) -> None: diff --git a/backend/druks/harnesses/claude.py b/backend/druks/harnesses/claude.py index 1777b89..e9fecb9 100644 --- a/backend/druks/harnesses/claude.py +++ b/backend/druks/harnesses/claude.py @@ -50,7 +50,7 @@ class ClaudeHarness(Harness): # Connect-flow (PKCE code-paste): authorize on claude.ai, land on the # console.anthropic.com code page, exchange JSON with the state echoed in the - # body. Verified in ENG-687. + # body. redirect_uri = "https://console.anthropic.com/oauth/code/callback" def build_invocation( @@ -217,7 +217,7 @@ def _grant_body(cls, refresh_token: str) -> dict: @classmethod def authorize_url(cls, *, verifier: str, challenge: str) -> tuple[str, str]: # Anthropic's console flow echoes the PKCE verifier back as the OAuth - # state (verified in ENG-687), so that's what login_complete checks. + # state, so that's what connect_complete checks. params = { "code": "true", "client_id": cls._CLIENT_ID, diff --git a/backend/druks/harnesses/codex.py b/backend/druks/harnesses/codex.py index 778785f..d8ce909 100644 --- a/backend/druks/harnesses/codex.py +++ b/backend/druks/harnesses/codex.py @@ -287,7 +287,7 @@ class CodexHarness(Harness): _CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" # Connect-flow (PKCE): authorize on auth.openai.com; the operator pastes the - # failed localhost redirect URL back. Verified in ENG-687. + # failed localhost redirect URL back. redirect_uri = "http://localhost:1455/auth/callback" @classmethod @@ -321,7 +321,7 @@ def authorize_url(cls, *, verifier: str, challenge: str) -> tuple[str, str]: params = { "id_token_add_organizations": "true", "codex_cli_simplified_flow": "true", - "originator": "pi", # the value verified in ENG-687; others untested + "originator": "pi", # the only value verified against the live exchange "client_id": cls._CLIENT_ID, "response_type": "code", "redirect_uri": cls.redirect_uri, diff --git a/backend/druks/harnesses/constants.py b/backend/druks/harnesses/constants.py index 73c8ecf..960d33d 100644 --- a/backend/druks/harnesses/constants.py +++ b/backend/druks/harnesses/constants.py @@ -1,4 +1,5 @@ -# Redis keys for harness logins: a pending flow's state while the operator -# completes it, and the per-connection SET NX lock that serializes refresh. -LOGIN_PENDING_PREFIX = "druks:login:pending:" +# Redis keys for harness connections: a pending connect flow's state while the +# operator completes it, and the per-connection SET NX lock that serializes +# refresh. +CONNECT_PENDING_PREFIX = "druks:harness:connect:pending:" REFRESH_LOCK_PREFIX = "druks:harness:refresh:" diff --git a/backend/druks/harnesses/datastructures.py b/backend/druks/harnesses/datastructures.py index 4649197..e7ecc48 100644 --- a/backend/druks/harnesses/datastructures.py +++ b/backend/druks/harnesses/datastructures.py @@ -32,12 +32,12 @@ class CodexToken: @dataclass(frozen=True) -class CompletedLogin: +class CompletedConnect: payload: dict provider_email: str expires_at: datetime | None - # The session account a reconnect was bound to at start; None on an - # initial login. + # The operator account the flow was bound to at start; None on an unbound + # none/zero setup flow. account_id: str | None diff --git a/backend/druks/harnesses/exceptions.py b/backend/druks/harnesses/exceptions.py index bfee00a..bcdba55 100644 --- a/backend/druks/harnesses/exceptions.py +++ b/backend/druks/harnesses/exceptions.py @@ -36,7 +36,7 @@ def __init__(self, tag: str) -> None: self.tag = tag -class LoginError(Exception): +class ConnectError(Exception): """A connect flow could not complete — expired/single-use pending state, a paste with no code, a state mismatch, or a provider-rejected exchange. The message is user-facing (surfaced inline in the Settings card).""" diff --git a/backend/druks/harnesses/models.py b/backend/druks/harnesses/models.py index bc315e7..4d7fa16 100644 --- a/backend/druks/harnesses/models.py +++ b/backend/druks/harnesses/models.py @@ -33,8 +33,8 @@ class HarnessConnection(Base, Uuid7Pk): updated_at: Mapped[datetime] = mapped_column(default=Base.utc_now, onupdate=Base.utc_now) @classmethod - def get(cls, login_id: str) -> "HarnessConnection | None": - return db_session().get(cls, login_id) + def get(cls, connection_id: str) -> "HarnessConnection | None": + return db_session().get(cls, connection_id) @classmethod def lookup(cls, harness: str, account_id: str | None) -> "HarnessConnection": @@ -69,18 +69,23 @@ def get_for_account( def list_all(cls) -> list["HarnessConnection"]: return list(db_session().scalars(select(cls).order_by(cls.harness, cls.id))) + @classmethod + def list_for_account(cls, account_id: str) -> list["HarnessConnection"]: + stmt = select(cls).where(cls.account_id == account_id).order_by(cls.harness) + return list(db_session().scalars(stmt)) + @classmethod def list_for_harness(cls, harness: str) -> list["HarnessConnection"]: stmt = select(cls).where(cls.harness == harness).order_by(cls.id) return list(db_session().scalars(stmt)) @classmethod - def reload(cls, login_id: str) -> "HarnessConnection | None": + def reload(cls, connection_id: str) -> "HarnessConnection | None": """Fresh-from-DB read of one row, past the identity map's cached state — the post-lock re-read that keeps a refresher from re-presenting a refresh token a concurrent winner already advanced.""" return db_session().scalar( - select(cls).where(cls.id == login_id).execution_options(populate_existing=True) + select(cls).where(cls.id == connection_id).execution_options(populate_existing=True) ) @classmethod diff --git a/backend/druks/harnesses/routes.py b/backend/druks/harnesses/routes.py new file mode 100644 index 0000000..07db83d --- /dev/null +++ b/backend/druks/harnesses/routes.py @@ -0,0 +1,110 @@ +import logging +from contextlib import suppress + +from fastapi import APIRouter, Body, Depends, HTTPException + +from druks.accounts.dependencies import current_session_account, current_session_or_setup +from druks.accounts.models import Account +from druks.accounts.schemas import AccountResponse +from druks.database import db_session +from druks.harnesses.base import Harness +from druks.harnesses.exceptions import ConnectError +from druks.harnesses.models import HarnessConnection +from druks.harnesses.registry import get_harness +from druks.user_settings.models import HarnessSettings, UserSettings +from druks.user_settings.schemas import HarnessResponse + +router = APIRouter(prefix="/api/harnesses", tags=["harnesses"]) + + +def _resolve_harness(name: str) -> type[Harness]: + harness = get_harness(name) + if harness: + return harness + raise HTTPException(status_code=404, detail=f"Unknown harness: {name!r}") + + +@router.post("/{name}/connection/start") +async def start_connection( + name: str, account: Account | None = Depends(current_session_or_setup) +) -> dict[str, str]: + harness = _resolve_harness(name) + # A resolved operator binds the flow; none/zero starts the unbound setup + # flow whose completion creates the operator. + url, connection_id = await harness.connect_start(account_id=account.id if account else None) + return {"authorizeUrl": url, "connectionId": connection_id} + + +@router.post( + "/{name}/connection/complete", + response_model=AccountResponse, + response_model_by_alias=True, +) +async def complete_connection( + name: str, + account: Account | None = Depends(current_session_or_setup), + code: str = Body(..., embed=True), + connection_id: str = Body(..., embed=True, alias="connectionId"), +) -> AccountResponse: + harness = _resolve_harness(name) + try: + completed = await harness.connect_complete(flow_id=connection_id, pasted=code) + except ConnectError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if completed.account_id: + # A bound flow must complete under the operator that started it — + # never rebound by email fallback. + if not account or account.id != completed.account_id: + raise HTTPException( + status_code=422, + detail="This connect was started under a different operator — start it again.", + ) + resolved = account + else: + # The unbound setup flow attaches to the operator this request resolved + # — a flow started before the account existed still lands on it. Only a + # still-account-less request creates the operator from the + # provider-verified email; get_or_create is atomic, so concurrent + # completions of the same email converge, and a true different-email + # race surfaces as the none-mode multi-operator refusal. + resolved = account or Account.get_or_create(completed.provider_email) + # Runs with no actor execute as the fallback account; claim the slot when + # none is set yet. + settings = UserSettings.get() + if not settings.fallback_account_id: + settings.set_fallback_account(resolved.id) + connection = HarnessConnection.connect( + harness=harness.name, + account=resolved, + payload=completed.payload, + expires_at=completed.expires_at, + provider_email=completed.provider_email, + ) + # Materialize the reply, then land the credential before any provider I/O — + # an await while flushed rows still hold their locks can stall every other + # writer on this event loop, and nothing past the point of durability may + # depend on another database read. + response = AccountResponse.model_validate(resolved) + db_session().commit() + try: + # Fresh picker right after connect; fetch failures are tagged inside. + # The single-use flow is already spent, so trouble here — including a + # database that vanished under the refresh — only logs. + await HarnessSettings.require(harness.name).refresh_models(connection) + except Exception: + logging.getLogger(__name__).exception("Model refresh after connect failed") + with suppress(Exception): + db_session().rollback() + return response + + +@router.delete("/{name}/connection", response_model=HarnessResponse, response_model_by_alias=True) +async def disconnect_harness( + name: str, account: Account = Depends(current_session_account) +) -> HarnessResponse: + harness = _resolve_harness(name) + connection = HarnessConnection.get_for_account(harness.name, account.id) + if connection: + # Only the requesting account's own connection — never another's. + connection.delete() + return HarnessResponse.from_row(HarnessSettings.require(harness.name), None, account) diff --git a/backend/druks/settings.py b/backend/druks/settings.py index cf345a9..900ff14 100644 --- a/backend/druks/settings.py +++ b/backend/druks/settings.py @@ -1,10 +1,10 @@ import base64 import logging from pathlib import Path -from typing import Annotated, Any +from typing import Annotated, Any, Literal import asyncssh -from pydantic import BeforeValidator, Field +from pydantic import BeforeValidator, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict DEFAULT_DATA_DIR = Path("/var/lib/druks") @@ -89,6 +89,16 @@ class Settings(BaseSettings): alias="DRUKS_DATABASE_URL", ) + # How a browser request resolves an account. ``none``: no authentication — + # loopback-only deployments with a single operator account. ``header``: the + # edge (exe.dev, Teleport, Cloudflare Access, …) authenticates and asserts + # the operator's email in ``auth_header``; druks maps it to an account. + # Bearer personal access tokens resolve first in either mode. + auth_mode: Literal["none", "header"] = Field(default="none", alias="DRUKS_AUTH_MODE") + # No default: the operator names their edge's header explicitly — druks + # blesses no provider. + auth_header: str = Field(default="", alias="DRUKS_AUTH_HEADER") + webhook_secret: str = Field(default="", alias="DRUKS_WEBHOOK_SECRET") # Public hostname webhook senders POST to (Caddy serves it; see # deploy/compose.yaml). Druks itself only reads it for the doctor's @@ -197,6 +207,14 @@ class Settings(BaseSettings): log_level: str = Field(default="INFO", alias="DRUKS_LOG_LEVEL") + @model_validator(mode="after") + def _header_mode_names_its_header(self) -> "Settings": + if self.auth_mode == "header" and not self.auth_header.strip(): + raise ValueError( + "DRUKS_AUTH_HEADER must name the edge's identity header when DRUKS_AUTH_MODE=header" + ) + return self + @property def logs_dir(self) -> Path: return self.data_dir / "logs" diff --git a/backend/druks/setup_env.py b/backend/druks/setup_env.py index 77d6c9e..e5e4097 100644 --- a/backend/druks/setup_env.py +++ b/backend/druks/setup_env.py @@ -126,6 +126,45 @@ def _secrets_key() -> str: ), ) +# How each install shape resolves browser identity. Both Caddy and druks read +# DRUKS_AUTH_HEADER: Caddy requires the edge's assertion, druks maps it to an +# account. +_EXE_IDENTITY_SECTION = Section( + "IDENTITY — exe.dev authenticates at the edge; druks maps the asserted email", + ( + Entry("DRUKS_AUTH_MODE", "header"), + Entry("DRUKS_AUTH_HEADER", "X-ExeDev-Email"), + ), +) + +_AWS_IDENTITY_SECTION = Section( + "IDENTITY — your edge proxy authenticates; druks maps the asserted email", + ( + Entry("DRUKS_AUTH_MODE", "header"), + Entry( + "DRUKS_AUTH_HEADER", + prompt="Identity header your edge proxy injects (e.g. X-Forwarded-Email)", + required=True, + comment=( + "The header your identity proxy (Teleport, Cloudflare Access, …)\n" + "injects after authenticating. The proxy must strip any\n" + "client-supplied copy before inserting its own." + ), + ), + ), +) + +_LOCAL_IDENTITY_SECTION = Section( + "IDENTITY — loopback dashboard, no authentication; one operator account", + (Entry("DRUKS_AUTH_MODE", "none"),), +) + +_IDENTITY_SECTIONS = { + "exe": _EXE_IDENTITY_SECTION, + "aws": _AWS_IDENTITY_SECTION, + "docker": _LOCAL_IDENTITY_SECTION, +} + # How druks reaches drukbox. The remote shapes run drukbox itself from this # same .env (compose `remote` profile): container on :8780, generated token; # SERVICE_TOKENS is drukbox's accepted-token list, mirrored from the sandbox @@ -213,7 +252,7 @@ def _secrets_key() -> str: def sections_for(provider: str) -> tuple[Section, ...]: - return (*_COMMON_SECTIONS, _PROVIDER_SECTIONS[provider]) + return (*_COMMON_SECTIONS, _IDENTITY_SECTIONS[provider], _PROVIDER_SECTIONS[provider]) def read_env(path: Path) -> dict[str, str]: diff --git a/backend/druks/user_settings/routes.py b/backend/druks/user_settings/routes.py index e290037..ebc4e80 100644 --- a/backend/druks/user_settings/routes.py +++ b/backend/druks/user_settings/routes.py @@ -45,12 +45,12 @@ def _resolve_harness(name: str) -> tuple[type, HarnessSettings]: def _harness_response(settings: HarnessSettings, account: Account) -> HarnessResponse: - login = HarnessConnection.get_for_account(settings.name, account.id) - return HarnessResponse.from_row(settings, login, account) + connection = HarnessConnection.get_for_account(settings.name, account.id) + return HarnessResponse.from_row(settings, connection, account) -# Connection state is the signed-in account's own login — connect/reconnect -# live on the /api/auth login surface, not here. +# Connection state is the requesting operator's own connection — +# connect/disconnect live on the /api/harnesses connection surface, not here. @router.get("/harnesses", response_model=list[HarnessResponse], response_model_by_alias=True) async def list_harness_settings( account: Account = Depends(current_account), @@ -79,20 +79,6 @@ async def update_harness_settings( return _harness_response(row, account) -@router.delete( - "/harnesses/{name}/login", response_model=HarnessResponse, response_model_by_alias=True -) -async def disconnect_harness( - name: str, account: Account = Depends(current_account) -) -> HarnessResponse: - _, row = _resolve_harness(name) - login = HarnessConnection.get_for_account(name, account.id) - if login: - # Only the signed-in account's own login — never another account's. - login.delete() - return _harness_response(row, account) - - @router.get("", response_model=UserSettingsResponse, response_model_by_alias=True) async def get_user_settings() -> UserSettings: return UserSettings.get() diff --git a/backend/druks/user_settings/schemas.py b/backend/druks/user_settings/schemas.py index e720bc5..7987203 100644 --- a/backend/druks/user_settings/schemas.py +++ b/backend/druks/user_settings/schemas.py @@ -26,7 +26,7 @@ class HarnessResponse(BaseResponse): timeout: int fast_mode: bool allowed_models: list[AllowedModel] - # The signed-in account's own connection; false until this account connects. + # The requesting account's own connection; false until this account connects. connected: bool kind: str | None account: str | None @@ -36,7 +36,10 @@ class HarnessResponse(BaseResponse): @classmethod def from_row( - cls, settings: "HarnessSettings", login: "HarnessConnection | None", account: "Account" + cls, + settings: "HarnessSettings", + connection: "HarnessConnection | None", + account: "Account", ) -> "HarnessResponse": return cls( name=settings.name, @@ -46,11 +49,11 @@ def from_row( timeout=settings.timeout, fast_mode=settings.fast_mode, allowed_models=settings.allowed_models, - connected=bool(login), - kind=login.kind if login else None, - account=account.username if login else None, - provider_email=login.provider_email if login else None, - expires_at=login.expires_at if login else None, + connected=bool(connection), + kind=connection.kind if connection else None, + account=account.username if connection else None, + provider_email=connection.provider_email if connection else None, + expires_at=connection.expires_at if connection else None, ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 859328b..c8fc35b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -72,12 +72,6 @@ async def set(self, key: str, value: str, *, nx: bool = False, ex: int | None = async def get(self, key: str) -> bytes | None: return self._data.get(key) - async def getex(self, key: str, *, ex: int | None = None) -> bytes | None: - value = self._data.get(key) - if value is not None and ex is not None: - self._ttls[key] = ex - return value - async def exists(self, key: str) -> int: return int(key in self._data) @@ -346,7 +340,7 @@ def configure_app_for_test( authenticated: bool = True, ): - from druks.accounts.dependencies import current_account + from druks.accounts.dependencies import current_account, current_session_account from druks.api.app import app if engine is None: @@ -356,9 +350,10 @@ def configure_app_for_test( app.state.settings = settings app.state.engine = engine if authenticated: - # Stand a signed-in account in for the gate; auth tests pass - # authenticated=False and walk the real cookie flow. + # Stand a resolved operator in for both identity gates; identity tests + # pass authenticated=False and walk the real per-request resolvers. app.dependency_overrides[current_account] = _test_account + app.dependency_overrides[current_session_account] = _test_account return app diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index 19803ee..2550acd 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -82,7 +82,11 @@ def test_openapi_pins_the_five_agent_routes(client: TestClient): def test_agent_routes_sit_behind_the_gate(tmp_path, db_session): - app = configure_app_for_test(settings=make_settings(tmp_path), authenticated=False) + # Header mode: an unasserted request is a 401, not none-mode's setup 409. + app = configure_app_for_test( + settings=make_settings(tmp_path, auth_mode="header", auth_header="X-Edge-Email"), + authenticated=False, + ) with TestClient(app) as anonymous: assert anonymous.get("/api/gates/x").status_code == 401 assert anonymous.get("/api/usage/summary").status_code == 401 diff --git a/backend/tests/test_api_settings.py b/backend/tests/test_api_settings.py index 759a60d..91bd300 100644 --- a/backend/tests/test_api_settings.py +++ b/backend/tests/test_api_settings.py @@ -38,11 +38,11 @@ def test_harness_response_carries_connection_state(tmp_path: Path): assert "expiresAt" in claude -def test_harnesses_show_only_the_signed_in_accounts_connection(tmp_path: Path, db_session): +def test_harnesses_show_only_the_requesting_accounts_connection(tmp_path: Path, db_session): from conftest import connect_harness from druks.harnesses.claude import ClaudeHarness - # The suite's gate stands in op@example.com; another account's + # The suite's identity gate stands in op@example.com; another account's # connection never shows on this card. connect_harness( ClaudeHarness, @@ -73,7 +73,7 @@ def test_harness_card_reports_identity(tmp_path: Path, db_session): assert claude["providerEmail"] == "seat@corp.com" -def test_disconnect_removes_only_the_signed_in_accounts_connection(tmp_path: Path, db_session): +def test_disconnect_removes_only_the_requesting_accounts_connection(tmp_path: Path, db_session): from conftest import connect_harness from druks.harnesses.claude import ClaudeHarness from druks.harnesses.models import HarnessConnection @@ -86,7 +86,7 @@ def test_disconnect_removes_only_the_signed_in_accounts_connection(tmp_path: Pat ) mine_id, other_id = mine.id, other.id with _build_client(tmp_path) as client: - response = client.delete("/api/settings/harnesses/claude/login") + response = client.delete("/api/harnesses/claude/connection") assert response.status_code == 200 assert response.json()["connected"] is False # The request deleted in its own task-scoped session; read past this @@ -97,7 +97,7 @@ def test_disconnect_removes_only_the_signed_in_accounts_connection(tmp_path: Pat def test_disconnect_without_a_connection_is_a_no_op(tmp_path: Path): with _build_client(tmp_path) as client: - response = client.delete("/api/settings/harnesses/claude/login") + response = client.delete("/api/harnesses/claude/connection") assert response.status_code == 200 assert response.json()["connected"] is False diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py deleted file mode 100644 index 82a8233..0000000 --- a/backend/tests/test_auth.py +++ /dev/null @@ -1,253 +0,0 @@ -import base64 -import json -from pathlib import Path - -import druks.redis -import httpx -import pytest -from conftest import configure_app_for_test, connect_harness, make_settings -from druks import database -from druks.accounts.constants import SESSION_PREFIX -from druks.accounts.models import Account -from druks.accounts.sessions import SESSION_COOKIE -from druks.harnesses import base as hbase -from druks.harnesses.claude import ClaudeHarness -from druks.harnesses.models import HarnessConnection -from druks.user_settings.models import UserSettings -from fastapi.testclient import TestClient -from sqlalchemy import select - - -@pytest.fixture(autouse=True) -def _clear_redis(): - druks.redis.get_client()._data.clear() - yield - - -def _client(tmp_path: Path, **settings_overrides) -> TestClient: - app = configure_app_for_test( - settings=make_settings(tmp_path, **settings_overrides), authenticated=False - ) - return TestClient(app) - - -def _grant(email: str = "me@example.com") -> dict: - return { - "access_token": "AT", - "refresh_token": "RT", - "expires_in": 28800, - "scope": "user:profile", - "account": {"email_address": email}, - } - - -def _mock_exchange(monkeypatch, grant: dict): - async def fake_post(self, url, *, json=None, data=None, **_kwargs): - return httpx.Response(200, text=_dumps(grant), request=httpx.Request("POST", url)) - - monkeypatch.setattr(hbase.httpx.AsyncClient, "post", fake_post) - - -def _dumps(value: dict) -> str: - return json.dumps(value) - - -def _login(client: TestClient, monkeypatch, *, email="me@example.com") -> dict: - start = client.post("/api/auth/harnesses/claude/login/start") - assert start.status_code == 200 - _mock_exchange(monkeypatch, _grant(email)) - return client.post( - "/api/auth/harnesses/claude/login/complete", - json={"code": "thecode", "loginId": start.json()["loginId"]}, - ) - - -def test_a_sessionless_request_gets_401(tmp_path): - # One behavioral canary; the boundary enumeration proves the full surface. - with _client(tmp_path) as client: - assert client.get("/api/auth/session").status_code == 401 - - -def test_login_flow_mints_session_and_account(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - start = client.post("/api/auth/harnesses/claude/login/start") - assert start.json()["authorizeUrl"].startswith("https://claude.ai/oauth/authorize?") - - response = _login(client, monkeypatch) - assert response.status_code == 200 - assert response.json()["username"] == "me@example.com" - cookie = response.headers["set-cookie"] - assert SESSION_COOKIE in cookie - assert "HttpOnly" in cookie - assert "SameSite=lax" in cookie - assert "Path=/" in cookie - - session = client.get("/api/auth/session") - assert session.status_code == 200 - assert session.json()["username"] == "me@example.com" - - -def test_the_session_read_slides_the_cookie(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - _login(client, monkeypatch) - # Every app load hits /session; that re-issue keeps the cookie sliding. - response = client.get("/api/auth/session") - assert SESSION_COOKIE in response.headers.get("set-cookie", "") - assert "Max-Age" in response.headers["set-cookie"] - - -def test_logout_drops_the_session_and_clears_the_cookie(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - _login(client, monkeypatch) - logout = client.post("/api/auth/logout") - assert logout.status_code == 204 - assert "Max-Age=0" in logout.headers["set-cookie"] - assert client.get("/api/auth/session").status_code == 401 - - -def test_login_rotates_any_prior_session_token(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - first = _login(client, monkeypatch) - first_token = client.cookies[SESSION_COOKIE] - second = _login(client, monkeypatch) - second_token = client.cookies[SESSION_COOKIE] - assert first.status_code == second.status_code == 200 - assert first_token != second_token - # The old token no longer resolves anywhere. - client.cookies.set(SESSION_COOKIE, first_token) - assert client.get("/api/auth/session").status_code == 401 - - -def test_redis_eviction_signs_out_but_keeps_credentials(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - _login(client, monkeypatch) - druks.redis.get_client()._data.clear() # Redis loss - assert client.get("/api/auth/session").status_code == 401 - # The durable credential is untouched — only the session died. - assert HarnessConnection.get_for_account( - "claude", Account.get_for_username("me@example.com").id - ) - - -def test_missing_account_does_not_drop_the_session(tmp_path, monkeypatch, db_session): - # A None from Account.get is a transient/anomalous read (nothing deletes - # accounts), so it 401s the request but must never destroy the session — - # else a blip becomes a forced re-login. - with _client(tmp_path) as client: - _login(client, monkeypatch) - key = f"{SESSION_PREFIX}{client.cookies[SESSION_COOKIE]}" - druks.redis.get_client()._data[key] = b"no-such-account" - assert client.get("/api/auth/session").status_code == 401 - assert key in druks.redis.get_client()._data - - -def test_session_keeps_its_account_across_reconnects(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - _login(client, monkeypatch, email="me@example.com") - # The session account wins; the login records the provider identity. - start = client.post("/api/auth/harnesses/codex/login/start") - _mock_exchange_codex(monkeypatch, email="corp-seat@corp.com") - complete = client.post( - "/api/auth/harnesses/codex/login/complete", - json={"code": "thecode", "loginId": start.json()["loginId"]}, - ) - assert complete.status_code == 200 - assert complete.json()["username"] == "me@example.com" - account = Account.get_for_username("me@example.com") - codex = HarnessConnection.get_for_account("codex", account.id) - assert codex.provider_email == "corp-seat@corp.com" - - -def test_bound_reconnect_requires_its_session_at_complete(tmp_path, monkeypatch, db_session): - with _client(tmp_path) as client: - _login(client, monkeypatch, email="me@example.com") - # A reconnect binds its flow to the session account… - start = client.post("/api/auth/harnesses/codex/login/start") - # …which dies before paste-back. - druks.redis.get_client()._data = { - key: value - for key, value in druks.redis.get_client()._data.items() - if not key.startswith("druks:session:") - } - _mock_exchange_codex(monkeypatch, email="corp-seat@corp.com") - response = client.post( - "/api/auth/harnesses/codex/login/complete", - json={"code": "thecode", "loginId": start.json()["loginId"]}, - ) - # The flow must never rebind the login by email fallback. - assert response.status_code == 422 - assert "different session" in response.json()["detail"] - assert not Account.get_for_username("corp-seat@corp.com") - assert not any(row.harness == "codex" for row in HarnessConnection.list_all()) - - -def test_two_accounts_may_connect_the_same_provider_login(tmp_path, monkeypatch, db_session): - # Deliberately unpoliced: the provider email is renameable upstream, so - # any uniqueness constraint on it would lapse silently. - connect_harness( - ClaudeHarness, - {"claudeAiOauth": {"accessToken": "x"}}, - provider_email="shared@corp.com", - ) - with _client(tmp_path) as client: - start = client.post("/api/auth/harnesses/codex/login/start") - _mock_exchange_codex(monkeypatch, email="me@example.com") - client.post( - "/api/auth/harnesses/codex/login/complete", - json={"code": "thecode", "loginId": start.json()["loginId"]}, - ) - assert _login(client, monkeypatch, email="shared@corp.com").status_code == 200 - - claude = [login for login in HarnessConnection.list_all() if login.harness == "claude"] - assert len(claude) == 2 # one row per account, same provider email - assert len({login.account_id for login in claude}) == 2 - - -def test_new_identity_cannot_acquire_legacy_logins(tmp_path, monkeypatch, db_session): - # The migration shape: the dashboard account owns the legacy login. - legacy = connect_harness( - ClaudeHarness, {"claudeAiOauth": {"accessToken": "x"}}, provider_email="op@example.com" - ) - with _client(tmp_path) as client: - response = _login(client, monkeypatch, email="newcomer@example.com") - assert response.status_code == 200 - accounts = {account.username: account for account in _all_accounts()} - assert set(accounts) == {"op@example.com", "newcomer@example.com", "system"} - # The legacy login stays put; the fallback account stays the operator's. - assert HarnessConnection.reload(legacy.id).account_id == accounts["op@example.com"].id - assert UserSettings.get().fallback_account_id == accounts["op@example.com"].id - - -def test_dashboard_identity_resolves_the_migrated_account(tmp_path, monkeypatch, db_session): - legacy = connect_harness( - ClaudeHarness, {"claudeAiOauth": {"accessToken": "x"}}, provider_email="op@example.com" - ) - legacy_id = legacy.id - with _client(tmp_path) as client: - response = _login(client, monkeypatch, email="op@example.com") - assert response.status_code == 200 - # No second account minted, the legacy login updated in place; reload() - # reads past this task's identity map. - assert {account.username for account in _all_accounts()} == {"op@example.com", "system"} - updated = HarnessConnection.reload(legacy_id) - assert dict(updated.payload)["claudeAiOauth"]["accessToken"] == "AT" - - -def _all_accounts() -> list[Account]: - return list(database.db_session().scalars(select(Account))) - - -def _mock_exchange_codex(monkeypatch, *, email: str): - claims = { - "https://api.openai.com/auth": {"chatgpt_account_id": "acc-1"}, - "https://api.openai.com/profile": {"email": email}, - "exp": 4102444800, - } - header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() - payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() - grant = {"access_token": f"{header}.{payload}.sig", "refresh_token": "RT", "id_token": "ID"} - - async def fake_post(self, url, *, json=None, data=None, **_kwargs): - return httpx.Response(200, text=_dumps(grant), request=httpx.Request("POST", url)) - - monkeypatch.setattr(hbase.httpx.AsyncClient, "post", fake_post) diff --git a/backend/tests/test_auth_boundary.py b/backend/tests/test_auth_boundary.py index 0a036bf..113aa1f 100644 --- a/backend/tests/test_auth_boundary.py +++ b/backend/tests/test_auth_boundary.py @@ -1,22 +1,36 @@ import pytest -from druks.accounts.dependencies import current_account, current_session_account +from druks.accounts.dependencies import ( + current_account, + current_account_or_setup, + current_session_account, + current_session_or_setup, +) from druks.api.app import app from fastapi.routing import APIRoute, _IncludedRouter -# Every /api path allowed to skip the session gate; additions are deliberate. +# Every /api path allowed to skip the identity gate; additions are deliberate. EXEMPT_API_PATHS = { "/api/system/health", - "/api/auth/harnesses/{name}/login/start", - "/api/auth/harnesses/{name}/login/complete", - "/api/auth/logout", + "/api/auth/me", + "/api/harnesses/{name}/connection/start", + "/api/harnesses/{name}/connection/complete", "/api/{path:path}", # the JSON-404 catch-all } -# PAT management admits the session cookie only — never a PAT, so a token -# cannot mint or revoke tokens. +# Capability management admits the session identity only — never a PAT, so a +# token cannot mint or revoke tokens, nor remove the harness connection it +# could never create. SESSION_ONLY_API_PATHS = { "/api/auth/personal-tokens", "/api/auth/personal-tokens/{pat_id}", + "/api/harnesses/{name}/connection", +} + +# The connection flow must answer during none/zero setup, before any account +# exists. +SETUP_CAPABLE_API_PATHS = { + "/api/harnesses/{name}/connection/start", + "/api/harnesses/{name}/connection/complete", } @@ -43,7 +57,7 @@ def _gated_by(route, gate) -> bool: return any(dependency.call is gate for dependency in route.dependant.dependencies) -def test_every_internal_api_route_sits_behind_the_session_gate(api_routes): +def test_every_internal_api_route_sits_behind_the_identity_gate(api_routes): unguarded = [ route.path for route in api_routes @@ -67,7 +81,28 @@ def test_the_exemptions_are_exactly_the_enumerated_ones(api_routes): assert any(route.path.startswith("/_external/") for route in api_routes) -def test_pat_management_is_session_only(api_routes): +def test_identity_bootstrap_is_the_only_setup_tolerant_read(api_routes): + listed = [route for route in api_routes if route.path == "/api/auth/me"] + assert listed + for route in listed: + assert _gated_by(route, current_account_or_setup), route.path + for route in api_routes: + if route.path != "/api/auth/me": + assert not _gated_by(route, current_account_or_setup), route.path + + +def test_connection_setup_uses_only_the_session_or_setup_resolver(api_routes): + listed = [route for route in api_routes if route.path in SETUP_CAPABLE_API_PATHS] + assert {route.path for route in listed} == SETUP_CAPABLE_API_PATHS + for route in listed: + assert _gated_by(route, current_session_or_setup), route.path + assert not _gated_by(route, current_account), route.path + for route in api_routes: + if route.path not in SETUP_CAPABLE_API_PATHS: + assert not _gated_by(route, current_session_or_setup), route.path + + +def test_capability_management_is_session_only(api_routes): listed = [route for route in api_routes if route.path in SESSION_ONLY_API_PATHS] assert {route.path for route in listed} == SESSION_ONLY_API_PATHS for route in listed: diff --git a/backend/tests/test_auth_pats.py b/backend/tests/test_auth_pats.py index 87753c4..cdd764e 100644 --- a/backend/tests/test_auth_pats.py +++ b/backend/tests/test_auth_pats.py @@ -6,14 +6,16 @@ import druks.redis import pytest from conftest import configure_app_for_test, make_settings -from druks.accounts.constants import PAT_TOKEN_TAG, SESSION_PREFIX +from druks.accounts.constants import PAT_TOKEN_TAG from druks.accounts.exceptions import InvalidPatError from druks.accounts.models import Account, PersonalAccessToken -from druks.accounts.sessions import SESSION_COOKIE from druks.database import db_session as session_registry from druks.models import Base from fastapi.testclient import TestClient +HEADER = "X-ExeDev-Email" +OPERATOR = {HEADER: "op@example.com"} + @pytest.fixture(autouse=True) def _clear_redis(): @@ -21,19 +23,16 @@ def _clear_redis(): yield -def _client(tmp_path: Path) -> TestClient: - app = configure_app_for_test(settings=make_settings(tmp_path), authenticated=False) +def _client(tmp_path: Path, **settings_overrides) -> TestClient: + settings_overrides.setdefault("auth_mode", "header") + if settings_overrides["auth_mode"] == "header": + settings_overrides.setdefault("auth_header", HEADER) + app = configure_app_for_test( + settings=make_settings(tmp_path, **settings_overrides), authenticated=False + ) return TestClient(app) -def _sign_in(client: TestClient, username: str = "op@example.com") -> Account: - account = Account.get_or_create(username) - token = f"session-{username}" - druks.redis.get_client()._data[f"{SESSION_PREFIX}{token}"] = account.id.encode() - client.cookies.set(SESSION_COOKIE, token) - return account - - def _bearer(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} @@ -108,39 +107,48 @@ def test_last_used_advances_at_most_hourly(db_session): def test_a_bearer_pat_authenticates_gated_routes(tmp_path, db_session): with _client(tmp_path) as client: _, token = _mint() - response = client.get("/api/auth/session", headers=_bearer(token)) + response = client.get("/api/auth/me", headers=_bearer(token)) assert response.status_code == 200 - assert response.json()["username"] == "agent@example.com" - # No cookie to slide on a Bearer request. + assert response.json()["account"]["username"] == "agent@example.com" assert "set-cookie" not in response.headers assert client.get("/api/settings", headers=_bearer(token)).status_code == 200 -def test_an_authorization_header_never_falls_back_to_the_cookie(tmp_path, db_session): +def test_an_unknown_token_never_falls_through_to_the_assertion(tmp_path, db_session): with _client(tmp_path) as client: - _sign_in(client) - assert client.get("/api/auth/session").status_code == 200 + assert client.get("/api/auth/me", headers=OPERATOR).status_code == 200 response = client.get( - "/api/auth/session", headers=_bearer(f"{PAT_TOKEN_TAG}_unknownpref1_nosecret") + "/api/auth/me", + headers={**OPERATOR, **_bearer(f"{PAT_TOKEN_TAG}_unknownpref1_nosecret")}, ) assert response.status_code == 401 assert response.headers["WWW-Authenticate"] == 'Bearer realm="druks", error="invalid_token"' -@pytest.mark.parametrize( - "header", ["", "Token abc", "Bearer", "Bearer ", "Bearer a b", "bearer lowercased"] -) -def test_a_malformed_authorization_header_is_challenged(tmp_path, db_session, header): +@pytest.mark.parametrize("header", ["", "Token abc", "Bearer", "Bearer "]) +def test_a_shapeless_authorization_header_is_challenged(tmp_path, db_session, header): with _client(tmp_path) as client: - response = client.get("/api/auth/session", headers={"Authorization": header}) + response = client.get("/api/auth/me", headers={"Authorization": header}) assert response.status_code == 401 assert response.headers["WWW-Authenticate"] == 'Bearer realm="druks"' -def test_an_empty_authorization_header_never_slides_to_the_cookie(tmp_path, db_session): +@pytest.mark.parametrize("header", ["Bearer a b", "bearer lowercased", "BEARER nope"]) +def test_any_scheme_case_reaches_authentication_and_fails_closed(tmp_path, db_session, header): + # RFC 7235 schemes are case-insensitive: these parse as credentials and die + # in authentication — a 401 either way, never a slide to the assertion. with _client(tmp_path) as client: - _sign_in(client) - response = client.get("/api/auth/session", headers={"Authorization": ""}) + response = client.get( + "/api/auth/me", headers={"Authorization": header, HEADER: "op@example.com"} + ) + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"].endswith('error="invalid_token"') + assert not Account.get_for_username("op@example.com") + + +def test_an_empty_authorization_header_never_slides_to_the_assertion(tmp_path, db_session): + with _client(tmp_path) as client: + response = client.get("/api/auth/me", headers={**OPERATOR, "Authorization": ""}) assert response.status_code == 401 @@ -148,7 +156,7 @@ def test_a_dead_token_401s_with_its_prefix_only(tmp_path, db_session): with _client(tmp_path) as client: pat, token = _mint() pat.revoke() - response = client.get("/api/auth/session", headers=_bearer(token)) + response = client.get("/api/auth/me", headers=_bearer(token)) assert response.status_code == 401 assert response.headers["WWW-Authenticate"] == 'Bearer realm="druks", error="invalid_token"' assert pat.token_prefix in response.json()["detail"] @@ -166,12 +174,30 @@ def test_a_pat_cannot_manage_pats(tmp_path, db_session): assert create.status_code == 401 revoke = client.delete(f"/api/auth/personal-tokens/{pat.id}", headers=_bearer(token)) assert revoke.status_code == 401 + # Even riding beside a valid identity assertion, a bearer is refused — + # management admits the session identity alone. + both = client.get("/api/auth/personal-tokens", headers={**OPERATOR, **_bearer(token)}) + assert both.status_code == 401 + + +def test_a_pat_cannot_disconnect_a_harness(tmp_path, db_session): + # Disconnect destroys a capability a bearer could never create — the same + # session-only rule as token management. + with _client(tmp_path) as client: + _, token = _mint("op@example.com") + alone = client.delete("/api/harnesses/claude/connection", headers=_bearer(token)) + assert alone.status_code == 401 + beside = client.delete( + "/api/harnesses/claude/connection", headers={**OPERATOR, **_bearer(token)} + ) + assert beside.status_code == 401 -def test_the_session_manages_the_token_lifecycle(tmp_path, db_session): +def test_the_operator_manages_the_token_lifecycle(tmp_path, db_session): with _client(tmp_path) as client: - _sign_in(client) - created = client.post("/api/auth/personal-tokens", json={"name": "ci bot"}) + created = client.post( + "/api/auth/personal-tokens", json={"name": "ci bot"}, headers=OPERATOR + ) assert created.status_code == 200 # Create answers only the plaintext, exactly once; the row surfaces # through the list. @@ -179,37 +205,50 @@ def test_the_session_manages_the_token_lifecycle(tmp_path, db_session): token = created.json()["token"] assert token.startswith(f"{PAT_TOKEN_TAG}_") - listed = client.get("/api/auth/personal-tokens").json() + listed = client.get("/api/auth/personal-tokens", headers=OPERATOR).json() assert [item["name"] for item in listed] == ["ci bot"] assert token.split("_")[2] == listed[0]["prefix"] assert "token" not in listed[0] row_id = listed[0]["id"] - revoked = client.delete(f"/api/auth/personal-tokens/{row_id}").json() + revoked = client.delete(f"/api/auth/personal-tokens/{row_id}", headers=OPERATOR).json() assert revoked["status"] == "revoked" # A repeat revoke answers the same state, same instant. - again = client.delete(f"/api/auth/personal-tokens/{row_id}").json() + again = client.delete(f"/api/auth/personal-tokens/{row_id}", headers=OPERATOR).json() assert again["revokedAt"] == revoked["revokedAt"] -def test_the_list_is_scoped_to_the_signed_in_account(tmp_path, db_session): +def test_the_none_mode_operator_manages_tokens_too(tmp_path, db_session): + Account.get_or_create("op@example.com") + with _client(tmp_path, auth_mode="none") as client: + created = client.post("/api/auth/personal-tokens", json={"name": "local"}) + assert created.status_code == 200 + listed = client.get("/api/auth/personal-tokens").json() + assert [item["name"] for item in listed] == ["local"] + + +def test_the_list_is_scoped_to_the_operator(tmp_path, db_session): with _client(tmp_path) as client: _mint("other@example.com") - _sign_in(client) - assert client.get("/api/auth/personal-tokens").json() == [] + assert client.get("/api/auth/personal-tokens", headers=OPERATOR).json() == [] def test_revoking_anothers_token_is_a_404(tmp_path, db_session): with _client(tmp_path) as client: pat, _ = _mint("other@example.com") - _sign_in(client) - assert client.delete(f"/api/auth/personal-tokens/{pat.id}").status_code == 404 + assert ( + client.delete(f"/api/auth/personal-tokens/{pat.id}", headers=OPERATOR).status_code + == 404 + ) session_registry().expire_all() assert not pat.revoked_at def test_a_token_needs_a_name_that_fits(tmp_path, db_session): with _client(tmp_path) as client: - _sign_in(client) - assert client.post("/api/auth/personal-tokens", json={"name": " "}).status_code == 422 - assert client.post("/api/auth/personal-tokens", json={"name": "x" * 81}).status_code == 422 + no_name = client.post("/api/auth/personal-tokens", json={"name": " "}, headers=OPERATOR) + assert no_name.status_code == 422 + too_long = client.post( + "/api/auth/personal-tokens", json={"name": "x" * 81}, headers=OPERATOR + ) + assert too_long.status_code == 422 diff --git a/backend/tests/test_durable_sdk.py b/backend/tests/test_durable_sdk.py index d6020da..bfa8b0d 100644 --- a/backend/tests/test_durable_sdk.py +++ b/backend/tests/test_durable_sdk.py @@ -175,7 +175,7 @@ def rt(): # An agent run checks the resolved harness is connected before any VM work; # AgentFlow's decider resolves to claude, so connect it for the module — # and mark the account as the execution fallback, the way the first - # login would. + # harness connection would. from druks.accounts.models import Account from druks.harnesses.models import HarnessConnection from druks.user_settings.models import UserSettings diff --git a/backend/tests/test_extensions.py b/backend/tests/test_extensions.py index 23fbe86..8eb6f94 100644 --- a/backend/tests/test_extensions.py +++ b/backend/tests/test_extensions.py @@ -81,7 +81,7 @@ def discover(cls) -> list[ModuleType]: monkeypatch.setattr(loader, "import_extension_models", lambda: None) app = FastAPI() load(app) - # Mounting is under test, not the session gate. + # Mounting is under test, not the identity gate. from druks.accounts.dependencies import current_account app.dependency_overrides[current_account] = lambda: None diff --git a/backend/tests/test_harness_auth.py b/backend/tests/test_harness_auth.py index 8ec0df7..d224421 100644 --- a/backend/tests/test_harness_auth.py +++ b/backend/tests/test_harness_auth.py @@ -121,21 +121,21 @@ def test_codex_load_token_expired(db_session): async def test_claude_fresh_not_refreshed(monkeypatch, db_session): - login = _seed_claude(expires_at=_NOW + timedelta(hours=6)) + connection = _seed_claude(expires_at=_NOW + timedelta(hours=6)) calls = _mock_post(monkeypatch, _resp(200, {})) - result = await ClaudeHarness.rotate_token(login.id, now=_NOW) + result = await ClaudeHarness.rotate_token(connection.id, now=_NOW) assert result.action == "fresh" - assert result.connection_id == login.id + assert result.connection_id == connection.id assert calls == [] async def test_claude_stale_refreshes_and_persists(monkeypatch, db_session): soon = _NOW + timedelta(minutes=30) - login = _seed_claude(access="old", refresh="R0", expires_at=soon) + connection = _seed_claude(access="old", refresh="R0", expires_at=soon) calls = _mock_post( monkeypatch, _resp(200, {"access_token": "new", "refresh_token": "R1", "expires_in": 28800}) ) - result = await ClaudeHarness.rotate_token(login.id, now=_NOW) + result = await ClaudeHarness.rotate_token(connection.id, now=_NOW) assert result.action == "refreshed" assert calls[0]["json"]["refresh_token"] == "R0" block = ClaudeHarness.get_credentials()["claudeAiOauth"] @@ -147,9 +147,9 @@ async def test_claude_stale_refreshes_and_persists(monkeypatch, db_session): async def test_claude_invalid_grant_drops_row(monkeypatch, db_session): - login = _seed_claude(access="old", expires_at=_NOW - timedelta(minutes=1)) + connection = _seed_claude(access="old", expires_at=_NOW - timedelta(minutes=1)) _mock_post(monkeypatch, _resp(400, {"error": "invalid_grant"})) - result = await ClaudeHarness.rotate_token(login.id, now=_NOW) + result = await ClaudeHarness.rotate_token(connection.id, now=_NOW) assert result.action == "failed" assert result.error == "invalid_grant" # A revoked lineage self-disconnects and commits inside the rotation — the @@ -160,34 +160,34 @@ async def test_claude_invalid_grant_drops_row(monkeypatch, db_session): async def test_claude_network_error_keeps_row(monkeypatch, db_session): - login = _seed_claude(access="old", expires_at=_NOW - timedelta(minutes=1)) + connection = _seed_claude(access="old", expires_at=_NOW - timedelta(minutes=1)) _mock_post(monkeypatch, httpx.ConnectError("boom")) - result = await ClaudeHarness.rotate_token(login.id, now=_NOW) + result = await ClaudeHarness.rotate_token(connection.id, now=_NOW) assert result.error == "network" assert ClaudeHarness.get_credentials()["claudeAiOauth"]["accessToken"] == "old" async def test_claude_http_500_keeps_row(monkeypatch, db_session): - login = _seed_claude(access="old", expires_at=_NOW - timedelta(minutes=1)) + connection = _seed_claude(access="old", expires_at=_NOW - timedelta(minutes=1)) _mock_post(monkeypatch, _resp(500, "")) - result = await ClaudeHarness.rotate_token(login.id, now=_NOW) + result = await ClaudeHarness.rotate_token(connection.id, now=_NOW) assert result.error == "http_500" assert ClaudeHarness.get_credentials()["claudeAiOauth"]["accessToken"] == "old" async def test_claude_bad_response_keeps_row(monkeypatch, db_session): - login = _seed_claude(access="old", expires_at=_NOW - timedelta(minutes=1)) + connection = _seed_claude(access="old", expires_at=_NOW - timedelta(minutes=1)) _mock_post(monkeypatch, _resp(200, "not json")) - result = await ClaudeHarness.rotate_token(login.id, now=_NOW) + result = await ClaudeHarness.rotate_token(connection.id, now=_NOW) assert result.error == "bad_response" assert ClaudeHarness.get_credentials()["claudeAiOauth"]["accessToken"] == "old" async def test_codex_invalid_grant_drops_row(monkeypatch, db_session): stale = _jwt(int((_NOW + timedelta(hours=1)).timestamp())) - login = _seed_codex(access=stale, refresh="R0") + connection = _seed_codex(access=stale, refresh="R0") _mock_post(monkeypatch, _resp(400, {"error": "invalid_grant"})) - result = await CodexHarness.rotate_token(login.id, now=_NOW) + result = await CodexHarness.rotate_token(connection.id, now=_NOW) assert result.error == "invalid_grant" assert not HarnessConnection.list_all() with pytest.raises(HarnessNotConnectedError): @@ -195,8 +195,8 @@ async def test_codex_invalid_grant_drops_row(monkeypatch, db_session): async def test_rotation_of_a_deleted_row_is_a_no_op(monkeypatch, db_session): - login = _seed_claude(access="old", expires_at=_NOW - timedelta(minutes=1)) - connection_id = login.id + connection = _seed_claude(access="old", expires_at=_NOW - timedelta(minutes=1)) + connection_id = connection.id _mock_post(monkeypatch, _resp(400, {"error": "invalid_grant"})) await ClaudeHarness.rotate_token(connection_id, now=_NOW) # Row is gone; rotating the stale id must short-circuit before any @@ -209,22 +209,22 @@ async def test_rotation_of_a_deleted_row_is_a_no_op(monkeypatch, db_session): async def test_claude_relogin_overwrite_picked_up(monkeypatch, db_session): - login = _seed_claude(refresh="R_NEW", expires_at=_NOW - timedelta(minutes=1)) + connection = _seed_claude(refresh="R_NEW", expires_at=_NOW - timedelta(minutes=1)) calls = _mock_post( monkeypatch, _resp(200, {"access_token": "a", "refresh_token": "b", "expires_in": 100}) ) - await ClaudeHarness.rotate_token(login.id, now=_NOW) + await ClaudeHarness.rotate_token(connection.id, now=_NOW) assert calls[0]["json"]["refresh_token"] == "R_NEW" async def test_codex_stale_refreshes_and_preserves(monkeypatch, db_session): stale = _jwt(int((_NOW + timedelta(hours=1)).timestamp())) fresh = _jwt(int((_NOW + timedelta(days=10)).timestamp())) - login = _seed_codex(access=stale, refresh="R0", account_id="acc-9") + connection = _seed_codex(access=stale, refresh="R0", account_id="acc-9") calls = _mock_post( monkeypatch, _resp(200, {"access_token": fresh, "refresh_token": "R1", "id_token": "id-1"}) ) - result = await CodexHarness.rotate_token(login.id, now=_NOW) + result = await CodexHarness.rotate_token(connection.id, now=_NOW) assert result.action == "refreshed" assert calls[0]["json"]["client_id"] == "app_EMoamEEZ73f0CkXaXp7hrann" data = CodexHarness.get_credentials() @@ -239,17 +239,17 @@ async def test_codex_stale_refreshes_and_preserves(monkeypatch, db_session): async def test_codex_keeps_refresh_when_omitted(monkeypatch, db_session): stale = _jwt(int((_NOW + timedelta(hours=1)).timestamp())) fresh = _jwt(int((_NOW + timedelta(days=10)).timestamp())) - login = _seed_codex(access=stale, refresh="KEEP") + connection = _seed_codex(access=stale, refresh="KEEP") _mock_post(monkeypatch, _resp(200, {"access_token": fresh})) - await CodexHarness.rotate_token(login.id, now=_NOW) + await CodexHarness.rotate_token(connection.id, now=_NOW) assert CodexHarness.get_credentials()["tokens"]["refresh_token"] == "KEEP" async def test_codex_no_refresh_token(monkeypatch, db_session): stale = _jwt(int((_NOW + timedelta(hours=1)).timestamp())) - login = _seed_codex(refresh=None, access=stale) + connection = _seed_codex(refresh=None, access=stale) calls = _mock_post(monkeypatch, _resp(200, {})) - result = await CodexHarness.rotate_token(login.id, now=_NOW) + result = await CodexHarness.rotate_token(connection.id, now=_NOW) assert result.action == "no_refresh_token" assert calls == [] @@ -290,8 +290,8 @@ async def test_invalid_grant_drops_only_the_addressed_row(monkeypatch, db_sessio async def test_concurrent_rotations_produce_one_grant(monkeypatch, db_session): - login = _seed_claude(access="old", refresh="R0", expires_at=_NOW + timedelta(minutes=30)) - connection_id = login.id + connection = _seed_claude(access="old", refresh="R0", expires_at=_NOW + timedelta(minutes=30)) + connection_id = connection.id calls = _mock_post( monkeypatch, _resp(200, {"access_token": "new", "refresh_token": "R1", "expires_in": 100}) ) @@ -308,14 +308,14 @@ async def test_concurrent_rotations_produce_one_grant(monkeypatch, db_session): async def test_rotation_lock_is_released_after_refresh(monkeypatch, db_session): - login = _seed_claude(access="old", refresh="R0", expires_at=_NOW + timedelta(minutes=30)) + connection = _seed_claude(access="old", refresh="R0", expires_at=_NOW + timedelta(minutes=30)) _mock_post( monkeypatch, _resp(200, {"access_token": "new", "refresh_token": "R1", "expires_in": 100}) ) - await ClaudeHarness.rotate_token(login.id, now=_NOW) + await ClaudeHarness.rotate_token(connection.id, now=_NOW) import druks.redis - assert await druks.redis.get_client().get(f"druks:harness:refresh:{login.id}") is None + assert await druks.redis.get_client().get(f"druks:harness:refresh:{connection.id}") is None def test_disconnect_removes_only_the_addressed_login(db_session): @@ -325,7 +325,7 @@ def test_disconnect_removes_only_the_addressed_login(db_session): mine.delete() assert HarnessConnection.get(other.id) - # The fallback account (the first) has no claude login left; another + # The fallback account (the first) has no claude connection left; another # account's credential never leaks into execution. with pytest.raises(HarnessNotConnectedError): ClaudeHarness.get_credentials() @@ -416,8 +416,8 @@ async def test_codex_fetch_usage_success(monkeypatch, db_session): def test_render_credentials_file_serializes_stored_payload(db_session): - login = _seed_claude(access="tok", refresh="R0") - rendered = ClaudeHarness.render_credentials_file(login.id) + connection = _seed_claude(access="tok", refresh="R0") + rendered = ClaudeHarness.render_credentials_file(connection.id) assert json.loads(rendered)["claudeAiOauth"]["accessToken"] == "tok" @@ -523,12 +523,12 @@ def test_render_credentials_file_renders_only_the_selected_login(db_session): assert rendered["claudeAiOauth"]["accessToken"] == "mine-token" -def test_render_credentials_file_for_a_deleted_login_raises(db_session): +def test_render_credentials_file_for_a_deleted_connection_raises(db_session): _seed_claude(provider_email="a@example.com") # the surviving fallback gone = _seed_claude(provider_email="b@example.com") gone_id = gone.id gone.delete() # A disconnect between selection and render fails the call — it must never # fall through to another account's payload. - with pytest.raises(HarnessNotConnectedError, match="disconnected"): + with pytest.raises(HarnessNotConnectedError, match="removed"): ClaudeHarness.render_credentials_file(gone_id) diff --git a/backend/tests/test_harness_login.py b/backend/tests/test_harness_connect.py similarity index 62% rename from backend/tests/test_harness_login.py rename to backend/tests/test_harness_connect.py index abfb5ba..2f9da50 100644 --- a/backend/tests/test_harness_login.py +++ b/backend/tests/test_harness_connect.py @@ -8,13 +8,13 @@ from druks.harnesses import base as hbase from druks.harnesses.claude import ClaudeHarness from druks.harnesses.codex import CodexHarness -from druks.harnesses.exceptions import LoginError +from druks.harnesses.exceptions import ConnectError @pytest.fixture(autouse=True) def _clear_pending(): - # The suite shares one in-memory fake Redis; clear the login pending keys so - # one test's stash never leaks into another. + # The suite shares one in-memory fake Redis; clear the connect pending keys + # so one test's stash never leaks into another. druks.redis.get_client()._data.clear() yield @@ -42,7 +42,7 @@ async def fake_post(self, url, *, json=None, data=None, **_kwargs): async def _pending(flow_id: str) -> dict | None: - raw = await druks.redis.get_client().get(f"druks:login:pending:{flow_id}") + raw = await druks.redis.get_client().get(f"druks:harness:connect:pending:{flow_id}") return json.loads(raw) if raw else None @@ -55,8 +55,8 @@ async def _pending(flow_id: str) -> dict | None: } -async def test_claude_login_start_builds_url_and_stashes_pending(db_session): - url, flow_id = await ClaudeHarness.login_start() +async def test_claude_connect_start_builds_url_and_stashes_pending(db_session): + url, flow_id = await ClaudeHarness.connect_start() assert url.startswith("https://claude.ai/oauth/authorize?") assert "code=true" in url assert "code_challenge_method=S256" in url @@ -64,20 +64,20 @@ async def test_claude_login_start_builds_url_and_stashes_pending(db_session): pending = await _pending(flow_id) assert pending["state"] == pending["verifier"] # claude echoes the verifier as state - # An initial login binds to nothing until account resolution. + # An unbound setup flow binds to nothing until account resolution. assert not pending["account_id"] -async def test_login_start_binds_the_session_account(db_session): - _, flow_id = await ClaudeHarness.login_start(account_id="acct-1") +async def test_connect_start_binds_the_operator_account(db_session): + _, flow_id = await ClaudeHarness.connect_start(account_id="acct-1") pending = await _pending(flow_id) assert pending["account_id"] == "acct-1" -async def test_claude_login_complete_returns_the_exchange(monkeypatch, db_session): - _, flow_id = await ClaudeHarness.login_start(account_id="acct-1") +async def test_claude_connect_complete_returns_the_exchange(monkeypatch, db_session): + _, flow_id = await ClaudeHarness.connect_start(account_id="acct-1") calls = _mock_post(monkeypatch, _resp(200, _CLAUDE_GRANT)) - completed = await ClaudeHarness.login_complete(flow_id=flow_id, pasted="thecode") + completed = await ClaudeHarness.connect_complete(flow_id=flow_id, pasted="thecode") block = completed.payload["claudeAiOauth"] assert block["accessToken"] == "AT" @@ -94,35 +94,35 @@ async def test_claude_login_complete_returns_the_exchange(monkeypatch, db_sessio assert not await _pending(flow_id) -async def test_concurrent_login_flows_do_not_clobber_each_other(monkeypatch, db_session): +async def test_concurrent_connect_flows_do_not_clobber_each_other(monkeypatch, db_session): # Two people connect the same harness at once: distinct flow ids, both # pendings live, and completing one leaves the other completable. - _, first_flow = await ClaudeHarness.login_start() - _, second_flow = await ClaudeHarness.login_start() + _, first_flow = await ClaudeHarness.connect_start() + _, second_flow = await ClaudeHarness.connect_start() assert first_flow != second_flow _mock_post(monkeypatch, _resp(200, _CLAUDE_GRANT)) - first = await ClaudeHarness.login_complete(flow_id=first_flow, pasted="code-1") + first = await ClaudeHarness.connect_complete(flow_id=first_flow, pasted="code-1") assert await _pending(second_flow) second_grant = dict(_CLAUDE_GRANT, account={"email_address": "other@example.com"}) _mock_post(monkeypatch, _resp(200, second_grant)) - second = await ClaudeHarness.login_complete(flow_id=second_flow, pasted="code-2") + second = await ClaudeHarness.connect_complete(flow_id=second_flow, pasted="code-2") assert first.provider_email == "me@example.com" assert second.provider_email == "other@example.com" -async def test_login_complete_without_provider_email_raises(monkeypatch, db_session): - _, flow_id = await ClaudeHarness.login_start() +async def test_connect_complete_without_provider_email_raises(monkeypatch, db_session): + _, flow_id = await ClaudeHarness.connect_start() grant = dict(_CLAUDE_GRANT, account={}) _mock_post(monkeypatch, _resp(200, grant)) - with pytest.raises(LoginError, match="no account email"): - await ClaudeHarness.login_complete(flow_id=flow_id, pasted="thecode") + with pytest.raises(ConnectError, match="no account email"): + await ClaudeHarness.connect_complete(flow_id=flow_id, pasted="thecode") -async def test_codex_login_complete_is_form_encoded_and_reads_jwt(monkeypatch, db_session): - _, flow_id = await CodexHarness.login_start() +async def test_codex_connect_complete_is_form_encoded_and_reads_jwt(monkeypatch, db_session): + _, flow_id = await CodexHarness.connect_start() pending = await _pending(flow_id) access = _jwt( { @@ -134,7 +134,7 @@ async def test_codex_login_complete_is_form_encoded_and_reads_jwt(monkeypatch, d calls = _mock_post( monkeypatch, _resp(200, {"access_token": access, "refresh_token": "RT", "id_token": "ID"}) ) - completed = await CodexHarness.login_complete( + completed = await CodexHarness.connect_complete( flow_id=flow_id, pasted=f"http://localhost:1455/auth/callback?code=thecode&state={pending['state']}", ) @@ -147,32 +147,34 @@ async def test_codex_login_complete_is_form_encoded_and_reads_jwt(monkeypatch, d assert "state" not in calls[0]["data"] -async def test_login_complete_unreadable_provider_json_raises_login_error(monkeypatch, db_session): - _, flow_id = await ClaudeHarness.login_start() +async def test_connect_complete_unreadable_provider_json_raises_connect_error( + monkeypatch, db_session +): + _, flow_id = await ClaudeHarness.connect_start() _mock_post(monkeypatch, _resp(200, "not json")) - with pytest.raises(LoginError) as error: - await ClaudeHarness.login_complete(flow_id=flow_id, pasted="code") + with pytest.raises(ConnectError) as error: + await ClaudeHarness.connect_complete(flow_id=flow_id, pasted="code") assert "unreadable response" in str(error.value) -async def test_login_complete_without_pending_raises(db_session): - with pytest.raises(LoginError): - await ClaudeHarness.login_complete(flow_id="not-a-flow", pasted="code") +async def test_connect_complete_without_pending_raises(db_session): + with pytest.raises(ConnectError): + await ClaudeHarness.connect_complete(flow_id="not-a-flow", pasted="code") -async def test_login_complete_state_mismatch_raises(db_session): - _, flow_id = await ClaudeHarness.login_start() - with pytest.raises(LoginError): - await ClaudeHarness.login_complete(flow_id=flow_id, pasted="code#not-the-state") +async def test_connect_complete_state_mismatch_raises(db_session): + _, flow_id = await ClaudeHarness.connect_start() + with pytest.raises(ConnectError): + await ClaudeHarness.connect_complete(flow_id=flow_id, pasted="code#not-the-state") -async def test_login_complete_provider_error_clears_pending(monkeypatch, db_session): - _, flow_id = await ClaudeHarness.login_start() +async def test_connect_complete_provider_error_clears_pending(monkeypatch, db_session): + _, flow_id = await ClaudeHarness.connect_start() _mock_post(monkeypatch, _resp(400, "invalid_grant: code expired")) - with pytest.raises(LoginError) as error: - await ClaudeHarness.login_complete(flow_id=flow_id, pasted="code") + with pytest.raises(ConnectError) as error: + await ClaudeHarness.connect_complete(flow_id=flow_id, pasted="code") assert "invalid_grant" in str(error.value) # Failure is single-use too — a retry must re-start. assert not await _pending(flow_id) diff --git a/backend/tests/test_identity.py b/backend/tests/test_identity.py new file mode 100644 index 0000000..7919a24 --- /dev/null +++ b/backend/tests/test_identity.py @@ -0,0 +1,370 @@ +import base64 +import json +from pathlib import Path + +import druks.redis +import httpx +import pytest +from conftest import configure_app_for_test, connect_harness, make_settings +from druks import database +from druks.accounts.dependencies import resolve_single_operator +from druks.accounts.exceptions import AuthConfigurationError +from druks.accounts.models import Account, PersonalAccessToken +from druks.harnesses import base as hbase +from druks.harnesses.claude import ClaudeHarness +from druks.harnesses.models import HarnessConnection +from druks.user_settings.models import HarnessSettings, UserSettings +from fastapi.testclient import TestClient +from sqlalchemy import select + +HEADER = "X-ExeDev-Email" + + +@pytest.fixture(autouse=True) +def _clear_redis(): + druks.redis.get_client()._data.clear() + yield + + +def _client(tmp_path: Path, **settings_overrides) -> TestClient: + app = configure_app_for_test( + settings=make_settings(tmp_path, **settings_overrides), authenticated=False + ) + return TestClient(app) + + +def _header_client(tmp_path: Path) -> TestClient: + return _client(tmp_path, auth_mode="header", auth_header=HEADER) + + +def _grant(email: str = "me@example.com") -> dict: + return { + "access_token": "AT", + "refresh_token": "RT", + "expires_in": 28800, + "scope": "user:profile", + "account": {"email_address": email}, + } + + +def _mock_exchange(monkeypatch, grant: dict): + async def fake_post(self, url, *, json=None, data=None, **_kwargs): + return httpx.Response(200, text=_dumps(grant), request=httpx.Request("POST", url)) + + monkeypatch.setattr(hbase.httpx.AsyncClient, "post", fake_post) + + +def _dumps(value: dict) -> str: + return json.dumps(value) + + +def _connect( + client: TestClient, + monkeypatch, + *, + harness: str = "claude", + email: str = "me@example.com", + headers: dict[str, str] | None = None, +): + start = client.post(f"/api/harnesses/{harness}/connection/start", headers=headers) + assert start.status_code == 200 + if harness == "claude": + _mock_exchange(monkeypatch, _grant(email)) + else: + _mock_exchange_codex(monkeypatch, email=email) + return client.post( + f"/api/harnesses/{harness}/connection/complete", + json={"code": "thecode", "connectionId": start.json()["connectionId"]}, + headers=headers, + ) + + +def _all_accounts() -> list[Account]: + return list(database.db_session().scalars(select(Account))) + + +def _mock_exchange_codex(monkeypatch, *, email: str): + claims = { + "https://api.openai.com/auth": {"chatgpt_account_id": "acc-1"}, + "https://api.openai.com/profile": {"email": email}, + "exp": 4102444800, + } + header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() + grant = {"access_token": f"{header}.{payload}.sig", "refresh_token": "RT", "id_token": "ID"} + + async def fake_post(self, url, *, json=None, data=None, **_kwargs): + return httpx.Response(200, text=_dumps(grant), request=httpx.Request("POST", url)) + + monkeypatch.setattr(hbase.httpx.AsyncClient, "post", fake_post) + + +# --- header mode ----------------------------------------------------------- + + +def test_header_mode_requires_exactly_one_nonblank_assertion(tmp_path, db_session): + with _header_client(tmp_path) as client: + assert client.get("/api/auth/me").status_code == 401 + assert client.get("/api/settings").status_code == 401 + assert client.get("/api/auth/me", headers={HEADER: " "}).status_code == 401 + two = client.get("/api/auth/me", headers=[(HEADER, "a@x.com"), (HEADER, "b@x.com")]) + assert two.status_code == 401 + # Rejection never enrolls anyone. + assert {account.username for account in _all_accounts()} == {"system"} + + +def test_an_asserted_email_open_enrolls_once_across_case_variants(tmp_path, db_session): + with _header_client(tmp_path) as client: + first = client.get("/api/auth/me", headers={HEADER: " Op@Example.com "}) + assert first.status_code == 200 + body = first.json() + assert body["authMode"] == "header" + assert body["account"]["username"] == "Op@Example.com" + assert body["onboardingRequired"] is True + + again = client.get("/api/auth/me", headers={HEADER: "op@example.COM"}) + assert again.json()["account"]["id"] == body["account"]["id"] + assert len(Account.list_non_system()) == 1 + + +def test_get_or_create_losing_the_insert_race_still_converges(db_session, monkeypatch): + existing = Account.get_or_create("race@example.com") + # Simulate losing the read-then-insert race: the pre-read misses, the + # INSERT hits ON CONFLICT DO NOTHING, the canonical lookup converges. + monkeypatch.setattr(Account, "get_for_username", classmethod(lambda cls, username: None)) + assert Account.get_or_create("Race@example.com").id == existing.id + assert len(Account.list_non_system()) == 1 + + +def test_a_valid_pat_wins_over_a_conflicting_header(tmp_path, db_session): + agent = Account.get_or_create("agent@example.com") + _, token = PersonalAccessToken.create(account_id=agent.id, name="agent") + with _header_client(tmp_path) as client: + response = client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {token}", HEADER: "op@example.com"}, + ) + assert response.status_code == 200 + assert response.json()["account"]["username"] == "agent@example.com" + # The losing assertion never enrolled. + assert not Account.get_for_username("op@example.com") + + +@pytest.mark.parametrize("header", ["", "Token abc", "Bearer", "Bearer a b", "Bearer garbage"]) +def test_a_bad_authorization_never_falls_through_to_the_assertion(tmp_path, db_session, header): + with _header_client(tmp_path) as client: + response = client.get( + "/api/auth/me", headers={"Authorization": header, HEADER: "op@example.com"} + ) + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"].startswith('Bearer realm="druks"') + assert not Account.get_for_username("op@example.com") + + +def test_onboarding_clears_once_the_account_has_a_connection(tmp_path, db_session): + connect_harness(ClaudeHarness, {"claudeAiOauth": {"accessToken": "x"}}) + with _header_client(tmp_path) as client: + body = client.get("/api/auth/me", headers={HEADER: "op@example.com"}).json() + assert body["onboardingRequired"] is False + + +# --- none mode ------------------------------------------------------------- + + +def test_none_mode_ignores_a_present_identity_header(tmp_path, db_session): + connect_harness(ClaudeHarness, {"claudeAiOauth": {"accessToken": "x"}}) + with _client(tmp_path) as client: + body = client.get("/api/auth/me", headers={HEADER: "intruder@example.com"}).json() + assert body["authMode"] == "none" + assert body["account"]["username"] == "op@example.com" + # Never open-enrolls in none mode. + assert not Account.get_for_username("intruder@example.com") + + +def test_none_zero_reads_as_setup(tmp_path, db_session): + with _client(tmp_path) as client: + me = client.get("/api/auth/me") + assert me.status_code == 200 + assert me.json() == {"authMode": "none", "account": None, "onboardingRequired": True} + assert client.get("/api/settings").status_code == 409 + + +def test_none_one_resolves_the_operator(tmp_path, db_session): + connect_harness(ClaudeHarness, {"claudeAiOauth": {"accessToken": "x"}}) + with _client(tmp_path) as client: + body = client.get("/api/auth/me").json() + assert body["account"]["username"] == "op@example.com" + assert body["onboardingRequired"] is False + assert client.get("/api/settings").status_code == 200 + + +def test_none_multi_refuses_requests_and_startup(tmp_path, db_session): + Account.get_or_create("one@example.com") + Account.get_or_create("two@example.com") + with _client(tmp_path) as client: + assert client.get("/api/settings").status_code == 503 + # The startup validator runs the same check and refuses boot. + with pytest.raises(AuthConfigurationError): + resolve_single_operator() + + +# --- connection flow ------------------------------------------------------- + + +def test_none_zero_setup_flow_creates_the_operator(tmp_path, monkeypatch, db_session): + with _client(tmp_path) as client: + response = _connect(client, monkeypatch, email="me@example.com") + assert response.status_code == 200 + assert response.json()["username"] == "me@example.com" + assert "set-cookie" not in response.headers + # The created operator now resolves and is past onboarding. + body = client.get("/api/auth/me").json() + assert body["account"]["username"] == "me@example.com" + assert body["onboardingRequired"] is False + account = Account.get_for_username("me@example.com") + assert UserSettings.get().fallback_account_id == account.id + assert HarnessConnection.get_for_account("claude", account.id) + + +def test_concurrent_setup_completions_with_one_email_converge(tmp_path, monkeypatch, db_session): + with _client(tmp_path) as client: + # Both flows start while zero accounts exist — both unbound. + first = client.post("/api/harnesses/claude/connection/start") + second = client.post("/api/harnesses/codex/connection/start") + _mock_exchange(monkeypatch, _grant("me@example.com")) + assert ( + client.post( + "/api/harnesses/claude/connection/complete", + json={"code": "c1", "connectionId": first.json()["connectionId"]}, + ).status_code + == 200 + ) + _mock_exchange_codex(monkeypatch, email="me@example.com") + assert ( + client.post( + "/api/harnesses/codex/connection/complete", + json={"code": "c2", "connectionId": second.json()["connectionId"]}, + ).status_code + == 200 + ) + assert len(Account.list_non_system()) == 1 + assert len(HarnessConnection.list_all()) == 2 + + +def test_a_stale_unbound_completion_attaches_to_the_operator(tmp_path, monkeypatch, db_session): + with _client(tmp_path) as client: + # Both flows start while zero accounts exist; the first completion + # creates the operator, so the second — a different provider email — + # must attach to that operator instead of minting a rival account and + # bricking none mode. + first = client.post("/api/harnesses/claude/connection/start") + second = client.post("/api/harnesses/codex/connection/start") + _mock_exchange(monkeypatch, _grant("a@example.com")) + client.post( + "/api/harnesses/claude/connection/complete", + json={"code": "c1", "connectionId": first.json()["connectionId"]}, + ) + _mock_exchange_codex(monkeypatch, email="b@example.com") + completed = client.post( + "/api/harnesses/codex/connection/complete", + json={"code": "c2", "connectionId": second.json()["connectionId"]}, + ) + assert completed.status_code == 200 + assert completed.json()["username"] == "a@example.com" + assert client.get("/api/settings").status_code == 200 + operator = Account.get_for_username("a@example.com") + assert len(Account.list_non_system()) == 1 + codex_connection = HarnessConnection.get_for_account("codex", operator.id) + # The capability keeps its own provider identity; it never rekeys the account. + assert codex_connection.provider_email == "b@example.com" + + +def test_a_connect_survives_a_failed_model_refresh(tmp_path, monkeypatch, db_session): + async def _refresh_boom(self, connection): + raise RuntimeError("picker flush failed") + + # The credential commits before the refresh runs; a refresh failure past + # that point must not turn the durable connect into a client-visible error. + monkeypatch.setattr(HarnessSettings, "refresh_models", _refresh_boom) + with _client(tmp_path) as client: + response = _connect(client, monkeypatch, email="me@example.com") + assert response.status_code == 200 + account = Account.get_for_username("me@example.com") + assert HarnessConnection.get_for_account("claude", account.id) + + +def test_a_bound_connect_cannot_complete_under_another_operator(tmp_path, monkeypatch, db_session): + with _header_client(tmp_path) as client: + start = client.post( + "/api/harnesses/claude/connection/start", headers={HEADER: "alice@example.com"} + ) + _mock_exchange(monkeypatch, _grant("seat@corp.com")) + response = client.post( + "/api/harnesses/claude/connection/complete", + json={"code": "thecode", "connectionId": start.json()["connectionId"]}, + headers={HEADER: "bob@example.com"}, + ) + assert response.status_code == 422 + assert "different operator" in response.json()["detail"] + assert not any(row.harness == "claude" for row in HarnessConnection.list_all()) + + +def test_first_connection_claims_the_fallback_slot_once(tmp_path, monkeypatch, db_session): + with _header_client(tmp_path) as client: + _connect(client, monkeypatch, email="seat@corp.com", headers={HEADER: "first@example.com"}) + first = Account.get_for_username("first@example.com") + assert UserSettings.get().fallback_account_id == first.id + _connect( + client, + monkeypatch, + harness="codex", + email="other-seat@corp.com", + headers={HEADER: "second@example.com"}, + ) + # The fallback stays with the first operator. + assert UserSettings.get().fallback_account_id == first.id + + +def test_reconnect_records_provider_email_but_keeps_the_operator(tmp_path, monkeypatch, db_session): + with _header_client(tmp_path) as client: + response = _connect( + client, + monkeypatch, + harness="codex", + email="corp-seat@corp.com", + headers={HEADER: "me@example.com"}, + ) + assert response.status_code == 200 + assert response.json()["username"] == "me@example.com" + account = Account.get_for_username("me@example.com") + codex = HarnessConnection.get_for_account("codex", account.id) + assert codex.provider_email == "corp-seat@corp.com" + + +def test_connection_flow_rejects_a_bearer(tmp_path, db_session): + agent = Account.get_or_create("agent@example.com") + _, token = PersonalAccessToken.create(account_id=agent.id, name="agent") + with _client(tmp_path) as client: + response = client.post( + "/api/harnesses/claude/connection/start", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +# --- the old browser-session surface is gone ------------------------------- + + +def test_the_session_era_routes_are_gone(tmp_path, db_session): + with _client(tmp_path) as client: + gone = [ + client.get("/api/auth/session"), + client.post("/api/auth/logout"), + client.post("/api/auth/harnesses/claude/login/start"), + client.post("/api/auth/harnesses/claude/login/complete"), + ] + for response in gone: + assert response.status_code == 404 + assert "set-cookie" not in response.headers + # The identity read never mints a cookie either. + assert "set-cookie" not in client.get("/api/auth/me").headers diff --git a/backend/tests/test_scaffolding.py b/backend/tests/test_scaffolding.py index 469dc5d..0cc732e 100644 --- a/backend/tests/test_scaffolding.py +++ b/backend/tests/test_scaffolding.py @@ -51,7 +51,7 @@ def test_create_extension_scaffolds_a_loadable_package(tmp_path): app = FastAPI() extension.load(app) - # Scaffolding is under test, not the session gate. + # Scaffolding is under test, not the identity gate. from druks.accounts.dependencies import current_account app.dependency_overrides[current_account] = lambda: None diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 7b471cf..3224353 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -1,4 +1,22 @@ +import pytest from druks.settings import Settings, ensure_data_dirs, load_settings +from pydantic import ValidationError + + +def test_auth_defaults_to_none_and_blesses_no_header(tmp_path, monkeypatch): + monkeypatch.setenv("DRUKS_DATA_DIR", str(tmp_path)) + monkeypatch.delenv("DRUKS_AUTH_MODE", raising=False) + monkeypatch.delenv("DRUKS_AUTH_HEADER", raising=False) + settings = load_settings() + assert settings.auth_mode == "none" + assert not settings.auth_header + + +@pytest.mark.parametrize("auth_header", ["", " "]) +def test_header_mode_requires_the_operator_to_name_the_header(tmp_path, monkeypatch, auth_header): + monkeypatch.setenv("DRUKS_DATA_DIR", str(tmp_path)) + with pytest.raises(ValidationError): + Settings(auth_mode="header", auth_header=auth_header) # type: ignore[call-arg] def test_ensure_data_dirs_provisions_skills_dir(tmp_path, monkeypatch): diff --git a/backend/tests/test_setup_env.py b/backend/tests/test_setup_env.py index f0253c1..2be01d2 100644 --- a/backend/tests/test_setup_env.py +++ b/backend/tests/test_setup_env.py @@ -31,6 +31,9 @@ def test_fresh_run_writes_template_with_secrets_and_reports_gaps(tmp_path): # Provider block present. assert values["DEFAULT_HOST_PROVIDER"] == "exe" assert values["TAILSCALE_ENABLED"] == "true" + # exe.dev is the identity edge; druks maps its asserted email header. + assert values["DRUKS_AUTH_MODE"] == "header" + assert values["DRUKS_AUTH_HEADER"] == "X-ExeDev-Email" # The secrets dir is created alongside. assert (tmp_path / "secrets").is_dir() @@ -47,6 +50,10 @@ def test_aws_provider_block(tmp_path): assert values["DRUKS_ENDPOINT"] == "" assert values["TAILSCALE_ENABLED"] == "false" assert "AWS_REGION" in values + # Bring-your-own-edge: header mode, and the operator must name the header + # their proxy injects — a required gap, never a guessed default. + assert values["DRUKS_AUTH_MODE"] == "header" + assert values["DRUKS_AUTH_HEADER"] == "" # The sandbox-VM instance profile stays a documented, commented knob. assert "# AWS_INSTANCE_PROFILE=" in env_path.read_text() @@ -69,6 +76,9 @@ def test_docker_provider_wires_drukbox_on_host(tmp_path): # The local dashboard's URL is fixed, so the OAuth-MCP callback base is # defaulted — connecting an OAuth MCP server works without a manual edit. assert values["DRUKS_ENDPOINT"] == "http://localhost:8001" + # Loopback dashboard, no edge: identity mode none. + assert values["DRUKS_AUTH_MODE"] == "none" + assert "DRUKS_AUTH_HEADER" not in values def test_rerun_preserves_values_secrets_and_operator_additions(tmp_path): @@ -89,6 +99,20 @@ def test_rerun_preserves_values_secrets_and_operator_additions(tmp_path): assert values["MY_CUSTOM_FLAG"] == "on" # hand edits survive +def test_rerun_preserves_the_identity_header_choice(tmp_path): + env_path = tmp_path / ".env" + _run(env_path) + env_path.write_text( + env_path.read_text().replace( + "DRUKS_AUTH_HEADER=X-ExeDev-Email", "DRUKS_AUTH_HEADER=X-Custom-Email" + ) + ) + + _run(env_path) + + assert read_env(env_path)["DRUKS_AUTH_HEADER"] == "X-Custom-Email" + + def test_rerun_keeps_the_provider_the_env_was_written_with(tmp_path): env_path = tmp_path / ".env" _run(env_path, provider="aws") diff --git a/deploy/README.md b/deploy/README.md index 469a0f1..7d4c162 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -3,7 +3,7 @@ The whole stack runs in Docker Compose: Druks (`web`, which embeds the DBOS durable engine and serves the dashboard SPA), Postgres, and Redis. A **remote** install (`DRUKS_PROVIDER=exe`/`aws`) also brings up stock Caddy -(auth gate + proxy, Caddyfile bind-mounted) and the Drukbox sandbox control +(identity edge + proxy, Caddyfile bind-mounted) and the Drukbox sandbox control plane (`sandbox-service`, `sandbox-janitor`). Those three live in the compose `remote` profile, which `install.sh` selects by writing `COMPOSE_PROFILES` to `.env`, so plain `docker compose` commands in the install dir pick it up. @@ -108,7 +108,8 @@ ssh exe.dev share set-public druks Public URLs: `https:///_external/{github,linear,jira}/events/` (HMAC-gated webhooks), `https:///mcp` (PAT-authenticated MCP endpoint — [Connect your agent](../docs/connect-your-agent.md)), and `https:///` -(exe.dev login at the edge; harness login in the app mints the session). +(exe.dev authenticates at the edge; druks maps its asserted email to your +account). **Elsewhere (e.g. AWS + Teleport)**, the dashboard goes through your identity proxy (set `DRUKS_AUTH_HEADER` to the header it injects), but @@ -151,42 +152,6 @@ code until they finish. Recovery does not preserve a live agent process inside a sandbox; it follows the operation boundary described in [Concepts](../docs/concepts.md#durability-and-recovery). -### One-time: upgrading across the accounts migration - -The accounts release re-keys `harness_logins` per account and encrypts the -stored credentials in place — a destructive schema change. Upgrade it as a -maintenance cutover: stop the stack (`docker compose down`), wait for active -calls to end, **back up Postgres**, run the migration with the new image -(`docker compose run --rm web druks init-db`), then `docker compose up -d`. -Never run old and new processes across this migration; parked workflows may -stay parked. Rolling back afterwards means restoring the pre-migration -database backup and the old image — there is no Alembic downgrade. - -With existing harness connections, the migration requires -`DRUKS_DASHBOARD_EMAIL` in the migration run's environment and attaches every -existing seat to one account with that email. A remote install needs no extra -step — `.env` already holds the value the old Caddy gate matched. **Warning:** -the one-run value must match the provider email you will actually sign in with -once account login lands; a mismatch strands the migrated seats on an account -you cannot enter (automation keeps working, and reconnecting after an eventual -`invalid_grant` re-creates the seat under your real account). Fresh installs -and installs with no connected harness skip all of this. - -Once the account-login release is deployed, finish the cutover — deploys never -refresh the host-copied `compose.yaml`/`Caddyfile`, so this is an explicit -step (re-running `install.sh` performs the copy for you): - -1. Replace the host copies of `deploy/compose.yaml` and - `deploy/caddy/Caddyfile` with this release's versions (the Caddy gate now - admits any nonempty trusted identity; the app enforces its own session). -2. Delete the now-unused `DRUKS_DASHBOARD_EMAIL` line from `~/druks/.env`. -3. Recreate the affected services: `docker compose up -d --force-recreate web - caddy`. - -Until those land, the new release still runs behind the old single-email gate -— that transition window is supported. Afterwards, each browser performs one -harness login to mint its session cookie. - ### One-time: upgrading a box that ran the backend as root The backend containers now run as the deploy user (`DRUKS_UID`/`DRUKS_GID`), @@ -247,5 +212,6 @@ Caddyfile fetched by the installer) enforces path-level access: the app; proxied unbuffered so its SSE frames stream. - Everything else — a nonempty trusted identity header (exe.dev login provides one) required, then proxied to `web` (`127.0.0.1:8001`), which - serves the API, the SPA, and extension frontends alike; the app itself - requires its session cookie, minted by harness login. + serves the API, the SPA, and extension frontends alike; the app maps that + asserted email to your account per request + ([access control](../docs/configuration.md#public-urls-and-access-control)). diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 89373aa..ed3ad21 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -42,17 +42,18 @@ http://:8000 { } } - # Admission is an upstream-injected trusted header: any nonempty value - # passes the edge. Identity inside the app is its own (harness login - # mints the session cookie); the header is never read by the backend. - # DRUKS_AUTH_HEADER defaults to exe.dev's; point it at another - # proxy's header (Teleport: X-Forwarded-Email, Cloudflare Access: - # Cf-Access-Authenticated-User-Email, …) to swap the auth edge with no - # code change. + # The edge authenticates; this proxy forwards its identity assertion and + # the backend maps the asserted email to an account (DRUKS_AUTH_MODE=header + # reads the same DRUKS_AUTH_HEADER). The upstream proxy must strip any + # client-supplied copy of the header before inserting its authenticated + # value. DRUKS_AUTH_HEADER has no default — name your edge's header in the + # .env (exe.dev: X-ExeDev-Email, Teleport: X-Forwarded-Email, Cloudflare + # Access: Cf-Access-Authenticated-User-Email, …); swapping the identity + # edge is a config change, never a code change. # The backend serves everything behind the gate — the API, the SPA at /, # extension frontends at /app/. Cache policy for the served # frontends lives with their server (api/app.py), not at the edge. - @dashboard_user header {$DRUKS_AUTH_HEADER:X-ExeDev-Email} * + @dashboard_user header {$DRUKS_AUTH_HEADER} * handle @dashboard_user { reverse_proxy {$DRUKS_UPSTREAM} } @@ -69,9 +70,10 @@ http://:8000 { # A-record at this box and Caddy auto-provisions TLS for it. Serves the # HMAC-verified /_external/* paths and the PAT-authenticated /mcp endpoint # (this host is the canonical MCP address) and nothing else: no dashboard -# routes, no identity header, so the trusted-header gate above stays -# unforgeable. Unset → an inert loopback listener; deployments whose edge -# already carries webhooks (exe.dev port-share) need nothing here. +# routes, and nothing served here resolves the DRUKS_AUTH_HEADER assertion, +# so edge identity stays unforgeable from the public side. Unset → an inert +# loopback listener; deployments whose edge already carries webhooks (exe.dev +# port-share) need nothing here. {$DRUKS_WEBHOOK_HOST:http://127.0.0.1:8081} { header { X-Content-Type-Options "nosniff" diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 8533128..2f86841 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -117,17 +117,20 @@ services: disable: true caddy: - # Stock Caddy — the external auth gate + webhook TLS. A local install - # skips it (dashboard reached directly on 127.0.0.1:8001), so it lives - # in the ``remote`` profile. Caddyfile is fetched next to this compose - # file by install.sh. + # Stock Caddy — the identity edge + webhook TLS. A local install skips it + # (dashboard reached directly on 127.0.0.1:8001, DRUKS_AUTH_MODE=none), so + # it lives in the ``remote`` profile. Caddyfile is fetched next to this + # compose file by install.sh. image: caddy:2.10-alpine profiles: ["remote"] network_mode: host restart: unless-stopped environment: DRUKS_UPSTREAM: ${DRUKS_UPSTREAM:-127.0.0.1:8001} - DRUKS_AUTH_HEADER: ${DRUKS_AUTH_HEADER:-X-ExeDev-Email} + # Both Caddy and druks read DRUKS_AUTH_HEADER (web gets it from env_file): + # Caddy requires the edge's assertion, druks maps it to an account. No + # default anywhere — the backend refuses header mode without it. + DRUKS_AUTH_HEADER: ${DRUKS_AUTH_HEADER:-} # ``:-`` so an empty .env value still collapses to the inert loopback # default — Caddy treats set-but-empty as a real (broken) site address. DRUKS_WEBHOOK_HOST: ${DRUKS_WEBHOOK_HOST:-http://127.0.0.1:8081} diff --git a/docs/concepts.md b/docs/concepts.md index 577b281..08b80aa 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -203,14 +203,18 @@ coordination such as webhook deduplication, OAuth state/token caches, and the sandbox provisioning gate. Drukbox provisions sandbox hosts; Druks then reaches them over SSH. -Harness login is the application's login: connecting Codex or Claude resolves -an account and mints the `druks_session` cookie (30-day sliding TTL in Redis) -that every internal API and SSE stream requires. In the shipped remote stack, -Caddy additionally requires an identity header inserted by exe.dev or another -upstream proxy — a pure admission check; the app never reads it. Public `/_external` -routes — webhooks and the token-authenticated notification respond — bypass -the session gate but keep their own authentication. A local dashboard bound directly to -`127.0.0.1:8001` still requires the session but has no edge in front and must -not be published as-is. The upstream proxy must remove any client-supplied -copy of the trusted identity header before inserting its authenticated value; -merely forwarding that header makes identity resolution forgeable. +Druks does not authenticate browsers; identity resolves per request. A +`Authorization: Bearer` personal access token always resolves first — present +means it must authenticate. Otherwise the configured `DRUKS_AUTH_MODE` +decides: in `header` mode the edge (exe.dev, Teleport, Cloudflare Access, …) +authenticates and asserts the operator's email in the trusted identity +header, and Druks maps it to an account — open enrollment, since the edge +gates who reaches Druks at all; in `none` mode there is no authentication and +exactly one operator account, created by the first completed harness +connection. Public `/_external` routes — webhooks and the token-authenticated +notification respond — and the PAT-authenticated `/mcp` endpoint sit outside +that identity gate but keep their own authentication. Connecting Codex or +Claude is a capability connect for the current account, not a login. See +[configuration](configuration.md#public-urls-and-access-control) for the +trust requirements, including why the edge must strip client-supplied copies +of the identity header. diff --git a/docs/configuration.md b/docs/configuration.md index 879b1b9..f70c1a4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,25 +35,51 @@ caches, and the sandbox provisioning gate. | `DRUKS_ENDPOINT` | Browser-visible dashboard base URL used to build MCP OAuth callbacks | | `DRUKS_WEBHOOK_HOST` | Public webhook hostname used by `druks doctor` for its ingress probe | | `DRUKS_WEBHOOK_SECRET` | Shared HMAC secret used by bundled webhook integrations | -| `DRUKS_AUTH_HEADER` | Identity header the edge (Caddy) requires; the app never reads it | +| `DRUKS_AUTH_MODE` | `none` (default; no authentication, single operator) or `header` (edge-asserted identity) | +| `DRUKS_AUTH_HEADER` | The trusted identity header; read by both the shipped Caddy edge and Druks. No default — header mode refuses to start without it | `DRUKS_ENDPOINT` and `DRUKS_WEBHOOK_HOST` are different. The first is where an operator's browser reaches Druks; the second is the public ingress webhook senders reach. They may share a hostname on exe.dev. -Harness login is the account door: signing in to Codex or Claude from the -dashboard resolves your account and mints the `druks_session` cookie -(HttpOnly, 30-day sliding TTL in Redis) that every internal API and SSE -stream requires. The shipped remote Caddy admits only requests carrying a -nonempty `DRUKS_AUTH_HEADER` identity — pure admission; the app never reads -the header. Public `POST /_external/*` routes bypass the -edge identity check and carry their own authentication — webhook signature -verification, and the notification respond route's correlation token. Do not -publish the local `127.0.0.1:8001` listener directly. Configure -the identity proxy to strip every client-supplied copy of -`DRUKS_AUTH_HEADER` — a client that can inject it walks past the edge. Terminate -TLS and set HSTS at that public proxy; the shipped Caddy listener is loopback -HTTP behind the TLS edge. +Druks does not authenticate browsers. Identity resolves per request, in this +order: + +1. **Personal access token.** When an `Authorization` header is present it + must authenticate — a malformed or dead bearer is a 401, never a fall + through to the modes below. +2. **`header` mode.** The edge (exe.dev, Teleport, Cloudflare Access, …) + authenticates and asserts the operator's email as exactly one nonblank + `DRUKS_AUTH_HEADER` value; Druks trims outer whitespace and maps it to an + account, creating one on first sight (open enrollment — the edge decides + who reaches Druks at all; the account column is case-insensitive). +3. **`none` mode.** No authentication and no identity edge: Druks resolves + the only non-system account. Zero accounts is the setup state — the + dashboard onboards by connecting a harness, and the first completed + connection creates the operator account from the provider-verified email. + More than one non-system account is configuration drift: Druks refuses + requests (and startup) loudly rather than guess. + +Trust requirements for `header` mode: the edge must authenticate every +dashboard request, must strip any client-supplied copy of +`DRUKS_AUTH_HEADER` before inserting its authenticated value — a client that +can inject the header can be anyone — and must terminate TLS and set HSTS. +The shipped Caddy listener is loopback HTTP behind that edge, and the Druks +web listener itself binds loopback by default. In `none` mode there is no +authentication at all, so the listener must stay loopback-only — never +publish it. + +Any public listener that bypasses the identity edge must never forward +`DRUKS_AUTH_HEADER` upstream. The shipped webhook listener already serves +only HMAC-verified `/_external/*` and the PAT-authenticated `/mcp` — nothing +that resolves the header — and any future public listener (for example the +planned MCP integrations listener) must keep that same isolation. + +Public `POST /_external/*` routes bypass the identity gate and carry their +own authentication — webhook signature verification, and the notification +respond route's correlation token. `GET /api/auth/me` answers without a +resolved account so the dashboard can render onboarding in the `none`-mode +setup state. ## Personal access tokens @@ -63,12 +89,13 @@ personal access tokens minted in Settings → Agent access, sent as `druks_pat__`; Druks stores only the SHA-256 of the full token, shows the plaintext exactly once at mint, and expires it 365 days after creation. When the header is present it must authenticate — a bad -token is a 401, never a fall back to the session cookie — and token -management itself accepts the dashboard session only, so a leaked token -cannot mint or revoke tokens. On compromise, revoke the token in Settings → -Agent access (immediate; the list shows each token's prefix and last use, -tracked hourly, to identify it) and mint a replacement — rotation is mint -first, revoke second. Agents consume the API through the MCP endpoint; see +token is a 401, never a fall back to edge identity — and token management +itself accepts the signed-in identity only (edge-asserted, or the none-mode +operator) and refuses any `Authorization` header, so a leaked token cannot +mint or revoke tokens. On compromise, revoke the token in Settings → Agent access +(immediate; the list shows each token's prefix and last use, tracked hourly, +to identify it) and mint a replacement — rotation is mint first, revoke +second. Agents consume the API through the MCP endpoint; see [Connect your agent](connect-your-agent.md). ## GitHub Apps @@ -160,9 +187,12 @@ extension and edited in the dashboard, not environment variables. ## Harnesses Claude and Codex subscription credentials are connected from **Settings → -Harnesses**. The login flow stores each credential in Postgres; Druks refreshes -it on a schedule and synthesizes the CLI credential file inside each sandbox. -It does not copy a host login. +Harnesses**. The connect flow stores each credential in Postgres; Druks +refreshes it on a schedule and synthesizes the CLI credential file inside each +sandbox. It does not copy a host login. Connecting is a capability connect for +the requesting account — in a fresh `none`-mode install the first completed +connection also creates the operator account (see +[access control](#public-urls-and-access-control)). Process settings such as `DRUKS_CLAUDE_CONFIG_DIR` and `DRUKS_CODEX_CONFIG_DIR` point at optional non-auth CLI configuration to carry diff --git a/docs/development.md b/docs/development.md index d7e7037..8afe7cc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,10 +15,12 @@ python3 -c 'import base64, os; print("DRUKS_SECRETS_KEY=" + base64.b64encode(os. uv run druks init-db ``` -Upgrading a dev database that already holds connected harnesses across the -accounts migration needs `DRUKS_DASHBOARD_EMAIL` exported for that one -`druks init-db` run — set it to the provider email you will sign in with once -account login lands. Fresh databases don't need it. +Local development runs `DRUKS_AUTH_MODE=none` (the `.env.example` default): +the loopback dashboard has no authentication and exactly one operator +account, created by your first harness connection. To exercise `header` mode +against the dev server, set `DRUKS_AUTH_MODE=header`, name a header in +`DRUKS_AUTH_HEADER` (no default), and send it yourself (for example with a +browser header extension or `curl -H 'X-Edge-Email: you@example.com'`). The dev Compose project creates two databases: diff --git a/docs/full-local.md b/docs/full-local.md index 78e5d1e..ffdc161 100644 --- a/docs/full-local.md +++ b/docs/full-local.md @@ -86,12 +86,12 @@ Success means the Compose services are up and the health endpoint returns Open **Settings → Harnesses** in the dashboard and connect Claude and Codex. Druks stores those subscription credentials in Postgres and writes a fresh credential file into each sandbox. It does not use host CLI login files. -That first connect is also the dashboard sign-in: harness login mints the -`druks_session` cookie every internal API requires — a local install has no -identity proxy in front, so the provider email is the account identity. -Protect database access and backups as credential-bearing data; unlike MCP -tokens and OAuth grants, harness payloads do not use the -`DRUKS_SECRETS_KEY` envelope. +The local profile runs `DRUKS_AUTH_MODE=none` — no browser authentication and +exactly one operator account. A fresh install shows onboarding until the +first harness connection completes; that connection creates the sole operator +account from the provider-verified email. Protect database access and backups +as credential-bearing data; unlike MCP tokens and OAuth grants, harness +payloads do not use the `DRUKS_SECRETS_KEY` envelope. Agent calls refuse before provisioning if their selected harness is not connected. `druks doctor` reports the connection and token expiry for every diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c81509a..663f2cc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -66,24 +66,38 @@ The installer performs these steps in this order. ## The dashboard is inaccessible -The shipped remote edge admits any request whose configured -`DRUKS_AUTH_HEADER` carries a nonempty trusted identity; from there the app -itself asks you to connect a harness, which mints the session cookie. A -redirect to `__exe.dev/login` means no trusted identity header reached Caddy. -A dashboard that loads but immediately shows the connect screen means the -session cookie is missing or expired — sign in with Codex or Claude again -(Redis loss signs everyone out but never touches stored credentials). +The edge authenticates; Druks maps its asserted identity to an account. +Distinguish the failure by what you see: + +- **A redirect to `__exe.dev/login`** — no trusted identity header reached + Caddy; sign in at the edge. +- **A "couldn't resolve your identity" page (`header` mode 401)** — the + request reached Druks without exactly one nonblank `DRUKS_AUTH_HEADER` + value. Confirm the proxy injects the header Druks expects, and that + nothing between them drops or duplicates it. +- **Onboarding ("connect a harness to finish setup")** — identity resolved + but that account has no harness connection yet; in a fresh `none`-mode + install the first completed connection creates the operator account. + Ordinary API calls answer 409 until then. +- **A 503 (or refused startup) in `none` mode** — more than one non-system + account exists. Druks refuses to guess which is the operator; remove the + extras or switch to `header` mode. +- **Runs refusing to start** — identity is fine; the selected harness is not + connected (see below). + +Redis loss does not sign browsers out — there are no sessions. It only drops +in-flight connect attempts and other transient coordination. Check: ```bash -grep -E '^(DRUKS_AUTH_HEADER|DRUKS_UPSTREAM)=' ~/druks/.env +grep -E '^(DRUKS_AUTH_MODE|DRUKS_AUTH_HEADER|DRUKS_UPSTREAM)=' ~/druks/.env docker compose logs --tail=200 caddy web ``` The local `docker` profile intentionally skips Caddy; use -. It has no application login and should remain -loopback-only. +. It runs `DRUKS_AUTH_MODE=none` — no authentication — +and must remain loopback-only. ## Webhooks are not arriving diff --git a/docs/writing-an-extension.md b/docs/writing-an-extension.md index 1dea069..8b0c661 100644 --- a/docs/writing-an-extension.md +++ b/docs/writing-an-extension.md @@ -136,8 +136,8 @@ attribution never changes that (two accounts starting the same subject share the one run). Wrap `start()` in a domain `dispatch()` method when the extension needs lookup, snapshot, or routing policy before launch. -A browser-origin start attributes itself: the session gate stamps the -request's signed-in account, and `start()` inherits it — a route that starts a +A browser-origin start attributes itself: the request identity gate stamps +the resolved account, and `start()` inherits it — a route that starts a workflow needs no ceremony. Pass `account_id` only when the dispatcher knows better (a webhook dispatch resolving the ticket assignee). Each agent call executes with the run's account's own connection, else the install's fallback diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index aa734fa..2bd6d2d 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,6 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { AUTH_EXPIRED_EVENT, UnauthorizedError, api, authApi, getJSON, postJSON } from './client' +import { + IDENTITY_INVALIDATED_EVENT, + UnauthorizedError, + api, + getJSON, + identityApi, + postJSON, +} from './client' function failWith(status: number, statusText: string, body: string) { vi.stubGlobal( @@ -45,21 +52,67 @@ describe('personal access tokens', () => { }) }) -describe('session identity', () => { - it('types a 401 and broadcasts the expiry', async () => { - failWith(401, 'Unauthorized', JSON.stringify({ error: 'HTTP_401', detail: 'Sign in.' })) - const expired = vi.fn() - window.addEventListener(AUTH_EXPIRED_EVENT, expired) +describe('request identity', () => { + it('reads /api/auth/me for the nested identity', async () => { + const identity = { + authMode: 'header', + account: { id: 'a1', username: 'me@example.com' }, + onboardingRequired: false, + } + const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( + async () => new Response(JSON.stringify(identity), { status: 200 }), + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(identityApi.me()).resolves.toEqual(identity) + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/auth/me') + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ credentials: 'same-origin' }) + }) + + it('types a 401 and broadcasts the invalidation', async () => { + failWith(401, 'Unauthorized', JSON.stringify({ error: 'HTTP_401', detail: 'No identity.' })) + const invalidated = vi.fn() + window.addEventListener(IDENTITY_INVALIDATED_EVENT, invalidated) try { await expect(getJSON('/api/x')).rejects.toBeInstanceOf(UnauthorizedError) - expect(expired).toHaveBeenCalledTimes(1) + expect(invalidated).toHaveBeenCalledTimes(1) } finally { - window.removeEventListener(AUTH_EXPIRED_EVENT, expired) + window.removeEventListener(IDENTITY_INVALIDATED_EVENT, invalidated) } }) - it('reads a dead session as null without broadcasting noise elsewhere', async () => { - failWith(401, 'Unauthorized', JSON.stringify({ error: 'HTTP_401', detail: 'Sign in.' })) - await expect(authApi.session()).resolves.toBeNull() + it('an unresolved identity rejects instead of reading as a signed-out null', async () => { + // The bootstrap must see the failure and show an edge error — a 401 is + // never converted into onboarding. + failWith(401, 'Unauthorized', JSON.stringify({ error: 'HTTP_401', detail: 'No identity.' })) + await expect(identityApi.me()).rejects.toBeInstanceOf(UnauthorizedError) + }) + + it('broadcasts when a recheck resolves a different account', async () => { + // The edge can switch who it asserts without any 401; the changed answer + // must remount account-scoped state, not stream on under the old mount. + const identityFor = (id: string) => ({ + authMode: 'header', + account: { id, username: `${id}@example.com` }, + onboardingRequired: false, + }) + let accountId = 'a1' + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(identityFor(accountId)), { status: 200 })), + ) + await identityApi.me() + const invalidated = vi.fn() + window.addEventListener(IDENTITY_INVALIDATED_EVENT, invalidated) + try { + accountId = 'b2' + await identityApi.me() + expect(invalidated).toHaveBeenCalledTimes(1) + // Settled on b2: rechecking the same account broadcasts nothing. + await identityApi.me() + expect(invalidated).toHaveBeenCalledTimes(1) + } finally { + window.removeEventListener(IDENTITY_INVALIDATED_EVENT, invalidated) + } }) }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 88852fd..70a33b7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,11 +2,12 @@ import type { Account, AgentCallFiles, ArtifactContent, + ConnectChallenge, DashboardHealth, FeedResponse, ExtensionsSettingsResponse, Harness, - LoginChallenge, + Identity, Pat, SubjectResponse, SubjectSummary, @@ -23,11 +24,12 @@ import type { UserSettings, } from './types' -// A 401 means the session is gone: typed to branch on, broadcast so the -// AuthProvider unmounts the app. +// A 401 means the request's identity did not resolve: typed to branch on, +// broadcast so the IdentityBootstrap rechecks /api/auth/me — never converted +// into onboarding. export class UnauthorizedError extends Error {} -export const AUTH_EXPIRED_EVENT = 'druks:auth-expired' +export const IDENTITY_INVALIDATED_EVENT = 'druks:identity-invalidated' // FastAPI puts the human-readable message in ``detail``; throw that as the // Error message so consumers display it as-is. Non-JSON bodies (proxy pages, @@ -45,7 +47,7 @@ async function throwApiError(response: Response, path: string): Promise { ? detail : `${response.status} ${response.statusText}: ${body || path}` if (response.status === 401) { - window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) + window.dispatchEvent(new Event(IDENTITY_INVALIDATED_EVENT)) throw new UnauthorizedError(message) } throw new Error(message) @@ -122,21 +124,25 @@ export async function postNoContent(path: string, body: unknown): Promise } } -// Harness login mints the session; landing and Settings reconnect share it. -export const authApi = { - session: (): Promise => - getJSON('/api/auth/session').catch((error) => { - if (error instanceof UnauthorizedError) return null - throw error - }), - startLogin: (name: string) => - postJSON(`/api/auth/harnesses/${encodeURIComponent(name)}/login/start`, {}), - completeLogin: (name: string, code: string, loginId: string) => - postJSON(`/api/auth/harnesses/${encodeURIComponent(name)}/login/complete`, { - code, - loginId, - }), - logout: () => postNoContent('/api/auth/logout', {}), +// The edge (or none-mode locality) asserts identity; /api/auth/me is the one +// bootstrap read. A 401 here rejects — the bootstrap shows an identity error, +// never onboarding. +let lastAccountId: string | null = null + +export const identityApi = { + me: async (): Promise => { + const identity = await getJSON('/api/auth/me') + const accountId = identity.account?.id ?? null + // The edge can switch who it asserts without any 401 — a recheck that + // resolves a different account broadcasts so the bootstrap remounts every + // account-scoped surface instead of streaming as one identity while + // rendering another. + if (lastAccountId && accountId && accountId !== lastAccountId) { + window.dispatchEvent(new Event(IDENTITY_INVALIDATED_EVENT)) + } + lastAccountId = accountId + return identity + }, } // The generic subject read-side every extension gets for free at @@ -181,8 +187,17 @@ export const api = { harnesses: () => getJSON('/api/settings/harnesses'), updateHarness: (name: string, body: UpdateHarnessRequest) => patchJSON(`/api/settings/harnesses/${encodeURIComponent(name)}`, body), + // The harness connection flow — the capability connect (and, during setup, + // what creates the operator account). + startHarnessConnect: (name: string) => + postJSON(`/api/harnesses/${encodeURIComponent(name)}/connection/start`, {}), + completeHarnessConnect: (name: string, code: string, connectionId: string) => + postJSON(`/api/harnesses/${encodeURIComponent(name)}/connection/complete`, { + code, + connectionId, + }), disconnectHarness: (name: string) => - deleteJSON(`/api/settings/harnesses/${encodeURIComponent(name)}/login`), + deleteJSON(`/api/harnesses/${encodeURIComponent(name)}/connection`), getExtensionSettings: () => getJSON('/api/settings/extensions'), updateExtensionSettings: (body: UpdateExtensionsSettingsRequest) => patchJSON('/api/settings/extensions', body), @@ -204,8 +219,8 @@ export const api = { ), // Personal access tokens — the agent door to this same API. The plaintext - // comes back once, on mint; list rows carry the prefix only. Management is - // session-only, so these calls always ride the cookie. + // comes back once, on mint; list rows carry the prefix only. Management + // admits the edge/none operator identity alone — never a bearer token. pats: () => getJSON('/api/auth/personal-tokens'), createPat: (name: string) => postJSON<{ token: string }>('/api/auth/personal-tokens', { name }), revokePat: (id: string) => diff --git a/frontend/src/api/sse.test.tsx b/frontend/src/api/sse.test.tsx index 0bfd05c..bab2f25 100644 --- a/frontend/src/api/sse.test.tsx +++ b/frontend/src/api/sse.test.tsx @@ -44,7 +44,13 @@ class FakeEventSource implements EventTarget { } } -function stubSession(body: string, status: number) { +const LIVE_IDENTITY = { + authMode: 'none', + account: { id: 'a1', username: 'me@example.com' }, + onboardingRequired: false, +} + +function stubIdentity(body: string, status: number) { const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( async () => new Response(body, { status }), ) @@ -55,8 +61,8 @@ function stubSession(body: string, status: number) { beforeEach(() => { FakeEventSource.instances = [] vi.stubGlobal('EventSource', FakeEventSource) - // Every SSE error rechecks the session; default to a live one. - stubSession(JSON.stringify({ id: 'a1', username: 'me@example.com' }), 200) + // Every SSE error rechecks identity; default to a live one. + stubIdentity(JSON.stringify(LIVE_IDENTITY), 200) }) afterEach(() => { @@ -217,8 +223,8 @@ describe('useSSE', () => { expect(onError).toHaveBeenCalledTimes(1) }) - it('rechecks the session on error and stays open while it is live', async () => { - const fetchMock = stubSession(JSON.stringify({ id: 'a1', username: 'me@example.com' }), 200) + it('rechecks identity on error and stays open while the account remains valid', async () => { + const fetchMock = stubIdentity(JSON.stringify(LIVE_IDENTITY), 200) render() act(() => { @@ -227,14 +233,30 @@ describe('useSSE', () => { await flushMicrotasks() expect(fetchMock).toHaveBeenCalledWith( - '/api/auth/session', + '/api/auth/me', expect.objectContaining({ credentials: 'same-origin' }), ) + // The EventSource keeps its automatic reconnect. expect(FakeEventSource.instances[0]?.closed).toBe(false) }) - it('closes the source when the session died — no blind reconnects', async () => { - stubSession(JSON.stringify({ error: 'HTTP_401', detail: 'Sign in.' }), 401) + it('closes the source when identity cannot be resolved — no blind reconnects', async () => { + stubIdentity(JSON.stringify({ error: 'HTTP_401', detail: 'No identity.' }), 401) + render() + + act(() => { + FakeEventSource.instances[0]?.emitError() + }) + await flushMicrotasks() + + expect(FakeEventSource.instances[0]?.closed).toBe(true) + }) + + it('closes the source when identity resolves to the setup state', async () => { + stubIdentity( + JSON.stringify({ authMode: 'none', account: null, onboardingRequired: true }), + 200, + ) render() act(() => { diff --git a/frontend/src/api/sse.ts b/frontend/src/api/sse.ts index 57a5d94..d832061 100644 --- a/frontend/src/api/sse.ts +++ b/frontend/src/api/sse.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' -import { authApi } from './client' +import { UnauthorizedError, identityApi } from './client' type Handler = (data: unknown) => void @@ -64,15 +64,20 @@ export function useSSE(url: string, { handlers, onError, enabled = true }: UseSS const errorListener: EventListener = (event) => { onErrorRef.current?.(event) - // An SSE error may be an expired session: recheck instead of letting - // EventSource reconnect forever; the recheck's 401 signals the - // AuthProvider. - void authApi - .session() - .then((account) => { - if (!account) source.close() + // An SSE error may be a dead identity: recheck /api/auth/me and close + // only when identity cannot be resolved (the recheck's 401 broadcasts + // to the IdentityBootstrap). While the same account remains valid the + // EventSource keeps its automatic reconnect. + void identityApi + .me() + .then((identity) => { + if (!identity.account) source.close() + }) + .catch((error: unknown) => { + // Only a dead identity ends the stream; a transient recheck failure + // leaves EventSource's automatic reconnect running. + if (error instanceof UnauthorizedError) source.close() }) - .catch(() => undefined) } source.addEventListener('error', errorListener) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7353342..fa66699 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -209,7 +209,7 @@ export interface Harness { fastMode: boolean effort: string timeout: number - // The signed-in account's own connection; false until this account connects. + // The requesting account's own connection; false until this account connects. connected: boolean kind: string | null account: string | null @@ -223,11 +223,20 @@ export interface Account { username: string } -export interface LoginChallenge { +/** What /api/auth/me answers: how this deployment authenticates, who the + * request resolved to (null in the none/zero setup state), and whether that + * identity still needs its first harness connection. */ +export interface Identity { + authMode: 'none' | 'header' + account: Account | null + onboardingRequired: boolean +} + +export interface ConnectChallenge { authorizeUrl: string /** Opaque id of this connect attempt; passed back on complete so - * concurrent sign-ins never clobber each other's pending state. */ - loginId: string + * concurrent connects never clobber each other's pending state. */ + connectionId: string } export interface UpdateHarnessRequest { @@ -346,7 +355,7 @@ export interface UsageHarnessSummary { // and legends key off it. name: string available: boolean - /** The signed-in account has its own connection; false renders a connect action. */ + /** The requesting account has its own connection; false renders a connect action. */ connected: boolean planTier: string | null fiveHour: UsageMetric | null diff --git a/frontend/src/components/AuthProvider.test.tsx b/frontend/src/components/AuthProvider.test.tsx deleted file mode 100644 index ba28d29..0000000 --- a/frontend/src/components/AuthProvider.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { act, cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { AUTH_EXPIRED_EVENT } from '../api/client' -import { AuthProvider } from './AuthProvider' - -function stubFetch(status: number, body: unknown) { - const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status })) - vi.stubGlobal('fetch', fetchMock) - return fetchMock -} - -afterEach(() => { - cleanup() - vi.unstubAllGlobals() -}) - -async function flush() { - await act(async () => { - await Promise.resolve() - }) -} - -describe('AuthProvider', () => { - it('mounts the app only once the session resolves', async () => { - stubFetch(200, { id: 'a1', username: 'me@example.com' }) - render( - -
- , - ) - // Loading: neither the app nor the landing shows yet. - expect(screen.queryByTestId('app')).toBeNull() - await flush() - expect(screen.getByTestId('app')).toBeTruthy() - }) - - it('shows the landing when there is no session', async () => { - stubFetch(401, { error: 'HTTP_401', detail: 'Sign in.' }) - render( - -
- , - ) - await flush() - expect(screen.queryByTestId('app')).toBeNull() - expect(screen.getByText('Connect Codex')).toBeTruthy() - expect(screen.getByText('Connect Claude')).toBeTruthy() - }) - - it('a broadcast 401 unmounts the app back to the landing', async () => { - stubFetch(200, { id: 'a1', username: 'me@example.com' }) - render( - -
- , - ) - await flush() - expect(screen.getByTestId('app')).toBeTruthy() - - act(() => { - window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) - }) - - expect(screen.queryByTestId('app')).toBeNull() - expect(screen.getByText('Connect Codex')).toBeTruthy() - }) -}) diff --git a/frontend/src/components/AuthProvider.tsx b/frontend/src/components/AuthProvider.tsx deleted file mode 100644 index 1cfe018..0000000 --- a/frontend/src/components/AuthProvider.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, - type ReactNode, -} from 'react' - -import { AUTH_EXPIRED_EVENT, authApi } from '../api/client' -import type { Account } from '../api/types' -import { Landing } from './Landing' - -interface AuthContextValue { - account: Account - signOut: () => Promise -} - -const AuthContext = createContext(null) - -// eslint-disable-next-line react-refresh/only-export-components -- hook co-located with its context -export function useAuth(): AuthContextValue { - const value = useContext(AuthContext) - if (!value) throw new Error('useAuth must be used inside AuthProvider') - return value -} - -// Only an authenticated state mounts the app's queries and EventSources; -// any 401 unmounts them, closing every stream. -export function AuthProvider({ children }: { children: ReactNode }) { - const [account, setAccount] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let cancelled = false - void authApi - .session() - .then((current) => { - if (!cancelled) setAccount(current) - }) - .catch(() => { - // Network trouble reads as signed out; the landing retries via login. - if (!cancelled) setAccount(null) - }) - .finally(() => { - if (!cancelled) setLoading(false) - }) - return () => { - cancelled = true - } - }, []) - - useEffect(() => { - const expire = () => setAccount(null) - window.addEventListener(AUTH_EXPIRED_EVENT, expire) - return () => window.removeEventListener(AUTH_EXPIRED_EVENT, expire) - }, []) - - const signOut = useCallback(async () => { - await authApi.logout().catch(() => undefined) - setAccount(null) - }, []) - - if (loading) return null - if (!account) return - return {children} -} diff --git a/frontend/src/components/AuthedApp.tsx b/frontend/src/components/AuthedApp.tsx index 8b83213..f3734c9 100644 --- a/frontend/src/components/AuthedApp.tsx +++ b/frontend/src/components/AuthedApp.tsx @@ -2,11 +2,16 @@ import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { App } from '../App' +import type { Account } from '../api/types' import { UserPreferencesProvider } from '../lib/preferences' -// The query cache is scoped to this mount: logout/expiry unmounts it, so +// The query cache is keyed to the account: an identity change remounts it, so // nothing cached for one account can render for the next. -export function AuthedApp() { +export function AuthedApp({ account }: { account: Account }) { + return +} + +function QueryCacheMount() { const [queryClient] = useState( () => new QueryClient({ diff --git a/frontend/src/components/HarnessConnect.test.tsx b/frontend/src/components/HarnessConnect.test.tsx index cd8371d..43ade7c 100644 --- a/frontend/src/components/HarnessConnect.test.tsx +++ b/frontend/src/components/HarnessConnect.test.tsx @@ -25,11 +25,13 @@ function harness(overrides: Partial = {}): Harness { function renderCard(value: Harness) { const queryClient = new QueryClient() - return render( + const invalidate = vi.spyOn(queryClient, 'invalidateQueries') + const view = render( , ) + return { view, invalidate } } afterEach(() => { @@ -44,16 +46,19 @@ async function flush() { } describe('HarnessConnect', () => { - it('shows the signed-in identity', () => { + it('shows the connected account identity', () => { renderCard(harness({ connected: true, account: 'ops@corp.com' })) expect(screen.getByText('connected · ops@corp.com')).toBeTruthy() expect(screen.getByText('Reconnect')).toBeTruthy() }) - it('drives the /api/auth connect flow end to end', async () => { + it('drives the connection flow end to end and refreshes only the harness query', async () => { const responses: Record = { - '/api/auth/harnesses/claude/login/start': { authorizeUrl: 'https://x/auth', loginId: 'L1' }, - '/api/auth/harnesses/claude/login/complete': { id: 'a1', username: 'me@example.com' }, + '/api/harnesses/claude/connection/start': { + authorizeUrl: 'https://x/auth', + connectionId: 'C1', + }, + '/api/harnesses/claude/connection/complete': { id: 'a1', username: 'me@example.com' }, } const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( async (url) => { @@ -63,11 +68,11 @@ describe('HarnessConnect', () => { ) vi.stubGlobal('fetch', fetchMock) - renderCard(harness()) + const { invalidate } = renderCard(harness()) fireEvent.click(screen.getByText('Connect')) await flush() - expect(screen.getByText('Open the sign-in page')).toBeTruthy() + expect(screen.getByText('Open the authorization page')).toBeTruthy() fireEvent.change(screen.getByPlaceholderText('Paste the code or redirect URL'), { target: { value: 'the-code' }, }) @@ -75,12 +80,15 @@ describe('HarnessConnect', () => { await flush() const completeCall = fetchMock.mock.calls.find( - ([url]) => url === '/api/auth/harnesses/claude/login/complete', + ([url]) => url === '/api/harnesses/claude/connection/complete', ) expect(JSON.parse(String(completeCall?.[1]?.body))).toEqual({ code: 'the-code', - loginId: 'L1', + connectionId: 'C1', }) + // Completion refreshes the harness card — and only that: the browser's + // own identity is untouched, so /api/auth/me is never rechecked. + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['harnesses'] }) + expect(fetchMock.mock.calls.some(([url]) => String(url) === '/api/auth/me')).toBe(false) }) - }) diff --git a/frontend/src/components/HarnessLogin.tsx b/frontend/src/components/HarnessConnectFlow.tsx similarity index 74% rename from frontend/src/components/HarnessLogin.tsx rename to frontend/src/components/HarnessConnectFlow.tsx index 4585291..06fc9be 100644 --- a/frontend/src/components/HarnessLogin.tsx +++ b/frontend/src/components/HarnessConnectFlow.tsx @@ -1,12 +1,15 @@ import { useState } from 'react' -import { authApi } from '../api/client' -import type { Account, LoginChallenge } from '../api/types' +import { api } from '../api/client' +import type { Account, ConnectChallenge } from '../api/types' -// The one PKCE paste-back flow, shared by the landing screen and Settings. +// The one PKCE paste-back connect flow, shared by onboarding and Settings. // eslint-disable-next-line react-refresh/only-export-components -- hook co-located with its steps UI -export function useHarnessLogin(name: string, onDone: (account: Account) => void | Promise) { - const [challenge, setChallenge] = useState(null) +export function useHarnessConnect( + name: string, + onDone: (account: Account) => void | Promise, +) { + const [challenge, setChallenge] = useState(null) const [code, setCode] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) @@ -23,12 +26,12 @@ export function useHarnessLogin(name: string, onDone: (account: Account) => void } } - const start = () => run(async () => setChallenge(await authApi.startLogin(name))) + const start = () => run(async () => setChallenge(await api.startHarnessConnect(name))) const finish = () => run(async () => { if (!challenge) return - const account = await authApi.completeLogin(name, code.trim(), challenge.loginId) + const account = await api.completeHarnessConnect(name, code.trim(), challenge.connectionId) setChallenge(null) setCode('') await onDone(account) @@ -43,10 +46,10 @@ export function useHarnessLogin(name: string, onDone: (account: Account) => void return { challenge, code, setCode, busy, error, start, finish, cancel } } -export function LoginSteps({ +export function ConnectSteps({ flow, }: { - flow: ReturnType + flow: ReturnType }) { if (!flow.challenge) return null return ( @@ -54,7 +57,7 @@ export function LoginSteps({
1 - Open the sign-in page + Open the authorization page , approve, then copy the code it shows (or the redirect URL).
diff --git a/frontend/src/components/IdentityBootstrap.test.tsx b/frontend/src/components/IdentityBootstrap.test.tsx new file mode 100644 index 0000000..607e2c6 --- /dev/null +++ b/frontend/src/components/IdentityBootstrap.test.tsx @@ -0,0 +1,136 @@ +import { useQueryClient } from '@tanstack/react-query' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Identity } from '../api/types' +import { AuthedApp } from './AuthedApp' +import { IdentityBootstrap } from './IdentityBootstrap' + +// AuthedApp mounts the whole dashboard; a probe recording its query client is +// enough to observe the cache mount being replaced. +const queryClients: unknown[] = [] +vi.mock('../App', () => ({ + App: () => { + queryClients.push(useQueryClient()) + return
+ }, +})) + +function stubRoutes(routes: Record { status: number; body: unknown }>) { + const fetchMock = vi.fn(async (url: string | URL | Request) => { + const route = routes[String(url)] + if (!route) return new Response('{}', { status: 404 }) + const { status, body } = route() + return new Response(JSON.stringify(body), { status }) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +function identity(overrides: Partial = {}): Identity { + return { + authMode: 'none', + account: { id: 'a1', username: 'me@example.com' }, + onboardingRequired: false, + ...overrides, + } +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + queryClients.length = 0 +}) + +async function flush() { + await act(async () => { + await Promise.resolve() + }) +} + +function renderBootstrap() { + return render( + + {(account) =>
} + , + ) +} + +describe('IdentityBootstrap', () => { + it('mounts the app only once identity resolves', async () => { + stubRoutes({ '/api/auth/me': () => ({ status: 200, body: identity() }) }) + renderBootstrap() + // Loading: neither the app nor onboarding shows yet. + expect(screen.queryByTestId('app')).toBeNull() + await flush() + expect(screen.getByTestId('app').dataset.account).toBe('me@example.com') + }) + + it('a setup-state identity renders onboarding', async () => { + stubRoutes({ + '/api/auth/me': () => ({ + status: 200, + body: identity({ account: null, onboardingRequired: true }), + }), + }) + renderBootstrap() + await flush() + expect(screen.queryByTestId('app')).toBeNull() + expect(screen.getByText('Connect a harness to finish setup')).toBeTruthy() + }) + + it('completing onboarding remounts with the created account', async () => { + let me = identity({ account: null, onboardingRequired: true }) + stubRoutes({ + '/api/auth/me': () => ({ status: 200, body: me }), + '/api/harnesses/claude/connection/start': () => ({ + status: 200, + body: { authorizeUrl: 'https://x/auth', connectionId: 'C1' }, + }), + '/api/harnesses/claude/connection/complete': () => { + // The completed connection created the operator; /me now resolves it. + me = identity() + return { status: 200, body: { id: 'a1', username: 'me@example.com' } } + }, + }) + renderBootstrap() + await flush() + + fireEvent.click(screen.getByText('Connect Claude')) + await flush() + fireEvent.change(screen.getByPlaceholderText('Paste the code or redirect URL'), { + target: { value: 'the-code' }, + }) + fireEvent.click(screen.getByText('Finish')) + await flush() + + expect(screen.getByTestId('app').dataset.account).toBe('me@example.com') + }) + + it('a failed identity resolution shows the error panel, never onboarding', async () => { + stubRoutes({ + '/api/auth/me': () => ({ + status: 401, + body: { error: 'HTTP_401', detail: 'The edge asserted no identity.' }, + }), + }) + renderBootstrap() + await flush() + expect(screen.queryByTestId('app')).toBeNull() + expect(screen.queryByText('Connect a harness to finish setup')).toBeNull() + expect(screen.getByText("Couldn't resolve your identity")).toBeTruthy() + expect(screen.getByText('The edge asserted no identity.')).toBeTruthy() + }) + + it('an account-id change replaces the query-cache mount', async () => { + stubRoutes({}) + const first = { id: 'a1', username: 'one@example.com' } + const second = { id: 'a2', username: 'two@example.com' } + const { rerender } = render() + rerender() + // Same account: the mount (and its QueryClient) survives re-renders. + expect(new Set(queryClients).size).toBe(1) + rerender() + expect(new Set(queryClients).size).toBe(2) + }) +}) diff --git a/frontend/src/components/IdentityBootstrap.tsx b/frontend/src/components/IdentityBootstrap.tsx new file mode 100644 index 0000000..6f94f45 --- /dev/null +++ b/frontend/src/components/IdentityBootstrap.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' + +import { IDENTITY_INVALIDATED_EVENT, identityApi } from '../api/client' +import type { Account, Identity } from '../api/types' +import { Onboarding } from './Onboarding' + +type Phase = + | { kind: 'loading' } + | { kind: 'ready'; identity: Identity } + | { kind: 'error'; message: string } + +// Resolves who this browser is — the edge (or none-mode locality) asserts +// identity; druks maps it — then mounts the app, or onboarding while the +// identity has no harness connection yet. A failed resolution is an edge, +// configuration, or network problem, never an invitation to onboard. +export function IdentityBootstrap({ + children, +}: { + children: (account: Account) => ReactNode +}) { + const [phase, setPhase] = useState({ kind: 'loading' }) + // One probe at a time: the probe's own 401 broadcasts the invalidation + // event like any API call, and an un-deduped listener would loop on it. + const checking = useRef(false) + + const check = useCallback(() => { + if (checking.current) return + checking.current = true + identityApi + .me() + .then( + (identity) => setPhase({ kind: 'ready', identity }), + (e: unknown) => + setPhase({ kind: 'error', message: e instanceof Error ? e.message : String(e) }), + ) + .finally(() => { + checking.current = false + }) + }, []) + + useEffect(() => { + check() + }, [check]) + + useEffect(() => { + // Any API 401 broadcasts here: recheck instead of tearing down — a live + // identity keeps the app; a dead one lands on the error panel. + window.addEventListener(IDENTITY_INVALIDATED_EVENT, check) + return () => window.removeEventListener(IDENTITY_INVALIDATED_EVENT, check) + }, [check]) + + if (phase.kind === 'loading') return null + if (phase.kind === 'error') return + const { account, onboardingRequired } = phase.identity + if (onboardingRequired || !account) { + // Completion rechecks /me: the connection (and in none/zero the account + // itself) now exists, so the app mounts under the resolved identity. + return void check()} /> + } + return children(account) +} + +function IdentityError({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+
+
+ druks. +
+
+

Couldn't resolve your identity

+

+ druks expects the edge in front of it (or a local none-mode install) to say who you + are, and that answer didn't arrive. Check the identity proxy, the + DRUKS_AUTH_MODE/DRUKS_AUTH_HEADER configuration, or your + network, then retry. +

+
+
+ ! + {message} +
+ +
+
+ ) +} diff --git a/frontend/src/components/Landing.test.tsx b/frontend/src/components/Landing.test.tsx deleted file mode 100644 index 00ce92d..0000000 --- a/frontend/src/components/Landing.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { Landing } from './Landing' - -afterEach(() => { - cleanup() - vi.unstubAllGlobals() -}) - -async function flush() { - await act(async () => { - await Promise.resolve() - }) -} - -describe('Landing', () => { - it('an active flow takes over the stage; cancel restores the harness cards', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => - new Response(JSON.stringify({ authorizeUrl: 'https://x/auth', loginId: 'L1' }), { - status: 200, - }), - ), - ) - - render( undefined} />) - fireEvent.click(screen.getByText('Connect Codex')) - await flush() - - // The challenge panel replaces both cards, not just its own. - expect(screen.getByText('Open the sign-in page')).toBeTruthy() - expect(screen.queryByText('Connect Claude')).toBeNull() - - fireEvent.click(screen.getByText('Cancel')) - expect(screen.queryByText('Open the sign-in page')).toBeNull() - expect(screen.getByText('Connect Claude')).toBeTruthy() - }) -}) diff --git a/frontend/src/components/Onboarding.test.tsx b/frontend/src/components/Onboarding.test.tsx new file mode 100644 index 0000000..be1e805 --- /dev/null +++ b/frontend/src/components/Onboarding.test.tsx @@ -0,0 +1,48 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Onboarding } from './Onboarding' + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +async function flush() { + await act(async () => { + await Promise.resolve() + }) +} + +describe('Onboarding', () => { + it('frames the door as finishing setup, never as signing in', () => { + render( undefined} />) + expect(screen.getByText('Connect a harness to finish setup')).toBeTruthy() + expect(screen.queryByText(/sign in/i)).toBeNull() + }) + + it('an active flow takes over the stage; cancel restores the harness cards', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL | Request) => { + expect(String(url)).toBe('/api/harnesses/codex/connection/start') + return new Response( + JSON.stringify({ authorizeUrl: 'https://x/auth', connectionId: 'C1' }), + { status: 200 }, + ) + }), + ) + + render( undefined} />) + fireEvent.click(screen.getByText('Connect Codex')) + await flush() + + // The challenge panel replaces both cards, not just its own. + expect(screen.getByText('Open the authorization page')).toBeTruthy() + expect(screen.queryByText('Connect Claude')).toBeNull() + + fireEvent.click(screen.getByText('Cancel')) + expect(screen.queryByText('Open the authorization page')).toBeNull() + expect(screen.getByText('Connect Claude')).toBeTruthy() + }) +}) diff --git a/frontend/src/components/Landing.tsx b/frontend/src/components/Onboarding.tsx similarity index 68% rename from frontend/src/components/Landing.tsx rename to frontend/src/components/Onboarding.tsx index 6aa21c5..71c1369 100644 --- a/frontend/src/components/Landing.tsx +++ b/frontend/src/components/Onboarding.tsx @@ -1,24 +1,26 @@ import type { CSSProperties } from 'react' -import { LoginSteps, useHarnessLogin } from './HarnessLogin' +import { ConnectSteps, useHarnessConnect } from './HarnessConnectFlow' import { harnessColors } from '../lib/harnessColors' import type { Account } from '../api/types' -type LandingEntry = { +type OnboardingEntry = { title: string mark: string fam: string - flow: ReturnType + flow: ReturnType } -// The unauthenticated door: connect a harness, get a session. -export function Landing({ onSignedIn }: { onSignedIn: (account: Account) => void }) { - const codex = useHarnessLogin('codex', onSignedIn) - const claude = useHarnessLogin('claude', onSignedIn) +// The setup door: the edge (or none-mode locality) already decided who you +// are — druks just needs its first harness connection. Works before any +// account exists (fresh none mode) and for a newly enrolled header identity. +export function Onboarding({ onConnected }: { onConnected: (account: Account) => void }) { + const codex = useHarnessConnect('codex', onConnected) + const claude = useHarnessConnect('claude', onConnected) // Accent slots follow registry enrolment order (claude, codex) so each - // harness keeps the colour it has everywhere in the signed-in app. + // harness keeps the colour it has everywhere in the app. const color = harnessColors(['claude', 'codex']) - const entries: LandingEntry[] = [ + const entries: OnboardingEntry[] = [ { title: 'Codex', mark: 'Cx', fam: color.codex!, flow: codex }, { title: 'Claude', mark: 'Cl', fam: color.claude!, flow: claude }, ] @@ -32,9 +34,9 @@ export function Landing({ onSignedIn }: { onSignedIn: (account: Account) => void
home for durable agent apps
-

Connect a harness to sign in

+

Connect a harness to finish setup

- druks runs agents on your own coding subscription. Connecting one signs you in. + druks runs agents on your own coding subscription. Connecting one finishes setup.

@@ -49,23 +51,23 @@ export function Landing({ onSignedIn }: { onSignedIn: (account: Account) => void ) } -function HarnessCard({ entry }: { entry: LandingEntry }) { +function HarnessCard({ entry }: { entry: OnboardingEntry }) { return (
- {entry.flow.error && } + {entry.flow.error && }
) } -function ConnectPanel({ entry }: { entry: LandingEntry }) { +function ConnectPanel({ entry }: { entry: OnboardingEntry }) { const { flow } = entry return (
@@ -79,8 +81,8 @@ function ConnectPanel({ entry }: { entry: LandingEntry }) {
{flow.challenge ? ( <> - - {flow.error && } + + {flow.error && } @@ -95,7 +97,7 @@ function ConnectPanel({ entry }: { entry: LandingEntry }) { ) } -function LandingError({ message }: { message: string }) { +function OnboardingError({ message }: { message: string }) { return (
! diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b5931a1..3ab8955 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../api/client' import { ExtensionGlyph } from './ExtensionGlyph' -import { LoginSteps, useHarnessLogin } from './HarnessLogin' +import { ConnectSteps, useHarnessConnect } from './HarnessConnectFlow' import { type Harness, type ExtensionSettings, @@ -767,12 +767,13 @@ export function HarnessConnect({ harness }: { harness: Harness }) { const [error, setError] = useState(null) const refresh = () => queryClient.invalidateQueries({ queryKey: ['harnesses'] }) - const flow = useHarnessLogin(harness.name, async () => { + const flow = useHarnessConnect(harness.name, async () => { await refresh() }) const disconnect = () => { - if (!window.confirm(`Disconnect ${harness.name}? You'll need to sign in again to run it.`)) return + if (!window.confirm(`Disconnect ${harness.name}? Reconnect it before agents can run on it.`)) + return setBusy(true) setError(null) void api @@ -808,7 +809,7 @@ export function HarnessConnect({ harness }: { harness: Harness }) { )}
- + {(error ?? flow.error) &&
{error ?? flow.error}
}
) @@ -1462,8 +1463,8 @@ export function AgentAccessPane() {
Mint a personal access token for an agent to call this druks — sent as{' '} - Authorization: Bearer …, same account and authority as your session. Revoking a - token cuts its access immediately. + Authorization: Bearer …, same account and authority as your browser identity. + Revoking a token cuts its access immediately.
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index fcee3a1..ce5df06 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { AuthedApp } from './components/AuthedApp' -import { AuthProvider } from './components/AuthProvider' +import { IdentityBootstrap } from './components/IdentityBootstrap' import './styles.css' const rootElement = document.getElementById('root') @@ -10,8 +10,6 @@ if (!rootElement) throw new Error('Root element #root not found') createRoot(rootElement).render( - - - + {(account) => } , ) diff --git a/frontend/src/styles.css b/frontend/src/styles.css index c8ba3b7..c7c5650 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2253,7 +2253,8 @@ input.set-select { background-image: none; padding-right: 12px; } .ins .wic-op-sub { display: inline-flex; align-items: center; gap: 7px; } .ins .wic-op-sub-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--text-faint); box-shadow: none; flex-shrink: 0; } -/* Landing — the unauthenticated door: connect a harness, get a session. */ +/* Landing shell — onboarding (connect a harness to finish setup) and the + identity-error panel share it. */ .landing { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 40px 20px; position: relative; overflow: hidden; } .landing::before { content: ""; position: absolute; inset: 0; background-image: linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px); background-size: 56px 56px; opacity: 0.28; mask-image: radial-gradient(120% 100% at 50% 42%, #000 0%, transparent 70%); } .landing::after { content: ""; position: absolute; top: -20%; left: 50%; width: 70%; height: 70%; background: radial-gradient(circle, color-mix(in oklch, var(--accent-violet) 12%, transparent), transparent 66%); filter: blur(24px); animation: landing-drift 16s ease-in-out infinite alternate; }