Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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 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.

## [1.30.0] - 2026-10-22
Expand Down
19 changes: 13 additions & 6 deletions cloudsmith_cli/cli/tests/commands/test_credential_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down
20 changes: 17 additions & 3 deletions cloudsmith_cli/credential_helpers/docker/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand Down
Loading