* Capture session paths via SessionStart hook + lock parallel pushes Replace the encoding-based scan of ~/.claude/projects/<encoded-cwd>/ with a queue file populated by a new `agnes capture-session` SessionStart hook. The hook reads the documented `transcript_path` field from Claude Code's hook stdin JSON, sidestepping the cwd-to-folder encoding (which is an internal implementation detail and varies by Claude Code version). - New `agnes capture-session` subcommand appends transcript_path to <workspace>/.claude/agnes-sessions.txt. Silent on all malformed input so a hook chain failure doesn't clutter Claude Code startup. - `agnes push` now consumes the queue: atomic snapshot rename guards against hooks writing during the push window, successful uploads land in agnes-sessions-uploaded.txt (TSV: timestamp + path), failed paths are requeued. - Cross-platform single-instance lock via the filelock package (fcntl on POSIX, msvcrt on Windows). Concurrent SessionEnd hooks — common when the user closes several sessions at once — silent-exit on the losing side instead of all racing the upload. - Recovery: pre-existing snapshot files from a crashed push are picked up and processed before the live queue. - The SessionStart `agnes push` self-heal entry is dropped — it became redundant once the queue persists across runs (orphans from headless / crashed sessions ship out on the next interactive SessionEnd push). Existing workspaces auto-migrate via the marker-based replace logic. - Legacy encoding scan stays available behind `--legacy-scan` for one- off backfills of sessions predating the queue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add /agnes-private + statusLine indicator for private sessions Users handling sensitive data inside Claude Code can now opt a session out of the Agnes upload pipeline, either proactively (right after session start) or reactively (mid-session). The `/agnes-private` slash command runs `agnes mark-private` deterministically via `!`-prefix direct bash — no AI in the loop. A workspace-installed statusLine surfaces a `🔒 agnes-private` indicator in Claude Code's status bar so the user sees the state at a glance. Authoritative source of "do not upload" is a separate file `<workspace>/.claude/agnes-sessions-private.txt` (one session_id per line). Both `capture-session` (queue writer) and `push` (queue reader) consult the list. This makes the slash-command / SessionStart-hook race impossible by construction: whichever runs first, the session is correctly filtered out. - `agnes mark-private` reads `CLAUDE_CODE_SESSION_ID` from env (set by Claude Code in every bash subprocess it spawns — stable documented API) and appends to the private list. - `agnes statusline` reads the session JSON Claude Code pipes on stdin, checks the private list, and emits the indicator or nothing. Optimized for the high call frequency of statusLine renders. - `capture-session` extracts session_id from hook stdin and skips queue write when the ID is already on the private list (race protection). - `push` filters snapshot entries by the private list and appends to a per-workspace audit log `agnes-sessions-private-skipped.txt`. - Queue format migrated from `<path>` to `<session_id>\t<path>`; legacy one-column lines still parse (empty session_id, still upload, can't be marked private retroactively — fine, they pre-date the feature). - `install_claude_hooks` writes a workspace statusLine unless the user already has a custom one (warn + preserve). Idempotent re-init. - `install_claude_commands` ships `agnes-private.md` alongside `update-agnes-plugins.md`. Per-template fallback so a missing template doesn't get clobbered with the wrong content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix setup-prompt + CLAUDE.md marketplace copy + drop skills step Three issues against the post-PR-#240 / post-PR-#237 state: 1. Setup prompt's marketplace block trailer (both has-stack and empty-stack variants) claimed the SessionStart hook keeps the marketplace clone in sync via `agnes refresh-marketplace --quiet` on every session and that admin grants land automatically — both false since PR #237 (0.47.x) moved the install/update path out of the hook into the `/update-agnes-plugins` slash command. The hook is `--check`-only: detects server-side changes, prompts the user to run the slash command, which does the full reconcile interactively with output visible in the transcript. 2. The empty-stack variant framed composition as "admin grants only", missing the actual three-source served stack: (admin RBAC ∩ /marketplace subscriptions) ∪ system-mandatory plugins (admin-pinned, auto-applied) ∪ Flea market installs (skills/agents bundled, plugins standalone) Updated copy spells out all three sources so analysts know where their stack picks live, and what the SessionStart hook actually does on change detection. 3. CLAUDE.md template's "Agnes Marketplace" section conflated eligibility (`resolve_allowed_plugins` — what's listed) with served stack (`resolve_user_marketplace` — what actually reaches Claude Code). The two are different: a user can be RBAC-eligible for a plugin without having subscribed to it on /marketplace. Rewrote the section to distinguish the eligibility set from the served stack and to describe the `--check`-only hook accurately. Plus: deleted the setup prompt's interactive Skills step (final step before Confirm). The named-opinion question — "do you want me to bulk-copy every skill into ~/.claude/skills/agnes/ or pull on-demand via `agnes skills show <name>`?" — had no obvious right answer for new users at the tail end of a wall of technical steps. On-demand lookup is the one-size-fits-all default; `agnes skills list/show` remain discoverable and the CLAUDE.md template references specific skills inline (e.g. agnes-data-querying in the BigQuery section) where they're relevant. Layout: Confirm shifts from step 9 to step 8. Tests updated, full setup/marketplace/welcome surface green (115 passed). Remaining full-suite failures are pre-existing (BQ/Keboola fixtures, Windows charmap collection error in test_v26_keboola_e2e) — verified against a clean stash, unrelated to this diff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix session-queue race + snapshot PID-reuse data loss Two blocker fixes from the PR #242 review: 1. Concurrent SessionStart hooks could corrupt the queue file on Windows. Python's `open(path, "a")` is not atomic there — the CRT does not pass FILE_APPEND_DATA to CreateFile, so concurrent appenders (user opening several Claude Code windows simultaneously) could interleave bytes mid-line. The malformed lines then silently fail the parser and the entries are dropped. Fix: wrap append_to_queue, requeue_failed, and snapshot_queue in a short-lived FileLock on a dedicated `agnes-queue.lock`. Separate from `agnes-push.lock` so capture-session hooks don't block on the push command. New test_append_concurrent_threads_no_corruption reproduces the race with 4 threads x 50 appends. 2. Snapshot filenames embedded only the PID (`agnes-sessions.snapshot. <PID>.txt`). After a crashed push left a snapshot on disk and the OS recycled the PID for a new push, `os.rename` would atomically overwrite the recovery snapshot — every entry in it lost, silently. Fix: append a uuid8 hex tail (`agnes-sessions.snapshot.<PID>. <uuid8>.txt`). find_recovery_snapshots already globs the prefix so it picks up both old and new format. New test_snapshot_filename_is_unique_per_call asserts two consecutive snapshots under the same PID don't collide. Targeted tests green (47/47 in session_queue/capture_session/cli_push). Full suite failures unchanged from baseline (pre-existing BQ/Keboola fixture issues per CLAUDE.md). * Auto-refresh workspace hooks + bash-wrap all hook entries (Windows) Fixes from PR #242 second review (ZdenekSrotyr): 1. `uv.lock` regenerated to include `filelock 3.29.0` (declared in pyproject.toml but missing from the lock file — CI's lockfile-consistency check would fail; `uv pip install` on a clean cache would silently miss the dep). 2. `agnes self-upgrade` now auto-refreshes the workspace Claude Code hooks via the new `cli.lib.hooks.maybe_refresh_claude_hooks`. Closes the silent-stop migration gap: a v0.48 workspace would auto-upgrade the CLI from its existing SessionStart self-upgrade entry but never pick up the new `agnes capture-session` SessionStart hook, leaving the queue empty and `agnes push` uploading nothing. The refresh fires on both the "info is None" fast path (CLI already current — catches the second SessionStart after a prior upgrade) and the install-success path. Guarded by `workspace_has_agnes_hooks` so it never writes `.claude/settings.json` into directories that aren't Agnes workspaces (e.g. `agnes self-upgrade` invoked from `~/`). Errors are surfaced on stderr but never flip the upgrade exit code. 3. All Agnes-managed hooks are now wrapped in `bash -c "..."`. The self-upgrade+pull chained SessionStart entry was the only one still shipping unwrapped — Claude Code on Windows runs hook commands directly without a shell, so the `;` chain + `2>/dev/null` + `|| true` shell syntax silently no-op'd on native Windows installs without Git Bash on PATH. Workspaces still on the old form auto-upgrade via the refresh path above. Tests: +12 in test_lib_hooks.py (guard semantics, v0.48→v0.49 migration end-to-end, third-party-hook preservation, bash-wrap invariant). +5 in test_self_upgrade.py (refresh fires on info=None, fires on install success, skipped on failure, skipped on --check-only, refresh failure never flips exit code). 130 targeted tests green. The 2 pre-existing Windows path-separator failures in `test_smoke_test_detects_version_mismatch[uv|pip]` are unrelated (path mismatch `\fake\uv\bin\agnes` vs `/fake/uv/bin/agnes` in test asserts, pre-PR baseline). * CHANGELOG: document PR-242 main features Closes ZdenekSrotyr #4: the [Unreleased] block was missing entries for the PR's primary surface — only the post-merge fix bullets and the unrelated setup-prompt copy change were captured. Adds: - ### Added: 6 bullets covering the session capture queue + new `agnes capture-session` subcommand, `/agnes-private` slash + `agnes mark-private`, `agnes statusline` + statusLine wiring, `--legacy-scan` opt-in fallback, single-instance push lock, and the new `filelock` runtime dep. - ### Changed: BREAKING bullet on the SessionStart / SessionEnd hook wire format change (capture-session as first SessionStart entry, push self-heal removed, SessionEnd push detached via nohup, all entries bash-wrapped). Folds the prior standalone bash-wrap bullet into this consolidated entry — Z's review flagged the layout shift as BREAKING, and grouping the related sub-changes makes the migration story readable in one place. - Operator migration is auto-handled by `maybe_refresh_claude_hooks` invoked from `agnes self-upgrade` (separate Changed entry below). No `agnes init` re-run required. Pre-queue session jsonls on upgrading workspaces still need a one-off `agnes push --legacy-scan` — flagged in the BREAKING bullet. No code change; doc only. * Drop permanent 4xx uploads instead of requeueing forever Closes ZdenekSrotyr #5. Previously the push retry path requeued any non-200 response except the literal "file not found on disk", so 401 (token expired), 403 (RBAC denial), 413 (payload too large), 400 (server-side validation) cycled through every push run forever — the queue grew without bound and each run re-bombarded the server with the same deterministically-failing upload. Now 4xx (except 408 Request Timeout + 429 Too Many Requests, which the HTTP spec marks as transient) is dropped and audit-logged to `<workspace>/.claude/agnes-sessions-failed.txt`: <iso_ts>\t<session_id>\t<status>\t<transcript_path> 5xx and network errors continue to requeue — those reflect server / transport state that can change between runs, so retry is the right behavior. The audit log piggybacks on the push single-instance lock (agnes-push.lock) — push is the only writer to this file, same as the existing `mark_uploaded` and `mark_private_skipped` paths, so no separate filelock is needed. `agnes push --json` surfaces a new `dropped_permanent` counter; non- quiet stdout mentions the audit-log path so operators tailing the output have a pointer to the forensic trail. Tests: +7 in test_cli_push.py (401/400/403/413 → drop; 408/429 → requeue; 500/502/503 → requeue; network exception → requeue; --json `dropped_permanent` counter; stdout audit-log pointer). +1 in test_session_queue.py (mark_failed_permanent TSV format). 127/129 targeted tests green. The 2 pre-existing Windows path-separator failures in `test_smoke_test_detects_version_mismatch [uv|pip]` are unrelated (path mismatch `\fake\uv\bin\agnes` vs `/fake/uv/bin/agnes` in test asserts, pre-PR baseline). * Catch OSError in push lock acquisition Closes ZdenekSrotyr #8. `acquire_or_skip` in `cli/lib/push_lock.py` previously caught only `filelock.Timeout`. Any `OSError` from `FileLock.acquire` — read-only filesystem, permission denied on `.claude/`, disk full, hardware I/O failure — propagated as an unhandled traceback. Two visible failure modes: - SessionEnd hook: `|| true` in the wrapper swallowed the error, so daily pushes silently never ran. Operator had no signal. - Manual `agnes push`: ugly Python traceback dumped to the terminal instead of a clean exit. Now `OSError` is treated the same as `Timeout` — yield `None`, caller returns cleanly with rc=0. The operator's environment in these scenarios has bigger problems than missing session uploads, so we swallow rather than retry-loop or surface a noisy warning. Test: `test_push_silent_exit_when_filelock_raises_oserror` patches the `FileLock` used inside `push_lock` to raise OSError on acquire, verifies push exits 0 with no traceback and the queue is preserved for the next attempt. * Address remaining S2 items from PR-242 review Four items from ZdenekSrotyr's S2 list: S2.10 — `_install_statusline` truthy check (cli/lib/hooks.py): replace `if existing:` with explicit `if existing is None or existing == "":`. Documents and tests the behavior for both edge cases (explicit-null and empty-string `statusLine`) — both treated as "not configured" rather than "explicit user opt-out", so we install ours. Two new tests in test_lib_hooks.py pin the contract. S2.6 — onboarding docs for /agnes-private. New "Private sessions" subsection in `config/claude_md_template.txt` (next to Data Sync) covering the slash command, statusbar indicator, and audit-log location. One-line tip in `app/web/setup_instructions.py` so the feature is discoverable at onboarding. S2.9 — e2e privacy test (tests/test_e2e_privacy.py). Wires capture_session → mark_private → push against a recording fake api_post and asserts zero session uploads for the marked one. Three cases: mark-before-capture (queue write skipped), mark-after-capture (push-side filter catches it + audit-logs), control (unmarked sessions upload normally). David #8 — `--legacy-scan` help text now documents the private-list gap (legacy entries carry empty session_id, so the filter is not consulted). The practical impact is bounded — pre-queue sessions cannot have been marked private since the private list is a queue-era feature — but the disclaimer in the help text means an operator running a backfill is not surprised. 68 targeted tests green (3 new e2e + 2 new truthy edge tests + existing). 2 pre-existing Windows path-separator failures in test_smoke_test_detects_version_mismatch[uv|pip] unchanged. Remaining S2 items (statusline mkdir push-back, capture-session silent-fail follow-up) handled in PR comment + follow-up issue respectively. * Address remaining S2 follow-ups (David #8, S2.7, David #11) Three items left over from Mina's bbf63472 batch — that commit addressed S2.6/S2.9/S2.10 + documented David #8 in help text but deferred the actual implementations of S2.7, David #11, and the real David #8 fix to follow-ups. This commit closes them. David #8 — `agnes push --legacy-scan` now consults the private list. Claude Code names jsonls `<session-id>.jsonl`, so the file stem IS the session id; the legacy-scan path can apply the same private filter the queue path uses. Both the dry-run and live-upload code paths fixed. Help text updated (no longer warns the filter is bypassed). Two new tests in test_cli_push.py cover the upload-skip path + the dry-run `would_skip_private` segregation. S2.7 — `statusline`/`is_private` no longer mkdir-pollutes arbitrary workdirs. Split `_claude_dir` into `_claude_dir_writable` (used only from `add_private`) and `_claude_dir_readonly` (no mkdir). The read-only public helpers (`private_list_path`, `read_all_private`, `is_private`) compose the no-mkdir variant by default; `add_private` opts in via `writable=True`. Added a process-local mtime-keyed cache around `read_all_private` so in-process callers (push doing one stat per upload candidate, future `agnes diagnose`) don't re-parse the file on every check. Cache eviction on `add_private` so a sub-second write+read sequence doesn't see stale data even on coarse-mtime filesystems. Two new tests pin the no-mkdir contract + the in-same-second add+read consistency. David #11 — `agnes capture-session` writes a breadcrumb log on every invocation. New `<workspace>/.claude/agnes-capture-session.log` TSV: `<iso_ts>\t<outcome>\t<detail>` where outcome covers every silent- exit path (`ok`, `private_skip`, `empty_stdin`, `bad_json`, `not_object`, `no_transcript_path`, `stdin_read_error`, `write_error`). Gives operators a signal to detect "hook fires but queue stays empty" — without it, an upstream Claude Code stdin- contract change is invisible because the hook always exits 0. Log rolls at 256 KiB so it doesn't grow unbounded on long-lived workspaces. Best-effort: a breadcrumb-write failure is itself swallowed so the hook contract stays "exit 0 always". Skipped in non-Agnes workdirs (no `.claude/` exists) so opening Claude Code in `~/` doesn't pollute it. Five new tests in test_capture_session.py cover the success / bad_json / no_transcript_path / private_skip / no-pollute paths. 115 targeted tests green (test_cli_push, test_capture_session, test_private_list, test_session_queue, test_e2e_privacy, test_lib_hooks, test_statusline, test_mark_private). --------- Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
796 lines
36 KiB
Python
796 lines
36 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 confirm. Preflight +
|
|
marketplace + MCP block are emitted even when the operator's served
|
|
stack is empty — registering the per-user marketplace clone pre-wires
|
|
Claude Code for future stack changes (admin grants, system pins,
|
|
Flea installs), and the Atlassian Remote MCP applies to every analyst
|
|
whose work touches Jira/Confluence. Skills step deleted — the
|
|
interactive copy-or-on-demand question was confusing and the
|
|
on-demand path is the one-size-fits-all default."""
|
|
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) Register the Atlassian MCP server" in joined
|
|
assert "7) Run diagnostics:" in joined
|
|
assert "8) Confirm:" in joined
|
|
# No stray Confirms at other positions.
|
|
assert "9) Confirm:" not in joined
|
|
assert "10) Confirm:" not in joined
|
|
assert "6) Confirm:" not in joined
|
|
# No skills step in any form.
|
|
assert "Skills (ask the user" not in joined
|
|
assert "Skills" not in joined or "agnes skills" in joined # comment refs still OK
|
|
assert "8) Skills" not in joined
|
|
assert "~/.claude/skills/agnes/" not in joined
|
|
# The marketplace step header adapts to the empty-stack copy
|
|
# rather than the plugin-installing variant.
|
|
assert "your stack is empty for now" 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. Collapsed to a single CLI call:
|
|
# `agnes refresh-marketplace --bootstrap` does clone + PAT-strip +
|
|
# chmod + register-with-Claude + auto-install-from-manifest internally.
|
|
# Pulling that out of the inline shell script avoided Claude Code's
|
|
# agent-driven `rm -rf` permission gate that the old multi-line
|
|
# sequence tripped on.
|
|
assert "5) Register the Agnes Claude Code marketplace and install your current stack" in joined
|
|
assert "agnes refresh-marketplace --bootstrap" in joined
|
|
# The destructive prep + per-plugin install commands are now inside
|
|
# the CLI; the prompt must not emit the inline shell forms in
|
|
# operator-runnable lines (comment lines documenting what the CLI
|
|
# does internally are fine — they're prose, not commands).
|
|
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.
|
|
assert "6) Register the Atlassian MCP server" in joined
|
|
# Step 7 — diagnose now AFTER marketplace + MCP wiring.
|
|
assert "7) Run diagnostics:" in joined
|
|
# Step 8 — Confirm.
|
|
assert "8) Confirm:" in joined
|
|
# No skills step in any form.
|
|
assert "Skills (ask the user" not in joined
|
|
assert "8) Skills" not in joined
|
|
assert "~/.claude/skills/agnes/" not in joined
|
|
for stray in ("4) Confirm:", "5) Confirm:", "6) Confirm:", "7) Confirm:", "9) 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")
|
|
mcp_idx = joined.index("6) Register the Atlassian MCP server")
|
|
diag_idx = joined.index("7) Run diagnostics:")
|
|
confirm_idx = joined.index("8) Confirm:")
|
|
assert install_idx < init_idx < catalog_idx < git_idx < market_idx < mcp_idx < diag_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 blocks — emit
|
|
step 0 even when plugin list is empty. Confirm step is at 8 in the
|
|
post-skills-removal layout. 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_diagnose_before_confirm():
|
|
"""Always-on layout (post-skills-removal):
|
|
install → init → catalog → preflight → marketplace → mcp_servers →
|
|
diagnose → confirm. Step numbers: 7 diagnose, 8 confirm."""
|
|
from app.web.setup_instructions import resolve_lines
|
|
|
|
joined = "\n".join(resolve_lines("agnes.whl"))
|
|
assert "7) Run diagnostics:" in joined
|
|
assert "8) Confirm:" in joined
|
|
diag_idx = joined.index("7) Run diagnostics:")
|
|
confirm_idx = joined.index("8) Confirm:")
|
|
assert diag_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
|