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 8f6016cd..75b9afe9 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 @@ -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( @@ -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. @@ -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 @@ -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]" + ) 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) diff --git a/tests/test_config_row_cli.py b/tests/test_config_row_cli.py index 8af879fa..80b12ec8 100644 --- a/tests/test_config_row_cli.py +++ b/tests/test_config_row_cli.py @@ -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