From 3a055c067a69cf5df08186fad6a3cdafa190512e Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Sun, 15 Feb 2026 20:54:02 -0800 Subject: [PATCH] fix(memfs): harden git pull auth and credential URL normalization (#971) Co-authored-by: Letta Co-authored-by: cpacker --- src/agent/memoryGit.ts | 52 ++++++++++++++++++++++---- src/tests/agent/memoryGit.auth.test.ts | 37 ++++++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 src/tests/agent/memoryGit.auth.test.ts diff --git a/src/agent/memoryGit.ts b/src/agent/memoryGit.ts index 659468f..001fbc1 100644 --- a/src/agent/memoryGit.ts +++ b/src/agent/memoryGit.ts @@ -38,9 +38,28 @@ export function getMemoryRepoDir(agentId: string): string { return join(getAgentRootDir(agentId), "memory"); } +/** + * Normalize a configured server URL for use in git credential config keys. + * + * Git credential config lookup is sensitive to URL key shape. We normalize to + * origin form (scheme + host + optional port) and remove trailing slashes so + * pull/push flows remain resilient when LETTA_BASE_URL has path/trailing-slash + * variations. + */ +export function normalizeCredentialBaseUrl(serverUrl: string): string { + const trimmed = serverUrl.trim().replace(/\/+$/, ""); + try { + const parsed = new URL(trimmed); + return parsed.origin; + } catch { + // Fall back to a conservative slash-trimmed value if URL parsing fails. + return trimmed; + } +} + /** Git remote URL for the agent's state repo */ function getGitRemoteUrl(agentId: string): string { - const baseUrl = getServerUrl(); + const baseUrl = getServerUrl().trim().replace(/\/+$/, ""); return `${baseUrl}/v1/git/${agentId}/state.git`; } @@ -95,10 +114,26 @@ async function configureLocalCredentialHelper( dir: string, token: string, ): Promise { - const baseUrl = getServerUrl(); + const rawBaseUrl = getServerUrl(); + const normalizedBaseUrl = normalizeCredentialBaseUrl(rawBaseUrl); const helper = `!f() { echo "username=letta"; echo "password=${token}"; }; f`; - await runGit(dir, ["config", `credential.${baseUrl}.helper`, helper]); - debugLog("memfs-git", "Configured local credential helper"); + + // Primary config: normalized origin key (most robust for git's credential lookup) + await runGit(dir, [ + "config", + `credential.${normalizedBaseUrl}.helper`, + helper, + ]); + + // Backcompat: also set raw configured URL key if it differs (older repos/configs) + if (rawBaseUrl !== normalizedBaseUrl) { + await runGit(dir, ["config", `credential.${rawBaseUrl}.helper`, helper]); + } + + debugLog( + "memfs-git", + `Configured local credential helper for ${normalizedBaseUrl}${rawBaseUrl !== normalizedBaseUrl ? ` (and raw ${rawBaseUrl})` : ""}`, + ); } /** @@ -332,7 +367,7 @@ export async function pullMemory( installPreCommitHook(dir); try { - const { stdout, stderr } = await runGit(dir, ["pull", "--ff-only"]); + const { stdout, stderr } = await runGit(dir, ["pull", "--ff-only"], token); const output = stdout + stderr; const updated = !output.includes("Already up to date"); return { @@ -343,13 +378,16 @@ export async function pullMemory( // If ff-only fails (diverged), try rebase debugWarn("memfs-git", "Fast-forward pull failed, trying rebase"); try { - const { stdout, stderr } = await runGit(dir, ["pull", "--rebase"]); + const { stdout, stderr } = await runGit(dir, ["pull", "--rebase"], token); return { updated: true, summary: (stdout + stderr).trim() }; } catch (rebaseErr) { const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr); debugWarn("memfs-git", `Pull failed: ${msg}`); - return { updated: false, summary: `Pull failed: ${msg}` }; + return { + updated: false, + summary: `Pull failed: ${msg}\nHint: verify remote and auth:\n- git -C ${dir} remote -v\n- git -C ${dir} config --get-regexp '^credential\\..*\\.helper$'`, + }; } } } diff --git a/src/tests/agent/memoryGit.auth.test.ts b/src/tests/agent/memoryGit.auth.test.ts new file mode 100644 index 0000000..b5184fa --- /dev/null +++ b/src/tests/agent/memoryGit.auth.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { normalizeCredentialBaseUrl } from "../../agent/memoryGit"; + +describe("normalizeCredentialBaseUrl", () => { + test("normalizes Letta Cloud URL to origin", () => { + expect(normalizeCredentialBaseUrl("https://api.letta.com")).toBe( + "https://api.letta.com", + ); + }); + + test("strips trailing slashes", () => { + expect(normalizeCredentialBaseUrl("https://api.letta.com///")).toBe( + "https://api.letta.com", + ); + }); + + test("drops path/query/fragment and keeps origin", () => { + expect( + normalizeCredentialBaseUrl( + "https://api.letta.com/custom/path?foo=bar#fragment", + ), + ).toBe("https://api.letta.com"); + }); + + test("preserves explicit port", () => { + expect(normalizeCredentialBaseUrl("http://localhost:8283/v1/")).toBe( + "http://localhost:8283", + ); + }); + + test("falls back to trimmed value when URL parsing fails", () => { + expect(normalizeCredentialBaseUrl("not-a-valid-url///")).toBe( + "not-a-valid-url", + ); + }); +});