From 4accc746bd096dc62cecf0963ad248435e5d861a Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 28 Aug 2026 06:20:10 +0700 Subject: [PATCH] #157: rewrite SSH origin to HTTPS in the worktree so the credential helper is reachable --- .../dev/ltms/fleet/session/GitWorktrees.java | 58 +++++++++++++++++ .../ltms/fleet/session/GitWorktreesTest.java | 65 +++++++++++++++++++ 2 files changed, 123 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 1a43c2f..bcf7c6d 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java +++ b/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java @@ -126,6 +126,7 @@ public final class GitWorktrees implements Worktrees { afterWorktreeAdded.accept(wt); requireCredentialFreeHttpsOrigin(wt); configureEnvironmentCredentialHelper(repoRoot, wt); + configureHttpsUrlRewriteForSshOrigin(repoRoot, wt); isolateToolSurface(wt); return wt; } @@ -186,6 +187,63 @@ public final class GitWorktrees implements Worktrees { ENVIRONMENT_CREDENTIAL_HELPER); } + /** + * {@link #configureEnvironmentCredentialHelper} only ever fires for an HTTPS origin — Git never + * consults a {@code credential.helper} for an SSH transport. This repo's own origin is + * {@code ssh://git@git.ltms.dev:2224/fleet/fleetd.git}, and once {@code memberCredentials.policy: + * allow-list} blocks {@code SSH_AUTH_SOCK} (fleetd #157), a member sitting on an SSH origin has no + * way to authenticate at all — there is no private key file on this host, only an ssh-agent socket. + * + *

The fix is a worktree-scoped URL rewrite: {@code url..insteadOf + * }, set with {@code --worktree} so it lands only in + * {@code /.git/worktrees//config.worktree} (enabled by + * {@code extensions.worktreeConfig}, already turned on above) and never touches the shared + * repo-level config the primary checkout also reads. {@code insteadOf} — not + * {@code pushInsteadOf} — because a member may also need to fetch or rebase, and a fetch over SSH + * fails for the exact same missing-agent reason a push would. + * + *

The host (and, for the rewrite's SSH-side match, the port) come from parsing the origin + * itself — never a hardcoded forge host, which is exactly what #177 removed. An origin that is + * already {@code https://} is left alone; the credential helper already covers it. An origin + * that is neither {@code ssh://} nor {@code https://} — including the scp-like shorthand + * ({@code git@host:path}, no scheme) — is left untouched deliberately: that shorthand's + * {@code host:path} split is defined by the user's ssh_config aliases, not by URI syntax, so + * guessing at it risks rewriting to the wrong place. A repo provisioned from that form keeps + * today's (broken, if the policy blocks the agent) SSH-only behaviour rather than a wrong rewrite. + */ + private void configureHttpsUrlRewriteForSshOrigin(String repoRoot, String worktreePath) { + 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) { + log.warn("origin URL {} is not a valid URI; skipping worktree HTTPS rewrite", origin); + return; + } + String scheme = uri.getScheme(); + if (!"ssh".equalsIgnoreCase(scheme)) { + // Already https:// (the credential helper covers it), or a scheme-less/scp-like origin + // left alone on purpose — see the javadoc above. + log.debug("origin scheme is not ssh ({}) — no worktree HTTPS rewrite needed", origin); + return; + } + String host = uri.getHost(); + String authority = uri.getRawAuthority(); + if (host == null || host.isBlank() || authority == null || authority.isBlank()) { + log.warn("ssh origin {} has no resolvable host; skipping worktree HTTPS rewrite", origin); + return; + } + String sshBase = "ssh://" + authority + "/"; + String httpsBase = "https://" + host + "/"; + exec("git", "-C", worktreePath, "config", "--worktree", "--replace-all", + "url." + httpsBase + ".insteadOf", sshBase); + log.info("worktree {} rewrites {} to {} (worktree-scoped; parent checkout untouched)", + worktreePath, sshBase, httpsBase); + } + /** * 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 abb8069..1ddce93 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java @@ -269,6 +269,71 @@ class GitWorktreesTest { assertFalse(credential.contains("operator-secret"), "Git used the inherited global helper"); } + /** + * fleetd #157 follow-up. An SSH origin never consults {@code credential.helper} — the environment + * credential helper set by {@link GitWorktrees#add} is therefore useless when a member's origin is + * SSH, which is exactly this repo's shape. The worktree must instead get a worktree-scoped + * {@code url..insteadOf } rewrite so both fetch and push resolve to HTTPS, while the + * parent checkout — sharing the same repo-level origin config — must resolve the original SSH URL + * completely unchanged. The host/port here (a synthetic {@code forge.example.test:2222}, not + * {@code git.ltms.dev}) proves the rewrite is derived from the origin, not a hardcoded constant. + */ + @Test + void aProvisionedWorktreeRewritesAnSshOriginToHttpsWorktreeScoped(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + git(repo, "remote", "add", "origin", "ssh://git@forge.example.test:2222/acme/proj.git"); + + String wt = new GitWorktrees(tmp.resolve("wts").toString()) + .add(repo.toString(), "fleetd-157-ssh-rewrite", "HEAD"); + + Path worktree = Path.of(wt); + assertEquals("https://forge.example.test/acme/proj.git", + gitOutput(worktree, "remote", "get-url", "origin").trim(), + "worktree fetch URL was not rewritten to HTTPS"); + assertEquals("https://forge.example.test/acme/proj.git", + gitOutput(worktree, "remote", "get-url", "--push", "origin").trim(), + "worktree push URL was not rewritten to HTTPS"); + // The raw config value is unchanged — only the resolved URL is rewritten, via insteadOf. + assertEquals("ssh://git@forge.example.test:2222/acme/proj.git", + gitOutput(worktree, "config", "--get", "remote.origin.url").trim()); + + assertEquals("ssh://git@forge.example.test:2222/acme/proj.git", + gitOutput(repo, "remote", "get-url", "origin").trim(), + "the parent checkout's fetch URL must be untouched"); + assertEquals("ssh://git@forge.example.test:2222/acme/proj.git", + gitOutput(repo, "remote", "get-url", "--push", "origin").trim(), + "the parent checkout's push URL must be untouched"); + } + + /** An origin already on HTTPS is left alone — the environment credential helper already covers it. */ + @Test + void aProvisionedWorktreeLeavesAnHttpsOriginAlone(@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-https-noop", "HEAD"); + + Path worktree = Path.of(wt); + assertEquals("https://git.ltms.dev/akb/kb.git", + gitOutput(worktree, "remote", "get-url", "origin").trim()); + assertEquals(1, exitCode("git", "-C", wt, "config", "--worktree", "--get-regexp", "^url\\."), + "no url.*.insteadOf rewrite should be added for an already-HTTPS origin"); + } + + /** Test-local exit-code probe, mirroring {@link GitWorktrees#exitCode} for an assertion the + * production class does not expose. */ + private static int exitCode(String... command) throws Exception { + ProcessBuilder pb = new ProcessBuilder(command).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(); + p.getInputStream().readAllBytes(); + assertTrue(p.waitFor(30, TimeUnit.SECONDS), "command timed out: " + String.join(" ", command)); + return p.exitValue(); + } + @Test void provisioningRefusesAWorktreeWhoseOriginStillHasHttpsUserInfo(@TempDir Path tmp) throws Exception { Path repo = initRepo(tmp.resolve("repo"));