mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 00:56:52 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can ask Paperclip to prepare a task repository at a specific
ref
> - Paperclip records that request and clones the repository at the next
admission
> - Paperclip did not check that the requested ref exists
> - When the ref was absent, every admission retried the same checkout
and failed setup
> - A corrected request was refused as a conflicting receipt, so the
task stayed blocked with no repair path
> - This pull request identifies a missing ref and lets a corrected
request replace it while nothing was published
> - The benefit is that a bad ref becomes a repairable error instead of
a permanent setup failure
## Linked Issues or Issue Description
**What happened?**
An agent asked for a catalog repository at ref `main`. The repository
default branch is `master` and has no `main` branch. Each later
admission failed with `setup_failed`: `git checkout main` reported
`pathspec 'main' did not match`. The task failed admission for more than
a week and became blocked. A new request with ref `master` returned 409
because the receipt with ref `main` already existed.
**Expected behavior**
Paperclip should report that the requested ref does not exist. A new
request with a valid ref should replace the bad ref when no checkout
exists.
**Steps to reproduce**
1. Use a repository whose only branch is `master`.
2. Request it as a task repository with `ref: "main"`.
3. Admit the task. Preparation fails, and the receipt goes to
`needs_repair`.
4. Request the same repository with `ref: "master"`. Before this change,
the request returns 409.
## What Changed
- `managed-repository-checkout.ts`: When `git checkout <ref>` fails with
Git's unknown-ref message, and each quiet `rev-parse --verify` probe
(`<ref>^{commit}`, `refs/remotes/origin/<ref>^{commit}`) exits 1, throw
`RepositoryRefNotFoundError`. The message tells the caller to request
the repository again with an existing branch, tag, or commit. Other
checkout failures keep their original cause.
- `execution-workspace-repositories.ts`: Record `failureCode:
"repository_ref_not_found"` for that error. Other failures keep
`repository_preparation_failed`.
- `execution-workspace-repositories.ts`: A request for the same
repository with a different ref replaces the ref only when all of these
are true: the receipt is `needs_repair` with `repository_ref_not_found`,
it has no pinned commit, the workspace is local (no remote authority),
and the checkout path is absent. The update is conditional on that state
and writes a `workspace.repository.ref_corrected` activity entry. All
other ref changes keep the existing 409 conflict.
- `execution-workspace-repositories.ts`: Local admission re-reads each
receipt inside the checkout lock and claims it with a conditional update
on its current ref. A correction that commits after the receipts were
listed is used, not discarded.
- Tests: one regression for the full missing-ref path, and one safety
test that keeps the receipt immutable when files exist at the checkout
path or when the failure was not a missing ref.
## Verification
- Red before the fix: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/task-workspace-repositories-persistence.test.ts` failed
with `expected ... /ref "main" does not exist/ but got 'Failed to
prepare managed checkout fo…'`.
- Green after the fix: the same file passes (11 tests). The new test
admits with ref `main` (fails, `repository_ref_not_found`, no checkout),
corrects to `master`, admits again (ready, pinned to the source commit),
and then confirms that a later ref change after publication still
returns a conflict.
- The regression also holds the checkout lock while admission waits,
corrects the ref, and then releases the lock. Without the in-lock
re-read it fails with 409; with it, admission publishes the corrected
ref.
- `heartbeat-managed-clone-credentials.test.ts` passes: an
authentication failure during checkout keeps its cause and is not
reported as a missing ref.
- Other checkout suites pass (9 files, 317 tests): reopen, base storage,
expiration, project env, rehome, nested clone migration, object cache,
trust, source copy privacy.
- Neighbor suites pass: `managed-repository-checkout.test.ts`,
`task-workspace-repositories.test.ts`,
`task-workspace-repositories-trust.test.ts`,
`heartbeat-project-repositories.test.ts` (47 tests). In one parallel run
under heavy machine load, `heartbeat-project-repositories.test.ts` had
timing failures in different tests on each run. The same file passed
alone with this change (47/47) and on master.
- `tsc --noEmit` for `server` passes.
## Risks
- Low risk. The new correction path is narrow and fails closed. Remote
(sandbox) workspaces keep the existing conflict because the control
plane cannot inspect the remote checkout.
- Existing receipts that failed before this change have
`repository_preparation_failed`. They need one more failed admission to
get `repository_ref_not_found` before a correction is accepted.
- No schema change. `failure_code` is free text.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, agentic coding with
tool use (shell, file edit, browser read-only diagnostics).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
476 lines
23 KiB
TypeScript
476 lines
23 KiB
TypeScript
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();
|
|
});
|
|
});
|