* fix(cta): fall back to textarea+execCommand when Clipboard API rejects
The "Setup a new Claude Code" CTA fetches /auth/tokens, parses the JSON
response, renders the setup script, THEN calls
`navigator.clipboard.writeText()`. Modern browsers (Safari, Firefox, and
Chrome on stricter configurations) reject `writeText` with
NotAllowedError when transient user activation has been consumed by an
intervening `await` — which is exactly the case here. Users perceived
this as "the browser blocked the copy" and got the manual-paste fallback
modal even though the textarea + `document.execCommand('copy')` path
WOULD have worked synchronously without needing fresh user activation.
`copyToClipboard` now:
- prefers the modern Clipboard API (unchanged for the happy path)
- on writeText rejection, falls back to `copyViaTextarea` instead of
surfacing the rejection to the caller's catch block.
`copyViaTextarea` is the previously-inline textarea fallback factored
out into a named helper, with two small hardening touches:
- `readonly` + `tabindex=-1` so the hidden textarea doesn't steal
focus or pop the virtual keyboard on mobile.
- explicit `setSelectionRange(0, text.length)` to belt-and-braces the
selection on iOS Safari (where `.select()` alone sometimes selects
zero chars on touch-focused textareas).
Only the CTA button needed this — the Step-1 install-command and the
connector-copy buttons all call `writeText` synchronously inside the
click handler (no awaits in between), so they keep their existing
user-gesture context and didn't hit the same rejection. No template
changes there.
* refactor(home): fold Atlassian MCP registration into connectors block
The standalone "Register the Atlassian MCP server" step (was step 6 in
the unified setup script) moves INTO the Atlassian connector's prompt
body so all Atlassian-related setup lives in one logical group. Same
intent that #247 carried for connectors, applied one level deeper:
the hosted Remote MCP registration is part of "set up Atlassian", not
its own ungrouped step.
What changed:
- `app/web/connector_prompts.py` — the Atlassian prompt's step 5
replaces the speculative "Register the on-demand Atlassian MCP under
.claude/mcp/atlassian" line with the actual hosted Remote MCP
registration: `claude mcp add --transport sse atlassian
https://mcp.atlassian.com/v1/sse || true`. The `|| true` keeps re-runs
idempotent and the body explains the OAuth-on-first-use contract.
Both /home's Atlassian tile and the inlined setup-script Atlassian
sub-block emit this line — single source of truth holds.
- `app/web/setup_instructions.py` — `_mcp_servers_block` deleted; the
`mcp_servers` step is removed from `_step_numbers`; resolve_lines no
longer calls it.
- Renumbering: install (1), init (2), catalog (3), preflight (4),
marketplace (5), diagnose (6), connectors (7), confirm (8). Was:
6 = mcp_servers, 7 = diagnose, 8 = connectors, 9 = confirm.
- `tests/test_setup_instructions.py` — Confirm step 9→8, Connect 8→7,
diagnose 7→6, mcp_servers references dropped.
`test_step_numbering_with_connectors_step` now asserts
`"mcp_servers" not in steps`. Stray-Confirm assertion lists shift
by one position.
- `tests/test_setup_page_unified.py` + `tests/test_web_ui.py` — same
step-number shifts in the rendered /setup preview assertions.
The `claude mcp add` line is still the Atlassian Remote-MCP path that
the 2026-05-10 init-report Fix C added — only its position in the
flow changes. /home Atlassian tile copying continues to install the
MCP too (the prompt body the tile pastes contains the same line).
112 tests pass.
* feat(atlassian): operator-overrideable base URL via AGNES_ATLASSIAN_BASE_URL
Adds an env var / YAML key the operator (Terraform module, customer-VM
template, OSS instance.yaml) can set to bake the Atlassian Cloud site
root into the connector prompt — so end users don't have to guess /
paste their org's `https://<myorg>.atlassian.net`.
When set, the Atlassian connector prompt (rendered on both /home tile
and inlined into the setup-script step 7 Atlassian sub-block) replaces
step 1's "Ask me for my Atlassian Cloud site URL and email" with a
one-line note that the URL is already provisioned by the operator and
asks only for the email. Step 4's helper-script body has the
`BASE_URL='<the site URL I gave you>'` placeholder substituted with
the literal value. When unset (empty), the existing "ask the user"
flow remains — no regression for OSS instances.
Resolution + normalization in `get_atlassian_base_url()`:
- env `AGNES_ATLASSIAN_BASE_URL` > yaml `instance.atlassian.base_url` > ""
- strips trailing slash + trailing `/wiki` so the canonical value is
the bare site root. Matches the per-user helper script's
normalization at storage time (atlassian_prompt step 4 guard 2), so
the literal baked in by the operator stays consistent with what the
user's helper script would have computed from their input.
Plumbing:
- `app/instance_config.py`: new `get_atlassian_base_url()` resolver.
- `app/web/connector_prompts.py`:
- `atlassian_prompt(*, base_url: str = "")` — string-replace two
explicit placeholder phrases when base_url is truthy; otherwise
return the prompt unchanged.
- `all_connector_prompts(..., atlassian_base_url: str = "")` —
forwards the kwarg.
- `app/web/router.py` (`_build_context`): reads
`get_atlassian_base_url()` and passes it through to
`all_connector_prompts(...)` so both the /home tile context AND the
inlined-script `resolve_lines(...)` call use the same value.
- `src/welcome_template.py` (`compute_default_agent_prompt`): same
threading via the existing import-on-demand path.
Tests (`tests/test_home_route_resolution.py`):
- `get_atlassian_base_url` resolver: default empty, env override,
trailing-slash strip, trailing-`/wiki` strip.
- `atlassian_prompt(base_url=...)`: literal URL baked in, ask-step
removed, placeholder replaced, operator-baked-in copy appears.
- `atlassian_prompt(base_url="")`: existing ask-the-user flow
unchanged.
- `all_connector_prompts(atlassian_base_url=...)`: kwarg threads
through to the rendered atlassian prompt.
135 tests pass.
* feat(asana): register hosted Asana Remote MCP in connector prompt
The Asana connector prompt only stored a PAT in the OS keychain + ran
a curl verify against /api/1.0/users/me. That set Claude Code up for
direct `curl` calls but didn't actually wire Asana into Claude's tool
list — so the user couldn't ask Claude to "find my open Asana tasks"
and have it work. Symmetric oversight to the Atlassian connector's
original speculative `.claude/mcp/atlassian` line that this branch
already replaced with `claude mcp add --transport sse atlassian
https://mcp.atlassian.com/v1/sse`.
Adds a new step 5 that registers Asana's hosted Remote MCP:
claude mcp add --transport http asana https://mcp.asana.com/mcp || true
This is the V2 endpoint (streamable HTTP transport, launched February
2026). The V1 SSE endpoint at https://mcp.asana.com/sse was deprecated
2026-05-11 (today) and must NOT be used — calling it out explicitly
in the prompt body so a future operator who finds an old reference
doesn't paste the dead URL. OAuth is handled by Claude Code at first
use, same model as the Atlassian MCP step.
The PAT stored in step 3 stays for direct `curl` calls (precheck +
ad-hoc scripts) — the MCP path uses its own OAuth grant, not the PAT.
Old step 5 (revoke instructions) renumbers to step 6 and adds the
`claude mcp remove asana` cleanup hint.
Same single-source-of-truth invariant holds: /home Asana tile + the
inlined Asana sub-block in the setup script (step 7 connectors) both
emit identical text from `asana_prompt()`.
71 tests pass.
* feat(asana): drive MCP OAuth login + end-to-end validation post-register
`claude mcp add --transport http asana ...` only registers the
server in Claude Code's local config — it does NOT trigger OAuth.
The browser tab opens the first time any `mcp__asana__*` tool gets
invoked. So the previous step 5 left a user looking at a "registered"
MCP that, in practice, hadn't authed yet and would fail on first
real use. Same blind spot Atlassian's prompt also has, but Asana was
the one called out in the latest review pass.
Adds a new step 6 between MCP registration (step 5) and the revoke
instructions (now step 7):
a. Tell the user verbatim what's about to happen — a low-impact
read through the MCP will pop the OAuth browser tab; sign in
with the same account whose PAT they stored in step 3 and
approve. Frames the OAuth as one-time so users don't wait
for it on every later call.
b. Drive an actual MCP read. Don't prescribe the exact tool name
because the Asana MCP's exposed surface (`mcp__asana__*`) is
versioned upstream and we don't want to pin to a name that
gets renamed. Instead: tell Claude to pick the lightest read
from its surfaced tool list (users-me / list-workspaces /
equivalent). Document the recovery path when Claude Code
times out waiting for the OAuth tool use: `claude mcp list`
to confirm registration before retrying.
c. Print a single one-line proof that combines wiring + auth:
"Asana MCP connected as <name> — <N> workspace(s) visible."
Explicit anti-echo callout for tokens, task content, comments.
On failure, surface the exact Claude-Code error and stop —
no silent pass.
d. Sanity-check that the MCP OAuth identity and the PAT identity
reference the same Asana account. Easy mistake to make when
the user has multiple Asana accounts — flag only on mismatch,
keep quiet when they match. Recovery: `claude mcp remove asana
&& claude logout asana` then redo step 5.
Step 7 (revoke) absorbs both the keychain delete + the
`claude logout asana` line so users have a single place to undo
everything.
43 tests pass.
* fix(init): clear stale CA env vars on Windows before any TLS handshake
Reported by the 2026-05-11 Windows test pass: after `agnes init` the
gws connector failed with `UnknownIssuer` TLS errors because
`SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` were still set in Windows
User scope pointing at `C:\Users\localadmin\.config\agnes\ca-bundle.pem`
— a file that did not exist on the test host. Past Agnes installs
(the setup-prompt trust block + older bootstrap helpers) write those
pointers when they materialize a combined Agnes-CA bundle; when the
bundle file later disappears (re-init on a new VM, machine swap, the
~/.agnes dir wiped), the pointers go stale and every native Windows
TLS handshake fails before Agnes itself runs. SSL_CERT_FILE in
particular REPLACES (not appends to) the trust store, so a stale
pointer is silently catastrophic.
`agnes init` now clears stale pointers in two layers before the first
server roundtrip:
1. Current-process env (os.environ) — what the immediately-following
`api_get` to /api/catalog/tables actually reads. Without this, init
itself blows up before it gets to step 2.
2. Windows User-scope env via PowerShell
`[Environment]::SetEnvironmentVariable(name, $null, 'User')` — what
every future shell + every native tool (gws, claude.exe, pip, uv)
inherits. The 2026-05-11 reporter expected this exact cleanup
("init was supposed to clear these but they persisted").
The cleanup is best-effort and conservative:
- Only deletes a var when its value points at a path that does NOT
exist on disk. Intentional operator config (e.g. SSL_CERT_FILE
pointing at a corp certifi bundle) stays put.
- PowerShell missing / restricted execution policy / WSL-without-pwsh:
swallowed silently. The current-process leg still runs, which
unblocks init even on hosts where the User-scope leg cannot fire.
Tests (`tests/test_init_ca_cleanup.py`, 6 cases):
- Stale pointers → removed from process env.
- Real-path pointers → preserved.
- Non-Windows hosts: PowerShell is not invoked.
- Windows hosts: PowerShell IS invoked with a script that checks
all three vars + uses Test-Path + SetEnvironmentVariable.
- PowerShell FileNotFoundError: cleanup swallows it, does not raise.
- `_is_windows_host()` reflects sys.platform.
* refactor(asana): MCP-first flow — drop PAT storage, precheck via `claude mcp list`
The Asana hosted MCP at https://mcp.asana.com/mcp authenticates via
OAuth (Claude Code holds the grant; browser tab pops on first tool
use). The earlier prompt walked the user through creating + keychain-
storing an Asana Personal Access Token AND registering the MCP — two
parallel auth surfaces for one connector. Once the MCP works, the PAT
has no consumer: the precheck/verify steps that used `curl
$BASE/api/1.0/users/me` are just redundant proof that Asana itself is
reachable, which the OAuth handshake already establishes.
Removed:
- Step 0 keychain probe + curl verify against /users/me with PAT.
- Step 1 open developer-console / create PAT.
- Step 2 click "+ New access token", warn shown-ONCE.
- Step 3 helper-script for keychain-storage (per-OS bodies: macOS
`security add-generic-password`, Linux `secret-tool store`, Windows
`cmdkey /generic`).
- Step 4 PAT-side `users/me` verify.
- Step 5's split that kept the PAT around for direct curl scripts.
- Step 6d's "MCP vs PAT identity sanity check" — there is no PAT
anymore, nothing to mismatch against.
New flow (3 steps total):
- Step 0 precheck: `claude mcp list | grep ^asana` — if found, the
server is registered AND Claude Code is holding its OAuth grant
(otherwise prior failure would have removed it); print
"Asana MCP already registered — skipping setup" and stop. Tells the
user the explicit reset command (`claude mcp remove asana && claude
logout asana`) so a re-register stays one paste.
- Step 1: `claude mcp add --transport http asana
https://mcp.asana.com/mcp` — no `|| true` because step 0 should have
caught the "already exists" case. Step explains the V2-vs-V1
endpoint distinction (V1 SSE deprecated 2026-05-11) and the
abort-clean recovery if the precheck somehow missed the existing
server.
- Step 2: same OAuth + low-impact-read validation pattern as before.
- Step 3: revoke instructions (mcp remove + logout + Asana-side app
revoke at app.asana.com/Settings → Apps).
Both surfaces (the /home Asana tile and the inlined Asana sub-block
in the setup script's step 7) emit the new text from the same
asana_prompt() — single-source-of-truth invariant intact.
77 tests pass.
885 lines
40 KiB
Python
885 lines
40 KiB
Python
"""Tests for the setup-instructions template + resolver.
|
|
|
|
`uv tool install` validates the PEP 427 filename in the URL path before
|
|
fetching, so our setup snippet cannot use a stable alias like `agnes.whl`.
|
|
These tests pin the wheel-filename substitution behavior, the marketplace
|
|
block layout, and the cross-platform TLS trust block (`ca_pem` path).
|
|
|
|
The trust-block tests assert behaviors that came out of a real-world
|
|
multi-machine setup pass — see the v2 design notes in the module docstring
|
|
of `app/web/setup_instructions.py` for the rationale behind each assertion
|
|
(combined CA bundle vs. single-cert SSL_CERT_FILE, OS-trust-store
|
|
registration for native binaries, platform-aware marketplace strategy,
|
|
curl-then-local-install around rustls' `CaUsedAsEndEntity`).
|
|
"""
|
|
|
|
|
|
def test_resolve_lines_substitutes_wheel_filename():
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
lines = resolve_lines("agnes_the_ai_analyst-2.0.0-py3-none-any.whl")
|
|
joined = "\n".join(lines)
|
|
assert "{wheel_filename}" not in joined
|
|
assert "/cli/wheel/agnes_the_ai_analyst-2.0.0-py3-none-any.whl" in joined
|
|
|
|
|
|
def test_resolve_lines_fallback_filename_is_honoured():
|
|
"""Callers pass `'agnes.whl'` when no wheel is on disk; substitution still works."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
lines = resolve_lines("agnes.whl")
|
|
assert "{wheel_filename}" not in "\n".join(lines)
|
|
assert any("/cli/wheel/agnes.whl" in line for line in lines)
|
|
|
|
|
|
def test_render_setup_instructions_wires_all_placeholders():
|
|
from app.web.setup_instructions import render_setup_instructions
|
|
|
|
out = render_setup_instructions(
|
|
server_url="https://agnes.example.com",
|
|
token="T-123",
|
|
wheel_filename="agnes_the_ai_analyst-2.0.0-py3-none-any.whl",
|
|
)
|
|
assert "{server_url}" not in out
|
|
assert "{token}" not in out
|
|
assert "{wheel_filename}" not in out
|
|
assert "https://agnes.example.com/cli/wheel/agnes_the_ai_analyst-2.0.0-py3-none-any.whl" in out
|
|
assert "T-123" in out
|
|
|
|
|
|
def test_resolve_lines_no_plugins_unified_layout():
|
|
"""Unified always-on layout: 1 install, 2 init, 3 catalog, 4 preflight,
|
|
5 marketplace, 6 mcp_servers, 7 diagnose, 8 connectors, 9 confirm.
|
|
Preflight + marketplace + MCP + connectors block are emitted even when
|
|
the operator has zero plugin grants — registering the per-user
|
|
marketplace clone pre-wires the SessionStart hook, the Atlassian
|
|
Remote MCP applies to every analyst whose work touches Jira/
|
|
Confluence, and the connectors block is per-connector default-yes
|
|
(the user can decline each individually). Skills step deleted in
|
|
#242."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(resolve_lines("agnes.whl"))
|
|
# Mandatory unified-flow steps.
|
|
assert "1) Install the CLI" in joined
|
|
assert "2) Bootstrap your Agnes workspace" in joined
|
|
assert "3) Verify the data is queryable:" in joined
|
|
assert "4) Make sure git and claude are installed" in joined
|
|
assert "5) Register the Agnes Claude Code marketplace" in joined
|
|
assert "6) Run diagnostics:" in joined
|
|
assert "7) Connect the user's tools" in joined
|
|
assert "8) Confirm:" in joined
|
|
# No stray Confirms at other positions.
|
|
assert "10) Confirm:" not in joined
|
|
assert "6) Confirm:" not in joined
|
|
# Skills step is intentionally absent.
|
|
assert "Skills (ask the user" not in joined
|
|
# The marketplace step header adapts to "no plugins granted yet" copy
|
|
# rather than the plugin-installing variant.
|
|
assert "no plugins granted yet" in joined
|
|
assert "agnes refresh-marketplace --bootstrap" in joined
|
|
# MCP step uses SSE transport for Atlassian's hosted Remote MCP.
|
|
assert "claude mcp add --transport sse atlassian https://mcp.atlassian.com/v1/sse" in joined
|
|
# Legacy `git config sslVerify=false` downgrade must NOT be emitted.
|
|
# Match the specific config line, not the bare substring (which appears
|
|
# in the preamble as a "don't do this" example).
|
|
assert "git config --global" not in joined
|
|
# Trust block isn't emitted without ca_pem either.
|
|
assert "0) Trust the Agnes TLS certificate" not in joined
|
|
assert "step 0(d)" not in joined
|
|
assert "Which CA bundle source got picked" not in joined
|
|
# Legacy admin-only auth verbs are gone — `agnes init` subsumes them.
|
|
assert "agnes auth import-token" not in joined
|
|
assert "agnes auth whoami" not in joined
|
|
|
|
|
|
def test_preamble_step_zero_d_reference_only_when_trust_block_emitted():
|
|
"""The preamble's "fallback chain inside step 0(d)" line is only
|
|
correct when step 0 actually exists. Without ca_pem the reference
|
|
points at a non-existent step."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
no_ca = "\n".join(resolve_lines("agnes.whl"))
|
|
assert "step 0(d)" not in no_ca
|
|
# The "don't disable TLS verification" guidance still appears (it's
|
|
# generic safety advice, valid regardless of trust block).
|
|
assert "NODE_TLS_REJECT_UNAUTHORIZED" in no_ca
|
|
|
|
fake_ca = (
|
|
"-----BEGIN CERTIFICATE-----\n"
|
|
"FAKEFAKEFAKE\n"
|
|
"-----END CERTIFICATE-----\n"
|
|
)
|
|
with_ca = "\n".join(resolve_lines("agnes.whl", ca_pem=fake_ca))
|
|
# Trust block emits step 0 → preamble's step 0(d) reference is now valid.
|
|
assert "step 0(d)" in with_ca
|
|
|
|
|
|
def test_finale_bullets_match_emitted_steps():
|
|
"""The Confirm step's bullets must reference only steps that were
|
|
actually emitted. CA bundle bullet is gated on `has_ca`. The
|
|
marketplace clone bullet is unconditional now (Fix B in 2026-05-10
|
|
init-report response: marketplace block is always emitted regardless
|
|
of plugin grants)."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
fake_ca = (
|
|
"-----BEGIN CERTIFICATE-----\n"
|
|
"FAKE\n"
|
|
"-----END CERTIFICATE-----\n"
|
|
)
|
|
|
|
# No ca, no plugins: marketplace bullet present, CA bullet absent.
|
|
plain = "\n".join(resolve_lines("agnes.whl"))
|
|
assert "Which CA bundle source got picked" not in plain
|
|
assert "~/.agnes/marketplace/.git/" in plain
|
|
|
|
# ca only: both bullets present.
|
|
ca_only = "\n".join(resolve_lines("agnes.whl", ca_pem=fake_ca))
|
|
assert "Which CA bundle source got picked" in ca_only
|
|
assert "~/.agnes/marketplace/.git/" in ca_only
|
|
|
|
# plugins only: marketplace bullet yes, CA bullet no.
|
|
pl_only = "\n".join(
|
|
resolve_lines("agnes.whl", plugin_install_names=["foo"], server_host="h")
|
|
)
|
|
assert "Which CA bundle source got picked" not in pl_only
|
|
assert "~/.agnes/marketplace/.git/" in pl_only
|
|
|
|
# Both: both bullets present.
|
|
both = "\n".join(
|
|
resolve_lines(
|
|
"agnes.whl",
|
|
plugin_install_names=["foo"],
|
|
server_host="h",
|
|
ca_pem=fake_ca,
|
|
)
|
|
)
|
|
assert "Which CA bundle source got picked" in both
|
|
assert "~/.agnes/marketplace/.git/" in both
|
|
|
|
|
|
def test_trust_block_rc_heredoc_writes_exactly_8_lines():
|
|
"""The trust block emits a heredoc that appends to the user's shell rc.
|
|
The companion `agnes-client-reset.sh` strips the block via awk that
|
|
`skip = 8` from the AGNES_CA_PEM_TRUST marker, so the heredoc MUST
|
|
write exactly 8 lines (marker + 7 export/comment lines). If the
|
|
heredoc body is 9+ lines, repeated install/reset cycles leave stray
|
|
empty lines in the rc file (Devin Review round 3 BUG_0001).
|
|
|
|
Source of truth pinning: this test cross-checks the marker count with
|
|
the reset script's `skip = N` so the two stay in sync."""
|
|
from app.web.setup_instructions import _tls_trust_block
|
|
|
|
fake_ca = (
|
|
"-----BEGIN CERTIFICATE-----\n"
|
|
"FAKE\n"
|
|
"-----END CERTIFICATE-----\n"
|
|
)
|
|
lines = _tls_trust_block(fake_ca)
|
|
joined = "\n".join(lines)
|
|
|
|
# Locate heredoc bounds in the emitted shell.
|
|
start = joined.index("<<'AGNES_RC_BLOCK'")
|
|
end = joined.index("\nAGNES_RC_BLOCK\n", start)
|
|
# Body = lines BETWEEN the opening `<<'AGNES_RC_BLOCK'` line and the
|
|
# closing `AGNES_RC_BLOCK` delimiter.
|
|
after_open = joined.index("\n", start) + 1 # first body line starts here
|
|
body = joined[after_open:end]
|
|
body_lines = body.split("\n")
|
|
|
|
# Must be exactly 8 lines: marker + 7 content lines.
|
|
assert len(body_lines) == 8, (
|
|
f"Heredoc body has {len(body_lines)} lines; reset script awk "
|
|
f"skips 8 lines, so any drift leaves stray lines in the rc file. "
|
|
f"Body was:\n" + "\n".join(f" {i+1:2d} {ln!r}" for i, ln in enumerate(body_lines))
|
|
)
|
|
# First body line MUST be the marker (anchor for the reset awk).
|
|
assert body_lines[0] == "# AGNES_CA_PEM_TRUST — added by Agnes setup"
|
|
|
|
|
|
def test_trust_block_rc_heredoc_count_matches_reset_script_skip():
|
|
"""Stronger version of the previous test: read the actual `skip = N`
|
|
integer literal out of `scripts/dev/agnes-client-reset.sh` and assert
|
|
it matches the heredoc body line count. If someone changes either
|
|
side without updating the other, this test fails loudly."""
|
|
import re
|
|
from pathlib import Path
|
|
from app.web.setup_instructions import _tls_trust_block
|
|
|
|
fake_ca = (
|
|
"-----BEGIN CERTIFICATE-----\n"
|
|
"FAKE\n"
|
|
"-----END CERTIFICATE-----\n"
|
|
)
|
|
joined = "\n".join(_tls_trust_block(fake_ca))
|
|
start = joined.index("<<'AGNES_RC_BLOCK'")
|
|
end = joined.index("\nAGNES_RC_BLOCK\n", start)
|
|
after_open = joined.index("\n", start) + 1
|
|
body_line_count = len(joined[after_open:end].split("\n"))
|
|
|
|
# Resolve the reset script relative to this test file (works from any cwd).
|
|
repo_root = Path(__file__).resolve().parents[1]
|
|
reset_sh = (repo_root / "scripts" / "dev" / "agnes-client-reset.sh").read_text()
|
|
match = re.search(r"AGNES_CA_PEM_TRUST.*?skip\s*=\s*(\d+)", reset_sh, re.DOTALL)
|
|
assert match, "Could not locate `skip = N` near AGNES_CA_PEM_TRUST in reset script"
|
|
reset_skip = int(match.group(1))
|
|
|
|
assert body_line_count == reset_skip, (
|
|
f"Heredoc body has {body_line_count} lines but reset script skips "
|
|
f"{reset_skip}. Update one side to match — either trim the heredoc "
|
|
f"or bump the awk skip count."
|
|
)
|
|
|
|
|
|
def test_trust_block_step_0c_does_not_reference_stale_step_number():
|
|
"""Step 0(c) used to say 'without this, step 7's marketplace add fails'
|
|
but after the layout reordering, marketplace is step 5 (when plugins
|
|
exist) or doesn't exist at all (when no plugins). The reference must
|
|
not name a stale step number."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
fake_ca = (
|
|
"-----BEGIN CERTIFICATE-----\n"
|
|
"FAKE\n"
|
|
"-----END CERTIFICATE-----\n"
|
|
)
|
|
joined = "\n".join(resolve_lines("agnes.whl", ca_pem=fake_ca))
|
|
# The stale "step 7's marketplace add" string must be gone.
|
|
assert "step 7's marketplace add" not in joined
|
|
# Replacement text describes the consequence without a step number.
|
|
assert "marketplace `git" in joined and "clone`" in joined
|
|
|
|
|
|
def test_resolve_lines_with_plugins_uses_install_first_diagnose_last_layout():
|
|
"""Marketplace layout puts install/init/catalog/preflight/marketplace
|
|
BEFORE diagnose, so diagnose is the final smoke test before Confirm.
|
|
Step numbers: 4 preflight, 5 marketplace, 6 mcp, 7 diagnose,
|
|
8 confirm. No skills step — interactive copy-or-on-demand question
|
|
was confusing; on-demand `agnes skills show` is the default."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
lines = resolve_lines(
|
|
"agnes.whl",
|
|
plugin_install_names=["foo", "bar"],
|
|
server_host="agnes.example.com",
|
|
)
|
|
joined = "\n".join(lines)
|
|
# Step 4 — pre-flight, with all three platforms' install commands.
|
|
assert "4) Make sure git and claude are installed" in joined
|
|
assert "git --version" in joined
|
|
assert "claude --version" in joined
|
|
assert "brew install git" in joined
|
|
assert "winget install --id Git.Git -e --source winget --silent" in joined
|
|
assert "sudo apt-get install git" in joined or "sudo dnf install git" in joined
|
|
# Step 5 — marketplace + stack install.
|
|
assert "5) Register the Agnes Claude Code marketplace" in joined
|
|
assert "agnes refresh-marketplace --bootstrap" in joined
|
|
# The destructive prep + per-plugin install commands are inside the
|
|
# CLI; the prompt must not emit the inline shell forms in
|
|
# operator-runnable lines.
|
|
executable = _executable_lines(joined)
|
|
assert "rm -rf ~/.agnes/marketplace" not in executable
|
|
assert "git clone " not in executable
|
|
assert "git remote set-url origin" not in executable
|
|
assert "claude plugin marketplace add" not in executable
|
|
assert "claude plugin install foo@agnes" not in executable
|
|
assert "claude plugin install bar@agnes" not in executable
|
|
# Step 6 — Atlassian MCP registration (Fix C in 2026-05-10 init-report response).
|
|
# Step 7 — diagnose now AFTER marketplace + MCP wiring.
|
|
assert "6) Run diagnostics:" in joined
|
|
# Step 8 — connectors, the LAST interactive step before Confirm
|
|
# (skills step deleted in #242).
|
|
assert "7) Connect the user's tools" in joined
|
|
assert "8) Confirm:" in joined
|
|
for stray in ("4) Confirm:", "5) Confirm:", "6) Confirm:", "7) Confirm:", "9) Confirm:", "10) Confirm:"):
|
|
assert stray not in joined
|
|
# Crucial ordering invariants for the new layout.
|
|
install_idx = joined.index("1) Install the CLI")
|
|
init_idx = joined.index("2) Bootstrap your Agnes workspace")
|
|
catalog_idx = joined.index("3) Verify the data is queryable:")
|
|
git_idx = joined.index("4) Make sure git and claude are installed")
|
|
market_idx = joined.index("5) Register the Agnes Claude Code marketplace")
|
|
diag_idx = joined.index("6) Run diagnostics:")
|
|
conn_idx = joined.index("7) Connect the user's tools")
|
|
confirm_idx = joined.index("8) Confirm:")
|
|
assert install_idx < init_idx < catalog_idx < git_idx < market_idx < diag_idx < conn_idx < confirm_idx
|
|
# Legacy `git config sslVerify=false` downgrade is gone — see CHANGELOG.
|
|
assert "git config --global" not in joined
|
|
# server_host is server-side substituted; the placeholder must be gone.
|
|
assert "{server_host}" not in joined
|
|
# server_url + token are still placeholders for click-time JS substitution.
|
|
assert "{server_url}" in joined
|
|
assert "{token}" in joined
|
|
|
|
|
|
def test_preflight_checks_both_git_and_claude():
|
|
"""Pre-flight (step 4 when marketplace is gated on) checks BOTH binaries
|
|
before the marketplace clone — `git --version` is needed for the clone
|
|
itself, `claude --version` is needed for the `claude plugin
|
|
marketplace add` / `claude plugin install` calls. Either missing
|
|
breaks the marketplace step in a confusing way, so we surface the
|
|
failure before we get there.
|
|
"""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(
|
|
resolve_lines(
|
|
"agnes.whl",
|
|
plugin_install_names=["foo"],
|
|
server_host="agnes.example.com",
|
|
)
|
|
)
|
|
# Both version checks present.
|
|
assert "git --version" in joined
|
|
assert "claude --version" in joined
|
|
# Header mentions both tools.
|
|
assert "Make sure git and claude are installed" in joined
|
|
# Install hints for claude — npm one-liner for Linux/WSL plus a doc URL
|
|
# for native installers on macOS / Windows. We don't try to one-line a
|
|
# native installer; the canonical instructions live upstream.
|
|
assert "npm i -g @anthropic-ai/claude-code" in joined
|
|
assert "https://docs.claude.com/claude-code" in joined
|
|
# Both checks come BEFORE the marketplace add line.
|
|
git_check_idx = joined.index("git --version")
|
|
claude_check_idx = joined.index("claude --version")
|
|
market_idx = joined.index("claude plugin marketplace add")
|
|
assert git_check_idx < market_idx
|
|
assert claude_check_idx < market_idx
|
|
|
|
|
|
def test_render_setup_instructions_with_plugins_substitutes_all_placeholders():
|
|
from app.web.setup_instructions import render_setup_instructions
|
|
|
|
out = render_setup_instructions(
|
|
server_url="https://agnes.example.com",
|
|
token="T-XYZ",
|
|
wheel_filename="agnes-1.0-py3-none-any.whl",
|
|
plugin_install_names=["foo", "bar"],
|
|
server_host="agnes.example.com",
|
|
)
|
|
# No raw placeholders remain in the final string.
|
|
assert "{server_url}" not in out
|
|
assert "{token}" not in out
|
|
assert "{wheel_filename}" not in out
|
|
assert "{server_host}" not in out
|
|
# Token still appears for `agnes init` (step 2). The marketplace
|
|
# step uses `agnes refresh-marketplace --bootstrap` which reads the
|
|
# token from the agnes config that step 2 just wrote, so no token
|
|
# in any URL inside step 5.
|
|
assert "T-XYZ" in out
|
|
# The legacy `git config --global ... sslVerify false` downgrade is gone
|
|
# (see CHANGELOG: it tripped Claude Code auto-mode classifiers and was
|
|
# only ever a safety net for AGNES_DEBUG_AUTH instances without a
|
|
# fullchain.pem on disk). Self-signed and private-CA cases are now
|
|
# exclusively handled by the step 0 trust block (gated on `ca_pem`).
|
|
assert "git config --global" not in out
|
|
# Marketplace step is the one-liner; no per-plugin install lines.
|
|
assert "agnes refresh-marketplace --bootstrap" in out
|
|
assert "claude plugin install foo@agnes" not in out
|
|
assert "claude plugin install bar@agnes" not in out
|
|
|
|
|
|
_FAKE_CA_PEM = (
|
|
"-----BEGIN CERTIFICATE-----\n"
|
|
"MIIBkTCB+wIJAKf9$x`cNotARealCert\n" # `$` and backtick: smoke test for shell-quote safety
|
|
"thisIsNotARealCertificateBodyJustAnInlinePlaceholder==\n"
|
|
"-----END CERTIFICATE-----\n"
|
|
)
|
|
|
|
|
|
def test_resolve_lines_with_ca_pem_emits_step_zero_trust_block():
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
lines = resolve_lines("agnes.whl", ca_pem=_FAKE_CA_PEM)
|
|
joined = "\n".join(lines)
|
|
|
|
# Step 0 header (must come BEFORE step 1 in the rendered prompt).
|
|
assert "0) Trust the Agnes TLS certificate" in joined
|
|
# The "1) Install the CLI" line wording differs between the ca_pem and
|
|
# no-ca_pem paths; the ca_pem path leads with "1) Install the CLI."
|
|
# (period). Ordering is what matters.
|
|
assert joined.index("0) Trust the Agnes TLS certificate") < joined.index("1) Install the CLI")
|
|
|
|
# PEM body inlined verbatim, flush-left (heredoc would corrupt indented content).
|
|
assert "-----BEGIN CERTIFICATE-----" in joined
|
|
assert "-----END CERTIFICATE-----" in joined
|
|
# The PEM is passed inside a single-quoted heredoc so `$` / backtick
|
|
# in real-world cert bodies are NOT shell-expanded — preserve verbatim.
|
|
assert "MIIBkTCB+wIJAKf9$x`cNotARealCert" in joined
|
|
assert "<<'AGNES_CA_PEM'" in joined
|
|
|
|
|
|
def test_resolve_lines_with_ca_pem_emits_cross_platform_substeps():
|
|
"""Step 0 must contain the v2 cross-platform sub-blocks: platform detection,
|
|
OS-trust-store registration, combined CA bundle build, env persistence."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(resolve_lines("agnes.whl", ca_pem=_FAKE_CA_PEM))
|
|
|
|
# (a) Platform detection — uname-driven, with all three families covered.
|
|
assert "case \"$(uname -s)\" in" in joined
|
|
assert "Darwin" in joined and "PLATFORM=macos" in joined
|
|
assert "Linux" in joined and "PLATFORM=linux" in joined
|
|
# MINGW/MSYS/CYGWIN cover Git Bash on Windows.
|
|
assert "MINGW*|MSYS*|CYGWIN*" in joined and "PLATFORM=windows" in joined
|
|
# Shell rc selection driven by $SHELL, not file existence.
|
|
assert 'SHELL_NAME="$(basename "${SHELL:-bash}")"' in joined
|
|
assert "bash:macos)" in joined and ".bash_profile" in joined # macOS bash → .bash_profile
|
|
|
|
# (c) OS trust store registration — one command per platform.
|
|
assert "certutil.exe -user -addstore" in joined # Windows
|
|
assert "security add-trusted-cert -r trustRoot" in joined # macOS
|
|
assert "update-ca-certificates" in joined # Linux Debian
|
|
assert "update-ca-trust" in joined # Linux RHEL
|
|
|
|
# (d) Combined CA bundle — multi-source fallback chain.
|
|
assert "ca-bundle.pem" in joined # the combined bundle path
|
|
assert "import certifi; print(certifi.where())" in joined # system Python source
|
|
# System curl bundle paths covering Git-for-Windows, macOS Homebrew, Debian, RHEL.
|
|
assert "/mingw64/ssl/certs/ca-bundle.crt" in joined
|
|
assert "/etc/ssl/certs/ca-certificates.crt" in joined
|
|
assert "/etc/ssl/cert.pem" in joined
|
|
# uv-fetched as last resort.
|
|
assert "uv run --native-tls --with certifi --no-project" in joined
|
|
|
|
|
|
def test_resolve_lines_with_ca_pem_uses_combined_bundle_for_replace_envs():
|
|
"""SSL_CERT_FILE/REQUESTS_CA_BUNDLE/GIT_SSL_CAINFO must point at the
|
|
COMBINED bundle (~/.agnes/ca-bundle.pem), not at the single Agnes cert.
|
|
Pointing them at the single cert would replace the trust store and
|
|
break PyPI / public-host access for any Python tool in the same shell.
|
|
NODE_EXTRA_CA_CERTS keeps pointing at just ca.pem because Node's
|
|
semantics is additive (appends to bundled roots)."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(resolve_lines("agnes.whl", ca_pem=_FAKE_CA_PEM))
|
|
|
|
# REPLACE-semantics envs → combined bundle.
|
|
assert 'export SSL_CERT_FILE="$HOME/.agnes/ca-bundle.pem"' in joined
|
|
assert 'export REQUESTS_CA_BUNDLE="$HOME/.agnes/ca-bundle.pem"' in joined
|
|
assert 'export GIT_SSL_CAINFO="$HOME/.agnes/ca-bundle.pem"' in joined
|
|
# APPEND-semantics env → single-cert file.
|
|
assert 'export NODE_EXTRA_CA_CERTS="$HOME/.agnes/ca.pem"' in joined
|
|
|
|
# Persisted to shell rc behind an idempotent grep guard so re-running
|
|
# setup doesn't duplicate the block.
|
|
assert "AGNES_CA_PEM_TRUST" in joined # marker grep-checks for
|
|
assert "AGNES_RC_BLOCK" in joined # the rc-append heredoc delimiter
|
|
|
|
|
|
def test_resolve_lines_with_ca_pem_switches_step_one_to_curl_then_local_install():
|
|
"""Step 1's install path differs by has_ca:
|
|
- has_ca=True → curl-then-local-install (avoids rustls CaUsedAsEndEntity)
|
|
- has_ca=False → direct `uv tool install <https-url>` (legacy)
|
|
"""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined_ca = "\n".join(resolve_lines("agnes-1.0-py3-none-any.whl", ca_pem=_FAKE_CA_PEM))
|
|
# curl-with-cacert downloads the wheel locally...
|
|
assert "curl -fsSL --cacert ~/.agnes/ca.pem" in joined_ca
|
|
assert 'WHEEL=/tmp/agnes-1.0-py3-none-any.whl' in joined_ca
|
|
# ...then uv installs from the local file with --native-tls.
|
|
assert 'uv tool install --native-tls --force "$WHEEL"' in joined_ca
|
|
# The direct `uv tool install <server-url>` form must NOT appear in the ca_pem path.
|
|
assert "uv tool install --force {server_url}/cli/wheel/" not in joined_ca
|
|
|
|
# No-ca_pem path keeps the legacy direct install.
|
|
joined_plain = "\n".join(resolve_lines("agnes-1.0-py3-none-any.whl"))
|
|
assert "uv tool install --force {server_url}/cli/wheel/agnes-1.0-py3-none-any.whl" in joined_plain
|
|
assert "curl -fsSL --cacert" not in joined_plain
|
|
assert "uv tool install --native-tls" not in joined_plain
|
|
|
|
|
|
def _executable_lines(section: str) -> str:
|
|
"""Strip shell comment lines so 'not in' assertions match against
|
|
operator-runnable code, not the prose documentation we put in
|
|
comments. A line is a comment when its first non-whitespace character
|
|
is `#`."""
|
|
out: list[str] = []
|
|
for line in section.splitlines():
|
|
if line.lstrip().startswith("#"):
|
|
continue
|
|
out.append(line)
|
|
return "\n".join(out)
|
|
|
|
|
|
def test_resolve_lines_with_ca_pem_marketplace_is_one_liner():
|
|
"""Step 5 collapses to a single CLI invocation: `agnes refresh-marketplace
|
|
--bootstrap`. The CLI does clone + PAT-strip + chmod + register-with-Claude
|
|
+ auto-install internally so the prompt itself emits no `rm -rf`, no
|
|
`git clone`, no per-plugin install lines.
|
|
|
|
The motivation is the Claude Code agent permission gate: when a user
|
|
pastes the install prompt into a Claude Code session, the agent that
|
|
executes it is denied `rm -rf` by default. Pulling the destructive
|
|
prep into the agnes binary (which uses Python `shutil.rmtree`, not
|
|
the `rm -rf` shell pattern) lets the CLI's own permission grant cover
|
|
the cleanup — the prompt stays Claude-Code-friendly.
|
|
|
|
Direct HTTPS via `claude plugin marketplace add <https-url>` is broken
|
|
end-to-end on every Claude Code distribution (see _marketplace_block
|
|
docstring), so we never emit it as an alternative."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(
|
|
resolve_lines(
|
|
"agnes.whl",
|
|
plugin_install_names=["foo", "bar"],
|
|
server_host="agnes.example.com",
|
|
ca_pem=_FAKE_CA_PEM,
|
|
)
|
|
)
|
|
# The marketplace step contains the one-liner.
|
|
assert "agnes refresh-marketplace --bootstrap" in joined
|
|
# And nothing else relating to the marketplace install — the inline
|
|
# shell sequence has been pulled into the CLI. We strip comment lines
|
|
# before asserting because the prompt does include a comment block
|
|
# describing what the CLI does internally; that prose is documentation,
|
|
# not operator-runnable code.
|
|
section_idx = joined.index("Register the Agnes Claude Code marketplace")
|
|
section = _executable_lines(joined[section_idx:])
|
|
assert "rm -rf ~/.agnes/marketplace" not in section
|
|
assert "git clone " not in section
|
|
assert "git -C ~/.agnes/marketplace remote set-url" not in section
|
|
assert "chmod 700 ~/.agnes/marketplace" not in section
|
|
assert "claude plugin marketplace add" not in section
|
|
assert "claude plugin install foo@agnes" not in section
|
|
assert "claude plugin install bar@agnes" not in section
|
|
# And no platform-aware switch in the marketplace section (there's
|
|
# still one in step 0(c) for OS trust-store registration; we anchored
|
|
# on the marketplace header above to narrow the slice).
|
|
assert 'case "$PLATFORM"' not in section
|
|
assert "MARKETPLACE_VIA=" not in section
|
|
|
|
|
|
def test_resolve_lines_with_ca_pem_marketplace_has_explicit_error_handling():
|
|
"""The marketplace one-liner must still fail loudly with `exit 1` on
|
|
a non-zero exit (so a CLI bootstrap failure blocks downstream steps
|
|
instead of letting them silently misbehave)."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(
|
|
resolve_lines(
|
|
"agnes.whl",
|
|
plugin_install_names=["foo", "bar"],
|
|
server_host="agnes.example.com",
|
|
ca_pem=_FAKE_CA_PEM,
|
|
)
|
|
)
|
|
assert "agnes refresh-marketplace --bootstrap || {" in joined
|
|
# Error message goes to stderr.
|
|
assert ">&2" in joined
|
|
|
|
|
|
def test_diagnose_step_documents_non_admin_role_state():
|
|
"""`db_schema: unknown` is normal in two cases — fresh install AND
|
|
non-admin roles (e.g. analyst) without grants on the system schema.
|
|
The original wording only mentioned 'fresh install', leading
|
|
operators on populated instances to chase a phantom yellow check.
|
|
Both contexts must be called out."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(resolve_lines("agnes.whl"))
|
|
assert "db_schema: unknown" in joined
|
|
assert "0 tables" in joined
|
|
# Both contexts called out.
|
|
assert "fresh install" in joined.lower()
|
|
assert "non-admin" in joined.lower() or "analyst" in joined.lower()
|
|
|
|
|
|
def test_resolve_lines_no_sslverify_downgrade_anywhere():
|
|
"""The legacy `git config sslVerify=false` downgrade is gone in every
|
|
rendering combination. Self-signed and private-CA servers must place
|
|
the fullchain at AGNES_TLS_FULLCHAIN_PATH (default
|
|
/data/state/certs/fullchain.pem) so step 0 picks it up via
|
|
_read_agnes_ca_pem; publicly-trusted certs need no trust block at
|
|
all. There is no third path."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
for kwargs in (
|
|
{"plugin_install_names": ["foo"], "server_host": "agnes.example.com"},
|
|
{"plugin_install_names": ["foo"], "server_host": "agnes.example.com",
|
|
"ca_pem": _FAKE_CA_PEM},
|
|
{"plugin_install_names": [], "server_host": "agnes.example.com"},
|
|
):
|
|
joined = "\n".join(resolve_lines("agnes.whl", **kwargs))
|
|
assert "git config --global" not in joined, (
|
|
f"sslVerify downgrade leaked through with kwargs={kwargs!r}"
|
|
)
|
|
assert "sslVerify false" not in joined, (
|
|
f"sslVerify downgrade leaked through with kwargs={kwargs!r}"
|
|
)
|
|
|
|
|
|
def test_resolve_lines_ca_pem_empty_string_is_treated_as_absent():
|
|
"""`ca_pem=''` (or whitespace-only) must NOT emit the trust block —
|
|
same as None. Guards against `Path.read_text()` returning empty for
|
|
a touched-but-unwritten cert file."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
for empty in ("", " ", "\n\n"):
|
|
joined = "\n".join(resolve_lines("agnes.whl", ca_pem=empty))
|
|
assert "0) Trust the Agnes TLS certificate" not in joined
|
|
# Also: the no-ca install path is used, not the curl-first one.
|
|
assert "curl -fsSL --cacert" not in joined
|
|
|
|
|
|
def test_resolve_lines_ca_pem_works_without_plugins():
|
|
"""Trust block is independent of the marketplace + MCP + connectors
|
|
blocks — emit step 0 even when plugin list is empty. Confirm step is
|
|
at 9 in the always-on layout (skills step deleted in #242, connectors
|
|
added in #243). Step 0 is preamble, not numbered."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(resolve_lines("agnes.whl", ca_pem=_FAKE_CA_PEM))
|
|
assert "0) Trust the Agnes TLS certificate" in joined
|
|
assert "8) Confirm:" in joined
|
|
# Marketplace block is now emitted unconditionally; the bootstrap
|
|
# one-liner does the `claude plugin marketplace add` internally so
|
|
# the literal string isn't in the prompt text — the user-facing
|
|
# invocation is `agnes refresh-marketplace --bootstrap`.
|
|
assert "agnes refresh-marketplace --bootstrap" in joined
|
|
|
|
|
|
def test_render_setup_instructions_propagates_ca_pem():
|
|
from app.web.setup_instructions import render_setup_instructions
|
|
|
|
out = render_setup_instructions(
|
|
server_url="https://agnes.example.com",
|
|
token="T-CA",
|
|
wheel_filename="agnes-1.0-py3-none-any.whl",
|
|
plugin_install_names=["foo"],
|
|
server_host="agnes.example.com",
|
|
ca_pem=_FAKE_CA_PEM,
|
|
)
|
|
assert "0) Trust the Agnes TLS certificate" in out
|
|
assert "-----BEGIN CERTIFICATE-----" in out
|
|
# The legacy `git config sslVerify=false` downgrade was deleted; the
|
|
# ca_pem trust block is the sole TLS-bootstrap path now.
|
|
assert "git config --global" not in out
|
|
# Other placeholders still substituted.
|
|
assert "{server_url}" not in out
|
|
assert "{token}" not in out
|
|
assert "T-CA" in out
|
|
# Curl-then-local-install path is rendered (with placeholders resolved).
|
|
assert "https://agnes.example.com/cli/wheel/agnes-1.0-py3-none-any.whl" in out
|
|
assert 'uv tool install --native-tls --force "$WHEEL"' in out
|
|
|
|
|
|
def test_diagnose_step_documents_normal_states():
|
|
"""Step 4 (diagnose) must call out that `db_schema: unknown` and
|
|
`data: 0 tables` are normal on a fresh install — without that the
|
|
operator running the prompt may chase phantom 'errors'."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(resolve_lines("agnes.whl"))
|
|
assert "db_schema: unknown" in joined
|
|
assert "0 tables" in joined
|
|
assert "NORMAL" in joined or "normal" in joined
|
|
|
|
|
|
def test_no_skills_step_emitted():
|
|
"""Skills step was removed: the interactive copy-or-on-demand question
|
|
was confusing for new users (named opinion call with no obvious right
|
|
answer after a wall of technical steps). On-demand lookup via
|
|
`agnes skills show <name>` is the one-size-fits-all default; CLAUDE.md
|
|
references specific skills (e.g. agnes-data-querying) when relevant.
|
|
|
|
Regression guard: the rendered prompt must not contain a numbered
|
|
Skills step or the bulk-copy shell loop into ~/.claude/skills/agnes/.
|
|
"""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
for kwargs in (
|
|
{},
|
|
{"plugin_install_names": ["foo"], "server_host": "h"},
|
|
):
|
|
joined = "\n".join(resolve_lines("agnes.whl", **kwargs))
|
|
assert "Skills (ask the user" not in joined
|
|
assert "8) Skills" not in joined
|
|
assert "9) Skills" not in joined
|
|
assert "~/.claude/skills/agnes/" not in joined
|
|
assert "for s in $(agnes skills list" not in joined
|
|
assert "Wait for the user's answer" not in joined
|
|
|
|
|
|
def test_no_plugins_layout_keeps_diagnose_before_connectors():
|
|
"""Always-on layout: install → init → catalog → preflight →
|
|
marketplace → mcp_servers → diagnose → connectors → confirm,
|
|
regardless of plugin grants. Step numbers: 7 diagnose, 8 connectors,
|
|
9 confirm. Skills step deleted in #242."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(resolve_lines("agnes.whl"))
|
|
assert "6) Run diagnostics:" in joined
|
|
assert "7) Connect the user's tools" in joined
|
|
assert "8) Confirm:" in joined
|
|
diag_idx = joined.index("6) Run diagnostics:")
|
|
conn_idx = joined.index("7) Connect the user's tools")
|
|
confirm_idx = joined.index("8) Confirm:")
|
|
assert diag_idx < conn_idx < confirm_idx
|
|
|
|
|
|
def test_unified_flow_uses_only_agnes_verbs():
|
|
"""No-legacy-`da`-verbs invariant for the unified /setup prompt.
|
|
|
|
Pin: every line emitted by `resolve_lines()` must use the `agnes` CLI
|
|
verb. The legacy `da` namespace was removed in the broader
|
|
clean-analyst-bootstrap rewrite, but the setup prompt is generated
|
|
string-by-string and a stale `da sync` / `da analyst setup` reference
|
|
could survive a refactor unnoticed.
|
|
|
|
Match `"da "` (with the trailing space) so we don't false-positive on
|
|
`Darwin`, `adapter`, `database`, etc. — any actual `da <verb>` invocation
|
|
is followed by a space.
|
|
|
|
Also re-verifies that `agnes init` carries an explicit `--token` arg
|
|
(commit 8784f10a fixed a stale-on-disk-token override: `init --token X`
|
|
must use X for the verify call, not the on-disk token). Without
|
|
`--token` in the emitted line, that fix's contract isn't surfaced to
|
|
the user.
|
|
"""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
fake_ca = (
|
|
"-----BEGIN CERTIFICATE-----\n"
|
|
"FAKE\n"
|
|
"-----END CERTIFICATE-----\n"
|
|
)
|
|
|
|
# Check both layouts (with and without marketplace) and both has_ca
|
|
# variants, since each path stitches together different helper output.
|
|
for kwargs in (
|
|
{},
|
|
{"plugin_install_names": ["foo"], "server_host": "h"},
|
|
{"ca_pem": fake_ca},
|
|
{"plugin_install_names": ["foo"], "server_host": "h", "ca_pem": fake_ca},
|
|
):
|
|
joined = "\n".join(resolve_lines("agnes.whl", **kwargs))
|
|
# No legacy `da <verb>` invocation anywhere.
|
|
assert "da " not in joined, (
|
|
f"Legacy `da ` verb leaked into resolve_lines output (kwargs={kwargs!r}).\n"
|
|
f"Search the rendered prompt for the offending line."
|
|
)
|
|
# `agnes init --token` is the contract that commit 8784f10a's
|
|
# ContextVar override pivots on. Pin it so a future refactor that
|
|
# accidentally drops `--token` from the emitted command surfaces as
|
|
# a test failure, not as a confusing 401 in production.
|
|
assert "agnes init --server-url" in joined
|
|
assert "--token" in joined
|
|
|
|
|
|
def test_install_page_uses_versioned_wheel_url(monkeypatch, tmp_path):
|
|
"""End-to-end: the /install preview must render the PEP 427 wheel URL,
|
|
so a user copy-pasting the snippet gets a URL `uv tool install` accepts."""
|
|
wheel = tmp_path / "agnes_the_ai_analyst-2.0.0-py3-none-any.whl"
|
|
wheel.write_bytes(b"PK\x03\x04")
|
|
monkeypatch.setenv("AGNES_CLI_DIST_DIR", str(tmp_path))
|
|
|
|
from fastapi.testclient import TestClient
|
|
from app.main import app
|
|
client = TestClient(app)
|
|
resp = client.get("/setup", headers={"host": "agnes.test", "Accept": "text/html"})
|
|
assert resp.status_code == 200
|
|
assert "/cli/wheel/agnes_the_ai_analyst-2.0.0-py3-none-any.whl" in resp.text
|
|
# The bare alias must no longer appear in the rendered snippet.
|
|
assert "/cli/agnes.whl" not in resp.text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connector block (step 9) — per-connector default-yes interactive asks
|
|
# wired to verbatim prompts from app/web/connector_prompts.py.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_connectors_block_renders_all_three_asks():
|
|
"""Step 9 must contain a default-yes ask for Asana, Google Workspace,
|
|
and Atlassian — in that order — and inline each connector's full
|
|
prompt body verbatim. Catches drift between the registry order and
|
|
what the script emits."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(resolve_lines("agnes.whl"))
|
|
# All three asks with the verbatim default-yes phrasing.
|
|
assert 'Ask: "Set up Asana now? (Y/n)"' in joined
|
|
assert 'Ask: "Set up Google Workspace now? (Y/n)"' in joined
|
|
assert 'Ask: "Set up Atlassian (Jira / Confluence) now? (Y/n)"' in joined
|
|
# The default-install copy is rendered once for the whole block.
|
|
assert "Treat empty/Enter as YES — the default is install" in joined
|
|
# Ordering: Asana → GWS → Atlassian (matches the CONNECTORS registry).
|
|
asana_idx = joined.index('Set up Asana now? (Y/n)')
|
|
gws_idx = joined.index('Set up Google Workspace now? (Y/n)')
|
|
atl_idx = joined.index('Set up Atlassian (Jira / Confluence) now? (Y/n)')
|
|
assert asana_idx < gws_idx < atl_idx
|
|
|
|
|
|
def test_connectors_block_uses_gws_configured_branch_when_oauth_set():
|
|
"""When the operator has provisioned a shared OAuth app (client_id +
|
|
secret), the inlined GWS prompt body skips the manual `gws auth
|
|
setup` walkthrough and writes client_secret.json directly. That's
|
|
the GCP-frictionless path that takes ~2 min instead of ~20."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
from app.web.connector_prompts import all_connector_prompts
|
|
|
|
prompts = all_connector_prompts(gws_oauth={
|
|
"configured": True,
|
|
"client_id": "1234-abc.apps.googleusercontent.com",
|
|
"client_secret": "FAKE-SECRET",
|
|
"project_id": "1234",
|
|
"oauthlib_insecure_transport": "1",
|
|
})
|
|
joined = "\n".join(resolve_lines("agnes.whl", connector_prompts=prompts))
|
|
# Configured branch signature: the operator's literal client_id is
|
|
# baked into the inlined client_secret.json snippet.
|
|
assert "1234-abc.apps.googleusercontent.com" in joined
|
|
assert "FAKE-SECRET" in joined
|
|
# The manual `gws auth setup` walkthrough must NOT appear when the
|
|
# configured branch is active.
|
|
assert "Run `gws auth setup` for me" not in joined
|
|
|
|
|
|
def test_connectors_block_uses_gws_manual_branch_when_oauth_unset():
|
|
"""Inverse: when no operator OAuth credentials are provisioned, the
|
|
inlined GWS prompt walks the user through the manual `gws auth
|
|
setup` flow (the ~20-min GCP clickops path)."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
from app.web.connector_prompts import all_connector_prompts
|
|
|
|
prompts = all_connector_prompts(gws_oauth={"configured": False})
|
|
joined = "\n".join(resolve_lines("agnes.whl", connector_prompts=prompts))
|
|
assert "Run `gws auth setup` for me" in joined
|
|
# The configured-branch landmark string ("Skip `gws auth setup` entirely")
|
|
# must NOT appear in the unconfigured branch.
|
|
assert "Skip `gws auth setup` entirely" not in joined
|
|
|
|
|
|
def test_step_numbering_with_connectors_step():
|
|
"""_step_numbers must return diagnose=6, connectors=7, confirm=8.
|
|
Anchors the numeric expectations the rest of the test suite assumes.
|
|
(Skills step deleted in #242; connectors added in #243; standalone
|
|
`mcp_servers` step retired and folded into the Atlassian connector's
|
|
prompt body, so the layout drops by one.)"""
|
|
from app.web.setup_instructions import _step_numbers
|
|
|
|
steps = _step_numbers()
|
|
assert steps["preflight"] == "4"
|
|
assert steps["marketplace"] == "5"
|
|
assert "mcp_servers" not in steps
|
|
assert steps["diagnose"] == "6"
|
|
assert steps["connectors"] == "7"
|
|
assert steps["confirm"] == "8"
|
|
assert "skills" not in steps # deleted in #242
|
|
|
|
|
|
def test_finale_bullets_mention_connector_outcomes():
|
|
"""The Confirm step's summary bullets must include "Which connectors
|
|
got set up: Asana, Google Workspace, and Atlassian — installed or
|
|
declined for each". Without this the assistant has no reason to
|
|
summarise the per-connector ask answers in the final Confirm
|
|
message."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(resolve_lines("agnes.whl"))
|
|
assert "Which connectors got set up" in joined
|
|
assert "Asana, Google Workspace, and Atlassian" in joined
|
|
assert "installed or declined for each" in joined
|