import { execFile as execFileCallback } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { pathToFileURL } from "node:url"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { ensureManagedProjectWorkspace, prepareProjectRepositoryWorkspaces } from "../services/heartbeat.ts"; import { materializeManagedProjectWorkspace, RepositoryRefNotFoundError } from "../services/managed-repository-checkout.js"; import { buildGitAuthInvocation, GIT_CREDENTIAL_TOKEN_ENV_KEY } from "../services/git-credentials.ts"; import { sanitizeRuntimeServiceBaseEnv } from "../services/workspace-runtime.ts"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts"; import { readGitConnectionFailure } from "../services/git-connection-failure.js"; const execFile = promisify(execFileCallback); let tempHome: string; let originalHome: string | undefined; beforeAll(async () => { originalHome = process.env.PAPERCLIP_HOME; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-clone-")); process.env.PAPERCLIP_HOME = tempHome; }); afterAll(async () => { if (originalHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = originalHome; await fs.rm(tempHome, { recursive: true, force: true }); }); async function createLocalSourceRepo() { const sourceRepo = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-clone-source-")); await execFile("git", ["init"], { cwd: sourceRepo }); await execFile("git", ["config", "user.email", "paperclip@example.com"], { cwd: sourceRepo }); await execFile("git", ["config", "user.name", "Paperclip Test"], { cwd: sourceRepo }); await fs.writeFile(path.join(sourceRepo, "README.md"), "hello\n", "utf8"); await execFile("git", ["add", "README.md"], { cwd: sourceRepo }); await execFile("git", ["commit", "-m", "init"], { cwd: sourceRepo }); return sourceRepo; } describe("ensureManagedProjectWorkspace clone credentials", () => { it.each([ ["authentication_failed", "fatal: Authentication failed for 'https://example.test/team/repo.git/'"], ["dns_failure", "ssh: Could not resolve hostname repository-alias.example.test: Name or service not known\nfatal: Could not read from remote repository."], [null, "fatal: Authentication failed for 'https://example.test/team/repo.git/'\nfatal: bad config line 1 in file /tmp/config"], [null, "fatal: corrupt loose object"], ] as const)("preserves only proven outbound clone classification through the actionable wrapper: %s", async (reason, stderr) => { const bin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fake-clone-")); await fs.writeFile(path.join(bin, "git"), `#!/bin/sh printf '%s\\n' "$FIXTURE_CLONE_STDERR" >&2 exit 128 `, { mode: 0o755 }); try { const failure = await ensureManagedProjectWorkspace({ companyId: "remote-clone-fixture", projectId: path.basename(bin), repoUrl: "https://fixture-user:fixture-password@example.test/team/repo.git", resolveGitAuth: async () => ({ configArgs: [], env: { PATH: bin, FIXTURE_CLONE_STDERR: stderr }, source: "company_secret", secretName: "GITHUB_TOKEN", }), }).catch((error: unknown) => error); expect(failure).toBeInstanceOf(Error); expect((failure as Error).message).toContain("Failed to prepare managed checkout"); expect((failure as Error).message).not.toContain("fixture-password"); expect(readGitConnectionFailure(failure)).toEqual(reason ? { schemaVersion: 1, provider: "git", operation: "clone", reason, } : null); const homeEntries = await fs.readdir(path.dirname(resolveManagedProjectWorkspaceDir({ companyId: "remote-clone-fixture", projectId: path.basename(bin), repoName: "repo", }))); expect(homeEntries.some((name) => name.includes(".clone-"))).toBe(false); } finally { await fs.rm(bin, { recursive: true, force: true }); } }); it("does not classify a credential-provider exception that resembles a Git failure", async () => { const failure = Object.assign(new Error("fatal: Authentication failed for 'https://example.test/repo.git'"), { code: 128, stderr: "fatal: Authentication failed for 'https://example.test/repo.git'", }); const caught = await ensureManagedProjectWorkspace({ companyId: "credential-provider-fixture", projectId: "project", repoUrl: "https://example.test/repo.git", resolveGitAuth: async () => { throw failure; }, }).catch((error: unknown) => error); expect(caught).toBe(failure); expect(readGitConnectionFailure(caught)).toBeNull(); }); it("does not transfer clone classification to a later local checkout failure", async () => { const bin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fake-checkout-")); const previousPath = process.env.PATH; await fs.writeFile(path.join(bin, "git"), `#!/bin/sh if [ "$1" = clone ]; then for argument in "$@"; do destination="$argument"; done /bin/mkdir -p "$destination/.git" exit 0 fi printf '%s\\n' "fatal: Authentication failed for 'https://example.test/repo.git'" >&2 exit 128 `, { mode: 0o755 }); try { process.env.PATH = bin; const failure = await materializeManagedProjectWorkspace(path.join(bin, "checkout"), { repoUrl: "https://example.test/repo.git", repoRef: "missing-branch", }).catch((error: unknown) => error); expect(failure).toBeInstanceOf(Error); expect((failure as Error).message).toContain("Authentication failed"); expect(readGitConnectionFailure(failure)).toBeNull(); } finally { if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; await fs.rm(bin, { recursive: true, force: true }); } }); it.each([ ["the ref still resolves", "exit 0"], ["the ref probe fails for another reason", "exit 128"], ])("keeps the original checkout failure when %s", async (_label, revParseExit) => { const bin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fake-resolvable-ref-")); const previousPath = process.env.PATH; await fs.writeFile(path.join(bin, "git"), `#!/bin/sh if [ "$1" = clone ]; then for argument in "$@"; do destination="$argument"; done /bin/mkdir -p "$destination/.git" exit 0 fi if [ "$3" = rev-parse ]; then ${revParseExit}; fi printf '%s\\n' "error: pathspec 'release' did not match any file(s) known to git" >&2 exit 1 `, { mode: 0o755 }); try { process.env.PATH = bin; const failure = await materializeManagedProjectWorkspace(path.join(bin, "checkout"), { repoUrl: "https://example.test/repo.git", repoRef: "release", }).catch((error: unknown) => error); expect(failure).toBeInstanceOf(Error); expect(failure).not.toBeInstanceOf(RepositoryRefNotFoundError); expect((failure as Error).message).toContain("did not match any file(s) known to git"); } finally { if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; await fs.rm(bin, { recursive: true, force: true }); } }); it("materializes every repository-only project row inside the task workspace and reuses local edits", async () => { const first = await createLocalSourceRepo(); const second = await createLocalSourceRepo(); try { const anchor = await ensureManagedProjectWorkspace({ companyId: "repo-only", projectId: "two", repoUrl: first }); await execFile("git", ["checkout", "-b", "project-branch"], { cwd: second }); const resolveGitAuth = vi.fn(async () => null); const input = { cwd: anchor.cwd, anchorRepoUrl: first, resolveGitAuth, workspaces: [ { id: "first", repoUrl: first, repoRef: null }, { id: "duplicate-first", repoUrl: first, repoRef: null }, { id: "second", repoUrl: second, repoRef: "project-branch" }, ], }; const additional = await prepareProjectRepositoryWorkspaces(input); expect(additional).toHaveLength(1); expect(additional[0]!.workspaceId).toBe("second"); expect(path.relative(anchor.cwd, additional[0]!.cwd)).toMatch(/^\.paperclip-repositories\//); expect((await execFile("git", ["branch", "--show-current"], { cwd: additional[0]!.cwd })).stdout.trim()).toBe("project-branch"); expect(resolveGitAuth).toHaveBeenCalledWith(second); await fs.writeFile(path.join(additional[0]!.cwd, "README.md"), "work in progress"); expect(await prepareProjectRepositoryWorkspaces(input)).toEqual(additional); expect(await fs.readFile(path.join(additional[0]!.cwd, "README.md"), "utf8")).toBe("work in progress"); expect((await execFile("git", ["status", "--porcelain"], { cwd: anchor.cwd })).stdout).toBe(""); } finally { await Promise.all([first, second].map((cwd) => fs.rm(cwd, { recursive: true, force: true }))); } }); it("clones a remote sibling with a missing configured cwd and rechecks destination access on reuse", async () => { const source = await createLocalSourceRepo(); const task = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-remote-sibling-")); try { const remote = "https://example.test/remote.git"; const resolveGitAuth = vi.fn(async () => ({ configArgs: ["-c", `url.${pathToFileURL(source).href}.insteadOf=${remote}`], env: {}, source: "company_secret" as const, secretName: "fixture", })); const prepareLocalSource = vi.fn(async (cwd: string) => { await fs.realpath(cwd); return async () => {}; }); let destinationReadable = true; const beforePublish = vi.fn(async () => { if (!destinationReadable) throw new Error("Retained private destination is not authorized"); }); const input = { cwd: task, anchorRepoUrl: null, resolveGitAuth, prepareLocalSource, beforePublish, workspaces: [{ id: "remote", cwd: path.join(task, "missing"), repoUrl: remote, repoRef: null }], }; const [repo] = await prepareProjectRepositoryWorkspaces(input); expect(await fs.readFile(path.join(repo!.cwd, "README.md"), "utf8")).toBe("hello\n"); expect(prepareLocalSource).not.toHaveBeenCalled(); expect(resolveGitAuth).toHaveBeenCalledTimes(1); await fs.writeFile(path.join(repo!.cwd, "README.md"), "retained task edits"); beforePublish.mockClear(); expect(await prepareProjectRepositoryWorkspaces(input)).toEqual([repo]); expect(beforePublish).toHaveBeenCalledTimes(1); expect(resolveGitAuth).toHaveBeenCalledTimes(1); expect(await fs.readFile(path.join(repo!.cwd, "README.md"), "utf8")).toBe("retained task edits"); destinationReadable = false; await expect(prepareProjectRepositoryWorkspaces(input)).rejects.toThrow("Retained private destination is not authorized"); expect(prepareLocalSource).not.toHaveBeenCalled(); expect(await fs.readFile(path.join(repo!.cwd, "README.md"), "utf8")).toBe("retained task edits"); } finally { await Promise.all([source, task].map((cwd) => fs.rm(cwd, { recursive: true, force: true }))); } }); it.each(["file_url", "absolute_path"] as const)("authorizes the actual %s fallback when the configured cwd is missing", async (transport) => { const source = await createLocalSourceRepo(); const task = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-local-sibling-")); try { let sourceReadable = false; const recheck = vi.fn(async () => {}); const prepareLocalSource = vi.fn(async (cwd: string) => { expect(cwd).toBe(source); if (!sourceReadable) throw new Error("Private source is not authorized"); return recheck; }); const input = { cwd: task, anchorRepoUrl: null, prepareLocalSource, workspaces: [{ id: "local", cwd: path.join(task, "missing"), repoUrl: transport === "file_url" ? pathToFileURL(source).href : source, repoRef: null }], }; await expect(prepareProjectRepositoryWorkspaces(input)).rejects.toThrow("Private source is not authorized"); expect(await fs.readdir(path.join(task, ".paperclip-repositories"))).toEqual([]); sourceReadable = true; await fs.writeFile(path.join(source, "README.md"), "uncommitted source edits"); const [repo] = await prepareProjectRepositoryWorkspaces(input); expect(await fs.readFile(path.join(repo!.cwd, "README.md"), "utf8")).toBe("hello\n"); expect(recheck).toHaveBeenCalled(); sourceReadable = false; await expect(prepareProjectRepositoryWorkspaces(input)).rejects.toThrow("Private source is not authorized"); } finally { await Promise.all([source, task].map((cwd) => fs.rm(cwd, { recursive: true, force: true }))); } }); it("fails task preparation when any attached repository cannot be cloned", async () => { const first = await createLocalSourceRepo(); try { const anchor = await ensureManagedProjectWorkspace({ companyId: "repo-failure", projectId: "two", repoUrl: first }); await expect(prepareProjectRepositoryWorkspaces({ cwd: anchor.cwd, anchorRepoUrl: first, workspaces: [{ id: "missing", repoUrl: path.join(first, "missing.git"), repoRef: null }], })).rejects.toThrow("Failed to prepare managed checkout"); } finally { await fs.rm(first, { recursive: true, force: true }); } }); it("seeds a configured second local checkout with its uncommitted work and ignores", async () => { const first = await createLocalSourceRepo(); const second = await createLocalSourceRepo(); try { const anchor = await ensureManagedProjectWorkspace({ companyId: "local-project", projectId: "two", repoUrl: first }); await fs.writeFile(path.join(second, "README.md"), "local changes"); await fs.writeFile(path.join(second, ".gitignore"), "secret.txt\n"); await fs.writeFile(path.join(second, "secret.txt"), "private"); const [repo] = await prepareProjectRepositoryWorkspaces({ cwd: anchor.cwd, anchorRepoUrl: first, workspaces: [{ id: "second", cwd: second, repoUrl: "https://github.com/example/backend.git", repoRef: null }], }); expect(await fs.readFile(path.join(repo!.cwd, "README.md"), "utf8")).toBe("local changes"); await expect(fs.stat(path.join(repo!.cwd, "secret.txt"))).rejects.toMatchObject({ code: "ENOENT" }); expect((await execFile("git", ["remote", "get-url", "origin"], { cwd: repo!.cwd })).stdout.trim()).toBe("https://github.com/example/backend.git"); } finally { await Promise.all([first, second].map((cwd) => fs.rm(cwd, { recursive: true, force: true }))); } }); it("keeps different repositories with the same name separate within one project", async () => { const first = await createLocalSourceRepo(); const second = await createLocalSourceRepo(); try { const results = await Promise.all([first, second].map((repoUrl) => ensureManagedProjectWorkspace({ companyId: "company-multiple", projectId: "project-multiple", repoUrl, }))); expect(results[0]!.cwd).not.toBe(results[1]!.cwd); for (let i = 0; i < results.length; i++) { const origin = await execFile("git", ["remote", "get-url", "origin"], { cwd: results[i]!.cwd }); expect(origin.stdout.trim()).toBe([first, second][i]); } } finally { await Promise.all([first, second].map((cwd) => fs.rm(cwd, { recursive: true, force: true }))); } }); it("rechecks the repository when another process wins the checkout rename", async () => { const first = await createLocalSourceRepo(); const second = await createLocalSourceRepo(); const companyId = "cross-process-race"; const projectId = "same-name"; const sharedCwd = resolveManagedProjectWorkspaceDir({ companyId, projectId }); try { // A different process does not share managedCheckoutMaterializations. Publish // its completed checkout after this caller chose its destination, before rename. const resolveGitAuth = vi.fn(async () => { if (!(await fs.stat(sharedCwd).catch(() => null))) { await execFile("git", ["clone", second, sharedCwd]); } return null; }); const result = await ensureManagedProjectWorkspace({ companyId, projectId, repoUrl: first, resolveGitAuth }); expect((await execFile("git", ["remote", "get-url", "origin"], { cwd: result.cwd })).stdout.trim()).toBe(first); expect((await execFile("git", ["remote", "get-url", "origin"], { cwd: sharedCwd })).stdout.trim()).toBe(second); expect(result.cwd).not.toBe(sharedCwd); } finally { await Promise.all([first, second].map((cwd) => fs.rm(cwd, { recursive: true, force: true }))); } }); it("clones exactly as before when no auth provider is configured", async () => { const sourceRepo = await createLocalSourceRepo(); try { const result = await ensureManagedProjectWorkspace({ companyId: "company-noauth", projectId: "project-1", repoUrl: sourceRepo, }); expect(result.warning).toBeNull(); const gitDir = await fs.stat(path.join(result.cwd, ".git")); expect(gitDir.isDirectory()).toBe(true); } finally { await fs.rm(sourceRepo, { recursive: true, force: true }); } }); it("consults the provider with the repo URL and clones normally when it returns null", async () => { const sourceRepo = await createLocalSourceRepo(); const resolveGitAuth = vi.fn(async () => null); try { const result = await ensureManagedProjectWorkspace({ companyId: "company-nullauth", projectId: "project-1", repoUrl: sourceRepo, resolveGitAuth, }); expect(resolveGitAuth).toHaveBeenCalledWith(sourceRepo); const gitDir = await fs.stat(path.join(result.cwd, ".git")); expect(gitDir.isDirectory()).toBe(true); } finally { await fs.rm(sourceRepo, { recursive: true, force: true }); } }); it("does not blame the credential when an authenticated clone fails for non-auth reasons", async () => { // The failure here is a missing local path, not an auth rejection — the error must not // claim the credential "was rejected". Attribution for genuinely auth-shaped failures is // covered by the describeGitAuthFailure unit tests in git-credentials.test.ts. const missingRepo = path.join(os.tmpdir(), "paperclip-definitely-missing", "repo.git"); const resolveGitAuth = vi.fn(async () => ({ configArgs: [], env: { [GIT_CREDENTIAL_TOKEN_ENV_KEY]: "token", GIT_TERMINAL_PROMPT: "0" }, source: "company_secret" as const, secretName: "GH_TOKEN", })); const error = await ensureManagedProjectWorkspace({ companyId: "company-authfail", projectId: "project-1", repoUrl: missingRepo, resolveGitAuth, }).then( () => { throw new Error("expected the clone to fail"); }, (err: unknown) => err as Error, ); expect(error.message).toContain("Failed to prepare managed checkout"); expect(error.message).not.toContain("GH_TOKEN company-secret GitHub credential"); }); it("serializes concurrent materializations of the same managed checkout", async () => { const sourceRepo = await createLocalSourceRepo(); try { const [first, second] = await Promise.all([ ensureManagedProjectWorkspace({ companyId: "company-concurrent", projectId: "project-1", repoUrl: sourceRepo, }), ensureManagedProjectWorkspace({ companyId: "company-concurrent", projectId: "project-1", repoUrl: sourceRepo, }), ]); expect(first.cwd).toBe(second.cwd); expect(first.warning).toBeNull(); expect(second.warning).toBeNull(); const gitDir = await fs.stat(path.join(first.cwd, ".git")); expect(gitDir.isDirectory()).toBe(true); // No temp clone directories left behind next to the target. const siblings = await fs.readdir(path.dirname(first.cwd)); expect(siblings.filter((name) => name.includes(".clone-"))).toEqual([]); } finally { await fs.rm(sourceRepo, { recursive: true, force: true }); } }); it("does not mention credentials when an unauthenticated clone fails for non-auth reasons", async () => { // The Settings → Secrets hint is reserved for auth-shaped failures (covered in // git-credentials.test.ts); a plain missing-repo failure must not suggest credentials. const missingRepo = path.join(os.tmpdir(), "paperclip-definitely-missing", "repo.git"); const error = await ensureManagedProjectWorkspace({ companyId: "company-noauthfail", projectId: "project-1", repoUrl: missingRepo, }).then( () => { throw new Error("expected the clone to fail"); }, (err: unknown) => err as Error, ); expect(error.message).toContain("Failed to prepare managed checkout"); expect(error.message).not.toContain("company secret"); }); it("leaves neither the target nor temp directories behind when the clone fails", async () => { const missingRepo = path.join(os.tmpdir(), "paperclip-definitely-missing", "repo.git"); const companyId = "company-cleanup"; const projectId = "project-1"; await expect(ensureManagedProjectWorkspace({ companyId, projectId, repoUrl: missingRepo, })).rejects.toThrow(); // Filesystem-path repo "URLs" derive no repo name, so the managed dir is the _default slot. const cwd = resolveManagedProjectWorkspaceDir({ companyId, projectId }); await expect(fs.stat(cwd)).rejects.toMatchObject({ code: "ENOENT" }); const siblings = await fs.readdir(path.dirname(cwd)); expect(siblings.filter((name) => name.includes(".clone-"))).toEqual([]); }); it("keeps using a pre-existing non-git directory as-is without attempting a clone", async () => { const companyId = "company-existing"; const projectId = "project-1"; const sourceRepo = await createLocalSourceRepo(); try { const cwd = resolveManagedProjectWorkspaceDir({ companyId, projectId }); await fs.mkdir(cwd, { recursive: true }); await fs.writeFile(path.join(cwd, "keep.txt"), "operator data\n", "utf8"); const result = await ensureManagedProjectWorkspace({ companyId, projectId, repoUrl: sourceRepo, }); expect(result.cwd).toBe(cwd); expect(result.warning).toContain("Using it as-is"); await expect(fs.readFile(path.join(cwd, "keep.txt"), "utf8")).resolves.toBe("operator data\n"); } finally { await fs.rm(sourceRepo, { recursive: true, force: true }); } }); it("keeps the credential env alive through the sanitizer spread order", () => { // The clone env is `{ ...sanitize(process.env), GIT_TERMINAL_PROMPT, ...auth.env }`. The // sanitizer strips every PAPERCLIP_* key, so the token env must be spread after it. const invocation = buildGitAuthInvocation({ token: "tok", source: "company_secret", secretName: "GITHUB_TOKEN", }); const cloneEnv: NodeJS.ProcessEnv = { ...sanitizeRuntimeServiceBaseEnv({ ...process.env, [GIT_CREDENTIAL_TOKEN_ENV_KEY]: "stale" }), GIT_TERMINAL_PROMPT: "0", ...invocation.env, }; expect(cloneEnv[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("tok"); expect(sanitizeRuntimeServiceBaseEnv({ [GIT_CREDENTIAL_TOKEN_ENV_KEY]: "stale" })[GIT_CREDENTIAL_TOKEN_ENV_KEY]) .toBeUndefined(); }); });