diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 54085a9f195..0151a641fda 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -868,6 +868,8 @@ Agent, project, environment, secret, skill, and workspace config edits are sampl When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details. +An explicit `reuse_existing` Git workspace keeps its recorded source repository, including after its project-workspace row is deleted or the project primary changes. The server verifies the original company, project, repository origin, and worktree registration before reuse. It does not attach a deleted association to the new primary. A conflicting explicit project-workspace selection, missing source identity, or an unverified legacy local checkout blocks the run. Restore the original source. Before intentionally clearing the existing-workspace binding to choose a new repository, review and preserve its retained work. Missing worktrees may be reconstructed only from the verified original source; current project defaults are not a fallback. + ## Workspace Git Scan Protection Paperclip applies one process-wide scheduler to expensive host-side workspace Git enumeration, including changed-file browsing, runtime/finalization cleanliness guards, and adapter sandbox-sync snapshots. The scheduler defaults to two active scans and a bounded queue of 32. Identical buffered scans of the same canonical worktree share one subprocess, while successful changed-file listings are cached for 10 seconds. Streaming snapshot scans have caller-owned sinks, so they use separate jobs in the same queue and are never cached or coalesced. Correctness-sensitive runtime guards bypass the result cache. diff --git a/doc/observability.md b/doc/observability.md index 0d55d0fe8b7..2114c16eb79 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -584,6 +584,17 @@ from 1 through 255. No paths, repository or branch names, IDs, command output, o arbitrary messages enter these fields. Validation still blocks reuse; it does not repair Git metadata, change the selected repository, or retry the task. +For explicit reuse of a retained Git workspace, `persisted_workspace_source_conflict` +uses the same two fields with the fixed reason codes `source_scope_mismatch`, +`explicit_project_workspace_conflict`, `source_path_unproven`, +`source_registration_unproven`, `source_repository_mismatch`, or +`source_repository_unavailable`. These distinguish an unverified original source +from a missing Git registration. Paths, repository URLs, and workspace IDs stay +out of the diagnostic fields. The task remains blocked until its original source +is available or the owner intentionally selects a different workspace. A failed +Git registration probe retains `git_inspection_failed` and the same bounded +inspection fields, including when it occurs during original-source selection. + When available, the saved `workspaceRestoreDiagnostic` adds the bounded fields `workspaceRestorePhase`, `workspaceRestoreStep`, `workspaceRestoreErrorCode`, `workspaceRestoreHttpStatus`, and `workspaceRestoreExitCode` to `run_execution`. diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index 6ccec0f02e3..9759f5a995e 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -1,9 +1,10 @@ import { execFile } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; import { and, eq, inArray } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { @@ -15,6 +16,7 @@ import { companies, companySkills, createDb, + closeRegisteredClients, documentRevisions, documents, environmentLeases, @@ -37,7 +39,8 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js"; -import { heartbeatService } from "../services/heartbeat.ts"; +import { buildEffectiveRunWorkspaceConfigMetadata, heartbeatService } from "../services/heartbeat.ts"; +import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/index.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; import { @@ -85,7 +88,7 @@ function fingerprintWorkspaceBranchIncoherenceForTest(input: { } const adapterExecute = vi.hoisted(() => - vi.fn(async () => ({ + vi.fn(async (_input?: unknown) => ({ exitCode: 0, signal: null, timedOut: false, @@ -109,7 +112,8 @@ vi.mock("../adapters/index.js", () => ({ runningProcesses: new Map(), })); -const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim(); +const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; if (!embeddedPostgresSupport.supported) { @@ -871,8 +875,11 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { const tempRoots: string[] = []; beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("paperclip-branch-containment-"); - db = createDb(tempDb.connectionString); + if (externalTestDatabaseUrl) db = createDb(externalTestDatabaseUrl); + else { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-branch-containment-"); + db = createDb(tempDb.connectionString); + } }, 20_000); afterEach(async () => { @@ -921,13 +928,136 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { await db.delete(environments); await db.delete(companySkills); await db.delete(companies); + vi.unstubAllEnvs(); }); afterAll(async () => { + if (externalTestDatabaseUrl) await closeRegisteredClients(externalTestDatabaseUrl); await db.$client.end(); await tempDb?.cleanup(); }, 60_000); + async function seedRetainedSourceRun(kind: "deleted" | "explicit_conflict" | "foreign_project" | "foreign_company" | "unproven") { + const home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-retained-source-home-"))); + tempRoots.push(home); + vi.stubEnv("PAPERCLIP_HOME", home); + const original = await createGitRepo(); + const replacement = await createGitRepo(); + tempRoots.push(original, replacement); + const companyId = randomUUID(), projectId = randomUUID(), oldWorkspaceId = randomUUID(), newWorkspaceId = randomUUID(); + const agentId = randomUUID(), issueId = randomUUID(), executionWorkspaceId = randomUUID(), runId = randomUUID(), wakeupId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const repoUrl = pathToFileURL(original).href; + const replacementUrl = pathToFileURL(replacement).href; + const owner = kind === "unproven" ? original : resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: path.basename(original) }); + if (owner !== original) { + await mkdir(path.dirname(owner), { recursive: true }); + await runGit(original, ["clone", repoUrl, owner]); + } + const branch = "retained-task"; + const worktree = path.join(owner, ".paperclip", "worktrees", branch); + await runGit(owner, ["worktree", "add", "-b", branch, worktree]); + await writeFile(path.join(worktree, "retained.txt"), "unexported original task edits\n"); + await writeFile(path.join(replacement, "replacement.txt"), "replacement source must stay untouched\n"); + const snapshot = { provisionCommand: null, runtimeProvisionCommand: null }; + const fingerprint = buildEffectiveRunWorkspaceConfigMetadata({ + mode: "isolated_workspace", projectId, projectWorkspaceId: oldWorkspaceId, strategyType: "git_worktree", + workspaceStrategy: { type: "git_worktree", baseRef: "HEAD" }, repoUrl, repoRef: "HEAD", configSnapshot: snapshot, + environment: null, realization: null, + }); + const metadata = { + createdByRuntime: false, config: snapshot, + configFingerprint: { version: fingerprint.version, workspaceHash: fingerprint.fingerprint, categories: fingerprint.categories, + categoryFingerprints: fingerprint.categoryFingerprints, lastEvaluatedAt: fingerprint.evaluatedAt }, + }; + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + await db.insert(companies).values({ id: companyId, name: "Retained source", issuePrefix, status: "active", defaultResponsibleUserId: "responsible-user" }); + await db.insert(projects).values({ id: projectId, companyId, name: "Repository replacement", status: "active", + executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "HEAD" } } }); + await db.insert(projectWorkspaces).values([ + { id: oldWorkspaceId, companyId, projectId, name: "Original", cwd: owner, repoUrl: kind === "unproven" ? null : repoUrl, isPrimary: true }, + { id: newWorkspaceId, companyId, projectId, name: "Replacement", cwd: replacement, repoUrl: replacementUrl, isPrimary: false }, + ]); + await db.insert(agents).values({ id: agentId, companyId, name: "Source tester", role: "engineer", status: "idle", adapterType: "codex_local", + adapterConfig: {}, runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } }, permissions: {} }); + await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, projectId, projectWorkspaceId: oldWorkspaceId, + mode: "isolated_workspace", strategyType: "git_worktree", name: branch, status: "active", cwd: worktree, providerRef: worktree, + providerType: "git_worktree", branchName: branch, repoUrl: kind === "unproven" ? null : repoUrl, baseRef: "HEAD", metadata }); + await db.insert(agentWakeupRequests).values({ id: wakeupId, companyId, agentId, source: "assignment", triggerDetail: "system", + reason: "issue_assigned", payload: { issueId }, status: "queued", runId }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "assignment", triggerDetail: "system", status: "queued", + wakeupRequestId: wakeupId, responsibleUserId: "responsible-user", contextSnapshot: { issueId, taskId: issueId, wakeReason: "issue_assigned" } }); + await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId: oldWorkspaceId, title: "Continue retained work", + status: "in_progress", workMode: "standard", assigneeAgentId: agentId, responsibleUserId: "responsible-user", issueNumber: 1, + identifier: `${issuePrefix}-1`, executionWorkspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: "isolated_workspace" }, + checkoutRunId: runId, executionRunId: runId, executionAgentNameKey: "source-tester", executionLockedAt: new Date() }); + await db.update(executionWorkspaces).set({ sourceIssueId: issueId }).where(eq(executionWorkspaces.id, executionWorkspaceId)); + // Exercise the real FK deletion semantics, rather than manufacturing nulls. + await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, oldWorkspaceId)); + await db.update(projectWorkspaces).set({ isPrimary: true }).where(eq(projectWorkspaces.id, newWorkspaceId)); + if (kind === "explicit_conflict") await db.update(issues).set({ projectWorkspaceId: newWorkspaceId }).where(eq(issues.id, issueId)); + if (kind === "foreign_project" || kind === "foreign_company") { + const foreignCompanyId = kind === "foreign_company" ? randomUUID() : companyId; + if (foreignCompanyId !== companyId) await db.insert(companies).values({ id: foreignCompanyId, name: "Other company", issuePrefix: `F${foreignCompanyId.slice(0, 7)}` }); + const foreignProjectId = randomUUID(); + await db.insert(projects).values({ id: foreignProjectId, companyId: foreignCompanyId, name: "Other project", status: "active" }); + await db.update(executionWorkspaces).set({ companyId: foreignCompanyId, projectId: foreignProjectId }).where(eq(executionWorkspaces.id, executionWorkspaceId)); + } + return { companyId, projectId, newWorkspaceId, issueId, agentId, runId, executionWorkspaceId, worktree, branch, owner, replacement, repoUrl, metadata }; + } + + it("preserves a retained task source after its project repository is replaced and the original FK is deleted", async () => { + const seeded = await seedRetainedSourceRun("deleted"); + const heartbeat = heartbeatService(db); + await heartbeat.resumeQueuedRuns(); + const run = await waitForRunToFinish(heartbeat, seeded.runId); + await heartbeat.waitForRunExecutionDrain(seeded.runId); + expect(run).toMatchObject({ status: "succeeded" }); + expect(adapterExecute).toHaveBeenCalledTimes(1); + expect(readAdapterWorkspace(adapterExecute.mock.calls[0]?.[0])).toEqual({ cwd: seeded.worktree, branchName: seeded.branch, executionWorkspaceId: seeded.executionWorkspaceId }); + expect(adapterExecute.mock.calls[0]?.[0]).toMatchObject({ context: { paperclipWorkspace: { + projectId: seeded.projectId, workspaceId: null, repoUrl: seeded.repoUrl, repoRef: "HEAD", source: "task_session", + } } }); + const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId)); + expect(workspace).toMatchObject({ companyId: seeded.companyId, projectId: seeded.projectId, projectWorkspaceId: null, + cwd: seeded.worktree, providerRef: seeded.worktree, branchName: seeded.branch, repoUrl: seeded.repoUrl, metadata: seeded.metadata }); + expect(issue).toMatchObject({ projectWorkspaceId: null, executionWorkspaceId: seeded.executionWorkspaceId, executionWorkspacePreference: "reuse_existing" }); + expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, seeded.companyId))).toHaveLength(1); + expect(run?.resultJson).toMatchObject({ configFreshness: { workspace: { action: "replace", reuseRequested: true, workspaceReused: true, configSnapshotRefreshed: false, + changedCategories: expect.arrayContaining(["projectWorkspace", "repo"]) } } }); + expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n"); + expect(await readFile(path.join(seeded.replacement, "replacement.txt"), "utf8")).toBe("replacement source must stay untouched\n"); + expect(await readGit(seeded.worktree, ["branch", "--show-current"])).toBe(seeded.branch); + expect(await readGit(seeded.owner, ["worktree", "list", "--porcelain"])).toContain(`worktree ${seeded.worktree}`); + }); + + it.each(["explicit_conflict", "foreign_project", "foreign_company", "unproven"] as const)( + "does not dispatch or rebind a retained source with %s authority", async (kind) => { + const seeded = await seedRetainedSourceRun(kind); + const heartbeat = heartbeatService(db); + await heartbeat.resumeQueuedRuns(); + const run = await waitForRunToFinish(heartbeat, seeded.runId); + await heartbeat.waitForRunExecutionDrain(seeded.runId); + expect(run).toMatchObject({ status: "failed", errorCode: "workspace_validation_failed" }); + expect(run?.resultJson).toMatchObject({ workspaceValidation: { reason: "persisted_workspace_source_conflict", + reasonCode: kind === "explicit_conflict" ? "explicit_project_workspace_conflict" + : kind === "unproven" ? "source_registration_unproven" : "source_scope_mismatch" } }); + expect(adapterExecute).not.toHaveBeenCalled(); + const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + expect(workspace).toMatchObject({ projectWorkspaceId: null, cwd: seeded.worktree, providerRef: seeded.worktree, branchName: seeded.branch, metadata: seeded.metadata }); + const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId)); + expect(issue).toMatchObject({ executionWorkspaceId: seeded.executionWorkspaceId, + projectWorkspaceId: kind === "explicit_conflict" ? seeded.newWorkspaceId : null }); + // Intentionally malformed cross-company/project FKs also prevent the + // existing issue-update service from changing status. The source gate + // still must fail before dispatch and may never silently repair that FK. + if (kind === "explicit_conflict" || kind === "unproven") expect(issue.status).toBe("blocked"); + expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n"); + expect(await readFile(path.join(seeded.replacement, "replacement.txt"), "utf8")).toBe("replacement source must stay untouched\n"); + }, + ); + it("blocks projectless isolated git-worktree issues before dispatch", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/persisted-workspace-source.test.ts b/server/src/__tests__/persisted-workspace-source.test.ts new file mode 100644 index 00000000000..819f95b43ed --- /dev/null +++ b/server/src/__tests__/persisted-workspace-source.test.ts @@ -0,0 +1,296 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolvePersistedGitWorkspaceSource } from "../services/persisted-workspace-source.js"; + +const exec = promisify(execFile); +const roots: string[] = []; +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +async function fixture() { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "persisted-source-"))); + roots.push(root); + const original = path.join(root, "original"); + const replacement = path.join(root, "replacement"); + const worktree = path.join(root, "task"); + for (const repo of [original, replacement]) { + await exec("git", ["init", repo]); + await exec("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "initial"]); + await exec("git", ["-C", repo, "remote", "add", "origin", `https://example.invalid/${path.basename(repo)}.git`]); + } + await exec("git", ["-C", original, "worktree", "add", "-b", "task", worktree]); + await fs.writeFile(path.join(worktree, "retained.txt"), "keep this work\n"); + const workspace = { + id: "execution-workspace", companyId: "company", projectId: "project", projectWorkspaceId: null, + repoUrl: "https://example.invalid/original.git", baseRef: "HEAD", cwd: worktree, providerRef: worktree, + }; + return { root, original, replacement, worktree, workspace, input: { + companyId: "company", projectId: "project", explicitProjectWorkspaceId: null, + workspace, boundProjectWorkspace: null, candidateBaseCwds: [original], managedSourceRoot: root, + } }; +} + +describe("persisted Git workspace source", () => { + it("keeps the original registered source after its project binding is deleted", async () => { + const { original, replacement, worktree, input } = await fixture(); + const source = await resolvePersistedGitWorkspaceSource(input); + expect(source).toBe(original); + expect(source).not.toBe(replacement); + expect(input.workspace.projectWorkspaceId).toBeNull(); + expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n"); + }); + + it.each(["companyId", "projectId"] as const)("rejects a foreign %s before source inspection or materialization", async field => { + const { input } = await fixture(); + const materializeOriginalRepository = vi.fn(); + await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, [field]: "foreign" }, + materializeOriginalRepository })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } }); + expect(materializeOriginalRepository).not.toHaveBeenCalled(); + }); + + it("rejects an explicit new project selection without rebinding the old task source", async () => { + const { input } = await fixture(); + await expect(resolvePersistedGitWorkspaceSource({ ...input, explicitProjectWorkspaceId: "replacement" })).rejects.toMatchObject({ + code: "workspace_validation_failed", resultJson: { workspaceValidation: { reasonCode: "explicit_project_workspace_conflict" } }, + }); + }); + + it.each([null, { id: "bound", companyId: "foreign", projectId: "project", cwd: null }, + { id: "bound", companyId: "company", projectId: "foreign", cwd: null }])("rejects an unverified non-null project binding (%j)", async binding => { + const { input } = await fixture(); + await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, projectWorkspaceId: "bound" }, + boundProjectWorkspace: binding })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } }); + }); + + it("rejects a supplied source binding when the saved association is null", async () => { + const { original, input } = await fixture(); + await expect(resolvePersistedGitWorkspaceSource({ ...input, + boundProjectWorkspace: { id: "foreign-binding", companyId: "company", projectId: "project", cwd: original }, + })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } }); + }); + + it("does not trust another Git owner just because its origin matches", async () => { + const { original, replacement, input } = await fixture(); + await exec("git", ["-C", replacement, "remote", "set-url", "origin", input.workspace.repoUrl]); + await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement] })).rejects.toMatchObject({ + resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } }, + }); + expect(original).not.toBe(replacement); + }); + + it("uses the stored origin identity without transport-only URL rewrites", async () => { + const { original, input } = await fixture(); + await exec("git", ["-C", original, "config", "url.ssh://git@example.invalid/.insteadOf", "https://example.invalid/"]); + expect((await exec("git", ["-C", original, "remote", "get-url", "origin"])).stdout.trim()).not.toBe(input.workspace.repoUrl); + expect(await resolvePersistedGitWorkspaceSource(input)).toBe(original); + }); + + it("rejects a symlink that redirects a managed source into another repository", async () => { + const { root, original, input } = await fixture(); + const alias = path.join(root, "scoped", "original"); + await fs.mkdir(path.dirname(alias)); + await fs.symlink(original, alias); + await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [alias] })).rejects.toMatchObject({ + resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } }, + }); + }); + + it("rejects multiple origin URLs even if the last one matches the saved repository", async () => { + const { original, input } = await fixture(); + await exec("git", ["-C", original, "remote", "set-url", "origin", "https://example.invalid/other.git"]); + await exec("git", ["-C", original, "config", "--add", "remote.origin.url", input.workspace.repoUrl]); + await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: { workspaceValidation: { + reasonCode: "source_repository_mismatch", + } } }); + }); + + it("rejects changed origin identity even at the recorded managed path", async () => { + const { original, input } = await fixture(); + await exec("git", ["-C", original, "remote", "set-url", "origin", "https://example.invalid/other.git"]); + await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: { workspaceValidation: { + reasonCode: "source_repository_mismatch", + } } }); + }); + + it("refuses an independent clone masquerading as the selected linked worktree", async () => { + const { root, original, input } = await fixture(); + const clone = path.join(root, "clone"); + await exec("git", ["clone", original, clone]); + await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, cwd: clone, providerRef: clone }, + candidateBaseCwds: [clone] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } }); + }); + + it("refuses a primary checkout whose separate Git directory impersonates a managed base", async () => { + const { root, original, input } = await fixture(); + const fakeOwner = path.join(root, "fake-owner"); + const clone = path.join(root, "separate-checkout"); + await fs.mkdir(fakeOwner); + await exec("git", ["clone", "--separate-git-dir", path.join(fakeOwner, ".git"), original, clone]); + await exec("git", ["-C", clone, "remote", "set-url", "origin", input.workspace.repoUrl]); + // This shape can appear in a complete worktree list, but has no per-worktree + // storage under the owner's .git/worktrees directory. + await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, cwd: clone, providerRef: clone }, + candidateBaseCwds: [fakeOwner] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } }); + }); + + it("preserves registered local sources whose configured cwd is a repository subfolder", async () => { + const { original, input } = await fixture(); + const cwd = path.join(original, "subfolder"); + await fs.mkdir(cwd); + expect(await resolvePersistedGitWorkspaceSource({ ...input, + workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [], + boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd }, + })).toBe(original); + }); + + it("supports a bound local repository with separate Git storage", async () => { + const { root, input } = await fixture(); + const owner = path.join(root, "separate-owner"); + const storage = path.join(root, "git-storage"); + const task = path.join(root, "separate-task"); + await exec("git", ["init", "--separate-git-dir", storage, owner]); + await exec("git", ["-C", owner, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "initial"]); + await exec("git", ["-C", owner, "worktree", "add", "-b", "retained", task]); + expect(await resolvePersistedGitWorkspaceSource({ ...input, + workspace: { ...input.workspace, repoUrl: null, cwd: task, providerRef: task, projectWorkspaceId: "bound" }, candidateBaseCwds: [], + boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: owner }, + })).toBe(owner); + }); + + it("does not reconstruct a missing local worktree from its binding's replacement cwd", async () => { + const { original, replacement, worktree, input } = await fixture(); + await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]); + await expect(resolvePersistedGitWorkspaceSource({ ...input, + workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [], + boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: replacement }, + })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } }); + }); + + it("fails closed when a legacy local source lost its only project association", async () => { + const { input } = await fixture(); + await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, repoUrl: null }, + candidateBaseCwds: [] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } }); + }); + + it("rejects a changed bound local cwd instead of adopting its new repository", async () => { + const { replacement, input } = await fixture(); + await expect(resolvePersistedGitWorkspaceSource({ ...input, + workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [], + boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: replacement }, + })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } }); + }); + + it("materializes only the recorded original source when both managed checkout and worktree are absent", async () => { + const { root, original, input } = await fixture(); + const restored = path.join(root, "restored-source"); + const missingWorktree = path.join(root, "missing-worktree"); + const materializeOriginalRepository = vi.fn(async () => { + await exec("git", ["clone", original, restored]); + await exec("git", ["-C", restored, "remote", "set-url", "origin", input.workspace.repoUrl]); + return restored; + }); + expect(await resolvePersistedGitWorkspaceSource({ ...input, + workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree }, + candidateBaseCwds: [restored], materializeOriginalRepository, + })).toBe(restored); + expect(materializeOriginalRepository).toHaveBeenCalledTimes(1); + }); + + it("rejects a materializer result outside the retained source candidates", async () => { + const { root, original, input } = await fixture(); + const missingWorktree = path.join(root, "missing-worktree"); + const materializeOriginalRepository = vi.fn(async () => original); + await expect(resolvePersistedGitWorkspaceSource({ ...input, + workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree }, + candidateBaseCwds: [path.join(root, "expected-clone")], materializeOriginalRepository, + })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } }); + expect(materializeOriginalRepository).toHaveBeenCalledTimes(1); + }); + + it("does not invoke materialization through a managed project symlink", async () => { + const { root, original, input } = await fixture(); + const scoped = path.join(root, "scoped"); + await fs.symlink(original, scoped); + const missingWorktree = path.join(root, "missing-worktree"); + const materializeOriginalRepository = vi.fn(async () => original); + await expect(resolvePersistedGitWorkspaceSource({ ...input, + workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree }, + candidateBaseCwds: [path.join(scoped, "new-clone")], materializeOriginalRepository, + })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } }); + expect(materializeOriginalRepository).not.toHaveBeenCalled(); + }); + + it.each([false, true])("keeps bounded Git inspection evidence before restoring a retained source (missing=%s)", async missing => { + const { root, original, worktree, input } = await fixture(); + if (missing) await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]); + const realGit = (await exec("/bin/sh", ["-c", "command -v git"])).stdout.trim(); + const bin = path.join(root, "bin"); + await fs.mkdir(bin); + await fs.writeFile(path.join(bin, "git"), `#!${process.execPath}\nconst args=process.argv.slice(2);\nif(args[0]==="worktree"&&args[1]==="list"){process.stderr.write("private-provider-output /private/path");process.exitCode=128;}else{const r=require("node:child_process").spawnSync(${JSON.stringify(realGit)},args,{stdio:"inherit"});process.exitCode=r.status??1;}\n`, { mode: 0o755 }); + vi.stubEnv("PATH", `${bin}${path.delimiter}${process.env.PATH ?? ""}`); + const materializeOriginalRepository = vi.fn(); + const failure = await resolvePersistedGitWorkspaceSource({ ...input, materializeOriginalRepository }).catch(error => error); + expect(failure).toMatchObject({ code: "workspace_validation_failed", resultJson: { workspaceValidation: { + reason: "git_worktree_not_reusable", reasonCode: "git_inspection_failed", + inspectionDiagnostic: { command: "worktree_list", failure: "nonzero_exit", exitCode: 128 }, + } } }); + expect(JSON.stringify(failure.resultJson)).not.toContain("private"); + expect(materializeOriginalRepository).not.toHaveBeenCalled(); + if (!missing) expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n"); + }); + + it("rebuilds a missing worktree only from the retained source, not a new primary", async () => { + const { original, replacement, worktree, input } = await fixture(); + await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]); + expect(await resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement, original] })).toBe(original); + await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement] })).rejects.toMatchObject({ + resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } }, + }); + }); + it("accepts a symlink for the configured instance root only", async () => { + const { root, original, input } = await fixture(); + const alias = `${root}-alias`; + roots.push(alias); + await fs.symlink(root, alias); + expect(await resolvePersistedGitWorkspaceSource({ ...input, managedSourceRoot: alias, + candidateBaseCwds: [path.join(alias, "original")] })).toBe(original); + }); + + it("mismatched saved paths never materialize another source", async () => { + const { replacement, input } = await fixture(); + const materializeOriginalRepository = vi.fn(); + await expect(resolvePersistedGitWorkspaceSource({ ...input, + workspace: { ...input.workspace, providerRef: replacement }, materializeOriginalRepository, + })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_path_unproven" } } }); + expect(materializeOriginalRepository).not.toHaveBeenCalled(); + }); + + it("a dangling saved-worktree symlink is not replaced", async () => { + const { root, input } = await fixture(); + const dangling = path.join(root, "dangling-task"); + await fs.symlink(path.join(root, "absent-target"), dangling); + const materializeOriginalRepository = vi.fn(); + await expect(resolvePersistedGitWorkspaceSource({ ...input, + workspace: { ...input.workspace, cwd: dangling, providerRef: dangling }, materializeOriginalRepository, + })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_path_unproven" } } }); + expect(materializeOriginalRepository).not.toHaveBeenCalled(); + expect((await fs.lstat(dangling)).isSymbolicLink()).toBe(true); + }); + + it("a substituted worktree backlink cannot authorize reuse", async () => { + const { original, worktree, input } = await fixture(); + const storage = (await exec("git", ["-C", worktree, "rev-parse", "--absolute-git-dir"])).stdout.trim(); + await fs.writeFile(path.join(storage, "gitdir"), path.join(original, ".git") + "\n"); + await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: { + workspaceValidation: { reasonCode: "source_registration_unproven" }, + } }); + expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n"); + }); + +}); diff --git a/server/src/services/__tests__/run-failure-diagnostics.test.ts b/server/src/services/__tests__/run-failure-diagnostics.test.ts index 751032c9a70..0bf0d2a9eb8 100644 --- a/server/src/services/__tests__/run-failure-diagnostics.test.ts +++ b/server/src/services/__tests__/run-failure-diagnostics.test.ts @@ -49,6 +49,30 @@ describe("run failure diagnostics", () => { expect(inspect({ ...base, inspectionDiagnostic: { command: "worktree_list", failure: "spawn_failed", errorCode: "EACCES", exitCode: 128 } })).toMatchObject({ workspaceValidationInspectionErrorCode: "EACCES" }); }); + it.each(["source_scope_mismatch", "explicit_project_workspace_conflict", "source_path_unproven", + "source_registration_unproven", "source_repository_mismatch", "source_repository_unavailable"])( + "exports only the fixed retained-source reason %s", reasonCode => { + const workspaceValidation = { reason: "persisted_workspace_source_conflict", reasonCode, + executionWorkspaceId: "private-id", repoUrl: "private-url", cwd: "/private-path", message: "private-message" }; + expect(collectRunFailureDiagnostics(run({ errorCode: "workspace_validation_failed", resultJson: { workspaceValidation } }), {}).execution) + .toEqual({ workspaceValidationReason: "persisted_workspace_source_conflict", workspaceValidationReasonCode: reasonCode }); + expect(collectRunFailureDiagnostics(run({ errorCode: "adapter_failed", resultJson: { workspaceValidation } }), {}).execution).toEqual({}); + }, + ); + + it("omits arbitrary retained-source reason strings and hostile getters", () => { + for (const reasonCode of ["private-url", Object.defineProperty({}, "value", { get() { throw new Error("private"); } })]) { + expect(collectRunFailureDiagnostics(run({ errorCode: "workspace_validation_failed", resultJson: { workspaceValidation: { + reason: "persisted_workspace_source_conflict", reasonCode, + } } }), {}).execution).toEqual({ workspaceValidationReason: "persisted_workspace_source_conflict" }); + } + const workspaceValidation = Object.defineProperty({ reason: "persisted_workspace_source_conflict" }, "reasonCode", { + get() { throw new Error("private"); }, + }); + expect(collectRunFailureDiagnostics(run({ errorCode: "workspace_validation_failed", resultJson: { workspaceValidation } }), {}).execution) + .toEqual({ workspaceValidationReason: "persisted_workspace_source_conflict" }); + }); + it("exports bounded orphan evidence only for a process-loss failure", () => { const processLossDiagnostic = { pidRecorded: false, groupRecorded: false, localCheck: "no_identifiers", retryEligible: false, runPredatesObserver: true, observerUptimeMs: 30_000, lastOutputAgeMs: 120_000, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 86dceae8c08..9c89809c661 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -434,6 +434,7 @@ import { type UnresolvedWorkspaceBaseRefError, sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; +import { resolvePersistedGitWorkspaceSource } from "./persisted-workspace-source.js"; import { readManagedWorktreeInstanceOwnership, WORKTREE_INSTANCE_ROOT_METADATA_KEY, @@ -3947,6 +3948,8 @@ export type ResolvedWorkspaceForRun = { materializationFailures: WorkspaceMaterializationFailure[]; /** True only for an explicitly configured local project path without a repository URL. */ localPathOnlyWorkspace?: boolean; + /** Current configuration for drift reporting; never an alternative restore source. */ + freshnessSource?: { projectId: string | null; workspaceId: string | null; repoUrl: string | null; repoRef: string | null }; /** * Read-only referenced (mentioned) project workspaces for this run, one per authorized * additional project. The array is empty unless the multi-project workspace-sync flag is on @@ -12601,6 +12604,85 @@ export function heartbeatService( }; } + async function resolveReusedGitWorkspaceAnchor(input: { + agent: typeof agents.$inferSelect; + workspace: ExecutionWorkspace; + projectId: string | null; + explicitProjectWorkspaceId: string | null; + issueId: string | null; + runId: string; + responsibleUserId: string | null; + immutableNativeBinding: boolean; + }): Promise { + const { workspace, agent } = input; + // Configuration preparation may have awaited credentials and skills since + // issueRef was read. Match the ordinary anchor resolver's fresh selection + // check; only an already-admitted native input owns immutable source scope. + const currentIssueSource = input.issueId && !input.immutableNativeBinding + ? await db.select({ projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId }).from(issues) + .where(and(eq(issues.id, input.issueId), eq(issues.companyId, agent.companyId))).then(rows => rows[0] ?? null) + : null; + const sourceProjectId = input.issueId && !input.immutableNativeBinding ? currentIssueSource?.projectId ?? null : input.projectId; + const explicitProjectWorkspaceId = currentIssueSource?.projectWorkspaceId ?? input.explicitProjectWorkspaceId; + const projectWorkspaceRows = await db.select().from(projectWorkspaces).where(and( + eq(projectWorkspaces.companyId, agent.companyId), + eq(projectWorkspaces.projectId, workspace.projectId), + )).orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); + const boundProjectWorkspace = projectWorkspaceRows.find(row => row.id === workspace.projectWorkspaceId) ?? null; + const configuredSource = prioritizeProjectWorkspaceCandidatesForRun(projectWorkspaceRows, explicitProjectWorkspaceId)[0]; + const managedBase = workspace.repoUrl ? resolveManagedProjectWorkspaceDir({ + companyId: agent.companyId, + projectId: workspace.projectId, + repoName: deriveRepoNameFromRepoUrl(workspace.repoUrl), + }) : null; + const candidateBaseCwds = [ + managedBase, + managedBase && workspace.repoUrl + ? `${managedBase}-${createHash("sha256").update(workspace.repoUrl).digest("hex").slice(0, 12)}` : null, + ].filter((value): value is string => Boolean(value)); + const cwd = await resolvePersistedGitWorkspaceSource({ + companyId: agent.companyId, + projectId: sourceProjectId, + explicitProjectWorkspaceId, + workspace, + boundProjectWorkspace, + candidateBaseCwds, + managedSourceRoot: resolvePaperclipInstanceRoot(), + materializeOriginalRepository: workspace.repoUrl ? async () => { + const original = await ensureManagedProjectWorkspace({ + companyId: agent.companyId, + projectId: workspace.projectId, + repoUrl: workspace.repoUrl, + resolveGitAuth: createGitRemoteAuthProvider(db, agent.companyId, { + issueId: input.issueId, heartbeatRunId: input.runId, agentId: agent.id, + responsibleUserId: input.responsibleUserId, + }), + }); + return original.cwd; + } : undefined, + }); + return { + cwd, + source: "task_session", + projectId: workspace.projectId, + workspaceId: workspace.projectWorkspaceId, + repoUrl: workspace.repoUrl, + repoRef: workspace.baseRef, + // Freshness must still expose drift in current project configuration. + // This snapshot authorizes no filesystem lookup or repository fallback. + freshnessSource: { + projectId: workspace.projectId, + workspaceId: configuredSource?.id ?? null, + repoUrl: configuredSource?.repoUrl ?? null, + repoRef: configuredSource?.repoRef ?? null, + }, + workspaceHints: [], + warnings: [], + baseCwdFallback: false, + materializationFailures: [], + }; + } + async function resolveAnchorWorkspaceForRun( agent: typeof agents.$inferSelect, context: Record, @@ -22288,6 +22370,22 @@ export function heartbeatService( { useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default", + anchorWorkspace: requestedShouldReuseExisting && reusableExistingExecutionWorkspace?.strategyType === "git_worktree" + ? await resolveReusedGitWorkspaceAnchor({ + agent, + workspace: reusableExistingExecutionWorkspace, + responsibleUserId, + immutableNativeBinding: Boolean(nativeRecoveryExecutionWorkspaceId), + projectId: nativeRecoveryExecutionWorkspaceId + ? reusableExistingExecutionWorkspace.projectId + : issueRef?.projectId ?? readNonEmptyString(context.projectId), + explicitProjectWorkspaceId: nativeRecoveryExecutionWorkspaceId + ? reusableExistingExecutionWorkspace.projectWorkspaceId + : readNonEmptyString(context.projectWorkspaceId), + issueId, + runId: run.id, + }) + : undefined, // Thread the selected environment driver so run-workspace resolution can tell a local // target from a remote one, and a confined sandbox target from an unconfined remote // target. A remote run resolves referenced projects only for the confined sandbox @@ -22371,17 +22469,18 @@ export function heartbeatService( trustPreset: trustPreset.kind, lowTrustSandboxDriver: lowTrustPreflightEnvironmentDriver, }; + const workspaceFreshnessSource = resolvedWorkspace.freshnessSource ?? executionWorkspaceBase; const latestWorkspaceConfigMetadata = buildEffectiveRunWorkspaceConfigMetadata({ mode: requestedExecutionWorkspaceMode, - projectId: executionWorkspaceBase.projectId, - projectWorkspaceId: executionWorkspaceBase.workspaceId, + projectId: workspaceFreshnessSource.projectId, + projectWorkspaceId: workspaceFreshnessSource.workspaceId, strategyType: latestWorkspaceStrategyType, workspaceStrategy: workspaceStrategyFingerprintValue, - repoUrl: executionWorkspaceBase.repoUrl, + repoUrl: workspaceFreshnessSource.repoUrl, repoRef: readNonEmptyString(workspaceStrategyForFingerprint.baseRef) ?? - executionWorkspaceBase.repoRef, + workspaceFreshnessSource.repoRef, configSnapshot, environment: workspaceEnvironmentFingerprint, realization: workspaceRealizationFingerprint, @@ -22560,7 +22659,9 @@ export function heartbeatService( executionProjectId ?? null; const resolvedProjectWorkspaceId = - issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; + resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree" + ? reusableExistingExecutionWorkspace.projectWorkspaceId + : issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; let persistedExecutionWorkspace: ExecutionWorkspace | null = null; let issueExecutionWorkspaceIdForRun = issueRef?.executionWorkspaceId ?? null; diff --git a/server/src/services/persisted-workspace-source.ts b/server/src/services/persisted-workspace-source.ts new file mode 100644 index 00000000000..b7b0983a542 --- /dev/null +++ b/server/src/services/persisted-workspace-source.ts @@ -0,0 +1,200 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import type { ExecutionWorkspace } from "@paperclipai/shared"; +import { inspectManagedGitWorktreeBranch, WorkspaceRuntimeValidationFailure } from "./workspace-runtime.js"; +import type { ManagedGitInspectionDiagnostic, PersistedWorkspaceSourceReasonCode } from "./workspace-validation-diagnostics.js"; + +const exec = promisify(execFile); +type SourceWorkspace = Pick; + +function fail(workspace: SourceWorkspace, reasonCode: PersistedWorkspaceSourceReasonCode): never { + throw new WorkspaceRuntimeValidationFailure( + "The selected execution workspace's original repository could not be verified. " + + "Restore its original project source. Before intentionally clearing the task's existing-workspace binding to choose another repository, review and preserve its retained work.", + { workspaceValidation: { reason: "persisted_workspace_source_conflict", reasonCode, executionWorkspaceId: workspace.id } }, + ); +} + +function failInspection(workspace: SourceWorkspace, diagnostic: ManagedGitInspectionDiagnostic): never { + throw new WorkspaceRuntimeValidationFailure( + "The original repository's complete Git worktree registration could not be inspected. Inspect the source before retrying; its workspace binding was preserved.", + { workspaceValidation: { reason: "git_worktree_not_reusable", reasonCode: "git_inspection_failed", + executionWorkspaceId: workspace.id, inspectionDiagnostic: diagnostic } }, + ); +} + +async function canonicalDirectory(value: string): Promise { + try { + const resolved = await fs.realpath(value); + return (await fs.stat(resolved)).isDirectory() ? resolved : null; + } catch { + return null; + } +} + +// Read the stored identity, not `remote get-url` which expands transport-only +// insteadOf settings (including the managed SSH-to-HTTPS credential rewrite). +async function readOrigin(cwd: string): Promise { + return exec("git", ["config", "--local", "--null", "--get-all", "remote.origin.url"], { + cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000, maxBuffer: 16 * 1024, + }).then(result => { + const urls = result.stdout.split("\0"); + if (urls.at(-1) === "") urls.pop(); + // Fetch uses the first URL, while a scalar config read can select the last. + // Ambiguous origins are not proof that this is the recorded repository. + return urls.length === 1 ? urls[0]!.trim() || null : null; + }).catch(() => null); +} + +async function gitDirectory(cwd: string, flag: "--absolute-git-dir" | "--git-common-dir"): Promise { + const directory = await exec("git", ["rev-parse", "--path-format=absolute", flag], { + cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000, maxBuffer: 16 * 1024, + }).then(result => result.stdout.trim() || null).catch(() => null); + return directory ? canonicalDirectory(directory) : null; +} + +async function hasLinkedWorktreeStorage(cwd: string, owner: string): Promise { + const [storage, common, ownerCommon] = await Promise.all([ + gitDirectory(cwd, "--absolute-git-dir"), gitDirectory(cwd, "--git-common-dir"), gitDirectory(owner, "--git-common-dir"), + ]); + if (!storage || !common || common !== ownerCommon) return false; + const worktrees = await canonicalDirectory(path.join(common, "worktrees")); + if (!worktrees || path.dirname(storage) !== worktrees) return false; + try { + const backlinkPath = path.join(storage, "gitdir"); + if ((await fs.stat(backlinkPath)).size > 16 * 1024) return false; + const backlink = (await fs.readFile(backlinkPath, "utf8")).trim(); + return await fs.realpath(backlink) === await fs.realpath(path.join(cwd, ".git")); + } catch { + return false; + } +} + +async function resolveBoundLocalOwner(cwd: string): Promise { + const root = await exec("git", ["rev-parse", "--show-toplevel"], { + cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000, maxBuffer: 16 * 1024, + }).then(result => result.stdout.trim() || null).catch(() => null); + return root ? canonicalDirectory(root) : null; +} + +async function canMaterializeManagedCandidate(root: string, candidate: string): Promise { + const relative = path.relative(root, candidate); + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false; + if (!await canonicalDirectory(root)) return false; + let current = root; + for (const part of relative.split(path.sep)) { + current = path.join(current, part); + try { + const entry = await fs.lstat(current); + if (!entry.isDirectory() || entry.isSymbolicLink()) return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; + } + } + return true; +} + +/** Source candidates come from scoped project state and the instance's managed path generator, never Git output. */ +export async function resolvePersistedGitWorkspaceSource(input: { + companyId: string; + projectId: string | null; + explicitProjectWorkspaceId: string | null; + workspace: SourceWorkspace; + boundProjectWorkspace: { id: string; companyId: string; projectId: string; cwd: string | null } | null; + candidateBaseCwds: string[]; + managedSourceRoot: string; + materializeOriginalRepository?: () => Promise; +}): Promise { + const workspace = input.workspace; + if (workspace.companyId !== input.companyId || workspace.projectId !== input.projectId) { + fail(workspace, "source_scope_mismatch"); + } + const binding = input.boundProjectWorkspace; + if ((workspace.projectWorkspaceId && !binding) || (binding && (binding.id !== workspace.projectWorkspaceId + || binding.companyId !== input.companyId || binding.projectId !== workspace.projectId))) { + fail(workspace, "source_scope_mismatch"); + } + if (input.explicitProjectWorkspaceId && input.explicitProjectWorkspaceId !== workspace.projectWorkspaceId) { + fail(workspace, "explicit_project_workspace_conflict"); + } + const cwd = workspace.providerRef ?? workspace.cwd; + if (!cwd || (workspace.cwd && workspace.providerRef && path.resolve(workspace.cwd) !== path.resolve(workspace.providerRef))) { + fail(workspace, "source_path_unproven"); + } + const roots = async () => { + const canonicalRoot = await canonicalDirectory(input.managedSourceRoot); + if (!canonicalRoot) return new Set(); + const candidates = await Promise.all(input.candidateBaseCwds.map(async candidate => { + const relative = path.relative(input.managedSourceRoot, candidate); + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null; + const resolved = await canonicalDirectory(candidate); + // The configured instance root may be a symlink. A project or repository + // below it may not redirect the scoped managed identity elsewhere. + return resolved === path.join(canonicalRoot, relative) ? resolved : null; + })); + return new Set(candidates.filter((value): value is string => value !== null)); + }; + const knownRoots = await roots(); + if (binding?.cwd) { + // A configured local path may be a repository subfolder or another linked + // checkout. Resolve its Git owner as the old restore path already did. + const localOwner = await resolveBoundLocalOwner(binding.cwd); + if (localOwner) knownRoots.add(localOwner); + } + const existing = await canonicalDirectory(cwd); + const sourceEntry = await fs.lstat(cwd).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + return fail(workspace, "source_path_unproven"); + }); + if (sourceEntry && !existing) fail(workspace, "source_path_unproven"); + if (existing) { + // An independent clone (including a stand-alone checkout with separate Git + // storage) is not a linked worktree authorized by the persisted binding. + const dotGit = await fs.lstat(path.join(existing, ".git")).catch(() => null); + if (!dotGit?.isFile()) fail(workspace, "source_registration_unproven"); + let repositoryMismatch = false; + let failedInspection: ManagedGitInspectionDiagnostic | null = null; + for (const owner of knownRoots) { + if (owner === existing) continue; + const inspection = await inspectManagedGitWorktreeBranch({ worktreePath: existing, repoRoot: owner, expectedBranchName: null }); + if (inspection.inspectionDiagnostic) failedInspection ??= inspection.inspectionDiagnostic; + if (!inspection.valid || !await hasLinkedWorktreeStorage(existing, owner)) continue; + if (workspace.repoUrl && await readOrigin(owner) !== workspace.repoUrl.trim()) { + repositoryMismatch = true; + continue; + } + return owner; + } + if (failedInspection) failInspection(workspace, failedInspection); + fail(workspace, repositoryMismatch ? "source_repository_mismatch" : "source_registration_unproven"); + } + + // A mutable local project cwd alone cannot prove the repository that owned a + // now-missing worktree. Do not reconstruct historical work from its new cwd. + if (!workspace.repoUrl) fail(workspace, "source_repository_unavailable"); + + // Reconstruct only from a source authorized by the old binding. A new project + // primary is never a fallback for a missing historical worktree. + let failedInspection: ManagedGitInspectionDiagnostic | null = null; + for (const candidate of knownRoots) { + if (workspace.repoUrl && await readOrigin(candidate) !== workspace.repoUrl.trim()) continue; + const inspection = await inspectManagedGitWorktreeBranch({ worktreePath: candidate, repoRoot: candidate, expectedBranchName: null }); + if (inspection.valid) return candidate; + if (inspection.inspectionDiagnostic) failedInspection ??= inspection.inspectionDiagnostic; + } + if (failedInspection) failInspection(workspace, failedInspection); + if (workspace.repoUrl && input.materializeOriginalRepository) { + if (input.candidateBaseCwds.length === 0 || !(await Promise.all(input.candidateBaseCwds.map( + candidate => canMaterializeManagedCandidate(input.managedSourceRoot, candidate), + ))).every(Boolean)) fail(workspace, "source_repository_unavailable"); + const materialized = await input.materializeOriginalRepository(); + const owner = await canonicalDirectory(materialized); + const expectedRoots = await roots(); + if (owner && expectedRoots.has(owner) && await readOrigin(owner) === workspace.repoUrl.trim()) return owner; + } + fail(workspace, "source_repository_unavailable"); +} diff --git a/server/src/services/run-failure-diagnostics.ts b/server/src/services/run-failure-diagnostics.ts index 007cdbe868b..605f0b8afef 100644 --- a/server/src/services/run-failure-diagnostics.ts +++ b/server/src/services/run-failure-diagnostics.ts @@ -7,7 +7,7 @@ import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/w import { redactCurrentUserText } from "../log-redaction.js"; import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js"; import { readNativeModelRejectionDiagnostic } from "./native-runtime/native-provider-failure.js"; -import { MANAGED_GIT_WORKTREE_REASON_CODES, readManagedGitInspectionDiagnostic } from "./workspace-validation-diagnostics.js"; +import { MANAGED_GIT_WORKTREE_REASON_CODES, PERSISTED_WORKSPACE_SOURCE_REASON_CODES, readManagedGitInspectionDiagnostic } from "./workspace-validation-diagnostics.js"; type Run = typeof heartbeatRuns.$inferSelect; type Context = Record; @@ -162,6 +162,11 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport if (diagnostic.exitCode !== undefined) execution.workspaceValidationInspectionExitCode = diagnostic.exitCode; } } + if (run.errorCode === "workspace_validation_failed" && read(workspaceValidation, "reason") === "persisted_workspace_source_conflict") { + execution.workspaceValidationReason = "persisted_workspace_source_conflict"; + const reasonCode = PERSISTED_WORKSPACE_SOURCE_REASON_CODES.find(code => code === read(workspaceValidation, "reasonCode")); + if (reasonCode) execution.workspaceValidationReasonCode = reasonCode; + } if (run.errorCode === "process_lost") { const diagnostic = readProcessLossDiagnostic(read(result, "processLossDiagnostic")); for (const [field, value] of Object.entries(diagnostic)) { diff --git a/server/src/services/workspace-validation-diagnostics.ts b/server/src/services/workspace-validation-diagnostics.ts index 73c4a452d88..b851c7e3227 100644 --- a/server/src/services/workspace-validation-diagnostics.ts +++ b/server/src/services/workspace-validation-diagnostics.ts @@ -1,3 +1,10 @@ +export const PERSISTED_WORKSPACE_SOURCE_REASON_CODES = [ + "source_scope_mismatch", "explicit_project_workspace_conflict", "source_path_unproven", + "source_registration_unproven", "source_repository_mismatch", "source_repository_unavailable", +] as const; + +export type PersistedWorkspaceSourceReasonCode = typeof PERSISTED_WORKSPACE_SOURCE_REASON_CODES[number]; + export const MANAGED_GIT_WORKTREE_REASON_CODES = [ "missing_worktree", "not_a_git_checkout", "not_registered", "wrong_repository_root", "branch_mismatch", "git_inspection_failed",