env, Float weight, Integer maxLoad) {
+ this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
+ mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad, null);
+ }
+
/** True when this profile's workers are granted a forge token to open their own PR (CB-302). */
public boolean hasGitToken() {
return gitTokenEnv != null && !gitTokenEnv.isBlank();
}
+ /**
+ * True when this profile's {@code env:} block names a worker-side Anthropic binding variable.
+ * Those two keys are the adapter's, never the operator's: on a claude-code worker
+ * {@code ANTHROPIC_BASE_URL} is the endpoint the {@code SubscriptionGuard} vetted, and
+ * {@code ANTHROPIC_AUTH_TOKEN} is injected from {@code tokenEnv}. An {@code env:} entry for
+ * either is a bypass vector — it is what the subscription path would otherwise let survive
+ * unguarded — so it is rejected at config load (see
+ * {@link BridgedConfig#validateSubscriptionProfiles()}).
+ */
+ public boolean envCarriesAnthropicBinding() {
+ return env != null
+ && (env.containsKey("ANTHROPIC_BASE_URL") || env.containsKey("ANTHROPIC_AUTH_TOKEN"));
+ }
+
/** True when workers should land in their own tab in the worker space. */
public boolean tabPlacement() {
return "tab".equals(placement);
@@ -632,6 +682,44 @@ public record BridgedConfig(
+ "confused.");
}
+ /**
+ * Reject a subscription profile whose {@code env:} block tries to reseat the Anthropic binding
+ * (CB-542).
+ *
+ * Why this must be fatal rather than sanitised: {@code subscription: true} deliberately
+ * stops the launcher from writing {@code ANTHROPIC_BASE_URL}/{@code ANTHROPIC_AUTH_TOKEN} and
+ * skips the {@code SubscriptionGuard} for that profile. But the profile's {@code env:} map is
+ * layered into the worker environment separately, so an {@code ANTHROPIC_BASE_URL} sitting
+ * there would survive into the worker having passed no guard at all — {@code subscription: true}
+ * plus an {@code env:} repoint is a contradiction just like {@code subscription: true} plus a
+ * {@code baseUrl}. The launcher also hard-strips these two keys from the worker env as a
+ * belt-and-braces measure; this method is the loud, load-time refusal so the operator is told
+ * about the mistake instead of having it silently cleaned up.
+ *
+ * @throws IllegalStateException when any subscription profile's {@code env:} names
+ * {@code ANTHROPIC_BASE_URL} or {@code ANTHROPIC_AUTH_TOKEN},
+ * naming the profile and the offending key(s)
+ */
+ public void validateSubscriptionProfiles() {
+ List bad = new java.util.ArrayList<>();
+ workerProfiles().forEach((name, w) -> {
+ if (w.isSubscription() && w.envCarriesAnthropicBinding()) {
+ List keys = w.env().keySet().stream()
+ .filter(k -> k.equals("ANTHROPIC_BASE_URL") || k.equals("ANTHROPIC_AUTH_TOKEN"))
+ .sorted()
+ .toList();
+ bad.add("worker profile '" + name + "' carries " + keys + " in env: — "
+ + "subscription: true forbids ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN there, "
+ + "because on the subscription no guard vets them (they would repoint the "
+ + "worker past the SubscriptionGuard). Remove them from env: (or drop "
+ + "subscription: true).");
+ }
+ });
+ if (!bad.isEmpty()) {
+ throw new IllegalStateException("refusing to start: " + String.join(" ", bad));
+ }
+ }
+
/** True for the loopback addresses and the unspecified-but-local forms we treat as same-host. */
private static boolean isLoopbackBind(String host) {
if (host == null || host.isBlank()) {
diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java
index 6a721ac..5149dcd 100644
--- a/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java
+++ b/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java
@@ -6,6 +6,8 @@ import dev.ltms.bridged.herdr.Agent;
import dev.ltms.bridged.herdr.AgentControl;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.peer.Capability;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.util.EnumSet;
import java.util.List;
@@ -36,6 +38,8 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
/** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */
private static final String NAME_PREFIX = "claude";
+ private static final Logger log = LoggerFactory.getLogger(ClaudeCodeLauncher.class);
+
private final SubscriptionGuard guard;
/**
@@ -116,14 +120,43 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
*/
@Override
protected Launch buildLaunch(BridgedConfig.Worker cfg) {
+ // CB-539: a profile may deliberately opt into the subscription (subscription: true) when no
+ // off-subscription endpoint exists for it — e.g. `sonnet` on `ccs`. That profile gets no
+ // ANTHROPIC_BASE_URL/AUTH_TOKEN (there is nothing to point them at) and the guard's base_url
+ // requirement is skipped FOR IT ONLY. Every other profile keeps the hard boundary below.
+ boolean onSubscription = cfg.isSubscription();
String baseUrl = cfg.baseUrl();
- guard.assertWorker(baseUrl); // hard stop before we spawn anything
+
+ if (onSubscription) {
+ // NO SILENT CONTRADICTION: subscription:true + a baseUrl state opposite intents; refuse
+ // loudly rather than pick a winner.
+ if (baseUrl != null && !baseUrl.isBlank()) {
+ throw new IllegalStateException("profile '" + cfg.profile()
+ + "' sets both subscription: true and a baseUrl ('" + baseUrl + "') — the two "
+ + "are contradictory: a subscription profile must not point at an endpoint. "
+ + "Drop baseUrl, or drop subscription: true.");
+ }
+ // Visible without anyone going looking for it: this worker bills the subscription.
+ log.warn("spawning profile '{}' on the Claude subscription (subscription: true) — this "
+ + "worker WILL bill the operator's subscription", cfg.profile());
+ } else {
+ guard.assertWorker(baseUrl); // hard stop before we spawn anything
+ }
Map workerEnv = baseEnv(cfg);
- workerEnv.put("ANTHROPIC_BASE_URL", baseUrl);
+ if (onSubscription) {
+ // CB-542 belt-and-braces: on the subscription path no guard vets these two keys, and the
+ // profile's env: is layered in by baseEnv — so strip any that rode in there. Config load
+ // already rejects this (loudly, naming the profile); this makes the boundary hold even
+ // for a profile built in code that never passed through that validation.
+ workerEnv.remove("ANTHROPIC_BASE_URL");
+ workerEnv.remove("ANTHROPIC_AUTH_TOKEN");
+ } else {
+ workerEnv.put("ANTHROPIC_BASE_URL", baseUrl);
+ putIfPresent(workerEnv, "ANTHROPIC_AUTH_TOKEN", env.apply(cfg.tokenEnv()));
+ }
putIfPresent(workerEnv, "ANTHROPIC_MODEL", cfg.model());
putIfPresent(workerEnv, "CLAUDE_CONFIG_DIR", cfg.configDir());
- putIfPresent(workerEnv, "ANTHROPIC_AUTH_TOKEN", env.apply(cfg.tokenEnv()));
applyGitToken(workerEnv, cfg);
return new Launch(workerEnv, argvWithModel(argvWithBridge(cfg), cfg));
diff --git a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java
index 8ed6587..b5bf45d 100644
--- a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java
@@ -640,4 +640,100 @@ class BridgedConfigTest {
assertEquals(1.0f, w.weight(), 0.0001f, "absent weight defaults to 1.0");
assertNull(w.maxLoad(), "absent maxLoad defaults to unlimited (null)");
}
+
+ @Test
+ void subscriptionFlagBindsAndDefaultsFalse(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("subscription.yaml");
+ Files.writeString(f, """
+ workers:
+ sonnet:
+ subscription: true
+ argv: ["ccs", "sonnet"]
+ opted:
+ baseUrl: http://gx10.gw:8000
+ """);
+
+ BridgedConfig cfg = BridgedConfig.load(f);
+ assertTrue(cfg.workerProfiles().get("sonnet").isSubscription(),
+ "subscription: true binds as an explicit opt-in");
+ assertFalse(cfg.workerProfiles().get("opted").isSubscription(),
+ "a profile without the key stays off-subscription (the default)");
+ }
+
+ // ── CB-542: subscription:true must not smuggle an unguarded endpoint via env: ───────────────
+
+ @Test
+ void aSubscriptionProfileWithAnthropicBaseUrlInEnvIsRejected(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("baseUrl.yaml");
+ Files.writeString(f, """
+ workers:
+ sonnet:
+ subscription: true
+ argv: ["ccs", "sonnet"]
+ env:
+ ANTHROPIC_BASE_URL: http://anything-not-on-the-allowlist
+ """);
+ BridgedConfig cfg = BridgedConfig.load(f);
+
+ IllegalStateException e = assertThrows(IllegalStateException.class,
+ cfg::validateSubscriptionProfiles);
+ assertTrue(e.getMessage().contains("sonnet"), "the refusal names the offending profile");
+ assertTrue(e.getMessage().contains("ANTHROPIC_BASE_URL"),
+ "the refusal names the offending key: " + e.getMessage());
+ }
+
+ @Test
+ void aSubscriptionProfileWithAnthropicAuthTokenInEnvIsRejected(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("authToken.yaml");
+ Files.writeString(f, """
+ workers:
+ sonnet:
+ subscription: true
+ argv: ["ccs", "sonnet"]
+ env:
+ ANTHROPIC_AUTH_TOKEN: sk-ant-not-on-any-allowlist
+ """);
+ BridgedConfig cfg = BridgedConfig.load(f);
+
+ IllegalStateException e = assertThrows(IllegalStateException.class,
+ cfg::validateSubscriptionProfiles);
+ assertTrue(e.getMessage().contains("sonnet"), "the refusal names the offending profile");
+ assertTrue(e.getMessage().contains("ANTHROPIC_AUTH_TOKEN"),
+ "the refusal names the offending key: " + e.getMessage());
+ }
+
+ @Test
+ void aSubscriptionProfileWithACleanEnvPassesValidation(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("clean.yaml");
+ Files.writeString(f, """
+ workers:
+ sonnet:
+ subscription: true
+ argv: ["ccs", "sonnet"]
+ env:
+ JAVA_HOME: /opt/jdk
+ """);
+ BridgedConfig cfg = BridgedConfig.load(f);
+
+ assertDoesNotThrow(cfg::validateSubscriptionProfiles,
+ "a subscription profile may carry env: — just not the Anthropic binding keys");
+ }
+
+ @Test
+ void aNonSubscriptionProfileMayCarryAnthropicEnvKeys(@TempDir Path dir) throws Exception {
+ // The override is only dangerous on the subscription path, where no guard could vet it. A
+ // plain profile's env: is still overwritten by the launcher's guard-checked value (CB-511).
+ Path f = dir.resolve("nonsub.yaml");
+ Files.writeString(f, """
+ workers:
+ gx10:
+ baseUrl: http://gx10.gw:8000
+ env:
+ ANTHROPIC_BASE_URL: http://something
+ """);
+ BridgedConfig cfg = BridgedConfig.load(f);
+
+ assertDoesNotThrow(cfg::validateSubscriptionProfiles,
+ "only subscription:true profiles are checked — off-subscription ones keep the baseUrl guard");
+ }
}
diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java
index f9c9563..a6f2d80 100644
--- a/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java
@@ -1,6 +1,7 @@
package dev.ltms.bridged.worker;
import dev.ltms.bridged.config.BridgedConfig;
+import dev.ltms.bridged.guard.GuardException;
import dev.ltms.bridged.guard.SubscriptionGuard;
import dev.ltms.bridged.herdr.AgentControl;
import dev.ltms.bridged.herdr.FakeHerdr;
@@ -615,4 +616,111 @@ class ClaudeCodeLauncherTest {
assertFalse(spawnedArgs(herdr).contains("--model"));
assertNull(startEnv(herdr).get("ANTHROPIC_MODEL"));
}
+
+ // --- CB-539: subscription-profile opt-in ----------------------------------------------------
+
+ /** A claude-code profile on the subscription: no baseUrl (by design), no off-sub endpoint. */
+ private static BridgedConfig.Worker subscriptionCfg(String profile, String baseUrl) {
+ return new BridgedConfig.Worker(
+ profile, baseUrl, "sonnet", null, "BRIDGED_WORKER_TOKEN",
+ List.of("ccs", profile), "tab", "bridged-workers", "w #{n}", null, null, null,
+ null, null, null, Map.of(), null, null, true);
+ }
+
+ @Test
+ void defaultRefusalIsPreservedForClaudeProfileWithNoBaseUrl() {
+ // Requirement 1: absent subscription:true ⇒ byte-identical refusal to today. A claude-code
+ // profile with no baseUrl and no subscription must still be refused (it would bill the sub).
+ FakeHerdr herdr = new FakeHerdr();
+ BridgedConfig.Worker cfg = new BridgedConfig.Worker(
+ "ltms-local", null, "coder", null, "BRIDGED_WORKER_TOKEN",
+ List.of("ccs", "ltms-local"), "tab", "bridged-workers", "w #{n}", null, null, null);
+ ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
+
+ GuardException ex = assertThrows(GuardException.class, () -> svc.spawn("ltms-local", null, null));
+ assertTrue(ex.getMessage().contains("no ANTHROPIC_BASE_URL"),
+ "the refusal names the missing baseUrl: " + ex.getMessage());
+ assertEquals(0, herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count(),
+ "nothing was spawned before the refusal");
+ }
+
+ @Test
+ void subscriptionProfileSpawnsWithoutInjectedAnthropicVars() {
+ // Requirement on subscription:true: no baseUrl is required (or injected), and neither
+ // ANTHROPIC_BASE_URL nor ANTHROPIC_AUTH_TOKEN is injected even though the token env would
+ // resolve one if asked.
+ FakeHerdr herdr = new FakeHerdr();
+ BridgedConfig.Worker cfg = subscriptionCfg("sonnet", null);
+ new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
+ _ -> "would-be-token").spawn();
+
+ Map env = startEnv(herdr);
+ assertNull(env.get("ANTHROPIC_BASE_URL"), "no baseUrl injected for a subscription profile");
+ assertNull(env.get("ANTHROPIC_AUTH_TOKEN"), "no auth token injected for a subscription profile");
+ assertEquals("sonnet", env.get("ANTHROPIC_MODEL"),
+ "the model alias is still injected; only the subscription-boundary vars are dropped");
+ }
+
+ @Test
+ void subscriptionPlusBaseUrlIsRefused() {
+ // Requirement 2: subscription:true + a baseUrl state opposite intents — refuse at spawn,
+ // naming the profile, rather than silently picking a winner.
+ FakeHerdr herdr = new FakeHerdr();
+ BridgedConfig.Worker cfg = subscriptionCfg("sonnet", "http://gx00.gw:8000");
+ ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
+
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
+ () -> svc.spawn("sonnet", null, null));
+ assertTrue(ex.getMessage().contains("sonnet"), "refusal names the profile: " + ex.getMessage());
+ assertTrue(ex.getMessage().contains("subscription"), "refusal explains the contradiction: " + ex.getMessage());
+ assertEquals(0, herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count(),
+ "nothing was spawned before the contradiction was refused");
+ }
+
+ @Test
+ void nonSubscriptionProfilesAreStillAllowlistChecked() {
+ // Requirement 3: the guard keeps its teeth for every other profile — a base_url whose host is
+ // not on the allowlist is still refused, whether or not any subscription profile exists.
+ FakeHerdr herdr = new FakeHerdr();
+ BridgedConfig.Worker rogue = new BridgedConfig.Worker(
+ "rogue", "http://evil.example.com:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
+ List.of("ccs", "rogue"), "tab", "bridged-workers", "w #{n}", null, null, null);
+ ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(rogue.profile(), rogue), rogue.profile(), _ -> null);
+
+ GuardException ex = assertThrows(GuardException.class, () -> svc.spawn("rogue", null, null));
+ assertTrue(ex.getMessage().contains("not on the"), "refusal cites the allowlist: " + ex.getMessage());
+ assertEquals(0, herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count(),
+ "nothing was spawned before the allowlist refusal");
+ }
+
+ @Test
+ void aSubscriptionProfileHasAnEnvSuppliedAnthropicBindingStripped() {
+ // CB-542: even a subscription profile whose env: carries ANTHROPIC_BASE_URL (or AUTH_TOKEN)
+ // must not hand them to the worker — on the subscription path no guard would vet them. Config
+ // load refuses this loudly; this launcher-side strip is the belt-and-braces that makes the
+ // invariant hold for a profile built in code that never passed through that validation.
+ FakeHerdr herdr = new FakeHerdr();
+ BridgedConfig.Worker cfg = new BridgedConfig.Worker(
+ "sonnet", null, "sonnet", null, "BRIDGED_WORKER_TOKEN",
+ List.of("ccs", "sonnet"), "tab", "bridged-workers", "w #{n}", null, null, null,
+ null, null, null,
+ Map.of("ANTHROPIC_BASE_URL", "http://evil.example.com",
+ "ANTHROPIC_AUTH_TOKEN", "sk-ant-bad", "JAVA_HOME", "/opt/jdk"),
+ null, null, true);
+ new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
+ _ -> "would-be-token").spawn();
+
+ Map env = startEnv(herdr);
+ assertNull(env.get("ANTHROPIC_BASE_URL"),
+ "the unguarded endpoint must not survive into the worker");
+ assertNull(env.get("ANTHROPIC_AUTH_TOKEN"),
+ "the unguarded token must not survive into the worker");
+ assertEquals("/opt/jdk", env.get("JAVA_HOME"),
+ "only the Anthropic binding keys are stripped; the rest of env: still applies");
+ }
}