feat(setup): cross-platform TLS bootstrap + marketplace plugin install (#137)
Bootstraps the Agnes Claude Code marketplace + RBAC-allowed plugins from the dashboard CTA, and inlines the server's TLS cert when the chain isn't publicly trusted (self-signed / private CA). Cross-platform setup prompt covers Windows Git Bash, macOS, Linux. Includes Bun-compiled `claude` fix (macOS goes via git-clone fallback, same as Windows), PAT stripping after clone, explicit error handling, and four rounds of Devin Review fixes (phantom step references, $PLATFORM re-detection, heredoc/awk line-count sync). Cuts 0.21.0. See CHANGELOG.md [0.21.0] section for details.
This commit is contained in:
parent
38f6b639d2
commit
4ec5ff44dd
7 changed files with 1956 additions and 68 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -10,6 +10,31 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.21.0] — 2026-04-30
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- `scripts/dev/agnes-client-reset.sh` — destructive cleanup of an Agnes *client* install on a developer workstation, mirror image of `app/web/setup_instructions.py` so an onboarding-from-scratch test is reproducible. Removes the `da` CLI (`uv tool uninstall`), `~/.config/da` / `~/.agnes` / `~/.claude/skills/agnes`, the Claude Code `agnes` marketplace + its plugins, the Agnes CA from the OS trust store (Windows `certutil -delstore`, macOS `security delete-certificate -Z`, Linux `update-ca-certificates`/`update-ca-trust`), the `AGNES_CA_PEM_TRUST` block from the user's shell rc (with `.agnes-reset.bak` backup), and `/tmp/agnes*.whl` matches. Cross-platform (Git Bash on Windows / macOS / Linux); `--yes` skips the confirm prompt, `--dry-run` prints actions without executing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Trust block heredoc trimmed to 8 lines so reset script's `skip = 8` matches exactly (Devin review round 3 on PR #137).** The `_tls_trust_block` heredoc was emitting 9 lines into the user's shell rc (a leading empty line + the `AGNES_CA_PEM_TRUST` marker + 7 export/comment lines), but `scripts/dev/agnes-client-reset.sh` awk strips exactly 8 lines starting at the marker — leaving the leading empty line behind. On repeated install/reset cycles, those stray empty lines accumulated in `~/.zshrc` / `~/.bashrc`. Removed the leading empty string from the heredoc body in `_tls_trust_block` so the heredoc now writes exactly 8 lines, matching the awk count. Added two regression tests that pin the invariant — one asserts the heredoc body length, the other parses `skip = N` out of the reset script via regex and cross-checks it against the heredoc body line count, so future drift on either side fails loudly.
|
||||||
|
|
||||||
|
- **Marketplace block re-detects `$PLATFORM` so Linux actually gets the direct-HTTPS attempt (Devin review round 2 on PR #137).** `$PLATFORM` is set in step 0(a) but the prompt itself warns that env vars don't persist across separate Bash invocations (step 0(e) IMPORTANT note). The marketplace step's `case "$PLATFORM" in` ran in a later Bash call where `$PLATFORM=""`, falling through to the `*)` catch-all which hard-codes `MARKETPLACE_VIA=clone` — defeating the Linux-only direct-HTTPS attempt that node-based `claude` would have honored via `NODE_EXTRA_CA_CERTS`. Marketplace block now re-detects `$PLATFORM` via the same `uname -s` switch from step 0(a) before its case statement, making the block self-contained. Same fix not applied to step 0(c)'s `$PLATFORM` use because step 0 is meant to run as a single Bash block (a→b→c→d→e in sequence) where the variable is still in scope.
|
||||||
|
|
||||||
|
- **Setup prompt no longer references steps that may not have been emitted (Devin review on PR #137).** Three places hard-coded references to optional steps regardless of whether those steps were actually rendered. (1) Confirm step's summary bullets unconditionally listed "Which CA bundle source got picked in step 0(d)" and "Whether the marketplace add went via direct HTTPS or via the git-clone fallback" — both phantom in the default no-CA, no-plugins flow, and an LLM following the prompt would either ask the user about non-existent steps or hallucinate. `_FINALE_LINES_TEMPLATE` constant replaced with `_finale_lines(has_ca, has_marketplace)` that conditionally appends each bullet. (2) Preamble's "The fallback chain inside step 0(d) is documented and OK to use" pointed at a non-existent step when `ca_pem` was unset. `_preamble_lines(has_ca)` now drops that line in the no-trust-block path; the "don't disable TLS verification" guidance stays unconditional (valid generic advice). (3) Trust block step 0(c) said "without this, step 7's marketplace add fails" — stale after the layout reordering moved marketplace to step 5 (and made it optional). Reworded to describe the consequence without naming a step number.
|
||||||
|
|
||||||
|
- **Marketplace step now uses the git-clone fallback on macOS too — not only Windows — and strips the PAT from the cloned repo's `.git/config` after clone.** First fix: `claude` on macOS arm64 ships as a Mach-O binary with a `__BUN` segment (single-file `bun build --compile`); reverse engineering its `strings` table shows it recognizes `NODE_EXTRA_CA_CERTS` / `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` (including a "NODE_EXTRA_CA_CERTS detected" log line) but in practice none of them — nor the macOS login keychain — is honored for the marketplace HTTPS request, leaving the direct-add path failing with `unable to verify the first certificate` even after step 0(c) registered the cert. So the marketplace `case` now matches `linux)` for the direct-then-fallback path and `*)` (= Windows + macOS, both Bun-compiled) for straight-to-clone. Second fix: `git clone https://x:<PAT>@host/...` writes the URL verbatim into `~/.agnes/marketplace/.git/config`, so the PAT then sits in plaintext at a path that gets read by cloud-sync agents (iCloud, OneDrive) and antivirus scanners on default home setups; after clone we now run `git remote set-url origin "https://<host>/marketplace.git/"` to drop the token, plus a best-effort `chmod 700`/`chmod 600` (wrapped in `|| true` so it's a no-op on Windows NTFS via MSYS / Git Bash). Marketplace registration uses the local FS path, not the remote URL, so removing the token doesn't break anything — refreshes go via re-running setup with a fresh PAT from the dashboard. Third fix: each shell-out (`git clone`, `claude plugin marketplace add`, `claude plugin install <name>@agnes`) is now wrapped in `|| { echo "ERROR..." >&2; exit 1; }` so a failure halts the prompt loudly instead of falling through to a confusing downstream error (e.g. failed clone → `marketplace 'agnes' not found` from the next `plugin install`). Fourth fix: the diagnose step now calls out that `db_schema: unknown` is also normal for non-admin roles (e.g. `analyst`) on populated instances, not just on fresh installs — analyst lacks grants on the system schema, so the field stays `unknown` forever and was previously misread as a yellow check.
|
||||||
|
|
||||||
|
- **Setup prompt step ordering reshuffled so all installation work runs before the human-loop skills question.** Old order interleaved the human question (skills, step 5) between install (step 1) and marketplace/plugins (step 7), which led the assistant to either block on the user mid-install or "do the rest in parallel" while waiting. New order: install → login → verify → git check → marketplace + plugins → diagnose → **skills (last interactive step before Confirm)** → Confirm. With marketplace plugins to install, that's steps 1-2-3-4-5-6-7-8; without plugins, 4-5 (git/marketplace) collapse out and diagnose/skills/confirm renumber to 4-5-6. The skills step now explicitly tells the assistant to *wait* for the user's answer before moving to Confirm — the old "you can continue in parallel" hint is gone because there's no longer anything to do in parallel. `da diagnose` running late doubles as a final smoke test after plugins are in place.
|
||||||
|
|
||||||
|
- **Setup prompt's TLS trust block rewritten to be cross-platform and to dodge three TLS pitfalls observed across real workstation setups.** The previous block exported `SSL_CERT_FILE`/`NODE_EXTRA_CA_CERTS`/`GIT_SSL_CAINFO` all pointing at the single Agnes CA; this caused (1) every Python tool in the same shell to lose its system trust store (PyPI immediately broke with `UnknownIssuer` because `SSL_CERT_FILE` is a *replace*, not an append), (2) `uv tool install <https-url>` against the Agnes wheel endpoint to fail with rustls' `CaUsedAsEndEntity` because the Agnes leaf cert is its own CA — `--native-tls` doesn't help (the rejection happens during chain validation, not trust lookup), and (3) `claude plugin marketplace add` to fail on Windows because `claude.exe` ignores both the OS trust store and `NODE_EXTRA_CA_CERTS` for marketplace HTTPS. The new step 0 (a) detects platform via `uname` + `$SHELL` and picks the correct shell rc file (zsh→`.zshrc`, bash on macOS→`.bash_profile`, else→`.bashrc`), (b) writes the cert PEM via single-quoted heredoc, (c) registers the cert in the OS trust store (Windows `certutil -user -addstore Root`, macOS `security add-trusted-cert`, Linux `update-ca-certificates`/`update-ca-trust`) — no admin rights needed, idempotent on re-run — so native binaries that bypass our env vars still trust the host, (d) builds a *combined* CA bundle at `~/.agnes/ca-bundle.pem` (system roots + Agnes CA) using a fallback chain for the system roots source (system `python3 -c 'import certifi'` → distro/curl bundle paths → `uv run --with certifi` as last resort), (e) persists `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`/`GIT_SSL_CAINFO` pointing at the *combined* bundle while keeping `NODE_EXTRA_CA_CERTS` on just `ca.pem` (Node's append semantics). When the trust block is emitted, step 1 also switches to a curl-then-local-install pattern (`curl --cacert` to download the wheel, `uv tool install --native-tls --force <local-file>` to install) so rustls never sees the Agnes host. Step 7 (marketplace) goes platform-aware: Windows skips the direct HTTPS attempt and uses a system `git clone` fallback (system git honors `GIT_SSL_CAINFO`), macOS/Linux try direct first. Step 4 (diagnose) calls out that `db_schema: unknown` and `data: 0 tables` are normal on fresh installs. Step 5 (skills) makes clear the assistant can continue with steps 6-7 while waiting for the user's skills answer. Step 12 (marketplace) calls out the harmless `git: 'credential-manager-core' is not a git command` warning so the operator doesn't chase it. The legacy `git config sslVerify=false` downgrade path stays as a fallback for instances without a `fullchain.pem` on disk (so existing `AGNES_DEBUG_AUTH` setups keep working).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **"Set up a new Claude Code" prompt now bootstraps the marketplace and plugins.** The clipboard payload generated by the dashboard CTA appends a git pre-flight check (`git --version`, with `brew install git` / `winget install --id Git.Git` install commands for macOS / Windows) followed by a marketplace-registration step that runs `claude plugin marketplace add "https://x:<PAT>@<host>/marketplace.git/"` and one `claude plugin install <plugin>@agnes --scope project` per RBAC-allowed plugin (resolved via `marketplace_filter.resolve_allowed_plugins`). When the user has no plugin grants, the original 6-step layout is preserved. When `AGNES_DEBUG_AUTH` is enabled on the server (dev/self-signed-cert instances), a host-scoped `git config --global http."<server>/".sslVerify false` line is also included so the marketplace clone works against the self-signed endpoint. Plugins load on the next `claude` start.
|
||||||
|
- **Setup prompt inlines the server's TLS cert as a step-0 trust block on instances with a private CA / self-signed chain.** `app.web.router._read_agnes_ca_pem` reads `/data/state/certs/fullchain.pem` (path overridable via `AGNES_TLS_FULLCHAIN_PATH`; the file is bind-mounted into the app container by `docker-compose.host-mount.yml` from the same location `agnes-tls-rotate.sh` writes). Self-signed leaves and CA-signed leaves whose issuer isn't in the server-side `certifi` trust store are inlined into the prompt; publicly-trusted chains (Let's Encrypt etc.) are skipped so users don't unnecessarily narrow their default Python TLS trust. The inlined block writes the PEM to `~/.agnes/ca.pem` via single-quoted heredoc (so `$`/backtick chars in the cert never shell-expand) and exports `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `GIT_SSL_CAINFO` for the current shell + persists them to `~/.bashrc`/`~/.zshrc` (idempotent via a marker grep guard) so `da` keeps trusting the host across new terminal sessions. When the trust block is emitted, the legacy `git config sslVerify=false` downgrade is suppressed — full TLS validation re-enabled, just against the inlined cert. Cross-platform (macOS bash/zsh + Windows Git Bash) — same env vars, same heredoc syntax. Replaces the `git config sslVerify=false`-only path that broke `claude plugin marketplace add` (Node has its own HTTPS client and ignores `git config`) and `uv tool install` (rustls, no insecure flag) on self-signed instances.
|
||||||
|
|
||||||
## [0.20.0] — 2026-04-29
|
## [0.20.0] — 2026-04-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -133,8 +133,82 @@ def _url_for_shim(endpoint: str, **kw) -> str:
|
||||||
return _URL_MAP.get(endpoint, f"/{endpoint}")
|
return _URL_MAP.get(endpoint, f"/{endpoint}")
|
||||||
|
|
||||||
|
|
||||||
def _build_context(request: Request, user: Optional[dict] = None, **extra) -> dict:
|
def _read_agnes_ca_pem() -> Optional[str]:
|
||||||
"""Build template context with config, user, and theme."""
|
"""Read the Agnes server's TLS fullchain for inlining into the setup prompt.
|
||||||
|
|
||||||
|
Returns the PEM string when the cert needs trust-bootstrapping —
|
||||||
|
self-signed (subject == issuer of the leaf), private CA chain, or any
|
||||||
|
case where we can't cheaply prove the OS would trust it. Returns None
|
||||||
|
only for chains where the leaf's issuer matches a CA already in the
|
||||||
|
server's `certifi`-backed default trust store (publicly-trusted CA
|
||||||
|
like Let's Encrypt or a real corp PKI root that's distributed widely
|
||||||
|
enough to be in `certifi`).
|
||||||
|
|
||||||
|
Inlining a publicly-trusted cert is harmless (clients already trust
|
||||||
|
it via OS roots), but it bloats the prompt and steers users into
|
||||||
|
setting SSL_CERT_FILE unnecessarily, which narrows their Python TLS
|
||||||
|
trust to just this host. So skip when we can confirm broad trust.
|
||||||
|
|
||||||
|
Path is configurable via AGNES_TLS_FULLCHAIN_PATH (defaults to
|
||||||
|
`/data/state/certs/fullchain.pem`, the location `agnes-tls-rotate.sh`
|
||||||
|
writes on every VM and `docker-compose.host-mount.yml` rbinds into
|
||||||
|
the app container). Missing / unreadable / unparseable → None, and
|
||||||
|
the setup prompt falls back to its pre-cert behavior.
|
||||||
|
"""
|
||||||
|
path = Path(os.environ.get("AGNES_TLS_FULLCHAIN_PATH", "/data/state/certs/fullchain.pem"))
|
||||||
|
try:
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
pem = path.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if "-----BEGIN CERTIFICATE-----" not in pem:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cryptography import x509
|
||||||
|
# Parse just the first cert in the chain — that's the leaf, and
|
||||||
|
# leaf issuer/subject is what determines self-signed vs CA-signed.
|
||||||
|
first_block = pem.split("-----END CERTIFICATE-----", 1)[0] + "-----END CERTIFICATE-----\n"
|
||||||
|
leaf = x509.load_pem_x509_certificate(first_block.encode("utf-8"))
|
||||||
|
|
||||||
|
if leaf.issuer == leaf.subject:
|
||||||
|
# Self-signed — definitely needs bootstrap on the client.
|
||||||
|
return pem
|
||||||
|
|
||||||
|
# CA-signed leaf: check whether `certifi`'s trust store already
|
||||||
|
# contains the issuer. If yes, the user's `da`/uv (which both
|
||||||
|
# use `certifi` by default) will validate without our help.
|
||||||
|
try:
|
||||||
|
import certifi
|
||||||
|
with open(certifi.where(), "rb") as fh:
|
||||||
|
trust_pem = fh.read()
|
||||||
|
except Exception:
|
||||||
|
return pem # can't enumerate trust → assume bootstrap needed
|
||||||
|
|
||||||
|
issuer_dn = leaf.issuer.rfc4514_string()
|
||||||
|
for ca in x509.load_pem_x509_certificates(trust_pem):
|
||||||
|
if ca.subject.rfc4514_string() == issuer_dn:
|
||||||
|
return None # publicly trusted; client OS already accepts
|
||||||
|
return pem
|
||||||
|
except Exception: # pragma: no cover — defensive: bad PEM / x509 error
|
||||||
|
logger.exception("Failed to evaluate Agnes TLS cert; skipping inline")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_context(
|
||||||
|
request: Request,
|
||||||
|
user: Optional[dict] = None,
|
||||||
|
conn: Optional[duckdb.DuckDBPyConnection] = None,
|
||||||
|
**extra,
|
||||||
|
) -> dict:
|
||||||
|
"""Build template context with config, user, and theme.
|
||||||
|
|
||||||
|
`conn` is optional: when supplied alongside a logged-in `user`, the
|
||||||
|
setup-prompt preview/clipboard payload is rendered with that user's
|
||||||
|
RBAC-allowed Claude Code marketplace plugins inlined as install
|
||||||
|
commands. Routes that don't render the env-setup-cta block can omit it.
|
||||||
|
"""
|
||||||
class ConfigProxy:
|
class ConfigProxy:
|
||||||
INSTANCE_NAME = get_instance_name()
|
INSTANCE_NAME = get_instance_name()
|
||||||
INSTANCE_SUBTITLE = get_instance_subtitle()
|
INSTANCE_SUBTITLE = get_instance_subtitle()
|
||||||
|
|
@ -182,7 +256,42 @@ def _build_context(request: Request, user: Optional[dict] = None, **extra) -> di
|
||||||
from app.api.cli_artifacts import _find_wheel
|
from app.api.cli_artifacts import _find_wheel
|
||||||
_wheel = _find_wheel()
|
_wheel = _find_wheel()
|
||||||
_wheel_filename = _wheel.name if _wheel else "agnes.whl"
|
_wheel_filename = _wheel.name if _wheel else "agnes.whl"
|
||||||
setup_instructions_lines = resolve_lines(_wheel_filename)
|
|
||||||
|
# Inline the user's RBAC-allowed marketplace plugins as `claude plugin
|
||||||
|
# install` commands so a single paste also bootstraps the marketplace
|
||||||
|
# and plugin set. Anonymous viewers (no user, or no DB conn) get the
|
||||||
|
# original 6-step layout.
|
||||||
|
plugin_install_names: list[str] = []
|
||||||
|
if user and conn is not None:
|
||||||
|
try:
|
||||||
|
from src import marketplace_filter
|
||||||
|
plugin_install_names = [
|
||||||
|
p["manifest_name"]
|
||||||
|
for p in marketplace_filter.resolve_allowed_plugins(conn, user)
|
||||||
|
]
|
||||||
|
except Exception: # pragma: no cover — defensive: never block dashboard render
|
||||||
|
logger.exception("Failed to resolve marketplace plugins for setup prompt")
|
||||||
|
plugin_install_names = []
|
||||||
|
|
||||||
|
# `AGNES_DEBUG_AUTH` is the existing dev/staging gate (see
|
||||||
|
# `app/api/me_debug.py`, `app/web/router.py` template ConfigProxy).
|
||||||
|
# When on, the setup prompt also disables host-scoped git TLS verify
|
||||||
|
# so `claude plugin marketplace add` works against self-signed instances.
|
||||||
|
# Subsumed by the cert trust block when `ca_pem` is loaded below.
|
||||||
|
self_signed_tls = os.environ.get("AGNES_DEBUG_AUTH", "").strip().lower() in (
|
||||||
|
"1", "true", "yes",
|
||||||
|
)
|
||||||
|
server_host = request.url.netloc
|
||||||
|
|
||||||
|
ca_pem = _read_agnes_ca_pem()
|
||||||
|
|
||||||
|
setup_instructions_lines = resolve_lines(
|
||||||
|
_wheel_filename,
|
||||||
|
plugin_install_names=plugin_install_names,
|
||||||
|
self_signed_tls=self_signed_tls,
|
||||||
|
server_host=server_host,
|
||||||
|
ca_pem=ca_pem,
|
||||||
|
)
|
||||||
ctx_server_url = str(request.base_url).rstrip("/")
|
ctx_server_url = str(request.base_url).rstrip("/")
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
|
|
@ -350,7 +459,7 @@ async def dashboard(
|
||||||
self.groups = []
|
self.groups = []
|
||||||
|
|
||||||
ctx = _build_context(
|
ctx = _build_context(
|
||||||
request, user=user,
|
request, user=user, conn=conn,
|
||||||
user_info=UserInfo(),
|
user_info=UserInfo(),
|
||||||
username=user.get("email", "").split("@")[0],
|
username=user.get("email", "").split("@")[0],
|
||||||
total_tables=total_tables,
|
total_tables=total_tables,
|
||||||
|
|
@ -624,12 +733,14 @@ async def activity_center(
|
||||||
async def install_page(
|
async def install_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: Optional[dict] = Depends(get_optional_user),
|
user: Optional[dict] = Depends(get_optional_user),
|
||||||
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
):
|
):
|
||||||
"""Public install instructions for the CLI."""
|
"""Public install instructions for the CLI."""
|
||||||
base_url = str(request.base_url).rstrip("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
ctx = _build_context(
|
ctx = _build_context(
|
||||||
request,
|
request,
|
||||||
user=user,
|
user=user,
|
||||||
|
conn=conn,
|
||||||
server_url=base_url,
|
server_url=base_url,
|
||||||
agnes_version=os.environ.get("AGNES_VERSION", "dev"),
|
agnes_version=os.environ.get("AGNES_VERSION", "dev"),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,93 +4,718 @@ Both the JS-embedded clipboard renderer (`_claude_setup_instructions.jinja`)
|
||||||
and the read-only HTML preview on the dashboard and /install pages consume
|
and the read-only HTML preview on the dashboard and /install pages consume
|
||||||
these lines. Keep it in Python so there is exactly ONE place that edits.
|
these lines. Keep it in Python so there is exactly ONE place that edits.
|
||||||
|
|
||||||
Placeholders `{server_url}`, `{token}`, and `{wheel_filename}` are substituted
|
Placeholders `{server_url}`, `{token}`, `{wheel_filename}`, and `{server_host}`
|
||||||
at render time. `{wheel_filename}` is pre-substituted server-side via
|
are substituted at render time. `{wheel_filename}` and `{server_host}` are
|
||||||
`resolve_lines()` because `uv tool install` validates the PEP 427 filename
|
pre-substituted server-side via `resolve_lines()`; `{server_url}` and
|
||||||
*in the URL path* before fetching, so a stable alias like `agnes.whl` fails
|
`{token}` survive into the JS template and are filled in at click time.
|
||||||
with "Must have a version" — we need the real versioned filename inlined.
|
|
||||||
|
`{wheel_filename}` is server-pre-substituted because `uv tool install`
|
||||||
|
validates the PEP 427 filename *in the URL path* before fetching, so a
|
||||||
|
stable alias like `agnes.whl` fails with "Must have a version" — we need
|
||||||
|
the real versioned filename inlined.
|
||||||
|
|
||||||
|
`{server_host}` is server-pre-substituted because the `git config` and
|
||||||
|
`claude plugin marketplace add` lines need the bare host (no scheme), and
|
||||||
|
the click-time JS only knows the full origin (`{server_url}`).
|
||||||
|
|
||||||
|
## Cross-platform trust strategy (when `ca_pem` is supplied)
|
||||||
|
|
||||||
|
The trust block (step 0) is the load-bearing piece. Three things bit us in
|
||||||
|
practice and the design here exists to dodge each one:
|
||||||
|
|
||||||
|
1. **rustls rejects the Agnes leaf cert as `CaUsedAsEndEntity`.** The Agnes
|
||||||
|
server's self-signed cert is simultaneously its own CA (basicConstraints
|
||||||
|
`CA:TRUE`) AND the leaf served on the wire — a setup OpenSSL tolerates
|
||||||
|
but webpki/rustls strictly refuses. So `uv tool install <https-url>`
|
||||||
|
never works against the Agnes wheel endpoint. We download the wheel via
|
||||||
|
curl first (curl uses OpenSSL, accepts the cert), then `uv tool install
|
||||||
|
--native-tls --force <local-file>` lets rustls reuse the OS trust store
|
||||||
|
for PyPI dependency resolution. No HTTPS hop through rustls touches the
|
||||||
|
Agnes host.
|
||||||
|
|
||||||
|
2. **`SSL_CERT_FILE` REPLACES the trust store, it doesn't append.** Pointing
|
||||||
|
it at `~/.agnes/ca.pem` alone breaks every Python tool that needs to
|
||||||
|
reach a public host (PyPI, GitHub) — `da` works fine because it only
|
||||||
|
talks to Agnes, but `uv run --with <pkg>` immediately fails with
|
||||||
|
`UnknownIssuer`. We materialize a combined bundle at
|
||||||
|
`~/.agnes/ca-bundle.pem` (system roots + Agnes CA) and point all
|
||||||
|
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `GIT_SSL_CAINFO` at it.
|
||||||
|
`NODE_EXTRA_CA_CERTS` keeps pointing at just `ca.pem` because Node's
|
||||||
|
semantics is *additive* (appends to bundled roots), so a single-cert
|
||||||
|
file is correct there.
|
||||||
|
|
||||||
|
3. **Bun-compiled `claude` (Windows + macOS distributions) ignores every
|
||||||
|
CA env var AND the OS trust store for marketplace HTTPS.** On macOS
|
||||||
|
arm64 the binary at `~/.local/bin/claude` is a Mach-O with a `__BUN`
|
||||||
|
segment (single-file `bun build --compile`); on Windows claude.exe is
|
||||||
|
the same shape. `strings` shows the binary recognizes
|
||||||
|
`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`,
|
||||||
|
`CURL_CA_BUNDLE` (including a "NODE_EXTRA_CA_CERTS detected" log
|
||||||
|
string), but in practice the values never reach the TLS context — a
|
||||||
|
known limitation of Bun's compiled-binary HTTPS path. Registering the
|
||||||
|
cert in the OS trust store (Windows: `certutil -user -addstore Root`;
|
||||||
|
macOS: `security add-trusted-cert`; Linux: `update-ca-certificates` /
|
||||||
|
`update-ca-trust`) doesn't fix it on Windows or macOS either — the
|
||||||
|
binary's bundled CA list isn't refreshable from the OS store.
|
||||||
|
|
||||||
|
So the marketplace step branches on platform:
|
||||||
|
- Windows + macOS → straight to system-`git clone` fallback
|
||||||
|
(system git honors `GIT_SSL_CAINFO`, so the clone works).
|
||||||
|
- Linux → typically the node-based npm install where
|
||||||
|
`NODE_EXTRA_CA_CERTS` does take effect; try direct first, fall
|
||||||
|
back to git clone on failure.
|
||||||
|
|
||||||
|
The OS trust-store registration in (c) is still done on all three
|
||||||
|
platforms because it's needed for *non-claude* native tools — e.g.
|
||||||
|
the system git fetch path itself (Schannel on Windows, Security
|
||||||
|
framework on macOS) trusts via the OS store, not via env vars.
|
||||||
|
|
||||||
|
## Step ordering
|
||||||
|
|
||||||
|
The numbered steps are arranged so that:
|
||||||
|
- All installation work (CLI, plugins) happens first, in one go.
|
||||||
|
- The interactive question (skills copy vs on-demand) is the LAST step
|
||||||
|
before Confirm — by that point everything else is done, the user only
|
||||||
|
needs to decide one thing, and the assistant blocks on their answer.
|
||||||
|
- `da diagnose` runs late so it doubles as a final smoke test after
|
||||||
|
plugins are in place, instead of gating them.
|
||||||
|
|
||||||
|
Layout (with marketplace plugins to install):
|
||||||
|
0 TLS trust block (only when ca_pem is supplied)
|
||||||
|
1 Install CLI
|
||||||
|
2 Login
|
||||||
|
3 Verify
|
||||||
|
4 Git check
|
||||||
|
5 Marketplace + plugins
|
||||||
|
6 Diagnose
|
||||||
|
7 Skills (interactive — assistant waits for user)
|
||||||
|
8 Confirm
|
||||||
|
|
||||||
|
Layout (no plugins): steps 4-5 collapse out, diagnose/skills/confirm
|
||||||
|
renumber to 4-5-6.
|
||||||
|
|
||||||
|
The combined-bundle source uses a fallback chain so the prompt still works
|
||||||
|
on machines without the system Python `certifi`: we try (a) `python3 -c
|
||||||
|
'import certifi'`, (b) the platform's curl/openssl bundle path, (c)
|
||||||
|
`uv run --with certifi` as a network last-resort. The user explicitly
|
||||||
|
permitted that fallback chain — it's not improvising-around-a-TLS-error.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
SETUP_INSTRUCTIONS_LINES: list[str] = [
|
# Marketplace name as published by app.marketplace_server.packager.
|
||||||
"Set up the Agnes CLI on this machine.",
|
# Hard-coded here (rather than imported) to keep this module dependency-free
|
||||||
"",
|
# and trivially testable. If the value ever drifts, the regression test
|
||||||
"Server: {server_url}",
|
# below catches it.
|
||||||
"Personal access token: {token}",
|
_MARKETPLACE_NAME = "agnes"
|
||||||
"(Just generated; treat it as a secret.)",
|
|
||||||
"",
|
|
||||||
"Run these, in order. If any step fails, paste the exact error back and stop.",
|
def _tls_trust_block(ca_pem: str) -> list[str]:
|
||||||
"",
|
"""Step 0 — cross-platform TLS trust bootstrap for the Agnes server.
|
||||||
"1) Install the CLI:",
|
|
||||||
" uv tool install --force {server_url}/cli/wheel/{wheel_filename}",
|
Emitted only when the server has a non-publicly-trusted cert. Does four
|
||||||
"",
|
things in a single numbered block (see module docstring for the full
|
||||||
" If uv is not installed yet:",
|
rationale):
|
||||||
" curl -LsSf https://astral.sh/uv/install.sh | sh",
|
|
||||||
"",
|
(a) Detect platform (Windows Git Bash / macOS / Linux) and pick the
|
||||||
" If `da --version` fails after install because ~/.local/bin is not on PATH:",
|
shell rc file that the user's login shell actually reads.
|
||||||
" export PATH=\"$HOME/.local/bin:$PATH\"",
|
`$SHELL`-driven, NOT existence-of-rc-driven — old setups put a
|
||||||
" # persist: append the same line to your ~/.zshrc or ~/.bashrc",
|
legacy `.bashrc` next to a default zsh shell on macOS, and the
|
||||||
|
`[ -f .bashrc ]` heuristic silently writes to the wrong file.
|
||||||
|
(b) Write the cert PEM to `~/.agnes/ca.pem` via single-quoted heredoc
|
||||||
|
(so `$` / backtick chars in real-world certs never shell-expand).
|
||||||
|
(c) Register the cert in the OS trust store (so native binaries that
|
||||||
|
bypass our env vars — claude.exe, system git's Schannel backend,
|
||||||
|
Python apps using `truststore` — still trust the host).
|
||||||
|
Idempotent: re-running just re-affirms the entry.
|
||||||
|
(d) Build a *combined* CA bundle (system roots + Agnes CA) at
|
||||||
|
`~/.agnes/ca-bundle.pem`, with a fallback chain for the system
|
||||||
|
roots source. Persist `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` /
|
||||||
|
`GIT_SSL_CAINFO` pointing at the bundle, plus
|
||||||
|
`NODE_EXTRA_CA_CERTS` pointing at just `ca.pem` (Node
|
||||||
|
appends-not-replaces). Persistence is idempotent via a grep
|
||||||
|
guard for the `AGNES_CA_PEM_TRUST` marker.
|
||||||
|
"""
|
||||||
|
pem = ca_pem.strip()
|
||||||
|
lines: list[str] = [
|
||||||
|
"0) Trust the Agnes TLS certificate — cross-platform setup for a self-signed / private-CA host.",
|
||||||
|
"",
|
||||||
|
" (a) Detect platform + pick the shell rc file your login shell actually reads.",
|
||||||
|
" Driven by $SHELL + uname (NOT by which rc files happen to exist on disk).",
|
||||||
|
"",
|
||||||
|
" case \"$(uname -s)\" in",
|
||||||
|
" Darwin) PLATFORM=macos ;;",
|
||||||
|
" Linux) PLATFORM=linux ;;",
|
||||||
|
" MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;;",
|
||||||
|
" *) echo \"Unsupported OS: $(uname -s)\" >&2; exit 1 ;;",
|
||||||
|
" esac",
|
||||||
|
" SHELL_NAME=\"$(basename \"${SHELL:-bash}\")\"",
|
||||||
|
" case \"${SHELL_NAME}:${PLATFORM}\" in",
|
||||||
|
" zsh:*) RC=\"$HOME/.zshrc\" ;;",
|
||||||
|
" bash:macos) RC=\"$HOME/.bash_profile\" ;;",
|
||||||
|
" bash:windows|bash:linux) RC=\"$HOME/.bashrc\" ;;",
|
||||||
|
" *) RC=\"$HOME/.profile\" ;;",
|
||||||
|
" esac",
|
||||||
|
" echo \"Platform: $PLATFORM, shell: $SHELL_NAME, rc: $RC\"",
|
||||||
|
"",
|
||||||
|
" (b) Write the cert (single-quoted heredoc so $/backticks in the body don't expand):",
|
||||||
|
"",
|
||||||
|
" mkdir -p ~/.agnes",
|
||||||
|
" cat > ~/.agnes/ca.pem <<'AGNES_CA_PEM'",
|
||||||
|
]
|
||||||
|
# PEM body is flush-left: `<<'DELIM'` heredocs preserve leading whitespace,
|
||||||
|
# and any indent inside the cert breaks `openssl x509` / Python ssl parsers.
|
||||||
|
lines.extend(pem.splitlines())
|
||||||
|
lines.extend([
|
||||||
|
"AGNES_CA_PEM",
|
||||||
|
"",
|
||||||
|
" (c) Register the cert in the OS trust store. Native binaries (claude.exe,",
|
||||||
|
" system git's Schannel/Security.framework backends) read the OS store",
|
||||||
|
" and ignore our env vars — without this, the later marketplace `git",
|
||||||
|
" clone` (when plugins are configured) and any user-side git/native",
|
||||||
|
" tooling against the Agnes host will fail.",
|
||||||
|
" No admin rights needed (user-store only). Idempotent.",
|
||||||
|
"",
|
||||||
|
" case \"$PLATFORM\" in",
|
||||||
|
" windows)",
|
||||||
|
" WIN_CA=\"$(cygpath -w ~/.agnes/ca.pem)\"",
|
||||||
|
" certutil.exe -user -addstore \"Root\" \"$WIN_CA\"",
|
||||||
|
" ;;",
|
||||||
|
" macos)",
|
||||||
|
" # Will prompt once for the keychain password.",
|
||||||
|
" security add-trusted-cert -r trustRoot \\",
|
||||||
|
" -k \"$HOME/Library/Keychains/login.keychain-db\" \\",
|
||||||
|
" ~/.agnes/ca.pem",
|
||||||
|
" ;;",
|
||||||
|
" linux)",
|
||||||
|
" if command -v update-ca-certificates >/dev/null 2>&1; then",
|
||||||
|
" sudo cp ~/.agnes/ca.pem /usr/local/share/ca-certificates/agnes.crt",
|
||||||
|
" sudo update-ca-certificates",
|
||||||
|
" elif command -v update-ca-trust >/dev/null 2>&1; then",
|
||||||
|
" sudo cp ~/.agnes/ca.pem /etc/pki/ca-trust/source/anchors/agnes.crt",
|
||||||
|
" sudo update-ca-trust",
|
||||||
|
" else",
|
||||||
|
" echo \"WARN: install ~/.agnes/ca.pem into your distro's trust store manually\" >&2",
|
||||||
|
" fi",
|
||||||
|
" ;;",
|
||||||
|
" esac",
|
||||||
|
"",
|
||||||
|
" (d) Build a COMBINED CA bundle (system roots + Agnes CA) for Python tools",
|
||||||
|
" and curl. SSL_CERT_FILE *replaces* the trust store, so pointing it at",
|
||||||
|
" the Agnes CA alone would break public hosts (PyPI etc.). Source the",
|
||||||
|
" system roots from a fallback chain — the first source that produces",
|
||||||
|
" a non-empty, existing path wins. Don't abort on the first miss; that's",
|
||||||
|
" what the chain is for.",
|
||||||
|
"",
|
||||||
|
" CERTIFI_PATH=\"$(python3 -c 'import certifi; print(certifi.where())' 2>/dev/null || true)\"",
|
||||||
|
" [ -z \"$CERTIFI_PATH\" ] && CERTIFI_PATH=\"$(python -c 'import certifi; print(certifi.where())' 2>/dev/null || true)\"",
|
||||||
|
" if [ -z \"$CERTIFI_PATH\" ]; then",
|
||||||
|
" for p in /mingw64/ssl/certs/ca-bundle.crt /usr/ssl/certs/ca-bundle.crt \\",
|
||||||
|
" /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt \\",
|
||||||
|
" /etc/ssl/cert.pem; do",
|
||||||
|
" [ -f \"$p\" ] && CERTIFI_PATH=\"$p\" && break",
|
||||||
|
" done",
|
||||||
|
" fi",
|
||||||
|
" if [ -z \"$CERTIFI_PATH\" ]; then",
|
||||||
|
" CERTIFI_PATH=\"$(uv run --native-tls --with certifi --no-project python -c 'import certifi; print(certifi.where())' 2>/dev/null || true)\"",
|
||||||
|
" fi",
|
||||||
|
" if [ -z \"$CERTIFI_PATH\" ] || [ ! -f \"$CERTIFI_PATH\" ]; then",
|
||||||
|
" echo \"ERROR: locate a system CA bundle. Install Python 3 + certifi and re-run.\" >&2",
|
||||||
|
" exit 1",
|
||||||
|
" fi",
|
||||||
|
" echo \"Base CA bundle: $CERTIFI_PATH\"",
|
||||||
|
" cat \"$CERTIFI_PATH\" ~/.agnes/ca.pem > ~/.agnes/ca-bundle.pem",
|
||||||
|
"",
|
||||||
|
" (e) Persist env vars in the rc file picked in (a). Idempotent — won't",
|
||||||
|
" duplicate on re-run thanks to the AGNES_CA_PEM_TRUST grep guard.",
|
||||||
|
" Note the asymmetry: SSL_CERT_FILE (and REQUESTS_CA_BUNDLE, GIT_SSL_CAINFO)",
|
||||||
|
" point at the COMBINED bundle because those tools REPLACE trust.",
|
||||||
|
" NODE_EXTRA_CA_CERTS points at just ca.pem because Node APPENDS to its",
|
||||||
|
" bundled roots.",
|
||||||
|
"",
|
||||||
|
" if ! grep -q 'AGNES_CA_PEM_TRUST' \"$RC\" 2>/dev/null; then",
|
||||||
|
" cat >> \"$RC\" <<'AGNES_RC_BLOCK'",
|
||||||
|
"# AGNES_CA_PEM_TRUST — added by Agnes setup",
|
||||||
|
"# Combined bundle (system roots + Agnes CA) for tools that REPLACE trust:",
|
||||||
|
"export SSL_CERT_FILE=\"$HOME/.agnes/ca-bundle.pem\"",
|
||||||
|
"export REQUESTS_CA_BUNDLE=\"$HOME/.agnes/ca-bundle.pem\"",
|
||||||
|
"export GIT_SSL_CAINFO=\"$HOME/.agnes/ca-bundle.pem\"",
|
||||||
|
"# Single-cert file for Node (APPENDS to bundled roots):",
|
||||||
|
"export NODE_EXTRA_CA_CERTS=\"$HOME/.agnes/ca.pem\"",
|
||||||
|
"export PATH=\"$HOME/.local/bin:$PATH\"",
|
||||||
|
"AGNES_RC_BLOCK",
|
||||||
|
" fi",
|
||||||
|
" # Apply for THIS shell too:",
|
||||||
|
" export SSL_CERT_FILE=\"$HOME/.agnes/ca-bundle.pem\"",
|
||||||
|
" export REQUESTS_CA_BUNDLE=\"$HOME/.agnes/ca-bundle.pem\"",
|
||||||
|
" export GIT_SSL_CAINFO=\"$HOME/.agnes/ca-bundle.pem\"",
|
||||||
|
" export NODE_EXTRA_CA_CERTS=\"$HOME/.agnes/ca.pem\"",
|
||||||
|
" export PATH=\"$HOME/.local/bin:$PATH\"",
|
||||||
|
"",
|
||||||
|
" IMPORTANT for the Bash tool: env vars do NOT persist between separate",
|
||||||
|
" Bash invocations. Re-export the four lines above (SSL_CERT_FILE,",
|
||||||
|
" REQUESTS_CA_BUNDLE, GIT_SSL_CAINFO, NODE_EXTRA_CA_CERTS) plus PATH at",
|
||||||
|
" the top of every later step's bash block that talks to Agnes.",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _install_cli_lines(*, has_ca: bool, server_url_placeholder: str = "{server_url}") -> list[str]:
|
||||||
|
"""Step 1 — install the `da` CLI.
|
||||||
|
|
||||||
|
When the trust block was emitted (`has_ca=True`), we MUST avoid
|
||||||
|
`uv tool install <https-url>` against the Agnes wheel endpoint:
|
||||||
|
rustls rejects the Agnes leaf cert with `CaUsedAsEndEntity`, regardless
|
||||||
|
of `--native-tls` (the rejection is at chain validation, not at trust
|
||||||
|
lookup — putting the cert in the OS store doesn't fix it). Solution:
|
||||||
|
download the wheel with `curl --cacert` (curl uses OpenSSL, no rustls),
|
||||||
|
then `uv tool install --native-tls` from the local file. PyPI deps
|
||||||
|
still resolve over HTTPS, but `--native-tls` makes uv use the OS trust
|
||||||
|
store for that path, which is fine because PyPI's CA chain is public.
|
||||||
|
|
||||||
|
When `has_ca=False`, we trust the server's cert is publicly valid, so
|
||||||
|
the simple direct install works.
|
||||||
|
"""
|
||||||
|
if has_ca:
|
||||||
|
return [
|
||||||
|
"1) Install the CLI.",
|
||||||
|
" The Agnes server's self-signed cert trips rustls' CaUsedAsEndEntity check,",
|
||||||
|
" so direct `uv tool install <https-url>` against the wheel endpoint fails",
|
||||||
|
" (even with --native-tls). Workaround: curl-then-local-install.",
|
||||||
|
"",
|
||||||
|
" If uv is missing first:",
|
||||||
|
" curl -LsSf https://astral.sh/uv/install.sh | sh",
|
||||||
|
" export PATH=\"$HOME/.local/bin:$PATH\"",
|
||||||
|
"",
|
||||||
|
" WHEEL=/tmp/{wheel_filename}",
|
||||||
|
f" curl -fsSL --cacert ~/.agnes/ca.pem -o \"$WHEEL\" {server_url_placeholder}/cli/wheel/{{wheel_filename}}",
|
||||||
|
" uv tool install --native-tls --force \"$WHEEL\"",
|
||||||
|
"",
|
||||||
|
" If `da --version` fails after install because ~/.local/bin is not on PATH:",
|
||||||
|
" export PATH=\"$HOME/.local/bin:$PATH\"",
|
||||||
|
" # persist: append the same line to your ~/.zshrc or ~/.bashrc",
|
||||||
|
" # (the trust block in step 0 already does this for you on first run).",
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
"1) Install the CLI:",
|
||||||
|
f" uv tool install --force {server_url_placeholder}/cli/wheel/{{wheel_filename}}",
|
||||||
|
"",
|
||||||
|
" If uv is not installed yet:",
|
||||||
|
" curl -LsSf https://astral.sh/uv/install.sh | sh",
|
||||||
|
"",
|
||||||
|
" If `da --version` fails after install because ~/.local/bin is not on PATH:",
|
||||||
|
" export PATH=\"$HOME/.local/bin:$PATH\"",
|
||||||
|
" # persist: append the same line to your ~/.zshrc or ~/.bashrc",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Steps 2-3: login + verify. Static — these always come right after install.
|
||||||
|
_LOGIN_VERIFY_LINES: list[str] = [
|
||||||
"",
|
"",
|
||||||
"2) Log in (also saves the server URL):",
|
"2) Log in (also saves the server URL):",
|
||||||
" da auth import-token --token \"{token}\" --server \"{server_url}\"",
|
" da auth import-token --token \"{token}\" --server \"{server_url}\"",
|
||||||
"",
|
"",
|
||||||
"3) Verify the login:",
|
"3) Verify the login:",
|
||||||
" da auth whoami",
|
" da auth whoami",
|
||||||
"",
|
|
||||||
"4) Run diagnostics:",
|
|
||||||
" da diagnose",
|
|
||||||
"",
|
|
||||||
" This should print \"Overall: healthy\" and a list of green checks. If",
|
|
||||||
" anything is yellow/red, paste the full output back.",
|
|
||||||
"",
|
|
||||||
"5) Skills (ask the user first):",
|
|
||||||
" The CLI ships with reusable markdown skills (setup, connectors,",
|
|
||||||
" corporate-memory, deploy, notifications, security, troubleshoot),",
|
|
||||||
" listable via `da skills list` and readable via `da skills show <name>`.",
|
|
||||||
"",
|
|
||||||
" Ask the user verbatim: \"Do you want me to copy the Agnes skills into",
|
|
||||||
" ~/.claude/skills/agnes/ so they are always loaded in Claude Code,",
|
|
||||||
" or should I pull them on-demand via `da skills show <name>` when",
|
|
||||||
" needed?\"",
|
|
||||||
"",
|
|
||||||
" If they say copy:",
|
|
||||||
" mkdir -p ~/.claude/skills/agnes",
|
|
||||||
" for s in $(da skills list | awk '{print $1}'); do",
|
|
||||||
" da skills show \"$s\" > ~/.claude/skills/agnes/\"$s\".md",
|
|
||||||
" done",
|
|
||||||
" echo \"Copied skills to ~/.claude/skills/agnes/\"",
|
|
||||||
"",
|
|
||||||
"6) Confirm:",
|
|
||||||
" Tell me \"Agnes CLI is ready\" and summarize:",
|
|
||||||
" - `da --version` output",
|
|
||||||
" - `da auth whoami` output (email + role)",
|
|
||||||
" - Whether skills were copied or left on-demand",
|
|
||||||
" - The `da diagnose` overall status",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def resolve_lines(wheel_filename: str) -> list[str]:
|
def _diagnose_skills_lines(*, diagnose_num: str, skills_num: str) -> list[str]:
|
||||||
"""Return the template lines with `{wheel_filename}` pre-substituted.
|
"""Diagnose + skills steps — moved AFTER the marketplace block.
|
||||||
|
|
||||||
Called by the web router before passing the lines to the Jinja partial
|
Putting these last (instead of right after `whoami`) means: by the time
|
||||||
(both preview and JS modes). Keeps the client side from having to know
|
we ask the user the skills question, all installation work is finished —
|
||||||
the wheel filename and keeps the two renderers byte-identical.
|
the only thing the prompt is still waiting on is one human-loop answer.
|
||||||
|
`da diagnose` then doubles as a server-health smoke test that runs after
|
||||||
|
plugins are in place, not as a gate before them. With the new ordering
|
||||||
|
skills is the LAST step before Confirm, so the assistant must wait for
|
||||||
|
the user's answer before finalizing — there's no "run other steps in
|
||||||
|
parallel" affordance any more (and it isn't needed).
|
||||||
|
|
||||||
|
Step numbers are filled in by the caller because they shift between
|
||||||
|
the no-marketplace layout (4, 5) and the marketplace layout (6, 7).
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
"",
|
||||||
|
f"{diagnose_num}) Run diagnostics:",
|
||||||
|
" da diagnose",
|
||||||
|
"",
|
||||||
|
" This should print \"Overall: healthy\". `db_schema: unknown` and",
|
||||||
|
" `data: 0 tables` are NORMAL in two cases:",
|
||||||
|
" - fresh install (no tables registered yet), and",
|
||||||
|
" - non-admin roles (e.g. `analyst`) that don't have grants to read",
|
||||||
|
" the system schema even on populated instances.",
|
||||||
|
" Only flag actual yellow/red checks (api / duckdb_state / users).",
|
||||||
|
"",
|
||||||
|
f"{skills_num}) Skills (ask the user — this is the last interactive step before Confirm):",
|
||||||
|
" The CLI ships with reusable markdown skills (setup, connectors,",
|
||||||
|
" corporate-memory, deploy, notifications, security, troubleshoot),",
|
||||||
|
" listable via `da skills list` and readable via `da skills show <name>`.",
|
||||||
|
"",
|
||||||
|
" Ask the user verbatim: \"Do you want me to copy the Agnes skills into",
|
||||||
|
" ~/.claude/skills/agnes/ so they are always loaded in Claude Code,",
|
||||||
|
" or should I pull them on-demand via `da skills show <name>` when",
|
||||||
|
" needed?\"",
|
||||||
|
"",
|
||||||
|
" Wait for the user's answer before moving to Confirm.",
|
||||||
|
"",
|
||||||
|
" If they say copy:",
|
||||||
|
" mkdir -p ~/.claude/skills/agnes",
|
||||||
|
" for s in $(da skills list | awk '{print $1}'); do",
|
||||||
|
" da skills show \"$s\" > ~/.claude/skills/agnes/\"$s\".md",
|
||||||
|
" done",
|
||||||
|
" echo \"Copied skills to ~/.claude/skills/agnes/\"",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _finale_lines(*, confirm_step_num: str, has_ca: bool, has_marketplace: bool) -> list[str]:
|
||||||
|
"""Final Confirm step. Bullets it asks the assistant to report on must
|
||||||
|
only reference earlier steps that were actually emitted, otherwise the
|
||||||
|
assistant either hallucinates an answer or asks the user about a
|
||||||
|
non-existent step. The CA-bundle-source bullet only makes sense when
|
||||||
|
the trust block ran (`has_ca`); the marketplace direct-vs-clone bullet
|
||||||
|
only makes sense when the marketplace block ran (`has_marketplace`).
|
||||||
|
Skills + diagnose + version + whoami always render, so their bullets
|
||||||
|
are unconditional."""
|
||||||
|
bullets = [
|
||||||
|
" - `da --version` output",
|
||||||
|
" - `da auth whoami` output (email + role)",
|
||||||
|
" - Whether skills were copied or left on-demand",
|
||||||
|
" - The `da diagnose` overall status",
|
||||||
|
]
|
||||||
|
if has_ca:
|
||||||
|
bullets.append(
|
||||||
|
" - Which CA bundle source got picked in step 0(d) "
|
||||||
|
"(system Python certifi / system curl bundle / uv-fetched)"
|
||||||
|
)
|
||||||
|
if has_marketplace:
|
||||||
|
bullets.append(
|
||||||
|
" - Whether the marketplace add went via direct HTTPS or via the "
|
||||||
|
"git-clone fallback (and on which platform)"
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
f"{confirm_step_num}) Confirm:",
|
||||||
|
" Tell me \"Agnes CLI is ready\" and summarize:",
|
||||||
|
*bullets,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _git_check_block(step_num: str) -> list[str]:
|
||||||
|
"""Git pre-flight check — runs before the marketplace clone.
|
||||||
|
|
||||||
|
`claude plugin marketplace add` (and our git-clone fallback) shells out
|
||||||
|
to `git`, so a missing git binary fails the marketplace step with a
|
||||||
|
confusing error. Cross-platform install commands cover the three
|
||||||
|
supported workstation OSes:
|
||||||
|
- macOS: Homebrew (`brew install git`). The Xcode CLT bundle also
|
||||||
|
ships git; we prefer brew because it's non-interactive.
|
||||||
|
- Windows: winget (`winget install --id Git.Git -e ...`). Bundled
|
||||||
|
with Windows 10 1809+ and Windows 11; non-interactive with --silent.
|
||||||
|
- Linux: apt or dnf, depending on distro family.
|
||||||
|
|
||||||
|
`step_num` is parameterized because step ordering shifted between
|
||||||
|
layouts (the marketplace block now runs before diagnose/skills, so
|
||||||
|
git-check + marketplace are steps 4-5 instead of 6-7).
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
"",
|
||||||
|
f"{step_num}) Make sure git is installed (required for the marketplace clone):",
|
||||||
|
" git --version",
|
||||||
|
"",
|
||||||
|
" If that fails (\"command not found\" or similar), install git:",
|
||||||
|
" - macOS: brew install git",
|
||||||
|
" - Windows: winget install --id Git.Git -e --source winget --silent",
|
||||||
|
" - Linux: sudo apt-get install git OR sudo dnf install git",
|
||||||
|
"",
|
||||||
|
" Then re-run `git --version` to confirm before continuing.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _marketplace_block(
|
||||||
|
plugin_install_names: list[str],
|
||||||
|
self_signed_tls: bool,
|
||||||
|
has_ca: bool,
|
||||||
|
step_num: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build the marketplace + plugin-install block.
|
||||||
|
|
||||||
|
Pre-condition: `plugin_install_names` is non-empty (caller checks).
|
||||||
|
|
||||||
|
`step_num` is parameterized because step ordering shifted between
|
||||||
|
layouts (this block now runs before diagnose/skills, so it's step 5
|
||||||
|
instead of the old step 7).
|
||||||
|
|
||||||
|
With `has_ca=True`: the user has the trust block from step 0, so we know
|
||||||
|
the cert is in the OS store and our env vars are set. Strategy:
|
||||||
|
- Windows: claude.exe is a Bun-compiled binary that ignores both the
|
||||||
|
Windows trust store AND NODE_EXTRA_CA_CERTS for marketplace HTTPS.
|
||||||
|
Skip the direct attempt; system `git clone` honors GIT_SSL_CAINFO
|
||||||
|
(the combined bundle from step 0) and works.
|
||||||
|
- macOS: same story. `claude` on macOS arm64 ships as a Mach-O binary
|
||||||
|
with a `__BUN` segment (single-file Bun build); empirically it
|
||||||
|
ignores SSL_CERT_FILE / NODE_EXTRA_CA_CERTS / login keychain alike,
|
||||||
|
even though `strings` shows the binary recognizes those env-var
|
||||||
|
names. Go straight to git-clone on macOS too.
|
||||||
|
- Linux: still ships node-based claude on most distros (npm install
|
||||||
|
path), where NODE_EXTRA_CA_CERTS does take effect. Try direct
|
||||||
|
first, fall back to git clone on failure.
|
||||||
|
|
||||||
|
Token hygiene: after the clone, we strip the PAT from the cloned repo's
|
||||||
|
`origin` URL (`git remote set-url`) and chmod ~/.agnes/marketplace tight.
|
||||||
|
Reason: `git clone https://x:<PAT>@host/...` writes the URL verbatim
|
||||||
|
into `.git/config`, where it sits in plaintext for anything that reads
|
||||||
|
home (cloud sync, antivirus scanners, peer processes). claude's
|
||||||
|
marketplace registration uses the local FS path, not the remote URL,
|
||||||
|
so stripping the token after clone is harmless — to refresh later, the
|
||||||
|
user re-runs setup from the dashboard with a fresh PAT.
|
||||||
|
|
||||||
|
With `has_ca=False`: the legacy path. If `self_signed_tls=True` we emit
|
||||||
|
the host-scoped `git config sslVerify=false` downgrade so the marketplace
|
||||||
|
git-clone (under claude's hood) works against an untrusted endpoint.
|
||||||
|
"""
|
||||||
|
if has_ca:
|
||||||
|
lines: list[str] = [
|
||||||
|
"",
|
||||||
|
f"{step_num}) Register the Agnes Claude Code marketplace and install plugins.",
|
||||||
|
"",
|
||||||
|
" Strategy depends on platform:",
|
||||||
|
" - Windows + macOS: `claude` ships as a Bun-compiled native binary on",
|
||||||
|
" these platforms, which ignores the OS trust store and our CA env",
|
||||||
|
" vars for marketplace HTTPS. Skip the direct attempt and use a",
|
||||||
|
" system `git clone` (system git honors GIT_SSL_CAINFO from step 0).",
|
||||||
|
" - Linux: claude is typically the node-based npm install, where",
|
||||||
|
" NODE_EXTRA_CA_CERTS works. Try direct first; fall back to git",
|
||||||
|
" clone on failure.",
|
||||||
|
"",
|
||||||
|
" # Re-detect $PLATFORM — env vars from step 0 don't persist across",
|
||||||
|
" # separate Bash invocations (per the IMPORTANT note in step 0(e)),",
|
||||||
|
" # so without this the case below would fall through `*)` on every",
|
||||||
|
" # platform and never attempt the direct path on Linux.",
|
||||||
|
" case \"$(uname -s)\" in",
|
||||||
|
" Darwin) PLATFORM=macos ;;",
|
||||||
|
" Linux) PLATFORM=linux ;;",
|
||||||
|
" MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;;",
|
||||||
|
" esac",
|
||||||
|
"",
|
||||||
|
" case \"$PLATFORM\" in",
|
||||||
|
" linux)",
|
||||||
|
" if claude plugin marketplace add \"https://x:{token}@{server_host}/marketplace.git/\" 2>/dev/null; then",
|
||||||
|
" MARKETPLACE_VIA=direct",
|
||||||
|
" else",
|
||||||
|
" MARKETPLACE_VIA=clone",
|
||||||
|
" fi",
|
||||||
|
" ;;",
|
||||||
|
" *)",
|
||||||
|
" MARKETPLACE_VIA=clone",
|
||||||
|
" ;;",
|
||||||
|
" esac",
|
||||||
|
"",
|
||||||
|
" if [ \"$MARKETPLACE_VIA\" = \"clone\" ]; then",
|
||||||
|
" # Heads-up: 'git: credential-manager-core is not a git command' is a",
|
||||||
|
" # harmless warning from a stale git config — the clone itself succeeds.",
|
||||||
|
" rm -rf ~/.agnes/marketplace",
|
||||||
|
" git clone \"https://x:{token}@{server_host}/marketplace.git/\" ~/.agnes/marketplace || {",
|
||||||
|
" echo \"ERROR: marketplace clone failed — verify step 0 trust block + network reachability\" >&2",
|
||||||
|
" exit 1",
|
||||||
|
" }",
|
||||||
|
" # Strip the PAT from the cloned repo's origin URL so it doesn't sit",
|
||||||
|
" # in plaintext at ~/.agnes/marketplace/.git/config. Future marketplace",
|
||||||
|
" # refreshes go via re-running setup (new PAT) from the dashboard, not",
|
||||||
|
" # via `git pull` against this clone.",
|
||||||
|
" git -C ~/.agnes/marketplace remote set-url origin \"https://{server_host}/marketplace.git/\"",
|
||||||
|
" # Best-effort tighten on POSIX; chmod is a no-op on Windows NTFS via",
|
||||||
|
" # MSYS / Git Bash, hence the `|| true` so the step never fails there.",
|
||||||
|
" chmod 700 ~/.agnes/marketplace ~/.agnes/marketplace/.git 2>/dev/null || true",
|
||||||
|
" chmod 600 ~/.agnes/marketplace/.git/config 2>/dev/null || true",
|
||||||
|
" claude plugin marketplace add ~/.agnes/marketplace || {",
|
||||||
|
" echo \"ERROR: claude plugin marketplace add failed\" >&2",
|
||||||
|
" exit 1",
|
||||||
|
" }",
|
||||||
|
" fi",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for name in plugin_install_names:
|
||||||
|
lines.append(
|
||||||
|
f" claude plugin install {name}@{_MARKETPLACE_NAME} --scope project || {{"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f" echo \"ERROR: claude plugin install {name}@{_MARKETPLACE_NAME} failed\" >&2; exit 1"
|
||||||
|
)
|
||||||
|
lines.append(" }")
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
" These run non-interactively. After they finish, tell the user to /exit",
|
||||||
|
" and run `claude` again so the new plugins load.",
|
||||||
|
])
|
||||||
|
return lines
|
||||||
|
|
||||||
|
# Legacy path: no ca_pem on disk. Keep the old behavior verbatim
|
||||||
|
# (host-scoped sslVerify=false when self_signed_tls is set, otherwise
|
||||||
|
# plain direct HTTPS) so existing AGNES_DEBUG_AUTH instances keep
|
||||||
|
# working until they roll a fullchain.pem.
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
f"{step_num}) Register the Agnes Claude Code marketplace and install plugins:",
|
||||||
|
]
|
||||||
|
if self_signed_tls:
|
||||||
|
lines.extend([
|
||||||
|
" # Self-signed TLS cert on this Agnes instance — scoped to the host above.",
|
||||||
|
" git config --global http.\"{server_url}/\".sslVerify false",
|
||||||
|
])
|
||||||
|
lines.append(
|
||||||
|
" claude plugin marketplace add \"https://x:{token}@{server_host}/marketplace.git/\""
|
||||||
|
)
|
||||||
|
for name in plugin_install_names:
|
||||||
|
lines.append(f" claude plugin install {name}@{_MARKETPLACE_NAME} --scope project")
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
" These run non-interactively. After they finish, tell the user to /exit",
|
||||||
|
" and run `claude` again so the new plugins load.",
|
||||||
|
])
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _preamble_lines(*, has_ca: bool) -> list[str]:
|
||||||
|
"""Header that opens the prompt before the numbered steps. The
|
||||||
|
`step 0(d) fallback chain` reference is only emitted when the trust
|
||||||
|
block actually exists (`has_ca`); without it the line points at a
|
||||||
|
non-existent step. The "don't disable TLS verification" advice itself
|
||||||
|
stays unconditional — it's good guidance regardless of whether the
|
||||||
|
server runs with a private CA."""
|
||||||
|
lines = [
|
||||||
|
"Set up the Agnes CLI on this machine.",
|
||||||
|
"",
|
||||||
|
"Server: {server_url}",
|
||||||
|
"Personal access token: {token}",
|
||||||
|
"(Just generated; treat it as a secret.)",
|
||||||
|
"",
|
||||||
|
"Run these, in order. The script is idempotent — safe to re-run if a step",
|
||||||
|
"fails partway through. If a step fails with an unfamiliar error, paste the",
|
||||||
|
"exact error back and stop. Do NOT improvise around TLS errors by disabling",
|
||||||
|
"verification (`-k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`,",
|
||||||
|
"`git -c http.sslVerify=false`, etc.) — those are dead ends that hide the",
|
||||||
|
"real problem.",
|
||||||
|
]
|
||||||
|
if has_ca:
|
||||||
|
lines.append(
|
||||||
|
"The fallback chain inside step 0(d) is documented and OK to "
|
||||||
|
"use; that's what fallback chains are for."
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_lines(
|
||||||
|
wheel_filename: str,
|
||||||
|
*,
|
||||||
|
plugin_install_names: list[str] | None = None,
|
||||||
|
self_signed_tls: bool = False,
|
||||||
|
server_host: str = "",
|
||||||
|
ca_pem: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the template lines with server-side placeholders substituted.
|
||||||
|
|
||||||
|
Pre-substitutes `{wheel_filename}` and `{server_host}`. Leaves
|
||||||
|
`{server_url}` and `{token}` as placeholders for click-time JS
|
||||||
|
substitution (or for `render_setup_instructions()` below).
|
||||||
|
|
||||||
|
When `plugin_install_names` is empty/None, the output matches the
|
||||||
|
original 6-step layout (Confirm = step 6). When non-empty, a step-6
|
||||||
|
git-check + step-7 marketplace block are inserted and Confirm becomes
|
||||||
|
step 8.
|
||||||
|
|
||||||
|
`ca_pem` (PEM-encoded fullchain of the Agnes server's TLS cert) gates
|
||||||
|
the cross-platform step-0 trust-bootstrap block AND switches step 1 to
|
||||||
|
the curl-then-local-install pattern AND switches step 7 to the
|
||||||
|
platform-aware marketplace strategy. Caller decides whether the cert
|
||||||
|
needs the bootstrap (typically: skip for publicly-trusted certs like
|
||||||
|
Let's Encrypt, emit for self-signed or private corp CA).
|
||||||
|
|
||||||
|
`self_signed_tls=True` is the legacy fallback when no `ca_pem` is
|
||||||
|
available — it prepends a host-scoped
|
||||||
|
`git config http."<host>/".sslVerify false` inside the marketplace
|
||||||
|
block (TLS *downgrade*, not bootstrap). When `ca_pem` is set, this
|
||||||
|
flag is ignored because the trust block subsumes it. No-op when the
|
||||||
|
marketplace block isn't rendered (no plugins).
|
||||||
|
|
||||||
Fallback: callers pass `"agnes.whl"` when no wheel is present on disk.
|
Fallback: callers pass `"agnes.whl"` when no wheel is present on disk.
|
||||||
The resulting URL (`/cli/wheel/agnes.whl`) will 404 at download time, but
|
The resulting URL (`/cli/wheel/agnes.whl`) will 404 at download time, but
|
||||||
the instruction text still renders so operators can see the snippet shape
|
the instruction text still renders so operators can see the snippet shape
|
||||||
and diagnose the missing wheel on the server.
|
and diagnose the missing wheel on the server.
|
||||||
"""
|
"""
|
||||||
return [line.replace("{wheel_filename}", wheel_filename) for line in SETUP_INSTRUCTIONS_LINES]
|
names = list(plugin_install_names or [])
|
||||||
|
has_marketplace = bool(names)
|
||||||
|
has_ca = bool(ca_pem and ca_pem.strip())
|
||||||
|
# Trust block subsumes the legacy sslVerify-off downgrade. Don't emit
|
||||||
|
# both: with `~/.agnes/ca-bundle.pem` wired into GIT_SSL_CAINFO, git already
|
||||||
|
# trusts the host without disabling verification.
|
||||||
|
effective_self_signed = self_signed_tls and not has_ca
|
||||||
|
|
||||||
|
# Step layout. Marketplace goes BEFORE diagnose/skills, so the human-loop
|
||||||
|
# skills question is the last step before Confirm. Numbers shift between
|
||||||
|
# the no-marketplace layout (only 4 = diagnose, 5 = skills, 6 = confirm)
|
||||||
|
# and the marketplace layout (4 = git, 5 = marketplace, 6 = diagnose,
|
||||||
|
# 7 = skills, 8 = confirm).
|
||||||
|
if has_marketplace:
|
||||||
|
git_step, marketplace_step = "4", "5"
|
||||||
|
diagnose_step, skills_step, confirm_step = "6", "7", "8"
|
||||||
|
else:
|
||||||
|
git_step = marketplace_step = "" # unused; here just for symmetry
|
||||||
|
diagnose_step, skills_step, confirm_step = "4", "5", "6"
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
if has_ca:
|
||||||
|
lines.extend(_tls_trust_block(ca_pem)) # type: ignore[arg-type]
|
||||||
|
lines.extend(_preamble_lines(has_ca=has_ca))
|
||||||
|
lines.extend(_install_cli_lines(has_ca=has_ca)) # 1
|
||||||
|
lines.extend(_LOGIN_VERIFY_LINES) # 2, 3
|
||||||
|
if has_marketplace:
|
||||||
|
lines.extend(_git_check_block(git_step)) # 4
|
||||||
|
lines.extend(_marketplace_block( # 5
|
||||||
|
names, effective_self_signed, has_ca=has_ca, step_num=marketplace_step,
|
||||||
|
))
|
||||||
|
# Diagnose + skills come AFTER the marketplace block (or right after
|
||||||
|
# whoami if there's no marketplace step at all).
|
||||||
|
lines.extend(_diagnose_skills_lines(
|
||||||
|
diagnose_num=diagnose_step, skills_num=skills_step,
|
||||||
|
))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(_finale_lines(
|
||||||
|
confirm_step_num=confirm_step,
|
||||||
|
has_ca=has_ca,
|
||||||
|
has_marketplace=has_marketplace,
|
||||||
|
))
|
||||||
|
|
||||||
|
return [
|
||||||
|
line.replace("{wheel_filename}", wheel_filename).replace("{server_host}", server_host)
|
||||||
|
for line in lines
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def render_setup_instructions(server_url: str, token: str, wheel_filename: str = "agnes.whl") -> str:
|
def render_setup_instructions(
|
||||||
|
server_url: str,
|
||||||
|
token: str,
|
||||||
|
wheel_filename: str = "agnes.whl",
|
||||||
|
*,
|
||||||
|
plugin_install_names: list[str] | None = None,
|
||||||
|
self_signed_tls: bool = False,
|
||||||
|
server_host: str = "",
|
||||||
|
ca_pem: str | None = None,
|
||||||
|
) -> str:
|
||||||
"""Render the setup instructions as a single string.
|
"""Render the setup instructions as a single string.
|
||||||
|
|
||||||
Used server-side for tests and any non-JS rendering path. The browser
|
Used server-side for tests and any non-JS rendering path. The browser
|
||||||
clipboard flow uses the JS renderer embedded in the Jinja partial; both
|
clipboard flow uses the JS renderer embedded in the Jinja partial; both
|
||||||
must produce byte-identical output for a given (server_url, token, wheel).
|
must produce byte-identical output for a given (server_url, token,
|
||||||
|
wheel, plugins, flag, host, ca_pem) tuple.
|
||||||
"""
|
"""
|
||||||
text = "\n".join(resolve_lines(wheel_filename))
|
lines = resolve_lines(
|
||||||
|
wheel_filename,
|
||||||
|
plugin_install_names=plugin_install_names,
|
||||||
|
self_signed_tls=self_signed_tls,
|
||||||
|
server_host=server_host,
|
||||||
|
ca_pem=ca_pem,
|
||||||
|
)
|
||||||
|
text = "\n".join(lines)
|
||||||
return text.replace("{server_url}", server_url).replace("{token}", token)
|
return text.replace("{server_url}", server_url).replace("{token}", token)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "agnes-the-ai-analyst"
|
name = "agnes-the-ai-analyst"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
description = "Agnes — AI Data Analyst platform for AI analytical systems"
|
description = "Agnes — AI Data Analyst platform for AI analytical systems"
|
||||||
requires-python = ">=3.11,<3.14"
|
requires-python = ">=3.11,<3.14"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
|
||||||
220
scripts/dev/agnes-client-reset.sh
Executable file
220
scripts/dev/agnes-client-reset.sh
Executable file
|
|
@ -0,0 +1,220 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Wipe every trace of an Agnes *client* install from this machine, so a
|
||||||
|
# developer can re-run the onboarding prompt (see app/web/setup_instructions.py)
|
||||||
|
# from a clean slate. Mirror image of that prompt — keep them in sync.
|
||||||
|
#
|
||||||
|
# Touches only the current user's HOME (no admin/root needed) except the Linux
|
||||||
|
# system trust-store path, which falls back to a warning if sudo is missing.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/dev/agnes-client-reset.sh # interactive confirm
|
||||||
|
# scripts/dev/agnes-client-reset.sh --yes # non-interactive
|
||||||
|
# scripts/dev/agnes-client-reset.sh --dry-run # print actions only
|
||||||
|
#
|
||||||
|
# Cross-platform: Git Bash on Windows, macOS, Linux. Detected via uname.
|
||||||
|
|
||||||
|
set -u # not -e: every step is best-effort and may legitimately no-op.
|
||||||
|
|
||||||
|
YES=0
|
||||||
|
DRY=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-y|--yes) YES=1 ;;
|
||||||
|
-n|--dry-run) DRY=1 ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Unknown arg: $arg" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) PLATFORM=macos ;;
|
||||||
|
Linux) PLATFORM=linux ;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;;
|
||||||
|
*) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
run() {
|
||||||
|
echo " \$ $*"
|
||||||
|
if [ "$DRY" -eq 0 ]; then
|
||||||
|
eval "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
step() { echo; echo "==> $*"; }
|
||||||
|
|
||||||
|
if [ "$YES" -eq 0 ] && [ "$DRY" -eq 0 ]; then
|
||||||
|
cat <<EOF
|
||||||
|
This will remove the Agnes client install from this machine:
|
||||||
|
- 'da' CLI (uv tool uninstall)
|
||||||
|
- ~/.config/da (token, server URL, sync state)
|
||||||
|
- ~/.agnes (CA cert, ca-bundle, marketplace clone)
|
||||||
|
- ~/.claude/skills/agnes
|
||||||
|
- Claude Code marketplace 'agnes' + its plugins
|
||||||
|
- 'AGNES_CA_PEM_TRUST' block from your shell rc
|
||||||
|
- Agnes CA from the OS trust store (certutil / keychain / ca-certificates)
|
||||||
|
- /tmp/agnes*.whl
|
||||||
|
|
||||||
|
Platform: $PLATFORM
|
||||||
|
EOF
|
||||||
|
printf "Continue? [y/N] "
|
||||||
|
read -r REPLY
|
||||||
|
case "$REPLY" in y|Y|yes|YES) ;; *) echo "Aborted."; exit 0 ;; esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Remove the Agnes CA from the OS trust store BEFORE we delete ~/.agnes —
|
||||||
|
# Windows certutil and macOS `security` need the cert PEM (or its hash) to
|
||||||
|
# locate the right entry.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
step "Remove Agnes CA from OS trust store"
|
||||||
|
CA_PEM="$HOME/.agnes/ca.pem"
|
||||||
|
if [ -f "$CA_PEM" ]; then
|
||||||
|
case "$PLATFORM" in
|
||||||
|
windows)
|
||||||
|
# certutil accepts the SHA1 thumbprint with colons stripped.
|
||||||
|
HASH="$(openssl x509 -in "$CA_PEM" -noout -fingerprint -sha1 2>/dev/null \
|
||||||
|
| sed 's/^.*=//;s/://g')"
|
||||||
|
if [ -n "$HASH" ]; then
|
||||||
|
run "certutil.exe -user -delstore \"Root\" \"$HASH\""
|
||||||
|
else
|
||||||
|
echo " (could not compute SHA1 fingerprint — openssl missing?)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
macos)
|
||||||
|
# Match by SHA1 hash (-Z) so we delete only the exact Agnes cert,
|
||||||
|
# never a same-CN cert from an unrelated source.
|
||||||
|
HASH="$(openssl x509 -in "$CA_PEM" -noout -fingerprint -sha1 2>/dev/null \
|
||||||
|
| sed 's/^.*=//;s/://g')"
|
||||||
|
if [ -n "$HASH" ]; then
|
||||||
|
run "security delete-certificate -Z \"$HASH\" \"$HOME/Library/Keychains/login.keychain-db\""
|
||||||
|
else
|
||||||
|
echo " (could not compute SHA1 fingerprint — openssl missing?)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
linux)
|
||||||
|
if [ -f /usr/local/share/ca-certificates/agnes.crt ]; then
|
||||||
|
run "sudo rm -f /usr/local/share/ca-certificates/agnes.crt"
|
||||||
|
run "sudo update-ca-certificates --fresh"
|
||||||
|
elif [ -f /etc/pki/ca-trust/source/anchors/agnes.crt ]; then
|
||||||
|
run "sudo rm -f /etc/pki/ca-trust/source/anchors/agnes.crt"
|
||||||
|
run "sudo update-ca-trust"
|
||||||
|
else
|
||||||
|
echo " (no agnes.crt found in system anchors — nothing to do)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
echo " (no $CA_PEM — skipping OS trust-store cleanup)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Claude Code marketplace + plugins. Best-effort: claude CLI may not exist.
|
||||||
|
# Marketplace name is hard-coded in app/marketplace_server/packager.py as
|
||||||
|
# 'agnes' — keep this string in sync if it ever changes.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
step "Remove Claude Code marketplace + plugins"
|
||||||
|
if command -v claude >/dev/null 2>&1; then
|
||||||
|
# Removing the marketplace also detaches its plugins from any project
|
||||||
|
# that referenced them (they go orphaned on next claude start).
|
||||||
|
run "claude plugin marketplace remove agnes 2>/dev/null || true"
|
||||||
|
echo " Note: per-project plugin entries persist in each project's"
|
||||||
|
echo " .claude/settings.json until you re-init that project."
|
||||||
|
else
|
||||||
|
echo " (claude CLI not found — skipping marketplace removal)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. The 'da' CLI itself, installed via 'uv tool install'.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
step "Uninstall 'da' CLI"
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
if uv tool list 2>/dev/null | grep -q '^agnes-the-ai-analyst'; then
|
||||||
|
run "uv tool uninstall agnes-the-ai-analyst"
|
||||||
|
else
|
||||||
|
echo " (agnes-the-ai-analyst not in 'uv tool list' — skipping)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " (uv not found — skipping)"
|
||||||
|
# Defensive cleanup if uv is gone but the binary lingers.
|
||||||
|
[ -e "$HOME/.local/bin/da" ] && run "rm -f \"$HOME/.local/bin/da\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Filesystem state directories.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
step "Remove Agnes filesystem state"
|
||||||
|
# Honor the same DA_CONFIG_DIR override the CLI reads.
|
||||||
|
DA_CONFIG_DIR_RESOLVED="${DA_CONFIG_DIR:-$HOME/.config/da}"
|
||||||
|
for path in \
|
||||||
|
"$DA_CONFIG_DIR_RESOLVED" \
|
||||||
|
"$HOME/.agnes" \
|
||||||
|
"$HOME/.claude/skills/agnes" \
|
||||||
|
; do
|
||||||
|
if [ -e "$path" ]; then
|
||||||
|
run "rm -rf \"$path\""
|
||||||
|
else
|
||||||
|
echo " (no $path — skipping)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Wheel cache from step 1 of the install prompt. Match only the exact package
|
||||||
|
# name (PEP 427 underscore form, the dash form, and the 'agnes.whl' fallback
|
||||||
|
# from setup_instructions.py:_DEFAULT). A naked /tmp/agnes*.whl glob is too
|
||||||
|
# loose — it'd catch unrelated wheels that just happen to start with 'agnes'.
|
||||||
|
step "Remove cached wheel(s)"
|
||||||
|
# shellcheck disable=SC2086 # glob expansion intentional
|
||||||
|
WHEELS=$(ls /tmp/agnes_the_ai_analyst-*.whl \
|
||||||
|
/tmp/agnes-the-ai-analyst-*.whl \
|
||||||
|
/tmp/agnes.whl 2>/dev/null || true)
|
||||||
|
if [ -n "$WHEELS" ]; then
|
||||||
|
# De-dupe (the two normalized forms can both resolve to the same file on
|
||||||
|
# case-insensitive filesystems, and `ls` would list it twice).
|
||||||
|
for w in $(echo "$WHEELS" | tr ' ' '\n' | sort -u); do
|
||||||
|
run "rm -f \"$w\""
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo " (no Agnes wheel in /tmp — skipping)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Shell rc cleanup. The install heredoc in setup_instructions.py:_tls_trust_block
|
||||||
|
# appends a fixed 8-line block: the '# AGNES_CA_PEM_TRUST' marker comment
|
||||||
|
# + 7 lines of comments and exports. Delete EXACTLY 8 lines from the marker
|
||||||
|
# so we never reach over into unrelated content even if the user hand-edited
|
||||||
|
# the block. (`sed ,+Nd` is GNU-only; awk is portable across macOS BSD sed.)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
step "Strip 'AGNES_CA_PEM_TRUST' block from shell rc files"
|
||||||
|
for rc in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile"; do
|
||||||
|
[ -f "$rc" ] || continue
|
||||||
|
if grep -q 'AGNES_CA_PEM_TRUST' "$rc" 2>/dev/null; then
|
||||||
|
if [ "$DRY" -eq 0 ]; then
|
||||||
|
cp "$rc" "$rc.agnes-reset.bak"
|
||||||
|
awk '
|
||||||
|
/# AGNES_CA_PEM_TRUST/ && skip == 0 { skip = 8 }
|
||||||
|
skip > 0 { skip--; next }
|
||||||
|
{ print }
|
||||||
|
' "$rc.agnes-reset.bak" > "$rc"
|
||||||
|
echo " patched $rc (backup at $rc.agnes-reset.bak)"
|
||||||
|
else
|
||||||
|
echo " would patch $rc (delete 8 lines starting at AGNES_CA_PEM_TRUST marker)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " (no AGNES_CA_PEM_TRUST in $rc — skipping)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
step "Done"
|
||||||
|
cat <<'EOF'
|
||||||
|
Open a NEW shell (or `source` your rc) so the SSL_CERT_FILE / NODE_EXTRA_CA_CERTS
|
||||||
|
exports drop out of the environment. You can now re-run the onboarding prompt
|
||||||
|
from /install on the Agnes server to validate a fresh-machine install.
|
||||||
|
|
||||||
|
Sanity checks for "fresh state":
|
||||||
|
command -v da # should be absent
|
||||||
|
ls ~/.config/da ~/.agnes # both should not exist
|
||||||
|
env | grep -E 'AGNES|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS' # empty
|
||||||
|
claude plugin marketplace list # no 'agnes' entry
|
||||||
|
EOF
|
||||||
167
tests/test_router_ca_pem.py
Normal file
167
tests/test_router_ca_pem.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"""Unit tests for `app.web.router._read_agnes_ca_pem`.
|
||||||
|
|
||||||
|
The helper inspects the on-disk Agnes server cert and decides whether the
|
||||||
|
setup prompt should inline it as a trust-bootstrap step. Tests cover:
|
||||||
|
|
||||||
|
- Self-signed leaf (subject == issuer) → return PEM (bootstrap needed).
|
||||||
|
- CA-signed leaf with issuer NOT in `certifi` → return PEM.
|
||||||
|
- CA-signed leaf with issuer in `certifi` → return None (publicly trusted).
|
||||||
|
- Missing / empty / non-PEM file → return None.
|
||||||
|
- AGNES_TLS_FULLCHAIN_PATH override is honored.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
|
||||||
|
def _self_signed_pem(common_name: str = "agnes.example.com") -> bytes:
|
||||||
|
"""Mirror what `agnes-tls-rotate.sh` self-signed fallback produces."""
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
name = x509.Name([
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
|
||||||
|
])
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(name)
|
||||||
|
.issuer_name(name)
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(dt.datetime.utcnow() - dt.timedelta(minutes=1))
|
||||||
|
.not_valid_after(dt.datetime.utcnow() + dt.timedelta(days=30))
|
||||||
|
.add_extension(x509.SubjectAlternativeName([x509.DNSName(common_name)]), critical=False)
|
||||||
|
.sign(key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
return cert.public_bytes(serialization.Encoding.PEM)
|
||||||
|
|
||||||
|
|
||||||
|
def _ca_signed_pem(issuer_cn: str = "Made Up Private CA") -> bytes:
|
||||||
|
"""Build a leaf signed by a CA whose subject CN is `issuer_cn` —
|
||||||
|
distinct from the leaf's CN, so issuer != subject (not self-signed).
|
||||||
|
Returned PEM is the leaf only (matches our parser, which reads the
|
||||||
|
first cert in the chain)."""
|
||||||
|
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer_cn)])
|
||||||
|
ca_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(ca_name)
|
||||||
|
.issuer_name(ca_name)
|
||||||
|
.public_key(ca_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(dt.datetime.utcnow() - dt.timedelta(days=1))
|
||||||
|
.not_valid_after(dt.datetime.utcnow() + dt.timedelta(days=365))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
leaf_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "leaf.example.com")])
|
||||||
|
leaf_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(leaf_name)
|
||||||
|
.issuer_name(ca_name)
|
||||||
|
.public_key(leaf_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(dt.datetime.utcnow() - dt.timedelta(minutes=1))
|
||||||
|
.not_valid_after(dt.datetime.utcnow() + dt.timedelta(days=30))
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
return leaf_cert.public_bytes(serialization.Encoding.PEM)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_agnes_ca_pem_self_signed_returns_pem(tmp_path, monkeypatch):
|
||||||
|
"""Self-signed leaf (issuer == subject) needs trust-bootstrap on
|
||||||
|
every workstation — return PEM so the prompt inlines it."""
|
||||||
|
pem = _self_signed_pem()
|
||||||
|
cert_path = tmp_path / "fullchain.pem"
|
||||||
|
cert_path.write_bytes(pem)
|
||||||
|
monkeypatch.setenv("AGNES_TLS_FULLCHAIN_PATH", str(cert_path))
|
||||||
|
|
||||||
|
from app.web.router import _read_agnes_ca_pem
|
||||||
|
out = _read_agnes_ca_pem()
|
||||||
|
assert out is not None
|
||||||
|
assert "-----BEGIN CERTIFICATE-----" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_agnes_ca_pem_private_ca_returns_pem(tmp_path, monkeypatch):
|
||||||
|
"""CA-signed leaf where the issuer isn't in `certifi`'s trust store —
|
||||||
|
still needs bootstrap because the user's OS doesn't know the CA."""
|
||||||
|
pem = _ca_signed_pem(issuer_cn="Definitely Not A Real CA Root XYZ123")
|
||||||
|
cert_path = tmp_path / "fullchain.pem"
|
||||||
|
cert_path.write_bytes(pem)
|
||||||
|
monkeypatch.setenv("AGNES_TLS_FULLCHAIN_PATH", str(cert_path))
|
||||||
|
|
||||||
|
from app.web.router import _read_agnes_ca_pem
|
||||||
|
out = _read_agnes_ca_pem()
|
||||||
|
assert out is not None
|
||||||
|
assert "-----BEGIN CERTIFICATE-----" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_agnes_ca_pem_publicly_trusted_returns_none(tmp_path, monkeypatch):
|
||||||
|
"""If the leaf's issuer matches a CA shipped in `certifi` (i.e. any
|
||||||
|
publicly-trusted root), the user's OS already trusts the chain —
|
||||||
|
skip the prompt's trust-bootstrap step."""
|
||||||
|
import certifi
|
||||||
|
# Pick the first real CA out of certifi's bundle and pretend our leaf
|
||||||
|
# is signed by it. The leaf-signature itself is fake (we sign with our
|
||||||
|
# own key, not the CA's), but `_read_agnes_ca_pem` only compares the
|
||||||
|
# *issuer name*, not the signature — so DN match is enough to assert
|
||||||
|
# the publicly-trusted code path.
|
||||||
|
with open(certifi.where(), "rb") as fh:
|
||||||
|
trust_pem = fh.read()
|
||||||
|
real_ca = next(iter(x509.load_pem_x509_certificates(trust_pem)))
|
||||||
|
real_issuer_cn = None
|
||||||
|
for attr in real_ca.subject:
|
||||||
|
if attr.oid == NameOID.COMMON_NAME:
|
||||||
|
real_issuer_cn = attr.value
|
||||||
|
break
|
||||||
|
if not real_issuer_cn: # pragma: no cover — every cert has a CN; defensive
|
||||||
|
real_issuer_cn = real_ca.subject.rfc4514_string()
|
||||||
|
|
||||||
|
# Build a leaf whose issuer's RFC4514 matches the real CA's.
|
||||||
|
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
leaf_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "leaf.example.com")])
|
||||||
|
leaf_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(leaf_name)
|
||||||
|
.issuer_name(real_ca.subject) # exact-match issuer DN
|
||||||
|
.public_key(leaf_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(dt.datetime.utcnow() - dt.timedelta(minutes=1))
|
||||||
|
.not_valid_after(dt.datetime.utcnow() + dt.timedelta(days=30))
|
||||||
|
.sign(leaf_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
cert_path = tmp_path / "fullchain.pem"
|
||||||
|
cert_path.write_bytes(leaf_cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
monkeypatch.setenv("AGNES_TLS_FULLCHAIN_PATH", str(cert_path))
|
||||||
|
|
||||||
|
from app.web.router import _read_agnes_ca_pem
|
||||||
|
assert _read_agnes_ca_pem() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_agnes_ca_pem_missing_file_returns_none(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("AGNES_TLS_FULLCHAIN_PATH", str(tmp_path / "nope.pem"))
|
||||||
|
from app.web.router import _read_agnes_ca_pem
|
||||||
|
assert _read_agnes_ca_pem() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_agnes_ca_pem_empty_file_returns_none(tmp_path, monkeypatch):
|
||||||
|
p = tmp_path / "empty.pem"
|
||||||
|
p.write_text("")
|
||||||
|
monkeypatch.setenv("AGNES_TLS_FULLCHAIN_PATH", str(p))
|
||||||
|
from app.web.router import _read_agnes_ca_pem
|
||||||
|
assert _read_agnes_ca_pem() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_agnes_ca_pem_garbage_returns_none(tmp_path, monkeypatch):
|
||||||
|
"""Non-PEM body (e.g. an HTML error page mistakenly stored at the path)
|
||||||
|
must not crash the dashboard render — return None and fall through."""
|
||||||
|
p = tmp_path / "garbage.pem"
|
||||||
|
p.write_text("<html>500 server error</html>")
|
||||||
|
monkeypatch.setenv("AGNES_TLS_FULLCHAIN_PATH", str(p))
|
||||||
|
from app.web.router import _read_agnes_ca_pem
|
||||||
|
assert _read_agnes_ca_pem() is None
|
||||||
|
|
@ -2,7 +2,15 @@
|
||||||
|
|
||||||
`uv tool install` validates the PEP 427 filename in the URL path before
|
`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`.
|
fetching, so our setup snippet cannot use a stable alias like `agnes.whl`.
|
||||||
These tests pin the wheel-filename substitution behavior.
|
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`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,6 +47,738 @@ def test_render_setup_instructions_wires_all_placeholders():
|
||||||
assert "T-123" in out
|
assert "T-123" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_lines_no_plugins_keeps_six_step_layout():
|
||||||
|
"""Backwards-compat: empty plugin list → original 6-step layout, Confirm = 6."""
|
||||||
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
||||||
|
joined = "\n".join(resolve_lines("agnes.whl"))
|
||||||
|
assert "6) Confirm:" in joined
|
||||||
|
assert "7) Confirm:" not in joined
|
||||||
|
assert "8) Confirm:" not in joined
|
||||||
|
assert "claude plugin marketplace add" not in joined
|
||||||
|
assert "claude plugin install" not 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
|
||||||
|
# Confirm step's CA bundle / marketplace bullets must NOT appear when
|
||||||
|
# those steps weren't emitted — otherwise the assistant is told to
|
||||||
|
# report on phantom steps.
|
||||||
|
assert "step 0(d)" not in joined
|
||||||
|
assert "Which CA bundle source got picked" not in joined
|
||||||
|
assert "Whether the marketplace add went via direct HTTPS" 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 only when has_ca=True; marketplace
|
||||||
|
direct-vs-clone bullet only when plugins are configured."""
|
||||||
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
||||||
|
fake_ca = (
|
||||||
|
"-----BEGIN CERTIFICATE-----\n"
|
||||||
|
"FAKE\n"
|
||||||
|
"-----END CERTIFICATE-----\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No ca, no plugins: neither bullet present.
|
||||||
|
plain = "\n".join(resolve_lines("agnes.whl"))
|
||||||
|
assert "Which CA bundle source got picked" not in plain
|
||||||
|
assert "Whether the marketplace add went via direct HTTPS" not in plain
|
||||||
|
|
||||||
|
# ca only: CA bullet yes, marketplace bullet no.
|
||||||
|
ca_only = "\n".join(resolve_lines("agnes.whl", ca_pem=fake_ca))
|
||||||
|
assert "Which CA bundle source got picked" in ca_only
|
||||||
|
assert "Whether the marketplace add went via direct HTTPS" not 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 "Whether the marketplace add went via direct HTTPS" 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 "Whether the marketplace add went via direct HTTPS" in both
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketplace_block_redetects_platform_for_self_containment():
|
||||||
|
"""Marketplace `case "$PLATFORM" in` would silently fall through to the
|
||||||
|
`*)` catch-all on every platform if `$PLATFORM` from step 0 isn't in
|
||||||
|
the current shell — which the prompt itself warns about
|
||||||
|
("env vars do NOT persist between separate Bash invocations"). Linux
|
||||||
|
would then never get the direct-HTTPS attempt the comment promises.
|
||||||
|
The marketplace block must therefore re-detect $PLATFORM via uname
|
||||||
|
before its case statement, mirroring step 0(a)."""
|
||||||
|
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",
|
||||||
|
plugin_install_names=["foo"],
|
||||||
|
server_host="agnes.example.com",
|
||||||
|
ca_pem=fake_ca,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Locate the marketplace section.
|
||||||
|
section_idx = joined.index("Register the Agnes Claude Code marketplace")
|
||||||
|
section = joined[section_idx:]
|
||||||
|
|
||||||
|
# Re-detection block must appear BEFORE the `case "$PLATFORM" in`
|
||||||
|
# check so the variable is set when the case runs.
|
||||||
|
redetect_idx = section.index('case "$(uname -s)" in')
|
||||||
|
platform_case_idx = section.index('case "$PLATFORM" in')
|
||||||
|
assert redetect_idx < platform_case_idx
|
||||||
|
# All three platform branches must be covered (same shape as step 0(a)).
|
||||||
|
redetect_block = section[redetect_idx:platform_case_idx]
|
||||||
|
assert "Darwin" in redetect_block and "PLATFORM=macos" in redetect_block
|
||||||
|
assert "Linux" in redetect_block and "PLATFORM=linux" in redetect_block
|
||||||
|
assert "MINGW*|MSYS*|CYGWIN*" in redetect_block and "PLATFORM=windows" in redetect_block
|
||||||
|
|
||||||
|
|
||||||
|
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/login/git/marketplace BEFORE diagnose
|
||||||
|
and skills, so the human-loop skills question is the final blocking
|
||||||
|
step before Confirm. Step numbers: 4 git, 5 marketplace, 6 diagnose,
|
||||||
|
7 skills, 8 confirm."""
|
||||||
|
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 — git pre-flight, with all three platforms' install commands.
|
||||||
|
assert "4) Make sure git is installed" in joined
|
||||||
|
assert "git --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 + plugins.
|
||||||
|
assert "5) Register the Agnes Claude Code marketplace and install plugins" in joined
|
||||||
|
assert (
|
||||||
|
'claude plugin marketplace add "https://x:{token}@agnes.example.com/marketplace.git/"'
|
||||||
|
in joined
|
||||||
|
)
|
||||||
|
assert "claude plugin install foo@agnes --scope project" in joined
|
||||||
|
assert "claude plugin install bar@agnes --scope project" in joined
|
||||||
|
# Step 6 — diagnose now AFTER marketplace (used to be step 4 right after whoami).
|
||||||
|
assert "6) Run diagnostics:" in joined
|
||||||
|
# Step 7 — skills, the last interactive step before Confirm.
|
||||||
|
assert "7) Skills" in joined
|
||||||
|
# Step 8 — Confirm renumbered (no stray Confirms at other positions).
|
||||||
|
assert "8) Confirm:" in joined
|
||||||
|
for stray in ("4) Confirm:", "5) Confirm:", "6) Confirm:", "7) Confirm:"):
|
||||||
|
assert stray not in joined
|
||||||
|
# Crucial ordering invariants for the new layout.
|
||||||
|
install_idx = joined.index("1) Install the CLI")
|
||||||
|
login_idx = joined.index("2) Log in")
|
||||||
|
verify_idx = joined.index("3) Verify the login:")
|
||||||
|
git_idx = joined.index("4) Make sure git is installed")
|
||||||
|
market_idx = joined.index("5) Register the Agnes Claude Code marketplace")
|
||||||
|
diag_idx = joined.index("6) Run diagnostics:")
|
||||||
|
skills_idx = joined.index("7) Skills")
|
||||||
|
confirm_idx = joined.index("8) Confirm:")
|
||||||
|
assert install_idx < login_idx < verify_idx < git_idx < market_idx < diag_idx < skills_idx < confirm_idx
|
||||||
|
# No git-config sslVerify=false line unless self_signed_tls is set.
|
||||||
|
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_resolve_lines_self_signed_legacy_path_adds_git_config_line():
|
||||||
|
"""Legacy fallback (no ca_pem on disk + self_signed_tls=True): the host-scoped
|
||||||
|
`git config sslVerify=false` downgrade is still emitted so existing
|
||||||
|
AGNES_DEBUG_AUTH instances keep working until they roll a fullchain.pem."""
|
||||||
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
||||||
|
joined = "\n".join(
|
||||||
|
resolve_lines(
|
||||||
|
"agnes.whl",
|
||||||
|
plugin_install_names=["foo"],
|
||||||
|
self_signed_tls=True,
|
||||||
|
server_host="agnes.example.com",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert 'git config --global http."{server_url}/".sslVerify false' in joined
|
||||||
|
# The git-config line must come BEFORE the marketplace add inside the
|
||||||
|
# marketplace step (regardless of which step number it lands on).
|
||||||
|
git_idx = joined.index('git config --global')
|
||||||
|
add_idx = joined.index('claude plugin marketplace add')
|
||||||
|
assert git_idx < add_idx
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_lines_self_signed_no_op_without_plugins():
|
||||||
|
"""`self_signed_tls=True` is a no-op when there are no plugins (no marketplace step to attach to)."""
|
||||||
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
||||||
|
joined = "\n".join(
|
||||||
|
resolve_lines("agnes.whl", plugin_install_names=[], self_signed_tls=True)
|
||||||
|
)
|
||||||
|
# Legacy downgrade line not present.
|
||||||
|
assert "git config --global" not in joined
|
||||||
|
assert "claude plugin" not in joined
|
||||||
|
# No git pre-flight either when there's no marketplace step.
|
||||||
|
assert "Make sure git is installed" not in joined
|
||||||
|
assert "6) Confirm:" in joined # original layout intact
|
||||||
|
|
||||||
|
|
||||||
|
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"],
|
||||||
|
self_signed_tls=True,
|
||||||
|
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 leaks into both the auth-import-token line and the marketplace URL.
|
||||||
|
assert "T-XYZ" in out
|
||||||
|
assert "https://x:T-XYZ@agnes.example.com/marketplace.git/" in out
|
||||||
|
assert 'git config --global http."https://agnes.example.com/".sslVerify false' in out
|
||||||
|
assert "claude plugin install foo@agnes --scope project" in out
|
||||||
|
assert "claude plugin install bar@agnes --scope project" 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 test_resolve_lines_with_ca_pem_marketplace_is_platform_aware():
|
||||||
|
"""When ca_pem is set + plugins requested, step 5 emits a platform branch:
|
||||||
|
Linux → try direct HTTPS first, fall back to git clone on failure
|
||||||
|
(node-based claude honors NODE_EXTRA_CA_CERTS);
|
||||||
|
Windows + macOS → straight to git-clone fallback (Bun-compiled claude
|
||||||
|
binary ignores OS trust store and CA env vars on both platforms)."""
|
||||||
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
||||||
|
joined = "\n".join(
|
||||||
|
resolve_lines(
|
||||||
|
"agnes.whl",
|
||||||
|
plugin_install_names=["foo"],
|
||||||
|
server_host="agnes.example.com",
|
||||||
|
ca_pem=_FAKE_CA_PEM,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# The platform branch + MARKETPLACE_VIA selector.
|
||||||
|
assert "MARKETPLACE_VIA=clone" in joined
|
||||||
|
assert "MARKETPLACE_VIA=direct" in joined
|
||||||
|
# Locate the marketplace step's case block specifically — there is
|
||||||
|
# ALSO a `case "$PLATFORM" in` block in step 0(c) (OS trust store
|
||||||
|
# registration), so we anchor on the marketplace section header to
|
||||||
|
# narrow the slice.
|
||||||
|
section_idx = joined.index("Register the Agnes Claude Code marketplace")
|
||||||
|
market_case_idx = joined.index('case "$PLATFORM" in', section_idx)
|
||||||
|
market_esac_idx = joined.index("esac", market_case_idx)
|
||||||
|
branch_block = joined[market_case_idx:market_esac_idx]
|
||||||
|
assert "linux)" in branch_block
|
||||||
|
# Direct attempt only in the linux branch.
|
||||||
|
assert (
|
||||||
|
'claude plugin marketplace add "https://x:{token}@agnes.example.com/marketplace.git/" 2>/dev/null'
|
||||||
|
in branch_block
|
||||||
|
)
|
||||||
|
# The default `*)` branch must hard-set clone (no direct attempt).
|
||||||
|
star_idx = branch_block.index("*)")
|
||||||
|
star_branch = branch_block[star_idx:]
|
||||||
|
assert "MARKETPLACE_VIA=clone" in star_branch
|
||||||
|
assert "claude plugin marketplace add" not in star_branch
|
||||||
|
# Git-clone fallback writes to ~/.agnes/marketplace and adds it as a local path.
|
||||||
|
assert 'git clone "https://x:{token}@agnes.example.com/marketplace.git/" ~/.agnes/marketplace' in joined
|
||||||
|
assert "claude plugin marketplace add ~/.agnes/marketplace" in joined
|
||||||
|
# Harmless credential-manager-core warning is called out.
|
||||||
|
assert "credential-manager-core" in joined
|
||||||
|
# Plugin install line stays unchanged (errors checked in a sibling test).
|
||||||
|
assert "claude plugin install foo@agnes --scope project" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_lines_with_ca_pem_marketplace_strips_pat_after_clone():
|
||||||
|
"""After `git clone https://x:<PAT>@host/...`, the cloned repo's
|
||||||
|
`.git/config` holds the PAT in plaintext at `[remote "origin"] url`.
|
||||||
|
On default home setups that file syncs to iCloud/OneDrive and gets
|
||||||
|
read by antivirus / sync agents. The marketplace step must run
|
||||||
|
`git remote set-url origin <url-without-token>` after clone, plus a
|
||||||
|
best-effort chmod tighten. claude registers the *local path* (not the
|
||||||
|
remote URL), so stripping the token doesn't break marketplace
|
||||||
|
registration — refreshes go via re-running setup with a fresh PAT."""
|
||||||
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
||||||
|
joined = "\n".join(
|
||||||
|
resolve_lines(
|
||||||
|
"agnes.whl",
|
||||||
|
plugin_install_names=["foo"],
|
||||||
|
server_host="agnes.example.com",
|
||||||
|
ca_pem=_FAKE_CA_PEM,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Token-bearing clone line still exists (we need the token to authenticate
|
||||||
|
# the initial clone) but a token-less remote set-url line follows.
|
||||||
|
clone_idx = joined.index(
|
||||||
|
'git clone "https://x:{token}@agnes.example.com/marketplace.git/"'
|
||||||
|
)
|
||||||
|
set_url_idx = joined.index(
|
||||||
|
'git -C ~/.agnes/marketplace remote set-url origin "https://agnes.example.com/marketplace.git/"'
|
||||||
|
)
|
||||||
|
add_idx = joined.index("claude plugin marketplace add ~/.agnes/marketplace")
|
||||||
|
assert clone_idx < set_url_idx < add_idx
|
||||||
|
# Token-less URL must NOT contain the placeholder or `x:` prefix.
|
||||||
|
set_url_line_end = joined.index("\n", set_url_idx)
|
||||||
|
set_url_line = joined[set_url_idx:set_url_line_end]
|
||||||
|
assert "{token}" not in set_url_line
|
||||||
|
assert "x:" not in set_url_line
|
||||||
|
|
||||||
|
# Best-effort chmod tighten — wrapped in `|| true` so MSYS / Git Bash
|
||||||
|
# on Windows (where chmod is a no-op against NTFS ACLs) doesn't fail
|
||||||
|
# the step.
|
||||||
|
assert "chmod 700 ~/.agnes/marketplace ~/.agnes/marketplace/.git" in joined
|
||||||
|
assert "chmod 600 ~/.agnes/marketplace/.git/config" in joined
|
||||||
|
assert "|| true" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_lines_with_ca_pem_marketplace_has_explicit_error_handling():
|
||||||
|
"""Each shell-out in the marketplace block must fail loudly with `exit 1`
|
||||||
|
on a non-zero exit, not silently fall through to the next step. Without
|
||||||
|
this, a failed `git clone` causes a confusing 'marketplace 'agnes' not
|
||||||
|
found' error from the subsequent `claude plugin install`."""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# git clone has an `|| { ... exit 1 }` guard.
|
||||||
|
assert (
|
||||||
|
'git clone "https://x:{token}@agnes.example.com/marketplace.git/" '
|
||||||
|
'~/.agnes/marketplace || {'
|
||||||
|
) in joined
|
||||||
|
# `claude plugin marketplace add ~/.agnes/marketplace` (the local path
|
||||||
|
# one — not the chmod best-effort lines) has its own guard.
|
||||||
|
assert "claude plugin marketplace add ~/.agnes/marketplace || {" in joined
|
||||||
|
# Each `claude plugin install <name>@agnes` has its own guard so we know
|
||||||
|
# which plugin failed.
|
||||||
|
assert "claude plugin install foo@agnes --scope project || {" in joined
|
||||||
|
assert "claude plugin install bar@agnes --scope project || {" in joined
|
||||||
|
# Error messages are written to stderr, not stdout.
|
||||||
|
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_with_ca_pem_suppresses_legacy_sslverify_line():
|
||||||
|
"""When ca_pem is supplied, the legacy `git config sslVerify=false`
|
||||||
|
downgrade must NOT appear — the trust block subsumes it (full TLS
|
||||||
|
validation re-enabled, just against the inlined cert)."""
|
||||||
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
||||||
|
joined = "\n".join(
|
||||||
|
resolve_lines(
|
||||||
|
"agnes.whl",
|
||||||
|
plugin_install_names=["foo"],
|
||||||
|
self_signed_tls=True, # legacy flag — should be ignored when ca_pem set
|
||||||
|
server_host="agnes.example.com",
|
||||||
|
ca_pem=_FAKE_CA_PEM,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Legacy git-config sslVerify=false downgrade is suppressed when ca_pem is set.
|
||||||
|
assert "git config --global" not in joined
|
||||||
|
# But the marketplace step itself still renders.
|
||||||
|
assert "claude plugin install foo@agnes --scope project" in joined
|
||||||
|
# And the trust block is present.
|
||||||
|
assert "0) Trust the Agnes TLS certificate" in joined
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_lines_without_ca_pem_keeps_legacy_self_signed_path():
|
||||||
|
"""Legacy fallback: no ca_pem + self_signed_tls=True still emits the
|
||||||
|
sslVerify=false line (so existing AGNES_DEBUG_AUTH instances keep
|
||||||
|
working until they roll a fullchain.pem onto disk)."""
|
||||||
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
||||||
|
joined = "\n".join(
|
||||||
|
resolve_lines(
|
||||||
|
"agnes.whl",
|
||||||
|
plugin_install_names=["foo"],
|
||||||
|
self_signed_tls=True,
|
||||||
|
server_host="agnes.example.com",
|
||||||
|
# no ca_pem
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert "0) Trust the Agnes TLS certificate" not in joined
|
||||||
|
assert 'sslVerify false' in joined
|
||||||
|
|
||||||
|
|
||||||
|
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 block — emit step 0
|
||||||
|
even when plugin list is empty. Confirm step number stays at 6
|
||||||
|
(the original layout) since 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 "6) Confirm:" in joined
|
||||||
|
assert "claude plugin marketplace add" not 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"],
|
||||||
|
self_signed_tls=True,
|
||||||
|
server_host="agnes.example.com",
|
||||||
|
ca_pem=_FAKE_CA_PEM,
|
||||||
|
)
|
||||||
|
assert "0) Trust the Agnes TLS certificate" in out
|
||||||
|
assert "-----BEGIN CERTIFICATE-----" in out
|
||||||
|
# ca_pem masks legacy sslVerify=false.
|
||||||
|
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_skills_step_is_last_blocking_step_before_confirm():
|
||||||
|
"""In the new layout, skills is the LAST interactive step before Confirm
|
||||||
|
(it used to come right after diagnose and before git/marketplace, which
|
||||||
|
invited the assistant to "do the rest in parallel"). We've moved the
|
||||||
|
install work earlier, so the skills question is now a single clear gate
|
||||||
|
— there's nothing left to do in parallel and the assistant must wait
|
||||||
|
for the user's answer.
|
||||||
|
|
||||||
|
Assert two things:
|
||||||
|
(a) The prompt explicitly tells the assistant to wait for the answer.
|
||||||
|
(b) The skills step appears AFTER the marketplace step in the rendered
|
||||||
|
line order — i.e., the legacy "skills before marketplace" flow
|
||||||
|
isn't accidentally back."""
|
||||||
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
||||||
|
joined = "\n".join(resolve_lines("agnes.whl", plugin_install_names=["foo"], server_host="h"))
|
||||||
|
flattened = " ".join(joined.split())
|
||||||
|
|
||||||
|
# (a) The prompt must instruct the assistant to wait — and must NOT
|
||||||
|
# contain the obsolete "you can continue in parallel" hint.
|
||||||
|
assert "Wait for the user's answer" in joined
|
||||||
|
assert "don't depend on the answer" not in flattened
|
||||||
|
assert "do not depend on the answer" not in flattened
|
||||||
|
|
||||||
|
# (b) Skills comes after marketplace in the rendered line order.
|
||||||
|
market_idx = joined.index("Register the Agnes Claude Code marketplace")
|
||||||
|
skills_idx = joined.index("Skills (ask the user")
|
||||||
|
assert market_idx < skills_idx
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_marketplace_layout_keeps_diagnose_before_skills():
|
||||||
|
"""Without plugins, the layout collapses to: install → login → verify →
|
||||||
|
diagnose → skills → confirm. (No git or marketplace steps to interleave.)
|
||||||
|
Step numbers: 4 diagnose, 5 skills, 6 confirm."""
|
||||||
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
||||||
|
joined = "\n".join(resolve_lines("agnes.whl"))
|
||||||
|
assert "4) Run diagnostics:" in joined
|
||||||
|
assert "5) Skills" in joined
|
||||||
|
assert "6) Confirm:" in joined
|
||||||
|
diag_idx = joined.index("4) Run diagnostics:")
|
||||||
|
skills_idx = joined.index("5) Skills")
|
||||||
|
confirm_idx = joined.index("6) Confirm:")
|
||||||
|
assert diag_idx < skills_idx < confirm_idx
|
||||||
|
|
||||||
|
|
||||||
def test_install_page_uses_versioned_wheel_url(monkeypatch, tmp_path):
|
def test_install_page_uses_versioned_wheel_url(monkeypatch, tmp_path):
|
||||||
"""End-to-end: the /install preview must render the PEP 427 wheel URL,
|
"""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."""
|
so a user copy-pasting the snippet gets a URL `uv tool install` accepts."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue