# Issues #14 + #10 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Resolve two independent GitHub issues — add `scripts/switch-dev-vm.sh` helper for the hackathon (#14) and make unauthenticated HTML routes redirect to `/login` instead of returning raw JSON 401 (#10). **Architecture:** Two independent changes on separate branches, shipped as two separate PRs. - **#14** is a new standalone shell script plus a one-liner in `docs/QUICKSTART.md`. No app code touched. Blast radius = zero. - **#10** adds a single FastAPI exception handler in `app/main.py` that intercepts `HTTPException(401)` for non-`/api/*` paths and redirects to `/login?next=`. Implementation choice: path-scoped global handler (not per-route dep-swap) because it's deterministic, keeps `app/web/router.py` unchanged, and guarantees API routes under `/api/*` keep their existing JSON-401 contract. The `?next=` round-trip is honored only for the password web-login form (`/auth/password/login/web`) — the most common path. Google OAuth and email-link logins continue to land on `/dashboard` as today (documented follow-up, no regression). **Tech Stack:** Bash, FastAPI, Starlette, pytest, Jinja2. --- ## Out of Scope - Rewriting Google OAuth or email-magic-link flows to honor `?next=`. Those land on `/dashboard` today; this PR does not change that. A follow-up issue can track it. - Any refactor of `app/auth/dependencies.py` beyond what's needed. `get_current_user` and `require_role` stay untouched. - Any change to `/api/*` auth behavior. JSON 401 remains the contract for API callers. --- ## Task 1: Add `scripts/switch-dev-vm.sh` helper (Issue #14) **Files:** - Create: `scripts/switch-dev-vm.sh` - Modify: `docs/QUICKSTART.md` (append a "Hackathon" section) Reference implementation lives in `docs/superpowers/plans/2026-04-21-hackathon-dry-run.md` lines 694–750 (Task 7.1). Copy that script verbatim. - [ ] **Step 1.1: Create the feature branch** ```bash cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" git checkout main git pull --ff-only git checkout -b feature/switch-dev-vm-helper ``` - [ ] **Step 1.2: Create `scripts/switch-dev-vm.sh`** Write the file at `scripts/switch-dev-vm.sh` with this exact content: ```bash #!/usr/bin/env bash # switch-dev-vm.sh — point the shared hackathon dev VM at the caller's branch image. # # Usage: # scripts/switch-dev-vm.sh # scripts/switch-dev-vm.sh hack-zs-metrics # # Prerequisite: your branch has been pushed and the release.yml workflow has completed, # producing ghcr.io/keboola/agnes-the-ai-analyst:dev-. # # The slug is derived from your branch name by stripping the leading "feature/" and # replacing non-alphanumeric chars with "-". For branch "feature/hack-zs-metrics" the slug # is "hack-zs-metrics". set -euo pipefail if [ $# -ne 1 ]; then echo "Usage: $0 " >&2 echo "Example: $0 hack-zs-metrics" >&2 exit 2 fi SLUG="$1" VM="agnes-dev" ZONE="europe-west1-b" TAG="dev-$SLUG" IMAGE="ghcr.io/keboola/agnes-the-ai-analyst:$TAG" echo "[1/4] Verifying $IMAGE exists on GHCR..." docker manifest inspect "$IMAGE" > /dev/null || { echo "ERROR: $IMAGE not found on GHCR. Did your release.yml run finish?" >&2 echo "Check: gh run list --branch feature/$SLUG --workflow release.yml" >&2 exit 1 } echo "[2/4] Updating AGNES_TAG on $VM to $TAG..." gcloud compute ssh "$VM" --zone="$ZONE" --quiet --command "\ sudo sed -i 's|^AGNES_TAG=.*|AGNES_TAG=$TAG|' /data/.env && \ sudo grep -E '^AGNES_TAG=' /data/.env" echo "[3/4] Triggering auto-upgrade..." gcloud compute ssh "$VM" --zone="$ZONE" --quiet --command \ "sudo /usr/local/bin/agnes-auto-upgrade.sh 2>&1 | tail -10" echo "[4/4] Waiting for app to become healthy..." for i in $(seq 1 30); do STATUS=$(curl -s --max-time 5 http://:8000/api/health | python3 -c 'import sys,json; print(json.load(sys.stdin).get("status","down"))' 2>/dev/null || echo down) echo " [$i/30] status=$STATUS" if [ "$STATUS" = "healthy" ] || [ "$STATUS" = "degraded" ]; then echo "OK — agnes-dev now running $TAG. Open http://:8000" exit 0 fi sleep 3 done echo "ERROR: agnes-dev did not become healthy in 90s. SSH in and check: docker compose logs" >&2 exit 1 ``` - [ ] **Step 1.3: Make executable and syntax-check** ```bash chmod +x scripts/switch-dev-vm.sh bash -n scripts/switch-dev-vm.sh ``` Expected: `bash -n` prints nothing and exits 0. - [ ] **Step 1.4: Append a Hackathon section to `docs/QUICKSTART.md`** At the end of `docs/QUICKSTART.md`, append: ```markdown ## Hackathon: switch the shared dev VM to your branch During the hackathon the shared VM `agnes-dev` can be pointed at any per-branch image built by `release.yml` (`ghcr.io/keboola/agnes-the-ai-analyst:dev-`). ```bash # Slug = branch name without "feature/" prefix, non-alphanumeric → "-" scripts/switch-dev-vm.sh hack-zs-metrics ``` The script verifies the image exists on GHCR, updates `AGNES_TAG` in `/data/.env` on the VM, triggers the auto-upgrade, and polls `/api/health` for up to 90 s. Requires `gcloud`, `docker`, `curl`, and `python3`. ``` - [ ] **Step 1.5: Commit** ```bash git add scripts/switch-dev-vm.sh docs/QUICKSTART.md git commit -m "chore: add switch-dev-vm.sh helper for hackathon (#14)" ``` - [ ] **Step 1.6: Push and open PR** ```bash git push -u origin HEAD gh pr create \ --base main \ --title "chore: add switch-dev-vm.sh helper for hackathon (#14)" \ --body "$(cat <<'EOF' ## Summary Adds `scripts/switch-dev-vm.sh` — one-shot helper for the hackathon that points `agnes-dev` at the caller's per-branch image and waits for the app to become healthy. Script verbatim from `docs/superpowers/plans/2026-04-21-hackathon-dry-run.md` Task 7.1. Closes #14. ## Test plan - [ ] `bash -n scripts/switch-dev-vm.sh` passes - [ ] Running against a non-existent tag exits non-zero before touching the VM - [ ] Running against a real `dev-` tag leaves `agnes-dev` healthy within 90 s (verified manually during hackathon dry-run) EOF )" ``` Expected: PR URL printed. Do not merge — leave for user review. --- ## Task 2: HTML routes redirect to `/login` on missing auth (Issue #10) **Files:** - Modify: `app/main.py` (register the exception handler) - Modify: `app/web/router.py:193-221` (`login_page` reads `?next=` from query and passes into template context) - Modify: `app/web/templates/login_email.html` (add hidden `` to the password form) - Modify: `app/auth/providers/password.py` (`password_login_web` accepts `next` form field and redirects to sanitized path) - Modify: `tests/test_web_ui.py` (add regression tests) **Approach:** A single `HTTPException` handler on the FastAPI app instance. When the raised status is `401` *and* the request path does not start with `/api/`, return `RedirectResponse("/login?next=", 302)`. Otherwise fall through to Starlette's default JSON response. API routes are unaffected by path scoping. The `?next=` round-trip is implemented only for the `password_login_web` path (most common). The login template receives the raw `next` query-string param and embeds it as a hidden form field. The web-login handler sanitizes (must start with `/`, must not start with `//`, must not contain a scheme) and redirects to that path on success. - [ ] **Step 2.1: Create the feature branch** ```bash cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss" git checkout main git pull --ff-only git checkout -b fix/web-auth-redirect-to-login ``` - [ ] **Step 2.2: Write the failing test for unauthenticated HTML redirect** Append to `tests/test_web_ui.py` (inside the existing file, after `TestWebUISmoke` or as a new class at the bottom): ```python class TestUnauthenticatedHtmlRedirects: def test_dashboard_unauthenticated_redirects_to_login(self, web_client): resp = web_client.get("/dashboard", follow_redirects=False) assert resp.status_code == 302 assert resp.headers["location"].startswith("/login") assert "next=%2Fdashboard" in resp.headers["location"] def test_catalog_unauthenticated_redirects_to_login(self, web_client): resp = web_client.get("/catalog", follow_redirects=False) assert resp.status_code == 302 assert resp.headers["location"].startswith("/login") assert "next=%2Fcatalog" in resp.headers["location"] def test_api_route_still_returns_json_401(self, web_client): # /api/sync/status requires auth; must keep JSON 401 (no redirect). resp = web_client.get("/api/sync/status", follow_redirects=False) assert resp.status_code == 401 assert resp.headers["content-type"].startswith("application/json") ``` - [ ] **Step 2.3: Run the new tests — confirm they fail** ```bash source .venv/bin/activate pytest tests/test_web_ui.py::TestUnauthenticatedHtmlRedirects -v ``` Expected: `test_dashboard_unauthenticated_redirects_to_login` and `test_catalog_unauthenticated_redirects_to_login` FAIL with `assert 401 == 302`. `test_api_route_still_returns_json_401` passes already (baseline behavior). - [ ] **Step 2.4: Register the exception handler in `app/main.py`** Open `app/main.py` and add these imports at the top of the file (alongside existing imports near line 9–14): ```python from urllib.parse import quote from starlette.exceptions import HTTPException as StarletteHTTPException from fastapi.responses import RedirectResponse ``` Then, inside `create_app()` after the routers are registered (after all `app.include_router(...)` calls but before `return app`), add: ```python @app.exception_handler(StarletteHTTPException) async def _html_auth_redirect_handler(request, exc: StarletteHTTPException): """Redirect unauthenticated HTML requests to /login; leave /api/* as JSON 401.""" if exc.status_code == 401 and not request.url.path.startswith("/api/"): next_param = quote(request.url.path, safe="") return RedirectResponse(url=f"/login?next={next_param}", status_code=302) # Fall back to Starlette's default JSON handler from starlette.exceptions import HTTPException as _SE from fastapi.exception_handlers import http_exception_handler return await http_exception_handler(request, exc) ``` Note: registering a handler for `StarletteHTTPException` catches both FastAPI's `HTTPException` (which subclasses it) and any direct Starlette-raised one. - [ ] **Step 2.5: Re-run the failing tests — confirm they now pass** ```bash pytest tests/test_web_ui.py::TestUnauthenticatedHtmlRedirects -v ``` Expected: all three tests PASS. - [ ] **Step 2.6: Run the full `test_web_ui.py` suite — confirm no regressions** ```bash pytest tests/test_web_ui.py -v ``` Expected: all tests PASS (existing + new). - [ ] **Step 2.7: Pass `?next=` into the login page context** In `app/web/router.py`, in the `login_page` function (around line 193), read the query param. Replace the existing function body: ```python @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): next_path = request.query_params.get("next", "") # Safety: only accept same-origin paths (must start with "/", must not start with "//"). if not next_path.startswith("/") or next_path.startswith("//"): next_path = "" providers = [] try: from app.auth.providers.google import is_available as google_available if google_available(): providers.append({"name": "google", "display_name": "Google", "icon": "google"}) except Exception: pass providers.append({"name": "password", "display_name": "Email & Password", "icon": "key"}) try: from app.auth.providers.email import is_available as email_available if email_available(): providers.append({"name": "email", "display_name": "Email Link", "icon": "mail"}) except Exception: pass login_buttons = [] for p in providers: if p["name"] == "google": login_buttons.append({"url": "/auth/google/login", "text": "Sign in with Google", "css_class": "btn-primary", "icon_html": ""}) elif p["name"] == "password": login_buttons.append({"url": "/login/password", "text": "Sign in with Email & Password", "css_class": "btn-secondary", "icon_html": ""}) elif p["name"] == "email": login_buttons.append({"url": "/login/email", "text": "Sign in with Email Link", "css_class": "btn-secondary", "icon_html": ""}) ctx = _build_context(request, providers=providers, login_buttons=login_buttons, next_path=next_path) return templates.TemplateResponse(request, "login.html", ctx) ``` Also update `login_password_page` (around line 224) the same way — it renders `login_email.html` which contains the password form: ```python @router.get("/login/password", response_class=HTMLResponse) async def login_password_page(request: Request): next_path = request.query_params.get("next", "") if not next_path.startswith("/") or next_path.startswith("//"): next_path = "" google_ok = False try: from app.auth.providers.google import is_available as google_available google_ok = google_available() except Exception: pass ctx = _build_context(request, google_available=google_ok, next_path=next_path) return templates.TemplateResponse(request, "login_email.html", ctx) ``` - [ ] **Step 2.8: Add the hidden `next` field to the password login form** First, inspect the current form markup to find the right insertion point: ```bash grep -n "action=\"/auth/password/login/web\"\|` that POSTs to `/auth/password/login/web`, add **as the first child of the form** (right after the opening `
` tag): ```html ``` If `login.html` also contains a direct-POST login form that hits `/auth/password/login/web`, add the same hidden input there too. (Grep first: `grep -n "/auth/password/login/web" app/web/templates/*.html`.) - [ ] **Step 2.9: Honor `next` in `password_login_web`** In `app/auth/providers/password.py`, replace the `password_login_web` function body so that the redirect target is derived from the `next` form field. The updated function: ```python @router.post("/login/web") async def password_login_web( email: str = Form(...), password: str = Form(""), next: str = Form(""), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Web form login — sets cookie and redirects to `next` (or /dashboard).""" repo = UserRepository(conn) user = repo.get_by_email(email) if not user or not user.get("password_hash"): return RedirectResponse(url="/login/password?error=invalid", status_code=302) try: ph = PasswordHasher() ph.verify(user["password_hash"], password) except (VerifyMismatchError, Exception): return RedirectResponse(url="/login/password?error=invalid", status_code=302) token = create_access_token(user["id"], user["email"], user["role"]) use_secure = os.environ.get("DOMAIN", "") != "" # Sanitize next: must start with "/" and not with "//" (prevent open-redirect). target = next if (next.startswith("/") and not next.startswith("//")) else "/dashboard" response = RedirectResponse(url=target, status_code=302) response.set_cookie( key="access_token", value=token, httponly=True, max_age=86400, samesite="lax", secure=use_secure, ) return response ``` - [ ] **Step 2.10: Add a test for the `?next=` round-trip** Append to `TestUnauthenticatedHtmlRedirects` in `tests/test_web_ui.py`: ```python def test_password_login_honors_next(self, web_client, tmp_path): from argon2 import PasswordHasher from src.db import get_system_db from src.repositories.users import UserRepository password = "TestPass1!" conn = get_system_db() UserRepository(conn).create( id="u1", email="u1@test.com", name="U1", role="admin", password_hash=PasswordHasher().hash(password), ) conn.close() resp = web_client.post( "/auth/password/login/web", data={"email": "u1@test.com", "password": password, "next": "/catalog"}, follow_redirects=False, ) assert resp.status_code == 302 assert resp.headers["location"] == "/catalog" def test_password_login_rejects_open_redirect(self, web_client, tmp_path): from argon2 import PasswordHasher from src.db import get_system_db from src.repositories.users import UserRepository password = "TestPass1!" conn = get_system_db() UserRepository(conn).create( id="u2", email="u2@test.com", name="U2", role="admin", password_hash=PasswordHasher().hash(password), ) conn.close() resp = web_client.post( "/auth/password/login/web", data={"email": "u2@test.com", "password": password, "next": "//evil.example/"}, follow_redirects=False, ) assert resp.status_code == 302 assert resp.headers["location"] == "/dashboard" ``` - [ ] **Step 2.11: Run the new tests — confirm they pass** ```bash pytest tests/test_web_ui.py::TestUnauthenticatedHtmlRedirects -v ``` Expected: all five tests PASS. - [ ] **Step 2.12: Run the broader suite to check for regressions** ```bash pytest tests/test_web_ui.py tests/test_auth_providers.py tests/test_access_control.py -v ``` Expected: all PASS. If any fail that previously passed, investigate before committing. - [ ] **Step 2.13: Commit** ```bash git add app/main.py app/web/router.py app/web/templates/login_email.html app/auth/providers/password.py tests/test_web_ui.py # Only stage login.html if it was actually edited in step 2.8: git status --short git commit -m "fix: redirect unauthenticated HTML routes to /login (#10)" ``` - [ ] **Step 2.14: Push and open PR** ```bash git push -u origin HEAD gh pr create \ --base main \ --title "fix: redirect unauthenticated HTML routes to /login (#10)" \ --body "$(cat <<'EOF' ## Summary Unauthenticated access to HTML pages like `/dashboard` returned a raw JSON 401 body; it now redirects to `/login?next=` and the password login form honors `next` on success. Implementation: a single `StarletteHTTPException` handler registered on the FastAPI app. When status is `401` and the request path does not start with `/api/`, return `302 /login?next=`. Otherwise fall through to Starlette's default JSON response, so `/api/*` routes keep their existing JSON 401 contract. Closes #10. ## What this PR does - `/dashboard`, `/catalog`, `/corporate-memory`, `/activity-center`, admin pages, etc. → 302 to `/login?next=` - `/api/*` routes unchanged — still return JSON 401 - Password web-login form carries `next` through as a hidden field and redirects there on success (sanitized: must start with `/`, must not start with `//`) ## Out of scope (follow-up) Google OAuth and the email-magic-link provider still land on `/dashboard` after login regardless of `next`. No regression vs. today; tracked for a follow-up issue. ## Test plan - [x] `pytest tests/test_web_ui.py -v` passes - [x] New tests cover: HTML redirect, API route JSON 401 preserved, `next` honored, open-redirect rejected - [ ] Manual: open `/dashboard` in a fresh browser → lands on `/login?next=%2Fdashboard`, sign in with email+password, end up on `/dashboard` EOF )" ``` Expected: PR URL printed. Do not merge — leave for user review. --- ## Self-Review - [x] **Spec coverage:** - Issue #14 acceptance: `chmod +x` + `bash -n` (Step 1.3), runs against good tag (PR test-plan manual), non-existent tag exits before VM (script logic lines), docs section (Step 1.4). All covered. - Issue #10 acceptance: unauthenticated HTML redirects (Step 2.2/2.3/2.5), `?next=` round-trip after sign-in (Step 2.10), API JSON 401 preserved (Step 2.2 third case), `/dashboard` 302 test (Step 2.2). All covered. - [x] **No placeholders:** every step has the concrete file path, code block, and command. No TBD/TODO. - [x] **Type consistency:** `next_path` used consistently in router + template context; form field name `next` consistent between template hidden input (Step 2.8) and `password_login_web` signature (Step 2.9).