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
|
### 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 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).
|
- 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)
|
- 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)
|
- 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.access import require_admin
|
||||||
from app.auth.dependencies import _get_db
|
from app.auth.dependencies import _get_db
|
||||||
from src.repositories.welcome_template import WelcomeTemplateRepository
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -50,6 +50,7 @@ class BannerResponse(BaseModel):
|
||||||
|
|
||||||
class TemplateGetResponse(BaseModel):
|
class TemplateGetResponse(BaseModel):
|
||||||
content: Optional[str]
|
content: Optional[str]
|
||||||
|
default: str # live default from setup_instructions.resolve_lines()
|
||||||
updated_at: Optional[str] = None
|
updated_at: Optional[str] = None
|
||||||
updated_by: Optional[str] = None
|
updated_by: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -64,12 +65,16 @@ class TemplatePreviewRequest(BaseModel):
|
||||||
|
|
||||||
@router.get("/api/admin/welcome-template", response_model=TemplateGetResponse)
|
@router.get("/api/admin/welcome-template", response_model=TemplateGetResponse)
|
||||||
async def admin_get_template(
|
async def admin_get_template(
|
||||||
|
request: Request,
|
||||||
user: dict = Depends(require_admin),
|
user: dict = Depends(require_admin),
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
):
|
):
|
||||||
row = WelcomeTemplateRepository(conn).get()
|
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(
|
return TemplateGetResponse(
|
||||||
content=row["content"],
|
content=row["content"],
|
||||||
|
default=live_default,
|
||||||
updated_at=row["updated_at"].isoformat() if row["updated_at"] else None,
|
updated_at=row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||||
updated_by=row["updated_by"],
|
updated_by=row["updated_by"],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -726,18 +726,52 @@ async def setup_page(
|
||||||
user: Optional[dict] = Depends(get_optional_user),
|
user: Optional[dict] = Depends(get_optional_user),
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
):
|
):
|
||||||
"""Setup instructions for the local agent (CLI + Claude Code)."""
|
"""Setup instructions for the local agent (CLI + Claude Code).
|
||||||
from src.welcome_template import render_agent_prompt_banner
|
|
||||||
|
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("/")
|
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(
|
ctx = _build_context(
|
||||||
request,
|
request,
|
||||||
user=user,
|
user=user,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
server_url=base_url,
|
server_url=base_url,
|
||||||
agnes_version=os.environ.get("AGNES_VERSION", "dev"),
|
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)
|
return templates.TemplateResponse(request, "install.html", ctx)
|
||||||
|
|
||||||
|
|
@ -905,12 +939,16 @@ async def admin_agent_prompt_page(
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
):
|
):
|
||||||
from src.repositories.welcome_template import WelcomeTemplateRepository
|
from src.repositories.welcome_template import WelcomeTemplateRepository
|
||||||
|
from src.welcome_template import compute_default_agent_prompt
|
||||||
|
|
||||||
row = WelcomeTemplateRepository(conn).get()
|
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(
|
ctx = _build_context(
|
||||||
request,
|
request,
|
||||||
user=user,
|
user=user,
|
||||||
current=row["content"] or "",
|
current=row["content"] or "",
|
||||||
|
default_template=default_template,
|
||||||
updated_at=row["updated_at"],
|
updated_at=row["updated_at"],
|
||||||
updated_by=row["updated_by"],
|
updated_by=row["updated_by"],
|
||||||
is_override=row["content"] is not None,
|
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"
|
<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=="
|
integrity="sha512-2OhXH4Il3n2tHKwLLSDPhrkgnLBC+6lHGGQzSFi3chgVB6DJ/v6+nbx+XYO9CugQyHVF/8D/0k3Hx1eaUK2K9g=="
|
||||||
crossorigin="anonymous">
|
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"
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"
|
||||||
integrity="sha512-OeZ4Yrb/W7d2W4rAMOO0HQ9Ro/aWLtpW9BUSR2UOWnSV2hprXLkkYnnCGc9NeLUxxE4ZG7zN16UuT1Elqq8Opg=="
|
integrity="sha512-OeZ4Yrb/W7d2W4rAMOO0HQ9Ro/aWLtpW9BUSR2UOWnSV2hprXLkkYnnCGc9NeLUxxE4ZG7zN16UuT1Elqq8Opg=="
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"
|
||||||
integrity="sha512-Z4le1RxwhD8lDCrspbBxjTLLP2HGC1+mKb9KHR2N/sEx8uOe2vre5XQo8YMPAz8FQTo43HjefjlDtjY4LtfaaQ=="
|
integrity="sha512-Z4le1RxwhD8lDCrspbBxjTLLP2HGC1+mKb9KHR2N/sEx8uOe2vre5XQo8YMPAz8FQTo43HjefjlDtjY4LtfaaQ=="
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/shell/shell.min.js"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.container:has(.welcome-page) { max-width: none; padding: 24px 16px; }
|
.container:has(.welcome-page) { max-width: none; padding: 24px 16px; }
|
||||||
|
|
@ -202,7 +206,7 @@
|
||||||
<div class="welcome-toolbar">
|
<div class="welcome-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="welcome-title">Agent Setup Prompt</h2>
|
<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>
|
||||||
<div id="status-chip">
|
<div id="status-chip">
|
||||||
{% if is_override %}
|
{% if is_override %}
|
||||||
|
|
@ -219,17 +223,19 @@
|
||||||
<div class="welcome-card">
|
<div class="welcome-card">
|
||||||
<div class="welcome-card-body">
|
<div class="welcome-card-body">
|
||||||
<p class="welcome-desc">
|
<p class="welcome-desc">
|
||||||
This banner is shown above the setup commands on <code>/setup</code>. Empty by default.
|
<strong>Default:</strong> the auto-generated bash bootstrap script (TLS trust, CLI install,
|
||||||
Use it for organisation-specific notes: VPN requirements, support channel, data classification
|
login, marketplace, skills). When you save an override, it replaces the default
|
||||||
policy, platform onboarding steps, etc.
|
everywhere — the <code>/setup</code> page display and the dashboard clipboard CTA.
|
||||||
The override is rendered server-side as HTML — Jinja2 placeholders like
|
</p>
|
||||||
<code>{{ "{{ user.name }}" }}</code> are substituted at render time.
|
<p class="welcome-desc">
|
||||||
Output is sanitised post-render: inline <code><script></code> tags and
|
<strong>Placeholders:</strong> <code>{server_url}</code> and <code>{token}</code> are
|
||||||
<code>on*=</code> event handlers are stripped as a safety net.
|
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>
|
</p>
|
||||||
|
|
||||||
<details class="welcome-cheatsheet">
|
<details class="welcome-cheatsheet">
|
||||||
<summary>Available placeholders</summary>
|
<summary>Available Jinja2 placeholders</summary>
|
||||||
<div class="code-block">
|
<div class="code-block">
|
||||||
<span id="placeholder-text" class="code-body">{{ "{{ instance.name }}" }} — instance display name
|
<span id="placeholder-text" class="code-body">{{ "{{ instance.name }}" }} — instance display name
|
||||||
{{ "{{ instance.subtitle }}" }} — operator / org name
|
{{ "{{ instance.subtitle }}" }} — operator / org name
|
||||||
|
|
@ -237,18 +243,22 @@
|
||||||
{{ "{{ server.hostname }}" }} — host part only
|
{{ "{{ server.hostname }}" }} — host part only
|
||||||
{{ "{{ user.email }}" }}, {{ "{{ user.name }}" }}, {{ "{{ user.is_admin }}" }}, {{ "{{ user.groups }}" }}
|
{{ "{{ user.email }}" }}, {{ "{{ user.name }}" }}, {{ "{{ user.is_admin }}" }}, {{ "{{ user.groups }}" }}
|
||||||
(user may be null for anonymous visitors — guard with {{ "{% if user %}" }})
|
(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>
|
<button class="btn-copy" data-copy-target="placeholder-text">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div class="welcome-editor-row">
|
<div class="welcome-editor-row">
|
||||||
<div class="welcome-editor-col">
|
<div class="welcome-editor-col">
|
||||||
<textarea id="content" name="content">{{ current }}</textarea>
|
<textarea id="content" name="content">{{ current or default_template }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="welcome-preview-col">
|
<div class="welcome-preview-col">
|
||||||
<h4>Live preview</h4>
|
<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 id="preview-error" class="welcome-preview-error" hidden></div>
|
||||||
</div>
|
</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-backdrop" id="reset-modal" role="dialog" aria-modal="true" aria-labelledby="reset-modal-title">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<h3 id="reset-modal-title">Reset to default?</h3>
|
<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">
|
<div class="modal-actions">
|
||||||
<button class="modal-btn" data-close-modal="reset-modal">Cancel</button>
|
<button class="modal-btn" data-close-modal="reset-modal">Cancel</button>
|
||||||
<button class="modal-btn danger" id="reset-confirm-btn">Reset</button>
|
<button class="modal-btn danger" id="reset-confirm-btn">Reset</button>
|
||||||
|
|
@ -336,7 +346,8 @@ async function renderPreview() {
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const j = await r.json();
|
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;
|
previewErr.hidden = true;
|
||||||
} else {
|
} else {
|
||||||
let detail = r.statusText;
|
let detail = r.statusText;
|
||||||
|
|
@ -396,7 +407,9 @@ async function refreshStatus() {
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
setStatusChip(data);
|
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();
|
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.
|
The prompt is admin-editable at /admin/agent-prompt. When no override is
|
||||||
It appears above the bash bootstrap commands on the /setup page and is
|
set, the default content is the live output of
|
||||||
intended for org-specific operational notes (VPN warning, support channel,
|
``app.web.setup_instructions.resolve_lines()`` — the full bash bootstrap
|
||||||
data classification reminder, platform requirements).
|
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
|
Override content is a Jinja2 template (autoescape=True, StrictUndefined).
|
||||||
DB table (singleton, content TEXT).
|
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
|
The bash default is **not** HTML-sanitized (it is bash, not HTML). Override
|
||||||
strip). The Jinja2 environment uses StrictUndefined with autoescape=True so
|
content IS HTML-sanitized after render: script/iframe/event-handler strip.
|
||||||
template typos raise immediately rather than silently emitting empty HTML.
|
|
||||||
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
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(
|
def render_agent_prompt_banner(
|
||||||
|
|
@ -126,28 +196,40 @@ def render_agent_prompt_banner(
|
||||||
user: dict[str, Any] | None,
|
user: dict[str, Any] | None,
|
||||||
server_url: str,
|
server_url: str,
|
||||||
) -> 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).
|
When an admin override is set:
|
||||||
Render failures are swallowed (logged) and return empty string so a
|
- Renders via Jinja2 (autoescape=True, StrictUndefined).
|
||||||
broken template never blocks the /setup page from rendering.
|
- 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()
|
row = WelcomeTemplateRepository(conn).get()
|
||||||
content = row.get("content")
|
content = row.get("content")
|
||||||
if not content:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
try:
|
if content:
|
||||||
env = Environment(undefined=StrictUndefined, autoescape=True)
|
# Admin-authored override — render as Jinja2 HTML, sanitize.
|
||||||
template = env.from_string(content)
|
try:
|
||||||
ctx = build_context(user=user, server_url=server_url)
|
env = Environment(undefined=StrictUndefined, autoescape=True)
|
||||||
rendered = template.render(**ctx)
|
template = env.from_string(content)
|
||||||
return _sanitize_banner_html(rendered)
|
ctx = build_context(user=user, server_url=server_url)
|
||||||
except TemplateError as exc:
|
rendered = template.render(**ctx)
|
||||||
logger.warning(
|
return _sanitize_banner_html(rendered)
|
||||||
"Agent-prompt banner render failed (template error): %s", exc
|
except TemplateError as exc:
|
||||||
)
|
logger.warning(
|
||||||
return ""
|
"Agent-prompt banner render failed (template error): %s", exc
|
||||||
except Exception:
|
)
|
||||||
logger.exception("Agent-prompt banner render failed (unexpected)")
|
# Fall through to default
|
||||||
return ""
|
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"
|
"title": "Content"
|
||||||
},
|
},
|
||||||
|
"default": {
|
||||||
|
"title": "Default",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
|
@ -2132,7 +2136,8 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"content"
|
"content",
|
||||||
|
"default"
|
||||||
],
|
],
|
||||||
"title": "TemplateGetResponse",
|
"title": "TemplateGetResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|
@ -11889,7 +11894,7 @@
|
||||||
},
|
},
|
||||||
"/setup": {
|
"/setup": {
|
||||||
"get": {
|
"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",
|
"operationId": "setup_page_setup_get",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,11 @@ def test_admin_get_template_initially_null(seeded_app):
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["content"] is None
|
assert body["content"] is None
|
||||||
# No longer returns a `default` field — banner default is empty
|
# default field must be present and contain the live setup script
|
||||||
assert "default" not in body or body.get("default") is None
|
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):
|
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()), (
|
assert set(_VALIDATION_STUB_CONTEXT["user"].keys()) == set(real_ctx["user"].keys()), (
|
||||||
"_VALIDATION_STUB_CONTEXT['user'] drifted from build_context output"
|
"_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 duckdb
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -8,6 +8,7 @@ from src.repositories.welcome_template import WelcomeTemplateRepository
|
||||||
from src.welcome_template import (
|
from src.welcome_template import (
|
||||||
_sanitize_banner_html,
|
_sanitize_banner_html,
|
||||||
build_context,
|
build_context,
|
||||||
|
compute_default_agent_prompt,
|
||||||
render_agent_prompt_banner,
|
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")
|
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
|
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
|
# Build context shape
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -188,14 +215,16 @@ def test_sanitize_allows_safe_html():
|
||||||
# Render failure → empty string (not exception)
|
# 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.
|
# StrictUndefined: referencing an unknown variable raises at render time.
|
||||||
WelcomeTemplateRepository(conn).set(
|
WelcomeTemplateRepository(conn).set(
|
||||||
"{{ does_not_exist }}", updated_by="admin@example.com"
|
"{{ does_not_exist }}", updated_by="admin@example.com"
|
||||||
)
|
)
|
||||||
out = render_agent_prompt_banner(conn, user=_user(), server_url="https://example.com")
|
out = render_agent_prompt_banner(conn, user=_user(), server_url="https://example.com")
|
||||||
# Must return empty string, not raise
|
# Must not raise — falls back to the live default script (non-empty)
|
||||||
assert out == ""
|
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):
|
def test_sanitize_applied_after_render(conn):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue