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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user