From f8f429989383be64741ccf24b92597edad4a7db5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:24:38 +0000 Subject: [PATCH 1/4] fix(config): keep oauth-url link intact and clickable (SUPPORT-17391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Zora Jelínková --- .../commands/_config_oauth.py | 5 +++- tests/test_config_row_cli.py | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/keboola_agent_cli/commands/_config_oauth.py b/src/keboola_agent_cli/commands/_config_oauth.py index 8f6016cd..cc255669 100644 --- a/src/keboola_agent_cli/commands/_config_oauth.py +++ b/src/keboola_agent_cli/commands/_config_oauth.py @@ -94,5 +94,8 @@ def config_oauth_url( 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]") + url = result["url"] + # soft_wrap keeps the URL a single line (no newlines inserted into it), + # link= makes the whole URL the click target in OSC-8 terminals. + formatter.console.print(f"[link={url}]{escape(url)}[/link]", soft_wrap=True) formatter.console.print("\n[dim]Open this URL in a browser and grant access.[/dim]") diff --git a/tests/test_config_row_cli.py b/tests/test_config_row_cli.py index 8af879fa..4bb8ecb4 100644 --- a/tests/test_config_row_cli.py +++ b/tests/test_config_row_cli.py @@ -644,6 +644,36 @@ 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 + # --------------------------------------------------------------------------- # is_disabled / is_enabled CLI tests From e52a4f6eadef106b466ac6f066d20f23b6a5ca6f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:41:55 +0000 Subject: [PATCH 2/4] fix(config): add --open to launch the oauth-url link directly (SUPPORT-17391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Zora Jelínková --- .../commands/_config_oauth.py | 33 ++++++++-- tests/test_config_row_cli.py | 62 +++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/keboola_agent_cli/commands/_config_oauth.py b/src/keboola_agent_cli/commands/_config_oauth.py index cc255669..50f7ce34 100644 --- a/src/keboola_agent_cli/commands/_config_oauth.py +++ b/src/keboola_agent_cli/commands/_config_oauth.py @@ -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 @@ -52,6 +53,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. @@ -62,6 +68,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 @@ -87,15 +96,27 @@ 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" ) - url = result["url"] - # soft_wrap keeps the URL a single line (no newlines inserted into it), - # link= makes the whole URL the click target in OSC-8 terminals. - formatter.console.print(f"[link={url}]{escape(url)}[/link]", soft_wrap=True) - formatter.console.print("\n[dim]Open this URL in a browser and grant access.[/dim]") + # soft_wrap keeps the URL on one logical line (Rich otherwise inserts real + # newlines into it), highlight=False keeps it a single OSC-8 anchor instead of + # one anchor per highlighted URL part, and link= is the click target. + formatter.console.print( + f"[link={url}]{escape(url)}[/link]", soft_wrap=True, highlight=False + ) + if opened: + formatter.console.print("\n[dim]Opened in your default browser.[/dim]") + else: + formatter.console.print( + "\n[dim]Open this URL in a browser and grant access." + " Terminals that wrap the link may only follow the first line --" + " re-run with [/dim][cyan]--open[/cyan][dim] to launch it directly.[/dim]" + ) diff --git a/tests/test_config_row_cli.py b/tests/test_config_row_cli.py index 4bb8ecb4..124fed0b 100644 --- a/tests/test_config_row_cli.py +++ b/tests/test_config_row_cli.py @@ -674,6 +674,68 @@ def test_human_output_prints_url_unwrapped(self, tmp_config_dir: Path, columns: assert result.exit_code == 0, result.output assert OAUTH_RESULT["url"] 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 From a6fce35e4b55df84c197ce1dabdf4ad4b0a5f806 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:43:37 +0000 Subject: [PATCH 3/4] fix(config): anchor the oauth-url hyperlink to a short label (SUPPORT-17391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Zora Jelínková --- .../commands/_config_oauth.py | 19 ++++++++++--------- tests/test_config_row_cli.py | 1 + 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/keboola_agent_cli/commands/_config_oauth.py b/src/keboola_agent_cli/commands/_config_oauth.py index 50f7ce34..b52790e4 100644 --- a/src/keboola_agent_cli/commands/_config_oauth.py +++ b/src/keboola_agent_cli/commands/_config_oauth.py @@ -106,17 +106,18 @@ def config_oauth_url( f"[bold]OAuth URL for[/bold] [cyan]{escape(component_id)}[/cyan]/" f"[cyan]{escape(config_id)}[/cyan]:\n" ) - # soft_wrap keeps the URL on one logical line (Rich otherwise inserts real - # newlines into it), highlight=False keeps it a single OSC-8 anchor instead of - # one anchor per highlighted URL part, and link= is the click target. - formatter.console.print( - f"[link={url}]{escape(url)}[/link]", soft_wrap=True, highlight=False - ) + # 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 this URL in a browser and grant access." - " Terminals that wrap the link may only follow the first line --" - " re-run with [/dim][cyan]--open[/cyan][dim] to launch it directly.[/dim]" + "\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]" ) diff --git a/tests/test_config_row_cli.py b/tests/test_config_row_cli.py index 124fed0b..80b12ec8 100644 --- a/tests/test_config_row_cli.py +++ b/tests/test_config_row_cli.py @@ -673,6 +673,7 @@ def test_human_output_prints_url_unwrapped(self, tmp_config_dir: Path, columns: 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.""" From c0dc28c9c814d1f311145c407128ee564d7f4e63 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:55:52 +0000 Subject: [PATCH 4/4] fix(kai): soft-wrap answer output; document oauth-url --open for chat agents (SUPPORT-17391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Zora Jelínková --- .../kbagent/references/commands-reference.md | 2 +- .../skills/kbagent/references/gotchas.md | 22 +++++++++++++++++++ .../commands/_config_oauth.py | 4 +++- src/keboola_agent_cli/commands/kai.py | 8 ++++--- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index efd7ba75..10836aa5 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -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. diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 96820b5c..e69a6695 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -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=]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 diff --git a/src/keboola_agent_cli/commands/_config_oauth.py b/src/keboola_agent_cli/commands/_config_oauth.py index b52790e4..75b9afe9 100644 --- a/src/keboola_agent_cli/commands/_config_oauth.py +++ b/src/keboola_agent_cli/commands/_config_oauth.py @@ -28,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( diff --git a/src/keboola_agent_cli/commands/kai.py b/src/keboola_agent_cli/commands/kai.py index 34345b4c..3fbdf3a5 100644 --- a/src/keboola_agent_cli/commands/kai.py +++ b/src/keboola_agent_cli/commands/kai.py @@ -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) @@ -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) @@ -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)