fix(setup): role-aware clipboard render + JSON-escape ROLE injection
Two Task 4 review fixes for app/web/templates/install.html:
1. JSON-escape `ROLE` JS const via `{{ role | tojson }}` (defense in
depth — removes the dependency on Jinja autoescape semantics for JS
contexts; FastAPI's Literal validator already constrains role values).
2. Verify the analyst tile's clipboard payload is the analyst layout.
The pre-existing role-aware plumbing (compute_default_agent_prompt
threading role into setup_instructions_lines, picked up by the JS
SETUP_INSTRUCTIONS_TEMPLATE array) was correct; adding regression tests
that pin to the JS clipboard block specifically so a future inversion
would fail loudly.
Tests: analyst clipboard contains `agnes init` + `agnes catalog` and
NOT `agnes auth import-token` / `agnes skills`; admin clipboard is the
inverse. Plus an explicit assertion that ROLE is rendered via tojson.
This commit is contained in:
parent
44234ba3ae
commit
8091620d33
2 changed files with 117 additions and 1 deletions
|
|
@ -996,7 +996,12 @@
|
|||
// Role from the Jinja ctx — drives the PAT mint shape below
|
||||
// (analyst tile mints a 1h scope=bootstrap-analyst PAT, admin keeps
|
||||
// the historical 90-day general-purpose PAT).
|
||||
const ROLE = "{{ role }}";
|
||||
// `tojson` produces a JSON-quoted JS string literal, which doubles as
|
||||
// defense-in-depth: even if `role` ever became attacker-influenced,
|
||||
// tojson would JSON-escape any embedded quotes/backslashes/script
|
||||
// sequences. (Today FastAPI's Literal validator already constrains
|
||||
// `role` to {"analyst","admin"}, so this is belt-and-suspenders.)
|
||||
const ROLE = {{ role | tojson }};
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,117 @@ def test_setup_page_admin_js_uses_general_scope(client):
|
|||
assert "expires_in_days" in text # still present in the admin body
|
||||
|
||||
|
||||
def test_setup_page_analyst_clipboard_renders_analyst_layout(client):
|
||||
"""The clipboard text the analyst tile produces must be the analyst layout
|
||||
(`agnes init` + `agnes catalog`), NOT the admin layout (`claude plugin
|
||||
marketplace add` + admin-only flow).
|
||||
|
||||
The rendered HTML embeds the role-aware `setup_instructions_lines` text
|
||||
into a JS array `SETUP_INSTRUCTIONS_TEMPLATE` (see
|
||||
`_claude_setup_instructions.jinja`); `renderSetupInstructions` substitutes
|
||||
`{server_url}` / `{token}` into that array at click time. So checking the
|
||||
embedded array against the served HTML is sufficient — if it carries the
|
||||
analyst layout, the clipboard payload will too.
|
||||
|
||||
Pinning to the JS array block specifically (not the whole page) avoids
|
||||
false positives from chrome / preview-mode <pre> renders elsewhere.
|
||||
"""
|
||||
import re
|
||||
|
||||
resp = client.get("/setup?role=analyst", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
text = resp.text
|
||||
|
||||
# Locate the JS array that holds the clipboard template body. The partial
|
||||
# emits `var SETUP_INSTRUCTIONS_TEMPLATE = [...].join("\n");` — match
|
||||
# everything between the `[` and the matching `]` so we can scope the
|
||||
# assertions to *just* what gets pasted into Claude Code, ignoring the
|
||||
# preview <pre> block that also embeds the lines.
|
||||
match = re.search(
|
||||
r"var\s+SETUP_INSTRUCTIONS_TEMPLATE\s*=\s*\[(.*?)\]\.join\(",
|
||||
text,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "SETUP_INSTRUCTIONS_TEMPLATE array not found in rendered HTML"
|
||||
clipboard_block = match.group(1)
|
||||
|
||||
# Analyst layout markers MUST be in the clipboard block.
|
||||
assert "agnes init" in clipboard_block, (
|
||||
"Analyst clipboard payload missing `agnes init` — is "
|
||||
"compute_default_agent_prompt(role=role) being threaded into "
|
||||
"setup_instructions_lines on the /setup route?"
|
||||
)
|
||||
assert "agnes catalog" in clipboard_block, (
|
||||
"Analyst clipboard payload missing `agnes catalog` smoke verify"
|
||||
)
|
||||
|
||||
# Admin-only markers MUST NOT be in the analyst clipboard block.
|
||||
# If they are, renderSetupInstructions is producing admin layout despite
|
||||
# the analyst tile being selected — the bug this test guards against.
|
||||
# `agnes auth import-token` is the admin login step (analyst uses
|
||||
# `agnes init` which bundles auth + workspace bootstrap).
|
||||
assert "agnes auth import-token" not in clipboard_block, (
|
||||
"Analyst clipboard block contains admin-only `agnes auth import-token` "
|
||||
"— role plumbing is broken; analyst sees admin instructions."
|
||||
)
|
||||
assert "agnes skills" not in clipboard_block, (
|
||||
"Analyst clipboard block contains admin-only skills setup step — "
|
||||
"analyst layout should not include skills management."
|
||||
)
|
||||
|
||||
|
||||
def test_setup_page_admin_clipboard_renders_admin_layout(client):
|
||||
"""Counterpart to the analyst test — admin tile MUST keep the existing
|
||||
full marketplace/plugins flow byte-equivalent (no regression from Task 4).
|
||||
"""
|
||||
import re
|
||||
|
||||
resp = client.get("/setup?role=admin", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
text = resp.text
|
||||
|
||||
match = re.search(
|
||||
r"var\s+SETUP_INSTRUCTIONS_TEMPLATE\s*=\s*\[(.*?)\]\.join\(",
|
||||
text,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "SETUP_INSTRUCTIONS_TEMPLATE array not found in rendered HTML"
|
||||
clipboard_block = match.group(1)
|
||||
|
||||
# Admin layout marker MUST be present. `agnes auth import-token` is the
|
||||
# admin login step (analyst replaces it with `agnes init`); `agnes skills`
|
||||
# is admin-only post-auth setup. Either one anchors the admin layout
|
||||
# without depending on RBAC plugin grants (which the unauthenticated
|
||||
# TestClient won't have).
|
||||
assert "agnes auth import-token" in clipboard_block, (
|
||||
"Admin clipboard payload missing `agnes auth import-token` — "
|
||||
"Task 4 should not have changed admin behavior."
|
||||
)
|
||||
assert "agnes skills" in clipboard_block, (
|
||||
"Admin clipboard payload missing the skills setup step"
|
||||
)
|
||||
# Analyst-only marker MUST NOT appear in admin layout. `agnes init` is
|
||||
# the analyst-only auth + workspace bootstrap; admin uses
|
||||
# `agnes auth import-token` instead.
|
||||
assert "agnes init" not in clipboard_block, (
|
||||
"Admin clipboard block leaked the analyst `agnes init` step"
|
||||
)
|
||||
|
||||
|
||||
def test_setup_page_role_is_json_escaped(client):
|
||||
"""The `ROLE` JS const must be injected via Jinja `tojson` (defense in
|
||||
depth) — not as a bare `"{{ role }}"` string interpolation. This makes
|
||||
JS string-escaping explicit and removes the dependency on Jinja
|
||||
autoescape semantics for JS contexts.
|
||||
"""
|
||||
resp = client.get("/setup?role=analyst", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
text = resp.text
|
||||
# tojson always emits double-quoted JSON: the rendered output is exactly
|
||||
# `const ROLE = "analyst";` (note: no extra space inside the quotes).
|
||||
assert 'const ROLE = "analyst";' in text
|
||||
|
||||
|
||||
def test_setup_page_js_ternary_keys_bootstrap_to_analyst(client):
|
||||
"""Mutation-resistant assertion: the `bootstrap-analyst` scope must sit on
|
||||
the truthy branch of `ROLE === "analyst"`, not on the falsy branch.
|
||||
|
|
|
|||
Loading…
Reference in a new issue