diff --git a/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java b/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java index ceaf80b..d9b3b7b 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java +++ b/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java @@ -7,6 +7,8 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.UncheckedIOException; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -64,6 +66,9 @@ public final class GitWorktrees implements Worktrees { /** What {@link #isolateToolSurface} writes for {@code .autoenv}: a valid, empty env file. */ private static final String NEUTRAL_AUTOENV_CONFIG = ""; + private static final String FORGE_HOST = "git.ltms.dev"; + private static final int FORGE_SSH_PORT = 2224; + /** * A tracked project config that is hostile in a provisioned worktree, and what to replace it * with. {@link #file} is the repo-relative path; {@link #stub} is a neutral but VALID payload for @@ -107,11 +112,40 @@ public final class GitWorktrees implements Worktrees { } String wt = path.toAbsolutePath().toString(); log.info("adding worktree branch={} path={} base={}", branch, wt, base); + convertForgeHttpsOriginToSsh(repoRoot); exec("git", "-C", repoRoot, "worktree", "add", wt, "-b", branch, base); isolateToolSurface(wt); return wt; } + /** + * A linked worktree shares its primary checkout's git config. Convert this forge's HTTPS origin + * before adding the worktree, so a credential accidentally embedded in that config cannot reach + * the member. SSH uses the host user's key at push time and stores no forge token in git config. + */ + private void convertForgeHttpsOriginToSsh(String repoRoot) { + if (exitCode("git", "-C", repoRoot, "config", "--get", "remote.origin.url") != 0) { + return; + } + String origin = exec("git", "-C", repoRoot, "config", "--get", "remote.origin.url").trim(); + URI uri; + try { + uri = new URI(origin); + } catch (URISyntaxException e) { + throw new WorktreeException("origin URL is invalid; cannot provision a safe worktree", e); + } + if (!"https".equalsIgnoreCase(uri.getScheme()) || !FORGE_HOST.equalsIgnoreCase(uri.getHost())) { + return; + } + if (uri.getRawPath() == null || uri.getRawPath().isBlank() || uri.getRawQuery() != null + || uri.getRawFragment() != null) { + throw new WorktreeException("origin URL cannot be converted to the forge SSH form safely"); + } + String sshOrigin = "ssh://git@" + FORGE_HOST + ":" + FORGE_SSH_PORT + uri.getRawPath(); + exec("git", "-C", repoRoot, "remote", "set-url", "origin", sshOrigin); + log.info("converted forge origin to SSH before provisioning worktree"); + } + /** * Neutralize the worktree's worktree-hostile project configs so a worker inherits only the tools * and environment its launcher mounts (the bridge via {@code --mcp-config}, the opencode config diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java index 3b40911..f6d1897 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java @@ -205,6 +205,27 @@ class GitWorktreesTest { "expected an explicitly empty server map, got:\n" + body); } + /** + * The worktree command shares the primary checkout's config, so this checks the URL git actually + * reads after {@link GitWorktrees#add}, rather than checking only a URL formatting helper. + */ + @Test + void aProvisionedWorktreeUsesTheForgeSshOrigin(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + git(repo, "remote", "add", "origin", "https://synthetic-test-token@git.ltms.dev/akb/kb.git"); + + String wt = new GitWorktrees(tmp.resolve("wts").toString()) + .add(repo.toString(), "fleetd-157-safe-origin", "HEAD"); + + Process p = new ProcessBuilder("git", "-C", wt, "config", "--get", "remote.origin.url") + .redirectErrorStream(true).start(); + String origin = new String(p.getInputStream().readAllBytes()).trim(); + assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git config timed out"); + assertEquals(0, p.exitValue(), "git config failed"); + assertEquals("ssh://git@git.ltms.dev:2224/akb/kb.git", origin); + assertFalse(origin.contains("synthetic-test-token"), "provisioned worktree kept user info"); + } + /** Neutralizing must not look like work in progress, or a worker would commit it into its PR. */ @Test void theNeutralizedConfigIsNotAPendingLocalModification(@TempDir Path tmp) throws Exception {