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
12 changes: 9 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,12 +511,18 @@ kbagent feature user-add --project ALIAS --email EMAIL --feature NAME [--dry-run
kbagent feature user-remove --project ALIAS --email EMAIL --feature NAME [--dry-run] [--yes]

# token: scoped Storage tokens (Keboola single-bucket-write pattern; acting token needs canManageTokens; secret shown once).
kbagent token list --project NAME
kbagent token create --project NAME --description DESC [--bucket-write BUCKET ...] [--bucket-read BUCKET ...] [--component-access ID ...] [--can-read-all-file-uploads] [--expires-in N]
kbagent token delete --project NAME --token-id ID [--yes]
kbagent token refresh --project NAME --token-id ID [--yes]
# SDK (importable Client(url,token)) now exposes create_scoped_token / delete_token / refresh_token /
# create_stream_source / get_stream_source / list_stream_sources / delete_stream_source: dicts on .raw,
# typed ScopedTokenResult / StreamSourceResult on the facade. See docs/sdk.md.
# `token list` (issue #599): GET /v2/storage/tokens -- the only way to see what already exists and to
# get the id `delete`/`refresh` need, without the web UI. Secrets are STRIPPED from every row: a
# project with the `force-decrypted-token` feature has the API embed live values in the listing, and
# echoing those would break the group's "revealed once, at mint" contract for every token at once.
# SDK (importable Client(url,token)) now exposes create_scoped_token / list_tokens / delete_token /
# refresh_token / create_stream_source / get_stream_source / list_stream_sources /
# delete_stream_source: dicts on .raw, typed ScopedTokenResult / TokenListEntryResult /
# StreamSourceResult on the facade. See docs/sdk.md.

# permissions: session write/destructive firewall. The top-level --deny-writes / --deny-destructive
# flags are the one-shot form; `permissions set` persists a policy (mode allow|deny + allow/deny patterns
Expand Down
9 changes: 6 additions & 3 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,10 @@ print(res.imported_rows, res.warnings)

Seven methods for the "provision an OTLP ingest endpoint, then mint a narrowly-scoped Storage token a device can hold" flow. They live on the facade and delegate straight to `KeboolaClient`; the token/stream ones return the typed models in [§5](#5-typed-result-models).

**Scoped Storage tokens** — mint, revoke, rotate:
**Scoped Storage tokens** — mint, list, revoke, rotate:

- **`create_scoped_token(*, description, bucket_permissions=None, component_access=None, can_read_all_file_uploads=False, expires_in=None) -> ScopedTokenResult`** — `POST /v2/storage/tokens`. `bucket_permissions` is `{bucket_id: "read"|"write"}`; `expires_in` is seconds. The acting token must carry **`canManageTokens`** or the create 403s.
- **`list_tokens() -> list[TokenListEntryResult]`** — `GET /v2/storage/tokens` (`0.86.0+`). Where the `token_id` for `delete_token` / `refresh_token` comes from. **Secrets are stripped before validation**: a project carrying the `force-decrypted-token` feature has the API embed live values in the listing, and `create_scoped_token` is meant to be the only reveal. The acting token needs `canManageTokens`.
- **`delete_token(token_id) -> None`** — `DELETE /v2/storage/tokens/{id}` (204, no body). Revokes.
- **`refresh_token(token_id) -> ScopedTokenResult`** — `POST .../tokens/{id}/refresh`. Rotates the secret in place; the returned `.token` is the new secret.

Expand Down Expand Up @@ -236,7 +237,7 @@ Escape hatch for endpoints the facade omits. See [§7](#7-clientraw-the-escape-h

## 5. Typed result models

`result_models.py` defines the **stable return shapes** (`JobResult`, `QueryResult`, `UploadTableResult`, `ConfigDetailResult`, `SyncPushResult`, `CloneResult`, and the `0.66.0+` device-enrollment pair `ScopedTokenResult` / `StreamSourceResult`), all re-exported from the package root. They exist so a downstream consumer types against a **semver-versioned contract** instead of an undocumented `dict[str, Any]` — a contract change then surfaces at *type-check* time, not at runtime against a customer build.
`result_models.py` defines the **stable return shapes** (`JobResult`, `QueryResult`, `UploadTableResult`, `ConfigDetailResult`, `SyncPushResult`, `CloneResult`, the `0.66.0+` device-enrollment pair `ScopedTokenResult` / `StreamSourceResult`, and `TokenListEntryResult` from `0.86.0+`), all re-exported from the package root. They exist so a downstream consumer types against a **semver-versioned contract** instead of an undocumented `dict[str, Any]` — a contract change then surfaces at *type-check* time, not at runtime against a customer build.

Two design rules every model follows (`_ApiResultModel` base):

Expand All @@ -257,7 +258,9 @@ The two device-enrollment models (`0.66.0+`) commit these named fields:
- **`ScopedTokenResult`** — `id`, `token` (the one-time secret, see the gotcha in §4), `description`, `expires` (`str | None`), `can_read_all_file_uploads` (alias `canReadAllFileUploads`).
- **`StreamSourceResult`** — `id`, `source_id`, `name`, `type`, `description`, `branch_id` (default `"default"`), `otlp_url` (ingest URL, secret in the path — unmasked), `otlp_secret`, `base_endpoint`, `sink_bucket_id` (`str | None`; the `in.c-otlp-<id>` bucket to grant a device token write on).

> **The committed surface is `__all__`.** Anything exported from `keboola_agent_cli` (`Client`, `Files`, `FileEntry`, the six result models, `JobIdempotencyStore`, `__version__`) is public API under semver. Renaming or removing a named field, or tightening a type, is a breaking change.
`TokenListEntryResult` (`0.86.0+`) commits `id`, `description`, `created` (`str | None`), `expires` (`str | None`), `is_expired` (alias `isExpired`), `is_master_token` (alias `isMasterToken`). It has **no secret field by design** — see `list_tokens` above.

> **The committed surface is `__all__`.** Anything exported from `keboola_agent_cli` (`Client`, `Files`, `FileEntry`, the result models, `JobIdempotencyStore`, `__version__`) is public API under semver. Renaming or removing a named field, or tightening a type, is a breaking change.

---

Expand Down
11 changes: 11 additions & 0 deletions plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,17 @@ read it when a trigger fires. Each `(X.Y.Z+)` tag is the version floor.
list` flags them). No migration command -- you do the argv mapping. Tool->command
map in `docs/mcp-migration.md`; recipe in gotchas.md.

**A write that failed with a 5xx**
- `POST`/`PATCH` is NOT retried on 5xx any more (0.86.0+); `retryable: false`
there is deliberate -- never wrap it in your own retry loop. The work may have
landed: check with `token list` / `job list` before repeating. The message
carries the `exceptionId` -- quote it when escalating. gotchas.md.

**Finding an existing Storage token**
- `kbagent token list -p P` (0.86.0+) -- the only source of the `--token-id`
that `token delete`/`refresh` need. Secrets are stripped from every row,
`--json` included; do not route around it with `kbagent http get`.

**Upgrading kbagent itself**
- `install_channel` in `kbagent --json version` => native binary; `kbagent
update` REFUSES by design. Quote `upgrade_command` (choco/winget/brew/apt/dnf);
Expand Down
1 change: 1 addition & 0 deletions plugins/kbagent/skills/kbagent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ When working inside a git repository or project directory, run `kbagent init` (o
| Enable a feature flag on a user | `kbagent feature user-add --project PROJECT --email EMAIL --feature FEATURE` |
| Disable a feature flag on a user (destructive) | `kbagent feature user-remove --project PROJECT --email EMAIL --feature FEATURE` |
| Mint a scoped Storage API token (secret shown once) | `kbagent token create --project PROJECT --description DESCRIPTION` |
| List the project's Storage API tokens (no secrets -- those are mint-only) | `kbagent token list --project PROJECT` |
| Revoke a Storage API token immediately (destructive; only non-master tokens) | `kbagent token delete --project PROJECT --token-id TOKEN-ID` |
| Rotate a token: generate a new value and invalidate the old one (secret shown once) | `kbagent token refresh --project PROJECT --token-id TOKEN-ID` |
| Show the current PAYG credit balance for one or more projects | `kbagent billing credits` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ Uses the per-project Storage token (no manage token). Control plane = `stream.<r

## Scoped Storage Tokens (since v0.66.0)
Mint, revoke, and rotate scoped Storage API tokens (the Keboola single-bucket-write pattern -- a device/component gets a token that can write to exactly one bucket). Uses the per-project Storage token from config (no manage token); the acting token must have the `canManageTokens` privilege. The token secret is displayed **once** on create/refresh and is never retrievable again. The importable SDK `Client(url, token)` mirrors this surface: `create_scoped_token` / `delete_token` / `refresh_token` (plus the stream-source primitives `create_stream_source` / `get_stream_source` / `list_stream_sources` / `delete_stream_source`) -- dicts on `.raw`, typed `ScopedTokenResult` / `StreamSourceResult` on the facade. See `sdk.md`.
- `token list --project NAME` -- list the project's tokens (GET `/v2/storage/tokens`): id, description, created, expires (with an expired marker), master flag, and the token that created each one. This is how you find the `--token-id` that `delete` / `refresh` need. Secret values are stripped from every row before output, including under `--json` -- on a project carrying the `force-decrypted-token` feature the API embeds live secrets in the listing, and reproducing them would break the "revealed once, at mint" rule for every token at once. (since v0.86.0)
- `token create --project NAME --description DESC [--bucket-write BUCKET ...] [--bucket-read BUCKET ...] [--component-access ID ...] [--can-read-all-file-uploads] [--expires-in N]` -- create a scoped token. `--bucket-write` / `--bucket-read` (both repeatable) grant per-bucket permissions; permissions are built read-first then write, so a bucket listed in both ends up writable. `--component-access` (repeatable) restricts the token to named components. `--expires-in N` sets a TTL in seconds. The secret is printed ONCE inside a Rich Panel.
- `token delete --project NAME --token-id ID [--yes]` -- revoke a token by its numeric id (DELETE `/v2/storage/tokens/{id}` -> 204). Destructive; confirms via prompt unless `--yes` or `--json`.
- `token refresh --project NAME --token-id ID [--yes]` -- rotate a token's secret (POST `.../refresh`); the new secret is printed ONCE. Confirms unless `--yes` or `--json`.
Expand Down
62 changes: 62 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -3709,3 +3709,65 @@ mapping in favor of `changed_since: adaptive`, which tracks a
assumption: here the empty state is the expensive, surprising path, and a
seeded checkpoint is the conservative one. Do not assume "no state = safe
default" when adaptive is involved.

## A failed POST/PATCH is no longer retried -- and the error now says what to do (since v0.86.0)

kbagent used to retry every request on `429/500/502/503/504`, regardless of
HTTP method. That is safe for a read and unsafe for a write, because a 5xx does
not mean "nothing happened":

- **Keboola's token mint persists the token row before the step that can
throw.** `POST /v2/storage/tokens` saves the new token, generates its secret,
and only then resolves bucket permissions -- and that last block is outside
any transaction. A 500 raised there leaves a live token behind that the
caller never saw, so three attempts could leave three of them. This is what
[issue #599](https://github.com/keboola/cli/issues/599) reported from the
`europe-west3.gcp` stack.
- The same hazard applies to every other non-idempotent call: `job run`,
`job terminate`, `config new`, `flow new`, `config oauth-url` (which mints a
token of its own), `data-app deploy`.

What changed:

- **5xx is retried only on `GET/HEAD/OPTIONS/PUT/DELETE`.** A `POST`/`PATCH`
answered with a 5xx fails on the first attempt.
- **429 is still retried on every method** -- the server is stating it did not
process the request, so repeating it is safe.
- **Transport failures are split by what they prove.** A refused connection
(`ConnectError`) or a connect/pool timeout never delivered the request and is
still retried on any method. A read/write timeout means the request was sent
and the outcome is unknown, so it is not retried on a `POST`/`PATCH`.

Two consequences for an agent reading an error:

1. **`retryable: false` on a 500 from a write is deliberate, not a
misclassification.** Do not paper over it with a retry loop of your own. The
correct next step is to check what the server already did -- for a token
mint, `kbagent token list`; for a job, `kbagent job list`.
2. **A 5xx message now carries the `exceptionId`** the Keboola API returns
alongside its generic `"Application error."`, plus one of two hints: the
request was not retried because the method is not idempotent, or the same
5xx survived all 3 attempts and is therefore an upstream incident. Quote the
`exceptionId` when escalating to Keboola support -- it is the only handle
that traces back to the actual server-side exception.

## `token list` is the only way to see what exists -- and it never shows secrets (since v0.86.0)

`kbagent token list --project P` (`GET /v2/storage/tokens`) closes the gap where
the `token` group could mint, revoke, and rotate but not enumerate -- so there
was no way to obtain the `--token-id` that `delete` / `refresh` require without
the web UI.

- **Secrets are stripped from every row, including under `--json`.** On a
project carrying the `force-decrypted-token` feature the Storage API embeds
each token's live value in the listing. kbagent removes it at the service
layer (and in the SDK facade) before anything is rendered: `token create` /
`token refresh` are the only reveals, and a listing that dumped live values
would break that contract for every token in the project at once. Do not
reach for `kbagent http get` to work around this.
- **It needs `canManageTokens`, same as `create`.** A plain Storage token gets
a 403 -> `ACCESS_DENIED`.
- The master token appears in the listing with `isMasterToken: true` and cannot
be deleted -- the API refuses.
- SDK parity: `Client.list_tokens() -> list[TokenListEntryResult]`, secrets
stripped there too.
2 changes: 2 additions & 0 deletions src/keboola_agent_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ScopedTokenResult,
StreamSourceResult,
SyncPushResult,
TokenListEntryResult,
UploadTableResult,
)
from .services.job_idempotency_store import JobIdempotencyStore
Expand All @@ -33,6 +34,7 @@
"ScopedTokenResult",
"StreamSourceResult",
"SyncPushResult",
"TokenListEntryResult",
"UploadTableResult",
"__version__",
]
25 changes: 21 additions & 4 deletions src/keboola_agent_cli/auth/auth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,18 +770,35 @@ def _extract_error_message(response: httpx.Response) -> str:
# Shared error mapping
# ------------------------------------------------------------------

def _raise_api_error(self, response: httpx.Response, base_url: str | None = None) -> None:
def _raise_api_error(
self,
response: httpx.Response,
base_url: str | None = None,
*,
hint: str | None = None,
retryable: bool | None = None,
) -> None:
"""Escalate a 404 before falling back to the shared error mapping.

`BaseHttpClient._do_request` calls this method for every
`_do_request`-based call in this client, so overriding it here
(rather than adding a check in each method) covers all of them at
once. `poll_device_token` bypasses `_do_request` entirely and calls
`_map_auth_error` directly for the same 404 case.

`hint` / `retryable` are the base class's 5xx guidance; they are
forwarded untouched so an auth-endpoint 500 reads the same as any
other (issue #599). A 404 is escalated before they matter.
"""
self._map_auth_error(response)
self._map_auth_error(response, hint=hint, retryable=retryable)

def _map_auth_error(self, response: httpx.Response) -> NoReturn:
def _map_auth_error(
self,
response: httpx.Response,
*,
hint: str | None = None,
retryable: bool | None = None,
) -> NoReturn:
"""Map a failed auth-endpoint response, escalating 404 to a dedicated code.

`login_password`/`verify_mfa_totp` never reach this method -- both
Expand Down Expand Up @@ -812,7 +829,7 @@ def _map_auth_error(self, response: httpx.Response) -> NoReturn:
error_code=ErrorCode.AUTH_NOT_SUPPORTED_ON_STACK,
retryable=False,
)
super()._raise_api_error(response, self._base_url)
super()._raise_api_error(response, self._base_url, hint=hint, retryable=retryable)
# BaseHttpClient._raise_api_error always raises; the static type
# checker cannot see that across the base-class call, so make the
# divergence explicit rather than relax this method's NoReturn type.
Expand Down
22 changes: 22 additions & 0 deletions src/keboola_agent_cli/client/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,28 @@ def create_scoped_token(
response = self._request("POST", "/v2/storage/tokens", data=data)
return response.json()

def list_tokens(self) -> list[dict[str, Any]]:
"""List the project's Storage API tokens (``GET /v2/storage/tokens``).

Returns the API's array verbatim -- one dict per token, carrying ``id``,
``description``, ``created``, ``expires``, ``isExpired``,
``isMasterToken``, the ``can*`` grants, ``bucketPermissions`` and (when
the token was minted by another token) ``creatorToken``.

The acting token must carry ``canManageTokens``; the API answers 403
otherwise (surfaced as ``ACCESS_DENIED``).

A **secret is never listed here as a rule, but the API is not a
guarantee**: on a project carrying the ``force-decrypted-token``
feature the response embeds each token's live value in a ``token``
field. Callers that render this must strip it -- ``TokenService``
does. Anything past the documented array shape (an envelope object,
say) degrades to an empty list rather than blowing up in the caller.
"""
response = self._request("GET", "/v2/storage/tokens")
payload = response.json()
return payload if isinstance(payload, list) else []

def delete_token(self, token_id: str) -> None:
"""Revoke a Storage API token immediately (``DELETE /v2/storage/tokens/{id}``).

Expand Down
4 changes: 4 additions & 0 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,10 @@

### Scoped Storage Tokens

kbagent token list --project NAME
List the project's Storage API tokens (id, description, created, expires, master flag,
creating token). Secrets are never listed -- `token create` is the only reveal. This is where
the --token-id for delete/refresh comes from. Acting token needs canManageTokens.
kbagent token create --project NAME --description DESC [--bucket-write BUCKET ...] [--bucket-read BUCKET ...] [--component-access ID ...] [--can-read-all-file-uploads] [--expires-in N]
Create a scoped Storage API token (Keboola single-bucket-write pattern). --bucket-write /
--bucket-read (repeatable) grant per-bucket write/read; write wins when a bucket is on both.
Expand Down
Loading