mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:10:40 +01:00
fix(workspaces): keep project references without source folders (#18504)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents can receive project references in normal task and chat messages. > - Remote workspace preparation checks those references before a native run starts. > - A project with no workspace source has no folder to attach. > - The current check rejects that project as an unsupported read-only folder. > - This pull request keeps the project reference and reports the missing source without attaching files. > - Projects with configured sources keep the current remote access restrictions. ## Linked Issues or Issue Description Refs #18403 and #18351. This is a product prerequisite for the hiring journey. It changes no journey code. **What happened?** A native chat run failed before execution when its message referenced an authorized project with zero configured workspaces. The error was `workspace_validation_failed`: “This remote runtime cannot safely provide read-only project folders.” The project had no repository or checkout. The failed QA run and original grade remain preserved. **Expected behavior** Keep the task workspace when a referenced project has no configured source. Report that no project folder was attached. Continue to reject configured cross-project sources that require an unsupported read-only mount. **Steps to reproduce** 1. Use a native remote task folder with multi-project references enabled. 2. Mention a same-company project that has no configured workspaces in a normal comment. 3. Prepare the run workspace. **Paperclip version or commit** Reproduced on `b889ac39b7fbed558c5f4cfbbc2b73c4ca916810` with the real workspace resolver and PostgreSQL migrations. The staging failure was recorded in workflow [41253428446](https://github.com/paperclipai/paperclip-cloud/actions/runs/41253428446). ## What Changed - Distinguish authorized project references with a known empty workspace list from configured sources. - Return a resolution warning and failure receipt for an absent source. Do not attach a folder or create a workspace. - Preserve project authorization, company filtering, the disabled remote-source gate, and the read-only restriction for configured sources. - Add real PostgreSQL regression cases and document the reference contract. ## Verification - Causal RED on unchanged `b889`: the metadata-only project case failed with the same read-only error. The real repository negative already passed. - GREEN: eight PostgreSQL cases pass. They cover an absent source with the remote-source switch both on and off, a saved existing workspace, a repository, a configured source without a repository URL, the disabled remote gate, mixed references, and a foreign company. - The existing assembled workspace test also passes: a real read-only project source is rejected before reservation or environment acquisition. Total: 9 selected tests pass; 119 unrelated tests were not selected. - Tests use PostgreSQL 17 and the current source migrations. The local Vitest configuration selects current DB, shared, and Runner sources because the borrowed dependency output predates the remote workspace feature. - Current-source TypeScript checking of both changed TypeScript files and their dependency graph passes. It uses the repository's real Express actor declaration. - `node scripts/check-maps.mjs` and `git diff --check` pass. - Canonical reproduction: `pnpm exec vitest run --project server server/src/services/heartbeat/remote-workspace-source.test.ts` with the existing embedded PostgreSQL support or `PAPERCLIP_TEST_POSTGRES_ADMIN_URL` set to a disposable test cluster. - Historical `0f2b` passed all 59 CI checks. A separate non-author manual source audit reported no actionable findings at confidence 4/5; it did not rerun tests or qualify live QA. The official 4/5 review requested a flag-scope test and saved-workspace control. This test-and-documentation update adds both, with production source bytes unchanged. - Current-head canonical [CI 41258109282](https://github.com/paperclipai/paperclip/actions/runs/41258109282) passed: all 59 checks are terminal (55 success, 3 skipped, 1 neutral), with no failures or pending checks. Build and typecheck passed. The eight-case regression file passed in the canonical Linux server 1/12 lane. Its checkout `6f69ccca70d10e78885b133fb54a80940bf175a5` has parents `b889` and this exact head; its tree equals this head. - Fresh Greptile and official Ponytail assessments each give 5/5 for exact `27660027d12ff31427d611019e90dabfb6d8503a`, with zero unresolved review threads. The separate manual audit remains 4/5. - Live QA remains pending on a reviewed app build. This regression proof does not claim that the hiring journey passed. ## Risks A project source could be added after project hydration. This change uses the same run-preparation source snapshot as the existing authorization and source check. It only permits a positively known empty workspace list. Configured sources, unknown shapes, foreign projects, and existing execution authority keep their current checks. No schema, credentials, provider, model, budget, or filesystem permissions change. ## Model Used OpenAI Codex, GPT-6 family. The session does not expose a more specific backend model ID or fixed context window. Used reasoning, repository tools, TypeScript and test execution. ## 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:
1 parent
66e8fab944
commit
756599e4ea
3 files changed
+145
-2
No files matched your search
@@ -2636,6 +2636,13 @@ the supported command path. Disabling new enrollment preserves existing authorit
|
||||
and recovery. An enrolled workspace whose persistence prerequisites are lost
|
||||
must fail admission rather than fall back to a controller checkout.
|
||||
|
||||
A reference to an authorized project with no configured workspace source does
|
||||
not attach a folder. It adds a resolution warning and preserves the task's
|
||||
workspace. A configured cross-project source remains subject to the read-only
|
||||
folder restriction. `PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC_REMOTE` gates configured
|
||||
sources; a source-less project reference needs no folder and stays metadata-only
|
||||
when that switch is off.
|
||||
|
||||
An imported saved Git workspace can resume from its saved files after its old
|
||||
local repository is removed. Local repository paths remain invalid for new
|
||||
remote acquisition. Saves reject unfinished Git operations, including rebase,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { agents, authUsers, companies, createDb, executionWorkspaces, heartbeatRuns, issueComments, issues, projects, projectWorkspaces, type Db } from "@paperclipai/db";
|
||||
import { buildProjectMentionHref } from "@paperclipai/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { toExecutionWorkspace } from "../execution-workspaces.js";
|
||||
import { resolveRemoteWorkspaceSource } from "./remote-workspace-source.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("remote referenced project sources", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("remote-project-reference-");
|
||||
db = createDb(database.connectionString);
|
||||
vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "true");
|
||||
vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC_REMOTE", "true");
|
||||
}, 60_000);
|
||||
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); vi.unstubAllEnvs(); });
|
||||
|
||||
async function fixture(repository: boolean) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
|
||||
const projectId = randomUUID(), userId = `fixture-${randomUUID()}`;
|
||||
await db.insert(authUsers).values({ id: userId, name: "Project reference owner", email: `${userId}@example.test`,
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companies).values({ id: companyId, name: "Referenced project fixture", issuePrefix: companyId.slice(0, 8) });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Lead", status: "running", adapterType: "paperclip_runner" });
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Delegate work here" });
|
||||
if (repository) await db.insert(projectWorkspaces).values({ companyId, projectId,
|
||||
name: "Repository", sourceType: "git", repoUrl: "https://example.test/team/repository.git" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Chat with lead", assigneeAgentId: agentId,
|
||||
conversationAgentId: agentId, conversationUserId: userId, conversationState: "active", status: "in_progress" });
|
||||
await db.insert(issueComments).values({ companyId, issueId, authorUserId: userId,
|
||||
body: `Create the teammate's task in [@Delegate work here](${buildProjectMentionHref(projectId)}).` });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId,
|
||||
nativeIssueId: issueId, runtimeMode: "native", status: "running" });
|
||||
return { companyId, agentId, issueId, runId, projectId };
|
||||
}
|
||||
|
||||
it.each(["true", "false"])("keeps a metadata-only project mention without a folder when the remote-source flag is %s", async (remoteEnabled) => {
|
||||
const f = await fixture(false);
|
||||
vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC_REMOTE", remoteEnabled);
|
||||
try {
|
||||
const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
|
||||
projectId: null, projectWorkspaceId: null, taskDirectory: true });
|
||||
expect(result).toMatchObject({ authority: "remote", source: { kind: "empty" }, projectId: null,
|
||||
projectWorkspaceId: null, referencedProjectFailures: [{ projectId: f.projectId, reason: "resolution" }] });
|
||||
expect(result.authority === "remote" && result.source.repositories).toBeUndefined();
|
||||
expect(result.authority === "remote" && result.warnings?.length).toBe(1);
|
||||
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]);
|
||||
} finally {
|
||||
vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC_REMOTE", "true");
|
||||
}
|
||||
});
|
||||
|
||||
it("still rejects an actual repository reference before any workspace is created", async () => {
|
||||
const f = await fixture(true);
|
||||
await expect(resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
|
||||
projectId: null, projectWorkspaceId: null, taskDirectory: true })).rejects.toMatchObject({
|
||||
code: "workspace_validation_failed", resultJson: { reason: "remote_workspace_readonly_sources_unsupported" },
|
||||
});
|
||||
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat a configured source without a repository URL as metadata-only", async () => {
|
||||
const f = await fixture(false);
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: f.projectId, name: "Configured source" });
|
||||
await expect(resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
|
||||
projectId: null, projectWorkspaceId: null, taskDirectory: true })).rejects.toMatchObject({
|
||||
resultJson: { reason: "remote_workspace_readonly_sources_unsupported" },
|
||||
});
|
||||
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves the disabled remote-source gate for a configured repository", async () => {
|
||||
const f = await fixture(true);
|
||||
vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC_REMOTE", "false");
|
||||
try {
|
||||
await expect(resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
|
||||
projectId: null, projectWorkspaceId: null, taskDirectory: true })).rejects.toMatchObject({
|
||||
resultJson: { reason: "remote_referenced_workspace_disabled" },
|
||||
});
|
||||
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]);
|
||||
} finally {
|
||||
vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC_REMOTE", "true");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a real repository denied when a source-less reference is also mentioned", async () => {
|
||||
const f = await fixture(true), metadataProjectId = randomUUID();
|
||||
await db.insert(projects).values({ id: metadataProjectId, companyId: f.companyId, name: "Metadata" });
|
||||
await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
|
||||
body: `Also mention [@Metadata](${buildProjectMentionHref(metadataProjectId)}).` });
|
||||
await expect(resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
|
||||
projectId: null, projectWorkspaceId: null, taskDirectory: true })).rejects.toMatchObject({
|
||||
resultJson: { reason: "remote_workspace_readonly_sources_unsupported" },
|
||||
});
|
||||
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not expose a source-less project from a different company", async () => {
|
||||
const f = await fixture(false), foreignCompanyId = randomUUID();
|
||||
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: foreignCompanyId.slice(0, 8) });
|
||||
await db.update(projects).set({ companyId: foreignCompanyId }).where(eq(projects.id, f.projectId));
|
||||
const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
|
||||
projectId: null, projectWorkspaceId: null, taskDirectory: true });
|
||||
expect(result).toMatchObject({ authority: "remote", source: { kind: "empty" },
|
||||
referencedProjectFailures: [], warnings: [] });
|
||||
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves an enrolled workspace's saved source for a metadata-only project mention", async () => {
|
||||
const f = await fixture(false), snapshotId = randomUUID();
|
||||
const source = { kind: "git" as const, repoUrl: "https://example.test/team/saved.git", ref: "saved-branch" };
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId,
|
||||
sourceIssueId: f.issueId, mode: "shared_workspace", strategyType: "project_primary",
|
||||
name: "Saved task workspace", providerType: "cloud_sandbox", cwd: "/workspaces/saved",
|
||||
metadata: { remoteWorkspace: { schema: "paperclip.remote-workspace.v1", epoch: 3, snapshotId,
|
||||
source, location: null, writerRunId: null, dirty: false } },
|
||||
}).returning();
|
||||
await db.update(issues).set({ executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
|
||||
const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
|
||||
projectId: null, projectWorkspaceId: null, existingWorkspace: toExecutionWorkspace(workspace) });
|
||||
expect(result).toMatchObject({ authority: "remote", source, projectId: null, projectWorkspaceId: null,
|
||||
referencedProjectFailures: [{ projectId: f.projectId, reason: "resolution" }] });
|
||||
expect(result.authority === "remote" && result.source.repositories).toBeUndefined();
|
||||
expect(result.authority === "remote" && result.warnings?.length).toBe(1);
|
||||
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId)))
|
||||
.toEqual([workspace]);
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,8 @@ export async function resolveRemoteWorkspaceSource(db: Db, input: {
|
||||
onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId },
|
||||
issues: issueService(db), projects: projectService(db), access: authorizationService(db),
|
||||
}) : { additional: [], warnings: [], failures: [] };
|
||||
if (referenced.additional.length > 0) {
|
||||
const sourceLess = referenced.additional.filter(({ project }) => Array.isArray(project.workspaces) && project.workspaces.length === 0);
|
||||
if (referenced.additional.length > sourceLess.length) {
|
||||
if (!isMultiProjectWorkspaceSyncRemoteEnabled()) throw new WorkspaceValidationFailure(
|
||||
"Referenced remote workspace access is disabled.", { reason: "remote_referenced_workspace_disabled" });
|
||||
// Native sandbox providers currently run with external sandbox authority.
|
||||
@@ -87,7 +88,10 @@ export async function resolveRemoteWorkspaceSource(db: Db, input: {
|
||||
}
|
||||
return { authority: "remote", source: { ...base, ...(extra.length ? { repositories: extra } : { repositories: undefined }) },
|
||||
projectId: anchorProjectId, projectWorkspaceId: sourceId,
|
||||
warnings: referenced.warnings, referencedProjectFailures: referenced.failures };
|
||||
warnings: [...referenced.warnings, ...sourceLess.map(({ projectId }) =>
|
||||
`Referenced project ${projectId} has no configured workspace source; no project folder was attached.`)],
|
||||
referencedProjectFailures: [...referenced.failures, ...sourceLess.map(({ projectId }) =>
|
||||
({ projectId, reason: "resolution" as const }))] };
|
||||
};
|
||||
const existing = readRemoteWorkspaceAuthority(input.existingWorkspace?.metadata);
|
||||
if (existing) return withReferences(existing.source, input.existingWorkspace!.projectId,
|
||||
|
||||
Reference in new issue
Block a user