Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2757bc7185 | |||
| 7655f1b51a | |||
| a5efb7c676 | |||
| 5a8cf4cb4d | |||
| a3fc7e4df8 | |||
| d811b30df3 | |||
| 3f4ac2b24e | |||
| 4644359128 | |||
| 95a8dbcea9 | |||
| 83b50753fe | |||
| 6f968d59a4 | |||
| d432df8e5c | |||
| 4b822731e6 | |||
| e38eac1a33 | |||
| 8101290933 |
@@ -26,9 +26,9 @@ was bound to. Don't infer what you can ask.
|
||||
|
||||
Only if that call is unavailable, fall back to these — each is one-way, so keep reading until one
|
||||
fires: the reply charter in your system prompt (*"You are a spawned member in the
|
||||
claude-bridge fleet"*) ⇒ **spawned member**; bridge tools prefixed `mcp__bridge__*` ⇒ **spawned
|
||||
claude-bridge fleet"*) ⇒ **spawned member**; fleet tools prefixed `mcp__fleet__*` ⇒ **spawned
|
||||
member** (the launcher fixes that mount name; a primary's mount is named by whoever wrote its
|
||||
`.mcp.json`, so it varies); `ANTHROPIC_BASE_URL` set ⇒ **spawned member** (Claude-model members run
|
||||
`.mcp.json`, so it varies — and a member spawned before CB-632 still says `mcp__bridge__*`); `ANTHROPIC_BASE_URL` set ⇒ **spawned member** (Claude-model members run
|
||||
on a clean env, so its *absence* proves nothing). None of these separate a worker from an architect —
|
||||
only `fleet_whoami` does. **Still unsure ⇒ act as a worker**, the most restricted member role. The
|
||||
two mistakes are not symmetric: a primary acting as a worker is refused by the authorization gate —
|
||||
@@ -271,7 +271,7 @@ Before you call any work done, check the row that matches what you touched:
|
||||
| a `fleet_*` tool — added, removed, renamed, or its params/semantics | the primary's intent→tool table; any rule that names that tool |
|
||||
| `Authz` / the role table | invariant 3, and the primary-only vs worker-only claims |
|
||||
| `ConnectionIdentity` / how a caller is resolved | the `fleet_whoami` paragraph and the fallback ladder |
|
||||
| `REPLY_CHARTER`, or a launcher's mount/flags | the fallback ladder (`mcp__bridge__*`), and the layering table's top row |
|
||||
| `REPLY_CHARTER`, or a launcher's mount/flags | the fallback ladder (`mcp__fleet__*`), and the layering table's top row |
|
||||
| the injector / status gating | invariant 4 |
|
||||
| worktree provisioning or the parity overlay | the "both roles read this file" premise — it rests on the worker's worktree being a checkout of this repo |
|
||||
| `.claude/skills/**` | the addendum's skill list, and the "name the playbook" rule |
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
target/
|
||||
dependency-reduced-pom.xml
|
||||
|
||||
# Local runtime config (copy from fleetd.example.yaml)
|
||||
# Local runtime config (copy from fleetd.example.yaml). Both names: the live file is still
|
||||
# bridged.yaml until the cutover, and Fleetd reads either one.
|
||||
fleetd.yaml
|
||||
bridged.yaml
|
||||
|
||||
# CB-505 audit trail + daemon stdout/stderr — runtime records, never source
|
||||
|
||||
+79
-12
@@ -126,6 +126,32 @@ herdrSocket: ~/.config/herdr/herdr.sock
|
||||
# Use `pane` for the legacy behaviour (split the focused tab).
|
||||
# mcpUrl → bridged mounts the bridge MCP (--mcp-config, inline) + reply charter
|
||||
# (--append-system-prompt) as launch flags; nothing is written to the profile.
|
||||
# ideMcpUrl → opt-in (CB-634), default off. When set, bridged mounts the IDE Index MCP as a
|
||||
# second inline server named `intellij`, and adds an IDE charter that pins every
|
||||
# ide_* call to the member's own worktree. A URL, not a boolean — host and port
|
||||
# are host-specific. Set it only on a host where the IDE actually runs.
|
||||
# ideProjectDir → repo-relative module dir the IDE opens and the overlay pins (CB-634). Only read
|
||||
# when ideMcpUrl is set. This repo's Maven pom lives in `bridged/`, not at the
|
||||
# worktree root, so opening the root imports no module and ide_* resolves nothing;
|
||||
# set this to `bridged`. Omit for a repo whose project is the worktree root.
|
||||
# ideOpenCommand → host command that opens ideProjectDir in the IDE at spawn (CB-634 auto-open).
|
||||
# Only read when ideMcpUrl is set. `{dir}` is replaced with the absolute module
|
||||
# dir and the command runs through `/bin/sh -c`, so set env inline if needed —
|
||||
# e.g. `env DISPLAY=:10.0 idea {dir}`. Best-effort: a failure is logged, never
|
||||
# fails the spawn. Omit to open the member's module by hand. There is no close
|
||||
# half yet — an opened module stays open until the operator closes it.
|
||||
# autoCompactWindow → opt-in, default off. A bounded token window that forces a spawned member to
|
||||
# compact its context instead of running on the backend's own default and dying
|
||||
# mid-turn (losing its fleet_reply — the whole point of the turn — with it).
|
||||
# Validated at config load to [100000, 1000000] — the band Claude Code's own
|
||||
# --autocompact flag accepts.
|
||||
# CROSS-BACKEND SEMANTICS DIFFER: on claude-code this is a launch-time
|
||||
# `--autocompact <tokens>` flag — the member compacts AT this window. opencode
|
||||
# has no equivalent flag (it only forces `compaction.auto: true`, unconditionally,
|
||||
# already), so this is instead applied as the model's `limit.context` in the
|
||||
# generated opencode.json — the member compacts WITHIN this window, not exactly
|
||||
# at it — and only when this profile's `model:` is in `provider/model` form; if it
|
||||
# isn't, bridged logs a WARN naming the profile rather than silently doing nothing.
|
||||
# tokenEnv → host env var holding the worker's auth token (value never stored in config);
|
||||
# omit for a backend that needs no token (e.g. a local ollama).
|
||||
# cwd → pin this profile's working directory (CB-112). Omit to inherit the primary's
|
||||
@@ -135,7 +161,8 @@ herdrSocket: ~/.config/herdr/herdr.sock
|
||||
# skills/MCP/hooks. Omit to leave the worker on the host default.
|
||||
# parityOverlay → repo-relative paths copied primary→worktree so a worker in a provisioned
|
||||
# worktree sees the same local config (CB-301-ext). Omit for the default set:
|
||||
# [.claude/settings.local.json, .env, .envrc].
|
||||
# [.env, .envrc]. (.claude/settings.local.json is NOT in the default — it
|
||||
# pre-approves IDE/tool grants a member must not hold ambiently; CB-525/CB-634.)
|
||||
#
|
||||
# Do NOT add .mcp.json (CB-525). A worker's tools are whatever its launcher
|
||||
# mounts — the bridge, and nothing else. Replicating the primary's MCP config
|
||||
@@ -237,7 +264,11 @@ profiles:
|
||||
# credentialId: shared-openai # opt-in: quarantine together with every other profile sharing this id (CB-578)
|
||||
# configDir: /Users/me/.ccs/instances/gx10 # CLAUDE_CONFIG_DIR — inherit that profile's skills/MCP
|
||||
# cwd: /Users/me/src/myrepo # pin the working dir; omit to inherit the primary's
|
||||
# parityOverlay: [".claude/settings.local.json", ".env", ".envrc"] # never add .mcp.json — see above
|
||||
# parityOverlay: [".env", ".envrc"] # the default; never add .mcp.json or .claude/settings.local.json — see above
|
||||
# ideMcpUrl: http://127.0.0.1:29170/index-mcp/streamable-http # opt-in (CB-634): IDE code intelligence, pinned to the worktree
|
||||
# ideProjectDir: bridged # CB-634: module dir the IDE opens + the overlay pins (this repo's pom is in bridged/)
|
||||
# ideOpenCommand: env DISPLAY=:10.0 idea {dir} # CB-634 auto-open: opens {dir} in the IDE at spawn; omit to open by hand
|
||||
# autoCompactWindow: 250000 # opt-in: bound member context; claude-code compacts AT this, opencode within it (model limit.context)
|
||||
gx11: # a second backend, so `placement: weighted` has a choice
|
||||
baseUrl: http://gx01.gw:8000 # self-hosted; ccs handles the model + token
|
||||
placement: tab
|
||||
@@ -505,20 +536,51 @@ guard:
|
||||
# through either: the daemon logs a WARN naming any credential-shaped env var it finds on neither
|
||||
# list (never its value), so a secret added to the store later does not go unnoticed forever.
|
||||
#
|
||||
# policy → only "deny-by-default" exists today (an operator-authored deny-list was deliberately
|
||||
# rejected — see above). An unrecognized value refuses to start, naming it.
|
||||
# allow → credential names a member legitimately needs. Left OUT of the pane's env overlay
|
||||
# entirely, so the value the pane's own (login) shell exports passes through untouched.
|
||||
# known → every credential name the operator's store is known to export. Every name here NOT
|
||||
# also in `allow` is overlaid with a non-secret sentinel value before the pane's login
|
||||
# shell runs — real protection only for names the login shell does not itself re-export
|
||||
# (see the ROUND-2 CORRECTION note above for the ones it does).
|
||||
# policy → "deny-by-default" (the default; also accepted spelled "deny-list") overlays each
|
||||
# known-but-not-allowed name BEFORE the pane's login shell runs — real protection only
|
||||
# where that shell does not re-export the name (see ROUND-2 CORRECTION above). An
|
||||
# unrecognized value refuses to start, naming it.
|
||||
# policy → "allow-list" (CB-633) moves the control to a per-spawn ZDOTDIR directory the daemon
|
||||
# generates and passes through tab.create's env map. Each generated startup file sources
|
||||
# its ~/ counterpart FIRST and then runs the scrub, so the scrub happens after the
|
||||
# operator's whole chain and no sourced file can undo it.
|
||||
# The scrub is sourced from BOTH the generated .zshrc and the generated .zlogin, because
|
||||
# herdr does not open the same kind of shell everywhere: macOS panes run a LOGIN zsh (so
|
||||
# .zlogin runs), Linux panes run a plain interactive zsh (so .zlogin never runs at all).
|
||||
# A scrub in .zlogin alone would be a control that silently does nothing on Linux.
|
||||
# The allow-list is DERIVED, never typed:
|
||||
# every profile's tokenEnv/gitTokenEnv/gitHostEnv values and env-map keys, plus an
|
||||
# infrastructure set (PATH HOME SHELL TERM LANG LC_* TMPDIR USER LOGNAME PWD SHLVL EDITOR
|
||||
# PAGER JAVA_HOME XDG_* ZDOTDIR), plus whatever keys this spawn's own env overlay carries.
|
||||
# Adding a profile can therefore only widen the list, never break another spawn's scrub.
|
||||
# Under this policy `known`/`allow` below become REPORTING ONLY — they feed the gap WARN,
|
||||
# they are no longer a control. If the member's login shell is NOT zsh, the daemon logs a
|
||||
# loud WARN saying protection is off and falls back to deny-by-default's overlay.
|
||||
# Each pane writes a scrub-report.txt naming how many variables it kept of how many it
|
||||
# saw; the daemon logs that "allowed N of M" line when the pane stops. If the report is
|
||||
# MISSING the daemon logs a WARN instead — the scrub cannot then be confirmed to have
|
||||
# run, and a silently-dead control is exactly what this policy exists to prevent.
|
||||
# allow → credential names a member legitimately needs. Under deny-by-default, left OUT of the
|
||||
# pane's env overlay entirely, so the value the pane's own (login) shell exports passes
|
||||
# through untouched. Under allow-list: reporting only.
|
||||
# known → every credential name the operator's store is known to export. Under deny-by-default,
|
||||
# every name here NOT also in `allow` is overlaid with a non-secret sentinel value before
|
||||
# the pane's login shell runs — real protection only for names that shell does not itself
|
||||
# re-export (see the ROUND-2 CORRECTION note above). Under allow-list: reporting only.
|
||||
# sshAuthSock → whether SSH_AUTH_SOCK may pass through under allow-list ("allow") or must be
|
||||
# blanked like any other non-derived name ("block", the default). This is a decision you
|
||||
# have to make explicitly: SSH_AUTH_SOCK is a handle to YOUR ssh-agent, and a member
|
||||
# holding it can sign with your keys — it sits in no secret file and looks like no
|
||||
# credential, which is why it slipped past three earlier tickets (gitea #110). Blocking
|
||||
# it breaks git over SSH inside members (push/fetch authenticate as you); use HTTPS
|
||||
# remotes or scoped deploy keys instead of allowing it lightly.
|
||||
#
|
||||
# HOT-RELOADABLE the same way `fleet:` is (CB-559): read fresh on every spawn, so editing this list
|
||||
# and reloading config (or restarting) changes what the NEXT spawn inherits; already-running members
|
||||
# are unaffected either way.
|
||||
# memberCredentials:
|
||||
# policy: deny-by-default
|
||||
# policy: deny-by-default # or "deny-list", or "allow-list" (CB-633) — see above
|
||||
# sshAuthSock: block # allow-list only; see the sshAuthSock note above
|
||||
# allow:
|
||||
# - AI_GATEWAY_TOKEN # named in a profile's tokenEnv (local/gx) — a member reaching the
|
||||
# # gateway is by design, not a leak
|
||||
@@ -593,11 +655,16 @@ guard:
|
||||
# RabbitMQ speaks the same AMQP 0-9-1, so it is a URI-only swap.
|
||||
# uri → AMQP connection URI. No trailing slash ⇒ the default vhost "/"; an empty path ("/")
|
||||
# is vhost "" and will NOT connect. Encode a named vhost as .../%2Fmyvhost.
|
||||
# uriEnv → CB-151: name of a host env var holding the AMQP URI, preferred over `uri` (wins
|
||||
# whenever set). The URI carries `user:pass@` inline, so naming a variable keeps the
|
||||
# password out of fleetd.yaml — same pattern as auth.tokenEnv/Profile.tokenEnv. A
|
||||
# uriEnv that resolves to an unset or blank variable is treated as NOT configured and
|
||||
# the daemon falls back to the in-memory inbox, warning loudly.
|
||||
# prefetch → CB-527: consumer basicQos, capping how many unacked messages the inbox holds
|
||||
# in-heap per owned target (the rest sits on the broker's durable queue instead of
|
||||
# growing the JVM heap). Default 32 when omitted.
|
||||
# broker:
|
||||
# uri: amqp://guest:guest@127.0.0.1:5672
|
||||
# uriEnv: LAVINMQ_URI
|
||||
# prefetch: 32
|
||||
|
||||
# Active push-to-primary (CB-307 Stage 3). When a worker reply lands with no open fleet_send,
|
||||
|
||||
@@ -371,18 +371,10 @@ public final class Fleetd {
|
||||
StatusPoller poller = new StatusPoller(agents, injector, Injector.POLL_INTERVAL_MILLIS);
|
||||
poller.start();
|
||||
|
||||
// CB-307: reply inbox. A broker: block (with a uri) selects the AMQP-backed durable adapter;
|
||||
// absent, bridged stays soft-state on the in-memory inbox. The AMQP inbox owns a broker
|
||||
// CB-307: reply inbox. A broker: block selects the AMQP-backed durable adapter; absent (or
|
||||
// unusable), bridged stays soft-state on the in-memory inbox. The AMQP inbox owns a broker
|
||||
// connection, so keep the reference to close it in the ordered shutdown hook.
|
||||
final ReplyInbox replyInbox;
|
||||
if (cfg.broker() != null && cfg.broker().isConfigured()) {
|
||||
replyInbox = AmqpReplyInbox.open(cfg.broker().uri(), cfg.broker().prefetchOrDefault());
|
||||
log.info("reply inbox: AMQP broker (durable) at {} (prefetch={})",
|
||||
cfg.broker().uri(), cfg.broker().prefetchOrDefault());
|
||||
} else {
|
||||
replyInbox = new InMemoryReplyInbox();
|
||||
log.info("reply inbox: in-memory (soft-state)");
|
||||
}
|
||||
final ReplyInbox replyInbox = selectReplyInbox(cfg.broker(), System.getenv(), AmqpReplyInbox::open);
|
||||
// CB-307: learn the primary's terminal from orchestration tool calls (or pin from config).
|
||||
// The pin also feeds CallerResolver below: a primary running inside a herdr pane would
|
||||
// otherwise resolve as a worker and be refused every orchestration tool.
|
||||
@@ -577,14 +569,101 @@ public final class Fleetd {
|
||||
return target -> presence.isPresent(target) || leads.get().containsKey(target);
|
||||
}
|
||||
|
||||
/** Injection seam for {@link #selectReplyInbox}: production binds {@link AmqpReplyInbox#open}. */
|
||||
@FunctionalInterface
|
||||
interface AmqpOpener {
|
||||
ReplyInbox open(String uri, int prefetch);
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-151/152: pick the reply inbox. A usable broker — a literal {@code uri}, or a {@code
|
||||
* uriEnv} whose variable resolves (both read from {@code env}) — selects the durable AMQP inbox.
|
||||
* Everything else falls back to the in-memory inbox: no broker block, a blank {@code uri}, a
|
||||
* {@code uriEnv} whose variable is unset or blank, or a broker unreachable at boot. The two
|
||||
* lossy paths warn <em>loudly</em> — never silently — because what is lost is durable,
|
||||
* cross-restart reply delivery. Package-private and env-injected so the selection is testable
|
||||
* without a real broker or a mutable process environment.
|
||||
*/
|
||||
static ReplyInbox selectReplyInbox(FleetConfig.Broker broker, Map<String, String> env, AmqpOpener amqp) {
|
||||
if (broker == null) {
|
||||
log.info("reply inbox: in-memory (soft-state)");
|
||||
return new InMemoryReplyInbox();
|
||||
}
|
||||
if (broker.hasUriEnv()) {
|
||||
// uriEnv is authoritative whenever set (CB-151): the operator moved off clear text, so
|
||||
// it must not quietly fall back onto a stale literal uri.
|
||||
if (broker.uri() != null && !broker.uri().isBlank()) {
|
||||
log.info("broker.uri is ignored because broker.uriEnv={} is set", broker.uriEnv());
|
||||
}
|
||||
String effectiveUri = broker.effectiveUri(env);
|
||||
if (effectiveUri == null) {
|
||||
log.warn("broker.uriEnv={} is unset or blank — durable AMQP reply inbox DISABLED. "
|
||||
+ "Replies are soft-state and will not survive a restart. Set {} in the "
|
||||
+ "daemon's environment (see scripts/redeploy-bridged.sh) and restart to "
|
||||
+ "use the durable broker inbox.",
|
||||
broker.uriEnv(), broker.uriEnv());
|
||||
log.info("reply inbox: in-memory (soft-state)");
|
||||
return new InMemoryReplyInbox();
|
||||
}
|
||||
log.info("reply inbox: AMQP broker (durable) via env var {} (prefetch={})",
|
||||
broker.uriEnv(), broker.prefetchOrDefault());
|
||||
return openAmqpOrFallback(effectiveUri, broker.prefetchOrDefault(), "uriEnv " + broker.uriEnv(), amqp);
|
||||
}
|
||||
// No uriEnv: the literal uri path (existing behaviour).
|
||||
if (broker.effectiveUri(env) == null) {
|
||||
log.info("reply inbox: in-memory (soft-state)");
|
||||
return new InMemoryReplyInbox();
|
||||
}
|
||||
log.info("reply inbox: AMQP broker (durable) (prefetch={})", broker.prefetchOrDefault());
|
||||
return openAmqpOrFallback(broker.uri(), broker.prefetchOrDefault(), "uri", amqp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the AMQP inbox, falling back to the in-memory inbox for this process lifetime if the
|
||||
* broker cannot be reached at boot (CB-152). Not silent: the warning says durable delivery is
|
||||
* off, replies are soft-state and will not survive a restart, plus the source that failed and
|
||||
* the URI <em>with credentials stripped</em>. Never retries in the background — a broker that
|
||||
* drops <em>after</em> startup already self-heals via the connection factory's automatic
|
||||
* recovery; only the boot path is changed here.
|
||||
*/
|
||||
private static ReplyInbox openAmqpOrFallback(String effectiveUri, int prefetch, String source,
|
||||
AmqpOpener amqp) {
|
||||
try {
|
||||
return amqp.open(effectiveUri, prefetch);
|
||||
} catch (IllegalStateException e) {
|
||||
log.warn("cannot reach AMQP broker ({}, {}) — falling back to the in-memory reply inbox "
|
||||
+ "for this process lifetime. Durable, cross-restart reply delivery is OFF; "
|
||||
+ "replies are soft-state and will not survive a restart. Reason: {}",
|
||||
source, stripCredentials(effectiveUri), reasonOf(e));
|
||||
log.info("reply inbox: in-memory (soft-state)");
|
||||
return new InMemoryReplyInbox();
|
||||
}
|
||||
}
|
||||
|
||||
/** An AMQP URI carries {@code user:pass@} inline — show the host/port, never the credentials. */
|
||||
static String stripCredentials(String uri) {
|
||||
return uri == null ? null : uri.replaceAll("://[^@/]*@", "://");
|
||||
}
|
||||
|
||||
/** The deepest cause's class and message — the outermost {@code IllegalStateException} echoes the URI (with password). */
|
||||
private static String reasonOf(Throwable e) {
|
||||
Throwable t = e;
|
||||
while (t.getCause() != null && t.getCause() != t) {
|
||||
t = t.getCause();
|
||||
}
|
||||
String msg = t.getMessage();
|
||||
return t.getClass().getSimpleName() + (msg == null || msg.isBlank() ? "" : ": " + msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-594: which env vars the loaded config actually needs, and why — every non-{@code
|
||||
* subscription} profile's {@code tokenEnv} (a subscription profile never reads one, see
|
||||
* {@link FleetConfig.Profile#isSubscription()}), plus every profile's {@code gitTokenEnv}
|
||||
* where set (opt-in). Derived from the config, not hard-coded, so a new profile is covered for
|
||||
* free. A var required by more than one profile is one entry naming every profile that needs
|
||||
* it. Deliberately excludes {@code auth.tokenEnv}: that one is already enforced loudly, by a
|
||||
* startup throw in {@code main()} — about 370 lines <em>below</em> this method's call site
|
||||
* where set (opt-in), plus a configured {@code broker.uriEnv} (CB-151). Derived from the
|
||||
* config, not hard-coded, so a new profile is covered for free. A var required by more than one
|
||||
* profile is one entry naming every profile that needs it. Deliberately excludes {@code
|
||||
* auth.tokenEnv}: that one is already enforced loudly, by a startup throw in {@code main()} —
|
||||
* about 370 lines <em>below</em> this method's call site
|
||||
* ({@link #reportRequiredSecrets(FleetConfig)}), not a few lines above it. That throw only
|
||||
* fires when {@code auth.mode: token} is configured; under the default loopback-trust mode it
|
||||
* never runs, and {@code auth.tokenEnv} is simply not required.
|
||||
@@ -604,6 +683,11 @@ public final class Fleetd {
|
||||
.add("profile '" + name + "' gitTokenEnv");
|
||||
}
|
||||
});
|
||||
FleetConfig.Broker broker = cfg.broker();
|
||||
if (broker != null && broker.hasUriEnv()) {
|
||||
requiredBy.computeIfAbsent(broker.uriEnv(), _ -> new ArrayList<>())
|
||||
.add("broker uriEnv");
|
||||
}
|
||||
return requiredBy;
|
||||
}
|
||||
|
||||
@@ -652,8 +736,12 @@ public final class Fleetd {
|
||||
static void reportMemberCredentialsGap(FleetConfig cfg) {
|
||||
FleetConfig.MemberCredentials creds = cfg.memberCredentials();
|
||||
if (creds != null && !creds.known().isEmpty()) {
|
||||
log.info("memberCredentials: {} known name(s), {} allowed — blocking {} on every spawn",
|
||||
creds.known().size(), creds.allow().size(), creds.blockedSet().size());
|
||||
log.info("memberCredentials: policy={}, {} known name(s), {} allowed — blocking {} on "
|
||||
+ "every spawn{}",
|
||||
creds.policy(), creds.known().size(), creds.allow().size(), creds.blockedSet().size(),
|
||||
creds.isAllowList()
|
||||
? " (allow-list: known/allow are reporting only — the control is the derived ZDOTDIR scrub)"
|
||||
: "");
|
||||
return;
|
||||
}
|
||||
log.warn("memberCredentials: absent or empty — the daemon will start anyway, and every "
|
||||
|
||||
@@ -276,6 +276,9 @@ public final class ConfigRef implements Supplier<FleetConfig> {
|
||||
&& Objects.equals(a.workspace(), b.workspace())
|
||||
&& Objects.equals(a.tabLabel(), b.tabLabel())
|
||||
&& Objects.equals(a.mcpUrl(), b.mcpUrl())
|
||||
// CB-634: the IDE MCP mount is a launch flag, fixed at spawn like mcpUrl — a
|
||||
// reload changes it only for members spawned after, so a changed value is deferred.
|
||||
&& Objects.equals(a.ideMcpUrl(), b.ideMcpUrl())
|
||||
&& Objects.equals(a.cwd(), b.cwd())
|
||||
&& Objects.equals(a.parityOverlay(), b.parityOverlay())
|
||||
&& Objects.equals(a.gitTokenEnv(), b.gitTokenEnv())
|
||||
|
||||
@@ -275,6 +275,19 @@ public record FleetConfig(
|
||||
* profile that does not opt in. Read live off the current config, so it is
|
||||
* HOT: a change takes effect on the next exhaustion classification / spawn,
|
||||
* no restart needed.
|
||||
* @param autoCompactWindow opt-in per-profile token window that forces a spawned member to
|
||||
* auto-compact its context at (Claude Code) or within (opencode) a bound the
|
||||
* operator chooses, instead of the backend's own default. {@code null} (the
|
||||
* default) leaves today's behaviour exactly — opencode already forces
|
||||
* {@code compaction.auto: true} unconditionally (CB-523) but has no absolute
|
||||
* window, and Claude Code has neither. When set, validated at config load to
|
||||
* {@code [100000, 1000000]} — the band Claude Code's own {@code --autocompact
|
||||
* <tokens>} flag accepts. The two backends honour it differently: Claude Code
|
||||
* compacts AT this window (a launch-time {@code --autocompact} flag);
|
||||
* opencode has no such knob, so this is applied as the model's
|
||||
* {@code limit.context} instead, which bounds the window opencode compacts
|
||||
* <em>within</em>, and only when {@code model} resolves to a
|
||||
* {@code provider/model} pair.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Profile(String profile, String baseUrl, String model,
|
||||
@@ -289,7 +302,11 @@ public record FleetConfig(
|
||||
Integer maxLoad,
|
||||
Boolean subscription,
|
||||
String exhaustedPattern,
|
||||
String credentialId) {
|
||||
String credentialId,
|
||||
String ideMcpUrl,
|
||||
String ideProjectDir,
|
||||
String ideOpenCommand,
|
||||
Integer autoCompactWindow) {
|
||||
|
||||
/** Peer kind spawned by {@link dev.ltms.fleet.member.ClaudeCodeLauncher} (the default). */
|
||||
public static final String KIND_CLAUDE_CODE = "claude-code";
|
||||
@@ -348,6 +365,21 @@ public record FleetConfig(
|
||||
// "quarantines alone" fallback actually lives, so today's behaviour needs no defaulting
|
||||
// here at all.
|
||||
credentialId = (credentialId == null || credentialId.isBlank()) ? null : credentialId;
|
||||
// CB-634: opt-in per profile, default off. A URL, not a boolean — host and port are
|
||||
// host-specific, mirroring mcpUrl. When set, the member gets the IDE Index MCP mounted
|
||||
// (pinned to its own worktree via the charter). Blank ⇒ off.
|
||||
ideMcpUrl = (ideMcpUrl == null || ideMcpUrl.isBlank()) ? null : ideMcpUrl;
|
||||
// CB-634 auto-open: both are only read when hasIdeMcp(). ideProjectDir is the repo-relative
|
||||
// module dir IntelliJ must open (this repo's pom lives in `bridged/`, not at the root), and
|
||||
// it is also the project_path the overlay pins. Blank ⇒ the worktree root (unchanged before
|
||||
// auto-open). ideOpenCommand is the host command that opens that dir in the IDE, with {dir}
|
||||
// substituted; blank ⇒ no auto-open (the operator opens the module by hand).
|
||||
ideProjectDir = (ideProjectDir == null || ideProjectDir.isBlank()) ? null : ideProjectDir;
|
||||
ideOpenCommand = (ideOpenCommand == null || ideOpenCommand.isBlank()) ? null : ideOpenCommand;
|
||||
// Opt-in per profile, default off (null). No clamping here — unlike ideMcpUrl/ideProjectDir
|
||||
// there is no blank-string form to normalize (it's an Integer), and the [100000, 1000000]
|
||||
// range is enforced eagerly at config load (rejectAutoCompactWindowOutOfRange), naming the
|
||||
// profile, rather than silently clamped here. A profile that never sets it keeps null.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,7 +424,47 @@ public record FleetConfig(
|
||||
public Profile withProfile(String p) {
|
||||
return new Profile(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad, subscription,
|
||||
exhaustedPattern, credentialId);
|
||||
exhaustedPattern, credentialId, ideMcpUrl, ideProjectDir, ideOpenCommand, autoCompactWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor without the {@code autoCompactWindow} field — the profile
|
||||
* leaves auto-compaction at the backend's own default (opencode's unconditional
|
||||
* {@code compaction.auto: true}, or Claude Code's built-in threshold), exactly as before this
|
||||
* key existed. This is the shape the canonical constructor had before the field was added —
|
||||
* every pre-existing Java call site (and any YAML that omits the key) keeps compiling and
|
||||
* behaving identically; Jackson binds the canonical (longest) constructor, so YAML omitting
|
||||
* {@code autoCompactWindow:} still lands here as {@code null} via that path, not this one.
|
||||
*/
|
||||
public Profile(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,
|
||||
Boolean subscription, String exhaustedPattern, String credentialId,
|
||||
String ideMcpUrl, String ideProjectDir, String ideOpenCommand) {
|
||||
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad,
|
||||
subscription, exhaustedPattern, credentialId, ideMcpUrl, ideProjectDir, ideOpenCommand,
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor without the CB-634 auto-open fields
|
||||
* ({@code ideProjectDir}/{@code ideOpenCommand}) — a profile that opts into IDE MCP still
|
||||
* pins the worktree root and does not auto-open. Keeps pre-auto-open call sites (and any YAML
|
||||
* that omits the keys) compiling and behaving identically. This is the shape the canonical
|
||||
* constructor had before the two fields were added.
|
||||
*/
|
||||
public Profile(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,
|
||||
Boolean subscription, String exhaustedPattern, String credentialId, String ideMcpUrl) {
|
||||
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad,
|
||||
subscription, exhaustedPattern, credentialId, ideMcpUrl, null, null);
|
||||
}
|
||||
|
||||
/** True when this profile is served by the Claude Code adapter (the default kind). */
|
||||
@@ -441,7 +513,7 @@ public record FleetConfig(
|
||||
Boolean subscription, String exhaustedPattern) {
|
||||
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad,
|
||||
subscription, exhaustedPattern, null);
|
||||
subscription, exhaustedPattern, null, null);
|
||||
}
|
||||
|
||||
/** True when this profile's CB-578 stage A backend-exhausted classification is configured. */
|
||||
@@ -489,6 +561,19 @@ public record FleetConfig(
|
||||
return mcpUrl != null && !mcpUrl.isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the IDE Index MCP should be mounted into a spawned worker (CB-634), pinned to
|
||||
* the worker's own worktree via the charter. Opt-in per profile, default off.
|
||||
*/
|
||||
public boolean hasIdeMcp() {
|
||||
return ideMcpUrl != null && !ideMcpUrl.isBlank();
|
||||
}
|
||||
|
||||
/** True when this profile mounts any MCP server into its member — the bridge, the IDE, or both. */
|
||||
public boolean mountsAnyMcp() {
|
||||
return hasMcp() || hasIdeMcp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render this member's tab label (CB-557): {@code {role}}, {@code {profile}},
|
||||
* {@code {model}} and {@code {n}} are substituted.
|
||||
@@ -551,16 +636,52 @@ public record FleetConfig(
|
||||
*
|
||||
* @param uri AMQP connection URI, e.g. {@code amqp://guest:guest@127.0.0.1:5672/}. Blank/
|
||||
* {@code null} ⇒ the broker block is treated as absent (in-memory adapter).
|
||||
* Ignored when {@code uriEnv} is set.
|
||||
* @param uriEnv name of a host env var holding the AMQP URI (CB-151). The URI carries its
|
||||
* credentials inline, so giving the variable <em>name</em> keeps the password
|
||||
* out of the config file, same as {@code auth.tokenEnv}/{@code Profile.tokenEnv}.
|
||||
* Wins over {@code uri} whenever set. Blank/{@code null} ⇒ ignored.
|
||||
* @param prefetch CB-527: the consumer's {@code basicQos} prefetch count, bounding how many
|
||||
* unacked messages the AMQP inbox holds in-heap per owned target. {@code null}/
|
||||
* non-positive ⇒ {@link AmqpReplyInbox#DEFAULT_PREFETCH}.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Broker(String uri, Integer prefetch) {
|
||||
public record Broker(String uri, String uriEnv, Integer prefetch) {
|
||||
|
||||
/** True when a usable broker URI is configured (an empty block does not enable AMQP). */
|
||||
/** True when a {@code uriEnv} is configured by name, whether or not its variable resolves. */
|
||||
public boolean hasUriEnv() {
|
||||
return uriEnv != null && !uriEnv.isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a usable broker URI is configured (an empty block does not enable AMQP).
|
||||
* Honors {@code uriEnv} first: if it names a variable that is unset or blank, the broker is
|
||||
* <em>not</em> configured (the daemon falls back to the in-memory inbox) — a bare {@code uri}
|
||||
* is only consulted when no {@code uriEnv} is set. Env lookup makes this process-dependent;
|
||||
* callers already reading {@link System#getenv} are the right ones to invoke it.
|
||||
*/
|
||||
public boolean isConfigured() {
|
||||
return uri != null && !uri.isBlank();
|
||||
return effectiveUri() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective AMQP URI to connect with. {@code uriEnv} wins when set (both over {@code uri}
|
||||
* and alone). When {@code uriEnv} names a variable that is unset or blank, returns {@code
|
||||
* null} rather than falling back to {@code uri} — an operator who moved to the secret store
|
||||
* must not silently drop back onto a stale clear-text URI. Returns the literal {@code uri}
|
||||
* when no {@code uriEnv} is configured.
|
||||
*/
|
||||
public String effectiveUri() {
|
||||
return effectiveUri(System.getenv());
|
||||
}
|
||||
|
||||
/** As {@link #effectiveUri()}, reading the variable value from {@code env} (the injection seam). */
|
||||
public String effectiveUri(Map<String, String> env) {
|
||||
if (hasUriEnv()) {
|
||||
String value = env.get(uriEnv);
|
||||
return (value != null && !value.isBlank()) ? value : null;
|
||||
}
|
||||
return (uri != null && !uri.isBlank()) ? uri : null;
|
||||
}
|
||||
|
||||
/** The prefetch to use, defaulting to {@link AmqpReplyInbox#DEFAULT_PREFETCH} when unset. */
|
||||
@@ -957,27 +1078,86 @@ public record FleetConfig(
|
||||
* UNLESS it is also in {@link #allow}. A name that shows up in neither list is not silently
|
||||
* allowed — see {@code HerdrPeerLauncher}'s gap detector, which logs it.
|
||||
*
|
||||
* @param policy how the block is computed. Only {@link #POLICY_DENY_BY_DEFAULT} is understood
|
||||
* today; {@code null}/blank defaults to it. An operator's own deny-list is
|
||||
* deliberately not supported — see above.
|
||||
* <p><b>CB-633: allow-list.</b> Deny-by-default's overlay is applied BEFORE the pane's login
|
||||
* shell runs, so any file that chain sources can re-export over it — and did. The allow-list
|
||||
* policy moves the control to a generated ZDOTDIR whose startup files run the scrub LAST, after
|
||||
* the whole operator chain, and blank every exported variable not on an allow-list DERIVED from
|
||||
* what the launcher itself injects (profiles' tokenEnv/gitTokenEnv/gitHostEnv/env keys plus an
|
||||
* infrastructure set) — never hand-typed, so adding a profile cannot break a spawn. Under this
|
||||
* policy {@link #allow} and {@link #known} stop being a control and become reporting only.
|
||||
*
|
||||
* @param policy how the block is computed. {@link #POLICY_DENY_BY_DEFAULT} (the default; also
|
||||
* accepted as {@link #POLICY_DENY_LIST}) shadows each {@code known}-but-not-allowed
|
||||
* name in the pane-creation env overlay — which a login shell that re-exports the
|
||||
* name defeats (see CB-596's round-2 correction). {@link #POLICY_ALLOW_LIST}
|
||||
* (CB-633) replaces the overlay with a per-spawn ZDOTDIR scrub that runs AFTER the
|
||||
* pane's login shell has finished sourcing everything, blanking every variable not
|
||||
* on the DERIVED allow-list (derived from what the launcher itself injects — never
|
||||
* hand-typed). Under {@code allow-list}, {@link #allow} and {@link #known} are
|
||||
* REPORTING ONLY: they feed the gap WARN, they are no longer a control.
|
||||
* @param allow credential names a member legitimately needs (e.g. the gateway token it reaches
|
||||
* the LLM through, the repo-scoped forge token it opens its own PR with). Every
|
||||
* name here is left unmentioned in the pane's env overlay, so the value the pane's
|
||||
* own (login) shell exports passes through untouched.
|
||||
* @param known every credential name the operator's store is known to export. Every name here
|
||||
* that is NOT also in {@link #allow} is overlaid with a non-secret sentinel value,
|
||||
* shadowing whatever the pane's login shell would otherwise export for it.
|
||||
* the LLM through, the repo-scoped forge token it opens its own PR with). Under
|
||||
* deny-list/deny-by-default every name here is left unmentioned in the pane's env
|
||||
* overlay; under allow-list this list is reporting only — the control is derived,
|
||||
* not configured.
|
||||
* @param known every credential name the operator's store is known to export. Under
|
||||
* deny-list/deny-by-default every name here that is NOT also in {@link #allow} is
|
||||
* overlaid with a non-secret sentinel value. Under allow-list this list is
|
||||
* reporting only.
|
||||
* @param sshAuthSock whether the member may inherit {@code SSH_AUTH_SOCK} under the allow-list
|
||||
* policy ({@code "allow"}) or must have it blanked ({@code "block"}, the default).
|
||||
* This is a DECISION, never a default: {@code SSH_AUTH_SOCK} is a handle to the
|
||||
* operator's ssh-agent, and a member holding it can sign with the operator's own
|
||||
* keys — but it appears in no secret file and is credential-shaped like nothing on
|
||||
* any list, which is why three earlier tickets missed it (gitea #110 / CB-607).
|
||||
* Blocking it breaks git over SSH inside the member; allow it only when members do
|
||||
* not need to authenticate as the operator over SSH. Ignored under deny-list /
|
||||
* deny-by-default, which never touch the name.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record MemberCredentials(String policy, List<String> allow, List<String> known) {
|
||||
public record MemberCredentials(String policy, List<String> allow, List<String> known,
|
||||
String sshAuthSock) {
|
||||
|
||||
/** The only policy this build understands: block every {@code known} name not in {@code allow}. */
|
||||
/** Default policy: block every {@code known} name not in {@code allow}, via the env overlay. */
|
||||
public static final String POLICY_DENY_BY_DEFAULT = "deny-by-default";
|
||||
|
||||
/** Alias of {@link #POLICY_DENY_BY_DEFAULT}, spelled the way CB-633 names the two policies. */
|
||||
public static final String POLICY_DENY_LIST = "deny-list";
|
||||
|
||||
/**
|
||||
* CB-633: derive the kept-name set from what the launcher itself injects, generate a
|
||||
* per-spawn ZDOTDIR whose startup files blank every exported variable not on it AFTER the
|
||||
* pane's shell has finished sourcing the operator's chain. The scrub is sourced from both
|
||||
* the generated {@code .zshrc} and {@code .zlogin}, because herdr opens a LOGIN zsh on macOS
|
||||
* and a plain interactive one on Linux — see {@code EnvAllowListScrub}.
|
||||
*/
|
||||
public static final String POLICY_ALLOW_LIST = "allow-list";
|
||||
|
||||
/** The pre-CB-633 three-field form — {@code sshAuthSock} defaults to blocked. */
|
||||
public MemberCredentials(String policy, List<String> allow, List<String> known) {
|
||||
this(policy, allow, known, null);
|
||||
}
|
||||
|
||||
public MemberCredentials {
|
||||
policy = (policy == null || policy.isBlank()) ? POLICY_DENY_BY_DEFAULT : policy.toLowerCase();
|
||||
String normalizedPolicy = (policy == null || policy.isBlank())
|
||||
? POLICY_DENY_BY_DEFAULT : policy.toLowerCase(java.util.Locale.ROOT);
|
||||
// deny-list is an alias of deny-by-default, not a third behaviour — normalize to one
|
||||
// spelling so every isDenyList()-style check has one value to compare against.
|
||||
policy = POLICY_DENY_LIST.equals(normalizedPolicy) ? POLICY_DENY_BY_DEFAULT : normalizedPolicy;
|
||||
allow = allow == null ? List.of() : List.copyOf(allow);
|
||||
known = known == null ? List.of() : List.copyOf(known);
|
||||
sshAuthSock = (sshAuthSock != null && "allow".equalsIgnoreCase(sshAuthSock.trim()))
|
||||
? "allow" : "block";
|
||||
}
|
||||
|
||||
/** True when this block selects the CB-633 derived-allow-list policy. */
|
||||
public boolean isAllowList() {
|
||||
return POLICY_ALLOW_LIST.equals(policy);
|
||||
}
|
||||
|
||||
/** True when {@code SSH_AUTH_SOCK} may pass through under the allow-list policy. Default: no. */
|
||||
public boolean sshAuthSockAllowed() {
|
||||
return "allow".equals(sshAuthSock);
|
||||
}
|
||||
|
||||
/** {@link #allow} as a set, for membership checks. */
|
||||
@@ -1055,6 +1235,7 @@ public record FleetConfig(
|
||||
warnUnknownTopLevelKeys(yaml, path);
|
||||
rejectDuplicateMemberSlots(yaml);
|
||||
rejectNegativeMaxLoad(yaml);
|
||||
rejectAutoCompactWindowOutOfRange(yaml);
|
||||
rejectUnknownKind(yaml);
|
||||
rejectUnknownAuthMode(yaml);
|
||||
rejectUnknownPlacement(yaml);
|
||||
@@ -1357,6 +1538,52 @@ public record FleetConfig(
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowest {@code autoCompactWindow} Claude Code's {@code --autocompact <tokens>} flag accepts. */
|
||||
static final int AUTO_COMPACT_WINDOW_MIN = 100_000;
|
||||
/** Highest {@code autoCompactWindow} Claude Code's {@code --autocompact <tokens>} flag accepts. */
|
||||
static final int AUTO_COMPACT_WINDOW_MAX = 1_000_000;
|
||||
|
||||
/**
|
||||
* Reject a profile whose {@code autoCompactWindow:} is set but outside the token band Claude
|
||||
* Code's own {@code --autocompact <tokens>} flag accepts (100k–1M), naming both the profile and
|
||||
* the value.
|
||||
*
|
||||
* <p>Unset/{@code null} means "off" and passes silently — today's behaviour for every profile
|
||||
* that does not opt in (see {@link Profile#autoCompactWindow()}). A profile that DOES set the key
|
||||
* is validated eagerly, at config load, rather than failing later when Claude Code itself refuses
|
||||
* the launch flag on spawn — the same "fail loud at load, not lazily at first spawn" reasoning as
|
||||
* {@link #rejectNegativeMaxLoad} and {@link #rejectUnknownPlacementPolicy}.
|
||||
*
|
||||
* @param yaml the raw config text
|
||||
* @throws IllegalStateException when any profile's {@code autoCompactWindow} is set and outside
|
||||
* {@code [100000, 1000000]}
|
||||
*/
|
||||
static void rejectAutoCompactWindowOutOfRange(String yaml) {
|
||||
Map<?, ?> raw;
|
||||
try {
|
||||
raw = YAML.readValue(yaml, Map.class);
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
return; // a malformed file is reported by the real parse, not here
|
||||
}
|
||||
if (raw == null || !(raw.get("profiles") instanceof Map<?, ?> profiles)) {
|
||||
return;
|
||||
}
|
||||
List<String> bad = profiles.entrySet().stream()
|
||||
.filter(e -> e.getValue() instanceof Map<?, ?> p
|
||||
&& p.get("autoCompactWindow") instanceof Number n
|
||||
&& (n.doubleValue() < AUTO_COMPACT_WINDOW_MIN || n.doubleValue() > AUTO_COMPACT_WINDOW_MAX))
|
||||
.map(e -> String.valueOf(e.getKey()))
|
||||
.sorted()
|
||||
.toList();
|
||||
if (!bad.isEmpty()) {
|
||||
throw new IllegalStateException("refusing to start: profile(s) [" + String.join(", ", bad)
|
||||
+ "] set autoCompactWindow outside [" + AUTO_COMPACT_WINDOW_MIN + ", "
|
||||
+ AUTO_COMPACT_WINDOW_MAX + "] — Claude Code's --autocompact flag accepts only "
|
||||
+ "that band of tokens; omit the key to leave auto-compaction at each backend's "
|
||||
+ "own default.");
|
||||
}
|
||||
}
|
||||
|
||||
/** The peer kinds this build has an adapter for — {@link Profile#kind()}'s only valid values. */
|
||||
private static final Set<String> KNOWN_KINDS = Set.of(Profile.KIND_CLAUDE_CODE, Profile.KIND_OPENCODE);
|
||||
|
||||
@@ -1487,9 +1714,15 @@ public record FleetConfig(
|
||||
}
|
||||
}
|
||||
|
||||
/** The member-credential policies this build understands — {@link MemberCredentials#policy()}'s only valid value. */
|
||||
/**
|
||||
* The member-credential policies this build understands — {@link MemberCredentials#policy()}'s
|
||||
* only valid values. Checked against the RAW yaml text (before {@link MemberCredentials}'s
|
||||
* compact constructor normalizes {@code deny-list} onto {@code deny-by-default}), so the alias
|
||||
* is listed explicitly.
|
||||
*/
|
||||
private static final Set<String> KNOWN_MEMBER_CREDENTIALS_POLICIES =
|
||||
Set.of(MemberCredentials.POLICY_DENY_BY_DEFAULT);
|
||||
Set.of(MemberCredentials.POLICY_DENY_BY_DEFAULT, MemberCredentials.POLICY_DENY_LIST,
|
||||
MemberCredentials.POLICY_ALLOW_LIST);
|
||||
|
||||
/**
|
||||
* Reject a {@code memberCredentials.policy} that is not {@link #KNOWN_MEMBER_CREDENTIALS_POLICIES}
|
||||
@@ -1602,6 +1835,9 @@ public record FleetConfig(
|
||||
// is deliberately not pre-populated with a Java-side name list (that would just reintroduce
|
||||
// the hardcoded-list defect this record replaces); the block must be configured in
|
||||
// bridged.yaml to protect anything. See fleetd.example.yaml's memberCredentials: comment.
|
||||
// CB-633: policy stays deny-by-default here — the allow-list scrub is opt-in, because it is
|
||||
// stricter than today's behaviour (it blanks every non-derived name, not just known ones)
|
||||
// and an upgrade must not change what a running deployment's members inherit.
|
||||
MemberCredentials mc = memberCredentials != null ? memberCredentials
|
||||
: new MemberCredentials(null, List.of(), List.of());
|
||||
return new FleetConfig(b, herdrSocket, profiles, g, worktreeRoot, l, timeout, pollMs,
|
||||
|
||||
@@ -17,6 +17,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import dev.ltms.fleet.peer.PeerLauncher;
|
||||
|
||||
/**
|
||||
* Starts the leads {@code fleet.leaders:} declares, when none is already running (CB-558).
|
||||
@@ -252,7 +253,8 @@ public final class LeadLauncher {
|
||||
List<String> argv = new ArrayList<>(profile.argv());
|
||||
if (profile.hasMcp()) {
|
||||
argv.add("--mcp-config");
|
||||
argv.add("{\"mcpServers\":{\"bridge\":{\"type\":\"http\",\"url\":\""
|
||||
argv.add("{\"mcpServers\":{\"" + PeerLauncher.MCP_MOUNT_NAME
|
||||
+ "\":{\"type\":\"http\",\"url\":\""
|
||||
+ profile.mcpUrl() + "\"}}}");
|
||||
}
|
||||
// Appended last, for the same reason the member launcher does it (CB-533): the argv is
|
||||
|
||||
@@ -6,6 +6,7 @@ import dev.ltms.fleet.herdr.Agent;
|
||||
import dev.ltms.fleet.herdr.AgentControl;
|
||||
import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||
import dev.ltms.fleet.peer.Capability;
|
||||
import dev.ltms.fleet.peer.PeerLauncher;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -211,6 +212,17 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
guard.assertWorker(baseUrl); // hard stop before we spawn anything
|
||||
}
|
||||
|
||||
// CB-634: the IDE guidance is delivered as an on-disk CLAUDE.local.md overlay, NOT through
|
||||
// the charter — the charter returns to role -> reply only. Best-effort: a failed overlay
|
||||
// must never fail the spawn, and `writeIdeOverlay` no-ops unless the cwd is a provisioned
|
||||
// worktree (see its .git-file safety gate). The overlay pins, and the auto-open opens, the
|
||||
// module dir (this repo's pom is in `bridged/`, not at the worktree root) — see ideProjectPath.
|
||||
if (cfg.hasIdeMcp()) {
|
||||
String projectPath = PeerLauncher.ideProjectPath(spec.cwd(), cfg.ideProjectDir());
|
||||
writeIdeOverlay(spec.cwd(), projectPath);
|
||||
PeerLauncher.openInIde(projectPath, cfg.ideOpenCommand(), log);
|
||||
}
|
||||
|
||||
Map<String, String> workerEnv = baseEnv(cfg);
|
||||
if (onSubscription) {
|
||||
// CB-542 belt-and-braces: on the subscription path no guard vets these two keys, and the
|
||||
@@ -236,7 +248,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
// has neither MCP nor a charter — session flags must be added into a list we own.
|
||||
List<String> argv = mutableArgv(argvWithFleet(cfg, spec));
|
||||
String agentSessionId = applySessionIdentity(argv, spec.sessionName(), spec.resumeSessionId());
|
||||
return new Launch(workerEnv, argvWithModel(argv, cfg), agentSessionId);
|
||||
return new Launch(workerEnv, argvWithAutoCompact(argvWithModel(argv, cfg), cfg), agentSessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,25 +303,31 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
*/
|
||||
private List<String> argvWithFleet(FleetConfig.Profile cfg, LaunchSpec spec) {
|
||||
String roleCharter = nonBlank(spec.roleCharter());
|
||||
// CB-634: the IDE guidance is delivered as an on-disk overlay (writeIdeOverlay), not through
|
||||
// the charter. The charter file is role -> reply only.
|
||||
String replyCharter = nonBlank(spec.replyCharter());
|
||||
Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".claude", "agents");
|
||||
if (!cfg.hasMcp() && roleCharter == null && replyCharter == null && agentFile == null) {
|
||||
if (!cfg.mountsAnyMcp() && roleCharter == null
|
||||
&& replyCharter == null && agentFile == null) {
|
||||
return cfg.argv();
|
||||
}
|
||||
List<String> argv = mutableArgv(cfg.argv());
|
||||
if (cfg.hasMcp()) {
|
||||
String mcpJson = "{\"mcpServers\":{\"bridge\":{\"type\":\"http\",\"url\":\""
|
||||
+ cfg.mcpUrl() + "\"}}}";
|
||||
if (cfg.mountsAnyMcp()) {
|
||||
argv.add("--mcp-config");
|
||||
argv.add(mcpJson);
|
||||
argv.add(mcpConfigJson(cfg));
|
||||
}
|
||||
if (roleCharter != null) {
|
||||
String combined = replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter;
|
||||
argv.add("--append-system-prompt-file");
|
||||
argv.add(writeCharterFile(combined).toString());
|
||||
} else if (replyCharter != null) {
|
||||
// Combine the charters in order role -> reply, dropping any that are absent. When two
|
||||
// or more survive they must ride one --append-system-prompt-file (CB-618 forbids the inline
|
||||
// flag and the file flag together). A lone reply charter keeps its proven inline delivery.
|
||||
List<String> charters = new java.util.ArrayList<>(2);
|
||||
if (roleCharter != null) charters.add(roleCharter);
|
||||
if (replyCharter != null) charters.add(replyCharter);
|
||||
if (charters.size() == 1 && replyCharter != null && roleCharter == null) {
|
||||
argv.add("--append-system-prompt");
|
||||
argv.add(replyCharter);
|
||||
} else if (!charters.isEmpty()) {
|
||||
argv.add("--append-system-prompt-file");
|
||||
argv.add(writeCharterFile(String.join("\n\n", charters)).toString());
|
||||
}
|
||||
if (agentFile != null) {
|
||||
argv.add("--agent");
|
||||
@@ -318,6 +336,83 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
return argv;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@code --mcp-config} JSON for this member: always the bridge mount when {@link
|
||||
* FleetConfig.Profile#hasMcp()}, plus the IDE Index MCP as a second server named {@code
|
||||
* intellij} when {@link FleetConfig.Profile#hasIdeMcp()} (CB-634). At least one is present —
|
||||
* the caller only reaches here when {@link FleetConfig.Profile#mountsAnyMcp()} is true.
|
||||
*/
|
||||
private static String mcpConfigJson(FleetConfig.Profile cfg) {
|
||||
StringBuilder servers = new StringBuilder();
|
||||
if (cfg.hasMcp()) {
|
||||
servers.append('"').append(PeerLauncher.MCP_MOUNT_NAME)
|
||||
.append("\":{\"type\":\"http\",\"url\":\"").append(cfg.mcpUrl()).append("\"}");
|
||||
}
|
||||
if (cfg.hasIdeMcp()) {
|
||||
if (servers.length() > 0) servers.append(',');
|
||||
servers.append("\"intellij\":{\"type\":\"http\",\"url\":\"")
|
||||
.append(cfg.ideMcpUrl()).append("\"}");
|
||||
}
|
||||
return "{\"mcpServers\":{" + servers + "}}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the shared IDE guidance ({@link PeerLauncher#ideOverlayText}) as an on-disk
|
||||
* {@code CLAUDE.local.md} overlay beside the project's own {@code CLAUDE.md} (CB-634), and
|
||||
* register the overlay in the repository's common {@code info/exclude} so it never shows as
|
||||
* untracked (git reads a worktree's excludes from the common dir, not the per-worktree gitdir).
|
||||
*
|
||||
* <p><strong>Safety gate:</strong> the overlay is written ONLY when {@code cwd/.git} is a
|
||||
* <em>regular file</em> — a provisioned worktree keeps a {@code .git} FILE holding a
|
||||
* {@code gitdir: <path>} pointer, while the primary's real checkout has a {@code .git}
|
||||
* DIRECTORY. Returning without writing when {@code .git} is a directory is the whole safety of
|
||||
* the feature: it must never write into a non-worktree cwd, i.e. never clobber a project that
|
||||
* does not want the overlay.
|
||||
*
|
||||
* <p>Best-effort: a failure is logged at debug and swallowed — a failed overlay must never fail
|
||||
* the spawn.
|
||||
*
|
||||
* @param cwd the member's worktree root, where the {@code CLAUDE.local.md} file is written
|
||||
* @param projectPath the module dir the overlay pins {@code project_path} to (see
|
||||
* {@link PeerLauncher#ideProjectPath}); equals {@code cwd} when no module subdir
|
||||
*/
|
||||
private static void writeIdeOverlay(String cwd, String projectPath) {
|
||||
try {
|
||||
Path dotGit = Path.of(cwd, ".git");
|
||||
if (!Files.isRegularFile(dotGit)) {
|
||||
// Not a provisioned worktree (primary's real checkout has a .git directory, or the
|
||||
// cwd is not a repo at all). Never write into it.
|
||||
return;
|
||||
}
|
||||
// The overlay FILE lives at the worktree root (claude-code's cwd), but its CONTENT pins
|
||||
// project_path to the module dir the IDE opened (projectPath), not the worktree root.
|
||||
Files.writeString(Path.of(cwd, "CLAUDE.local.md"), PeerLauncher.ideOverlayText(projectPath));
|
||||
String gitdirLine = Files.readString(dotGit).trim();
|
||||
Path gitDir = Path.of(gitdirLine.replaceFirst("^gitdir:\\s*", ""));
|
||||
if (!gitDir.isAbsolute()) {
|
||||
gitDir = Path.of(cwd).resolve(gitDir).normalize();
|
||||
}
|
||||
// git reads info/exclude from the COMMON dir, never the per-worktree gitdir (only
|
||||
// info/sparse-checkout is per-worktree). A provisioned worktree's gitdir is
|
||||
// <common>/worktrees/<name>, so the common dir is two levels up; writing the entry into
|
||||
// the per-worktree gitdir leaves it un-honoured and the overlay shows as untracked.
|
||||
Path commonDir = gitDir;
|
||||
if (gitDir.getParent() != null && gitDir.getParent().getFileName() != null
|
||||
&& "worktrees".equals(gitDir.getParent().getFileName().toString())) {
|
||||
commonDir = gitDir.getParent().getParent();
|
||||
}
|
||||
Path exclude = commonDir.resolve("info").resolve("exclude");
|
||||
Files.createDirectories(exclude.getParent());
|
||||
String overlayLine = "CLAUDE.local.md";
|
||||
if (!Files.exists(exclude) || Files.readAllLines(exclude).stream().noneMatch(overlayLine::equals)) {
|
||||
Files.writeString(exclude, (Files.exists(exclude) ? System.lineSeparator() : "")
|
||||
+ overlayLine + System.lineSeparator());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("cannot write IDE overlay into worktree '{}'", cwd, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@code s}, or {@code null} when {@code s} is null/blank — the charter-presence test used above. */
|
||||
private static String nonBlank(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s;
|
||||
@@ -368,6 +463,30 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
|
||||
return withModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a bounded auto-compaction window on the command line via {@code --autocompact <tokens>},
|
||||
* opt-in per profile (CB-634's sibling ticket: a member that runs out of context dies mid-turn
|
||||
* and its {@code fleet_reply} — the whole point of the turn — is lost with it; opencode already
|
||||
* forces {@code compaction.auto: true} unconditionally, CB-523, but Claude Code has no equivalent
|
||||
* and runs at the backend's own default window).
|
||||
*
|
||||
* <p>Mirrors {@link #argvWithModel}: appended after it, so it survives the {@code ccs <profile>}
|
||||
* wrapper the same way {@code --model} does, and outranks env/settings and the operator's own
|
||||
* {@code argv}. Verified: {@code claude 2.1.241 --help} lists {@code --autocompact <auto|tokens>}
|
||||
* (either the literal {@code auto}, or an integer 100k–1M) — {@link FleetConfig#load} rejects a
|
||||
* configured value outside that band before this ever runs, so the flag Claude Code receives here
|
||||
* is always in range.
|
||||
*/
|
||||
private static List<String> argvWithAutoCompact(List<String> argv, FleetConfig.Profile cfg) {
|
||||
if (cfg.autoCompactWindow() == null) {
|
||||
return argv;
|
||||
}
|
||||
List<String> withAutoCompact = mutableArgv(argv);
|
||||
withAutoCompact.add("--autocompact");
|
||||
withAutoCompact.add(String.valueOf(cfg.autoCompactWindow()));
|
||||
return withAutoCompact;
|
||||
}
|
||||
|
||||
// --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) ---
|
||||
|
||||
/** Spawn a worker for the default profile in the resolved default cwd. */
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package dev.ltms.fleet.member;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* CB-633: generates the per-spawn {@code ZDOTDIR} directory whose startup files enforce
|
||||
* {@code memberCredentials.policy: allow-list}.
|
||||
*
|
||||
* <p>The seam: zsh reads its startup files from {@code $ZDOTDIR}, and the daemon puts that variable
|
||||
* in the pane-creation env map. The operator's whole chain ({@code ~/.zshrc} → secret store) runs
|
||||
* inside those files, so a scrub appended to the LAST one runs after everything the operator
|
||||
* sourced, and nothing later can re-export over it. This is the property CB-596's env-overlay
|
||||
* control lacked: herdr applies that overlay BEFORE the shell starts, so any sourced file can undo
|
||||
* it — and did.
|
||||
*
|
||||
* <p><b>Which file is last depends on the platform, so the scrub runs from two of them.</b> zsh
|
||||
* reads {@code .zshenv} always, {@code .zprofile} and {@code .zlogin} only for a LOGIN shell, and
|
||||
* {@code .zshrc} only for an INTERACTIVE one. herdr does not open the same kind of shell
|
||||
* everywhere — measured on herdr 0.8.0: macOS panes run {@code -zsh} (login, so {@code .zlogin}
|
||||
* runs), Linux panes run a plain {@code /usr/bin/zsh} (interactive but NOT login, so
|
||||
* {@code .zlogin} never runs at all). A scrub in {@code .zlogin} alone is therefore a control that
|
||||
* silently does nothing on Linux — the exact failure this class exists to remove, one platform
|
||||
* over.
|
||||
*
|
||||
* <p>So both {@code .zshrc} and {@code .zlogin} source the same generated {@code scrub.zsh} after
|
||||
* sourcing their {@code $HOME} counterpart. On Linux only the first fires; on macOS both do, and
|
||||
* the second pass is deliberate rather than merely harmless — it re-scrubs anything the operator's
|
||||
* own {@code ~/.zlogin} exported after {@code .zshrc} had finished. Re-running is idempotent: a
|
||||
* name already blank is blanked again, and the report is rewritten with the same counts.
|
||||
*
|
||||
* <p>Each generated file sources its {@code $HOME} counterpart FIRST, so {@code PATH} and every
|
||||
* toolchain binary still resolve exactly as the operator configured them; only afterwards does
|
||||
* {@code .zlogin} run the scrub: every EXPORTED variable not on the derived allow-list is re-exported
|
||||
* blank. Blank, not credential-shaped-pattern-filtered: a pattern list ({@code *TOKEN*}, …) is an
|
||||
* enumeration and misses what it did not think of — a username is the other half of a credential and
|
||||
* is shaped like none. Credential-SHAPED names among the blanked set go to the WARN log only,
|
||||
* never to the control.
|
||||
*
|
||||
* <p>The scrub also writes {@code scrub-report.txt} into its own directory: one {@code allowed N of
|
||||
* M} line (N = exports left untouched, M = exports present when the scrub ran), then the blanked
|
||||
* NAMES — never values. The launcher reads this back at teardown and logs it, because a blocked
|
||||
* count next to an unknown denominator is not a finding.
|
||||
*/
|
||||
public final class EnvAllowListScrub {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EnvAllowListScrub.class);
|
||||
|
||||
/** Name of the report file written into the generated directory by the scrub itself. */
|
||||
static final String REPORT_FILE = "scrub-report.txt";
|
||||
|
||||
/** The scrub body, generated once and sourced from both {@code .zshrc} and {@code .zlogin}. */
|
||||
static final String SCRUB_FILE = "scrub.zsh";
|
||||
|
||||
/** Prefix of every generated directory — also what {@link #reapOrphans} matches on. */
|
||||
static final String DIR_PREFIX = "bridged-zdotdir-";
|
||||
|
||||
/**
|
||||
* How old an orphan must be before {@link #reapOrphans} removes it. Comfortably longer than any
|
||||
* spawn takes, so a directory belonging to a pane that is still starting is never removed.
|
||||
*/
|
||||
private static final Duration ORPHAN_AGE = Duration.ofHours(24);
|
||||
|
||||
/** Appended to the two startup files that must run the scrub, after their {@code $HOME} source. */
|
||||
private static final String SOURCE_SCRUB =
|
||||
"source \"$ZDOTDIR/" + SCRUB_FILE + "\"\n";
|
||||
|
||||
private EnvAllowListScrub() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed {@code scrub-report.txt}: how many exported variables existed when the scrub ran,
|
||||
* how many were left untouched (allowed), and the NAMES that were blanked. Values never appear.
|
||||
*/
|
||||
record ScrubReport(int allowed, int total, List<String> blanked) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh ZDOTDIR directory under {@code parentDir} holding the four zsh startup files.
|
||||
* Every file (and the directory) registers {@code deleteOnExit}, next to the existing per-spawn
|
||||
* charter/config temp cleanup; the launcher additionally deletes eagerly at pane release.
|
||||
*
|
||||
* @param allowedNames the DERIVED allow-list — exact variable names that must survive the scrub
|
||||
* @return the directory path (to be passed as the pane's {@code ZDOTDIR})
|
||||
* @throws UncheckedIOException when the directory or any file cannot be written — a spawn whose
|
||||
* protection cannot even be materialized must fail loudly rather
|
||||
* than start unprotected
|
||||
*/
|
||||
public static Path generate(Path parentDir, Set<String> allowedNames) {
|
||||
try {
|
||||
reapOrphans(parentDir);
|
||||
Path dir = Files.createTempDirectory(parentDir, DIR_PREFIX);
|
||||
dir.toFile().deleteOnExit();
|
||||
// The report is written by zsh, after these hooks are registered, so register its path
|
||||
// too — otherwise the directory is non-empty at JVM exit and cannot be removed at all.
|
||||
dir.resolve(REPORT_FILE).toFile().deleteOnExit();
|
||||
write(dir, SCRUB_FILE, scrubScript(allowedNames));
|
||||
write(dir, ".zshenv", homeSourcingFile(".zshenv"));
|
||||
write(dir, ".zprofile", homeSourcingFile(".zprofile"));
|
||||
write(dir, ".zshrc", homeSourcingFile(".zshrc") + SOURCE_SCRUB);
|
||||
write(dir, ".zlogin", homeSourcingFile(".zlogin") + SOURCE_SCRUB);
|
||||
return dir;
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("cannot generate ZDOTDIR scrub files under " + parentDir, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** One operator-sourcing startup file: source the {@code $HOME} counterpart, change nothing else. */
|
||||
private static String homeSourcingFile(String name) {
|
||||
return """
|
||||
# generated by fleetd (CB-633 memberCredentials policy=allow-list) — do not edit.
|
||||
# Source the operator's own %s first, so PATH and the agent binaries resolve as usual.
|
||||
[ -r "$HOME/%s" ] && source "$HOME/%s"
|
||||
""".formatted(name, name, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The generated {@code scrub.zsh} — the scrub body on its own, so the two startup files that
|
||||
* must run it ({@code .zshrc} and {@code .zlogin}) hold one copy between them rather than two
|
||||
* that can drift. Package-private so tests can assert on the exact script handed to zsh — the
|
||||
* artefact here IS a shell file, and a test that checks only the Java string assembly proves
|
||||
* nothing about whether zsh accepts it.
|
||||
*/
|
||||
static String scrubScript(Set<String> allowedNames) {
|
||||
StringBuilder names = new StringBuilder();
|
||||
for (String n : allowedNames.stream().sorted().toList()) {
|
||||
if (names.length() > 0) {
|
||||
names.append(' ');
|
||||
}
|
||||
// Names are validated against [A-Za-z_][A-Za-z0-9_]* before they get here; single quotes
|
||||
// keep even a non-conforming name inert rather than executable.
|
||||
names.append('\'').append(n.replace("'", "")).append('\'');
|
||||
}
|
||||
return """
|
||||
# generated by fleetd (CB-633 memberCredentials policy=allow-list) — do not edit.
|
||||
# Sourced from .zshrc and again from .zlogin, each time AFTER that file has sourced
|
||||
# its $HOME counterpart — so this runs after everything the operator sourced, on a
|
||||
# login shell (macOS panes) and on a plain interactive one (Linux panes) alike.
|
||||
# Running twice is idempotent and deliberate: the second pass catches anything
|
||||
# ~/.zlogin exported after ~/.zshrc had finished.
|
||||
|
||||
typeset -A _cb633_allowed
|
||||
for _cb633_n in %s; do _cb633_allowed[$_cb633_n]=1; done
|
||||
|
||||
# Enumerate EXPORTED variable NAMES from `env` itself. Deliberately NOT the special
|
||||
# `parameters` assoc: its subscript is evaluated arithmetically on this host's zsh
|
||||
# and blows up on some names ("bad math expression"). Names not matching the
|
||||
# identifier pattern (junk from multi-line values) are skipped, never scrubbed.
|
||||
typeset -a _cb633_names
|
||||
_cb633_names=("${(@f)$(command env | command cut -d= -f1)}")
|
||||
typeset -a _cb633_blank
|
||||
_cb633_blank=()
|
||||
integer _cb633_total=0
|
||||
for _cb633_n in "${_cb633_names[@]}"; do
|
||||
[[ "$_cb633_n" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
||||
(( _cb633_total += 1 ))
|
||||
[[ -n "${_cb633_allowed[$_cb633_n]-}" ]] && continue
|
||||
case "$_cb633_n" in %s) continue ;; esac
|
||||
_cb633_blank+=("$_cb633_n")
|
||||
done
|
||||
|
||||
{ for _cb633_n in "${_cb633_blank[@]}"; do export "$_cb633_n="; done; } 2>/dev/null
|
||||
|
||||
integer _cb633_kept=$(( _cb633_total - ${#_cb633_blank} ))
|
||||
{
|
||||
print -r -- "allowed $_cb633_kept of $_cb633_total"
|
||||
for _cb633_n in "${_cb633_blank[@]}"; do print -r -- "$_cb633_n"; done
|
||||
} > "$ZDOTDIR/%s" 2>/dev/null
|
||||
|
||||
unset _cb633_allowed _cb633_names _cb633_blank _cb633_n _cb633_total _cb633_kept
|
||||
""".formatted(names, MemberEnvAllowList.zshCasePattern(), REPORT_FILE);
|
||||
}
|
||||
|
||||
private static void write(Path dir, String fileName, String content) throws IOException {
|
||||
Path file = dir.resolve(fileName);
|
||||
Files.writeString(file, content);
|
||||
file.toFile().deleteOnExit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse {@link #REPORT_FILE} out of a generated ZDOTDIR directory. Returns {@code null}
|
||||
* when absent or unreadable (the pane may have been torn down before its login shell ever got to
|
||||
* the scrub) — callers treat that as "no measurement available", never as success.
|
||||
*/
|
||||
static ScrubReport readReport(Path zdotdir) {
|
||||
Path report = zdotdir.resolve(REPORT_FILE);
|
||||
if (!Files.isRegularFile(report)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
List<String> lines = Files.readAllLines(report);
|
||||
if (lines.isEmpty() || !lines.getFirst().startsWith("allowed ")) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = lines.getFirst().substring("allowed ".length()).trim().split("\\s+");
|
||||
if (parts.length != 3 || !"of".equals(parts[1])) {
|
||||
return null;
|
||||
}
|
||||
List<String> blanked = new ArrayList<>();
|
||||
for (int i = 1; i < lines.size(); i++) {
|
||||
if (!lines.get(i).isBlank()) {
|
||||
blanked.add(lines.get(i));
|
||||
}
|
||||
}
|
||||
return new ScrubReport(Integer.parseInt(parts[0]), Integer.parseInt(parts[2]),
|
||||
List.copyOf(blanked));
|
||||
} catch (IOException | NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort recursive delete; failures are swallowed — JVM-exit cleanup is the backstop. */
|
||||
/**
|
||||
* Remove generated directories left behind by an earlier daemon process.
|
||||
*
|
||||
* <p>{@link #generate} registers each directory for deletion at JVM exit, which covers a clean
|
||||
* shutdown and covers nothing else. A {@code kill -9}, a crash, or a host reboot leaves the
|
||||
* directory in the temp dir for good, and the daemon is restarted often enough that these
|
||||
* accumulate. They hold no secrets — the generated files contain variable NAMES and a report of
|
||||
* names, never a value — but an unbounded pile of them in {@code /tmp} is still our mess to
|
||||
* clear.
|
||||
*
|
||||
* <p>Called from {@link #generate}, so it runs on the path that creates them and needs no
|
||||
* separate wiring or scheduler. Only directories older than {@link #ORPHAN_AGE} are touched,
|
||||
* which keeps it clear of any pane that is merely still starting, including one belonging to a
|
||||
* different daemon instance running right now. Best-effort: every failure is ignored, because
|
||||
* tidying temp files must never be the reason a spawn fails.
|
||||
*/
|
||||
static void reapOrphans(Path parentDir) {
|
||||
Instant cutoff = Instant.now().minus(ORPHAN_AGE);
|
||||
try (Stream<Path> entries = Files.list(parentDir)) {
|
||||
entries.filter(d -> d.getFileName().toString().startsWith(DIR_PREFIX))
|
||||
.filter(Files::isDirectory)
|
||||
.filter(d -> olderThan(d, cutoff))
|
||||
.forEach(EnvAllowListScrub::deleteRecursively);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.debug("could not scan {} for orphaned ZDOTDIRs: {}", parentDir, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean olderThan(Path dir, Instant cutoff) {
|
||||
try {
|
||||
return Files.getLastModifiedTime(dir).toInstant().isBefore(cutoff);
|
||||
} catch (IOException e) {
|
||||
return false; // unreadable timestamp ⇒ leave it alone
|
||||
}
|
||||
}
|
||||
|
||||
static void deleteRecursively(Path dir) {
|
||||
if (dir == null || !Files.exists(dir)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> walk = Files.walk(dir)) {
|
||||
walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (IOException ignored) {
|
||||
// best effort — deleteOnExit retries at JVM shutdown
|
||||
}
|
||||
});
|
||||
} catch (IOException ignored) {
|
||||
// same
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,20 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
private final ConcurrentMap<String, String> paneByAgentId = new ConcurrentHashMap<>();
|
||||
private final AtomicBoolean resetUnsupportedLogged = new AtomicBoolean();
|
||||
|
||||
/**
|
||||
* CB-633: the per-spawn ZDOTDIR directory generated for a pane under
|
||||
* {@code memberCredentials.policy: allow-list}, keyed by herdr pane id so every teardown exit
|
||||
* ({@link #stop} is reached from explicit DELETE, orphan reap, and the spawn-readiness gate
|
||||
* timeout alike) can read the scrub's own report and then remove the directory. A pane that
|
||||
* never reaches {@code stop} (spawn failure) leaks its directory only until JVM exit, where the
|
||||
* generator's {@code deleteOnExit} hooks are the backstop — the same cleanup shape the existing
|
||||
* charter/config temp files use.
|
||||
*/
|
||||
private final ConcurrentMap<String, Path> zdotdirByPane = new ConcurrentHashMap<>();
|
||||
|
||||
/** Guards {@link #warnNonZsh} to one WARN per launcher instance, not one per spawn. */
|
||||
private final AtomicBoolean nonZshShellWarned = new AtomicBoolean();
|
||||
|
||||
/**
|
||||
* @param namePrefix label prefix for this peer kind (drives naming and reap)
|
||||
* @param agents herdr agent control (start, status, close)
|
||||
@@ -420,9 +434,27 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
try {
|
||||
Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter,
|
||||
roleCharter, replyCharter, cwd));
|
||||
Agent agent = cfg.tabPlacement()
|
||||
? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet)
|
||||
: spawnAsPane(cfg, launch.env(), launch.argv(), cwd, charter);
|
||||
// CB-633: applied AFTER buildLaunch so the generated scrub's allow-list can also cover
|
||||
// the exact env-map keys this launch injects (ANTHROPIC_*, OPENCODE_CONFIG, GITEA_TOKEN, …)
|
||||
// — anything the daemon deliberately sets must survive its own control.
|
||||
Path zdotdir = applyEnvironmentAllowListPolicy(cfg, launch);
|
||||
Agent agent;
|
||||
try {
|
||||
agent = cfg.tabPlacement()
|
||||
? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet)
|
||||
: spawnAsPane(cfg, launch.env(), launch.argv(), cwd, charter);
|
||||
} catch (RuntimeException e) {
|
||||
// A failed spawn has no pane id, so nothing would ever key this directory for
|
||||
// teardown and it would sit in the temp dir until the JVM exits cleanly — which,
|
||||
// for a daemon, may be never. Remove it on the way out.
|
||||
if (zdotdir != null) {
|
||||
EnvAllowListScrub.deleteRecursively(zdotdir);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (zdotdir != null) {
|
||||
zdotdirByPane.put(agent.paneId(), zdotdir);
|
||||
}
|
||||
logCharterReceipt(receipt, true);
|
||||
return new Spawned(agent, launch.agentSessionId(), receipt);
|
||||
} catch (RuntimeException e) {
|
||||
@@ -774,6 +806,14 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
log.debug("not closing tab {} — it holds {} panes (not a dedicated peer tab)",
|
||||
loc.tabId(), loc.tabPaneCount());
|
||||
}
|
||||
// CB-633: log this pane's allowed-N-of-M scrub report, then remove the generated ZDOTDIR.
|
||||
// Last in, best-effort — a failure here must not mask a real teardown failure above.
|
||||
try {
|
||||
releaseZdotdir(paneId);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("memberCredentials allow-list: releasing ZDOTDIR for pane {} failed: {}",
|
||||
paneId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether any configured profile places peers in their own tab (so tabs may need cleanup). */
|
||||
@@ -957,16 +997,134 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
* one whose {@code known} list is empty — shadows nothing. This is a real, config-driven gap
|
||||
* (see {@link FleetConfig.MemberCredentials}'s javadoc), not a safe default: deny-by-default
|
||||
* only defends names the operator has actually enumerated in {@code known}.
|
||||
*
|
||||
* <p>CB-633: under {@code policy: allow-list} this overlay is NOT the control anymore — it is
|
||||
* applied before the login shell runs and a sourced file can (and did) undo it. The control is
|
||||
* the ZDOTDIR scrub ({@link #applyEnvironmentAllowListPolicy}); {@code known}/{@code allow}
|
||||
* remain as reporting only via {@link #logCredentialGap}.
|
||||
*/
|
||||
private void applyMemberCredentialPolicy(Map<String, String> workerEnv) {
|
||||
FleetConfig.MemberCredentials creds = memberCredentials == null ? null : memberCredentials.get();
|
||||
if (creds == null) {
|
||||
return;
|
||||
}
|
||||
if (!creds.isAllowList()) {
|
||||
overlayBlockedCredentials(workerEnv, creds);
|
||||
}
|
||||
logCredentialGap(creds);
|
||||
}
|
||||
|
||||
/** Put {@link #BLOCKED_CREDENTIAL_SENTINEL} over every blocked name in the pane-creation env map. */
|
||||
private static void overlayBlockedCredentials(Map<String, String> workerEnv,
|
||||
FleetConfig.MemberCredentials creds) {
|
||||
for (String name : creds.blockedSet()) {
|
||||
workerEnv.put(name, BLOCKED_CREDENTIAL_SENTINEL);
|
||||
}
|
||||
logCredentialGap(creds);
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-633: under {@code memberCredentials.policy: allow-list}, generate the per-spawn ZDOTDIR
|
||||
* directory whose startup files blank every exported variable not on the DERIVED allow-list —
|
||||
* running AFTER the pane's shell has finished sourcing the operator's chain, which is what no
|
||||
* pre-shell env overlay can achieve. {@code EnvAllowListScrub} sources the scrub from both the
|
||||
* generated {@code .zshrc} and {@code .zlogin}, since a herdr pane is a login shell on macOS
|
||||
* and a plain interactive one on Linux. Mutates {@code launch.env()} to carry
|
||||
* {@code ZDOTDIR=<dir>}, so both placement paths ({@link #spawnInTab}, {@link #spawnAsPane})
|
||||
* pass it through {@code tab.create}/{@code pane.split}. Returns the directory for teardown
|
||||
* registration, or {@code null} when the policy does not apply.
|
||||
*
|
||||
* <p>The allow-list handed to the generator is the derived profile set ({@link
|
||||
* MemberEnvAllowList#derive}) UNIONed with the exact keys of THIS launch's env map — names the
|
||||
* daemon itself injects must survive its own control. {@code SSH_AUTH_SOCK} is added ONLY when
|
||||
* the config explicitly allows it; by default it is absent, so the scrub blanks it like any
|
||||
* other non-derived name.
|
||||
*/
|
||||
private Path applyEnvironmentAllowListPolicy(FleetConfig.Profile cfg, Launch launch) {
|
||||
FleetConfig.MemberCredentials creds = memberCredentials == null ? null : memberCredentials.get();
|
||||
if (creds == null || !creds.isAllowList()) {
|
||||
return null;
|
||||
}
|
||||
String loginShell = resolveEnv("SHELL");
|
||||
boolean zsh = loginShell != null && (loginShell.endsWith("/zsh") || loginShell.equals("zsh"));
|
||||
if (!zsh) {
|
||||
// A non-zsh login shell ignores ZDOTDIR entirely: NO scrub would run, so pretending
|
||||
// otherwise would be worse than saying so. Warn loudly and fall back to the CB-596
|
||||
// sentinel overlay over the enumerated known: names — weaker (a sourced file can undo
|
||||
// it), but strictly better than nothing.
|
||||
warnNonZsh(loginShell);
|
||||
overlayBlockedCredentials(launch.env(), creds);
|
||||
logCredentialGap(creds);
|
||||
return null;
|
||||
}
|
||||
Set<String> allowed = new java.util.TreeSet<>(MemberEnvAllowList.derive(profiles.values()));
|
||||
if (creds.sshAuthSockAllowed()) {
|
||||
allowed.add(SSH_AUTH_SOCK);
|
||||
} // blocked by default: absent from the set ⇒ blanked by the scrub like any other name
|
||||
allowed.addAll(launch.env().keySet());
|
||||
Path dir = EnvAllowListScrub.generate(Path.of(System.getProperty("java.io.tmpdir")), allowed);
|
||||
launch.env().put("ZDOTDIR", dir.toAbsolutePath().toString());
|
||||
log.info("memberCredentials policy=allow-list: profile={} generated ZDOTDIR {} — derived "
|
||||
+ "allow-list holds {} name(s); the pane reports allowed N of M at release",
|
||||
cfg.profile(), dir.getFileName(), allowed.size());
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** The operator ssh-agent handle — kept ONLY by explicit config decision, never by default. */
|
||||
private static final String SSH_AUTH_SOCK = "SSH_AUTH_SOCK";
|
||||
|
||||
/**
|
||||
* CB-633: a non-zsh login shell means the allow-list control CANNOT run — say so once per
|
||||
* launcher instance, naming the shell, instead of failing silently.
|
||||
*/
|
||||
private void warnNonZsh(String shell) {
|
||||
if (nonZshShellWarned.compareAndSet(false, true)) {
|
||||
log.warn("memberCredentials policy=allow-list: member login shell '{}' is NOT zsh — "
|
||||
+ "ZDOTDIR scrubbing cannot run, so members' inherited environment is "
|
||||
+ "UNPROTECTED beyond the enumerated known: fallback. Move herdr onto a "
|
||||
+ "zsh account or switch policy back to deny-by-default.",
|
||||
shell == null ? "<unset>" : shell);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-633 teardown half: read the pane's scrub report (the denominator report the generated
|
||||
* scrub wrote) and delete the directory. Called from {@link #stop}, which is the one funnel
|
||||
* every teardown exit already goes through.
|
||||
*
|
||||
* <p><b>A missing report is a WARN, not a debug line.</b> The report is the only evidence that
|
||||
* the scrub ran at all in that pane. Its absence has an innocent reading — the pane died before
|
||||
* its shell finished starting — and a serious one: the shell was not zsh, or it read its
|
||||
* startup files from somewhere other than the directory we generated, in which case the member
|
||||
* ran for its whole life with the operator's full secret store in its environment and nothing
|
||||
* said so. We cannot tell those two apart from here, so the line says what is and is not known
|
||||
* rather than picking one. Logging this at debug is how a control that silently stopped working
|
||||
* stays unnoticed — the failure mode this whole class exists to remove.
|
||||
*/
|
||||
private void releaseZdotdir(String paneId) {
|
||||
Path dir = zdotdirByPane.remove(paneId);
|
||||
if (dir == null) {
|
||||
return;
|
||||
}
|
||||
EnvAllowListScrub.ScrubReport report = EnvAllowListScrub.readReport(dir);
|
||||
if (report == null) {
|
||||
log.warn("memberCredentials allow-list: pane {} left no scrub report in {} — the "
|
||||
+ "environment scrub cannot be confirmed to have run. Either the pane ended "
|
||||
+ "before its shell finished starting, or its shell never read our generated "
|
||||
+ "startup files, in which case that member saw the full host environment.",
|
||||
paneId, dir);
|
||||
} else {
|
||||
log.info("memberCredentials allow-list: pane {} allowed {} of {} environment variables",
|
||||
paneId, report.allowed(), report.total());
|
||||
List<String> shaped = report.blanked().stream()
|
||||
.filter(name -> CREDENTIAL_SHAPED_NAME.matcher(name).matches())
|
||||
.toList();
|
||||
if (!shaped.isEmpty()) {
|
||||
log.warn("memberCredentials allow-list: pane {} blanked credential-shaped variable(s) "
|
||||
+ "{} — confirm none of them was something a member legitimately needed",
|
||||
paneId, shaped);
|
||||
}
|
||||
}
|
||||
EnvAllowListScrub.deleteRecursively(dir);
|
||||
}
|
||||
|
||||
/** Credential-shaped env var name heuristic for {@link #logCredentialGap} — case-insensitive. */
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package dev.ltms.fleet.member;
|
||||
|
||||
import dev.ltms.fleet.config.FleetConfig;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* CB-633: the set of environment variable NAMES a spawned member is allowed to keep under
|
||||
* {@code memberCredentials.policy: allow-list} — DERIVED from what the launcher itself injects,
|
||||
* never hand-typed.
|
||||
*
|
||||
* <p>A hand-typed allow-list is the defect this class exists to prevent: a name an operator forgets
|
||||
* to type is a credential that passes through to every member, and a profile added to config later
|
||||
* would silently break spawns whose scrub did not know its names. Derivation closes both ends. The
|
||||
* kept-name set is the union of:
|
||||
*
|
||||
* <ul>
|
||||
* <li>every configured {@link FleetConfig.Profile profile}'s {@code gitTokenEnv},
|
||||
* {@code gitHostEnv}, and {@code tokenEnv} values — these are variable <em>names</em> held in
|
||||
* config, and the launcher reads their values out of exactly these variables;</li>
|
||||
* <li>every key of every profile's {@code env:} map — anything the operator routes into a pane on
|
||||
* purpose;</li>
|
||||
* <li>{@link #INFRASTRUCTURE_PASSTHROUGH} — names that are not credentials at all but that a shell
|
||||
* or the agent binary genuinely needs to function.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Because the union spans EVERY profile (not just the one spawning), adding a new profile can
|
||||
* only ever widen the list — it cannot break another spawn's scrub. And because the launcher also
|
||||
* unions in the exact keys of each spawn's own env map at generation time (see {@code
|
||||
* HerdrPeerLauncher}), anything the daemon deliberately injects for THIS spawn survives its own
|
||||
* control.
|
||||
*
|
||||
* <p>{@code SSH_AUTH_SOCK} is deliberately NOT here. It is a handle to the operator's ssh-agent — a
|
||||
* member holding it can sign with the operator's keys — so keeping it is a config decision
|
||||
* ({@code memberCredentials.sshAuthSock: allow}), not a derivation default.
|
||||
*/
|
||||
public final class MemberEnvAllowList {
|
||||
|
||||
/**
|
||||
* Names that are not credentials and that a login shell or agent binary genuinely needs.
|
||||
*
|
||||
* <p>Every name here is a location or a shell setting, never a credential. That rule is load
|
||||
* bearing, and CB-633's first cut broke it: it also listed {@code ANTHROPIC_AUTH_TOKEN},
|
||||
* {@code GITEA_TOKEN}, {@code GITEA_HOST}, {@code ANTHROPIC_BASE_URL}, {@code ANTHROPIC_MODEL},
|
||||
* {@code CLAUDE_CONFIG_DIR}, {@code OPENCODE_CONFIG} and {@code BRIDGED_MEMBER} "because the
|
||||
* launcher injects them". The launcher does — but only on the spawns where it actually sets
|
||||
* them, and {@code HerdrPeerLauncher} already unions THIS spawn's env-map keys into the
|
||||
* allow-list. So a static entry adds nothing on a spawn that injects the name, and on a spawn
|
||||
* that does not it lets the operator's own value through under exactly the name a member reads.
|
||||
* {@code ANTHROPIC_BASE_URL} is the sharpest case: an inherited one silently moves a member off
|
||||
* the endpoint the profile chose.
|
||||
*
|
||||
* <p>{@code ZDOTDIR} stays because it is this control's own handle — lose it and every later
|
||||
* sub-shell loses the scrub. {@code JAVA_HOME} and the {@code XDG_*} roots are toolchain
|
||||
* locations. Everything else a member needs must arrive via a profile's {@code env:} or the
|
||||
* launcher's own injection, both of which land on the derived set automatically.
|
||||
*/
|
||||
public static final Set<String> INFRASTRUCTURE_PASSTHROUGH = Set.of(
|
||||
"PATH", "HOME", "SHELL", "TERM", "LANG", "TMPDIR",
|
||||
"USER", "LOGNAME", "PWD", "SHLVL", "EDITOR", "PAGER",
|
||||
"_",
|
||||
"ZDOTDIR",
|
||||
"JAVA_HOME",
|
||||
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME");
|
||||
|
||||
/** Locale-category prefix kept as infrastructure ({@code LC_ALL}, {@code LC_CTYPE}, …). */
|
||||
private static final String INFRASTRUCTURE_NAME_PREFIX = "LC_";
|
||||
|
||||
private MemberEnvAllowList() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the allowed NAME set from the given profiles plus {@link #INFRASTRUCTURE_PASSTHROUGH}.
|
||||
* Deterministic (sorted) so generated scrub files are diffable run-to-run.
|
||||
*/
|
||||
public static Set<String> derive(Collection<FleetConfig.Profile> profiles) {
|
||||
Set<String> derived = new TreeSet<>(INFRASTRUCTURE_PASSTHROUGH);
|
||||
if (profiles != null) {
|
||||
for (FleetConfig.Profile p : profiles) {
|
||||
addIfPresent(derived, p.gitTokenEnv());
|
||||
addIfPresent(derived, p.gitHostEnv());
|
||||
addIfPresent(derived, p.tokenEnv());
|
||||
if (p.env() != null) {
|
||||
derived.addAll(p.env().keySet());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Set.copyOf(derived);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code name} survives the scrub when {@code allowedNames} is the derived set: an exact
|
||||
* match, or an infrastructure-prefixed name ({@code LC_*}). Prefix rules live ONLY here and in
|
||||
* the generated script's {@code case} pattern, which is written from this constant's value.
|
||||
*/
|
||||
public static boolean keeps(Set<String> allowedNames, String name) {
|
||||
return allowedNames.contains(name) || name.startsWith(INFRASTRUCTURE_NAME_PREFIX);
|
||||
}
|
||||
|
||||
/** The prefix rule as a zsh {@code case} pattern, so the script and Java cannot drift apart. */
|
||||
public static String zshCasePattern() {
|
||||
return INFRASTRUCTURE_NAME_PREFIX + "*";
|
||||
}
|
||||
|
||||
private static void addIfPresent(Set<String> into, String name) {
|
||||
if (name != null && !name.isBlank()) {
|
||||
into.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||
import dev.ltms.fleet.peer.Capability;
|
||||
import dev.ltms.fleet.peer.CharterReceipt;
|
||||
import dev.ltms.fleet.peer.PeerHandle;
|
||||
import dev.ltms.fleet.peer.PeerLauncher;
|
||||
import dev.ltms.fleet.peer.SpawnRequest;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -23,6 +24,9 @@ import java.util.function.Function;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link HerdrPeerLauncher} adapter for <strong>opencode</strong> — an open-source,
|
||||
* provider-agnostic terminal coding agent. Its whole reason for existing is to prove the
|
||||
@@ -49,6 +53,8 @@ import java.util.function.Supplier;
|
||||
*/
|
||||
public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OpenCodeLauncher.class);
|
||||
|
||||
/** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */
|
||||
private static final String NAME_PREFIX = "opencode";
|
||||
|
||||
@@ -203,9 +209,20 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
@Override
|
||||
protected Launch buildLaunch(FleetConfig.Profile cfg, LaunchSpec spec) {
|
||||
Map<String, String> workerEnv = baseEnv(cfg);
|
||||
// A config file is needed for the bridge MCP mount, a member charter, or a pinned endpoint (CB-508).
|
||||
if (cfg.hasMcp() || spec.charter() != null || hasCustomProvider(cfg)) {
|
||||
workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg, spec.charter()).toString());
|
||||
// autoCompactWindow's opencode lever (limit.context) only targets a specific provider/model
|
||||
// entry, so it needs model: in "provider/model" form. A profile that opts in without that
|
||||
// shape gets no silent no-op — log it, once, here, whether or not writeConfig ends up running.
|
||||
boolean wantsContextLimit = cfg.autoCompactWindow() != null && splitProviderModel(cfg.model()) != null;
|
||||
if (cfg.autoCompactWindow() != null && !wantsContextLimit) {
|
||||
log.warn("profile '{}' sets autoCompactWindow but model '{}' is not \"<provider>/<model>\" "
|
||||
+ "form — opencode's per-model context limit could not be applied for this profile",
|
||||
cfg.profile(), cfg.model());
|
||||
}
|
||||
// A config file is needed for the bridge MCP mount, a member charter, the IDE MCP (+ its
|
||||
// guidance overlay, CB-634), a pinned endpoint (CB-508), or a resolvable autoCompactWindow.
|
||||
if (cfg.hasMcp() || cfg.hasIdeMcp() || spec.charter() != null || hasCustomProvider(cfg)
|
||||
|| wantsContextLimit) {
|
||||
workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg, spec.charter(), spec.cwd()).toString());
|
||||
}
|
||||
applyGitToken(workerEnv, cfg);
|
||||
List<String> argv = argvWithResume(argvWithModel(argvWithAuto(cfg), cfg), spec.resumeSessionId());
|
||||
@@ -289,7 +306,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* {@code OPENCODE_CONFIG}. The dir is unique per spawn so concurrent workers never race on it;
|
||||
* it is best-effort cleaned on JVM exit (worker config is disposable — regenerated every spawn).
|
||||
*/
|
||||
private Path writeConfig(FleetConfig.Profile cfg, String charterText) {
|
||||
private Path writeConfig(FleetConfig.Profile cfg, String charterText, String cwd) {
|
||||
try {
|
||||
Path dir = Files.createTempDirectory(configRoot, "bridged-opencode-");
|
||||
dir.toFile().deleteOnExit();
|
||||
@@ -320,15 +337,42 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
root.putArray("instructions").add(charter.toAbsolutePath().toString());
|
||||
}
|
||||
|
||||
if (cfg.hasMcp()) {
|
||||
ObjectNode bridge = root.putObject("mcp").putObject("bridge");
|
||||
bridge.put("type", "remote");
|
||||
bridge.put("url", cfg.mcpUrl());
|
||||
bridge.put("enabled", true);
|
||||
if (cfg.hasMcp() || cfg.hasIdeMcp()) {
|
||||
// One shared mcp node for both servers — putObject would replace the node (and thus
|
||||
// the other server) on the second call, so build into a single get-or-create node.
|
||||
ObjectNode mcp = root.withObject("mcp");
|
||||
if (cfg.hasMcp()) {
|
||||
ObjectNode mount = mcp.putObject(PeerLauncher.MCP_MOUNT_NAME);
|
||||
mount.put("type", "remote");
|
||||
mount.put("url", cfg.mcpUrl());
|
||||
mount.put("enabled", true);
|
||||
}
|
||||
// CB-634: mount the IDE Index MCP in the same shape as the bridge remote server, and
|
||||
// deliver its guidance via the instructions array (opencode does not read
|
||||
// CLAUDE.local.md) rather than any system-prompt string.
|
||||
if (cfg.hasIdeMcp()) {
|
||||
ObjectNode ide = mcp.putObject("intellij");
|
||||
ide.put("type", "remote");
|
||||
ide.put("url", cfg.ideMcpUrl());
|
||||
ide.put("enabled", true);
|
||||
|
||||
// CB-634: pin the overlay and open the IDE at the module dir (this repo's pom is
|
||||
// in `bridged/`, not at the worktree root) — see PeerLauncher.ideProjectPath.
|
||||
String projectPath = PeerLauncher.ideProjectPath(cwd, cfg.ideProjectDir());
|
||||
Path rules = dir.resolve("ide-rules.md");
|
||||
Files.writeString(rules, PeerLauncher.ideOverlayText(projectPath));
|
||||
rules.toFile().deleteOnExit();
|
||||
// The array may already hold the member-charter path; withArray gets-or-creates.
|
||||
root.withArray("instructions").add(rules.toAbsolutePath().toString());
|
||||
PeerLauncher.openInIde(projectPath, cfg.ideOpenCommand(), log);
|
||||
}
|
||||
}
|
||||
if (hasCustomProvider(cfg)) {
|
||||
addCustomProvider(root, cfg);
|
||||
}
|
||||
if (cfg.autoCompactWindow() != null) {
|
||||
applyContextLimit(root, cfg);
|
||||
}
|
||||
|
||||
Path cfgFile = dir.resolve("opencode.json");
|
||||
// Built with Jackson rather than string concatenation: the provider block is nested and
|
||||
@@ -373,18 +417,68 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
||||
* default gateway — a worker quietly talking to the wrong endpoint is the failure this avoids.
|
||||
*/
|
||||
private static String[] splitModelSelector(FleetConfig.Profile cfg) {
|
||||
String model = cfg.model();
|
||||
int slash = model == null ? -1 : model.indexOf('/');
|
||||
if (model == null || model.isBlank() || slash <= 0 || slash == model.length() - 1) {
|
||||
String[] parts = splitProviderModel(cfg.model());
|
||||
if (parts == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"profile " + cfg.profile() + " sets baseUrl (a pinned opencode endpoint) so"
|
||||
+ " model: must be \"<provider>/<model>\", e.g."
|
||||
+ " \"local-vllm/deepseek-v4-flash\"; got "
|
||||
+ (model == null ? "null" : '"' + model + '"'));
|
||||
+ (cfg.model() == null ? "null" : '"' + cfg.model() + '"'));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split {@code model} into its {@code provider} and {@code model} halves, or {@code null} when
|
||||
* it is not in that shape (unset/blank, or no non-trailing {@code /}). Unlike
|
||||
* {@link #splitModelSelector}, non-throwing — callers that only *optionally* need the split
|
||||
* (autoCompactWindow's context-limit application) use this to fall back to a WARN rather than an
|
||||
* exception, since a profile without {@code baseUrl} is not required to name a provider/model.
|
||||
*/
|
||||
private static String[] splitProviderModel(String model) {
|
||||
int slash = model == null ? -1 : model.indexOf('/');
|
||||
if (model == null || model.isBlank() || slash <= 0 || slash == model.length() - 1) {
|
||||
return null;
|
||||
}
|
||||
return new String[]{model.substring(0, slash), model.substring(slash + 1)};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the per-profile {@code autoCompactWindow} as opencode's per-model context limit.
|
||||
*
|
||||
* <p>opencode has no absolute "compact at N tokens" knob — its {@code compaction} block only
|
||||
* exposes {@code auto}/{@code prune}/{@code reserved}/{@code tail_turns}/
|
||||
* {@code preserve_recent_tokens} — so the real lever is the model's own
|
||||
* {@code provider.<p>.models.<m>.limit.context}, which bounds the window opencode compacts
|
||||
* <em>within</em> rather than compacting exactly AT it the way Claude Code's {@code --autocompact}
|
||||
* does.
|
||||
*
|
||||
* <p>Uses get-or-create nodes ({@code withObject}) at every level so this MERGES with any provider
|
||||
* block {@link #addCustomProvider} already wrote for a custom-provider (pinned-endpoint) profile —
|
||||
* it must never overwrite that block's {@code npm}/{@code name}/{@code options}. For a gateway
|
||||
* profile (no {@code baseUrl}, so no prior provider block) this writes a partial
|
||||
* {@code provider.<p>.models.<m>.limit} override, which opencode merges over its own built-in
|
||||
* provider definition.
|
||||
*
|
||||
* <p>opencode's {@code limit} schema requires both {@code context} and {@code output}; there is no
|
||||
* independent signal for the latter here, so 16384 is written as a safe default (documented in
|
||||
* {@code fleetd.example.yaml}).
|
||||
*
|
||||
* <p>Silently does nothing when {@code model:} is not in {@code provider/model} form — a warning
|
||||
* for that case is already logged once in {@code buildLaunch}, so this stays quiet rather than
|
||||
* duplicating it.
|
||||
*/
|
||||
private static void applyContextLimit(ObjectNode root, FleetConfig.Profile cfg) {
|
||||
String[] parts = splitProviderModel(cfg.model());
|
||||
if (parts == null) {
|
||||
return;
|
||||
}
|
||||
ObjectNode limit = root.withObject("provider").withObject(parts[0])
|
||||
.withObject("models").withObject(parts[1]).withObject("limit");
|
||||
limit.put("context", cfg.autoCompactWindow());
|
||||
limit.put("output", 16384);
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenAI-compatible base URL for {@code baseUrl}. A bare {@code host:port} gets {@code /v1}
|
||||
* appended (where these servers put the API); a URL that already carries a path is taken as-is,
|
||||
|
||||
@@ -13,31 +13,31 @@ import java.util.Map;
|
||||
*
|
||||
* <p>The set is deliberately small: each series maps to a failure mode this project has actually
|
||||
* hit, not to whatever was easy to count. The two worth watching in practice are
|
||||
* {@code bridged_sends_total{outcome="completion_fallback"}} — a rising share means turn detection
|
||||
* {@code fleet_sends_total{outcome="completion_fallback"}} — a rising share means turn detection
|
||||
* is degrading, the CB-115/116/118 failure family — and
|
||||
* {@code bridged_push_nudges_total{outcome="exhausted"}}, which means the primary stopped draining
|
||||
* {@code fleet_push_nudges_total{outcome="exhausted"}}, which means the primary stopped draining
|
||||
* its inbox and CB-307's active push gave up.
|
||||
*/
|
||||
public final class FleetMetrics {
|
||||
|
||||
/** Counter: delegated sends by terminal outcome. */
|
||||
public static final String SENDS = "bridged_sends_total";
|
||||
public static final String SENDS = "fleet_sends_total";
|
||||
/** Counter: worker replies by the path that carried them (rendezvous vs stranded-to-inbox). */
|
||||
public static final String REPLIES = "bridged_replies_total";
|
||||
public static final String REPLIES = "fleet_replies_total";
|
||||
/** Counter: push-loop nudges to the primary, by outcome. */
|
||||
public static final String PUSH_NUDGES = "bridged_push_nudges_total";
|
||||
public static final String PUSH_NUDGES = "fleet_push_nudges_total";
|
||||
/** Counter: idle-lead heartbeat nudges to the lead, by outcome (CB-551). */
|
||||
public static final String HEARTBEAT_NUDGES = "bridged_lead_heartbeat_nudges_total";
|
||||
public static final String HEARTBEAT_NUDGES = "fleet_lead_heartbeat_nudges_total";
|
||||
/** Counter: spawn attempts by peer kind and outcome. */
|
||||
public static final String SPAWNS = "bridged_spawns_total";
|
||||
public static final String SPAWNS = "fleet_spawns_total";
|
||||
/** Counter: herdr socket calls by method and outcome. */
|
||||
public static final String HERDR_CALLS = "bridged_herdr_calls_total";
|
||||
public static final String HERDR_CALLS = "fleet_herdr_calls_total";
|
||||
/** Counter: rejected requests by reason (CB-501). */
|
||||
public static final String AUTH_FAILURES = "bridged_auth_failures_total";
|
||||
public static final String AUTH_FAILURES = "fleet_auth_failures_total";
|
||||
/** Gauge: session census by lifecycle state. */
|
||||
public static final String SESSIONS = "bridged_sessions";
|
||||
public static final String SESSIONS = "fleet_sessions";
|
||||
/** Gauge: undrained replies held per target. */
|
||||
public static final String INBOX_DEPTH = "bridged_inbox_depth";
|
||||
public static final String INBOX_DEPTH = "fleet_inbox_depth";
|
||||
|
||||
private FleetMetrics() {
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package dev.ltms.fleet.peer;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* SPI for materializing a connected peer — the only way the bridge core creates or tears down
|
||||
* a peer process. Every launcher is a first-party, in-tree adapter selected by (future) profile
|
||||
@@ -18,6 +21,91 @@ import java.util.Set;
|
||||
*/
|
||||
public interface PeerLauncher {
|
||||
|
||||
/**
|
||||
* The name every launcher gives the bridge's MCP server in the config it writes for its peer.
|
||||
* The peer's tools are addressed as {@code mcp__<this>__fleet_*}, and {@code CLAUDE.md}'s
|
||||
* role-detection ladder names that prefix, so the two must agree.
|
||||
*
|
||||
* <p>It is a constant because three launchers write it — {@code ClaudeCodeLauncher} and
|
||||
* {@code LeadLauncher} into a {@code --mcp-config} literal, {@code OpenCodeLauncher} into an
|
||||
* {@code opencode.json} node. Three hand-written copies of one name is how a rename lands in
|
||||
* two of them (CB-632).
|
||||
*/
|
||||
String MCP_MOUNT_NAME = "fleet";
|
||||
|
||||
/**
|
||||
* Shared IDE-guidance text (CB-634), delivered per-backend as an on-disk overlay rather than
|
||||
* any one adapter's system-prompt charter, so a project's own {@code CLAUDE.md} is never
|
||||
* clobbered. It pins every {@code ide_*} call to the member's own worktree, which is the whole
|
||||
* point of the mechanism. Both launchers render their own overlay from this single source.
|
||||
*
|
||||
* @param projectPath the path the member must pin every {@code ide_*} call to — the module dir
|
||||
* IntelliJ opened as the project, which is {@link #ideProjectPath} of the
|
||||
* member's own worktree (the worktree root when no module subdir is set)
|
||||
*/
|
||||
static String ideOverlayText(String projectPath) {
|
||||
return "## IDE code intelligence — your worktree only\n"
|
||||
+ "An IntelliJ IDE Index MCP server is mounted as `mcp__intellij__ide_*`. Prefer it "
|
||||
+ "over `grep`/`find` for symbol lookups, references, call and type hierarchy, and "
|
||||
+ "diagnostics — it resolves the real AST, text search does not.\n\n"
|
||||
+ "Every `ide_*` call MUST pass `project_path: \"" + projectPath + "\"` — your own "
|
||||
+ "worktree — and never any other path. A call without it errors "
|
||||
+ "`multiple_projects_open`; a call with a different path reads another checkout, "
|
||||
+ "not your changes. This is not the primary's IDE: it is your worktree, pinned to "
|
||||
+ "you.";
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute path IntelliJ must open as the project, and the {@code project_path} the overlay
|
||||
* pins (CB-634). It is {@code cwd} resolved against {@code ideProjectDir}. The distinction
|
||||
* matters because this repo (like {@code fleet/fleetd}) keeps its Maven module in a subdir
|
||||
* ({@code bridged/}), not at the worktree root: opening the root imports no module and
|
||||
* {@code ide_*} resolves nothing, so the module dir is the correct pin and open target.
|
||||
*
|
||||
* @param cwd the member's worktree root
|
||||
* @param ideProjectDir repo-relative module subdir, or {@code null}/blank for the worktree root
|
||||
* @return the absolute, normalized module dir as a string
|
||||
*/
|
||||
static String ideProjectPath(String cwd, String ideProjectDir) {
|
||||
Path base = Path.of(cwd);
|
||||
if (ideProjectDir == null || ideProjectDir.isBlank()) {
|
||||
return base.toString();
|
||||
}
|
||||
return base.resolve(ideProjectDir).normalize().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: open {@code projectPath} in the host IDE by running {@code openCommand} with
|
||||
* every {@code {dir}} replaced by {@code projectPath} (CB-634 auto-open). The command runs
|
||||
* through {@code /bin/sh -c} so an operator can set env inline — e.g.
|
||||
* {@code "env DISPLAY=:10.0 idea {dir}"} — because the daemon's own env may lack {@code DISPLAY}.
|
||||
*
|
||||
* <p>A blank command is a no-op: the profile opted into IDE MCP but not auto-open, so the
|
||||
* operator opens the module by hand. The child process is detached and its exit is not awaited;
|
||||
* any failure is logged and swallowed, because a member must spawn whether or not an IDE is
|
||||
* running. There is no close half yet (CB-634 defers it): an opened module stays open until the
|
||||
* operator closes it, and opening the same module again just refocuses it.
|
||||
*
|
||||
* @param projectPath the module dir to open (typically {@link #ideProjectPath})
|
||||
* @param openCommand the host command template, with {@code {dir}} substituted; null/blank ⇒ no-op
|
||||
* @param log the calling launcher's logger, for the best-effort WARN
|
||||
*/
|
||||
static void openInIde(String projectPath, String openCommand, Logger log) {
|
||||
if (openCommand == null || openCommand.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String cmd = openCommand.replace("{dir}", projectPath);
|
||||
try {
|
||||
new ProcessBuilder("/bin/sh", "-c", cmd)
|
||||
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||
.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
.start();
|
||||
log.info("CB-634 auto-open: launched IDE open for {}", projectPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("CB-634 auto-open of '{}' failed (member still spawns): {}", projectPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of {@link Capability capabilities} this launcher declares. A peer whose profile
|
||||
* opts into a git-forge token should include {@link Capability#SELF_PR}; the base set for
|
||||
|
||||
@@ -7,6 +7,7 @@ import dev.ltms.fleet.auth.Authz;
|
||||
import dev.ltms.fleet.auth.CallerResolver;
|
||||
import dev.ltms.fleet.auth.Principal;
|
||||
import dev.ltms.fleet.guard.GuardException;
|
||||
import dev.ltms.fleet.metrics.FleetMetrics;
|
||||
import dev.ltms.fleet.metrics.Metrics;
|
||||
import dev.ltms.fleet.herdr.Agent;
|
||||
import dev.ltms.fleet.herdr.HerdrClient;
|
||||
@@ -166,7 +167,7 @@ public final class FleetApp {
|
||||
|
||||
private void countAuthFailure(String reason) {
|
||||
if (metrics != null) {
|
||||
metrics.inc("bridged_auth_failures_total", "reason", reason);
|
||||
metrics.inc(FleetMetrics.AUTH_FAILURES, "reason", reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package dev.ltms.fleet;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import dev.ltms.fleet.config.FleetConfig;
|
||||
import dev.ltms.fleet.msg.AmqpReplyInbox;
|
||||
import dev.ltms.fleet.msg.InMemoryReplyInbox;
|
||||
import dev.ltms.fleet.msg.ReplyInbox;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* CB-151/152: the reply inbox is selected in {@link Fleetd#selectReplyInbox}, NOT in the config
|
||||
* record — a test that only exercises {@code broker.isConfigured()} would pass even if {@code
|
||||
* Fleetd} never honoured {@code uriEnv}. These tests drive the real selection logic with an
|
||||
* injected env map and an injected AMQP opener, so they prove which inbox the daemon actually
|
||||
* picks, and that it never logs the resolved URI (which carries the password).
|
||||
*/
|
||||
class FleetdReplyInboxSelectionTest {
|
||||
|
||||
public static final String SECRET = "s3cr3tPw";
|
||||
/** A resolved URI whose userinfo carries the password, so we can assert it never leaks. */
|
||||
private static final String RESOLVED_URI = "amqp://user:" + SECRET + "@broker.example:5672/vh";
|
||||
|
||||
/** Fake AMQP opener: records the URI it was offered, or fails as if the broker were unreachable. */
|
||||
private static final class RecordingAmqp implements Fleetd.AmqpOpener {
|
||||
String offeredUri;
|
||||
boolean unreachable;
|
||||
final ReplyInbox inbox = new InMemoryReplyInbox();
|
||||
|
||||
@Override
|
||||
public ReplyInbox open(String uri, int prefetch) {
|
||||
if (unreachable) {
|
||||
throw new IllegalStateException("cannot connect to AMQP broker at " + uri,
|
||||
new java.net.ConnectException("Connection refused"));
|
||||
}
|
||||
this.offeredUri = uri;
|
||||
return inbox;
|
||||
}
|
||||
}
|
||||
|
||||
private static ListAppender<ILoggingEvent> attach() {
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(Fleetd.class);
|
||||
// logback-test.xml pins dev.ltms.fleet to WARN; raise it so INFO selection lines are captured.
|
||||
logger.setLevel(Level.INFO);
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
return appender;
|
||||
}
|
||||
|
||||
private static void detach(ListAppender<ILoggingEvent> appender) {
|
||||
((Logger) LoggerFactory.getLogger(Fleetd.class)).detachAppender(appender);
|
||||
}
|
||||
|
||||
private static void assertNoLogContains(ListAppender<ILoggingEvent> appender, String secret) {
|
||||
assertTrue(appender.list.stream().noneMatch(e -> e.getFormattedMessage().contains(secret)),
|
||||
"no log line may contain the resolved URI's password");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uriEnvSetAndPresentSelectsAmqpWithTheResolvedUri() {
|
||||
FleetConfig.Broker broker = new FleetConfig.Broker(null, "LAVINMQ_URI", null);
|
||||
recording(broker, Map.of("LAVINMQ_URI", RESOLVED_URI), false, (opener, appender, inbox) -> {
|
||||
assertEquals(RESOLVED_URI, opener.offeredUri,
|
||||
"the daemon must connect with the value resolved from uriEnv — selection, not just parse");
|
||||
assertEquals(opener.inbox, inbox, "the AMQP opener's inbox is what is selected");
|
||||
assertNoLogContains(appender, SECRET);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void uriEnvSetButVariableMissingFallsBackToInMemoryAndWarns() {
|
||||
FleetConfig.Broker broker = new FleetConfig.Broker(null, "LAVINMQ_URI", null);
|
||||
recording(broker, Map.of(), false, (opener, appender, inbox) -> {
|
||||
assertInstanceOf(InMemoryReplyInbox.class, inbox);
|
||||
assertNull(opener.offeredUri, "AMQP must never be attempted when the variable is missing");
|
||||
assertTrue(hasWarnContaining(appender, "LAVINMQ_URI") && hasWarnContaining(appender, "DISABLED"),
|
||||
"a missing uriEnv variable must warn loudly, not fail silently");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void uriEnvSetButVariableBlankFallsBackToInMemoryAndWarns() {
|
||||
FleetConfig.Broker broker = new FleetConfig.Broker("amqp://user:lame@old:5672/", "LAVINMQ_URI", null);
|
||||
recording(broker, Map.of("LAVINMQ_URI", " "), false, (opener, appender, inbox) -> {
|
||||
assertInstanceOf(InMemoryReplyInbox.class, inbox);
|
||||
assertNull(opener.offeredUri, "a blank env value must not select AMQP, not even via the literal uri");
|
||||
assertTrue(hasWarnContaining(appender, "LAVINMQ_URI"),
|
||||
"a blank uriEnv value must warn, and must not fall back to the literal uri");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothUriAndUriEnvSetUriEnvWinsDeterministically() {
|
||||
FleetConfig.Broker broker
|
||||
= new FleetConfig.Broker("amqp://user:oldpw@old.example:5672/", "LAVINMQ_URI", null);
|
||||
recording(broker, Map.of("LAVINMQ_URI", RESOLVED_URI), false, (opener, appender, inbox) -> {
|
||||
assertEquals(RESOLVED_URI, opener.offeredUri,
|
||||
"uriEnv must win over uri, deterministically, every run");
|
||||
assertTrue(appender.list.stream().anyMatch(e -> e.getFormattedMessage().contains("broker.uri is ignored")),
|
||||
"must log that the literal uri is ignored when uriEnv is set");
|
||||
assertNoLogContains(appender, SECRET);
|
||||
assertNoLogContains(appender, "oldpw");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableBrokerStartsDaemonWithInMemoryInboxAndLoudWarning() {
|
||||
FleetConfig.Broker broker = new FleetConfig.Broker(null, "LAVINMQ_URI", null);
|
||||
recording(broker, Map.of("LAVINMQ_URI", RESOLVED_URI), true, (opener, appender, inbox) -> {
|
||||
assertInstanceOf(InMemoryReplyInbox.class, inbox, "an unreachable broker must NOT stop the daemon");
|
||||
String warn = appender.list.stream()
|
||||
.filter(e -> e.getLevel() == Level.WARN)
|
||||
.map(ILoggingEvent::getFormattedMessage)
|
||||
.reduce("", (a, b) -> a + "\n" + b)
|
||||
.toLowerCase();
|
||||
assertTrue(warn.contains("durable") && warn.contains("soft-state"),
|
||||
"the warning must say exactly what was lost: durable delivery off, replies soft-state");
|
||||
assertTrue(!warn.contains(SECRET), "the failing URI must be logged with credentials stripped");
|
||||
assertNoLogContains(appender, SECRET);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void noBrokerConfiguredStaysQuietInMemory() {
|
||||
FleetConfig.Broker broker = null;
|
||||
recording(broker, Map.of(), false, (opener, appender, inbox) -> {
|
||||
assertInstanceOf(InMemoryReplyInbox.class, inbox);
|
||||
assertTrue(appender.list.stream().noneMatch(e -> e.getLevel() == Level.WARN),
|
||||
"no broker configured must keep the existing QUIET in-memory path — no warning");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRealUnreachableBrokerFallsBackViaTheRealOpener() throws Exception {
|
||||
// A guaranteed-closed port: grab an ephemeral one, release it, then connect to the now-dead
|
||||
// address. This exercises AmqpReplyInbox.open's real throw path without any container.
|
||||
int closedPort;
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
closedPort = s.getLocalPort();
|
||||
}
|
||||
FleetConfig.Broker broker = new FleetConfig.Broker(null, "LAVINMQ_URI", null);
|
||||
ListAppender<ILoggingEvent> appender = attach();
|
||||
try {
|
||||
ReplyInbox inbox = Fleetd.selectReplyInbox(
|
||||
broker, Map.of("LAVINMQ_URI", "amqp://user:" + SECRET + "@127.0.0.1:" + closedPort + "/vh"),
|
||||
AmqpReplyInbox::open);
|
||||
assertInstanceOf(InMemoryReplyInbox.class, inbox,
|
||||
"a genuinely unreachable broker (real AmqpReplyInbox::open) must fall back to in-memory");
|
||||
} finally {
|
||||
detach(appender);
|
||||
}
|
||||
assertNoLogContains(appender, SECRET);
|
||||
}
|
||||
|
||||
private boolean hasWarnContaining(ListAppender<ILoggingEvent> appender, String fragment) {
|
||||
return appender.list.stream().anyMatch(e ->
|
||||
e.getLevel() == Level.WARN && e.getFormattedMessage().contains(fragment));
|
||||
}
|
||||
|
||||
/** Runs one selection under a captured log, asserting on its outcome. */
|
||||
private void recording(FleetConfig.Broker broker, Map<String, String> env, boolean unreachable, Check check) {
|
||||
RecordingAmqp opener = new RecordingAmqp();
|
||||
opener.unreachable = unreachable;
|
||||
ListAppender<ILoggingEvent> appender = attach();
|
||||
ReplyInbox inbox;
|
||||
try {
|
||||
inbox = Fleetd.selectReplyInbox(broker, env, opener);
|
||||
} finally {
|
||||
detach(appender);
|
||||
}
|
||||
check.run(opener, appender, inbox);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface Check {
|
||||
void run(RecordingAmqp opener, ListAppender<ILoggingEvent> appender, ReplyInbox inbox);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,49 @@ class FleetConfigTest {
|
||||
assertTrue(cfg.guard().hostSet().contains("ollama.ltms.dev"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProfileWithAnAutoCompactWindowBelowTheAcceptedRangeIsRejectedAtLoad(@TempDir Path dir)
|
||||
throws Exception {
|
||||
Path f = dir.resolve("low-window.yaml");
|
||||
Files.writeString(f, """
|
||||
profiles:
|
||||
ltms-local:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
autoCompactWindow: 50000
|
||||
""");
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, () -> FleetConfig.load(f));
|
||||
assertTrue(e.getMessage().contains("ltms-local"), "the offending profile is named");
|
||||
assertTrue(e.getMessage().contains("autoCompactWindow"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProfileWithAnAutoCompactWindowInRangeLoadsFine(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("in-range-window.yaml");
|
||||
Files.writeString(f, """
|
||||
profiles:
|
||||
ltms-local:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
autoCompactWindow: 250000
|
||||
""");
|
||||
|
||||
FleetConfig cfg = FleetConfig.load(f);
|
||||
assertEquals(250_000, cfg.profiles().get("ltms-local").autoCompactWindow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProfileWithNoAutoCompactWindowLeavesItNull(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("no-window.yaml");
|
||||
Files.writeString(f, """
|
||||
profiles:
|
||||
ltms-local:
|
||||
baseUrl: http://gx00.gw:8000
|
||||
""");
|
||||
|
||||
assertNull(FleetConfig.load(f).profiles().get("ltms-local").autoCompactWindow(),
|
||||
"unset means off — today's behaviour, unchanged");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesDefaultsForMissingSections(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("minimal.yaml");
|
||||
@@ -1513,6 +1556,66 @@ class FleetConfigTest {
|
||||
"blockedSet is known minus allow");
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-633: {@code policy: allow-list} binds, is case-insensitive, and flips the block into
|
||||
* derived-scrub mode ({@link FleetConfig.MemberCredentials#isAllowList()}).
|
||||
*/
|
||||
@Test
|
||||
void allowListPolicyBinds(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("member-credentials-allow-list.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
memberCredentials:
|
||||
policy: ALLOW-LIST
|
||||
""");
|
||||
|
||||
FleetConfig.MemberCredentials mc = FleetConfig.load(f).memberCredentials();
|
||||
assertEquals(FleetConfig.MemberCredentials.POLICY_ALLOW_LIST, mc.policy());
|
||||
assertTrue(mc.isAllowList(), "allow-list must select the CB-633 derived-scrub policy");
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-633: {@code deny-list} is an accepted spelling of today's behaviour, normalized onto the
|
||||
* one canonical value — an operator upgrading from prose that says "deny-list" must not be told
|
||||
* their policy is unrecognized.
|
||||
*/
|
||||
@Test
|
||||
void denyListIsAnAcceptedAliasOfDenyByDefault(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("member-credentials-deny-list.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
memberCredentials:
|
||||
policy: deny-list
|
||||
known:
|
||||
- GITEA_ACCESS_TOKEN
|
||||
""");
|
||||
|
||||
FleetConfig.MemberCredentials mc = FleetConfig.load(f).memberCredentials();
|
||||
assertFalse(mc.isAllowList());
|
||||
assertEquals(FleetConfig.MemberCredentials.POLICY_DENY_BY_DEFAULT, mc.policy(),
|
||||
"deny-list normalizes onto the canonical deny-by-default value");
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-633: {@code SSH_AUTH_SOCK} is a decision, never a default — absent, blank, or misspelled,
|
||||
* it stays BLOCKED; only the literal "allow" (any case) passes it through. A typo like "alow"
|
||||
* failing safe here is the whole point of making it a knob.
|
||||
*/
|
||||
@Test
|
||||
void sshAuthSockDefaultsToBlockedAndOnlyExplicitAllowUnblocksIt() {
|
||||
assertTrue(new FleetConfig.MemberCredentials("allow-list", List.of(), List.of()).sshAuthSock().equals("block"),
|
||||
"absent knob blocks SSH_AUTH_SOCK");
|
||||
assertFalse(new FleetConfig.MemberCredentials("allow-list", List.of(), List.of()).sshAuthSockAllowed());
|
||||
assertFalse(new FleetConfig.MemberCredentials(null, null, null, "").sshAuthSockAllowed(),
|
||||
"blank knob blocks SSH_AUTH_SOCK");
|
||||
assertFalse(new FleetConfig.MemberCredentials(null, null, null, "alow").sshAuthSockAllowed(),
|
||||
"a misspelled value fails SAFE, not open");
|
||||
assertTrue(new FleetConfig.MemberCredentials(null, null, null, "ALLOW").sshAuthSockAllowed(),
|
||||
"the literal allow (case-insensitive) unblocks SSH_AUTH_SOCK");
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-596: omitting {@code memberCredentials:} entirely must NOT crash a reader that assumes a
|
||||
* non-null block (the same "fill in nested defaults" contract every other structural field
|
||||
|
||||
@@ -31,7 +31,7 @@ class AgentControlContractTest {
|
||||
assumeTrue(!noSocket(), "no herdr socket — skipping");
|
||||
try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) {
|
||||
WorkspaceControl spaces = new WorkspaceControl(herdr);
|
||||
Workspace space = spaces.ensureWorkspace("__bridged_env_contract__");
|
||||
Workspace space = spaces.ensureWorkspace("__fleet_env_contract__");
|
||||
Tab.Created tab = spaces.createTab(space.workspaceId(), null,
|
||||
Map.of("ANTHROPIC_BASE_URL", "http://gx00.gw:8000"));
|
||||
try {
|
||||
|
||||
@@ -25,7 +25,7 @@ class PaneLocatorContractTest {
|
||||
assumeTrue(Files.exists(UnixSocketHerdrClient.defaultSocketPath()), "no herdr socket — skipping");
|
||||
try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) {
|
||||
WorkspaceControl spaces = new WorkspaceControl(herdr);
|
||||
Workspace space = spaces.ensureWorkspace("__bridged_pid_contract__");
|
||||
Workspace space = spaces.ensureWorkspace("__fleet_pid_contract__");
|
||||
Tab.Created tab = spaces.createTab(space.workspaceId(), null, Map.of());
|
||||
try {
|
||||
JsonNode info = herdr.call("pane.process_info", Map.of("pane_id", tab.rootPaneId()))
|
||||
|
||||
@@ -22,7 +22,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
@Tag("contract")
|
||||
class WorkspacePlacementContractTest {
|
||||
|
||||
private static final String LABEL = "__bridged_contract__";
|
||||
private static final String LABEL = "__fleet_contract__";
|
||||
|
||||
private boolean noSocket() {
|
||||
return !Files.exists(UnixSocketHerdrClient.defaultSocketPath());
|
||||
|
||||
@@ -185,7 +185,10 @@ class LeadLauncherTest {
|
||||
|
||||
List<String> args = startedArgs(herdr);
|
||||
assertTrue(args.contains("--mcp-config"));
|
||||
assertTrue(args.stream().anyMatch(a -> a.contains("http://127.0.0.1:8765/mcp")), args.toString());
|
||||
assertTrue(args.stream().anyMatch(a -> a.contains("\"fleet\"")
|
||||
&& a.contains("http://127.0.0.1:8765/mcp")), args.toString());
|
||||
assertTrue(args.stream().noneMatch(a -> a.contains("\"bridge\"")),
|
||||
"the mount is named fleet since CB-632");
|
||||
assertEquals("claude-opus-5", args.get(args.indexOf("--model") + 1));
|
||||
assertTrue(args.indexOf("--model") > args.indexOf("--mcp-config"),
|
||||
"--model is appended last so it outranks the ccs wrapper (CB-533)");
|
||||
|
||||
@@ -444,7 +444,7 @@ class FleetMcpTest {
|
||||
FleetConfig.Profile wcfg = new FleetConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
|
||||
"tab", "bridged-workers", "worker: {profile} #{n}", null,
|
||||
null, null, null, null, null, null, null, 0, null, null, null);
|
||||
null, null, null, null, null, null, null, 0, null, null, null, null);
|
||||
Map<String, FleetConfig.Profile> profiles = Map.of(wcfg.profile(), wcfg);
|
||||
ClaudeCodeLauncher delegate = new ClaudeCodeLauncher(
|
||||
new AgentControl(h), new WorkspaceControl(h), new SubscriptionGuard(Set.of("gx00.gw")),
|
||||
|
||||
@@ -12,6 +12,7 @@ import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||
import dev.ltms.fleet.peer.Capability;
|
||||
import dev.ltms.fleet.peer.MemberRole;
|
||||
import dev.ltms.fleet.peer.PeerHandle;
|
||||
import dev.ltms.fleet.peer.PeerLauncher;
|
||||
import dev.ltms.fleet.peer.PeerUnreachableException;
|
||||
import dev.ltms.fleet.peer.SpawnRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -54,12 +55,183 @@ class ClaudeCodeLauncherTest {
|
||||
|
||||
List<String> args = spawnedArgs(herdr);
|
||||
assertTrue(args.contains("--mcp-config"));
|
||||
assertTrue(args.stream().anyMatch(a -> a.contains("\"bridge\"") && a.contains("http://127.0.0.1:8765/mcp")),
|
||||
"inline bridge MCP config present");
|
||||
assertTrue(args.stream().anyMatch(a -> a.contains("\"fleet\"") && a.contains("http://127.0.0.1:8765/mcp")),
|
||||
"inline fleet MCP config present");
|
||||
assertTrue(args.stream().noneMatch(a -> a.contains("\"bridge\"")),
|
||||
"the mount is named fleet since CB-632 — a member addresses its tools as "
|
||||
+ "mcp__fleet__*, and CLAUDE.md's role-detection ladder names that prefix");
|
||||
assertTrue(args.contains("--append-system-prompt"));
|
||||
assertTrue(args.stream().anyMatch(a -> a.contains("fleet_reply")), "reply charter present");
|
||||
}
|
||||
|
||||
// CB-634: a profile with ideMcpUrl set mounts the IDE Index MCP as a second server and pins
|
||||
// every ide_* call to the member's own worktree via the charter.
|
||||
|
||||
/** A profile carrying an ideMcpUrl (plus optional bridge mcpUrl and cwd). ideMcpUrl is the last record component. */
|
||||
private FleetConfig.Profile ideProfile(String mcpUrl, String ideMcpUrl, String cwd) {
|
||||
return new FleetConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "w #{n}", mcpUrl, cwd, null,
|
||||
null, null, null, null, null, null, null, null, null, ideMcpUrl);
|
||||
}
|
||||
|
||||
/** As {@link #ideProfile} but carrying the CB-634 auto-open fields (module subdir + open command). */
|
||||
private FleetConfig.Profile ideProfileModule(String ideMcpUrl, String cwd, String ideProjectDir,
|
||||
String ideOpenCommand) {
|
||||
return new FleetConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, cwd, null,
|
||||
null, null, null, null, null, null, null, null, null, ideMcpUrl, ideProjectDir, ideOpenCommand);
|
||||
}
|
||||
|
||||
private ClaudeCodeLauncher launcher(FakeHerdr herdr, FleetConfig.Profile cfg) {
|
||||
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mountsIdeMcpAsSecondServerWhenIdeMcpUrlSet() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
launcher(herdr, ideProfile("http://127.0.0.1:8765/mcp",
|
||||
"http://127.0.0.1:29170/index-mcp/streamable-http", null)).spawn();
|
||||
|
||||
List<String> args = spawnedArgs(herdr);
|
||||
assertTrue(args.contains("--mcp-config"));
|
||||
String json = args.get(args.indexOf("--mcp-config") + 1);
|
||||
assertTrue(json.contains("\"fleet\"") && json.contains("http://127.0.0.1:8765/mcp"),
|
||||
"bridge mount still present: " + json);
|
||||
assertTrue(json.contains("\"intellij\"") && json.contains("29170"),
|
||||
"IDE Index MCP mounted as a second server named intellij: " + json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ideMcpUrlAloneStillEmitsTheMount() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
launcher(herdr, ideProfile(null, "http://127.0.0.1:29170/index-mcp/streamable-http", null)).spawn();
|
||||
|
||||
List<String> args = spawnedArgs(herdr);
|
||||
assertTrue(args.contains("--mcp-config"),
|
||||
"the mount gate fires on ideMcpUrl alone, not only on mcpUrl");
|
||||
String json = args.get(args.indexOf("--mcp-config") + 1);
|
||||
assertTrue(json.contains("\"intellij\""), "IDE server present: " + json);
|
||||
assertFalse(json.contains("\"fleet\""), "no bridge server when mcpUrl is unset: " + json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleAndReplyOnlyComposeTheCharterFile_NotIdeGuidance() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
String roleCharter = "You review changes.";
|
||||
String worktree = "/tmp/.fleet-worktrees/rev-1";
|
||||
FleetConfig.Profile cfg = ideProfile("http://127.0.0.1:8765/mcp",
|
||||
"http://127.0.0.1:29170/index-mcp/streamable-http", worktree);
|
||||
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null,
|
||||
0, 0L, () -> fleet(Map.of("reviewer", roleCharter), null));
|
||||
|
||||
svc.spawn(new SpawnRequest("ltms-local", null, null, null, null, MemberRole.REVIEWER));
|
||||
|
||||
List<String> args = spawnedArgs(herdr);
|
||||
assertTrue(args.stream().noneMatch(a -> a.contains("\n")),
|
||||
"no argv element may be multi-line: " + args);
|
||||
assertFalse(args.contains("--append-system-prompt"),
|
||||
"CB-618: role + reply ride one --append-system-prompt-file, never both flags: " + args);
|
||||
int fileFlag = args.indexOf("--append-system-prompt-file");
|
||||
assertTrue(fileFlag >= 0, "the combined charter is mounted via file: " + args);
|
||||
assertDoesNotThrow(() -> {
|
||||
String w = Files.readString(Path.of(args.get(fileFlag + 1)));
|
||||
assertTrue(w.startsWith(roleCharter), "role charter first: " + w);
|
||||
assertFalse(w.contains("project_path"),
|
||||
"CB-634: the IDE guidance is delivered as an on-disk overlay, not the charter: " + w);
|
||||
assertTrue(w.endsWith(HerdrPeerLauncher.REPLY_CHARTER),
|
||||
"the reply charter is last — it is the rule that must survive: " + w);
|
||||
}, "the --append-system-prompt-file path must be a readable file");
|
||||
}
|
||||
|
||||
// CB-634: the IDE guidance is delivered as a CLAUDE.local.md overlay (written only into a
|
||||
// provisioned worktree — cwd with a `.git` FILE) and registered in the repository's COMMON
|
||||
// info/exclude. git reads a worktree's excludes from the common dir, not the per-worktree
|
||||
// gitdir (only info/sparse-checkout is per-worktree), so a real provisioned layout
|
||||
// <common>/worktrees/<name> must land the entry in <common>/info/exclude.
|
||||
|
||||
@Test
|
||||
void writeIdeOverlayWritesClaudeLocalAndAddsItToCommonInfoExclude(@TempDir Path root) throws Exception {
|
||||
Path worktree = Files.createDirectory(root.resolve("worktree"));
|
||||
// Real worktree layout: the .git FILE points at <common>/worktrees/<name>.
|
||||
Path commonDir = Files.createDirectory(root.resolve("dotgit"));
|
||||
Path gitDir = Files.createDirectories(commonDir.resolve("worktrees").resolve("wt1"));
|
||||
Files.writeString(worktree.resolve(".git"), "gitdir: " + gitDir);
|
||||
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
launcher(herdr, ideProfile(null, "http://127.0.0.1:29170/index-mcp/streamable-http",
|
||||
worktree.toString())).spawn();
|
||||
|
||||
Path overlay = worktree.resolve("CLAUDE.local.md");
|
||||
assertTrue(Files.exists(overlay), "the overlay is written beside the project's CLAUDE.md");
|
||||
assertTrue(Files.readString(overlay).contains("project_path: \"" + worktree + "\""),
|
||||
"the overlay pins every ide_* call to the member's own worktree");
|
||||
Path commonExclude = commonDir.resolve("info").resolve("exclude");
|
||||
assertTrue(Files.exists(commonExclude), "info/exclude is created in the COMMON git dir");
|
||||
assertTrue(Files.readAllLines(commonExclude).contains("CLAUDE.local.md"),
|
||||
"the overlay is registered in the common exclude git actually honours for a worktree");
|
||||
assertFalse(Files.exists(gitDir.resolve("info").resolve("exclude")),
|
||||
"the entry must NOT go to the per-worktree gitdir, which git ignores for excludes");
|
||||
}
|
||||
|
||||
// CB-634 auto-open pin fix: for a repo whose Maven module is a subdir (this repo's pom lives in
|
||||
// `bridged/`, not at the worktree root), the overlay must pin project_path to the MODULE dir the
|
||||
// IDE opened, not the worktree root — else ide_* resolves against a root that imports no module.
|
||||
@Test
|
||||
void writeIdeOverlayPinsModuleDirWhenIdeProjectDirSet(@TempDir Path root) throws Exception {
|
||||
Path worktree = Files.createDirectory(root.resolve("worktree"));
|
||||
Path commonDir = Files.createDirectory(root.resolve("dotgit"));
|
||||
Path gitDir = Files.createDirectories(commonDir.resolve("worktrees").resolve("wt1"));
|
||||
Files.writeString(worktree.resolve(".git"), "gitdir: " + gitDir);
|
||||
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
// ideProjectDir "bridged" ⇒ the pin is <worktree>/bridged, not <worktree>.
|
||||
launcher(herdr, ideProfileModule("http://127.0.0.1:29170/index-mcp/streamable-http",
|
||||
worktree.toString(), "bridged", null)).spawn();
|
||||
|
||||
Path overlay = worktree.resolve("CLAUDE.local.md");
|
||||
assertTrue(Files.exists(overlay), "the overlay file still lives at the worktree root");
|
||||
String module = worktree.resolve("bridged").toString();
|
||||
assertTrue(Files.readString(overlay).contains("project_path: \"" + module + "\""),
|
||||
"the overlay pins the MODULE dir the IDE opened, not the worktree root");
|
||||
assertFalse(Files.readString(overlay).contains("project_path: \"" + worktree + "\""),
|
||||
"the bare worktree root must NOT be the pin when a module subdir is set");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ideProjectPathResolvesModuleSubdirAndDefaultsToWorktreeRoot() {
|
||||
assertEquals("/wt/x/bridged", PeerLauncher.ideProjectPath("/wt/x", "bridged"),
|
||||
"a module subdir resolves under the worktree root");
|
||||
assertEquals("/wt/x", PeerLauncher.ideProjectPath("/wt/x", null),
|
||||
"no module subdir ⇒ the worktree root itself");
|
||||
assertEquals("/wt/x", PeerLauncher.ideProjectPath("/wt/x", " "),
|
||||
"a blank module subdir ⇒ the worktree root itself");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openInIdeIsNoOpAndNeverThrowsWhenCommandBlank() {
|
||||
// A profile that opts into IDE MCP but sets no open command must not fail the spawn.
|
||||
assertDoesNotThrow(() -> PeerLauncher.openInIde("/wt/x", null, LoggerFactory.getLogger("test")));
|
||||
assertDoesNotThrow(() -> PeerLauncher.openInIde("/wt/x", " ", LoggerFactory.getLogger("test")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeIdeOverlayDoesNothingWhenDotGitIsADirectory(@TempDir Path root) throws Exception {
|
||||
Path worktree = Files.createDirectory(root.resolve("worktree"));
|
||||
// The primary's real checkout has a `.git` DIRECTORY, not the worktree's `.git` FILE.
|
||||
Files.createDirectories(worktree.resolve(".git"));
|
||||
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
launcher(herdr, ideProfile(null, "http://127.0.0.1:29170/index-mcp/streamable-http",
|
||||
worktree.toString())).spawn();
|
||||
|
||||
assertFalse(Files.exists(worktree.resolve("CLAUDE.local.md")),
|
||||
"the safety gate refuses to write into a non-worktree cwd (.git directory)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startRetriesWhileTheSeedShellBoots() {
|
||||
// tab.create returns before the seed shell reaches its prompt; herdr refuses agent.start
|
||||
@@ -966,6 +1138,42 @@ class ClaudeCodeLauncherTest {
|
||||
assertNull(startEnv(herdr).get("ANTHROPIC_MODEL"));
|
||||
}
|
||||
|
||||
// ── autoCompactWindow: --autocompact is pinned on the command line, opt-in per profile ───────
|
||||
|
||||
/** A launcher for a profile identical but for its {@code autoCompactWindow:} — the only variable. */
|
||||
private ClaudeCodeLauncher serviceWithAutoCompactWindow(FakeHerdr herdr, Integer window) {
|
||||
FleetConfig.Profile cfg = profileWithAutoCompactWindow(window);
|
||||
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
|
||||
_ -> null);
|
||||
}
|
||||
|
||||
private static FleetConfig.Profile profileWithAutoCompactWindow(Integer window) {
|
||||
return new FleetConfig.Profile("sonnet", "http://gx00.gw:8000", "claude-sonnet-5", null,
|
||||
"BRIDGED_WORKER_TOKEN", List.of("ccs", "sonnet"), "tab", "bridged-workers",
|
||||
"w #{n}", "http://127.0.0.1:8765/mcp", null, null, null, null, null, Map.of(),
|
||||
null, null, null, null, null, null, null, null, window);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aConfiguredAutoCompactWindowIsPassedAsAnAutocompactFlag() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
serviceWithAutoCompactWindow(herdr, 250_000).spawn("sonnet", null, null);
|
||||
|
||||
List<String> args = spawnedArgs(herdr);
|
||||
int flag = args.indexOf("--autocompact");
|
||||
assertTrue(flag >= 0, "the flag is what survives a wrapper argv like [ccs, sonnet]");
|
||||
assertEquals("250000", args.get(flag + 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProfileWithNoAutoCompactWindowGetsNoAutocompactFlag() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
serviceWithAutoCompactWindow(herdr, null).spawn("sonnet", null, null);
|
||||
|
||||
assertFalse(spawnedArgs(herdr).contains("--autocompact"));
|
||||
}
|
||||
|
||||
// --- CB-539: subscription-profile opt-in ----------------------------------------------------
|
||||
|
||||
/** A claude-code profile on the subscription: no baseUrl (by design), no off-sub endpoint. */
|
||||
|
||||
@@ -136,7 +136,7 @@ class CompositePeerLauncherTest {
|
||||
return new FleetConfig.Profile(profile, "http://gx00.gw:8000", "coder",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers",
|
||||
"w #{n}", null, null, null, null, null, null, null, null, null,
|
||||
null, null, credentialId);
|
||||
null, null, credentialId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package dev.ltms.fleet.member;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* CB-633: the artefact here is a shell file handed to ANOTHER PROGRAM — so the only test that
|
||||
* proves anything is one that runs that program. A unit test on {@link EnvAllowListScrub}'s string
|
||||
* assembly proves nothing about zsh; this repo has shipped a green suite before whose tests checked
|
||||
* argv we build and none ran the binary that has to accept it.
|
||||
*
|
||||
* <p>This test starts a REAL login interactive zsh from a CLEAN parent ({@code env -i}), once with
|
||||
* the generated ZDOTDIR and once without (the baseline), and asserts the surviving exported NAME set
|
||||
* EQUALS the allow-list intersection of the baseline — equality, not "these names are blocked". A
|
||||
* blocked-name list can only check names somebody already thought of; that is exactly the failure
|
||||
* being fixed. Only NAMES are compared — never values.
|
||||
*
|
||||
* <p>The scrub run sources this operator's real {@code ~/.zshenv}/~/.zprofile/~/.zshrc/~/.zlogin}
|
||||
* chain, so it skips cleanly (JUnit {@code assumeTrue}) on a machine with no /bin/zsh or no real
|
||||
* shell rc files rather than failing there.
|
||||
*/
|
||||
class EnvAllowListScrubTest {
|
||||
|
||||
private static final Path ZSH = Path.of("/bin/zsh");
|
||||
|
||||
/** Env var names appearing in command output; anything else (prompts, wrapped lines) is noise. */
|
||||
private static final Pattern ENV_NAME = Pattern.compile("^([A-Za-z_][A-Za-z0-9_]*)$");
|
||||
|
||||
/**
|
||||
* The equality test. Expected survivors = baseline exports ∩ allowed — i.e. every survivor is
|
||||
* allowed AND every allowed name that existed survives. The operator's own secret-store exports
|
||||
* (~30 names on this host) are the decoys: none is on the derived list, so every one must be
|
||||
* gone from the scrub run.
|
||||
*/
|
||||
@Test
|
||||
void scrubbedLoginShellSurvivorsEqualTheDerivedAllowList(@TempDir Path tmp) throws Exception {
|
||||
assumeTrue(Files.isExecutable(ZSH), "/bin/zsh not present — nothing to prove here");
|
||||
Path homeZshrc = Path.of(System.getProperty("user.home"), ".zshrc");
|
||||
assumeTrue(Files.exists(homeZshrc), "$HOME/.zshrc does not exist — no real login chain to test against");
|
||||
|
||||
// Derived shape, zero profiles: infrastructure + LC_* rule. SSH_AUTH_SOCK deliberately NOT
|
||||
// included — blocked by default is the decision under test.
|
||||
Set<String> allowed = MemberEnvAllowList.derive(List.of());
|
||||
Path zdotdir = EnvAllowListScrub.generate(tmp, allowed);
|
||||
|
||||
Map<String, String> cleanParent = Map.of(
|
||||
"HOME", System.getProperty("user.home"),
|
||||
"PATH", "/usr/bin:/bin",
|
||||
"SHELL", "/bin/zsh",
|
||||
"USER", System.getProperty("user.name", "nobody"),
|
||||
"TMPDIR", tmp.toString());
|
||||
|
||||
Set<String> baseline = exportedNamesFromCleanParent(cleanParent, null);
|
||||
Set<String> scrubbed = exportedNamesFromCleanParent(cleanParent, zdotdir);
|
||||
|
||||
// The scrub RUN itself carries ZDOTDIR (the harness set it; it is infrastructure and MUST
|
||||
// survive, or every later login shell loses the scrub) — so the expected set starts from
|
||||
// baseline plus that one name.
|
||||
Set<String> expected = new TreeSet<>();
|
||||
for (String name : baseline) {
|
||||
if (MemberEnvAllowList.keeps(allowed, name)) {
|
||||
expected.add(name);
|
||||
}
|
||||
}
|
||||
assertTrue(MemberEnvAllowList.keeps(allowed, "ZDOTDIR"));
|
||||
expected.add("ZDOTDIR");
|
||||
assertEquals(expected, scrubbed,
|
||||
"surviving exported names must EQUAL baseline ∩ allow-list — a survivor outside the "
|
||||
+ "list is a leak; a missing allowed name means the scrub broke something it "
|
||||
+ "should have kept. Names only, values never printed.");
|
||||
}
|
||||
|
||||
/** The scrub writes its denominator report next to itself; names only, parseable. */
|
||||
@Test
|
||||
void scrubWritesAnAllowedNofMReport(@TempDir Path tmp) throws Exception {
|
||||
Set<String> allowed = MemberEnvAllowList.derive(List.of());
|
||||
Path zdotdir = EnvAllowListScrub.generate(tmp, allowed);
|
||||
|
||||
Map<String, String> cleanParent = Map.of(
|
||||
"HOME", System.getProperty("user.home"),
|
||||
"PATH", "/usr/bin:/bin",
|
||||
"SHELL", "/bin/zsh");
|
||||
exportedNamesFromCleanParent(cleanParent, zdotdir); // runs the login shell → runs the scrub
|
||||
|
||||
EnvAllowListScrub.ScrubReport report = EnvAllowListScrub.readReport(zdotdir);
|
||||
assertNotNull(report, "a completed login shell must leave a report behind");
|
||||
assertTrue(report.allowed() >= 0 && report.total() >= report.allowed(),
|
||||
"allowed N of M with N <= M — the denominator is always reported");
|
||||
}
|
||||
|
||||
/** Report parsing is lenient: absent file → null (no measurement), not an exception. */
|
||||
@Test
|
||||
void readReportReturnsNullForADirectoryWithoutOne(@TempDir Path dir) {
|
||||
assertNull(EnvAllowListScrub.readReport(dir));
|
||||
}
|
||||
|
||||
/**
|
||||
* The same equality, for a shell that is INTERACTIVE but NOT a login shell — the shape herdr
|
||||
* opens on Linux.
|
||||
*
|
||||
* <p>Why this test exists. The first version of this control put the scrub in {@code .zlogin}
|
||||
* alone. zsh reads {@code .zlogin} only for a login shell, and herdr does not open one
|
||||
* everywhere: measured on herdr 0.8.0, a macOS pane runs {@code -zsh} (login) while a Linux pane
|
||||
* runs a plain {@code /usr/bin/zsh}. So the control would have passed every test on the
|
||||
* developer's Mac and protected nothing at all on the vhost it was being built for, in silence.
|
||||
*
|
||||
* <p>This runs {@code zsh -i} — no {@code -l} — so {@code .zprofile} and {@code .zlogin} are
|
||||
* skipped exactly as they are on Linux. It therefore tests the Linux code path from a Mac,
|
||||
* which is the only place we can currently run it. Reverting the scrub to {@code .zlogin} only
|
||||
* makes this test fail while the login-shell test above still passes.
|
||||
*/
|
||||
@Test
|
||||
void scrubAlsoRunsInAnInteractiveNonLoginShell(@TempDir Path tmp) throws Exception {
|
||||
assumeTrue(Files.isExecutable(ZSH), "/bin/zsh not present — nothing to prove here");
|
||||
Path homeZshrc = Path.of(System.getProperty("user.home"), ".zshrc");
|
||||
assumeTrue(Files.exists(homeZshrc), "$HOME/.zshrc does not exist — no real chain to test against");
|
||||
|
||||
Set<String> allowed = MemberEnvAllowList.derive(List.of());
|
||||
Path zdotdir = EnvAllowListScrub.generate(tmp, allowed);
|
||||
|
||||
Map<String, String> cleanParent = Map.of(
|
||||
"HOME", System.getProperty("user.home"),
|
||||
"PATH", "/usr/bin:/bin",
|
||||
"SHELL", "/bin/zsh",
|
||||
"USER", System.getProperty("user.name", "nobody"),
|
||||
"TMPDIR", tmp.toString());
|
||||
|
||||
List<String> interactiveOnly = List.of("-i");
|
||||
Set<String> baseline = exportedNamesFromCleanParent(cleanParent, null, interactiveOnly);
|
||||
Set<String> scrubbed = exportedNamesFromCleanParent(cleanParent, zdotdir, interactiveOnly);
|
||||
|
||||
Set<String> expected = new TreeSet<>();
|
||||
for (String name : baseline) {
|
||||
if (MemberEnvAllowList.keeps(allowed, name)) {
|
||||
expected.add(name);
|
||||
}
|
||||
}
|
||||
expected.add("ZDOTDIR"); // the harness set it and it is infrastructure, so it must survive
|
||||
assertEquals(expected, scrubbed,
|
||||
"a non-login interactive zsh is what a herdr pane runs on Linux; its surviving "
|
||||
+ "exported names must EQUAL baseline \u2229 allow-list, exactly as for a login "
|
||||
+ "shell. A difference here means the scrub is dead on Linux.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run {@code /bin/zsh -l -i} from a clean parent and return the NAMES it has exported by prompt
|
||||
* time. With {@code zdotdir} non-null, {@code ZDOTDIR} points at a generated scrub directory, so
|
||||
* the login chain ends in CB-633's scrub; null gives the un-scrubbed baseline.
|
||||
*/
|
||||
private static Set<String> exportedNamesFromCleanParent(Map<String, String> cleanParent,
|
||||
Path zdotdir) throws IOException, InterruptedException {
|
||||
return exportedNamesFromCleanParent(cleanParent, zdotdir, List.of("-l", "-i"));
|
||||
}
|
||||
|
||||
private static Set<String> exportedNamesFromCleanParent(Map<String, String> cleanParent,
|
||||
Path zdotdir, List<String> shellFlags)
|
||||
throws IOException, InterruptedException {
|
||||
List<String> argv = new ArrayList<>();
|
||||
argv.add("/bin/zsh");
|
||||
argv.addAll(shellFlags);
|
||||
ProcessBuilder pb = new ProcessBuilder(argv);
|
||||
pb.environment().clear();
|
||||
pb.environment().putAll(cleanParent);
|
||||
if (zdotdir != null) {
|
||||
pb.environment().put("ZDOTDIR", zdotdir.toAbsolutePath().toString());
|
||||
}
|
||||
pb.redirectError(ProcessBuilder.Redirect.DISCARD); // prompts and rc chatter, never data
|
||||
|
||||
Process zsh = pb.start();
|
||||
// Names of exports whose VALUE is still non-empty. A blanked variable stays EXPORTED with
|
||||
// an empty value ("NAME=") — that is the control working, not surviving — so plain
|
||||
// `env | cut -d= -f1` would wrongly count blanked names as survivors.
|
||||
String probeScript = "command env | awk -F= '/^[A-Za-z_][A-Za-z0-9_]*=/ && length($0) > "
|
||||
+ "length($1)+1 { print $1 }' | command sort -u\nexit\n";
|
||||
zsh.getOutputStream().write(probeScript.getBytes(StandardCharsets.UTF_8));
|
||||
zsh.getOutputStream().flush();
|
||||
|
||||
String stdout = new String(zsh.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertTrue(zsh.waitFor(60, java.util.concurrent.TimeUnit.SECONDS),
|
||||
"the probe login shell did not exit within 60s");
|
||||
assertTrue(zsh.exitValue() == 0, "probe zsh exited non-zero — see test failure, values never printed");
|
||||
|
||||
Set<String> names = new HashSet<>();
|
||||
for (String line : stdout.split("\n")) {
|
||||
Matcher m = ENV_NAME.matcher(line.trim());
|
||||
if (m.matches()) {
|
||||
names.add(m.group(1));
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package dev.ltms.fleet.member;
|
||||
|
||||
import dev.ltms.fleet.config.FleetConfig;
|
||||
import dev.ltms.fleet.herdr.AgentControl;
|
||||
import dev.ltms.fleet.herdr.FakeHerdr;
|
||||
import dev.ltms.fleet.herdr.WorkspaceControl;
|
||||
import dev.ltms.fleet.peer.Capability;
|
||||
import dev.ltms.fleet.peer.MemberRole;
|
||||
import dev.ltms.fleet.peer.SpawnRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* CB-633: proves the allow-list scrub is actually WIRED INTO the spawn path — not merely that its
|
||||
* pieces work when a test calls them directly.
|
||||
*
|
||||
* <p>Why this test exists, and why it is separate from {@link EnvAllowListScrubTest}. Every other
|
||||
* test of this feature calls {@code EnvAllowListScrub} or {@code MemberEnvAllowList} itself. Those
|
||||
* prove the scrub is correct. None of them proves anyone runs it: deleting the single
|
||||
* {@code applyEnvironmentAllowListPolicy(cfg, launch)} line from {@code spawnInternal} left all 896
|
||||
* tests green while turning the control completely off. That is the recurring shape in this
|
||||
* codebase — a feature behind one call, with every test on the far side of it (CB-586, CB-611).
|
||||
*
|
||||
* <p>So this test starts a real spawn through {@link HerdrPeerLauncher#spawn} and asserts on what
|
||||
* reached herdr. It deliberately checks the pane-creation parameters rather than the launcher's own
|
||||
* map, because the map is an intermediate: {@code ZDOTDIR} only protects anything if it is in the
|
||||
* env herdr uses to create the pane, and that is the last point we can observe before the shell
|
||||
* starts.
|
||||
*/
|
||||
class HerdrPeerLauncherAllowListWiringTest {
|
||||
|
||||
/** A name the daemon itself injects — it must survive its own scrub, so it must be allowed. */
|
||||
private static final String INJECTED = "ANTHROPIC_BASE_URL";
|
||||
|
||||
@Test
|
||||
void spawningUnderAllowListPolicyGivesThePaneAGeneratedZdotdir() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
WiringLauncher launcher = new WiringLauncher(herdr, allowList());
|
||||
|
||||
launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
|
||||
|
||||
String paneParams = String.valueOf(herdr.lastCall("pane.split").params());
|
||||
assertTrue(paneParams.contains("ZDOTDIR"),
|
||||
"the spawn must hand herdr a ZDOTDIR so the pane's zsh reads our generated startup "
|
||||
+ "files; without it the scrub never runs and the member inherits the whole "
|
||||
+ "host environment. pane.split params were: " + paneParams);
|
||||
|
||||
Path dir = Path.of(launcher.env.get("ZDOTDIR"));
|
||||
assertTrue(Files.isDirectory(dir), "ZDOTDIR must point at a directory that exists: " + dir);
|
||||
// Both startup files must exist and both must source the scrub: .zlogin covers macOS panes
|
||||
// (login shells), .zshrc covers Linux panes (interactive, NOT login). Checking only one
|
||||
// would pass on the platform it was written for and ship a dead control on the other.
|
||||
for (String file : List.of(".zshrc", ".zlogin")) {
|
||||
Path f = dir.resolve(file);
|
||||
assertTrue(Files.isRegularFile(f), file + " must be generated: " + f);
|
||||
assertTrue(readAll(f).contains(EnvAllowListScrub.SCRUB_FILE),
|
||||
file + " must source " + EnvAllowListScrub.SCRUB_FILE + " — a scrub only one of "
|
||||
+ "them runs is dead on the platform that reads the other");
|
||||
}
|
||||
assertTrue(readAll(dir.resolve(EnvAllowListScrub.SCRUB_FILE)).contains(INJECTED),
|
||||
"the allow-list must include the names this very launch injects (" + INJECTED
|
||||
+ "), or the daemon's own configuration is blanked by its own control");
|
||||
}
|
||||
|
||||
/** The default policy must not generate anything — an upgrade changes nothing until asked. */
|
||||
@Test
|
||||
void spawningUnderTheDefaultPolicyGeneratesNoZdotdir() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
WiringLauncher launcher = new WiringLauncher(herdr, () -> new FleetConfig.MemberCredentials(
|
||||
null, List.of(), List.of(), null));
|
||||
|
||||
launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
|
||||
|
||||
assertFalse(launcher.env.containsKey("ZDOTDIR"),
|
||||
"policy=deny-by-default is the shipped default; it must not silently start "
|
||||
+ "rewriting members' shell startup files");
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-zsh shell cannot read {@code ZDOTDIR} at all. The launcher must fall back rather than
|
||||
* generate a directory nothing will ever read — a directory that would look like protection.
|
||||
*/
|
||||
@Test
|
||||
void aNonZshShellGeneratesNothingAndFallsBack() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
WiringLauncher launcher = new WiringLauncher(herdr, allowList(), "/bin/bash");
|
||||
|
||||
launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
|
||||
|
||||
assertFalse(launcher.env.containsKey("ZDOTDIR"),
|
||||
"bash ignores ZDOTDIR; setting it would be protection theatre");
|
||||
}
|
||||
|
||||
private static Supplier<FleetConfig.MemberCredentials> allowList() {
|
||||
return () -> new FleetConfig.MemberCredentials(
|
||||
FleetConfig.MemberCredentials.POLICY_ALLOW_LIST, List.of(), List.of(), null);
|
||||
}
|
||||
|
||||
private static String readAll(Path p) {
|
||||
try {
|
||||
return Files.readString(p);
|
||||
} catch (java.io.IOException e) {
|
||||
throw new AssertionError("cannot read " + p, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static FleetConfig.Profile profile() {
|
||||
return new FleetConfig.Profile("test", "http://gx00.gw:8000", null, null,
|
||||
"BRIDGED_WORKER_TOKEN", List.of("test"), "pane", null, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal peer launcher whose {@code buildLaunch} returns a MUTABLE env map holding one name
|
||||
* the daemon injects. Mutable on purpose: the policy adds {@code ZDOTDIR} to this very map, so
|
||||
* an immutable one would throw and the test would pass for the wrong reason.
|
||||
*/
|
||||
private static final class WiringLauncher extends HerdrPeerLauncher {
|
||||
private final Map<String, String> env = new HashMap<>(Map.of(INJECTED, "http://gateway"));
|
||||
|
||||
WiringLauncher(FakeHerdr herdr, Supplier<FleetConfig.MemberCredentials> creds) {
|
||||
this(herdr, creds, "/bin/zsh");
|
||||
}
|
||||
|
||||
WiringLauncher(FakeHerdr herdr, Supplier<FleetConfig.MemberCredentials> creds, String shell) {
|
||||
super("test", new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
Map.of("test", profile()), "test",
|
||||
name -> "SHELL".equals(name) ? shell : null,
|
||||
0, () -> 0L, () -> { }, null, creds);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Launch buildLaunch(FleetConfig.Profile cfg, LaunchSpec spec) {
|
||||
return new Launch(env, List.of("test"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Capability> capabilities() {
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** The generated directory is a temp directory; make sure the test does not leave a pile. */
|
||||
@Test
|
||||
void theGeneratedDirectoryIsRemovedWhenThePaneIsStopped() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
WiringLauncher launcher = new WiringLauncher(herdr, allowList());
|
||||
|
||||
var spawned = launcher.spawn(new SpawnRequest("test", null, null, null, null, MemberRole.DEV));
|
||||
Path dir = Path.of(launcher.env.get("ZDOTDIR"));
|
||||
assertNotNull(spawned, "spawn returned nothing");
|
||||
assertTrue(Files.isDirectory(dir));
|
||||
|
||||
launcher.stop(spawned.id());
|
||||
|
||||
assertEquals(false, Files.exists(dir),
|
||||
"stopping the pane must remove its generated ZDOTDIR: " + dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package dev.ltms.fleet.member;
|
||||
|
||||
import dev.ltms.fleet.config.FleetConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* CB-633: the allow-list a member's environment scrub enforces is DERIVED from what the launcher
|
||||
* itself injects — never hand-typed. These tests pin the derivation: adding a profile can only
|
||||
* widen the set (never break another spawn), and a name no profile references is not on it.
|
||||
*/
|
||||
class MemberEnvAllowListTest {
|
||||
|
||||
/** The 18-arg Profile ctor (back-compat + CB-511 {@code env} passthrough). */
|
||||
private static FleetConfig.Profile profile(String name, String tokenEnv,
|
||||
String gitTokenEnv, String gitHostEnv,
|
||||
Map<String, String> env) {
|
||||
return new FleetConfig.Profile(name, "http://gx00.gw:8000", null, null, tokenEnv,
|
||||
List.of("claude"), null, null, null, null, null, null,
|
||||
gitTokenEnv, gitHostEnv, FleetConfig.Profile.KIND_CLAUDE_CODE, env, 1.0f, 5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyKeyOfEveryProfileEnvMapLandsOnTheDerivedList() {
|
||||
FleetConfig.Profile p = profile("p", null, null, null,
|
||||
Map.of("MY_TOOL_ENDPOINT", "http://10.0.0.1", "MY_TOOL_OPTION", "x"));
|
||||
|
||||
Set<String> derived = MemberEnvAllowList.derive(List.of(p));
|
||||
|
||||
assertTrue(derived.contains("MY_TOOL_ENDPOINT"));
|
||||
assertTrue(derived.contains("MY_TOOL_OPTION"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyProfileTokenEnvGitTokenEnvAndGitHostEnvNameLandsOnTheDerivedList() {
|
||||
FleetConfig.Profile p = profile("p", "GATEWAY_TOKEN_FOR_P",
|
||||
"WORKER_GITEA_TOKEN", "MY_GITEA_HOST", Map.of());
|
||||
|
||||
Set<String> derived = MemberEnvAllowList.derive(List.of(p));
|
||||
|
||||
assertTrue(derived.contains("GATEWAY_TOKEN_FOR_P"), "tokenEnv is a variable NAME held in config");
|
||||
assertTrue(derived.contains("WORKER_GITEA_TOKEN"));
|
||||
assertTrue(derived.contains("MY_GITEA_HOST"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNameNoProfileReferencesIsNotOnTheDerivedList() {
|
||||
Set<String> derived = MemberEnvAllowList.derive(List.of(
|
||||
profile("p", "TOKEN_A", null, null, Map.of("KEY_A", "v"))));
|
||||
|
||||
assertFalse(derived.contains("SOME_SECRET_NOBODY_CONFIGURED"),
|
||||
"a hand-typed-feeling name must not appear by magic");
|
||||
assertFalse(derived.contains("CONFLUENCE_USERNAME"),
|
||||
"the exact class of name pattern filters missed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void infrastructurePassthroughNamesAreAlwaysOnTheDerivedList() {
|
||||
for (String infra : new String[]{"PATH", "HOME", "TERM", "TMPDIR", "SHLVL", "ZDOTDIR"}) {
|
||||
assertTrue(MemberEnvAllowList.INFRASTRUCTURE_PASSTHROUGH.contains(infra),
|
||||
infra + " is infrastructure, not a credential");
|
||||
assertTrue(MemberEnvAllowList.derive(List.of()).contains(infra),
|
||||
"derived list holds infrastructure even with zero profiles");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The property the ticket hangs the design on: ADDING a profile only ever widens the set, so a
|
||||
* new profile in bridged.yaml cannot break an existing spawn's scrub.
|
||||
*/
|
||||
@Test
|
||||
void addingAProfileOnlyWidensTheDerivedSetNeverShrinksIt() {
|
||||
FleetConfig.Profile first = profile("first", "TOKEN_FIRST", null, null, Map.of("FIRST_KEY", "v"));
|
||||
Set<String> before = MemberEnvAllowList.derive(List.of(first));
|
||||
|
||||
FleetConfig.Profile second = profile("second", "TOKEN_SECOND", "GIT_TOK", null,
|
||||
Map.of("SECOND_KEY", "v"));
|
||||
Set<String> after = MemberEnvAllowList.derive(List.of(first, second));
|
||||
|
||||
assertTrue(after.containsAll(before), "widening only");
|
||||
assertTrue(after.containsAll(Set.of("TOKEN_SECOND", "GIT_TOK", "SECOND_KEY")));
|
||||
}
|
||||
|
||||
/** {@code LC_*} categories are infrastructure by prefix; everything else needs an exact match. */
|
||||
@Test
|
||||
void keepsMatchesExactlyPlusTheLocalePrefixRule() {
|
||||
Set<String> derived = MemberEnvAllowList.derive(List.of());
|
||||
|
||||
assertTrue(MemberEnvAllowList.keeps(derived, "LC_FOO"), "prefix rule lives here, not in the caller");
|
||||
assertFalse(MemberEnvAllowList.keeps(derived, "LCD_VAR"),
|
||||
"a name that merely STARTS with the prefix letters is not LC_*");
|
||||
assertTrue(MemberEnvAllowList.keeps(Set.of("MINE"), "MINE"));
|
||||
assertFalse(MemberEnvAllowList.keeps(Set.of(), "SSH_AUTH_SOCK"),
|
||||
"the ssh-agent handle is kept ONLY by explicit config decision, never by this rule");
|
||||
assertEquals("LC_*", MemberEnvAllowList.zshCasePattern(),
|
||||
"the generated script's case pattern and this rule must not drift apart");
|
||||
}
|
||||
}
|
||||
@@ -99,11 +99,13 @@ class OpenCodeLauncherTest {
|
||||
JsonNode json = new ObjectMapper().readTree(Path.of(cfgPath).toFile());
|
||||
assertTrue(json.path("compaction").path("auto").asBoolean(),
|
||||
"spawned opencode peers explicitly enable automatic compaction");
|
||||
JsonNode bridge = json.path("mcp").path("bridge");
|
||||
assertEquals("remote", bridge.path("type").asText(), "bridge is mounted as a remote MCP server");
|
||||
assertEquals("http://127.0.0.1:8765/mcp", bridge.path("url").asText(),
|
||||
"the profile's bridge MCP url is present");
|
||||
assertTrue(bridge.path("enabled").asBoolean(), "the bridge server is enabled");
|
||||
JsonNode mount = json.path("mcp").path("fleet");
|
||||
assertEquals("remote", mount.path("type").asText(), "the fleet MCP server is mounted as remote");
|
||||
assertEquals("http://127.0.0.1:8765/mcp", mount.path("url").asText(),
|
||||
"the profile's fleet MCP url is present");
|
||||
assertTrue(mount.path("enabled").asBoolean(), "the fleet server is enabled");
|
||||
assertTrue(json.path("mcp").path("bridge").isMissingNode(),
|
||||
"the mount is named fleet since CB-632, not bridge");
|
||||
assertTrue(json.path("instructions").isArray() && !json.path("instructions").isEmpty(),
|
||||
"the member charter is mounted via instructions");
|
||||
|
||||
@@ -473,8 +475,8 @@ class OpenCodeLauncherTest {
|
||||
|
||||
JsonNode json = new ObjectMapper()
|
||||
.readTree(Path.of(startEnv(herdr).get("OPENCODE_CONFIG")).toFile());
|
||||
assertEquals("remote", json.path("mcp").path("bridge").path("type").asText(),
|
||||
"pinning an endpoint must not drop the bridge MCP mount");
|
||||
assertEquals("remote", json.path("mcp").path("fleet").path("type").asText(),
|
||||
"pinning an endpoint must not drop the fleet MCP mount");
|
||||
assertFalse(json.path("provider").path("local-vllm").isMissingNode(),
|
||||
"and the provider block is still declared alongside it");
|
||||
assertTrue(json.path("instructions").isArray() && !json.path("instructions").isEmpty(),
|
||||
@@ -492,4 +494,121 @@ class OpenCodeLauncherTest {
|
||||
assertTrue(json.path("provider").isMissingNode(),
|
||||
"without a baseUrl opencode resolves its own provider as before");
|
||||
}
|
||||
|
||||
// --- autoCompactWindow: opencode has no absolute compact-at-N knob, so this is applied as the
|
||||
// model's own limit.context, only when model: resolves to "provider/model" -----------------
|
||||
|
||||
private static FleetConfig.Profile opencodeCfgWithAutoCompactWindow(String model, String baseUrl,
|
||||
Integer window) {
|
||||
return new FleetConfig.Profile("gemini", baseUrl, model, null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", null,
|
||||
null, null, null, null, FleetConfig.Profile.KIND_OPENCODE, Map.of(), null, null,
|
||||
null, null, null, null, null, null, window);
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoCompactWindowIsAppliedAsThePerModelContextLimit(@TempDir Path root) throws Exception {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, root, opencodeCfgWithAutoCompactWindow("openai/gpt-5", null, 250_000)).spawn();
|
||||
|
||||
String cfgPath = startEnv(herdr).get("OPENCODE_CONFIG");
|
||||
assertNotNull(cfgPath, "autoCompactWindow alone must trigger config generation, with no MCP"
|
||||
+ " and no custom provider set");
|
||||
JsonNode limit = new ObjectMapper().readTree(Path.of(cfgPath).toFile())
|
||||
.path("provider").path("openai").path("models").path("gpt-5").path("limit");
|
||||
assertEquals(250_000, limit.path("context").asInt());
|
||||
assertEquals(16384, limit.path("output").asInt(),
|
||||
"opencode's limit schema requires both keys; output gets a safe documented default");
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoCompactWindowMergesIntoACustomProviderRatherThanOverwritingIt(@TempDir Path root)
|
||||
throws Exception {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, root, opencodeCfgWithAutoCompactWindow(
|
||||
"local-vllm/deepseek-v4-flash", "http://127.0.0.1:8000", 300_000)).spawn();
|
||||
|
||||
JsonNode provider = new ObjectMapper()
|
||||
.readTree(Path.of(startEnv(herdr).get("OPENCODE_CONFIG")).toFile())
|
||||
.path("provider").path("local-vllm");
|
||||
assertEquals("@ai-sdk/openai-compatible", provider.path("npm").asText(),
|
||||
"addCustomProvider's own fields must survive the later limit merge");
|
||||
assertEquals(300_000, provider.path("models").path("deepseek-v4-flash")
|
||||
.path("limit").path("context").asInt());
|
||||
assertEquals("deepseek-v4-flash", provider.path("models").path("deepseek-v4-flash")
|
||||
.path("name").asText(),
|
||||
"the model's pre-existing 'name' field must survive the limit merge too");
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoCompactWindowWithNoProviderSlashInModelGetsNoLimitAndAWarn(@TempDir Path root)
|
||||
throws Exception {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
// mcpUrl set too, only so a config file gets written at all to inspect; a bare model name
|
||||
// with no other config-triggering knob would leave OPENCODE_CONFIG unset entirely, which is
|
||||
// also correct (nothing to write) but not what this test is asserting.
|
||||
service(herdr, root, new FleetConfig.Profile("gemini", null, "some-free-model", null,
|
||||
"BRIDGED_WORKER_TOKEN", List.of("opencode"), "tab", "bridged-workers",
|
||||
"opencode: {model} #{n}", "http://127.0.0.1:8765/mcp", null, null, null, null,
|
||||
FleetConfig.Profile.KIND_OPENCODE, Map.of(), null, null, null, null, null, null,
|
||||
null, null, 250_000)).spawn();
|
||||
|
||||
String cfgPath = startEnv(herdr).get("OPENCODE_CONFIG");
|
||||
JsonNode json = new ObjectMapper().readTree(Path.of(cfgPath).toFile());
|
||||
assertTrue(json.path("provider").isMissingNode(),
|
||||
"a bare model name cannot be targeted at a specific provider/model limit entry — "
|
||||
+ "no silent no-op, but also no broken partial write");
|
||||
}
|
||||
|
||||
// --- CB-634: IDE Index MCP + guidance overlay (opencode does not read CLAUDE.local.md) -------
|
||||
|
||||
private static FleetConfig.Profile opencodeIdeCfg(String mcpUrl, String ideUrl, String cwd) {
|
||||
return new FleetConfig.Profile("gemini", null, null, null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl,
|
||||
cwd, null, null, null, FleetConfig.Profile.KIND_OPENCODE,
|
||||
null, null, null, null, null, null, ideUrl);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ideMcpUrlAddsTheIntellijServerAndAnInstructionsRulesEntry(@TempDir Path root) throws Exception {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Path cwd = Files.createDirectory(root.resolve("checkout"));
|
||||
service(herdr, root, opencodeIdeCfg("http://127.0.0.1:8765/mcp",
|
||||
"http://127.0.0.1:29170/index-mcp/streamable-http", cwd.toString())).spawn();
|
||||
|
||||
String cfgPath = startEnv(herdr).get("OPENCODE_CONFIG");
|
||||
assertNotNull(cfgPath, "an IDE profile needs a config file");
|
||||
JsonNode json = new ObjectMapper().readTree(Path.of(cfgPath).toFile());
|
||||
JsonNode ide = json.path("mcp").path("intellij");
|
||||
assertEquals("remote", ide.path("type").asText(),
|
||||
"the IDE server uses the same remote shape as the bridge mount");
|
||||
assertEquals("http://127.0.0.1:29170/index-mcp/streamable-http", ide.path("url").asText());
|
||||
assertTrue(ide.path("enabled").asBoolean(), "the IDE server is enabled");
|
||||
assertEquals("remote", json.path("mcp").path("fleet").path("type").asText(),
|
||||
"the bridge mount still coexists with the IDE server");
|
||||
|
||||
// The instructions array gains an entry pointing at a real rules file pinning the worktree.
|
||||
String rulesContent = null;
|
||||
for (JsonNode n : json.path("instructions")) {
|
||||
Path p = Path.of(n.asText());
|
||||
if (p.getFileName().toString().equals("ide-rules.md")) {
|
||||
rulesContent = Files.readString(p);
|
||||
}
|
||||
}
|
||||
assertNotNull(rulesContent, "an ide-rules.md instructions entry is present");
|
||||
assertTrue(rulesContent.contains("project_path: \"" + cwd + "\""),
|
||||
"the rules pin every ide_* call to the worker's own cwd");
|
||||
}
|
||||
|
||||
@Test
|
||||
void noIdeServerWhenIdeMcpUrlUnset(@TempDir Path root) throws Exception {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, root, opencodeCfg("google/gemini-2.5-pro", "http://127.0.0.1:8765/mcp", null))
|
||||
.spawn();
|
||||
|
||||
JsonNode json = new ObjectMapper()
|
||||
.readTree(Path.of(startEnv(herdr).get("OPENCODE_CONFIG")).toFile());
|
||||
assertTrue(json.path("mcp").path("intellij").isMissingNode(),
|
||||
"no IDE server when ideMcpUrl is unset");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package dev.ltms.fleet.metrics;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* CB-632: every exported series is named {@code fleet_*}, and nothing still says {@code bridged_}.
|
||||
*
|
||||
* <p>Why this test exists. The rename from {@code bridged_} to {@code fleet_} had nothing watching
|
||||
* it. {@link FleetMetrics} holds the names as constants, but one caller had written
|
||||
* {@code "bridged_auth_failures_total"} as a literal instead of using {@link
|
||||
* FleetMetrics#AUTH_FAILURES} — a second hand-written copy of a name, which is how these drift. A
|
||||
* rename that updated the constants and missed that literal would have shipped a daemon exporting
|
||||
* eight series under one prefix and one under another, and no test would have failed.
|
||||
*
|
||||
* <p>The check is reflective on purpose. A test that lists the nine names is itself a second
|
||||
* hand-written copy, so it would pass while a tenth constant added later went unchecked. Reading
|
||||
* the fields means a new series is covered the moment it is declared.
|
||||
*
|
||||
* <p>It also asserts its own denominator. "No name starts with bridged_" is true of an empty set,
|
||||
* so a reflective sweep that silently found nothing would pass loudly. Asserting the count means a
|
||||
* broken sweep fails instead of reporting success.
|
||||
*/
|
||||
class MetricNamesTest {
|
||||
|
||||
/** The number of series {@link FleetMetrics} declares. Update deliberately when adding one. */
|
||||
private static final int EXPECTED_SERIES = 9;
|
||||
|
||||
private static List<Field> nameConstants() {
|
||||
List<Field> out = new ArrayList<>();
|
||||
for (Field f : FleetMetrics.class.getDeclaredFields()) {
|
||||
int m = f.getModifiers();
|
||||
if (Modifier.isPublic(m) && Modifier.isStatic(m) && Modifier.isFinal(m)
|
||||
&& f.getType() == String.class) {
|
||||
out.add(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String valueOf(Field f) {
|
||||
try {
|
||||
return (String) f.get(null);
|
||||
} catch (IllegalAccessException e) {
|
||||
throw new AssertionError("cannot read " + f.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSweepActuallyFoundTheSeriesItClaimsToCheck() {
|
||||
assertEquals(EXPECTED_SERIES, nameConstants().size(),
|
||||
"this test reads FleetMetrics' name constants reflectively; if the count moved, "
|
||||
+ "either a series was added (update EXPECTED_SERIES) or the sweep broke "
|
||||
+ "and every assertion below is now vacuously true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyDeclaredSeriesUsesTheFleetPrefix() {
|
||||
for (Field f : nameConstants()) {
|
||||
String name = valueOf(f);
|
||||
assertTrue(name.startsWith("fleet_"),
|
||||
"FleetMetrics." + f.getName() + " is \"" + name + "\" — every exported series "
|
||||
+ "must be named fleet_* since CB-632");
|
||||
assertFalse(name.contains("bridged"),
|
||||
"FleetMetrics." + f.getName() + " still says bridged: \"" + name + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The names must also be unique. Two constants sharing a value would collide on the wire, and
|
||||
* a copy-paste when adding a series is exactly how that happens.
|
||||
*/
|
||||
@Test
|
||||
void noTwoSeriesShareAName() {
|
||||
List<String> names = nameConstants().stream().map(MetricNamesTest::valueOf).toList();
|
||||
assertEquals(names.size(), names.stream().distinct().count(),
|
||||
"two FleetMetrics constants hold the same series name: " + names);
|
||||
}
|
||||
}
|
||||
@@ -13,29 +13,29 @@ class MetricsTest {
|
||||
@Test
|
||||
void countersAccumulatePerLabelSet() {
|
||||
Metrics m = new Metrics();
|
||||
m.inc("bridged_sends_total", "outcome", "replied");
|
||||
m.inc("bridged_sends_total", "outcome", "replied");
|
||||
m.inc("bridged_sends_total", "outcome", "timeout");
|
||||
m.inc("fleet_sends_total", "outcome", "replied");
|
||||
m.inc("fleet_sends_total", "outcome", "replied");
|
||||
m.inc("fleet_sends_total", "outcome", "timeout");
|
||||
|
||||
assertEquals(2, m.count("bridged_sends_total", "outcome", "replied"));
|
||||
assertEquals(1, m.count("bridged_sends_total", "outcome", "timeout"));
|
||||
assertEquals(0, m.count("bridged_sends_total", "outcome", "failed"),
|
||||
assertEquals(2, m.count("fleet_sends_total", "outcome", "replied"));
|
||||
assertEquals(1, m.count("fleet_sends_total", "outcome", "timeout"));
|
||||
assertEquals(0, m.count("fleet_sends_total", "outcome", "failed"),
|
||||
"an untouched series reads as zero, not an error");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersHelpAndTypeOncePerFamily() {
|
||||
Metrics m = new Metrics();
|
||||
m.describe("bridged_sends_total", "counter", "Delegated sends by outcome.");
|
||||
m.inc("bridged_sends_total", "outcome", "replied");
|
||||
m.inc("bridged_sends_total", "outcome", "timeout");
|
||||
m.describe("fleet_sends_total", "counter", "Delegated sends by outcome.");
|
||||
m.inc("fleet_sends_total", "outcome", "replied");
|
||||
m.inc("fleet_sends_total", "outcome", "timeout");
|
||||
|
||||
String out = m.render();
|
||||
assertEquals(1, countOccurrences(out, "# HELP bridged_sends_total"),
|
||||
assertEquals(1, countOccurrences(out, "# HELP fleet_sends_total"),
|
||||
"HELP is per family, not per series");
|
||||
assertEquals(1, countOccurrences(out, "# TYPE bridged_sends_total counter"));
|
||||
assertTrue(out.contains("bridged_sends_total{outcome=\"replied\"} 1"));
|
||||
assertTrue(out.contains("bridged_sends_total{outcome=\"timeout\"} 1"));
|
||||
assertEquals(1, countOccurrences(out, "# TYPE fleet_sends_total counter"));
|
||||
assertTrue(out.contains("fleet_sends_total{outcome=\"replied\"} 1"));
|
||||
assertTrue(out.contains("fleet_sends_total{outcome=\"timeout\"} 1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -53,11 +53,11 @@ class MetricsTest {
|
||||
void gaugesAreEvaluatedAtScrapeTimeNotRegistrationTime() {
|
||||
Metrics m = new Metrics();
|
||||
int[] live = {1};
|
||||
m.gauge("bridged_sessions", () -> live[0], "state", "ready");
|
||||
m.gauge("fleet_sessions", () -> live[0], "state", "ready");
|
||||
|
||||
assertTrue(m.render().contains("bridged_sessions{state=\"ready\"} 1"));
|
||||
assertTrue(m.render().contains("fleet_sessions{state=\"ready\"} 1"));
|
||||
live[0] = 5;
|
||||
assertTrue(m.render().contains("bridged_sessions{state=\"ready\"} 5"),
|
||||
assertTrue(m.render().contains("fleet_sessions{state=\"ready\"} 5"),
|
||||
"the gauge must read current state on every scrape");
|
||||
}
|
||||
|
||||
@@ -78,15 +78,15 @@ class MetricsTest {
|
||||
void collectorsDiscoverTheirLabelSetPerScrape() {
|
||||
Metrics m = new Metrics();
|
||||
Map<String, Number> depths = new LinkedHashMap<>();
|
||||
m.collector("bridged_inbox_depth", "target", () -> depths);
|
||||
m.collector("fleet_inbox_depth", "target", () -> depths);
|
||||
|
||||
assertFalse(m.render().contains("bridged_inbox_depth"), "no targets yet ⇒ no series");
|
||||
assertFalse(m.render().contains("fleet_inbox_depth"), "no targets yet ⇒ no series");
|
||||
|
||||
depths.put("term_a", 2);
|
||||
depths.put("term_b", 0);
|
||||
String out = m.render();
|
||||
assertTrue(out.contains("bridged_inbox_depth{target=\"term_a\"} 2"));
|
||||
assertTrue(out.contains("bridged_inbox_depth{target=\"term_b\"} 0"));
|
||||
assertTrue(out.contains("fleet_inbox_depth{target=\"term_a\"} 2"));
|
||||
assertTrue(out.contains("fleet_inbox_depth{target=\"term_b\"} 0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -184,7 +184,7 @@ class FleetAppAuthTest {
|
||||
HttpResponse<String> ok = send(port, "GET", "/metrics", null, "Bearer s3cret");
|
||||
assertEquals(200, ok.statusCode());
|
||||
assertTrue(ok.headers().firstValue("Content-Type").orElse("").startsWith("text/plain"));
|
||||
assertTrue(ok.body().contains("bridged_sessions{state=\"ready\"}"),
|
||||
assertTrue(ok.body().contains("fleet_sessions{state=\"ready\"}"),
|
||||
"the session census gauge is exported even when empty");
|
||||
}
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ class FleetAppTest {
|
||||
FleetConfig.Profile wcfg = new FleetConfig.Profile(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
|
||||
"tab", "bridged-workers", "worker: {profile} #{n}", null,
|
||||
null, null, null, null, null, null, null, 0, null, null, null);
|
||||
null, null, null, null, null, null, null, 0, null, null, null, null);
|
||||
Map<String, FleetConfig.Profile> profiles = Map.of(wcfg.profile(), wcfg);
|
||||
ClaudeCodeLauncher delegate = new ClaudeCodeLauncher(
|
||||
new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
**Status:** ✅ shipped — implemented at commit `97ecc71` (per-worker git worktree + config-parity
|
||||
overlay). As-built: `session/GitWorktrees.java` behind the `Worktrees` port, wired in
|
||||
`Fleetd.main` and configurable via `worktreeRoot` / per-profile `parityOverlay`
|
||||
(see `bridged.example.yaml`). Branch/worktree surface in `fleet_list` landed with CB-304
|
||||
(see `fleetd.example.yaml`). Branch/worktree surface in `fleet_list` landed with CB-304
|
||||
(`9fe04bf`); the worker-opened-PR checkpoint landed as CB-302 (`64e70ef`).
|
||||
**Extends:** [CB-301 Session Manager](CB-301-Session-Manager.md) (shipped, commit `54d907c`).
|
||||
**Realizes:** the config-parity requirement in [Worker Git Workflow](Worker-Git-Workflow.md).
|
||||
|
||||
@@ -138,7 +138,7 @@ String kind // "claude-code" (default) | "opencode"
|
||||
so the record stays declarative.)
|
||||
- Keep the existing back-compat constructors; `kind` is additive and optional.
|
||||
|
||||
`bridged.example.yaml` documents a two-kind `workers:` block.
|
||||
`fleetd.example.yaml` documents a two-kind `workers:` block.
|
||||
|
||||
### C. `OpenCodeLauncher` — the adapter hooks for opencode
|
||||
|
||||
@@ -208,7 +208,7 @@ sequenceDiagram
|
||||
`ClaudeCodeLauncher` extend it with `namePrefix()="claude"` and `buildLaunch()` wrapping
|
||||
today's guard+env+argv logic. Green build, identical tests — pure refactor. *(IDE
|
||||
`refactor` where possible; the primary re-runs the gate workers can't.)*
|
||||
2. **`kind:` discriminator.** Add the field + normalization + `bridged.example.yaml`. Default
|
||||
2. **`kind:` discriminator.** Add the field + normalization + `fleetd.example.yaml`. Default
|
||||
path unchanged (`kind=claude-code`).
|
||||
3. **`OpenCodeLauncher`.** Implement the three hooks; unit-test `buildLaunch` (env has no
|
||||
`ANTHROPIC_BASE_URL`; `OPENCODE_CONFIG` points at a file carrying the bridge MCP block +
|
||||
@@ -301,5 +301,5 @@ identity (loopback peer PID → herdr pane) classified an **opencode** process a
|
||||
opencode-specific handling — confirming the identity model is peer-kind-agnostic, which is exactly
|
||||
what CB-308 needs when it stretches the roster across hosts.
|
||||
|
||||
CB-502 counters for the same run: `bridged_sends_total{outcome="replied"} 1`,
|
||||
`bridged_replies_total{path="rendezvous"} 1`, `bridged_inbox_depth{...} 0`.
|
||||
CB-502 counters for the same run: `fleet_sends_total{outcome="replied"} 1`,
|
||||
`fleet_replies_total{path="rendezvous"} 1`, `fleet_inbox_depth{...} 0`.
|
||||
|
||||
@@ -188,15 +188,15 @@ Deliberately small; every one maps to a failure mode we have actually hit.
|
||||
|
||||
| Metric | Type | Why it exists |
|
||||
|---|---|---|
|
||||
| `bridged_sends_total{outcome}` | counter | outcome ∈ replied\|completion_fallback\|timeout\|failed — the completion-fallback rate is the health signal for turn detection (CB-115/116/118) |
|
||||
| `bridged_send_duration_seconds` | histogram | delegated turn latency |
|
||||
| `bridged_replies_total{path}` | counter | path ∈ rendezvous\|inbox — how often a reply strands (CB-307's whole reason to exist) |
|
||||
| `bridged_inbox_depth{target}` | gauge | undrained replies; steady-state should be 0 |
|
||||
| `bridged_push_nudges_total{outcome}` | counter | outcome ∈ delivered\|exhausted — a rising `exhausted` means the primary is not draining |
|
||||
| `bridged_spawns_total{kind,outcome}` | counter | outcome ∈ ready\|timeout\|guard_rejected; per peer kind (CB-402) |
|
||||
| `bridged_sessions{state}` | gauge | SPAWNING/READY/BUSY/DONE census |
|
||||
| `bridged_herdr_calls_total{method,outcome}` | counter | socket health — the dependency everything rests on |
|
||||
| `bridged_auth_failures_total{reason}` | counter | only meaningful once CB-501 lands; catches misconfigured workers |
|
||||
| `fleet_sends_total{outcome}` | counter | outcome ∈ replied\|completion_fallback\|timeout\|failed — the completion-fallback rate is the health signal for turn detection (CB-115/116/118) |
|
||||
| `fleet_send_duration_seconds` | histogram | delegated turn latency |
|
||||
| `fleet_replies_total{path}` | counter | path ∈ rendezvous\|inbox — how often a reply strands (CB-307's whole reason to exist) |
|
||||
| `fleet_inbox_depth{target}` | gauge | undrained replies; steady-state should be 0 |
|
||||
| `fleet_push_nudges_total{outcome}` | counter | outcome ∈ delivered\|exhausted — a rising `exhausted` means the primary is not draining |
|
||||
| `fleet_spawns_total{kind,outcome}` | counter | outcome ∈ ready\|timeout\|guard_rejected; per peer kind (CB-402) |
|
||||
| `fleet_sessions{state}` | gauge | SPAWNING/READY/BUSY/DONE census |
|
||||
| `fleet_herdr_calls_total{method,outcome}` | counter | socket health — the dependency everything rests on |
|
||||
| `fleet_auth_failures_total{reason}` | counter | only meaningful once CB-501 lands; catches misconfigured workers |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -687,14 +687,14 @@ The sink response body is ignored. A webhook cannot direct recovery. n8n remains
|
||||
M4 adds bounded-label series:
|
||||
|
||||
```text
|
||||
bridged_health_incidents{scope,state,severity}
|
||||
bridged_health_incidents_total{event}
|
||||
bridged_health_notifications_total{event,outcome}
|
||||
bridged_health_notification_queue_depth
|
||||
bridged_health_notification_last_success_seconds
|
||||
bridged_health_notification_capability{mode,status}
|
||||
bridged_lead_health{lead,state}
|
||||
bridged_lead_assigned_incidents{lead}
|
||||
fleet_health_incidents{scope,state,severity}
|
||||
fleet_health_incidents_total{event}
|
||||
fleet_health_notifications_total{event,outcome}
|
||||
fleet_health_notification_queue_depth
|
||||
fleet_health_notification_last_success_seconds
|
||||
fleet_health_notification_capability{mode,status}
|
||||
fleet_lead_health{lead,state}
|
||||
fleet_lead_assigned_incidents{lead}
|
||||
```
|
||||
|
||||
Metric labels never include terminal ids, incident ids, URLs, or error text.
|
||||
@@ -927,7 +927,7 @@ Acceptance criteria:
|
||||
15. Webhook response bodies are ignored and cannot direct recovery.
|
||||
16. Tests cover disabled mode, one lead without sink, open/update/reminder/resolve, restart, dedup,
|
||||
reassignment, disable/re-enable, and sink failure while local health continues.
|
||||
17. `bridged.example.yaml` documents all hot keys and compiled floors.
|
||||
17. `fleetd.example.yaml` documents all hot keys and compiled floors.
|
||||
18. The operator Features wiki is updated separately. The portable `CLAUDE.md` block is checked and
|
||||
changed only if shipped tool or inbox semantics make it untrue.
|
||||
19. `mvn clean install` passes.
|
||||
|
||||
@@ -179,6 +179,22 @@ else
|
||||
warn "This only matters once a profile points at llm.ltms.dev — harmless before that."
|
||||
fi
|
||||
|
||||
# Third variable, same trap (CB-635). broker.uriEnv names the env var holding the AMQP URI, so the
|
||||
# password stays out of bridged.yaml — but that moves the failure into the environment. If the
|
||||
# variable is empty the daemon still starts: since #152 it warns and falls back to the in-memory
|
||||
# reply inbox, so nothing crashes and replies simply stop surviving a restart. Only this check says
|
||||
# so before the fact. Read the name out of the config so a renamed key cannot make the check lie.
|
||||
BROKER_URI_ENV=$(sed -n 's/^[[:space:]]*uriEnv:[[:space:]]*\([A-Za-z_][A-Za-z0-9_]*\).*/\1/p' "$BRIDGED/bridged.yaml" | head -1)
|
||||
if [ -z "$BROKER_URI_ENV" ]; then
|
||||
ok "no broker.uriEnv configured — reply inbox is in-memory by design"
|
||||
elif zsh -lc "[ -n \"\${$BROKER_URI_ENV:-}\" ]" 2>/dev/null; then
|
||||
ok "$BROKER_URI_ENV (broker.uriEnv) resolves in a login shell"
|
||||
else
|
||||
warn "$BROKER_URI_ENV (broker.uriEnv) is EMPTY in a login shell."
|
||||
warn "The daemon will start and fall back to the IN-MEMORY reply inbox."
|
||||
warn "Replies stop surviving a restart — a held report is lost, not delayed."
|
||||
fi
|
||||
|
||||
if [ "$CHECK_ONLY" = 1 ]; then
|
||||
say "--check: nothing changed"
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user