Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Requires a **super-admin** Manage API token (same kind as `org setup`). Same def
- `config row-create --project NAME --component-id ID --config-id ID --name ROW_NAME [--description D] [--configuration JSON|@file|-] [--is-disabled] [--branch ID] [--allow-plaintext-on-encrypt-failure]` -- create a new configuration row. Returns the full row dict with `id`, `name`, `version`. Optional `--configuration` accepts JSON inline, `@file`, or stdin (`-`). `#`-prefixed secrets auto-encrypt before write (fail-closed; since 0.54.0, #378).
- `config row-update --project NAME --component-id ID --config-id ID --row-id ID [--name N] [--description D] [--configuration JSON|@file|-] [--change-description TEXT] [--is-disabled | --is-enabled] [--branch ID] [--allow-plaintext-on-encrypt-failure]` -- update an existing configuration row. Pass only the fields you want to change; omitted fields are preserved. `--is-disabled` / `--is-enabled` toggle the row's enabled state. `--change-description` sets the new row version's `changeDescription` audit line (default: auto-generated). `#`-prefixed secrets auto-encrypt before write (fail-closed; since 0.54.0, #378).
- `config row-delete --project NAME --component-id ID --config-id ID --row-id ID [--branch ID] [--yes]` -- delete a configuration row. Destructive (gated behind `--allow-destructive`). Branch-aware. Without `--yes` and outside `--json` mode, prompts for interactive confirmation; `--json` mode auto-skips the prompt.
- `config oauth-url --project NAME --component-id ID --config-id ID [--redirect-url URL]` -- return the OAuth authorization URL for a component that uses OAuth authentication. **Requires a master Storage API token** (canManageTokens privilege) -- non-master tokens fail with `MISSING_MASTER_TOKEN` exit 3 on a fail-fast pre-flight check before any HTTP write happens. Open the URL in a browser to complete the OAuth flow.
- `config oauth-url --project NAME --component-id ID --config-id ID [--redirect-url URL] [--open]` -- return the OAuth authorization URL for a component that uses OAuth authentication. **Requires a master Storage API token** (canManageTokens privilege) -- non-master tokens fail with `MISSING_MASTER_TOKEN` exit 3 on a fail-fast pre-flight check before any HTTP write happens. Open the URL in a browser to complete the OAuth flow. The URL is ~200 chars and never fits one terminal row, so human output makes a short `Authorize in browser` label the clickable (OSC-8) target and prints the URL itself on one unwrapped line; `--open` launches it in the default browser instead. **Never re-print the URL into a chat transcript** -- renderers that wrap it turn the visible link into the first row only, and a truncated `config_id` makes the wizard answer `Failed to load config data`. Pass `--open`, or hand the user the URL from `--json` (`data.url`) as a single unwrapped line.
- `config state-get --project NAME --component-id ID --config-id ID [--row-id ID] [--branch ID]` -- (since 0.84.2, #593) read a configuration's runtime `state`. Without `--row-id` returns the root config's state; with `--row-id` returns that row's state (a missing row id fails loudly, it does not silently return `{}`). For row-based components the root state node is unused -- read the row state instead. See [config-state-workflow](references/config-state-workflow.md).
- `config state-set --project NAME --component-id ID --config-id ID [--row-id ID] --state JSON|@file|- [--branch ID] [--dry-run] [--yes]` -- (since 0.84.2, #593) write a configuration's runtime `state` via the dedicated branch-scoped `PUT .../state` endpoint. `--state` must be a JSON object (not an array/scalar) under 4 MB. `--row-id` targets a row's state instead of the root. `--dry-run` previews the current-vs-new diff (same shape as `config update --dry-run`) without writing; a no-op (new state equals current) short-circuits with `changed: false` and no write. Guarded write: prompts for confirmation unless `--yes` or `--json`. This is the fix for `config update --set 'state...'`, which never reached runtime state (see [gotchas](references/gotchas.md)). See [config-state-workflow](references/config-state-workflow.md) for the seed-before-migrate playbook.

Expand Down
22 changes: 22 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -3181,6 +3181,28 @@ requires a **master (admin) token** — `canManageTokens` alone is not enough
working credential here. `token create` carries the same guard since
v0.89.0 (`token refresh` does not — that endpoint has no such defect).

## A pasted OAuth wizard URL is only as long as one terminal row (SUPPORT-17391)

The `config oauth-url` URL is ~200 chars (`token` + encoded `sapiUrl` + the
`#/component/config` fragment). It fits no terminal row, and two independent
layers then truncate it:

- **Rich layout.** `console.print(url)` inserts REAL newlines at the console
width, so a copy of one visual row silently loses the tail. Fixed in the
command by `soft_wrap=True`; anything else printing a URL through Rich
(chat/answer renderers included) needs the same flag.
- **Per-row link detection.** Terminals and chat renderers that autodetect URLs
scope detection to one visual row, so a wrapped URL becomes a link to its
first row only. `token` and `sapiUrl` survive (they come first), the
`config_id` in the fragment does not — the wizard authenticates and then
answers `Failed to load config data. Please contact us on support@keboola.com`,
which reads like a broken OAuth flow but is a truncated URL.

The clickable target must therefore be a SHORT label carrying the URL as an
OSC-8 target (`[link=<url>]Authorize in browser[/link]`), never the URL text.
For agents: run `config oauth-url --open` (opens the default browser via
`auth.environment.open_browser`) instead of pasting the link into a transcript.

## `data-app logs` is the only unconstrained log surface (since v0.43.8)

- The upstream `keboola-mcp-server` `get_data_apps` MCP tool hardcodes a
Expand Down
35 changes: 31 additions & 4 deletions src/keboola_agent_cli/commands/_config_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import typer
from rich.markup import escape

from ..auth.environment import open_browser
from ..errors import ConfigError, ErrorCode, KeboolaApiError
from ._helpers import get_formatter, get_service, map_error_to_exit_code

Expand All @@ -27,7 +28,9 @@ def register(app: typer.Typer) -> None:
"oauth-url",
rich_help_panel="OAuth",
help=(
"Requires master token. Generate an OAuth authorization URL for a component configuration."
"Requires master token. Generate an OAuth authorization URL for a component "
"configuration; --open launches it (the URL is too long to survive being "
"pasted into a wrapping chat transcript)."
),
)
def config_oauth_url(
Expand All @@ -52,6 +55,11 @@ def config_oauth_url(
"--redirect-url",
help="Optional URL to return to after the OAuth flow completes (sets returnUrl query param)",
),
open_url: bool = typer.Option(
False,
"--open",
help="Open the authorization URL in the default browser instead of only printing it",
),
) -> None:
"""Generate an OAuth authorization URL for a component configuration.

Expand All @@ -62,6 +70,9 @@ def config_oauth_url(
Examples:
kbagent config oauth-url --project P --component-id keboola.ex-google-drive --config-id ID

# Open the link straight in the default browser (no copy/paste)
kbagent config oauth-url --project P --component-id keboola.ex-google-drive --config-id ID --open

# Redirect back to a custom URL after the OAuth flow completes
kbagent config oauth-url --project P --component-id keboola.ex-google-drive --config-id ID \\
--redirect-url https://example.com/oauth-done
Expand All @@ -87,12 +98,28 @@ def config_oauth_url(
)
raise typer.Exit(code=map_error_to_exit_code(exc)) from None

url = result["url"]
opened = open_browser(url) if open_url else False

if formatter.json_mode:
formatter.output(result)
formatter.output({**result, "opened_in_browser": opened} if open_url else result)
else:
formatter.console.print(
f"[bold]OAuth URL for[/bold] [cyan]{escape(component_id)}[/cyan]/"
f"[cyan]{escape(config_id)}[/cyan]:\n"
)
formatter.console.print(f" [link]{result['url']}[/link]")
formatter.console.print("\n[dim]Open this URL in a browser and grant access.[/dim]")
# The URL is ~200 chars, so it never fits one terminal row. The click target
# is therefore a short label that cannot wrap -- terminals that scope link
# detection to a single visual row would otherwise follow only the first row
# of a wrapped URL and hand the browser a truncated config id. The URL itself
# is printed plain with soft_wrap so Rich inserts no newlines into it and a
# copy (or a line-based parser) always gets it whole.
formatter.console.print(f"[link={url}]Authorize in browser[/link] [dim](click)[/dim]")
formatter.console.print(url, soft_wrap=True, highlight=False, markup=False)
if opened:
formatter.console.print("\n[dim]Opened in your default browser.[/dim]")
else:
formatter.console.print(
"\n[dim]Open the link in a browser and grant access, or re-run with"
" [/dim][cyan]--open[/cyan][dim] to launch it directly.[/dim]"
)
8 changes: 5 additions & 3 deletions src/keboola_agent_cli/commands/kai.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ def kai_ask(
raise typer.Exit(code=map_error_to_exit_code(exc)) from None

def _human(console, data):
console.print(data["response"])
# soft_wrap: Rich would otherwise insert real newlines into long lines, which
# breaks any URL the answer carries (a copied fragment stops being a valid link).
console.print(data["response"], soft_wrap=True)

formatter.output(result, _human)

Expand Down Expand Up @@ -141,7 +143,7 @@ def kai_chat(
raise typer.Exit(code=map_error_to_exit_code(exc)) from None

def _human(console, data):
console.print(data["response"])
console.print(data["response"], soft_wrap=True)
console.print(f"\n[dim]Chat ID: {data['chat_id']}[/dim]")

formatter.output(result, _human)
Expand Down Expand Up @@ -243,7 +245,7 @@ def _human(console, data):
role = msg["role"]
style = "cyan" if role == "user" else "green"
console.print(f"\n[bold {style}]{role}:[/bold {style}]")
console.print(msg["content"])
console.print(msg["content"], soft_wrap=True)

formatter.output(result, _human)

Expand Down
93 changes: 93 additions & 0 deletions tests/test_config_row_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,99 @@ def test_redirect_url_propagates(self, tmp_config_dir: Path) -> None:
output = json.loads(result.output)
assert output["data"]["redirect_url"] == "https://example.com/done"

@pytest.mark.parametrize("columns", ["40", "80", "120"])
def test_human_output_prints_url_unwrapped(self, tmp_config_dir: Path, columns: str) -> None:
"""Human mode keeps the complete URL intact at any terminal width."""
service = self._make_oauth_service(tmp_config_dir)

with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"keboola_agent_cli.commands._config_oauth.get_service",
lambda ctx, name: service,
)
result = runner.invoke(
app,
[
"--config-dir",
str(tmp_config_dir),
"config",
"oauth-url",
"--project",
"prod",
"--component-id",
"keboola.ex-google-drive",
"--config-id",
"cfg-001",
],
env={"COLUMNS": columns, "TERM": "dumb"},
)

assert result.exit_code == 0, result.output
assert OAUTH_RESULT["url"] in result.output
assert "Authorize in browser" in result.output

def test_open_flag_launches_browser(self, tmp_config_dir: Path) -> None:
"""--open hands the complete URL to the browser opener."""
service = self._make_oauth_service(tmp_config_dir)
opened: list[str] = []

def _record(url: str) -> bool:
opened.append(url)
return True

with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"keboola_agent_cli.commands._config_oauth.get_service",
lambda ctx, name: service,
)
mp.setattr("keboola_agent_cli.commands._config_oauth.open_browser", _record)
result = _invoke(
tmp_config_dir,
"oauth-url",
[
"--project",
"prod",
"--component-id",
"keboola.ex-google-drive",
"--config-id",
"cfg-001",
"--open",
],
)

assert result.exit_code == 0, result.output
assert opened == [OAUTH_RESULT["url"]]
assert json.loads(result.output)["data"]["opened_in_browser"] is True

def test_no_open_flag_does_not_launch_browser(self, tmp_config_dir: Path) -> None:
"""Without --open the browser is never touched and JSON stays unchanged."""
service = self._make_oauth_service(tmp_config_dir)

def _fail(url: str) -> bool:
raise AssertionError("browser must not be opened without --open")

with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"keboola_agent_cli.commands._config_oauth.get_service",
lambda ctx, name: service,
)
mp.setattr("keboola_agent_cli.commands._config_oauth.open_browser", _fail)
result = _invoke(
tmp_config_dir,
"oauth-url",
[
"--project",
"prod",
"--component-id",
"keboola.ex-google-drive",
"--config-id",
"cfg-001",
],
)

assert result.exit_code == 0, result.output
assert "opened_in_browser" not in json.loads(result.output)["data"]


# ---------------------------------------------------------------------------
# is_disabled / is_enabled CLI tests
Expand Down