agnes-the-ai-analyst/tests/test_home_route_resolution.py
Vojtech ae67c40a81
fix(onboarding): /home install flow + agnes init UX hardening (#350)
* fix(web): /home Step 2 recommends --dangerously-skip-permissions for setup

The Step 4 paste runs ~20 shell commands (CLI install, workspace
bootstrap, marketplace clone, MCP register, connector logins). Previous
Step 2 recommended auto-accept-edits via Shift + Tab, which covers file
edits but not Bash — users still clicked ~20 Yes prompts during setup.

Step 2 now leads with `claude --dangerously-skip-permissions` as the
recommended session flag (Bash + edits both skip). Session-scoped, drops
on next plain `claude` — safe here because the pasted script is
generated by this server and ends after a fixed sequence; the flag does
not weaken future Claude sessions.

Auto-accept-edits via Shift + Tab kept as the strict-review fallback;
persistent YOLO allowlist link to /setup-advanced#yolo unchanged.

* fix(web): swap /home Steps 2↔3, claude --yolo as copy-button command

Folder creation moves to Step 2; Step 3 launches Claude from that
directory with `claude --dangerously-skip-permissions`. The YOLO flag
is rendered through the standard .install-cmd + copy-button affordance
(matching Step 1 + Step 2), not inline prose. Step 4 paste runs ~20
shell commands that auto-accept-edits would not cover (Bash still
prompts), so the YOLO flag is the default recommendation; session-
scoped, drops on next plain `claude`.

Setup script's pwd-check warning copy refreshed to reference "/home
Step 2" (the new folder-creation step number).

# Conflicts:
#	CHANGELOG.md

* fix(web): open YOLO setup-advanced link in new tab

Step 3 install-hero's persistent-YOLO link now opens /setup-advanced#yolo
in a new window so users don't lose their /home install context mid-
setup. target="_blank" + rel="noopener" (no reverse-tabnabbing).

* fix(web): merge /home Step 3 fallback prose into prior paragraph

Drop the <br><br> between the 'Session-scoped' line and the 'Prefer
reviewing each command' line so the strict-review fallback flows on
the same paragraph — less vertical space in the install-hero block.

* docs(web): add "What leaves your machine" privacy callout on /home

Install-hero lead now includes a short privacy paragraph: explains that
session telemetry (prompts / tool-calls / tool-responses) flows back to
the central catalog for failure-pattern analysis while raw data rows
the user queries locally stay on their machine. Points at /agnes-private
as the per-session opt-out.

Also collapses leftover cherry-pick conflict markers in CHANGELOG.md
into one clean [Unreleased] section.

* fix(init): harden agnes init UX — 5 issues from David's report

1. chmod +x hooks. agnes init + agnes refresh-marketplace --bootstrap
   now set the execute bit on every .sh they land on disk
   (`<workspace>/.claude/hooks/*.sh` after init; every `.sh` under the
   `~/.agnes/marketplace` clone after a bootstrap/pull). Git checkout
   doesn't always preserve filemode (filemode=false repos, ZIP
   extractions), so hooks were firing with "Permission denied" — silent
   SessionStart / PreToolUse breakage. Best-effort, no-op on Windows.

2. --token-file + AGNES_TOKEN. agnes init now accepts `--token-file
   <path>` and an `AGNES_TOKEN` env fallback alongside `--token`.
   Precedence: --token > --token-file > AGNES_TOKEN. The file / env-var
   paths dodge Claude Code's auto-classifier, which sometimes flags a
   long bearer token in `--token "eyJ..."` command line as a credential-
   exfil pattern. The pasted setup script now uses `--token-file
   ~/.agnes/token` (token written via single-quoted heredoc, umask 077)
   for the same reason.

3. Bash(agnes *) in allow. Default `.claude/settings.json` permissions.
   allow seeded by agnes init now includes `Bash(agnes *)` alongside the
   bare `Bash` entry, so Claude Code's classifier sees an explicit allow
   for subsequent `agnes <verb>` calls inside the workspace it just
   bootstrapped.

4. .zshrc PATH dedup. Setup-script step 1's PATH-persist snippet
   (no-CA install path) replaced with a `grep -qF + ||` idiom so a
   re-run doesn't append a duplicate `export PATH=...` line. Fixed-
   string match (not regex) per the dedup-bug report.

5. `!` prefix doc note. Setup-script step 3 now explicitly tells the
   user: if Claude Code blocks an `agnes` command, prefix it with `!`
   (e.g. `! agnes init …`) to run the command directly in the shell,
   bypassing the auto-classifier.

* release: 0.55.1 — /home onboarding install-hero rework + agnes init UX hardening

---------

Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-19 15:26:35 +02:00

413 lines
17 KiB
Python

"""``get_home_route`` and the ``/`` redirect chain.
Resolution order is env > yaml > default ``/dashboard``. The env path is
the Terraform-overrideable knob — operators set ``AGNES_HOME_ROUTE`` on
the VM without forking instance.yaml. Bad values fall through to the
default rather than producing an external-host redirect.
"""
from __future__ import annotations
import tempfile
import uuid
import pytest
@pytest.fixture
def fresh_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
monkeypatch.setenv("DATA_DIR", tmp)
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
# Ensure the env-var override is unset between tests.
monkeypatch.delenv("AGNES_HOME_ROUTE", raising=False)
yield tmp
def _make_user_and_session(conn, email="u@example.com"):
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
uid = str(uuid.uuid4())
UserRepository(conn).create(id=uid, email=email, name=email.split("@")[0])
return uid, create_access_token(user_id=uid, email=email)
def _client():
from fastapi.testclient import TestClient
from app.main import app
return TestClient(app, follow_redirects=False)
def test_default_home_route_is_dashboard(fresh_db, monkeypatch):
monkeypatch.delenv("AGNES_HOME_ROUTE", raising=False)
from app.instance_config import get_home_route
assert get_home_route() == "/dashboard"
def test_env_overrides_default(fresh_db, monkeypatch):
monkeypatch.setenv("AGNES_HOME_ROUTE", "/home")
from app.instance_config import get_home_route
assert get_home_route() == "/home"
def test_env_rejects_external_redirect(fresh_db, monkeypatch):
"""An attacker controlling the env var (or a typo) must not pivot
the root redirect to ``//evil.com`` or ``https://evil.com``."""
monkeypatch.setenv("AGNES_HOME_ROUTE", "//evil.com/path")
from app.instance_config import get_home_route
assert get_home_route() == "/dashboard"
monkeypatch.setenv("AGNES_HOME_ROUTE", "https://evil.com")
assert get_home_route() == "/dashboard"
def test_root_redirect_authed_user_uses_home_route(fresh_db, monkeypatch):
"""``GET /`` for an authenticated user redirects to the configured
home route, not the hard-coded ``/dashboard``."""
monkeypatch.setenv("AGNES_HOME_ROUTE", "/home")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
resp = c.get("/", cookies={"access_token": sess})
assert resp.status_code == 302
assert resp.headers["location"] == "/home"
def test_root_redirect_unauthed_goes_to_login(fresh_db):
c = _client()
resp = c.get("/")
assert resp.status_code == 302
assert resp.headers["location"] == "/login"
def test_instance_admin_email_default_empty(fresh_db, monkeypatch):
"""Unset env + unset YAML → empty string. Template branches on
truthiness so empty hides the GWS Email-admin button cleanly."""
monkeypatch.delenv("AGNES_INSTANCE_ADMIN_EMAIL", raising=False)
from app.instance_config import get_instance_admin_email
assert get_instance_admin_email() == ""
def test_instance_admin_email_env_overrides(fresh_db, monkeypatch):
"""env var takes precedence over YAML / default."""
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
from app.instance_config import get_instance_admin_email
assert get_instance_admin_email() == "ops@example.com"
def test_instance_admin_email_strips_whitespace(fresh_db, monkeypatch):
"""Operator quoting habits ("` ops@example.com `") shouldn't break the
mailto link — strip surrounding whitespace at the resolver."""
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", " ops@example.com ")
from app.instance_config import get_instance_admin_email
assert get_instance_admin_email() == "ops@example.com"
def test_instance_admin_email_empty_env_treated_as_unset(fresh_db, monkeypatch):
"""Empty-string env var is intentional opt-out, not garbage."""
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "")
from app.instance_config import get_instance_admin_email
assert get_instance_admin_email() == ""
def test_gws_oauth_default_unset(fresh_db, monkeypatch):
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
from app.instance_config import get_gws_oauth_credentials
creds = get_gws_oauth_credentials()
assert creds["configured"] is False
assert creds["client_id"] == ""
assert creds["client_secret"] == ""
# OAUTHLIB_INSECURE_TRANSPORT defaults to "1" (gws CLI uses HTTP loopback)
assert creds["oauthlib_insecure_transport"] == "1"
def test_gws_oauth_env_overrides(fresh_db, monkeypatch):
monkeypatch.setenv("AGNES_GWS_CLIENT_ID", "abc.apps.googleusercontent.com")
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-secret")
from app.instance_config import get_gws_oauth_credentials
creds = get_gws_oauth_credentials()
assert creds["configured"] is True
assert creds["client_id"] == "abc.apps.googleusercontent.com"
assert creds["client_secret"] == "GOCSPX-secret"
def test_gws_oauth_project_id_derived_from_client_id(fresh_db, monkeypatch):
"""Numeric project_id is the prefix of the client_id before the first '-'.
Required by the gws CLI's client_secret.json schema (non-Option in Rust)."""
monkeypatch.setenv(
"AGNES_GWS_CLIENT_ID", "123456789012-abcd5678efgh.apps.googleusercontent.com"
)
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-x")
monkeypatch.delenv("AGNES_GWS_PROJECT_ID", raising=False)
from app.instance_config import get_gws_oauth_credentials
assert get_gws_oauth_credentials()["project_id"] == "123456789012"
def test_gws_oauth_project_id_explicit_override(fresh_db, monkeypatch):
"""Explicit AGNES_GWS_PROJECT_ID wins over the derived value — covers
edge cases where the client_id doesn't contain a numeric prefix."""
monkeypatch.setenv(
"AGNES_GWS_CLIENT_ID", "abc-x.apps.googleusercontent.com"
)
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-x")
monkeypatch.setenv("AGNES_GWS_PROJECT_ID", "explicit-id")
from app.instance_config import get_gws_oauth_credentials
assert get_gws_oauth_credentials()["project_id"] == "explicit-id"
def test_gws_oauth_half_configured_falls_back(fresh_db, monkeypatch):
"""Only client_id, no secret → not configured. Half-configuration must
not engage the shortcut branch."""
monkeypatch.setenv("AGNES_GWS_CLIENT_ID", "abc.apps.googleusercontent.com")
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
from app.instance_config import get_gws_oauth_credentials
assert get_gws_oauth_credentials()["configured"] is False
def test_home_renders_configured_gws_branch(fresh_db, monkeypatch):
"""Configured branch writes ~/.config/gws/client_secret.json directly
instead of exporting env vars. Claude Code's security layer redacts
env vars whose name contains 'SECRET', so the file-write path is the
only reliable way to seed the OAuth app credentials.
The gws prompt body now flows through Jinja's autoescape (the template
moved from inline `<code>` text to a `{{ connector_prompts.gws }}`
expression after the connector-prompts extraction). That means `"`
characters render as `&quot;` in the served HTML — the browser
un-escapes them on read, but the raw response body has the entity-
encoded form. So the test un-escapes before substring-matching."""
import html as _html
monkeypatch.setenv(
"AGNES_GWS_CLIENT_ID", "123456789012-abcd5678efgh.apps.googleusercontent.com"
)
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-secret-xyz")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
resp = c.get("/home", cookies={"access_token": sess})
assert resp.status_code == 200
body = _html.unescape(resp.text)
# Configured branch — JSON file path
assert "~/.config/gws/client_secret.json" in body
assert '"client_id": "123456789012-abcd5678efgh.apps.googleusercontent.com"' in body
assert '"client_secret": "GOCSPX-secret-xyz"' in body
# Project ID derived from client_id prefix
assert '"project_id": "123456789012"' in body
# Full read+write scopes — no --readonly flag (Agnes needs Drive/Gmail
# write so the agent can create, edit, and send on the user's behalf).
assert "gws auth login --readonly" not in body
assert "OAUTHLIB_INSECURE_TRANSPORT=1 gws auth login" in body
# Manual-setup walkthrough should NOT appear in the configured branch
assert "Run `gws auth setup` for me" not in body
# Old env-var approach should not leak back in
assert "export GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=" not in body
def test_home_renders_manual_gws_branch_when_unset(fresh_db, monkeypatch):
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
resp = c.get("/home", cookies={"access_token": sess})
assert resp.status_code == 200
body = resp.text
# Manual setup walkthrough renders
assert "Run `gws auth setup` for me" in body
# No leaked client_id placeholder
assert "GOOGLE_WORKSPACE_CLI_CLIENT_ID=" not in body
def test_home_automode_default_show(fresh_db, monkeypatch):
monkeypatch.delenv("AGNES_HOME_SHOW_AUTOMODE", raising=False)
from app.instance_config import get_home_automode_visibility
assert get_home_automode_visibility() is True
def test_home_automode_env_can_hide(fresh_db, monkeypatch):
monkeypatch.setenv("AGNES_HOME_SHOW_AUTOMODE", "0")
from app.instance_config import get_home_automode_visibility
assert get_home_automode_visibility() is False
def test_home_renders_automode_block_by_default(fresh_db, monkeypatch):
"""The permission-mode step renders by default for the not-onboarded
/home view. The block is Step 3 (folder creation moved up to Step 2
so the user mkdir+cd's first, then this step launches Claude in that
directory with the right flag for Step 4's ~20 shell commands).
Label primarily recommends `claude --dangerously-skip-permissions`
via the standard `.install-cmd` + copy-button affordance; auto-
accept-edits via Shift + Tab kept as the strict fallback for users
who want to review each command."""
monkeypatch.delenv("AGNES_HOME_SHOW_AUTOMODE", raising=False)
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
body = c.get("/home", cookies={"access_token": sess}).text
assert "Step 3 — start Claude Code with permission-skip" in body
# Recommended path: `claude --dangerously-skip-permissions`.
assert "claude --dangerously-skip-permissions" in body
# Strict fallback: Shift + Tab → auto-accept-edits.
assert "Shift + Tab" in body
def test_home_hides_automode_block_when_env_off(fresh_db, monkeypatch):
monkeypatch.setenv("AGNES_HOME_SHOW_AUTOMODE", "0")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
body = c.get("/home", cookies={"access_token": sess}).text
assert "Step 3 — start Claude Code with permission-skip" not in body
def test_navbar_home_link_uses_home_route(fresh_db, monkeypatch):
"""The shared navbar's primary "Home" link respects
``AGNES_HOME_ROUTE`` so a single env flip routes it to /home or
/dashboard. Tested by rendering an authed page and grepping the
rendered HTML — keeps the assertion close to what users see."""
monkeypatch.setenv("AGNES_HOME_ROUTE", "/home")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
# /home page itself renders the shared header.
resp = c.get("/home", cookies={"access_token": sess})
assert resp.status_code == 200
# Navbar link href reflects the resolved home_route, not hard-coded /dashboard.
# Label is "Home" (was "Dashboard" before the nav reorg).
assert 'href="/home">Home' in resp.text
# ---------------------------------------------------------------------------
# Atlassian base URL — operator-provisioned site root, Terraform-overrideable
# via AGNES_ATLASSIAN_BASE_URL.
# ---------------------------------------------------------------------------
def test_atlassian_base_url_default_empty(fresh_db, monkeypatch):
"""Unset env + unset YAML → empty string. Connector prompt falls
back to asking the user for the site URL (the existing flow)."""
monkeypatch.delenv("AGNES_ATLASSIAN_BASE_URL", raising=False)
from app.instance_config import get_atlassian_base_url
assert get_atlassian_base_url() == ""
def test_atlassian_base_url_env_overrides(fresh_db, monkeypatch):
"""Env var takes precedence over YAML / default."""
monkeypatch.setenv("AGNES_ATLASSIAN_BASE_URL", "https://acme.atlassian.net")
from app.instance_config import get_atlassian_base_url
assert get_atlassian_base_url() == "https://acme.atlassian.net"
def test_atlassian_base_url_strips_trailing_slash(fresh_db, monkeypatch):
"""`https://acme.atlassian.net/` → `https://acme.atlassian.net`.
Matches the per-user helper script's normalization at storage time
(atlassian_prompt step 4 guard 2). Without this, $BASE_URL/rest/...
becomes $BASE_URL//rest/... which some CDN paths reject."""
monkeypatch.setenv("AGNES_ATLASSIAN_BASE_URL", "https://acme.atlassian.net/")
from app.instance_config import get_atlassian_base_url
assert get_atlassian_base_url() == "https://acme.atlassian.net"
def test_atlassian_base_url_strips_trailing_wiki(fresh_db, monkeypatch):
"""`https://acme.atlassian.net/wiki` (the Confluence path) →
`https://acme.atlassian.net` (bare site root). The connector
prompt's verify step probes both Jira (root) and Confluence
(root + /wiki), so the canonical stored value is the root."""
monkeypatch.setenv("AGNES_ATLASSIAN_BASE_URL", "https://acme.atlassian.net/wiki")
from app.instance_config import get_atlassian_base_url
assert get_atlassian_base_url() == "https://acme.atlassian.net"
monkeypatch.setenv("AGNES_ATLASSIAN_BASE_URL", "https://acme.atlassian.net/wiki/")
assert get_atlassian_base_url() == "https://acme.atlassian.net"
def test_atlassian_prompt_uses_base_url_when_set():
"""The atlassian connector prompt bakes the operator's base URL into
the helper script instead of asking the user. Saves a chat round-
trip and avoids the "guess your org's Atlassian URL" footgun."""
from app.web.connector_prompts import atlassian_prompt
p = atlassian_prompt(base_url="https://acme.atlassian.net")
# The literal URL is baked into the prompt body.
assert "https://acme.atlassian.net" in p
# The "ask me for the site URL" step disappears.
assert "Ask me for my Atlassian Cloud site URL" not in p
# The placeholder in step 4's helper-script body is replaced with the literal.
assert "<the site URL I gave you>" not in p
# The new "operator baked it in" wording appears in step 1.
assert "already provisioned by the Agnes operator" in p
def test_atlassian_prompt_asks_user_when_base_url_empty():
"""When no operator override is set, prompt falls back to the
existing "ask me for the site URL" flow — no regression for OSS
instances that don't set the env var."""
from app.web.connector_prompts import atlassian_prompt
p = atlassian_prompt(base_url="")
assert "Ask me for my Atlassian Cloud site URL" in p
assert "<the site URL I gave you>" in p
assert "already provisioned by the Agnes operator" not in p
def test_all_connector_prompts_threads_atlassian_base_url():
"""all_connector_prompts() must forward the atlassian_base_url
kwarg to atlassian_prompt — otherwise the operator's Terraform
override never reaches the rendered text."""
from app.web.connector_prompts import all_connector_prompts
out = all_connector_prompts(atlassian_base_url="https://acme.atlassian.net")
assert "https://acme.atlassian.net" in out["atlassian"]
assert "Ask me for my Atlassian Cloud site URL" not in out["atlassian"]