diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index ad898a2..f619a79 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -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) diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 212871b..bb7301e 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -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(); diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index a66c5d0..6116506 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -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 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. */ diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/AgentControl.java b/bridged/src/main/java/dev/ltms/bridged/herdr/AgentControl.java index 31a9a49..cc2f5d2 100644 --- a/bridged/src/main/java/dev/ltms/bridged/herdr/AgentControl.java +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/AgentControl.java @@ -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 argv, Map 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 not 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 argv, Map env, String tabId, String cwd) { Map 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")); } diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/WorkspaceControl.java b/bridged/src/main/java/dev/ltms/bridged/herdr/WorkspaceControl.java index f5b6c99..99b2a40 100644 --- a/bridged/src/main/java/dev/ltms/bridged/herdr/WorkspaceControl.java +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/WorkspaceControl.java @@ -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. */ diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java index 96a303a..6f72321 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -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 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())); } diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/ConnectionIdentity.java b/bridged/src/main/java/dev/ltms/bridged/mcp/ConnectionIdentity.java index fca4705..00bf746 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/ConnectionIdentity.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/ConnectionIdentity.java @@ -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) { diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/LsofProcessCwdLookup.java b/bridged/src/main/java/dev/ltms/bridged/mcp/LsofProcessCwdLookup.java new file mode 100644 index 0000000..f53e801 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/LsofProcessCwdLookup.java @@ -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 + * -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' 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; + } + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/ProcessCwdLookup.java b/bridged/src/main/java/dev/ltms/bridged/mcp/ProcessCwdLookup.java new file mode 100644 index 0000000..708738b --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/ProcessCwdLookup.java @@ -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); +} diff --git a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java index 3871f2f..cbf5b35 100644 --- a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java +++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java @@ -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")); diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java index 3cf74c1..6535a4f 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java @@ -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 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 workerEnv, List argv) { + /** Dedicated worker space → own tab → start the worker (rooted at {@code cwd}) → drop the shell. */ + private Agent spawnInTab(BridgedConfig.Worker cfg, Map workerEnv, + List 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 workerEnv, List 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 workerEnv, + List 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 workerEnv, - List argv, String tabId) { + List 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); diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java index 22c9ef6..28b9ad8 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -38,7 +38,7 @@ class BridgeMcpTest { private static WorkerService workerService(FakeHerdr h, String baseUrl, Set 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 start = (Map) h.lastCall("agent.start").params(); + assertEquals("/req/dir", start.get("cwd")); + } + @Test void profilesListsConfiguredProfilesAndDefault() { FakeHerdr h = new FakeHerdr(); diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/ConnectionIdentityTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/ConnectionIdentityTest.java index 1048524..cad86d4 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/ConnectionIdentityTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/ConnectionIdentityTest.java @@ -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"); + } } diff --git a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java index fc1563a..804ff2b 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -52,7 +52,7 @@ class BridgedAppTest { private int start(FakeHerdr herdr, String workerBaseUrl, Set 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(); diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java index 8c2574e..83ba30a 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java @@ -19,7 +19,7 @@ class WorkerServiceTest { private WorkerService service(FakeHerdr herdr, List 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) 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"); + } } diff --git a/docs/Worker-Startup-and-Trust.md b/docs/Worker-Startup-and-Trust.md index a135268..7679ce1 100644 --- a/docs/Worker-Startup-and-Trust.md +++ b/docs/Worker-Startup-and-Trust.md @@ -19,7 +19,7 @@ flowchart TD B -->|"no"| D{"caller PID resolvable?
(MCP peer PID)"} D -->|"yes"| E["cwd = the primary's cwd
lsof -a -p PID -d cwd"] D -->|"no (REST / off-host)"| F["cwd = bridged daemon cwd
(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)