Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <activity>`** copies an activity type, and
**`dashboards duplicate <dashboard>`** copies a dashboard with its
dashlets. `--name` names the copy, and `dashboards duplicate` also takes
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 12 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
57 changes: 46 additions & 11 deletions src/kizen_builder/api/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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})
Expand All @@ -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 ------------------------------------------------
Expand Down Expand Up @@ -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 = (
Expand All @@ -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}; "
Expand Down
153 changes: 153 additions & 0 deletions src/kizen_builder/auth_helper.py
Original file line number Diff line number Diff line change
@@ -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 <token>"}``, 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 `<name>` <problem>``, 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
2 changes: 2 additions & 0 deletions src/kizen_builder/cli/envs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()

Expand Down
5 changes: 3 additions & 2 deletions src/kizen_builder/cli/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion src/kizen_builder/cli/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading