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:
DottaandPaperclip authored and GitHub committed 2026-10-31 20:19:28 -05:00
1 parent 66e8fab944
commit 756599e4ea
3 files changed
+145 -2

No files matched your search

+7
View File
@@ -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,