fix(adapter-utils): honor .gitignore for referenced-project staging (#12184)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Sandbox adapters stage project files before an agent starts.
> - Referenced projects ignored Git-ignored paths and copied large local
directories.
> - This behavior increased staging time and disk use, and it differed
from anchor workspaces.
> - This pull request resolves Git-ignored paths once and shares that
result across all referenced-project consumers.
> - The benefit is smaller, faster, and consistent project staging.

## Linked Issues or Issue Description

No public GitHub issue exists for this bug.

**What happened?**
Referenced-project staging copied Git-ignored paths, except for a fixed
list of heavy directory names. A large repository therefore used much
more time and disk space than the same repository in an anchor
workspace.

**Expected behavior**
Referenced-project staging should exclude the same Git-ignored paths
that the workspace staging path excludes.

**Steps to reproduce**
1. Create a referenced project with a large Git-ignored directory.
2. Start a sandbox or SSH run that stages the referenced project.
3. Observe that the ignored directory enters the staged content.

**Paperclip version or commit**
Commit `9964b034bbff24e700c8eccf5a8b1fc3daa44bf2`.

**Deployment mode**
Built from source.

## What Changed

- Resolve each referenced project's Git-ignored paths once before
staging.
- Carry the resolved paths as a required field on
`SandboxAdditionalSource`.
- Reuse the resolved paths in sandbox staging, SSH staging, and
content-signature code.
- Harden the read-only Git helper with a bounded process, a reduced
environment, and disabled system and global configuration.
- Fail closed on Git errors, timeouts, and invalid path relations.
- Escape tar glob metacharacters in ignore-derived exclude entries.
- Add and update unit tests for the resolver and its three consumers.

## Verification

- `pnpm vitest run --config packages/adapter-utils/vitest.config.ts`
passes 266 tests locally.
- `pnpm exec tsc --noEmit -p packages/adapter-utils/tsconfig.json`
passes locally.
- CI must pass on this pull request.
- Greptile must report 5/5 with no unresolved comments before merge.

## Risks

- A Git error or timeout now prevents staging for the affected
referenced project.
- The resolver uses a bounded read-only Git process and fails closed by
design.
- The change stays inside `packages/adapter-utils` and does not change
the database schema.

## Model Used

Claude Sonnet 5 (Anthropic) assisted the implementation with code
execution and tool use. The exact context window and reasoning mode are
not recorded.

## 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-25 14:22:47 -07:00 committed by GitHub
parent 9fc2f594ae
commit 6880213de5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 902 additions and 49 deletions

View File

@ -31,11 +31,13 @@ import {
findAncestorBin,
geminiVersionSupportsNativeAcpFlag,
parseGeminiVersionParts,
referencedSourceContentSignature,
rewriteGeminiAcpFlagForVersion,
summarizeAcpxTurnUsage,
type AcpxEngineExecutorOptions,
} from "./execute.js";
import { runChildProcess } from "../server-utils.js";
import { setExpensiveWorkspaceGitExecutor } from "../git-workspace-sync.js";
import {
getActiveStepContext,
runWithRuntimeParent,
@ -1417,6 +1419,50 @@ describe("shared ACPX engine runtime behavior", () => {
expect(fp(repointed)).not.toBe(fp(two));
});
it("routes every referenced project's Git-ignore scan through the registered scheduler, not an unbounded direct spawn", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "workspace");
const baseConfig = { agentCommand: "node ./fake-acp.js", stateDir };
// The paths need not exist: the scheduler hook intercepts the scan before
// any real `git` process (or even a directory-existence check) runs.
const projectPaths = ["/host/project-a", "/host/project-b", "/host/project-c"];
const scannedPaths: string[] = [];
setExpensiveWorkspaceGitExecutor(async (input) => {
scannedPaths.push(input.localDir);
const error = new Error("fatal: not a git repository (or any of the parent directories): .git");
throw Object.assign(error, { stdout: "", stderr: error.message });
});
try {
await runExecutor(baseConfig, {
context: {
taskId: "issue-1",
wakeReason: "issue_assigned",
paperclipWorkspace: {
cwd,
realization: {
additional: projectPaths.map((localPath, index) => ({
path: localPath,
projectId: String.fromCharCode(97 + index),
})),
},
},
},
});
} finally {
setExpensiveWorkspaceGitExecutor(null);
}
// Every referenced project's scan reached the registered scheduler hook.
// The `Promise.all` fan-out over projects can no longer bypass it by
// spawning `git` directly, so a host process that bounds concurrent scans
// there bounds referenced-project scans too, however many projects a run
// configures.
expect(scannedPaths.sort()).toEqual([...projectPaths].sort());
});
it("busts the session fingerprint when referenced-project files change at the same host path", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
@ -1513,6 +1559,53 @@ describe("shared ACPX engine runtime behavior", () => {
expect(fp(after)).not.toBe(fp(before));
});
describe("referencedSourceContentSignature", () => {
it("returns a stable marker without walking the tree when the ignore resolution failed", async () => {
const root = await makeTempRoot();
// A directory that does not exist: a walk would throw ENOENT. The
// `failed` resolution must short-circuit before any `fs.readdir` call.
const localPath = path.join(root, "does-not-exist");
const signature = await referencedSourceContentSignature(localPath, { kind: "failed", reason: "git status timed out" });
expect(signature).toBe("unreadable:git status timed out");
});
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");
await fs.mkdir(localPath, { recursive: true });
await fs.writeFile(path.join(localPath, "kept.txt"), "kept\n", "utf8");
await fs.writeFile(path.join(localPath, "secret.env"), "TOKEN=1\n", "utf8");
const resolution = { kind: "git" as const, ignoredPaths: ["secret.env"] };
const before = await referencedSourceContentSignature(localPath, resolution);
await fs.writeFile(path.join(localPath, "secret.env"), "TOKEN=2\n", "utf8");
const afterIgnoredEdit = await referencedSourceContentSignature(localPath, resolution);
await fs.writeFile(path.join(localPath, "kept.txt"), "kept, changed\n", "utf8");
const afterKeptEdit = await referencedSourceContentSignature(localPath, resolution);
// Editing the ignored file never busts the signature; editing the kept file does.
expect(afterIgnoredEdit).toBe(before);
expect(afterKeptEdit).not.toBe(before);
});
it("skips every file under a Git-ignored directory (prefix match)", async () => {
const root = await makeTempRoot();
const localPath = path.join(root, "project");
await fs.mkdir(path.join(localPath, "build", "nested"), { recursive: true });
await fs.writeFile(path.join(localPath, "kept.txt"), "kept\n", "utf8");
await fs.writeFile(path.join(localPath, "build", "nested", "artifact.js"), "v1\n", "utf8");
const resolution = { kind: "git" as const, ignoredPaths: ["build"] };
const before = await referencedSourceContentSignature(localPath, resolution);
await fs.writeFile(path.join(localPath, "build", "nested", "artifact.js"), "v2\n", "utf8");
const after = await referencedSourceContentSignature(localPath, resolution);
expect(after).toBe(before);
});
});
it("shapes ACPX session env for remote execution identities", async () => {
const root = await makeTempRoot();
const localCwd = path.join(root, "local");

View File

@ -22,6 +22,7 @@ import {
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetTimeout,
resolveReferencedSourceIgnore,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
startAdapterExecutionTargetProcessSessionBridge,
@ -31,6 +32,7 @@ import {
type AdapterExecutionTargetTimeoutResolution,
type AdapterManagedRuntimeAsset,
type PreparedAdapterExecutionTargetRuntime,
type ReferencedSourceIgnoreResolution,
type SandboxAdditionalSource,
} from "@paperclipai/adapter-utils/execution-target";
import type { DuplexLossReason } from "../duplex-observability.js";
@ -519,9 +521,14 @@ export function finalizeLaunchEnvironment(
}
// Directory names the staging path never ships for a referenced project (heavy
// build/cache output and git history). The content signature skips them so it
// reflects only the staged tree and never reads their bytes. Keep this set equal
// to the staging excludes in the sandbox and remote runtimes.
// build/cache output and git history), applied regardless of the project's
// ignore resolution. The content signature skips them so it reflects only the
// staged tree and never reads their bytes. Keep this set equal to the fixed
// excludes the sandbox and SSH runtimes always apply. A project's OWN resolved
// Git-ignored paths (see `resolveReferencedSourceIgnore`) are matched
// separately, by relative path, inside `referencedSourceContentSignature` — that
// is the real invariant now: the signature and both staging lanes must consume
// the SAME one resolution per project, not just this fixed name list.
const REFERENCED_SOURCE_SIGNATURE_SKIP_DIRS = new Set([
"node_modules",
"vendor",
@ -551,12 +558,30 @@ const REFERENCED_SOURCE_SIGNATURE_SKIP_DIRS = new Set([
* re-checkout that restores the same size and timestamp. The byte hash busts on any
* content change, so the fingerprint busts and the next launch stages the current
* tree. The walk skips the heavy build, cache, and git directories the staging path
* never ships, and records a symlink by its target text without following it. On a
* read error the function returns a stable marker, so the fingerprint does not churn
* while staging surfaces the real error. The walk runs only when the run carries
* referenced projects (the multi-project sync path).
* never ships, plus the project's own resolved Git-ignored paths, and records a
* symlink by its target text without following it. On a read error the function
* returns a stable marker, so the fingerprint does not churn while staging
* surfaces the real error. The walk runs only when the run carries referenced
* projects (the multi-project sync path).
*
* `ignoreResolution` is the ONE resolution `resolveReferencedSourceIgnore`
* computed for this project the same one the sandbox lane and the SSH lane
* consume. A `failed` resolution skips the walk entirely and returns a stable
* marker instead, because a failed project is not staged and its bytes are not
* read anywhere.
*/
async function referencedSourceContentSignature(localPath: string): Promise<string> {
export async function referencedSourceContentSignature(
localPath: string,
ignoreResolution: ReferencedSourceIgnoreResolution,
): Promise<string> {
if (ignoreResolution.kind === "failed") {
return `unreadable:${ignoreResolution.reason}`;
}
const isIgnoredByGitResolution = (relativePath: string): boolean =>
ignoreResolution.kind === "git" &&
ignoreResolution.ignoredPaths.some(
(entry) => relativePath === entry || relativePath.startsWith(`${entry}/`),
);
const hash = createHash("sha256");
const walk = async (relative: string): Promise<void> => {
const current = relative ? path.join(localPath, relative) : localPath;
@ -564,6 +589,9 @@ async function referencedSourceContentSignature(localPath: string): Promise<stri
dirents.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
for (const dirent of dirents) {
const next = relative ? path.posix.join(relative, dirent.name) : dirent.name;
if (isIgnoredByGitResolution(next)) {
continue;
}
if (dirent.isDirectory()) {
if (REFERENCED_SOURCE_SIGNATURE_SKIP_DIRS.has(dirent.name)) {
continue;
@ -1545,32 +1573,56 @@ async function buildRuntime(input: {
const additionalSourceRecords = (
Array.isArray(realizationContext.additional) ? realizationContext.additional : []
).map((entry) => parseObject(entry));
const additionalSources: SandboxAdditionalSource[] = additionalSourceRecords
.map((entry) => ({ localPath: asString(entry.path, ""), projectId: asString(entry.projectId, "") }))
const additionalSourceCandidates = additionalSourceRecords
.map((entry) => ({
localPath: asString(entry.path, ""),
projectId: asString(entry.projectId, ""),
projectWorkspaceId: asString(entry.projectWorkspaceId, ""),
repoUrl: asString(entry.repoUrl, ""),
repoRef: asString(entry.repoRef, ""),
}))
.filter((entry) => entry.localPath.length > 0 && entry.projectId.length > 0);
// Resolve each referenced project's Git-ignored paths ONCE, here, before any
// staging site runs. The sandbox lane, the SSH lane, and the content signature
// below all consume this SAME resolution per project, so they can never apply
// a different exclusion set to the same project. See
// `resolveReferencedSourceIgnore` for the fail-closed rules.
const additionalSourcesWithIgnore = await Promise.all(
additionalSourceCandidates.map(async (entry) => ({
...entry,
ignoreResolution: await resolveReferencedSourceIgnore(entry.localPath),
})),
);
const additionalSources: SandboxAdditionalSource[] = additionalSourcesWithIgnore.map((entry) => ({
localPath: entry.localPath,
projectId: entry.projectId,
ignoreResolution: entry.ignoreResolution,
}));
// Stable identity of the referenced-project set for the session fingerprint.
// The staged-runtime cache reuses already-staged referenced-project trees on a
// compatible resume, so the fingerprint must change when the set OR a project's
// pinned checkout changes. Without this, a resume reuses a stale staged tree.
// Fold in each project's id, host path, workspace id, and pinned ref; sort by
// projectId so the identity depends on the set, not the record order.
const additionalSourcesIdentityBase = additionalSourceRecords
const additionalSourcesIdentityBase = additionalSourcesWithIgnore
.map((entry) => ({
projectId: asString(entry.projectId, ""),
localPath: asString(entry.path, ""),
projectWorkspaceId: asString(entry.projectWorkspaceId, ""),
repoUrl: asString(entry.repoUrl, ""),
repoRef: asString(entry.repoRef, ""),
projectId: entry.projectId,
localPath: entry.localPath,
projectWorkspaceId: entry.projectWorkspaceId,
repoUrl: entry.repoUrl,
repoRef: entry.repoRef,
ignoreResolution: entry.ignoreResolution,
}))
.filter((entry) => entry.localPath.length > 0 && entry.projectId.length > 0)
.sort((a, b) => (a.projectId < b.projectId ? -1 : a.projectId > b.projectId ? 1 : 0));
// Metadata alone does not change on a content-only checkout change (same host
// path and pinned ref, new file bytes). Fold in each tree's content signature so
// a file add, remove, or edit busts the fingerprint and the resume re-stages.
// The signature reads the same `ignoreResolution` the staging sites above use,
// so it never disagrees with what was actually shipped.
const additionalSourcesIdentity = await Promise.all(
additionalSourcesIdentityBase.map(async (entry) => ({
additionalSourcesIdentityBase.map(async ({ ignoreResolution, ...entry }) => ({
...entry,
contentSignature: await referencedSourceContentSignature(entry.localPath),
contentSignature: await referencedSourceContentSignature(entry.localPath, ignoreResolution),
})),
);
// Referenced-project workspace hints exposed to the agent through PAPERCLIP_WORKSPACES_JSON. The

View File

@ -356,9 +356,9 @@ describe("command managed runtime", () => {
adapterKey: "claude",
workspaceLocalDir: localWorkspaceDir,
additionalSources: [
{ localPath: goodOne, projectId: "one" },
{ localPath: path.join(rootDir, "missing"), projectId: "broken" },
{ localPath: goodTwo, projectId: "two" },
{ localPath: goodOne, projectId: "one", ignoreResolution: { kind: "other" } },
{ localPath: path.join(rootDir, "missing"), projectId: "broken", ignoreResolution: { kind: "other" } },
{ localPath: goodTwo, projectId: "two", ignoreResolution: { kind: "other" } },
],
});

View File

@ -20,8 +20,12 @@ import type {
AdditionalSourceStagingFailure,
SandboxAdditionalSource,
} from "./sandbox-managed-runtime.js";
export {
resolveReferencedSourceIgnore,
} from "./sandbox-managed-runtime.js";
export type {
AdditionalSourceStagingFailure,
ReferencedSourceIgnoreResolution,
SandboxAdditionalSource,
} from "./sandbox-managed-runtime.js";
import {

View File

@ -14,6 +14,7 @@ import {
integrateImportedGitHead,
isMissingGitPrerequisiteError,
readGitWorkspaceSnapshot,
readReferencedSourceGitIgnoredPaths,
runLocalGit,
sanitizeGitRemoteUrl,
setExpensiveWorkspaceGitExecutor,
@ -534,6 +535,113 @@ describe("git workspace sync", () => {
.rejects.toThrow(/Failed to merge concurrent remote git histories/);
expect(await git(repo, ["rev-parse", "HEAD"])).toBe(currentHead);
});
describe("readReferencedSourceGitIgnoredPaths", () => {
it("returns null for a directory that is not a Git work tree", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-nogit-"));
cleanupDirs.push(rootDir);
const plainDir = path.join(rootDir, "plain");
await mkdir(plainDir, { recursive: true });
await writeFile(path.join(plainDir, "file.txt"), "body\n", "utf8");
await expect(readReferencedSourceGitIgnoredPaths(plainDir)).resolves.toBeNull();
});
it("reads the repository top level and the ignored paths of a Git work tree", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-git-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
await writeFile(path.join(repo, ".gitignore"), "secret.env\nbuild/\n", "utf8");
await writeFile(path.join(repo, "secret.env"), "TOKEN=abc\n", "utf8");
await mkdir(path.join(repo, "build"), { recursive: true });
await writeFile(path.join(repo, "build", "out.js"), "artifact\n", "utf8");
const scan = await readReferencedSourceGitIgnoredPaths(repo);
expect(scan?.toplevel).toBe(await git(repo, ["rev-parse", "--show-toplevel"]));
expect(scan?.ignoredPaths).toEqual(["build", "secret.env"]);
});
it("preserves trailing whitespace in an ignored path entry", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-trailing-ws-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
// A wildcard pattern avoids the separate rule that git trims an
// unescaped trailing space in a .gitignore pattern itself; the trailing
// space under test lives in the matched FILE name, not the pattern.
const paddedName = "secret.env ";
await writeFile(path.join(repo, ".gitignore"), "secret.env*\n", "utf8");
await writeFile(path.join(repo, paddedName), "TOKEN=abc\n", "utf8");
const scan = await readReferencedSourceGitIgnoredPaths(repo);
expect(scan?.ignoredPaths).toEqual([paddedName]);
});
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);
const repo = await createRepo(rootDir);
await writeFile(path.join(repo, ".gitignore"), "build/\n", "utf8");
await mkdir(path.join(repo, "build"), { recursive: true });
await writeFile(path.join(repo, "build", "out.js"), "artifact\n", "utf8");
const operations: string[] = [];
setExpensiveWorkspaceGitExecutor(async (input) => {
operations.push(input.operation);
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
const scan = await readReferencedSourceGitIgnoredPaths(repo);
expect(scan?.ignoredPaths).toEqual(["build"]);
// Both the toplevel probe and the ignored-paths read go through the SAME
// process-wide admission seam the anchor workspace's expensive reads
// use. A host process that bounds concurrent scans there also bounds
// referenced-project scans, so a run with many referenced projects
// cannot spawn one unbounded Git process per project.
expect(operations.sort()).toEqual(["referenced_source.ignored_files", "referenced_source.toplevel"]);
});
it("carries the hardened arguments and does not inherit a poisoned GIT_CONFIG_GLOBAL", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-hardened-env-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
const badGlobalConfig = path.join(rootDir, "bad-global-gitconfig");
await writeFile(badGlobalConfig, "this is not valid git config syntax [[[\n", "utf8");
const priorGlobal = process.env.GIT_CONFIG_GLOBAL;
process.env.GIT_CONFIG_GLOBAL = badGlobalConfig;
try {
// A plain invocation inherits the poisoned global config and fails to parse it.
await expect(execFile("git", ["-C", repo, "status", "--porcelain"])).rejects.toThrow();
// The hardened helper does not inherit GIT_CONFIG_GLOBAL from this process's
// environment, so it succeeds regardless.
await expect(readReferencedSourceGitIgnoredPaths(repo)).resolves.toMatchObject({ ignoredPaths: [] });
} finally {
if (priorGlobal === undefined) delete process.env.GIT_CONFIG_GLOBAL;
else process.env.GIT_CONFIG_GLOBAL = priorGlobal;
}
});
it("neutralizes a repository-local core.fsmonitor hook", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-fsmonitor-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
const markerPath = path.join(rootDir, "pwned.txt");
// A malicious repository-local config: a non-boolean `core.fsmonitor` value
// is a hook COMMAND Git runs on every status-like read. `--no-optional-locks`
// alone does not stop this; only the command-line `-c core.fsmonitor=false`
// override does, because command-line config wins over repository config.
await git(repo, ["config", "core.fsmonitor", `sh -c 'touch ${markerPath}; printf 1'`]);
await readReferencedSourceGitIgnoredPaths(repo);
await expect(stat(markerPath)).rejects.toThrow();
});
});
});
describe("sanitizeGitRemoteUrl", () => {

View File

@ -23,6 +23,15 @@ export interface ExpensiveWorkspaceGitInput {
operation: string;
timeout: number;
maxBuffer: number;
/**
* Optional environment override for the invocation. Absent for the anchor
* workspace's own full-tree walks (they inherit the process environment, a
* directory this process already controls). A referenced-project scan sets
* this to its hardened environment (see {@link buildHardenedGitEnv}), so a
* host executor that honors it still runs the read hardened even though it
* dispatches through the same seam as the anchor's reads.
*/
env?: NodeJS.ProcessEnv;
}
export type ExpensiveWorkspaceGitExecutor = (
@ -69,6 +78,7 @@ export async function runLocalGit(
options: {
timeout?: number;
maxBuffer?: number;
env?: NodeJS.ProcessEnv;
} = {},
): Promise<GitCommandResult> {
return await new Promise<GitCommandResult>((resolve, reject) => {
@ -78,6 +88,7 @@ export async function runLocalGit(
{
timeout: options.timeout ?? 15_000,
maxBuffer: options.maxBuffer ?? 1024 * 128,
env: options.env ?? process.env,
},
(error, stdout, stderr) => {
if (error) {
@ -97,7 +108,7 @@ async function runExpensiveWorkspaceGit(
localDir: string,
args: string[],
operation: string,
options: { timeout: number; maxBuffer: number },
options: { timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv },
): Promise<GitCommandResult> {
if (expensiveWorkspaceGitExecutor) {
return await expensiveWorkspaceGitExecutor({
@ -106,6 +117,7 @@ async function runExpensiveWorkspaceGit(
operation,
timeout: options.timeout,
maxBuffer: options.maxBuffer,
env: options.env,
});
}
return await runLocalGit(localDir, args, options);
@ -168,6 +180,143 @@ export async function readGitWorkspaceSnapshot(localDir: string): Promise<GitWor
}
}
/** The `git status --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;
/** Ignored paths, relative to `toplevel`, trailing slashes stripped, sorted. */
ignoredPaths: string[];
}
/**
* Build the environment for a hardened, read-only Git invocation against a
* directory this process does not control (a referenced project, not the
* anchor workspace). Two protections apply:
*
* - Drop every inherited `GIT_*` variable, so an already-set override in this
* process's own environment cannot change how the read-only command runs.
* - Point the global config file at `/dev/null` (in addition to the
* command-line `GIT_CONFIG_NOSYSTEM=1` the caller sets), so neither this
* host's global nor system Git configuration can add a setting the
* read-only command was not built to expect.
*
* This does not defend against the directory's OWN repository-local
* configuration; the command-line `-c core.fsmonitor=false` override in
* {@link runHardenedReadOnlyGit} does that instead, because command-line
* config always wins over repository-local config.
*/
function buildHardenedGitEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith("GIT_") || value === undefined) continue;
env[key] = value;
}
env.GIT_CONFIG_NOSYSTEM = "1";
env.GIT_CONFIG_GLOBAL = "/dev/null";
return env;
}
/**
* Run a read-only Git command against a directory this process does not
* control, hardened against a hostile repository-local configuration, and
* dispatched through {@link runExpensiveWorkspaceGit} the SAME process-wide
* admission seam the anchor workspace's expensive full-tree reads use. A host
* process that registers a bounded scheduler there (see
* `setExpensiveWorkspaceGitExecutor`) governs referenced-project scans too, so
* a run with many referenced projects cannot spawn one unbounded Git process
* per project; each request queues behind the same concurrency limit.
*
* Every call still carries `--no-optional-locks` (never blocks on, or is
* blocked by, a concurrent Git process in the directory) and
* `-c core.fsmonitor=false` (neutralizes a repository-local `core.fsmonitor`
* setting that would otherwise run an arbitrary configured program on this
* read). See {@link buildHardenedGitEnv} for the paired environment hardening,
* carried through the executor's optional `env` field so hardening survives
* the hop through a host-registered scheduler.
*/
async function runHardenedReadOnlyGit(
localDir: string,
args: string[],
operation: string,
options: { timeout: number; maxBuffer: number },
): Promise<GitCommandResult> {
return await runExpensiveWorkspaceGit(
localDir,
["-c", "core.fsmonitor=false", "--no-optional-locks", ...args],
operation,
{ timeout: options.timeout, maxBuffer: options.maxBuffer, env: buildHardenedGitEnv() },
);
}
/**
* True when a failed `git` invocation failed specifically because `localDir`
* is not inside a Git work tree Git's own "not a git repository" fatal
* error. Distinguishes the expected non-Git case from a real failure (a
* timeout, a permissions error, a corrupt repository), which must still
* surface as a failure and never look like "no Git tree here".
*/
function isNotAGitRepositoryError(error: unknown): boolean {
const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr: unknown }).stderr) : "";
const message = error instanceof Error ? error.message : String(error);
return /not a git repository/i.test(stderr) || /not a git repository/i.test(message);
}
/**
* Read the Git-ignored paths of a referenced-project host directory, for the
* staging path to exclude them (see `resolveReferencedSourceIgnore` in
* `sandbox-managed-runtime.ts`). Every command runs through
* {@link runHardenedReadOnlyGit}, because the directory is a host checkout the
* staging code does not control, unlike the anchor workspace.
*
* 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.
*/
export async function readReferencedSourceGitIgnoredPaths(
localDir: string,
): Promise<ReferencedSourceGitIgnoreScan | null> {
let toplevel: string;
try {
const toplevelResult = await runHardenedReadOnlyGit(
localDir,
["rev-parse", "--show-toplevel"],
"referenced_source.toplevel",
{ timeout: 15_000, maxBuffer: 64 * 1024 },
);
toplevel = toplevelResult.stdout.trim();
} catch (error) {
if (isNotAGitRepositoryError(error)) {
return null;
}
throw error;
}
if (!toplevel) {
throw new Error(`git rev-parse --show-toplevel returned an empty path for ${localDir}`);
}
const ignoredResult = await runHardenedReadOnlyGit(
localDir,
["status", "--ignored", "--porcelain=v1", "-z", "--untracked-files=normal"],
"referenced_source.ignored_files",
{ timeout: 60_000, maxBuffer: 16 * 1024 * 1024 },
);
// 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));
return { toplevel, ignoredPaths };
}
// scp-like ssh remote (`user@host:path`). The syntax has no password slot, so
// it cannot embed a secret. Conservative shape: exactly one `@`, no colon in
// the user segment (a colon there could smuggle credential-looking material),

View File

@ -125,9 +125,9 @@ describe("remote managed runtime", () => {
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [
{ localPath: firstDir, projectId: "first" },
{ localPath: brokenDir, projectId: "broken" },
{ localPath: secondDir, projectId: "second" },
{ localPath: firstDir, projectId: "first", ignoreResolution: { kind: "other" } },
{ localPath: brokenDir, projectId: "broken", ignoreResolution: { kind: "other" } },
{ localPath: secondDir, projectId: "second", ignoreResolution: { kind: "other" } },
],
});
@ -171,8 +171,8 @@ describe("remote managed runtime", () => {
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [
{ localPath: "relative/referenced", projectId: "relative" },
{ localPath: healthyDir, projectId: "healthy" },
{ localPath: "relative/referenced", projectId: "relative", ignoreResolution: { kind: "other" } },
{ localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } },
],
});
@ -183,4 +183,83 @@ describe("remote managed runtime", () => {
localDir: "relative/referenced",
}));
});
it("passes a project's resolved Git-ignored paths to the SSH exclude list, escaped", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-ignore-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const projectDir = path.join(rootDir, "referenced-project");
await mkdir(workspaceDir, { recursive: true });
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-ignore",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [
{
localPath: projectDir,
projectId: "proj",
ignoreResolution: { kind: "git", ignoredPaths: ["secret.env", "build", "weird[1].txt"] },
},
],
});
const call = syncDirectoryToSsh.mock.calls.find((entry) => entry[0].localDir === projectDir);
expect(call).toBeDefined();
const exclude = (call![0] as { exclude?: string[] }).exclude ?? [];
// The resolved ignored paths ride the exclude list, glob-escaped, on top of
// the fixed heavy-directory excludes the SSH lane already applies.
expect(exclude).toContain("secret.env");
expect(exclude).toContain("build");
expect(exclude).toContain("weird\\[1].txt");
expect(exclude).toContain("node_modules");
});
it("skips a project whose ignore resolution failed without ever calling syncDirectoryToSsh for it", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-failed-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const healthyDir = path.join(rootDir, "referenced-healthy");
const failedDir = path.join(rootDir, "referenced-failed");
await mkdir(workspaceDir, { recursive: true });
const prepared = 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-failed",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [
{ localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } },
{ localPath: failedDir, projectId: "failed", ignoreResolution: { kind: "failed", reason: "git status timed out" } },
],
});
// Fail closed: the failed project never reaches the transfer at all — no
// bytes are sent for it — while the healthy project still stages.
expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["healthy"]);
expect(syncDirectoryToSsh).not.toHaveBeenCalledWith(expect.objectContaining({ localDir: failedDir }));
});
});

View File

@ -7,13 +7,19 @@ import {
restoreWorkspaceFromSshExecution,
syncDirectoryToSsh,
} from "./ssh.js";
import type {
SandboxAdditionalSource,
SandboxManagedRuntimeAssetRestoreContext,
import {
mergeExcludes,
referencedSourceIgnoreExcludeEntries,
type SandboxAdditionalSource,
type SandboxManagedRuntimeAssetRestoreContext,
} from "./sandbox-managed-runtime.js";
import { captureDirectorySnapshot } from "./workspace-restore-merge.js";
import type { RuntimeProgressSink } from "./runtime-progress.js";
// The fixed heavy-directory excludes every referenced project drops,
// regardless of its ignore resolution. A `git`-resolved project additionally
// drops its own resolved ignored paths (see `referencedSourceIgnoreExcludeEntries`
// and the per-project merge below); an `other` project keeps only this set.
const REMOTE_ADDITIONAL_SOURCE_HEAVY_DIR_EXCLUDES = [
"node_modules",
"vendor",
@ -179,7 +185,7 @@ export async function prepareRemoteManagedRuntime(input: {
// the other projects continue (no workspace restore, unlike an asset failure).
const additionalSourceDirs: Record<string, string> = {};
for (const source of input.additionalSources ?? []) {
const { localPath, projectId } = source;
const { localPath, projectId, ignoreResolution } = source;
try {
if (!path.posix.isAbsolute(localPath)) {
throw new Error(`additional source localPath is not an absolute path: ${localPath}`);
@ -192,12 +198,21 @@ export async function prepareRemoteManagedRuntime(input: {
) {
throw new Error(`additional source projectId is not a simple path segment: ${projectId}`);
}
// Fail closed: a project whose ignore resolution failed is not staged at
// all — the existing per-project skip-and-warn path below handles it.
if (ignoreResolution.kind === "failed") {
throw new Error(`referenced project ignore resolution failed: ${ignoreResolution.reason}`);
}
const remoteDir = path.posix.join(runtimeRootDir, `project-${projectId}`);
const exclude = mergeExcludes(
REMOTE_ADDITIONAL_SOURCE_HEAVY_DIR_EXCLUDES,
referencedSourceIgnoreExcludeEntries(ignoreResolution),
);
await syncDirectoryToSsh({
spec: input.spec,
localDir: localPath,
remoteDir,
exclude: REMOTE_ADDITIONAL_SOURCE_HEAVY_DIR_EXCLUDES,
exclude,
onProgress: input.onProgress,
progressLabel: `project-${projectId}`,
});

View File

@ -257,7 +257,11 @@ describe("sandbox native file sync", () => {
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
additionalSources: projects.map((project) => ({ localPath: project.localDir, projectId: project.projectId })),
additionalSources: projects.map((project) => ({
localPath: project.localDir,
projectId: project.projectId,
ignoreResolution: { kind: "other" },
})),
});
// Each project lands in its OWN `project-<projectId>` directory under the
@ -320,9 +324,9 @@ describe("sandbox native file sync", () => {
client,
workspaceLocalDir: localWorkspaceDir,
additionalSources: [
{ localPath: goodDir, projectId: "good-a" },
{ localPath: path.join(rootDir, "does-not-exist"), projectId: "broken" },
{ localPath: goodDir, projectId: "good-b" },
{ localPath: goodDir, projectId: "good-a", ignoreResolution: { kind: "other" } },
{ localPath: path.join(rootDir, "does-not-exist"), projectId: "broken", ignoreResolution: { kind: "other" } },
{ localPath: goodDir, projectId: "good-b", ignoreResolution: { kind: "other" } },
],
});

View File

@ -9,8 +9,12 @@ import { resetLocalGitIndexToHead } from "./git-workspace-sync.js";
import {
assertSyncOperationsConfined,
escapeTarExcludeLiteral,
mirrorDirectory,
prepareSandboxManagedRuntime,
resolveReferencedSourceIgnore,
type PreparedSandboxManagedRuntime,
type ReferencedSourceIgnoreResolution,
type SandboxManagedRuntimeAsset,
type SandboxManagedRuntimeClient,
type SandboxSyncOperation,
@ -160,6 +164,64 @@ async function git(cwd: string, args: string[]): Promise<string> {
return stdout.trim();
}
// A minimal committed Git repository, for the referenced-project ignore tests.
async function initGitRepo(repoDir: string): Promise<void> {
await mkdir(repoDir, { recursive: true });
await git(repoDir, ["init", "-q"]);
await git(repoDir, ["config", "user.name", "Paperclip Test"]);
await git(repoDir, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(repoDir, "README.md"), "root\n", "utf8");
await git(repoDir, ["add", "README.md"]);
await git(repoDir, ["commit", "-qm", "base"]);
}
// A `CommandManagedRuntimeRunner` that runs real shell commands on the host
// filesystem (host FS stands in for the sandbox FS), exposing no native
// `syncIn` — staging rides the real base64/tar fallback, the same transport a
// provider without native sync uses. Unlike the in-file fake clients, this
// fallback DOES build a real tarball via `createTarballFromDirectory` for a
// `directory`-kind mapping, so it is the one that genuinely applies `exclude`.
function makeInlineSpawnRunner(): CommandManagedRuntimeRunner {
return {
execute: (input) =>
new Promise<RunProcessResult>((resolve) => {
const startedAt = new Date().toISOString();
const command =
input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
const child = spawn(command, input.args ?? [], { cwd: input.cwd, env: { ...process.env, ...input.env } });
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
child.on("error", () => resolve({ exitCode: 127, signal: null, timedOut: false, stdout, stderr, pid: null, startedAt }));
child.on("close", (code) => resolve({ exitCode: code ?? 0, signal: null, timedOut: false, stdout, stderr, pid: child.pid ?? null, startedAt }));
if (input.stdin != null) child.stdin.write(input.stdin);
child.stdin.end();
}),
};
}
// Stage ONE referenced project end-to-end (real tar fallback, real `exclude`
// filtering) through the full `prepareCommandManagedRuntime` seam, for the
// referenced-project ignore regression tests.
async function stageOneReferencedProject(
referencedDir: string,
ignoreResolution: ReferencedSourceIgnoreResolution,
): Promise<PreparedSandboxManagedRuntime> {
const rootDir = path.dirname(referencedDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8");
return await prepareCommandManagedRuntime({
runner: makeInlineSpawnRunner(),
spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 },
adapterKey: "test-adapter",
workspaceLocalDir: localWorkspaceDir,
additionalSources: [{ localPath: referencedDir, projectId: "proj", ignoreResolution }],
});
}
async function listTarMembers(rootDir: string, name: string, bytes: Buffer): Promise<string[]> {
const tarPath = path.join(rootDir, name);
await writeFile(tarPath, bytes);
@ -2087,7 +2149,7 @@ describe("sandbox managed runtime", () => {
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
additionalSources: [{ localPath: referencedDir, projectId: "proj-first" }],
additionalSources: [{ localPath: referencedDir, projectId: "proj-first", ignoreResolution: { kind: "other" } }],
});
const referencedMapping = captured
@ -2169,9 +2231,9 @@ describe("sandbox managed runtime", () => {
adapterKey: "test-adapter",
workspaceLocalDir: localWorkspaceDir,
additionalSources: [
{ localPath: first, projectId: "proj-first" },
{ localPath: path.join(rootDir, "referenced-missing"), projectId: "proj-missing" },
{ localPath: second, projectId: "proj-second" },
{ localPath: first, projectId: "proj-first", ignoreResolution: { kind: "other" } },
{ localPath: path.join(rootDir, "referenced-missing"), projectId: "proj-missing", ignoreResolution: { kind: "other" } },
{ localPath: second, projectId: "proj-second", ignoreResolution: { kind: "other" } },
],
});
@ -2213,6 +2275,150 @@ describe("sandbox managed runtime", () => {
}
});
describe("resolveReferencedSourceIgnore", () => {
it("re-relativizes root-relative ignored paths to a nested localPath", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-nested-"));
cleanupDirs.push(rootDir);
const repo = path.join(rootDir, "repo");
await initGitRepo(repo);
// An ignored entry OUTSIDE the referenced project's localPath, and two
// ignored entries inside it (one top-level, one nested).
await writeFile(path.join(repo, ".gitignore"), "outside-secret.env\npackages/app/secret.env\npackages/app/build/\n", "utf8");
await writeFile(path.join(repo, "outside-secret.env"), "outside\n", "utf8");
const localPath = path.join(repo, "packages", "app");
// Commit a tracked file under `localPath` first. Otherwise the whole
// `packages/` directory is untracked, and `git status` collapses it to
// one `packages/` line instead of reporting entries inside it
// individually — the fixture needs the individual entries.
await mkdir(localPath, { recursive: true });
await writeFile(path.join(localPath, "index.ts"), "export {};\n", "utf8");
await git(repo, ["add", "packages/app/index.ts"]);
await git(repo, ["commit", "-qm", "add app"]);
await mkdir(path.join(localPath, "build"), { recursive: true });
await writeFile(path.join(localPath, "secret.env"), "TOKEN=abc\n", "utf8");
await writeFile(path.join(localPath, "build", "out.js"), "artifact\n", "utf8");
const resolution = await resolveReferencedSourceIgnore(localPath);
// Only the entries under `localPath` apply, re-relativized to it — the
// sibling `outside-secret.env` never appears, and the prefix
// `packages/app/` is stripped.
expect(resolution).toEqual({ kind: "git", ignoredPaths: ["build", "secret.env"] });
});
it("keeps today's fixed excludes for a non-Git source", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-nongit-"));
cleanupDirs.push(rootDir);
const plainDir = path.join(rootDir, "plain-project");
await mkdir(plainDir, { recursive: true });
await writeFile(path.join(plainDir, "file.txt"), "body\n", "utf8");
await expect(resolveReferencedSourceIgnore(plainDir)).resolves.toEqual({ kind: "other" });
});
it("fails closed on a real Git error instead of returning an unfiltered result", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-fail-"));
cleanupDirs.push(rootDir);
const repo = path.join(rootDir, "repo");
await initGitRepo(repo);
// Corrupt the index so `git rev-parse --show-toplevel` still succeeds but
// `git status --ignored` fails with a real error (not "not a git repository").
await writeFile(path.join(repo, ".git", "index"), "not a valid index\n", "utf8");
const resolution = await resolveReferencedSourceIgnore(repo);
expect(resolution.kind).toBe("failed");
expect((resolution as { reason: string }).reason.length).toBeGreaterThan(0);
});
});
it("escapes glob metacharacters so a literal ignored path never over-excludes a sibling", async () => {
// A bare tar `--exclude` pattern treats `*`/`?`/`[` as globs. Without
// escaping, an ignored file named `secret[1].txt` would exclude the
// unrelated sibling `secret1.txt` too (both match the glob `secret[1].txt`,
// whose `[1]` is a one-character class matching the literal digit `1`).
expect(escapeTarExcludeLiteral("secret[1].txt")).toBe("secret\\[1].txt");
expect(escapeTarExcludeLiteral("wildcard*name")).toBe("wildcard\\*name");
expect(escapeTarExcludeLiteral("question?mark")).toBe("question\\?mark");
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-glob-"));
cleanupDirs.push(rootDir);
const referencedDir = path.join(rootDir, "referenced-project");
await initGitRepo(referencedDir);
// The gitignore pattern itself escapes `[` and `]` (gitignore patterns are
// globs too), so it ignores ONLY the literal file `secret[1].txt`.
await writeFile(path.join(referencedDir, ".gitignore"), "secret\\[1\\].txt\n", "utf8");
await writeFile(path.join(referencedDir, "secret[1].txt"), "ignored\n", "utf8");
// A sibling that would ALSO match the UNESCAPED tar exclude glob
// `secret[1].txt` (its `[1]` is a one-character class matching `1`), if the
// staging path failed to escape the ignored entry before passing it to tar.
await writeFile(path.join(referencedDir, "secret1.txt"), "must stay\n", "utf8");
const ignoreResolution = await resolveReferencedSourceIgnore(referencedDir);
expect(ignoreResolution).toEqual({ kind: "git", ignoredPaths: ["secret[1].txt"] });
const prepared = await stageOneReferencedProject(referencedDir, ignoreResolution);
const stagedDir = prepared.additionalSourceDirs["proj"]!;
await expect(readFile(path.join(stagedDir, "secret[1].txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(stagedDir, "secret1.txt"), "utf8")).resolves.toBe("must stay\n");
});
it("never ships a Git-ignored secret in a referenced project's staged tree", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-secret-"));
cleanupDirs.push(rootDir);
const referencedDir = path.join(rootDir, "referenced-project");
await initGitRepo(referencedDir);
await writeFile(path.join(referencedDir, ".gitignore"), "secret.env\n", "utf8");
await writeFile(path.join(referencedDir, "secret.env"), "TOKEN=abc\n", "utf8");
await writeFile(path.join(referencedDir, "tracked.md"), "kept\n", "utf8");
const ignoreResolution = await resolveReferencedSourceIgnore(referencedDir);
expect(ignoreResolution).toEqual({ kind: "git", ignoredPaths: ["secret.env"] });
const prepared = await stageOneReferencedProject(referencedDir, ignoreResolution);
const stagedDir = prepared.additionalSourceDirs["proj"]!;
await expect(readFile(path.join(stagedDir, "secret.env"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(stagedDir, "tracked.md"), "utf8")).resolves.toBe("kept\n");
});
it("stages no bytes for a project whose ignore resolution failed, and stages the rest", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-failed-skip-"));
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 failedDir = path.join(rootDir, "referenced-failed");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(healthyDir, { recursive: true });
await mkdir(failedDir, { 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(failedDir, "should-never-ship.txt"), "must not stage\n", "utf8");
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: failedDir, projectId: "failed", ignoreResolution: { kind: "failed", reason: "boom: git status timed out" } },
],
});
// The failed project is not staged at all (fail closed) — no bytes reach the
// sandbox for it — and is recorded as a first-class failure. The healthy
// project stages normally.
expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["healthy"]);
expect(prepared.additionalSourceFailures.map((failure) => failure.projectId)).toEqual(["failed"]);
expect(prepared.additionalSourceFailures[0]!.error).toContain("boom: git status timed out");
const runtimeRootDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "test-adapter");
await expect(readFile(path.join(runtimeRootDir, "project-failed", "should-never-ship.txt"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
});
it("builds the workspace tarball inside one host pack span for a usual workspace sync", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pack-span-"));
cleanupDirs.push(rootDir);
@ -2655,8 +2861,8 @@ describe("sandbox managed runtime inbound coordinator", () => {
syncWorkspace: false,
workspaceLocalDir: workspaceDir,
additionalSources: [
{ localPath: dirOf("good"), projectId: "good" },
{ localPath: dirOf("bad"), projectId: "bad" },
{ localPath: dirOf("good"), projectId: "good", ignoreResolution: { kind: "other" } },
{ localPath: dirOf("bad"), projectId: "bad", ignoreResolution: { kind: "other" } },
],
});
@ -2748,7 +2954,7 @@ describe("sandbox managed runtime inbound coordinator", () => {
client,
workspaceLocalDir: workspaceDir,
assets: [{ key: "home", localDir: dirOf("home") }],
additionalSources: [{ localPath: dirOf("proj"), projectId: "proj-1" }],
additionalSources: [{ localPath: dirOf("proj"), projectId: "proj-1", ignoreResolution: { kind: "other" } }],
runtimeSpan,
});

View File

@ -14,6 +14,7 @@ import {
GIT_ARCHIVE_EXCLUDES,
integrateImportedGitHead,
readGitWorkspaceSnapshot,
readReferencedSourceGitIgnoredPaths,
resetLocalGitIndexToHead,
withShallowGitWorkspaceClone,
} from "./git-workspace-sync.js";
@ -135,6 +136,27 @@ export interface SandboxManagedRuntimeAsset {
restore?: (ctx: SandboxManagedRuntimeAssetRestoreContext) => Promise<void>;
}
/**
* How a referenced project's Git-ignored paths were resolved, computed once
* per project by `resolveReferencedSourceIgnore` before staging starts. The
* sandbox lane, the SSH lane, and the content-signature walk each consume
* this ONE resolution, so the three sites never drift apart.
*
* - `git`: `localPath` is a Git work tree. `ignoredPaths` are its ignored
* entries, already re-relativized to `localPath` (see
* `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.
*/
export type ReferencedSourceIgnoreResolution =
| { kind: "git"; ignoredPaths: string[] }
| { kind: "other" }
| { kind: "failed"; reason: string };
/**
* A referenced (additional) project to stage into the run sandbox as a plain,
* read-only tree. `localPath` is the host checkout directory. Upstream code
@ -145,10 +167,113 @@ export interface SandboxManagedRuntimeAsset {
* Additional sources are plain trees only. They never carry the anchor
* workspace's git-history, overlay, or `.paperclip-runtime` preservation
* semantics those stay anchor-only.
*
* `ignoreResolution` is required so every construction site must supply it
* explicitly a caller cannot default to the unfiltered legacy behavior by
* omission. Resolve it once per project with `resolveReferencedSourceIgnore`.
*/
export interface SandboxAdditionalSource {
localPath: string;
projectId: string;
ignoreResolution: ReferencedSourceIgnoreResolution;
}
/**
* Escape tar `--exclude` glob metacharacters (`*`, `?`, `[`) in a literal
* path, so a Git-ignored path that happens to contain one of them is matched
* literally instead of as a pattern. Without this, a repository-controlled
* path containing e.g. `*` could exclude unrelated sibling files that
* happen to match the resulting glob. GNU tar and bsdtar both honor a
* backslash as a `fnmatch` escape character, so this is not command
* injection `createTarballFromDirectory` and the SSH tar equivalent both
* pass `--exclude` values as argument-vector entries, never through a shell.
*/
export function escapeTarExcludeLiteral(entry: string): string {
return entry.replace(/\\/g, "\\\\").replace(/([*?[])/g, "\\$1");
}
/**
* The tar `--exclude` entries a referenced project's resolved ignore set
* contributes, on top of the fixed heavy-directory excludes every site
* already applies. Empty for `other` (today's fixed excludes are enough)
* and for `failed` (the project is not staged at all, so no exclude list
* matters).
*/
export function referencedSourceIgnoreExcludeEntries(resolution: ReferencedSourceIgnoreResolution): string[] {
return resolution.kind === "git" ? resolution.ignoredPaths.map(escapeTarExcludeLiteral) : [];
}
/**
* Compute the relative position of `localPath` under a Git `toplevel`
* directory, as a POSIX path with no leading or trailing slash. Returns `""`
* when `localPath` IS the toplevel. Returns `null` when the relation is not a
* plain descendant `localPath` escapes upward from `toplevel`, resolves to
* an absolute/rooted result (a different filesystem root), or the two paths
* are otherwise not comparable. The caller treats `null` as a resolution
* failure (fail closed), never as "nothing to exclude".
*/
function relativizeUnderGitToplevel(input: { toplevel: string; localPath: string }): string | null {
const toplevel = path.resolve(input.toplevel);
const localPath = path.resolve(input.localPath);
const relative = path.relative(toplevel, localPath);
if (relative === "") return "";
if (path.isAbsolute(relative)) return null;
if (relative === ".." || relative.startsWith(`..${path.sep}`)) return null;
return relative.split(path.sep).join("/");
}
/**
* Re-relativize root-relative ignored paths (as `git status --ignored`
* 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
* elsewhere in the repository does not apply to this project's staged tree
* and strips the `offset` prefix so the result matches the tar member
* namespace, which is `localPath`-relative.
*/
function reRelativizeIgnoredPathsToLocalPath(input: { ignoredPaths: string[]; offset: string }): string[] {
if (input.offset === "") {
return [...input.ignoredPaths];
}
const prefix = `${input.offset}/`;
return input.ignoredPaths
.filter((entry) => entry.startsWith(prefix))
.map((entry) => entry.slice(prefix.length))
.filter(Boolean);
}
/**
* 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".
*/
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) };
}
if (!scan) {
return { kind: "other" };
}
const offset = relativizeUnderGitToplevel({ toplevel: scan.toplevel, 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: "git",
ignoredPaths: reRelativizeIgnoredPathsToLocalPath({ ignoredPaths: scan.ignoredPaths, offset }),
};
}
/**
@ -681,7 +806,7 @@ async function emitRuntimeStatus(
await Promise.resolve(sink({ phase, message })).catch(() => undefined);
}
function mergeExcludes(...groups: Array<string[] | undefined>): string[] {
export function mergeExcludes(...groups: Array<string[] | undefined>): string[] {
return [...new Set(groups.flatMap((group) => group ?? []))];
}
@ -850,8 +975,11 @@ export async function prepareSandboxManagedRuntime(input: {
const additionalSourceFailures: AdditionalSourceStagingFailure[] = [];
// Additional projects stage as plain trees. Drop the heavy build/cache dirs a
// reference tree does not need, and `.git` — additional sources never carry
// git-history semantics (anchor-only).
const additionalSourceExclude = mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [".git"]);
// git-history semantics (anchor-only). Each project also drops its OWN
// resolved Git-ignored paths (or keeps this fixed set as-is for a non-Git
// source) — see `resolveReferencedSourceIgnore` and the per-project merge
// below.
const additionalSourceBaseExclude = mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [".git"]);
// Every delegated post-upload command (extract/wipe/remove-deleted/asset merge)
// must run under the run-specific timeout (`spec.timeoutMs`), not the provider
@ -1122,7 +1250,7 @@ export async function prepareSandboxManagedRuntime(input: {
inboundTaskIsRequired.push(false);
inboundTasks.push(() =>
runStepSpan(`stage.project.${source.projectId}`, async () => {
const { localPath, projectId } = source;
const { localPath, projectId, ignoreResolution } = source;
const label = `project-${projectId}`;
try {
if (!path.posix.isAbsolute(localPath)) {
@ -1136,14 +1264,24 @@ export async function prepareSandboxManagedRuntime(input: {
) {
throw new Error(`additional source projectId is not a simple path segment: ${projectId}`);
}
// Fail closed: a project whose ignore resolution failed is not staged
// at all. Shipping it with only the fixed heavy-directory excludes
// would defeat the resolution's purpose.
if (ignoreResolution.kind === "failed") {
throw new Error(`referenced project ignore resolution failed: ${ignoreResolution.reason}`);
}
const remoteProjectDir = path.posix.join(runtimeRootDir, label);
const exclude = mergeExcludes(
additionalSourceBaseExclude,
referencedSourceIgnoreExcludeEntries(ignoreResolution),
);
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing referenced project to environment");
await stageConfinedSyncIn({
files: [{
sourcePath: localPath,
targetPath: remoteProjectDir,
kind: "directory",
exclude: additionalSourceExclude,
exclude,
access: "ro",
}],
sourceRoots: [localPath],

View File

@ -882,6 +882,11 @@ setExpensiveWorkspaceGitExecutor(async (input) => {
timeoutMs: input.timeout,
maxStdoutBytes: input.maxBuffer,
maxStderrBytes: input.maxBuffer,
// Absent for the anchor workspace's own reads (they inherit the process
// environment, a directory this process already controls). A
// referenced-project scan sets this to its hardened environment, so the
// hardening survives the hop through this shared scheduler.
env: input.env,
});
return { stdout: result.stdout, stderr: result.stderr };
});