mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The run layer must move project context into the sandbox that executes the agent > - Local sandboxes already stage referenced projects for @-mentions > - Remote confined sandboxes dropped the whole referenced set, so the agent lost needed files and paths > - This pull request keeps the confined sandbox transport aligned with the local behavior for referenced projects > - It does this behind a remote-only flag that defaults on, while SSH keeps the old drop-only path > - The benefit is that remote runs can read the same referenced project context that local runs already provide ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting. This change touches server orchestration, sandbox transport, and observability. **Problem or motivation** A run can @-mention another project. Local targets stage each referenced project and give the agent a path. Remote confined sandboxes dropped the full referenced set, so the agent could not read those project files or paths. **Proposed solution** Enable referenced-project sync for the confined sandbox transport behind `PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC_REMOTE`, which defaults on. Keep the SSH transport out of scope and keep it dropping referenced projects. Repoint each referenced workspace hint at its staged `project-<projectId>` sandbox directory. Publish `PAPERCLIP_WORKSPACES_JSON` on the confined sandbox lane. Count each per-project remote staging failure as a `staging` failure in the requested-vs-synced metrics. **Alternatives considered** Keep the remote path drop-only. That keeps the gap open. Move the change into SSH too. That expands scope beyond the target transport and adds risk. **Roadmap alignment** No matching item in `ROADMAP.md` showed up in this review. **Additional context** The change lands in three commits. The first commit opens the gate for the confined sandbox transport. The second commit repoints the workspace hints and publishes the workspace map. The third commit records per-project staging failure data. ## What Changed - Opened remote referenced-project sync for the confined sandbox transport behind `PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC_REMOTE`. - Repointed referenced workspace hints to the staged `project-<projectId>` sandbox directories and published `PAPERCLIP_WORKSPACES_JSON`. - Counted per-project remote staging failures as first-class `staging` failures in the requested-vs-synced observability. ## Verification - The pushed ref `refs/heads/feat/sync-referenced-projects-remote-sandbox` resolves to the authorized submit SHA. - `git log --oneline origin/master..origin/feat/sync-referenced-projects-remote-sandbox` shows exactly the three expected commits. - The handoff reports server typecheck clean, adapter-utils typecheck clean, and the listed unit tests passing. - The handoff also reports no open review comments and no Greptile score yet. ## Risks - The change touches authorization and sandbox path handling, so regressions could block remote runs or expose the wrong project context. - The new flag defaults on, so any bug in the remote path affects normal remote use. - SSH stays out of scope, so the two transport paths must remain distinct. ## Model Used OpenAI GPT-5 via Codex. Tool use enabled. Context window not reported in this run. ## 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>
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
isConfinedRemoteStagingDriver,
|
|
isMultiProjectWorkspaceSyncRemoteEnabled,
|
|
MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS,
|
|
MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS,
|
|
MULTI_PROJECT_WORKSPACE_SYNC_REMOTE_ENV,
|
|
resolveAdditionalRunWorkspaces,
|
|
type ResolveAdditionalRunWorkspacesOptions,
|
|
type ResolvedAdditionalWorkspace,
|
|
} from "../services/heartbeat.ts";
|
|
import type { AuthorizationActor, AuthorizationDecision } from "../services/authorization.ts";
|
|
|
|
// These tests exercise the remote gate in `resolveAdditionalRunWorkspaces` with fully injected
|
|
// dependencies (no database). The function reads mentions through `issues.findMentionedProjectIds`,
|
|
// hydrates candidates through `projects.listByIds`, authorizes each through `access.decide`, and
|
|
// resolves an admitted project to a workspace through `resolveProjectWorkspace`. Each dependency is
|
|
// a stub here, so a test asserts the exact gate behavior for a sandbox target, an SSH target, and
|
|
// the remote kill switch.
|
|
|
|
const buildActor = (companyId: string): AuthorizationActor => ({
|
|
type: "agent",
|
|
agentId: randomUUID(),
|
|
companyId,
|
|
source: "agent_key",
|
|
});
|
|
|
|
const decision = (allowed: boolean): AuthorizationDecision => ({
|
|
allowed,
|
|
action: "project:read",
|
|
reason: allowed ? "allow_company_agent" : "deny_company_boundary",
|
|
explanation: "test decision",
|
|
});
|
|
|
|
// A `projects.listByIds` stub that returns one minimal record per requested id, in request order.
|
|
// The referenced-project record only needs an `id` here; the workspace resolution is stubbed, so no
|
|
// other field is read. The cast keeps the stub minimal without restating the full project row shape.
|
|
const listByIdsStub: ResolveAdditionalRunWorkspacesOptions["projects"] = {
|
|
listByIds: async (_companyId, ids) =>
|
|
ids.map(
|
|
(id) =>
|
|
({ id, name: `Project ${id}`, status: "in_progress" }) as Awaited<
|
|
ReturnType<ResolveAdditionalRunWorkspacesOptions["projects"]["listByIds"]>
|
|
>[number],
|
|
),
|
|
};
|
|
|
|
// A `findMentionedProjectIds` stub that returns a fixed mention set for any issue.
|
|
const mentionsStub = (
|
|
mentionedProjectIds: string[],
|
|
): ResolveAdditionalRunWorkspacesOptions["issues"] => ({
|
|
findMentionedProjectIds: async () => mentionedProjectIds,
|
|
});
|
|
|
|
// Records every `project:read` authorization call and answers via the supplied resolver.
|
|
const recordingAccess = (
|
|
resolve: (projectId: string) => AuthorizationDecision,
|
|
): { decidedProjectIds: string[]; access: ResolveAdditionalRunWorkspacesOptions["access"] } => {
|
|
const decidedProjectIds: string[] = [];
|
|
const access: ResolveAdditionalRunWorkspacesOptions["access"] = {
|
|
decide: async (input) => {
|
|
const resource = input.resource;
|
|
const projectId = resource.type === "project" ? (resource.projectId ?? "") : "";
|
|
decidedProjectIds.push(projectId);
|
|
return resolve(projectId);
|
|
},
|
|
};
|
|
return { decidedProjectIds, access };
|
|
};
|
|
|
|
// Records every workspace that resolution stages and returns a read-only workspace stub for it.
|
|
const recordingResolveProjectWorkspace = (): {
|
|
stagedProjectIds: string[];
|
|
resolveProjectWorkspace: ResolveAdditionalRunWorkspacesOptions["resolveProjectWorkspace"];
|
|
} => {
|
|
const stagedProjectIds: string[] = [];
|
|
const resolveProjectWorkspace: ResolveAdditionalRunWorkspacesOptions["resolveProjectWorkspace"] =
|
|
async (project) => {
|
|
stagedProjectIds.push(project.projectId);
|
|
return {
|
|
cwd: `/tmp/referenced/${project.projectId}`,
|
|
projectId: project.projectId,
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
} satisfies ResolvedAdditionalWorkspace;
|
|
};
|
|
return { stagedProjectIds, resolveProjectWorkspace };
|
|
};
|
|
|
|
const baseOpts = (
|
|
companyId: string,
|
|
overrides: Partial<ResolveAdditionalRunWorkspacesOptions> &
|
|
Pick<ResolveAdditionalRunWorkspacesOptions, "issues" | "access" | "resolveProjectWorkspace">,
|
|
): ResolveAdditionalRunWorkspacesOptions => ({
|
|
enabled: true,
|
|
companyId,
|
|
actor: buildActor(companyId),
|
|
projects: listByIdsStub,
|
|
...overrides,
|
|
});
|
|
|
|
describe("remote referenced-project kill switch", () => {
|
|
it("is ON by default and disabled only by an explicit false env value", () => {
|
|
// Default ON: an unset value resolves the remote path live (go-live default).
|
|
expect(isMultiProjectWorkspaceSyncRemoteEnabled({})).toBe(true);
|
|
// The targeted kill switch: an explicit false value (the rollback path) disables it.
|
|
expect(
|
|
isMultiProjectWorkspaceSyncRemoteEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_REMOTE_ENV]: "" }),
|
|
).toBe(false);
|
|
expect(
|
|
isMultiProjectWorkspaceSyncRemoteEnabled({
|
|
[MULTI_PROJECT_WORKSPACE_SYNC_REMOTE_ENV]: "false",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
isMultiProjectWorkspaceSyncRemoteEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_REMOTE_ENV]: "0" }),
|
|
).toBe(false);
|
|
expect(
|
|
isMultiProjectWorkspaceSyncRemoteEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_REMOTE_ENV]: "off" }),
|
|
).toBe(false);
|
|
// Any other value keeps the remote path on.
|
|
expect(
|
|
isMultiProjectWorkspaceSyncRemoteEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_REMOTE_ENV]: "true" }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("classifies only the sandbox driver as a confined remote staging transport", () => {
|
|
// Only the sandbox driver confines each staged referenced tree, so only it opens the gate.
|
|
expect(isConfinedRemoteStagingDriver("sandbox")).toBe(true);
|
|
// The SSH and plugin drivers keep dropping referenced projects.
|
|
expect(isConfinedRemoteStagingDriver("ssh")).toBe(false);
|
|
expect(isConfinedRemoteStagingDriver("plugin")).toBe(false);
|
|
expect(isConfinedRemoteStagingDriver("local")).toBe(false);
|
|
expect(isConfinedRemoteStagingDriver(null)).toBe(false);
|
|
expect(isConfinedRemoteStagingDriver(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("resolveAdditionalRunWorkspaces remote gate", () => {
|
|
it("resolves and authorizes referenced workspaces on a sandbox target with the remote flag on", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const anchorProjectId = randomUUID();
|
|
const mentionedProjectId = randomUUID();
|
|
const { decidedProjectIds, access } = recordingAccess(() => decision(true));
|
|
const { stagedProjectIds, resolveProjectWorkspace } = recordingResolveProjectWorkspace();
|
|
|
|
const result = await resolveAdditionalRunWorkspaces(
|
|
issueId,
|
|
anchorProjectId,
|
|
baseOpts(companyId, {
|
|
executionTargetIsRemote: true,
|
|
targetStagesConfined: true,
|
|
remoteReferencedSyncEnabled: true,
|
|
issues: mentionsStub([mentionedProjectId]),
|
|
access,
|
|
resolveProjectWorkspace,
|
|
}),
|
|
);
|
|
|
|
// The run authorized the referenced project against the run actor and staged its workspace.
|
|
expect(decidedProjectIds).toEqual([mentionedProjectId]);
|
|
expect(stagedProjectIds).toEqual([mentionedProjectId]);
|
|
expect(result.additionalWorkspaces.map((workspace) => workspace.projectId)).toEqual([
|
|
mentionedProjectId,
|
|
]);
|
|
expect(result.failures).toEqual([]);
|
|
});
|
|
|
|
it("fails closed on a sandbox target when the remote flag is off (no authorization, no staging)", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const anchorProjectId = randomUUID();
|
|
const mentionedProjectId = randomUUID();
|
|
const { decidedProjectIds, access } = recordingAccess(() => decision(true));
|
|
const { stagedProjectIds, resolveProjectWorkspace } = recordingResolveProjectWorkspace();
|
|
|
|
const result = await resolveAdditionalRunWorkspaces(
|
|
issueId,
|
|
anchorProjectId,
|
|
baseOpts(companyId, {
|
|
executionTargetIsRemote: true,
|
|
targetStagesConfined: true,
|
|
remoteReferencedSyncEnabled: false,
|
|
issues: mentionsStub([mentionedProjectId]),
|
|
access,
|
|
resolveProjectWorkspace,
|
|
}),
|
|
);
|
|
|
|
// Fail closed: no authorization decision and no workspace staging ran.
|
|
expect(decidedProjectIds).toEqual([]);
|
|
expect(stagedProjectIds).toEqual([]);
|
|
expect(result.additionalWorkspaces).toEqual([]);
|
|
// The whole referenced set is still counted as a staging-layer drop.
|
|
expect(result.failures).toEqual([{ projectId: mentionedProjectId, reason: "staging" }]);
|
|
});
|
|
|
|
it("drops referenced projects on an SSH target whether the remote flag is on or off", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const anchorProjectId = randomUUID();
|
|
const mentionedProjectId = randomUUID();
|
|
|
|
for (const remoteReferencedSyncEnabled of [true, false]) {
|
|
const { decidedProjectIds, access } = recordingAccess(() => decision(true));
|
|
const { stagedProjectIds, resolveProjectWorkspace } = recordingResolveProjectWorkspace();
|
|
|
|
const result = await resolveAdditionalRunWorkspaces(
|
|
issueId,
|
|
anchorProjectId,
|
|
baseOpts(companyId, {
|
|
executionTargetIsRemote: true,
|
|
// The SSH transport does not confine its staging path, so it stays out of scope.
|
|
targetStagesConfined: false,
|
|
remoteReferencedSyncEnabled,
|
|
issues: mentionsStub([mentionedProjectId]),
|
|
access,
|
|
resolveProjectWorkspace,
|
|
}),
|
|
);
|
|
|
|
expect(decidedProjectIds).toEqual([]);
|
|
expect(stagedProjectIds).toEqual([]);
|
|
expect(result.additionalWorkspaces).toEqual([]);
|
|
expect(result.failures).toEqual([{ projectId: mentionedProjectId, reason: "staging" }]);
|
|
}
|
|
});
|
|
|
|
it("drops a referenced project the run agent cannot read on a sandbox target", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const anchorProjectId = randomUUID();
|
|
const readableProjectId = randomUUID();
|
|
const unreadableProjectId = randomUUID();
|
|
// Deny project:read for the unreadable project; allow it for the readable one.
|
|
const { decidedProjectIds, access } = recordingAccess((projectId) =>
|
|
decision(projectId === readableProjectId),
|
|
);
|
|
const { stagedProjectIds, resolveProjectWorkspace } = recordingResolveProjectWorkspace();
|
|
|
|
const result = await resolveAdditionalRunWorkspaces(
|
|
issueId,
|
|
anchorProjectId,
|
|
baseOpts(companyId, {
|
|
executionTargetIsRemote: true,
|
|
targetStagesConfined: true,
|
|
remoteReferencedSyncEnabled: true,
|
|
issues: mentionsStub([readableProjectId, unreadableProjectId]),
|
|
access,
|
|
resolveProjectWorkspace,
|
|
}),
|
|
);
|
|
|
|
// Both projects were authorized against the run actor, but only the readable one was staged.
|
|
expect(decidedProjectIds).toEqual([readableProjectId, unreadableProjectId]);
|
|
expect(stagedProjectIds).toEqual([readableProjectId]);
|
|
expect(result.additionalWorkspaces.map((workspace) => workspace.projectId)).toEqual([
|
|
readableProjectId,
|
|
]);
|
|
// The denied project is a first-class authorization failure.
|
|
expect(result.failures).toEqual([{ projectId: unreadableProjectId, reason: "authorization" }]);
|
|
});
|
|
|
|
it("never authorizes the anchor project on a sandbox target", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const anchorProjectId = randomUUID();
|
|
const mentionedProjectId = randomUUID();
|
|
const { decidedProjectIds, access } = recordingAccess(() => decision(true));
|
|
const { resolveProjectWorkspace } = recordingResolveProjectWorkspace();
|
|
|
|
await resolveAdditionalRunWorkspaces(
|
|
issueId,
|
|
anchorProjectId,
|
|
baseOpts(companyId, {
|
|
executionTargetIsRemote: true,
|
|
targetStagesConfined: true,
|
|
remoteReferencedSyncEnabled: true,
|
|
// The mention set includes the anchor itself; the anchor must never be re-authorized.
|
|
issues: mentionsStub([anchorProjectId, mentionedProjectId]),
|
|
access,
|
|
resolveProjectWorkspace,
|
|
}),
|
|
);
|
|
|
|
// The anchor exemption drops the anchor from the mention set before the per-project check, so
|
|
// only the non-anchor referenced project is authorized.
|
|
expect(decidedProjectIds).toEqual([mentionedProjectId]);
|
|
expect(decidedProjectIds).not.toContain(anchorProjectId);
|
|
});
|
|
|
|
it("holds the admitted-project cap on a sandbox target", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const anchorProjectId = randomUUID();
|
|
// One more mention than the admitted cap.
|
|
const mentionedProjectIds = Array.from(
|
|
{ length: MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS + 1 },
|
|
() => randomUUID(),
|
|
);
|
|
const { access } = recordingAccess(() => decision(true));
|
|
const { stagedProjectIds, resolveProjectWorkspace } = recordingResolveProjectWorkspace();
|
|
|
|
const result = await resolveAdditionalRunWorkspaces(
|
|
issueId,
|
|
anchorProjectId,
|
|
baseOpts(companyId, {
|
|
executionTargetIsRemote: true,
|
|
targetStagesConfined: true,
|
|
remoteReferencedSyncEnabled: true,
|
|
issues: mentionsStub(mentionedProjectIds),
|
|
access,
|
|
resolveProjectWorkspace,
|
|
}),
|
|
);
|
|
|
|
// The cap bounds how many additional projects a sandbox run materializes.
|
|
expect(stagedProjectIds).toHaveLength(MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS);
|
|
expect(result.additionalWorkspaces).toHaveLength(MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS);
|
|
// The one project past the cap is a first-class resolution failure.
|
|
expect(result.failures).toEqual([
|
|
{ projectId: mentionedProjectIds[MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS], reason: "resolution" },
|
|
]);
|
|
});
|
|
|
|
it("holds the candidate-evaluation cap on a sandbox target", async () => {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const anchorProjectId = randomUUID();
|
|
// Flood the run with denied mentions past the evaluation cap. Every candidate is denied, so the
|
|
// admitted cap is never reached; the evaluation cap must still bound the authorization fan-out.
|
|
const mentionedProjectIds = Array.from(
|
|
{ length: MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS + 5 },
|
|
() => randomUUID(),
|
|
);
|
|
const { decidedProjectIds, access } = recordingAccess(() => decision(false));
|
|
const { resolveProjectWorkspace } = recordingResolveProjectWorkspace();
|
|
|
|
const result = await resolveAdditionalRunWorkspaces(
|
|
issueId,
|
|
anchorProjectId,
|
|
baseOpts(companyId, {
|
|
executionTargetIsRemote: true,
|
|
targetStagesConfined: true,
|
|
remoteReferencedSyncEnabled: true,
|
|
issues: mentionsStub(mentionedProjectIds),
|
|
access,
|
|
resolveProjectWorkspace,
|
|
}),
|
|
);
|
|
|
|
// The evaluation cap bounds authorization decisions even under a denied-mention flood.
|
|
expect(decidedProjectIds).toHaveLength(MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS);
|
|
expect(result.additionalWorkspaces).toEqual([]);
|
|
});
|
|
});
|