CB-112: workers inherit the primary's working directory (not $HOME)

A worker now opens the same directory the primary is in, unless told otherwise.
Resolution: explicit spawn cwd → per-profile config cwd → the primary's cwd
(auto-detected from the bridge_spawn caller's PID via lsof -d cwd) → the daemon's
cwd. Never $HOME.

Mechanism (found by live probe, corrects the earlier assumption): an agent.start
pane does NOT inherit its tab's or workspace's cwd — it starts in $HOME. herdr's
agent.start honours an (undocumented) cwd param, so the resolved cwd is threaded
onto agent.start {cwd} (both tab and pane placement), not tab.create.

Surfaces: bridge_spawn {cwd?} + auto-detect via ConnectionIdentity.resolve (peer
PID) + ProcessCwdLookup (lsof); REST POST /workers ?cwd= / body cwd; per-profile
'cwd:' config. Validated live: explicit cwd → worker rooted there; no cwd over
REST → daemon cwd, not $HOME. Also clears the ccs folder-trust prompt when the
project dir is already trusted (see docs/Worker-Startup-and-Trust.md).
This commit is contained in:
Dai Ha
2026-07-15 16:33:46 +02:00
parent 959f04bc96
commit 926724a279
16 changed files with 311 additions and 69 deletions
+3
View File
@@ -23,6 +23,9 @@ herdrSocket: ~/.config/herdr/herdr.sock
# (--append-system-prompt) as launch flags; nothing is written to the profile.
# 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
# cwd on an MCP spawn, else the daemon's cwd — never $HOME. See
# docs/Worker-Startup-and-Trust.md.
# Put `defaultMode: "auto"` in each ccs profile so the worker runs autonomously.
workers:
gx10: # ccs profile name (NOT a hostname)
@@ -12,6 +12,7 @@ import dev.ltms.bridged.inject.StatusPoller;
import dev.ltms.bridged.mcp.BridgeMcp;
import dev.ltms.bridged.mcp.ConnectionIdentity;
import dev.ltms.bridged.mcp.LsofPeerPidLookup;
import dev.ltms.bridged.mcp.LsofProcessCwdLookup;
import dev.ltms.bridged.msg.MessageService;
import dev.ltms.bridged.msg.Rendezvous;
import dev.ltms.bridged.rest.BridgedApp;
@@ -69,7 +70,8 @@ public final class Bridged {
// MCP server face (CB-105): bridge_send/bridge_reply/bridge_status, mounted at /mcp.
// Caller identity is resolved from the connection (peer PID → herdr pane), not arguments.
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), new LsofPeerPidLookup());
ConnectionIdentity identity = new ConnectionIdentity(
new PaneLocator(herdr), new LsofPeerPidLookup(), new LsofProcessCwdLookup());
BridgeMcp mcp = new BridgeMcp(messages, rendezvous, workers, identity);
Runtime.getRuntime().addShutdownHook(new Thread(mcp::close));
Javalin app = new BridgedApp(herdr, workers, messages, rendezvous, mcp.servlet()).build();
@@ -63,11 +63,14 @@ public record BridgedConfig(
* @param mcpUrl bridge MCP URL to provision into the worker's {@code configDir} so it
* can call {@code bridge_reply} ({@code null}/blank → no provisioning; the
* worker won't reply, only the fallback/timeout resolves the send)
* @param cwd fixed working directory for this profile's workers (CB-112 "told otherwise");
* {@code null}/blank → inherit the primary's cwd, else the daemon's
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Worker(String profile, String baseUrl, String model,
String configDir, String tokenEnv, List<String> argv,
String placement, String workspace, String tabLabel, String mcpUrl) {
String placement, String workspace, String tabLabel, String mcpUrl,
String cwd) {
public Worker {
argv = (argv == null || argv.isEmpty()) ? List.of("claude") : List.copyOf(argv);
tokenEnv = (tokenEnv == null || tokenEnv.isBlank()) ? "BRIDGED_WORKER_TOKEN" : tokenEnv;
@@ -78,7 +81,8 @@ public record BridgedConfig(
/** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */
public Worker withProfile(String p) {
return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, mcpUrl);
return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
mcpUrl, cwd);
}
/** True when workers should land in their own tab in the worker space. */
@@ -47,12 +47,20 @@ public final class AgentControl {
return start(name, argv, env, null);
}
/**
* Spawn an agent into a specific tab. With a non-null {@code tabId} the worker lands
* in that tab (the placement policy's dedicated worker tab); with {@code null} herdr
* splits the currently-focused tab (legacy pane placement).
*/
/** Spawn an agent into {@code tabId} at herdr's default cwd. */
public Agent start(String name, List<String> argv, Map<String, String> env, String tabId) {
return start(name, argv, env, tabId, null);
}
/**
* Spawn an agent. With a non-null {@code tabId} the worker lands in that tab (the placement
* policy's dedicated worker tab); with {@code null} herdr splits the currently-focused tab
* (legacy pane placement). A non-blank {@code cwd} sets the worker process's working directory —
* {@code agent.start} honours {@code cwd} directly (an agent pane does <em>not</em> inherit the
* tab's or workspace's cwd, so this is the only way to root a worker in the primary's directory;
* CB-112).
*/
public Agent start(String name, List<String> argv, Map<String, String> env, String tabId, String cwd) {
Map<String, Object> params = new LinkedHashMap<>();
params.put("name", name);
params.put("argv", argv);
@@ -60,6 +68,9 @@ public final class AgentControl {
if (tabId != null) {
params.put("tab_id", tabId);
}
if (cwd != null && !cwd.isBlank()) {
params.put("cwd", cwd);
}
JsonNode result = herdr.call("agent.start", params);
return Agent.from(result.get("agent"));
}
@@ -65,13 +65,13 @@ public final class WorkspaceControl {
}
/**
* A brand-new tab in {@code workspaceId} plus the placeholder shell pane herdr seeds
* it with. Start the worker into the tab, then {@code pane.close} the root pane so the
* tab holds only the worker.
* A brand-new tab in {@code workspaceId} plus the placeholder shell pane herdr seeds it with.
* Start the worker into the tab, then {@code pane.close} the root pane so the tab holds only the
* worker. (The worker's own cwd is set on {@code agent.start}, not here — an {@code agent.start}
* pane does not inherit the tab's cwd; see {@code AgentControl.start}.)
*/
public Tab.Created createTab(String workspaceId) {
JsonNode result = herdr.call("tab.create", Map.of("workspace_id", workspaceId));
return Tab.Created.from(result);
return Tab.Created.from(herdr.call("tab.create", Map.of("workspace_id", workspaceId)));
}
/** Give a worker's tab a human label in the tab bar. */
@@ -43,6 +43,8 @@ public final class BridgeMcp {
/** Transport-context key under which the extractor stashes the resolved caller identity. */
static final String CALLER_TERMINAL = "callerTerminal";
/** Transport-context key under which the extractor stashes the caller's PID (for cwd inherit). */
static final String CALLER_PID = "callerPid";
private final HttpServletStreamableServerTransportProvider transport;
private final McpSyncServer server;
@@ -53,10 +55,15 @@ public final class BridgeMcp {
this.transport = HttpServletStreamableServerTransportProvider.builder()
.jsonMapper(json)
.mcpEndpoint("/mcp")
// Resolve the caller's worker identity from the connection (peer PID → herdr pane),
// so bridge_reply needs no spoofable session argument.
.contextExtractor(req -> McpTransportContext.create(Map.of(
CALLER_TERMINAL, orEmpty(identity.callerTerminal(req.getRemoteAddr(), req.getRemotePort())))))
// Resolve the caller from the connection (peer PID → herdr pane) in one lookup: the
// worker terminal for bridge_reply (no spoofable arg), and the PID so bridge_spawn can
// inherit the primary's cwd (CB-112).
.contextExtractor(req -> {
ConnectionIdentity.Caller c = identity.resolve(req.getRemoteAddr(), req.getRemotePort());
return McpTransportContext.create(Map.of(
CALLER_TERMINAL, orEmpty(c.terminal()),
CALLER_PID, Long.toString(c.pid())));
})
.build();
this.server = McpServer.sync(transport)
.serverInfo("bridge", "0.1.0")
@@ -76,7 +83,12 @@ public final class BridgeMcp {
.toolCall(pollTool(), (_, req) ->
poll(messages, str(req.arguments(), "ticket")))
// Fleet management (CB-108): spawn/list/stop over WorkerService.
.toolCall(spawnTool(), (_, req) -> spawn(workers, str(req.arguments(), "profile")))
.toolCall(spawnTool(), (exchange, req) -> {
Map<String, Object> a = req.arguments();
// CB-112: worker inherits the primary's cwd unless the call pins one.
String callerCwd = identity.cwdForPid(callerPid(exchange));
return spawn(workers, str(a, "profile"), str(a, "cwd"), callerCwd);
})
.toolCall(listTool(), (_, _) -> listWorkers(workers))
.toolCall(stopTool(), (_, req) -> stop(workers, str(req.arguments(), "paneId")))
.toolCall(profilesTool(), (_, _) -> profiles(workers))
@@ -90,6 +102,16 @@ public final class BridgeMcp {
return (s == null || s.isBlank()) ? null : s;
}
/** The caller's PID resolved from this call's connection, or {@code -1} if unknown. */
private static long callerPid(McpSyncServerExchange exchange) {
Object v = exchange.transportContext().get(CALLER_PID);
try {
return v == null ? -1 : Long.parseLong(v.toString());
} catch (NumberFormatException e) {
return -1;
}
}
private static String orEmpty(String s) {
return s == null ? "" : s;
}
@@ -196,13 +218,20 @@ public final class BridgeMcp {
// --- fleet management logic (CB-108) -------------------------------------------------------
/** {@code bridge_spawn} without cwd/caller context (default resolution). */
static McpSchema.CallToolResult spawn(WorkerService workers, String profile) {
return spawn(workers, profile, null, null);
}
/**
* {@code bridge_spawn}: launch a guard-checked worker for {@code profile} (blank → the default
* profile) and return its session id + pane id.
* profile) and return its session id + pane id. The worker's cwd is {@code requestedCwd} if given,
* else the profile's config, else {@code callerCwd} (the primary's directory), else the daemon's.
*/
static McpSchema.CallToolResult spawn(WorkerService workers, String profile) {
static McpSchema.CallToolResult spawn(WorkerService workers, String profile,
String requestedCwd, String callerCwd) {
try {
Agent worker = isBlank(profile) ? workers.spawn() : workers.spawn(profile);
Agent worker = workers.spawn(isBlank(profile) ? null : profile, requestedCwd, callerCwd);
return text(json(workerView(worker)));
} catch (GuardException e) {
return error("subscription boundary: " + e.getMessage());
@@ -288,10 +317,12 @@ public final class BridgeMcp {
private static McpSchema.Tool spawnTool() {
return tool("bridge_spawn",
"Spawn a new off-subscription worker session. Pass a profile (from bridge_profiles) to "
+ "pick the backend, or omit it for the default. Returns the worker's sessionId "
+ "(use with bridge_send) and paneId (use with bridge_stop).",
+ "pick the backend, or omit it for the default. The worker opens your current "
+ "directory by default; pass cwd to pin a different one. Returns the worker's "
+ "sessionId (use with bridge_send) and paneId (use with bridge_stop).",
objectSchema(Map.of(
"profile", stringProp("Worker profile to spawn (omit for the default profile)")),
"profile", stringProp("Worker profile to spawn (omit for the default profile)"),
"cwd", stringProp("Working directory for the worker (omit to inherit yours)")),
List.of()));
}
@@ -17,10 +17,34 @@ public final class ConnectionIdentity {
private final PaneLocator panes;
private final PeerPidLookup pids;
private final ProcessCwdLookup cwds;
/** Identity only (no cwd resolution — {@link #cwdForPid} returns {@code null}). */
public ConnectionIdentity(PaneLocator panes, PeerPidLookup pids) {
this(panes, pids, _ -> null);
}
/** Identity plus cwd resolution (CB-112 — inherit the primary's directory on spawn). */
public ConnectionIdentity(PaneLocator panes, PeerPidLookup pids, ProcessCwdLookup cwds) {
this.panes = panes;
this.pids = pids;
this.cwds = cwds;
}
/**
* The caller resolved from the connection: its worker {@code terminal} (or {@code null} for the
* primary / an off-host client) and its {@code pid} (or {@code -1} if not resolvable).
*/
public record Caller(String terminal, long pid) {
}
/** Resolve the caller's terminal and PID from one peer-PID lookup. */
public Caller resolve(String remoteAddr, int remotePort) {
if (!isLoopback(remoteAddr)) {
return new Caller(null, -1); // only same-host callers can be workers
}
long pid = pids.pidForLocalPort(remotePort);
return new Caller(panes.terminalForPid(pid), pid);
}
/**
@@ -28,10 +52,12 @@ public final class ConnectionIdentity {
* on-host worker (treat as the primary).
*/
public String callerTerminal(String remoteAddr, int remotePort) {
if (!isLoopback(remoteAddr)) {
return null; // only same-host callers can be workers
}
return panes.terminalForPid(pids.pidForLocalPort(remotePort));
return resolve(remoteAddr, remotePort).terminal();
}
/** The working directory of {@code pid} (the primary's cwd on an MCP spawn), or {@code null}. */
public String cwdForPid(long pid) {
return pid > 0 ? cwds.cwdForPid(pid) : null;
}
private static boolean isLoopback(String addr) {
@@ -0,0 +1,48 @@
package dev.ltms.bridged.mcp;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
/**
* {@link ProcessCwdLookup} via {@code lsof} (present on macOS and Linux): {@code lsof -a -p <pid>
* -d cwd -Fn} prints the process's cwd on the {@code n…} line. Used to inherit the primary's
* working directory for a spawned worker (CB-112).
*/
public final class LsofProcessCwdLookup implements ProcessCwdLookup {
private static final Logger log = LoggerFactory.getLogger(LsofProcessCwdLookup.class);
@Override
public String cwdForPid(long pid) {
if (pid <= 0) {
return null;
}
try {
Process p = new ProcessBuilder("lsof", "-a", "-p", Long.toString(pid), "-d", "cwd", "-Fn")
.redirectErrorStream(true).start();
String cwd = null;
try (BufferedReader r = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
if (line.startsWith("n")) { // 'n<path>' is the file-name field for the cwd fd
cwd = line.substring(1);
break;
}
}
}
if (!p.waitFor(2, TimeUnit.SECONDS)) {
p.destroyForcibly();
}
return (cwd == null || cwd.isBlank()) ? null : cwd;
} catch (Exception e) {
log.debug("lsof cwd lookup for pid {} failed: {}", pid, e.getMessage());
return null;
}
}
}
@@ -0,0 +1,13 @@
package dev.ltms.bridged.mcp;
/**
* Resolves a process's current working directory from its PID — the OS half of CB-112's
* "a worker inherits the primary's directory." Injectable so {@link ConnectionIdentity} stays
* testable without shelling out.
*/
@FunctionalInterface
public interface ProcessCwdLookup {
/** The working directory of {@code pid}, or {@code null} if unknown. */
String cwdForPid(long pid);
}
@@ -124,18 +124,22 @@ public final class BridgedApp {
*/
private void spawnWorker(Context ctx) {
String profile = ctx.queryParam("profile");
if (profile == null || profile.isBlank()) {
String cwd = ctx.queryParam("cwd");
if (profile == null || profile.isBlank() || cwd == null || cwd.isBlank()) {
try {
String body = ctx.body();
if (!body.isBlank()) {
profile = mapper.readTree(body).path("profile").asText(null);
JsonNode b = mapper.readTree(body);
if (profile == null || profile.isBlank()) profile = b.path("profile").asText(null);
if (cwd == null || cwd.isBlank()) cwd = b.path("cwd").asText(null);
}
} catch (Exception ignored) {
// A malformed/empty body just means "no profile" → fall through to the default.
// A malformed/empty body just means "no overrides" → fall through to defaults.
}
}
try {
Agent worker = (profile == null || profile.isBlank()) ? workers.spawn() : workers.spawn(profile);
// No MCP caller over REST, so callerCwd is null → the daemon cwd is the last resort.
Agent worker = workers.spawn(blankToNull(profile), blankToNull(cwd), null);
ctx.status(201).json(view(worker));
} catch (GuardException e) {
ctx.status(403).json(Map.of("error", "subscription_boundary", "detail", e.getMessage()));
@@ -144,6 +148,10 @@ public final class BridgedApp {
}
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
/** Tear a worker down by pane id. */
private void stopWorker(Context ctx) {
workers.stop(ctx.pathParam("paneId"));
@@ -86,20 +86,32 @@ public final class WorkerService {
return defaultProfile;
}
/** Spawn a worker for the default profile. Guard runs before any herdr call. */
/** Spawn a worker for the default profile in the resolved default cwd. */
public Agent spawn() {
if (defaultProfile == null || defaultProfile.isBlank()) {
return spawn(null, null, null);
}
/** Spawn a worker for a named profile (null → default) in the resolved default cwd. */
public Agent spawn(String profileName) {
return spawn(profileName, null, null);
}
/**
* Spawn a worker. {@code profileName} null/blank → the default profile. The worker's working
* directory (CB-112) is resolved by {@link #resolveCwd}: an explicit {@code requestedCwd} (a
* spawn argument), else the profile's configured {@code cwd}, else {@code callerCwd} (the
* primary's cwd, when the spawn came from the primary over MCP), else the daemon's cwd — never
* assumed to be {@code $HOME}. Guard runs before any herdr call.
*/
public Agent spawn(String profileName, String requestedCwd, String callerCwd) {
String name = (profileName == null || profileName.isBlank()) ? defaultProfile : profileName;
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("no default worker profile is configured — "
+ "pass a profile; configured: " + profiles.keySet());
}
return spawn(defaultProfile);
}
/** Spawn a worker for a named profile. Guard runs before any herdr call. */
public Agent spawn(String profileName) {
BridgedConfig.Worker cfg = profiles.get(profileName);
BridgedConfig.Worker cfg = profiles.get(name);
if (cfg == null) {
throw new IllegalArgumentException("unknown worker profile '" + profileName
throw new IllegalArgumentException("unknown worker profile '" + name
+ "' — configured: " + profiles.keySet());
}
String baseUrl = cfg.baseUrl();
@@ -115,8 +127,23 @@ public final class WorkerService {
// Mount the bridge MCP + reply charter as launch FLAGS (non-invasive: nothing written to
// the worker's profile/config dir). Identity is connection-based, so the mount is shared.
List<String> argv = argvWithBridge(cfg);
String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
return cfg.tabPlacement() ? spawnInTab(cfg, workerEnv, argv) : spawnAsPane(cfg, workerEnv, argv);
return cfg.tabPlacement()
? spawnInTab(cfg, workerEnv, argv, cwd)
: spawnAsPane(cfg, workerEnv, argv, cwd);
}
/** CB-112 cwd resolution: spawn arg → profile config → the primary's cwd → the daemon's cwd. */
private static String resolveCwd(String requestedCwd, BridgedConfig.Worker cfg, String callerCwd) {
return firstNonBlank(requestedCwd, cfg.cwd(), callerCwd, System.getProperty("user.dir"));
}
private static String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) return v;
}
return null;
}
/**
@@ -138,16 +165,17 @@ public final class WorkerService {
return argv;
}
/** Dedicated worker space → own tab → drop the placeholder shell so only the worker remains. */
private Agent spawnInTab(BridgedConfig.Worker cfg, Map<String, String> workerEnv, List<String> argv) {
/** Dedicated worker space → own tab → start the worker (rooted at {@code cwd}) → drop the shell. */
private Agent spawnInTab(BridgedConfig.Worker cfg, Map<String, String> workerEnv,
List<String> argv, String cwd) {
Workspace space = spaces.ensureWorkspace(cfg.workspace());
Tab.Created tab = spaces.createTab(space.workspaceId());
log.info("spawning worker profile={} base_url={} space={} tab={}",
cfg.profile(), cfg.baseUrl(), space.workspaceId(), tab.tab().tabId());
log.info("spawning worker profile={} base_url={} space={} tab={} cwd={}",
cfg.profile(), cfg.baseUrl(), space.workspaceId(), tab.tab().tabId(), cwd);
Started started;
try {
started = startUniquelyNamed(cfg, workerEnv, argv, tab.tab().tabId());
started = startUniquelyNamed(cfg, workerEnv, argv, tab.tab().tabId(), cwd);
} catch (RuntimeException e) {
// The worker never started — don't leave the tab we just created orphaned.
// Best-effort cleanup; never let it mask the real spawn failure.
@@ -186,11 +214,12 @@ public final class WorkerService {
}
}
/** Legacy placement: herdr splits the currently-focused tab. */
private Agent spawnAsPane(BridgedConfig.Worker cfg, Map<String, String> workerEnv, List<String> argv) {
log.info("spawning worker (pane placement) profile={} base_url={} argv={}",
cfg.profile(), cfg.baseUrl(), argv);
Agent worker = startUniquelyNamed(cfg, workerEnv, argv, null).agent();
/** Legacy placement: herdr splits the currently-focused tab; the worker still starts in {@code cwd}. */
private Agent spawnAsPane(BridgedConfig.Worker cfg, Map<String, String> workerEnv,
List<String> argv, String cwd) {
log.info("spawning worker (pane placement) profile={} base_url={} cwd={} argv={}",
cfg.profile(), cfg.baseUrl(), cwd, argv);
Agent worker = startUniquelyNamed(cfg, workerEnv, argv, null, cwd).agent();
log.info("worker started pane={} terminal={}", worker.paneId(), worker.terminalId());
return worker;
}
@@ -210,13 +239,13 @@ public final class WorkerService {
* the name is a label only — herdr detects kind and status from terminal output, not it.
*/
private Started startUniquelyNamed(BridgedConfig.Worker cfg, Map<String, String> workerEnv,
List<String> argv, String tabId) {
List<String> argv, String tabId, String cwd) {
HerdrException last = null;
for (int attempt = 0; attempt < NAME_RETRIES; attempt++) {
long seq = nameSeq.incrementAndGet();
String name = "claude-" + cfg.profile() + "-" + nameNonce + "-" + seq;
try {
return new Started(agents.start(name, argv, workerEnv, tabId), seq);
return new Started(agents.start(name, argv, workerEnv, tabId, cwd), seq);
} catch (HerdrException e) {
if (!"agent_name_taken".equals(e.code())) throw e;
log.debug("worker name '{}' taken, retrying", name);
@@ -38,7 +38,7 @@ class BridgeMcpTest {
private static WorkerService workerService(FakeHerdr h, String baseUrl, Set<String> allow) {
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
"ltms-local", baseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null,
"tab", "bridged-workers", "worker: {profile} #{n}", null);
"tab", "bridged-workers", "worker: {profile} #{n}", null, null);
return new WorkerService(new AgentControl(h), new WorkspaceControl(h),
new SubscriptionGuard(allow), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> "tok");
}
@@ -149,6 +149,17 @@ class BridgeMcpTest {
assertTrue(textOf(res).contains("unknown worker profile"), textOf(res));
}
@Test
void spawnPassesTheRequestedCwdToTheWorker() {
FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.spawn(
workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "/req/dir", null);
assertNotEquals(Boolean.TRUE, res.isError());
@SuppressWarnings("unchecked")
Map<String, Object> start = (Map<String, Object>) h.lastCall("agent.start").params();
assertEquals("/req/dir", start.get("cwd"));
}
@Test
void profilesListsConfiguredProfilesAndDefault() {
FakeHerdr h = new FakeHerdr();
@@ -31,4 +31,16 @@ class ConnectionIdentityTest {
// e.g. the primary — its PID maps to no worker pane.
assertNull(with(_ -> 999_999).callerTerminal("127.0.0.1", 55555));
}
@Test
void resolvesTheCallersPidAndCwd() {
// CB-112: the primary maps to no pane, but its PID and cwd are still readable.
ConnectionIdentity id = new ConnectionIdentity(
new PaneLocator(herdr), _ -> 999_999, pid -> pid == 999_999 ? "/main/project" : null);
ConnectionIdentity.Caller c = id.resolve("127.0.0.1", 55555);
assertNull(c.terminal(), "the primary owns no worker pane");
assertEquals(999_999, c.pid());
assertEquals("/main/project", id.cwdForPid(c.pid()), "the primary's cwd is resolvable from its PID");
assertNull(id.cwdForPid(-1), "no cwd for an unresolved PID");
}
}
@@ -52,7 +52,7 @@ class BridgedAppTest {
private int start(FakeHerdr herdr, String workerBaseUrl, Set<String> allow, String placement) {
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
"ltms-local", workerBaseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null,
placement, "bridged-workers", "worker: {profile} #{n}", null);
placement, "bridged-workers", "worker: {profile} #{n}", null, null);
AgentControl agents = new AgentControl(herdr);
WorkerService workers = new WorkerService(
agents, new WorkspaceControl(herdr), new SubscriptionGuard(allow),
@@ -171,6 +171,14 @@ class BridgedAppTest {
assertEquals("ltms-local", body.get("profiles").get(0).asText());
}
@Test
void spawnWithACwdParamRootsTheWorkerThere() throws Exception {
FakeHerdr herdr = new FakeHerdr();
int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"));
assertEquals(201, req(port, "POST", "/workers?cwd=/tmp/proj").statusCode());
assertEquals("/tmp/proj", params(herdr, "agent.start").get("cwd"), "the worker starts in cwd");
}
@Test
void spawnWithAnUnknownProfileIs400() throws Exception {
FakeHerdr herdr = new FakeHerdr();
@@ -19,7 +19,7 @@ class WorkerServiceTest {
private WorkerService service(FakeHerdr herdr, List<String> argv, String mcpUrl) {
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
argv, "tab", "bridged-workers", "worker: {profile} #{n}", mcpUrl);
argv, "tab", "bridged-workers", "worker: {profile} #{n}", mcpUrl, null);
return new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
}
@@ -52,9 +52,9 @@ class WorkerServiceTest {
private WorkerService multiProfile(FakeHerdr herdr) {
BridgedConfig.Worker gx10 = new BridgedConfig.Worker("gx10", "http://gx10.gw:8000", "coder",
null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "gx10"), "tab", "bridged-workers", "w #{n}", null);
null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "gx10"), "tab", "bridged-workers", "w #{n}", null, null);
BridgedConfig.Worker ollama = new BridgedConfig.Worker("ollama", "http://ollama.ltms.dev", null,
null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ollama"), "tab", "bridged-workers", "w #{n}", null);
null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ollama"), "tab", "bridged-workers", "w #{n}", null, null);
return new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx10.gw", "ollama.ltms.dev")),
Map.of("gx10", gx10, "ollama", ollama), "gx10", _ -> "tok");
@@ -77,4 +77,37 @@ class WorkerServiceTest {
FakeHerdr herdr = new FakeHerdr();
assertThrows(IllegalArgumentException.class, () -> multiProfile(herdr).spawn("nope"));
}
@SuppressWarnings("unchecked")
private static String startCwd(FakeHerdr herdr) {
// The worker's cwd is set on agent.start (an agent pane does not inherit the tab's cwd).
Object v = ((Map<String, Object>) herdr.lastCall("agent.start").params()).get("cwd");
return v == null ? null : v.toString();
}
@Test
void requestedCwdRootsTheWorker() {
FakeHerdr herdr = new FakeHerdr();
service(herdr, List.of("ccs", "ltms-local"), null).spawn("ltms-local", "/work/proj", "/caller/home");
assertEquals("/work/proj", startCwd(herdr), "an explicit spawn cwd wins over everything");
}
@Test
void profileConfigCwdBeatsTheCallerCwd() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker cfg = new BridgedConfig.Worker("ltms-local", "http://gx00.gw:8000", "coder",
null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers",
"w #{n}", null, "/pinned/dir");
WorkerService svc = new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of("ltms-local", cfg), "ltms-local", _ -> null);
svc.spawn("ltms-local", null, "/caller/home");
assertEquals("/pinned/dir", startCwd(herdr), "a profile-pinned cwd overrides the caller's");
}
@Test
void inheritsTheCallerCwdWhenNothingElseIsSet() {
FakeHerdr herdr = new FakeHerdr();
service(herdr, List.of("ccs", "ltms-local"), null).spawn("ltms-local", null, "/primary/project");
assertEquals("/primary/project", startCwd(herdr), "no explicit/config cwd → inherit the primary's");
}
}
+13 -10
View File
@@ -19,7 +19,7 @@ flowchart TD
B -->|"no"| D{"caller PID resolvable?<br/>(MCP peer PID)"}
D -->|"yes"| E["cwd = the primary's cwd<br/>lsof -a -p PID -d cwd"]
D -->|"no (REST / off-host)"| F["cwd = bridged daemon cwd<br/>(never $HOME by assumption)"]
C --> G["workspace.create {cwd} → tab.create → agent.start"]
C --> G["ensureWorkspace → tab.create → agent.start {cwd}"]
E --> G
F --> G
G --> H{"does the CLI trust this folder?"}
@@ -40,10 +40,11 @@ otherwise. Never assume `$HOME`.** If the primary is in `/Users/you/LTMS/claude-
open there too — so delegated tasks share the same relative paths and the same (already-trusted)
project folder.
**The herdr seam.** herdr fixes a session's working directory at **`workspace.create {cwd}`** —
`agent.start` takes `{name, argv, env, tab_id}` with **no** `cwd`. So the worker's cwd is whatever
its *workspace/tab* was created with; controlling it means threading a `cwd` into the placement step,
not the launch step.
**The herdr seam.** An `agent.start` pane does **not** inherit its tab's or workspace's cwd — it
starts in `$HOME` unless told otherwise. herdr's `agent.start` honours an (undocumented) **`cwd`**
param, verified live: setting it roots the worker process at that directory. So the worker's cwd is
threaded onto `agent.start {…, cwd}`, not the placement step (`workspace.create`/`tab.create` cwd
only affect the seed shell, which the bridge closes).
**Resolution order** (first match wins):
@@ -69,16 +70,18 @@ sequenceDiagram
O-->>B: "pid"
B->>O: "cwd of pid (lsof -d cwd)"
O-->>B: "/Users/you/LTMS/claude-bridge"
B->>H: "workspace.create {cwd} / tab.create"
B->>H: "agent.start {argv, env, tab_id}"
B->>H: "tab.create (placement)"
B->>H: "agent.start {argv, env, tab_id, cwd}"
H-->>B: "worker in the primary's directory"
```
*Figure 2 — a no-cwd spawn inherits the primary's directory from the caller's PID.*
> **Status:** the inheritance (sources 1–3) is the **target design**; today `bridged` creates one
> shared worker space and workers land in herdr's default cwd. Wiring `cwd` through
> `workspace.create`/`tab.create` is the follow-up that implements this rule.
> **Status:** implemented (CB-112). `bridged` threads the resolved `cwd` onto **`agent.start {cwd}`**
> (verified: the worker process is rooted there), keeping the single shared worker space. On an MCP
> `bridge_spawn` the primary's cwd is auto-detected from the caller's PID; over REST (no MCP caller)
> it is the explicit `cwd` param else the daemon's cwd. Both placements (`tab` and legacy `pane`)
> carry it, since it rides `agent.start`.
## Assumed launcher: `ccs` (Claude Code)