[5.4.0] Keep credentials cached across build commands. (#16884)

When using a credential helper, the lifetime of the credential cache is currently tied to an individual command, which causes the helper to be called for every command resulting in poor incremental build latency for builds using a non-trivial helper.

Since the cache must be shared by RemoteModule and BazelBuildServiceModule, I've introduced a new CredentialModule whose sole purpose is to provide access to it.

Closes #16822.

PiperOrigin-RevId: 491598103
Change-Id: Ib668954b635a0e9498f0a7418707d6a2dfae0265
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 f6480381..e3b4036 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
@@ -174,7 +174,11 @@
       converter = DurationConverter.class,
       documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
       effectTags = {OptionEffectTag.UNKNOWN},
-      help = "Configures the duration for which credentials from Credential Helpers are cached.")
+      help =
+          "Configures the duration for which credentials from Credential Helpers are cached.\n\n"
+              + "Invoking with a different value will adjust the lifetime of preexisting entries;"
+              + " pass zero to clear the cache. A clean command always clears the cache, regardless"
+              + " of this flag.")
   public Duration credentialHelperCacheTimeout;
 
   /** One of the values of the `--credential_helper` flag. */
diff --git a/src/main/java/com/google/devtools/build/lib/authandtls/BUILD b/src/main/java/com/google/devtools/build/lib/authandtls/BUILD
index 28d266d..32f6e7f 100644
--- a/src/main/java/com/google/devtools/build/lib/authandtls/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/authandtls/BUILD
@@ -22,6 +22,7 @@
         "//src/main/java/com/google/devtools/common/options",
         "//third_party:auth",
         "//third_party:auto_value",
+        "//third_party:caffeine",
         "//third_party:guava",
         "//third_party:jsr305",
         "//third_party:netty",
diff --git a/src/main/java/com/google/devtools/build/lib/authandtls/GoogleAuthUtils.java b/src/main/java/com/google/devtools/build/lib/authandtls/GoogleAuthUtils.java
index 93c80f5..47ad9d9 100644
--- a/src/main/java/com/google/devtools/build/lib/authandtls/GoogleAuthUtils.java
+++ b/src/main/java/com/google/devtools/build/lib/authandtls/GoogleAuthUtils.java
@@ -14,11 +14,14 @@
 
 package com.google.devtools.build.lib.authandtls;
 
+import com.github.benmanes.caffeine.cache.Cache;
 import com.google.auth.Credentials;
 import com.google.auth.oauth2.GoogleCredentials;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialHelperCredentials;
 import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialHelperEnvironment;
 import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialHelperProvider;
@@ -47,6 +50,7 @@
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
+import java.net.URI;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -235,6 +239,7 @@
    */
   public static Credentials newCredentials(
       CredentialHelperEnvironment credentialHelperEnvironment,
+      Cache<URI, ImmutableMap<String, ImmutableList<String>>> credentialCache,
       CommandLinePathFactory commandLinePathFactory,
       FileSystem fileSystem,
       AuthAndTLSOptions authAndTlsOptions)
@@ -244,12 +249,12 @@
     Preconditions.checkNotNull(fileSystem);
     Preconditions.checkNotNull(authAndTlsOptions);
 
-    Optional<Credentials> credentials = newGoogleCredentials(authAndTlsOptions);
+    Optional<Credentials> fallbackCredentials = newGoogleCredentials(authAndTlsOptions);
 
-    if (credentials.isEmpty()) {
+    if (fallbackCredentials.isEmpty()) {
       // Fallback to .netrc if it exists.
       try {
-        credentials =
+        fallbackCredentials =
             newCredentialsFromNetrc(credentialHelperEnvironment.getClientEnvironment(), fileSystem);
       } catch (IOException e) {
         // TODO(yannic): Make this fail the build.
@@ -263,8 +268,8 @@
             commandLinePathFactory,
             authAndTlsOptions.credentialHelpers),
         credentialHelperEnvironment,
-        credentials,
-        authAndTlsOptions.credentialHelperCacheTimeout);
+        credentialCache,
+        fallbackCredentials);
   }
 
   /**
diff --git a/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/BUILD b/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/BUILD
index 62a58d7..46b0a6d 100644
--- a/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/BUILD
@@ -11,8 +11,22 @@
 )
 
 java_library(
+    name = "credential_module",
+    srcs = ["CredentialModule.java"],
+    deps = [
+        "//src/main/java/com/google/devtools/build/lib:runtime",
+        "//src/main/java/com/google/devtools/build/lib/authandtls",
+        "//third_party:caffeine",
+        "//third_party:guava",
+    ],
+)
+
+java_library(
     name = "credentialhelper",
-    srcs = glob(["*.java"]),
+    srcs = glob(
+        ["*.java"],
+        exclude = ["CredentialModule.java"],
+    ),
     deps = [
         "//src/main/java/com/google/devtools/build/lib/events",
         "//src/main/java/com/google/devtools/build/lib/profiler",
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 2219a59..c1f0a09 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
@@ -67,7 +67,7 @@
    * @return The response from the subprocess.
    */
   public GetCredentialsResponse getCredentials(CredentialHelperEnvironment environment, URI uri)
-      throws InterruptedException, IOException {
+      throws IOException {
     Preconditions.checkNotNull(environment);
     Preconditions.checkNotNull(uri);
 
@@ -81,7 +81,16 @@
           GSON.toJson(GetCredentialsRequest.newBuilder().setUri(uri).build(), stdin);
         }
 
-        process.waitFor();
+        try {
+          process.waitFor();
+        } catch (InterruptedException e) {
+          throw new CredentialHelperException(
+              String.format(
+                  Locale.US,
+                  "Failed to get credentials for '%s' from helper '%s': process was interrupted",
+                  uri,
+                  path));
+        }
 
         if (process.timedout()) {
           throw new CredentialHelperException(
diff --git a/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialHelperCredentials.java b/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialHelperCredentials.java
index ecc40e1..5de7608 100644
--- a/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialHelperCredentials.java
+++ b/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialHelperCredentials.java
@@ -14,15 +14,13 @@
 
 package com.google.devtools.build.lib.authandtls.credentialhelper;
 
-import com.github.benmanes.caffeine.cache.CacheLoader;
-import com.github.benmanes.caffeine.cache.Caffeine;
-import com.github.benmanes.caffeine.cache.LoadingCache;
+import com.github.benmanes.caffeine.cache.Cache;
 import com.google.auth.Credentials;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import java.io.IOException;
 import java.net.URI;
-import java.time.Duration;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -33,29 +31,34 @@
  * helper} as subprocess, falling back to another {@link Credentials} if no suitable helper exists.
  */
 public class CredentialHelperCredentials extends Credentials {
+  private final CredentialHelperProvider credentialHelperProvider;
+  private final CredentialHelperEnvironment credentialHelperEnvironment;
+  private final Cache<URI, ImmutableMap<String, ImmutableList<String>>> credentialCache;
   private final Optional<Credentials> fallbackCredentials;
 
-  private final LoadingCache<URI, GetCredentialsResponse> credentialCache;
+  /** Wraps around an {@link IOException} so we can smuggle it through {@link Cache#get}. */
+  public static final class WrappedIOException extends RuntimeException {
+    private final IOException wrapped;
+
+    WrappedIOException(IOException e) {
+      super(e);
+      this.wrapped = e;
+    }
+
+    IOException getWrapped() {
+      return wrapped;
+    }
+  }
 
   public CredentialHelperCredentials(
       CredentialHelperProvider credentialHelperProvider,
       CredentialHelperEnvironment credentialHelperEnvironment,
-      Optional<Credentials> fallbackCredentials,
-      Duration cacheTimeout) {
-    Preconditions.checkNotNull(credentialHelperProvider);
-    Preconditions.checkNotNull(credentialHelperEnvironment);
+      Cache<URI, ImmutableMap<String, ImmutableList<String>>> credentialCache,
+      Optional<Credentials> fallbackCredentials) {
+    this.credentialHelperProvider = Preconditions.checkNotNull(credentialHelperProvider);
+    this.credentialHelperEnvironment = Preconditions.checkNotNull(credentialHelperEnvironment);
+    this.credentialCache = Preconditions.checkNotNull(credentialCache);
     this.fallbackCredentials = Preconditions.checkNotNull(fallbackCredentials);
-    Preconditions.checkNotNull(cacheTimeout);
-    Preconditions.checkArgument(
-        !cacheTimeout.isNegative() && !cacheTimeout.isZero(),
-        "Cache timeout must be greater than 0");
-
-    credentialCache =
-        Caffeine.newBuilder()
-            .expireAfterWrite(cacheTimeout)
-            .build(
-                new CredentialHelperCacheLoader(
-                    credentialHelperProvider, credentialHelperEnvironment));
   }
 
   @Override
@@ -68,12 +71,18 @@
   }
 
   @Override
+  @SuppressWarnings("unchecked") // Map<String, ImmutableList<String>> to Map<String<List<String>>
   public Map<String, List<String>> getRequestMetadata(URI uri) throws IOException {
     Preconditions.checkNotNull(uri);
 
-    Optional<Map<String, List<String>>> credentials = getRequestMetadataFromCredentialHelper(uri);
-    if (credentials.isPresent()) {
-      return credentials.get();
+    ImmutableMap<String, ImmutableList<String>> credentials;
+    try {
+      credentials = credentialCache.get(uri, this::getCredentialsFromHelper);
+    } catch (WrappedIOException e) {
+      throw e.getWrapped();
+    }
+    if (credentials != null) {
+      return (Map) credentials;
     }
 
     if (fallbackCredentials.isPresent()) {
@@ -83,13 +92,28 @@
     return ImmutableMap.of();
   }
 
-  @SuppressWarnings("unchecked") // Map<String, ImmutableList<String>> to Map<String<List<String>>
-  private Optional<Map<String, List<String>>> getRequestMetadataFromCredentialHelper(URI uri) {
+  @Nullable
+  private ImmutableMap<String, ImmutableList<String>> getCredentialsFromHelper(URI uri) {
     Preconditions.checkNotNull(uri);
 
-    GetCredentialsResponse response = credentialCache.get(uri);
+    Optional<CredentialHelper> maybeCredentialHelper =
+        credentialHelperProvider.findCredentialHelper(uri);
+    if (maybeCredentialHelper.isEmpty()) {
+      return null;
+    }
+    CredentialHelper credentialHelper = maybeCredentialHelper.get();
 
-    return Optional.ofNullable(response).map(value -> (Map) value.getHeaders());
+    GetCredentialsResponse response;
+    try {
+      response = credentialHelper.getCredentials(credentialHelperEnvironment, uri);
+    } catch (IOException e) {
+      throw new WrappedIOException(e);
+    }
+    if (response == null) {
+      return null;
+    }
+
+    return response.getHeaders();
   }
 
   @Override
@@ -110,32 +134,4 @@
 
     credentialCache.invalidateAll();
   }
-
-  private static final class CredentialHelperCacheLoader
-      implements CacheLoader<URI, GetCredentialsResponse> {
-    private final CredentialHelperProvider credentialHelperProvider;
-    private final CredentialHelperEnvironment credentialHelperEnvironment;
-
-    public CredentialHelperCacheLoader(
-        CredentialHelperProvider credentialHelperProvider,
-        CredentialHelperEnvironment credentialHelperEnvironment) {
-      this.credentialHelperProvider = Preconditions.checkNotNull(credentialHelperProvider);
-      this.credentialHelperEnvironment = Preconditions.checkNotNull(credentialHelperEnvironment);
-    }
-
-    @Nullable
-    @Override
-    public GetCredentialsResponse load(URI uri) throws IOException, InterruptedException {
-      Preconditions.checkNotNull(uri);
-
-      Optional<CredentialHelper> maybeCredentialHelper =
-          credentialHelperProvider.findCredentialHelper(uri);
-      if (maybeCredentialHelper.isEmpty()) {
-        return null;
-      }
-      CredentialHelper credentialHelper = maybeCredentialHelper.get();
-
-      return credentialHelper.getCredentials(credentialHelperEnvironment, uri);
-    }
-  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialModule.java b/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialModule.java
new file mode 100644
index 0000000..95af2af
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper/CredentialModule.java
@@ -0,0 +1,52 @@
+// 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.credentialhelper;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions;
+import com.google.devtools.build.lib.runtime.BlazeModule;
+import com.google.devtools.build.lib.runtime.CommandEnvironment;
+import java.net.URI;
+import java.time.Duration;
+
+/** A module whose sole purpose is to hold the credential cache which is shared by other modules. */
+public class CredentialModule extends BlazeModule {
+  private final Cache<URI, ImmutableMap<String, ImmutableList<String>>> credentialCache =
+      Caffeine.newBuilder().expireAfterWrite(Duration.ZERO).build();
+
+  /** Returns the credential cache. */
+  public Cache<URI, ImmutableMap<String, ImmutableList<String>>> getCredentialCache() {
+    return credentialCache;
+  }
+
+  @Override
+  public void beforeCommand(CommandEnvironment env) {
+    // Update the cache expiration policy according to the command options.
+    AuthAndTLSOptions authAndTlsOptions = env.getOptions().getOptions(AuthAndTLSOptions.class);
+    credentialCache
+        .policy()
+        .expireAfterWrite()
+        .get()
+        .setExpiresAfter(authAndTlsOptions.credentialHelperCacheTimeout);
+
+    // Clear the cache on clean.
+    if (env.getCommand().name().equals("clean")) {
+      credentialCache.invalidateAll();
+    }
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/BUILD b/src/main/java/com/google/devtools/build/lib/bazel/BUILD
index 968c540..51496d1 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/bazel/BUILD
@@ -131,6 +131,7 @@
         ":spawn_log_module",
         "//src/main/java/com/google/devtools/build/lib:runtime",
         "//src/main/java/com/google/devtools/build/lib/analysis:blaze_version_info",
+        "//src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper:credential_module",
         "//src/main/java/com/google/devtools/build/lib/bazel/coverage",
         "//src/main/java/com/google/devtools/build/lib/bazel/debug:workspace-rule-module",
         "//src/main/java/com/google/devtools/build/lib/bazel/repository",
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java b/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java
index c6c4df2..86af64f 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.lib.analysis.BlazeVersionInfo;
 import com.google.devtools.build.lib.bazel.repository.starlark.StarlarkRepositoryDebugModule;
+import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialModule;
 import com.google.devtools.build.lib.runtime.BlazeModule;
 import com.google.devtools.build.lib.runtime.BlazeRuntime;
 import java.io.IOException;
@@ -42,6 +43,8 @@
           // This module needs to be registered before any module providing a SpawnCache
           // implementation.
           com.google.devtools.build.lib.runtime.NoSpawnCacheModule.class,
+          // This module needs to be registered before any module that uses the credential cache.
+          CredentialModule.class,
           com.google.devtools.build.lib.runtime.CommandLogModule.class,
           com.google.devtools.build.lib.platform.SleepPreventionModule.class,
           com.google.devtools.build.lib.platform.SystemSuspensionModule.class,
diff --git a/src/main/java/com/google/devtools/build/lib/buildeventservice/BUILD b/src/main/java/com/google/devtools/build/lib/buildeventservice/BUILD
index 26f91c3..4f7e9f1 100644
--- a/src/main/java/com/google/devtools/build/lib/buildeventservice/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/buildeventservice/BUILD
@@ -37,9 +37,11 @@
     deps = [
         ":buildeventservice-options",
         "//src/main/java/com/google/devtools/build/lib:runtime",
+        "//src/main/java/com/google/devtools/build/lib/analysis:blaze_directories",
         "//src/main/java/com/google/devtools/build/lib/analysis:test/test_configuration",
         "//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/authandtls/credentialhelper:credential_module",
         "//src/main/java/com/google/devtools/build/lib/bugreport",
         "//src/main/java/com/google/devtools/build/lib/buildeventservice/client",
         "//src/main/java/com/google/devtools/build/lib/buildeventstream",
diff --git a/src/main/java/com/google/devtools/build/lib/buildeventservice/BazelBuildEventServiceModule.java b/src/main/java/com/google/devtools/build/lib/buildeventservice/BazelBuildEventServiceModule.java
index c0f6b5e..cd1e0ac 100644
--- a/src/main/java/com/google/devtools/build/lib/buildeventservice/BazelBuildEventServiceModule.java
+++ b/src/main/java/com/google/devtools/build/lib/buildeventservice/BazelBuildEventServiceModule.java
@@ -22,12 +22,16 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.devtools.build.lib.analysis.BlazeDirectories;
 import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions;
 import com.google.devtools.build.lib.authandtls.GoogleAuthUtils;
 import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialHelperEnvironment;
+import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialModule;
 import com.google.devtools.build.lib.buildeventservice.client.BuildEventServiceClient;
 import com.google.devtools.build.lib.buildeventservice.client.BuildEventServiceGrpcClient;
+import com.google.devtools.build.lib.runtime.BlazeRuntime;
 import com.google.devtools.build.lib.runtime.CommandEnvironment;
+import com.google.devtools.build.lib.runtime.WorkspaceBuilder;
 import io.grpc.ClientInterceptor;
 import io.grpc.ManagedChannel;
 import io.grpc.Metadata;
@@ -68,6 +72,15 @@
   private BuildEventServiceClient client;
   private BackendConfig config;
 
+  private CredentialModule credentialModule;
+
+  @Override
+  public void workspaceInit(
+      BlazeRuntime runtime, BlazeDirectories directories, WorkspaceBuilder builder) {
+    Preconditions.checkState(credentialModule == null, "credentialModule must be null");
+    credentialModule = Preconditions.checkNotNull(runtime.getBlazeModule(CredentialModule.class));
+  }
+
   @Override
   protected Class<BuildEventServiceOptions> optionsClass() {
     return BuildEventServiceOptions.class;
@@ -93,6 +106,7 @@
                   .setClientEnvironment(env.getClientEnv())
                   .setHelperExecutionTimeout(authAndTLSOptions.credentialHelperTimeout)
                   .build(),
+              credentialModule.getCredentialCache(),
               env.getCommandLinePathFactory(),
               env.getRuntime().getFileSystem(),
               newConfig.authAndTLSOptions());
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 9a08c88..c101a40 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/remote/BUILD
@@ -53,12 +53,14 @@
         "//src/main/java/com/google/devtools/build/lib/actions:file_metadata",
         "//src/main/java/com/google/devtools/build/lib/actions:fileset_output_symlink",
         "//src/main/java/com/google/devtools/build/lib/analysis:analysis_cluster",
+        "//src/main/java/com/google/devtools/build/lib/analysis:blaze_directories",
         "//src/main/java/com/google/devtools/build/lib/analysis:config/build_options",
         "//src/main/java/com/google/devtools/build/lib/analysis:configured_target",
         "//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/authandtls/credentialhelper:credential_module",
         "//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",
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 2a60fbc..1ad9a82 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
@@ -18,6 +18,8 @@
 
 import build.bazel.remote.execution.v2.DigestFunction;
 import build.bazel.remote.execution.v2.ServerCapabilities;
+import build.bazel.remote.execution.v2.SymlinkAbsolutePathStrategy;
+import com.github.benmanes.caffeine.cache.Cache;
 import com.google.auth.Credentials;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Ascii;
@@ -27,6 +29,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.GoogleLogger;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.ListeningScheduledExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
@@ -36,6 +39,7 @@
 import com.google.devtools.build.lib.actions.ActionInput;
 import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.analysis.AnalysisResult;
+import com.google.devtools.build.lib.analysis.BlazeDirectories;
 import com.google.devtools.build.lib.analysis.ConfiguredTarget;
 import com.google.devtools.build.lib.analysis.FilesToRunProvider;
 import com.google.devtools.build.lib.analysis.RunfilesSupport;
@@ -48,6 +52,7 @@
 import com.google.devtools.build.lib.authandtls.CallCredentialsProvider;
 import com.google.devtools.build.lib.authandtls.GoogleAuthUtils;
 import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialHelperEnvironment;
+import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialModule;
 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;
@@ -72,6 +77,7 @@
 import com.google.devtools.build.lib.remote.util.TracingMetadataUtils;
 import com.google.devtools.build.lib.remote.util.Utils;
 import com.google.devtools.build.lib.runtime.BlazeModule;
+import com.google.devtools.build.lib.runtime.BlazeRuntime;
 import com.google.devtools.build.lib.runtime.BuildEventArtifactUploaderFactory;
 import com.google.devtools.build.lib.runtime.Command;
 import com.google.devtools.build.lib.runtime.CommandEnvironment;
@@ -79,6 +85,7 @@
 import com.google.devtools.build.lib.runtime.RepositoryRemoteExecutor;
 import com.google.devtools.build.lib.runtime.RepositoryRemoteExecutorFactory;
 import com.google.devtools.build.lib.runtime.ServerBuilder;
+import com.google.devtools.build.lib.runtime.WorkspaceBuilder;
 import com.google.devtools.build.lib.server.FailureDetails;
 import com.google.devtools.build.lib.server.FailureDetails.FailureDetail;
 import com.google.devtools.build.lib.server.FailureDetails.RemoteExecution;
@@ -156,6 +163,8 @@
 
   private final MutableSupplier<Downloader> remoteDownloaderSupplier = new MutableSupplier<>();
 
+  private CredentialModule credentialModule;
+
   @Override
   public void serverInit(OptionsParsingResult startupOptions, ServerBuilder builder) {
     builder.addBuildEventArtifactUploaderFactory(
@@ -164,12 +173,16 @@
     builder.setDownloaderSupplier(remoteDownloaderSupplier);
   }
 
-  /** Returns whether remote execution should be available. */
+  /**
+   * Returns whether remote execution should be available.
+   */
   public static boolean shouldEnableRemoteExecution(RemoteOptions options) {
     return !Strings.isNullOrEmpty(options.remoteExecutor);
   }
 
-  /** Returns whether remote downloading should be available. */
+  /**
+   * Returns whether remote downloading should be available.
+   */
   private static boolean shouldEnableRemoteDownloader(RemoteOptions options) {
     return !Strings.isNullOrEmpty(options.remoteDownloader);
   }
@@ -207,33 +220,16 @@
 
   private void initHttpAndDiskCache(
       CommandEnvironment env,
+      Credentials credentials,
       AuthAndTLSOptions authAndTlsOptions,
       RemoteOptions remoteOptions,
       DigestUtil digestUtil) {
-    Credentials creds;
-    try {
-      creds =
-          newCredentials(
-              CredentialHelperEnvironment.newBuilder()
-                  .setEventReporter(env.getReporter())
-                  .setWorkspacePath(env.getWorkspace())
-                  .setClientEnvironment(env.getClientEnv())
-                  .setHelperExecutionTimeout(authAndTlsOptions.credentialHelperTimeout)
-                  .build(),
-              env.getCommandLinePathFactory(),
-              env.getRuntime().getFileSystem(),
-              authAndTlsOptions,
-              remoteOptions);
-    } catch (IOException e) {
-      handleInitFailure(env, e, Code.CREDENTIALS_INIT_FAILURE);
-      return;
-    }
     RemoteCacheClient cacheClient;
     try {
       cacheClient =
           RemoteCacheClientFactory.create(
               remoteOptions,
-              creds,
+              credentials,
               Preconditions.checkNotNull(env.getWorkingDirectory(), "workingDirectory"),
               digestUtil);
     } catch (IOException e) {
@@ -247,6 +243,13 @@
   }
 
   @Override
+  public void workspaceInit(
+      BlazeRuntime runtime, BlazeDirectories directories, WorkspaceBuilder builder) {
+    Preconditions.checkState(credentialModule == null, "credentialModule must be null");
+    credentialModule = Preconditions.checkNotNull(runtime.getBlazeModule(CredentialModule.class));
+  }
+
+  @Override
   public void beforeCommand(CommandEnvironment env) throws AbruptExitException {
     Preconditions.checkState(actionContextProvider == null, "actionContextProvider must be null");
     Preconditions.checkState(actionInputFetcher == null, "actionInputFetcher must be null");
@@ -330,8 +333,28 @@
       executorService = Executors.newCachedThreadPool(threadFactory);
     }
 
+    Credentials credentials;
+    try {
+      credentials =
+          createCredentials(
+              CredentialHelperEnvironment.newBuilder()
+                  .setEventReporter(env.getReporter())
+                  .setWorkspacePath(env.getWorkspace())
+                  .setClientEnvironment(env.getClientEnv())
+                  .setHelperExecutionTimeout(authAndTlsOptions.credentialHelperTimeout)
+                  .build(),
+              credentialModule.getCredentialCache(),
+              env.getCommandLinePathFactory(),
+              env.getRuntime().getFileSystem(),
+              authAndTlsOptions,
+              remoteOptions);
+    } catch (IOException e) {
+      handleInitFailure(env, e, Code.CREDENTIALS_INIT_FAILURE);
+      return;
+    }
+
     if ((enableHttpCache || enableDiskCache) && !enableGrpcCache) {
-      initHttpAndDiskCache(env, authAndTlsOptions, remoteOptions, digestUtil);
+      initHttpAndDiskCache(env, credentials, authAndTlsOptions, remoteOptions, digestUtil);
       return;
     }
 
@@ -428,27 +451,9 @@
       }
     }
 
-    CallCredentialsProvider callCredentialsProvider;
-    try {
-      callCredentialsProvider =
-          GoogleAuthUtils.newCallCredentialsProvider(
-              newCredentials(
-                  CredentialHelperEnvironment.newBuilder()
-                      .setEventReporter(env.getReporter())
-                      .setWorkspacePath(env.getWorkspace())
-                      .setClientEnvironment(env.getClientEnv())
-                      .setHelperExecutionTimeout(authAndTlsOptions.credentialHelperTimeout)
-                      .build(),
-                  env.getCommandLinePathFactory(),
-                  env.getRuntime().getFileSystem(),
-                  authAndTlsOptions,
-                  remoteOptions));
-    } catch (IOException e) {
-      handleInitFailure(env, e, Code.CREDENTIALS_INIT_FAILURE);
-      return;
-    }
-
-    CallCredentials credentials = callCredentialsProvider.getCallCredentials();
+    CallCredentialsProvider callCredentialsProvider =
+        GoogleAuthUtils.newCallCredentialsProvider(credentials);
+    CallCredentials callCredentials = callCredentialsProvider.getCallCredentials();
 
     RemoteRetrier retrier =
         new RemoteRetrier(
@@ -470,7 +475,7 @@
           verifyServerCapabilities(
               remoteOptions,
               execChannel,
-              credentials,
+              callCredentials,
               retrier,
               env,
               digestUtil,
@@ -478,7 +483,7 @@
           verifyServerCapabilities(
               remoteOptions,
               cacheChannel,
-              credentials,
+              callCredentials,
               retrier,
               env,
               digestUtil,
@@ -487,7 +492,7 @@
           verifyServerCapabilities(
               remoteOptions,
               execChannel,
-              credentials,
+              callCredentials,
               retrier,
               env,
               digestUtil,
@@ -497,7 +502,7 @@
         verifyServerCapabilities(
             remoteOptions,
             cacheChannel,
-            credentials,
+            callCredentials,
             retrier,
             env,
             digestUtil,
@@ -654,7 +659,7 @@
               buildRequestId,
               invocationId,
               downloaderChannel.retain(),
-              Optional.ofNullable(credentials),
+              Optional.ofNullable(callCredentials),
               retrier,
               cacheClient,
               remoteOptions));
@@ -1053,8 +1058,10 @@
     return actionContextProvider;
   }
 
-  static Credentials newCredentials(
+  @VisibleForTesting
+  static Credentials createCredentials(
       CredentialHelperEnvironment credentialHelperEnvironment,
+      Cache<URI, ImmutableMap<String, ImmutableList<String>>> credentialCache,
       CommandLinePathFactory commandLinePathFactory,
       FileSystem fileSystem,
       AuthAndTLSOptions authAndTlsOptions,
@@ -1062,7 +1069,11 @@
       throws IOException {
     Credentials credentials =
         GoogleAuthUtils.newCredentials(
-            credentialHelperEnvironment, commandLinePathFactory, fileSystem, authAndTlsOptions);
+            credentialHelperEnvironment,
+            credentialCache,
+            commandLinePathFactory,
+            fileSystem,
+            authAndTlsOptions);
 
     try {
       if (credentials != null
diff --git a/src/test/java/com/google/devtools/build/lib/buildeventservice/BUILD b/src/test/java/com/google/devtools/build/lib/buildeventservice/BUILD
index 9fa5d10..38ea3dc 100644
--- a/src/test/java/com/google/devtools/build/lib/buildeventservice/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/buildeventservice/BUILD
@@ -48,6 +48,7 @@
         "//src/main/java/com/google/devtools/build/lib:runtime",
         "//src/main/java/com/google/devtools/build/lib/actions:action_lookup_data",
         "//src/main/java/com/google/devtools/build/lib/authandtls",
+        "//src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper:credential_module",
         "//src/main/java/com/google/devtools/build/lib/bugreport",
         "//src/main/java/com/google/devtools/build/lib/buildeventservice",
         "//src/main/java/com/google/devtools/build/lib/buildeventservice:buildeventservice-options",
diff --git a/src/test/java/com/google/devtools/build/lib/buildeventservice/BazelBuildEventServiceModuleTest.java b/src/test/java/com/google/devtools/build/lib/buildeventservice/BazelBuildEventServiceModuleTest.java
index 4ae061ca..31bb526 100644
--- a/src/test/java/com/google/devtools/build/lib/buildeventservice/BazelBuildEventServiceModuleTest.java
+++ b/src/test/java/com/google/devtools/build/lib/buildeventservice/BazelBuildEventServiceModuleTest.java
@@ -30,6 +30,7 @@
 import com.google.devtools.build.lib.actions.ActionLookupData;
 import com.google.devtools.build.lib.analysis.util.AnalysisMock;
 import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions;
+import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialModule;
 import com.google.devtools.build.lib.bugreport.BugReport;
 import com.google.devtools.build.lib.buildeventservice.BazelBuildEventServiceModule.BackendConfig;
 import com.google.devtools.build.lib.buildeventstream.BuildEventArtifactUploader;
@@ -155,6 +156,7 @@
               }
             })
         .addBlazeModule(new NoSpawnCacheModule())
+        .addBlazeModule(new CredentialModule())
         .addBlazeModule(
             new BazelBuildEventServiceModule() {
               @Override
diff --git a/src/test/java/com/google/devtools/build/lib/buildtool/BUILD b/src/test/java/com/google/devtools/build/lib/buildtool/BUILD
index 367e442..1563eae 100644
--- a/src/test/java/com/google/devtools/build/lib/buildtool/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/buildtool/BUILD
@@ -560,6 +560,9 @@
         "//src/main/java/com/google/devtools/build/lib/actions:artifacts",
         "//src/main/java/com/google/devtools/build/lib/analysis:analysis_cluster",
         "//src/main/java/com/google/devtools/build/lib/analysis:configured_target",
+        "//src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper:credential_module",
+        "//src/main/java/com/google/devtools/build/lib/buildeventservice",
+        "//src/main/java/com/google/devtools/build/lib/buildeventstream/proto:build_event_stream_java_proto",
         "//src/main/java/com/google/devtools/build/lib/cmdline",
         "//src/main/java/com/google/devtools/build/lib/collect/nestedset",
         "//src/test/java/com/google/devtools/build/lib/analysis/util",
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 74ac012..3ca14e5 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/remote/BUILD
@@ -55,6 +55,7 @@
         "//src/main/java/com/google/devtools/build/lib/analysis:server_directories",
         "//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/authandtls/credentialhelper:credential_module",
         "//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",
@@ -95,6 +96,7 @@
         "//src/test/java/com/google/devtools/build/lib/testutil:TestUtils",
         "//third_party:api_client",
         "//third_party:auth",
+        "//third_party:caffeine",
         "//third_party:guava",
         "//third_party:junit4",
         "//third_party:mockito",
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 5194ac0..ba26c09 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
@@ -25,6 +25,8 @@
 import build.bazel.remote.execution.v2.ExecutionCapabilities;
 import build.bazel.remote.execution.v2.GetCapabilitiesRequest;
 import build.bazel.remote.execution.v2.ServerCapabilities;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
 import com.google.auth.Credentials;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -34,6 +36,7 @@
 import com.google.devtools.build.lib.analysis.config.CoreOptions;
 import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions;
 import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialHelperEnvironment;
+import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialModule;
 import com.google.devtools.build.lib.events.Reporter;
 import com.google.devtools.build.lib.exec.BinTools;
 import com.google.devtools.build.lib.exec.ExecutionOptions;
@@ -71,11 +74,14 @@
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 
-/** Tests for {@link RemoteModule}. */
+/**
+ * Tests for {@link RemoteModule}.
+ */
 @RunWith(JUnit4.class)
 public final class RemoteModuleTest {
 
-  private static CommandEnvironment createTestCommandEnvironment(RemoteOptions remoteOptions)
+  private static CommandEnvironment createTestCommandEnvironment(
+      RemoteModule remoteModule, RemoteOptions remoteOptions)
       throws IOException, AbruptExitException {
     CoreOptions coreOptions = Options.getDefaults(CoreOptions.class);
     CommonCommandOptions commonCommandOptions = Options.getDefaults(CommonCommandOptions.class);
@@ -106,6 +112,8 @@
             .setServerDirectories(serverDirectories)
             .setStartupOptionsProvider(
                 OptionsParser.builder().optionsClasses(BlazeServerStartupOptions.class).build())
+            .addBlazeModule(new CredentialModule())
+            .addBlazeModule(remoteModule)
             .build();
 
     BlazeDirectories directories =
@@ -181,7 +189,7 @@
       RemoteOptions remoteOptions = Options.getDefaults(RemoteOptions.class);
       remoteOptions.remoteExecutor = executionServerName;
 
-      CommandEnvironment env = createTestCommandEnvironment(remoteOptions);
+      CommandEnvironment env = createTestCommandEnvironment(remoteModule, remoteOptions);
 
       remoteModule.beforeCommand(env);
 
@@ -220,7 +228,7 @@
       RemoteOptions remoteOptions = Options.getDefaults(RemoteOptions.class);
       remoteOptions.remoteCache = cacheServerName;
 
-      CommandEnvironment env = createTestCommandEnvironment(remoteOptions);
+      CommandEnvironment env = createTestCommandEnvironment(remoteModule, remoteOptions);
 
       remoteModule.beforeCommand(env);
 
@@ -266,7 +274,7 @@
       remoteOptions.remoteExecutor = executionServerName;
       remoteOptions.remoteCache = cacheServerName;
 
-      CommandEnvironment env = createTestCommandEnvironment(remoteOptions);
+      CommandEnvironment env = createTestCommandEnvironment(remoteModule, remoteOptions);
 
       remoteModule.beforeCommand(env);
 
@@ -321,7 +329,7 @@
       remoteOptions.remoteExecutor = executionServerName;
       remoteOptions.remoteCache = cacheServerName;
 
-      CommandEnvironment env = createTestCommandEnvironment(remoteOptions);
+      CommandEnvironment env = createTestCommandEnvironment(remoteModule, remoteOptions);
 
       remoteModule.beforeCommand(env);
 
@@ -359,7 +367,7 @@
           (target, proxy, options, interceptors) ->
               InProcessChannelBuilder.forName(target).directExecutor().build());
 
-      CommandEnvironment env = createTestCommandEnvironment(remoteOptions);
+      CommandEnvironment env = createTestCommandEnvironment(remoteModule, remoteOptions);
 
       assertThrows(AbruptExitException.class, () -> remoteModule.beforeCommand(env));
     } finally {
@@ -392,7 +400,7 @@
           (target, proxy, options, interceptors) ->
               InProcessChannelBuilder.forName(target).directExecutor().build());
 
-      CommandEnvironment env = createTestCommandEnvironment(remoteOptions);
+      CommandEnvironment env = createTestCommandEnvironment(remoteModule, remoteOptions);
 
       assertThrows(AbruptExitException.class, () -> remoteModule.beforeCommand(env));
     } finally {
@@ -424,7 +432,7 @@
           (target, proxy, options, interceptors) ->
               InProcessChannelBuilder.forName(target).directExecutor().build());
 
-      CommandEnvironment env = createTestCommandEnvironment(remoteOptions);
+      CommandEnvironment env = createTestCommandEnvironment(remoteModule, remoteOptions);
 
       remoteModule.beforeCommand(env);
 
@@ -462,7 +470,7 @@
           (target, proxy, options, interceptors) ->
               InProcessChannelBuilder.forName(target).directExecutor().build());
 
-      CommandEnvironment env = createTestCommandEnvironment(remoteOptions);
+      CommandEnvironment env = createTestCommandEnvironment(remoteModule, remoteOptions);
 
       remoteModule.beforeCommand(env);
 
@@ -486,14 +494,18 @@
     AuthAndTLSOptions authAndTLSOptions = Options.getDefaults(AuthAndTLSOptions.class);
     RemoteOptions remoteOptions = Options.getDefaults(RemoteOptions.class);
 
+    Cache<URI, ImmutableMap<String, ImmutableList<String>>> credentialCache =
+        Caffeine.newBuilder().build();
+
     Credentials credentials =
-        RemoteModule.newCredentials(
+        RemoteModule.createCredentials(
             CredentialHelperEnvironment.newBuilder()
                 .setEventReporter(new Reporter(new EventBus()))
                 .setWorkspacePath(fileSystem.getPath("/workspace"))
                 .setClientEnvironment(ImmutableMap.of("NETRC", netrc))
                 .setHelperExecutionTimeout(Duration.ZERO)
                 .build(),
+            credentialCache,
             new CommandLinePathFactory(fileSystem, ImmutableMap.of()),
             fileSystem,
             authAndTLSOptions,
diff --git a/src/test/shell/bazel/remote/remote_execution_test.sh b/src/test/shell/bazel/remote/remote_execution_test.sh
index bd10beb..3eff3cd 100755
--- a/src/test/shell/bazel/remote/remote_execution_test.sh
+++ b/src/test/shell/bazel/remote/remote_execution_test.sh
@@ -71,56 +71,76 @@
   declare -r EXE_EXT=""
 fi
 
-function setup_credential_helper() {
+function setup_credential_helper_test() {
+  # Each helper call atomically writes one byte to this file.
+  # We can later read the file to determine how many calls were made.
+  cat > "${TEST_TMPDIR}/credhelper_log"
+
   cat > "${TEST_TMPDIR}/credhelper" <<'EOF'
 #!/usr/bin/env python3
+import os
+
+path = os.path.join(os.environ["TEST_TMPDIR"], "credhelper_log")
+fd = os.open(path, os.O_WRONLY|os.O_CREAT|os.O_APPEND)
+os.write(fd, b"1")
+os.close(fd)
+
 print("""{"headers":{"Authorization":["Bearer secret_token"]}}""")
 EOF
   chmod +x "${TEST_TMPDIR}/credhelper"
+
+  mkdir -p a
+
+  cat > a/BUILD <<'EOF'
+[genrule(
+  name = x,
+  outs = [x + ".txt"],
+  cmd = "touch $(OUTS)",
+) for x in ["a", "b"]]
+EOF
+
+  stop_worker
+  start_worker --expected_authorization_token=secret_token
+}
+
+function expect_credential_helper_calls() {
+  local -r expected=$1
+  local -r actual=$(wc -c "${TEST_TMPDIR}/credhelper_log" | awk '{print $1}')
+  if [[ "$expected" != "$actual" ]]; then
+    fail "expected $expected instead of $actual credential helper calls"
+  fi
 }
 
 function test_credential_helper_remote_cache() {
-  setup_credential_helper
-
-  mkdir -p a
-
-  cat > a/BUILD <<'EOF'
-genrule(
-  name = "a",
-  outs = ["a.txt"],
-  cmd = "touch $(OUTS)",
-)
-EOF
-
-  stop_worker
-  start_worker --expected_authorization_token=secret_token
+  setup_credential_helper_test
 
   bazel build \
       --remote_cache=grpc://localhost:${worker_port} \
       //a:a >& $TEST_log && fail "Build without credentials should have failed"
   expect_log "Failed to query remote execution capabilities"
 
+  # Helper shouldn't have been called yet.
+  expect_credential_helper_calls 0
+
   bazel build \
       --remote_cache=grpc://localhost:${worker_port} \
       --experimental_credential_helper="${TEST_TMPDIR}/credhelper" \
       //a:a >& $TEST_log || fail "Build with credentials should have succeeded"
+
+  # First build should have called helper for 4 distinct URIs.
+  expect_credential_helper_calls 4
+
+  bazel build \
+      --remote_cache=grpc://localhost:${worker_port} \
+      --experimental_credential_helper="${TEST_TMPDIR}/credhelper" \
+      //a:b >& $TEST_log || fail "Build with credentials should have succeeded"
+
+  # Second build should have hit the credentials cache.
+  expect_credential_helper_calls 4
 }
 
 function test_credential_helper_remote_execution() {
-  setup_credential_helper
-
-  mkdir -p a
-
-  cat > a/BUILD <<'EOF'
-genrule(
-  name = "a",
-  outs = ["a.txt"],
-  cmd = "touch $(OUTS)",
-)
-EOF
-
-  stop_worker
-  start_worker --expected_authorization_token=secret_token
+  setup_credential_helper_test
 
   bazel build \
       --spawn_strategy=remote \
@@ -128,11 +148,49 @@
       //a:a >& $TEST_log && fail "Build without credentials should have failed"
   expect_log "Failed to query remote execution capabilities"
 
+  # Helper shouldn't have been called yet.
+  expect_credential_helper_calls 0
+
   bazel build \
       --spawn_strategy=remote \
       --remote_executor=grpc://localhost:${worker_port} \
       --experimental_credential_helper="${TEST_TMPDIR}/credhelper" \
       //a:a >& $TEST_log || fail "Build with credentials should have succeeded"
+
+  # First build should have called helper for 5 distinct URIs.
+  expect_credential_helper_calls 5
+
+  bazel build \
+      --spawn_strategy=remote \
+      --remote_executor=grpc://localhost:${worker_port} \
+      --experimental_credential_helper="${TEST_TMPDIR}/credhelper" \
+      //a:b >& $TEST_log || fail "Build with credentials should have succeeded"
+
+  # Second build should have hit the credentials cache.
+  expect_credential_helper_calls 5
+}
+
+function test_credential_helper_clear_cache() {
+  setup_credential_helper_test
+
+  bazel build \
+      --spawn_strategy=remote \
+      --remote_executor=grpc://localhost:${worker_port} \
+      --experimental_credential_helper="${TEST_TMPDIR}/credhelper" \
+      //a:a >& $TEST_log || fail "Build with credentials should have succeeded"
+
+  expect_credential_helper_calls 5
+
+  bazel clean
+
+  bazel build \
+      --spawn_strategy=remote \
+      --remote_executor=grpc://localhost:${worker_port} \
+      --experimental_credential_helper="${TEST_TMPDIR}/credhelper" \
+      //a:b >& $TEST_log || fail "Build with credentials should have succeeded"
+
+  # Build after clean should have called helper again.
+  expect_credential_helper_calls 10
 }
 
 function test_remote_grpc_cache_with_protocol() {