Files
PaperClipAI/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts
T
DottaandClaude Opus 5.5 d8dbe55368 fix(workspaces): let a missing task repository ref be corrected (#19072)
## 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>
2026-11-04 18:25:41 -06:00

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();
});
});