diff --git a/CHANGELOG.md b/CHANGELOG.md index bb59ab1..318f98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,24 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ## [Unreleased] +### Added + +- **`instance.admin_email` operator config knob** (env `AGNES_INSTANCE_ADMIN_EMAIL` > YAML `instance.admin_email` > unset). When set, the `/home` Google Workspace connector tile renders an "Email admin" mailto button so analysts whose operator hasn't pre-provisioned a shared OAuth app can request one without leaving the workspace. Empty default cleanly hides the button. + +- **Connector setup folded into the main install script (step 8).** New `app/web/connector_prompts.py` is the single source of truth for the Asana / Google Workspace / Atlassian per-tool prompts; `_connectors_block` in `setup_instructions.py` inlines them under per-connector default-yes asks (empty/Enter installs; only "no" skips). Same prompts power the `/home` tile cards via `{{ connector_prompts. }}` so editing one place updates both surfaces. Resolves the "extra paste step" friction surfaced by the 2026-05-09 onboarding test — fresh install becomes one paste end-to-end (Agnes + skills + connectors). Note: see #246 for the planned move of the connector prompt set into the operator-side overlay (so non-Atlassian/Asana/GWS shops aren't bound to this opinion). + +### Changed + +- **`/home` install hero polish** — license-options link contrast against the blue gradient (white + underline; matches lead-paragraph pattern), step reorder so auto-mode (Shift+Tab) becomes step 2 and Agnes install shifts to step 3 (auto-mode must be on BEFORE the ~20-command bash bootstrap so each Bash/edit doesn't need a manual approve click), step-2 simplification (Shift+Tab-only — Claude Code prompts to persist as default; no `~/.claude/settings.json` snippet to maintain). Onboarded users no longer see the auto-mode block. Completion banner reads "Step 1, 2 & 3 done — Claude Code installed, auto-mode set, Agnes ready". + +- **`/home` onboarding friction fixes from internal usability testing** — improved hero copy clarity, connector tile gating notes (so users understand why some tiles are disabled), Asana / GWS / Atlassian prompt-correctness fixes (Atlassian three-guard structure: length floor → URL normalization → Jira-then-Confluence verify with 401 short-circuit; GWS `127.0.0.1` → `localhost` correction grounded in `strings` analysis of the `gws` binary), step layout clarification, and post-OAuth-session fallback line for users who closed the OAuth window before saving. + +- **Setup script step layout: connectors becomes step 8, Confirm shifts to step 9.** Skills step deleted in #242 (on-demand `agnes skills show ` is the default; bulk-copying skills was an opinion question). Layout now: install (1), init (2), catalog (3), preflight (4), marketplace (5), mcp_servers (6), diagnose (7), connectors (8), confirm (9). + +### Removed + +- **BREAKING: `/corporate-memory` page + dashboard widget + nav link restricted to admins.** The `/corporate-memory` route now requires `require_admin` (was `get_current_user`); non-admin users hitting it see 403 (was 200). The Memory link in the top nav and the corporate-memory widget on `/dashboard` are hidden via `{% if session.user.is_admin %}` guards. **Asymmetry:** the underlying `/api/memory/*` endpoints stay on `get_current_user` so CLI / agent flows that POST a knowledge item or fetch `/api/memory` keep working; the gating is web-UI-only. Operators who relied on non-admin web access need to either grant Admin to those users or use the API. + ## [0.49.0] — 2026-05-11 ### Fixed (PR #242 follow-ups) diff --git a/app/instance_config.py b/app/instance_config.py index 2c0a091..1cef0a8 100644 --- a/app/instance_config.py +++ b/app/instance_config.py @@ -258,6 +258,23 @@ def get_instance_subtitle() -> str: return get_value("instance", "subtitle", default="") +def get_instance_admin_email() -> str: + """Operator-facing contact address shown in user-side prompts that + suggest the user reach out to their Agnes admin (e.g. the /home GWS + connector tile renders an "Email admin" mailto button when no shared + OAuth app is provisioned). Empty string when unset — the template + branches on the value being truthy, so an empty value hides the + button rather than rendering a broken `mailto:` link. + + Resolution: ``AGNES_INSTANCE_ADMIN_EMAIL`` env > ``instance.admin_email`` YAML > "". + Mirrors :func:`get_home_route` shape so Terraform overrides work. + """ + raw = os.environ.get("AGNES_INSTANCE_ADMIN_EMAIL") + if raw is None: + raw = get_value("instance", "admin_email", default="") + return (raw or "").strip() + + def get_sync_interval() -> str: """Human-readable refresh cadence shown in the analyst welcome prompt.""" return get_value("instance", "sync_interval", default="1 hour") diff --git a/app/web/connector_prompts.py b/app/web/connector_prompts.py new file mode 100644 index 0000000..1149460 --- /dev/null +++ b/app/web/connector_prompts.py @@ -0,0 +1,351 @@ +"""Connector setup prompts — single source of truth. + +Two consumers share these strings: + +1. ``app/web/templates/home_not_onboarded.html`` — renders each one inside + a "Copy prompt" tile so an already-onboarded user can grab a single + connector's prompt and paste it into Claude Code. +2. ``app/web/setup_instructions.py`` — inlines all three into the main + "Setup a new Claude Code" script as step 9's interactive ask-then- + inline-prompt block, so a fresh user gets connectors wired up in + the same paste-and-go flow that installs Agnes. + +Keeping them here (instead of duplicating across template + script) means +edits land in one place. The shape of each connector — slug, display +name, what the prompt instructs Claude to do — is invariant; the GWS +prompt is the only one that branches at render time (operator-provisioned +OAuth client vs manual ``gws auth setup``), which is why ``gws_prompt`` +takes the credentials dict. + +The text deliberately reads like a Claude Code prompt rather than a shell +script. The whole flow is "paste into Claude Code, let it do the work" — +the prompts tell Claude how to ask the user, where to write helper +scripts, and how to verify against live APIs before storing anything. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +# --------------------------------------------------------------------------- +# Public registry — single place to add / remove / reorder a connector. +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Connector: + """One connector's identity, surfaced both in the /home tile registry + and in the setup-script step. Adding a fourth connector means: one + entry here, one ``_prompt()`` function below, one branch in + :func:`all_connector_prompts`. No template or setup-script changes.""" + + slug: str + display_name: str + description: str + + +CONNECTORS: list[Connector] = [ + Connector( + slug="asana", + display_name="Asana", + description="Read tasks and projects, comment, create updates — Claude works alongside your project boards without leaving the terminal.", + ), + Connector( + slug="gws", + display_name="Google Workspace", + description="Drive, Calendar, Gmail, Docs, Sheets, Chat — Claude reads and acts across your work account via the official `gws` CLI.", + ), + Connector( + slug="atlassian", + display_name="Atlassian (Jira / Confluence)", + description="Read and write Jira issues, search Confluence pages — Claude pulls ticket context and posts updates without leaving the workspace.", + ), +] + + +def all_connector_prompts( + *, + gws_oauth: dict | None = None, + instance_admin_email: str = "", +) -> dict[str, str]: + """Resolve every connector's prompt text with the operator's runtime + config baked in. Caller (router._build_context, setup_instructions + consumers) passes the already-resolved ``gws_oauth`` dict from + :func:`app.instance_config.get_gws_oauth_credentials` and the admin + email from :func:`get_instance_admin_email`. Returns a dict keyed by + connector slug so both the template (``{{ connector_prompts.asana + }}``) and the setup script (``connector_prompts['asana']``) read the + same shape. + + ``instance_admin_email`` is currently unused inside the prompt bodies + (the Email-admin button on /home is tile chrome, not prompt content) + but is plumbed through anyway so a future GWS prompt branch that + references the admin contact can add the string without changing the + call sites. + """ + gws_oauth = gws_oauth or {} + return { + "asana": asana_prompt(), + "gws": gws_prompt( + gws_oauth_configured=bool(gws_oauth.get("configured")), + gws_client_id=str(gws_oauth.get("client_id") or ""), + gws_client_secret=str(gws_oauth.get("client_secret") or ""), + gws_project_id=str(gws_oauth.get("project_id") or ""), + oauthlib_insecure_transport=str( + gws_oauth.get("oauthlib_insecure_transport") or "1" + ), + instance_admin_email=instance_admin_email, + ), + "atlassian": atlassian_prompt(), + } + + +# --------------------------------------------------------------------------- +# Individual prompt builders. +# +# Each returns the verbatim prompt body that Claude Code follows when the +# user pastes it. Strings are plain Python (real `<` / `>` / `&` chars) — +# the Jinja template re-escapes for HTML rendering, and the setup script +# inlines them straight into bash heredocs / numbered steps. +# --------------------------------------------------------------------------- + +def asana_prompt() -> str: + """Asana PAT setup. Stores token in OS keychain under + ``agnes-asana-pat``. Idempotent — re-running short-circuits when the + cached token still passes the Asana ``users/me`` probe.""" + return _ASANA_PROMPT + + +def gws_prompt( + *, + gws_oauth_configured: bool, + gws_client_id: str = "", + gws_client_secret: str = "", + gws_project_id: str = "", + oauthlib_insecure_transport: str = "1", + instance_admin_email: str = "", # noqa: ARG001 — plumbed for future use +) -> str: + """Google Workspace setup via the official ``gws`` CLI. + + Step 5 branches on whether the operator has provisioned a shared + OAuth app (``gws_oauth_configured=True``, set when both + ``AGNES_GWS_CLIENT_ID`` + ``AGNES_GWS_CLIENT_SECRET`` are present). + Configured → write ``client_secret.json`` directly, skip the + ``gws auth setup`` walkthrough entirely (~2 min, zero clickops). + Unconfigured → fall back to the manual GCP project walkthrough + (~20 min, user needs GCP-admin help). + + ``oauthlib_insecure_transport`` only flows into step 6 because the + gws CLI's loopback redirect is HTTP (Google's oauthlib refuses that + without the env var set).""" + if gws_oauth_configured: + step5 = _GWS_STEP5_CONFIGURED_TEMPLATE.format( + client_id=gws_client_id, + project_id=gws_project_id, + client_secret=gws_client_secret, + ) + # When configured, step 6 reuses the operator's `oauthlib_insecure_transport` + # setting verbatim — even though "1" is the always-safe default, an operator + # MAY have flipped it off via instance.yaml and we honour that. + oauth_env = oauthlib_insecure_transport or "1" + else: + step5 = _GWS_STEP5_MANUAL + oauth_env = "1" + return _GWS_PROMPT_HEAD + step5 + _GWS_PROMPT_TAIL_TEMPLATE.format( + oauth_env=oauth_env, + ) + + +def atlassian_prompt() -> str: + """Atlassian (Jira + Confluence) API token setup. Stores token in OS + keychain under ``agnes-atlassian-api-token``, plus email + normalized + base URL in ``~/.claude/agnes/secrets.env``. Jira-first / Confluence- + fallback verify so Confluence-only sites still onboard.""" + return _ATLASSIAN_PROMPT + + +# --------------------------------------------------------------------------- +# Prompt bodies — kept as module-level constants so they're free of +# any per-call allocation cost and trivially diffable. +# --------------------------------------------------------------------------- + +_ASANA_PROMPT = """Set up an Asana personal access token for Claude Code. Walk me through it step by step. + +Ground rules: this is idempotent — safe to re-run, the precheck below short-circuits when Asana is already wired up. If any step fails with an unfamiliar error, paste the exact error back and stop. Do NOT improvise around TLS errors by disabling verification (`-k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`, `git -c http.sslVerify=false`, etc.) — those hide the real problem. + +0. Precheck — skip the rest if Asana is already connected. Detect my OS, then look up an existing keychain entry under the service name `agnes-asana-pat` and verify it against Asana's API. macOS: `t=$(security find-generic-password -s 'agnes-asana-pat' -w 2>/dev/null) && curl -fsS -H "Authorization: Bearer $t" https://app.asana.com/api/1.0/users/me | jq -r '.data | "Already connected as \\(.name) (\\(.workspaces | length) workspace(s)). Skipping setup."' && exit 0`. Linux: `t=$(secret-tool lookup service agnes-asana-pat username "$USER" 2>/dev/null) && ...same curl...`. Windows PowerShell: `$cred = cmdkey /list:agnes-asana-pat 2>$null; if ($LASTEXITCODE -eq 0) { Write-Host "Asana cred entry found — verify in your terminal before re-running setup." }` (Windows can't read the password back without a CredentialManager module — print a hint and let me confirm). If the verify call returns 200, print the one-line "Already connected" message and STOP. Only continue to step 1 when no cred exists OR the cached token returns 401. +1. Open the Asana developer tokens page in my default browser — use your Bash tool: `open https://app.asana.com/0/developer-console/tokens` on macOS, `xdg-open https://app.asana.com/0/developer-console/tokens` on Linux/WSL, or `Start-Process https://app.asana.com/0/developer-console/tokens` on Windows. Detect OS first. If that URL doesn't render the tokens UI (rare), tell me to click my avatar (top right) → Settings → "Apps" tab → "Manage Developer Apps" → Personal access tokens. +2. Tell me to click "+ New access token", name it "Claude Code — Agnes", and click "Create token". Warn me the token is shown ONCE and Asana PATs do not expire — I'd need to revoke it from the same page if it leaks. +3. Important: do NOT ask me to paste the token into the chat. Chat input is saved to ~/.claude/projects/.../*.jsonl. Instead, prepare a tiny helper script for me to run in my real terminal: + a. Detect my OS. Use the Write/Edit tool (NOT a shell here-doc that prints the body) to create ~/.claude/agnes/bin/store-asana.sh on macOS/Linux, or ~/.claude/agnes/bin/store-asana.ps1 on Windows. chmod 700 the file. Body for macOS: + #!/usr/bin/env bash + set -e + read -srp 'Paste Asana token (hidden): ' t; echo + security add-generic-password -U -s 'agnes-asana-pat' -a "$USER" -w "$t" + unset t + echo 'Stored in macOS Keychain.' + Linux variant: same shape but `printf %s "$t" | secret-tool store --label='Agnes Asana PAT' service agnes-asana-pat username "$USER"`. Windows .ps1: `$t = Read-Host 'Paste Asana token' -AsSecureString; $p = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($t)); cmdkey /generic:agnes-asana-pat /user:$env:USERNAME /pass:$p > $null; Remove-Variable p,t; 'Stored.'` + b. Tell me to open a real terminal (Terminal.app / iTerm / WSL / PowerShell — NOT Claude Code's `!` prefix, which has no TTY) and run `bash ~/.claude/agnes/bin/store-asana.sh` (or `pwsh ~/.claude/agnes/bin/store-asana.ps1` on Windows). The script will wait silently at the hidden prompt. + c. Walk me through the clipboard order: copy the launcher first, paste it in my terminal, press Enter (terminal now waiting). Switch to the Asana tab, copy the token from step 2. Switch back to terminal, paste at the silent prompt, press Enter. Token enters via stdin only — not shown on screen, not in shell history, not in clipboard at the moment Claude is involved. +4. After I report "Stored", verify by calling `curl -sS -H "Authorization: Bearer $(security find-generic-password -s 'agnes-asana-pat' -w)" https://app.asana.com/api/1.0/users/me | jq -r '.data | "\\(.name) — \\(.workspaces | length) workspace(s)"'` (macOS; Linux uses `secret-tool lookup` instead). Print only the one-line result. Never echo the token. +5. Remind me where the token is stored and how to revoke: in macOS Keychain Access search "agnes-asana-pat" or run `security delete-generic-password -s 'agnes-asana-pat'`; on Asana, revoke from the same developer-console page.""" + + +_GWS_PROMPT_HEAD = """Set up Google Workspace access for Claude Code using the official `gws` CLI from https://github.com/googleworkspace/cli (install steps: README → Installation). The npm path is what we'll use because (a) it's the README's documented convenience path, (b) it works the same on macOS / Linux / WSL / Windows, and (c) it can run with zero admin rights when Node is managed by `nvm` (Unix) or `fnm` (Windows). + +Ground rules: this is idempotent — safe to re-run, the precheck below short-circuits when `gws` is already installed and authed. If any step fails with an unfamiliar error, paste the exact error back and stop — don't half-finish. Do NOT improvise around TLS errors by disabling verification (`-k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`, npm `strict-ssl=false`, etc.) — those mask the real problem. + +YOU run every command via your Bash tool. Do NOT print install commands and ask me to type them. Only stop and ask me when I have to (a) approve an OAuth consent screen in a browser, (b) make a product decision (Cloud project name), or (c) paste OAuth client credentials Google shows me. + +0. Precheck — skip the rest if Google Workspace is already connected. Run `command -v gws` AND `gws auth status` AND a low-impact verify call: `gws drive files list --params '{"pageSize": 1}' && gws chat spaces list --params '{"pageSize": 1}'`. If both succeed, the gws CLI is installed AND authed AND the Chat scope is present. Print "Already connected as — Drive + Chat scopes verified. Skipping setup." and STOP. If `gws drive` succeeds but `gws chat` fails with 403/PERMISSION_DENIED, the user authed without `--full` previously — skip to step 6 (re-login with widened scopes), do NOT re-install. Only walk steps 1–5 (install + OAuth client setup) when `command -v gws` itself fails. + +1. Detect my OS (`uname -s` → Darwin / Linux, or PowerShell `$env:OS` → Windows_NT). On Linux check `grep -qi microsoft /proc/version` and treat WSL as Linux. + +2. Check `command -v gws` (or `Get-Command gws` on Windows). If `gws` is already installed, skip to step 5. + +3. Install Node.js 18+ to my user directory — no sudo, no UAC, no system package manager. + + Unix (macOS / Linux / WSL): + a. Check `command -v node && node --version` — if 18+ already, skip. + b. Otherwise install nvm into ~/.nvm: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash`. The installer writes to ~/.nvm and appends shellenv to ~/.bashrc / ~/.zshrc — no sudo. Source it for the current shell: `export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh"`. + c. `nvm install --lts && nvm use --lts`. Verify `node --version` shows v20.x or v22.x. + + Native Windows (NOT WSL): + a. Check `node --version` — if 18+, skip. + b. Install fnm to user profile (no admin): run `winget install Schniz.fnm --scope user --accept-source-agreements --accept-package-agreements`. If winget triggers UAC, fall back to the manual zip from https://github.com/Schniz/fnm/releases/latest — extract `fnm.exe` to `$HOME\\.local\\bin\\` and add that dir to my user PATH via `[Environment]::SetEnvironmentVariable('Path', "$env:Path;$HOME\\.local\\bin", 'User')`. + c. `fnm install --lts; fnm use lts-latest`. `fnm env --use-on-cd | Out-String | Invoke-Expression` to source it for the current shell. + +4. Install `gws` via npm — runs as my user because Node is managed by nvm/fnm, so the global prefix lives inside ~/.nvm/versions/node//lib/ (Unix) or ~/.fnm/.../lib/ (Windows). No sudo, no UAC, no `npm config set prefix` workaround needed. + + a. `npm install -g @googleworkspace/cli` (run via Bash tool). Wait for it. If npm fails (network, registry, peer-dep), report the exact stderr and pause — don't half-finish. + + b. nvm/fnm Node + npm-installed binaries land under ~/.nvm/versions/node//bin/ — only on PATH when nvm is sourced interactively. YOUR Bash tool runs non-interactive subshells that do NOT source ~/.zshrc or ~/.bashrc, so `gws` and `node` will appear "not found" on the very next call. Symlink them into ~/.local/bin (which is on PATH in every shell context) right after install: + `mkdir -p ~/.local/bin` + `ln -sf "$(command -v gws)" ~/.local/bin/gws` + `ln -sf "$(command -v node)" ~/.local/bin/node` + Run these while nvm/fnm is sourced in the same Bash call so `command -v` resolves correctly. On native Windows, copy `gws.cmd` from the npm prefix into `$HOME\\.local\\bin\\` instead — symlinks need admin on Windows by default. + + c. Verify `gws --version` from a fresh `bash -c 'gws --version'` (deliberately non-interactive) — confirms the symlink path works for future tool calls. + +""" + + +_GWS_STEP5_CONFIGURED_TEMPLATE = """5. The Agnes operator has already provisioned a shared Google Workspace OAuth app for this instance. Skip `gws auth setup` entirely. Do NOT use environment variables (Claude Code's security layer redacts vars containing the substring "SECRET" from non-interactive subshells, so the env-var approach is unreliable). Instead, write the credentials directly to the file `gws auth status` reads as `credential_source`: + + Use the Write tool to create `~/.config/gws/client_secret.json` (or `%APPDATA%\\gws\\client_secret.json` on native Windows) with EXACTLY the schema Google Cloud Console exports — the gws CLI's Rust struct rejects partial files with "Invalid client_secret.json format: missing field 'project_id'". Both `installed.project_id` (numeric project number) and the URI fields are mandatory: + {{ + "installed": {{ + "client_id": "{client_id}", + "project_id": "{project_id}", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "{client_secret}", + "redirect_uris": ["http://localhost"] + }} + }} + + Then `mkdir -p ~/.config/gws && chmod 700 ~/.config/gws && chmod 600 ~/.config/gws/client_secret.json`. Verify by running `gws auth status` — it should report this file as `credential_source` without complaining about missing fields. The values identify the OAuth app, not me; treat the secret like a publishable bundle key, not a per-user credential. +""" + + +_GWS_STEP5_MANUAL = """5. Run `gws auth setup` for me. This is a one-time Google Cloud project config; gcloud is NOT required (when gcloud is absent, `gws auth setup` walks through the manual OAuth flow). Open the URL it prints in my default browser, then walk me through each click because I am NOT a GCP admin: + a. Pick or create a Google Cloud project (free tier is fine). + b. Enable the APIs the connector needs: Google Drive API, Google Calendar API, Gmail API. Tell me each menu click. + c. Create an OAuth 2.0 client. Either "Desktop app" or "Web application" works. For Web application: add `http://localhost` (exact value — no port, no path, no trailing slash) to Authorized redirect URIs. Google's loopback exemption then matches the `http://localhost:` redirect that `gws auth login` actually uses. Desktop app needs no URI registration. + d. Copy the resulting client_id and client_secret. Paste them back into the terminal where `gws auth setup` is waiting. These identify the OAuth app — not the user — but still don't echo them back to me in chat. +""" + + +_GWS_PROMPT_TAIL_TEMPLATE = """ +6. Run `gws auth login --full` (no `--readonly` flag — Agnes uses full read + write access across Drive / Calendar / Gmail / Sheets / Docs / Chat so the agent can actually create, edit, and send on my behalf). The `--full` flag widens the default scope picker; without it Chat / People / Tasks scopes are silently dropped. One env var the loopback redirect needs is OAUTHLIB_INSECURE_TRANSPORT — set it in the SAME Bash invocation that runs login: `OAUTHLIB_INSECURE_TRANSPORT={oauth_env} gws auth login --full`. The CLI binds a local loopback server at `http://localhost:` — an OS-assigned ephemeral port, NOT a fixed 8080 — and prints an OAuth URL. If this errors with `redirect_uri_mismatch`, the Cloud Console OAuth client is a Web application type that's missing the `http://localhost` entry in Authorized redirect URIs (no port, no path) — add that exact value and retry. + + Capture the URL from gws's stdout. Before opening the browser, append the Chat write scopes (`https://www.googleapis.com/auth/chat.spaces` and `https://www.googleapis.com/auth/chat.messages`) to the URL's `scope=` query parameter — `--full` includes the readonly Chat scopes but NOT the read+write ones, and `gws chat ... send` calls fail without them. Decode the existing scope list, append the two URLs space-separated, re-encode, then open. Python one-liner via Bash tool: + + `URL=$(printf '%s' "$URL" | python3 -c 'import sys,urllib.parse as u; q=u.urlparse(sys.stdin.read().strip()); p=u.parse_qs(q.query); s=set(p.get("scope",[""])[0].split()); s |= {{"https://www.googleapis.com/auth/chat.spaces","https://www.googleapis.com/auth/chat.messages"}}; p["scope"]=[" ".join(sorted(s))]; print(q._replace(query=u.urlencode(p, doseq=True, quote_via=u.quote)).geturl())')` + + Then open the rewritten URL programmatically — do NOT print it to chat. Markdown line-wrapping in chat corrupts the long scope query string when the user copies it. Use your Bash tool: macOS `open "$URL"`, Linux/WSL `xdg-open "$URL"`, Windows `Start-Process "$URL"`. Detect OS first. + + While the browser tab is loading, read each requested scope in plain language for me — full read + write across Drive, Calendar, Gmail, Chat, and the rest — so I know what I'm consenting to before I click Approve. Tell me I can revoke any time at https://myaccount.google.com/permissions if I change my mind. + + If `gws auth status` later shows Chat scopes missing (e.g. on a re-run where a stale token cached the previous scope set), `rm ~/.config/gws/token.json` (or `%APPDATA%\\gws\\token.json` on native Windows) and re-run this step — the OAuth flow re-prompts with the new scope list. + +7. Find where gws stored my credentials (`gws auth status` should show the path; typically ~/.config/gws/ on Unix, %APPDATA%\\gws\\ on Windows). chmod 600 on Unix; on native Windows, restrict ACLs to my user with `icacls "$creds_path" /inheritance:r /grant:r "$env:USERNAME:F"` — file is already in my user profile so this needs no admin. + +8. Verify with two low-impact reads, one per scope group: `gws drive files list --params '{{"pageSize": 1}}'` (Drive scope landed) and `gws chat spaces list --params '{{"pageSize": 1}}'` (Chat scope landed). Print only "Connected as " plus the file + space counts. Never echo tokens, file/message metadata, or scope strings to chat. + +9. Remind me how to revoke later: `gws auth logout` clears local creds; the OAuth grant also appears at https://myaccount.google.com/permissions for Google-side revocation.""" + + +_ATLASSIAN_PROMPT = """Set up Atlassian (Jira + Confluence) API access for Claude Code. Walk me through it step by step. + +Ground rules: this is idempotent — safe to re-run, the precheck below short-circuits when Atlassian is already wired up. If any step fails with an unfamiliar error, paste the exact error back and stop. Do NOT improvise around TLS errors by disabling verification (`-k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`, `git -c http.sslVerify=false`, etc.) — those hide the real problem. + +0. Precheck — skip the rest if Atlassian is already connected. The setup script stores email + the *normalized* site root URL (no trailing slash, no `/wiki` suffix) in `~/.claude/agnes/secrets.env` and the API token in the OS keychain under `agnes-atlassian-api-token`. Verify all three exist + auth works against the LIVE Atlassian API before reinstalling, and probe BOTH Jira and Confluence — sites can have either product enabled, so Jira's `/rest/api/3/myself` returns 404 on Confluence-only sites and vice-versa. macOS: `[ -r ~/.claude/agnes/secrets.env ] && . ~/.claude/agnes/secrets.env && t=$(security find-generic-password -s 'agnes-atlassian-api-token' -a "$ATLASSIAN_EMAIL" -w 2>/dev/null) && B="${ATLASSIAN_BASE_URL%/}" && B="${B%/wiki}" && tmp=$(mktemp) && code=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$ATLASSIAN_EMAIL:$t" "$B/rest/api/3/myself") && { [ "$code" = "404" ] && code=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$ATLASSIAN_EMAIL:$t" "$B/wiki/rest/api/user/current"); :; } && [ "$code" = "200" ] && jq -r '"Already connected as \\(.displayName) (\\(.emailAddress // "no email scope")) on '"$B"'. Skipping setup."' < "$tmp" && rm -f "$tmp" && exit 0`. Linux: same shape but `t=$(secret-tool lookup service agnes-atlassian-api-token username "$ATLASSIAN_EMAIL")`. Windows: read `secrets.env`, then `cmdkey /list:agnes-atlassian-api-token` — if entry exists, print "Atlassian cred entry found — verify in your real terminal before re-running setup." and let me confirm rather than auto-skipping. If the verify call (either probe) returns 200, STOP with the "Already connected" line. Continue to step 1 only when secrets.env is missing OR keychain lookup fails OR BOTH probes return non-200. Treat 401 from either probe as "real auth failure — token is bad" and skip the second probe. +1. Ask me for my Atlassian Cloud site URL (looks like https://.atlassian.net) and the email I sign in with. Site URL and email are NOT secrets — fine to type into chat. Don't proceed until I've given you both. +2. Open the Atlassian API tokens page in my default browser — use your Bash tool: `open https://id.atlassian.com/manage-profile/security/api-tokens` on macOS, `xdg-open ...` on Linux/WSL, or `Start-Process ...` on Windows. Detect OS first. If I land on a generic profile page, tell me: avatar (top right) → Manage account → Security → "Create and manage API tokens". +3. Tell me to click "Create API token" (NOT "Create API token with scopes" unless I specifically need fine-grained — one-line trade-off: scoped tokens are limited per project but expire and need rotation; unscoped is simplest for personal use). Label it "Claude Code — Agnes", click Create, copy the token. Warn me it is shown ONCE. +4. Important: do NOT ask me to paste the token into the chat. Prepare a helper script for me to run in my real terminal, with my email and site URL baked in as literals (so they're not re-prompted at runtime): + a. Use the Write tool to create ~/.claude/agnes/bin/store-atlassian.sh on macOS/Linux (or .ps1 on Windows). chmod 700. The script must (i) reject obviously-truncated tokens via a length floor, (ii) NORMALIZE the base URL so the verify call hits a real endpoint, and (iii) verify the credentials against the live Atlassian API — trying Jira first, then Confluence on 404 — BEFORE writing anything to the keychain. The length guard exists because Atlassian's "shown ONCE" copy panel commonly truncates if the user click-copies instead of using the panel's Copy button — silently storing a 43-char fragment then discovering it later is the failure mode we're avoiding. The URL-normalization + product-fallback exists because `/rest/api/3/myself` only lives under Jira and returns 404 on Confluence-only sites (and vice-versa for `/wiki/rest/api/user/current`); previously a perfectly valid token paired with a Confluence-only URL or a URL the user pasted with a `/wiki` or trailing slash would 404 here and the prompt would falsely report the token as broken. Body for macOS: + #!/usr/bin/env bash + set -e + EMAIL='' + BASE_URL='' + read -srp 'Paste Atlassian API token (hidden): ' t; echo + + # Guard 1 — Atlassian Cloud tokens are typically 192+ chars; sub-100 + # means a truncated copy. Bail before touching the keychain. + tlen=$(printf %s "$t" | wc -c | tr -d ' ') + if [ "$tlen" -lt 100 ]; then + echo "Token looks too short ($tlen chars) — copy the full value via the Copy button on the Atlassian token page. Aborting." >&2 + unset t + exit 1 + fi + + # Guard 2 — normalize the site root: strip a trailing slash, then a + # trailing /wiki if present, so $BASE_URL is the bare site root. + # `$BASE_URL/rest/api/3/myself` (Jira) and `$BASE_URL/wiki/rest/api/user/current` + # (Confluence) both resolve correctly from the same normalized value. + BASE_URL="${BASE_URL%/}" + BASE_URL="${BASE_URL%/wiki}" + + # Guard 3 — verify against the live API before storing. Try Jira first + # (most sites have it), fall back to Confluence on 404 only. On 401 + # we stop immediately: the token itself is bad, no point probing the + # other product. Anything else (5xx, network) also aborts. + tmp=$(mktemp) + product=jira + status=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$EMAIL:$t" "$BASE_URL/rest/api/3/myself" || true) + if [ "$status" = "404" ]; then + product=confluence + status=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$EMAIL:$t" "$BASE_URL/wiki/rest/api/user/current" || true) + fi + if [ "$status" != "200" ]; then + if [ "$status" = "401" ]; then + echo "API verification failed (HTTP 401 — token rejected by Atlassian). The token is either wrong, revoked, or paired with the wrong email. Aborting without storing." >&2 + elif [ "$status" = "404" ]; then + echo "API verification failed (HTTP 404 on both Jira and Confluence probes). The site URL '$BASE_URL' is reachable but exposes neither product to this token — double-check the URL (it should be your Atlassian Cloud site root, e.g. https://yourorg.atlassian.net) or that your account has access to Jira or Confluence on this site. Aborting without storing." >&2 + else + echo "API verification failed (HTTP $status). Aborting without storing." >&2 + fi + cat "$tmp" >&2 2>/dev/null || true + rm -f "$tmp"; unset t + exit 1 + fi + display=$(python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("displayName","?"))' < "$tmp") + rm -f "$tmp" + + # Verified — write token to Keychain + URL/email to secrets.env. + security add-generic-password -U -s 'agnes-atlassian-api-token' -a "$EMAIL" -w "$t" + umask 077; mkdir -p ~/.claude/agnes + printf 'ATLASSIAN_EMAIL=%s\\nATLASSIAN_BASE_URL=%s\\n' "$EMAIL" "$BASE_URL" > ~/.claude/agnes/secrets.env + chmod 600 ~/.claude/agnes/secrets.env + unset t + echo "Stored ($product). Verified as $display." + + Linux variant: replace `security add-generic-password ...` with `printf %s "$t" | secret-tool store --label='Agnes Atlassian token' service agnes-atlassian-api-token username "$EMAIL"`. All three guards (length floor, URL normalization, Jira-then-Confluence verification) stay identical — they run before the storage call. Windows .ps1: same control flow — `Read-Host -AsSecureString`, convert via `Marshal::PtrToStringAuto`, check `$t.Length -lt 100`, then `$BASE_URL = $BASE_URL.TrimEnd('/').TrimEnd('/wiki')` (or `if ($BASE_URL.EndsWith('/wiki')) { $BASE_URL = $BASE_URL.Substring(0, $BASE_URL.Length - 5) }`), `try { Invoke-RestMethod -Uri "$BASE_URL/rest/api/3/myself" -Authentication Basic -Credential (New-Object PSCredential($EMAIL, $secureToken)) } catch { if ($_.Exception.Response.StatusCode.value__ -eq 404) { Invoke-RestMethod -Uri "$BASE_URL/wiki/rest/api/user/current" -Authentication Basic -Credential ... } else { throw } }` — write to `cmdkey` + `secrets.env` only after a 200 lands from either probe. + b. Tell me to open a real terminal (not Claude Code's `!`) and run `bash ~/.claude/agnes/bin/store-atlassian.sh` (or `pwsh ~/.claude/agnes/bin/store-atlassian.ps1` on Windows). The script will wait silently at the hidden prompt. + c. Walk me through clipboard order: copy the launcher first, paste in terminal, Enter (terminal waiting). Switch to the Atlassian tab, copy the token from step 3 — use the panel's "Copy" button, NOT click-and-drag (which often truncates). Switch back to terminal, paste at the silent prompt, Enter. The script will print "Stored. Verified as ." on success, or fail loudly with the exact reason (too short / HTTP 401 / etc.) without writing anything. +5. Register the on-demand Atlassian MCP under .claude/mcp/atlassian referencing the stored credentials (read token from keychain via `security find-generic-password -s 'agnes-atlassian-api-token' -w` at MCP startup). +6. The store script already verified the token end-to-end. If I want a second redacted readback later, hit `GET $BASE_URL/rest/api/3/myself` (Jira) or `GET $BASE_URL/wiki/rest/api/user/current` (Confluence) — try Jira first, fall back to Confluence on 404, same shape as the store script's verify. Print just displayName + accountId — never the token. +7. Remind me how to revoke: same API tokens page on Atlassian, plus `security delete-generic-password -s 'agnes-atlassian-api-token'` locally (macOS) / `secret-tool clear service agnes-atlassian-api-token` (Linux) / `cmdkey /delete:agnes-atlassian-api-token` (Windows).""" diff --git a/app/web/router.py b/app/web/router.py index fda4bcf..c8c3152 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -23,7 +23,9 @@ from app.instance_config import ( get_instance_name, get_instance_subtitle, get_datasets, get_theme, get_corporate_memory_config, get_home_route, get_gws_oauth_credentials, get_home_automode_visibility, + get_instance_admin_email, ) +from app.web.connector_prompts import all_connector_prompts from src.repositories.sync_state import SyncStateRepository from src.repositories.sync_settings import SyncSettingsRepository from src.repositories.knowledge import KnowledgeRepository @@ -403,11 +405,21 @@ def _build_context( server_host = request.url.netloc ca_pem = _read_agnes_ca_pem() + # Connector prompts wired through so step 9 inlines the same text + # the /home tiles render. all_connector_prompts() reads operator + # GWS OAuth config so the GCP-frictionless branch fires when the + # admin has provisioned a shared client_id+secret. + _connector_prompts = all_connector_prompts( + gws_oauth=get_gws_oauth_credentials(), + instance_admin_email=get_instance_admin_email(), + ) + setup_instructions_lines = resolve_lines( _wheel_filename, plugin_install_names=[], server_host=server_host, ca_pem=ca_pem, + connector_prompts=_connector_prompts, ) ctx = { @@ -431,6 +443,20 @@ def _build_context( # /home connector prompt. {} when unset → template falls back # to manual `gws auth setup`. See app.instance_config docstring. "gws_oauth": get_gws_oauth_credentials(), + # Operator-facing contact email used by the /home GWS connector + # tile's "Email admin" mailto button. Empty string hides the + # button — template guards with `{% if instance_admin_email %}`. + "instance_admin_email": get_instance_admin_email(), + # Resolved connector setup prompts — single source of truth for + # both the /home "Copy prompt" tiles and the main setup script + # (app/web/setup_instructions.py inlines them in step 9). The + # gws prompt branches on `gws_oauth.configured` so both surfaces + # render the operator-provisioned shortcut when credentials are + # set, and the manual GCP walkthrough when they're not. + "connector_prompts": all_connector_prompts( + gws_oauth=get_gws_oauth_credentials(), + instance_admin_email=get_instance_admin_email(), + ), # Whether /home renders the "Step 3 — turn on auto-accept mode" # install-block. Operator can hide it via AGNES_HOME_SHOW_AUTOMODE=0 # for cautious rollouts; same content stays on /setup-advanced. @@ -826,9 +852,27 @@ async def catalog( @router.get("/corporate-memory", response_class=HTMLResponse) async def corporate_memory( request: Request, - user: dict = Depends(get_current_user), + user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): + """Corporate Memory web view — admin-only. + + The page route gates on ``require_admin``; non-admin users see 403. + The Memory nav link in `_app_header.html` and the corporate-memory + widget on `/dashboard` are correspondingly hidden behind + ``{% if session.user.is_admin %}`` guards (defence in depth — the + backend is the authoritative gate). + + **Asymmetry**: the underlying ``/api/memory/*`` endpoints stay on + ``get_current_user`` (not ``require_admin``). CLI / agent flows that + POST a knowledge item or read ``/api/memory`` keep working for any + authenticated user. The gating here is web-UI-only — the API is the + surface the agent rails care about (`agnes` CLI, knowledge-extract + pipeline), and locking it down would break the corporate-memory + feature outright. Operators who want to relax the web-UI gate can + either grant Admin to those users or revert this route to + ``get_current_user`` in their fork. + """ repo = KnowledgeRepository(conn) items = repo.list_items(statuses=["approved", "mandatory"], limit=100) diff --git a/app/web/setup_instructions.py b/app/web/setup_instructions.py index 978d703..9fa9477 100644 --- a/app/web/setup_instructions.py +++ b/app/web/setup_instructions.py @@ -367,18 +367,18 @@ def _diagnose_lines(*, diagnose_num: str) -> list[str]: Putting it last (instead of right after `whoami`) means it doubles as a server-health smoke test that runs once everything else is in place, - not as a gate before them. It is the last step before Confirm — the - whole prompt is non-interactive. + not as a gate before them. The bundled `agnes skills` knowledge base (markdown documents listable via `agnes skills list` / readable via `agnes skills show `) is - no longer surfaced from this prompt: discovery happens organically - when CLAUDE.md or another skill references a specific entry (see the + intentionally NOT surfaced as its own setup step (#242 dropped that + interactive prompt). Discovery happens organically when CLAUDE.md or + another skill references a specific entry (see the `agnes skills show agnes-data-querying` mention in the CLAUDE.md template's BigQuery section). Bulk-copying every skill into - `~/.claude/skills/agnes/` at setup time was an interactive opinion - question with no obvious right answer; on-demand lookup is the - one-size-fits-all default. + `~/.claude/skills/agnes/` at setup time was an opinion question with + no obvious right answer; on-demand lookup is the one-size-fits-all + default. """ return [ "", @@ -394,6 +394,69 @@ def _diagnose_lines(*, diagnose_num: str) -> list[str]: ] +def _connectors_block( + step_num: str, + connector_prompts: dict[str, str], + *, + confirm_step_num: str, +) -> list[str]: + """Per-connector interactive ask + inline prompt. Last interactive + step before Confirm. + + Defaults to install (Y) — the user has to actively type "no" to skip. + Rationale: when the operator provisions a shared GWS OAuth app + (AGNES_GWS_CLIENT_ID + AGNES_GWS_CLIENT_SECRET), the GWS path runs + in ~2 minutes with zero GCP clickops; Asana + Atlassian only need + the user's own API token from their respective developer consoles — + which they need either way to use the tool. Default-install matches + "wire everything up" — the common path. + + Each connector's prompt body comes verbatim from + ``app/web/connector_prompts.py`` — same source the /home tile cards + render, so editing one place updates both surfaces. The prompts + themselves are idempotent (each has its own step-0 precheck) so + re-runs short-circuit cleanly. + + Connectors are presented in the order they appear in the + ``connector_prompts`` dict — the caller controls ordering by how + they build the dict. Today that's asana → gws → atlassian, matching + the /home tile order. + """ + lines = [ + "", + f"{step_num}) Connect the user's tools (last interactive ask before Confirm):", + "", + " For each tool below, ask the user verbatim: \"Set up now? (Y/n)\".", + " Treat empty/Enter as YES — the default is install. Only skip when the", + " user types an explicit \"no\" / \"n\" / \"skip\". Wait for each answer", + " before moving to the next. The prompts below are idempotent and", + " safe to re-run if anything goes sideways.", + "", + ] + # Stable ordering — let the dict iteration order win (Python 3.7+ insertion-ordered). + # Caller controls which connectors are present; we just iterate. + sub_letters = "abcdefghij" + for i, (slug, body) in enumerate(connector_prompts.items()): + # Display name is sourced from the registry so the ask phrasing stays + # in lockstep with what /home renders. + from app.web.connector_prompts import CONNECTORS # local import — avoids module-load cycles + display = next((c.display_name for c in CONNECTORS if c.slug == slug), slug) + desc = next((c.description for c in CONNECTORS if c.slug == slug), "") + lines.append(f" {sub_letters[i]}) {display} — {desc}") + lines.append(f" Ask: \"Set up {display} now? (Y/n)\"") + lines.append(" If yes (default) — follow this inline prompt verbatim:") + lines.append("") + # Indent the prompt body two spaces so it visually nests under the + # ask. Empty lines stay empty (no trailing whitespace). + for body_line in body.split("\n"): + lines.append(f" {body_line}" if body_line else "") + lines.append("") + lines.extend([ + f" After all asks (regardless of answers) continue to step {confirm_step_num}.", + ]) + return lines + + def _finale_lines(*, confirm_step_num: str, has_ca: bool) -> list[str]: """Final Confirm step. Bullets it asks the assistant to report on must only reference earlier steps that were actually emitted, otherwise the @@ -402,18 +465,20 @@ def _finale_lines(*, confirm_step_num: str, has_ca: bool) -> list[str]: the trust block ran (`has_ca`). The marketplace clone bullet is unconditional now — preflight + marketplace are always emitted (Fix B in the 2026-05-10 init-report response). Init + catalog + diagnose + - version always render, so their bullets are unconditional.""" + skills + connectors + version always render, so their bullets are + unconditional.""" bullets = [ " - `agnes --version` output", " - First few lines of `agnes catalog` (tables you can see)", " - Confirmation that `./CLAUDE.md` and `./AGNES_WORKSPACE.md` exist", " - Confirmation that `./.claude/settings.json` contains SessionStart/End hooks", " - The `agnes diagnose` overall status", + " - Whether skills were copied or left on-demand", " - Confirmation that `~/.agnes/marketplace/.git/` exists " - "(the marketplace clone) and that any plugins currently in the " - "served stack installed cleanly", - " - Reminder to scroll to the connector cards on /home and connect " - "Asana / Google Workspace / Atlassian (those run separately from this script)", + "(the marketplace clone) and that any granted plugins installed", + " - Which connectors got set up: Asana, Google Workspace, and " + "Atlassian — installed or declined for each (the per-connector ask " + "in the previous step drives this)", ] if has_ca: bullets.append( @@ -478,26 +543,13 @@ def _marketplace_block( ) -> list[str]: """Build the marketplace + plugin-install block. - `plugin_install_names` is the user's current *served stack* as - computed by `src/marketplace_filter.py:resolve_user_marketplace` — - i.e.:: - - (admin_RBAC ∩ /marketplace subscriptions) - ∪ system-mandatory plugins (admin-pinned, auto-applied) - ∪ Flea market installs (skills/agents bundled, plugins standalone) - - May be empty: the served stack is curated by the user on the - `/marketplace` page (admin grants are eligibility only — the user - opts in via "Add to stack") plus whatever the admin pinned as - system-mandatory plus the user's own Flea market picks. A brand-new - account with no system plugins and no curation has an empty stack - until something lands in any of those three buckets. - - Registering the marketplace clone is unconditional regardless — - Claude Code learns about the `agnes` marketplace at bootstrap, and - the moment the served stack becomes non-empty, the user's next - `/update-agnes-plugins` run installs the diff. No need to re-run - setup when the stack changes server-side. + `plugin_install_names` may be empty: registering the per-user + marketplace clone with Claude Code is useful even when the operator + has zero plugin grants, because it pre-wires the SessionStart hook + and the grant flow — admin grants land on the next Claude Code + session without re-running setup. The block copy adapts for the + empty case so the comment-bullet doesn't promise plugin installs + that won't happen. `step_num` is parameterized because step ordering shifted between layouts (this block now runs before diagnose/skills, so it's step 5 @@ -553,41 +605,29 @@ def _marketplace_block( """ has_plugins = bool(plugin_install_names) header = ( - "Register the Agnes Claude Code marketplace and install your current stack:" + "Register the Agnes Claude Code marketplace and install plugins:" if has_plugins - else "Register the Agnes Claude Code marketplace (your stack is empty for now):" + else "Register the Agnes Claude Code marketplace (no plugins granted yet):" ) bullet_5 = ( - " # 5. install every plugin currently in your served stack" + " # 5. install every plugin listed in the served manifest" if has_plugins - else " # 5. (your served stack is empty right now — nothing to install yet)" + else " # 5. (no plugins to install — your account has zero grants)" ) if has_plugins: trailer = [ " These run non-interactively. After they finish, tell the user to /exit", - " and run `claude` again so the new plugins load.", - "", - " Stack curation lives on the server — visit /marketplace to add or", - " remove items (admin-granted opt-ins, system plugins your org pinned,", - " and uploads from the Flea market tab). The SessionStart hook checks", - " for server-side changes on every Claude Code session and, when it", - " detects a diff, prompts you to run `/update-agnes-plugins` inside", - " Claude Code to apply it. No silent auto-install at session start —", - " the slash command runs full reconcile with output visible in the", - " transcript, under your control.", + " and run `claude` again so the new plugins load. From then on, the", + " SessionStart hook keeps the marketplace clone in sync via", + " `agnes refresh-marketplace --quiet` on every Claude Code session.", ] else: trailer = [ - " Your served stack is empty right now — nothing to install yet.", - " Registering the marketplace clone anyway pre-wires Claude Code so", - " future picks land cleanly: visit /marketplace to add plugins to", - " your stack (admin-granted opt-ins, uploads from the Flea market", - " tab), or wait for your admin to pin something as system-mandatory.", - "", - " When your stack becomes non-empty, the SessionStart hook detects", - " the change on the next Claude Code session and prompts you to run", - " `/update-agnes-plugins` inside Claude Code to install the new", - " items. No need to re-run this setup script.", + " Your account has no plugin grants right now, but registering the", + " marketplace anyway pre-wires the SessionStart hook. When an admin", + " grants you a plugin later, `agnes refresh-marketplace` (run by the", + " hook on every Claude Code session) will install it automatically —", + " no need to re-run this setup script.", ] return [ "", @@ -680,7 +720,7 @@ def _preamble_lines(*, has_ca: bool) -> list[str]: return lines -def _step_numbers() -> dict[str, str]: +def _step_numbers(*, has_connectors: bool = True) -> dict[str, str]: """Compute the step numbers for the unified layout. Returns a dict keyed by logical step name; values are stringified @@ -689,14 +729,28 @@ def _step_numbers() -> dict[str, str]: Steps (always emitted): install (1), init (2), catalog (3), preflight (4), marketplace (5), mcp_servers (6), diagnose (7), - confirm (8). Preflight + marketplace + mcp_servers are all always-on: - - Marketplace registration is useful even with an empty served - stack (future admin grants / system pins / Flea installs land - cleanly without re-running setup). + connectors (8), confirm (9). Preflight + marketplace + mcp_servers + + connectors are all always-on: + - Marketplace registration is useful even when the operator has + zero plugin grants (SessionStart hook reconciles future grants + automatically). - Atlassian MCP registration is unattended-safe (hosted Remote MCP with Claude Code-managed OAuth) and applies to every analyst whose work touches Jira/Confluence — high enough hit rate to justify default-on. + - Connectors (Asana / GWS / Atlassian) are per-connector default-yes + asks — the user can decline each individually, so always-emitting + the block costs nothing for users who skip everything. + + The interactive "Skills" step that previously sat between diagnose + and Confirm was deleted in #242 — on-demand `agnes skills show + ` is the one-size-fits-all default; bulk-copying every skill + into ``~/.claude/skills/agnes/`` was an opinion question without an + obvious right answer. + + `has_connectors` is kept as a parameter for future flexibility + (default True). When set False, the connectors step is skipped and + Confirm shifts down to step 8. Step-0 (TLS trust block) sits outside this numbering — it is gated by has_ca and has its own "0)" header rendered inside the trust block @@ -707,12 +761,16 @@ def _step_numbers() -> dict[str, str]: marketplace = str(n); n += 1 mcp_servers = str(n); n += 1 diagnose = str(n); n += 1 + connectors = str(n) if has_connectors else "" + if has_connectors: + n += 1 confirm = str(n) return { "preflight": preflight, "marketplace": marketplace, "mcp_servers": mcp_servers, "diagnose": diagnose, + "connectors": connectors, "confirm": confirm, } @@ -723,6 +781,7 @@ def resolve_lines( plugin_install_names: list[str] | None = None, server_host: str = "", ca_pem: str | None = None, + connector_prompts: dict[str, str] | None = None, ) -> list[str]: """Return the template lines with server-side placeholders substituted. @@ -730,13 +789,6 @@ def resolve_lines( `{server_url}` and `{token}` as placeholders for click-time JS substitution (or for `render_setup_instructions()` below). - The layout is the same regardless of `plugin_install_names`: install - (1), init (2), catalog (3), preflight (4), marketplace (5), - mcp_servers (6), diagnose (7), confirm (8). The marketplace block's - copy adapts to an empty served stack but the step is always emitted - so future stack changes (admin grants, system pins, Flea installs) - land cleanly without re-running setup. - `ca_pem` (PEM-encoded fullchain of the Agnes server's TLS cert) gates the cross-platform step-0 trust-bootstrap block AND switches step 1 to the curl-then-local-install pattern AND switches step 5 to the @@ -744,6 +796,13 @@ def resolve_lines( needs the bootstrap (typically: skip for publicly-trusted certs like Let's Encrypt, emit for self-signed or private corp CA). + `connector_prompts` is a dict {slug: prompt_text} sourced from + :func:`app.web.connector_prompts.all_connector_prompts`. When empty + or None we fall back to the module's defaults (no operator GWS OAuth + credentials baked in — the unconfigured GCP-walkthrough branch + renders). Both the /home tiles and this setup script consume the + same dict so the prompt text stays in lockstep across surfaces. + Fallback: callers pass `"agnes.whl"` when no wheel is present on disk. The resulting URL (`/cli/wheel/agnes.whl`) will 404 at download time, but the instruction text still renders so operators can see the snippet shape @@ -752,10 +811,22 @@ def resolve_lines( names = list(plugin_install_names or []) has_ca = bool(ca_pem and ca_pem.strip()) - # Step layout — single fixed shape; `_step_numbers` returns the - # renumbered step labels in one place so the layout is unambiguous - # and trivially extendable when a future step is added. - steps = _step_numbers() + # Lazy default for connector_prompts. Imported inline so the + # setup_instructions module stays import-light when callers don't + # actually emit the connectors block (tests that hit a single helper + # don't pay the cost of loading the prompt strings). + if not connector_prompts: + from app.web.connector_prompts import all_connector_prompts + connector_prompts = all_connector_prompts() + + # Step layout. Preflight + marketplace + MCP go BEFORE diagnose; + # connectors is the LAST interactive ask before Confirm — once plugins + # + MCP + diagnose are settled, the only remaining work is plugging + # the user's tools (Asana / GWS / Atlassian). The Skills step that + # used to sit between diagnose and Confirm was deleted in #242 + # (on-demand `agnes skills show ` is the default; + # bulk-copying skills was an opinion question). + steps = _step_numbers(has_connectors=True) lines: list[str] = [] if has_ca: @@ -766,9 +837,13 @@ def resolve_lines( lines.extend(_preflight_block(steps["preflight"])) # 4 lines.extend(_marketplace_block(names, step_num=steps["marketplace"])) # 5 lines.extend(_mcp_servers_block(steps["mcp_servers"])) # 6 - # Diagnose runs AFTER marketplace + MCP wiring so it doubles as a - # final smoke test, not a pre-install gate. lines.extend(_diagnose_lines(diagnose_num=steps["diagnose"])) # 7 + # Connectors are the LAST interactive ask before Confirm. Per-connector + # default-yes — empty/Enter is install, explicit "no" skips. + lines.extend(_connectors_block( + steps["connectors"], connector_prompts, + confirm_step_num=steps["confirm"], + )) lines.append("") lines.extend(_finale_lines( confirm_step_num=steps["confirm"], @@ -789,19 +864,21 @@ def render_setup_instructions( plugin_install_names: list[str] | None = None, server_host: str = "", ca_pem: str | None = None, + connector_prompts: dict[str, str] | None = None, ) -> str: """Render the setup instructions as a single string. Used server-side for tests and any non-JS rendering path. The browser clipboard flow uses the JS renderer embedded in the Jinja partial; both must produce byte-identical output for a given (server_url, token, - wheel, plugins, host, ca_pem) tuple. + wheel, plugins, host, ca_pem, connector_prompts) tuple. """ lines = resolve_lines( wheel_filename, plugin_install_names=plugin_install_names, server_host=server_host, ca_pem=ca_pem, + connector_prompts=connector_prompts, ) text = "\n".join(lines) return text.replace("{server_url}", server_url).replace("{token}", token) diff --git a/app/web/templates/_app_header.html b/app/web/templates/_app_header.html index 74fbac6..75fc9eb 100644 --- a/app/web/templates/_app_header.html +++ b/app/web/templates/_app_header.html @@ -24,8 +24,14 @@ Home Marketplace Data Packages - Memory + {# Memory + Admin menu: both admin-only. Backend gates the routes + themselves via require_admin (see app/web/router.py for + /corporate-memory + /corporate-memory/admin + /admin/*), so + hiding the links is purely a visibility tidy-up — non-admins + who deep-link still get a 403 from the route handler. Single + guard wraps both for clarity. #} {% if session.user.is_admin %} + Memory {% set _admin_active = _path.startswith('/admin/tables') or _path.startswith('/admin/tokens') or _path.startswith('/admin/users') or _path.startswith('/admin/groups') or _path.startswith('/admin/access') or _path.startswith('/admin/server-config') or _path.startswith('/admin/agent-prompt') or _path.startswith('/admin/workspace-prompt') or _path.startswith('/admin/marketplaces') or _path.startswith('/admin/store') or _path.startswith('/admin/scheduler-runs') or _path.startswith('/activity') %}
+ + {# P0-1 — terminal-howto disclosure. Two panels (unix / windows) + that flip in lockstep with the install-cmd OS tabs above. #} +
+ Don't have a terminal open? — show how +
+

macOS: press + Space, type Terminal, press Enter.

+

Linux: press Ctrl + Alt + T (most distros), or open the apps menu and search "Terminal".

+

WSL: open the Windows Start menu, search "Ubuntu" (or your installed distro), press Enter.

+

Paste the command above with Ctrl + Shift + V (Linux/WSL) or + V (macOS), then press Enter.

+
+ +
+
+ {% if home_automode.show %}
-
Step 2 — install Agnes from inside Claude Code
+
Step 2 — turn on auto-mode (recommended before Step 3)
+
+ In the Claude Code session you just signed into, press Shift + Tab. Claude cycles modes: default → auto-accept edits → plan mode → default; the footer shows ⏵⏵ when auto-accept is on. On the first cycle to auto-accept, Claude asks whether to make it the default — say yes. Closed the session already? Run claude again, then press Shift + Tab. +

+ Want full auto-approve including Bash? See YOLO mode on /setup-advanced — pairs --dangerously-skip-permissions with a reviewed ~/.claude/settings.local.json allowlist. Skip if you're not sure. +
+
+ {% endif %} + +
+
Step 3 — install Agnes from inside Claude Code

- Click the button — Agnes mints a 90-day personal access token, copies a ready-to-paste setup script to your clipboard, and the script bootstraps everything in ~/Agnes when you paste it into Claude Code. + Click the button — {{ instance_name or "Agnes" }} creates a 90-day login token, copies a ready-to-paste setup script to your clipboard, and a follow-up popup tells you exactly where to paste it. The script bootstraps everything in ~/Agnes once Claude Code receives it.

{% endif %} + {# P1-6 — auto-detect badge is the PRIMARY affordance after the + install-script copy: agnes-init's first POST to + /api/me/onboarded flips state automatically and the page + reloads. The manual "Mark me as onboarded" button below it + stays as a fallback when auto-flip never lands. #} + {% if not onboarded %} +
+ + Waiting for your first agnes pull — auto-detects within ~30 s of the setup script finishing. +
+ {% endif %} + {# Self-mark control lives inside the blue hero in both states. When onboarded, the install steps above are hidden so this is the only thing rendered below the lead paragraph. #} @@ -977,11 +1292,13 @@
{% if onboarded %} - {# User-controlled minimize toggle for Step 3 + Connect-your-tools. - Default OFF (sections render flat). State persists in + {# User-controlled minimize toggle for Connect-your-tools. + Default OFF (section renders flat). State persists in localStorage so the choice is per-device. The agnes-init auto-flip of users.onboarded never triggers a collapse on - its own — only an explicit click here does. #} + its own — only an explicit click here does. The auto-mode + block used to be a peer collapsible (`step3`); it now lives + inside the install-hero as Step 2 and is not collapsible. #}
- {% if home_automode.show %} -
- - - Step 3 — auto-accept mode (reference) - - -
-
- ⚡️ -
-

Step 3 — turn on auto-accept mode (recommended)

-

By default, Claude asks permission before every action. Auto-accept mode auto-approves file edits while keeping shell commands and other side-effect tools gated — fast for daily work, still safe.

-
-
-
-
-
1
-
- One session - Inside any active Claude Code session, press Shift + Tab to cycle modes:
- default → auto-accept edits → plan mode → default. The footer shows ⏵⏵ when auto-accept is on. -
-
-
-
2
-
- Persist across sessions - Add to ~/.claude/settings.json: -
{
-  "permissions": {
-    "defaultMode": "acceptEdits"
-  }
-}
-
-
-
-

- Want full auto-approve including Bash? See YOLO mode on /setup-advanced — pairs --dangerously-skip-permissions with a reviewed ~/.claude/settings.local.json allowlist. Skip if you're not sure. -

-
-
- {% endif %} + {# Auto-mode card used to live here as a `
` reference block; + moved into the install-hero as the new Step 2 so users enable it + BEFORE Step 3's install runs ~20 commands. Gated by the same + `home_automode.show` flag at the call site. #}
@@ -1045,183 +1323,83 @@
-
Asana
+ {# P0-3 — title row + time-badge + post-copy hint. The hint + container is rendered hidden; the JS reveals it after the + copy succeeds, then auto-hides after 8 s. #} +
+ Asana + ~5 min · self-serve +
Read tasks and projects, comment, create updates — Claude works alongside your project boards without leaving the terminal.
- + +
+ ✅ Copied. Now paste into Claude Code — run claude in your terminal, then paste & press Enter. +
Show prompt -
Set up an Asana personal access token for Claude Code. Walk me through it step by step. - -Ground rules: this is idempotent — safe to re-run, the precheck below short-circuits when Asana is already wired up. If any step fails with an unfamiliar error, paste the exact error back and stop. Do NOT improvise around TLS errors by disabling verification (`-k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`, `git -c http.sslVerify=false`, etc.) — those hide the real problem. - -0. Precheck — skip the rest if Asana is already connected. Detect my OS, then look up an existing keychain entry under the service name `agnes-asana-pat` and verify it against Asana's API. macOS: `t=$(security find-generic-password -s 'agnes-asana-pat' -w 2>/dev/null) && curl -fsS -H "Authorization: Bearer $t" https://app.asana.com/api/1.0/users/me | jq -r '.data | "Already connected as \(.name) (\(.workspaces | length) workspace(s)). Skipping setup."' && exit 0`. Linux: `t=$(secret-tool lookup service agnes-asana-pat username "$USER" 2>/dev/null) && ...same curl...`. Windows PowerShell: `$cred = cmdkey /list:agnes-asana-pat 2>$null; if ($LASTEXITCODE -eq 0) { Write-Host "Asana cred entry found — verify in your terminal before re-running setup." }` (Windows can't read the password back without a CredentialManager module — print a hint and let me confirm). If the verify call returns 200, print the one-line "Already connected" message and STOP. Only continue to step 1 when no cred exists OR the cached token returns 401. -1. Open the Asana developer tokens page in my default browser — use your Bash tool: `open https://app.asana.com/0/developer-console/tokens` on macOS, `xdg-open https://app.asana.com/0/developer-console/tokens` on Linux/WSL, or `Start-Process https://app.asana.com/0/developer-console/tokens` on Windows. Detect OS first. If that URL doesn't render the tokens UI (rare), tell me to click my avatar (top right) → Settings → "Apps" tab → "Manage Developer Apps" → Personal access tokens. -2. Tell me to click "+ New access token", name it "Claude Code — Agnes", and click "Create token". Warn me the token is shown ONCE and Asana PATs do not expire — I'd need to revoke it from the same page if it leaks. -3. Important: do NOT ask me to paste the token into the chat. Chat input is saved to ~/.claude/projects/.../*.jsonl. Instead, prepare a tiny helper script for me to run in my real terminal: - a. Detect my OS. Use the Write/Edit tool (NOT a shell here-doc that prints the body) to create ~/.claude/agnes/bin/store-asana.sh on macOS/Linux, or ~/.claude/agnes/bin/store-asana.ps1 on Windows. chmod 700 the file. Body for macOS: - #!/usr/bin/env bash - set -e - read -srp 'Paste Asana token (hidden): ' t; echo - security add-generic-password -U -s 'agnes-asana-pat' -a "$USER" -w "$t" - unset t - echo 'Stored in macOS Keychain.' - Linux variant: same shape but `printf %s "$t" | secret-tool store --label='Agnes Asana PAT' service agnes-asana-pat username "$USER"`. Windows .ps1: `$t = Read-Host 'Paste Asana token' -AsSecureString; $p = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($t)); cmdkey /generic:agnes-asana-pat /user:$env:USERNAME /pass:$p > $null; Remove-Variable p,t; 'Stored.'` - b. Tell me to open a real terminal (Terminal.app / iTerm / WSL / PowerShell — NOT Claude Code's `!` prefix, which has no TTY) and run `bash ~/.claude/agnes/bin/store-asana.sh` (or `pwsh ~/.claude/agnes/bin/store-asana.ps1` on Windows). The script will wait silently at the hidden prompt. - c. Walk me through the clipboard order: copy the launcher first, paste it in my terminal, press Enter (terminal now waiting). Switch to the Asana tab, copy the token from step 2. Switch back to terminal, paste at the silent prompt, press Enter. Token enters via stdin only — not shown on screen, not in shell history, not in clipboard at the moment Claude is involved. -4. After I report "Stored", verify by calling `curl -sS -H "Authorization: Bearer $(security find-generic-password -s 'agnes-asana-pat' -w)" https://app.asana.com/api/1.0/users/me | jq -r '.data | "\(.name) — \(.workspaces | length) workspace(s)"'` (macOS; Linux uses `secret-tool lookup` instead). Print only the one-line result. Never echo the token. -5. Remind me where the token is stored and how to revoke: in macOS Keychain Access search "agnes-asana-pat" or run `security delete-generic-password -s 'agnes-asana-pat'`; on Asana, revoke from the same developer-console page.
+ {# Asana prompt body sourced from app/web/connector_prompts.py + (`asana_prompt()`). Same string the main setup script + inlines in step 9, so the two surfaces stay in lockstep. #} +
{{ connector_prompts.asana }}
📚 -
Google Workspace
+ {# P0-3 + P1-8 — Google Workspace tile. Time badge warns when + the operator hasn't provisioned a shared OAuth app (forces + the user to set up GCP themselves, which is a ~20-min + clickops detour). The gating-note + email-admin button + appear in that same un-configured branch so the user has + a way out before copying. #} +
+ Google Workspace + {% if gws_oauth.configured %} + ~5 min · self-serve + {% else %} + ~20 min · admin help likely + {% endif %} +
Drive, Calendar, Gmail, Docs, Sheets, Chat — Claude reads and acts across your work account via the official gws CLI.
+ {% if not gws_oauth.configured %} +
+ Heads up: your Agnes admin hasn't provisioned a shared Google Cloud OAuth app yet, so this connector needs GCP project setup (creating an OAuth client, enabling APIs). It's fastest to ask your admin first — the button below pre-fills the email. +
+ {% endif %}
- + + {% if not gws_oauth.configured and instance_admin_email %} + + {% endif %} +
+ ✅ Copied. Now paste into Claude Code — run claude in your terminal, then paste & press Enter. +
Show prompt -
Set up Google Workspace access for Claude Code using the official `gws` CLI from https://github.com/googleworkspace/cli (install steps: README → Installation). The npm path is what we'll use because (a) it's the README's documented convenience path, (b) it works the same on macOS / Linux / WSL / Windows, and (c) it can run with zero admin rights when Node is managed by `nvm` (Unix) or `fnm` (Windows). - -Ground rules: this is idempotent — safe to re-run, the precheck below short-circuits when `gws` is already installed and authed. If any step fails with an unfamiliar error, paste the exact error back and stop — don't half-finish. Do NOT improvise around TLS errors by disabling verification (`-k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`, npm `strict-ssl=false`, etc.) — those mask the real problem. - -YOU run every command via your Bash tool. Do NOT print install commands and ask me to type them. Only stop and ask me when I have to (a) approve an OAuth consent screen in a browser, (b) make a product decision (Cloud project name), or (c) paste OAuth client credentials Google shows me. - -0. Precheck — skip the rest if Google Workspace is already connected. Run `command -v gws` AND `gws auth status` AND a low-impact verify call: `gws drive files list --params '{"pageSize": 1}' && gws chat spaces list --params '{"pageSize": 1}'`. If both succeed, the gws CLI is installed AND authed AND the Chat scope is present. Print "Already connected as <email from `gws auth status`> — Drive + Chat scopes verified. Skipping setup." and STOP. If `gws drive` succeeds but `gws chat` fails with 403/PERMISSION_DENIED, the user authed without `--full` previously — skip to step 6 (re-login with widened scopes), do NOT re-install. Only walk steps 1–5 (install + OAuth client setup) when `command -v gws` itself fails. - -1. Detect my OS (`uname -s` → Darwin / Linux, or PowerShell `$env:OS` → Windows_NT). On Linux check `grep -qi microsoft /proc/version` and treat WSL as Linux. - -2. Check `command -v gws` (or `Get-Command gws` on Windows). If `gws` is already installed, skip to step 5. - -3. Install Node.js 18+ to my user directory — no sudo, no UAC, no system package manager. - - Unix (macOS / Linux / WSL): - a. Check `command -v node && node --version` — if 18+ already, skip. - b. Otherwise install nvm into ~/.nvm: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash`. The installer writes to ~/.nvm and appends shellenv to ~/.bashrc / ~/.zshrc — no sudo. Source it for the current shell: `export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"`. - c. `nvm install --lts && nvm use --lts`. Verify `node --version` shows v20.x or v22.x. - - Native Windows (NOT WSL): - a. Check `node --version` — if 18+, skip. - b. Install fnm to user profile (no admin): run `winget install Schniz.fnm --scope user --accept-source-agreements --accept-package-agreements`. If winget triggers UAC, fall back to the manual zip from https://github.com/Schniz/fnm/releases/latest — extract `fnm.exe` to `$HOME\.local\bin\` and add that dir to my user PATH via `[Environment]::SetEnvironmentVariable('Path', "$env:Path;$HOME\.local\bin", 'User')`. - c. `fnm install --lts; fnm use lts-latest`. `fnm env --use-on-cd | Out-String | Invoke-Expression` to source it for the current shell. - -4. Install `gws` via npm — runs as my user because Node is managed by nvm/fnm, so the global prefix lives inside ~/.nvm/versions/node/<v>/lib/ (Unix) or ~/.fnm/.../lib/ (Windows). No sudo, no UAC, no `npm config set prefix` workaround needed. - - a. `npm install -g @googleworkspace/cli` (run via Bash tool). Wait for it. If npm fails (network, registry, peer-dep), report the exact stderr and pause — don't half-finish. - - b. nvm/fnm Node + npm-installed binaries land under ~/.nvm/versions/node/<v>/bin/ — only on PATH when nvm is sourced interactively. YOUR Bash tool runs non-interactive subshells that do NOT source ~/.zshrc or ~/.bashrc, so `gws` and `node` will appear "not found" on the very next call. Symlink them into ~/.local/bin (which is on PATH in every shell context) right after install: - `mkdir -p ~/.local/bin` - `ln -sf "$(command -v gws)" ~/.local/bin/gws` - `ln -sf "$(command -v node)" ~/.local/bin/node` - Run these while nvm/fnm is sourced in the same Bash call so `command -v` resolves correctly. On native Windows, copy `gws.cmd` from the npm prefix into `$HOME\.local\bin\` instead — symlinks need admin on Windows by default. - - c. Verify `gws --version` from a fresh `bash -c 'gws --version'` (deliberately non-interactive) — confirms the symlink path works for future tool calls. - -{% if gws_oauth.configured %}5. The Agnes operator has already provisioned a shared Google Workspace OAuth app for this instance. Skip `gws auth setup` entirely. Do NOT use environment variables (Claude Code's security layer redacts vars containing the substring "SECRET" from non-interactive subshells, so the env-var approach is unreliable). Instead, write the credentials directly to the file `gws auth status` reads as `credential_source`: - - Use the Write tool to create `~/.config/gws/client_secret.json` (or `%APPDATA%\gws\client_secret.json` on native Windows) with EXACTLY the schema Google Cloud Console exports — the gws CLI's Rust struct rejects partial files with "Invalid client_secret.json format: missing field 'project_id'". Both `installed.project_id` (numeric project number) and the URI fields are mandatory: - { - "installed": { - "client_id": "{{ gws_oauth.client_id }}", - "project_id": "{{ gws_oauth.project_id }}", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_secret": "{{ gws_oauth.client_secret }}", - "redirect_uris": ["http://127.0.0.1"] - } - } - - Then `mkdir -p ~/.config/gws && chmod 700 ~/.config/gws && chmod 600 ~/.config/gws/client_secret.json`. Verify by running `gws auth status` — it should report this file as `credential_source` without complaining about missing fields. The values identify the OAuth app, not me; treat the secret like a publishable bundle key, not a per-user credential. -{% else %}5. Run `gws auth setup` for me. This is a one-time Google Cloud project config; gcloud is NOT required (when gcloud is absent, `gws auth setup` walks through the manual OAuth flow). Open the URL it prints in my default browser, then walk me through each click because I am NOT a GCP admin: - a. Pick or create a Google Cloud project (free tier is fine). - b. Enable the APIs the connector needs: Google Drive API, Google Calendar API, Gmail API. Tell me each menu click. - c. Create an OAuth 2.0 client of type "Desktop app". - d. Copy the resulting client_id and client_secret. Paste them back into the terminal where `gws auth setup` is waiting. These identify the OAuth app — not the user — but still don't echo them back to me in chat.{% endif %} - -6. Run `gws auth login --full` (no `--readonly` flag — Agnes uses full read + write access across Drive / Calendar / Gmail / Sheets / Docs / Chat so the agent can actually create, edit, and send on my behalf). The `--full` flag widens the default scope picker; without it Chat / People / Tasks scopes are silently dropped. One env var the loopback redirect needs is OAUTHLIB_INSECURE_TRANSPORT — set it in the SAME Bash invocation that runs login: `OAUTHLIB_INSECURE_TRANSPORT={{ gws_oauth.oauthlib_insecure_transport if gws_oauth.configured else "1" }} gws auth login --full`. The CLI binds a local loopback server at http://127.0.0.1:8080 and prints an OAuth URL. - - Capture the URL from gws's stdout. Before opening the browser, append the Chat write scopes (`https://www.googleapis.com/auth/chat.spaces` and `https://www.googleapis.com/auth/chat.messages`) to the URL's `scope=` query parameter — `--full` includes the readonly Chat scopes but NOT the read+write ones, and `gws chat ... send` calls fail without them. Decode the existing scope list, append the two URLs space-separated, re-encode, then open. Python one-liner via Bash tool: - - `URL=$(printf '%s' "$URL" | python3 -c 'import sys,urllib.parse as u; q=u.urlparse(sys.stdin.read().strip()); p=u.parse_qs(q.query); s=set(p.get("scope",[""])[0].split()); s |= {"https://www.googleapis.com/auth/chat.spaces","https://www.googleapis.com/auth/chat.messages"}; p["scope"]=[" ".join(sorted(s))]; print(q._replace(query=u.urlencode(p, doseq=True, quote_via=u.quote)).geturl())')` - - Then open the rewritten URL programmatically — do NOT print it to chat. Markdown line-wrapping in chat corrupts the long scope query string when the user copies it. Use your Bash tool: macOS `open "$URL"`, Linux/WSL `xdg-open "$URL"`, Windows `Start-Process "$URL"`. Detect OS first. - - While the browser tab is loading, read each requested scope in plain language for me — full read + write across Drive, Calendar, Gmail, Chat, and the rest — so I know what I'm consenting to before I click Approve. Tell me I can revoke any time at https://myaccount.google.com/permissions if I change my mind. - - If `gws auth status` later shows Chat scopes missing (e.g. on a re-run where a stale token cached the previous scope set), `rm ~/.config/gws/token.json` (or `%APPDATA%\gws\token.json` on native Windows) and re-run this step — the OAuth flow re-prompts with the new scope list. - -7. Find where gws stored my credentials (`gws auth status` should show the path; typically ~/.config/gws/ on Unix, %APPDATA%\gws\ on Windows). chmod 600 on Unix; on native Windows, restrict ACLs to my user with `icacls "$creds_path" /inheritance:r /grant:r "$env:USERNAME:F"` — file is already in my user profile so this needs no admin. - -8. Verify with two low-impact reads, one per scope group: `gws drive files list --params '{"pageSize": 1}'` (Drive scope landed) and `gws chat spaces list --params '{"pageSize": 1}'` (Chat scope landed). Print only "Connected as <my email>" plus the file + space counts. Never echo tokens, file/message metadata, or scope strings to chat. - -9. Remind me how to revoke later: `gws auth logout` clears local creds; the OAuth grant also appears at https://myaccount.google.com/permissions for Google-side revocation.
+
{{ connector_prompts.gws }}
🎟️ -
Atlassian (Jira / Confluence)
+
+ Atlassian (Jira / Confluence) + ~7 min · self-serve +
Read and write Jira issues, search Confluence pages — Claude pulls ticket context and posts updates without leaving the workspace.
- + +
+ ✅ Copied. Now paste into Claude Code — run claude in your terminal, then paste & press Enter. +
Show prompt -
Set up Atlassian (Jira + Confluence) API access for Claude Code. Walk me through it step by step. - -Ground rules: this is idempotent — safe to re-run, the precheck below short-circuits when Atlassian is already wired up. If any step fails with an unfamiliar error, paste the exact error back and stop. Do NOT improvise around TLS errors by disabling verification (`-k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`, `git -c http.sslVerify=false`, etc.) — those hide the real problem. - -0. Precheck — skip the rest if Atlassian is already connected. The setup script stores email + base URL in `~/.claude/agnes/secrets.env` and the API token in the OS keychain under `agnes-atlassian-api-token`. Verify all three exist + auth works before reinstalling. macOS: `[ -r ~/.claude/agnes/secrets.env ] && . ~/.claude/agnes/secrets.env && t=$(security find-generic-password -s 'agnes-atlassian-api-token' -a "$ATLASSIAN_EMAIL" -w 2>/dev/null) && curl -fsS -u "$ATLASSIAN_EMAIL:$t" "$ATLASSIAN_BASE_URL/rest/api/3/myself" | jq -r '"Already connected as \(.displayName) (\(.emailAddress)) on '"$ATLASSIAN_BASE_URL"'. Skipping setup."' && exit 0`. Linux: same shape but `t=$(secret-tool lookup service agnes-atlassian-api-token username "$ATLASSIAN_EMAIL")`. Windows: read `secrets.env`, then `cmdkey /list:agnes-atlassian-api-token` — if entry exists, print "Atlassian cred entry found — verify in your real terminal before re-running setup." and let me confirm rather than auto-skipping. If the verify call returns 200, STOP with the "Already connected" line. Continue to step 1 only when secrets.env is missing OR keychain lookup fails OR `myself` returns 401. -1. Ask me for my Atlassian Cloud site URL (looks like https://<myorg>.atlassian.net) and the email I sign in with. Site URL and email are NOT secrets — fine to type into chat. Don't proceed until I've given you both. -2. Open the Atlassian API tokens page in my default browser — use your Bash tool: `open https://id.atlassian.com/manage-profile/security/api-tokens` on macOS, `xdg-open ...` on Linux/WSL, or `Start-Process ...` on Windows. Detect OS first. If I land on a generic profile page, tell me: avatar (top right) → Manage account → Security → "Create and manage API tokens". -3. Tell me to click "Create API token" (NOT "Create API token with scopes" unless I specifically need fine-grained — one-line trade-off: scoped tokens are limited per project but expire and need rotation; unscoped is simplest for personal use). Label it "Claude Code — Agnes", click Create, copy the token. Warn me it is shown ONCE. -4. Important: do NOT ask me to paste the token into the chat. Prepare a helper script for me to run in my real terminal, with my email and site URL baked in as literals (so they're not re-prompted at runtime): - a. Use the Write tool to create ~/.claude/agnes/bin/store-atlassian.sh on macOS/Linux (or .ps1 on Windows). chmod 700. The script must (i) reject obviously-truncated tokens via a length floor and (ii) verify the credentials against the Atlassian API BEFORE writing anything to the keychain. Both guards exist because Atlassian's "shown ONCE" copy panel commonly truncates if the user click-copies instead of using the panel's Copy button — silently storing a 43-char fragment then discovering it later is the failure mode we're avoiding. Body for macOS: - #!/usr/bin/env bash - set -e - EMAIL='<the email I gave you>' - BASE_URL='<the site URL I gave you, no trailing slash>' - read -srp 'Paste Atlassian API token (hidden): ' t; echo - - # Guard 1 — Atlassian Cloud tokens are typically 192+ chars; sub-100 - # means a truncated copy. Bail before touching the keychain. - tlen=$(printf %s "$t" | wc -c | tr -d ' ') - if [ "$tlen" -lt 100 ]; then - echo "Token looks too short ($tlen chars) — copy the full value via the Copy button on the Atlassian token page. Aborting." >&2 - unset t - exit 1 - fi - - # Guard 2 — verify against the live API before storing. If auth fails - # the keychain stays untouched and we know immediately why. - tmp=$(mktemp) - status=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$EMAIL:$t" "$BASE_URL/rest/api/3/myself" || true) - if [[ "$status" != "200" ]]; then - echo "API verification failed (HTTP $status). Aborting without storing." >&2 - cat "$tmp" >&2 2>/dev/null || true - rm -f "$tmp"; unset t - exit 1 - fi - display=$(python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("displayName","?"))' < "$tmp") - rm -f "$tmp" - - # Verified — write token to Keychain + URL/email to secrets.env. - security add-generic-password -U -s 'agnes-atlassian-api-token' -a "$EMAIL" -w "$t" - umask 077; mkdir -p ~/.claude/agnes - printf 'ATLASSIAN_EMAIL=%s\nATLASSIAN_BASE_URL=%s\n' "$EMAIL" "$BASE_URL" > ~/.claude/agnes/secrets.env - chmod 600 ~/.claude/agnes/secrets.env - unset t - echo "Stored. Verified as $display." - - Linux variant: replace `security add-generic-password ...` with `printf %s "$t" | secret-tool store --label='Agnes Atlassian token' service agnes-atlassian-api-token username "$EMAIL"`. Both guards (length floor, API verification) stay identical — they run before the storage call. Windows .ps1: same control flow using `Read-Host -AsSecureString`, convert to plain via `Marshal::PtrToStringAuto`, check `$t.Length -lt 100`, run `Invoke-RestMethod -Uri "$BASE_URL/rest/api/3/myself" -Authentication Basic -Credential (New-Object PSCredential($EMAIL, $secureToken))` wrapped in try/catch (writes to `cmdkey` only on success), then write secrets.env via Set-Content. - b. Tell me to open a real terminal (not Claude Code's `!`) and run `bash ~/.claude/agnes/bin/store-atlassian.sh` (or `pwsh ~/.claude/agnes/bin/store-atlassian.ps1` on Windows). The script will wait silently at the hidden prompt. - c. Walk me through clipboard order: copy the launcher first, paste in terminal, Enter (terminal waiting). Switch to the Atlassian tab, copy the token from step 3 — use the panel's "Copy" button, NOT click-and-drag (which often truncates). Switch back to terminal, paste at the silent prompt, Enter. The script will print "Stored. Verified as <your name>." on success, or fail loudly with the exact reason (too short / HTTP 401 / etc.) without writing anything. -5. Register the on-demand Atlassian MCP under .claude/mcp/atlassian referencing the stored credentials (read token from keychain via `security find-generic-password -s 'agnes-atlassian-api-token' -w` at MCP startup). -6. The store script already verified the token end-to-end. If I want a second redacted readback later, you can hit `GET $BASE_URL/rest/api/3/myself` again and print just displayName + accountId — never the token. -7. Remind me how to revoke: same API tokens page on Atlassian, plus `security delete-generic-password -s 'agnes-atlassian-api-token'` locally (macOS) / `secret-tool clear service agnes-atlassian-api-token` (Linux) / `cmdkey /delete:agnes-atlassian-api-token` (Windows).
+
{{ connector_prompts.atlassian }}
@@ -1242,12 +1420,17 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci
Curated data packages
Tables, schema, and metric definitions your team has registered. Subscribe and Claude can query them with documented business rules.
+ {# Corporate memory + Activity center are both admin-only — matches + the top-nav gating logic (the "Memory" link is hidden for + non-admin in _app_header.html, and the /corporate-memory route + is `require_admin`). Without this gate non-admin users would + see a Corporate memory tile that 403s when clicked. #} + {% if is_admin %} 🧠
Corporate memory
Shared analyst knowledge and prior solutions to draw from. Searchable, versioned, fed back into Claude's context on demand.
- {% if is_admin %} 📈
Activity center
@@ -1277,6 +1460,44 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci
+{# P0-2 — Post-CTA modal. Opens after the shared CTA include below has + created the token + copied the script. _claude_setup_cta.jinja still + owns the token request + clipboard write; this modal layers on top + with a 3-step "where to paste" guide. Lives at body level so the + home-mock styles don't bleed into it. #} +{% if not onboarded %} + +{% endif %} + {# Shared "Setup a new Claude Code" CTA behaviour — provides the JS that POSTs /auth/tokens, copies the rendered instructions to the clipboard, and falls back to a modal when the clipboard API is blocked. The button @@ -1301,9 +1522,41 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci }).catch(function () { btn.textContent = 'Copy failed'; }); }); } - document.querySelectorAll('.copy-btn[data-copy-target], .connector-copy[data-copy-target]').forEach(wireCopy); + document.querySelectorAll('.copy-btn[data-copy-target]').forEach(wireCopy); - // OS tab switching for Step 1. + // P0-3 + P2-12 — Connector copy buttons. Three things on click: + // 1) copy the prompt (same textContent trick so closed
+ // still copies); + // 2) flip the button to "✓ Copied" + disabled for 8 s so the user + // stops mashing it while figuring out where to paste; + // 3) reveal a paste hint pointing the user at Claude Code. + document.querySelectorAll('.connector-copy[data-copy-target]').forEach(function (btn) { + btn.addEventListener('click', function () { + var src = document.getElementById(btn.getAttribute('data-copy-target')); + if (!src) return; + var raw = src.textContent || ''; + var connector = btn.getAttribute('data-connector') || ''; + var hint = connector + ? document.querySelector('.copy-next-hint[data-hint-for="' + connector + '"]') + : null; + navigator.clipboard.writeText(raw).then(function () { + var orig = btn.textContent; + btn.classList.add('copied'); + btn.textContent = '✓ Copied'; + btn.disabled = true; + if (hint) hint.classList.add('is-visible'); + setTimeout(function () { + btn.classList.remove('copied'); + btn.textContent = orig; + btn.disabled = false; + if (hint) hint.classList.remove('is-visible'); + }, 8000); + }).catch(function () { btn.textContent = 'Copy failed'; }); + }); + }); + + // OS tab switching for Step 1. Flips both the command panel AND the + // P0-1 terminal-howto body so the howto matches the active OS. document.querySelectorAll('.os-tab[data-os-tab]').forEach(function (tab) { tab.addEventListener('click', function () { var target = tab.getAttribute('data-os-tab'); @@ -1320,9 +1573,49 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci p.setAttribute('hidden', ''); } }); + // P0-1 — flip the howto bodies in lockstep. + scope.querySelectorAll('[data-howto-panel]').forEach(function (p) { + if (p.getAttribute('data-howto-panel') === target) { + p.removeAttribute('hidden'); + } else { + p.setAttribute('hidden', ''); + } + }); }); }); + // P0-2 — Post-CTA modal. _claude_setup_cta.jinja owns the click + // handler that POSTs /auth/tokens + copies the script; we wait for + // its success signal (agnes:setup-script-copied custom event) and + // then open the "where to paste" guide. Without the event the modal + // simply never opens — the include's own fallback paths handle + // older browsers / blocked clipboard. + var ctaModal = document.getElementById('cta-modal-backdrop'); + var ctaModalClose = document.getElementById('cta-modal-close'); + function openCtaModal() { + if (!ctaModal) return; + ctaModal.removeAttribute('hidden'); + ctaModal.classList.add('is-open'); + if (ctaModalClose) ctaModalClose.focus(); + } + function closeCtaModal() { + if (!ctaModal) return; + ctaModal.classList.remove('is-open'); + ctaModal.setAttribute('hidden', ''); + var setupBtn = document.getElementById('setupClaudeBtn'); + if (setupBtn) setupBtn.focus(); + } + if (ctaModal) { + document.addEventListener('agnes:setup-script-copied', openCtaModal); + if (ctaModalClose) ctaModalClose.addEventListener('click', closeCtaModal); + ctaModal.addEventListener('click', function (ev) { + if (ev.target === ctaModal) closeCtaModal(); + }); + document.addEventListener('keydown', function (ev) { + if (ev.key === 'Escape' && ctaModal.classList.contains('is-open')) closeCtaModal(); + }); + } + var btn = document.getElementById('self-mark-btn'); var status = document.getElementById('self-mark-status'); if (!btn) return; diff --git a/app/web/templates/home_onboarded.html b/app/web/templates/home_onboarded.html index 973d563..80c3af1 100644 --- a/app/web/templates/home_onboarded.html +++ b/app/web/templates/home_onboarded.html @@ -122,18 +122,22 @@
Catalog
Browse tables, schema, and metric definitions.
+ {# Activity Center + Corporate Memory both admin-only — parity + with the top-nav gating (Memory link hidden for non-admin in + _app_header.html, /corporate-memory route is `require_admin`, + same for the active /home not-onboarded view). #} {% if is_admin %} 📈
Activity Center
Per-user analytics on Agnes adoption.
- {% endif %} 🧠
Corporate Memory
Shared analyst knowledge and prior solutions.
+ {% endif %} {% if is_admin %} ⚙️ diff --git a/config/.env.template b/config/.env.template index 0e4e4cf..688f050 100644 --- a/config/.env.template +++ b/config/.env.template @@ -155,3 +155,10 @@ SESSION_SECRET= # python -c "import secrets; print(secrets.token_he # Resolution order when unset: LOCAL_DEV_MODE=1 -> "local"; else # RELEASE_CHANNEL value; else AGNES_DEPLOYMENT_ENV; else "unknown". # POSTHOG_ENVIRONMENT=production + +# ── INSTANCE METADATA OVERRIDES ───────────────────── +# Operator contact shown on /home GWS connector tile as an "Email admin" +# mailto button. Analysts whose operator hasn't pre-provisioned a shared +# OAuth app can request one without leaving the workspace. Empty/unset +# hides the button. YAML equivalent: instance.admin_email in instance.yaml. +# AGNES_INSTANCE_ADMIN_EMAIL=ops@acme.com diff --git a/config/claude_md_template.txt b/config/claude_md_template.txt index 1ed1565..bdeee46 100644 --- a/config/claude_md_template.txt +++ b/config/claude_md_template.txt @@ -85,28 +85,15 @@ and use `agnes snapshot create --estimate` to size-check before fetching. {% if marketplaces -%} ## Agnes Marketplace — plugins available to you -The Agnes server publishes a per-user **`agnes`** Claude Code marketplace, -aggregated from upstream marketplaces and RBAC-filtered. The list below is -your **eligibility set** — plugins your account is allowed to add to its -stack on the `/marketplace` page. Eligibility ≠ installed. +These plugins reach Claude Code through the per-user **`agnes`** marketplace +served by this server (an aggregated, RBAC-filtered view of the upstream +marketplaces below). When you install or invoke one of these plugins inside +Claude Code, address it as `@agnes` regardless of which upstream it +came from — e.g. `claude plugin install @agnes`. The +`agnes refresh-marketplace` command (run by the SessionStart hook every +session) keeps the local clone in sync. -What actually reaches Claude Code is your **served stack**, which is: -- plugins you opted in to on `/marketplace` (from the eligibility set above), -- plus any plugin your admin pinned as system-mandatory (auto-applied to every user), -- plus skills / agents / plugins you installed from the **Flea market** tab on `/marketplace`. - -When you install or invoke a stack plugin inside Claude Code, address it -as `@agnes` regardless of which upstream marketplace it came from -— e.g. `claude plugin install @agnes`. - -Updates: the SessionStart hook runs `agnes refresh-marketplace --check` -on every Claude Code session — it only **detects** server-side changes -(new admin grants, new system plugins, your own `/marketplace` picks) and -prompts you to run `/update-agnes-plugins` inside Claude Code to install -the diff. The slash command does the full reconcile with output visible -in the transcript; no silent auto-install at session start. - -Upstream marketplaces folded into your `agnes` view (eligibility): +Upstream marketplaces folded into your `agnes` view: {% for mp in marketplaces -%} - **{{ mp.name }}** ({{ mp.slug }}): {{ mp.plugins | map(attribute="name") | join(", ") }} {% endfor %} diff --git a/config/instance.yaml.example b/config/instance.yaml.example index b59f0d7..78711d2 100644 --- a/config/instance.yaml.example +++ b/config/instance.yaml.example @@ -19,6 +19,11 @@ instance: # logo_svg: Full element for header logo (optional, default: Keboola logo) # Example: 'Logo' # sync_interval: "1 hour" # Cadence shown in analyst CLAUDE.md (e.g., "1 hour", "30 minutes", "daily") + # admin_email: "ops@acme.com" # Operator contact shown on /home GWS connector tile as + # an "Email admin" mailto button (analysts whose operator + # hasn't pre-provisioned a shared OAuth app can request + # one without leaving the workspace). Empty/unset hides + # the button. Env override: AGNES_INSTANCE_ADMIN_EMAIL. # --- Server --- server: diff --git a/src/welcome_template.py b/src/welcome_template.py index b9485bc..ef0e5a3 100644 --- a/src/welcome_template.py +++ b/src/welcome_template.py @@ -193,11 +193,32 @@ def compute_default_agent_prompt( except Exception: pass + # Resolve connector prompts via the shared module so the bash + # script's step-9 connector block uses the same operator-side + # config (GWS OAuth credentials, admin email) as the /home tile + # cards. Failure here falls back to the module's default empty + # config — the unconfigured GCP-walkthrough branch renders, which + # is the same behaviour as today on an instance with no + # AGNES_GWS_CLIENT_ID / AGNES_GWS_CLIENT_SECRET set. + connector_prompts: dict[str, str] | None = None + try: + from app.web.connector_prompts import all_connector_prompts + from app.instance_config import ( + get_gws_oauth_credentials, get_instance_admin_email, + ) + connector_prompts = all_connector_prompts( + gws_oauth=get_gws_oauth_credentials(), + instance_admin_email=get_instance_admin_email(), + ) + except Exception: + logger.exception("compute_default_agent_prompt: connector prompt resolution failed; using module defaults") + lines = resolve_lines( _wheel_filename, plugin_install_names=plugin_install_names, server_host=server_host, ca_pem=ca_pem, + connector_prompts=connector_prompts, ) return "\n".join(lines) except Exception: diff --git a/tests/test_corporate_memory_page.py b/tests/test_corporate_memory_page.py index 838a024..6fb5faa 100644 --- a/tests/test_corporate_memory_page.py +++ b/tests/test_corporate_memory_page.py @@ -59,13 +59,14 @@ class TestPendingBannerForAdmins: assert "awaiting review" not in body.lower() -class TestNonAdminNeverSeesPendingBanner: - def test_analyst_does_not_see_banner_even_with_pending_items(self, seeded_app): +class TestNonAdminBlocked: + def test_analyst_gets_403_on_corporate_memory(self, seeded_app): + """Corporate Memory is admin-only — both the nav link and the + widget are hidden for non-admin in the templates, and the route + itself rejects with 403. Banner-leakage to non-admin is moot + because the whole page is gated.""" _seed_pending_item("p_no_admin_1") c = seeded_app["client"] token = seeded_app["analyst_token"] resp = c.get("/corporate-memory", headers=_auth(token)) - assert resp.status_code == 200 - body = resp.text - # Non-admin must not see the admin-only banner copy. - assert "awaiting review" not in body.lower() + assert resp.status_code == 403 diff --git a/tests/test_home_route_resolution.py b/tests/test_home_route_resolution.py index 34e6b03..2f8f32f 100644 --- a/tests/test_home_route_resolution.py +++ b/tests/test_home_route_resolution.py @@ -91,6 +91,36 @@ def test_root_redirect_unauthed_goes_to_login(fresh_db): 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) @@ -150,7 +180,16 @@ 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.""" + only reliable way to seed the OAuth app credentials. + + The gws prompt body now flows through Jinja's autoescape (the template + moved from inline `` text to a `{{ connector_prompts.gws }}` + expression after the connector-prompts extraction). That means `"` + characters render as `"` 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" ) @@ -168,7 +207,7 @@ def test_home_renders_configured_gws_branch(fresh_db, monkeypatch): c = _client() resp = c.get("/home", cookies={"access_token": sess}) assert resp.status_code == 200 - body = resp.text + 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 @@ -221,8 +260,10 @@ def test_home_automode_env_can_hide(fresh_db, monkeypatch): def test_home_renders_automode_block_by_default(fresh_db, monkeypatch): - """Step 3 — turn on auto-accept mode renders by default for the - not-onboarded /home view.""" + """The auto-mode step renders by default for the not-onboarded /home + view. The block is now Step 2 (the install-flow reorder put auto-mode + BEFORE the Agnes install so users have auto-accept on for Step 3's + ~20 commands), so its label is "Step 2 — turn on auto-mode".""" monkeypatch.delenv("AGNES_HOME_SHOW_AUTOMODE", raising=False) from src.db import get_system_db, close_system_db @@ -236,9 +277,11 @@ def test_home_renders_automode_block_by_default(fresh_db, monkeypatch): c = _client() body = c.get("/home", cookies={"access_token": sess}).text - assert "Step 3 — turn on auto-accept mode" in body - assert '
' in body # rendered element, not CSS selector - assert "acceptEdits" in body # ~/.claude/settings.json snippet + assert "Step 2 — turn on auto-mode" in body + # The auto-mode step now lives inside the install-hero as an + # install-block (peer with Step 1 + Step 3), not as a separate + # automode-card. Look for the label + the keystroke prompt. + assert "Shift + Tab" in body def test_home_hides_automode_block_when_env_off(fresh_db, monkeypatch): @@ -255,9 +298,7 @@ def test_home_hides_automode_block_when_env_off(fresh_db, monkeypatch): c = _client() body = c.get("/home", cookies={"access_token": sess}).text - assert "Step 3 — turn on auto-accept mode" not in body - # HTML element absent (CSS selector with same name still in