* Make /home install-hero links readable against blue background The Claude license-options link added in the previous commit inherited the default `<a>` style (`var(--hp-primary)` blue), which renders as blue-on-blue and is unreadable inside the blue install-hero. Add a scoped `.install-hero a` rule that uses white with an underline (matching the existing lead-paragraph contrast pattern) so any link nested in the hero stays legible. * Reorder /home install flow: auto-mode is now Step 2, Agnes install becomes Step 3 Step 3 (was Step 2) pastes a ~20-command bash bootstrap into a fresh Claude Code session. Without auto-mode enabled first, each Bash/edit command needs a manual approve click — bad UX for first-time users. Move auto-mode from the outside-hero `<details>` reference block into the install-hero as a real Step 2, between "install Claude Code" and "install Agnes". Content is the persistent `acceptEdits` snippet (write to ~/.claude/settings.json) plus a one-liner pointing at Shift+Tab for users who are already inside a running Claude Code session. YOLO mode for full Bash auto-approve stays on /setup-advanced behind the existing link. The outside-hero `setup-collapsible[data-section="step3"]` block is dropped — auto-mode is no longer reference content, it's a real install step, and duplicating it would just diverge over time. Onboarded users no longer see the auto-mode block at all (consistent with Steps 1 + 3 also hiding post-onboarding). Completion banner copy updated: "Step 1, 2 & 3 done — Claude Code installed, auto-mode set, Agnes ready". Dashboard CTA partial and other templates don't reference step numbers for this flow, so no adaptation needed there. * Simplify /home Step 2 to Shift+Tab only — drop the JSON snippet Operator pointed out two issues with the prior Step 2: 1. The settings.json snippet is redundant. Claude Code's first Shift+Tab cycle to auto-accept mode already prompts the user whether to persist it as default — Claude writes the config itself, no manual file edit needed. 2. The snippet only showed the POSIX path `~/.claude/settings.json`, which doesn't translate to native Windows. Replace the snippet + copy button with a plain Shift+Tab instruction, explicitly call out the first-time "make this the default?" prompt, and note that Claude handles the config write itself — same flow on macOS / Linux / WSL / Windows. Adds a fallback line for users who already closed the post-OAuth session. * Tighten /home Step 2 install-note to two paragraphs Operator: drop the 'Claude writes the setting itself, so this works the same on macOS / Linux / WSL / Windows...' line plus the 'auto-approves file edits going forward; Bash commands stay gated — that's the safe default' line. Both were filler — the make-default prompt already implies persistence, and gated Bash is the obvious default users won't be surprised by. Result: paragraph 1 carries Shift+Tab + first-time make-default say-yes + closed-session fallback in one breath; paragraph 2 keeps the verbatim YOLO link. Same affordances, less vertical space.
283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""Render the agent-setup-prompt for the /setup page.
|
|
|
|
The prompt is admin-editable at /admin/agent-prompt. When no override is
|
|
set, the default content is the live output of
|
|
``app.web.setup_instructions.resolve_lines()`` — the full bash bootstrap
|
|
script (TLS trust, CLI install, login, marketplace, skills). When an
|
|
override is saved it replaces the default everywhere: both the /setup page
|
|
display and the dashboard clipboard CTA.
|
|
|
|
Override content is a Jinja2 template (autoescape=False, StrictUndefined).
|
|
Available placeholders: instance.{name,subtitle}, server.{url,hostname},
|
|
user (may be None for anonymous visitors), now, today.
|
|
|
|
The bash default is **not** HTML-sanitized (it is bash, not HTML). Override
|
|
content IS HTML-sanitized after render: script/iframe/event-handler strip.
|
|
|
|
See also: surfaced as the "Agent Setup Prompt" admin editor at /admin/agent-prompt.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from datetime import date, datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
import duckdb
|
|
from jinja2 import Environment, StrictUndefined, TemplateError
|
|
|
|
from app.instance_config import (
|
|
get_instance_name,
|
|
get_instance_subtitle,
|
|
)
|
|
from src.repositories.welcome_template import WelcomeTemplateRepository
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTML sanitization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_RE_SCRIPT = re.compile(
|
|
r"<script[\s\S]*?</script>", re.IGNORECASE
|
|
)
|
|
_RE_IFRAME = re.compile(
|
|
r"<iframe[\s\S]*?(?:</iframe>|/>)", re.IGNORECASE
|
|
)
|
|
_RE_ON_EVENT = re.compile(
|
|
r"\s+on\w+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)", re.IGNORECASE
|
|
)
|
|
_RE_JS_URI = re.compile(
|
|
r"""(?:href|src|action)\s*=\s*(?:"|')(javascript:|data:)""", re.IGNORECASE
|
|
)
|
|
|
|
|
|
def _sanitize_banner_html(html: str) -> str:
|
|
"""Strip dangerous constructs from admin-authored HTML.
|
|
|
|
Defense-in-depth only — admins are trusted, but this prevents accidental
|
|
XSS from copy-pasted snippets reaching the public /setup page.
|
|
|
|
Strips:
|
|
- <script>…</script> blocks (any content)
|
|
- <iframe>…</iframe> tags
|
|
- on*= event handler attributes (onclick=, onload=, etc.)
|
|
- javascript: / data: URI schemes in href/src/action attributes
|
|
"""
|
|
html = _RE_SCRIPT.sub("", html)
|
|
html = _RE_IFRAME.sub("", html)
|
|
html = _RE_ON_EVENT.sub("", html)
|
|
html = _RE_JS_URI.sub(
|
|
lambda m: m.group(0).replace(m.group(1), "#"), html
|
|
)
|
|
return html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Render context
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_context(
|
|
*,
|
|
user: dict[str, Any] | None,
|
|
server_url: str,
|
|
) -> dict[str, Any]:
|
|
"""Compose the Jinja2 render context for the banner.
|
|
|
|
Intentionally small: instance identity, server URL, and the requesting
|
|
user (may be None for anonymous /setup visitors). No tables, metrics, or
|
|
marketplaces — the banner is for org-operational notes, not data-catalog
|
|
content.
|
|
|
|
Note: ``now`` is tz-aware UTC.
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
parsed = urlparse(server_url)
|
|
user_ctx: dict[str, Any] | None = None
|
|
if user:
|
|
user_ctx = {
|
|
"id": user.get("id", ""),
|
|
"email": user.get("email", ""),
|
|
"name": user.get("name") or "",
|
|
"is_admin": bool(user.get("is_admin")),
|
|
"groups": user.get("groups") or [],
|
|
}
|
|
return {
|
|
"instance": {
|
|
"name": get_instance_name(),
|
|
"subtitle": get_instance_subtitle(),
|
|
},
|
|
"server": {
|
|
"url": server_url,
|
|
"hostname": parsed.hostname or "",
|
|
},
|
|
"user": user_ctx,
|
|
"now": now,
|
|
"today": date.today().isoformat(),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Default content — live setup script
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def compute_default_agent_prompt(
|
|
conn: duckdb.DuckDBPyConnection,
|
|
*,
|
|
user: dict[str, Any] | None,
|
|
server_url: str,
|
|
) -> str:
|
|
"""Return the live default setup script from setup_instructions.resolve_lines().
|
|
|
|
This is the unified bash bootstrap prompt that /setup shows when no
|
|
admin override is set. The returned string is bash (not HTML) —
|
|
callers must NOT pass it through _sanitize_banner_html.
|
|
|
|
``conn`` and ``user`` are forwarded to resolve the RBAC-filtered plugin
|
|
install list. The same RBAC pass runs for everyone (admin and
|
|
non-admin alike): users with no plugin grants get the no-marketplace
|
|
layout (Confirm = step 6); users with grants get the marketplace + plugins
|
|
block inserted (Confirm = step 8). Anonymous visitors / no conn fall
|
|
through to the no-marketplace layout.
|
|
|
|
``server_url`` is used to derive the server host for the marketplace
|
|
block.
|
|
"""
|
|
try:
|
|
from app.web.setup_instructions import resolve_lines
|
|
from app.api.cli_artifacts import _find_wheel
|
|
|
|
_wheel = _find_wheel()
|
|
_wheel_filename = _wheel.name if _wheel else "agnes.whl"
|
|
|
|
# The install commands emitted in the marketplace block must match
|
|
# exactly what /marketplace.zip + /marketplace.git/ serve. That's
|
|
# the `resolve_user_marketplace` view: admin grants minus the
|
|
# user's opt-outs, plus their Store installs (skills + agents
|
|
# rolled up into the synth `agnes-store-bundle` plugin, plugin-
|
|
# typed entities standalone). `resolve_allowed_plugins` was the
|
|
# pre-store admin-only feed and would emit installs for plugins
|
|
# the user has opted out of, while skipping the bundle entirely.
|
|
#
|
|
# Dedup by manifest_name handles the documented case where two
|
|
# upstream marketplaces ship a plugin with the same name (see
|
|
# CLAUDE.md "Same-named plugins ... collide in the catalog by
|
|
# design"). The synth marketplace.json carries one entry per
|
|
# name; a second `claude plugin install <name>@agnes` would be
|
|
# a no-op anyway.
|
|
plugin_install_names: list[str] = []
|
|
if user and conn is not None:
|
|
try:
|
|
from src import marketplace_filter
|
|
seen: set[str] = set()
|
|
for p in marketplace_filter.resolve_user_marketplace(conn, user):
|
|
name = p["manifest_name"]
|
|
if name in seen:
|
|
continue
|
|
seen.add(name)
|
|
plugin_install_names.append(name)
|
|
except Exception:
|
|
logger.exception("compute_default_agent_prompt: marketplace plugin resolution failed")
|
|
|
|
from urllib.parse import urlparse as _urlparse
|
|
parsed = _urlparse(server_url)
|
|
server_host = parsed.netloc or parsed.hostname or ""
|
|
|
|
ca_pem: str | None = None
|
|
try:
|
|
from app.web.router import _read_agnes_ca_pem
|
|
ca_pem = _read_agnes_ca_pem()
|
|
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:
|
|
logger.exception("compute_default_agent_prompt: unexpected error; returning empty")
|
|
return ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prompt renderer (override or default)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def render_agent_prompt_banner(
|
|
conn: duckdb.DuckDBPyConnection,
|
|
*,
|
|
user: dict[str, Any] | None,
|
|
server_url: str,
|
|
) -> str:
|
|
"""Render the agent setup prompt for the /setup page.
|
|
|
|
When an admin override is set:
|
|
- Renders via Jinja2 (autoescape=True, StrictUndefined).
|
|
- HTML-sanitizes the output.
|
|
- Returns the sanitized HTML string.
|
|
|
|
When no override is set:
|
|
- Returns the live default from compute_default_agent_prompt() — the
|
|
full bash bootstrap script. This is bash, not HTML, so no
|
|
sanitization is applied.
|
|
|
|
Render failures on the override path are swallowed (logged) and fall back
|
|
to the live default so a broken template never blocks /setup.
|
|
"""
|
|
row = WelcomeTemplateRepository(conn).get()
|
|
content = row.get("content")
|
|
|
|
if content:
|
|
# Admin-authored override — render as Jinja2, sanitize.
|
|
# autoescape=False to match /setup rendering — the outer Jinja2 template
|
|
# applies escaping where needed.
|
|
try:
|
|
env = Environment(undefined=StrictUndefined, autoescape=False)
|
|
template = env.from_string(content)
|
|
ctx = build_context(user=user, server_url=server_url)
|
|
rendered = template.render(**ctx)
|
|
return _sanitize_banner_html(rendered)
|
|
except TemplateError as exc:
|
|
logger.warning(
|
|
"Agent-prompt banner render failed (template error): %s", exc
|
|
)
|
|
# Fall through to default
|
|
except Exception:
|
|
logger.exception("Agent-prompt banner render failed (unexpected)")
|
|
# Fall through to default
|
|
|
|
# No override (or broken override) — return live default bash script.
|
|
# Same unified flow for everyone; admin-vs-analyst is no longer a
|
|
# layout branch. The marketplace block is gated by the caller's
|
|
# plugin grants in `resource_grants`, which `compute_default_agent_prompt`
|
|
# resolves unconditionally.
|
|
return compute_default_agent_prompt(
|
|
conn, user=user, server_url=server_url,
|
|
)
|