From 6d7c7c9532e83c91c1562750fe93dd6ce8490fc0 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Date: Mon, 1 Jun 2026 15:11:25 -0300 Subject: [PATCH 1/2] fix(cli): platform-specific keychain store hints after login (#235) Branch error messages on macOS, Linux, and Windows when OAuth succeeds but store_session fails. Document the macOS errSecParam (-25244) ACL workaround in docs/cli/auth.md. --- docs/cli/auth.md | 14 ++++- packages/cli/src/pipefy_cli/commands/auth.py | 16 +---- .../commands/auth_keychain_hints.py | 60 +++++++++++++++++++ .../cli/tests/test_auth_keychain_hints.py | 37 ++++++++++++ packages/cli/tests/test_auth_login.py | 26 ++++++-- 5 files changed, 132 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/pipefy_cli/commands/auth_keychain_hints.py create mode 100644 packages/cli/tests/test_auth_keychain_hints.py diff --git a/docs/cli/auth.md b/docs/cli/auth.md index d5380c7c..74e84357 100644 --- a/docs/cli/auth.md +++ b/docs/cli/auth.md @@ -216,9 +216,19 @@ Every `PIPEFY_*` env var is validated against a semantically meaningful regex at ### `Login succeeded but the session could not be stored in your keychain ()` -The login worked but `keyring` couldn't write the entry. On macOS / Windows this is rare. On headless Linux it usually means no Secret Service daemon is running — install `gnome-keyring` or `kwallet`, set `PIPEFY_KEYCHAIN_BACKEND=file` to use a plaintext file backend under the Pipefy config directory, or fall back to a static `PIPEFY_TOKEN`. +The login worked but `keyring` couldn't write the entry. -When `PIPEFY_KEYCHAIN_BACKEND=file` is active the backend reports as `PlaintextKeyring` and the hint switches to a config-directory writability check (the file backend writes to `keyring.cfg` under the resolved config directory). +**macOS (Keychain / `Keyring` backend).** OAuth can succeed while persistence fails with `Can't store password on keychain: (-25244, 'Unknown Error')`. That code is `errSecParam` from Security.framework — common when the calling process cannot surface the new-item ACL dialog (IDE slash commands, agent hosts, and other non-TTY subprocesses that do not share an interactive Aqua session). The fix is independent of how `pipefy` was installed (`uvx`, `uv tool install`, or a wheel). + +1. Run `pipefy auth login` **once** from a regular **Terminal.app** session (not from inside the IDE). +2. When macOS prompts for keychain access for the Python binary running `pipefy`, click **Always Allow**. +3. Retry login from the IDE or agent integration — refresh writes should succeed without another prompt. + +**Linux (headless / CI).** Usually no Secret Service daemon — install `gnome-keyring` or `kwallet`, set `PIPEFY_KEYCHAIN_BACKEND=file` for a plaintext file backend under the Pipefy config directory, or use a static `PIPEFY_TOKEN`. + +**Windows.** Credential Manager may block non-interactive callers; run `pipefy auth login` once from an interactive Command Prompt or PowerShell window, or use `PIPEFY_KEYCHAIN_BACKEND=file` / `PIPEFY_TOKEN`. + +When `PIPEFY_KEYCHAIN_BACKEND=file` is active the backend reports as `PlaintextKeyring` and the CLI hint switches to a config-directory writability check (the file backend writes to `keyring.cfg` under the resolved config directory). ### `Missing Pipefy authentication. Set PIPEFY_TOKEN, configure PIPEFY_SERVICE_ACCOUNT_*, or run \`pipefy auth login\`.` diff --git a/packages/cli/src/pipefy_cli/commands/auth.py b/packages/cli/src/pipefy_cli/commands/auth.py index 8dccf341..efce41ce 100644 --- a/packages/cli/src/pipefy_cli/commands/auth.py +++ b/packages/cli/src/pipefy_cli/commands/auth.py @@ -44,6 +44,7 @@ to_display_source, ) from pipefy_cli.commands._common import settings_and_auth_from_ctx +from pipefy_cli.commands.auth_keychain_hints import keychain_store_failure_hint from pipefy_cli.output import render_json AuthSessionState = Literal["active", "refresh-expired", "needs-login", "n/a"] @@ -193,20 +194,7 @@ def _open(url: str) -> bool: # without ownership) surfaces as ``PermissionError`` from # ``os.makedirs`` inside the backend, which is not a ``KeyringError``. backend = keychain_backend_name() - if backend == "PlaintextKeyring": - from pipefy_infra.config import config_dir - - hint = ( - f"Ensure the config directory is writable ({config_dir()}), " - "or use a static PIPEFY_TOKEN." - ) - else: - hint = ( - "On headless Linux, ensure a Secret Service daemon " - "(gnome-keyring, kwallet) is running, set " - "PIPEFY_KEYCHAIN_BACKEND=file to use a plaintext file backend, " - "or use a static PIPEFY_TOKEN." - ) + hint = keychain_store_failure_hint(backend=backend) typer.echo( f"Login succeeded but the session could not be stored in your " f"keychain ({backend}): {exc}. {hint}", diff --git a/packages/cli/src/pipefy_cli/commands/auth_keychain_hints.py b/packages/cli/src/pipefy_cli/commands/auth_keychain_hints.py new file mode 100644 index 00000000..f2ecaadf --- /dev/null +++ b/packages/cli/src/pipefy_cli/commands/auth_keychain_hints.py @@ -0,0 +1,60 @@ +"""Platform-specific hints when OS keychain session storage fails after OAuth.""" + +from __future__ import annotations + +import platform + +from pipefy_cli._docs import DOCS_CLI_AUTH_REF + +_LINUX_HINT = ( + "On headless Linux, ensure a Secret Service daemon " + "(gnome-keyring, kwallet) is running, set " + "PIPEFY_KEYCHAIN_BACKEND=file to use a plaintext file backend, " + "or use a static PIPEFY_TOKEN." +) + +_MACOS_HINT = ( + "This is usually macOS denying the calling subprocess access to the " + "keychain ACL prompt (errSecParam, -25244). Run `pipefy auth login` once " + "from a regular Terminal.app session and click Always Allow when macOS " + "prompts; subsequent runs (including agent / IDE integrations) will write " + "without prompting." +) + +_WINDOWS_HINT = ( + "On Windows, Credential Manager may block writes from non-interactive " + "callers. Run `pipefy auth login` once from an interactive Command Prompt " + "or PowerShell window, or set PIPEFY_KEYCHAIN_BACKEND=file, or use a " + "static PIPEFY_TOKEN." +) + +_GENERIC_HINT = ( + f"Run `pipefy auth status` to inspect the active backend, or see " + f"{DOCS_CLI_AUTH_REF} for keychain troubleshooting." +) + + +def keychain_store_failure_hint(*, backend: str) -> str: + """Return a remediation hint after ``store_session`` fails post-login.""" + if backend == "PlaintextKeyring": + from pipefy_infra.config import config_dir + + return ( + f"Ensure the config directory is writable ({config_dir()}), " + "or use a static PIPEFY_TOKEN." + ) + return _keychain_hint_for_platform() + + +def _keychain_hint_for_platform() -> str: + system = platform.system() + if system == "Darwin": + return _MACOS_HINT + if system == "Linux": + return _LINUX_HINT + if system == "Windows": + return _WINDOWS_HINT + return _GENERIC_HINT + + +__all__ = ["keychain_store_failure_hint"] diff --git a/packages/cli/tests/test_auth_keychain_hints.py b/packages/cli/tests/test_auth_keychain_hints.py new file mode 100644 index 00000000..89771241 --- /dev/null +++ b/packages/cli/tests/test_auth_keychain_hints.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import platform + +import pytest + +from pipefy_cli.commands import auth_keychain_hints as hints + + +@pytest.mark.parametrize( + ("system", "expected_fragment"), + [ + ("Darwin", "errSecParam"), + ("Darwin", "Terminal.app"), + ("Darwin", "Always Allow"), + ("Linux", "Secret Service"), + ("Windows", "Credential Manager"), + ("FreeBSD", "pipefy auth status"), + ], +) +def test_keychain_hint_for_platform( + monkeypatch: pytest.MonkeyPatch, system: str, expected_fragment: str +) -> None: + monkeypatch.setattr(platform, "system", lambda: system) + hint = hints.keychain_store_failure_hint(backend="Keyring") + assert expected_fragment in hint + if system == "Darwin": + assert "Secret Service" not in hint + + +def test_plaintext_backend_hint_ignores_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(platform, "system", lambda: "Darwin") + hint = hints.keychain_store_failure_hint(backend="PlaintextKeyring") + assert "config directory is writable" in hint + assert "errSecParam" not in hint diff --git a/packages/cli/tests/test_auth_login.py b/packages/cli/tests/test_auth_login.py index 05324398..d55b0a9f 100644 --- a/packages/cli/tests/test_auth_login.py +++ b/packages/cli/tests/test_auth_login.py @@ -750,17 +750,31 @@ def _boom(**_kwargs: object) -> flow.LoginResult: assert "Login failed" in result.stderr assert "invalid_grant" in result.stderr - def test_store_failure_default_backend_hints_at_secret_service( + @pytest.mark.parametrize( + ("system", "expected_fragment", "forbidden_fragment"), + [ + ("Linux", "Secret Service", "Terminal.app"), + ("Darwin", "errSecParam", "Secret Service"), + ("Windows", "Credential Manager", "Secret Service"), + ], + ) + def test_store_failure_default_backend_hint_matches_platform( self, cli_runner, monkeypatch: pytest.MonkeyPatch, fake_keyring: InMemoryKeyring, clean_pipefy_env, saved_cwd, + system: str, + expected_fragment: str, + forbidden_fragment: str, ) -> None: - """A keychain write failure on the default OS backend surfaces the - Secret Service hint and the file-backend escape hatch.""" + """A keychain write failure on the default OS backend surfaces a + platform-appropriate remediation hint.""" + import platform + monkeypatch.setenv("PIPEFY_AUTH_URL", "https://x.test/realms/foo") + monkeypatch.setattr(platform, "system", lambda: system) from keyring.errors import KeyringError @@ -783,8 +797,10 @@ def _boom(**_kwargs: object) -> None: result = cli_runner.invoke(cli_app, ["auth", "login"]) assert result.exit_code == 1 assert "could not be stored in your keychain" in result.stderr - assert "Secret Service" in result.stderr - assert "PIPEFY_KEYCHAIN_BACKEND=file" in result.stderr + assert expected_fragment in result.stderr + assert forbidden_fragment not in result.stderr + if system == "Linux": + assert "PIPEFY_KEYCHAIN_BACKEND=file" in result.stderr def test_store_failure_file_backend_hints_at_config_dir( self, From ec94d8d67721a387a4bcc63ac7f75b79204d59a7 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Date: Mon, 13 Jul 2026 14:32:20 -0300 Subject: [PATCH 2/2] fix(cli): address keychain hint review feedback Qualify the macOS ACL note for uvx path churn, tighten platform hint tests with required/forbidden fragments, and rename the helper to _auth_keychain_hints.py. Align the MCP README with the same diagnosis. --- docs/cli/auth.md | 2 +- ...chain_hints.py => _auth_keychain_hints.py} | 4 +-- packages/cli/src/pipefy_cli/commands/auth.py | 2 +- .../cli/tests/test_auth_keychain_hints.py | 26 ++++++++++--------- packages/mcp/README.md | 2 +- 5 files changed, 19 insertions(+), 17 deletions(-) rename packages/cli/src/pipefy_cli/commands/{auth_keychain_hints.py => _auth_keychain_hints.py} (97%) diff --git a/docs/cli/auth.md b/docs/cli/auth.md index 74e84357..90cc7417 100644 --- a/docs/cli/auth.md +++ b/docs/cli/auth.md @@ -218,7 +218,7 @@ Every `PIPEFY_*` env var is validated against a semantically meaningful regex at The login worked but `keyring` couldn't write the entry. -**macOS (Keychain / `Keyring` backend).** OAuth can succeed while persistence fails with `Can't store password on keychain: (-25244, 'Unknown Error')`. That code is `errSecParam` from Security.framework — common when the calling process cannot surface the new-item ACL dialog (IDE slash commands, agent hosts, and other non-TTY subprocesses that do not share an interactive Aqua session). The fix is independent of how `pipefy` was installed (`uvx`, `uv tool install`, or a wheel). +**macOS (Keychain / `Keyring` backend).** OAuth can succeed while persistence fails with `Can't store password on keychain: (-25244, 'Unknown Error')`. That code is `errSecParam` from Security.framework — common when the calling process cannot surface the new-item ACL dialog (IDE slash commands, agent hosts, and other non-TTY subprocesses that do not share an interactive Aqua session). The remediation procedure is the same regardless of install method. macOS binds the keychain ACL to the calling Python binary's path, so `uv tool install` and a wheel install give you a stable grant; with `uvx`, the cached environment's binary path can change (for example after `uvx --refresh` or a uv version bump), and macOS will prompt again from Terminal.app. 1. Run `pipefy auth login` **once** from a regular **Terminal.app** session (not from inside the IDE). 2. When macOS prompts for keychain access for the Python binary running `pipefy`, click **Always Allow**. diff --git a/packages/cli/src/pipefy_cli/commands/auth_keychain_hints.py b/packages/cli/src/pipefy_cli/commands/_auth_keychain_hints.py similarity index 97% rename from packages/cli/src/pipefy_cli/commands/auth_keychain_hints.py rename to packages/cli/src/pipefy_cli/commands/_auth_keychain_hints.py index f2ecaadf..d0856287 100644 --- a/packages/cli/src/pipefy_cli/commands/auth_keychain_hints.py +++ b/packages/cli/src/pipefy_cli/commands/_auth_keychain_hints.py @@ -4,6 +4,8 @@ import platform +from pipefy_infra.config import config_dir + from pipefy_cli._docs import DOCS_CLI_AUTH_REF _LINUX_HINT = ( @@ -37,8 +39,6 @@ def keychain_store_failure_hint(*, backend: str) -> str: """Return a remediation hint after ``store_session`` fails post-login.""" if backend == "PlaintextKeyring": - from pipefy_infra.config import config_dir - return ( f"Ensure the config directory is writable ({config_dir()}), " "or use a static PIPEFY_TOKEN." diff --git a/packages/cli/src/pipefy_cli/commands/auth.py b/packages/cli/src/pipefy_cli/commands/auth.py index efce41ce..efdc1d96 100644 --- a/packages/cli/src/pipefy_cli/commands/auth.py +++ b/packages/cli/src/pipefy_cli/commands/auth.py @@ -43,8 +43,8 @@ get_authenticated_client, to_display_source, ) +from pipefy_cli.commands._auth_keychain_hints import keychain_store_failure_hint from pipefy_cli.commands._common import settings_and_auth_from_ctx -from pipefy_cli.commands.auth_keychain_hints import keychain_store_failure_hint from pipefy_cli.output import render_json AuthSessionState = Literal["active", "refresh-expired", "needs-login", "n/a"] diff --git a/packages/cli/tests/test_auth_keychain_hints.py b/packages/cli/tests/test_auth_keychain_hints.py index 89771241..6f175061 100644 --- a/packages/cli/tests/test_auth_keychain_hints.py +++ b/packages/cli/tests/test_auth_keychain_hints.py @@ -4,28 +4,30 @@ import pytest -from pipefy_cli.commands import auth_keychain_hints as hints +from pipefy_cli.commands import _auth_keychain_hints as hints @pytest.mark.parametrize( - ("system", "expected_fragment"), + ("system", "required", "forbidden"), [ - ("Darwin", "errSecParam"), - ("Darwin", "Terminal.app"), - ("Darwin", "Always Allow"), - ("Linux", "Secret Service"), - ("Windows", "Credential Manager"), - ("FreeBSD", "pipefy auth status"), + ("Darwin", ["errSecParam", "Terminal.app", "Always Allow"], ["Secret Service"]), + ("Linux", ["Secret Service"], ["Terminal.app", "Credential Manager"]), + ("Windows", ["Credential Manager"], ["Secret Service", "Terminal.app"]), + ("FreeBSD", ["pipefy auth status"], []), ], ) def test_keychain_hint_for_platform( - monkeypatch: pytest.MonkeyPatch, system: str, expected_fragment: str + monkeypatch: pytest.MonkeyPatch, + system: str, + required: list[str], + forbidden: list[str], ) -> None: monkeypatch.setattr(platform, "system", lambda: system) hint = hints.keychain_store_failure_hint(backend="Keyring") - assert expected_fragment in hint - if system == "Darwin": - assert "Secret Service" not in hint + for fragment in required: + assert fragment in hint + for fragment in forbidden: + assert fragment not in hint def test_plaintext_backend_hint_ignores_platform( diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 590529a9..dfbc4510 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -32,7 +32,7 @@ Full reference (every `PIPEFY_*` variable, validation rules, TOML schema, preced ### macOS keychain `errSecParam (-25244)` -`pipefy auth login` may exit with `errSecParam (-25244)` at the final keychain-write step even though OAuth itself succeeded. The cause is not yet reliably diagnosed — direct `keyring.set_password` calls from the same uv-tool-installed Python succeed under repro testing, so this is likely a transient `Security.framework` condition rather than a deterministic per-binary ACL problem. If it occurs, retry the slash command (Claude Code) or `pipefy auth login` (terminal) first; as a fallback, run `pipefy auth login` once from a regular Terminal.app session and approve any macOS keychain dialog that appears. [Issue #235](https://github.com/pipefy/ai-toolkit/issues/235) tracks platform-aware error messaging. +`pipefy auth login` may exit with `errSecParam (-25244)` at the final keychain-write step even though OAuth itself succeeded. That usually means the calling process could not surface the macOS keychain ACL dialog (IDE slash commands, agent hosts, and other non-TTY subprocesses). Run `pipefy auth login` once from a regular Terminal.app session and click **Always Allow** when prompted; refresh writes from the IDE or agent should then succeed. macOS binds the ACL to the calling Python binary's path, so a stable install (`uv tool install` / wheel) keeps the grant; with `uvx`, a path change (for example after `uvx --refresh`) can prompt again. See [`docs/cli/auth.md`](../../docs/cli/auth.md) for the full platform-specific troubleshooting. ### Claude Code: `claude mcp add` (per-project terminal flow)