Merge CB-539 + CB-542: subscription profiles, with the env: bypass closed
Verified by the lead in a clean worktree at 5afe8e1 rather than on the worker's
report: Tests run: 474, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS (main
was at 464).
The invariant this lands: there is no configuration in which a worker reaches an
Anthropic endpoint that no guard vetted. Closed at two layers — a fatal, profile-
naming refusal at config load, and a launcher-side strip so it holds for profiles
built in code that never passed validation.
The wiki half is a separate branch on the submodule's own remote; the pointer bump
follows as its own commit on main.
This commit was merged in pull request #14.
This commit is contained in:
@@ -87,6 +87,9 @@ public final class Bridged {
|
|||||||
// dangerous configuration cannot be reached by ignoring a log line.
|
// dangerous configuration cannot be reached by ignoring a log line.
|
||||||
cfg.validateAuthExposure();
|
cfg.validateAuthExposure();
|
||||||
cfg.validateLeadScan();
|
cfg.validateLeadScan();
|
||||||
|
// CB-542: a subscription:true profile whose env: reseats ANTHROPIC_BASE_URL/AUTH_TOKEN would
|
||||||
|
// reach an unguarded endpoint (the launcher skips SubscriptionGuard for it). Refuse at load.
|
||||||
|
cfg.validateSubscriptionProfiles();
|
||||||
|
|
||||||
Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank()
|
Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank()
|
||||||
? Path.of(cfg.herdrSocket())
|
? Path.of(cfg.herdrSocket())
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ public record BridgedConfig(
|
|||||||
* each adapter drives only its own kind. Normalised to lower-case; blank ⇒ the
|
* each adapter drives only its own kind. Normalised to lower-case; blank ⇒ the
|
||||||
* default. It selects the adapter, not the transport — placement, tabs, cwd, and
|
* default. It selects the adapter, not the transport — placement, tabs, cwd, and
|
||||||
* the readiness gate are kind-independent and stay in the shared base.
|
* the readiness gate are kind-independent and stay in the shared base.
|
||||||
|
* @param subscription {@code true} to run this profile's workers on the operator's Claude
|
||||||
|
* subscription, on purpose (CB-539). When set, the launcher neither requires
|
||||||
|
* nor injects {@code ANTHROPIC_BASE_URL} / {@code ANTHROPIC_AUTH_TOKEN}, and the
|
||||||
|
* {@code SubscriptionGuard} base_url requirement is skipped <em>for this
|
||||||
|
* profile only</em>. Absent/{@code false} (the default) keeps today's hard
|
||||||
|
* refusal: a claude-code profile with no base_url may not spawn, because
|
||||||
|
* spawning one would bill the subscription. Mutually exclusive with
|
||||||
|
* {@code baseUrl} — setting both is a configuration error (the two state
|
||||||
|
* opposite intents). For the same reason, an {@code env:} entry naming
|
||||||
|
* {@code ANTHROPIC_BASE_URL} or {@code ANTHROPIC_AUTH_TOKEN} is refused at
|
||||||
|
* config load (CB-542): on the subscription path no guard would vet it.
|
||||||
*/
|
*/
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record Worker(String profile, String baseUrl, String model,
|
public record Worker(String profile, String baseUrl, String model,
|
||||||
@@ -130,7 +141,8 @@ public record BridgedConfig(
|
|||||||
String kind,
|
String kind,
|
||||||
Map<String, String> env,
|
Map<String, String> env,
|
||||||
Float weight,
|
Float weight,
|
||||||
Integer maxLoad) {
|
Integer maxLoad,
|
||||||
|
Boolean subscription) {
|
||||||
|
|
||||||
/** Peer kind spawned by {@link dev.ltms.bridged.worker.ClaudeCodeLauncher} (the default). */
|
/** Peer kind spawned by {@link dev.ltms.bridged.worker.ClaudeCodeLauncher} (the default). */
|
||||||
public static final String KIND_CLAUDE_CODE = "claude-code";
|
public static final String KIND_CLAUDE_CODE = "claude-code";
|
||||||
@@ -163,6 +175,7 @@ public record BridgedConfig(
|
|||||||
env = (env == null) ? Map.of() : Map.copyOf(env);
|
env = (env == null) ? Map.of() : Map.copyOf(env);
|
||||||
weight = (weight == null || weight <= 0.0f) ? 1.0f : weight;
|
weight = (weight == null || weight <= 0.0f) ? 1.0f : weight;
|
||||||
maxLoad = (maxLoad == null || maxLoad <= 0) ? null : maxLoad;
|
maxLoad = (maxLoad == null || maxLoad <= 0) ? null : maxLoad;
|
||||||
|
subscription = (subscription != null && subscription) ? Boolean.TRUE : Boolean.FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,7 +188,7 @@ public record BridgedConfig(
|
|||||||
String placement, String workspace, String tabLabel, String mcpUrl,
|
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||||
String cwd, List<String> parityOverlay) {
|
String cwd, List<String> parityOverlay) {
|
||||||
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||||
mcpUrl, cwd, parityOverlay, null, null, null, null, null, null);
|
mcpUrl, cwd, parityOverlay, null, null, null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,7 +200,7 @@ public record BridgedConfig(
|
|||||||
String placement, String workspace, String tabLabel, String mcpUrl,
|
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||||
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv) {
|
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv) {
|
||||||
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, null, null, null, null);
|
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,13 +213,13 @@ public record BridgedConfig(
|
|||||||
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv,
|
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv,
|
||||||
String kind) {
|
String kind) {
|
||||||
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, null, null, null);
|
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */
|
/** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */
|
||||||
public Worker withProfile(String p) {
|
public Worker withProfile(String p) {
|
||||||
return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad);
|
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad, subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when this profile is served by the Claude Code adapter (the default kind). */
|
/** True when this profile is served by the Claude Code adapter (the default kind). */
|
||||||
@@ -219,11 +232,48 @@ public record BridgedConfig(
|
|||||||
return KIND_OPENCODE.equals(kind);
|
return KIND_OPENCODE.equals(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this profile runs on the operator's Claude subscription, on purpose (CB-539).
|
||||||
|
* Absent/{@code false} (the default) keeps the hard refusal: a claude-code profile with no
|
||||||
|
* base_url may not spawn, because doing so would bill the subscription.
|
||||||
|
*/
|
||||||
|
public boolean isSubscription() {
|
||||||
|
return Boolean.TRUE.equals(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible constructor without the CB-539 subscription flag — the worker stays on
|
||||||
|
* the off-subscription boundary (the default). Keeps pre-CB-539 call sites (and any YAML
|
||||||
|
* that omits the flag) compiling and behaving identically.
|
||||||
|
*/
|
||||||
|
public Worker(String profile, String baseUrl, String model,
|
||||||
|
String configDir, String tokenEnv, List<String> argv,
|
||||||
|
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||||
|
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv,
|
||||||
|
String kind, Map<String, String> 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). */
|
/** True when this profile's workers are granted a forge token to open their own PR (CB-302). */
|
||||||
public boolean hasGitToken() {
|
public boolean hasGitToken() {
|
||||||
return gitTokenEnv != null && !gitTokenEnv.isBlank();
|
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. */
|
/** True when workers should land in their own tab in the worker space. */
|
||||||
public boolean tabPlacement() {
|
public boolean tabPlacement() {
|
||||||
return "tab".equals(placement);
|
return "tab".equals(placement);
|
||||||
@@ -632,6 +682,44 @@ public record BridgedConfig(
|
|||||||
+ "confused.");
|
+ "confused.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a subscription profile whose {@code env:} block tries to reseat the Anthropic binding
|
||||||
|
* (CB-542).
|
||||||
|
*
|
||||||
|
* <p>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<String> bad = new java.util.ArrayList<>();
|
||||||
|
workerProfiles().forEach((name, w) -> {
|
||||||
|
if (w.isSubscription() && w.envCarriesAnthropicBinding()) {
|
||||||
|
List<String> 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. */
|
/** True for the loopback addresses and the unspecified-but-local forms we treat as same-host. */
|
||||||
private static boolean isLoopbackBind(String host) {
|
private static boolean isLoopbackBind(String host) {
|
||||||
if (host == null || host.isBlank()) {
|
if (host == null || host.isBlank()) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import dev.ltms.bridged.herdr.Agent;
|
|||||||
import dev.ltms.bridged.herdr.AgentControl;
|
import dev.ltms.bridged.herdr.AgentControl;
|
||||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||||
import dev.ltms.bridged.peer.Capability;
|
import dev.ltms.bridged.peer.Capability;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
import java.util.List;
|
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). */
|
/** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */
|
||||||
private static final String NAME_PREFIX = "claude";
|
private static final String NAME_PREFIX = "claude";
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClaudeCodeLauncher.class);
|
||||||
|
|
||||||
private final SubscriptionGuard guard;
|
private final SubscriptionGuard guard;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,14 +120,43 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected Launch buildLaunch(BridgedConfig.Worker cfg) {
|
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();
|
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<String, String> workerEnv = baseEnv(cfg);
|
Map<String, String> 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, "ANTHROPIC_MODEL", cfg.model());
|
||||||
putIfPresent(workerEnv, "CLAUDE_CONFIG_DIR", cfg.configDir());
|
putIfPresent(workerEnv, "CLAUDE_CONFIG_DIR", cfg.configDir());
|
||||||
putIfPresent(workerEnv, "ANTHROPIC_AUTH_TOKEN", env.apply(cfg.tokenEnv()));
|
|
||||||
applyGitToken(workerEnv, cfg);
|
applyGitToken(workerEnv, cfg);
|
||||||
|
|
||||||
return new Launch(workerEnv, argvWithModel(argvWithBridge(cfg), cfg));
|
return new Launch(workerEnv, argvWithModel(argvWithBridge(cfg), cfg));
|
||||||
|
|||||||
@@ -640,4 +640,100 @@ class BridgedConfigTest {
|
|||||||
assertEquals(1.0f, w.weight(), 0.0001f, "absent weight defaults to 1.0");
|
assertEquals(1.0f, w.weight(), 0.0001f, "absent weight defaults to 1.0");
|
||||||
assertNull(w.maxLoad(), "absent maxLoad defaults to unlimited (null)");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.ltms.bridged.worker;
|
package dev.ltms.bridged.worker;
|
||||||
|
|
||||||
import dev.ltms.bridged.config.BridgedConfig;
|
import dev.ltms.bridged.config.BridgedConfig;
|
||||||
|
import dev.ltms.bridged.guard.GuardException;
|
||||||
import dev.ltms.bridged.guard.SubscriptionGuard;
|
import dev.ltms.bridged.guard.SubscriptionGuard;
|
||||||
import dev.ltms.bridged.herdr.AgentControl;
|
import dev.ltms.bridged.herdr.AgentControl;
|
||||||
import dev.ltms.bridged.herdr.FakeHerdr;
|
import dev.ltms.bridged.herdr.FakeHerdr;
|
||||||
@@ -615,4 +616,111 @@ class ClaudeCodeLauncherTest {
|
|||||||
assertFalse(spawnedArgs(herdr).contains("--model"));
|
assertFalse(spawnedArgs(herdr).contains("--model"));
|
||||||
assertNull(startEnv(herdr).get("ANTHROPIC_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<String, String> 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<String, String> 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user