diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbf17a4..a9b768b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith credential-helper install` and `uninstall` no longer rewrite a config file when they make no change to its data. Previously, the CLI always rewrote a file in a different formatting, such as the tab-indented `config.json` that Docker writes. The command also made a `.bak` copy and reported changes that it did not make. - When the API host name or the proxy host name does not exist, for example because of a typo in `--api-host`, commands now fail immediately. Previously, they retried for approximately 62 seconds without output. The CLI still retries a DNS failure that the operating system reports as temporary. - When the CLI waits before it retries a failed connection or a 5xx response, it now writes `Request failed: retrying in N second(s) ...` to stderr. Previously, it waited without output. +- `docker pull` of a public image from a Cloudsmith registry no longer fails when the Docker credential helper has no credential. The helper now writes `credentials not found in native keychain` to stdout, so Docker continues without credentials. A pull from a private repository now fails with the 401 error of the registry. The helper still writes its guidance to stderr. - `cloudsmith mcp start` now stops without an error when the MCP client closes the connection while the server writes a reply. Previously, the command stopped with `BrokenPipeError` and exit status 1. - The CLI now exits with status 1 when you interrupt it with Ctrl+C after the reader of its stderr has closed the pipe. Before, the CLI sent an error report and exited with status 120. - `cloudsmith check rates` no longer stops with `TypeError` when the API does not give a value for an interval, limit, remaining count, reset time or throttled status. The command now shows `None` for that value. diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py index 7d150b0c..bcd1cbf4 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py @@ -65,8 +65,15 @@ def _server_domain(name, domain_type, scope="WORKSPACE"): json.dumps({"Username": "token", "Secret": "k_abc"}), None, ), - # get-refusal: get_credentials returns None → (1, None, refusal) - ("get", None, "evil.example.com", 1, None, "Unable to retrieve credentials"), + # get-refusal: get_credentials returns None → (1, not-found, refusal) + ( + "get", + None, + "evil.example.com", + 1, + "credentials not found in native keychain", + "Unable to retrieve credentials", + ), # store: drains stdin, returns (0, None, None) ("store", None, '{"ServerURL": "docker.cloudsmith.io"}', 0, None, None), # erase: drains stdin, returns (0, None, None) @@ -205,13 +212,13 @@ def test_cli_no_arg_defaults_to_get(runner): assert json.dumps(fake_creds) in result.stdout -def test_execute_get_empty_stdin_returns_exit_1(): - """execute get with empty stdin → (1, None, 'No server URL...').""" - stdin = io.StringIO("") +def test_execute_get_blank_stdin_returns_exit_1(): + """execute get with whitespace-only stdin → (1, missing-URL, 'No server URL...').""" + stdin = io.StringIO(" \n") code, stdout, stderr = execute("get", stdin) assert code == 1 - assert stdout is None + assert stdout == "no credentials server URL" assert "No server URL provided" in stderr diff --git a/cloudsmith_cli/credential_helpers/docker/runtime.py b/cloudsmith_cli/credential_helpers/docker/runtime.py index 93883a83..f796ab05 100644 --- a/cloudsmith_cli/credential_helpers/docker/runtime.py +++ b/cloudsmith_cli/credential_helpers/docker/runtime.py @@ -11,12 +11,19 @@ import json import logging +from typing import TextIO +from ...core.credentials.models import CredentialResult from ..backends import BackendKind from ..common import REFUSAL_MESSAGE, is_cloudsmith_domain logger = logging.getLogger(__name__) +# Docker treats these exact stdout strings as typed errors, and pulls +# anonymously on the not-found one. See docker-credential-helpers credentials/error.go. +CREDENTIALS_NOT_FOUND_MESSAGE = "credentials not found in native keychain" +MISSING_SERVER_URL_MESSAGE = "no credentials server URL" + def get_credentials(server_url, credential=None, api_host=None, org=None): """ @@ -50,19 +57,26 @@ def get_credentials(server_url, credential=None, api_host=None, org=None): def _execute_get( - stdin, credential, api_host, org + stdin: TextIO, + credential: CredentialResult | None, + api_host: str | None, + org: str | None, ) -> tuple[int, str | None, str | None]: """Handle the 'get' operation of the Docker credential helper protocol.""" try: server_url = stdin.read().strip() if not server_url: - return (1, None, "Error: No server URL provided on stdin") + return ( + 1, + MISSING_SERVER_URL_MESSAGE, + "Error: No server URL provided on stdin", + ) creds = get_credentials( server_url, credential=credential, api_host=api_host, org=org ) if creds is None: - return (1, None, REFUSAL_MESSAGE) + return (1, CREDENTIALS_NOT_FOUND_MESSAGE, REFUSAL_MESSAGE) return (0, json.dumps(creds), None) except Exception as exc: # pylint: disable=broad-except