mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
## 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>
158 lines
10 KiB
TypeScript
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);
|
|
}
|