Files
PaperClipAI/server/src/services/heartbeat/remote-workspace-source.ts
T
DottaandPaperclip 756599e4ea 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>
2026-10-31 20:19:28 -05:00

158 lines
10 KiB
TypeScript

import { asc, and, eq } from "drizzle-orm";
import { 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";
import { issueService } from "../issues.js";
import { projectService } from "../projects.js";
import { authorizationService } from "../authorization.js";
import { readRemoteWorkspaceAuthority, type RemoteWorkspaceSource } from "../remote-workspaces.js";
import { isMultiProjectWorkspaceSyncEnabled, isMultiProjectWorkspaceSyncRemoteEnabled,
resolveRunReferencedProjects, WorkspaceValidationFailure, type ResolvedWorkspaceForRun } from "./workspaces.js";
import type { HeartbeatWorkspacePreparationInput } from "./workspace-preparation.js";
function remoteRepositorySource(repoUrl: string): boolean {
const value = repoUrl.trim();
if (!value || /[\x00-\x1f\x7f]/.test(value)) throw new WorkspaceValidationFailure(
"The repository source is invalid.", { reason: "remote_workspace_source_invalid" });
if (/^[a-z]:/i.test(value) || /^file:/i.test(value)) return false;
try {
const parsed = new URL(value);
if (["https:", "http:", "ssh:", "git:", "git+ssh:"].includes(parsed.protocol) && parsed.hostname) return true;
} catch { /* Git also accepts scp-style SSH sources and local paths. */ }
if (!value.includes("://") && /^(?:[^@:/\s]+@)?(?:\[[a-f\d:]+\]|[a-z\d.-]+):[^:\s][^\r\n]*$/i.test(value)) return true;
if (!/^[a-z][a-z\d+.-]*:/i.test(value)) return false;
throw new WorkspaceValidationFailure("The repository source is invalid.", { reason: "remote_workspace_source_invalid" });
}
type RemoteWorkspaceSourceResolution = { authority: "remote"; source: RemoteWorkspaceSource;
projectId: string | null; projectWorkspaceId: string | null; warnings?: string[];
referencedProjectFailures?: ResolvedWorkspaceForRun["referencedProjectFailures"] } | { authority: "host" };
/** Resolve an authorized source without materializing any controller directory. */
export async function resolveRemoteWorkspaceSource(db: Db, input: {
companyId: string;
issueId: string;
agentId: string;
runId: string;
responsibleUserId: string | null;
projectId: string | null;
projectWorkspaceId: string | null;
selection?: TaskWorkspaceIntent | null;
existingWorkspace?: HeartbeatWorkspacePreparationInput["reuse"]["existingExecutionWorkspace"];
taskDirectory?: boolean;
configuredCwd?: string | null;
automaticEnrollment?: boolean;
ref?: string | null;
branchName?: string | null;
branchMode?: "create" | "existing";
}): Promise<RemoteWorkspaceSourceResolution> {
const selected = await assertTaskWorkspaceExecutionAccess(db, {
type: "agent", source: "agent_jwt", companyId: input.companyId,
agentId: input.agentId, runId: input.runId,
onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId,
}, input.companyId, { issueId: input.issueId,
...(!input.selection && input.existingWorkspace ? { workspaceId: input.existingWorkspace.id } : {}),
...(input.selection ? { persistedSelection: input.selection.selection } : {}),
});
const hostSource = (): { authority: "host" } => {
if (input.automaticEnrollment) return { authority: "host" };
throw new WorkspaceValidationFailure("Import local repository folders before using them as remote workspace sources.",
{ reason: "remote_workspace_local_source_requires_import" });
};
const withReferences = async (base: RemoteWorkspaceSource, anchorProjectId: string | null,
sourceId: string | null, savedSource = false): Promise<RemoteWorkspaceSourceResolution> => {
const extra: NonNullable<RemoteWorkspaceSource["repositories"]> =
(base.repositories ?? []).filter(repository => !repository.readOnly);
// A verified saved archive owns these files after import. Its original Git
// source remains an identity and need not be reachable from the new sandbox.
if (!savedSource) {
if (base.kind === "git" && !remoteRepositorySource(base.repoUrl)) return hostSource();
for (const repository of extra) if (!remoteRepositorySource(repository.repoUrl)) return hostSource();
}
const referenced = isMultiProjectWorkspaceSyncEnabled() ? await resolveRunReferencedProjects(input.issueId, anchorProjectId, {
companyId: input.companyId,
actor: { type: "agent", source: "agent_jwt", companyId: input.companyId,
agentId: input.agentId, runId: input.runId,
onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId },
issues: issueService(db), projects: projectService(db), access: authorizationService(db),
}) : { additional: [], warnings: [], failures: [] };
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.
// They cannot enforce a separate read-only filesystem root for a project.
throw new WorkspaceValidationFailure("This remote runtime cannot safely provide read-only project folders.",
{ reason: "remote_workspace_readonly_sources_unsupported" });
}
return { authority: "remote", source: { ...base, ...(extra.length ? { repositories: extra } : { repositories: undefined }) },
projectId: anchorProjectId, projectWorkspaceId: sourceId,
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,
input.existingWorkspace!.projectWorkspaceId, existing.snapshotId !== null);
if (input.existingWorkspace && input.automaticEnrollment) return { authority: "host" };
if (input.existingWorkspace) throw new WorkspaceValidationFailure(
"The existing workspace must be imported before it can use remote workspace storage.",
{ reason: "remote_workspace_migration_required" });
if (input.taskDirectory || input.selection?.selection.kind === "task_directory") {
return withReferences({ kind: "empty" }, null, null);
}
if (input.configuredCwd) return hostSource();
const projectId = selected.task.projectId;
const sourceId = input.selection?.selection.kind === "configured_source"
? input.selection.selection.projectWorkspaceId : selected.source?.id ?? selected.task.projectWorkspaceId;
const [source] = sourceId ? await db.select().from(projectWorkspaces).where(and(
eq(projectWorkspaces.id, sourceId), eq(projectWorkspaces.companyId, input.companyId),
)) : projectId ? await db.select().from(projectWorkspaces).where(and(
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(
"The selected project has no repository source for a remote workspace.",
{ reason: "remote_workspace_source_unavailable" });
return withReferences({ kind: "empty" }, null, null);
}
// Recheck authorization against the concrete configured source; the earliest
// project default may have been selected after the initial task read.
await assertTaskWorkspaceExecutionAccess(db, {
type: "agent", source: "agent_jwt", companyId: input.companyId,
agentId: input.agentId, runId: input.runId,
onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId,
}, input.companyId, { issueId: input.issueId,
...(selected.source ? { projectWorkspaceId: source.id } : {}),
});
if (source.sourceType === "remote_managed") return hostSource();
if (source.cwd && source.cwd !== "/__paperclip_repo_only__") return hostSource();
if (!source.repoUrl) throw new WorkspaceValidationFailure(
"The selected workspace has no repository URL.", { reason: "remote_workspace_source_unavailable" });
if (!remoteRepositorySource(source.repoUrl)) return hostSource();
const sameProject = await db.select().from(projectWorkspaces).where(and(
eq(projectWorkspaces.companyId, input.companyId), eq(projectWorkspaces.projectId, source.projectId),
)).orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const repositories: NonNullable<RemoteWorkspaceSource["repositories"]> = [];
const identities = new Set([projectRepositoryIdentity(source.repoUrl)]);
for (const repository of sameProject) {
if (!repository.repoUrl || identities.has(projectRepositoryIdentity(repository.repoUrl))) continue;
if (repository.sourceType === "remote_managed") return hostSource();
if (repository.cwd && repository.cwd !== "/__paperclip_repo_only__") return hostSource();
if (!remoteRepositorySource(repository.repoUrl)) return hostSource();
identities.add(projectRepositoryIdentity(repository.repoUrl));
repositories.push({ id: repository.id, relativePath: `.paperclip-repositories/${managedProjectCheckoutName({
id: repository.id, repoUrl: repository.repoUrl, repoRef: repository.repoRef,
})}`, repoUrl: repository.repoUrl, ref: repository.repoRef, projectId: source.projectId,
projectWorkspaceId: repository.id });
}
return withReferences({ kind: "git", repoUrl: source.repoUrl,
ref: input.ref ?? source.repoRef ?? source.defaultRef ?? null,
...(input.branchName ? { branchName: input.branchName } : {}),
...(input.branchMode ? { branchMode: input.branchMode } : {}),
...(repositories.length ? { repositories } : {}),
}, source.projectId, source.id);
}