fix(adapter-utils): harden the referenced-project ignore scan (#12214)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent adapters stage referenced projects into controlled sandboxes
> - The ignore scan must preserve exact Git path bytes and fail closed
on unsafe input
> - Unbounded ignored-path data and raw diagnostics can harm resource
use or expose host details
> - This pull request adds exact path parsing, input bounds, fixed
failure categories, and saturation-only retry
> - The benefit is safer and more predictable referenced-project staging

## Linked Issues or Issue Description

**What happened?**

The referenced-project ignore scan trimmed NUL-delimited Git paths. It
also accepted a large ignored-path set and exposed raw failure details
through staging errors and warnings.

**Expected behavior**

The scan must preserve leading and trailing whitespace in Git paths. It
must reject oversized ignored-path data and expose only fixed failure
categories.

**Steps to reproduce**

1. Run the referenced-project ignore scan with paths that start or end
with whitespace.
2. Provide more than 10,000 ignored entries or more than 2 MiB of path
bytes.
3. Trigger a scan failure and inspect the reported reason.

**Paperclip version or commit**

d560bc2ae2

**Deployment mode**

Built from source.

**Installation method**

Built from source.

**Agent adapter(s) involved**

Not adapter-specific.

**Database mode**

Not database-related.

**Additional context**

This change covers the overlay diff, untracked, deleted, and ignored Git
paths. It also retries only typed scheduler saturation failures.

## What Changed

- Preserve all bytes in NUL-delimited Git path records.
- Bound ignored-entry count and total UTF-8 path bytes during parsing.
- Redact scan failure details to three fixed reason categories.
- Retry only the typed scheduler saturation error, with three total
attempts and 1 second then 2 second waits.
- Add tests for path whitespace, limits, diagnostics, retry behavior,
and scheduler code parity.

## Verification

- `npx tsc --noEmit` in `packages/adapter-utils` passed.
- `npx vitest run packages/adapter-utils` passed with 977 tests and 4
skipped.
- Continuous integration must run the server suite and the full
repository gates.

## Risks

The scan now rejects ignored-path data above fixed limits. Saturation
retries add up to 3 seconds before a final failure. The resolver still
fails closed for all other errors.

## Model Used

OpenAI GPT-5. The model used tool calls, code inspection, and command
execution. The exact context window and reasoning mode are not exposed
by the runtime.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-08-26 08:30:42 -07:00 committed by GitHub
parent a9d0927fe8
commit 198fc8b281
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 847 additions and 39 deletions

View File

@ -38,6 +38,7 @@ import {
} from "./execute.js";
import { runChildProcess } from "../server-utils.js";
import { setExpensiveWorkspaceGitExecutor } from "../git-workspace-sync.js";
import { resolveReferencedSourceIgnore } from "../sandbox-managed-runtime.js";
import {
getActiveStepContext,
runWithRuntimeParent,
@ -1591,6 +1592,37 @@ describe("shared ACPX engine runtime behavior", () => {
expect(signature).toBe("unreadable:git status timed out");
});
it("never leaks a raw absolute path into the signature, even when the underlying scan embedded one", async () => {
const root = await makeTempRoot();
const localPath = path.join(root, "does-not-exist");
// A raw toplevel string that makes `localPath` a non-descendant, carrying
// a sensitive absolute path — exactly the shape a caught Git diagnostic
// could embed. `resolveReferencedSourceIgnore` is the single choke point
// that must reduce it to the fixed category before the signature (which
// embeds `reason` verbatim as `unreadable:${reason}`) ever sees it.
const sensitivePath = "/home/alice/project";
let ignoreResolution;
try {
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.toplevel") {
return { stdout: `${sensitivePath}\n`, stderr: "" };
}
return { stdout: "", stderr: "" };
});
ignoreResolution = await resolveReferencedSourceIgnore(localPath);
} finally {
setExpensiveWorkspaceGitExecutor(null);
}
expect(ignoreResolution.kind).toBe("failed");
const signature = await referencedSourceContentSignature(localPath, ignoreResolution);
expect(signature).toBe("unreadable:git-toplevel-not-descendant");
expect(signature).not.toContain(sensitivePath);
expect(signature).not.toContain(localPath);
});
it("skips a Git-ignored file (exact match) so its content never affects the signature", async () => {
const root = await makeTempRoot();
const localPath = path.join(root, "project");

View File

@ -14,7 +14,10 @@ import {
integrateImportedGitHead,
isMissingGitPrerequisiteError,
readGitWorkspaceSnapshot,
ReferencedSourceIgnoreScanLimitExceededError,
readReferencedSourceGitIgnoredPaths,
REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT,
REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES,
runLocalGit,
sanitizeGitRemoteUrl,
setExpensiveWorkspaceGitExecutor,
@ -64,6 +67,44 @@ describe("git workspace sync", () => {
]);
});
it("keeps every filename byte for a padded name in each of the four anchor lanes", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-anchor-whitespace-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
// Deleted lane: commit the file first (in isolation, before anything else
// is staged), then remove it from the work tree.
const deletedName = " deleted padded ";
await writeFile(path.join(repo, deletedName), "deleted\n", "utf8");
await git(repo, ["add", deletedName]);
await git(repo, ["commit", "-qm", "add deleted padded"]);
await rm(path.join(repo, deletedName));
// Overlay lane, staged-new half: `git diff --diff-filter=ACMRTUXB HEAD`
// reports a staged-but-uncommitted file as added.
const overlayName = " overlay padded ";
await writeFile(path.join(repo, overlayName), "overlay\n", "utf8");
await git(repo, ["add", overlayName]);
// Overlay lane, untracked half: `ls-files --others --exclude-standard`.
const untrackedName = " untracked padded ";
await writeFile(path.join(repo, untrackedName), "untracked\n", "utf8");
// Ignored lane: a double-wildcard pattern avoids the separate rule that
// Git trims an unescaped trailing space in a .gitignore PATTERN itself;
// the padding under test lives in the matched FILE name.
const ignoredName = " ignored padded ";
await writeFile(path.join(repo, ".gitignore"), "*ignored*padded*\n", "utf8");
await writeFile(path.join(repo, ignoredName), "ignored\n", "utf8");
const snapshot = await readGitWorkspaceSnapshot(repo);
expect(snapshot?.overlayPaths).toContain(overlayName);
expect(snapshot?.overlayPaths).toContain(untrackedName);
expect(snapshot?.deletedPaths).toContain(deletedName);
expect(snapshot?.ignoredPaths).toContain(ignoredName);
});
async function createRepo(rootDir: string): Promise<string> {
const repo = path.join(rootDir, "repo");
await mkdir(repo, { recursive: true });
@ -576,6 +617,157 @@ describe("git workspace sync", () => {
expect(scan?.ignoredPaths).toEqual([paddedName]);
});
it("fails closed when the parsed ignored-entry count exceeds the bound", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-bound-count-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
// Synthesize the `git ls-files --others --ignored -z` output directly,
// rather than creating ten thousand real files, by intercepting the
// scan at the executor seam. The parser must reject this before it
// sorts or re-relativizes the list.
const overLimitCount = REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT + 1;
const syntheticIgnored = `${Array.from({ length: overLimitCount }, (_, index) => `entry-${index}`).join("\0")}\0`;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
return { stdout: syntheticIgnored, stderr: "" };
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
await expect(readReferencedSourceGitIgnoredPaths(repo)).rejects.toBeInstanceOf(
ReferencedSourceIgnoreScanLimitExceededError,
);
});
it("fails closed when the summed UTF-8 byte size of ignored paths exceeds the bound", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-bound-bytes-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
// One entry alone exceeds the byte bound, well under the entry-count bound.
const hugeEntry = "a".repeat(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES + 1);
const syntheticIgnored = `${hugeEntry}\0`;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
return { stdout: syntheticIgnored, stderr: "" };
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
await expect(readReferencedSourceGitIgnoredPaths(repo)).rejects.toBeInstanceOf(
ReferencedSourceIgnoreScanLimitExceededError,
);
});
it("fails closed on the byte bound while it is still accumulating, before it would ever reach a later entry-count breach", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-bound-order-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
// Three entries alone cross the byte bound. Many more small entries
// follow, so the FULL response also carries more than the entry-count
// bound. A parser that fully builds the list before checking either
// bound (post-parse) would report the entry-count breach, because it
// checks that bound first against the whole materialized list. A
// parser that checks both bounds while the list accumulates rejects on
// the byte bound instead, the moment the third entry crosses it, well
// before the count bound is ever reached.
const oversizedEntry = "a".repeat(Math.ceil(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES / 2) + 1);
const bigEntries = Array.from({ length: 3 }, (_, index) => `${oversizedEntry}-${index}`);
const trailingEntries = Array.from(
{ length: REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT + 10 },
(_, index) => `trailing-${index}`,
);
const syntheticIgnored = `${[...bigEntries, ...trailingEntries].join("\0")}\0`;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
return { stdout: syntheticIgnored, stderr: "" };
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
await expect(readReferencedSourceGitIgnoredPaths(repo)).rejects.toThrow(/UTF-8 bytes/);
});
it("bounds the raw command-output allowance to the ignore-scan limits, not the general-purpose full-tree ceiling", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-raw-buffer-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
let observedMaxBuffer: number | undefined;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
observedMaxBuffer = input.maxBuffer;
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
await readReferencedSourceGitIgnoredPaths(repo);
// Enough headroom for a scan within bounds to complete, but a small
// multiple of the byte bound — not the far larger allowance the
// anchor workspace's general-purpose full-tree reads use.
expect(observedMaxBuffer).toBeGreaterThan(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES);
expect(observedMaxBuffer).toBeLessThan(16 * 1024 * 1024);
});
it("does not fail closed on a huge amount of unrelated tracked-change and untracked noise, when the ignored set itself stays in bounds", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-mixed-status-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
await writeFile(path.join(repo, ".gitignore"), "secret.env\n", "utf8");
await writeFile(path.join(repo, "secret.env"), "TOKEN=abc\n", "utf8");
// Many long-named, untracked, NOT-ignored files at the repository root.
// `git status` reports one record per file (root-level files are never
// collapsed the way an entirely untracked directory is), so this alone
// makes the raw `git status --ignored` response exceed the raw buffer
// bound this scan used to apply to the WHOLE response, well before the
// parser ever got to discard these non-ignored records. The ignored set
// above stays a single small entry throughout.
const noiseNameLength = 220;
const noiseFileCount = 30_000;
const noiseNames = Array.from(
{ length: noiseFileCount },
(_, index) => `${"n".repeat(noiseNameLength - 6)}${String(index).padStart(6, "0")}`,
);
const writeConcurrency = 200;
for (let start = 0; start < noiseNames.length; start += writeConcurrency) {
const batch = noiseNames.slice(start, start + writeConcurrency);
await Promise.all(batch.map((name) => writeFile(path.join(repo, name), "", "utf8")));
}
// Confirm this test actually reproduces the reported defect precondition:
// the raw `git status --ignored` response for this repository state is
// larger than the 4 MiB raw buffer bound the scan used to apply to the
// whole response, not just to the declared ignored-set limits. A large
// explicit maxBuffer is required here only to observe that raw size;
// the scan under test never issues this command.
const rawStatusResult = await runLocalGit(
repo,
["status", "--ignored", "--porcelain=v1", "-z", "--untracked-files=normal"],
{ maxBuffer: 16 * 1024 * 1024 },
);
expect(Buffer.byteLength(rawStatusResult.stdout, "utf8")).toBeGreaterThan(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES * 2);
const scan = await readReferencedSourceGitIgnoredPaths(repo);
expect(scan?.ignoredPaths).toEqual(["secret.env"]);
});
it("routes both scan commands through the registered scheduler instead of spawning git directly", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-scheduler-"));
cleanupDirs.push(rootDir);

View File

@ -40,6 +40,19 @@ export type ExpensiveWorkspaceGitExecutor = (
let expensiveWorkspaceGitExecutor: ExpensiveWorkspaceGitExecutor | null = null;
/**
* The workspace Git scan scheduler's typed code for a saturated queue
* (`server/src/services/workspace-git-operation-scheduler.ts`,
* `WORKSPACE_GIT_SCAN_ERROR_CODES.saturated`). Declared again here because
* `adapter-utils` cannot import from `server` (the reverse direction is
* allowed, not this one); `server` carries a test that asserts the two
* literals stay equal. `resolveReferencedSourceIgnore` in
* `sandbox-managed-runtime.ts` reads this code off a caught error's `code`
* property, never off its message text, to retry only a saturated queue and
* fail closed on every other Git scan error.
*/
export const WORKSPACE_GIT_SCAN_SATURATED_CODE = "workspace_git_scan_saturated";
/**
* Lets a host process apply its process-wide admission policy to the adapter
* package's full-tree Git walks. Standalone adapter-utils consumers retain the
@ -161,7 +174,16 @@ export async function readGitWorkspaceSnapshot(localDir: string): Promise<GitWor
]);
const branchName = branchResult.stdout.trim();
const splitNul = (value: string) => value.split("\0").map((entry) => entry.trim()).filter(Boolean);
// `-z` already delimits each record with a NUL byte, so a leading or
// trailing space in a record is part of the path itself, not padding to
// remove — trimming it would resolve to a path that does not exist. A
// length check finds the one genuinely empty record `-z` appends after
// the last NUL, without eating a real path's own leading or trailing
// whitespace. This applies to all four NUL-delimited outputs below (the
// overlay diff, the untracked list, the deleted list, and the ignored
// list); `branchName` and `headCommit` come from non-`-z` commands and
// keep their own `.trim()` above and below, which is safe.
const splitNul = (value: string) => value.split("\0").filter((entry) => entry.length > 0);
return {
headCommit: headCommitResult.stdout.trim(),
branchName: branchName && branchName !== "HEAD" ? branchName : null,
@ -180,7 +202,7 @@ export async function readGitWorkspaceSnapshot(localDir: string): Promise<GitWor
}
}
/** The `git status --ignored` output for one directory, read by {@link readReferencedSourceGitIgnoredPaths}. */
/** The `git ls-files --others --ignored` output for one directory, read by {@link readReferencedSourceGitIgnoredPaths}. */
export interface ReferencedSourceGitIgnoreScan {
/** The absolute repository top level `git rev-parse --show-toplevel` reports. */
toplevel: string;
@ -261,6 +283,43 @@ function isNotAGitRepositoryError(error: unknown): boolean {
return /not a git repository/i.test(stderr) || /not a git repository/i.test(message);
}
/** Bound on the number of parsed ignored entries `readReferencedSourceGitIgnoredPaths` accepts before it fails closed. */
export const REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT = 10_000;
/** Bound on the summed UTF-8 byte length of the resolved ignored-path strings `readReferencedSourceGitIgnoredPaths` accepts before it fails closed. */
export const REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
/**
* Bound on the raw `git ls-files --others --ignored` output
* `readReferencedSourceGitIgnoredPaths` lets Node buffer, kept proportionate
* to {@link REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES} instead of the far
* larger allowance the anchor workspace's general-purpose full-tree reads
* use. The command reports only ignored entries (see the invocation below),
* so this raw allowance is not exposed to an unrelated tracked-change or
* ordinary-untracked record count a repository with a huge diff or a huge
* untracked set never grows this command's output. The parser below still
* enforces the real entry-count and byte bounds while it reads each record,
* so this value only needs headroom for the NUL delimiter and the trailing
* slash on every entry, not room for an oversized ignored-path list to land
* in memory in the first place.
*/
const REFERENCED_SOURCE_IGNORE_MAX_RAW_BUFFER = REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES * 2;
/**
* Thrown by {@link readReferencedSourceGitIgnoredPaths} when the parsed
* ignored-path list breaches {@link REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT}
* or {@link REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES}, so the caller can
* classify the failure as a bound breach instead of a plain Git read error.
* The message never leaves this package: `resolveReferencedSourceIgnore`
* replaces it with a fixed category before the failure reaches any consumer.
*/
export class ReferencedSourceIgnoreScanLimitExceededError extends Error {
constructor(message: string) {
super(message);
this.name = "ReferencedSourceIgnoreScanLimitExceededError";
}
}
/**
* Read the Git-ignored paths of a referenced-project host directory, for the
* staging path to exclude them (see `resolveReferencedSourceIgnore` in
@ -270,8 +329,10 @@ function isNotAGitRepositoryError(error: unknown): boolean {
*
* Returns `null` when `localDir` is not a Git work tree the caller keeps
* today's fixed excludes for that case. Throws on any other Git error, a
* timeout, or malformed output, so the caller can fail closed and skip
* staging that one project instead of shipping it unfiltered.
* timeout, malformed output, or a bound breach (see
* {@link ReferencedSourceIgnoreScanLimitExceededError}), so the caller can
* fail closed and skip staging that one project instead of shipping it
* unfiltered.
*/
export async function readReferencedSourceGitIgnoredPaths(
localDir: string,
@ -295,24 +356,70 @@ export async function readReferencedSourceGitIgnoredPaths(
throw new Error(`git rev-parse --show-toplevel returned an empty path for ${localDir}`);
}
// `ls-files --others --ignored --exclude-standard` reports only ignored
// entries — unlike `git status --ignored`, it never also reports a tracked
// change or an ordinary untracked file. A repository with a huge diff or a
// huge untracked set (unrelated to what is ignored) cannot inflate this
// command's raw output, so the raw buffer bound below only ever has to
// cover the declared ignored-set limits, not an unbounded amount of
// unrelated status noise ahead of them.
// `--directory` collapses an entirely ignored directory into one entry with
// a trailing slash, matching `git status --ignored`'s traditional mode.
// `--full-name` reports paths relative to the repository toplevel, so this
// still matches the toplevel-relative shape `resolveReferencedSourceIgnore`
// re-relativizes against, regardless of `localDir`'s position under it.
const ignoredResult = await runHardenedReadOnlyGit(
localDir,
["status", "--ignored", "--porcelain=v1", "-z", "--untracked-files=normal"],
["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--full-name", "-z"],
"referenced_source.ignored_files",
{ timeout: 60_000, maxBuffer: 16 * 1024 * 1024 },
{ timeout: 60_000, maxBuffer: REFERENCED_SOURCE_IGNORE_MAX_RAW_BUFFER },
);
// Do not trim each entry: `git status -z` already delimits entries with a
// NUL byte, so a leading or trailing space in an entry is part of the path
// itself, not padding to remove. A length check finds the one genuinely
// empty entry `-z` appends after the last NUL, without eating a real path's
// own leading or trailing whitespace.
const ignoredPaths = ignoredResult.stdout
.split("\0")
.filter((entry) => entry.length > 0)
.filter((entry) => entry.startsWith("!! "))
.map((entry) => entry.slice(3).replace(/\/+$/, ""))
.filter((entry) => entry.length > 0)
.sort((left, right) => left.localeCompare(right));
// Read one NUL-delimited record at a time and enforce both bounds while the
// ignored-entry list accumulates, instead of splitting and mapping the
// whole response into a list first and only then checking its size. A
// pathologically large ignore set (a huge repository, or one crafted to
// hold many ignored entries) must fail closed the moment it breaches a
// bound, without this scan first retaining and transforming the full
// oversized response.
//
// Do not trim each entry: `-z` already delimits entries with a NUL byte, so
// a leading or trailing space in an entry is part of the path itself, not
// padding to remove. A length check finds the one genuinely empty record
// `-z` appends after the last NUL, without eating a real path's own
// leading or trailing whitespace.
const rawIgnored = ignoredResult.stdout;
const parsedIgnoredEntries: string[] = [];
let totalIgnoredBytes = 0;
let recordStart = 0;
while (recordStart < rawIgnored.length) {
const nulIndex = rawIgnored.indexOf("\0", recordStart);
const recordEnd = nulIndex === -1 ? rawIgnored.length : nulIndex;
const record = rawIgnored.slice(recordStart, recordEnd);
recordStart = nulIndex === -1 ? rawIgnored.length : nulIndex + 1;
const entry = record.replace(/\/+$/, "");
if (entry.length === 0) {
continue;
}
if (parsedIgnoredEntries.length + 1 > REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT) {
throw new ReferencedSourceIgnoreScanLimitExceededError(
`referenced project ignore scan found more than ${REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT} ignored entries`,
);
}
totalIgnoredBytes += Buffer.byteLength(entry, "utf8");
if (totalIgnoredBytes > REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES) {
throw new ReferencedSourceIgnoreScanLimitExceededError(
`referenced project ignore scan exceeded ${REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES} UTF-8 bytes of ignored paths`,
);
}
parsedIgnoredEntries.push(entry);
}
// The list is bounded by both checks above, so sorting and re-relativizing
// it here never costs more than the accepted bounds allow.
const ignoredPaths = parsedIgnoredEntries.sort((left, right) => left.localeCompare(right));
return { toplevel, ignoredPaths };
}

View File

@ -26,6 +26,8 @@ vi.mock("./ssh.js", () => ({
}));
import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js";
import { resolveReferencedSourceIgnore } from "./sandbox-managed-runtime.js";
import { setExpensiveWorkspaceGitExecutor } from "./git-workspace-sync.js";
describe("remote managed runtime", () => {
const cleanupDirs: string[] = [];
@ -262,4 +264,62 @@ describe("remote managed runtime", () => {
expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["healthy"]);
expect(syncDirectoryToSsh).not.toHaveBeenCalledWith(expect.objectContaining({ localDir: failedDir }));
});
it("never leaks a raw absolute path into the remote per-project staging warning", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-redact-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const failedDir = path.join(rootDir, "referenced-failed");
await mkdir(workspaceDir, { recursive: true });
await mkdir(failedDir, { recursive: true });
// A raw toplevel string that makes `failedDir` a non-descendant, carrying
// a sensitive absolute path — exactly the shape a caught Git diagnostic
// could embed. `resolveReferencedSourceIgnore` is the single choke point
// that must reduce it to the fixed category before anything downstream
// (here, the remote lane's warning) ever sees it.
const sensitivePath = "/srv/alice/project";
let ignoreResolution;
try {
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.toplevel") {
return { stdout: `${sensitivePath}\n`, stderr: "" };
}
return { stdout: "", stderr: "" };
});
ignoreResolution = await resolveReferencedSourceIgnore(failedDir);
} finally {
setExpensiveWorkspaceGitExecutor(null);
}
expect(ignoreResolution.kind).toBe("failed");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
await prepareRemoteManagedRuntime({
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/app",
remoteCwd: "/app",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
strictHostKeyChecking: true,
},
runId: "run-redact",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [{ localPath: failedDir, projectId: "failed", ignoreResolution }],
});
const warnedText = warnSpy.mock.calls.map((call) => call.join(" ")).join("\n");
expect(warnedText).toContain("failed");
expect(warnedText).not.toContain(sensitivePath);
expect(warnedText).not.toContain(failedDir);
} finally {
warnSpy.mockRestore();
}
});
});

View File

@ -6,13 +6,19 @@ import path from "node:path";
import { execFile as execFileCallback, spawn } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetLocalGitIndexToHead } from "./git-workspace-sync.js";
import {
resetLocalGitIndexToHead,
runLocalGit,
setExpensiveWorkspaceGitExecutor,
WORKSPACE_GIT_SCAN_SATURATED_CODE,
} from "./git-workspace-sync.js";
import {
assertSyncOperationsConfined,
escapeTarExcludeLiteral,
mirrorDirectory,
prepareSandboxManagedRuntime,
REFERENCED_SOURCE_IGNORE_FAILURE_REASONS,
resolveReferencedSourceIgnore,
type PreparedSandboxManagedRuntime,
type ReferencedSourceIgnoreResolution,
@ -1003,6 +1009,65 @@ describe("sandbox managed runtime", () => {
expect(downloadMembers.some((entry) => entry.includes("/node_modules/") || entry.endsWith("/node_modules"))).toBe(false);
});
it("excludes an anchor-workspace ignored file whose name has leading and trailing whitespace from the staged tree", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-ignored-whitespace-"));
cleanupDirs.push(rootDir);
const workspaceLocalDir = path.join(rootDir, "workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await initGitRepo(workspaceLocalDir);
// A double-wildcard pattern avoids the separate rule that Git trims an
// unescaped trailing space in a .gitignore PATTERN itself; the padding
// under test lives in the matched FILE name, proving the anchor `splitNul`
// parser keeps it instead of trimming it away and missing the exclude.
const ignoredName = " ignored padded ";
await writeFile(path.join(workspaceLocalDir, ".gitignore"), "*ignored*padded*\n", "utf8");
await writeFile(path.join(workspaceLocalDir, ignoredName), "TOKEN=abc\n", "utf8");
await writeFile(path.join(workspaceLocalDir, "kept.txt"), "kept\n", "utf8");
const uploadedTars: { remotePath: string; bytes: Buffer }[] = [];
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes) => {
await mkdir(path.dirname(remotePath), { recursive: true });
const buffer = Buffer.from(bytes);
if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer });
await writeFile(remotePath, buffer);
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
},
};
attachFallbackSyncIn(client);
await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir,
});
const workspaceUpload = uploadedTars.find((entry) => path.posix.basename(entry.remotePath) === "workspace-upload.tar");
expect(workspaceUpload).toBeDefined();
const members = await listTarMembers(rootDir, "ignored-whitespace-workspace-upload.tar", workspaceUpload!.bytes);
expect(members).not.toContain(ignoredName);
expect(members).toContain("kept.txt");
});
it("builds workspace/asset tarballs without a './' self-entry (so untar does not chmod/utime an unowned target dir)", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-tarself-"));
cleanupDirs.push(rootDir);
@ -2460,8 +2525,272 @@ describe("sandbox managed runtime", () => {
const resolution = await resolveReferencedSourceIgnore(repo);
expect(resolution.kind).toBe("failed");
expect((resolution as { reason: string }).reason.length).toBeGreaterThan(0);
// The reason is the fixed category, never the caught error's own message
// (which would embed `repo`, an absolute host path).
expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed });
});
// Nadia's required probes: none of these three example absolute paths —
// an ordinary POSIX path, a home directory, and a Windows path — may ever
// reach `reason`, however they arrive (a caught Git error, or a raw
// toplevel string that makes `localPath` a non-descendant).
const SENSITIVE_PATH_PROBES = ["/srv/alice/project", "/home/alice/project", "C:\\Users\\alice\\project"];
for (const sensitivePath of SENSITIVE_PATH_PROBES) {
it(`redacts a caught Git error embedding ${sensitivePath} to the fixed category`, async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-redact-caught-"));
cleanupDirs.push(rootDir);
const repo = path.join(rootDir, "repo");
await initGitRepo(repo);
try {
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
throw Object.assign(
new Error(`fatal: unable to read tree object for ${sensitivePath}, pid 4242`),
{ stderr: `fatal: unable to read tree object for ${sensitivePath}, pid 4242` },
);
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
const resolution = await resolveReferencedSourceIgnore(repo);
expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed });
} finally {
setExpensiveWorkspaceGitExecutor(null);
}
});
it(`redacts a non-descendant toplevel embedding ${sensitivePath} to the fixed category`, async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-redact-nondescendant-"));
cleanupDirs.push(rootDir);
const localPath = path.join(rootDir, "referenced");
await mkdir(localPath, { recursive: true });
try {
// A toplevel string with no relation to `localPath` — the resolver
// must treat it as a non-descendant and fail closed with the fixed
// category, never a message built from `sensitivePath` or `localPath`.
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.toplevel") {
return { stdout: `${sensitivePath}\n`, stderr: "" };
}
return { stdout: "", stderr: "" };
});
const resolution = await resolveReferencedSourceIgnore(localPath);
expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.toplevelNotDescendant });
} finally {
setExpensiveWorkspaceGitExecutor(null);
}
});
}
it("fails closed with a fixed category when the parsed ignored-entry count exceeds the bound", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-bound-count-"));
cleanupDirs.push(rootDir);
const repo = path.join(rootDir, "repo");
await initGitRepo(repo);
const overLimitCount = 10_001;
const syntheticIgnored = `${Array.from({ length: overLimitCount }, (_, index) => `!! entry-${index}`).join("\0")}\0`;
try {
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
return { stdout: syntheticIgnored, stderr: "" };
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
const resolution = await resolveReferencedSourceIgnore(repo);
expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded });
} finally {
setExpensiveWorkspaceGitExecutor(null);
}
});
it("fails closed with a fixed category when the total UTF-8 byte size of ignored paths exceeds the bound", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-bound-bytes-"));
cleanupDirs.push(rootDir);
const repo = path.join(rootDir, "repo");
await initGitRepo(repo);
// One entry alone exceeds the 2 MiB bound, well under the entry-count bound.
const hugeEntry = "a".repeat(3 * 1024 * 1024);
const syntheticIgnored = `!! ${hugeEntry}\0`;
try {
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
return { stdout: syntheticIgnored, stderr: "" };
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
const resolution = await resolveReferencedSourceIgnore(repo);
expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded });
} finally {
setExpensiveWorkspaceGitExecutor(null);
}
});
it("stages no bytes for either a count-breach or a byte-breach project, and stages a healthy sibling", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-bound-staging-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const healthyDir = path.join(rootDir, "referenced-healthy");
const countBreachDir = path.join(rootDir, "referenced-count-breach");
const byteBreachDir = path.join(rootDir, "referenced-byte-breach");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(healthyDir, { recursive: true });
await mkdir(countBreachDir, { recursive: true });
await mkdir(byteBreachDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8");
await writeFile(path.join(healthyDir, "notes.md"), "healthy\n", "utf8");
await writeFile(path.join(countBreachDir, "should-never-ship.txt"), "must not stage\n", "utf8");
await writeFile(path.join(byteBreachDir, "should-never-ship.txt"), "must not stage\n", "utf8");
const countBreachReason = { kind: "failed" as const, reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded };
const byteBreachReason = { kind: "failed" as const, reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded };
const prepared = await prepareCommandManagedRuntime({
runner: makeInlineSpawnRunner(),
spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 },
adapterKey: "test-adapter",
workspaceLocalDir: localWorkspaceDir,
additionalSources: [
{ localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } },
{ localPath: countBreachDir, projectId: "count-breach", ignoreResolution: countBreachReason },
{ localPath: byteBreachDir, projectId: "byte-breach", ignoreResolution: byteBreachReason },
],
});
expect(Object.keys(prepared.additionalSourceDirs).sort()).toEqual(["healthy"]);
expect(prepared.additionalSourceFailures.map((failure) => failure.projectId).sort()).toEqual([
"byte-breach",
"count-breach",
]);
const runtimeRootDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "test-adapter");
await expect(readFile(path.join(runtimeRootDir, "project-count-breach", "should-never-ship.txt"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(runtimeRootDir, "project-byte-breach", "should-never-ship.txt"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
});
describe("saturation retry", () => {
afterEach(() => {
vi.useRealTimers();
setExpensiveWorkspaceGitExecutor(null);
});
function throwSaturated(): never {
throw Object.assign(
new Error("Changed files are temporarily unavailable because the Git scan queue is full"),
{ code: WORKSPACE_GIT_SCAN_SATURATED_CODE },
);
}
// Every case here synthesizes BOTH scan operations at the executor seam
// instead of spawning real `git` — the property under test is the
// retry's own timing and attempt count, and a fake-timer-driven test
// must not also depend on a real child process's independent, real-time
// completion. `toplevel` need not exist on disk: `resolveReferencedSourceIgnore`
// falls back to a plain string compare when `fs.realpath` fails, and the
// fixture path is used unchanged on both sides of that compare.
const repo = "/fixture/referenced-project";
it("retries a saturated scan up to two times and succeeds on the third attempt", async () => {
let ignoredCallCount = 0;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.toplevel") {
return { stdout: `${repo}\n`, stderr: "" };
}
ignoredCallCount += 1;
if (ignoredCallCount <= 2) throwSaturated();
return { stdout: "", stderr: "" };
});
vi.useFakeTimers();
const resolutionPromise = resolveReferencedSourceIgnore(repo);
// Bounded backoff: none before attempt 1, 1 s before attempt 2, 2 s
// before attempt 3.
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(2_000);
const resolution = await resolutionPromise;
expect(resolution).toEqual({ kind: "git", ignoredPaths: [] });
expect(ignoredCallCount).toBe(3);
});
it("fails closed after three saturated attempts, with no further Git invocation", async () => {
let ignoredCallCount = 0;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.toplevel") {
return { stdout: `${repo}\n`, stderr: "" };
}
ignoredCallCount += 1;
throwSaturated();
});
vi.useFakeTimers();
const resolutionPromise = resolveReferencedSourceIgnore(repo);
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(2_000);
const resolution = await resolutionPromise;
expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed });
// Three total attempts (the first plus two retries) — no fourth,
// unscheduled invocation past the retry budget.
expect(ignoredCallCount).toBe(3);
});
it("makes exactly one attempt and fails closed on a timeout, never retrying it", async () => {
let ignoredCallCount = 0;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.toplevel") {
return { stdout: `${repo}\n`, stderr: "" };
}
ignoredCallCount += 1;
throw Object.assign(new Error("Workspace Git scan timed out after 8000ms"), {
code: "workspace_git_scan_timeout",
});
});
const resolution = await resolveReferencedSourceIgnore(repo);
expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed });
expect(ignoredCallCount).toBe(1);
});
it("makes exactly one attempt and fails closed on an output-limit breach, never retrying it", async () => {
let ignoredCallCount = 0;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.toplevel") {
return { stdout: `${repo}\n`, stderr: "" };
}
ignoredCallCount += 1;
throw Object.assign(new Error("Workspace Git scan exceeded its output limit"), {
code: "workspace_git_scan_output_limit",
});
});
const resolution = await resolveReferencedSourceIgnore(repo);
expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed });
expect(ignoredCallCount).toBe(1);
});
});
});

View File

@ -14,9 +14,11 @@ import {
GIT_ARCHIVE_EXCLUDES,
integrateImportedGitHead,
readGitWorkspaceSnapshot,
ReferencedSourceIgnoreScanLimitExceededError,
readReferencedSourceGitIgnoredPaths,
resetLocalGitIndexToHead,
withShallowGitWorkspaceClone,
WORKSPACE_GIT_SCAN_SATURATED_CODE,
} from "./git-workspace-sync.js";
import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
import {
@ -147,16 +149,39 @@ export interface SandboxManagedRuntimeAsset {
* `resolveReferencedSourceIgnore`).
* - `other`: `localPath` is not a Git work tree. The staging path keeps
* today's fixed heavy-directory excludes.
* - `failed`: the Git read failed, timed out, or returned output the
* resolver could not parse or safely re-relativize. The project is NOT
* staged (fail closed) every site records it as a per-project failure
* instead of shipping it unfiltered.
* - `failed`: the Git read failed, timed out, breached a parse bound, or
* returned output the resolver could not safely re-relativize. The project
* is NOT staged (fail closed) every site records it as a per-project
* failure instead of shipping it unfiltered. `reason` is always one of
* {@link REFERENCED_SOURCE_IGNORE_FAILURE_REASONS} never a raw Git or tar
* diagnostic, an absolute host path, or a basename.
*/
export type ReferencedSourceIgnoreResolution =
| { kind: "git"; ignoredPaths: string[] }
| { kind: "other" }
| { kind: "failed"; reason: string };
/**
* The fixed, allowlisted failure categories a `failed`
* {@link ReferencedSourceIgnoreResolution} reports as `reason`. This is the
* ENTIRE vocabulary: no absolute host path, no basename, no opaque token, and
* no raw Git or tar stderr ever reaches `reason` only one of these three
* stable strings, chosen once at the single construction point in
* `resolveReferencedSourceIgnore`. A `failed` resolution always prevents
* staging and is always re-resolved before its next use, so two different
* underlying failures colliding on the same category (e.g. a timeout and a
* malformed-output error both reporting `scanFailed`) never weakens the
* fail-closed decision.
*/
export const REFERENCED_SOURCE_IGNORE_FAILURE_REASONS = {
/** A Git read failed, timed out, was cancelled, or returned malformed output — including a saturated scan queue that never recovered after its retries. */
scanFailed: "git-ignore-scan-failed",
/** The parsed ignored-entry count or total UTF-8 byte size breached its bound (see `readReferencedSourceGitIgnoredPaths`). */
limitExceeded: "git-ignore-scan-limit-exceeded",
/** The referenced project's `localPath` is not a descendant of its own Git top level. */
toplevelNotDescendant: "git-toplevel-not-descendant",
} as const;
/**
* A referenced (additional) project to stage into the run sandbox as a plain,
* read-only tree. `localPath` is the host checkout directory. Upstream code
@ -240,7 +265,7 @@ function relativizeUnderGitToplevel(input: { toplevel: string; localPath: string
}
/**
* Re-relativize root-relative ignored paths (as `git status --ignored`
* Re-relativize root-relative ignored paths (as `readReferencedSourceGitIgnoredPaths`
* reports them, from the repository toplevel) to `offset`, the position of
* the referenced project's `localPath` under that toplevel. Keeps only the
* entries that are `offset` itself or a descendant of it an ignored path
@ -259,23 +284,78 @@ function reRelativizeIgnoredPathsToLocalPath(input: { ignoredPaths: string[]; of
.filter(Boolean);
}
/**
* Bounded backoff before each retry of a saturated Git scan: none before the
* first attempt, 1 second before the second, 2 seconds before the third. Three
* total attempts (the first plus these two retries) is a liveness parameter,
* not a security control the retry only ever fires for the scheduler's
* typed saturation code (see {@link isWorkspaceGitScanSaturatedError}).
*/
const REFERENCED_SOURCE_IGNORE_SCAN_RETRY_DELAYS_MS = [1_000, 2_000] as const;
async function delay(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
/**
* True only when `error` carries the workspace Git scan scheduler's typed
* saturation code on its `code` property. Matches the code alone, never
* message text a message can change wording without changing meaning, and
* matching text would silently stop retrying (or start retrying the wrong
* failure) the moment it did.
*/
function isWorkspaceGitScanSaturatedError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === WORKSPACE_GIT_SCAN_SATURATED_CODE
);
}
/**
* Resolve a referenced project's Git-ignored paths ONCE, before any staging
* site runs. Called once per project (see `execute.ts`); the sandbox lane,
* the SSH lane, and the content-signature walk all consume this one result,
* so they can never apply a different exclusion set to the same project.
*
* Fails closed: a Git read error, a timeout, malformed output, or a `localPath`
* that is not a plain descendant of its own Git toplevel all return `failed`,
* never an empty ignore list an empty list means "resolved, nothing extra to
* exclude", which is a different claim than "the resolution did not run".
* Fails closed: a Git read error, a timeout, malformed output, a parse-bound
* breach, or a `localPath` that is not a plain descendant of its own Git
* toplevel all return `failed`, never an empty ignore list an empty list
* means "resolved, nothing extra to exclude", which is a different claim than
* "the resolution did not run".
*
* Retries ONLY a saturated scan queue (the shared workspace Git operation
* scheduler rejecting before spawn because it is at capacity) a liveness
* condition, not an integrity one. Three attempts total, with the bounded
* backoff in {@link REFERENCED_SOURCE_IGNORE_SCAN_RETRY_DELAYS_MS}, retried
* through the same registered scheduler every time. No direct-spawn fallback
* exists: bypassing the scheduler would defeat the process-wide concurrency
* limit it enforces. Every other failure timeout, cancellation, an output
* limit, a permission error, malformed output, a real Git failure, or a bound
* breach makes exactly one attempt and fails closed immediately.
*/
export async function resolveReferencedSourceIgnore(localPath: string): Promise<ReferencedSourceIgnoreResolution> {
let scan: Awaited<ReturnType<typeof readReferencedSourceGitIgnoredPaths>>;
try {
scan = await readReferencedSourceGitIgnoredPaths(localPath);
} catch (error) {
return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
let scan: Awaited<ReturnType<typeof readReferencedSourceGitIgnoredPaths>> = null;
let failureReason: string | null = null;
for (let attempt = 0; attempt <= REFERENCED_SOURCE_IGNORE_SCAN_RETRY_DELAYS_MS.length; attempt += 1) {
try {
scan = await readReferencedSourceGitIgnoredPaths(localPath);
failureReason = null;
break;
} catch (error) {
failureReason = error instanceof ReferencedSourceIgnoreScanLimitExceededError
? REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded
: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed;
const isLastAttempt = attempt === REFERENCED_SOURCE_IGNORE_SCAN_RETRY_DELAYS_MS.length;
if (isLastAttempt || !isWorkspaceGitScanSaturatedError(error)) {
break;
}
await delay(REFERENCED_SOURCE_IGNORE_SCAN_RETRY_DELAYS_MS[attempt]!);
}
}
if (failureReason !== null) {
return { kind: "failed", reason: failureReason };
}
if (!scan) {
return { kind: "other" };
@ -285,10 +365,7 @@ export async function resolveReferencedSourceIgnore(localPath: string): Promise<
localPath: await physicalPath(localPath),
});
if (offset === null) {
return {
kind: "failed",
reason: `referenced project path is not a descendant of its own Git top level: ${localPath} under ${scan.toplevel}`,
};
return { kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.toplevelNotDescendant };
}
return {
kind: "git",

View File

@ -9,6 +9,7 @@ import {
workspaceGitSchedulerOptionsFromEnv,
type WorkspaceGitRunner,
} from "./workspace-git-operation-scheduler.js";
import { WORKSPACE_GIT_SCAN_SATURATED_CODE } from "@paperclipai/adapter-utils/git-workspace-sync";
const tempPaths: string[] = [];
@ -429,3 +430,13 @@ describe("WorkspaceGitOperationScheduler", () => {
expect(scheduler.snapshot()).toMatchObject({ activeCount: 0, queuedCount: 0, inFlightCount: 0 });
});
});
describe("WORKSPACE_GIT_SCAN_SATURATED_CODE parity", () => {
it("stays equal to WORKSPACE_GIT_SCAN_ERROR_CODES.saturated", () => {
// `adapter-utils` cannot import this module (the reverse direction is
// allowed, not this one), so `resolveReferencedSourceIgnore` declares its
// own copy of the saturation code to key its retry off. This test is the
// one place both literals meet, so the two copies cannot drift apart.
expect(WORKSPACE_GIT_SCAN_SATURATED_CODE).toBe(WORKSPACE_GIT_SCAN_ERROR_CODES.saturated);
});
});