From 73f6b12dd89076af134e451bf543d8acfb41cbfe Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 13 Aug 2026 08:37:45 +0200 Subject: [PATCH] CB-539: let a worker profile run on the subscription, explicitly Add a per-profile subscription: true opt-in that lets a claude-code worker run on the operator's Claude subscription when there is no off-subscription endpoint for it (e.g. sonnet on ccs). When set, the launcher injects neither ANTHROPIC_BASE_URL nor ANTHROPIC_AUTH_TOKEN and skips SubscriptionGuard's base_url requirement for that profile only, logging a WARN naming the profile. subscription: true alongside a baseUrl is refused as contradictory. The default (absent/false) keeps today's hard refusal unchanged; every other profile stays allowlist-checked and SubscriptionGuard is untouched. --- .../ltms/bridged/config/BridgedConfig.java | 44 ++++++++-- .../bridged/worker/ClaudeCodeLauncher.java | 32 +++++++- .../bridged/config/BridgedConfigTest.java | 19 +++++ .../worker/ClaudeCodeLauncherTest.java | 80 +++++++++++++++++++ 4 files changed, 167 insertions(+), 8 deletions(-) diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index a732e93..7b9b47d 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -119,6 +119,15 @@ public record BridgedConfig( * 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 * 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 for this + * profile only. 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). */ @JsonIgnoreProperties(ignoreUnknown = true) public record Worker(String profile, String baseUrl, String model, @@ -130,7 +139,8 @@ public record BridgedConfig( String kind, Map env, Float weight, - Integer maxLoad) { + Integer maxLoad, + Boolean subscription) { /** Peer kind spawned by {@link dev.ltms.bridged.worker.ClaudeCodeLauncher} (the default). */ public static final String KIND_CLAUDE_CODE = "claude-code"; @@ -163,6 +173,7 @@ public record BridgedConfig( env = (env == null) ? Map.of() : Map.copyOf(env); weight = (weight == null || weight <= 0.0f) ? 1.0f : weight; maxLoad = (maxLoad == null || maxLoad <= 0) ? null : maxLoad; + subscription = (subscription != null && subscription) ? Boolean.TRUE : Boolean.FALSE; } /** @@ -175,7 +186,7 @@ public record BridgedConfig( String placement, String workspace, String tabLabel, String mcpUrl, String cwd, List parityOverlay) { 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 +198,7 @@ public record BridgedConfig( String placement, String workspace, String tabLabel, String mcpUrl, String cwd, List parityOverlay, String gitTokenEnv, String gitHostEnv) { 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 +211,13 @@ public record BridgedConfig( String cwd, List parityOverlay, String gitTokenEnv, String gitHostEnv, String kind) { 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. */ public Worker withProfile(String p) { 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). */ @@ -219,6 +230,29 @@ public record BridgedConfig( 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 argv, + String placement, String workspace, String tabLabel, String mcpUrl, + String cwd, List parityOverlay, String gitTokenEnv, String gitHostEnv, + String kind, Map 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(); 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..1e4e392 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,36 @@ 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) { + 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..9972a3f 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,23 @@ 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)"); + } } 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..ce4cfc8 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; @@ -614,5 +615,84 @@ 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"); } }