From 207c8a6d35a8d8109484820ec6913cb48ffd5e44 Mon Sep 17 00:00:00 2001 From: anvil Date: Sat, 18 Apr 2026 00:30:02 +0000 Subject: [PATCH 1/3] =?UTF-8?q?cli:=20axctl=20bootstrap-agent=20=E2=80=94?= =?UTF-8?q?=20one-shot=20scoped=20agent=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses the 15-step manual sequence documented in shared/state/axctl-friction-2026-04-17.md §0 into a single command: axctl bootstrap-agent axolotl \ --space-id \ --description "..." \ --model codex:gpt-5.4 \ --audience both \ --save-to /home/ax-agent/agents/axolotl \ --profile next-axolotl What it does, in order: 1. Requires a user PAT (axp_u_). Agent PATs are refused with an actionable message — they can't create agents or mint credentials. 2. Prints the effective-config line (base_url + user_env + source path) so operators don't silently target the wrong environment. 3. POST /api/v1/agents with X-Space-Id — the proven-prod creation path. 4. If the agent already exists in the target space and --allow-existing is set, reuses it; otherwise exits 2 with a clear error. 5. Optional metadata polish via PUT /api/v1/agents/manage/{name}. 6. Mints an agent-bound PAT. Tries /credentials/agent-pat first (canonical per the ax-operator skill); on HTML/404/405 falls back to POST /api/v1/keys with bound_agent_id + allowed_agent_ids + audience + prod-compatible scopes (api:read, api:write). The fallback exists because /credentials/* isn't routed to the backend on prod today. 7. Writes workspace: {save_to}/.ax/config.toml and .ax/token at 0600. 8. Optional named profile compatible with `axctl profile verify`. 9. Verifies with GET /auth/me + the fresh PAT and prints resolved allowed_spaces so the caller sees containment (agent-lock + space-lock). client.py: extended create_key() with bound_agent_id, audience, scopes, and space_id parameters. Backward-compatible (all kwargs default None). Tests: 9 new covering happy-path with mgmt route, HTML-fallback, 404-fallback, already-exists abort, already-exists reuse, user-PAT-required gate, agent-PAT rejection, --dry-run (zero side effects), and the effective-config line. 230 existing tests still pass. Tracked friction items closed: §0 (one-shot), §2 (effective-config line on mutating commands), §3 (CLI-side fallback when /credentials/* isn't routed), §4 (prod scope vocabulary hidden behind the command). Co-Authored-By: Claude Opus 4.7 (1M context) --- ax_cli/client.py | 29 +- ax_cli/commands/bootstrap.py | 507 ++++++++++++++++++++++++++++++++++ ax_cli/main.py | 2 + tests/test_bootstrap_agent.py | 393 ++++++++++++++++++++++++++ 4 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 ax_cli/commands/bootstrap.py create mode 100644 tests/test_bootstrap_agent.py diff --git a/ax_cli/client.py b/ax_cli/client.py index c86b4a6..6fe4c92 100644 --- a/ax_cli/client.py +++ b/ax_cli/client.py @@ -713,12 +713,37 @@ def search_messages(self, query: str, limit: int = 20, *, agent_id: str | None = # --- Keys (PAT management) --- - def create_key(self, name: str, *, allowed_agent_ids: list[str] | None = None) -> dict: + def create_key( + self, + name: str, + *, + allowed_agent_ids: list[str] | None = None, + bound_agent_id: str | None = None, + audience: str | None = None, + scopes: list[str] | None = None, + space_id: str | None = None, + ) -> dict: + """POST /api/v1/keys — mint a user PAT, optionally bound to an agent. + + When ``bound_agent_id`` is set, the resulting PAT inherits the agent's + allowed-spaces policy and can only be used to send as that agent. This + is the prod-friendly alternative to ``/credentials/agent-pat`` when the + latter isn't routed (see axctl-friction-2026-04-17 §3). + """ body: dict = {"name": name} if allowed_agent_ids: body["agent_scope"] = "agents" body["allowed_agent_ids"] = allowed_agent_ids - r = self._http.post("/api/v1/keys", json=body) + if bound_agent_id: + body["bound_agent_id"] = bound_agent_id + if audience: + body["audience"] = audience + if scopes: + body["scopes"] = scopes + headers = dict(self._base_headers) + if space_id: + headers["X-Space-Id"] = space_id + r = self._http.post("/api/v1/keys", json=body, headers=headers) r.raise_for_status() return self._parse_json(r) diff --git a/ax_cli/commands/bootstrap.py b/ax_cli/commands/bootstrap.py new file mode 100644 index 0000000..128833f --- /dev/null +++ b/ax_cli/commands/bootstrap.py @@ -0,0 +1,507 @@ +"""ax bootstrap-agent — one-shot scoped agent + PAT + workspace setup. + +Collapses the 15-step manual flow documented in +``shared/state/axctl-friction-2026-04-17.md §0`` into a single command: + + axctl bootstrap-agent axolotl \\ + --space-id ed81ae98-50cb-4268-b986-1b9fe76df742 \\ + --description "Playful ax-cli helper" \\ + --model codex:gpt-5.4 \\ + --audience both \\ + --save-to /home/ax-agent/agents/axolotl \\ + --profile next-axolotl + +What it does, in order: + +1. Require a user PAT (``axp_u_``). Agent PATs can't create agents. +2. Print the effective-config line so operators don't silently target + the wrong environment. +3. POST ``/api/v1/agents`` with ``X-Space-Id`` — the creation path that + actually works on prod. Body carries ``description``/``model`` when + provided. +4. If the agent already exists in the target space and ``--allow-existing`` + is set, reuse it; otherwise abort. +5. Optionally update ``bio``/``specialization`` via the legacy + ``/api/v1/agents/manage/{name}`` PUT (the one that IS proxied). +6. Mint an agent-bound PAT. Try ``/credentials/agent-pat`` first (canonical + per the ax-operator skill); on an HTML/404/405 response fall back to + ``POST /api/v1/keys`` with ``bound_agent_id``, ``allowed_agent_ids``, + ``audience``, and prod-compatible scopes (``api:read``/``api:write``). +7. Write workspace config: ``{save_to}/.ax/config.toml`` plus a ``token`` + file at mode 0600. Optionally create a named profile. +8. Verify with a ``GET /auth/me`` using the new PAT and print the resolved + ``allowed_spaces`` so the caller sees containment worked. + +Every mutating step logs a one-liner; failures bail loudly with the source +of the token being used (no more "Invalid credential" without a file path). +""" + +from __future__ import annotations + +import os +import socket +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import httpx +import typer + +from ..config import ( + _resolve_user_env, + _user_config_path, + get_user_client, + resolve_user_base_url, + resolve_user_token, +) +from ..output import JSON_OPTION, console, handle_error, print_json + +# Scopes the backend /api/v1/keys endpoint actually accepts on prod (see +# axctl-friction-2026-04-17 §4). Other scope vocabularies get silently rejected. +DEFAULT_KEY_SCOPES: list[str] = ["api:read", "api:write"] + + +# ── Dataclasses + helpers ──────────────────────────────────────────────── + + +@dataclass +class BootstrapResult: + """What bootstrap_agent produces. Shape is frozen for --json output.""" + + agent_id: str + agent_name: str + space_id: str + base_url: str + token_path: Optional[str] + config_path: Optional[str] + profile_name: Optional[str] + pat_source: str # "mgmt" | "keys_fallback" + allowed_spaces: list[dict] + + def as_dict(self) -> dict: + return { + "agent_id": self.agent_id, + "agent_name": self.agent_name, + "space_id": self.space_id, + "base_url": self.base_url, + "token_path": self.token_path, + "config_path": self.config_path, + "profile_name": self.profile_name, + "pat_source": self.pat_source, + "allowed_spaces": self.allowed_spaces, + } + + +def _effective_config_line() -> str: + """Same one-liner as commands.agents._effective_config_line — kept here + to avoid a circular import and stay local.""" + base_url = resolve_user_base_url() + user_env = _resolve_user_env() or "default" + user_cfg_path = _user_config_path() + source = str(user_cfg_path) if user_cfg_path.exists() else "(none)" + return f"[dim]base_url={base_url} user_env={user_env} source={source}[/dim]" + + +def _is_route_miss(exc: httpx.HTTPStatusError) -> bool: + """Management routes sometimes get caught by the frontend proxy on prod, + returning HTML or 404/405. Detect so we can fall back.""" + r = exc.response + ct = r.headers.get("content-type", "") + if "text/html" in ct or r.text.lstrip().startswith(" Optional[dict]: + """Return the agent dict if it already exists in the target space, else None.""" + try: + headers = {"X-Space-Id": space_id} + r = client._http.get("/api/v1/agents", params={"space_id": space_id}, headers=headers) + r.raise_for_status() + payload = client._parse_json(r) + agents = payload if isinstance(payload, list) else payload.get("agents", []) + return next((a for a in agents if a.get("name", "").lower() == name.lower()), None) + except httpx.HTTPStatusError: + return None + + +def _create_agent_in_space(client, *, name: str, space_id: str, description: str | None, model: str | None) -> dict: + """POST /api/v1/agents with X-Space-Id. This is the creation path proven + to route through the ALB on prod (POST survives; PATCH/PUT to the same + prefix don't — see avatar-day PR).""" + body: dict = {"name": name} + if description is not None: + body["description"] = description + if model is not None: + body["model"] = model + if space_id: + body["space_id"] = space_id + headers = {"X-Space-Id": space_id} if space_id else None + r = client._http.post("/api/v1/agents", json=body, headers=headers) + r.raise_for_status() + return client._parse_json(r) + + +def _polish_metadata( + client, + *, + name: str, + bio: str | None, + specialization: str | None, + system_prompt: str | None, +) -> None: + """PUT /api/v1/agents/manage/{name} for the fields the POST body ignores. + + Skipped silently when nothing to update — this path isn't mandatory. + """ + fields: dict = {} + if bio is not None: + fields["bio"] = bio + if specialization is not None: + fields["specialization"] = specialization + if system_prompt is not None: + fields["system_prompt"] = system_prompt + if not fields: + return + client.update_agent(name, **fields) + + +def _mint_agent_pat( + client, + *, + agent_id: str, + agent_name: str, + audience: str, + expires_in_days: int, + pat_name: str, + space_id: str, +) -> tuple[str, str]: + """Mint an agent-bound PAT, preferring the canonical mgmt path, falling + back to /api/v1/keys when the former isn't routed. + + Returns (token, source) where source is 'mgmt' or 'keys_fallback'. + """ + # Try canonical path first — works on dev and any env that proxies /credentials/*. + try: + data = client.mgmt_issue_agent_pat( + agent_id, + name=pat_name, + expires_in_days=expires_in_days, + audience=audience, + ) + token = data.get("token") or data.get("access_token") or "" + if token: + return token, "mgmt" + except httpx.HTTPStatusError as exc: + if not _is_route_miss(exc): + raise + except Exception: + # Best-effort fallback on any transport exception — we're about to + # try the other path anyway. + pass + + # Fallback: /api/v1/keys. Prod-compatible. Ensures the PAT is space-locked + # and agent-locked so containment survives without the mgmt endpoint. + data = client.create_key( + pat_name, + allowed_agent_ids=[agent_id], + bound_agent_id=agent_id, + audience=audience, + scopes=DEFAULT_KEY_SCOPES, + space_id=space_id, + ) + token = data.get("token") or data.get("access_token") or "" + if not token: + raise RuntimeError("Mint succeeded but no token field in response") + return token, "keys_fallback" + + +def _write_workspace( + save_to: str, + *, + base_url: str, + agent_name: str, + agent_id: str, + space_id: str, + token: str, +) -> tuple[Path, Path]: + """Write {save_to}/.ax/{token,config.toml}. Returns (token_path, config_path).""" + save_dir = Path(save_to).expanduser().resolve() + ax_dir = save_dir / ".ax" if save_dir.name != ".ax" else save_dir + ax_dir.mkdir(parents=True, exist_ok=True) + + token_path = ax_dir / "token" + token_path.write_text(token) + token_path.chmod(0o600) + + config_path = ax_dir / "config.toml" + config_content = ( + f'base_url = "{base_url}"\n' + f'agent_name = "{agent_name}"\n' + f'agent_id = "{agent_id}"\n' + f'space_id = "{space_id}"\n' + f'token_file = "{token_path}"\n' + f'principal_type = "agent"\n' + ) + config_path.write_text(config_content) + config_path.chmod(0o600) + return token_path, config_path + + +def _create_profile( + profile_name: str, + *, + base_url: str, + agent_name: str, + token_path: Path, +) -> None: + """Delegate to the same profile writer mint.py uses, for compat with + ``ax profile verify`` / ``ax profile env``.""" + from .profile import _profile_path, _token_sha256, _workdir_hash, _write_toml + + profile_data = { + "name": profile_name, + "base_url": base_url, + "agent_name": agent_name, + "token_file": str(token_path.resolve()), + "token_sha256": _token_sha256(str(token_path)), + "host_binding": socket.gethostname(), + "workdir_hash": _workdir_hash(), + "workdir_path": str(Path.cwd().resolve()), + "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + _write_toml(_profile_path(profile_name), profile_data) + + +def _verify_with_new_token( + base_url: str, + token: str, + agent_name: str, + agent_id: str, + space_id: str, +) -> list[dict]: + """Call /auth/me with the freshly minted PAT (in agent mode) and return + the resolved ``allowed_spaces`` list so the caller sees containment.""" + headers = { + "Authorization": f"Bearer {token}", + "X-Agent-Name": agent_name, + "X-Agent-Id": agent_id, + "X-Space-Id": space_id, + } + with httpx.Client(base_url=base_url, timeout=10.0) as hc: + r = hc.get("/auth/me", headers=headers) + r.raise_for_status() + data = r.json() + bound = data.get("bound_agent") or {} + return bound.get("allowed_spaces") or [] + + +# ── Command ────────────────────────────────────────────────────────────── + + +def bootstrap_agent( + name: str = typer.Argument(..., help="Agent name (will be created if missing)."), + space_id: str = typer.Option(..., "--space-id", help="Target space UUID (required)."), + description: Optional[str] = typer.Option(None, "--description", "-d", help="Short description."), + bio: Optional[str] = typer.Option(None, "--bio", "-b", help="Longer bio line."), + model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model identifier."), + specialization: Optional[str] = typer.Option(None, "--specialization", help="Specialization."), + system_prompt: Optional[str] = typer.Option(None, "--system-prompt", help="System prompt."), + audience: str = typer.Option( + "both", + "--audience", + help="PAT audience: cli, mcp, or both.", + ), + expires_days: int = typer.Option(90, "--expires", help="PAT lifetime in days."), + save_to: Optional[str] = typer.Option( + None, + "--save-to", + help="Directory to write .ax/config.toml and .ax/token (0600). Parent created if needed.", + ), + profile_name: Optional[str] = typer.Option( + None, + "--profile", + help="After minting, create a named profile for use with `ax profile use`.", + ), + allow_existing: bool = typer.Option( + False, + "--allow-existing", + help="Reuse the agent if it already exists in the target space (default: abort).", + ), + env_name: Optional[str] = typer.Option( + None, + "--env", + help="Use a named user-login environment created with `axctl login --env`.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Print the plan without touching the API or the filesystem.", + ), + as_json: bool = JSON_OPTION, +): + """Stand up a scoped agent + PAT + workspace in one command. + + Exists because the manual sequence is 15 steps across three APIs and + two scope vocabularies. This command collapses it and also patches the + three most common footguns: silent user-env override, the PATCH-vs-PUT + routing gap on prod, and the ``/credentials/agent-pat`` proxy miss. + """ + + def status(msg: str) -> None: + if not as_json: + console.print(msg) + + if env_name: + os.environ["AX_USER_ENV"] = env_name + + status(_effective_config_line()) + + # 1) User PAT gate + token = resolve_user_token() + if not token: + console.print("[red]No user token found.[/red] Run `axctl login` first.") + raise typer.Exit(1) + if token.startswith("axp_a_"): + console.print( + "[red]Cannot bootstrap with an agent PAT.[/red] " + "Need a user PAT (axp_u_) to create agents and mint credentials." + ) + raise typer.Exit(1) + if not token.startswith("axp_u_"): + console.print(f"[yellow]Warning:[/yellow] token prefix '{token[:6]}' is not a recognized user PAT. Proceeding.") + + client = get_user_client() + base_url = client.base_url + + if dry_run: + plan = { + "would_create_or_reuse": {"name": name, "space_id": space_id, "description": description, "model": model}, + "would_polish": {"bio": bio, "specialization": specialization, "system_prompt": system_prompt}, + "would_mint_pat": {"audience": audience, "expires_days": expires_days}, + "would_write": {"save_to": save_to, "profile": profile_name}, + "base_url": base_url, + } + if as_json: + print_json(plan) + else: + console.print("[yellow]--dry-run:[/yellow] not calling the API.") + console.print(plan) + raise typer.Exit(0) + + # 2) Find or create the agent in the target space + existing = _find_agent_in_space(client, name, space_id) + if existing: + if not allow_existing: + console.print( + f"[red]Agent '{name}' already exists in space {space_id[:8]}…[/red] " + f"Pass --allow-existing to reuse, or pick another name." + ) + raise typer.Exit(2) + status(f"[yellow]Reusing existing agent[/yellow] {name} ({existing.get('id', '?')[:8]}…)") + agent_id = existing["id"] + else: + status(f"[cyan]Creating agent[/cyan] {name} in space {space_id[:8]}…") + try: + created = _create_agent_in_space(client, name=name, space_id=space_id, description=description, model=model) + except httpx.HTTPStatusError as e: + handle_error(e) + raise typer.Exit(1) + agent_id = created.get("id") or "" + if not agent_id: + console.print("[red]Agent creation returned no id.[/red]") + raise typer.Exit(1) + status(f"[green]Created[/green] id={agent_id}") + + # 3) Polish metadata via the proxied manage path (optional) + try: + _polish_metadata(client, name=name, bio=bio, specialization=specialization, system_prompt=system_prompt) + except httpx.HTTPStatusError as e: + console.print(f"[yellow]Metadata polish failed[/yellow] (non-fatal): {e.response.status_code}") + + # 4) Mint the PAT with fallback + pat_label = f"{name}-runtime" + status(f"[cyan]Minting agent-bound PAT[/cyan] '{pat_label}' (audience={audience}, expires={expires_days}d)") + try: + new_token, pat_source = _mint_agent_pat( + client, + agent_id=agent_id, + agent_name=name, + audience=audience, + expires_in_days=expires_days, + pat_name=pat_label, + space_id=space_id, + ) + except httpx.HTTPStatusError as e: + handle_error(e) + raise typer.Exit(1) + status(f"[green]Minted[/green] via {pat_source}") + + # 5) Write workspace + token_path: Optional[Path] = None + config_path: Optional[Path] = None + if save_to: + token_path, config_path = _write_workspace( + save_to, + base_url=base_url, + agent_name=name, + agent_id=agent_id, + space_id=space_id, + token=new_token, + ) + status(f"[green]Wrote[/green] {config_path} (0600)") + status(f"[green]Wrote[/green] {token_path} (0600)") + + # 6) Named profile (optional; needs a token file) + if profile_name: + if token_path is None: + # Put the token in ~/.ax/_token so profile_verify can find it. + default_dir = Path.home() / ".ax" + default_dir.mkdir(parents=True, exist_ok=True) + token_path = default_dir / f"{name}_token" + token_path.write_text(new_token) + token_path.chmod(0o600) + _create_profile( + profile_name, + base_url=base_url, + agent_name=name, + token_path=token_path, + ) + status(f"[green]Profile[/green] {profile_name} ready (try: `axctl profile verify {profile_name}`)") + + # 7) Verify with the fresh PAT + try: + allowed = _verify_with_new_token( + base_url=base_url, + token=new_token, + agent_name=name, + agent_id=agent_id, + space_id=space_id, + ) + except httpx.HTTPStatusError as e: + console.print( + f"[yellow]Verify failed[/yellow]: {e.response.status_code} — token minted but /auth/me refused it." + ) + allowed = [] + status(f"[green]Verified[/green] allowed_spaces={[s.get('name') or s.get('space_id') for s in allowed]}") + + result = BootstrapResult( + agent_id=agent_id, + agent_name=name, + space_id=space_id, + base_url=base_url, + token_path=str(token_path) if token_path else None, + config_path=str(config_path) if config_path else None, + profile_name=profile_name, + pat_source=pat_source, + allowed_spaces=allowed, + ) + if as_json: + print_json(result.as_dict()) + else: + console.print( + f"\n[green bold]Done.[/green bold] Agent {name} is live in space " + f"{space_id[:8]}… Next: `tmux new -s {name}` + launcher, or " + f"`axctl profile use {profile_name}` if you passed --profile." + ) diff --git a/ax_cli/main.py b/ax_cli/main.py index f6331e3..a7fe3c3 100644 --- a/ax_cli/main.py +++ b/ax_cli/main.py @@ -11,6 +11,7 @@ alerts, apps, auth, + bootstrap, channel, context, credentials, @@ -49,6 +50,7 @@ app.add_typer(channel.app, name="channel") app.add_typer(mint.app, name="token") app.add_typer(qa.app, name="qa") +app.command("bootstrap-agent")(bootstrap.bootstrap_agent) app.command("handoff")(handoff.run) diff --git a/tests/test_bootstrap_agent.py b/tests/test_bootstrap_agent.py new file mode 100644 index 0000000..3c8b413 --- /dev/null +++ b/tests/test_bootstrap_agent.py @@ -0,0 +1,393 @@ +"""Tests for `axctl bootstrap-agent` — see +shared/state/axctl-friction-2026-04-17.md §0. + +The one-shot command composes four APIs and two scope vocabularies, so we +mock httpx at the client layer and assert on the request shape + the +workspace artifacts written to a tmp_path. The critical invariant under +test is the /credentials/agent-pat → /api/v1/keys fallback, since that's +the footgun the command exists to hide.""" + +from __future__ import annotations + +from pathlib import Path + +import httpx +import pytest +from typer.testing import CliRunner + +from ax_cli.commands import bootstrap as bootstrap_cmd +from ax_cli.main import app + +runner = CliRunner() + + +SPACE_ID = "ed81ae98-50cb-4268-b986-1b9fe76df742" +AGENT_ID = "6452707e-2892-412f-8439-8ae46dfcc4e6" + + +class _FakeHttp: + """Captures each request so tests can assert request shape + sequence. + + Configure responses per ``(METHOD, prefix)`` prefix match — lets us + simulate the mgmt-route-miss on /credentials/agent-pat while still + returning real JSON for /api/v1/agents and /api/v1/keys. + """ + + def __init__(self, routes: dict[tuple[str, str], tuple[int, dict | str, str | None]]): + """routes keys: (METHOD, url-prefix-match) → (status, body, content_type).""" + self.routes = routes + self.calls: list[dict] = [] + + def _lookup(self, method: str, url: str): + for (m, prefix), payload in self.routes.items(): + if m == method and url.startswith(prefix): + return payload + return (404, {"detail": "no route mocked"}, "application/json") + + def _respond(self, method: str, url: str, json=None, params=None, headers=None): + self.calls.append( + {"method": method, "url": url, "json": json, "params": params, "headers": dict(headers or {})} + ) + status, body, ct = self._lookup(method, url) + request = httpx.Request(method, f"http://test.local{url}") + if isinstance(body, (dict, list)): + return httpx.Response(status, json=body, request=request) + return httpx.Response( + status, content=body or b"", headers={"content-type": ct or "text/plain"}, request=request + ) + + def post(self, url, json=None, params=None, headers=None, **kw): + return self._respond("POST", url, json=json, params=params, headers=headers) + + def put(self, url, json=None, headers=None, **kw): + return self._respond("PUT", url, json=json, headers=headers) + + def patch(self, url, json=None, headers=None, **kw): + return self._respond("PATCH", url, json=json, headers=headers) + + def get(self, url, params=None, headers=None, **kw): + return self._respond("GET", url, params=params, headers=headers) + + +class _FakeClient: + base_url = "https://next.paxai.app" + + def __init__(self, http: _FakeHttp): + self._http = http + # Mirror AxClient surface the bootstrap module touches + self._base_headers: dict = {} + self._exchanger = None + + def _parse_json(self, r: httpx.Response): + return r.json() + + def update_agent(self, identifier, **fields): + r = self._http.put(f"/api/v1/agents/manage/{identifier}", json=fields) + r.raise_for_status() + return r.json() + + def mgmt_issue_agent_pat(self, agent_id, *, name=None, expires_in_days=90, audience="cli"): + body = {"agent_id": agent_id, "expires_in_days": expires_in_days, "audience": audience} + if name: + body["name"] = name + r = self._http.post("/credentials/agent-pat", json=body) + r.raise_for_status() + return r.json() + + def create_key( + self, + name, + *, + allowed_agent_ids=None, + bound_agent_id=None, + audience=None, + scopes=None, + space_id=None, + ): + body = {"name": name} + if allowed_agent_ids: + body["agent_scope"] = "agents" + body["allowed_agent_ids"] = allowed_agent_ids + if bound_agent_id: + body["bound_agent_id"] = bound_agent_id + if audience: + body["audience"] = audience + if scopes: + body["scopes"] = scopes + headers = {"X-Space-Id": space_id} if space_id else None + r = self._http.post("/api/v1/keys", json=body, headers=headers) + r.raise_for_status() + return r.json() + + +# ── fixtures ──────────────────────────────────────────────────────────── + + +@pytest.fixture +def user_pat(monkeypatch): + """Pretend a user PAT is resolved + the user env is 'default'.""" + monkeypatch.setattr( + bootstrap_cmd, "resolve_user_token", lambda: "axp_u_test1234567890abcd.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ) + monkeypatch.setattr(bootstrap_cmd, "resolve_user_base_url", lambda: "https://next.paxai.app") + monkeypatch.setattr(bootstrap_cmd, "_resolve_user_env", lambda: "default") + monkeypatch.setattr(bootstrap_cmd, "_user_config_path", lambda: Path("/tmp/nope-not-real.toml")) + + +@pytest.fixture +def verify_stub(monkeypatch): + """Stub the /auth/me verify call so we don't need to mock the whole + transport for a post-mint read.""" + monkeypatch.setattr( + bootstrap_cmd, + "_verify_with_new_token", + lambda **kw: [{"space_id": kw["space_id"], "name": "ax-cli-dev", "is_default": True}], + ) + + +# ── happy path (canonical mgmt route works) ──────────────────────────── + + +def test_bootstrap_happy_path_uses_mgmt(monkeypatch, tmp_path, user_pat, verify_stub): + http = _FakeHttp( + { + ("GET", "/api/v1/agents"): (200, {"agents": []}, None), # doesn't exist yet + ("POST", "/api/v1/agents"): ( + 201, + {"id": AGENT_ID, "name": "axolotl", "space_id": SPACE_ID}, + None, + ), + ("POST", "/credentials/agent-pat"): ( + 201, + {"token": "axp_a_mintedViaMgmt", "credential_id": "c-1"}, + None, + ), + } + ) + monkeypatch.setattr(bootstrap_cmd, "get_user_client", lambda: _FakeClient(http)) + + result = runner.invoke( + app, + [ + "bootstrap-agent", + "axolotl", + "--space-id", + SPACE_ID, + "--description", + "Friendly amphibian", + "--model", + "codex:gpt-5.4", + "--audience", + "both", + "--save-to", + str(tmp_path / "axolotl"), + ], + ) + assert result.exit_code == 0, result.output + + # Mgmt path was used — no fallback request to /api/v1/keys + posts = [c for c in http.calls if c["method"] == "POST"] + assert any(c["url"] == "/credentials/agent-pat" for c in posts), "mgmt path should have been tried" + assert not any(c["url"] == "/api/v1/keys" for c in posts), "fallback should not fire when mgmt works" + + # Workspace was written, 0600 + token_file = tmp_path / "axolotl" / ".ax" / "token" + config_file = tmp_path / "axolotl" / ".ax" / "config.toml" + assert token_file.exists() + assert config_file.exists() + assert oct(token_file.stat().st_mode)[-3:] == "600" + assert oct(config_file.stat().st_mode)[-3:] == "600" + assert token_file.read_text() == "axp_a_mintedViaMgmt" + cfg = config_file.read_text() + assert f'agent_id = "{AGENT_ID}"' in cfg + assert f'space_id = "{SPACE_ID}"' in cfg + assert 'principal_type = "agent"' in cfg + + +# ── fallback when /credentials/agent-pat is frontend-caught ───────────── + + +def test_bootstrap_falls_back_to_keys_when_mgmt_returns_html(monkeypatch, tmp_path, user_pat, verify_stub): + html_fixture = "frontend" + http = _FakeHttp( + { + ("GET", "/api/v1/agents"): (200, {"agents": []}, None), + ("POST", "/api/v1/agents"): (201, {"id": AGENT_ID, "name": "axolotl"}, None), + # Prod-style frontend catch — 200 + HTML + ("POST", "/credentials/agent-pat"): (200, html_fixture, "text/html; charset=utf-8"), + ("POST", "/api/v1/keys"): ( + 201, + {"token": "axp_a_mintedViaFallback", "credential_id": "c-2"}, + None, + ), + } + ) + monkeypatch.setattr(bootstrap_cmd, "get_user_client", lambda: _FakeClient(http)) + + result = runner.invoke( + app, + [ + "bootstrap-agent", + "axolotl", + "--space-id", + SPACE_ID, + "--save-to", + str(tmp_path / "ax"), + ], + ) + assert result.exit_code == 0, result.output + + urls = [(c["method"], c["url"]) for c in http.calls] + assert ("POST", "/credentials/agent-pat") in urls + assert ("POST", "/api/v1/keys") in urls + + # Fallback call shape: must be agent-bound and space-locked + keys_call = next(c for c in http.calls if c["url"] == "/api/v1/keys") + assert keys_call["json"]["bound_agent_id"] == AGENT_ID + assert keys_call["json"]["allowed_agent_ids"] == [AGENT_ID] + assert keys_call["json"]["audience"] == "both" + assert keys_call["json"]["scopes"] == bootstrap_cmd.DEFAULT_KEY_SCOPES + assert keys_call["headers"].get("X-Space-Id") == SPACE_ID + + assert (tmp_path / "ax" / ".ax" / "token").read_text() == "axp_a_mintedViaFallback" + + +def test_bootstrap_falls_back_on_404(monkeypatch, tmp_path, user_pat, verify_stub): + http = _FakeHttp( + { + ("GET", "/api/v1/agents"): (200, {"agents": []}, None), + ("POST", "/api/v1/agents"): (201, {"id": AGENT_ID, "name": "axolotl"}, None), + ("POST", "/credentials/agent-pat"): (404, {"detail": "not found"}, None), + ("POST", "/api/v1/keys"): (201, {"token": "axp_a_via404fallback"}, None), + } + ) + monkeypatch.setattr(bootstrap_cmd, "get_user_client", lambda: _FakeClient(http)) + + result = runner.invoke( + app, + ["bootstrap-agent", "axolotl", "--space-id", SPACE_ID, "--save-to", str(tmp_path / "a")], + ) + assert result.exit_code == 0, result.output + assert (tmp_path / "a" / ".ax" / "token").read_text() == "axp_a_via404fallback" + + +# ── already-exists behaviour ──────────────────────────────────────────── + + +def test_bootstrap_aborts_when_agent_exists_without_allow_existing(monkeypatch, tmp_path, user_pat): + http = _FakeHttp( + { + ("GET", "/api/v1/agents"): ( + 200, + {"agents": [{"id": AGENT_ID, "name": "axolotl"}]}, + None, + ), + } + ) + monkeypatch.setattr(bootstrap_cmd, "get_user_client", lambda: _FakeClient(http)) + + result = runner.invoke( + app, + ["bootstrap-agent", "axolotl", "--space-id", SPACE_ID, "--save-to", str(tmp_path / "a")], + ) + assert result.exit_code == 2, result.output + assert "already exists" in result.output + # Didn't mint anything + posts = [c for c in http.calls if c["method"] == "POST"] + assert not any("credentials" in c["url"] or "keys" in c["url"] for c in posts) + + +def test_bootstrap_reuses_existing_agent_when_allowed(monkeypatch, tmp_path, user_pat, verify_stub): + http = _FakeHttp( + { + ("GET", "/api/v1/agents"): ( + 200, + {"agents": [{"id": AGENT_ID, "name": "axolotl"}]}, + None, + ), + ("POST", "/credentials/agent-pat"): (201, {"token": "axp_a_reuse"}, None), + } + ) + monkeypatch.setattr(bootstrap_cmd, "get_user_client", lambda: _FakeClient(http)) + + result = runner.invoke( + app, + [ + "bootstrap-agent", + "axolotl", + "--space-id", + SPACE_ID, + "--allow-existing", + "--save-to", + str(tmp_path / "a"), + ], + ) + assert result.exit_code == 0, result.output + # No POST to /api/v1/agents because we reused + assert not any(c for c in http.calls if c["method"] == "POST" and c["url"] == "/api/v1/agents") + + +# ── guardrails on token type ──────────────────────────────────────────── + + +def test_bootstrap_rejects_agent_pat(monkeypatch, user_pat): + monkeypatch.setattr(bootstrap_cmd, "resolve_user_token", lambda: "axp_a_agentTokenShouldFail") + result = runner.invoke( + app, + ["bootstrap-agent", "axolotl", "--space-id", SPACE_ID], + ) + assert result.exit_code == 1 + assert "Cannot bootstrap with an agent PAT" in result.output + + +def test_bootstrap_requires_user_token(monkeypatch, user_pat): + monkeypatch.setattr(bootstrap_cmd, "resolve_user_token", lambda: None) + result = runner.invoke(app, ["bootstrap-agent", "axolotl", "--space-id", SPACE_ID]) + assert result.exit_code == 1 + assert "No user token found" in result.output + + +# ── dry-run ───────────────────────────────────────────────────────────── + + +def test_bootstrap_dry_run_touches_nothing(monkeypatch, tmp_path, user_pat): + http = _FakeHttp({}) # no routes — any call would 404 + monkeypatch.setattr(bootstrap_cmd, "get_user_client", lambda: _FakeClient(http)) + + result = runner.invoke( + app, + [ + "bootstrap-agent", + "axolotl", + "--space-id", + SPACE_ID, + "--save-to", + str(tmp_path / "a"), + "--dry-run", + ], + ) + assert result.exit_code == 0, result.output + assert http.calls == [] + assert not (tmp_path / "a").exists() + + +# ── effective-config line is printed ─────────────────────────────────── + + +def test_bootstrap_prints_effective_config(monkeypatch, tmp_path, user_pat, verify_stub): + http = _FakeHttp( + { + ("GET", "/api/v1/agents"): (200, {"agents": []}, None), + ("POST", "/api/v1/agents"): (201, {"id": AGENT_ID, "name": "axolotl"}, None), + ("POST", "/credentials/agent-pat"): (201, {"token": "axp_a_x"}, None), + } + ) + monkeypatch.setattr(bootstrap_cmd, "get_user_client", lambda: _FakeClient(http)) + + result = runner.invoke( + app, + ["bootstrap-agent", "axolotl", "--space-id", SPACE_ID, "--save-to", str(tmp_path / "a")], + ) + assert result.exit_code == 0, result.output + assert "base_url=" in result.output + assert "user_env=" in result.output From 35cfdf0fd2e5737526e9a586a367a11860f7923e Mon Sep 17 00:00:00 2001 From: anvil Date: Sat, 18 Apr 2026 01:01:34 +0000 Subject: [PATCH 2/3] bootstrap: propagate auth/5xx errors on agent existence check (PR #67 review fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses axolotl's PR #67 review finding. The prior implementation caught all httpx.HTTPStatusError and returned None, which silently treated 401/403/5xx as "agent not found" and proceeded to creation. Axolotl reproduced with a fake 401: the helper returned None. Fix: - Narrow the exception handling in _find_agent_in_space: only a 404 is treated as "not found" (space gone / non-member → downstream POST gives a cleaner error). Everything else (401, 403, 5xx, network errors) propagates so users see the real failure instead of a confusing duplicate-create downstream. Regression tests added (parametrized): - test_bootstrap_does_not_swallow_existence_check_errors[401/403/500/503] asserts the command exits non-zero AND never reaches POST /api/v1/agents or any PAT-mint path when the existence check returns an auth/server error. - test_bootstrap_handles_404_on_existence_as_not_found asserts a 404 is still treated as "agent absent, continue" — the one benign case. All 14 bootstrap tests + 235 suite total pass. Ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- ax_cli/commands/bootstrap.py | 27 ++++++++++------ tests/test_bootstrap_agent.py | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/ax_cli/commands/bootstrap.py b/ax_cli/commands/bootstrap.py index 128833f..51f39d4 100644 --- a/ax_cli/commands/bootstrap.py +++ b/ax_cli/commands/bootstrap.py @@ -114,16 +114,25 @@ def _is_route_miss(exc: httpx.HTTPStatusError) -> bool: def _find_agent_in_space(client, name: str, space_id: str) -> Optional[dict]: - """Return the agent dict if it already exists in the target space, else None.""" - try: - headers = {"X-Space-Id": space_id} - r = client._http.get("/api/v1/agents", params={"space_id": space_id}, headers=headers) - r.raise_for_status() - payload = client._parse_json(r) - agents = payload if isinstance(payload, list) else payload.get("agents", []) - return next((a for a in agents if a.get("name", "").lower() == name.lower()), None) - except httpx.HTTPStatusError: + """Return the agent dict if it already exists in the target space, else None. + + Narrow exception handling: only a clean 200 with an empty/filtered list + counts as "not found". Auth failures (401/403), server errors (5xx), + and network errors must propagate so the user sees them instead of the + command silently proceeding to re-create an agent that already exists. + See axolotl's review of PR #67 for the original repro. + """ + headers = {"X-Space-Id": space_id} + r = client._http.get("/api/v1/agents", params={"space_id": space_id}, headers=headers) + if r.status_code == 404: + # The space doesn't exist or the caller isn't a member — that's + # "agent definitely not there", and downstream create will give a + # cleaner error on the POST. return None + r.raise_for_status() + payload = client._parse_json(r) + agents = payload if isinstance(payload, list) else payload.get("agents", []) + return next((a for a in agents if a.get("name", "").lower() == name.lower()), None) def _create_agent_in_space(client, *, name: str, space_id: str, description: str | None, model: str | None) -> dict: diff --git a/tests/test_bootstrap_agent.py b/tests/test_bootstrap_agent.py index 3c8b413..b58381e 100644 --- a/tests/test_bootstrap_agent.py +++ b/tests/test_bootstrap_agent.py @@ -271,6 +271,64 @@ def test_bootstrap_falls_back_on_404(monkeypatch, tmp_path, user_pat, verify_stu assert (tmp_path / "a" / ".ax" / "token").read_text() == "axp_a_via404fallback" +# ── error propagation on existence check (PR #67 review, v2) ────────── + + +@pytest.mark.parametrize("status_code", [401, 403, 500, 503]) +def test_bootstrap_does_not_swallow_existence_check_errors(monkeypatch, tmp_path, user_pat, status_code): + """Regression for axolotl's PR #67 review finding: a 401/403/5xx on the + existence check MUST NOT be silently treated as 'agent not found'. If + that happens, bootstrap would proceed to create and potentially clobber + an existing agent (or bury a real infra failure under a confusing + downstream error). Propagate loudly instead.""" + http = _FakeHttp( + { + ("GET", "/api/v1/agents"): (status_code, {"detail": "boom"}, None), + # If the bug regresses, the command will happily proceed to POST + # and mint — we assert it does NOT reach those routes. + ("POST", "/api/v1/agents"): (201, {"id": AGENT_ID, "name": "axolotl"}, None), + ("POST", "/credentials/agent-pat"): (201, {"token": "axp_a_shouldNotHappen"}, None), + } + ) + monkeypatch.setattr(bootstrap_cmd, "get_user_client", lambda: _FakeClient(http)) + + result = runner.invoke( + app, + ["bootstrap-agent", "axolotl", "--space-id", SPACE_ID, "--save-to", str(tmp_path / "a")], + ) + assert result.exit_code != 0, result.output + + posts = [c for c in http.calls if c["method"] == "POST"] + assert not any(c["url"] == "/api/v1/agents" for c in posts), ( + f"bootstrap swallowed {status_code} and proceeded to create — regression" + ) + assert not any("credentials" in c["url"] or "keys" in c["url"] for c in posts) + + +def test_bootstrap_handles_404_on_existence_as_not_found(monkeypatch, tmp_path, user_pat, verify_stub): + """A 404 from the list endpoint is the one case where 'not found' is + the sensible interpretation — the space is gone or the caller isn't a + member; the downstream POST will produce a clearer error. Bootstrap + should continue rather than halt.""" + http = _FakeHttp( + { + ("GET", "/api/v1/agents"): (404, {"detail": "not a member"}, None), + # The POST is expected to fail too in this case, but we're only + # asserting that the existence check didn't halt bootstrap. + ("POST", "/api/v1/agents"): (201, {"id": AGENT_ID, "name": "axolotl"}, None), + ("POST", "/credentials/agent-pat"): (201, {"token": "axp_a_404case"}, None), + } + ) + monkeypatch.setattr(bootstrap_cmd, "get_user_client", lambda: _FakeClient(http)) + + result = runner.invoke( + app, + ["bootstrap-agent", "axolotl", "--space-id", SPACE_ID, "--save-to", str(tmp_path / "a")], + ) + assert result.exit_code == 0, result.output + assert any(c["method"] == "POST" and c["url"] == "/api/v1/agents" for c in http.calls) + + # ── already-exists behaviour ──────────────────────────────────────────── From c77d6f189716dbeef81fb6840b4a19ab2dd2b937 Mon Sep 17 00:00:00 2001 From: anvil Date: Wed, 22 Apr 2026 05:19:59 +0000 Subject: [PATCH 3/3] Clean bootstrap-agent examples and test host --- ax_cli/commands/bootstrap.py | 4 ++-- tests/test_bootstrap_agent.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ax_cli/commands/bootstrap.py b/ax_cli/commands/bootstrap.py index 51f39d4..6d918b7 100644 --- a/ax_cli/commands/bootstrap.py +++ b/ax_cli/commands/bootstrap.py @@ -8,8 +8,8 @@ --description "Playful ax-cli helper" \\ --model codex:gpt-5.4 \\ --audience both \\ - --save-to /home/ax-agent/agents/axolotl \\ - --profile next-axolotl + --save-to ~/agents/axolotl \\ + --profile axolotl What it does, in order: diff --git a/tests/test_bootstrap_agent.py b/tests/test_bootstrap_agent.py index b58381e..950f05e 100644 --- a/tests/test_bootstrap_agent.py +++ b/tests/test_bootstrap_agent.py @@ -70,7 +70,7 @@ def get(self, url, params=None, headers=None, **kw): class _FakeClient: - base_url = "https://next.paxai.app" + base_url = "https://paxai.app" def __init__(self, http: _FakeHttp): self._http = http @@ -129,7 +129,7 @@ def user_pat(monkeypatch): monkeypatch.setattr( bootstrap_cmd, "resolve_user_token", lambda: "axp_u_test1234567890abcd.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ) - monkeypatch.setattr(bootstrap_cmd, "resolve_user_base_url", lambda: "https://next.paxai.app") + monkeypatch.setattr(bootstrap_cmd, "resolve_user_base_url", lambda: "https://paxai.app") monkeypatch.setattr(bootstrap_cmd, "_resolve_user_env", lambda: "default") monkeypatch.setattr(bootstrap_cmd, "_user_config_path", lambda: Path("/tmp/nope-not-real.toml"))