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:
ZdenekSrotyr 2026-05-03 16:31:35 +02:00
parent d7705b5aa3
commit dc931a6556
8 changed files with 292 additions and 72 deletions

View file

@ -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).

View file

@ -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"],
)

View file

@ -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,

View file

@ -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>&lt;script&gt;</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();
}

View file

@ -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)

View file

@ -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": [
{

View file

@ -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"]

View file

@ -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):