From 756599e4ea069c9338c320fe8846523d3dfb92a0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:19:28 -0500 Subject: [PATCH] fix(workspaces): keep project references without source folders (#18504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- doc/SPEC-implementation.md | 7 + .../heartbeat/remote-workspace-source.test.ts | 132 ++++++++++++++++++ .../heartbeat/remote-workspace-source.ts | 8 +- 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 server/src/services/heartbeat/remote-workspace-source.test.ts diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 7a58fada4fd..3569953e34b 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -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, diff --git a/server/src/services/heartbeat/remote-workspace-source.test.ts b/server/src/services/heartbeat/remote-workspace-source.test.ts new file mode 100644 index 00000000000..3cf53358b60 --- /dev/null +++ b/server/src/services/heartbeat/remote-workspace-source.test.ts @@ -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>; + 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]); + }); +}); diff --git a/server/src/services/heartbeat/remote-workspace-source.ts b/server/src/services/heartbeat/remote-workspace-source.ts index a459d2ff4a5..220fc4c1af9 100644 --- a/server/src/services/heartbeat/remote-workspace-source.ts +++ b/server/src/services/heartbeat/remote-workspace-source.ts @@ -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,