mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Preserve native recovery transport and legacy workspace bindings
Fence host roots only for local and sandbox execution targets. Accept verified run-ID bindings for older native tasks and avoid unrelated historical path failures without weakening pending-acquisition guards. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d66a2de539
commit
fa31e4ade7
5 files changed
+196
-44
No files matched your search
@@ -39,9 +39,9 @@ Source inspection and isolated copies require source-project read access. Writab
|
||||
|
||||
Independent repository acquisition does not inherit private-repository authority from a chat endpoint's sponsor. Effective low-trust tasks may submit anonymous repository URLs only; catalog selection, managed credentials and reuse of managed repository receipts remain forbidden. Resolve current agent, task, organizational project and workspace-source policies together with the retained run policy at request and admission. Existing configured-source authorization remains the path for private sources. A trust change must fail before cloning or reusing existing private bytes.
|
||||
|
||||
Admission with no repository requests remains heartbeat-owned and requires no additional repository-mutation permission. Low-trust admission still rejects managed acquisition bytes in the same physical folder. Immutable recovery uses its admitted folder and skips new repository acquisition even if the task's current binding has changed. A reservation-only recovery hook fences legacy admitted roots before provider reattachment; remote recovery obtains the host path from its verified immutable sync descriptor.
|
||||
Admission with no repository requests remains heartbeat-owned and requires no additional repository-mutation permission. Low-trust admission still rejects managed acquisition bytes in the same physical folder. Immutable recovery uses its admitted folder and skips new repository acquisition even if the task's current binding has changed. A reservation-only recovery hook fences legacy admitted host roots before input capture or provider dispatch. Local recovery uses its immutable local root; sandbox recovery obtains the host path from its verified immutable sync descriptor. SSH recovery uses its remote filesystem directly and does not require a host snapshot descriptor. Older projectless native runs may use the exact persisted run-ID workspace binding without a workspace row.
|
||||
|
||||
Independent acquisition reserves a company-scoped canonical physical root as either `anonymous_only` or `managed` in an internal durable record. A short database fence serializes these reservations with repository requests and credential promotion. Low-trust use rejects every managed intent, including a clone still in staging, and reserves anonymous use before capture. Future managed requests cannot publish into that reserved root. The reservation survives controller restart, workspace aliases, metadata replacement, relocation and archival; changing the acquisition mode requires a separate physical root. No database lock remains held through cloning, input capture or provider execution. Arbitrary external file writers remain subject to the existing shared-folder constraints.
|
||||
Independent acquisition reserves a company-scoped canonical physical root as either `anonymous_only` or `managed` in an internal durable record. A short database fence serializes these reservations with repository requests and credential promotion. Low-trust use rejects every managed intent, including a clone still in staging, and reserves anonymous use before capture. Future managed requests cannot publish into that reserved root. The reservation survives controller restart, workspace aliases, metadata replacement, relocation and archival; changing the acquisition mode requires a separate physical root. No database lock remains held through cloning, input capture or provider execution. Known reservations do not rescan historical workspace paths. Initial reservations compare legacy managed intents through accessible canonical ancestors, conservatively rejecting overlapping inaccessible paths while unavailable unrelated historical folders do not block admission. Arbitrary external file writers remain subject to the existing shared-folder constraints.
|
||||
|
||||
Channel defaults use optional properties (inherit), explicit null (clear), concrete value (override). Resource override beats endpoint. Defaults are captured once at task creation and reauthorized on use. Changing/deleting/revoking configuration cannot relocate existing tasks. Project choice and workspace choice are independent; choosing a source does not silently assign organizational project. Endpoints without configuration work normally.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execFile as execFileCallback } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, mkdir, writeFile, readFile, realpath, rm, symlink } from "node:fs/promises";
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -9,6 +10,8 @@ import { eq } from "drizzle-orm";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositories, executionWorkspaces,
|
||||
heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
|
||||
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
|
||||
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
|
||||
import { buildGitAuthInvocation } from "../services/git-credentials.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
@@ -33,7 +36,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
db = createDb(database.connectionString);
|
||||
root = await mkdtemp(path.join(tmpdir(), "paperclip-repository-trust-"));
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterEach(() => { vi.clearAllMocks(); });
|
||||
afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); });
|
||||
afterAll(async () => { await database?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); });
|
||||
|
||||
async function fixture() {
|
||||
@@ -195,6 +198,70 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["standard", "retained_low_trust", "current_low_trust"] as const)("recovers an exact legacy run-ID binding with %s trust", async trust => {
|
||||
const f = await fixture();
|
||||
const execution = buildNativeExecutionInput({
|
||||
companyId: f.companyId, runId: f.runId, agentId: f.agentId,
|
||||
issue: { id: f.issueId, identifier: "LEG-1", title: "Legacy recovery", description: null, workMode: "standard" },
|
||||
taskPrompt: "Continue", normalizedSessionId: randomUUID(), provider: "codex",
|
||||
workspace: { id: f.runId, cwd: f.admission.cwd, repoUrl: null, repoRef: null, branchName: null },
|
||||
completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1", contract: { revision: "1", objective: "Recover", criteria: [{ id: "objective", requirement: "Keep the admitted folder" }] } },
|
||||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
});
|
||||
await db.update(heartbeatRuns).set({ runtimeMode: "native", runnerProfileJson: { nativeExecutionInput: execution },
|
||||
contextSnapshot: { issueId: f.issueId, ...(trust === "retained_low_trust" ? { executionPolicy: f.policy } : {}) } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
if (trust === "current_low_trust") await db.update(agents).set({ permissions: f.policy }).where(eq(agents.id, f.agentId));
|
||||
await expect(f.service.reserveForRecovery({ ...f.admission, workspaceId: f.runId })).resolves.toBeUndefined();
|
||||
const reservations = await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId));
|
||||
expect(reservations).toHaveLength(trust === "standard" ? 0 : 1);
|
||||
if (trust !== "standard") expect(reservations[0].mode).toBe("anonymous_only");
|
||||
await expect(f.service.reserveForRecovery({ ...f.admission, workspaceId: randomUUID() })).rejects.toThrow(/recovery identity/);
|
||||
execution.binding.agentId = randomUUID();
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeExecutionInput: execution } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(f.service.reserveForRecovery({ ...f.admission, workspaceId: f.runId })).rejects.toThrow(/recovery identity/);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["ENOTDIR", "EACCES"] as const)("ignores unrelated unavailable legacy managed paths (%s) while preserving anonymous roots", async code => {
|
||||
const f = await fixture();
|
||||
await f.request({ kind: "catalog", id: "private-repo" });
|
||||
await db.delete(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId));
|
||||
const peerRoot = path.join(root, `${f.issueId}-anonymous`);
|
||||
await mkdir(peerRoot);
|
||||
const peer = await lowTrustPeer(f, peerRoot);
|
||||
const unavailableParent = path.join(root, `${f.issueId}-unavailable`);
|
||||
await writeFile(unavailableParent, "Archived directory replaced by a file");
|
||||
const unavailableCwd = path.join(unavailableParent, "checkout");
|
||||
await db.update(executionWorkspaces).set({ cwd: unavailableCwd, status: "archived" }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
if (code === "EACCES") {
|
||||
const actual = fs.realpath.bind(fs);
|
||||
vi.spyOn(fs, "realpath").mockImplementation(async pathname => {
|
||||
if (String(pathname).startsWith(unavailableParent)) throw Object.assign(new Error("fixture access denied"), { code: "EACCES" });
|
||||
return actual(pathname);
|
||||
});
|
||||
}
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
// Once fenced, unrelated historical paths need no further filesystem reads.
|
||||
vi.restoreAllMocks();
|
||||
const actual = fs.realpath.bind(fs);
|
||||
vi.spyOn(fs, "realpath").mockImplementation(async pathname => {
|
||||
if (String(pathname) === unavailableCwd) throw new Error("Historical path must not be revisited");
|
||||
return actual(pathname);
|
||||
});
|
||||
await expect(executionWorkspaceRepositoryService(db).prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("still rejects a legacy pending managed receipt through a canonical path alias", async () => {
|
||||
const f = await fixture();
|
||||
await f.request({ kind: "catalog", id: "private-repo" });
|
||||
await db.delete(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, f.companyId));
|
||||
const alias = path.join(root, `${f.issueId}-legacy-alias`);
|
||||
await symlink(f.admission.cwd, alias);
|
||||
await db.update(executionWorkspaces).set({ cwd: alias }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
const peer = await lowTrustPeer(f);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/anonymous public URLs only/);
|
||||
});
|
||||
|
||||
it("reserves a legacy low-trust recovery folder after its task binding moves", async () => {
|
||||
const f = await fixture();
|
||||
const peer = await lowTrustPeer(f);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { activityLog, agents, heartbeatRuns, projects, executionWorkspaceReposit
|
||||
import { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository, type ProjectRepository } from "@paperclipai/shared";
|
||||
import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { conflict, forbidden, notFound } from "../errors.js";
|
||||
import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js";
|
||||
import { assertTaskWorkspaceAccess } from "./task-workspace-source-access.js";
|
||||
import { authorizationService, canActorReadExecutionWorkspace, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
|
||||
@@ -34,6 +35,34 @@ async function canonicalRepositoryRoot(cwd: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** An unavailable legacy path must not block unrelated roots. Preserve the
|
||||
* unresolved suffix and reject roots inside an inaccessible prefix, since a
|
||||
* hidden symlink may still point at the candidate's admitted directory. */
|
||||
async function legacyRepositoryRootMayMatch(cwd: string, candidate: string): Promise<boolean> {
|
||||
const absolute = path.resolve(cwd);
|
||||
if (absolute === candidate) return true;
|
||||
let probe = absolute;
|
||||
let suffix = "";
|
||||
let inaccessiblePrefix: string | null = null;
|
||||
for (;;) {
|
||||
try {
|
||||
const canonical = await fs.realpath(probe);
|
||||
if (path.join(canonical, suffix) === candidate) return true;
|
||||
if (!inaccessiblePrefix) return false;
|
||||
const unresolved = path.join(canonical, path.relative(probe, inaccessiblePrefix));
|
||||
return candidate === unresolved || candidate.startsWith(`${unresolved}${path.sep}`);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (!["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(code ?? "")) throw error;
|
||||
if (code === "EACCES" || code === "EPERM") inaccessiblePrefix = probe;
|
||||
const parent = path.dirname(probe);
|
||||
if (parent === probe) return true;
|
||||
suffix = path.join(path.basename(probe), suffix);
|
||||
probe = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lowTrustRepositoryMessage = "Low-trust tasks may prepare anonymous public URLs only. Use an authorized configured source for private repositories.";
|
||||
|
||||
function resolveAcquisitionSource(request: PrepareWorkspaceRepository["repository"], available: ProjectRepository[], anonymousOnly: boolean) {
|
||||
@@ -71,7 +100,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
return (await toolAccessService(db).listProjectRepositories(companyId, userId, localTrusted)).repositories;
|
||||
}
|
||||
async function requiresAnonymousAcquisition(reader: Db | DbTransaction, input: {
|
||||
issue: typeof issues.$inferSelect; workspace: typeof executionWorkspaces.$inferSelect;
|
||||
issue: typeof issues.$inferSelect; workspace: typeof executionWorkspaces.$inferSelect | null;
|
||||
agentId?: string | null; runId?: string | null;
|
||||
}) {
|
||||
const { issue, workspace } = input;
|
||||
@@ -79,7 +108,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
const [agent] = agentId ? await reader.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, issue.companyId))) : [];
|
||||
if (agentId && !agent) throw forbidden("Task repository agent is unavailable");
|
||||
const [project] = issue.projectId ? await reader.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, issue.companyId))) : [];
|
||||
const [workspaceSourceProject] = workspace.projectId ? await reader.select().from(projects).where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, issue.companyId))) : [];
|
||||
const [workspaceSourceProject] = workspace?.projectId ? await reader.select().from(projects).where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, issue.companyId))) : [];
|
||||
const runId = input.runId ?? issue.executionRunId;
|
||||
const [run] = runId ? await reader.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId))) : [];
|
||||
if (runId && (!run || (input.runId && run.agentId !== agentId) || (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId) !== issue.id)) {
|
||||
@@ -101,6 +130,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
const [existing] = await tx.select().from(executionWorkspaceRepositoryRoots).where(and(
|
||||
eq(executionWorkspaceRepositoryRoots.companyId, companyId), eq(executionWorkspaceRepositoryRoots.rootKey, rootKey)));
|
||||
if (existing && (existing.canonicalCwd !== canonicalCwd || existing.mode !== mode)) throw forbidden(lowTrustRepositoryMessage);
|
||||
if (existing) return canonicalCwd;
|
||||
if (mode === "anonymous_only") {
|
||||
// Legacy receipts predate root reservations. Even pending/staging work
|
||||
// can publish after admission, so no managed intent in this root is safe.
|
||||
@@ -109,7 +139,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
.where(and(eq(executionWorkspaceRepositories.companyId, companyId), eq(executionWorkspaces.companyId, companyId),
|
||||
isNotNull(executionWorkspaceRepositories.catalogRepositoryId)));
|
||||
for (const peer of managed) {
|
||||
if (peer.cwd && await canonicalRepositoryRoot(peer.cwd) === canonicalCwd) throw forbidden(lowTrustRepositoryMessage);
|
||||
if (peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd)) throw forbidden(lowTrustRepositoryMessage);
|
||||
}
|
||||
}
|
||||
if (!existing) await tx.insert(executionWorkspaceRepositoryRoots).values({ companyId, canonicalCwd, rootKey, mode });
|
||||
@@ -189,8 +219,22 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
async function reserveForRecovery(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string }) {
|
||||
const [issue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId)));
|
||||
if (!issue || !workspace) throw forbidden("Task repository recovery identity is unavailable");
|
||||
const anonymousOnly = await requiresAnonymousAcquisition(db, { issue, workspace, agentId: input.agentId, runId: input.runId });
|
||||
if (!issue) throw forbidden("Task repository recovery identity is unavailable");
|
||||
if (!workspace) {
|
||||
// Older projectless native admissions used the run ID as a transient
|
||||
// workspace binding. Only the persisted exact native binding grants this exception.
|
||||
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
let execution;
|
||||
try { execution = parseNativeExecutionInput(run?.runnerProfileJson?.nativeExecutionInput); } catch { /* Fail closed below. */ }
|
||||
const binding = execution?.binding;
|
||||
if (input.workspaceId !== input.runId || run?.runtimeMode !== "native" || !binding
|
||||
|| binding.companyId !== input.companyId || binding.runId !== input.runId
|
||||
|| binding.agentId !== input.agentId || binding.issueId !== input.issueId
|
||||
|| binding.executionWorkspaceId !== input.runId) {
|
||||
throw forbidden("Task repository recovery identity is unavailable");
|
||||
}
|
||||
}
|
||||
const anonymousOnly = await requiresAnonymousAcquisition(db, { issue, workspace: workspace ?? null, agentId: input.agentId, runId: input.runId });
|
||||
if (anonymousOnly) await reserveAdmissionRoot(input.companyId, input.cwd, "anonymous_only");
|
||||
else if ((await list(input.companyId, input.workspaceId)).some(row => row.catalogRepositoryId)) {
|
||||
await reserveAdmissionRoot(input.companyId, input.cwd, "managed");
|
||||
@@ -229,7 +273,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
const [issue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId)));
|
||||
if (!issue || !workspace || issue.executionWorkspaceId !== workspace.id) throw forbidden("Task repository workspace binding is unavailable");
|
||||
const anonymousOnly = await requiresAnonymousAcquisition(db, { issue, workspace, agentId: input.agentId, runId: input.runId });
|
||||
const anonymousOnly = await requiresAnonymousAcquisition(db, { issue, workspace: workspace ?? null, agentId: input.agentId, runId: input.runId });
|
||||
// Ordinary admission without acquisitions is owned by heartbeat, not this mutation service.
|
||||
if (!rows.length) {
|
||||
if (anonymousOnly) await reserveAdmissionRoot(input.companyId, input.cwd, "anonymous_only");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { captureDirectorySnapshot, directorySnapshotSha256, serializeDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import { agents, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, environmentLeases, heartbeatRuns, issues, projects, projectWorkspaces, type Db } from "@paperclipai/db";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -13,7 +14,7 @@ import { environmentService } from "../environments.js";
|
||||
import { executionWorkspaceService } from "../execution-workspaces.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { workspaceOperationService } from "../workspace-operations.js";
|
||||
import { nativeWorkspaceSyncInternals } from "../native-runtime/native-workspace-sync.js";
|
||||
import { nativeWorkspaceSyncInternals, prepareNativeWorkspaceSync } from "../native-runtime/native-workspace-sync.js";
|
||||
import { createHeartbeatWorkspaceResolver, resolveExecutionWorkspaceReuseRequestForIssue } from "./workspaces.js";
|
||||
import { prepareHeartbeatWorkspace, type HeartbeatWorkspacePreparationInput } from "./workspace-preparation.js";
|
||||
|
||||
@@ -160,53 +161,90 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
const anchor = vi.spyOn(input.services, "resolveReusedGitWorkspaceAnchor");
|
||||
const prepareRepositories = vi.spyOn(input.services.executionWorkspacesSvc, "prepareTaskRepositoriesForAdmission");
|
||||
const reserveRoot = vi.spyOn(input.services.executionWorkspacesSvc, "reserveTaskRepositoryRootForRecovery");
|
||||
const acquire = input.services.envOrchestrator.acquireForRun;
|
||||
vi.spyOn(input.services.envOrchestrator, "acquireForRun").mockImplementation(async args => {
|
||||
expect(reserveRoot).toHaveBeenCalledWith({ companyId: issue.companyId, issueId: issue.id,
|
||||
workspaceId: first.persistedExecutionWorkspace!.id, cwd: first.executionWorkspace.cwd,
|
||||
agentId: input.agent.id, runId: input.run.id });
|
||||
return acquire(args);
|
||||
});
|
||||
const next = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(reserveRoot).toHaveBeenCalledWith({ companyId: issue.companyId, issueId: issue.id,
|
||||
workspaceId: first.persistedExecutionWorkspace!.id, cwd: first.executionWorkspace.cwd,
|
||||
agentId: input.agent.id, runId: input.run.id });
|
||||
expect(prepareRepositories).not.toHaveBeenCalled();
|
||||
expect(next.persistedExecutionWorkspace!.id).toBe(first.persistedExecutionWorkspace!.id);
|
||||
expect(anchor).toHaveBeenCalledWith(expect.objectContaining({ immutableNativeBinding: true, explicitProjectWorkspaceId: first.persistedExecutionWorkspace!.projectWorkspaceId }));
|
||||
expect(await readIssue(issue.id)).toMatchObject({ executionWorkspaceId: null, projectWorkspaceId: null });
|
||||
});
|
||||
|
||||
it.each([false, true])("requires the immutable host descriptor for remote recovery (present=%s)", async present => {
|
||||
it.each([
|
||||
{ transport: "local", descriptor: "absent" },
|
||||
{ transport: "ssh", descriptor: "absent" },
|
||||
{ transport: "daytona", descriptor: "valid" },
|
||||
{ transport: "daytona", descriptor: "absent" },
|
||||
{ transport: "daytona", descriptor: "corrupt" },
|
||||
] as const)("reserves the admitted host root for $transport recovery with $descriptor descriptor", async ({ transport, descriptor }) => {
|
||||
const { input, issue } = await fixture(true);
|
||||
const first = await prepareHeartbeatWorkspace(db, input);
|
||||
await reuse(input, first.persistedExecutionWorkspace!);
|
||||
await db.update(issues).set({ executionWorkspaceId: null, projectWorkspaceId: null }).where(eq(issues.id, issue.id));
|
||||
input.task.issueRef = { ...input.task.issueRef!, executionWorkspaceId: null, projectWorkspaceId: null };
|
||||
input.reuse.nativeRecoveryExecutionWorkspaceId = first.persistedExecutionWorkspace!.id;
|
||||
input.environment.selectedEnvironmentForConfig = { ...input.environment.localEnvironment, driver: "ssh" };
|
||||
const admittedRoot = join(home, `admitted-${input.run.id}`);
|
||||
await mkdir(admittedRoot);
|
||||
if (present) {
|
||||
const remoteCwd = "/remote/admitted-task";
|
||||
input.reuse.persistedNativeExecutionInput = {
|
||||
schema: "paperclip.native-execution-input.v1",
|
||||
binding: { companyId: issue.companyId, runId: input.run.id, issueId: issue.id,
|
||||
agentId: input.agent.id, executionWorkspaceId: first.persistedExecutionWorkspace!.id },
|
||||
task: { identifier: "WSP-1", title: issue.title, description: null, prompt: "Continue admitted work", workMode: "standard" },
|
||||
workspace: { cwd: transport === "local" ? admittedRoot : remoteCwd, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { normalizedSessionId: null, driverKind: "codex_app_server", protocolVersion: 1,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
|
||||
provider: { kind: "codex", model: null },
|
||||
completionContract: { id: "contract", sha256: "abc123", schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: { revision: "1", objective: "Continue admitted work", criteria: [{ id: "objective", requirement: "Finish" }] } },
|
||||
interactionResponses: [], credentialBindings: [],
|
||||
};
|
||||
const target: AdapterExecutionTarget = transport === "local" ? { kind: "local" }
|
||||
: transport === "ssh" ? { kind: "remote", transport: "ssh", remoteCwd,
|
||||
spec: { host: "fixture.invalid", port: 22, username: "fixture", remoteWorkspacePath: remoteCwd,
|
||||
privateKey: null, knownHosts: null, strictHostKeyChecking: true, remoteCwd } }
|
||||
: { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd };
|
||||
// Double the external transport boundary, while retaining real workspace,
|
||||
// database, lease and descriptor handling. Driver hints alone cannot decide.
|
||||
const realize = input.services.envOrchestrator.realizeForRun;
|
||||
vi.spyOn(input.services.envOrchestrator, "realizeForRun").mockImplementation(async args => ({
|
||||
...await realize(args), executionTarget: target,
|
||||
}));
|
||||
if (descriptor !== "absent") {
|
||||
const baseline = await captureDirectorySnapshot(admittedRoot);
|
||||
const reference = await nativeWorkspaceSyncInternals.writeDescriptor({
|
||||
schema: "paperclip.native-workspace-sync/v1", state: "prepared",
|
||||
binding: { companyId: issue.companyId, runId: input.run.id, workspaceId: first.persistedExecutionWorkspace!.id,
|
||||
leaseId: "admitted-lease", providerLeaseId: "admitted-provider", localCwd: admittedRoot, remoteCwd: "/workspace" },
|
||||
leaseId: "admitted-lease", providerLeaseId: "admitted-provider", localCwd: admittedRoot, remoteCwd },
|
||||
baseline: serializeDirectorySnapshot(baseline), baselineSha256: directorySnapshotSha256(baseline),
|
||||
gitSnapshot: null, seed: null, createdAt: new Date().toISOString(), finalizedAt: null, finalHostSha256: null, resourceDisposition: null,
|
||||
});
|
||||
input.run.runnerProfileJson = { nativeWorkspaceSync: reference };
|
||||
if (descriptor === "corrupt") {
|
||||
await writeFile(nativeWorkspaceSyncInternals.descriptorPath(input.run.id, reference.descriptorSha256), "{}");
|
||||
}
|
||||
}
|
||||
const reserve = vi.spyOn(input.services.executionWorkspacesSvc, "reserveTaskRepositoryRootForRecovery");
|
||||
const prepare = vi.spyOn(input.services.executionWorkspacesSvc, "prepareTaskRepositoriesForAdmission");
|
||||
const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
|
||||
if (present) {
|
||||
await prepareHeartbeatWorkspace(db, input);
|
||||
expect(reserve).toHaveBeenCalledWith({ companyId: issue.companyId, issueId: issue.id,
|
||||
if (transport !== "daytona" || descriptor === "valid") {
|
||||
const result = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(result.executionTarget).toEqual(target);
|
||||
if (transport === "ssh") expect(reserve).not.toHaveBeenCalled();
|
||||
else expect(reserve).toHaveBeenCalledWith({ companyId: issue.companyId, issueId: issue.id,
|
||||
workspaceId: first.persistedExecutionWorkspace!.id, cwd: admittedRoot, agentId: input.agent.id, runId: input.run.id });
|
||||
if (transport !== "daytona") {
|
||||
await expect(prepareNativeWorkspaceSync({ db, runId: input.run.id, companyId: issue.companyId,
|
||||
workspaceId: first.persistedExecutionWorkspace!.id, workspaceLocalDir: first.executionWorkspace.cwd,
|
||||
target, lease: result.activeEnvironmentLease.lease })).resolves.toBeNull();
|
||||
}
|
||||
expect(admittedRoot).not.toBe(first.executionWorkspace.cwd);
|
||||
} else {
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("native_workspace_sync_descriptor_binding_missing");
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow(descriptor === "absent"
|
||||
? "native_workspace_sync_descriptor_binding_missing" : "native_workspace_sync_descriptor_digest_mismatch");
|
||||
expect(reserve).not.toHaveBeenCalled();
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
// The outer run owner retains its normal terminalization/lease cleanup path.
|
||||
expect(await leases(input.run.id)).toMatchObject([{ status: "active" }]);
|
||||
}
|
||||
expect(prepare).not.toHaveBeenCalled();
|
||||
expect((await readIssue(issue.id)).executionWorkspaceId).toBeNull();
|
||||
|
||||
@@ -891,23 +891,6 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
|
||||
agentId: agent.id, runId: run.id, responsibleUserId });
|
||||
projectRepositoryPaths.push(...taskRepositories.map(repository => repository.relativePath));
|
||||
}
|
||||
if (!isDotRun && issueId && (persistedNativeExecutionInput || nativeRecoveryExecutionWorkspaceId)) {
|
||||
const workspaceId = persistedNativeExecutionInput?.binding.executionWorkspaceId ?? nativeRecoveryExecutionWorkspaceId!;
|
||||
const reference = readNativeWorkspaceSyncReference(parseObject(run.runnerProfileJson).nativeWorkspaceSync);
|
||||
if (!reference && selectedEnvironmentForConfig?.driver !== "local") {
|
||||
throw new Error("native_workspace_sync_descriptor_binding_missing");
|
||||
}
|
||||
// Recovery keeps admitted files and trust even when the task now selects
|
||||
// another workspace. Reserve only; never acquire newly requested repositories.
|
||||
const cwd = reference
|
||||
? await readNativeWorkspaceSyncLocalCwd({ companyId: agent.companyId, runId: run.id, workspaceId, reference })
|
||||
: persistedNativeExecutionInput && persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6"
|
||||
&& selectedEnvironmentForConfig?.driver === "local"
|
||||
? persistedNativeExecutionInput.workspace.cwd
|
||||
: executionWorkspace.cwd;
|
||||
await executionWorkspacesSvc.reserveTaskRepositoryRootForRecovery({ companyId: agent.companyId,
|
||||
issueId, workspaceId, cwd, agentId: agent.id, runId: run.id });
|
||||
}
|
||||
if (persistedExecutionWorkspace) {
|
||||
context.executionWorkspaceId = persistedExecutionWorkspace.id;
|
||||
await db
|
||||
@@ -1070,6 +1053,26 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
|
||||
await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace);
|
||||
const workspaceRealization = realizationResult.workspaceRealization;
|
||||
const executionTarget = realizationResult.executionTarget;
|
||||
// SSH uses its remote filesystem directly; only local and sandbox runs
|
||||
// expose or transfer the host root protected by repository reservations.
|
||||
if (!isDotRun && issueId && (persistedNativeExecutionInput || nativeRecoveryExecutionWorkspaceId)
|
||||
&& !(executionTarget?.kind === "remote" && executionTarget.transport === "ssh")) {
|
||||
const workspaceId = persistedNativeExecutionInput?.binding.executionWorkspaceId ?? nativeRecoveryExecutionWorkspaceId!;
|
||||
const reference = readNativeWorkspaceSyncReference(parseObject(run.runnerProfileJson).nativeWorkspaceSync);
|
||||
if (!reference && executionTarget?.kind === "remote" && executionTarget.transport === "sandbox") {
|
||||
throw new Error("native_workspace_sync_descriptor_binding_missing");
|
||||
}
|
||||
// Recovery keeps admitted files and trust even when the task now selects
|
||||
// another workspace. Reserve only; never acquire newly requested repositories.
|
||||
const cwd = reference
|
||||
? await readNativeWorkspaceSyncLocalCwd({ companyId: agent.companyId, runId: run.id, workspaceId, reference })
|
||||
: persistedNativeExecutionInput && persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6"
|
||||
&& executionTarget?.kind !== "remote"
|
||||
? persistedNativeExecutionInput.workspace.cwd
|
||||
: executionWorkspace.cwd;
|
||||
await executionWorkspacesSvc.reserveTaskRepositoryRootForRecovery({ companyId: agent.companyId,
|
||||
issueId, workspaceId, cwd, agentId: agent.id, runId: run.id });
|
||||
}
|
||||
// Preserve the host-owned source before adapter context can share lease
|
||||
// metadata. A later copy-back failure must not adopt a rebound source.
|
||||
const workspaceRestoreSource = structuredClone(realizationResult.lease);
|
||||
|
||||
Reference in new issue
Block a user