diff --git a/CLAUDE.md b/CLAUDE.md index a1ffd3cf6..12b5cbb05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -880,7 +880,7 @@ kbagent workspace from-transformation --project ALIAS --component-id ID --config kbagent data-app list [--project NAME ...] [--branch ID] kbagent data-app detail --project NAME --app-id ID [--branch ID] -kbagent data-app create --project ALIAS --name NAME --slug SLUG (--git-repo URL | --use-managed-git-repo) [--description STR | --description-file PATH] [--git-branch main] [--git-public/--no-git-public] [--git-username USER] [--git-pat-env VAR | --git-pat-file PATH | --git-pat-encrypted KBC::Project...] [--auth password|public] [--size tiny|small|medium|large] [--auto-suspend SECONDS] [--type python-js|python|streamlit|r|...] [--workspace/--no-workspace] [--branch ID] [--no-deploy] [--wait] [--timeout SECONDS] [--keep-on-failure] [--dry-run] +kbagent data-app create --project ALIAS --name NAME --slug SLUG (--git-repo URL | --use-managed-git-repo) [--description STR | --description-file PATH] [--git-branch main] [--git-public/--no-git-public] [--git-username USER] [--git-pat-env VAR | --git-pat-file PATH | --git-pat-encrypted KBC::Project...] [--auth password|public] [--size tiny|small|medium|large] [--auto-suspend SECONDS] [--type python-js|python|streamlit|r|...] [--workspace/--no-workspace] [--branch ID] [--no-deploy] [--wait] [--timeout SECONDS] [--keep-on-failure] [--dry-run] [--copy] [--reveal] # --workspace / --no-workspace (0.87.0+): DEFAULT ON. Writes runtime.workspace.enabled=true -- # the ONLY switch that makes the platform provision the ephemeral workspace and inject WORKSPACE_ID, # QUERY_SERVICE_URL and KBC_WORKSPACE_MANIFEST_PATH. Every app that reads Storage needs it. Before @@ -903,11 +903,27 @@ kbagent data-app create --project ALIAS --name NAME --slug SLUG (--git-repo URL # deploy pins the LATEST configVersion when a git block is present and omits it for a PURE managed # repo (deploys from managedGitRepoId). Use `data-app runs` to debug a deploy that reverts to # stopped (setup-phase failures produce no container logs). -kbagent data-app deploy --project NAME --app-id ID [--config-version N] [--wait] [--timeout SECONDS] [--branch ID] +kbagent data-app deploy --project NAME --app-id ID [--config-version N] [--wait] [--timeout SECONDS] [--branch ID] [--copy] [--reveal] kbagent data-app start --project NAME --app-id ID [--wait] [--timeout SECONDS] kbagent data-app stop --project NAME --app-id ID [--wait] [--timeout SECONDS] kbagent data-app delete --project NAME --app-id ID [--yes] -kbagent data-app password --project NAME --app-id ID +kbagent data-app password --project NAME --app-id ID [--copy] [--reveal] [--open] +# data-app password: never prints the password unless --reveal, so it does not go into an AI +# agent's context. Project token only (static or session) -- the Manage token and its prompt are +# gone. In a terminal (human mode) it waits for `c` to copy (Enter/Esc/q finishes, 120 s timeout); +# without a terminal or with --json only --copy copies. Nothing copied -> exit 0, +# password_delivered_to null, ui_url = the Keboola UI page that shows it. --reveal + --copy = +# INVALID_ARGUMENT. Refuses a non-password app (VALIDATION_ERROR) and a null password (NOT_FOUND). +# serve: GET /data-apps/{p}/{app}/password takes no X-Manage-Token; ?reveal=true adds `password`. +# create --wait / deploy --wait deliver the password the same way once the app runs (shared +# CopyOption / RevealOption + deliver_password in commands/_data_app_password.py); there +# --copy / --reveal need --wait and a deploy (else INVALID_ARGUMENT, exit 2, no API call; +# --dry-run applies the same rules and adds a `password_delivery` plan). Without a flag and +# without the terminal prompt nothing is read (output as before). With a flag, JSON adds only +# ui_url / password_delivered_to / password (--reveal); any failed read after the deploy is a +# warnings[] entry, exit 0. The serve create / deploy routes are unchanged. BREAKING: scripts +# reading `.data.password` from `data-app password` must add --reveal. +# Version gate + agent rules: gotchas.md / AGENT_CONTEXT. kbagent data-app logs --project NAME --app-id ID [--lines N] [--since ISO8601] kbagent data-app runs --project NAME --app-id ID [--limit N] kbagent data-app secrets-set --project ALIAS --app-id ID --secret '#KEY=VALUE' [--secret ...] [--secrets-file PATH] [--branch ID] [--allow-plaintext-on-encrypt-failure] [--dry-run] [--no-hint-next] diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index 1c06a7c31..6d5325283 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -920,20 +920,22 @@ kbagent --json data-app create \ `--auth password` (the default) wraps the app in a simpleAuth gate. The 20-character hex password is auto-generated by the platform on first -deploy. To retrieve it: +deploy. The command above uses `--json`, so it never prompts; run it +without `--json` in a terminal and `--wait` asks you to press `c` to copy +the password as soon as the app runs (`deploy --wait` does the same). To +copy it later (the project token is enough): ```bash -# Manage API token: interactive prompt by default. For CI, -# add `--allow-env-manage-token` and set KBC_MANAGE_API_TOKEN in env. -kbagent --json data-app password \ - --project prod --app-id 12345678 \ - | jq -r '.data.password' -# <20-character hex password, e.g. a1b2c3d4e5f6a7b8c9d0> +kbagent data-app password --project prod --app-id 12345678 +# Shows the app URL and the Keboola UI page, then: press c to copy the +# password, Enter to finish. The password is not printed. ``` -The password cannot be rotated; to change it, delete and recreate the -app. (See [§3](#3-add-a-whole-organization) for `KBC_MANAGE_API_TOKEN` -setup -- it is the same Manage token `org setup` uses.) +Without a terminal (a script, or an AI agent running the command), add +`--copy` to copy it at once. `--reveal` prints it instead, for scripts and +CI only -- an AI agent that sees it puts it into the chat history. When +nothing was copied, `ui_url` in the output is the Keboola UI page that +shows the password. The Keboola UI can reset the password. ### 9.3 Roll out a new version: `data-app deploy` after `config update` diff --git a/docs/web-server-endpoints.md b/docs/web-server-endpoints.md index 466ac1c6a..95710a58e 100644 --- a/docs/web-server-endpoints.md +++ b/docs/web-server-endpoints.md @@ -305,7 +305,7 @@ Python/JS (default), Streamlit and R data apps -- create, deploy, start/stop, ma | `POST` | `/data-apps/{project}/{app_id}/deploy` | Deploy a data app version | | `POST` | `/data-apps/{project}/{app_id}/start` | Start a data app | | `POST` | `/data-apps/{project}/{app_id}/stop` | Stop a data app | -| `GET` | `/data-apps/{project}/{app_id}/password` | Get data app access password | +| `GET` | `/data-apps/{project}/{app_id}/password` | Get data app password metadata (password only with reveal=true) | | `GET` | `/data-apps/{project}/{app_id}/logs` | Tail data app container logs | | `GET` | `/data-apps/{project}/{app_id}/secrets` | List data app secrets | | `PUT` | `/data-apps/{project}/{app_id}/secrets` | Set data app secrets | diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 2e08d1e6c..38f4e7996 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -141,7 +141,7 @@ been retired, so its absence is NOT a promise (see §1 Rule 6). | Bring a new data app online | `kbagent data-app create --project P --name N --slug S --git-repo URL [--git-pat-env VAR \| --git-public]` -- Storage access is ON by default on 0.87.0+ (`--no-workspace` opts out; on <= 0.86.0 patch `runtime.workspace.enabled` after create or it reads NOTHING) -- or `--use-managed-git-repo` for an empty Keboola-hosted repo (mutually exclusive; forces `--no-deploy`; then `git-credentials-create` + push + `deploy`). See [data-app-workflow.md](../skills/kbagent/references/data-app-workflow.md). **Authoring the repo itself is a different contract** (nginx `listen 8888`, no `[program:nginx]`, health check polls `GET /`) owned by Keboola's `dataapp-developer` skill in `keboola/ai-kit` -- read it before writing `keboola-config/`; `validate-repo` checks only a subset, so 0 BLOCKING does not promise the app starts | `config new --component-id keboola.data-apps` + `encrypt values` + raw `POST /apps`, only for custom shapes | `PATCH desiredState=running` without `configVersion` + `restartIfRunning` (pins to the v2 empty shell; errors `dataApp.git.repository is required`) | | Roll out / wake / pause / tear down a data app | `data-app deploy --wait` after ANY config change (sends the `{desiredState, configVersion, restartIfRunning}` trio); `data-app start` wakes a parked app without bumping the version; `data-app stop` pauses; `data-app delete` is irreversible and cascades to the Storage config | -- | `config update` then `job run` (data apps are not jobs); deleting the `keboola.data-apps` config by hand (orphans the deployment record) | | Debug a data app (failed deploy or runtime crash) | `kbagent data-app runs --app-id N` FIRST -- lists deploy attempts with `failure_reason` + `startup_logs`, and works on failed/never-started apps where `data-app logs` 400s | `kbagent data-app logs --app-id N [--lines N \| --since ISO8601]` for a running container's tail (may echo runtime secrets) | opening the UI "Terminal Log" tab; concluding anything from an empty log grep | -| Read the data-app simpleAuth password | `kbagent data-app password --project P --app-id N` -- needs a Manage API token (interactive prompt; `--allow-env-manage-token` for CI) | -- | trying to "rotate" it (unsupported -- delete + recreate) | +| User needs a data-app password (vNEXT+) | Recommend that the user runs `kbagent data-app password --project P --app-id N` (`data-app deploy ... --wait` only when a deploy is needed anyway -- it restarts the app) in their OWN terminal window (not through you, not through `!` mode) and presses `c`; or offer to run it with `--copy` (+ `--wait` on create / deploy; the password replaces the clipboard content, never enters the chat). `password_delivered_to: null` -> give the user `ui_url` | the Keboola UI page at `ui_url` (Open App); reset the password there | `--reveal` unless the user asks (warn first: the password goes into the chat history); reading the clipboard; `kbagent http` / curl / `serve` `reveal=true`; asking the user to paste it; running it on < vNEXT (it prints the password) | | Manage data-app runtime secrets | `kbagent data-app secrets-set --app-id N --secret '#KEY=VAL'` then `data-app deploy --wait` -- per-project KMS, fail-closed, never auto-deploys; `secrets-list` is metadata-only; `secrets-get` yields a literal value only for a PLAIN key; `secrets-remove --yes` is idempotent | `encrypt values --component-id keboola.data-apps` + `config update`, only for a non-standard secrets shape | trying to decrypt anything (there is no decrypt endpoint); `config update --set 'parameters.dataApp.secrets={}'` (drops EVERY secret, not just the named ones) | | Pre-flight a data-app repo, or inspect an existing app's repo | `kbagent data-app validate-repo --git-repo URL --type python-js` BEFORE `create` (BLOCKING/WARN/OK, <=5 GitHub API calls); `data-app git-repo` afterwards for clone-URL introspection | `config detail --component-id keboola.data-apps` then read `parameters.dataApp.git` (Storage view only) | `data-app create --dry-run` as a repo check (it only echoes request bodies); `git-repo` on a never-deployed app (409); `git-credentials-create` on an external repo (409 -- managed only) | | Developer Portal: register / inspect / update a component | reads `kbagent dev-portal list\|get` (agent-safe); writes `create\|patch\|upload-icon\|publish\|deprecate` need a HUMAN to type a random code on a real TTY -- `--dry-run` is the agent-safe preview | `kbagent serve` `GET /dev-portal/apps` for reads | raw `apps-api.keboola.com`; ANY write from a non-TTY/agent shell (no bypass; exits 6) | @@ -366,6 +366,15 @@ its absence is NOT a promise the entry is version-independent (see §1 Rule 6). `config detail` -> `configuration.runtime` FIRST (an empty `data-app logs` grep rules nothing out). `create` defaults it ON at **0.87.0+**; <= 0.86.0 patch + redeploy. +- **`data-app password` keeps the password out of the chat (vNEXT+)**: it + never prints it without `--reveal`; the user copies it with `c` in their own + terminal, or you run `--copy`. Project token only -- no Manage token. Below + vNEXT the same command PRINTS the password and needs a Manage token: do not + run it there; send the user to the Keboola UI. Non-password apps (oidc, + public) fail with `VALIDATION_ERROR`. `create --wait` / `deploy --wait` + deliver the password the same way (the password exists only after a + deploy); `--copy` / `--reveal` there need `--wait`. Never redeploy just to + get the password. gotchas.md. - **Data-app type in `sync`**: a `keboola.data-apps` config's runtime type (`python-js` / `streamlit`) lives only on the Data Science `/apps` record. `sync pull` records it as `_keboola.data_app_type`, and `sync push` / `sync clone` send it through `create_app`. A tree pulled before this carries no type (since 0.94.0). A type-less data app is created as `python-js`, the default, with a `data_app_type_default` push warning *(since vNEXT)*. So re-pull the source before you clone a Streamlit app, or set `_keboola.data_app_type: streamlit`. - **`ENCRYPTION_FAILED` on an Azure stack is a VERSION GATE, not a bad token**: <= 0.85.0 rejected the Azure `KBC::ProjectSecureKV::` cipher, so private-repo diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index f0b263ad0..2d0fe1a19 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -144,7 +144,6 @@ When working inside a git repository or project directory, run `kbagent init` (o | Wake an auto-suspended data app at its currently-pinned configVersion | `kbagent data-app start --project PROJECT --app-id APP-ID` | | Stop a running data app (preserves the URL and Storage config) | `kbagent data-app stop --project PROJECT --app-id APP-ID` | | Delete the deployment AND the Storage config (cascade, irreversible) | `kbagent data-app delete --project PROJECT --app-id APP-ID` | -| Retrieve the simpleAuth password for a password-gated data app | `kbagent data-app password --project PROJECT --app-id APP-ID` | | Tail the container logs for a deployed data app | `kbagent data-app logs --project PROJECT --app-id APP-ID` | | List a data app's recent deployment attempts (runs), newest first | `kbagent data-app runs --project PROJECT --app-id APP-ID` | | Pre-flight check that a git repo follows the Keboola data-app Golden Rule | `kbagent data-app validate-repo --git-repo GIT-REPO` | @@ -155,6 +154,7 @@ When working inside a git repository or project directory, run `kbagent init` (o | List the keys in parameters.dataApp.secrets, with derived runtime env-var names | `kbagent data-app secrets-list --project PROJECT --app-id APP-ID` | | Show ONE key from parameters.dataApp.secrets | `kbagent data-app secrets-get --project PROJECT --app-id APP-ID --key KEY` | | Remove one or more app-runtime secrets. | `kbagent data-app secrets-remove --project PROJECT --app-id APP-ID --key KEY` | +| Copy the password of a password-protected data app to the clipboard | `kbagent data-app password --project PROJECT --app-id APP-ID` | | List jobs from connected projects | `kbagent job list` | | Show detailed information about a specific job | `kbagent job detail --project PROJECT --job-id JOB-ID` | | Run a job for a component configuration | `kbagent job run --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 594af3090..f37e20a64 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -100,7 +100,7 @@ All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the ## Permission flags (top-level, session-only) - `--deny-writes` -- block all write/destructive/admin operations for this single invocation. Merges with any persisted permission policy; never written to config.json. Exit code 6 (PERMISSION_DENIED) on blocked operations - `--deny-destructive` -- block only destructive operations (delete-table, delete-bucket, terminate-job, etc.) for this invocation. Pure-write ops like create-table stay allowed. Use this when you want to keep build-up capabilities but lock out tear-downs -- `--allow-env-manage-token` -- opt in to reading `KBC_MANAGE_API_TOKEN` from env (default-deny since v0.29.0). Without it the env var is ignored and an interactive hidden prompt is required for `org setup` / `project refresh` / `data-app password`. Closes the AI-exfiltration risk where any subprocess inherits the manage token via env. Session-only; not persisted; no env-var equivalent (intentional, would re-create the hole). REPL forwards this flag to nested invocations the same way it forwards the deny-* flags +- `--allow-env-manage-token` -- opt in to reading `KBC_MANAGE_API_TOKEN` from env (default-deny since v0.29.0). Without it the env var is ignored and an interactive hidden prompt is required for `org setup` / `project refresh`. Closes the AI-exfiltration risk where any subprocess inherits the manage token via env. Session-only; not persisted; no env-var equivalent (intentional, would re-create the hole). REPL forwards this flag to nested invocations the same way it forwards the deny-* flags - All three flags compose: `kbagent --deny-writes --deny-destructive --allow-env-manage-token ...` is the safest CI-friendly invocation ## Permissions (session firewall commands) @@ -284,12 +284,12 @@ Non-SOX Branches 2.0: merge a dev branch into production with review. Alias `mr` Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, encrypted secrets, runtime size) with Data Science API (`/apps` -- deployment record, state, URL, configVersion). The CLI encapsulates the §9 redeploy contract so callers cannot pin to the empty-shell v2; see `data-app-workflow.md` for the gotcha inventory and recipes. Since v0.33.0 the JSON output envelope's data-app id key is `app_id` (renamed from bare `id` for symmetry with the `--app-id` input flag); `config_id` is unchanged. - `data-app list [--project NAME ...] [--branch ID]` -- list data apps across projects (Data Science index merged with Storage names). Since v0.43.9 filters out workspace/sandbox deployments (`componentId=keboola.sandboxes`, `type=snowflake`/`bigquery`) that the Data Science `/apps` collection also returns, so the listing matches the Apps UI. Envelope carries `component_id` per app. - `data-app detail --project NAME --app-id ID [--branch ID]` -- merged view (state, desired, url, configVersion, slug, git block with PAT redacted) -- `data-app create --project ALIAS --name NAME --slug SLUG (--git-repo URL | --use-managed-git-repo) [--git-public/--no-git-public] [--git-username USER] [--git-pat-env VAR | --git-pat-file PATH | --git-pat-encrypted KBC::Project...] [--auth password|public] [--size tiny|small|medium|large] [--auto-suspend SECONDS] [--type python-js|python|streamlit|r|...] [--workspace/--no-workspace] [--branch ID] [--no-deploy] [--wait] [--timeout SECONDS] [--keep-on-failure] [--dry-run]` -- POST shell + encrypt PAT + PUT Storage config (with auto-injected `parameters.id`) + PATCH deploy with the §9 trio. Cleanup-in-finally on failure unless `--keep-on-failure`. Default `--auth password` mints a 20-char hex simpleAuth password (retrievable via `data-app password`). **Exactly one git source required.** `--use-managed-git-repo` provisions an EMPTY Keboola-hosted repo (POST `useManagedGitRepo:true`), writes NO `parameters.dataApp.git` block, and forces `--no-deploy` (empty repo, nothing to run); mutually exclusive with `--git-repo` and all `--git-*`/PAT flags. Managed-repo deploy WORKS with no credential wiring (verified live -- tic-tac-toe deployed and serving from a Keboola-managed repo). Full flow to a RUNNING app: `git-credentials-create --type http_token --permissions readWrite` -> `git push` your code to the managed repo URL (`data-app git-repo` shows it) -> `data-app deploy`. The platform injects the clone credentials at deploy time, so nothing extra is wired into the config; the minted credential is only used to authenticate YOUR push. **`--workspace` (since 0.87.0) is ON by default** and writes `runtime.workspace.enabled: true` -- the single switch that makes the platform provision the app's ephemeral workspace and inject `WORKSPACE_ID` / `QUERY_SERVICE_URL` / `KBC_WORKSPACE_MANIFEST_PATH`. Every app that reads Storage needs it; before 0.87.0 kbagent never wrote it, so such an app deployed, reported `state=running`, passed its health probe and read nothing, with NO platform-side diagnostic (check `config detail` -> `configuration.runtime`; a `Missing env vars: WORKSPACE_ID` log line, if any, comes from the app's own code, so its absence rules nothing out). Pass `--no-workspace` only for an app that never touches Storage -- it omits the key entirely (no `enabled: false`), leaving the body identical to 0.86.0. The block is a sibling of `runtime.backend`. Not gated on any project feature. Retrofit an existing app with `config update --merge --set 'runtime.workspace.enabled=true'` then `data-app deploy`. -- `data-app deploy --project NAME --app-id ID [--config-version N] [--wait] [--timeout SECONDS] [--branch ID]` -- the §9 redeploy contract. Default reads latest Storage version; `--config-version` pins an older version (rollback). Since 0.65.0: omits `configVersion` for a PURE managed repo (no git block -- deploys from `app.managedGitRepoId`, and the platform injects the clone credentials) and pins the LATEST Storage `configVersion` when a git block is present (external repos). An explicit `--config-version` always wins. +- `data-app create --project ALIAS --name NAME --slug SLUG (--git-repo URL | --use-managed-git-repo) [--git-public/--no-git-public] [--git-username USER] [--git-pat-env VAR | --git-pat-file PATH | --git-pat-encrypted KBC::Project...] [--auth password|public] [--size tiny|small|medium|large] [--auto-suspend SECONDS] [--type python-js|python|streamlit|r|...] [--workspace/--no-workspace] [--branch ID] [--no-deploy] [--wait] [--timeout SECONDS] [--keep-on-failure] [--dry-run] [--copy] [--reveal]` -- POST shell + encrypt PAT + PUT Storage config (with auto-injected `parameters.id`) + PATCH deploy with the §9 trio. Cleanup-in-finally on failure unless `--keep-on-failure`. Default `--auth password` makes the platform generate a 20-char hex simpleAuth password during the deploy. With `--wait` the password is delivered like `data-app password` once the app runs (since vNEXT); `--copy` / `--reveal` need `--wait` and a deploy (exit 2 with `--no-deploy` or `--use-managed-git-repo`). `--dry-run` accepts and refuses the same flags and adds `password_delivery` (`prompt` / `clipboard` / `stdout`) plus the warning the real run would give. **Exactly one git source required.** `--use-managed-git-repo` provisions an EMPTY Keboola-hosted repo (POST `useManagedGitRepo:true`), writes NO `parameters.dataApp.git` block, and forces `--no-deploy` (empty repo, nothing to run); mutually exclusive with `--git-repo` and all `--git-*`/PAT flags. Managed-repo deploy WORKS with no credential wiring (verified live -- tic-tac-toe deployed and serving from a Keboola-managed repo). Full flow to a RUNNING app: `git-credentials-create --type http_token --permissions readWrite` -> `git push` your code to the managed repo URL (`data-app git-repo` shows it) -> `data-app deploy`. The platform injects the clone credentials at deploy time, so nothing extra goes into the config; the created credential is only used to authenticate YOUR push. **`--workspace` (since 0.87.0) is ON by default** and writes `runtime.workspace.enabled: true` -- the single switch that makes the platform provision the app's ephemeral workspace and inject `WORKSPACE_ID` / `QUERY_SERVICE_URL` / `KBC_WORKSPACE_MANIFEST_PATH`. Every app that reads Storage needs it; before 0.87.0 kbagent never wrote it, so such an app deployed, reported `state=running`, passed its health probe and read nothing, with NO platform-side diagnostic (check `config detail` -> `configuration.runtime`; a `Missing env vars: WORKSPACE_ID` log line, if any, comes from the app's own code, so its absence rules nothing out). Pass `--no-workspace` only for an app that never touches Storage -- it omits the key entirely (no `enabled: false`), leaving the body identical to 0.86.0. The block is a sibling of `runtime.backend`. Not gated on any project feature. Retrofit an existing app with `config update --merge --set 'runtime.workspace.enabled=true'` then `data-app deploy`. +- `data-app deploy --project NAME --app-id ID [--config-version N] [--wait] [--timeout SECONDS] [--branch ID] [--copy] [--reveal]` -- the §9 redeploy contract. With `--wait` on a password app (since vNEXT) the password is delivered like `data-app password`: the `c` prompt in a terminal (the command then ends after Enter or 120 s), `--copy`, or `--reveal`; both flags need `--wait` (exit 2 otherwise, before any API call). With no flag and no prompt (no terminal, a background job, `--json`) the password is not read and the output is as before. With a flag, `--json` adds only `ui_url`, `password_delivered_to` and (`--reveal`) `password`; a non-password app with a flag, or any failure to read the password after the deploy, is a `warnings[]` entry with exit 0. The `kbagent serve` create / deploy routes do not deliver the password. Default reads latest Storage version; `--config-version` pins an older version (rollback). Since 0.65.0: omits `configVersion` for a PURE managed repo (no git block -- deploys from `app.managedGitRepoId`, and the platform injects the clone credentials) and pins the LATEST Storage `configVersion` when a git block is present (external repos). An explicit `--config-version` always wins. - `data-app start --project NAME --app-id ID [--wait] [--timeout SECONDS]` -- wake an auto-suspended app at the currently-pinned version. Distinct from deploy: does NOT bump configVersion. - `data-app stop --project NAME --app-id ID [--wait] [--timeout SECONDS]` -- stop a running app (URL and Storage config preserved). - `data-app delete --project NAME --app-id ID [--yes]` -- destructive, cascades to Storage config; URL retired permanently. -- `data-app password --project NAME --app-id ID` -- read the simpleAuth password. Manage token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+. Auto-generated, not rotatable -- delete + recreate to mint a new one. +- `data-app password --project NAME --app-id ID [--copy] [--reveal] [--open]` -- give the user the password of a password-protected app without printing it (since vNEXT; older versions printed it and needed a Manage token). Project token only (static or session). In a terminal (human mode) it prints `app_url` + `ui_url`, then `c` copies the password, Enter / Esc / `q` finishes, 120 s timeout. Without a terminal or with `--json`: only `--copy` copies (clipboard tool, password on stdin; WSL falls back to `/mnt/c/Windows/System32/clip.exe`). Nothing copied -> exit 0, `password_delivered_to: null`, `ui_url` is the Keboola UI page that shows it under Open App. `--reveal` prints it (`password_delivered_to: "stdout"`); `--reveal` + `--copy` = `INVALID_ARGUMENT`. `--open` also opens `app_url` (`app_opened`). `VALIDATION_ERROR` when the app's auth is not `password`; `NOT_FOUND` when it has no password yet. The Keboola UI can reset the password. BREAKING (vNEXT): scripts that read `.data.password` must add `--reveal` (without it the key is absent and exit is 0); REST clients must pass `reveal=true`; `KBC_MANAGE_API_TOKEN` / `--allow-env-manage-token` are no longer used by this command. Agents: recommend the user runs it in their own terminal and presses `c` (`data-app deploy --wait` only when a deploy is needed anyway -- it restarts the app), or offer `--copy`; no `--reveal` unless the user asks; never read the clipboard or ask the user to paste the password. - `data-app logs --project NAME --app-id ID [--lines N] [--since ISO8601]` -- tail container logs (Data Science `/apps/{id}/logs/tail`). Plain-text body covering the full spin-up trace ([TIMING] git_clone, Cloning into /app, uv install, supervisord, runtime stack traces). Default `--lines 500`; pass `--lines 0` for the full current buffer (no server-side cap). `--lines` and `--since` are mutually exclusive on the server; `--since` requires a timezone (Z or +00:00). App must be running or recently-stopped — never-started apps return 400 "App X is not running" (recover with `data-app start` or `data-app deploy`). Closes the upstream `keboola-mcp-server` gap where `get_data_apps` hardcodes a 20-line cap; this CLI surface is unconstrained. The log buffer can echo runtime secrets the app printed to stdout/stderr — consider hygiene before piping `--json` output into AI agent context. - `data-app runs --project NAME --app-id ID [--limit N]` -- list deployment attempts newest-first (Data Science `/apps/{id}/runs`), each with `failure_reason` + `startup_logs`. Captures setup-phase failures (e.g. git-clone errors) that produce NO container logs, so unlike `data-app logs` it works on never-started / failed apps where `data-app logs` returns HTTP 400. This is the way to find WHY a deploy reverted to stopped. Auth: ordinary project storage token only. - `data-app secrets-set --project ALIAS --app-id ID --secret '#KEY=VALUE' [--secret ...] [--secrets-file PATH] [--branch ID] [--allow-plaintext-on-encrypt-failure] [--dry-run] [--no-hint-next]` -- encrypt and write `#`-prefixed secrets to `parameters.dataApp.secrets`. Per-project KMS encryption, fail-closed. Read-modify-write at the service layer (NOT Storage `merge=True` -- shallow). Runtime exposes each key as an env var with `#` stripped, `-` -> `_`, uppercased. Adding bumps the Storage version; the running container keeps the OLD config until the next `data-app deploy`. @@ -299,7 +299,7 @@ Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, - `data-app validate-repo --git-repo URL [--git-branch BRANCH] [--git-public/--no-git-public] [--git-pat-env VAR | --git-pat-file PATH] [--type python-js] [--strict]` -- pre-flight Golden-Rule check for a data-app git repo (https://help.keboola.com/data-apps/python-js/). GitHub-only; ≤5 API calls (1 tree + ≤4 contents) regardless of repo size. `--type` restricted to `python-js` in 0.28.0; streamlit / pure-Python / R / Node-only follow-up. `--strict` treats WARNs as failures. Since 0.88.0 (#636) the two `setup.sh` rules (`golden-rule.setup-sh-no-pip`, `golden-rule.setup-sh-uv-sync`) match against **comment-stripped code**: a comment reading `# never pip install` no longer BLOCKS, and a comment merely mentioning `uv sync` no longer satisfies the uv-sync rule - `data-app git-repo --project NAME --app-id ID` -- show the clone URLs (`ssh_url` / `https_url`) of the app's configured git repo + `is_managed_git_repo` (sandboxes-service `GET /apps/{id}/git-repo`). Read-only, project storage token only. **GOTCHA**: returns 409 `no Git repository configured` until the app has been DEPLOYED at least once -- the git block is synced from the Storage config into the Data Science app record at deploy time; a `--no-deploy` app has no git repo from the service's point of view. - `data-app git-credentials --project NAME --app-id ID` -- list the credentials of the app's MANAGED git repo (`id`, `type`, `permissions`, `name`, `owner_admin_id`, `created_at`). The secret is NEVER returned here. Needs an admin storage token; external repos have none. -- `data-app git-credentials-create --project NAME --app-id ID --type ssh_key|http_token --permissions readOnly|readWrite [--public-key KEY | --public-key-file PATH] [--name LABEL] [--yes]` -- mint a git credential for the app's MANAGED git repo. `ssh_key` requires a public key; `http_token` returns a ONE-TIME secret (shown once, never retrievable again -- mirrors `data-app password`). Needs an admin storage token. Apps from `data-app create --git-repo` are EXTERNAL => 409 `no managed Git repository`. Confirmation unless `--yes`/`--json`. For a managed-repo app this credential authenticates YOUR `git push` of the code; the deploy itself uses the platform's injected clone credentials -- no further wiring needed. +- `data-app git-credentials-create --project NAME --app-id ID --type ssh_key|http_token --permissions readOnly|readWrite [--public-key KEY | --public-key-file PATH] [--name LABEL] [--yes]` -- create a git credential for the app's MANAGED git repo. `ssh_key` requires a public key; `http_token` returns a ONE-TIME secret (shown once, never retrievable again). Needs an admin storage token. Apps from `data-app create --git-repo` are EXTERNAL => 409 `no managed Git repository`. Confirmation unless `--yes`/`--json`. For a managed-repo app this credential authenticates YOUR `git push` of the code; the deploy itself uses the platform's injected clone credentials -- no further wiring needed. ## The `tool` group (REMOVED in v0.85.0 -- epic #390 phase 3) `tool list` / `tool call` and `agent --type mcp_tool` no longer exist. Every catalog tool has a native command -- look an old tool name up in `docs/mcp-migration.md` and run its replacement. Surviving `mcp_tool` agent tasks are inert tombstones: they never run, `agent list` flags them, and `doctor` reports them as FAIL. `keboola-mcp-server` itself is unaffected (a separate distribution kbagent no longer installs or updates -- refresh it with `uv tool install --upgrade --prerelease=allow keboola-mcp-server`). @@ -494,7 +494,7 @@ CLI parity for the `/agents` REST surface. Reads/writes `/agents.jso | `KBC_TOKEN` | Fallback for `--token`. Also the credential source for headless `__env__` mode (see `KBAGENT_PROJECT_FROM_ENV`) | | `KBC_STORAGE_API_URL` | Default stack URL. Also the stack source for headless `__env__` mode | | `KBAGENT_PROJECT_FROM_ENV` | Set to `1`/`true`/`yes`/`on` to synthesize an in-memory project `__env__` from `KBC_TOKEN` + `KBC_STORAGE_API_URL`. Headless / token-only: no `project add`, no `config.json` on disk; token stays in memory (never persisted). Use `--project __env__`. Works for CLI and `kbagent serve`. Fails fast if creds missing | -| `KBC_MANAGE_API_TOKEN` | Manage API token (org setup, project refresh, data-app password). Default-DENY since 0.28.0: requires top-level `--allow-env-manage-token` to opt in, otherwise ignored with a warning. | +| `KBC_MANAGE_API_TOKEN` | Manage API token (org setup, project refresh). Default-DENY since 0.28.0: requires top-level `--allow-env-manage-token` to opt in, otherwise ignored with a warning. | | `KBAGENT_CONFIG_DIR` | Override config directory | | `KBAGENT_CONVERSATION_ID` | Set the `X-Conversation-ID` observability header for every invocation in the shell/session. Lower precedence than `--conversation-id` (since 0.92.0, #716) | | `KBAGENT_SERVE_URL` | Self-URL of `kbagent serve` (used by `kbagent http`; auto-injected into scheduled-agent subprocesses) | diff --git a/plugins/kbagent/skills/kbagent/references/data-app-workflow.md b/plugins/kbagent/skills/kbagent/references/data-app-workflow.md index 9d06040bc..66ff927b8 100644 --- a/plugins/kbagent/skills/kbagent/references/data-app-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/data-app-workflow.md @@ -164,18 +164,40 @@ kbagent --json data-app create \ `--git-pat-env` is the recommended PAT input mode -- the plaintext token never appears in argv. The service encrypts it under THIS project's KMS via the Encryption API before writing it to Storage. `--auth password` (the -default) auto-mints a 20-character hex simpleAuth password; retrieve it -with: +default) makes the platform generate a 20-character hex simpleAuth password +during the deploy, so the password exists only after a deploy. The user copies +it to the clipboard with (since vNEXT; project token only, no Manage token): ```bash kbagent data-app password --project prod --app-id -# Manage token: interactive prompt by default (since v0.29.0); for CI add -# --allow-env-manage-token alongside KBC_MANAGE_API_TOKEN. Storage token -# is read from .kbagent/config.json as usual. +# In a terminal: press c to copy, Enter to finish. The password is never printed. +kbagent --json data-app password --project prod --app-id --copy +# No terminal (agent, CI): --copy copies it; password_delivered_to says where it went. ``` -The simpleAuth password CANNOT be rotated (writeup §11.2). To change it, -delete and recreate the app. +`create --wait` and `deploy --wait` deliver it the same way once the app +runs: the `c` prompt in a terminal (the command then ends after Enter or +120 s), `--copy`, or `--reveal`. Without `--wait` (or with `--no-deploy`) +`--copy` / `--reveal` are refused with exit 2, because no password exists +yet; `create --dry-run` refuses the same flags and otherwise reports the +planned delivery as `password_delivery`. With no flag and no prompt (no +terminal, `--json`) the password is not read and the output is as before. +With a flag, the `--json` result gains only `ui_url`, +`password_delivered_to` and, with `--reveal`, `password`. If the password +cannot be read after a successful deploy, the result carries a +`warnings[]` entry and the command still exits 0. The `kbagent serve` +create / deploy routes do not deliver the password. + +The password must not go into an AI agent's chat. An agent recommends that +the user runs the first command in their own terminal window, or offers the +`--copy` form (the password then replaces the clipboard content). Only when +a deploy is needed anyway does the agent recommend `kbagent data-app deploy +--project prod --app-id --wait` there instead: a deploy restarts the +app, so it is never a way to get the password. When `password_delivered_to` +is `null`, `ui_url` is the Keboola UI page that shows the password under +Open App. `--reveal` prints it, for scripts and CI only (a script that read +`.data.password` must add `--reveal` since vNEXT). The Keboola UI can reset +the password. ### Roll out a new code version (no Storage edit) @@ -425,7 +447,7 @@ yours at runtime. | Roll out a new Storage config | `config update` (any field) → `data-app deploy` | | Wake an auto-suspended app | `data-app start --app-id N` | | Pause a running app temporarily | `data-app stop --app-id N` | -| Read the simpleAuth password | `data-app password --app-id N` (needs Manage token) | +| Give the user the simpleAuth password | `data-app password --app-id N` in the user's terminal (press `c`; `deploy --wait` does the same when a deploy is needed anyway), or `--copy` (since vNEXT; never printed without `--reveal`) | | Set or rotate app-runtime secrets | `data-app secrets-set --app-id N --secret '#KEY=VAL'` then `data-app deploy --wait` | | Inspect what's set (secrets + plain env vars) | `data-app secrets-list --app-id N` (metadata only, never decrypts) | | Read one key | `data-app secrets-get --app-id N --key KEY` (`#` optional; encrypted → metadata only, plain → value) | @@ -450,8 +472,8 @@ yours at runtime. --set 'runtime.backend.size="medium"' --merge` then `data-app deploy`. `PATCH /apps {config:{...}}` is silently dropped by the API (writeup §8 row 3). -- **Rotating the simpleAuth password** — not supported by the API. To - change the password, delete and recreate the app (writeup §11.2). +- **Resetting the simpleAuth password** — done in the Keboola UI; kbagent + has no command for it. ## Endpoints used @@ -459,12 +481,12 @@ yours at runtime. |---|---|---| | `POST` | `data-science./apps` | `data-app create` step 1 | | `GET` | `data-science./apps` | `data-app list` | -| `GET` | `data-science./apps/{id}` | `data-app detail`, poll loop | +| `GET` | `data-science./apps/{id}` | `data-app detail`, poll loop, `data-app password` | | `PATCH` | `data-science./apps/{id}` | `data-app deploy / start / stop` | | `DELETE` | `data-science./apps/{id}` | `data-app delete` (cascades to Storage) | -| `GET` | `data-science./apps/{id}/password` | `data-app password` (needs Manage) | +| `GET` | `data-science./apps/{id}/password` | `data-app password` (project token only, since vNEXT) | | `GET` | `data-science./apps/{id}/logs/tail` | `data-app logs` (since 0.43.8; `lines` / `since` mutex) | | `GET` | `data-science./apps/{id}/runs` | `data-app runs` (since 0.65.0; deployment attempts + failure_reason / startup_logs) | | `POST` | `encryption./encrypt` | `data-app create` step 2 (private repo) | | `PUT` | `connection./v2/storage/.../keboola.data-apps/configs/{id}` | `data-app create` step 3, also `config update` | -| `GET` | `connection./v2/storage/.../keboola.data-apps/configs/{id}` | `data-app detail` (latest version), `data-app deploy` (read latest) | +| `GET` | `connection./v2/storage/.../keboola.data-apps/configs/{id}` | `data-app detail` (latest version), `data-app deploy` (read latest), `data-app password` (auth check) | diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index b187268a3..b1540aac3 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -2162,13 +2162,92 @@ config, the retry fires, and the retry destroys it for good. with the shape above. The previous URL stays retired in either case (the proxy URL is bound to the deployment record, not the config). - **`--auth password` behaviour unchanged.** Mints a 20-char hex - simpleAuth password retrievable via `kbagent data-app password` - (Manage token required) or visible in the UI's Authentication tab. + simpleAuth password. The user copies it with `kbagent data-app password` + (see the `data-app password` entry below) or reads it in the Keboola UI. - **Other auth providers (OIDC / GitHub OAuth / GitLab OAuth / JumpCloud / Auth0)** are NOT yet supported by the CLI's `--auth` flag. Use the Keboola UI to configure them after `data-app create`. Tracked as a follow-up issue. +## `data-app password` keeps the password out of the chat: `c` in a terminal, `--copy` elsewhere + +*(since vNEXT)* + +- **The password is not printed without `--reveal`.** Before vNEXT the + command printed it (`Password: ...`, and a `password` key in `--json`), so + it went into the context of any AI agent that ran it. On an older kbagent, + do not run the command from an agent: send the user to the Keboola UI. +- **Migration (breaking).** A script that reads `.data.password` must add + `--reveal`: without it the key is absent and the exit code is still 0. A + REST client must pass `reveal=true`. `KBC_MANAGE_API_TOKEN` and + `--allow-env-manage-token` are no longer used by this command. +- **In a terminal** (human mode, stdin and stdout a TTY, and not a + background job) it prints the message, `app_url` and `ui_url`, then waits: `c` copies the password once, + Enter / Esc / `q` finishes, and after 120 s it ends with a line that says + the password was not copied. An arrow key does not end the prompt. With no + clipboard tool there is no prompt; the message points to `ui_url`. +- **Without a terminal** (agent, CI, a background job) **or with `--json`** + there is no prompt. Only `--copy` copies the password. The clipboard tool + gets it on stdin, never in argv; a tool that hangs times out, and a tool + that fails (e.g. `xclip` without an X display) falls through to the next. `--copy` in a terminal + copies at once, without the prompt. On WSL without the Windows PATH, + kbagent runs `/mnt/c/Windows/System32/clip.exe` by its full path. +- **Nothing copied is not an error**: exit 0, `password_delivered_to: null`, + and `ui_url` is the Keboola UI page that shows the password under Open App. + Other values: `"clipboard"`, `"stdout"` (`--reveal`). `--reveal` together + with `--copy` is `INVALID_ARGUMENT` (exit 2). +- **Project token only.** No Manage API token, no prompt for it, and + `--allow-env-manage-token` does not apply: the password endpoint checks + only that the token belongs to the app's project. A browser-login session + works too. +- **Auth check first.** kbagent reads the app's config and asks for the + password only when `authorization.app_proxy.auth_providers[0].type` is + `password` -- the check the Keboola UI makes. Otherwise `VALIDATION_ERROR` + names the auth (`oidc`, `public`, `missing`, ...). A `null` password (none + set yet) is `NOT_FOUND`. +- **The Keboola UI can reset the password** (the API has + `POST /apps/{id}/reset-password`). Earlier kbagent docs said it could not + be rotated. kbagent has no reset command. +- **`data-app create --wait` and `data-app deploy --wait` deliver it too.** + The platform creates the password during the first deploy of a password-auth + app, so it exists only after a deploy. Once `--wait` sees the app running, + both commands deliver the password the same way as `data-app password`: the + `c` prompt in a terminal (so `deploy --wait` in a terminal ends only after + Enter or 120 s), `--copy`, or `--reveal`. With no flag and no prompt (no + terminal, a background job, or `--json`) they do not read the password at + all: no extra call, output as before. With a flag, `--json` adds only + `ui_url`, `password_delivered_to` and (with `--reveal`) `password`; + existing keys do not change. `--copy` / `--reveal` without `--wait`, or on + `create --no-deploy` / `--use-managed-git-repo`, is `INVALID_ARGUMENT` + (exit 2) before any API call. Reading the password is the operation + `data-app.password`, also here: when the permission policy denies it, + `--copy` / `--reveal` exit 6 (`PERMISSION_DENIED`) before any API call, and + the terminal prompt is skipped. The deploy result stays the result: a + non-password app with `--copy` / `--reveal`, or any failure to read the + password after the deploy, adds a `warnings[]` entry and exits 0 (a + password that is not ready yet: run `data-app password` in a moment). + `create --dry-run` accepts and refuses exactly the same flags; a valid run + adds `password_delivery` (`prompt`, `clipboard` or `stdout`) to the plan, + plus the warning the real run would give (no clipboard tool, or no + password for a `--auth public` app), with no API call, prompt or copy. The + `kbagent serve` create / deploy routes do not deliver the password; use + `GET /data-apps/{project}/{app_id}/password`. +- **`kbagent serve`**: `GET /data-apps/{project}/{app_id}/password` needs no + `X-Manage-Token`. It leaves the password out (`password_delivered_to: + null`) unless `?reveal=true` (`password_delivered_to: "response"`). +- **Agent rules.** Recommend that the user runs `kbagent data-app password + --project P --app-id ID` in their own terminal window (not through the + agent, not through Claude Code's `!` mode) and presses `c`. Recommend + `kbagent data-app deploy --project P --app-id ID --wait` there only when a + deploy is needed anyway -- a deploy restarts the app, so never redeploy + just to get the password. Or offer to run the command with `--copy` (with + `--wait` on create / deploy), and say that the password then replaces the + clipboard content but does not go into the chat. No `--reveal` unless the + user asks, and warn first that the password then goes into the chat history. Never + read the clipboard (`pbpaste`, `xclip -o`, `wl-paste`, `Get-Clipboard`), + never get the password another way (`kbagent http`, curl, `serve` with + `reveal=true`), never ask the user to paste it into the chat. + ## `data-app secrets-*` -- per-project KMS, idempotent remove, never decryptable - **Encryption is per-project KMS.** `kbagent data-app secrets-set` calls @@ -2290,7 +2369,8 @@ config, the retry fires, and the retry destroys it for good. - `KBC_MANAGE_API_TOKEN` is no longer auto-resolved on the three surfaces that consume it (`kbagent org setup`, - `kbagent project refresh`, `kbagent data-app password`). Default + `kbagent project refresh`, `kbagent data-app password` -- the last one + needs no Manage token since vNEXT). Default behaviour on 0.29.0+ is **default-deny**: the env var is ignored, a TTY hidden-input prompt is shown instead. With no TTY (CI / cron / systemd / `< /dev/null`) the resolver exits **2** with the message @@ -3941,8 +4021,8 @@ Other behaviors of this family: (`CanManageAppRepoCredentials`), unlike `git-repo` which needs only the ordinary project storage token. - For `--type http_token`, the create response carries a **one-time secret** - that is printed once and can never be retrieved again (mirrors - `data-app password`); the `git-credentials` list never returns it. `--type + that is printed once and can never be retrieved again; the + `git-credentials` list never returns it. `--type ssh_key` requires a `--public-key` / `--public-key-file` and returns no secret. ## `data-app` managed-repo deploy: omit configVersion (the platform injects clone creds) (since v0.65.0; guidance corrected v0.65.1 -- no credential wiring needed) diff --git a/scripts/file_size_baseline.json b/scripts/file_size_baseline.json index 49a035e9c..74d7f7390 100644 --- a/scripts/file_size_baseline.json +++ b/scripts/file_size_baseline.json @@ -4,7 +4,7 @@ "commands/config.py": 2007, "commands/lineage.py": 1271, "commands/storage.py": 2221, - "services/data_app_service.py": 1641, + "services/data_app_service.py": 1633, "services/storage_service.py": 1684, "services/sync_service.py": 1655 } diff --git a/src/keboola_agent_cli/auth/environment.py b/src/keboola_agent_cli/auth/environment.py index ed2ca9d9d..f670a7d91 100644 --- a/src/keboola_agent_cli/auth/environment.py +++ b/src/keboola_agent_cli/auth/environment.py @@ -168,7 +168,7 @@ def detect_browser_environment() -> BrowserEnvironment: return BrowserEnvironment(loopback_browser_usable=True, reason="", opener=opener) -def open_browser(url: str) -> bool: +def open_browser(url: str, *, wait_seconds: float = 0.0) -> bool: """Best-effort `webbrowser.open` on a daemon thread. Never raises and never logs ``url`` -- it carries the PKCE code @@ -177,13 +177,21 @@ def open_browser(url: str) -> bool: before spawning the thread) and True once the open has been dispatched; a True return does not guarantee a browser window actually appeared, only that a handler accepted the request. + + ``wait_seconds`` joins the thread for up to that long. A caller that exits + right after the call (``data-app password --open``) passes it, because a + daemon thread dies with the process -- possibly before the opener starts. + The login flow keeps polling afterwards, so it leaves the default 0. """ try: webbrowser.get() except webbrowser.Error: return False - threading.Thread(target=_open_silently, args=(url,), daemon=True).start() + thread = threading.Thread(target=_open_silently, args=(url,), daemon=True) + thread.start() + if wait_seconds > 0: + thread.join(timeout=wait_seconds) return True diff --git a/src/keboola_agent_cli/commands/_data_app_password.py b/src/keboola_agent_cli/commands/_data_app_password.py new file mode 100644 index 000000000..7d3335a3f --- /dev/null +++ b/src/keboola_agent_cli/commands/_data_app_password.py @@ -0,0 +1,502 @@ +"""Data-app password delivery -- ``data-app password``, and after ``create`` / ``deploy --wait``. + +Split out of ``data_app.py`` to respect the file-size budget (CONTRIBUTING.md +"File-size budgets"). ``data-app password`` attaches to the ``data-app`` +sub-app through :func:`register_password_command`; ``create`` and ``deploy`` +in ``data_app.py`` use the same options (:data:`CopyOption`, +:data:`RevealOption`) and the same delivery (:func:`deliver_password`), so +the three commands cannot drift apart. + +The password never goes to stdout unless ``--reveal`` asks for it, so that it +does not enter the context of an AI agent that runs the command: + +- In a terminal (human mode, stdin and stdout a TTY) the user presses ``c`` to + copy it (``CopyableUrlWait`` from ``_url_copy.py``, the same prompt device + login uses). +- Anywhere else (an agent, CI, ``--json``) it is copied only with ``--copy``. + +The platform creates the password during the first deploy of a password-auth +app, so ``create`` / ``deploy`` can deliver it only after ``--wait`` sees the +app running -- and they read it only when something will deliver it (a flag, +or the terminal prompt). ``create --dry-run`` accepts and refuses the same +flags and only reports the delivery it would make (``password_delivery``). +The clipboard and the browser are local to the user's machine, which is why +this lives in the command layer and not in the service. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, replace +from typing import Annotated, Any, NoReturn + +import typer +from rich.console import Console +from rich.markup import escape +from rich.text import Text + +from ..auth import environment +from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ..output import OutputFormatter +from ..services._data_app_password import PASSWORD_AUTH, DataAppPassword, data_app_auth_kind +from ..services.data_app_service import RUNNING_STATE +from . import _url_copy +from ._helpers import check_cli_operation, get_formatter, get_service, map_error_to_exit_code + +DELIVERED_TO_CLIPBOARD = "clipboard" +DELIVERED_TO_STDOUT = "stdout" + +# Long enough for `webbrowser` to start the opener, short enough that an +# opener it blocks on (a text-mode browser) cannot hold the command. +_BROWSER_OPEN_WAIT_SECONDS = 2.0 + +_COPY_PROMPT_TIMEOUT_SECONDS = 120.0 +_COPY_PROMPT_HINT = "Press c to copy the password, Enter to finish" +# Enter arrives as "\n" in cbreak mode on POSIX and as "\r" from msvcrt on Windows. +_COPY_PROMPT_FINISH_KEYS = frozenset({"\n", "\r", "\x1b", "q"}) +# The terminal flow prints the UI URL itself, so its lines only refer to it. +_UI_URL_SHOWS_IT = "The UI URL shows it under Open App after login." + +# -- Shared options: one definition for `password`, `create` and `deploy` ------- + +CopyOption = Annotated[ + bool, + typer.Option( + "--copy", + help=( + "Copy the data-app password to the clipboard at once, without the terminal " + "prompt. The only way to copy it when there is no terminal (an AI agent, CI) " + "or with --json. On create / deploy it needs --wait." + ), + ), +] +RevealOption = Annotated[ + bool, + typer.Option( + "--reveal", + help=( + "Print the data-app password (human and --json output). For scripts and CI; " + "in an AI agent session the password then goes into the chat history. " + "On create / deploy it needs --wait." + ), + ), +] + + +# The operation that reads a password. `create` and `deploy` are checked as +# their own operations by the group callback, so reading the password after +# their deploy needs its own check: a policy can deny this one on purpose. +PASSWORD_OPERATION = "data-app.password" + + +@dataclass(frozen=True) +class PasswordFlags: + """The --copy / --reveal choice of one invocation. + + ``permitted`` is False when the permission policy denies + :data:`PASSWORD_OPERATION`; :func:`check_password_flags` sets it. + """ + + copy: bool = False + reveal: bool = False + permitted: bool = True + + @property + def any(self) -> bool: + return self.copy or self.reveal + + +@dataclass(frozen=True) +class PasswordDelivery: + """How :func:`deliver_password` delivered the password (never the password itself).""" + + delivered_to: str | None # DELIVERED_TO_CLIPBOARD, DELIVERED_TO_STDOUT or None + message: str + prompted: bool # the terminal prompt ran and printed its own lines + + def json_fields(self, lookup: DataAppPassword) -> dict[str, Any]: + """``ui_url`` and ``password_delivered_to``; ``password`` only for --reveal.""" + fields: dict[str, Any] = { + "ui_url": lookup.ui_url, + "password_delivered_to": self.delivered_to, + } + if self.delivered_to == DELIVERED_TO_STDOUT: + fields["password"] = lookup.password + return fields + + +def _invalid_argument(formatter: OutputFormatter, message: str) -> NoReturn: + formatter.error(error_code=ErrorCode.INVALID_ARGUMENT, message=message) + raise typer.Exit(code=2) + + +def check_password_flags( + ctx: typer.Context, flags: PasswordFlags, *, deploy_blocker: str = "" +) -> PasswordFlags: + """Check the flags before any API call and return them with ``permitted`` set. + + Exits 2 (INVALID_ARGUMENT) on flags that cannot work, and 6 + (PERMISSION_DENIED) when --copy / --reveal asks for a password that the + policy denies (:data:`PASSWORD_OPERATION`). ``deploy_blocker`` is for + ``create`` / ``deploy``: why the command will not wait for a deploy (e.g. + "--wait is missing"), "" when it will. + """ + formatter = get_formatter(ctx) + if flags.copy and flags.reveal: + _invalid_argument(formatter, "--reveal and --copy are mutually exclusive.") + if flags.any and deploy_blocker: + _invalid_argument( + formatter, + f"--copy and --reveal cannot be used here: {deploy_blocker}. The platform " + "creates the password during the deploy, so kbagent can read it only after " + "--wait sees the app running.", + ) + if flags.any: + check_cli_operation(ctx, PASSWORD_OPERATION) + engine = ctx.obj.get("permission_engine") if isinstance(ctx.obj, dict) else None + permitted = engine is None or not engine.active or engine.is_allowed(PASSWORD_OPERATION) + return replace(flags, permitted=permitted) + + +def deploy_blocker( + *, wait: bool, no_deploy: bool = False, use_managed_git_repo: bool = False +) -> str: + """Why ``create`` / ``deploy`` will not wait for a deploy; "" when it will. + + ``--dry-run`` is deliberately not a blocker: a dry run accepts and refuses + exactly what the real run does. + """ + if no_deploy: + return "--no-deploy skips the deploy" + if use_managed_git_repo: + return "--use-managed-git-repo skips the deploy (the repository starts empty)" + if not wait: + return "--wait is missing" + return "" + + +def _copy_now(password: str) -> bool: + """Copy through the detected clipboard tool; False when there is none or it fails.""" + copier = _url_copy.detect_clipboard() + return copier is not None and copier(password) + + +def _open_app(app_url: str) -> bool: + """Open the app in the browser; False when there is no URL or no browser.""" + if not app_url: + return False + return environment.open_browser(app_url, wait_seconds=_BROWSER_OPEN_WAIT_SECONDS) + + +def _non_interactive_message( + lookup: DataAppPassword, delivered_to: str | None, *, copy: bool +) -> str: + if delivered_to == DELIVERED_TO_CLIPBOARD: + return f"The password of data app {lookup.app_id} is on the clipboard." + if delivered_to == DELIVERED_TO_STDOUT: + return f"The password of data app {lookup.app_id} is in this output (--reveal)." + if copy: + return ( + f"The password of data app {lookup.app_id} was not copied: no clipboard tool " + f"was found, or the copy failed. {lookup.ui_hint()}" + ) + return ( + f"The password of data app {lookup.app_id} was not copied; pass --copy to copy " + f"it to the clipboard. {lookup.ui_hint()}" + ) + + +def _print_line(console: Console, *parts: str | tuple[str, str]) -> None: + """One line of plain text parts: no markup parsing, no highlighting, and a + URL in it is never folded (non-TTY consoles wrap at 80 columns).""" + console.print(Text.assemble(*parts), highlight=False, soft_wrap=True) + + +def _print_links(console: Console, *, app_url: str, ui_url: str | None, app_opened: bool) -> None: + _print_line(console, (" App URL:", "bold"), f" {app_url or '-'}") + _print_line(console, (" UI URL:", "bold"), f" {ui_url or '-'}") + if app_opened: + console.print(" Opened the app in the browser.") + + +def _print_result(console: Console, data: dict[str, Any]) -> None: + label = ( + ("Success:", "bold green") if data["password_delivered_to"] else ("Warning:", "bold yellow") + ) + _print_line(console, label, f" {data['message']}") + _print_links( + console, app_url=data["app_url"], ui_url=data["ui_url"], app_opened=data["app_opened"] + ) + if "password" in data: + _print_line(console, ("\nPassword:", "bold yellow"), f" {data['password']}") + + +def _copy_on_keypress(console: Console, lookup: DataAppPassword, *, app_opened: bool) -> bool: + """Terminal flow: show the links, then copy the password when the user presses c.""" + wait = _url_copy.CopyableUrlWait( + console, + interactive=True, + hint=_COPY_PROMPT_HINT, + finish_keys=_COPY_PROMPT_FINISH_KEYS, + ) + if not wait.enabled: + console.print( + f"[bold yellow]Warning:[/bold yellow] No clipboard tool was found, so the password " + f"of data app {escape(lookup.app_id)} cannot be copied. {_UI_URL_SHOWS_IT}" + ) + _print_links(console, app_url=lookup.app_url, ui_url=lookup.ui_url, app_opened=app_opened) + return False + console.print(f"The password of data app {escape(lookup.app_id)} is ready to copy.") + _print_links(console, app_url=lookup.app_url, ui_url=lookup.ui_url, app_opened=app_opened) + wait.prompt_and_wait(lookup.password, _COPY_PROMPT_TIMEOUT_SECONDS) + if wait.copied: + return True + # Also reached when c was pressed but the clipboard tool failed. + seconds = int(_COPY_PROMPT_TIMEOUT_SECONDS) + reason = "" if wait.finished else f"No key pressed for {seconds} s. " + console.print(f"{reason}The password was not copied. {_UI_URL_SHOWS_IT}") + return False + + +def _would_prompt(formatter: OutputFormatter, flags: PasswordFlags) -> bool: + """The terminal ``c`` prompt runs only in human mode, in a foreground terminal, with no flag.""" + return not (flags.any or formatter.json_mode) and _url_copy.stdio_is_interactive() + + +def deliver_password( + formatter: OutputFormatter, + lookup: DataAppPassword, + flags: PasswordFlags, + *, + app_opened: bool = False, +) -> PasswordDelivery: + """Deliver the password: the terminal ``c`` prompt, ``--copy``, or ``--reveal``. + + The prompt runs only in human mode with a terminal and neither flag; it + prints its own lines (``prompted=True``). Otherwise nothing is printed + here -- the caller renders ``message`` and :meth:`PasswordDelivery.json_fields`. + """ + if _would_prompt(formatter, flags): + copied = _copy_on_keypress(formatter.console, lookup, app_opened=app_opened) + state = "is on the clipboard" if copied else "was not copied" + return PasswordDelivery( + delivered_to=DELIVERED_TO_CLIPBOARD if copied else None, + message=f"The password of data app {lookup.app_id} {state}.", + prompted=True, + ) + delivered_to: str | None = None + if flags.reveal: + delivered_to = DELIVERED_TO_STDOUT + elif flags.copy and _copy_now(lookup.password): + delivered_to = DELIVERED_TO_CLIPBOARD + return PasswordDelivery( + delivered_to=delivered_to, + message=_non_interactive_message(lookup, delivered_to, copy=flags.copy), + prompted=False, + ) + + +def _delivery_data( + lookup: DataAppPassword, delivery: PasswordDelivery, *, app_opened: bool +) -> dict[str, Any]: + return { + **lookup.metadata(), + **delivery.json_fields(lookup), + "app_opened": app_opened, + "message": delivery.message, + } + + +# -- After `create --wait` / `deploy --wait` ---------------------------------- + + +def _add_warning(result: dict[str, Any], message: str) -> None: + result.setdefault("warnings", []).append(message) + + +def _no_password_warning(subject: str, auth: str) -> str: + return f"{subject} uses auth '{auth}', so it has no password to copy." + + +def _read_failure_warning(app_id: str, exc: Exception) -> str: + if isinstance(exc, KeboolaApiError) and exc.error_code == ErrorCode.NOT_FOUND: + return ( + f"The deploy succeeded, but the password of data app {app_id} is not ready yet; " + "run `kbagent data-app password` in a moment." + ) + if isinstance(exc, KeboolaApiError) and exc.error_code == ErrorCode.VALIDATION_ERROR: + return exc.message + # Only the type for an unexpected exception: its text is not known to be safe to show. + detail = exc.message if isinstance(exc, KeboolaApiError | ConfigError) else type(exc).__name__ + return ( + f"The deploy succeeded, but the password was not read ({detail}); " + "run `kbagent data-app password` to try again." + ) + + +def _plan_password_delivery(result: dict[str, Any], flags: PasswordFlags) -> None: + """``create --dry-run``: record the delivery the real run would make, call nothing. + + Reached only when a delivery would happen (a flag, or the terminal prompt). + Checks only what can be checked without side effects: the planned auth, + and whether a clipboard tool exists (probed, never run). + """ + planned_config = (result.get("requests") or {}).get("put_storage_config") or {} + auth = data_app_auth_kind(planned_config) + if auth != PASSWORD_AUTH: + if flags.any: + _add_warning(result, _no_password_warning("The data app", auth)) + return + if flags.reveal: + result["password_delivery"] = DELIVERED_TO_STDOUT + return + result["password_delivery"] = DELIVERED_TO_CLIPBOARD if flags.copy else "prompt" + if _url_copy.detect_clipboard() is None: + _add_warning(result, "No clipboard tool was found, so the password would not be copied.") + + +def password_after_deploy( + formatter: OutputFormatter, + service: Any, + result: dict[str, Any], + flags: PasswordFlags, + *, + alias: str, + waited: bool, +) -> DataAppPassword | None: + """The password to deliver after a deploy the command waited for, or None. + + The app id comes from ``result`` (both the create and the deploy result carry it). + + Returns None, with no API call and no change to ``result``, when the + command did not wait for a deploy or nothing would deliver the password + (no --copy / --reveal and no terminal prompt: no terminal, a background + job, or --json). A dry run records its plan instead (``password_delivery``). + The deploy already succeeded, so nothing here fails the command: a problem + becomes a ``warnings[]`` entry and the return is None. An app without + password auth is silent unless --copy / --reveal asked for its password. + """ + if not waited or not (flags.any or _would_prompt(formatter, flags)): + return None + if not flags.permitted: # only the implicit prompt gets here; the flags exit earlier + return None + if result.get("dry_run"): + _plan_password_delivery(result, flags) + return None + app_id = str(result.get("app_id") or "") + state = str(result.get("state") or "") + if state != RUNNING_STATE: + if flags.any: + _add_warning( + result, + f"Data app {app_id} is not running (state={state or '?'}), " + "so its password was not read.", + ) + return None + auth = result.get("auth") # `create` knows it; `deploy` leaves it to the service + if auth is not None and auth != PASSWORD_AUTH: + if flags.any: + _add_warning(result, _no_password_warning(f"Data app {app_id}", auth)) + return None + try: + return service.get_data_app_password(alias=alias, app_id=app_id) + # Everything here runs after a deploy that succeeded, so ANY failure is a + # warning with exit 0 -- never a failed command that invites a redeploy. + except Exception as exc: + not_password_auth = ( + isinstance(exc, KeboolaApiError) and exc.error_code == ErrorCode.VALIDATION_ERROR + ) + if flags.any or not not_password_auth: + _add_warning(result, _read_failure_warning(app_id, exc)) + return None + + +def output_with_password( + formatter: OutputFormatter, + result: dict[str, Any], + lookup: DataAppPassword | None, + flags: PasswordFlags, + *, + human: Callable[[Console, dict[str, Any]], None], +) -> None: + """Print a create / deploy result, then deliver the password when ``lookup`` is set. + + ``--json`` adds only ``ui_url``, ``password_delivered_to`` and (``--reveal``) + ``password`` to the result. Human mode prints the result with ``human``, + a dry run's ``password_delivery`` plan, the ``warnings``, and then the + prompt or the delivery message. + """ + if formatter.json_mode: + if lookup is not None: + result.update(deliver_password(formatter, lookup, flags).json_fields(lookup)) + formatter.output(result) + return + human(formatter.console, result) + if result.get("password_delivery"): # a dry run's plan + formatter.console.print(f" Password after the deploy: {result['password_delivery']}") + for warning in result.get("warnings") or []: + formatter.warning(escape(str(warning))) + if lookup is None: + return + delivery = deliver_password(formatter, lookup, flags) + if not delivery.prompted: + _print_result(formatter.console, _delivery_data(lookup, delivery, app_opened=False)) + + +# -- `data-app password` --------------------------------------------------------- + + +def register_password_command(app: typer.Typer) -> None: + """Attach ``data-app password`` to the data-app sub-app.""" + + @app.command("password") + def data_app_password( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), + copy: CopyOption = False, + reveal: RevealOption = False, + open_app: bool = typer.Option( + False, "--open", help="Also open the app URL in the browser." + ), + ) -> None: + """Copy the password of a password-protected data app to the clipboard. + + The password is not printed, so it does not go into an AI agent's + context. In a terminal the command shows the app URL and the Keboola + UI page, then waits: press c to copy the password, Enter, Esc or q to + finish (it gives up after 120 s). Without a terminal (an AI agent, CI) + or with --json there is no prompt: only --copy copies the password. + The clipboard tool gets it on stdin (pbcopy, clip, wl-copy, xclip, + xsel, or clip.exe on WSL). When nothing is copied the command still + exits 0 with `password_delivered_to: null`; `ui_url` is the Keboola UI + page that shows the password under Open App. + + Needs only the project token (static or browser-login session), no + Manage API token. Fails with VALIDATION_ERROR when the app does not + use password auth, and with NOT_FOUND when it has no password yet + (the platform creates it during the first deploy). The Keboola UI + can reset the password. + """ + formatter = get_formatter(ctx) + flags = check_password_flags(ctx, PasswordFlags(copy=copy, reveal=reveal)) + service = get_service(ctx, "data_app_service") + try: + lookup = service.get_data_app_password(alias=project, app_id=app_id) + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + app_opened = _open_app(lookup.app_url) if open_app else False + delivery = deliver_password(formatter, lookup, flags, app_opened=app_opened) + if delivery.prompted: + return + formatter.output(_delivery_data(lookup, delivery, app_opened=app_opened), _print_result) diff --git a/src/keboola_agent_cli/commands/_url_copy.py b/src/keboola_agent_cli/commands/_url_copy.py index a1fc63e88..c0d2cde5f 100644 --- a/src/keboola_agent_cli/commands/_url_copy.py +++ b/src/keboola_agent_cli/commands/_url_copy.py @@ -1,11 +1,12 @@ -"""'press c to copy' option for a URL printed by a command that then waits. +"""'press c to copy' option for a value printed by a command that then waits. -Only the device-login flow uses this today (``commands/auth.py``). The design -is deliberately narrow: it folds a single-key read into the wait the command -already does between device-token polls (the ``sleep`` seam of -``auth/device.run_device_flow``), so there is no background thread and no -in-place redraw. When the terminal or the clipboard cannot support it, the -option disables itself and the command's output is byte-for-byte unchanged. +Two users: the device-login flow (``commands/auth.py``) copies the +verification URL, and ``data-app password`` copies the password (which it never +prints). The design is deliberately narrow: it folds a single-key read into a +wait -- for device login, the wait between device-token polls (the ``sleep`` +seam of ``auth/device.run_device_flow``) -- so there is no background thread +and no in-place redraw. When the terminal or the clipboard cannot support it, +the option disables itself and the command's output is byte-for-byte unchanged. The clipboard backend is a native command detected by probe, so its presence is known before anything is printed -- that is what lets the hint stay hidden @@ -15,16 +16,39 @@ from __future__ import annotations import contextlib +import os import shutil import subprocess import sys import time from collections.abc import Callable +from pathlib import Path from rich.console import Console _POSIX = sys.platform != "win32" +# A clipboard command that hangs (e.g. xclip waiting on an unreachable X +# display) must not hang the command that called it. +_CLIPBOARD_TIMEOUT_SECONDS = 5.0 + +# WSL registers these binfmt entries whenever Windows interop is on, also in a +# shell where ``WSL_INTEROP`` is unset and the Windows PATH is missing -- the +# case in which ``clip.exe`` is not on PATH but still runs by its full path. +# Newer WSL releases with systemd register it as ``WSLInterop-late``. +_WSL_INTEROP_MARKERS = ( + Path("/proc/sys/fs/binfmt_misc/WSLInterop"), + Path("/proc/sys/fs/binfmt_misc/WSLInterop-late"), +) +_WSL_CLIP_EXE = Path("/mnt/c/Windows/System32/clip.exe") + +# How long to wait for the next byte after ESC. The bytes of an escape +# sequence (an arrow key sends ESC [ A) arrive together, so a short gap tells +# a lone Esc apart from the start of a sequence. +_ESCAPE_SEQUENCE_GAP_SECONDS = 0.05 +# What ``_read_key`` returns for an escape sequence: a key no caller acts on. +_IGNORED_KEY = "\x00" + if _POSIX: import select import termios @@ -34,7 +58,11 @@ def _make_copier(argv: list[str]) -> Callable[[str], bool]: - """Build a copy function that pipes text into ``argv`` on stdin.""" + """Build a copy function that pipes text into ``argv`` on stdin. + + The text goes only to stdin, never into argv, so it cannot appear in a + process listing. A failure or a timeout returns False. + """ def _copy(text: str) -> bool: try: @@ -44,6 +72,7 @@ def _copy(text: str) -> bool: check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=_CLIPBOARD_TIMEOUT_SECONDS, ) return True except (OSError, subprocess.SubprocessError): @@ -57,13 +86,16 @@ def detect_clipboard() -> Callable[[str], bool] | None: Detection probes for a native clipboard command with ``shutil.which`` so the result is deterministic and known up front -- the caller reads ``None`` - as "do not offer copy". First hit wins, per platform: + as "do not offer copy". Nothing is run here. Candidates, in order, per + platform (the copy tries the next one when a tool fails, e.g. ``xclip`` + with no X display): - macOS: ``pbcopy`` - Windows: ``clip`` - Linux / other (incl. WSL): ``wl-copy`` (Wayland), ``xclip`` / ``xsel`` (X11), then ``clip.exe`` (the WSL bridge to the Windows clipboard when no - X or Wayland tool is installed). + X or Wayland tool is installed). On WSL, when none of them is on PATH, + ``/mnt/c/Windows/System32/clip.exe`` by its full path. """ if sys.platform == "darwin": candidates = [["pbcopy"]] @@ -76,38 +108,82 @@ def detect_clipboard() -> Callable[[str], bool] | None: ["xsel", "-b", "-i"], ["clip.exe"], ] - for argv in candidates: - if shutil.which(argv[0]): - return _make_copier(argv) + available = [argv for argv in candidates if shutil.which(argv[0])] + wsl_clip = _wsl_clip_exe() + if wsl_clip is not None: + available.append([str(wsl_clip)]) + if not available: + return None + copiers = [_make_copier(argv) for argv in available] + + def _copy_with_first_working(text: str) -> bool: + return any(copier(text) for copier in copiers) + + return _copy_with_first_working + + +def _wsl_clip_exe() -> Path | None: + """``clip.exe`` by its full path when this is WSL with Windows interop on.""" + if not sys.platform.startswith("linux"): + return None + on_wsl = bool(os.environ.get("WSL_INTEROP")) or any( + marker.exists() for marker in _WSL_INTEROP_MARKERS + ) + if on_wsl and _WSL_CLIP_EXE.is_file(): + return _WSL_CLIP_EXE return None -def _stdio_is_interactive() -> bool: - """True only when both stdin and stdout are a real terminal.""" +def stdio_is_interactive() -> bool: + """True only when stdin and stdout are a terminal and this process is in its foreground. + + A background job (``kbagent ... &``) still has a terminal, but changing + the terminal mode from the background makes the kernel stop the process + (SIGTTOU), so it counts as not interactive. + """ return ( hasattr(sys.stdin, "isatty") and sys.stdin.isatty() and hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + and _in_terminal_foreground() ) +def _in_terminal_foreground() -> bool: + """False for a background process group; True where the check does not apply.""" + if not _POSIX: + return True + try: + return os.getpgrp() == os.tcgetpgrp(sys.stdin.fileno()) + except (OSError, ValueError, AttributeError): + return False + + class CopyableUrlWait: - """A 'press c to copy' option folded into a poll wait. + """A 'press c to copy' option folded into a wait. Lifecycle: - - ``on_prompt(url)``: remember the URL, print the hint, and put the - terminal in cbreak mode so a single key needs no Enter. cbreak (not raw) - keeps signal keys live, so Ctrl+C still interrupts. + - ``on_prompt(value)``: remember the value, put the terminal in cbreak + mode so a single key needs no Enter, then print the hint (cbreak first, + so a key pressed as soon as the hint shows is not flushed). cbreak (not + raw) keeps signal keys live, so Ctrl+C still interrupts. The value + itself is never printed. - ``wait(interval)``: sleep up to ``interval`` seconds, but watch stdin - meanwhile; on ``c`` copy the URL and print a confirmation line once. - This is passed as the ``sleep`` seam of ``run_device_flow``, so the poll - cadence is unchanged -- each poll still waits its full interval. - - ``restore()``: undo the cbreak mode. Call it from a ``finally``. + meanwhile; on ``c`` copy the value and print a confirmation line once. + Device login passes this as the ``sleep`` seam of ``run_device_flow``, so + the poll cadence is unchanged -- each poll still waits its full interval. + A key in ``finish_keys`` ends the wait early and sets ``finished``; + so does end of input when ``finish_keys`` is set. An escape sequence + (arrow keys) is read whole and ignored; a lone Esc is a key. + - ``restore()``: undo the cbreak mode. Call it from a ``finally``; + :meth:`prompt_and_wait` does all three steps that way. - When ``enabled`` is False every method degrades to a plain sleep and prints - nothing, so a non-interactive or clipboard-less run behaves as before. + ``hint`` is the line printed by ``on_prompt`` (default: the device-login + link hint). When ``enabled`` is False every method degrades to a plain + sleep and prints nothing, so a non-interactive or clipboard-less run + behaves as before. """ def __init__( @@ -117,29 +193,52 @@ def __init__( key: str = "c", copier: Callable[[str], bool] | None = None, interactive: bool | None = None, + hint: str | None = None, + finish_keys: frozenset[str] = frozenset(), ) -> None: self._console = console self._key = key self._copier = copier if copier is not None else detect_clipboard() - is_interactive = _stdio_is_interactive() if interactive is None else interactive + is_interactive = stdio_is_interactive() if interactive is None else interactive self.enabled = self._copier is not None and is_interactive - self._url: str | None = None + self._hint = hint if hint is not None else f"Press {key} to copy the link" + self._finish_keys = finish_keys + self._value: str | None = None self._copied = False + self._finished = False self._saved_termios: list | None = None - def on_prompt(self, url: str | None) -> None: - """Arm the option for ``url`` (no-op when disabled or ``url`` is empty).""" - if not self.enabled or not url: + @property + def copied(self) -> bool: + """True once the value was copied to the clipboard.""" + return self._copied + + @property + def finished(self) -> bool: + """True once a key in ``finish_keys`` ended the wait.""" + return self._finished + + def on_prompt(self, value: str | None) -> None: + """Arm the option for ``value`` (no-op when disabled or ``value`` is empty).""" + if not self.enabled or not value: return - self._url = url - # cyan matches the copied URL's colour at the call site, so the hint - # and the link it copies read as the same thing. - self._console.print(f"[cyan]Press {self._key} to copy the link[/cyan]") + self._value = value self._enter_cbreak() + # cyan matches the copied URL's colour at the device-login call site, + # so the hint and the link it copies read as the same thing. + self._console.print(f"[cyan]{self._hint}[/cyan]") + + def prompt_and_wait(self, value: str, timeout: float) -> None: + """``on_prompt`` + ``wait``; the terminal mode is restored even on Ctrl+C.""" + try: + self.on_prompt(value) + self.wait(timeout) + finally: + self.restore() def wait(self, interval: float) -> None: - """Wait ``interval`` seconds, copying the URL if ``c`` is pressed.""" - if not self.enabled or self._url is None: + """Wait ``interval`` seconds, copying the value if ``c`` is pressed.""" + if not self.enabled or self._value is None: time.sleep(interval) return deadline = time.monotonic() + interval @@ -150,11 +249,17 @@ def wait(self, interval: float) -> None: key = self._read_key(remaining) if key is None: return - if key == "": # EOF on a stdin that is not a real terminal + if key == "": # end of input: nothing more can arrive + if self._finish_keys: + self._finished = True + return time.sleep(max(0.0, deadline - time.monotonic())) return if key.lower() == self._key and not self._copied: self._do_copy() + elif key.lower() in self._finish_keys: + self._finished = True + return # any other key: keep waiting out the remaining interval def restore(self) -> None: @@ -178,19 +283,28 @@ def _enter_cbreak(self) -> None: self._saved_termios = None def _read_key(self, timeout: float) -> str | None: - """Read one key within ``timeout``. ``None`` = timed out, ``''`` = EOF.""" + """Read one key within ``timeout``. ``None`` = timed out, ``''`` = EOF. + + POSIX reads the file descriptor with ``os.read``, not ``sys.stdin``: + a buffered read would pull a burst such as ``c`` + Enter into Python's + buffer, where ``select`` no longer sees the Enter. + """ if _POSIX: try: - ready, _, _ = select.select([sys.stdin], [], [], timeout) + fd = sys.stdin.fileno() + ready, _, _ = select.select([fd], [], [], timeout) except (OSError, ValueError): time.sleep(timeout) return None if not ready: return None try: - return sys.stdin.read(1) - except (OSError, ValueError): - return None + data = os.read(fd, 1) + except OSError: # the terminal went away (EIO after a hangup) + return "" + if data == b"\x1b" and _read_escape_tail(fd): + return _IGNORED_KEY + return data.decode("utf-8", "replace") end = time.monotonic() + timeout # pragma: no cover - Windows-only while time.monotonic() < end: if msvcrt.kbhit(): @@ -199,7 +313,37 @@ def _read_key(self, timeout: float) -> str | None: return None def _do_copy(self) -> None: - assert self._url is not None - if self._copier is not None and self._copier(self._url): + assert self._value is not None + if self._copier is not None and self._copier(self._value): self._copied = True self._console.print("[green]✓ Copied to clipboard[/green]") + + +def _read_byte_within(fd: int, timeout: float) -> bytes | None: + """One byte from ``fd`` if it arrives within ``timeout``, else None.""" + try: + ready, _, _ = select.select([fd], [], [], timeout) + if not ready: + return None + return os.read(fd, 1) or None + except OSError: + return None + + +def _read_escape_tail(fd: int) -> bool: + """After ESC, read the rest of an escape sequence; False for a lone Esc. + + CSI (ESC [ ...) ends with a byte in 0x40-0x7E (arrow keys: ESC [ A); + SS3 (ESC O x) is one more byte; anything else (Alt+key) is one byte. + """ + first = _read_byte_within(fd, _ESCAPE_SEQUENCE_GAP_SECONDS) + if first is None: + return False + if first == b"[": + while True: + byte = _read_byte_within(fd, _ESCAPE_SEQUENCE_GAP_SECONDS) + if byte is None or 0x40 <= byte[0] <= 0x7E: + break + elif first == b"O": + _read_byte_within(fd, _ESCAPE_SEQUENCE_GAP_SECONDS) + return True diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index a2368e45b..cf7463d03 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1395,8 +1395,12 @@ [--auth password|public] [--size tiny|small|medium|large] [--auto-suspend SECONDS] [--type python-js|python|streamlit|r|...] [--workspace/--no-workspace] [--branch ID] [--no-deploy] [--wait] [--timeout SECONDS] [--keep-on-failure] [--dry-run] + [--copy] [--reveal] Create + configure + deploy in one call. Default `--auth password` mints - a 20-char hex simpleAuth password (retrievable via `data-app password`). + a 20-char hex simpleAuth password during the deploy. With --wait the + password is delivered like `data-app password` once the app runs (see + there); --copy / --reveal need --wait and a deploy. --dry-run accepts + the same flags and adds `password_delivery` (prompt|clipboard|stdout). PAT input (private repo): env var (recommended) > file > pre-encrypted. Pre-encrypted PATs MUST start with KBC::Project (project-scoped KMS). Cleanup-in-finally if PUT or initial deploy fails (orphan shell deleted @@ -1423,7 +1427,14 @@ then redeploy (deploy pins the LATEST version, so the change takes effect). kbagent data-app deploy --project NAME --app-id ID [--config-version N] - [--wait] [--timeout SECONDS] [--branch ID] + [--wait] [--timeout SECONDS] [--branch ID] [--copy] [--reveal] + With --wait on a password app (vNEXT+): the password is delivered like + `data-app password` (the c prompt in a terminal, which then waits for + Enter or 120 s; --copy / --reveal need --wait). Without a flag and + without the prompt (no terminal, --json) nothing is read: output as + before. With a flag, JSON adds only ui_url, password_delivered_to + (+ password with --reveal); a failed password read or a non-password + app with --copy is a `warnings[]` entry, exit 0. The §9 redeploy contract. Default reads the latest Storage config version and pins to it; --config-version pins an older version (rollback). Always sends {{desiredState=running, configVersion, restartIfRunning=true}} @@ -1442,12 +1453,43 @@ URL is permanently retired. Confirmation prompt unless --yes. kbagent data-app password --project NAME --app-id ID - Retrieve the simpleAuth password. Requires the Manage API token in - addition to the project's Storage token. Token is read from interactive - hidden prompt by default; pass top-level --allow-env-manage-token to - use KBC_MANAGE_API_TOKEN from env (default-deny since 0.29.0). Never - persisted, never logged. Password is auto-generated at create time - and CANNOT be rotated -- delete and recreate the app to mint a new one. + [--copy] [--reveal] [--open] + Give the user the password of a password-protected app WITHOUT printing + it (vNEXT+; older versions printed it and needed a Manage API token). + Project token only (static or session), no Manage token. In a terminal + (human mode, stdin + stdout a TTY, not a background job) it shows the app + URL and `ui_url`, then waits: `c` copies the password, Enter / Esc / q + finishes, 120 s timeout. + Without a terminal or with --json there is no prompt: only --copy copies + it (the clipboard tool gets it on stdin). Nothing copied -> exit 0 with + `password_delivered_to: null`; `ui_url` is the Keboola UI page that shows + the password under Open App. --reveal prints it (human + JSON, + `password_delivered_to: "stdout"`); --reveal + --copy = INVALID_ARGUMENT. + --open also opens `app_url` in the browser (`app_opened`). JSON keys: + project_alias, app_id, auth, app_url, ui_url, password_delivered_to, + app_opened, message (+ password with --reveal). VALIDATION_ERROR when the + app's auth is not `password` (e.g. oidc, public); NOT_FOUND when the app + has no password yet. The Keboola UI can reset the password. + BREAKING: scripts that read `.data.password` must add --reveal (without + it the key is absent, exit 0); REST clients pass reveal=true; + KBC_MANAGE_API_TOKEN / --allow-env-manage-token are no longer used here. + AGENT RULES -- the password must not go into the chat: + 1. Recommend that the user runs `kbagent data-app password --project P + --app-id ID` in their own terminal window (not through you, not + through Claude Code's `!` mode) and presses c. Recommend + `kbagent data-app deploy --project P --app-id ID --wait` there + only when a deploy is needed anyway (c after the deploy); a deploy + restarts the app, so never redeploy just to get the password. + 2. Offer to run it yourself with --copy (plus --wait on create / + deploy); say that the password then replaces the clipboard content + but does not go into the chat. When `password_delivered_to` is + null, give the user `ui_url`. + 3. Do not pass --reveal unless the user asks for it, and warn first + that the password then goes into the chat history. + 4. Never read the clipboard (pbpaste, `xclip -o`, wl-paste, + Get-Clipboard), never get the password another way (`kbagent http`, + curl, `serve` with reveal=true), never ask the user to paste it into + the chat, never repeat it. kbagent data-app logs --project NAME --app-id ID [--lines N] [--since ISO8601] Tail the container log buffer (Data Science /apps/{{id}}/logs/tail). @@ -1538,7 +1580,7 @@ [--public-key KEY | --public-key-file PATH] [--name LABEL] [--yes] Mint a git credential for the app's MANAGED git repository. ssh_key requires a public key; http_token returns a ONE-TIME secret printed - once and never retrievable again (mirrors data-app password). Requires + once and never retrievable again. Requires an admin storage token. Apps created via `data-app create --git-repo` are EXTERNAL (not managed) -> 409 "no managed Git repository". Confirmation prompt unless --yes or --json. @@ -2195,7 +2237,7 @@ a standalone `export` does not survive between tool calls. KBC_TOKEN Storage API token (fallback for --token) KBC_STORAGE_API_URL Default stack URL (fallback for --url) - KBC_MANAGE_API_TOKEN Manage API token (org setup, project refresh, data-app password). + KBC_MANAGE_API_TOKEN Manage API token (org setup, project refresh). Default-DENY since 0.29.0: pass --allow-env-manage-token to opt in, otherwise this var is ignored and a TTY prompt is required. Closes AI-exfiltration via subprocess env. diff --git a/src/keboola_agent_cli/commands/data_app.py b/src/keboola_agent_cli/commands/data_app.py index 3ecda7af3..930a80c50 100644 --- a/src/keboola_agent_cli/commands/data_app.py +++ b/src/keboola_agent_cli/commands/data_app.py @@ -13,6 +13,7 @@ import os from datetime import datetime from pathlib import Path +from typing import Any import typer from rich.console import Console @@ -22,6 +23,16 @@ from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._data_app_git import register_git_commands +from ._data_app_password import ( + CopyOption, + PasswordFlags, + RevealOption, + check_password_flags, + deploy_blocker, + output_with_password, + password_after_deploy, + register_password_command, +) from ._data_app_runtime import register_secrets_commands from ._helpers import ( check_cli_permission, @@ -29,7 +40,6 @@ get_formatter, get_service, map_error_to_exit_code, - resolve_manage_token, ) # Canonical Keboola help-doc references appended to each --help epilog so @@ -200,6 +210,41 @@ def data_app_detail( # --------------------------------------------------------------------------- +def _print_create_result(console: Console, result: dict[str, Any]) -> None: + """Human output of `data-app create` (the --json output is the result dict).""" + if result.get("dry_run"): + console.print("[bold]DRY RUN -- no API calls were made.[/bold]") + console.print(result["requests"]) + else: + console.print(f"[bold green]Success:[/bold green] {result.get('message', '')}") + console.print(f" [bold]App ID:[/bold] {result['app_id']}") + console.print(f" [bold]Config ID:[/bold] {result['config_id']}") + if result.get("use_managed_git_repo"): + repo_id = result.get("managed_git_repo_id") or "(provisioning)" + console.print(f" [bold]Managed git repo:[/bold] {repo_id}") + if result.get("workspace"): + # Report what we WROTE, not what the platform will do with it. + # kbagent sets the key; whether the runtime honours it is the + # platform's call, so "requested" is the honest verb here. + console.print( + " [bold]Storage access:[/bold] requested (runtime.workspace.enabled=true)" + ) + else: + # --no-workspace is the footgun shape: say it out loud + # so a dead data path is never a silent surprise at runtime. + console.print( + " [bold yellow]Storage access:[/bold yellow] DISABLED " + "(--no-workspace) -- WORKSPACE_ID / QUERY_SERVICE_URL will " + "not be injected; an app that reads Storage will serve no data" + ) + if result.get("url"): + console.print(f" [bold]URL:[/bold] {result['url']}") + console.print( + f" [bold]State:[/bold] {result.get('state', '?')} " + f"(desired={result.get('desired_state', '?')})" + ) + + @data_app_app.command("create") def data_app_create( ctx: typer.Context, @@ -325,9 +370,22 @@ def data_app_create( "--dry-run", help="Print the three request bodies without making any API call.", ), + copy: CopyOption = False, + reveal: RevealOption = False, ) -> None: - """Create a Keboola data app end-to-end (POST + encrypt + PUT + deploy).""" + """Create a Keboola data app end-to-end (POST + encrypt + PUT + deploy). + + With --wait on a password-auth app, the password is delivered once the app + runs, as in `data-app password`: a c-to-copy prompt in a terminal, + --copy / --reveal elsewhere. The password itself is never printed + without --reveal. + """ formatter = get_formatter(ctx) + flags = PasswordFlags(copy=copy, reveal=reveal) + blocker = deploy_blocker( + wait=wait, no_deploy=no_deploy, use_managed_git_repo=use_managed_git_repo + ) + flags = check_password_flags(ctx, flags, deploy_blocker=blocker) branch = resolve_branch(ctx.obj["config_store"], project, branch, ignore_active_branch=True) service = get_service(ctx, "data_app_service") @@ -423,42 +481,10 @@ def data_app_create( formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None - if formatter.json_mode: - formatter.output(result) - else: - if result.get("dry_run"): - formatter.console.print("[bold]DRY RUN -- no API calls were made.[/bold]") - formatter.console.print(result["requests"]) - else: - formatter.console.print( - f"[bold green]Success:[/bold green] {result.get('message', '')}" - ) - formatter.console.print(f" [bold]App ID:[/bold] {result['app_id']}") - formatter.console.print(f" [bold]Config ID:[/bold] {result['config_id']}") - if result.get("use_managed_git_repo"): - repo_id = result.get("managed_git_repo_id") or "(provisioning)" - formatter.console.print(f" [bold]Managed git repo:[/bold] {repo_id}") - if result.get("workspace"): - # Report what we WROTE, not what the platform will do with it. - # kbagent sets the key; whether the runtime honours it is the - # platform's call, so "requested" is the honest verb here. - formatter.console.print( - " [bold]Storage access:[/bold] requested (runtime.workspace.enabled=true)" - ) - else: - # --no-workspace is the footgun shape: say it out loud - # so a dead data path is never a silent surprise at runtime. - formatter.console.print( - " [bold yellow]Storage access:[/bold yellow] DISABLED " - "(--no-workspace) -- WORKSPACE_ID / QUERY_SERVICE_URL will " - "not be injected; an app that reads Storage will serve no data" - ) - if result.get("url"): - formatter.console.print(f" [bold]URL:[/bold] {result['url']}") - formatter.console.print( - f" [bold]State:[/bold] {result.get('state', '?')} " - f"(desired={result.get('desired_state', '?')})" - ) + lookup = password_after_deploy( + formatter, service, result, flags, alias=project, waited=not blocker + ) + output_with_password(formatter, result, lookup, flags, human=_print_create_result) # --------------------------------------------------------------------------- @@ -466,6 +492,10 @@ def data_app_create( # --------------------------------------------------------------------------- +def _print_lifecycle_result(console: Console, result: dict[str, Any]) -> None: + console.print(f"[bold green]Success:[/bold green] {result.get('message', '')}") + + def _run_lifecycle( ctx: typer.Context, service_method: str, @@ -475,7 +505,10 @@ def _run_lifecycle( wait: bool, timeout: float, extra: dict | None = None, + delivery: PasswordFlags | None = None, ) -> None: + """Run deploy / start / stop. ``delivery`` (deploy only) turns on the + password delivery after ``--wait`` (see ``_data_app_password``).""" formatter = get_formatter(ctx) service = get_service(ctx, "data_app_service") method = getattr(service, service_method) @@ -495,10 +528,11 @@ def _run_lifecycle( except ConfigError as exc: formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None - formatter.output( - result, - lambda c, d: c.print(f"[bold green]Success:[/bold green] {d.get('message', '')}"), + flags = delivery or PasswordFlags() + lookup = password_after_deploy( + formatter, service, result, flags, alias=project, waited=wait and delivery is not None ) + output_with_password(formatter, result, lookup, flags, human=_print_lifecycle_result) @data_app_app.command("deploy") @@ -520,8 +554,17 @@ def data_app_deploy( "--branch", help="Storage branch for reading the latest version (defaults to production).", ), + copy: CopyOption = False, + reveal: RevealOption = False, ) -> None: - """Deploy the latest Storage config (the §9 redeploy contract).""" + """Deploy the latest Storage config (the §9 redeploy contract). + + With --wait on a password-auth app, the password is delivered once the app + runs, as in `data-app password`: a c-to-copy prompt in a terminal (the + command then ends after Enter or 120 s), --copy / --reveal elsewhere. + """ + flags = PasswordFlags(copy=copy, reveal=reveal) + flags = check_password_flags(ctx, flags, deploy_blocker=deploy_blocker(wait=wait)) branch = resolve_branch(ctx.obj["config_store"], project, branch, ignore_active_branch=True) _run_lifecycle( ctx, @@ -531,6 +574,7 @@ def data_app_deploy( wait=wait, timeout=timeout, extra={"config_version": config_version, "branch_id": branch}, + delivery=flags, ) @@ -628,53 +672,6 @@ def data_app_delete( ) -# --------------------------------------------------------------------------- -# data-app password (requires Manage token) -# --------------------------------------------------------------------------- - - -@data_app_app.command("password") -def data_app_password( - ctx: typer.Context, - project: str = typer.Option(..., "--project", help="Project alias"), - app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), -) -> None: - """Retrieve the simpleAuth password for a password-gated data app. - - Requires the Manage API token in addition to the project's Storage - token. Default-deny since 0.28.0: read from an interactive hidden - prompt; pass top-level --allow-env-manage-token to read - KBC_MANAGE_API_TOKEN from env (CI/CD). Never persisted, never logged. - """ - formatter = get_formatter(ctx) - service = get_service(ctx, "data_app_service") - manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) - - try: - result = service.get_data_app_password( - alias=project, app_id=app_id, manage_token=manage_token - ) - except KeboolaApiError as exc: - formatter.error( - message=exc.message, - error_code=exc.error_code, - retryable=exc.retryable, - details=exc.details, - ) - raise typer.Exit(code=map_error_to_exit_code(exc)) from None - except ConfigError as exc: - formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) - raise typer.Exit(code=5) from None - - formatter.output( - result, - lambda c, d: ( - c.print(f"[bold green]Success:[/bold green] {d['message']}"), - c.print(f"\n[bold yellow]Password:[/bold yellow] {d['password']}"), - ), - ) - - # --------------------------------------------------------------------------- # data-app logs (Data Science /apps/{id}/logs/tail) # --------------------------------------------------------------------------- @@ -1004,9 +1001,11 @@ def data_app_validate_repo( raise typer.Exit(code=1) -# Attach the data-app git-* and secrets-* commands. They live in -# _data_app_git.py / _data_app_runtime.py to keep this module under the -# file-size budget (CONTRIBUTING.md "File-size budgets"); they still register -# as `kbagent data-app git-*` / `secrets-*` on this sub-app. +# Attach the data-app git-*, secrets-* and password commands. They live in +# _data_app_git.py / _data_app_runtime.py / _data_app_password.py to keep this +# module under the file-size budget (CONTRIBUTING.md "File-size budgets"); they +# still register as `kbagent data-app git-*` / `secrets-*` / `password` on this +# sub-app. register_git_commands(data_app_app) register_secrets_commands(data_app_app) +register_password_command(data_app_app) diff --git a/src/keboola_agent_cli/data_science_client.py b/src/keboola_agent_cli/data_science_client.py index 0cc5dfe38..05242a4ab 100644 --- a/src/keboola_agent_cli/data_science_client.py +++ b/src/keboola_agent_cli/data_science_client.py @@ -8,10 +8,10 @@ URL derivation: ``https://data-science.`` from the project's connection URL via ``BaseHttpClient._derive_service_url``. Auth: same -``X-StorageApi-Token`` as the Storage API. The single exception is -``GET /apps/{id}/password`` which additionally requires -``X-KBC-ManageApiToken`` -- the manage token is passed per-call so the -client itself stays project-scoped. +``X-StorageApi-Token`` as the Storage API (or, for a browser-login session, +``Authorization: Bearer`` + ``X-KBC-ProjectId`` through ``http_auth``). Every +endpoint below, ``GET /apps/{id}/password`` included, needs only a token of +the app's own project. Verified shapes (writeup §2 / §6 / §9, replayed in this PR's live validation): @@ -25,9 +25,8 @@ ``config:{...}`` is silently dropped) DELETE /apps/{id} -> 202, cascades to Storage config - GET /apps/{id}/password -> 200, {password: "<20 hex>"} - (requires both Storage and - Manage tokens) + GET /apps/{id}/password -> 200, {password: "<20 hex>" | null} + (null = no password yet) GET /apps/{id}/logs/tail -> 200, text/plain container log tail. ``lines=N`` and ``since=ISO8601`` are mutually @@ -211,28 +210,16 @@ def delete_app(self, app_id: str) -> None: """ self._do_request("DELETE", f"/apps/{quote(str(app_id), safe='')}") - def get_app_password(self, app_id: str, manage_token: str) -> dict[str, Any]: - """Retrieve the auto-generated simpleAuth password. + def get_app_password(self, app_id: str) -> dict[str, Any]: + """Return ``{"password": str | None}`` for a password-protected app. - Requires both the project's Storage token (already on - ``self._client``) AND a Manage API token, supplied per-call so the - manage token never lives on the client instance. - - The 20-character hex password is auto-generated at app create time - and is NOT rotatable -- to change it you must delete and recreate - the app (writeup §11.2). + The sandboxes-service authorizes this call with the project token + alone (``StorageApiTokenAuth`` + ``CanManageApp``, which only checks + that the token's project is the app's project), the same call the + Keboola UI makes. ``password`` is ``None`` while the app has no + password yet. """ - path = f"/apps/{quote(str(app_id), safe='')}/password" - # Pass the Manage token via per-request `headers=`. httpx merges these - # with the client's persistent headers for this call only, so the - # manage token never lives on `self._client`. Using `_do_request` - # gives us the same retry/backoff and uniform error mapping as every - # other call in this client (no bespoke try/except needed). - response = self._do_request( - "GET", - path, - headers={"X-KBC-ManageApiToken": manage_token}, - ) + response = self._do_request("GET", f"/apps/{quote(str(app_id), safe='')}/password") return response.json() def tail_app_logs( diff --git a/src/keboola_agent_cli/server/routers/data_apps.py b/src/keboola_agent_cli/server/routers/data_apps.py index 2f39dd312..cf0a10b5a 100644 --- a/src/keboola_agent_cli/server/routers/data_apps.py +++ b/src/keboola_agent_cli/server/routers/data_apps.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel -from ..dependencies import ServiceRegistry, get_manage_token, get_registry +from ..dependencies import ServiceRegistry, get_registry router = APIRouter(prefix="/data-apps", tags=["data-apps"]) @@ -201,19 +201,38 @@ def delete( return registry.data_app.delete_data_app(alias=project, app_id=app_id) -@router.get("/{project}/{app_id}/password", summary="Get data app access password") +@router.get( + "/{project}/{app_id}/password", + summary="Get data app password metadata (password only with reveal=true)", +) def password( project: str, app_id: str, - manage_token: str | None = Depends(get_manage_token), + reveal: bool = False, registry: ServiceRegistry = Depends(get_registry), ) -> dict[str, Any]: - """Fetch the password for a password-protected data app. Mirrors `kbagent data-app password`.""" - if not manage_token: - raise HTTPException(status_code=401, detail="Missing X-Manage-Token header.") - return registry.data_app.get_data_app_password( - alias=project, app_id=app_id, manage_token=manage_token - ) + """Password metadata of a password-protected data app. Mirrors `kbagent data-app password`. + + Needs only the project token. The server cannot use the caller's + clipboard, so by default the response leaves the password out + (`password_delivered_to: null`) and `ui_url` names the Keboola UI page + that shows it. `reveal=true` adds `password` + (`password_delivered_to: "response"`). + """ + lookup = registry.data_app.get_data_app_password(alias=project, app_id=app_id) + if not reveal: + hint = lookup.ui_hint() + return { + **lookup.metadata(), + "password_delivered_to": None, + "message": f"The password of data app {lookup.app_id} is not in this response. {hint}", + } + return { + **lookup.metadata(), + "password_delivered_to": "response", + "message": f"The password of data app {lookup.app_id} is in `password`.", + "password": lookup.password, + } @router.get("/{project}/{app_id}/logs", summary="Tail data app container logs") diff --git a/src/keboola_agent_cli/services/_data_app_password.py b/src/keboola_agent_cli/services/_data_app_password.py new file mode 100644 index 000000000..598df8c78 --- /dev/null +++ b/src/keboola_agent_cli/services/_data_app_password.py @@ -0,0 +1,136 @@ +"""Pure helpers for ``DataAppService.get_data_app_password``. + +Split out of ``data_app_service.py``, which is on the file-size ratchet +(CONTRIBUTING.md "File-size budgets"). The service method does the three API +calls; this module holds the decisions around them: + +- which auth a data-app config uses (the Keboola UI fetches the password only + when ``auth_providers[0].type == "password"``, and so does kbagent); +- the Keboola UI page that shows the password (``ui_url``); +- the result shape. :class:`DataAppPassword` keeps the password apart from the + metadata and out of ``repr``, so no formatter can print it by accident. The + password reaches output only where a caller adds it on purpose (``--reveal`` + on the CLI, ``reveal=true`` over ``serve``). + +The password must never be part of an exception message: the command layer +sends error text to telemetry (``commands/_helpers.map_error_to_exit_code``). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from ..errors import ErrorCode, KeboolaApiError +from ..models import ProjectConfig + +PASSWORD_AUTH = "password" +PUBLIC_AUTH = "public" +MISSING_AUTH = "missing" +UNKNOWN_AUTH = "unknown" + + +@dataclass(frozen=True) +class DataAppPassword: + """Metadata of a password-protected data app, plus its password. + + ``password`` is excluded from ``repr`` and from :meth:`metadata`, so + printing or serializing the metadata never includes it. + """ + + project_alias: str + app_id: str + auth: str + app_url: str + ui_url: str | None + password: str = field(repr=False) + + def metadata(self) -> dict[str, Any]: + """Every field except the password.""" + return { + "project_alias": self.project_alias, + "app_id": self.app_id, + "auth": self.auth, + "app_url": self.app_url, + "ui_url": self.ui_url, + } + + def ui_hint(self) -> str: + """Tell the user where the Keboola UI shows the password.""" + if self.ui_url: + return f"Open {self.ui_url} and log in; the password is shown under Open App." + return "Open the data app in the Keboola UI; the password is shown under Open App." + + +def app_branch_id(app: dict[str, Any]) -> int | None: + """The numeric ``branchId`` of a Data Science app record, or None (default branch).""" + raw = str(app.get("branchId") or "") + return int(raw) if raw.isdigit() else None + + +def data_app_auth_kind(configuration: dict[str, Any]) -> str: + """Name the auth a ``keboola.data-apps`` configuration sets. + + Returns the first auth provider's ``type`` (``password``, ``oidc``, ...), + ``public`` for an empty provider list (the UI "None" option), or + ``missing`` when the config has no ``authorization.app_proxy`` block. + """ + authorization = configuration.get("authorization") + app_proxy = authorization.get("app_proxy") if isinstance(authorization, dict) else None + if not isinstance(app_proxy, dict) or "auth_providers" not in app_proxy: + return MISSING_AUTH + providers = app_proxy.get("auth_providers") + if not isinstance(providers, list) or not providers: + return PUBLIC_AUTH + first = providers[0] + kind = first.get("type") if isinstance(first, dict) else None + return str(kind) if kind else UNKNOWN_AUTH + + +def require_password_auth(app_id: str, auth: str) -> None: + """Refuse an app whose auth is not ``password`` -- it has no password to read.""" + if auth != PASSWORD_AUTH: + raise KeboolaApiError( + error_code=ErrorCode.VALIDATION_ERROR, + message=( + f"Data app {app_id} does not use password authentication (auth: {auth}), " + "so it has no password to read." + ), + ) + + +def data_app_ui_url(project: ProjectConfig, branch_id: int | None, config_id: str) -> str | None: + """The Keboola UI detail page of a data app, or None when an id is unknown.""" + if project.project_id is None or not config_id: + return None + branch = branch_id if branch_id is not None else "default" + stack_url = project.stack_url.rstrip("/") + return f"{stack_url}/admin/projects/{project.project_id}/branch/{branch}/data-apps/{config_id}" + + +def build_data_app_password( + *, + alias: str, + app_id: str, + project: ProjectConfig, + app: dict[str, Any], + payload: Any, +) -> DataAppPassword: + """Assemble the result, or refuse when the app has no password yet (``null``).""" + password = payload.get("password") if isinstance(payload, dict) else None + if not password: + raise KeboolaApiError( + error_code=ErrorCode.NOT_FOUND, + message=( + f"Data app {app_id} has no password yet. " + "Deploy the app (`kbagent data-app deploy`), then try again." + ), + ) + return DataAppPassword( + project_alias=alias, + app_id=str(app_id), + auth=PASSWORD_AUTH, + app_url=str(app.get("url") or ""), + ui_url=data_app_ui_url(project, app_branch_id(app), str(app.get("configId") or "")), + password=str(password), + ) diff --git a/src/keboola_agent_cli/services/data_app_service.py b/src/keboola_agent_cli/services/data_app_service.py index 069015460..ea27d99df 100644 --- a/src/keboola_agent_cli/services/data_app_service.py +++ b/src/keboola_agent_cli/services/data_app_service.py @@ -22,6 +22,7 @@ import re import time from collections.abc import Callable +from contextlib import ExitStack from datetime import datetime from typing import Any @@ -44,6 +45,13 @@ _redact_storage_config, _secret_fingerprint, ) +from ._data_app_password import ( + DataAppPassword, + app_branch_id, + build_data_app_password, + data_app_auth_kind, + require_password_auth, +) from .base import BaseService, ClientFactory, make_session_aware_client_factory, project_error_entry from .encrypt_service import EncryptService @@ -796,47 +804,38 @@ def delete_data_app(self, alias: str, app_id: str) -> dict[str, Any]: ), } - def get_data_app_password( - self, - alias: str, - app_id: str, - manage_token: str, - ) -> dict[str, Any]: - """Return the auto-generated simpleAuth password. + def get_data_app_password(self, alias: str, app_id: str) -> DataAppPassword: + """Read the password of a password-protected data app. - Requires both project Storage token and a Manage API token. The - Manage token is passed per-call -- it is never persisted, never - attached to the long-lived client, and never logged. + Needs only the project token (static or session): the password + endpoint checks just that the token belongs to the app's project. + Reads the app record and its Storage config first and refuses + (``VALIDATION_ERROR``) when the auth is not ``password`` -- the same + check the Keboola UI makes before it asks for the password. The + password comes back apart from the metadata; see + :class:`DataAppPassword`. """ - if not manage_token: - raise KeboolaApiError( - message=( - "Manage API token is required to read the data-app simpleAuth " - "password. Run interactively (default since v0.28.0), or pass " - "--allow-env-manage-token + set KBC_MANAGE_API_TOKEN for CI." - ), - status_code=0, - error_code=ErrorCode.INVALID_TOKEN, - retryable=False, - ) projects = self.resolve_projects([alias]) project = projects[alias] - ds_client = self._ds_client_factory(project.stack_url, project.token) - try: - payload = ds_client.get_app_password(app_id, manage_token=manage_token) - finally: - ds_client.close() - password = payload.get("password", "") if isinstance(payload, dict) else "" - return { - "project_alias": alias, - "app_id": str(app_id), - "password": password, - "message": ( - f"Retrieved simpleAuth password for data app {app_id}. " - "This password is auto-generated and cannot be rotated; " - "delete and recreate the app to mint a new one." - ), - } + # ExitStack closes whatever was created, also when the second factory raises. + with ExitStack() as clients: + ds_client = self._ds_client_factory(project.stack_url, project.token) + clients.callback(ds_client.close) + storage_client = self._client_factory(project.stack_url, project.token) + clients.callback(storage_client.close) + app = ds_client.get_app(app_id) + config_id = str(app.get("configId") or "") + configuration: dict[str, Any] = {} + if config_id: + detail = storage_client.get_config_detail( + DATA_APP_COMPONENT_ID, config_id, branch_id=app_branch_id(app) + ) + configuration = _coerce_config_dict(detail.get("configuration")) + require_password_auth(str(app_id), data_app_auth_kind(configuration)) + payload = ds_client.get_app_password(app_id) + return build_data_app_password( + alias=alias, app_id=app_id, project=project, app=app, payload=payload + ) def get_app_logs( self, @@ -2115,9 +2114,7 @@ def _format_create_message( # wait=True if state == RUNNING_STATE: tail = ( - " Run `kbagent data-app password` to retrieve the simpleAuth password." - if auth == "password" - else "" + " Copy its password with `kbagent data-app password`." if auth == "password" else "" ) return f"Data app '{name}' is running.{tail}" return f"Data app '{name}' deploy reached state={state}." diff --git a/tests/test_auth_environment.py b/tests/test_auth_environment.py index 40891c1a9..3d46ec32a 100644 --- a/tests/test_auth_environment.py +++ b/tests/test_auth_environment.py @@ -350,6 +350,20 @@ def test_returns_true_and_dispatches_open_on_a_thread( assert opened == ["https://connection.keboola.com/admin/auth/pkce/authorize?x=1"] + def test_wait_seconds_joins_the_opener_thread(self, monkeypatch: pytest.MonkeyPatch) -> None: + """With ``wait_seconds`` the open has run by the time the call returns. + + ``data-app password --open`` exits right after the call; a daemon + thread that has not run yet would die with the process. + """ + opened: list[str] = [] + + monkeypatch.setattr(environment.webbrowser, "get", lambda: object()) + monkeypatch.setattr(environment.webbrowser, "open", opened.append) + + assert open_browser("https://app.example.com", wait_seconds=5.0) is True + assert opened == ["https://app.example.com"] + def test_never_raises_even_when_webbrowser_open_itself_raises( self, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_data_app_cli.py b/tests/test_data_app_cli.py index e63bc7e7c..50f248653 100644 --- a/tests/test_data_app_cli.py +++ b/tests/test_data_app_cli.py @@ -12,12 +12,16 @@ from typing import Any from unittest.mock import MagicMock, patch +import pytest from typer.testing import CliRunner +from keboola_agent_cli.auth import environment from keboola_agent_cli.cli import app +from keboola_agent_cli.commands import _url_copy from keboola_agent_cli.config_store import ConfigStore -from keboola_agent_cli.errors import ConfigError, KeboolaApiError +from keboola_agent_cli.errors import ConfigError, ErrorCode, KeboolaApiError from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.services._data_app_password import DataAppPassword from keboola_agent_cli.services.config_service import ConfigService from keboola_agent_cli.services.job_service import JobService from keboola_agent_cli.services.project_service import ProjectService @@ -519,52 +523,63 @@ def test_delete_with_yes(self, tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# data-app password (manage token) +# data-app password -- option handling with the service mocked. Delivery (the +# terminal prompt, --copy), the leak checks and the request headers run end +# to end in tests/test_data_app_password.py. # --------------------------------------------------------------------------- +def _password_lookup() -> DataAppPassword: + return DataAppPassword( + project_alias="prod", + app_id="42", + auth="password", + app_url="https://app-42.hub.keboola.com", + ui_url="https://connection.keboola.com/admin/projects/1234/branch/default/data-apps/c1", + password="deadbeefcafe", + ) + + class TestDataAppPassword: - def test_password_success(self, tmp_path: Path, monkeypatch) -> None: + @pytest.fixture(autouse=True) + def _no_clipboard_no_browser(self, monkeypatch: pytest.MonkeyPatch) -> list[str]: + """No real clipboard or browser; records the URLs --open asks for.""" + monkeypatch.setattr(_url_copy, "detect_clipboard", lambda: None) + monkeypatch.setattr(_url_copy, "stdio_is_interactive", lambda: False) + opened: list[str] = [] + + def _open(url: str, *, wait_seconds: float = 0.0) -> bool: + assert wait_seconds > 0 + opened.append(url) + return True + + monkeypatch.setattr(environment, "open_browser", _open) + return opened + + def _store(self, tmp_path: Path) -> ConfigStore: config_dir = tmp_path / "config" config_dir.mkdir() - store = _setup_config(config_dir, {"prod": {"token": TEST_TOKEN}}) - mock = MagicMock() - mock.get_data_app_password.return_value = { - "project_alias": "prod", - "app_id": "42", - "password": "deadbeefcafe", - "message": "Retrieved.", - } + return _setup_config(config_dir, {"prod": {"token": TEST_TOKEN}}) + + def test_needs_no_manage_token(self, tmp_path: Path, monkeypatch) -> None: + """A Manage token in env without --allow-env-manage-token no longer blocks it.""" monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "manage-token") + mock = MagicMock() + mock.get_data_app_password.return_value = _password_lookup() result = _invoke( - [ - "--allow-env-manage-token", - "--json", - "data-app", - "password", - "--project", - "prod", - "--app-id", - "42", - ], - store=store, + ["--json", "data-app", "password", "--project", "prod", "--app-id", "42"], + store=self._store(tmp_path), data_app_mock=mock, ) - assert result.exit_code == 0 - body = json.loads(result.output) - assert body["data"]["password"] == "deadbeefcafe" - # The Manage token should have been forwarded but never logged. - assert "manage-token" not in result.output - mock.get_data_app_password.assert_called_once_with( - alias="prod", app_id="42", manage_token="manage-token" - ) + assert result.exit_code == 0, result.output + mock.get_data_app_password.assert_called_once_with(alias="prod", app_id="42") + data = json.loads(result.output)["data"] + assert data["password_delivered_to"] is None + assert "password" not in data + assert "deadbeefcafe" not in result.output - def test_password_missing_manage_token_no_tty(self, tmp_path: Path, monkeypatch) -> None: - config_dir = tmp_path / "config" - config_dir.mkdir() - store = _setup_config(config_dir, {"prod": {"token": TEST_TOKEN}}) + def test_reveal_and_copy_are_mutually_exclusive(self, tmp_path: Path) -> None: mock = MagicMock() - monkeypatch.delenv("KBC_MANAGE_API_TOKEN", raising=False) result = _invoke( [ "--json", @@ -574,12 +589,64 @@ def test_password_missing_manage_token_no_tty(self, tmp_path: Path, monkeypatch) "prod", "--app-id", "42", + "--reveal", + "--copy", ], - store=store, + store=self._store(tmp_path), data_app_mock=mock, ) - # CliRunner stdin is non-TTY, so resolve_manage_token returns exit 2. assert result.exit_code == 2 + assert json.loads(result.output)["error"]["code"] == "INVALID_ARGUMENT" + mock.get_data_app_password.assert_not_called() + + def test_reveal_prints_the_password(self, tmp_path: Path) -> None: + mock = MagicMock() + mock.get_data_app_password.return_value = _password_lookup() + result = _invoke( + ["data-app", "password", "--project", "prod", "--app-id", "42", "--reveal"], + store=self._store(tmp_path), + data_app_mock=mock, + ) + assert result.exit_code == 0, result.output + assert "Password: deadbeefcafe" in result.output + + def test_open_opens_the_app_url(self, tmp_path: Path, _no_clipboard_no_browser) -> None: + mock = MagicMock() + mock.get_data_app_password.return_value = _password_lookup() + result = _invoke( + ["--json", "data-app", "password", "--project", "prod", "--app-id", "42", "--open"], + store=self._store(tmp_path), + data_app_mock=mock, + ) + assert result.exit_code == 0, result.output + assert _no_clipboard_no_browser == ["https://app-42.hub.keboola.com"] + assert json.loads(result.output)["data"]["app_opened"] is True + + def test_open_without_a_browser_reports_false(self, tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(environment, "open_browser", lambda url, *, wait_seconds=0.0: False) + mock = MagicMock() + mock.get_data_app_password.return_value = _password_lookup() + result = _invoke( + ["--json", "data-app", "password", "--project", "prod", "--app-id", "42", "--open"], + store=self._store(tmp_path), + data_app_mock=mock, + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["data"]["app_opened"] is False + + def test_validation_error_maps_to_exit_1(self, tmp_path: Path) -> None: + mock = MagicMock() + mock.get_data_app_password.side_effect = KeboolaApiError( + error_code=ErrorCode.VALIDATION_ERROR, + message="Data app 42 does not use password authentication (auth: oidc).", + ) + result = _invoke( + ["--json", "data-app", "password", "--project", "prod", "--app-id", "42"], + store=self._store(tmp_path), + data_app_mock=mock, + ) + assert result.exit_code == 1 + assert json.loads(result.output)["error"]["code"] == "VALIDATION_ERROR" # --------------------------------------------------------------------------- diff --git a/tests/test_data_app_password.py b/tests/test_data_app_password.py new file mode 100644 index 000000000..6d37d405c --- /dev/null +++ b/tests/test_data_app_password.py @@ -0,0 +1,1179 @@ +"""End-to-end tests for ``data-app password`` (CLI-23). + +The real CLI, service and HTTP clients run against pytest-httpx; only the +clipboard tool, the terminal and the browser are faked, so no test touches the +real clipboard, opens a browser or reaches a Keboola API. Three concerns: + +1. Delivery: the terminal prompt (``c`` / Enter / timeout / no clipboard, and + on a real pty), ``--copy`` without a terminal, ``--reveal``, the + ``create --dry-run`` plan. +2. No leak: on every path except ``--reveal`` the password is absent from + stdout, stderr, the DEBUG log (``--verbose``) and the telemetry event -- + for ``data-app password`` and for ``create`` / ``deploy --wait``. +3. Auth: the requests carry only the project token -- ``X-StorageApi-Token``, + or ``Authorization: Bearer`` + ``X-KBC-ProjectId`` for a browser-login + session -- and never ``X-KBC-ManageApiToken``. +""" + +from __future__ import annotations + +import json +import logging +import os +import select +import subprocess +import sys +import time +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +from typer.testing import CliRunner, Result + +from helpers import setup_single_project +from keboola_agent_cli import telemetry +from keboola_agent_cli.auth import environment +from keboola_agent_cli.auth.models import StackSession +from keboola_agent_cli.auth.sentinel import make_session_token +from keboola_agent_cli.auth.state_store import AuthStateStore +from keboola_agent_cli.auth.token_provider import reset_provider_registry +from keboola_agent_cli.cli import app +from keboola_agent_cli.commands import _url_copy +from keboola_agent_cli.commands._data_app_password import ( + PasswordFlags, + password_after_deploy, +) +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError +from keboola_agent_cli.output import OutputFormatter +from keboola_agent_cli.services._data_app_bodies import ( + _build_public_auth_block, + _build_simple_auth_block, +) +from keboola_agent_cli.services.data_app_service import DataAppService + +if sys.platform != "win32": + import pty + +runner = CliRunner() + +SENTINEL = "pw-sentinel-0f3c9a2b7e" +STATIC_TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" +DS_URL = "https://data-science.keboola.com" +CONFIG_URL = "https://connection.keboola.com/v2/storage/components/keboola.data-apps/configs/cfg-1" +APP_URL = "https://app-42.hub.keboola.com" +UI_URL = "https://connection.keboola.com/admin/projects/258/branch/default/data-apps/cfg-1" + + +class _Clipboard: + """Fake copier returned by the patched ``detect_clipboard``.""" + + def __init__(self, *, works: bool = True) -> None: + self.copied: list[str] = [] + self._works = works + + def __call__(self, text: str) -> bool: + self.copied.append(text) + return self._works + + +def _mock_api( + httpx_mock: Any, + *, + authorization: dict[str, Any] | None = None, + password: str | None = SENTINEL, + password_call: bool = True, +) -> None: + """The three calls: app record, its Storage config, the password.""" + httpx_mock.add_response( + method="GET", + url=f"{DS_URL}/apps/42", + json={"id": 42, "configId": "cfg-1", "branchId": None, "url": APP_URL}, + ) + block = _build_simple_auth_block() if authorization is None else authorization + httpx_mock.add_response( + method="GET", + url=CONFIG_URL, + json={"id": "cfg-1", "configuration": {"authorization": block}}, + ) + if password_call: + httpx_mock.add_response( + method="GET", url=f"{DS_URL}/apps/42/password", json={"password": password} + ) + + +@pytest.fixture +def config_dir(tmp_config_dir: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A static-token project, no clipboard tool, no browser, telemetry on.""" + setup_single_project(tmp_config_dir, token=STATIC_TOKEN) + for var in ("KBAGENT_DISABLE_TELEMETRY", "DO_NOT_TRACK"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr(_url_copy, "detect_clipboard", lambda: None) + monkeypatch.setattr(_url_copy, "stdio_is_interactive", lambda: False) + + def _no_browser(url: str, *, wait_seconds: float = 0.0) -> bool: + raise AssertionError("a test without --open must not open a browser") + + monkeypatch.setattr(environment, "open_browser", _no_browser) + return tmp_config_dir + + +def _clipboard(monkeypatch: pytest.MonkeyPatch, *, works: bool = True) -> _Clipboard: + clipboard = _Clipboard(works=works) + monkeypatch.setattr(_url_copy, "detect_clipboard", lambda: clipboard) + return clipboard + + +def _terminal(monkeypatch: pytest.MonkeyPatch, keys: list[str | None]) -> None: + """A fake terminal: interactive stdio and a key reader fed from ``keys``.""" + feed: Iterator[str | None] = iter(keys) + monkeypatch.setattr(_url_copy, "stdio_is_interactive", lambda: True) + monkeypatch.setattr(_url_copy.CopyableUrlWait, "_enter_cbreak", lambda self: None) + monkeypatch.setattr( + _url_copy.CopyableUrlWait, "_read_key", lambda self, timeout: next(feed, None) + ) + + +def _argv(config_dir: Path, *extra: str, json_mode: bool) -> list[str]: + head = ["--verbose", "--config-dir", str(config_dir)] + if json_mode: + head.append("--json") + return [*head, "data-app", "password", "--project", "prod", "--app-id", "42", *extra] + + +def _run(argv: list[str], caplog: pytest.LogCaptureFixture) -> Result: + telemetry.reset() + caplog.set_level(logging.DEBUG) + return runner.invoke(app, argv) + + +def _telemetry_payload(argv: list[str], result: Result, monkeypatch: pytest.MonkeyPatch) -> str: + """Build the usage event this invocation would post; return it as text.""" + sent: list[dict[str, Any]] = [] + monkeypatch.setattr( + telemetry, "_send_event", lambda _config_store, **kwargs: sent.append(kwargs) + ) + telemetry.emit_cli_invocation(["kbagent", *argv], result.exit_code, None, 0.1) + assert len(sent) == 1, "the usage event was not built -- the check would prove nothing" + return repr(sent) + + +def _assert_no_leak( + argv: list[str], + result: Result, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert SENTINEL not in result.stdout + assert SENTINEL not in result.stderr + assert SENTINEL not in caplog.text + assert SENTINEL not in _telemetry_payload(argv, result, monkeypatch) + + +# --------------------------------------------------------------------------- +# Without a terminal (an AI agent, CI) and in --json mode +# --------------------------------------------------------------------------- + + +class TestWithoutTerminal: + @pytest.mark.parametrize("json_mode", [True, False]) + def test_default_copies_nothing_and_points_to_the_ui( + self, config_dir, httpx_mock, caplog, monkeypatch, json_mode: bool + ) -> None: + _mock_api(httpx_mock) + clipboard = _clipboard(monkeypatch) + argv = _argv(config_dir, json_mode=json_mode) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [] + assert "--copy" in result.stdout + assert UI_URL in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + def test_json_envelope(self, config_dir, httpx_mock, caplog, monkeypatch) -> None: + _mock_api(httpx_mock) + result = _run(_argv(config_dir, json_mode=True), caplog) + + data = json.loads(result.stdout)["data"] + assert data == { + "project_alias": "prod", + "app_id": "42", + "auth": "password", + "app_url": APP_URL, + "ui_url": UI_URL, + "password_delivered_to": None, + "app_opened": False, + "message": data["message"], + } + + @pytest.mark.parametrize("json_mode", [True, False]) + def test_copy_puts_the_password_on_the_clipboard( + self, config_dir, httpx_mock, caplog, monkeypatch, json_mode: bool + ) -> None: + _mock_api(httpx_mock) + clipboard = _clipboard(monkeypatch) + argv = _argv(config_dir, "--copy", json_mode=json_mode) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [SENTINEL] + assert "on the clipboard" in result.stdout + if json_mode: + assert '"password_delivered_to": "clipboard"' in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + @pytest.mark.parametrize("json_mode", [True, False]) + @pytest.mark.parametrize("tool", ["fails", "missing"]) + def test_copy_failure_exits_0_and_points_to_the_ui( + self, config_dir, httpx_mock, caplog, monkeypatch, json_mode: bool, tool: str + ) -> None: + _mock_api(httpx_mock) + if tool == "fails": + _clipboard(monkeypatch, works=False) + argv = _argv(config_dir, "--copy", json_mode=json_mode) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert "was not copied" in result.stdout + assert UI_URL in result.stdout + if json_mode: + assert '"password_delivered_to": null' in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + def test_json_in_a_terminal_never_prompts( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_api(httpx_mock) + clipboard = _clipboard(monkeypatch) + _terminal(monkeypatch, ["c"]) + argv = _argv(config_dir, json_mode=True) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [] + assert "Press c" not in result.output + assert '"password_delivered_to": null' in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + +# --------------------------------------------------------------------------- +# In a terminal: press c to copy +# --------------------------------------------------------------------------- + + +class TestTerminalPrompt: + def test_c_copies_once_then_enter_finishes( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_api(httpx_mock) + clipboard = _clipboard(monkeypatch) + _terminal(monkeypatch, ["c", "c", "\n"]) + argv = _argv(config_dir, json_mode=False) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [SENTINEL] + assert "Press c to copy the password, Enter to finish" in result.stdout + assert "Copied to clipboard" in result.stdout + assert APP_URL in result.stdout + assert UI_URL in result.stdout + assert "not copied" not in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + @pytest.mark.parametrize("key", ["\n", "\r", "\x1b", "q"]) + def test_finish_key_ends_without_copying( + self, config_dir, httpx_mock, caplog, monkeypatch, key: str + ) -> None: + _mock_api(httpx_mock) + clipboard = _clipboard(monkeypatch) + _terminal(monkeypatch, [key]) + argv = _argv(config_dir, json_mode=False) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [] + assert "No key pressed" not in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + def test_timeout_says_the_password_was_not_copied( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_api(httpx_mock) + clipboard = _clipboard(monkeypatch) + _terminal(monkeypatch, [None]) # _read_key timed out + argv = _argv(config_dir, json_mode=False) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [] + assert "No key pressed for 120 s. The password was not copied." in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + def test_failed_copy_says_so_and_points_to_the_ui( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_api(httpx_mock) + clipboard = _clipboard(monkeypatch, works=False) + _terminal(monkeypatch, ["c", "\n"]) + argv = _argv(config_dir, json_mode=False) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [SENTINEL] + assert "Copied to clipboard" not in result.stdout + assert "The password was not copied." in result.stdout + assert UI_URL in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + def test_no_clipboard_skips_the_prompt_and_points_to_the_ui( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_api(httpx_mock) + _terminal(monkeypatch, ["c"]) + argv = _argv(config_dir, json_mode=False) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert "Press c" not in result.stdout + assert "cannot be copied" in result.stdout + assert UI_URL in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + def test_copy_flag_in_a_terminal_copies_without_the_prompt( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_api(httpx_mock) + clipboard = _clipboard(monkeypatch) + _terminal(monkeypatch, []) + argv = _argv(config_dir, "--copy", json_mode=False) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [SENTINEL] + assert "Press c" not in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + +# --------------------------------------------------------------------------- +# Errors, --reveal +# --------------------------------------------------------------------------- + + +class TestErrorsAndReveal: + @pytest.mark.parametrize("json_mode", [True, False]) + def test_non_password_app_fails_before_the_password_call( + self, config_dir, httpx_mock, caplog, monkeypatch, json_mode: bool + ) -> None: + _mock_api(httpx_mock, authorization=_build_public_auth_block(), password_call=False) + argv = _argv(config_dir, json_mode=json_mode) + + result = _run(argv, caplog) + + assert result.exit_code == 1 + if json_mode: + assert json.loads(result.stdout)["error"]["code"] == "VALIDATION_ERROR" + assert "auth: public" in result.output + assert all(not r.url.path.endswith("/password") for r in httpx_mock.get_requests()) + _assert_no_leak(argv, result, caplog, monkeypatch) + + def test_no_password_yet_fails(self, config_dir, httpx_mock, caplog, monkeypatch) -> None: + _mock_api(httpx_mock, password=None) + argv = _argv(config_dir, json_mode=True) + + result = _run(argv, caplog) + + assert result.exit_code == 1 + assert '"code": "NOT_FOUND"' in result.stdout + assert "no password yet" in result.stdout + + @pytest.mark.parametrize("json_mode", [True, False]) + def test_reveal_prints_the_password_but_not_into_logs_or_telemetry( + self, config_dir, httpx_mock, caplog, monkeypatch, json_mode: bool + ) -> None: + _mock_api(httpx_mock) + clipboard = _clipboard(monkeypatch) + argv = _argv(config_dir, "--reveal", json_mode=json_mode) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert SENTINEL in result.stdout + assert clipboard.copied == [] + if json_mode: + assert '"password_delivered_to": "stdout"' in result.stdout + assert SENTINEL not in result.stderr + assert SENTINEL not in caplog.text + assert SENTINEL not in _telemetry_payload(argv, result, monkeypatch) + + +# --------------------------------------------------------------------------- +# Auth headers: project token only +# --------------------------------------------------------------------------- + + +def _seed_fresh_session(config_dir: Path, *, access_token: str) -> None: + now = datetime.now(UTC) + AuthStateStore(config_dir).put_session( + StackSession( + stack_url="https://connection.keboola.com", + session_id="s1", + access_token=access_token, + refresh_token="kbc_rt_x", + access_expires_at=now + timedelta(hours=1), + refresh_expires_at=now + timedelta(days=30), + created_at=now, + ) + ) + + +class TestAuthHeaders: + def test_static_token_sends_storage_token_and_no_manage_token( + self, tmp_config_dir: Path, httpx_mock, monkeypatch + ) -> None: + # A Manage token in env must not be picked up any more. + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "manage-token-must-not-be-sent") + store = setup_single_project(tmp_config_dir, token=STATIC_TOKEN) + _mock_api(httpx_mock) + + result = DataAppService(config_store=store).get_data_app_password("prod", "42") + + assert result.password == SENTINEL + requests = httpx_mock.get_requests() + assert [r.url.path for r in requests] == [ + "/apps/42", + "/v2/storage/components/keboola.data-apps/configs/cfg-1", + "/apps/42/password", + ] + for request in requests: + assert request.headers["X-StorageApi-Token"] == STATIC_TOKEN + assert "X-KBC-ManageApiToken" not in request.headers + assert "Authorization" not in request.headers + + def test_session_project_sends_bearer_and_project_id( + self, tmp_config_dir: Path, httpx_mock + ) -> None: + reset_provider_registry() + try: + setup_single_project(tmp_config_dir, token=make_session_token(258)) + _seed_fresh_session(tmp_config_dir, access_token="kbc_at_fresh") + _mock_api(httpx_mock) + + service = DataAppService(config_store=ConfigStore(config_dir=tmp_config_dir)) + result = service.get_data_app_password("prod", "42") + finally: + reset_provider_registry() + + assert result.password == SENTINEL + assert result.ui_url == UI_URL + requests = httpx_mock.get_requests() + assert len(requests) == 3 + for request in requests: + assert request.headers["Authorization"] == "Bearer kbc_at_fresh" + assert request.headers["X-KBC-ProjectId"] == "258" + assert "X-StorageApi-Token" not in request.headers + assert "X-KBC-ManageApiToken" not in request.headers + + +# --------------------------------------------------------------------------- +# After `deploy --wait` / `create --wait` +# --------------------------------------------------------------------------- + +_RUNNING_APP = { + "id": 42, + "configId": "cfg-1", + "branchId": None, + "url": APP_URL, + "state": "running", + "desiredState": "running", + "configVersion": "5", +} + + +def _mock_deployed_app( + httpx_mock: Any, + *, + authorization: dict[str, Any] | None = None, + password: str | None = SENTINEL, + password_call: bool = True, +) -> None: + """GET app + GET config (reused by the deploy and by the password read), then + the password. Only the deploy PATCH / create POST + PUT are added per test.""" + httpx_mock.add_response( + method="GET", url=f"{DS_URL}/apps/42", json=_RUNNING_APP, is_reusable=True + ) + block = _build_simple_auth_block() if authorization is None else authorization + configuration = { + "authorization": block, + "parameters": {"dataApp": {"git": {"repository": "https://github.com/o/r"}}}, + } + httpx_mock.add_response( + method="GET", + url=CONFIG_URL, + json={"id": "cfg-1", "version": "5", "configuration": configuration}, + is_reusable=True, + ) + if password_call: + httpx_mock.add_response( + method="GET", url=f"{DS_URL}/apps/42/password", json={"password": password} + ) + + +def _mock_deploy_patch(httpx_mock: Any) -> None: + httpx_mock.add_response( + method="PATCH", url=f"{DS_URL}/apps/42", json={**_RUNNING_APP, "state": "starting"} + ) + + +def _deploy_argv(config_dir: Path, *extra: str, json_mode: bool) -> list[str]: + head = ["--verbose", "--config-dir", str(config_dir)] + if json_mode: + head.append("--json") + return [*head, "data-app", "deploy", "--project", "prod", "--app-id", "42", *extra] + + +def _create_argv(config_dir: Path, *extra: str, json_mode: bool) -> list[str]: + head = ["--verbose", "--config-dir", str(config_dir)] + if json_mode: + head.append("--json") + return [ + *head, + "data-app", + "create", + "--project", + "prod", + "--name", + "App", + "--slug", + "my-app", + "--git-repo", + "https://github.com/o/r", + "--git-public", + *extra, + ] + + +def _mock_create(httpx_mock: Any) -> None: + httpx_mock.add_response( + method="POST", url=f"{DS_URL}/apps", json={"id": 42, "configId": "cfg-1", "url": APP_URL} + ) + httpx_mock.add_response(method="PUT", url=CONFIG_URL, json={"id": "cfg-1", "version": "2"}) + _mock_deploy_patch(httpx_mock) + + +def _deny_password_read(config_dir: Path) -> None: + """Persist a policy that allows everything but reading a data app password.""" + config_path = config_dir / "config.json" + config = json.loads(config_path.read_text()) + config["permissions"] = {"mode": "allow", "allow": [], "deny": ["data-app.password"]} + config_path.write_text(json.dumps(config)) + + +class TestPasswordPermission: + """Reading the password is the operation `data-app.password`, also after a deploy.""" + + @pytest.mark.parametrize( + "argv_tail", + [ + ("deploy", "--wait", "--copy"), + ("deploy", "--wait", "--reveal"), + ("create", "--wait", "--copy"), + ("create", "--dry-run", "--wait", "--reveal"), + ], + ) + def test_denied_password_read_exits_6_before_any_http_call( + self, config_dir, httpx_mock, caplog, argv_tail: tuple[str, ...] + ) -> None: + _deny_password_read(config_dir) + command, *extra = argv_tail + build = _create_argv if command == "create" else _deploy_argv + result = _run(build(config_dir, *extra, json_mode=True), caplog) + + assert result.exit_code == 6 + assert json.loads(result.stdout)["error"]["code"] == "PERMISSION_DENIED" + assert httpx_mock.get_requests() == [] + + def test_denied_password_read_skips_the_terminal_prompt( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _deny_password_read(config_dir) + _mock_deployed_app(httpx_mock, password_call=False) + _mock_deploy_patch(httpx_mock) + clipboard = _clipboard(monkeypatch) + _terminal(monkeypatch, ["c", "\n"]) + + result = _run(_deploy_argv(config_dir, "--wait", json_mode=False), caplog) + + assert result.exit_code == 0, result.output + assert "Press c to copy" not in result.stdout + assert clipboard.copied == [] + assert not any(r.url.path.endswith("/password") for r in httpx_mock.get_requests()) + + +class TestAfterDeploy: + def test_deploy_wait_in_a_terminal_prompts_and_copies( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_deployed_app(httpx_mock) + _mock_deploy_patch(httpx_mock) + clipboard = _clipboard(monkeypatch) + _terminal(monkeypatch, ["c", "\n"]) + argv = _deploy_argv(config_dir, "--wait", json_mode=False) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [SENTINEL] + assert "deploy requested" in result.stdout # the deploy result comes first + assert "Press c to copy the password, Enter to finish" in result.stdout + assert "Copied to clipboard" in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + def test_create_wait_in_a_terminal_prompts_and_copies( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_create(httpx_mock) + _mock_deployed_app(httpx_mock) + clipboard = _clipboard(monkeypatch) + _terminal(monkeypatch, ["c", "\n"]) + argv = _create_argv(config_dir, "--wait", json_mode=False) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [SENTINEL] + assert "is running" in result.stdout + assert "Press c to copy the password, Enter to finish" in result.stdout + _assert_no_leak(argv, result, caplog, monkeypatch) + + @pytest.mark.parametrize("json_mode", [True, False]) + @pytest.mark.parametrize("command", ["deploy", "create"]) + def test_no_flag_and_no_prompt_reads_nothing( + self, config_dir, httpx_mock, caplog, monkeypatch, command: str, json_mode: bool + ) -> None: + """--json (even on a terminal) or no terminal, no flag: output as before, no extra call.""" + if command == "create": + _mock_create(httpx_mock) + else: + _mock_deploy_patch(httpx_mock) + httpx_mock.add_response( + method="GET", + url=CONFIG_URL, + json={"id": "cfg-1", "version": "5", "configuration": {}}, + ) + httpx_mock.add_response( + method="GET", url=f"{DS_URL}/apps/42", json=_RUNNING_APP, is_reusable=True + ) + clipboard = _clipboard(monkeypatch) + if json_mode: + _terminal(monkeypatch, ["c"]) # a terminal, but --json never prompts + build = _create_argv if command == "create" else _deploy_argv + argv = build(config_dir, "--wait", json_mode=json_mode) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert all(not r.url.path.endswith("/password") for r in httpx_mock.get_requests()) + assert clipboard.copied == [] + assert "Press c" not in result.output + assert "--copy" not in result.stdout + if json_mode: + data = json.loads(result.stdout)["data"] + assert data["state"] == "running" + for key in ("ui_url", "password_delivered_to", "password", "warnings"): + assert key not in data + + @pytest.mark.parametrize("command", ["deploy", "create"]) + def test_copy_after_wait(self, config_dir, httpx_mock, caplog, monkeypatch, command) -> None: + if command == "create": + _mock_create(httpx_mock) + else: + _mock_deploy_patch(httpx_mock) + _mock_deployed_app(httpx_mock) + clipboard = _clipboard(monkeypatch) + build = _create_argv if command == "create" else _deploy_argv + argv = build(config_dir, "--wait", "--copy", json_mode=True) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert clipboard.copied == [SENTINEL] + assert json.loads(result.stdout)["data"]["password_delivered_to"] == "clipboard" + _assert_no_leak(argv, result, caplog, monkeypatch) + + @pytest.mark.parametrize("command", ["deploy", "create"]) + def test_reveal_after_wait(self, config_dir, httpx_mock, caplog, monkeypatch, command) -> None: + if command == "create": + _mock_create(httpx_mock) + else: + _mock_deploy_patch(httpx_mock) + _mock_deployed_app(httpx_mock) + build = _create_argv if command == "create" else _deploy_argv + argv = build(config_dir, "--wait", "--reveal", json_mode=True) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + data = json.loads(result.stdout)["data"] + assert data["password"] == SENTINEL + assert data["password_delivered_to"] == "stdout" + assert SENTINEL not in result.stderr + assert SENTINEL not in caplog.text + assert SENTINEL not in _telemetry_payload(argv, result, monkeypatch) + + @pytest.mark.parametrize( + "argv_tail", + [ + ("deploy", "--copy"), + ("deploy", "--reveal"), + ("deploy", "--wait", "--copy", "--reveal"), + ("create", "--copy"), + ("create", "--wait", "--no-deploy", "--copy"), + ("create", "--dry-run", "--copy"), + ("create", "--dry-run", "--wait", "--no-deploy", "--reveal"), + ("create", "--dry-run", "--wait", "--copy", "--reveal"), + ], + ) + def test_flags_that_cannot_work_exit_2_before_any_http_call( + self, config_dir, httpx_mock, caplog, argv_tail: tuple[str, ...] + ) -> None: + command, *extra = argv_tail + build = _create_argv if command == "create" else _deploy_argv + result = _run(build(config_dir, *extra, json_mode=True), caplog) + + assert result.exit_code == 2 + error = json.loads(result.stdout)["error"] + assert error["code"] == "INVALID_ARGUMENT" + if "--reveal" not in extra or "--copy" not in extra: + assert "creates the password during the deploy" in error["message"] + assert httpx_mock.get_requests() == [] + + def test_non_password_app_with_copy_warns_and_exits_0( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_deployed_app( + httpx_mock, authorization=_build_public_auth_block(), password_call=False + ) + _mock_deploy_patch(httpx_mock) + clipboard = _clipboard(monkeypatch) + argv = _deploy_argv(config_dir, "--wait", "--copy", json_mode=True) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + data = json.loads(result.stdout)["data"] + assert any("auth: public" in w for w in data["warnings"]) + assert "ui_url" not in data + assert clipboard.copied == [] + + def test_non_password_app_without_flags_stays_silent( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_deployed_app( + httpx_mock, authorization=_build_public_auth_block(), password_call=False + ) + _mock_deploy_patch(httpx_mock) + argv = _deploy_argv(config_dir, "--wait", json_mode=True) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + data = json.loads(result.stdout)["data"] + assert "warnings" not in data + assert "ui_url" not in data + + def test_create_with_public_auth_and_copy_warns_without_extra_calls( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _mock_create(httpx_mock) + httpx_mock.add_response( + method="GET", url=f"{DS_URL}/apps/42", json=_RUNNING_APP, is_reusable=True + ) + argv = _create_argv(config_dir, "--auth", "public", "--wait", "--copy", json_mode=True) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + data = json.loads(result.stdout)["data"] + assert any("uses auth 'public'" in w for w in data["warnings"]) + assert all( + r.method != "GET" or "/configs/" not in r.url.path for r in httpx_mock.get_requests() + ) + + @pytest.mark.parametrize("json_mode", [True, False]) + def test_password_read_failure_after_deploy_warns_and_exits_0( + self, config_dir, httpx_mock, caplog, monkeypatch, json_mode: bool + ) -> None: + _mock_deployed_app(httpx_mock, password=None) + _mock_deploy_patch(httpx_mock) + _clipboard(monkeypatch) + argv = _deploy_argv(config_dir, "--wait", "--copy", json_mode=json_mode) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + if json_mode: + data = json.loads(result.stdout)["data"] + assert any("is not ready yet" in w for w in data["warnings"]) + assert "password_delivered_to" not in data + else: + assert "is not ready yet" in result.stderr + _assert_no_leak(argv, result, caplog, monkeypatch) + + def test_managed_repo_with_copy_exits_2_before_any_http_call( + self, config_dir, httpx_mock, caplog + ) -> None: + argv = [ + "--config-dir", + str(config_dir), + "--json", + "data-app", + "create", + "--project", + "prod", + "--name", + "App", + "--slug", + "my-app", + "--use-managed-git-repo", + "--wait", + "--copy", + ] + result = _run(argv, caplog) + + assert result.exit_code == 2 + assert "--use-managed-git-repo" in json.loads(result.stdout)["error"]["message"] + assert httpx_mock.get_requests() == [] + + +# --------------------------------------------------------------------------- +# `create --dry-run`: same flag rules, a plan, no call, no prompt, no copy +# --------------------------------------------------------------------------- + + +class TestDryRun: + @pytest.mark.parametrize(("flag", "plan"), [("--copy", "clipboard"), ("--reveal", "stdout")]) + def test_valid_flags_add_the_plan( + self, config_dir, httpx_mock, caplog, monkeypatch, flag: str, plan: str + ) -> None: + clipboard = _clipboard(monkeypatch) + argv = _create_argv(config_dir, "--dry-run", "--wait", flag, json_mode=True) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + data = json.loads(result.stdout)["data"] + assert data["dry_run"] is True + assert data["password_delivery"] == plan + assert "warnings" not in data + assert clipboard.copied == [] + assert httpx_mock.get_requests() == [] + + def test_copy_without_a_clipboard_tool_warns(self, config_dir, httpx_mock, caplog) -> None: + argv = _create_argv(config_dir, "--dry-run", "--wait", "--copy", json_mode=True) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + data = json.loads(result.stdout)["data"] + assert data["password_delivery"] == "clipboard" + assert data["warnings"] == [ + "No clipboard tool was found, so the password would not be copied." + ] + + def test_public_auth_with_copy_warns_there_is_nothing_to_copy( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _clipboard(monkeypatch) + argv = _create_argv( + config_dir, "--dry-run", "--wait", "--auth", "public", "--copy", json_mode=True + ) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + data = json.loads(result.stdout)["data"] + assert "password_delivery" not in data + assert data["warnings"] == [ + "The data app uses auth 'public', so it has no password to copy." + ] + + def test_terminal_plans_the_prompt_without_running_it( + self, config_dir, httpx_mock, caplog, monkeypatch + ) -> None: + _clipboard(monkeypatch) + _terminal(monkeypatch, []) + read_calls: list[float] = [] + monkeypatch.setattr( + _url_copy.CopyableUrlWait, "_read_key", lambda self, timeout: read_calls.append(timeout) + ) + argv = _create_argv(config_dir, "--dry-run", "--wait", json_mode=False) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert "DRY RUN" in result.stdout + assert "Password after the deploy: prompt" in result.stdout + assert "Press c" not in result.stdout + assert read_calls == [] + + def test_json_without_flags_has_no_plan(self, config_dir, httpx_mock, caplog) -> None: + argv = _create_argv(config_dir, "--dry-run", "--wait", json_mode=True) + + result = _run(argv, caplog) + + assert result.exit_code == 0, result.output + assert "password_delivery" not in json.loads(result.stdout)["data"] + + +class TestPasswordAfterDeploy: + """Branches the end-to-end tests cannot reach (a successful --wait is always running).""" + + _JSON = OutputFormatter(json_mode=True) + + def test_no_wait_means_no_call(self) -> None: + service = MagicMock() + result: dict[str, Any] = {"app_id": "42", "state": "starting"} + flags = PasswordFlags(copy=True) + assert ( + password_after_deploy(self._JSON, service, result, flags, alias="p", waited=False) + is None + ) + service.get_data_app_password.assert_not_called() + assert "warnings" not in result + + def test_no_flag_and_no_prompt_means_no_call(self) -> None: + service = MagicMock() + result: dict[str, Any] = {"app_id": "42", "state": "running"} + lookup = password_after_deploy( + self._JSON, service, result, PasswordFlags(), alias="p", waited=True + ) + assert lookup is None + service.get_data_app_password.assert_not_called() + assert result == {"app_id": "42", "state": "running"} + + def test_not_running_with_a_flag_warns(self) -> None: + service = MagicMock() + result: dict[str, Any] = {"app_id": "42", "state": "starting"} + flags = PasswordFlags(copy=True) + assert ( + password_after_deploy(self._JSON, service, result, flags, alias="p", waited=True) + is None + ) + service.get_data_app_password.assert_not_called() + assert result["warnings"] == [ + "Data app 42 is not running (state=starting), so its password was not read." + ] + + def test_config_error_becomes_a_warning(self) -> None: + service = MagicMock() + service.get_data_app_password.side_effect = ConfigError("Project 'p' not found.") + result: dict[str, Any] = {"app_id": "42", "state": "running"} + flags = PasswordFlags(copy=True) + assert ( + password_after_deploy(self._JSON, service, result, flags, alias="p", waited=True) + is None + ) + assert result["warnings"] == [ + ( + "The deploy succeeded, but the password was not read (Project 'p' not found.); " + "run `kbagent data-app password` to try again." + ) + ] + + def test_unexpected_exception_is_a_warning_without_its_text(self) -> None: + service = MagicMock() + service.get_data_app_password.side_effect = RuntimeError("internal detail") + result: dict[str, Any] = {"app_id": "42", "state": "running"} + flags = PasswordFlags(reveal=True) + assert ( + password_after_deploy(self._JSON, service, result, flags, alias="p", waited=True) + is None + ) + (warning,) = result["warnings"] + assert "RuntimeError" in warning + assert "internal detail" not in warning + + +# --------------------------------------------------------------------------- +# A real terminal: the prompt runs in a child process on a pty +# --------------------------------------------------------------------------- + +# The child runs the real delivery (deliver_password -> terminal prompt) with +# the pty as its controlling terminal, a fake copier, and writes what happened +# to a report file. The password comes in argv and is never printed. +_PTY_CHILD = r""" +import fcntl, json, sys, termios +# A new session (start_new_session) takes the pty as its controlling terminal, +# as a login shell does, so Ctrl+C and the foreground check work as for a user. +fcntl.ioctl(0, termios.TIOCSCTTY, 0) +from keboola_agent_cli.commands import _data_app_password as pw +from keboola_agent_cli.commands import _url_copy +from keboola_agent_cli.output import OutputFormatter +from keboola_agent_cli.services._data_app_password import DataAppPassword + +report_path, password = sys.argv[1], sys.argv[2] +copied = [] +def fake_copier(text): + copied.append(text) + return True +_url_copy.detect_clipboard = lambda: fake_copier +pw._COPY_PROMPT_TIMEOUT_SECONDS = 20.0 +lookup = DataAppPassword( + project_alias="p", app_id="42", auth="password", + app_url="https://app-42.example", ui_url="https://ui.example", password=password, +) +outcome, delivered = "returned", None +try: + delivered = pw.deliver_password(OutputFormatter(), lookup, pw.PasswordFlags()).delivered_to +except KeyboardInterrupt: + outcome = "interrupted" +lflag = termios.tcgetattr(sys.stdin.fileno())[3] +with open(report_path, "w") as f: + json.dump({ + "outcome": outcome, + "delivered_to": delivered, + "copied_password": copied == [password], + "copies": len(copied), + "icanon": bool(lflag & termios.ICANON), + "echo": bool(lflag & termios.ECHO), + }, f) +""" + +_PTY_DEADLINE_SECONDS = 30.0 + + +class _PtyChild: + """The child on its pty: read its output, type keys, wait for its report.""" + + def __init__(self, tmp_path: Path) -> None: + self.report = tmp_path / "report.json" + self.output = b"" + self.master, slave = pty.openpty() + self.proc = subprocess.Popen( + [sys.executable, "-c", _PTY_CHILD, str(self.report), SENTINEL], + stdin=slave, + stdout=slave, + stderr=slave, + start_new_session=True, + ) + os.close(slave) + + def _read_available(self, timeout: float) -> bool: + ready, _, _ = select.select([self.master], [], [], timeout) + if not ready: + return True + try: + chunk = os.read(self.master, 4096) + except OSError: # EIO: the child closed its side + return False + self.output += chunk + return bool(chunk) + + def _drain(self) -> None: + """Read what the child left in the pty after it exited.""" + while True: + ready, _, _ = select.select([self.master], [], [], 0.05) + if not ready: + return + try: + chunk = os.read(self.master, 4096) + except OSError: + return + if not chunk: + return + self.output += chunk + + def wait_for_output(self, text: bytes) -> None: + deadline = time.monotonic() + _PTY_DEADLINE_SECONDS + while text not in self.output: + assert time.monotonic() < deadline, f"no {text!r} in {self.output!r}" + if not self._read_available(0.1): + raise AssertionError(f"child ended before {text!r}: {self.output!r}") + + def type(self, keys: bytes) -> None: + os.write(self.master, keys) + + def is_running(self) -> bool: + return self.proc.poll() is None + + def finish(self) -> dict[str, Any]: + deadline = time.monotonic() + _PTY_DEADLINE_SECONDS + try: + while self.is_running(): + self._read_available(0.05) + assert time.monotonic() < deadline, f"child still running: {self.output!r}" + self._drain() + finally: + if self.is_running(): + self.proc.kill() + self.proc.wait() + os.close(self.master) + assert SENTINEL.encode() not in self.output + return json.loads(self.report.read_text()) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX pty") +class TestRealTerminal: + def test_c_then_enter(self, tmp_path: Path) -> None: + child = _PtyChild(tmp_path) + child.wait_for_output(b"Press c") + child.type(b"c") + child.wait_for_output(b"Copied to clipboard") + child.type(b"\n") + report = child.finish() + assert report["delivered_to"] == "clipboard" + assert report["copied_password"] is True + assert report["icanon"] and report["echo"] + + def test_c_and_enter_in_one_burst(self, tmp_path: Path) -> None: + child = _PtyChild(tmp_path) + child.wait_for_output(b"Press c") + started = time.monotonic() + child.type(b"c\n") + report = child.finish() + assert time.monotonic() - started < 10 # the Enter was seen, no timeout + assert report["delivered_to"] == "clipboard" + assert report["copies"] == 1 + + def test_arrow_key_is_ignored(self, tmp_path: Path) -> None: + child = _PtyChild(tmp_path) + child.wait_for_output(b"Press c") + child.type(b"\x1b[A") + time.sleep(0.5) + assert child.is_running() # the arrow did not end the prompt + child.type(b"q") + report = child.finish() + assert report["outcome"] == "returned" + assert report["delivered_to"] is None + assert b"No key pressed" not in child.output + + def test_lone_esc_finishes(self, tmp_path: Path) -> None: + child = _PtyChild(tmp_path) + child.wait_for_output(b"Press c") + child.type(b"\x1b") + report = child.finish() + assert report["delivered_to"] is None + assert report["copies"] == 0 + + def test_ctrl_c_restores_the_terminal(self, tmp_path: Path) -> None: + child = _PtyChild(tmp_path) + child.wait_for_output(b"Press c") + child.type(b"\x03") + report = child.finish() + assert report["outcome"] == "interrupted" + assert report["icanon"] is True + assert report["echo"] is True diff --git a/tests/test_data_app_service.py b/tests/test_data_app_service.py index bf9d111a6..3364733c4 100644 --- a/tests/test_data_app_service.py +++ b/tests/test_data_app_service.py @@ -2,7 +2,7 @@ Covers: input validation, the §9 redeploy contract, cleanup-in-finally, the §8 pitfall #1 (transient stopped during initial deploy), encryption -round-trip, and password retrieval. +round-trip, and password retrieval (auth gating, null password, UI URL). The tests speak to a fully-mocked Data Science + Storage + Encryption stack -- they verify orchestration, not HTTP shapes (those live in @@ -20,6 +20,10 @@ from keboola_agent_cli.config_store import ConfigStore from keboola_agent_cli.errors import ErrorCode, KeboolaApiError from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.services._data_app_bodies import ( + _build_public_auth_block, + _build_simple_auth_block, +) from keboola_agent_cli.services.data_app_service import ( DataAppService, _redact_git_block, @@ -28,7 +32,6 @@ ) TEST_TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" -TEST_MANAGE_TOKEN = "manage-test-token" # --------------------------------------------------------------------------- @@ -1318,24 +1321,116 @@ def test_diagnostic_is_best_effort(self, tmp_path: Path) -> None: # --------------------------------------------------------------------------- +def _stub_password_app( + ds_mock: MagicMock, + storage_mock: MagicMock, + *, + authorization: dict[str, Any] | None, + branch_id: Any = None, + password: str | None = "deadbeefcafe", +) -> None: + """A deployed app record, its Storage config (auth block) and its password.""" + ds_mock.get_app.return_value = { + "id": 42, + "configId": "cfg-1", + "branchId": branch_id, + "url": "https://app-42.hub.keboola.com", + } + configuration = {} if authorization is None else {"authorization": authorization} + storage_mock.get_config_detail.return_value = {"id": "cfg-1", "configuration": configuration} + ds_mock.get_app_password.return_value = {"password": password} + + class TestDataAppPassword: - def test_returns_password(self, tmp_path: Path) -> None: + def test_returns_metadata_and_password_apart(self, tmp_path: Path) -> None: store = _make_store(tmp_path) - service, ds_mock, _storage, _enc = _make_service(store) - ds_mock.get_app_password.return_value = {"password": "deadbeefcafe"} + service, ds_mock, storage_mock, _enc = _make_service(store) + _stub_password_app(ds_mock, storage_mock, authorization=_build_simple_auth_block()) + + result = service.get_data_app_password(alias="prod", app_id="42") - result = service.get_data_app_password( - alias="prod", app_id="42", manage_token=TEST_MANAGE_TOKEN + assert result.password == "deadbeefcafe" + assert result.metadata() == { + "project_alias": "prod", + "app_id": "42", + "auth": "password", + "app_url": "https://app-42.hub.keboola.com", + "ui_url": ( + "https://connection.keboola.com/admin/projects/5725/branch/default/data-apps/cfg-1" + ), + } + # Neither the metadata nor the repr carries the password. + assert "deadbeefcafe" not in repr(result) + assert "deadbeefcafe" not in str(result.metadata()) + # Only the project token: no Manage token argument any more. + ds_mock.get_app_password.assert_called_once_with("42") + storage_mock.get_config_detail.assert_called_once_with( + "keboola.data-apps", "cfg-1", branch_id=None ) - assert result["password"] == "deadbeefcafe" - ds_mock.get_app_password.assert_called_once_with("42", manage_token=TEST_MANAGE_TOKEN) + ds_mock.close.assert_called_once() + storage_mock.close.assert_called_once() + + @pytest.mark.parametrize( + ("authorization", "auth_kind"), + [ + ( + {"app_proxy": {"auth_providers": [{"id": "oidc", "type": "oidc"}]}}, + "oidc", + ), + (_build_public_auth_block(), "public"), + (None, "missing"), + ], + ) + def test_non_password_auth_is_refused_before_the_password_call( + self, tmp_path: Path, authorization: dict[str, Any] | None, auth_kind: str + ) -> None: + store = _make_store(tmp_path) + service, ds_mock, storage_mock, _enc = _make_service(store) + _stub_password_app(ds_mock, storage_mock, authorization=authorization) + + with pytest.raises(KeboolaApiError) as excinfo: + service.get_data_app_password(alias="prod", app_id="42") + + assert excinfo.value.error_code == ErrorCode.VALIDATION_ERROR + assert f"auth: {auth_kind}" in excinfo.value.message + ds_mock.get_app_password.assert_not_called() + ds_mock.close.assert_called_once() + storage_mock.close.assert_called_once() - def test_missing_manage_token(self, tmp_path: Path) -> None: + def test_no_password_yet_is_not_found(self, tmp_path: Path) -> None: store = _make_store(tmp_path) - service, _ds, _storage, _enc = _make_service(store) + service, ds_mock, storage_mock, _enc = _make_service(store) + _stub_password_app( + ds_mock, storage_mock, authorization=_build_simple_auth_block(), password=None + ) + with pytest.raises(KeboolaApiError) as excinfo: - service.get_data_app_password(alias="prod", app_id="42", manage_token="") - assert excinfo.value.error_code == ErrorCode.INVALID_TOKEN + service.get_data_app_password(alias="prod", app_id="42") + + assert excinfo.value.error_code == ErrorCode.NOT_FOUND + assert "no password yet" in excinfo.value.message + + @pytest.mark.parametrize( + ("branch_id", "config_branch", "ui_branch"), + [(None, None, "default"), ("7788", 7788, "7788"), (7788, 7788, "7788")], + ) + def test_ui_url_and_config_read_follow_the_app_branch( + self, tmp_path: Path, branch_id: Any, config_branch: int | None, ui_branch: str + ) -> None: + store = _make_store(tmp_path) + service, ds_mock, storage_mock, _enc = _make_service(store) + _stub_password_app( + ds_mock, storage_mock, authorization=_build_simple_auth_block(), branch_id=branch_id + ) + + result = service.get_data_app_password(alias="prod", app_id="42") + + assert result.ui_url == ( + f"https://connection.keboola.com/admin/projects/5725/branch/{ui_branch}/data-apps/cfg-1" + ) + storage_mock.get_config_detail.assert_called_once_with( + "keboola.data-apps", "cfg-1", branch_id=config_branch + ) # --------------------------------------------------------------------------- @@ -1587,11 +1682,9 @@ def test_delete_envelope(self, tmp_path: Path) -> None: def test_password_envelope(self, tmp_path: Path) -> None: store = _make_store(tmp_path) - service, ds_mock, _storage, _enc = _make_service(store) - ds_mock.get_app_password.return_value = {"password": "deadbeefcafe"} - result = service.get_data_app_password( - alias="prod", app_id="42", manage_token=TEST_MANAGE_TOKEN - ) + service, ds_mock, storage_mock, _enc = _make_service(store) + _stub_password_app(ds_mock, storage_mock, authorization=_build_simple_auth_block()) + result = service.get_data_app_password(alias="prod", app_id="42").metadata() assert result["app_id"] == "42" assert "id" not in result diff --git a/tests/test_manage_token_cli.py b/tests/test_manage_token_cli.py index adec79253..a3cc781e6 100644 --- a/tests/test_manage_token_cli.py +++ b/tests/test_manage_token_cli.py @@ -5,10 +5,12 @@ resolution. The flag is session-only (not persisted), mirroring --deny-writes / --deny-destructive. -Three CLI surfaces consume the manage token: `org setup`, -`project refresh`, and `data-app password`. Each is tested in both -modes: default-deny (asserts the service was never reached) and -allow-env (asserts the service received manage_token=). +Two CLI commands covered here consume the manage token: `org setup` and +`project refresh`. Each is tested in both modes: default-deny (asserts the +service was never reached) and allow-env (asserts the service received +manage_token=). `data-app password` needed it until CLI-23 and +now reads the password with the project token alone +(tests/test_data_app_password.py). """ from pathlib import Path @@ -114,40 +116,6 @@ def test_project_refresh_allow_env_uses_env_token(self, tmp_path: Path, monkeypa # JSON output (the resolver and service handle masking). assert SENTINEL_MANAGE_TOKEN not in result.output - def test_data_app_password_default_deny_no_tty_exits_2( - self, tmp_path: Path, monkeypatch - ) -> None: - """data-app password mirrors project refresh: env ignored without - the flag, service never called.""" - store = _make_store(tmp_path) - monkeypatch.setenv("KBC_MANAGE_API_TOKEN", SENTINEL_MANAGE_TOKEN) - - mock_data_app = MagicMock() - with ( - patch("keboola_agent_cli.cli.ConfigStore") as MockStore, - patch("keboola_agent_cli.cli.DataAppService") as MockDataAppService, - ): - MockStore.return_value = store - MockDataAppService.return_value = mock_data_app - result = runner.invoke( - app, - [ - "--json", - "data-app", - "password", - "--project", - "prod", - "--app-id", - "1", - ], - ) - - assert result.exit_code == 2 - assert "found in environment but ignored" in result.output - assert "--allow-env-manage-token" in result.output - mock_data_app.get_data_app_password.assert_not_called() - assert SENTINEL_MANAGE_TOKEN not in result.output - def test_org_setup_allow_env_passes_token_through(self, tmp_path: Path, monkeypatch) -> None: """org setup with --allow-env-manage-token forwards the env token to OrgService.setup_organization.""" diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index ad37b3277..9ad34bc90 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -36,6 +36,7 @@ from keboola_agent_cli.errors import ErrorCode, KeboolaApiError from keboola_agent_cli.server import create_app from keboola_agent_cli.server.dependencies import ServiceRegistry, get_manage_token, get_registry +from keboola_agent_cli.services._data_app_password import DataAppPassword from keboola_agent_cli.services.flow_service import FlowSchemaFetch AUTH = {"Authorization": "Bearer test-token"} @@ -464,54 +465,55 @@ def test_storage_file_download_passes_output_path_kwarg(tmp_path: Path) -> None: # --------------------------------------------------------------------------- # data_apps.py GET /{p}/{app}/password -# Service: data_app.get_data_app_password(manage_token=...) -# Also: omitting X-Manage-Token header returns 401. +# Service: data_app.get_data_app_password(alias=, app_id=) -- project token only, +# no X-Manage-Token. The password is in the response only with ?reveal=true. # --------------------------------------------------------------------------- +_APP_PASSWORD_SENTINEL = "pw-sentinel-5f1e9a" -def test_data_app_password_passes_manage_token_kwarg(tmp_path: Path) -> None: - """Router must pass ``manage_token=`` to DataAppService.get_data_app_password.""" + +def _password_app(tmp_path: Path) -> tuple[Any, MagicMock]: data_app_svc = MagicMock() - data_app_svc.get_data_app_password.return_value = {"password": "s3cr3t"} + data_app_svc.get_data_app_password.return_value = DataAppPassword( + project_alias=PROJECT, + app_id=APP_ID, + auth="password", + app_url="https://app-1234.hub.keboola.com", + ui_url="https://connection.keboola.com/admin/projects/1/branch/default/data-apps/c1", + password=_APP_PASSWORD_SENTINEL, + ) registry = _mock_registry(data_app=data_app_svc) - app = _make_app_with_registry(tmp_path, registry) - # Override get_manage_token to provide a token - app.dependency_overrides[get_manage_token] = lambda: "mgmt-tok" + return _make_app_with_registry(tmp_path, registry), data_app_svc + + +def test_data_app_password_needs_no_manage_token_and_omits_the_password(tmp_path: Path) -> None: + """No X-Manage-Token header; by default the response has no password.""" + app, data_app_svc = _password_app(tmp_path) with TestClient(app) as client: - res = client.get( - f"/data-apps/{PROJECT}/{APP_ID}/password", - headers=AUTH, - ) + res = client.get(f"/data-apps/{PROJECT}/{APP_ID}/password", headers=AUTH) assert res.status_code == 200, res.text - kwargs = data_app_svc.get_data_app_password.call_args.kwargs - assert kwargs.get("manage_token") == "mgmt-tok", ( - f"Expected manage_token='mgmt-tok', got kwargs={kwargs}" - ) + data_app_svc.get_data_app_password.assert_called_once_with(alias=PROJECT, app_id=APP_ID) + assert _APP_PASSWORD_SENTINEL not in res.text + payload = res.json() + assert payload["password_delivered_to"] is None + assert "password" not in payload + assert payload["ui_url"].endswith("/data-apps/c1") -def test_data_app_password_missing_manage_token_returns_401(tmp_path: Path) -> None: - """GET /{p}/{app}/password without X-Manage-Token must return 401.""" - data_app_svc = MagicMock() - registry = _mock_registry(data_app=data_app_svc) - app = _make_app_with_registry(tmp_path, registry) - # Explicitly provide None (no token) -- this mirrors the real behaviour when - # the header is absent; no dependency override so the real get_manage_token runs. +def test_data_app_password_reveal_returns_the_password(tmp_path: Path) -> None: + app, _svc = _password_app(tmp_path) with TestClient(app) as client: res = client.get( - f"/data-apps/{PROJECT}/{APP_ID}/password", - headers=AUTH, # Bearer auth present but NO X-Manage-Token + f"/data-apps/{PROJECT}/{APP_ID}/password", params={"reveal": "true"}, headers=AUTH ) - assert res.status_code == 401, f"Expected 401, got {res.status_code}: {res.text}" - body = res.json() - # The app wraps HTTPException via a global handler into - # {"status": "error", "error": {"code": ..., "message": ...}}. - msg = body.get("detail") or body.get("error", {}).get("message", "") - assert "X-Manage-Token" in msg, f"Expected message mentioning X-Manage-Token, got: {body}" - data_app_svc.get_data_app_password.assert_not_called() + assert res.status_code == 200, res.text + payload = res.json() + assert payload["password"] == _APP_PASSWORD_SENTINEL + assert payload["password_delivered_to"] == "response" # --------------------------------------------------------------------------- diff --git a/tests/test_url_copy.py b/tests/test_url_copy.py index e01bfbf69..3aa92519d 100644 --- a/tests/test_url_copy.py +++ b/tests/test_url_copy.py @@ -3,7 +3,11 @@ from __future__ import annotations import io -from typing import cast +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, cast import pytest from rich.console import Console @@ -39,6 +43,8 @@ def __call__(self, text: str) -> bool: def test_detect_clipboard_none_when_no_backend(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(_url_copy.shutil, "which", lambda _cmd: None) + # Without this the WSL clip.exe fallback finds the real one on a WSL machine. + monkeypatch.setattr(_url_copy, "_wsl_clip_exe", lambda: None) assert detect_clipboard() is None @@ -124,3 +130,267 @@ def test_wait_sleeps_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: wait = CopyableUrlWait(_console(), copier=None, interactive=True) wait.wait(0.5) assert slept == [0.5] + + +class _RunRecorder: + """Stand-in for ``subprocess.run``: records each call, optionally raises.""" + + def __init__(self, raises: BaseException | None = None) -> None: + self.calls: list[tuple[list[str], dict[str, Any]]] = [] + self._raises = raises + + def __call__(self, argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess: + self.calls.append((argv, kwargs)) + if self._raises is not None: + raise self._raises + return subprocess.CompletedProcess(argv, 0) + + +def _linux_without_path_tools(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_url_copy.sys, "platform", "linux") + monkeypatch.setattr(_url_copy.shutil, "which", lambda _cmd: None) + monkeypatch.delenv("WSL_INTEROP", raising=False) + + +def _fake_wsl(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *, marker: bool) -> Path: + """Point the WSL probes at files under ``tmp_path``; return the fake clip.exe.""" + marker_path = tmp_path / "WSLInterop" + if marker: + marker_path.write_text("enabled\n") + clip = tmp_path / "clip.exe" + clip.write_text("") + monkeypatch.setattr(_url_copy, "_WSL_INTEROP_MARKERS", (marker_path,)) + monkeypatch.setattr(_url_copy, "_WSL_CLIP_EXE", clip) + return clip + + +def test_copier_sends_text_on_stdin_never_in_argv(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_url_copy.sys, "platform", "darwin") + monkeypatch.setattr(_url_copy.shutil, "which", lambda cmd: f"/usr/bin/{cmd}") + run = _RunRecorder() + monkeypatch.setattr(_url_copy.subprocess, "run", run) + + copier = detect_clipboard() + assert copier is not None + assert copier("s3cret-value") is True + + argv, kwargs = run.calls[0] + assert argv == ["pbcopy"] + assert kwargs["input"] == b"s3cret-value" + assert kwargs["timeout"] == _url_copy._CLIPBOARD_TIMEOUT_SECONDS + assert all("s3cret-value" not in arg for arg in argv) + + +@pytest.mark.parametrize( + "error", + [ + subprocess.TimeoutExpired(["xclip"], _url_copy._CLIPBOARD_TIMEOUT_SECONDS), + subprocess.CalledProcessError(1, ["xclip"]), + FileNotFoundError("xclip"), + ], +) +def test_copier_reports_a_hung_or_failed_tool_as_false( + monkeypatch: pytest.MonkeyPatch, error: BaseException +) -> None: + monkeypatch.setattr(_url_copy.sys, "platform", "linux") + monkeypatch.setattr( + _url_copy.shutil, "which", lambda cmd: "/usr/bin/xclip" if cmd == "xclip" else None + ) + monkeypatch.setattr(_url_copy.subprocess, "run", _RunRecorder(raises=error)) + copier = detect_clipboard() + assert copier is not None + assert copier("x") is False + + +@pytest.mark.parametrize("via", ["marker", "env"]) +def test_wsl_falls_back_to_clip_exe_by_full_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, via: str +) -> None: + """A WSL shell without the Windows PATH still reaches clip.exe by its full path.""" + _linux_without_path_tools(monkeypatch) + clip = _fake_wsl(monkeypatch, tmp_path, marker=via == "marker") + if via == "env": + monkeypatch.setenv("WSL_INTEROP", "/run/WSL/1_interop") + run = _RunRecorder() + monkeypatch.setattr(_url_copy.subprocess, "run", run) + + copier = detect_clipboard() + assert copier is not None + assert copier("pw") is True + argv, kwargs = run.calls[0] + assert argv == [str(clip)] + assert kwargs["input"] == b"pw" + + +def test_no_clip_exe_fallback_outside_wsl(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _linux_without_path_tools(monkeypatch) + _fake_wsl(monkeypatch, tmp_path, marker=False) + assert detect_clipboard() is None + + +def test_no_clip_exe_fallback_when_clip_exe_is_missing( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _linux_without_path_tools(monkeypatch) + _fake_wsl(monkeypatch, tmp_path, marker=True) + monkeypatch.setattr(_url_copy, "_WSL_CLIP_EXE", tmp_path / "missing" / "clip.exe") + assert detect_clipboard() is None + + +def test_path_tool_wins_over_the_wsl_fallback( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(_url_copy.sys, "platform", "linux") + monkeypatch.setattr( + _url_copy.shutil, "which", lambda cmd: "/usr/bin/wl-copy" if cmd == "wl-copy" else None + ) + _fake_wsl(monkeypatch, tmp_path, marker=True) + run = _RunRecorder() + monkeypatch.setattr(_url_copy.subprocess, "run", run) + copier = detect_clipboard() + assert copier is not None + copier("x") + assert run.calls[0][0] == ["wl-copy"] + + +def test_custom_hint_and_value_never_print_the_value() -> None: + console = _console() + rec = _Recorder() + wait = CopyableUrlWait(console, copier=rec, interactive=True, hint="Press c to copy it") + wait.on_prompt("the-secret") + assert "Press c to copy it" in _output(console) + assert "the-secret" not in _output(console) + + +def test_finish_key_ends_the_wait_without_copying(monkeypatch: pytest.MonkeyPatch) -> None: + rec = _Recorder() + wait = CopyableUrlWait( + _console(), copier=rec, interactive=True, finish_keys=frozenset({"\n", "q"}) + ) + wait.on_prompt("the-secret") + keys = iter(["x", "Q", "c"]) + monkeypatch.setattr(wait, "_read_key", lambda _timeout: next(keys, None)) + wait.wait(60.0) + assert wait.finished is True + assert wait.copied is False + assert rec.copied == [] + + +def test_copy_then_finish_key(monkeypatch: pytest.MonkeyPatch) -> None: + console = _console() + rec = _Recorder() + wait = CopyableUrlWait(console, copier=rec, interactive=True, finish_keys=frozenset({"\n"})) + wait.on_prompt("the-secret") + keys = iter(["c", "c", "\n"]) + monkeypatch.setattr(wait, "_read_key", lambda _timeout: next(keys, None)) + wait.wait(60.0) + assert rec.copied == ["the-secret"] + assert wait.copied is True + assert wait.finished is True + assert "the-secret" not in _output(console) + + +def test_a_failing_tool_falls_through_to_the_next_and_to_wsl_clip_exe( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """xclip without an X display fails at copy time; the next tool is tried.""" + monkeypatch.setattr(_url_copy.sys, "platform", "linux") + monkeypatch.setattr( + _url_copy.shutil, + "which", + lambda cmd: f"/usr/bin/{cmd}" if cmd in ("xclip", "xsel") else None, + ) + clip = _fake_wsl(monkeypatch, tmp_path, marker=True) + calls: list[str] = [] + + def _run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess: + calls.append(argv[0]) + if argv[0] != str(clip): + raise subprocess.CalledProcessError(1, argv) + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr(_url_copy.subprocess, "run", _run) + copier = detect_clipboard() + assert copier is not None + assert copier("pw") is True + assert calls == ["xclip", "xsel", str(clip)] + + +def test_prompt_and_wait_restores_the_terminal_when_wait_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wait = CopyableUrlWait(_console(), copier=_Recorder(), interactive=True) + restored: list[bool] = [] + + def _interrupt(_interval: float) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr(wait, "wait", _interrupt) + monkeypatch.setattr(wait, "restore", lambda: restored.append(True)) + with pytest.raises(KeyboardInterrupt): + wait.prompt_and_wait("the-secret", 60.0) + assert restored == [True] + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file descriptors") +class TestKeyReadingFromAPipe: + """``_read_key`` on a real file descriptor (a pipe stands in for stdin).""" + + @pytest.fixture + def pipe(self, monkeypatch: pytest.MonkeyPatch) -> Any: + read_fd, write_fd = os.pipe() + reader = os.fdopen(read_fd, "r") + monkeypatch.setattr(_url_copy.sys, "stdin", reader) + yield write_fd + reader.close() + + def _wait(self, finish_keys: frozenset[str] = frozenset({"\n", "\x1b", "q"})) -> Any: + wait = CopyableUrlWait( + _console(), copier=_Recorder(), interactive=True, finish_keys=finish_keys + ) + wait.on_prompt("the-secret") + return wait + + def test_eof_finishes_when_finish_keys_are_set(self, pipe: int) -> None: + wait = self._wait() + os.close(pipe) + wait.wait(5.0) + assert wait.finished is True + + def test_eof_keeps_the_old_wait_without_finish_keys( + self, pipe: int, monkeypatch: pytest.MonkeyPatch + ) -> None: + slept: list[float] = [] + monkeypatch.setattr(_url_copy.time, "sleep", lambda seconds: slept.append(seconds)) + wait = self._wait(frozenset()) + os.close(pipe) + wait.wait(5.0) + assert wait.finished is False + assert slept and slept[0] > 0 # device login: sleep out the poll interval + + def test_escape_sequence_is_ignored_and_the_burst_is_read(self, pipe: int) -> None: + wait = self._wait() + os.write(pipe, b"\x1b[Ac\n") + wait.wait(5.0) + assert wait.copied is True + assert wait.finished is True + + def test_lone_esc_finishes(self, pipe: int) -> None: + wait = self._wait() + os.write(pipe, b"\x1b") + wait.wait(5.0) + assert wait.finished is True + assert wait.copied is False + + +def test_background_process_group_is_not_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Changing the terminal mode from the background would stop the process (SIGTTOU).""" + tty_stream = type("Tty", (), {"isatty": lambda self: True, "fileno": lambda self: 0})() + monkeypatch.setattr(_url_copy.sys, "stdin", tty_stream) + monkeypatch.setattr(_url_copy.sys, "stdout", tty_stream) + monkeypatch.setattr(_url_copy, "_POSIX", True) + monkeypatch.setattr(_url_copy.os, "getpgrp", lambda: 100, raising=False) + monkeypatch.setattr(_url_copy.os, "tcgetpgrp", lambda _fd: 200, raising=False) + assert _url_copy.stdio_is_interactive() is False + monkeypatch.setattr(_url_copy.os, "tcgetpgrp", lambda _fd: 100, raising=False) + assert _url_copy.stdio_is_interactive() is True