From 987783ef4c148a8dc37b59873024d151e822ab42 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 23 Oct 2026 22:28:01 +0100 Subject: [PATCH] fix(no-ticket): report a missing Docker credential in the protocol format When no credential resolves, the Docker credential helper now writes "credentials not found in native keychain" to stdout. Docker then pulls without credentials, so public images work again. The guidance message stays on stderr. An empty server URL now gives the protocol message "no credentials server URL" on stdout. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ .../tests/commands/test_credential_helper.py | 19 ++++++++++++------ .../credential_helpers/docker/runtime.py | 20 ++++++++++++++++--- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0127743c7..f7351ae79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - After a SAML login, `cloudsmith auth` saves the Workspace to `config.ini`. It writes to the `[profile:NAME]` section of the selected profile, or to `[default]` when you do not select a profile. The CLI writes to the file or directory that `--config-file` gives, or else to your user-level `config.ini`. If the `config.ini` does not exist, the CLI creates it. +### Fixed + +- `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. + ## [1.30.0] - 2026-10-22 ### Added diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py index 7d150b0c4..bcd1cbf40 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 93883a83a..f796ab05b 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