Let Starlark tests inherit env variables (#15217)

Adds an inherited_environment field to testing.TestEnvironment to allow
Starlark rules to implement the equivalent of the native rules'
`env_inherit` attribute.

Work towards #7364. To fully resolve that issue, it remains to handle
executable non-test rules.

RELNOTES: Starlark test rules can use the new inherited_environment
parameter of testing.TestEnvironment to specify environment variables
whose values should be inherited from the shell environment.

Closes #14849.

PiperOrigin-RevId: 439277689

Cherry-pick contains parts of:

Delete non-interning, non-singleton @AutoCodec.

PiperOrigin-RevId: 411683398

Cherry-pick makes the following additional changes:

- Replace use of ImmutableMap.buildKeepingLast with .copyOf

Co-authored-by: Chenchu Kolli <ckolli@google.com>
diff --git a/src/main/java/com/google/devtools/build/lib/actions/AbstractAction.java b/src/main/java/com/google/devtools/build/lib/actions/AbstractAction.java
index 3dc5903..8978789 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/AbstractAction.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/AbstractAction.java
@@ -701,7 +701,7 @@
 
   @Override
   public Dict<String, String> getEnv() {
-    return Dict.immutableCopyOf(env.getFixedEnv().toMap());
+    return Dict.immutableCopyOf(env.getFixedEnv());
   }
 
   @Override
diff --git a/src/main/java/com/google/devtools/build/lib/actions/ActionEnvironment.java b/src/main/java/com/google/devtools/build/lib/actions/ActionEnvironment.java
index f1ff0a0..8a9e3d1 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/ActionEnvironment.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/ActionEnvironment.java
@@ -17,7 +17,6 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.devtools.build.lib.skyframe.serialization.autocodec.AutoCodec;
 import com.google.devtools.build.lib.util.Fingerprint;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -44,26 +43,36 @@
  * action cache), such that Bazel knows exactly which actions it needs to rerun, and does not have
  * to reanalyze the entire dependency graph.
  */
-@AutoCodec
 public final class ActionEnvironment {
 
-  /** A map of environment variables. */
+  /**
+   * A map of environment variables together with a list of variables to inherit from the shell
+   * environment.
+   */
   public interface EnvironmentVariables {
 
     /**
-     * Returns the environment variables as a map.
+     * Returns the fixed environment variables as a map.
      *
-     * <p>WARNING: this allocations additional objects if the underlying implementation is a {@link
+     * <p>WARNING: this allocates additional objects if the underlying implementation is a {@link
      * CompoundEnvironmentVariables}; use sparingly.
      */
-    ImmutableMap<String, String> toMap();
+    ImmutableMap<String, String> getFixedEnvironment();
+
+    /**
+     * Returns the inherited environment variables as a set.
+     *
+     * <p>WARNING: this allocates additional objects if the underlying implementation is a {@link
+     * CompoundEnvironmentVariables}; use sparingly.
+     */
+    ImmutableSet<String> getInheritedEnvironment();
 
     default boolean isEmpty() {
-      return toMap().isEmpty();
+      return getFixedEnvironment().isEmpty() && getInheritedEnvironment().isEmpty();
     }
 
     default int size() {
-      return toMap().size();
+      return getFixedEnvironment().size() + getInheritedEnvironment().size();
     }
   }
 
@@ -72,11 +81,21 @@
    * allocation a new map.
    */
   static class CompoundEnvironmentVariables implements EnvironmentVariables {
+
+    static EnvironmentVariables create(
+        Map<String, String> fixedVars, Set<String> inheritedVars, EnvironmentVariables base) {
+      if (fixedVars.isEmpty() && inheritedVars.isEmpty() && base.isEmpty()) {
+        return EMPTY_ENVIRONMENT_VARIABLES;
+      }
+      return new CompoundEnvironmentVariables(fixedVars, inheritedVars, base);
+    }
+
     private final EnvironmentVariables current;
     private final EnvironmentVariables base;
 
-    CompoundEnvironmentVariables(Map<String, String> vars, EnvironmentVariables base) {
-      this.current = new SimpleEnvironmentVariables(vars);
+    private CompoundEnvironmentVariables(
+        Map<String, String> fixedVars, Set<String> inheritedVars, EnvironmentVariables base) {
+      this.current = SimpleEnvironmentVariables.create(fixedVars, inheritedVars);
       this.base = base;
     }
 
@@ -86,39 +105,54 @@
     }
 
     @Override
-    public ImmutableMap<String, String> toMap() {
-      Map<String, String> result = new LinkedHashMap<>();
-      result.putAll(base.toMap());
-      result.putAll(current.toMap());
+    public ImmutableMap<String, String> getFixedEnvironment() {
+      LinkedHashMap<String, String> result = new LinkedHashMap<>();
+      result.putAll(base.getFixedEnvironment());
+      result.putAll(current.getFixedEnvironment());
       return ImmutableMap.copyOf(result);
     }
+
+    @Override
+    public ImmutableSet<String> getInheritedEnvironment() {
+      ImmutableSet.Builder<String> result = new ImmutableSet.Builder<>();
+      result.addAll(base.getInheritedEnvironment());
+      result.addAll(current.getInheritedEnvironment());
+      return result.build();
+    }
   }
 
   /** A simple {@link EnvironmentVariables}. */
   static class SimpleEnvironmentVariables implements EnvironmentVariables {
 
-    static EnvironmentVariables create(Map<String, String> vars) {
-      if (vars.isEmpty()) {
+    static EnvironmentVariables create(Map<String, String> fixedVars, Set<String> inheritedVars) {
+      if (fixedVars.isEmpty() && inheritedVars.isEmpty()) {
         return EMPTY_ENVIRONMENT_VARIABLES;
       }
-      return new SimpleEnvironmentVariables(vars);
+      return new SimpleEnvironmentVariables(fixedVars, inheritedVars);
     }
 
-    private final ImmutableMap<String, String> vars;
+    private final ImmutableMap<String, String> fixedVars;
+    private final ImmutableSet<String> inheritedVars;
 
-    private SimpleEnvironmentVariables(Map<String, String> vars) {
-      this.vars = ImmutableMap.copyOf(vars);
+    private SimpleEnvironmentVariables(Map<String, String> fixedVars, Set<String> inheritedVars) {
+      this.fixedVars = ImmutableMap.copyOf(fixedVars);
+      this.inheritedVars = ImmutableSet.copyOf(inheritedVars);
     }
 
     @Override
-    public ImmutableMap<String, String> toMap() {
-      return vars;
+    public ImmutableMap<String, String> getFixedEnvironment() {
+      return fixedVars;
+    }
+
+    @Override
+    public ImmutableSet<String> getInheritedEnvironment() {
+      return inheritedVars;
     }
   }
 
   /** An empty {@link EnvironmentVariables}. */
   public static final EnvironmentVariables EMPTY_ENVIRONMENT_VARIABLES =
-      new SimpleEnvironmentVariables(ImmutableMap.of());
+      new SimpleEnvironmentVariables(ImmutableMap.of(), ImmutableSet.of());
 
   /**
    * An empty environment, mainly for testing. Production code should never use this, but instead
@@ -126,8 +160,7 @@
    */
   // TODO(ulfjack): Migrate all production code to use the proper action environment, and then make
   // this @VisibleForTesting or rename it to clarify.
-  public static final ActionEnvironment EMPTY =
-      new ActionEnvironment(EMPTY_ENVIRONMENT_VARIABLES, ImmutableSet.of());
+  public static final ActionEnvironment EMPTY = new ActionEnvironment(EMPTY_ENVIRONMENT_VARIABLES);
 
   /**
    * Splits the given map into a map of variables with a fixed value, and a set of variables that
@@ -147,15 +180,13 @@
         inheritedEnv.add(key);
       }
     }
-    return create(new SimpleEnvironmentVariables(fixedEnv), ImmutableSet.copyOf(inheritedEnv));
+    return create(SimpleEnvironmentVariables.create(fixedEnv, inheritedEnv));
   }
 
-  private final EnvironmentVariables fixedEnv;
-  private final ImmutableSet<String> inheritedEnv;
+  private final EnvironmentVariables vars;
 
-  private ActionEnvironment(EnvironmentVariables fixedEnv, ImmutableSet<String> inheritedEnv) {
-    this.fixedEnv = fixedEnv;
-    this.inheritedEnv = inheritedEnv;
+  private ActionEnvironment(EnvironmentVariables vars) {
+    this.vars = vars;
   }
 
   /**
@@ -163,35 +194,43 @@
    * undefined, so callers need to take care that the key set of the {@code fixedEnv} map and the
    * set of {@code inheritedEnv} elements are disjoint.
    */
-  @AutoCodec.Instantiator
-  public static ActionEnvironment create(
-      EnvironmentVariables fixedEnv, ImmutableSet<String> inheritedEnv) {
-    if (fixedEnv.isEmpty() && inheritedEnv.isEmpty()) {
+  private static ActionEnvironment create(EnvironmentVariables vars) {
+    if (vars.isEmpty()) {
       return EMPTY;
     }
-    return new ActionEnvironment(fixedEnv, inheritedEnv);
+    return new ActionEnvironment(vars);
   }
 
   public static ActionEnvironment create(
       Map<String, String> fixedEnv, ImmutableSet<String> inheritedEnv) {
-    return new ActionEnvironment(SimpleEnvironmentVariables.create(fixedEnv), inheritedEnv);
+    return ActionEnvironment.create(SimpleEnvironmentVariables.create(fixedEnv, inheritedEnv));
   }
 
   public static ActionEnvironment create(Map<String, String> fixedEnv) {
-    return new ActionEnvironment(new SimpleEnvironmentVariables(fixedEnv), ImmutableSet.of());
+    return ActionEnvironment.create(SimpleEnvironmentVariables.create(fixedEnv, ImmutableSet.of()));
   }
 
   /**
    * Returns a copy of the environment with the given fixed variables added to it, <em>overwriting
    * any existing occurrences of those variables</em>.
    */
-  public ActionEnvironment addFixedVariables(Map<String, String> vars) {
-    return new ActionEnvironment(new CompoundEnvironmentVariables(vars, fixedEnv), inheritedEnv);
+  public ActionEnvironment addFixedVariables(Map<String, String> fixedVars) {
+    return ActionEnvironment.create(
+        CompoundEnvironmentVariables.create(fixedVars, ImmutableSet.of(), vars));
+  }
+
+  /**
+   * Returns a copy of the environment with the given fixed and inherited variables added to it,
+   * <em>overwriting any existing occurrences of those variables</em>.
+   */
+  public ActionEnvironment addVariables(Map<String, String> fixedVars, Set<String> inheritedVars) {
+    return ActionEnvironment.create(
+        CompoundEnvironmentVariables.create(fixedVars, inheritedVars, vars));
   }
 
   /** Returns the combined size of the fixed and inherited environments. */
   public int size() {
-    return fixedEnv.size() + inheritedEnv.size();
+    return vars.size();
   }
 
   /**
@@ -199,8 +238,8 @@
    * fixed values and their values. This should only be used for testing and to compute the cache
    * keys of actions. Use {@link #resolve} instead to get the complete environment.
    */
-  public EnvironmentVariables getFixedEnv() {
-    return fixedEnv;
+  public ImmutableMap<String, String> getFixedEnv() {
+    return vars.getFixedEnvironment();
   }
 
   /**
@@ -210,7 +249,7 @@
    * get the complete environment.
    */
   public ImmutableSet<String> getInheritedEnv() {
-    return inheritedEnv;
+    return vars.getInheritedEnvironment();
   }
 
   /**
@@ -221,8 +260,8 @@
    */
   public void resolve(Map<String, String> result, Map<String, String> clientEnv) {
     checkNotNull(clientEnv);
-    result.putAll(fixedEnv.toMap());
-    for (String var : inheritedEnv) {
+    result.putAll(vars.getFixedEnvironment());
+    for (String var : vars.getInheritedEnvironment()) {
       String value = clientEnv.get(var);
       if (value != null) {
         result.put(var, value);
@@ -231,7 +270,7 @@
   }
 
   public void addTo(Fingerprint f) {
-    f.addStringMap(fixedEnv.toMap());
-    f.addStrings(inheritedEnv);
+    f.addStringMap(vars.getFixedEnvironment());
+    f.addStrings(vars.getInheritedEnvironment());
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/RuleConfiguredTargetBuilder.java b/src/main/java/com/google/devtools/build/lib/analysis/RuleConfiguredTargetBuilder.java
index f7e147a..59cde31 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/RuleConfiguredTargetBuilder.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/RuleConfiguredTargetBuilder.java
@@ -470,6 +470,7 @@
             providersBuilder.getProvider(TestEnvironmentInfo.PROVIDER.getKey());
     if (environmentProvider != null) {
       testActionBuilder.addExtraEnv(environmentProvider.getEnvironment());
+      testActionBuilder.addExtraInheritedEnv(environmentProvider.getInheritedEnvironment());
     }
 
     TestParams testParams =
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/actions/SpawnAction.java b/src/main/java/com/google/devtools/build/lib/analysis/actions/SpawnAction.java
index 14c42dd..ca3c553 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/actions/SpawnAction.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/actions/SpawnAction.java
@@ -442,7 +442,7 @@
     StringBuilder message = new StringBuilder();
     message.append(getProgressMessage());
     message.append('\n');
-    for (Map.Entry<String, String> entry : env.getFixedEnv().toMap().entrySet()) {
+    for (Map.Entry<String, String> entry : env.getFixedEnv().entrySet()) {
       message.append("  Environment variable: ");
       message.append(ShellEscaper.escapeString(entry.getKey()));
       message.append('=');
@@ -551,7 +551,7 @@
     // ActionEnvironment to avoid developers misunderstanding the purpose of this method. That
     // requires first updating all subclasses and callers to actually handle environments correctly,
     // so it's not a small change.
-    return env.getFixedEnv().toMap();
+    return env.getFixedEnv();
   }
 
   /**
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/config/BuildConfiguration.java b/src/main/java/com/google/devtools/build/lib/analysis/config/BuildConfiguration.java
index 34be910..c10bcca 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/config/BuildConfiguration.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/config/BuildConfiguration.java
@@ -442,7 +442,7 @@
    */
   @Override
   public ImmutableMap<String, String> getLocalShellEnvironment() {
-    return actionEnv.getFixedEnv().toMap();
+    return actionEnv.getFixedEnv();
   }
 
   /**
@@ -579,7 +579,7 @@
    */
   @Override
   public ImmutableMap<String, String> getTestEnv() {
-    return testEnv.getFixedEnv().toMap();
+    return testEnv.getFixedEnv();
   }
 
   /**
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/test/TestActionBuilder.java b/src/main/java/com/google/devtools/build/lib/analysis/test/TestActionBuilder.java
index b1b3eb2..d545812 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/test/TestActionBuilder.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/test/TestActionBuilder.java
@@ -55,7 +55,9 @@
 import com.google.devtools.build.lib.vfs.PathFragment;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.TreeMap;
+import java.util.TreeSet;
 import javax.annotation.Nullable;
 
 /**
@@ -96,10 +98,12 @@
   private InstrumentedFilesInfo instrumentedFiles;
   private int explicitShardCount;
   private final Map<String, String> extraEnv;
+  private final Set<String> extraInheritedEnv;
 
   public TestActionBuilder(RuleContext ruleContext) {
     this.ruleContext = ruleContext;
     this.extraEnv = new TreeMap<>();
+    this.extraInheritedEnv = new TreeSet<>();
     this.additionalTools = new ImmutableList.Builder<>();
   }
 
@@ -154,6 +158,11 @@
     return this;
   }
 
+  public TestActionBuilder addExtraInheritedEnv(List<String> extraInheritedEnv) {
+    this.extraInheritedEnv.addAll(extraInheritedEnv);
+    return this;
+  }
+
   /**
    * Set the explicit shard count. Note that this may be overridden by the sharding strategy.
    */
@@ -442,7 +451,9 @@
                 coverageArtifact,
                 coverageDirectory,
                 testProperties,
-                runfilesSupport.getActionEnvironment().addFixedVariables(extraTestEnv),
+                runfilesSupport
+                    .getActionEnvironment()
+                    .addVariables(extraTestEnv, extraInheritedEnv),
                 executionSettings,
                 shard,
                 run,
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/test/TestEnvironmentInfo.java b/src/main/java/com/google/devtools/build/lib/analysis/test/TestEnvironmentInfo.java
index f7e4100..d524533 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/test/TestEnvironmentInfo.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/test/TestEnvironmentInfo.java
@@ -15,10 +15,13 @@
 package com.google.devtools.build.lib.analysis.test;
 
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.lib.concurrent.ThreadSafety.Immutable;
 import com.google.devtools.build.lib.packages.BuiltinProvider;
 import com.google.devtools.build.lib.packages.NativeInfo;
 import com.google.devtools.build.lib.starlarkbuildapi.test.TestEnvironmentInfoApi;
+import java.util.List;
 import java.util.Map;
 
 /** Provider containing any additional environment variables for use in the test action. */
@@ -29,11 +32,14 @@
   public static final BuiltinProvider<TestEnvironmentInfo> PROVIDER =
       new BuiltinProvider<TestEnvironmentInfo>("TestEnvironment", TestEnvironmentInfo.class) {};
 
-  private final Map<String, String> environment;
+  private final ImmutableMap<String, String> environment;
+  private final ImmutableList<String> inheritedEnvironment;
 
-  /** Constructs a new provider with the given variable name to variable value mapping. */
-  public TestEnvironmentInfo(Map<String, String> environment) {
-    this.environment = Preconditions.checkNotNull(environment);
+  /** Constructs a new provider with the given fixed and inherited environment variables. */
+  public TestEnvironmentInfo(Map<String, String> environment, List<String> inheritedEnvironment) {
+    this.environment = ImmutableMap.copyOf(Preconditions.checkNotNull(environment));
+    this.inheritedEnvironment =
+        ImmutableList.copyOf(Preconditions.checkNotNull(inheritedEnvironment));
   }
 
   @Override
@@ -48,4 +54,10 @@
   public Map<String, String> getEnvironment() {
     return environment;
   }
+
+  /** Returns names of environment variables whose value should be obtained from the environment. */
+  @Override
+  public ImmutableList<String> getInheritedEnvironment() {
+    return inheritedEnvironment;
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/test/TestRunnerAction.java b/src/main/java/com/google/devtools/build/lib/analysis/test/TestRunnerAction.java
index 83385b4..01795eb 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/test/TestRunnerAction.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/test/TestRunnerAction.java
@@ -997,7 +997,7 @@
   @Override
   public ImmutableMap<String, String> getIncompleteEnvironmentForTesting()
       throws ActionExecutionException {
-    return getEnvironment().getFixedEnv().toMap();
+    return getEnvironment().getFixedEnv();
   }
 
   @Override
diff --git a/src/main/java/com/google/devtools/build/lib/query2/aquery/ActionGraphTextOutputFormatterCallback.java b/src/main/java/com/google/devtools/build/lib/query2/aquery/ActionGraphTextOutputFormatterCallback.java
index 78fe828..5976c40 100644
--- a/src/main/java/com/google/devtools/build/lib/query2/aquery/ActionGraphTextOutputFormatterCallback.java
+++ b/src/main/java/com/google/devtools/build/lib/query2/aquery/ActionGraphTextOutputFormatterCallback.java
@@ -218,7 +218,7 @@
       // TODO(twerth): This handles the fixed environment. We probably want to output the inherited
       // environment as well.
       Iterable<Map.Entry<String, String>> fixedEnvironment =
-          abstractAction.getEnvironment().getFixedEnv().toMap().entrySet();
+          abstractAction.getEnvironment().getFixedEnv().entrySet();
       if (!Iterables.isEmpty(fixedEnvironment)) {
         stringBuilder
             .append("  Environment: [")
diff --git a/src/main/java/com/google/devtools/build/lib/rules/java/JavaCompileAction.java b/src/main/java/com/google/devtools/build/lib/rules/java/JavaCompileAction.java
index 31f97c2..e34e51d 100644
--- a/src/main/java/com/google/devtools/build/lib/rules/java/JavaCompileAction.java
+++ b/src/main/java/com/google/devtools/build/lib/rules/java/JavaCompileAction.java
@@ -575,7 +575,7 @@
     // ActionEnvironment to avoid developers misunderstanding the purpose of this method. That
     // requires first updating all subclasses and callers to actually handle environments correctly,
     // so it's not a small change.
-    return env.getFixedEnv().toMap();
+    return env.getFixedEnv();
   }
 
   @Override
diff --git a/src/main/java/com/google/devtools/build/lib/rules/test/StarlarkTestingModule.java b/src/main/java/com/google/devtools/build/lib/rules/test/StarlarkTestingModule.java
index 08f938c..1c6fcb6 100644
--- a/src/main/java/com/google/devtools/build/lib/rules/test/StarlarkTestingModule.java
+++ b/src/main/java/com/google/devtools/build/lib/rules/test/StarlarkTestingModule.java
@@ -18,6 +18,8 @@
 import com.google.devtools.build.lib.starlarkbuildapi.test.TestingModuleApi;
 import net.starlark.java.eval.Dict;
 import net.starlark.java.eval.EvalException;
+import net.starlark.java.eval.Sequence;
+import net.starlark.java.eval.StarlarkList;
 
 /** A class that exposes testing infrastructure to Starlark. */
 public class StarlarkTestingModule implements TestingModuleApi {
@@ -29,9 +31,13 @@
   }
 
   @Override
-  public TestEnvironmentInfo testEnvironment(Dict<?, ?> environment /* <String, String> */)
+  public TestEnvironmentInfo testEnvironment(
+      Dict<?, ?> environment /* <String, String> */,
+      Sequence<?> inheritedEnvironment /* <String> */)
       throws EvalException {
     return new TestEnvironmentInfo(
-        Dict.cast(environment, String.class, String.class, "environment"));
+        Dict.cast(environment, String.class, String.class, "environment"),
+        StarlarkList.immutableCopyOf(
+            Sequence.cast(inheritedEnvironment, String.class, "inherited_environment")));
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/actiongraph/v2/ActionGraphDump.java b/src/main/java/com/google/devtools/build/lib/skyframe/actiongraph/v2/ActionGraphDump.java
index 5cc56c9..1646a7b 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/actiongraph/v2/ActionGraphDump.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/actiongraph/v2/ActionGraphDump.java
@@ -15,6 +15,7 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.devtools.build.lib.actions.ActionAnalysisMetadata;
@@ -159,7 +160,7 @@
       SpawnAction spawnAction = (SpawnAction) action;
       // TODO(twerth): This handles the fixed environment. We probably want to output the inherited
       // environment as well.
-      Map<String, String> fixedEnvironment = spawnAction.getEnvironment().getFixedEnv().toMap();
+      ImmutableMap<String, String> fixedEnvironment = spawnAction.getEnvironment().getFixedEnv();
       for (Map.Entry<String, String> environmentVariable : fixedEnvironment.entrySet()) {
         actionBuilder.addEnvironmentVariables(
             AnalysisProtosV2.KeyValuePair.newBuilder()
diff --git a/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/test/TestEnvironmentInfoApi.java b/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/test/TestEnvironmentInfoApi.java
index c843097..ed11b24 100644
--- a/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/test/TestEnvironmentInfoApi.java
+++ b/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/test/TestEnvironmentInfoApi.java
@@ -15,6 +15,7 @@
 package com.google.devtools.build.lib.starlarkbuildapi.test;
 
 import com.google.devtools.build.lib.starlarkbuildapi.core.StructApi;
+import java.util.List;
 import java.util.Map;
 import net.starlark.java.annot.StarlarkBuiltin;
 import net.starlark.java.annot.StarlarkMethod;
@@ -28,4 +29,10 @@
       doc = "A dict containing environment variables which should be set on the test action.",
       structField = true)
   Map<String, String> getEnvironment();
+
+  @StarlarkMethod(
+      name = "inherited_environment",
+      doc = "A list of variables that the test action should inherit from the shell environment.",
+      structField = true)
+  List<String> getInheritedEnvironment();
 }
diff --git a/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/test/TestingModuleApi.java b/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/test/TestingModuleApi.java
index a6f243a..1570f80 100644
--- a/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/test/TestingModuleApi.java
+++ b/src/main/java/com/google/devtools/build/lib/starlarkbuildapi/test/TestingModuleApi.java
@@ -15,10 +15,12 @@
 package com.google.devtools.build.lib.starlarkbuildapi.test;
 
 import net.starlark.java.annot.Param;
+import net.starlark.java.annot.ParamType;
 import net.starlark.java.annot.StarlarkBuiltin;
 import net.starlark.java.annot.StarlarkMethod;
 import net.starlark.java.eval.Dict;
 import net.starlark.java.eval.EvalException;
+import net.starlark.java.eval.Sequence;
 import net.starlark.java.eval.StarlarkValue;
 
 /** Helper module for accessing test infrastructure. */
@@ -56,12 +58,26 @@
       parameters = {
         @Param(
             name = "environment",
-            named = false,
+            named = true,
             positional = true,
             doc =
                 "A map of string keys and values that represent environment variables and their"
-                    + " values. These will be made available during the test execution.")
+                    + " values. These will be made available during the test execution."),
+        @Param(
+            name = "inherited_environment",
+            allowedTypes = {@ParamType(type = Sequence.class, generic1 = String.class)},
+            defaultValue = "[]",
+            named = true,
+            positional = true,
+            doc =
+                "A sequence of names of environment variables. These variables are made available"
+                    + " during the test execution with their current value taken from the shell"
+                    + " environment. If a variable is contained in both <code>environment</code>"
+                    + " and <code>inherited_environment</code>, the value inherited from the"
+                    + " shell environment will take precedence if set.")
       })
-  TestEnvironmentInfoApi testEnvironment(Dict<?, ?> environment // <String, String> expected
-      ) throws EvalException;
+  TestEnvironmentInfoApi testEnvironment(
+      Dict<?, ?> environment, // <String, String> expected
+      Sequence<?> inheritedEnvironment /* <String> expected */)
+      throws EvalException;
 }
diff --git a/src/test/java/com/google/devtools/build/lib/actions/ActionEnvironmentTest.java b/src/test/java/com/google/devtools/build/lib/actions/ActionEnvironmentTest.java
index 8ff8e44..d48d266 100644
--- a/src/test/java/com/google/devtools/build/lib/actions/ActionEnvironmentTest.java
+++ b/src/test/java/com/google/devtools/build/lib/actions/ActionEnvironmentTest.java
@@ -34,10 +34,10 @@
     // entries added by env2 override the existing entries
     ActionEnvironment env2 = env1.addFixedVariables(ImmutableMap.of("FOO", "foo2"));
 
-    assertThat(env1.getFixedEnv().toMap()).containsExactly("FOO", "foo1", "BAR", "bar");
+    assertThat(env1.getFixedEnv()).containsExactly("FOO", "foo1", "BAR", "bar");
     assertThat(env1.getInheritedEnv()).containsExactly("baz");
 
-    assertThat(env2.getFixedEnv().toMap()).containsExactly("FOO", "foo2", "BAR", "bar");
+    assertThat(env2.getFixedEnv()).containsExactly("FOO", "foo2", "BAR", "bar");
     assertThat(env2.getInheritedEnv()).containsExactly("baz");
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/rules/BazelRuleClassProviderTest.java b/src/test/java/com/google/devtools/build/lib/bazel/rules/BazelRuleClassProviderTest.java
index 4e339f7..a40319b 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/rules/BazelRuleClassProviderTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/rules/BazelRuleClassProviderTest.java
@@ -175,8 +175,8 @@
             "--action_env=FOO=bar");
 
     ActionEnvironment env = BazelRuleClassProvider.SHELL_ACTION_ENV.getActionEnvironment(options);
-    assertThat(env.getFixedEnv().toMap()).containsEntry("PATH", "/bin:/usr/bin:/usr/local/bin");
-    assertThat(env.getFixedEnv().toMap()).containsEntry("FOO", "bar");
+    assertThat(env.getFixedEnv()).containsEntry("PATH", "/bin:/usr/bin:/usr/local/bin");
+    assertThat(env.getFixedEnv()).containsEntry("FOO", "bar");
   }
 
   @Test
diff --git a/src/test/java/com/google/devtools/build/lib/rules/test/StarlarkTestingModuleTest.java b/src/test/java/com/google/devtools/build/lib/rules/test/StarlarkTestingModuleTest.java
index 051c4ad..77943fe 100644
--- a/src/test/java/com/google/devtools/build/lib/rules/test/StarlarkTestingModuleTest.java
+++ b/src/test/java/com/google/devtools/build/lib/rules/test/StarlarkTestingModuleTest.java
@@ -80,6 +80,39 @@
   }
 
   @Test
+  public void testStarlarkRulePropagatesTestEnvironmentProviderWithInheritedEnv() throws Exception {
+    scratch.file("examples/rule/BUILD");
+    scratch.file(
+        "examples/rule/apple_rules.bzl",
+        "def my_rule_impl(ctx):",
+        "  test_env = testing.TestEnvironment(",
+        "    {'XCODE_VERSION_OVERRIDE': '7.3.1'},",
+        "    [",
+        "      'DEVELOPER_DIR',",
+        "      'XCODE_VERSION_OVERRIDE',",
+        "    ]",
+        ")",
+        "  return [test_env]",
+        "my_rule = rule(implementation = my_rule_impl,",
+        "  attrs = {},",
+        ")");
+    scratch.file(
+        "examples/apple_starlark/BUILD",
+        "package(default_visibility = ['//visibility:public'])",
+        "load('//examples/rule:apple_rules.bzl', 'my_rule')",
+        "my_rule(",
+        "    name = 'my_target',",
+        ")");
+
+    ConfiguredTarget starlarkTarget = getConfiguredTarget("//examples/apple_starlark:my_target");
+    TestEnvironmentInfo provider = starlarkTarget.get(TestEnvironmentInfo.PROVIDER);
+
+    assertThat(provider.getEnvironment()).containsEntry("XCODE_VERSION_OVERRIDE", "7.3.1");
+    assertThat(provider.getInheritedEnvironment()).contains("DEVELOPER_DIR");
+    assertThat(provider.getInheritedEnvironment()).contains("XCODE_VERSION_OVERRIDE");
+  }
+
+  @Test
   public void testExecutionInfoProviderCanMarkTestAsLocal() throws Exception {
     scratch.file("examples/rule/BUILD");
     scratch.file(
diff --git a/src/test/shell/bazel/bazel_rules_test.sh b/src/test/shell/bazel/bazel_rules_test.sh
index ef70918..5650f0d 100755
--- a/src/test/shell/bazel/bazel_rules_test.sh
+++ b/src/test/shell/bazel/bazel_rules_test.sh
@@ -593,4 +593,75 @@
   assert_contains "hello world" bazel-bin/pkg/hello_gen.txt
 }
 
+function test_starlark_test_with_test_environment() {
+  mkdir pkg
+  cat >pkg/BUILD <<'EOF'
+load(":rules.bzl", "my_test")
+my_test(
+  name = "my_test",
+)
+EOF
+
+  # On Windows this file needs to be acceptable by CreateProcessW(), rather
+  # than a Bourne script.
+  if "$is_windows"; then
+    cat >pkg/rules.bzl <<'EOF'
+_SCRIPT_EXT = ".bat"
+_SCRIPT_CONTENT = """@ECHO OFF
+if not "%FIXED_ONLY%" == "fixed" exit /B 1
+if not "%FIXED_AND_INHERITED%" == "inherited" exit /B 1
+if not "%FIXED_AND_INHERITED_BUT_NOT_SET%" == "fixed" exit /B 1
+if not "%INHERITED_ONLY%" == "inherited" exit /B 1
+"""
+EOF
+  else
+    cat >pkg/rules.bzl <<'EOF'
+_SCRIPT_EXT = ".sh"
+_SCRIPT_CONTENT = """#!/bin/bash
+[[ "$FIXED_ONLY" == "fixed" \
+  && "$FIXED_AND_INHERITED" == "inherited" \
+  && "$FIXED_AND_INHERITED_BUT_NOT_SET" == "fixed" \
+  && "$INHERITED_ONLY" == "inherited" ]]
+"""
+EOF
+  fi
+
+  cat >>pkg/rules.bzl <<'EOF'
+def _my_test_impl(ctx):
+    test_sh = ctx.actions.declare_file(ctx.attr.name + _SCRIPT_EXT)
+    ctx.actions.write(
+        output = test_sh,
+        content = _SCRIPT_CONTENT,
+        is_executable = True,
+    )
+    test_env = testing.TestEnvironment(
+      {
+        "FIXED_AND_INHERITED": "fixed",
+        "FIXED_AND_INHERITED_BUT_NOT_SET": "fixed",
+        "FIXED_ONLY": "fixed",
+      },
+      [
+        "FIXED_AND_INHERITED",
+        "FIXED_AND_INHERITED_BUT_NOT_SET",
+        "INHERITED_ONLY",
+      ]
+    )
+    return [
+        DefaultInfo(
+            executable = test_sh,
+        ),
+        test_env
+    ]
+
+my_test = rule(
+    implementation = _my_test_impl,
+    attrs = {},
+    test = True,
+)
+EOF
+
+  FIXED_AND_INHERITED=inherited INHERITED_ONLY=inherited \
+    bazel test //pkg:my_test &> /dev/null || fail "Test should pass"
+}
+
 run_suite "rules test"