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,