diff --git a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java
index 39a252e..af0470e 100644
--- a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java
+++ b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java
@@ -766,10 +766,28 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
}
}
- /** A fresh mutable env map — the conventional starting point for {@link #buildLaunch}. */
+ /**
+ * CB-592: overlay value that shadows the admin {@code GITEA_ACCESS_TOKEN} a herdr pane
+ * otherwise inherits from herdr's own login-shell process environment (gitea issue #77).
+ * herdr spawns a pane from its own process environment and layers our map on top —
+ * {@link dev.ltms.bridged.herdr.WorkspaceControl#createTab} and {@code #splitPane} send only
+ * the keys we put in that map, so any key we never mention passes straight through from
+ * herdr's own shell, admin token included.
+ *
+ *
Deliberately a non-blank sentinel, not {@code ""}. Whether an empty-string overlay value
+ * overrides an inherited variable or is skipped as blank could not be settled by reading this
+ * codebase — herdr's server-side merge is an external process, not something in this repo.
+ * A non-blank replacement sidesteps that ambiguity entirely: {@link #baseEnv}'s own {@code
+ * PATH} seeding already depends on the overlay reliably replacing an inherited value (see its
+ * javadoc), and that is only demonstrated for a non-blank value, so this reuses the same,
+ * proven-reliable shape rather than the unverified one.
+ */
+ private static final String BLOCKED_GITEA_ACCESS_TOKEN =
+ "blocked-by-bridged-cb592-see-gitea-issue-77";
+
/**
* Seed a worker's environment (CB-511): the daemon's own {@code PATH}, then the profile's
- * {@code env:} entries.
+ * {@code env:} entries, then the CB-592 admin-token shadow.
*
*
Why this exists: bridged passes herdr an explicit env map, and herdr merges it into
* its own process environment. So before this, a worker inherited whatever PATH the
@@ -783,6 +801,11 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
* overriding {@code ANTHROPIC_BASE_URL} and slipping past {@link
* dev.ltms.bridged.guard.SubscriptionGuard}, which is checked against the profile's
* {@code baseUrl} and nothing else.
+ *
+ *
The CB-592 shadow is put in last, after the profile's own {@code env:}, so no
+ * profile — present or future — can restore the admin token by naming it in config. This is
+ * the one place the shadow is applied: every {@code buildLaunch} in every adapter calls this
+ * first, so a new profile, and a peer kind not yet written, gets it for free.
*/
protected Map baseEnv(BridgedConfig.Profile cfg) {
Map workerEnv = new LinkedHashMap<>();
@@ -793,6 +816,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
if (cfg != null && cfg.env() != null) {
workerEnv.putAll(cfg.env());
}
+ workerEnv.put("GITEA_ACCESS_TOKEN", BLOCKED_GITEA_ACCESS_TOKEN);
return workerEnv;
}
diff --git a/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java
index ca5ccad..9b77a03 100644
--- a/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java
@@ -676,6 +676,46 @@ class ClaudeCodeLauncherTest {
"the guard-checked baseUrl must win over any env: entry, or the boundary is bypassable");
}
+ // --- CB-592: the admin GITEA_ACCESS_TOKEN never reaches a member -----------------------------
+
+ /**
+ * herdr's env map is an overlay onto its own (login-shell) process environment, so a worker
+ * inherits whatever the daemon's shell carries — including the admin GITEA_ACCESS_TOKEN — for
+ * every key baseEnv does not explicitly shadow. This pins that the launcher DOES send an
+ * explicit (non-blank) GITEA_ACCESS_TOKEN to herdr on every spawn, whatever the profile is, so
+ * a future baseEnv refactor cannot silently drop it and reopen the leak. Asserted against what
+ * tab.create's params actually carry, not an internal map built in the test (gitea #77).
+ */
+ @Test
+ void everySpawnShadowsTheAdminGiteaAccessToken() {
+ FakeHerdr herdr = new FakeHerdr();
+ service(herdr, List.of("claude"), null).spawn();
+
+ String shadowed = startEnv(herdr).get("GITEA_ACCESS_TOKEN");
+ assertNotNull(shadowed, "GITEA_ACCESS_TOKEN must be explicitly overlaid, not left unmentioned");
+ assertFalse(shadowed.isBlank(), "a blank overlay value's override behaviour is unverified — must be non-blank");
+ }
+
+ /**
+ * No profile — present or future — may restore the admin token by naming it in {@code env:}.
+ * The shadow is applied after the profile's own env in {@link HerdrPeerLauncher#baseEnv}
+ * precisely so this can never happen; this test pins that ordering.
+ */
+ @Test
+ void aProfileEnvEntryCannotRestoreTheAdminGiteaAccessToken() {
+ FakeHerdr herdr = new FakeHerdr();
+ BridgedConfig.Profile cfg = new BridgedConfig.Profile(
+ "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
+ List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null,
+ null, Map.of("GITEA_ACCESS_TOKEN", "admin-secret-from-profile-config"), null, null);
+ new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
+ _ -> null).spawn();
+
+ assertNotEquals("admin-secret-from-profile-config", startEnv(herdr).get("GITEA_ACCESS_TOKEN"),
+ "a profile's own env: must not be able to smuggle the admin token back in");
+ }
+
// ── CB-533: the model is pinned on the command line, not only in the environment ────────────
/** A launcher for a profile identical but for its {@code model:} — the only variable here. */
diff --git a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java
index 5c1600e..0ee1bfa 100644
--- a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java
@@ -208,6 +208,21 @@ class OpenCodeLauncherTest {
"a git-token profile gets the peer-neutral GITEA_TOKEN grant, same as Claude");
}
+ /**
+ * CB-592: the shadow lives in {@link HerdrPeerLauncher#baseEnv}, shared by every adapter — this
+ * pins that the opencode path gets it too, not just Claude's. See the matching test in
+ * {@code ClaudeCodeLauncherTest} for the full rationale (gitea #77).
+ */
+ @Test
+ void everySpawnShadowsTheAdminGiteaAccessToken(@TempDir Path root) {
+ FakeHerdr herdr = new FakeHerdr();
+ service(herdr, root, opencodeCfg(null, null, null)).spawn();
+
+ String shadowed = startEnv(herdr).get("GITEA_ACCESS_TOKEN");
+ assertNotNull(shadowed, "GITEA_ACCESS_TOKEN must be explicitly overlaid, not left unmentioned");
+ assertFalse(shadowed.isBlank(), "a blank overlay value's override behaviour is unverified — must be non-blank");
+ }
+
@Test
void capabilitiesDeclareOrphanReapAndMcpAskAndConditionalSelfPr(@TempDir Path root) {
FakeHerdr herdr = new FakeHerdr();