* 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>
399 lines
17 KiB
Python
399 lines
17 KiB
Python
"""GET /home — state-aware landing page.
|
||
|
||
The boolean ``users.onboarded`` drives template selection. No
|
||
auto-transition: the not-onboarded view stays put until the user reloads
|
||
(the brainstorm called this out explicitly — quiet UI is preferable to a
|
||
surprise redirect mid-setup).
|
||
|
||
See origin: docs/brainstorms/home-page-requirements.md.
|
||
"""
|
||
|
||
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!!")
|
||
yield tmp
|
||
|
||
|
||
def _make_user_and_session(conn, email="u@example.com", onboarded=False):
|
||
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])
|
||
if onboarded:
|
||
conn.execute("UPDATE users SET onboarded = TRUE WHERE id = ?", [uid])
|
||
return uid, create_access_token(user_id=uid, email=email)
|
||
|
||
|
||
def _client(follow_redirects: bool = True):
|
||
from fastapi.testclient import TestClient
|
||
from app.main import app
|
||
|
||
return TestClient(app, follow_redirects=follow_redirects)
|
||
|
||
|
||
def test_home_unauth_redirects_to_login(fresh_db):
|
||
"""Non-API HTML routes redirect 401→/login per app.main's
|
||
StarletteHTTPException handler. /home follows that contract."""
|
||
c = _client(follow_redirects=False)
|
||
resp = c.get("/home")
|
||
assert resp.status_code == 302
|
||
assert resp.headers["location"].startswith("/login")
|
||
|
||
|
||
def test_home_not_onboarded_user_sees_setup_view(fresh_db):
|
||
"""A FALSE-onboarded user gets the install/setup template, identifiable
|
||
by its 'Install Claude Code' heading and the self-mark button."""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn, onboarded=False)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
|
||
c = _client()
|
||
resp = c.get("/home", cookies={"access_token": sess})
|
||
assert resp.status_code == 200
|
||
body = resp.text
|
||
assert "install Claude Code" in body # step 1 label
|
||
assert "install Agnes" in body # step 2 label
|
||
assert "self-mark-btn" in body # self-acknowledged escape hatch
|
||
assert "setupClaudeBtn" in body # primary one-click CTA from shared partial
|
||
|
||
|
||
def test_home_onboarded_user_sees_nav_hub(fresh_db):
|
||
"""A TRUE-onboarded user gets the post-onboarding view: the blue
|
||
install-hero is gone entirely (no welcome banner, no completion
|
||
badge, no inline step commands), the offboard escape strip is the
|
||
only setup-flow remnant rendered, and the rest of /home (connector
|
||
tiles, news, etc.) stays. PR #289 collapsed the dual-state hero
|
||
into a single not-onboarded-only render — pre-PR the onboarded
|
||
branch reused the same `.install-hero` shell with welcome copy
|
||
and a "Steps 1–4 done" badge."""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn, onboarded=True)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
|
||
c = _client()
|
||
resp = c.get("/home", cookies={"access_token": sess})
|
||
assert resp.status_code == 200
|
||
body = resp.text
|
||
# Install hero entirely absent for onboarded users.
|
||
assert '<div class="install-hero">' not in body
|
||
# Offboard escape strip + its button replace the in-hero self-mark control.
|
||
assert '<div class="offboard-strip">' in body
|
||
assert "Mark me as offboarded" in body
|
||
# All four inline install-blocks are hidden post-onboarding — the
|
||
# labels rendered inside the install-block divs go away.
|
||
assert "Step 1 — install Claude Code" not in body
|
||
assert "Step 2 — create your workspace folder" not in body
|
||
assert "Step 3 — start Claude Code with permission-skip" not in body
|
||
assert "Step 4 — install" not in body
|
||
|
||
|
||
def test_connectors_section_removed_from_home(fresh_db):
|
||
"""The dedicated `<details data-section="connectors">` block was
|
||
dropped from `/home` — the install-hero's Step 4 clipboard payload
|
||
(rendered via `_claude_setup_instructions.jinja` inside the manual
|
||
fallback) already inlines the same Asana / GWS / Atlassian prompts
|
||
from `app/web/connector_prompts.py` via
|
||
`app/web/setup_instructions.py::_connectors_block`. Showing them
|
||
twice on the same page was duplicate UX. The lead paragraph in the
|
||
install-hero now mentions the connectors briefly so users still see
|
||
the benefit before they hit the install.
|
||
|
||
Co-asserts the auto-mode block removal that this test originally
|
||
pinned — onboarded users still see neither the connectors block
|
||
nor the legacy auto-mode peer section."""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn, onboarded=True)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
|
||
c = _client()
|
||
resp = c.get("/home", cookies={"access_token": sess})
|
||
assert resp.status_code == 200
|
||
body = resp.text
|
||
# Auto-mode peer section still gone (legacy guard, not regressed).
|
||
assert 'class="automode-card"' not in body
|
||
assert 'data-section="step3"' not in body
|
||
assert "Step 3 — start Claude Code with permission-skip" not in body
|
||
# Dedicated connectors block is gone from /home in BOTH states.
|
||
assert 'class="connector-tiles"' not in body
|
||
assert 'data-section="connectors"' not in body
|
||
# Server-rendered HTML never carries the data-setup-minimized
|
||
# attribute on the .home-mock root — that's a client-side
|
||
# localStorage decision applied via JS on load.
|
||
assert '<div class="home-mock" data-setup-minimized' not in body
|
||
assert 'class="home-mock"\n' in body or '<div class="home-mock">' in body
|
||
|
||
# Not-onboarded path: same — the section disappears regardless of
|
||
# state. Lead-paragraph still surfaces the connector names so users
|
||
# know the benefit exists before they kick off the install.
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess2 = _make_user_and_session(
|
||
conn, email="not-onboarded@example.com", onboarded=False
|
||
)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body2 = _client().get("/home", cookies={"access_token": sess2}).text
|
||
assert 'class="connector-tiles"' not in body2
|
||
assert 'data-section="connectors"' not in body2
|
||
# Lead-paragraph mentions the three connector families so the
|
||
# benefit isn't lost when the dedicated section disappears.
|
||
assert "Asana, Google Workspace, Atlassian" in body2
|
||
|
||
|
||
def test_minimize_toggle_no_longer_rendered(fresh_db):
|
||
"""The "Minimize setup view" toggle used to live inside the
|
||
onboarded-branch of the install-hero. PR #289 hides the hero
|
||
entirely once `users.onboarded=true`, so the minimize toggle
|
||
has no rendering site anymore — verify the markup is absent
|
||
from both states. (The localStorage `agnes_home_setup_minimized`
|
||
flag and its applyMinimize() JS handler stay in the page so a
|
||
stale flag from a pre-PR session no-ops cleanly.)"""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
for onboarded in (False, True):
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(
|
||
conn, email=f"user-{onboarded}@example.com", onboarded=onboarded
|
||
)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
c = _client()
|
||
resp = c.get("/home", cookies={"access_token": sess})
|
||
assert resp.status_code == 200
|
||
assert '<button id="setupMinimizeToggle"' not in resp.text
|
||
assert 'class="setup-minimize"' not in resp.text
|
||
|
||
|
||
def test_home_no_auto_transition_after_post_until_reload(fresh_db):
|
||
"""POST /api/me/onboarded flips the flag in the DB but the in-flight
|
||
/home response from before the POST keeps showing the setup view —
|
||
the next GET /home picks up the new state. Verifies the manual-reload
|
||
contract called out in the brainstorm."""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(conn, onboarded=False)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
|
||
c = _client()
|
||
|
||
pre = c.get("/home", cookies={"access_token": sess})
|
||
# `class="install-block"` is the not-onboarded-only structural element
|
||
# holding the inline Step-1 install pane. Use it as the discriminator
|
||
# instead of a free-form string like "install Claude Code", which now
|
||
# also appears in the always-on SETUP_INSTRUCTIONS_TEMPLATE clipboard
|
||
# payload's preflight comment after the 2026-05-10 init-report fix.
|
||
assert 'class="install-block"' in pre.text # setup view
|
||
|
||
flip = c.post("/api/me/onboarded", cookies={"access_token": sess})
|
||
assert flip.status_code == 200
|
||
|
||
post = c.get("/home", cookies={"access_token": sess})
|
||
# PR #289: hero disappears entirely; offboard strip is the
|
||
# only setup-flow remnant. Use either as the nav-hub view marker.
|
||
assert '<div class="offboard-strip">' in post.text
|
||
assert 'class="install-block"' not in post.text
|
||
|
||
|
||
# ── GWS Email-admin button render tests (admin_email knob coverage) ────────
|
||
|
||
|
||
def test_home_hides_email_admin_button_when_admin_email_unset(fresh_db, monkeypatch):
|
||
"""When ``instance.admin_email`` is unset, the GWS connector tile
|
||
must NOT render the mailto link (template guards on truthiness;
|
||
empty resolver value cleanly hides). Defends against a `mailto:?`
|
||
link sneaking out as a render-time artifact."""
|
||
monkeypatch.delenv("AGNES_INSTANCE_ADMIN_EMAIL", raising=False)
|
||
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()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
# No "Email admin" CTA, no mailto: link in the body.
|
||
assert "Email admin" not in body
|
||
assert "mailto:?" not in body # specifically, no broken empty mailto
|
||
|
||
|
||
def test_home_no_longer_shows_email_admin_button(fresh_db, monkeypatch):
|
||
"""The Email-admin mailto CTA used to live inside the /home GWS
|
||
connector tile. With the dedicated `<details data-section="connectors">`
|
||
block removed (see test_connectors_section_removed_from_home above),
|
||
the button has no rendering site even when admin_email is set + GWS
|
||
is unconfigured. The escalation path lives inside the install
|
||
script's GWS step now — Claude prompts the user with the admin
|
||
email when the connector setup hits an OAuth gating wall, so the
|
||
affordance moves to the surface where it's actually useful."""
|
||
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
|
||
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()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
assert "Email admin" not in body
|
||
assert 'mailto:ops@example.com' not in body
|
||
|
||
|
||
def test_home_hides_email_admin_button_when_gws_configured(fresh_db, monkeypatch):
|
||
"""Even with admin_email set, when GWS OAuth is operator-provisioned
|
||
(gws_oauth.configured = True), the Email-admin CTA is redundant —
|
||
user can just connect. Template gates on `not gws_oauth.configured`."""
|
||
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
|
||
monkeypatch.setenv("AGNES_GWS_CLIENT_ID", "abc.apps.googleusercontent.com")
|
||
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-secret")
|
||
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()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
assert "Email admin" not in body
|
||
|
||
|
||
# `test_home_renders_connector_prompts_from_shared_module` was dropped here
|
||
# alongside the removal of the /home `<details data-section="connectors">`
|
||
# block. The test pinned source-of-truth parity between the home tile
|
||
# `<code id="*-prompt">` blocks and `app/web/connector_prompts.py`. With the
|
||
# tiles gone, the only surface left for those strings is the install-hero's
|
||
# Step 4 clipboard payload (rendered via `_claude_setup_instructions.jinja`
|
||
# from `setup_instructions_lines`, which is built in
|
||
# `app/web/setup_instructions.py::_connectors_block` calling the same
|
||
# `connector_prompts.py` functions). One surface, no drift risk → the
|
||
# parity test is redundant. If a second surface ever re-renders these
|
||
# prompts, restore a parity test scoped to that new consumer.
|
||
|
||
|
||
# ── Getting Started + Overview + Usage modes (PR #289 home additions) ────
|
||
|
||
|
||
def test_getting_started_card_renders_on_home(fresh_db):
|
||
"""The dismissible Getting Started card now renders BEFORE the
|
||
install-hero (chronologically first in the not-onboarded flow) as
|
||
a <details> element — collapsed by default so the install hero
|
||
stays visible on first paint. Disappears when the user is
|
||
onboarded (no `<details class="home-getting-started">`) so the
|
||
in-page #install-hero anchor on the first row never points at
|
||
nothing. First row links to #install-hero (same-page jump to the
|
||
blue setup hero); second row still leaves the page for
|
||
/setup-advanced."""
|
||
from src.db import get_system_db, close_system_db
|
||
|
||
# Not-onboarded: GS is rendered + install-hero anchor target exists.
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess = _make_user_and_session(
|
||
conn, email="gs-not-onboarded@example.com", onboarded=False
|
||
)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
assert '<details class="home-getting-started"' in body
|
||
assert 'data-dismiss-key="agnes_home_gs_dismissed"' in body
|
||
assert 'class="home-gs-item" href="#install-hero"' in body
|
||
assert 'class="home-gs-item" href="/setup-advanced"' in body
|
||
# Install-hero must carry the matching id so the first-row anchor
|
||
# resolves. Co-asserted with the GS markup so a refactor that drops
|
||
# one but not the other breaks here, not in the browser.
|
||
assert '<div class="install-hero" id="install-hero">' in body
|
||
|
||
# Onboarded: install-hero is gone, GS rides alongside it — neither
|
||
# renders. Prevents a dangling #install-hero anchor.
|
||
conn = get_system_db()
|
||
try:
|
||
_, sess2 = _make_user_and_session(
|
||
conn, email="gs-onboarded@example.com", onboarded=True
|
||
)
|
||
finally:
|
||
conn.close()
|
||
close_system_db()
|
||
body2 = _client().get("/home", cookies={"access_token": sess2}).text
|
||
assert '<details class="home-getting-started"' not in body2
|
||
assert '<div class="install-hero"' not in body2
|
||
|
||
|
||
def test_overview_section_renders_when_yaml_set(fresh_db, monkeypatch):
|
||
"""Setting `AGNES_INSTANCE_OVERVIEW` env (mirrors
|
||
instance.overview yaml) injects raw HTML into the Overview section
|
||
via the same `| safe` filter as news_intro. The marker text must
|
||
appear inside the rendered section wrapper. Overview deliberately
|
||
has NO dismiss button — it's operator-owned reference content
|
||
(privacy posture, telemetry policy, product framing), and a
|
||
per-device hide would leave returning users unable to re-read
|
||
it without clearing localStorage."""
|
||
monkeypatch.setenv("AGNES_INSTANCE_OVERVIEW", "<p>OVERVIEW_TEST_MARKER</p>")
|
||
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()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
assert '<section class="home-overview">' in body
|
||
assert "OVERVIEW_TEST_MARKER" in body
|
||
# Overview must NOT carry a dismiss key — content stays
|
||
# reachable on every visit so users can re-read it.
|
||
assert 'data-dismiss-key="agnes_home_overview_dismissed"' not in body
|
||
|
||
|
||
def test_overview_section_hidden_when_yaml_empty(fresh_db, monkeypatch):
|
||
"""Default empty `instance.overview` (no env override) hides the
|
||
section entirely so the OSS ships without a stray empty
|
||
Overview placeholder."""
|
||
monkeypatch.delenv("AGNES_INSTANCE_OVERVIEW", 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()
|
||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||
assert '<section class="home-overview">' not in body
|