feat(admin-prompt): default = live setup script; override replaces /setup content
The /admin/agent-prompt editor now pre-fills with the full bash bootstrap
script from setup_instructions.resolve_lines() instead of being empty.
When an admin saves an override it replaces the default everywhere — the
/setup page display and the dashboard clipboard CTA — rather than adding a
banner above the auto-generated commands.
GET /api/admin/welcome-template now returns a `default` field with the live
computed script so the editor always shows meaningful starting content.
{server_url} and {token} single-brace placeholders survive Jinja2 rendering
and are substituted by JavaScript at clipboard-copy time as before.
Preview pane switches to textContent (not innerHTML) since content is bash.
This commit is contained in:
parent
d7705b5aa3
commit
dc931a6556
8 changed files with 292 additions and 72 deletions
|
|
@ -12,7 +12,7 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
|
||||
### Added
|
||||
|
||||
- **Agent Setup Prompt** — customizable HTML banner shown above the bash bootstrap commands on `/setup`. Empty by default. Configure at `/admin/agent-prompt` (Jinja2 HTML editor). REST API: `GET /api/admin/welcome-template` returns `{content, updated_at, updated_by}` (`content` is `null` when no override is set); `PUT` to set an override; `DELETE` to clear; `POST /api/admin/welcome-template/preview` for live HTML preview without persisting. Available placeholders: `instance.{name,subtitle}`, `server.{url,hostname}`, `user` (may be `null` for anonymous visitors), `now`, `today`. HTML sanitization applied post-render (script/iframe/event-handler strip). See `docs/agent-setup-prompt.md`.
|
||||
- **Agent Setup Prompt** — customizable bash setup script shown on `/setup` and copied by the dashboard clipboard CTA. Default = the live `setup_instructions.resolve_lines()` output (TLS trust bootstrap, CLI install, login, marketplace, skills). Admin override at `/admin/agent-prompt` — full replacement of the default, not a banner added on top. Override flows to both the `/setup` page display and the dashboard clipboard payload. Jinja2 is available for `{{ instance.name }}` etc.; `{server_url}` and `{token}` are JS-substituted at clipboard-copy time and survive Jinja2 rendering unchanged. REST API: `GET /api/admin/welcome-template` returns `{content, default, updated_at, updated_by}` (`content` is `null` when no override is set; `default` is always the live computed script); `PUT` to set an override; `DELETE` to clear; `POST /api/admin/welcome-template/preview` for live preview without persisting. Available Jinja2 placeholders: `instance.{name,subtitle}`, `server.{url,hostname}`, `user` (may be `null` for anonymous visitors), `now`, `today`. Override content is HTML-sanitized post-render (script/iframe/event-handler strip). See `docs/agent-setup-prompt.md`.
|
||||
- DuckDB schema v21: `welcome_template` singleton table backing the banner override. Auto-migration v20→v21 on first start.
|
||||
- DuckDB schema v22: `setup_banner` table reserved (no consumers; retained for forward compatibility with already-migrated instances).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""REST endpoints for the agent-setup-prompt banner.
|
||||
"""REST endpoints for the agent-setup-prompt.
|
||||
|
||||
- GET /api/admin/welcome-template : raw template override (admin)
|
||||
- GET /api/admin/welcome-template : raw template override + live default (admin)
|
||||
- PUT /api/admin/welcome-template : set override (admin)
|
||||
- DELETE /api/admin/welcome-template : reset to default / no banner (admin)
|
||||
- DELETE /api/admin/welcome-template : reset to default (admin)
|
||||
- POST /api/admin/welcome-template/preview : live preview without persisting (admin)
|
||||
"""
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ from pydantic import BaseModel, Field
|
|||
from app.auth.access import require_admin
|
||||
from app.auth.dependencies import _get_db
|
||||
from src.repositories.welcome_template import WelcomeTemplateRepository
|
||||
from src.welcome_template import build_context, render_agent_prompt_banner
|
||||
from src.welcome_template import build_context, compute_default_agent_prompt, render_agent_prompt_banner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -50,6 +50,7 @@ class BannerResponse(BaseModel):
|
|||
|
||||
class TemplateGetResponse(BaseModel):
|
||||
content: Optional[str]
|
||||
default: str # live default from setup_instructions.resolve_lines()
|
||||
updated_at: Optional[str] = None
|
||||
updated_by: Optional[str] = None
|
||||
|
||||
|
|
@ -64,12 +65,16 @@ class TemplatePreviewRequest(BaseModel):
|
|||
|
||||
@router.get("/api/admin/welcome-template", response_model=TemplateGetResponse)
|
||||
async def admin_get_template(
|
||||
request: Request,
|
||||
user: dict = Depends(require_admin),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
row = WelcomeTemplateRepository(conn).get()
|
||||
server_url = str(request.base_url).rstrip("/")
|
||||
live_default = compute_default_agent_prompt(conn, user=user, server_url=server_url)
|
||||
return TemplateGetResponse(
|
||||
content=row["content"],
|
||||
default=live_default,
|
||||
updated_at=row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
updated_by=row["updated_by"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -726,18 +726,52 @@ async def setup_page(
|
|||
user: Optional[dict] = Depends(get_optional_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Setup instructions for the local agent (CLI + Claude Code)."""
|
||||
from src.welcome_template import render_agent_prompt_banner
|
||||
"""Setup instructions for the local agent (CLI + Claude Code).
|
||||
|
||||
When an admin override is saved, the override replaces the auto-generated
|
||||
setup_instructions output everywhere (both the /setup page display and the
|
||||
dashboard clipboard CTA). When no override is set, the live default from
|
||||
setup_instructions.resolve_lines() is used.
|
||||
"""
|
||||
from src.repositories.welcome_template import WelcomeTemplateRepository
|
||||
from src.welcome_template import compute_default_agent_prompt
|
||||
from jinja2 import Environment, StrictUndefined, TemplateError
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
banner_html = render_agent_prompt_banner(conn, user=user, server_url=base_url)
|
||||
|
||||
# Determine the script text: override (Jinja2-rendered) or live default.
|
||||
row = WelcomeTemplateRepository(conn).get()
|
||||
override_content = row.get("content")
|
||||
if override_content:
|
||||
# Admin override — render Jinja2 placeholders server-side.
|
||||
# {server_url} and {token} survive because Jinja2 only processes
|
||||
# double-brace {{ }} syntax; single-brace {x} pass through unchanged.
|
||||
try:
|
||||
from src.welcome_template import build_context as _build_banner_ctx
|
||||
env = Environment(undefined=StrictUndefined, autoescape=False)
|
||||
template = env.from_string(override_content)
|
||||
ctx_vars = _build_banner_ctx(user=user, server_url=base_url)
|
||||
setup_script_text = template.render(**ctx_vars)
|
||||
except (TemplateError, Exception) as exc:
|
||||
logger.warning("setup_page: override render failed (%s); falling back to default", exc)
|
||||
setup_script_text = compute_default_agent_prompt(conn, user=user, server_url=base_url)
|
||||
else:
|
||||
setup_script_text = compute_default_agent_prompt(conn, user=user, server_url=base_url)
|
||||
|
||||
# Split for the legacy setup_instructions_lines list variable that the
|
||||
# Jinja2 partial (_claude_setup_instructions.jinja) uses.
|
||||
setup_instructions_lines = setup_script_text.split("\n")
|
||||
|
||||
ctx = _build_context(
|
||||
request,
|
||||
user=user,
|
||||
conn=conn,
|
||||
server_url=base_url,
|
||||
agnes_version=os.environ.get("AGNES_VERSION", "dev"),
|
||||
banner_html=banner_html,
|
||||
banner_html="", # no separate banner — the script IS the content
|
||||
# Override both variables so the partial and the JS array stay in sync.
|
||||
setup_instructions_lines=setup_instructions_lines,
|
||||
setup_script_text=setup_script_text,
|
||||
)
|
||||
return templates.TemplateResponse(request, "install.html", ctx)
|
||||
|
||||
|
|
@ -905,12 +939,16 @@ async def admin_agent_prompt_page(
|
|||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
from src.repositories.welcome_template import WelcomeTemplateRepository
|
||||
from src.welcome_template import compute_default_agent_prompt
|
||||
|
||||
row = WelcomeTemplateRepository(conn).get()
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
default_template = compute_default_agent_prompt(conn, user=user, server_url=base_url)
|
||||
ctx = _build_context(
|
||||
request,
|
||||
user=user,
|
||||
current=row["content"] or "",
|
||||
default_template=default_template,
|
||||
updated_at=row["updated_at"],
|
||||
updated_by=row["updated_by"],
|
||||
is_override=row["content"] is not None,
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@
|
|||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"
|
||||
integrity="sha512-2OhXH4Il3n2tHKwLLSDPhrkgnLBC+6lHGGQzSFi3chgVB6DJ/v6+nbx+XYO9CugQyHVF/8D/0k3Hx1eaUK2K9g=="
|
||||
crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/shell/shell.min.css"
|
||||
crossorigin="anonymous">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"
|
||||
integrity="sha512-OeZ4Yrb/W7d2W4rAMOO0HQ9Ro/aWLtpW9BUSR2UOWnSV2hprXLkkYnnCGc9NeLUxxE4ZG7zN16UuT1Elqq8Opg=="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"
|
||||
integrity="sha512-Z4le1RxwhD8lDCrspbBxjTLLP2HGC1+mKb9KHR2N/sEx8uOe2vre5XQo8YMPAz8FQTo43HjefjlDtjY4LtfaaQ=="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/shell/shell.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<style>
|
||||
.container:has(.welcome-page) { max-width: none; padding: 24px 16px; }
|
||||
|
|
@ -202,7 +206,7 @@
|
|||
<div class="welcome-toolbar">
|
||||
<div>
|
||||
<h2 class="welcome-title">Agent Setup Prompt</h2>
|
||||
<p class="welcome-sub">Customise the banner shown above the setup commands on <code>/setup</code>.</p>
|
||||
<p class="welcome-sub">The bash setup script shown on <code>/setup</code> and copied by the dashboard CTA.</p>
|
||||
</div>
|
||||
<div id="status-chip">
|
||||
{% if is_override %}
|
||||
|
|
@ -219,17 +223,19 @@
|
|||
<div class="welcome-card">
|
||||
<div class="welcome-card-body">
|
||||
<p class="welcome-desc">
|
||||
This banner is shown above the setup commands on <code>/setup</code>. Empty by default.
|
||||
Use it for organisation-specific notes: VPN requirements, support channel, data classification
|
||||
policy, platform onboarding steps, etc.
|
||||
The override is rendered server-side as HTML — Jinja2 placeholders like
|
||||
<code>{{ "{{ user.name }}" }}</code> are substituted at render time.
|
||||
Output is sanitised post-render: inline <code><script></code> tags and
|
||||
<code>on*=</code> event handlers are stripped as a safety net.
|
||||
<strong>Default:</strong> the auto-generated bash bootstrap script (TLS trust, CLI install,
|
||||
login, marketplace, skills). When you save an override, it replaces the default
|
||||
everywhere — the <code>/setup</code> page display and the dashboard clipboard CTA.
|
||||
</p>
|
||||
<p class="welcome-desc">
|
||||
<strong>Placeholders:</strong> <code>{server_url}</code> and <code>{token}</code> are
|
||||
substituted by JavaScript at clipboard-copy time — leave them literal in your edits.
|
||||
Jinja2 variables (<code>{{ "{{ instance.name }}" }}</code>, <code>{{ "{{ user.email }}" }}</code>, etc.)
|
||||
are server-rendered before the page is served.
|
||||
</p>
|
||||
|
||||
<details class="welcome-cheatsheet">
|
||||
<summary>Available placeholders</summary>
|
||||
<summary>Available Jinja2 placeholders</summary>
|
||||
<div class="code-block">
|
||||
<span id="placeholder-text" class="code-body">{{ "{{ instance.name }}" }} — instance display name
|
||||
{{ "{{ instance.subtitle }}" }} — operator / org name
|
||||
|
|
@ -237,18 +243,22 @@
|
|||
{{ "{{ server.hostname }}" }} — host part only
|
||||
{{ "{{ user.email }}" }}, {{ "{{ user.name }}" }}, {{ "{{ user.is_admin }}" }}, {{ "{{ user.groups }}" }}
|
||||
(user may be null for anonymous visitors — guard with {{ "{% if user %}" }})
|
||||
{{ "{{ now }}" }}, {{ "{{ today }}" }} — server time (UTC) / date string</span>
|
||||
{{ "{{ now }}" }}, {{ "{{ today }}" }} — server time (UTC) / date string
|
||||
|
||||
# JS-substituted at clipboard-copy time (leave as literal placeholders):
|
||||
{server_url} — full server origin (https://agnes.example.com)
|
||||
{token} — the user's freshly-generated personal access token</span>
|
||||
<button class="btn-copy" data-copy-target="placeholder-text">Copy</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="welcome-editor-row">
|
||||
<div class="welcome-editor-col">
|
||||
<textarea id="content" name="content">{{ current }}</textarea>
|
||||
<textarea id="content" name="content">{{ current or default_template }}</textarea>
|
||||
</div>
|
||||
<div class="welcome-preview-col">
|
||||
<h4>Live preview</h4>
|
||||
<div id="preview-content">(rendering…)</div>
|
||||
<pre id="preview-content" style="white-space: pre-wrap; word-break: break-word; font-family: var(--font-mono, monospace); font-size: 12px; margin: 0;">(rendering…)</pre>
|
||||
<div id="preview-error" class="welcome-preview-error" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -264,7 +274,7 @@
|
|||
<div class="modal-backdrop" id="reset-modal" role="dialog" aria-modal="true" aria-labelledby="reset-modal-title">
|
||||
<div class="modal-card">
|
||||
<h3 id="reset-modal-title">Reset to default?</h3>
|
||||
<p class="sub">Your override will be permanently removed. No banner will be shown on <code>/setup</code>. This cannot be undone.</p>
|
||||
<p class="sub">Your override will be permanently removed. The auto-generated bash bootstrap script will be shown on <code>/setup</code> instead. This cannot be undone.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn" data-close-modal="reset-modal">Cancel</button>
|
||||
<button class="modal-btn danger" id="reset-confirm-btn">Reset</button>
|
||||
|
|
@ -336,7 +346,8 @@ async function renderPreview() {
|
|||
});
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
previewBox.innerHTML = j.content;
|
||||
// Use textContent (not innerHTML) — content is bash/text, not trusted HTML
|
||||
previewBox.textContent = j.content;
|
||||
previewErr.hidden = true;
|
||||
} else {
|
||||
let detail = r.statusText;
|
||||
|
|
@ -396,7 +407,9 @@ async function refreshStatus() {
|
|||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
setStatusChip(data);
|
||||
editor.setValue(data.content !== null ? data.content : "");
|
||||
// When an override is set, show it; otherwise show the live default so the
|
||||
// editor is never empty and admins can see what they're overriding.
|
||||
editor.setValue(data.content !== null ? data.content : (data.default || ""));
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,29 @@
|
|||
"""Render the agent-setup-prompt banner shown on /setup.
|
||||
"""Render the agent-setup-prompt for the /setup page.
|
||||
|
||||
The banner is a small HTML snippet admin-editable at /admin/agent-prompt.
|
||||
It appears above the bash bootstrap commands on the /setup page and is
|
||||
intended for org-specific operational notes (VPN warning, support channel,
|
||||
data classification reminder, platform requirements).
|
||||
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.
|
||||
|
||||
Default: no banner (empty string). Admins override via the welcome_template
|
||||
DB table (singleton, content TEXT).
|
||||
Override content is a Jinja2 template (autoescape=True, StrictUndefined).
|
||||
Available placeholders: instance.{name,subtitle}, server.{url,hostname},
|
||||
user (may be None for anonymous visitors), now, today.
|
||||
|
||||
Security: output is HTML-sanitized after render (script/iframe/event-handler
|
||||
strip). The Jinja2 environment uses StrictUndefined with autoescape=True so
|
||||
template typos raise immediately rather than silently emitting empty HTML.
|
||||
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.
|
||||
"""
|
||||
# See also: surfaced as the "Agent Setup Prompt" admin editor at /admin/agent-prompt.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
|
@ -117,7 +122,72 @@ def build_context(
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Banner renderer
|
||||
# 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 full 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 (anonymous visitors / no conn get 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"
|
||||
|
||||
plugin_install_names: list[str] = []
|
||||
if user and conn is not None:
|
||||
try:
|
||||
from src import marketplace_filter
|
||||
plugin_install_names = [
|
||||
p["manifest_name"]
|
||||
for p in marketplace_filter.resolve_allowed_plugins(conn, user)
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("compute_default_agent_prompt: marketplace plugin resolution failed")
|
||||
|
||||
self_signed_tls = os.environ.get("AGNES_DEBUG_AUTH", "").strip().lower() in (
|
||||
"1", "true", "yes",
|
||||
)
|
||||
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
|
||||
|
||||
lines = resolve_lines(
|
||||
_wheel_filename,
|
||||
plugin_install_names=plugin_install_names,
|
||||
self_signed_tls=self_signed_tls,
|
||||
server_host=server_host,
|
||||
ca_pem=ca_pem,
|
||||
)
|
||||
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(
|
||||
|
|
@ -126,28 +196,40 @@ def render_agent_prompt_banner(
|
|||
user: dict[str, Any] | None,
|
||||
server_url: str,
|
||||
) -> str:
|
||||
"""Render the admin-configured HTML banner for the /setup page.
|
||||
"""Render the agent setup prompt for the /setup page.
|
||||
|
||||
Returns an empty string when no override is set (default = no banner).
|
||||
Render failures are swallowed (logged) and return empty string so a
|
||||
broken template never blocks the /setup page from rendering.
|
||||
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 not content:
|
||||
return ""
|
||||
|
||||
try:
|
||||
env = Environment(undefined=StrictUndefined, autoescape=True)
|
||||
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
|
||||
)
|
||||
return ""
|
||||
except Exception:
|
||||
logger.exception("Agent-prompt banner render failed (unexpected)")
|
||||
return ""
|
||||
if content:
|
||||
# Admin-authored override — render as Jinja2 HTML, sanitize.
|
||||
try:
|
||||
env = Environment(undefined=StrictUndefined, autoescape=True)
|
||||
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.
|
||||
return compute_default_agent_prompt(conn, user=user, server_url=server_url)
|
||||
|
|
|
|||
|
|
@ -2108,6 +2108,10 @@
|
|||
],
|
||||
"title": "Content"
|
||||
},
|
||||
"default": {
|
||||
"title": "Default",
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -2132,7 +2136,8 @@
|
|||
}
|
||||
},
|
||||
"required": [
|
||||
"content"
|
||||
"content",
|
||||
"default"
|
||||
],
|
||||
"title": "TemplateGetResponse",
|
||||
"type": "object"
|
||||
|
|
@ -11889,7 +11894,7 @@
|
|||
},
|
||||
"/setup": {
|
||||
"get": {
|
||||
"description": "Setup instructions for the local agent (CLI + Claude Code).",
|
||||
"description": "Setup instructions for the local agent (CLI + Claude Code).\n\nWhen an admin override is saved, the override replaces the auto-generated\nsetup_instructions output everywhere (both the /setup page display and the\ndashboard clipboard CTA). When no override is set, the live default from\nsetup_instructions.resolve_lines() is used.",
|
||||
"operationId": "setup_page_setup_get",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -34,8 +34,11 @@ def test_admin_get_template_initially_null(seeded_app):
|
|||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["content"] is None
|
||||
# No longer returns a `default` field — banner default is empty
|
||||
assert "default" not in body or body.get("default") is None
|
||||
# default field must be present and contain the live setup script
|
||||
assert "default" in body
|
||||
assert body["default"] # non-empty
|
||||
# Must contain setup-script markers
|
||||
assert "da auth" in body["default"] or "uv tool install" in body["default"] or "curl" in body["default"]
|
||||
|
||||
|
||||
def test_admin_can_set_and_reset_template(seeded_app):
|
||||
|
|
@ -164,3 +167,48 @@ def test_validation_stub_matches_build_context_shape(seeded_app, tmp_path, monke
|
|||
assert set(_VALIDATION_STUB_CONTEXT["user"].keys()) == set(real_ctx["user"].keys()), (
|
||||
"_VALIDATION_STUB_CONTEXT['user'] drifted from build_context output"
|
||||
)
|
||||
|
||||
|
||||
def test_setup_page_uses_override_when_set(seeded_app):
|
||||
"""When admin saves an override, /setup serves the override instead of the
|
||||
auto-generated setup_instructions output."""
|
||||
c = seeded_app["client"]
|
||||
admin = _auth(seeded_app["admin_token"])
|
||||
|
||||
# Save override (plain text — no Jinja2 needed here; PUT validates with stub ctx)
|
||||
r = c.put(
|
||||
"/api/admin/welcome-template",
|
||||
json={"content": "# Custom setup script\necho hello"},
|
||||
headers=admin,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# /setup should now contain the override content
|
||||
r = c.get("/setup")
|
||||
assert r.status_code == 200
|
||||
assert "Custom setup script" in r.text
|
||||
assert "echo hello" in r.text
|
||||
|
||||
# Reset to default
|
||||
r = c.delete("/api/admin/welcome-template", headers=admin)
|
||||
assert r.status_code == 204
|
||||
|
||||
# /setup back to default — should NOT contain the custom override
|
||||
r = c.get("/setup")
|
||||
assert r.status_code == 200
|
||||
assert "Custom setup script" not in r.text
|
||||
# Default contains setup_instructions output
|
||||
assert "da analyst setup" in r.text or "da auth" in r.text or "curl" in r.text
|
||||
|
||||
|
||||
def test_get_template_default_field_has_server_url_placeholder(seeded_app):
|
||||
"""{server_url} placeholder must survive in the default — it is a JS-substituted
|
||||
single-brace placeholder and must NOT be consumed by Jinja2 at render time."""
|
||||
c = seeded_app["client"]
|
||||
admin = _auth(seeded_app["admin_token"])
|
||||
|
||||
r = c.get("/api/admin/welcome-template", headers=admin)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "{server_url}" in body["default"]
|
||||
assert "{token}" in body["default"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Unit tests for the agent-setup-prompt banner renderer."""
|
||||
"""Unit tests for the agent-setup-prompt renderer."""
|
||||
|
||||
import duckdb
|
||||
import pytest
|
||||
|
|
@ -8,6 +8,7 @@ from src.repositories.welcome_template import WelcomeTemplateRepository
|
|||
from src.welcome_template import (
|
||||
_sanitize_banner_html,
|
||||
build_context,
|
||||
compute_default_agent_prompt,
|
||||
render_agent_prompt_banner,
|
||||
)
|
||||
|
||||
|
|
@ -33,12 +34,43 @@ def _user(email="alice@example.com"):
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default (no override) → empty string
|
||||
# Default (no override) → live setup script, not empty string
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_returns_empty_when_no_override(conn):
|
||||
def test_returns_default_script_when_no_override(conn):
|
||||
"""When no override is set, render_agent_prompt_banner returns the live
|
||||
setup script (not an empty string)."""
|
||||
out = render_agent_prompt_banner(conn, user=_user(), server_url="https://example.com")
|
||||
assert out == ""
|
||||
# Must be non-empty — the default IS the setup script
|
||||
assert out != ""
|
||||
# Must contain key markers from setup_instructions.resolve_lines()
|
||||
assert "da analyst setup" in out or "da auth" in out or "curl" in out
|
||||
|
||||
|
||||
def test_compute_default_returns_setup_script(conn):
|
||||
"""compute_default_agent_prompt returns a non-empty string with setup
|
||||
script markers including {server_url} and da-related commands."""
|
||||
out = compute_default_agent_prompt(conn, user=_user(), server_url="https://example.com")
|
||||
assert out != ""
|
||||
# {server_url} placeholder must survive (not replaced by Jinja2)
|
||||
assert "{server_url}" in out
|
||||
# Should reference da auth or CLI install
|
||||
assert "da auth" in out or "uv tool install" in out
|
||||
|
||||
|
||||
def test_compute_default_server_url_placeholder_survives(conn):
|
||||
"""{server_url} and {token} are single-brace JS placeholders.
|
||||
compute_default_agent_prompt must NOT replace them — they stay literal."""
|
||||
out = compute_default_agent_prompt(conn, user=_user(), server_url="https://example.com")
|
||||
assert "{server_url}" in out
|
||||
assert "{token}" in out
|
||||
|
||||
|
||||
def test_returns_empty_for_none_user_with_no_override(conn):
|
||||
"""Anonymous visitor with no override → still returns the default script."""
|
||||
out = render_agent_prompt_banner(conn, user=None, server_url="https://example.com")
|
||||
# No override → default (non-empty bash script)
|
||||
assert out != ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -92,11 +124,6 @@ def test_renders_with_anonymous_user(conn):
|
|||
assert "Hi" not in out
|
||||
|
||||
|
||||
def test_returns_empty_for_none_user_with_no_override(conn):
|
||||
out = render_agent_prompt_banner(conn, user=None, server_url="https://example.com")
|
||||
assert out == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build context shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -188,14 +215,16 @@ def test_sanitize_allows_safe_html():
|
|||
# Render failure → empty string (not exception)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_render_failure_returns_empty_not_exception(conn):
|
||||
def test_render_failure_falls_back_to_default_not_exception(conn):
|
||||
# StrictUndefined: referencing an unknown variable raises at render time.
|
||||
WelcomeTemplateRepository(conn).set(
|
||||
"{{ does_not_exist }}", updated_by="admin@example.com"
|
||||
)
|
||||
out = render_agent_prompt_banner(conn, user=_user(), server_url="https://example.com")
|
||||
# Must return empty string, not raise
|
||||
assert out == ""
|
||||
# Must not raise — falls back to the live default script (non-empty)
|
||||
assert out != ""
|
||||
# Must contain default setup script markers
|
||||
assert "da auth" in out or "uv tool install" in out or "curl" in out
|
||||
|
||||
|
||||
def test_sanitize_applied_after_render(conn):
|
||||
|
|
|
|||
Loading…
Reference in a new issue