From 23a11708a579e13613240a92afa756222acd9bda Mon Sep 17 00:00:00 2001 From: Keegan Donley Date: Wed, 21 Oct 2026 16:03:03 -0500 Subject: [PATCH] KZN-19166 - Add machinery for app-builder-cli to drive admin command with oauth credentials --- CHANGELOG.md | 11 + CONTRIBUTING.md | 1 + README.md | 4 +- docs/ARCHITECTURE.md | 19 +- src/kizen_builder/api/client.py | 57 ++- src/kizen_builder/auth_helper.py | 153 ++++++++ src/kizen_builder/cli/envs.py | 2 + src/kizen_builder/cli/init.py | 5 +- src/kizen_builder/cli/upgrade.py | 10 +- src/kizen_builder/config.py | 147 ++++++- src/kizen_builder/docs/operating.md | 19 + tests/test_auth_helper.py | 570 ++++++++++++++++++++++++++++ 12 files changed, 966 insertions(+), 32 deletions(-) create mode 100644 src/kizen_builder/auth_helper.py create mode 100644 tests/test_auth_helper.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9bb40..0fc1ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,17 @@ called out explicitly under **Changed** or **Removed**. ### Added +- **`kizen` can run under app-builder with no profile.** + When `KIZEN_AUTH_HELPER` is set, the environment comes from + `KIZEN_BASE_URL`, `KIZEN_BUSINESS_ID` and `KIZEN_USER_ID`, and every request + authenticates with a Bearer token printed by the helper command, refreshed + once on a 401. `~/.config/kizen/credentials.toml` isn't read, and a pinned + directory still refuses a different `business_id`. In this mode `kizen + init`, `kizen envs list` and `kizen upgrade` refuse, and `kizen upgrade + --check` says the installation is managed by app-builder and exits 0. + Profile mode is unchanged. `kizen docs show operating` has the variable + contract. + - **`activities duplicate `** copies an activity type, and **`dashboards duplicate `** copies a dashboard with its dashlets. `--name` names the copy, and `dashboards duplicate` also takes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dab20d8..08ef939 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -214,6 +214,7 @@ src/kizen_builder/ init.py # kizen init code.py # kizen code test config.py # Resolves the active env's credentials (chokepoint). + auth_helper.py # Running under app-builder: runs $KIZEN_AUTH_HELPER, caches the Bearer header, refreshes on 401. docs.py # Resolves the packaged docs tree (chokepoint) + env stubs. profiles.py # Central credentials.toml store + .kizen/profile pin. upgrade.py # Install-shape detection and update planning. diff --git a/README.md b/README.md index 4530d88..1bf54cd 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ non-secret files: `AGENTS.md` is left alone. For scripted setup, `kizen init --help` lists the flags. The environment needs -`--base-url`, which has no `KIZEN_*` variable. With nothing on stdin, a missing -value that has no default exits 2 and names the flag to pass. +`--base-url`; `kizen init` reads no `KIZEN_*` variable for it. With nothing +on stdin, a missing value that has no default exits 2 and names the flag to pass. ## Then talk to it diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d0a930d..b52cf16 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -51,13 +51,18 @@ package and is served by `kizen docs show`. `automations.py`, `records.py`, `permissions.py`, …). Each function builds a path, hands a dict to `KizenClient`, and returns the parsed response. No business logic, no spec awareness, no user-facing formatting. `api/client.py` -owns the three things everything else depends on: the three auth headers -(`X-API-KEY`, `X-BUSINESS-ID`, `X-USER-ID`, injected on every request from -`EnvConfig.auth_headers()`, file downloads included), `KizenAPIError`, the -single exception type every non-2xx response normalizes into, and the retry -policy. One error shape means callers catch one thing; one injection point -means no endpoint module can forget the headers. Retry is decided by method and -failure kind, never by endpoint: a GET retries a transport error or a +owns the three things everything else depends on: authentication on every +request (file downloads included), `KizenAPIError`, the single exception type +every non-2xx response normalizes into, and the retry policy. One error shape +means callers catch one thing; one injection point means no endpoint module can +forget the headers. The headers follow `EnvConfig.auth`. A profile's +`ApiKeyAuth` sends `X-API-KEY`, `X-BUSINESS-ID` and `X-USER-ID`. Under +app-builder, `HelperAuth` becomes +[`BearerHelperAuth`](../src/kizen_builder/auth_helper.py), which sends only +`Authorization: Bearer` and resends once after a 401 with a refreshed token; +the environment contract is in +[`operating.md`](../src/kizen_builder/docs/operating.md). Retry is decided by +method and failure kind, never by endpoint: a GET retries a transport error or a 429/502/503/504, any request retries a connection that never opened, and a write is never retried once sent. A write whose answer was lost says the server may have applied it instead. A search sent as POST passes `read_only=True`, diff --git a/src/kizen_builder/api/client.py b/src/kizen_builder/api/client.py index 70bbfee..318b5c1 100644 --- a/src/kizen_builder/api/client.py +++ b/src/kizen_builder/api/client.py @@ -1,5 +1,10 @@ -"""Thin httpx wrapper that injects the three Kizen auth headers on every call, -retries reads, and normalizes errors into a single exception type. +"""Thin httpx wrapper that authenticates every call, retries reads, and +normalizes errors into a single exception type. + +A profile authenticates with the three ``X-*`` headers. Helper mode sends only +``Authorization: Bearer``, from :class:`~kizen_builder.auth_helper.BearerHelperAuth`, +which refreshes the token and resends once on a 401 inside a single attempt; +the retries below wrap that whole exchange. A GET is retried on a transport error or a 429/502/503/504. Any method is retried when the connection was never made. A write is never retried once it @@ -14,7 +19,8 @@ import httpx -from kizen_builder.config import EnvConfig +from kizen_builder.auth_helper import BearerHelperAuth +from kizen_builder.config import ApiKeyAuth, EnvConfig, HelperAuth _MAX_ATTEMPTS = 3 _RETRY_STATUSES = frozenset({429, 502, 503, 504}) @@ -39,14 +45,20 @@ class KizenClient: def __init__(self, config: EnvConfig, timeout: float = 30.0) -> None: self._config = config + headers = {"Accept": "application/json", "Content-Type": "application/json"} + auth: httpx.Auth | None = None + match config.auth: + case ApiKeyAuth(api_key=api_key): + headers = { + "X-API-KEY": api_key, + "X-BUSINESS-ID": config.business_id, + "X-USER-ID": config.user_id, + **headers, + } + case HelperAuth(argv=argv): + auth = BearerHelperAuth(argv) self._client = httpx.Client( - base_url=config.base_url, - headers={ - **config.auth_headers(), - "Accept": "application/json", - "Content-Type": "application/json", - }, - timeout=timeout, + base_url=config.base_url, headers=headers, auth=auth, timeout=timeout ) # --- context manager ------------------------------------------------ @@ -173,7 +185,9 @@ def _error( body = resp.text message = _extract_error_message(body) or f"{method} {path} failed" - if resp.status_code in (401, 403): + if isinstance(self._config.auth, HelperAuth): + message = _helper_auth_message(message, resp.status_code, body) + elif resp.status_code in (401, 403): # Auth failure — surface the exact command the user can run to fix. label = self._config.name message = ( @@ -185,6 +199,27 @@ def _error( return KizenAPIError(resp.status_code, message, body=body) +def _helper_auth_message(message: str, status: int, body: Any) -> str: + """Helper mode's advice for an auth failure. `kizen init` doesn't apply: + app-builder owns the credentials.""" + if status == 401: + return f"{message} — Kizen rejected the token from the auth helper." + # DRF gives every PermissionDenied the code `permission_denied`, so the + # detail text is what singles out the OAuth-permission check (webapp + # apps/oauth_provider/authentication.py). + if ( + status == 403 + and isinstance(body, dict) + and body.get("code") == "permission_denied" + and "oauth" in str(body.get("detail", "")).lower() + ): + return ( + f"{message} — this Kizen user needs the OAuth authorizations " + "permission to use kizen through app-builder." + ) + return message + + def _may_have_applied(message: str, method: str) -> str: return ( f"{message} — the server may have applied this {method}; " diff --git a/src/kizen_builder/auth_helper.py b/src/kizen_builder/auth_helper.py new file mode 100644 index 0000000..f9be6fd --- /dev/null +++ b/src/kizen_builder/auth_helper.py @@ -0,0 +1,153 @@ +"""Run the helper-mode auth command and cache the header it prints. + +In helper mode (``$KIZEN_AUTH_HELPER``, see :mod:`kizen_builder.config`) a +wrapper owns OAuth. It gives us a command that prints exactly one line on +stdout, ``{"Authorization": "Bearer "}``, refreshing the token first +when it is close to expiry. On failure it prints one line on stderr and +nothing on stdout, possibly still exiting 0, so empty stdout is a failure. + +The header is cached for the life of the process, keyed by the command, and +re-fetched only when the API rejects it (:class:`BearerHelperAuth`). The token +never appears in an error message: errors quote the helper's stderr, never its +stdout. +""" + +from __future__ import annotations + +import json +import re +import subprocess +from collections.abc import Generator + +import httpx + +from kizen_builder.config import ConfigError + +TIMEOUT_S = 15.0 +_STDERR_LIMIT = 500 +# "Bearer " plus a token of visible ASCII. Anything else (empty, whitespace, +# a CR/LF) is refused here rather than handed to httpx, whose header-validation +# error would quote the value. +_BEARER = re.compile(r"Bearer [\x21-\x7e]+") + +_cache: dict[tuple[str, ...], str] = {} + + +class AuthHelperError(ConfigError): + """The helper didn't produce a usable Authorization header. + + A ``ConfigError`` so the CLI's root group prints it as one ``error:`` line. + """ + + +def header(argv: tuple[str, ...]) -> str: + """The cached Authorization value for ``argv``, running the helper once.""" + value = _cache.get(argv) + if value is None: + value = refresh(argv) + return value + + +def refresh(argv: tuple[str, ...]) -> str: + """Run the helper, replace the cached value, and return it.""" + value = _run(argv) + _cache[argv] = value + return value + + +def clear_cache() -> None: + _cache.clear() + + +def _run(argv: tuple[str, ...]) -> str: + name = argv[0] + try: + proc = subprocess.run( + argv, + capture_output=True, + stdin=subprocess.DEVNULL, + timeout=TIMEOUT_S, + check=False, + ) + except FileNotFoundError: + raise AuthHelperError(_message(name, "was not found", b"")) from None + except subprocess.TimeoutExpired as exc: + raise AuthHelperError( + _message(name, f"timed out after {TIMEOUT_S:g}s", exc.stderr) + ) from None + except OSError as exc: + raise AuthHelperError( + _message(name, f"could not be run ({exc.strerror or exc})", b"") + ) from None + + stderr = proc.stderr + if proc.returncode != 0: + raise AuthHelperError( + _message(name, f"exited with status {proc.returncode}", stderr) + ) + stdout = proc.stdout.decode("utf-8", errors="replace").strip() + if not stdout: + raise AuthHelperError(_message(name, "printed nothing on stdout", stderr)) + try: + payload = json.loads(stdout) + except ValueError: + raise AuthHelperError( + _message(name, "printed something other than JSON", stderr) + ) from None + if not isinstance(payload, dict): + raise AuthHelperError( + _message(name, "printed JSON that isn't an object", stderr) + ) + value = payload.get("Authorization") + if value is None: + raise AuthHelperError( + _message(name, "printed JSON with no Authorization key", stderr) + ) + if not isinstance(value, str) or not _BEARER.fullmatch(value): + raise AuthHelperError( + _message( + name, "printed an Authorization value that isn't a Bearer token", stderr + ) + ) + return value + + +def _message(name: str, problem: str, stderr: bytes | str | None) -> str: + """``auth helper `` ``, plus the helper's stderr if any.""" + text = ( + stderr.decode("utf-8", errors="replace") + if isinstance(stderr, bytes) + else stderr or "" + ) + said = " ".join(text.split()) + if len(said) > _STDERR_LIMIT: + said = said[: _STDERR_LIMIT - 1] + "…" + message = f"auth helper `{name}` {problem}" + return f"{message}: {said}" if said else f"{message}." + + +class BearerHelperAuth(httpx.Auth): + """Attach the helper's Authorization header; on a 401, refresh it once and + resend once. + + A 401 on a Bearer request is rejected by authentication before the view + runs, so resending a write after one can't apply it twice. The body is + buffered up front so the resend can replay it. + """ + + requires_request_body = True + + def __init__(self, argv: tuple[str, ...]) -> None: + self._argv = argv + + def __repr__(self) -> str: + return f"BearerHelperAuth(argv={self._argv!r})" + + def auth_flow( + self, request: httpx.Request + ) -> Generator[httpx.Request, httpx.Response, None]: + request.headers["Authorization"] = header(self._argv) + response = yield request + if response.status_code == 401: + request.headers["Authorization"] = refresh(self._argv) + yield request diff --git a/src/kizen_builder/cli/envs.py b/src/kizen_builder/cli/envs.py index b1f46bd..ce74e09 100644 --- a/src/kizen_builder/cli/envs.py +++ b/src/kizen_builder/cli/envs.py @@ -13,6 +13,7 @@ console, err_console, ) +from kizen_builder.config import refuse_in_helper_mode from kizen_builder.tools import envs as env_tools envs_app = typer.Typer( @@ -27,6 +28,7 @@ def envs_list( json_out: bool = JSON_OPTION, ) -> None: """List the Kizen env configured in this directory.""" + refuse_in_helper_mode("envs list", "the environment") fmt = out.resolve_format(output, json_out) items = env_tools.list_envs() diff --git a/src/kizen_builder/cli/init.py b/src/kizen_builder/cli/init.py index e775343..4a22a63 100644 --- a/src/kizen_builder/cli/init.py +++ b/src/kizen_builder/cli/init.py @@ -17,7 +17,7 @@ from kizen_builder import profiles from kizen_builder.api.client import KizenAPIError, KizenClient from kizen_builder.cli._shared import app, console, err_console -from kizen_builder.config import EnvConfig +from kizen_builder.config import ApiKeyAuth, EnvConfig, refuse_in_helper_mode # The dotted-halftone Kizen logo mark from Kizen's node CLI (@kizenapps/cli's # src/ui/Logo.tsx), transcribed verbatim from that package's published source @@ -302,6 +302,7 @@ def init( anything still missing is prompted for, so this works interactively and headlessly from the same command. """ + refuse_in_helper_mode("init", "this environment's credentials") _print_banner() cwd = Path.cwd() if not profile: @@ -344,10 +345,10 @@ def init( if not skip_validation: cfg = EnvConfig( name=profile.lower(), - api_key=api_key, business_id=business_id, user_id=user_id, base_url=base_url_in, + auth=ApiKeyAuth(api_key), ) try: _validate_creds(cfg) diff --git a/src/kizen_builder/cli/upgrade.py b/src/kizen_builder/cli/upgrade.py index 390a691..d8dbf3c 100644 --- a/src/kizen_builder/cli/upgrade.py +++ b/src/kizen_builder/cli/upgrade.py @@ -10,6 +10,7 @@ from kizen_builder import upgrade as upgrade_mod from kizen_builder.cli._shared import app, console, err_console +from kizen_builder.config import helper_mode, refuse_in_helper_mode def _print_check(result: upgrade_mod.CheckResult, install: upgrade_mod.Install) -> None: @@ -57,8 +58,15 @@ def upgrade( `--check` is the session-start form: it reports whether a newer version exists, caches the answer for a day, and always exits 0 — offline, behind a - proxy, or with no remote configured, it just says so quietly. + proxy, or with no remote configured, it just says so quietly. Under + app-builder it says the installation is managed there, and still exits 0. """ + if check and helper_mode(): + # app-builder owns the install, so there is nothing to look up. Keep + # --check's exit-0 contract rather than refusing. + console.print("[dim]this installation is managed by app-builder.[/dim]") + return + refuse_in_helper_mode("upgrade", "this installation") install = upgrade_mod.detect_install() if check: diff --git a/src/kizen_builder/config.py b/src/kizen_builder/config.py index 5114a9f..854b029 100644 --- a/src/kizen_builder/config.py +++ b/src/kizen_builder/config.py @@ -15,13 +15,22 @@ That checksum is the hard guarantee that a command cannot act against the wrong environment from a pinned directory. The canonical identity of a Kizen environment is its ``business_id``. + +**Helper mode** replaces all of the above except the pin check. When +``$KIZEN_AUTH_HELPER`` is in the process environment, a wrapper (app-builder) +owns authentication: the config comes from ``KIZEN_*`` variables alone, the +credential store is never opened, and requests carry a Bearer header that the +helper command prints (:mod:`kizen_builder.auth_helper`). The variable contract +is documented in ``docs/operating.md``. """ from __future__ import annotations import os -from dataclasses import dataclass +import shlex +from dataclasses import dataclass, field from pathlib import Path +from urllib.parse import urlsplit from dotenv import find_dotenv, load_dotenv @@ -42,22 +51,62 @@ class ConfigError(Exception): """Raised when required env vars for the active environment are missing.""" +# Helper mode's environment contract. The first one switches the mode on. +HELPER_ENV = "KIZEN_AUTH_HELPER" +_HELPER_VARS = (HELPER_ENV, "KIZEN_BASE_URL", "KIZEN_BUSINESS_ID", "KIZEN_USER_ID") + + +@dataclass(frozen=True) +class ApiKeyAuth: + """A profile's API key, sent as ``X-API-KEY`` with the identity headers.""" + + api_key: str = field(repr=False) + + +@dataclass(frozen=True) +class HelperAuth: + """A command that prints ``{"Authorization": "Bearer "}``. + + ``argv`` is already split; it runs without a shell. + """ + + argv: tuple[str, ...] + + +Auth = ApiKeyAuth | HelperAuth + + @dataclass(frozen=True) class EnvConfig: """Resolved credentials + base URL for one environment.""" name: str # Local label from conf file (e.g. "sandbox"). Cosmetic only. - api_key: str business_id: str # Canonical identity — checked against the directory pin. user_id: str base_url: str + auth: Auth + + +def helper_mode() -> bool: + """Whether a wrapper supplies auth through ``$KIZEN_AUTH_HELPER``. + + Read from the process environment only: :func:`load_env_config` drops the + variable if a ``.env`` file supplied it, so a checked-in file can't make + the CLI run a command. + """ + return HELPER_ENV in os.environ + + +def refuse_in_helper_mode(command: str, managed: str) -> None: + """Stop a command that manages what app-builder owns in helper mode. - def auth_headers(self) -> dict[str, str]: - return { - "X-API-KEY": self.api_key, - "X-BUSINESS-ID": self.business_id, - "X-USER-ID": self.user_id, - } + ``managed`` completes "app-builder manages ...", e.g. + "this installation". + """ + if helper_mode(): + raise ConfigError( + f"`kizen {command}` is unavailable here: app-builder manages {managed}." + ) def load_env_config( @@ -70,11 +119,21 @@ def load_env_config( ``$KIZEN_PROFILE`` > directory pin > ``$KIZEN_ENV``), loads its credentials from the central store and enforces the directory pin's ``business_id`` checksum before returning. + + In helper mode (``$KIZEN_AUTH_HELPER`` set) the config comes from the + ``KIZEN_*`` helper variables instead, no ``.env`` is loaded and the + credential store is never read; the pin's ``business_id`` is still + enforced. """ + if helper_mode(): + return _load_helper_config(env_name) + if dotenv_path is not None: load_dotenv(dotenv_path, override=False) else: load_dotenv(find_dotenv(usecwd=True), override=False) + # Helper mode comes from the real environment only (see helper_mode()). + os.environ.pop(HELPER_ENV, None) pin = profiles.load_pin() @@ -121,8 +180,78 @@ def _load_profile(name: str) -> EnvConfig: ) return EnvConfig( name=name.lower(), - api_key=creds.api_key, business_id=creds.business_id, user_id=creds.user_id, base_url=creds.base_url, + auth=ApiKeyAuth(creds.api_key), + ) + + +def _load_helper_config(env_name: str | None) -> EnvConfig: + """Build an EnvConfig from the helper-mode variables. Never reads the + credential store or a ``.env`` file.""" + selectors = [ + label + for label, value in ( + ("an explicit environment name", env_name), + ("--profile", _cli_profile_override), + ("$KIZEN_PROFILE", os.environ.get("KIZEN_PROFILE")), + ) + if value + ] + if selectors: + raise ConfigError( + f"${HELPER_ENV} is set, so app-builder supplies the environment, " + f"but {' and '.join(selectors)} also selects a profile. " + "Use one or the other." + ) + + values = {var: os.environ.get(var, "").strip() for var in _HELPER_VARS} + missing = [var for var, value in values.items() if not value] + if missing: + raise ConfigError( + f"${HELPER_ENV} is set, but {', '.join(missing)} " + f"{'is' if len(missing) == 1 else 'are'} missing or empty. " + f"Running under app-builder needs all of {', '.join(_HELPER_VARS)}." + ) + + try: + argv = tuple(shlex.split(values[HELPER_ENV])) + except ValueError as exc: + raise ConfigError(f"${HELPER_ENV} can't be parsed: {exc}") from None + base_url = _helper_base_url(values["KIZEN_BASE_URL"]) + business_id = values["KIZEN_BUSINESS_ID"] + + pin = profiles.load_pin() + if pin and pin.business_id and business_id != pin.business_id: + raise ConfigError( + f"Directory pinned to profile '{pin.profile}' " + f"(business_id {pin.business_id}), but KIZEN_BUSINESS_ID is " + f"{business_id}. Refusing.\n" + f"To retarget this directory, edit {pin.path}." + ) + + return EnvConfig( + name=urlsplit(base_url).netloc, + business_id=business_id, + user_id=values["KIZEN_USER_ID"], + base_url=base_url, + auth=HelperAuth(argv), ) + + +def _helper_base_url(value: str) -> str: + """``$KIZEN_BASE_URL`` as an https origin, like a profile's ``base_url``.""" + parts = urlsplit(value.rstrip("/")) + if ( + parts.scheme != "https" + or not parts.netloc + or parts.path + or parts.query + or parts.fragment + ): + raise ConfigError( + f"KIZEN_BASE_URL must be an https origin such as " + f"https://app.go.kizen.com, with no path; got {value!r}." + ) + return f"https://{parts.netloc}" diff --git a/src/kizen_builder/docs/operating.md b/src/kizen_builder/docs/operating.md index f31b77b..221297f 100644 --- a/src/kizen_builder/docs/operating.md +++ b/src/kizen_builder/docs/operating.md @@ -33,6 +33,25 @@ gets pointed at the wrong host by hand; free-text URL entry is still there for self-hosted or one-off setups, reached with a deliberate `url` choice, not a default. +**Under app-builder, there is no profile.** The wrapper +launches `kizen` with these variables in the process environment (a `.env` +file can't supply them): + +| Variable | Value | +|---|---| +| `KIZEN_AUTH_HELPER` | A command that prints `{"Authorization": "Bearer "}` on one line. Split like a shell word list but run without a shell, with a 15s timeout. Its presence turns this mode on. | +| `KIZEN_BASE_URL` | The https origin, shaped like a profile's `base_url` (`https://staging.kizen.com`, no `/api`). | +| `KIZEN_BUSINESS_ID`, `KIZEN_USER_ID` | The identity. The directory pin's `business_id` is still enforced against `KIZEN_BUSINESS_ID`. | + +All four are required. The credential store is never read. `--profile` or +`$KIZEN_PROFILE` alongside them is an error. Requests carry only the Bearer +header. The helper runs on the first request; whenever Kizen answers 401 it +runs again and that request is resent once. The Kizen user needs the "OAuth +authorizations" permission; without it every request is a 403. `kizen init`, +`kizen envs list` and `kizen upgrade` refuse, because app-builder owns the +credentials and the installation; `kizen upgrade --check` says the +installation is managed by app-builder and exits 0. + **2. Pull live before reasoning.** Before planning any change, call the relevant read commands to fetch the current state of the env. Don't assume from prior turns or from any local notes — Kizen state can change between diff --git a/tests/test_auth_helper.py b/tests/test_auth_helper.py new file mode 100644 index 0000000..397487c --- /dev/null +++ b/tests/test_auth_helper.py @@ -0,0 +1,570 @@ +"""Helper mode: config from KIZEN_* variables, Bearer auth from a helper command. + +The helper is a real script under ``tmp_path``, run through ``sys.executable``, +so every subprocess path (exit codes, stderr, timeouts, no shell) is the real +one. HTTP goes through respx. +""" + +from __future__ import annotations + +import json +import shlex +import sys +from pathlib import Path + +import httpx +import pytest +import respx +from typer.testing import CliRunner + +from kizen_builder import auth_helper, config, profiles +from kizen_builder.api.client import KizenAPIError, KizenClient +from kizen_builder.auth_helper import AuthHelperError +from kizen_builder.cli import app +from kizen_builder.config import ConfigError, HelperAuth, load_env_config +from tests.conftest import FAKE_BUSINESS_ID, FAKE_USER_ID + +SECRET = "SECRET-TOKEN" +BASE = "https://helper.kizen.test" +THING = f"{BASE}/api/thing" +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch): + """No cached header or --profile override leaks between tests, and the + conftest's KIZEN_ENV (a profile-mode fallback) stays set, as a wrapper's + environment might carry it.""" + auth_helper.clear_cache() + config.set_profile_override(None) + for var in config._HELPER_VARS: + monkeypatch.delenv(var, raising=False) + yield + auth_helper.clear_cache() + config.set_profile_override(None) + + +def _script(tmp_path: Path, body: str, name: str = "helper.py") -> Path: + """Write a helper script that appends one line to ``calls.log`` per run, + then runs ``body``.""" + log = tmp_path / "calls.log" + path = tmp_path / name + path.write_text( + "import sys\n" + f"with open({str(log)!r}, 'a') as fh:\n" + " fh.write('run\\n')\n" + body + ) + return path + + +def _calls(tmp_path: Path) -> int: + log = tmp_path / "calls.log" + return len(log.read_text().splitlines()) if log.exists() else 0 + + +def _ok_body(token: str = SECRET) -> str: + return f"print({json.dumps(json.dumps({'Authorization': f'Bearer {token}'}))})\n" + + +def _helper_env(monkeypatch, script: Path, *extra: str) -> None: + command = shlex.join([sys.executable, str(script), *extra]) + monkeypatch.setenv("KIZEN_AUTH_HELPER", command) + monkeypatch.setenv("KIZEN_BASE_URL", BASE) + monkeypatch.setenv("KIZEN_BUSINESS_ID", FAKE_BUSINESS_ID) + monkeypatch.setenv("KIZEN_USER_ID", FAKE_USER_ID) + + +@pytest.fixture +def ok_helper(tmp_path, monkeypatch) -> Path: + script = _script(tmp_path, _ok_body()) + _helper_env(monkeypatch, script) + return script + + +def _pin(monkeypatch, business_id: str | None) -> None: + pin = profiles.Pin(profile="alpha", business_id=business_id, path=Path("/x/p")) + monkeypatch.setattr(profiles, "load_pin", lambda start=None: pin) + + +# --- config resolution ------------------------------------------------------ + + +def test_helper_mode_builds_config_from_env(ok_helper): + cfg = load_env_config() + assert cfg.base_url == BASE + assert cfg.business_id == FAKE_BUSINESS_ID + assert cfg.user_id == FAKE_USER_ID + assert isinstance(cfg.auth, HelperAuth) + assert cfg.auth.argv == (sys.executable, str(ok_helper)) + + +def test_helper_mode_never_opens_credential_store(ok_helper, tmp_path, monkeypatch): + """(b) A malformed credentials.toml would raise if anything parsed it.""" + xdg = tmp_path / "xdg" + (xdg / "kizen").mkdir(parents=True) + (xdg / "kizen" / "credentials.toml").write_text("[[[ not toml") + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + with pytest.raises(Exception): # noqa: B017 - prove the store is poisoned + profiles.list_profiles() + + cfg = load_env_config() + assert cfg.business_id == FAKE_BUSINESS_ID + with respx.mock: + respx.get(THING).respond(json={"ok": True}) + with KizenClient(cfg) as client: + assert client.get("/api/thing") == {"ok": True} + + +def test_helper_mode_pin_match_passes(ok_helper, monkeypatch): + _pin(monkeypatch, FAKE_BUSINESS_ID) + assert load_env_config().business_id == FAKE_BUSINESS_ID + + +def test_helper_mode_pin_mismatch_refuses(ok_helper, monkeypatch): + """(c)""" + _pin(monkeypatch, "ZZZZ") + with pytest.raises(ConfigError, match="Refusing") as exc: + load_env_config() + assert "KIZEN_BUSINESS_ID" in str(exc.value) + + +@pytest.mark.parametrize( + "unset", ["KIZEN_BASE_URL", "KIZEN_BUSINESS_ID", "KIZEN_USER_ID"] +) +def test_helper_mode_partial_vars_raise_naming_missing(ok_helper, monkeypatch, unset): + """(d) partial set.""" + monkeypatch.delenv(unset) + with pytest.raises(ConfigError) as exc: + load_env_config() + assert unset in str(exc.value) + others = {"KIZEN_BASE_URL", "KIZEN_BUSINESS_ID", "KIZEN_USER_ID"} - {unset} + missing_clause = str(exc.value).split(" missing")[0] + assert not any(o in missing_clause for o in others) + + +def test_helper_mode_empty_helper_counts_as_missing(ok_helper, monkeypatch): + monkeypatch.setenv("KIZEN_AUTH_HELPER", " ") + with pytest.raises(ConfigError, match="KIZEN_AUTH_HELPER is missing"): + load_env_config() + + +def test_helper_mode_with_kizen_profile_is_ambiguous(ok_helper, monkeypatch): + """(d) mixed with a profile.""" + monkeypatch.setenv("KIZEN_PROFILE", "testenv") + with pytest.raises(ConfigError, match=r"\$KIZEN_PROFILE"): + load_env_config() + + +def test_helper_mode_with_profile_flag_is_ambiguous(ok_helper): + config.set_profile_override("testenv") + with pytest.raises(ConfigError, match="--profile"): + load_env_config() + + +def test_helper_mode_with_explicit_env_name_is_ambiguous(ok_helper): + with pytest.raises(ConfigError, match="explicit environment name"): + load_env_config("testenv") + + +def test_helper_mode_profile_flag_via_cli_is_ambiguous(ok_helper): + result = runner.invoke(app, ["--profile", "testenv", "objects", "list"]) + assert result.exit_code == 1 + assert "Use one or the other" in result.output + + +@pytest.mark.parametrize( + "url", + [ + "http://helper.kizen.test", + "https://helper.kizen.test/api", + "helper.kizen.test", + "https://helper.kizen.test?x=1", + ], +) +def test_helper_mode_rejects_non_https_origin(ok_helper, monkeypatch, url): + monkeypatch.setenv("KIZEN_BASE_URL", url) + with pytest.raises(ConfigError, match="https origin"): + load_env_config() + + +def test_helper_mode_trailing_slash_is_normalized(ok_helper, monkeypatch): + monkeypatch.setenv("KIZEN_BASE_URL", BASE + "/") + assert load_env_config().base_url == BASE + + +def test_helper_from_dotenv_is_ignored(tmp_path, monkeypatch): + """A .env file can't turn helper mode on (it would run its command).""" + dotenv = tmp_path / ".env" + dotenv.write_text("KIZEN_AUTH_HELPER=touch pwned\n") + cfg = load_env_config(dotenv_path=dotenv) + assert cfg.name == "testenv" + assert not config.helper_mode() + + +def test_profile_mode_unaffected_by_stray_helper_vars(monkeypatch): + """Without KIZEN_AUTH_HELPER, the identity variables (also `kizen init`'s + envvars) don't change profile resolution.""" + monkeypatch.setenv("KIZEN_BUSINESS_ID", "other") + monkeypatch.setenv("KIZEN_BASE_URL", BASE) + cfg = load_env_config() + assert cfg.name == "testenv" + assert cfg.business_id == FAKE_BUSINESS_ID + + +# --- client ----------------------------------------------------------------- + + +@respx.mock +def test_helper_mode_sends_bearer_and_no_x_headers(ok_helper): + """(g)""" + route = respx.route(url__startswith=BASE).respond(json={"ok": True}) + with KizenClient(load_env_config()) as client: + client.get("/api/thing") + client.post("/api/thing", json={"a": 1}) + client.patch("/api/thing", json={"a": 1}) + client.put("/api/thing", json={"a": 1}) + client.delete("/api/thing") + client.get_bytes("/api/thing") + assert route.call_count == 6 + for call in route.calls: + sent = call.request.headers + assert sent["Authorization"] == f"Bearer {SECRET}" + for name in ("X-API-KEY", "X-USER-ID", "X-BUSINESS-ID"): + assert name not in sent + + +@respx.mock +def test_helper_runs_once_per_process(ok_helper, tmp_path): + """(f) N requests across several clients run the helper once.""" + route = respx.get(THING).respond(json={}) + cfg = load_env_config() + for _ in range(3): + with KizenClient(cfg) as client: + client.get("/api/thing") + client.get("/api/thing") + assert route.call_count == 6 + assert _calls(tmp_path) == 1 + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +def test_401_then_200_refreshes_once_and_succeeds(ok_helper, tmp_path, method): + """(e) 401 → helper again → resend → 200.""" + with respx.mock: + route = respx.route(method=method, url=THING).mock( + side_effect=[ + httpx.Response(401, json={"detail": "expired"}), + httpx.Response(200, json={"ok": True}), + ] + ) + with KizenClient(load_env_config()) as client: + result = ( + client.get("/api/thing") + if method == "GET" + else client.post("/api/thing", json={"name": "x"}) + ) + assert result == {"ok": True} + assert _calls(tmp_path) == 2 + assert route.call_count == 2 + if method == "POST": + assert [json.loads(c.request.content) for c in route.calls] == [ + {"name": "x"}, + {"name": "x"}, + ] + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +def test_401_then_401_runs_helper_twice_then_errors(ok_helper, tmp_path, method): + """(e) A second 401 surfaces as the auth error; no third send.""" + with respx.mock: + route = respx.route(method=method, url=THING).respond( + 401, json={"detail": "bad token"} + ) + with ( + KizenClient(load_env_config()) as client, + pytest.raises(KizenAPIError) as exc, + ): + if method == "GET": + client.get("/api/thing") + else: + client.post("/api/thing", json={}) + assert exc.value.status_code == 401 + assert "bad token" in exc.value.message + assert "auth helper" in exc.value.message + assert "kizen init" not in exc.value.message + assert route.call_count == 2 + assert _calls(tmp_path) == 2 + + +def test_401_refresh_composes_with_get_retry(ok_helper, tmp_path, monkeypatch): + """A 503 retry after a refreshed 401 reuses the refreshed header.""" + monkeypatch.setattr("time.sleep", lambda s: None) + with respx.mock: + route = respx.get(THING).mock( + side_effect=[ + httpx.Response(401), + httpx.Response(503), + httpx.Response(200, json={"ok": True}), + ] + ) + with KizenClient(load_env_config()) as client: + assert client.get("/api/thing") == {"ok": True} + assert route.call_count == 3 + assert _calls(tmp_path) == 2 + + +def test_403_oauth_permission_denied_message(ok_helper): + body = { + "detail": "You do not have permission to use oauth authorization.", + "code": "permission_denied", + } + with respx.mock: + respx.get(THING).respond(403, json=body) + with ( + KizenClient(load_env_config()) as client, + pytest.raises(KizenAPIError) as exc, + ): + client.get("/api/thing") + assert exc.value.status_code == 403 + assert "OAuth authorizations permission" in exc.value.message + + +def test_403_other_permission_denied_keeps_server_message(ok_helper): + body = { + "detail": "You do not have permission to perform this action.", + "code": "permission_denied", + } + with respx.mock: + respx.get(THING).respond(403, json=body) + with ( + KizenClient(load_env_config()) as client, + pytest.raises(KizenAPIError) as exc, + ): + client.get("/api/thing") + assert exc.value.message == body["detail"] + + +# --- helper failures (i) ---------------------------------------------------- + +FAILURES = { + "nonzero_exit": ( + "sys.stderr.write('login expired\\n'); sys.exit(3)\n", + "exited with status 3", + ), + "nonzero_exit_with_token_on_stdout": ( + _ok_body() + "sys.stderr.write('login expired\\n'); sys.exit(1)\n", + "exited with status 1", + ), + "empty_stdout_exit_0": ( + "sys.stderr.write('login expired\\n')\n", + "printed nothing on stdout", + ), + "not_json": ( + f"print('Bearer {SECRET}'); sys.stderr.write('login expired\\n')\n", + "other than JSON", + ), + "json_not_object": ( + f"print('[\"Bearer {SECRET}\"]'); sys.stderr.write('login expired\\n')\n", + "printed JSON that isn't an object", + ), + "missing_authorization": ( + f"print('{{\"token\": \"{SECRET}\"}}'); sys.stderr.write('login expired\\n')\n", + "no Authorization key", + ), + "not_bearer": ( + f'print(\'{{"Authorization": "Basic {SECRET}"}}\'); ' + "sys.stderr.write('login expired\\n')\n", + "isn't a Bearer token", + ), + "bearer_with_newline": ( + f'print(\'{{"Authorization": "Bearer {SECRET}\\\\nX: y"}}\'); ' + "sys.stderr.write('login expired\\n')\n", + "isn't a Bearer token", + ), + "timeout": ( + f'print(\'{{"Authorization": "Bearer {SECRET}"}}\', flush=True); ' + "sys.stderr.write('login expired\\n'); sys.stderr.flush()\n" + "import time; time.sleep(30)\n", + "timed out after", + ), +} + + +@pytest.mark.parametrize("case", sorted(FAILURES)) +def test_helper_failure_is_clean_error_with_stderr(case, tmp_path, monkeypatch): + """(i) + (a): each failure names the problem and the helper's stderr, and + never the token.""" + body, expected = FAILURES[case] + _helper_env(monkeypatch, _script(tmp_path, body)) + if case == "timeout": + monkeypatch.setattr(auth_helper, "TIMEOUT_S", 1.0) + with respx.mock: + respx.get(THING).respond(json={}) + with ( + KizenClient(load_env_config()) as client, + pytest.raises(AuthHelperError) as exc, + ): + client.get("/api/thing") + text = str(exc.value) + assert expected in text + assert "login expired" in text + assert SECRET not in text + assert SECRET not in repr(exc.value) + + +def test_helper_not_found_is_clean_error(monkeypatch, tmp_path): + """(i) command not found.""" + _helper_env(monkeypatch, tmp_path / "x.py") + monkeypatch.setenv("KIZEN_AUTH_HELPER", str(tmp_path / "no-such-helper")) + with ( + respx.mock, + KizenClient(load_env_config()) as client, + pytest.raises(AuthHelperError, match="was not found"), + ): + client.get("/api/thing") + + +def test_helper_not_executable_is_clean_error(monkeypatch, tmp_path): + """(i) An OSError other than not-found (here EACCES) is a clean error.""" + script = tmp_path / "helper.sh" + script.write_text("#!/bin/sh\necho hi\n") + script.chmod(0o644) + _helper_env(monkeypatch, script) + monkeypatch.setenv("KIZEN_AUTH_HELPER", str(script)) + with ( + respx.mock, + KizenClient(load_env_config()) as client, + pytest.raises(AuthHelperError, match=r"could not be run \(") as exc, + ): + client.get("/api/thing") + assert "was not found" not in str(exc.value) + + +@pytest.mark.parametrize("case", ["nonzero_exit_with_token_on_stdout", "not_bearer"]) +def test_cli_helper_failure_has_no_traceback_or_token(case, tmp_path, monkeypatch): + """(a) + (i) through the CLI: one error line, exit 1, no token anywhere.""" + _helper_env(monkeypatch, _script(tmp_path, FAILURES[case][0])) + monkeypatch.delenv("KIZEN_DEBUG", raising=False) + with respx.mock: + respx.route(url__startswith=BASE).respond(json={"results": [], "next": None}) + result = runner.invoke(app, ["objects", "list"]) + assert result.exit_code == 1 + assert "error: auth helper" in result.output + assert "Traceback" not in result.output + assert SECRET not in result.output + assert SECRET not in repr(result.exception) + + +def test_token_absent_from_output_on_api_failures(ok_helper, monkeypatch): + """(a) 401 twice, 403 and a network error through the CLI never print it.""" + monkeypatch.setattr("time.sleep", lambda s: None) + for response in ( + httpx.Response(401, json={"detail": "nope"}), + httpx.Response(403, json={"detail": "oauth", "code": "permission_denied"}), + httpx.ConnectError("boom"), + ): + auth_helper.clear_cache() + with respx.mock: + route = respx.route(url__startswith=BASE) + if isinstance(response, Exception): + route.mock(side_effect=response) + else: + route.mock(return_value=response) + result = runner.invoke(app, ["objects", "list"]) + assert result.exit_code == 1, result.output + assert SECRET not in result.output + assert SECRET not in repr(result.exception) + + +def test_config_and_auth_repr_hide_secrets(ok_helper): + cfg = load_env_config() + with KizenClient(cfg) as client: + assert SECRET not in repr(client._client.auth) + assert SECRET not in repr(cfg) + profile_cfg = config._load_profile("testenv") + assert "test-api-key" not in repr(profile_cfg) + + +# --- no shell (h) ----------------------------------------------------------- + + +@pytest.mark.parametrize("arg", ["; touch {p}", "$(touch {p})", "`touch {p}`"]) +def test_helper_runs_without_a_shell(tmp_path, monkeypatch, arg): + """Shell syntax in the command reaches the helper as literal argv.""" + marker = tmp_path / "pwned" + argv_log = tmp_path / "argv.json" + script = _script( + tmp_path, + f"import json; open({str(argv_log)!r}, 'w').write(json.dumps(sys.argv[1:]))\n" + + _ok_body(), + ) + _helper_env(monkeypatch, script) + literal = arg.format(p=marker) + monkeypatch.setenv( + "KIZEN_AUTH_HELPER", + f"{shlex.quote(sys.executable)} {shlex.quote(str(script))} {literal}", + ) + with respx.mock: + respx.get(THING).respond(json={}) + with KizenClient(load_env_config()) as client: + client.get("/api/thing") + assert not marker.exists() + assert json.loads(argv_log.read_text()) == shlex.split(literal) + + +# --- commands that manage what app-builder owns ----------------------------- + + +@pytest.mark.parametrize( + ("argv", "message"), + [ + ( + ["upgrade"], + "error: `kizen upgrade` is unavailable here: " + "app-builder manages this installation.", + ), + ( + ["init", "--profile", "x", "--skip-validation"], + "error: `kizen init` is unavailable here: " + "app-builder manages this environment's credentials.", + ), + ( + ["envs", "list"], + "error: `kizen envs list` is unavailable here: " + "app-builder manages the environment.", + ), + ], +) +def test_store_and_install_commands_refuse_in_helper_mode( + ok_helper, argv, message, tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + before = profiles.credentials_path().read_bytes() + result = runner.invoke(app, argv) + assert result.exit_code == 1 + assert result.output.strip() == message + assert profiles.credentials_path().read_bytes() == before + assert not (tmp_path / ".kizen").exists() + assert _calls(tmp_path) == 0 + + +def test_upgrade_check_in_helper_mode_notes_managed_and_exits_0( + ok_helper, tmp_path, monkeypatch +): + """`--check` keeps its always-exit-0 contract: one quiet note, and no + install detection, version lookup, git or network access.""" + from kizen_builder import upgrade as upgrade_mod + + def explode(*args, **kwargs): + raise AssertionError("--check must not look anything up in helper mode") + + monkeypatch.setattr(upgrade_mod, "detect_install", explode) + monkeypatch.setattr(upgrade_mod, "check_latest", explode) + monkeypatch.setattr(upgrade_mod, "run_steps", explode) + monkeypatch.setattr("subprocess.run", explode) + with respx.mock(assert_all_called=False) as router: + any_http = router.route().respond(500) + result = runner.invoke(app, ["upgrade", "--check"]) + assert result.exit_code == 0, result.output + assert result.output.strip() == ("this installation is managed by app-builder.") + assert any_http.call_count == 0 + assert _calls(tmp_path) == 0