Repository: bazelbuild/bazel
Issue: Implement the credential helpers proposal
Labels: type: feature request
P2
team-Remote-Exec

### Description of the feature request:


Tracking issue for implementing https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md.

### What underlying problem are you trying to solve with this feature?


N/A

### Which operating system are you running Bazel on?


N/A

### What is the output of `bazel info release`?


N/A

### If `bazel info release` returns `development version` or `(@non-git)`, tell us how you built Bazel.


N/A

### What's the output of `git remote get-url origin; git rev-parse master; git rev-parse HEAD` ?


```text
N/A
```


### Have you found anything relevant by searching the web?

_No response_

### Any other information, logs, or outputs that you want to share?

_No response_

Issue comments:
@bazel-io flag
@bazel-io fork 5.3.0
Since this feature is implemented and cherry picked to 5.3.0, should we close this one?
The proposal hasn't been fully implemented yet (e.g. we're missing support for repository fetching).
Marking this as a release blocker since we want the repository fetching support to make it into 6.0.0 if possible.
@tjgq What's the ETA for implementing this? Ideally, we don't want to delay the 6.0 release further more, and since this should be a backwards compatible feature, we can cherry pick it in 6.x releases?
I was planning to get this submitted in time for 6.0.0, but other things got in the way. Let's do it for a 6.x release instead.
This has been fully implemented in 7.x and backported to 6.x.

Pull request review comments:

Patch that resolved the issue:
diff --git a/src/main/java/com/google/devtools/build/lib/authandtls/AuthAndTLSOptions.java b/src/main/java/com/google/devtools/build/lib/authandtls/AuthAndTLSOptions.java
index 2a54e3d7f96297..8641d1c1f8c23e 100644
--- a/src/main/java/com/google/devtools/build/lib/authandtls/AuthAndTLSOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/authandtls/AuthAndTLSOptions.java
@@ -14,6 +14,10 @@
 
 package com.google.devtools.build.lib.authandtls;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.devtools.common.options.Converter;
 import com.google.devtools.common.options.Converters.CommaSeparatedOptionListConverter;
 import com.google.devtools.common.options.Converters.DurationConverter;
 import com.google.devtools.common.options.Option;
@@ -21,8 +25,11 @@
 import com.google.devtools.common.options.OptionEffectTag;
 import com.google.devtools.common.options.OptionMetadataTag;
 import com.google.devtools.common.options.OptionsBase;
+import com.google.devtools.common.options.OptionsParsingException;
 import java.time.Duration;
 import java.util.List;
+import java.util.Optional;
+import javax.annotation.Nullable;
 
 /**
  * Common options for authentication and TLS.
@@ -131,4 +138,53 @@ public class AuthAndTLSOptions extends OptionsBase {
               + "granularity; it is an error to set a value less than one second. If keep-alive "
               + "pings are disabled, then this setting is ignored.")
   public Duration grpcKeepaliveTimeout;
+
+  /** One of the values of the `--credential_helper` flag. */
+  @AutoValue
+  public abstract static class UnresolvedScopedCredentialHelper {
+    /** Returns the scope of the credential helper (if any). */
+    public abstract Optional<String> getScope();
+
+    /** Returns the (unparsed) path of the credential helper. */
+    public abstract String getPath();
+  }
+
+  /** A {@link Converter} for the `--credential_helper` flag. */
+  public static final class UnresolvedScopedCredentialHelperConverter
+      implements Converter<UnresolvedScopedCredentialHelper> {
+    public static final UnresolvedScopedCredentialHelperConverter INSTANCE =
+        new UnresolvedScopedCredentialHelperConverter();
+
+    @Override
+    public String getTypeDescription() {
+      return "An (unresolved) path to a credential helper for a scope.";
+    }
+
+    @Override
+    public UnresolvedScopedCredentialHelper convert(String input) throws OptionsParsingException {
+      Preconditions.checkNotNull(input);
+
+      int pos = input.indexOf('=');
+      if (pos >= 0) {
+        String scope = input.substring(0, pos);
+        if (Strings.isNullOrEmpty(scope)) {
+          throw new OptionsParsingException("Scope of credential helper must not be empty");
+        }
+        String path = checkPath(input.substring(pos + 1));
+        return new AutoValue_AuthAndTLSOptions_UnresolvedScopedCredentialHelper(
+            Optional.of(scope), path);
+      }
+
+      // `input` does not specify a scope.
+      return new AutoValue_AuthAndTLSOptions_UnresolvedScopedCredentialHelper(
+          Optional.empty(), checkPath(input));
+    }
+
+    private String checkPath(@Nullable String input) throws OptionsParsingException {
+      if (Strings.isNullOrEmpty(input)) {
+        throw new OptionsParsingException("Path to credential helper must not be empty");
+      }
+      return input;
+    }
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialHelper.java b/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialHelper.java
index c82417b0034383..e410c0b52bc771 100644
--- a/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialHelper.java
+++ b/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialHelper.java
@@ -49,7 +49,7 @@ public final class CredentialHelper {
   }
 
   @VisibleForTesting
-  Path getPath() {
+  public Path getPath() {
     return path;
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/remote/BUILD b/src/main/java/com/google/devtools/build/lib/remote/BUILD
index 849e4c23fb0f53..60b32486cb14e8 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/remote/BUILD
@@ -57,6 +57,7 @@ java_library(
         "//src/main/java/com/google/devtools/build/lib/analysis:top_level_artifact_context",
         "//src/main/java/com/google/devtools/build/lib/analysis/platform:platform_utils",
         "//src/main/java/com/google/devtools/build/lib/authandtls",
+        "//src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper",
         "//src/main/java/com/google/devtools/build/lib/bazel/repository/downloader",
         "//src/main/java/com/google/devtools/build/lib/buildeventstream",
         "//src/main/java/com/google/devtools/build/lib/clock",
@@ -99,6 +100,7 @@ java_library(
         "//src/main/java/com/google/devtools/common/options",
         "//src/main/protobuf:failure_details_java_proto",
         "//third_party:auth",
+        "//third_party:auto_value",
         "//third_party:caffeine",
         "//third_party:flogger",
         "//third_party:guava",
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
index 871a9d42e6a4d7..0122226f23a59e 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
@@ -19,6 +19,7 @@
 import build.bazel.remote.execution.v2.DigestFunction;
 import build.bazel.remote.execution.v2.ServerCapabilities;
 import com.google.auth.Credentials;
+import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Ascii;
 import com.google.common.base.Preconditions;
@@ -45,11 +46,14 @@
 import com.google.devtools.build.lib.analysis.configuredtargets.RuleConfiguredTarget;
 import com.google.devtools.build.lib.analysis.test.TestProvider;
 import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions;
+import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions.UnresolvedScopedCredentialHelper;
 import com.google.devtools.build.lib.authandtls.CallCredentialsProvider;
 import com.google.devtools.build.lib.authandtls.GoogleAuthUtils;
 import com.google.devtools.build.lib.authandtls.Netrc;
 import com.google.devtools.build.lib.authandtls.NetrcCredentials;
 import com.google.devtools.build.lib.authandtls.NetrcParser;
+import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialHelperEnvironment;
+import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialHelperProvider;
 import com.google.devtools.build.lib.bazel.repository.downloader.Downloader;
 import com.google.devtools.build.lib.buildeventstream.BuildEventArtifactUploader;
 import com.google.devtools.build.lib.buildeventstream.LocalFilesArtifactUploader;
@@ -77,6 +81,7 @@
 import com.google.devtools.build.lib.runtime.BuildEventArtifactUploaderFactory;
 import com.google.devtools.build.lib.runtime.Command;
 import com.google.devtools.build.lib.runtime.CommandEnvironment;
+import com.google.devtools.build.lib.runtime.CommandLinePathFactory;
 import com.google.devtools.build.lib.runtime.RepositoryRemoteExecutor;
 import com.google.devtools.build.lib.runtime.RepositoryRemoteExecutorFactory;
 import com.google.devtools.build.lib.runtime.ServerBuilder;
@@ -1134,4 +1139,37 @@ static Credentials newCredentials(
 
     return creds;
   }
+
+  @VisibleForTesting
+  static CredentialHelperProvider newCredentialHelperProvider(
+      CredentialHelperEnvironment environment,
+      CommandLinePathFactory pathFactory,
+      List<UnresolvedScopedCredentialHelper> helpers)
+      throws IOException {
+    Preconditions.checkNotNull(environment);
+    Preconditions.checkNotNull(pathFactory);
+    Preconditions.checkNotNull(helpers);
+
+    CredentialHelperProvider.Builder builder = CredentialHelperProvider.builder();
+    for (UnresolvedScopedCredentialHelper helper : helpers) {
+      Optional<String> scope = helper.getScope();
+      Path path = pathFactory.create(environment.getClientEnvironment(), helper.getPath());
+      if (scope.isPresent()) {
+        builder.add(scope.get(), path);
+      } else {
+        builder.add(path);
+      }
+    }
+    return builder.build();
+  }
+
+  @VisibleForTesting
+  @AutoValue
+  abstract static class ScopedCredentialHelper {
+    /** Returns the scope of the credential helper (if any). */
+    public abstract Optional<String> getScope();
+
+    /** Returns the path of the credential helper. */
+    public abstract Path getPath();
+  }
 }


Test patch:
diff --git a/src/test/java/com/google/devtools/build/lib/authandtls/BUILD b/src/test/java/com/google/devtools/build/lib/authandtls/BUILD
index 724726bd61e8e4..b2fbbd60ec8b8e 100644
--- a/src/test/java/com/google/devtools/build/lib/authandtls/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/authandtls/BUILD
@@ -25,6 +25,7 @@ java_library(
     ),
     deps = [
         "//src/main/java/com/google/devtools/build/lib/authandtls",
+        "//src/main/java/com/google/devtools/common/options",
         "//third_party:guava",
         "//third_party:junit4",
         "//third_party:mockito",
diff --git a/src/test/java/com/google/devtools/build/lib/authandtls/UnresolvedScopedCredentialHelperConverterTest.java b/src/test/java/com/google/devtools/build/lib/authandtls/UnresolvedScopedCredentialHelperConverterTest.java
new file mode 100644
index 00000000000000..e4708348719207
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/authandtls/UnresolvedScopedCredentialHelperConverterTest.java
@@ -0,0 +1,108 @@
+// Copyright 2022 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.lib.authandtls;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions.UnresolvedScopedCredentialHelper;
+import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions.UnresolvedScopedCredentialHelperConverter;
+import com.google.devtools.common.options.OptionsParsingException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Test for {@link UnresolvedScopedCredentialHelperConverter}. */
+@RunWith(JUnit4.class)
+public class UnresolvedScopedCredentialHelperConverterTest {
+  @Test
+  public void convertAbsolutePath() throws Exception {
+    UnresolvedScopedCredentialHelper helper1 =
+        UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("/absolute/path");
+    assertThat(helper1.getScope()).isEmpty();
+    assertThat(helper1.getPath()).isEqualTo("/absolute/path");
+
+    UnresolvedScopedCredentialHelper helper2 =
+        UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("example.com=/absolute/path");
+    assertThat(helper2.getScope()).hasValue("example.com");
+    assertThat(helper2.getPath()).isEqualTo("/absolute/path");
+
+    UnresolvedScopedCredentialHelper helper3 =
+        UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("*.example.com=/absolute/path");
+    assertThat(helper3.getScope()).hasValue("*.example.com");
+    assertThat(helper3.getPath()).isEqualTo("/absolute/path");
+  }
+
+  @Test
+  public void convertRootRelativePath() throws Exception {
+    UnresolvedScopedCredentialHelper helper1 =
+        UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("%workspace%/path");
+    assertThat(helper1.getScope()).isEmpty();
+    assertThat(helper1.getPath()).isEqualTo("%workspace%/path");
+
+    UnresolvedScopedCredentialHelper helper2 =
+        UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("example.com=%workspace%/path");
+    assertThat(helper2.getScope()).hasValue("example.com");
+    assertThat(helper2.getPath()).isEqualTo("%workspace%/path");
+
+    UnresolvedScopedCredentialHelper helper3 =
+        UnresolvedScopedCredentialHelperConverter.INSTANCE.convert(
+            "*.example.com=%workspace%/path");
+    assertThat(helper3.getScope()).hasValue("*.example.com");
+    assertThat(helper3.getPath()).isEqualTo("%workspace%/path");
+  }
+
+  @Test
+  public void convertPathLookup() throws Exception {
+    UnresolvedScopedCredentialHelper helper1 =
+        UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("foo");
+    assertThat(helper1.getScope()).isEmpty();
+    assertThat(helper1.getPath()).isEqualTo("foo");
+
+    UnresolvedScopedCredentialHelper helper2 =
+        UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("example.com=foo");
+    assertThat(helper2.getScope()).hasValue("example.com");
+    assertThat(helper2.getPath()).isEqualTo("foo");
+
+    UnresolvedScopedCredentialHelper helper3 =
+        UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("*.example.com=foo");
+    assertThat(helper3.getScope()).hasValue("*.example.com");
+    assertThat(helper3.getPath()).isEqualTo("foo");
+  }
+
+  @Test
+  public void emptyPath() {
+    assertThrows(
+        OptionsParsingException.class,
+        () -> UnresolvedScopedCredentialHelperConverter.INSTANCE.convert(""));
+    assertThrows(
+        OptionsParsingException.class,
+        () -> UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("foo="));
+    assertThrows(
+        OptionsParsingException.class,
+        () -> UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("="));
+  }
+
+  @Test
+  public void emptyScope() {
+    assertThrows(
+        OptionsParsingException.class,
+        () -> UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("=/foo"));
+    assertThrows(
+        OptionsParsingException.class,
+        () -> UnresolvedScopedCredentialHelperConverter.INSTANCE.convert("="));
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/remote/BUILD b/src/test/java/com/google/devtools/build/lib/remote/BUILD
index 34a317a3915a1e..88d69b7436bf37 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/remote/BUILD
@@ -56,6 +56,7 @@ java_test(
         "//src/main/java/com/google/devtools/build/lib/analysis:server_directories",
         "//src/main/java/com/google/devtools/build/lib/analysis/platform:platform_utils",
         "//src/main/java/com/google/devtools/build/lib/authandtls",
+        "//src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper",
         "//src/main/java/com/google/devtools/build/lib/buildeventstream",
         "//src/main/java/com/google/devtools/build/lib/clock",
         "//src/main/java/com/google/devtools/build/lib/collect/nestedset",
diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteModuleTest.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteModuleTest.java
index bfc0171b6b84f6..e15cdf3ecbe2c4 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/RemoteModuleTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteModuleTest.java
@@ -14,6 +14,7 @@
 package com.google.devtools.build.lib.remote;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.Assert.assertThrows;
 import static org.mockito.Mockito.mock;
@@ -27,6 +28,7 @@
 import build.bazel.remote.execution.v2.GetCapabilitiesRequest;
 import build.bazel.remote.execution.v2.ServerCapabilities;
 import com.google.auth.Credentials;
+import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
@@ -35,7 +37,10 @@
 import com.google.devtools.build.lib.analysis.ServerDirectories;
 import com.google.devtools.build.lib.analysis.config.CoreOptions;
 import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions;
+import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions.UnresolvedScopedCredentialHelper;
 import com.google.devtools.build.lib.authandtls.BasicHttpAuthenticationEncoder;
+import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialHelperEnvironment;
+import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialHelperProvider;
 import com.google.devtools.build.lib.events.Reporter;
 import com.google.devtools.build.lib.exec.BinTools;
 import com.google.devtools.build.lib.exec.ExecutionOptions;
@@ -47,15 +52,18 @@
 import com.google.devtools.build.lib.runtime.ClientOptions;
 import com.google.devtools.build.lib.runtime.Command;
 import com.google.devtools.build.lib.runtime.CommandEnvironment;
+import com.google.devtools.build.lib.runtime.CommandLinePathFactory;
 import com.google.devtools.build.lib.runtime.CommonCommandOptions;
 import com.google.devtools.build.lib.runtime.commands.BuildCommand;
 import com.google.devtools.build.lib.testutil.Scratch;
 import com.google.devtools.build.lib.util.AbruptExitException;
 import com.google.devtools.build.lib.vfs.DigestHashFunction;
 import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
 import com.google.devtools.common.options.Options;
 import com.google.devtools.common.options.OptionsParser;
+import com.google.devtools.common.options.OptionsParsingException;
 import com.google.devtools.common.options.OptionsParsingResult;
 import io.grpc.BindableService;
 import io.grpc.Server;
@@ -65,7 +73,9 @@
 import io.grpc.stub.StreamObserver;
 import io.grpc.util.MutableHandlerRegistry;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.net.URI;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -576,4 +586,258 @@ private static void assertRequestMetadata(
     assertThat(Iterables.getOnlyElement(requestMetadata.values()))
         .containsExactly(BasicHttpAuthenticationEncoder.encode(username, password, UTF_8));
   }
+
+  @Test
+  public void testCredentialHelperProvider() throws Exception {
+    FileSystem fileSystem = new InMemoryFileSystem(DigestHashFunction.SHA256);
+
+    Path workspace = fileSystem.getPath("/workspace");
+    Path pathValue = fileSystem.getPath("/usr/local/bin");
+    pathValue.createDirectoryAndParents();
+
+    CredentialHelperEnvironment credentialHelperEnvironment =
+        CredentialHelperEnvironment.newBuilder()
+            .setEventReporter(new Reporter(new EventBus()))
+            .setWorkspacePath(workspace)
+            .setClientEnvironment(ImmutableMap.of("PATH", pathValue.getPathString()))
+            .setHelperExecutionTimeout(Duration.ZERO)
+            .build();
+    CommandLinePathFactory commandLinePathFactory =
+        new CommandLinePathFactory(fileSystem, ImmutableMap.of("workspace", workspace));
+
+    Path unusedHelper = createExecutable(fileSystem, "/unused/helper");
+
+    Path defaultHelper = createExecutable(fileSystem, "/default/helper");
+    Path exampleComHelper = createExecutable(fileSystem, "/example/com/helper");
+    Path fooExampleComHelper = createExecutable(fileSystem, "/foo/example/com/helper");
+    Path exampleComWildcardHelper = createExecutable(fileSystem, "/example/com/wildcard/helper");
+
+    Path exampleOrgHelper = createExecutable(workspace.getRelative("helpers/example-org"));
+
+    // No helpers.
+    CredentialHelperProvider credentialHelperProvider1 =
+        newCredentialHelperProvider(
+            credentialHelperEnvironment, commandLinePathFactory, ImmutableList.of());
+    assertThat(credentialHelperProvider1.findCredentialHelper(URI.create("https://example.com")))
+        .isEmpty();
+    assertThat(
+            credentialHelperProvider1.findCredentialHelper(URI.create("https://foo.example.com")))
+        .isEmpty();
+
+    // Default helper only.
+    CredentialHelperProvider credentialHelperProvider2 =
+        newCredentialHelperProvider(
+            credentialHelperEnvironment,
+            commandLinePathFactory,
+            ImmutableList.of(defaultHelper.getPathString()));
+    assertThat(
+            credentialHelperProvider2
+                .findCredentialHelper(URI.create("https://example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(defaultHelper);
+    assertThat(
+            credentialHelperProvider2
+                .findCredentialHelper(URI.create("https://foo.example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(defaultHelper);
+
+    // Default and exact match.
+    CredentialHelperProvider credentialHelperProvider3 =
+        newCredentialHelperProvider(
+            credentialHelperEnvironment,
+            commandLinePathFactory,
+            ImmutableList.of(
+                defaultHelper.getPathString(), "example.com=" + exampleComHelper.getPathString()));
+    assertThat(
+            credentialHelperProvider3
+                .findCredentialHelper(URI.create("https://example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(exampleComHelper);
+    assertThat(
+            credentialHelperProvider3
+                .findCredentialHelper(URI.create("https://foo.example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(defaultHelper);
+
+    // Exact match without default.
+    CredentialHelperProvider credentialHelperProvider4 =
+        newCredentialHelperProvider(
+            credentialHelperEnvironment,
+            commandLinePathFactory,
+            ImmutableList.of("example.com=" + exampleComHelper.getPathString()));
+    assertThat(
+            credentialHelperProvider4
+                .findCredentialHelper(URI.create("https://example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(exampleComHelper);
+    assertThat(
+            credentialHelperProvider4.findCredentialHelper(URI.create("https://foo.example.com")))
+        .isEmpty();
+
+    // Multiple scoped helpers with default.
+    CredentialHelperProvider credentialHelperProvider5 =
+        newCredentialHelperProvider(
+            credentialHelperEnvironment,
+            commandLinePathFactory,
+            ImmutableList.of(
+                defaultHelper.getPathString(),
+                "example.com=" + exampleComHelper.getPathString(),
+                "*.foo.example.com=" + fooExampleComHelper.getPathString(),
+                "*.example.com=" + exampleComWildcardHelper.getPathString(),
+                "example.org=%workspace%/helpers/example-org"));
+    assertThat(
+            credentialHelperProvider5
+                .findCredentialHelper(URI.create("https://anotherdomain.com"))
+                .get()
+                .getPath())
+        .isEqualTo(defaultHelper);
+    assertThat(
+            credentialHelperProvider5
+                .findCredentialHelper(URI.create("https://example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(exampleComHelper);
+    assertThat(
+            credentialHelperProvider5
+                .findCredentialHelper(URI.create("https://foo.example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(fooExampleComHelper);
+    assertThat(
+            credentialHelperProvider5
+                .findCredentialHelper(URI.create("https://abc.foo.example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(fooExampleComHelper);
+    assertThat(
+            credentialHelperProvider5
+                .findCredentialHelper(URI.create("https://bar.example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(exampleComWildcardHelper);
+    assertThat(
+            credentialHelperProvider5
+                .findCredentialHelper(URI.create("https://abc.bar.example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(exampleComWildcardHelper);
+    assertThat(
+            credentialHelperProvider5
+                .findCredentialHelper(URI.create("https://example.org"))
+                .get()
+                .getPath())
+        .isEqualTo(exampleOrgHelper);
+
+    // Helpers override.
+    CredentialHelperProvider credentialHelperProvider6 =
+        newCredentialHelperProvider(
+            credentialHelperEnvironment,
+            commandLinePathFactory,
+            ImmutableList.of(
+                // <system .bazelrc>
+                unusedHelper.getPathString(),
+
+                // <user .bazelrc>
+                defaultHelper.getPathString(),
+                "example.com=" + unusedHelper.getPathString(),
+                "*.example.com=" + unusedHelper.getPathString(),
+                "example.org=" + unusedHelper.getPathString(),
+                "*.example.org=" + exampleOrgHelper.getPathString(),
+
+                // <workspace .bazelrc>
+                "*.example.com=" + exampleComWildcardHelper.getPathString(),
+                "example.org=" + exampleOrgHelper.getPathString(),
+                "*.foo.example.com=" + unusedHelper.getPathString(),
+
+                // <command-line>
+                "example.com=" + exampleComHelper.getPathString(),
+                "*.foo.example.com=" + fooExampleComHelper.getPathString()));
+    assertThat(
+            credentialHelperProvider6
+                .findCredentialHelper(URI.create("https://anotherdomain.com"))
+                .get()
+                .getPath())
+        .isEqualTo(defaultHelper);
+    assertThat(
+            credentialHelperProvider6
+                .findCredentialHelper(URI.create("https://example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(exampleComHelper);
+    assertThat(
+            credentialHelperProvider6
+                .findCredentialHelper(URI.create("https://foo.example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(fooExampleComHelper);
+    assertThat(
+            credentialHelperProvider6
+                .findCredentialHelper(URI.create("https://bar.example.com"))
+                .get()
+                .getPath())
+        .isEqualTo(exampleComWildcardHelper);
+    assertThat(
+            credentialHelperProvider6
+                .findCredentialHelper(URI.create("https://example.org"))
+                .get()
+                .getPath())
+        .isEqualTo(exampleOrgHelper);
+    assertThat(
+            credentialHelperProvider6
+                .findCredentialHelper(URI.create("https://foo.example.org"))
+                .get()
+                .getPath())
+        .isEqualTo(exampleOrgHelper);
+  }
+
+  private static Path createExecutable(FileSystem fileSystem, String path) throws IOException {
+    Preconditions.checkNotNull(fileSystem);
+    Preconditions.checkNotNull(path);
+
+    return createExecutable(fileSystem.getPath(path));
+  }
+
+  private static Path createExecutable(Path path) throws IOException {
+    Preconditions.checkNotNull(path);
+
+    path.getParentDirectory().createDirectoryAndParents();
+    try (OutputStream unused = path.getOutputStream()) {
+      // Nothing to do.
+    }
+    path.setExecutable(true);
+
+    return path;
+  }
+
+  private static CredentialHelperProvider newCredentialHelperProvider(
+      CredentialHelperEnvironment credentialHelperEnvironment,
+      CommandLinePathFactory commandLinePathFactory,
+      ImmutableList<String> inputs)
+      throws Exception {
+    Preconditions.checkNotNull(credentialHelperEnvironment);
+    Preconditions.checkNotNull(commandLinePathFactory);
+    Preconditions.checkNotNull(inputs);
+
+    return RemoteModule.newCredentialHelperProvider(
+        credentialHelperEnvironment,
+        commandLinePathFactory,
+        ImmutableList.copyOf(
+            Iterables.transform(inputs, s -> createUnresolvedScopedCredentialHelper(s))));
+  }
+
+  private static UnresolvedScopedCredentialHelper createUnresolvedScopedCredentialHelper(
+      String input) {
+    Preconditions.checkNotNull(input);
+
+    try {
+      return AuthAndTLSOptions.UnresolvedScopedCredentialHelperConverter.INSTANCE.convert(input);
+    } catch (OptionsParsingException e) {
+      throw new IllegalStateException(e);
+    }
+  }
 }