From bcb402b6889f589de2dbf3fee81885125dc7ca67 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 28 Aug 2026 05:54:15 +0700 Subject: [PATCH 1/3] #157: convert forge worktree origins to SSH --- .../dev/ltms/fleet/session/GitWorktrees.java | 34 +++++++++++++++++++ .../ltms/fleet/session/GitWorktreesTest.java | 21 ++++++++++++ 2 files changed, 55 insertions(+) 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 { -- 2.52.0 From 46ac6e4e38bc66f3c7904d7908292591a0003cf1 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 28 Aug 2026 06:02:09 +0700 Subject: [PATCH 2/3] #157: use environment git credential helper --- .../dev/ltms/fleet/session/GitWorktrees.java | 65 +++++++++++++++---- .../ltms/fleet/session/GitWorktreesTest.java | 41 ++++++++++-- 2 files changed, 85 insertions(+), 21 deletions(-) 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 d9b3b7b..bb3e6fa 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java +++ b/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java @@ -22,6 +22,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; import java.util.stream.Collectors; /** @@ -66,8 +67,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 credential helper command which reads only an environment variable at Git call time. */ + private static final String ENVIRONMENT_CREDENTIAL_HELPER = "!f() { if [ \"$1\" = get ]; then " + + "printf 'username=%s\\n\\n' \"${GITEA_TOKEN:-$WORKER_GITEA_TOKEN}\"; fi; }; f"; /** * A tracked project config that is hostile in a provisioned worktree, and what to replace it @@ -86,6 +88,7 @@ public final class GitWorktrees implements Worktrees { ); private final String configuredRoot; + private final Consumer afterWorktreeAdded; private final SecureRandom random = new SecureRandom(); private final AtomicLong seq = new AtomicLong(); @@ -96,7 +99,13 @@ public final class GitWorktrees implements Worktrees { /** @param configuredRoot nullable absolute or relative path; null/blank derives a sibling of the repo root. */ public GitWorktrees(String configuredRoot) { + this(configuredRoot, _ -> {}); + } + + /** Test seam for changing a real worktree between its creation and its security check. */ + GitWorktrees(String configuredRoot, Consumer afterWorktreeAdded) { this.configuredRoot = configuredRoot; + this.afterWorktreeAdded = afterWorktreeAdded == null ? _ -> {} : afterWorktreeAdded; } @Override @@ -112,18 +121,20 @@ public final class GitWorktrees implements Worktrees { } String wt = path.toAbsolutePath().toString(); log.info("adding worktree branch={} path={} base={}", branch, wt, base); - convertForgeHttpsOriginToSsh(repoRoot); + removeUserInfoFromHttpsOrigin(repoRoot); exec("git", "-C", repoRoot, "worktree", "add", wt, "-b", branch, base); + afterWorktreeAdded.accept(wt); + requireCredentialFreeHttpsOrigin(wt); + configureEnvironmentCredentialHelper(repoRoot, wt); 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. + * A linked worktree shares its primary checkout's git config. Remove HTTPS user info before + * adding one, so a credential accidentally embedded in that config cannot reach the member. */ - private void convertForgeHttpsOriginToSsh(String repoRoot) { + private void removeUserInfoFromHttpsOrigin(String repoRoot) { if (exitCode("git", "-C", repoRoot, "config", "--get", "remote.origin.url") != 0) { return; } @@ -134,16 +145,42 @@ public final class GitWorktrees implements Worktrees { } 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())) { + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() == null) { 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"); + int schemeEnd = origin.indexOf("://") + 3; + int userInfoEnd = origin.indexOf('@', schemeEnd); + if (userInfoEnd < schemeEnd) { + throw new WorktreeException("origin URL has invalid HTTPS user info; cannot provision 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"); + String cleanOrigin = origin.substring(0, schemeEnd) + origin.substring(userInfoEnd + 1); + exec("git", "-C", repoRoot, "remote", "set-url", "origin", cleanOrigin); + log.info("removed HTTPS user info from forge origin before provisioning worktree"); + } + + /** Refuse the worktree if Git still resolves any HTTPS origin URL with embedded credentials. */ + private void requireCredentialFreeHttpsOrigin(String worktreePath) { + if (exitCode("git", "-C", worktreePath, "remote", "get-url", "--all", "origin") != 0) { + return; + } + String origins = exec("git", "-C", worktreePath, "remote", "get-url", "--all", "origin"); + for (String origin : origins.split("\\R")) { + try { + URI uri = new URI(origin); + if ("https".equalsIgnoreCase(uri.getScheme()) && uri.getUserInfo() != null) { + throw new WorktreeException("worktree origin contains HTTPS user info; refusing provision"); + } + } catch (URISyntaxException e) { + throw new WorktreeException("worktree origin URL is invalid; refusing provision", e); + } + } + } + + /** Configure a per-worktree helper that supplies a token from the member environment at call time. */ + private void configureEnvironmentCredentialHelper(String repoRoot, String worktreePath) { + exec("git", "-C", repoRoot, "config", "extensions.worktreeConfig", "true"); + exec("git", "-C", worktreePath, "config", "--worktree", "credential.helper", + ENVIRONMENT_CREDENTIAL_HELPER); } /** 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 f6d1897..2ced046 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java @@ -55,12 +55,17 @@ class GitWorktreesTest { } private static void git(Path cwd, String... args) throws Exception { + gitOutput(cwd, args); + } + + private static String gitOutput(Path cwd, String... args) throws Exception { List cmd = new java.util.ArrayList<>(List.of("git")); cmd.addAll(List.of(args)); Process p = new ProcessBuilder(cmd).directory(cwd.toFile()).redirectErrorStream(true).start(); String out = new String(p.getInputStream().readAllBytes()); assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git timed out: " + String.join(" ", cmd)); assertEquals(0, p.exitValue(), "git " + String.join(" ", args) + " failed:\n" + out); + return out; } /** Pending changes to {@code file} in {@code cwd}, empty when git considers it unmodified. */ @@ -210,20 +215,42 @@ class GitWorktreesTest { * reads after {@link GitWorktrees#add}, rather than checking only a URL formatting helper. */ @Test - void aProvisionedWorktreeUsesTheForgeSshOrigin(@TempDir Path tmp) throws Exception { + void aProvisionedWorktreeUsesACleanHttpsOrigin(@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); + Path worktree = Path.of(wt); + String origin = gitOutput(worktree, "config", "--get", "remote.origin.url").trim(); + assertEquals("https://git.ltms.dev/akb/kb.git", origin); assertFalse(origin.contains("synthetic-test-token"), "provisioned worktree kept user info"); + assertFalse(gitOutput(worktree, "remote", "-v").contains("synthetic-test-token"), + "git remote -v exposed user info"); + assertFalse(gitOutput(worktree, "config", "--list").contains("synthetic-test-token"), + "git config --list exposed user info"); + String helper = gitOutput(worktree, "config", "--worktree", "--get", "credential.helper"); + assertTrue(helper.contains("GITEA_TOKEN"), "credential helper does not read the member environment"); + assertFalse(helper.contains("synthetic-test-token"), "credential helper stored user info"); + } + + @Test + void provisioningRefusesAWorktreeWhoseOriginStillHasHttpsUserInfo(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + git(repo, "remote", "add", "origin", "https://git.ltms.dev/akb/kb.git"); + GitWorktrees worktrees = new GitWorktrees(tmp.resolve("wts").toString(), worktreePath -> { + try { + git(Path.of(worktreePath), "remote", "set-url", "origin", + "https://synthetic-test-token@git.ltms.dev/akb/kb.git"); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + WorktreeException error = assertThrows(WorktreeException.class, + () -> worktrees.add(repo.toString(), "fleetd-157-refuse-origin", "HEAD")); + assertEquals("worktree origin contains HTTPS user info; refusing provision", error.getMessage()); } /** Neutralizing must not look like work in progress, or a worker would commit it into its PR. */ -- 2.52.0 From ee8f570fd71d70b386a00f8272dbfb17337409cf Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 28 Aug 2026 06:06:12 +0700 Subject: [PATCH 3/3] #157: isolate worktree credential helpers --- .../dev/ltms/fleet/session/GitWorktrees.java | 7 +++- .../ltms/fleet/session/GitWorktreesTest.java | 38 ++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) 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 bb3e6fa..1a43c2f 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java +++ b/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java @@ -69,7 +69,7 @@ public final class GitWorktrees implements Worktrees { /** A credential helper command which reads only an environment variable at Git call time. */ private static final String ENVIRONMENT_CREDENTIAL_HELPER = "!f() { if [ \"$1\" = get ]; then " - + "printf 'username=%s\\n\\n' \"${GITEA_TOKEN:-$WORKER_GITEA_TOKEN}\"; fi; }; f"; + + "printf 'username=%s\\npassword=%s\\n\\n' git \"$WORKER_GITEA_TOKEN\"; fi; }; f"; /** * A tracked project config that is hostile in a provisioned worktree, and what to replace it @@ -179,7 +179,10 @@ public final class GitWorktrees implements Worktrees { /** Configure a per-worktree helper that supplies a token from the member environment at call time. */ private void configureEnvironmentCredentialHelper(String repoRoot, String worktreePath) { exec("git", "-C", repoRoot, "config", "extensions.worktreeConfig", "true"); - exec("git", "-C", worktreePath, "config", "--worktree", "credential.helper", + // An empty helper resets values inherited from the system or global config. Without it Git + // asks the next helper after this one, which can expose an operator-level credential. + exec("git", "-C", worktreePath, "config", "--worktree", "--replace-all", "credential.helper", ""); + exec("git", "-C", worktreePath, "config", "--worktree", "--add", "credential.helper", ENVIRONMENT_CREDENTIAL_HELPER); } 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 2ced046..abb8069 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java @@ -61,7 +61,11 @@ class GitWorktreesTest { private static String gitOutput(Path cwd, String... args) throws Exception { List cmd = new java.util.ArrayList<>(List.of("git")); cmd.addAll(List.of(args)); - Process p = new ProcessBuilder(cmd).directory(cwd.toFile()).redirectErrorStream(true).start(); + ProcessBuilder pb = new ProcessBuilder(cmd).directory(cwd.toFile()).redirectErrorStream(true); + pb.environment().put("GIT_CONFIG_GLOBAL", "/dev/null"); + pb.environment().put("GIT_CONFIG_SYSTEM", "/dev/null"); + pb.environment().put("GIT_TERMINAL_PROMPT", "0"); + Process p = pb.start(); String out = new String(p.getInputStream().readAllBytes()); assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git timed out: " + String.join(" ", cmd)); assertEquals(0, p.exitValue(), "git " + String.join(" ", args) + " failed:\n" + out); @@ -231,10 +235,40 @@ class GitWorktreesTest { assertFalse(gitOutput(worktree, "config", "--list").contains("synthetic-test-token"), "git config --list exposed user info"); String helper = gitOutput(worktree, "config", "--worktree", "--get", "credential.helper"); - assertTrue(helper.contains("GITEA_TOKEN"), "credential helper does not read the member environment"); + assertTrue(helper.contains("WORKER_GITEA_TOKEN"), "credential helper does not read the member environment"); assertFalse(helper.contains("synthetic-test-token"), "credential helper stored user info"); } + @Test + void worktreeCredentialHelperCompletesWithoutUsingAnInheritedHelper(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + git(repo, "remote", "add", "origin", "https://git.ltms.dev/akb/kb.git"); + String wt = new GitWorktrees(tmp.resolve("wts").toString()) + .add(repo.toString(), "fleetd-157-helper", "HEAD"); + Path globalConfig = tmp.resolve("global.gitconfig"); + Files.writeString(globalConfig, """ + [credential] + helper = !f() { printf 'username=%s\\npassword=%s\\n\\n' operator operator-secret; }; f + """); + + ProcessBuilder pb = new ProcessBuilder("git", "credential", "fill") + .directory(Path.of(wt).toFile()).redirectErrorStream(true); + pb.environment().put("GIT_CONFIG_GLOBAL", globalConfig.toString()); + pb.environment().put("GIT_CONFIG_SYSTEM", "/dev/null"); + pb.environment().put("GIT_TERMINAL_PROMPT", "0"); + pb.environment().put("WORKER_GITEA_TOKEN", "synthetic-worker-value"); + Process p = pb.start(); + p.getOutputStream().write("protocol=https\nhost=git.ltms.dev\n\n".getBytes(StandardCharsets.UTF_8)); + p.getOutputStream().close(); + String credential = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git credential fill timed out"); + assertEquals(0, p.exitValue(), "git credential fill failed"); + assertTrue(credential.contains("username=git"), "helper did not return its fixed username"); + assertTrue(credential.contains("password=synthetic-worker-value"), + "helper did not return the worker token as the password"); + assertFalse(credential.contains("operator-secret"), "Git used the inherited global helper"); + } + @Test void provisioningRefusesAWorktreeWhoseOriginStillHasHttpsUserInfo(@TempDir Path tmp) throws Exception { Path repo = initRepo(tmp.resolve("repo")); -- 2.52.0