diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 3569953e34b..224925dbc54 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -2593,6 +2593,11 @@ publication receipts, binding checks, and lifecycle locks remain authoritative. A task's organizational project and filesystem workspace are independent. New tasks that need a filesystem and have no selected/configured workspace get a company/task directory, not the agent home. Plain tasks need neither Git nor a project. Existing bindings and admitted roots remain authoritative; changing project does not move files. Explicit workspace selections apply at a subsequent normal admission after prior execution and persistence obligations settle. +The remote default also uses a task-owned empty source for an authorized project +with no configured workspaces or explicit workspace policy. Its derived managed +folder is not an admitted source. Existing bindings, explicit source selections, +and required Git or folder settings do not become empty-source fallbacks. + Chat connection and destination settings may supply project/workspace defaults. Destination fields override connection fields; omitted values inherit and null clears the inherited choice. Resolve and authorize these defaults once for a new conversation, then retain the resulting task intent. Existing conversations do not change when settings change. Trust confinement still takes precedence. Agents inspect/select workspaces and request contained repository preparation through ordinary authorized APIs. Repository preparation is idempotent and may require the next admission; it never claims a host-only clone is available in an already-running remote session. Simultaneous tasks may still explicitly share one physical local/SSH folder. Remote sharing requires actual placement and coherent persistence capability. diff --git a/server/src/services/heartbeat/remote-workspace-source.test.ts b/server/src/services/heartbeat/remote-workspace-source.test.ts index 3cf53358b60..a7747eda699 100644 --- a/server/src/services/heartbeat/remote-workspace-source.test.ts +++ b/server/src/services/heartbeat/remote-workspace-source.test.ts @@ -1,9 +1,13 @@ import { randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; 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 { projectService } from "../projects.js"; import { toExecutionWorkspace } from "../execution-workspaces.js"; import { resolveRemoteWorkspaceSource } from "./remote-workspace-source.js"; @@ -11,13 +15,16 @@ const support = await getEmbeddedPostgresTestSupport(); describe.skipIf(!support.supported)("remote referenced project sources", () => { let database: Awaited>; let db: Db; + let home: string; beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("remote-project-reference-"); db = createDb(database.connectionString); + home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-task-anchor-"))); + vi.stubEnv("PAPERCLIP_HOME", home); 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(); }); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); if (home) await rm(home, { recursive: true, force: true }); vi.unstubAllEnvs(); }); async function fixture(repository: boolean) { const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); @@ -38,6 +45,75 @@ describe.skipIf(!support.supported)("remote referenced project sources", () => { return { companyId, agentId, issueId, runId, projectId }; } + async function assignedTask(repository = false) { + const f = await fixture(repository); + await db.update(issues).set({ projectId: f.projectId, conversationAgentId: null, + conversationUserId: null, conversationState: null }).where(eq(issues.id, f.issueId)); + return f; + } + + it("gives an ordinary metadata-project task its private empty source without adopting the facade folder", async () => { + const f = await assignedTask(); + const project = (await projectService(db).getById(f.projectId))!; + expect(project.workspaces).toEqual([]); + expect(project.codebase).toMatchObject({ workspaceId: null, repoUrl: null, localFolder: null, origin: "managed_checkout" }); + expect(project.codebase.managedFolder.startsWith(`${home}${path.sep}`)).toBe(true); + await mkdir(project.codebase.managedFolder, { recursive: true }); + const sentinel = path.join(project.codebase.managedFolder, "unselected-project-files.txt"); + await writeFile(sentinel, "Preserve this unselected managed folder."); + const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null, + projectId: f.projectId, projectWorkspaceId: null }); + expect(result).toMatchObject({ authority: "remote", source: { kind: "empty" }, projectId: null, projectWorkspaceId: null }); + expect(result.authority === "remote" && result.source.repositories).toBeUndefined(); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]!.projectId).toBe(f.projectId); + expect(await readFile(sentinel, "utf8")).toBe("Preserve this unselected managed folder."); + expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]); + }); + + it("retains a configured anchor repository instead of substituting private scratch", async () => { + const f = await assignedTask(true); + const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null, + projectId: f.projectId, projectWorkspaceId: null }); + expect(result).toMatchObject({ authority: "remote", projectId: f.projectId, + source: { kind: "git", repoUrl: "https://example.test/team/repository.git" } }); + expect(result.authority === "remote" && result.projectWorkspaceId).toBeTruthy(); + }); + + it("does not replace a configured anchor without a repository URL", async () => { + const f = await assignedTask(); + await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: f.projectId, name: "Required source" }); + await expect(resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null, + projectId: f.projectId, projectWorkspaceId: null })).rejects.toMatchObject({ + resultJson: { reason: "remote_workspace_source_unavailable" }, + }); + }); + + it.each(["missing-source", "unknown-selection", "required-policy", "required-git", "explicit-cwd"])("does not turn %s into metadata-project scratch", async kind => { + const f = await assignedTask(); + const input: Parameters[1] = { ...f, responsibleUserId: null, + projectId: f.projectId, projectWorkspaceId: null }; + if (kind === "missing-source" || kind === "unknown-selection") { + const selection = { version: 1 as const, source: "explicit" as const, selection: kind === "missing-source" + ? { kind: "configured_source", projectWorkspaceId: randomUUID() } : { kind: "unknown" } }; + await db.update(issues).set({ workspaceSelection: selection as never }).where(eq(issues.id, f.issueId)); + input.selection = selection as never; + } else if (kind === "required-policy") { + await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, defaultProjectWorkspaceId: randomUUID() } }).where(eq(projects.id, f.projectId)); + } else if (kind === "required-git") input.branchName = "required-task-branch"; + else input.configuredCwd = home; + await expect(resolveRemoteWorkspaceSource(db, input)).rejects.toThrow(); + expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]); + }); + + it("does not use a foreign organizational project as private scratch authority", async () => { + const f = await assignedTask(), foreignCompanyId = randomUUID(), foreignProjectId = randomUUID(); + await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign owner", issuePrefix: foreignCompanyId.slice(0, 8) }); + await db.insert(projects).values({ id: foreignProjectId, companyId: foreignCompanyId, name: "Foreign project" }); + await db.update(issues).set({ projectId: foreignProjectId }).where(eq(issues.id, f.issueId)); + await expect(resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null, + projectId: foreignProjectId, projectWorkspaceId: null })).rejects.toThrow(); + }); + 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); diff --git a/server/src/services/heartbeat/remote-workspace-source.ts b/server/src/services/heartbeat/remote-workspace-source.ts index 220fc4c1af9..22aea2105e7 100644 --- a/server/src/services/heartbeat/remote-workspace-source.ts +++ b/server/src/services/heartbeat/remote-workspace-source.ts @@ -1,5 +1,5 @@ -import { asc, and, eq } from "drizzle-orm"; -import { projectWorkspaces, type Db } from "@paperclipai/db"; +import { asc, and, eq, isNull, notExists } from "drizzle-orm"; +import { projects, projectWorkspaces, type Db } from "@paperclipai/db"; import type { TaskWorkspaceIntent } from "@paperclipai/shared"; import { assertTaskWorkspaceExecutionAccess } from "../task-workspace-access.js"; import { managedProjectCheckoutName, projectRepositoryIdentity } from "../managed-project-checkout-name.js"; @@ -113,7 +113,20 @@ export async function resolveRemoteWorkspaceSource(db: Db, input: { eq(projectWorkspaces.projectId, projectId), eq(projectWorkspaces.companyId, input.companyId), )).orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)).limit(1) : []; if (!source) { - if (sourceId || projectId) throw new WorkspaceValidationFailure( + let implicitTaskDirectory = false; + if (projectId && !sourceId && !input.selection && !selected.workspace + && selected.task.executionWorkspaceId == null && selected.task.projectWorkspaceId == null + && selected.task.workspaceSelection == null && selected.task.workspacePendingSelection == null + && input.ref == null && input.branchName == null && input.branchMode !== "existing") { + const [metadataProject] = await db.select({ id: projects.id }).from(projects).where(and( + eq(projects.id, projectId), eq(projects.companyId, input.companyId), isNull(projects.executionWorkspacePolicy), + notExists(db.select({ id: projectWorkspaces.id }).from(projectWorkspaces).where(and( + eq(projectWorkspaces.projectId, projects.id), eq(projectWorkspaces.companyId, input.companyId), + ))), + )).limit(1); + implicitTaskDirectory = Boolean(metadataProject); + } + if (sourceId || projectId && !implicitTaskDirectory) throw new WorkspaceValidationFailure( "The selected project has no repository source for a remote workspace.", { reason: "remote_workspace_source_unavailable" }); return withReferences({ kind: "empty" }, null, null); diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index ec9825f857d..5aa66c1d9cd 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -42,6 +42,7 @@ import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@open import { selectHeartbeatEnvironment } from "./environment-selection.js"; import { assertNativeSessionPreparationAuthority } from "../native-runtime/native-execution-lease.js"; import { currentNativeControllerIdentity } from "../native-runtime/native-restart-recovery.js"; +import { projectService } from "../projects.js"; const execFile = promisify(execFileCallback); const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_WORKSPACE_PREPARATION_TEST_DATABASE_URL"); @@ -511,6 +512,42 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace expect(await readFile(join(source, "README.md"), "utf8")).toBe("source content\n"); }); + it("reserves private remote task files for an organizational project without a configured source", async () => { + const { input, issue, source, envOrchestrator } = await fixture(); + await db.update(issues).set({ projectWorkspaceId: null }).where(eq(issues.id, issue.id)); + await db.delete(projectWorkspaces).where(eq(projectWorkspaces.projectId, issue.projectId!)); + input.task.issueRef = await readIssue(issue.id); + const project = (await projectService(db).getById(issue.projectId!))!; + expect(project.workspaces).toEqual([]); + expect(project.codebase).toMatchObject({ workspaceId: null, repoUrl: null, localFolder: null, origin: "managed_checkout" }); + await mkdir(project.codebase.managedFolder, { recursive: true }); + const sentinel = join(project.codebase.managedFolder, "unselected-project-files.txt"); + await writeFile(sentinel, "Unselected project files\n"); + configureRemotePersistence(); + input.agent.adapterType = "paperclip_runner"; + input.policy.requestedExecutionWorkspaceMode = "agent_default"; + input.environment.selectedEnvironmentForConfig = { ...input.environment.localEnvironment, + driver: "sandbox", config: { provider: "daytona" } }; + const resolveHost = vi.spyOn(input.services, "resolveWorkspaceForRun"); + const acquire = vi.spyOn(envOrchestrator, "acquireForRun").mockImplementation(async args => { + expect(args.persistedExecutionWorkspace).toMatchObject({ companyId: issue.companyId, sourceIssueId: issue.id, + projectId: null, projectWorkspaceId: null, providerType: "cloud_sandbox", repoUrl: null, + metadata: { remoteWorkspace: { source: { kind: "empty" }, writerRunId: input.run.id } } }); + throw new Error("private task workspace reached acquisition"); + }); + await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("private task workspace reached acquisition"); + expect(acquire).toHaveBeenCalledOnce(); + expect(resolveHost).not.toHaveBeenCalled(); + const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.sourceIssueId, issue.id)); + expect(workspace).toMatchObject({ companyId: issue.companyId, sourceIssueId: issue.id, + projectId: null, projectWorkspaceId: null, providerType: "cloud_sandbox", repoUrl: null }); + expect(workspace.cwd).toBe(`/workspaces/${workspace.id}`); + expect((await readIssue(issue.id))).toMatchObject({ projectId: issue.projectId, executionWorkspaceId: workspace.id }); + await expect(access(workspace.cwd!)).rejects.toThrow(); + expect(await readFile(sentinel, "utf8")).toBe("Unselected project files\n"); + expect(await readFile(join(source, "README.md"), "utf8")).toBe("source content\n"); + }); + it("rejects read-only project references before reserving or acquiring a remote workspace", async () => { const { input, source, issue, envOrchestrator } = await fixture(); const previous = new Map(["PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC_REMOTE"]