Files
PaperClipAI/server/src/services/heartbeat/remote-workspace-source.test.ts
T
Nicky LeachandClaude Opus 5.5 be20d4ad97 fix(projects): keep projects on the storage chosen at creation (#19036)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A project stores its work in Core/local storage or in a storage
plugin
> - Today, Core finds the installed default each time it reads a project
> - So installing a default storage plugin moves existing projects,
without a saved change
> - Agents that cannot reach the new storage then lose access to new
workspaces
> - This pull request makes Core apply the default once, when it creates
a project
> - The benefit is that a project stays on its storage until a person
moves it

## Linked Issues or Issue Description

No issue exists. This describes the problem. I searched open pull
requests for related work and found no duplicate.

**What existing behavior does this improve?**

How Core chooses the storage for a project when a plugin claims
`defaultForProjects`.

**Current behavior**

Installing such a plugin changes the storage of every existing project
with no explicit choice. Disabling the plugin changes it back. Nothing
is saved, so the project has no fixed owner. Execution environments and
storage are separate contracts, so an agent can lose access to new
workspaces on a project that it used before.

**Proposed behavior**

Core saves the storage choice on the project when it creates the
project. A project with no saved choice uses Core/local storage. A
plugin-backed project stays attached to its plugin. A Core/local project
can move to a plugin.

**Reason and benefit**

A project stays on its storage until a person moves it, so installing or
disabling a plugin does not change where work goes.

**Alternatives considered**

- Keep the default at read time and add a process that saves a choice on
existing projects when a plugin arrives. This adds a background step and
a race with new projects.
- Add a new database column. The existing policy field already holds the
choice, so a migration is not necessary.

## What Changed

- `resolveProjectWorkspaceProvider` reads only the saved choice. It no
longer applies an installed default.
- `withDefaultProjectStorage` applies the default when Core creates a
project, including a personal private project. With more than one
default, a project with no explicit choice is refused. This is the
existing conflict rule, now applied at creation.
- `projectService.update` refuses a change of storage on a plugin-backed
project. An update that omits the field keeps the attachment.
- Plugin lifecycle delivery sends project events to a storage provider
only for projects attached to it, and for deleted projects. Core
acknowledges the other events for that provider. Other plugins continue
to receive all project events.
- The project settings control has no "Inherit" option. It is locked on
a plugin-backed project.
- `ProjectWorkspaceProviderConfiguration.source` no longer has the
`"default"` value.
- A project with no repository or folder ignores its saved storage
choice when a task runs. A storage plugin cannot manage this type of
project, so its tasks use a plain task directory. Without this change,
the second run of a task reset the agent session, and remote agents
could not use an empty task directory.
- The plugin SDK README and the plugin authoring guide describe the new
rules.

## Verification

- `npx vitest run
server/src/__tests__/project-workspace-provider.integration.test.ts
server/src/__tests__/plugin-lifecycle-delivery.test.ts
server/src/__tests__/plugin-workspaces.integration.test.ts
packages/plugins/sdk/tests/workspace-provider.test.ts`: 93 tests pass.
- `npx vitest run src/components/ProjectProperties.concurrency.test.tsx`
in `ui/`: 9 tests pass.
- `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter
@paperclipai/ui typecheck` pass.
- `npx vitest run` on `heartbeat-session-preparation`,
`workspace-preparation`, `remote-workspace-source`,
`project-workspace-provider.integration`, and
`plugin-workspaces.integration` in `server/`: 231 tests pass.
- I did not run the full test suite locally. CI runs it.
- I did not test this change on a running instance.

## Risks

- **Behavior change at upgrade:** an existing project that uses a plugin
only through the installed default, with no saved choice, moves to
Core/local storage after upgrade. Its plugin files stay in place, but
new work does not use them until an operator sets
`storageProviderPluginId` on the project. This pull request has no
automatic backfill.
- **Missed lifecycle events:** a provider does not receive events for a
Core/local project. If the project moves to that provider later, the
update event starts its reconcile.
- **One-way move:** a person cannot move a plugin-backed project back
through the API or the UI. This is intended. If a plugin is disabled,
the project fails closed, as it does today for an explicit choice.
- This pull request does not stop a person from assigning an agent to a
project with storage that the agent cannot reach. That is follow-up
work.
- This changes core plugin behavior, and I did not discuss it in `#dev`
first. Maintainers can tell me if they want a discussion before review.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), through Claude Code, with tool
use and code 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:07:29 -08:00

338 lines
24 KiB
TypeScript

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";
const support = await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("remote referenced project sources", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
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(); 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();
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 };
}
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<typeof resolveRemoteWorkspaceSource>[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.each([
{ enabled: true, storageProviderPluginId: randomUUID() },
{ enabled: false, storageProviderPluginId: null },
])("keeps the private empty source for a project with no repository and saved storage %j", async (policy) => {
const f = await assignedTask();
await db.update(projects).set({ executionWorkspacePolicy: policy }).where(eq(projects.id, f.projectId));
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 });
});
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);
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("attaches a referenced project's repositories as editable task copies", async () => {
const f = await fixture(true);
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: f.projectId,
name: "Second repository", sourceType: "git_repo", repoUrl: "https://example.test/team/second.git", repoRef: "release" });
const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
projectId: null, projectWorkspaceId: null, taskDirectory: true });
expect(result).toMatchObject({ authority: "remote", source: { kind: "empty", repositories: [
{ projectId: f.projectId, repoUrl: "https://example.test/team/repository.git", ref: null },
{ projectId: f.projectId, repoUrl: "https://example.test/team/second.git", ref: "release" },
] }, referencedProjectFailures: [], warnings: [] });
if (result.authority !== "remote") throw new Error("Expected remote task copies");
expect(result.source.repositories).toHaveLength(2);
for (const repository of result.source.repositories!) {
expect(repository.readOnly).not.toBe(true);
expect(repository.relativePath).toMatch(/^\.paperclip-repositories\/[^/]+$/);
expect(repository.projectWorkspaceId).toBe(repository.id);
}
expect(new Set(result.source.repositories!.map(repository => repository.relativePath)).size).toBe(2);
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, f.companyId))).toEqual([]);
});
it("keeps the selected anchor and adds the other project's writable repository", async () => {
const f = await assignedTask(true), referencedProjectId = randomUUID();
await db.insert(projects).values({ id: referencedProjectId, companyId: f.companyId, name: "Skill sources" });
const [repository] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: referencedProjectId,
name: "Skills", sourceType: "git_repo", repoUrl: "https://example.test/team/skills.git" }).returning();
await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
body: `Write a skill in [@Skill sources](${buildProjectMentionHref(referencedProjectId)}).` });
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", repositories: [
{ id: repository.id, projectId: referencedProjectId, repoUrl: "https://example.test/team/skills.git" },
] } });
expect(result.authority === "remote" && result.source.repositories).toHaveLength(1);
expect(result.authority === "remote" && result.source.repositories![0]!.readOnly).not.toBe(true);
});
it.each([null, "feature"])("uses a referenced repository's default ref unless overridden by %s", async repoRef => {
const f = await fixture(true);
await db.update(projectWorkspaces).set({ defaultRef: "release", repoRef }).where(eq(projectWorkspaces.projectId, f.projectId));
const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
projectId: null, projectWorkspaceId: null, taskDirectory: true });
expect(result).toMatchObject({ authority: "remote", source: { repositories: [{ ref: repoRef ?? "release" }] } });
});
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_source_unavailable", projectId: f.projectId },
});
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("attaches a repository when a source-less project 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)}).` });
const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
projectId: null, projectWorkspaceId: null, taskDirectory: true });
expect(result).toMatchObject({ authority: "remote", source: { repositories: [
{ projectId: f.projectId, repoUrl: "https://example.test/team/repository.git" },
] }, referencedProjectFailures: [{ projectId: metadataProjectId, reason: "resolution" }] });
expect(result.authority === "remote" && result.source.repositories).toHaveLength(1);
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("does not attach a project outside the run's low-trust boundary", async () => {
const f = await fixture(true);
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId, projectIds: [] } } };
await db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, f.issueId));
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
const result = await resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
projectId: null, projectWorkspaceId: null, taskDirectory: true });
expect(result).toMatchObject({ authority: "remote", source: { kind: "empty" },
referencedProjectFailures: [{ projectId: f.projectId, reason: "authorization" }] });
expect(result.authority === "remote" && result.source.repositories).toBeUndefined();
});
it("does not apply a referenced project's restricted trust policy to an ordinary task", async () => {
const f = await fixture(true);
await db.update(projects).set({ executionWorkspacePolicy: { trustPreset: "low_trust_review", authorizationPolicy: {
trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId: f.companyId,
rootIssueId: f.issueId, projectIds: [f.projectId] },
} } }).where(eq(projects.id, f.projectId));
await expect(resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
projectId: null, projectWorkspaceId: null, taskDirectory: true })).rejects.toMatchObject({ status: 403 });
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]);
});
it.each(["saved", "read-only", "foreign-project", "restricted-trust", "low-trust-boundary"])("preserves %s repository authority on a later turn", async scenario => {
const f = await fixture(true);
const [repository] = await db.select().from(projectWorkspaces).where(eq(projectWorkspaces.projectId, f.projectId));
const savedRepository = { id: repository.id, projectWorkspaceId: repository.id, projectId: f.projectId,
relativePath: ".paperclip-repositories/saved-skills", repoUrl: repository.repoUrl!, ref: "saved-branch",
pinnedCommit: "a".repeat(40), ...(scenario === "read-only" ? { readOnly: true } : {}) };
const source = { kind: "empty" as const, repositories: [savedRepository] };
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId,
sourceIssueId: f.issueId, mode: "isolated_workspace", strategyType: "project_primary",
name: "Saved task copies", providerType: "cloud_sandbox", cwd: "/workspaces/saved",
metadata: { remoteWorkspace: { schema: "paperclip.remote-workspace.v1", epoch: 2, snapshotId: randomUUID(),
source, location: null, writerRunId: null, dirty: false } },
}).returning();
await db.update(issues).set({ executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
if (scenario === "foreign-project") {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Other company", issuePrefix: companyId.slice(0, 8) });
await db.update(projects).set({ companyId }).where(eq(projects.id, f.projectId));
} else {
await db.update(projectWorkspaces).set({ repoRef: "new-default" }).where(eq(projectWorkspaces.id, repository.id));
await db.delete(issueComments).where(eq(issueComments.issueId, f.issueId));
}
if (scenario === "restricted-trust") {
await db.update(projects).set({ executionWorkspacePolicy: { trustPreset: "low_trust_review", authorizationPolicy: {
trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId: f.companyId,
rootIssueId: f.issueId, projectIds: [f.projectId] },
} } }).where(eq(projects.id, f.projectId));
} else if (scenario === "low-trust-boundary") {
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId, projectIds: [] } } };
await db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, f.issueId));
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
}
const resolve = () => resolveRemoteWorkspaceSource(db, { ...f, responsibleUserId: null,
projectId: null, projectWorkspaceId: null, existingWorkspace: toExecutionWorkspace(workspace) });
if (scenario === "saved") {
const result = await resolve();
expect(result).toMatchObject({ authority: "remote", source });
expect(result.authority === "remote" && result.source.repositories).toHaveLength(1);
} else if (scenario === "read-only") {
await expect(resolve()).rejects.toMatchObject({
resultJson: { reason: "remote_workspace_readonly_sources_unsupported" },
});
} else if (scenario === "low-trust-boundary") {
await expect(resolve()).rejects.toMatchObject({ status: 403, message: "Project is outside this low-trust boundary." });
} else await expect(resolve()).rejects.toMatchObject({
code: "workspace_validation_failed",
message: "A saved project folder is no longer accessible or its trust policy no longer permits this task.",
resultJson: { reason: "remote_workspace_source_access_denied", projectId: f.projectId },
});
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspace.id)))[0]).toEqual(workspace);
});
});