From a25d73c977600ee369cace971d3126a5a0022c60 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Tue, 8 Sep 2026 10:29:56 +0100 Subject: [PATCH 1/2] feat: add Buildkite OIDC detector --- README.md | 6 +- .../credentials/oidc/detectors/__init__.py | 2 + .../credentials/oidc/detectors/buildkite.py | 51 +++++++++++ .../core/tests/test_buildkite_detector.py | 91 +++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py create mode 100644 cloudsmith_cli/core/tests/test_buildkite_detector.py diff --git a/README.md b/README.md index f0784306..9be10072 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,10 @@ See the [Bitbucket Pipelines OIDC documentation](https://support.atlassian.com/b In CircleCI, OIDC credential discovery works out of the box with no extra dependencies — the CLI reads the token from the `CIRCLE_OIDC_TOKEN_V2` (preferred) or `CIRCLE_OIDC_TOKEN` environment variable that CircleCI injects into every job. The Cloudsmith OIDC provider must expect the audience CircleCI mints, which is your CircleCI organization UUID. See the [Cloudsmith CircleCI integration guide](https://docs.cloudsmith.com/integrations/integrating-with-circleci). +#### Buildkite OIDC Support + +In Buildkite, OIDC credential discovery works out of the box with no extra dependencies — the CLI requests a token for the current job through `buildkite-agent oidc request-token`. By default it requests the `cloudsmith` audience; use `--oidc-audience` or `CLOUDSMITH_OIDC_AUDIENCE` if your Cloudsmith OIDC provider expects a different audience. See the [Buildkite OIDC documentation](https://buildkite.com/docs/pipelines/security/oidc). + #### Azure DevOps OIDC Support In Azure DevOps Pipelines, OIDC credential discovery works out of the box with no extra dependencies — the CLI fetches an OIDC token from the `SYSTEM_OIDCREQUESTURI` endpoint using the pipeline's `SYSTEM_ACCESSTOKEN`. Make sure `SYSTEM_ACCESSTOKEN` is mapped into the step's environment. The Cloudsmith OIDC provider must expect the audience `api://AzureADTokenExchange`, which Azure DevOps always mints (any requested audience is ignored). See the [Cloudsmith Azure DevOps integration guide](https://docs.cloudsmith.com/integrations/integrating-with-azure-devops). @@ -242,7 +246,7 @@ By default the CLI tries each detector in a fixed priority order and uses the fi - **Disable a detector** — set `CLOUDSMITH_OIDC__DISABLED=true` to skip it entirely. Only the literal value `true` (case-insensitive) disables; anything else leaves the detector enabled. For example, `CLOUDSMITH_OIDC_AWS_DISABLED=true` skips the AWS detector so an explicitly-set `CLOUDSMITH_OIDC_TOKEN` is picked up by the generic detector instead. - **Reorder evaluation** — use `--oidc-detector-order` (or the `CLOUDSMITH_OIDC_DETECTOR_ORDER` environment variable) with a comma-separated list of detector ids to control both which detectors are considered and the order they are tried in (first match wins). Ids not listed are skipped; unrecognised ids are ignored with a warning. For example, `--oidc-detector-order=generic,aws` tries the generic detector first and considers only those two. -When both are set, the order list defines the candidate set and sequence, then the `*_DISABLED` flags are applied on top — so a disabled detector is always skipped even if it appears in the order list. Detector ids are: `aws`, `azure_devops`, `bitbucket`, `circleci`, `generic`, `github`, `gitlab`. +When both are set, the order list defines the candidate set and sequence, then the `*_DISABLED` flags are applied on top — so a disabled detector is always skipped even if it appears in the order list. Detector ids are: `aws`, `azure_devops`, `bitbucket`, `buildkite`, `circleci`, `generic`, `github`, `gitlab`. Both controls can also be set in `config.ini`, under `[default]` or a profile section: diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index 0a77eb3e..a741a426 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -8,6 +8,7 @@ from .aws import AWSDetector from .azure_devops import AzureDevOpsDetector from .bitbucket_pipelines import BitbucketPipelinesDetector +from .buildkite import BuildkiteDetector from .circleci import CircleCIDetector from .generic import GenericDetector from .github_actions import GitHubActionsDetector @@ -22,6 +23,7 @@ logger = logging.getLogger(__name__) _DETECTORS: list[type[EnvironmentDetector]] = [ + BuildkiteDetector, CircleCIDetector, AzureDevOpsDetector, GitHubActionsDetector, diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py b/cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py new file mode 100644 index 00000000..af51e571 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py @@ -0,0 +1,51 @@ +# Copyright 2026 Cloudsmith Ltd +"""Buildkite OIDC detector. + +Requests an OIDC token for the current job through the ``buildkite-agent`` +command, which is available in Buildkite pipeline jobs. + +References: + https://buildkite.com/docs/pipelines/security/oidc + https://buildkite.com/docs/agent/cli/reference/oidc +""" + +from __future__ import annotations + +import os +import subprocess + +from .base import EnvironmentDetector + +DEFAULT_AUDIENCE = "cloudsmith" + + +class BuildkiteDetector(EnvironmentDetector): + """Detects Buildkite and requests an OIDC token from its agent.""" + + name = "Buildkite" + id = "buildkite" + + def detect(self) -> bool: + return os.environ.get("BUILDKITE") == "true" and bool( + os.environ.get("BUILDKITE_JOB_ID") + ) + + def get_token(self) -> str: + audience = self.context.oidc_audience or DEFAULT_AUDIENCE + result = subprocess.run( + [ + "buildkite-agent", + "oidc", + "request-token", + "--audience", + audience, + ], + capture_output=True, + check=True, + text=True, + timeout=30, + ) + token = result.stdout.strip() + if not token: + raise ValueError("Buildkite agent OIDC request returned an empty token") + return token diff --git a/cloudsmith_cli/core/tests/test_buildkite_detector.py b/cloudsmith_cli/core/tests/test_buildkite_detector.py new file mode 100644 index 00000000..e33ee03f --- /dev/null +++ b/cloudsmith_cli/core/tests/test_buildkite_detector.py @@ -0,0 +1,91 @@ +"""Tests for the Buildkite OIDC detector.""" + +import subprocess +from unittest import mock + +import pytest + +from cloudsmith_cli.core.credentials.models import CredentialContext +from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment +from cloudsmith_cli.core.credentials.oidc.detectors.buildkite import BuildkiteDetector + + +@pytest.fixture +def buildkite_env(): + env = { + "BUILDKITE": "true", + "BUILDKITE_JOB_ID": "0184990a-477b-4fa8-9968-496074483cee", + } + with mock.patch.dict("os.environ", env, clear=True): + yield env + + +class TestDetect: + def test_detects_when_buildkite_job_present(self, buildkite_env): + detector = BuildkiteDetector(context=CredentialContext()) + assert detector.detect() is True + + def test_not_detected_when_unset(self): + with mock.patch.dict("os.environ", {}, clear=True): + detector = BuildkiteDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_without_buildkite_flag(self, buildkite_env): + del buildkite_env["BUILDKITE"] + with mock.patch.dict("os.environ", buildkite_env, clear=True): + detector = BuildkiteDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_without_job_id(self, buildkite_env): + del buildkite_env["BUILDKITE_JOB_ID"] + with mock.patch.dict("os.environ", buildkite_env, clear=True): + detector = BuildkiteDetector(context=CredentialContext()) + assert detector.detect() is False + + +class TestGetToken: + def test_requests_token_with_default_audience(self, buildkite_env): + completed = subprocess.CompletedProcess([], 0, stdout="the-jwt\n", stderr="") + with mock.patch("subprocess.run", return_value=completed) as run: + detector = BuildkiteDetector(context=CredentialContext()) + + assert detector.get_token() == "the-jwt" + + run.assert_called_once_with( + [ + "buildkite-agent", + "oidc", + "request-token", + "--audience", + "cloudsmith", + ], + capture_output=True, + check=True, + text=True, + timeout=30, + ) + + def test_uses_custom_audience(self, buildkite_env): + completed = subprocess.CompletedProcess([], 0, stdout="the-jwt", stderr="") + with mock.patch("subprocess.run", return_value=completed) as run: + detector = BuildkiteDetector( + context=CredentialContext(oidc_audience="custom-audience") + ) + + detector.get_token() + + assert run.call_args.args[0][-1] == "custom-audience" + + def test_raises_when_agent_returns_empty_token(self, buildkite_env): + completed = subprocess.CompletedProcess([], 0, stdout="\n", stderr="") + with mock.patch("subprocess.run", return_value=completed): + detector = BuildkiteDetector(context=CredentialContext()) + + with pytest.raises(ValueError, match="empty token"): + detector.get_token() + + +class TestIntegration: + def test_detect_environment_selects_buildkite(self, buildkite_env): + detector = detect_environment(CredentialContext()) + assert isinstance(detector, BuildkiteDetector) From f2754b0eaf0498f8beaae9e0e3c62a32be276694 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:16:20 +0000 Subject: [PATCH 2/2] fix: require Buildkite agent in PATH for OIDC detection Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com> --- .../core/credentials/oidc/detectors/buildkite.py | 7 +++++-- .../core/tests/test_buildkite_detector.py | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py b/cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py index af51e571..846a9a7a 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py @@ -12,6 +12,7 @@ from __future__ import annotations import os +import shutil import subprocess from .base import EnvironmentDetector @@ -26,8 +27,10 @@ class BuildkiteDetector(EnvironmentDetector): id = "buildkite" def detect(self) -> bool: - return os.environ.get("BUILDKITE") == "true" and bool( - os.environ.get("BUILDKITE_JOB_ID") + return ( + os.environ.get("BUILDKITE") == "true" + and bool(os.environ.get("BUILDKITE_JOB_ID")) + and shutil.which("buildkite-agent") is not None ) def get_token(self) -> str: diff --git a/cloudsmith_cli/core/tests/test_buildkite_detector.py b/cloudsmith_cli/core/tests/test_buildkite_detector.py index e33ee03f..7385ff52 100644 --- a/cloudsmith_cli/core/tests/test_buildkite_detector.py +++ b/cloudsmith_cli/core/tests/test_buildkite_detector.py @@ -16,7 +16,10 @@ def buildkite_env(): "BUILDKITE": "true", "BUILDKITE_JOB_ID": "0184990a-477b-4fa8-9968-496074483cee", } - with mock.patch.dict("os.environ", env, clear=True): + with ( + mock.patch.dict("os.environ", env, clear=True), + mock.patch("shutil.which", return_value="/usr/bin/buildkite-agent"), + ): yield env @@ -42,6 +45,13 @@ def test_not_detected_without_job_id(self, buildkite_env): detector = BuildkiteDetector(context=CredentialContext()) assert detector.detect() is False + def test_not_detected_without_agent(self, buildkite_env): + with mock.patch("shutil.which", return_value=None) as which: + detector = BuildkiteDetector(context=CredentialContext()) + assert detector.detect() is False + + which.assert_called_once_with("buildkite-agent") + class TestGetToken: def test_requests_token_with_default_audience(self, buildkite_env): @@ -89,3 +99,7 @@ class TestIntegration: def test_detect_environment_selects_buildkite(self, buildkite_env): detector = detect_environment(CredentialContext()) assert isinstance(detector, BuildkiteDetector) + + def test_detect_environment_skips_buildkite_without_agent(self, buildkite_env): + with mock.patch("shutil.which", return_value=None): + assert detect_environment(CredentialContext()) is None