fix(memfs): harden git pull auth and credential URL normalization (#971)

Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: cpacker <packercharles@gmail.com>
This commit is contained in:
Sarah Wooders
2026-02-15 20:54:02 -08:00
committed by GitHub
parent 784b0eb52b
commit 3a055c067a
2 changed files with 82 additions and 7 deletions

View File

@@ -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<void> {
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$'`,
};
}
}
}

View File

@@ -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",
);
});
});