mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
Harden work-folder recovery and add deployed staging acceptance tools
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a13bb6b395
commit
6c7d1bbbf0
23 files changed
+656
-38
No files matched your search
@@ -101,6 +101,17 @@ describe.sequential("cli auth routes", () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it.each(["session", "cloud_tenant"])("recognizes an authenticated %s user for board CLI approval", async (source) => {
|
||||
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", requestedAccess: "board", status: "pending" });
|
||||
const app = await createApp({ type: "board", source, userId: "user-1", companyIds: ["company-1"], isInstanceAdmin: false });
|
||||
const result = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toMatchObject({ requiresSignIn: false, canApprove: true, currentUserId: "user-1" });
|
||||
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", requestedAccess: "instance_admin_required", status: "pending" });
|
||||
const admin = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
|
||||
expect(admin.body.canApprove).toBe(false);
|
||||
});
|
||||
|
||||
it.sequential("creates a CLI auth challenge with approval metadata", async () => {
|
||||
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
||||
challenge: {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { agents, companies, createDb, heartbeatRuns, issues, environments, environmentLeases, projects, projectWorkspaces, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
|
||||
import { agents, assets, companyMemberships, issueAttachments, companies, createDb, heartbeatRuns, issues, environments, environmentLeases, projects, projectWorkspaces, taskRepositoryBindings, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { prepareSandboxWorkFolders } from "../services/sandbox-work-folders.js";
|
||||
import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "../services/work-folder-retention.js";
|
||||
@@ -44,14 +45,14 @@ describe("shared sandbox work-folder lifecycle", () => {
|
||||
for (const run of active) await run.stop().catch(() => {});
|
||||
await database?.cleanup(); if (root) await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
async function prepare(home: string, leaseId: string, physicalId = leaseId) {
|
||||
async function prepare(home: string, leaseId: string, physicalId = leaseId, responsibleUserId: string | null = null) {
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, responsibleUserId, status: "running" });
|
||||
const lease = { id: leaseId, companyId, environmentId, provider: "test", providerLeaseId: physicalId };
|
||||
await db.insert(environmentLeases).values({ ...lease, heartbeatRunId: runId }).onConflictDoUpdate({ target: environmentLeases.id, set: { heartbeatRunId: runId } });
|
||||
const run = await prepareSandboxWorkFolders({ db, companyId, agentId, projectId, taskId, runId,
|
||||
responsibleUserId: null, storage, sandboxKey: workFolderSandboxKey(lease), target: { kind: "remote", transport: "sandbox", leaseId, remoteCwd: home,
|
||||
responsibleUserId, storage, sandboxKey: workFolderSandboxKey(lease), target: { kind: "remote", transport: "sandbox", leaseId, remoteCwd: home,
|
||||
runner: { execute: (input) => localTestWorkFolderRunner.execute({ ...input, env: { ...input.env, HOME: home } }) } } });
|
||||
active.push(run); return run;
|
||||
}
|
||||
@@ -123,4 +124,78 @@ describe("shared sandbox work-folder lifecycle", () => {
|
||||
await resumed.stop(); active.splice(active.indexOf(resumed), 1);
|
||||
}, 120_000);
|
||||
|
||||
it("stops private-file synchronization after responsible-user membership is revoked", async () => {
|
||||
const userId = randomUUID();
|
||||
const [membership] = await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "member" }).returning();
|
||||
const leaseId = randomUUID();
|
||||
const run = await prepare(path.join(root, "revoked-user"), leaseId, leaseId, userId);
|
||||
await fs.writeFile(path.join(run.home, "user/private"), "pending private edit");
|
||||
await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.id, membership!.id));
|
||||
await expect(run.stop()).rejects.toThrow("no longer authorized");
|
||||
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(true);
|
||||
const svc = workFolderService(db, storage);
|
||||
const folder = await svc.ensure({ companyId, scope: "user", ownerId: userId });
|
||||
expect((await svc.list(folder)).files).toHaveLength(0);
|
||||
expect(await fs.readFile(path.join(run.home, "user/private"), "utf8")).toBe("pending private edit");
|
||||
await db.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.id, membership!.id));
|
||||
await run.stop(); active.splice(active.indexOf(run), 1);
|
||||
}, 120_000);
|
||||
|
||||
it("does not publish a partial repository checkpoint and retries a failed final save", async () => {
|
||||
const leaseId = randomUUID();
|
||||
const run = await prepare(path.join(root, "interrupted-checkpoint"), leaseId);
|
||||
await run.flush();
|
||||
const bindingId = run.manifest.repositories[0]!.bindingId;
|
||||
const [before] = await db.select().from(taskRepositoryBindings).where(eq(taskRepositoryBindings.id, bindingId));
|
||||
await fs.writeFile(path.join(run.primaryRepo, "new-unsaved-file"), "must survive a failed save");
|
||||
const put = storage.putObject.bind(storage);
|
||||
const fail = vi.spyOn(storage, "putObject").mockImplementation(async (input) => {
|
||||
if (input.objectKey.includes("/checkpoints/")) throw new Error("Injected storage outage");
|
||||
return put(input);
|
||||
});
|
||||
try {
|
||||
await expect(run.stop()).rejects.toThrow("Injected storage outage");
|
||||
const [after] = await db.select().from(taskRepositoryBindings).where(eq(taskRepositoryBindings.id, bindingId));
|
||||
expect(after!.checkpointKey).toBe(before!.checkpointKey);
|
||||
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(true);
|
||||
} finally { fail.mockRestore(); }
|
||||
await run.stop(); active.splice(active.indexOf(run), 1);
|
||||
await fs.rm(run.home, { recursive: true });
|
||||
const recovered = await prepare(path.join(root, "interrupted-recovered"), randomUUID());
|
||||
expect(await fs.readFile(path.join(recovered.primaryRepo, "new-unsaved-file"), "utf8")).toBe("must survive a failed save");
|
||||
await recovered.stop(); active.splice(active.indexOf(recovered), 1);
|
||||
}, 120_000);
|
||||
|
||||
it("seeds duplicate and reserved attachment names idempotently without changing original uploads", async () => {
|
||||
const attachmentIds: string[] = [];
|
||||
const originalKeys: string[] = [];
|
||||
const content = Buffer.from("original upload");
|
||||
for (const originalFilename of ["same.txt", "same.txt", ".", ".paperclip-runtime"]) {
|
||||
const id = randomUUID();
|
||||
const objectKey = `${companyId}/attachments/${id}`;
|
||||
originalKeys.push(objectKey);
|
||||
await storage.putObject({ objectKey, body: content, contentType: "text/plain", contentLength: content.length });
|
||||
await db.insert(assets).values({ id, companyId, provider: storage.id, objectKey, contentType: "text/plain", byteSize: content.length,
|
||||
sha256: createHash("sha256").update(content).digest("hex"), originalFilename });
|
||||
const attachmentId = randomUUID(); attachmentIds.push(attachmentId);
|
||||
await db.insert(issueAttachments).values({ id: attachmentId, companyId, issueId: taskId, assetId: id });
|
||||
}
|
||||
const leaseId = randomUUID();
|
||||
const home = path.join(root, "attachment-seeding");
|
||||
const first = await prepare(home, leaseId);
|
||||
const files = (await fs.readdir(path.join(home, "task"))).filter((file) => attachmentIds.some((id) => file.includes(id)));
|
||||
expect(files).toHaveLength(4);
|
||||
await fs.writeFile(path.join(home, "task", files[0]!), "edited working copy");
|
||||
await first.stop(); active.splice(active.indexOf(first), 1);
|
||||
const warm = await prepare(home, randomUUID(), leaseId);
|
||||
expect(await fs.readFile(path.join(home, "task", files[0]!), "utf8")).toBe("edited working copy");
|
||||
expect((await fs.readdir(path.join(home, "task"))).filter((file) => attachmentIds.some((id) => file.includes(id)))).toEqual(files);
|
||||
await warm.stop(); active.splice(active.indexOf(warm), 1);
|
||||
for (const objectKey of originalKeys) {
|
||||
const original = await storage.getObject({ objectKey });
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of original.stream) chunks.push(Buffer.from(chunk));
|
||||
expect(Buffer.concat(chunks)).toEqual(content);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -2797,7 +2797,7 @@ export function accessRoutes(
|
||||
|
||||
const isSignedInBoardUser =
|
||||
req.actor.type === "board" &&
|
||||
(req.actor.source === "session" || isLocalImplicit(req)) &&
|
||||
(req.actor.source === "session" || req.actor.source === "cloud_tenant" || isLocalImplicit(req)) &&
|
||||
Boolean(req.actor.userId);
|
||||
const canApprove =
|
||||
isSignedInBoardUser &&
|
||||
|
||||
@@ -1794,6 +1794,10 @@ function createSandboxEnvironmentDriver(
|
||||
}
|
||||
|
||||
const workerConfig = stripSandboxProviderEnvelope(parsed.config);
|
||||
// A provider reaper must not delete the only copy after a failed final
|
||||
// checkpoint. Normal release still deletes ephemeral sandboxes after
|
||||
// the host confirms durability; idle stop/archive remains enabled.
|
||||
if (boundRun && parsed.config.provider === "daytona") workerConfig.autoDeleteInterval = -1;
|
||||
const storedConfig = storedParsed.config;
|
||||
const providerConfigForLease = sandboxConfigForLeaseMetadata(storedConfig);
|
||||
// Require the reusable-lease capability AND a worker that verifies the
|
||||
@@ -1816,7 +1820,6 @@ function createSandboxEnvironmentDriver(
|
||||
declaredReusableLeases && capabilityIsVerified("reusableLeases", pluginVerifiedMethods);
|
||||
const leaseFingerprint =
|
||||
supportsReusableLeases &&
|
||||
parsed.config.reuseLease &&
|
||||
input.heartbeatRunId !== null &&
|
||||
input.executionWorkspaceId !== null &&
|
||||
input.agentId !== null
|
||||
@@ -1845,13 +1848,12 @@ function createSandboxEnvironmentDriver(
|
||||
// or terminal rows cannot be matched.
|
||||
const reusableCandidateLeases =
|
||||
supportsReusableLeases &&
|
||||
parsed.config.reuseLease &&
|
||||
input.heartbeatRunId !== null &&
|
||||
input.executionWorkspaceId !== null &&
|
||||
input.agentId !== null
|
||||
? (await environmentsSvc.listLeases(input.environment.id))
|
||||
.filter((lease) =>
|
||||
lease.leasePolicy === "reuse_by_environment" &&
|
||||
((parsed.config.reuseLease && lease.leasePolicy === "reuse_by_environment") || lease.metadata?.workFolderRecoveryRequired === true) &&
|
||||
reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) &&
|
||||
lease.executionWorkspaceId === input.executionWorkspaceId &&
|
||||
lease.metadata?.agentId === input.agentId,
|
||||
@@ -1876,6 +1878,9 @@ function createSandboxEnvironmentDriver(
|
||||
lease.heartbeatRunId === input.heartbeatRunId,
|
||||
}),
|
||||
);
|
||||
if (reusableCandidateLeases.some((lease) => lease.metadata?.workFolderRecoveryRequired === true && !reusableExistingLeases.includes(lease))) {
|
||||
throw new Error("Unsaved sandbox work requires recovery with its original run identity and configuration");
|
||||
}
|
||||
if (reusableCandidateLeases.length > reusableExistingLeases.length) {
|
||||
await cleanupObsoleteReusableSandboxLeases({
|
||||
environment: input.environment,
|
||||
@@ -1885,7 +1890,6 @@ function createSandboxEnvironmentDriver(
|
||||
}
|
||||
const reusableProviderLeaseId =
|
||||
supportsReusableLeases &&
|
||||
parsed.config.reuseLease &&
|
||||
input.heartbeatRunId !== null &&
|
||||
input.executionWorkspaceId !== null &&
|
||||
input.agentId !== null
|
||||
@@ -1982,6 +1986,9 @@ function createSandboxEnvironmentDriver(
|
||||
});
|
||||
}
|
||||
if (!providerLease) {
|
||||
if (await retainUnsavedWorkFolderLease(db, reusableLease)) {
|
||||
throw new Error("Saved sandbox could not be resumed; unsaved work was retained for recovery");
|
||||
}
|
||||
if (
|
||||
input.adapterType === "paperclip_runner" &&
|
||||
!verifyNativeHarnessBackupStamp(
|
||||
@@ -2046,7 +2053,7 @@ function createSandboxEnvironmentDriver(
|
||||
metadata: acquiredLease.metadata,
|
||||
schema: pluginProvider.resolved.driver.configSchema as Record<string, unknown> | null | undefined,
|
||||
});
|
||||
const reusableScope = resolvedLeasePolicy === "reuse_by_environment"
|
||||
const reusableScope = supportsReusableLeases && input.heartbeatRunId !== null
|
||||
? buildReusableSandboxLeaseScope({
|
||||
responsibleUserId,
|
||||
issueId: input.issueId,
|
||||
@@ -2186,7 +2193,6 @@ function createSandboxEnvironmentDriver(
|
||||
const providerConfigForLease = sandboxConfigForLeaseMetadata(parsed.config);
|
||||
const leaseFingerprint =
|
||||
supportsReusableLeases &&
|
||||
parsed.config.reuseLease &&
|
||||
input.heartbeatRunId !== null &&
|
||||
input.executionWorkspaceId !== null &&
|
||||
input.agentId !== null
|
||||
@@ -2203,13 +2209,12 @@ function createSandboxEnvironmentDriver(
|
||||
: null;
|
||||
const reusableCandidateLeases =
|
||||
supportsReusableLeases &&
|
||||
parsed.config.reuseLease &&
|
||||
input.heartbeatRunId !== null &&
|
||||
input.executionWorkspaceId !== null &&
|
||||
input.agentId !== null
|
||||
? (await environmentsSvc.listLeases(input.environment.id))
|
||||
.filter((lease) =>
|
||||
lease.leasePolicy === "reuse_by_environment" &&
|
||||
((parsed.config.reuseLease && lease.leasePolicy === "reuse_by_environment") || lease.metadata?.workFolderRecoveryRequired === true) &&
|
||||
reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) &&
|
||||
lease.executionWorkspaceId === input.executionWorkspaceId &&
|
||||
lease.metadata?.agentId === input.agentId,
|
||||
@@ -2234,6 +2239,9 @@ function createSandboxEnvironmentDriver(
|
||||
lease.heartbeatRunId === input.heartbeatRunId,
|
||||
}),
|
||||
);
|
||||
if (reusableCandidateLeases.some((lease) => lease.metadata?.workFolderRecoveryRequired === true && !reusableExistingLeases.includes(lease))) {
|
||||
throw new Error("Unsaved sandbox work requires recovery with its original run identity and configuration");
|
||||
}
|
||||
if (reusableCandidateLeases.length > reusableExistingLeases.length) {
|
||||
await cleanupObsoleteReusableSandboxLeases({
|
||||
environment: input.environment,
|
||||
@@ -2243,7 +2251,6 @@ function createSandboxEnvironmentDriver(
|
||||
}
|
||||
const reusableProviderLeaseId =
|
||||
supportsReusableLeases &&
|
||||
parsed.config.reuseLease &&
|
||||
input.heartbeatRunId !== null &&
|
||||
input.executionWorkspaceId !== null &&
|
||||
input.agentId !== null
|
||||
@@ -2290,7 +2297,7 @@ function createSandboxEnvironmentDriver(
|
||||
const resolvedLeasePolicy = supportsReusableLeases && parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||
? "reuse_by_environment"
|
||||
: "ephemeral";
|
||||
const reusableScope = resolvedLeasePolicy === "reuse_by_environment"
|
||||
const reusableScope = supportsReusableLeases && input.heartbeatRunId !== null
|
||||
? buildReusableSandboxLeaseScope({
|
||||
responsibleUserId,
|
||||
issueId: input.issueId,
|
||||
|
||||
@@ -19743,6 +19743,7 @@ export function heartbeatService(
|
||||
sandboxWorkFolders = await prepareSandboxWorkFolders({ db, companyId: run.companyId, runId: run.id,
|
||||
agentId: agent.id, responsibleUserId: run.responsibleUserId ?? null,
|
||||
taskId: issueRef?.id ?? null, projectId: issueRef?.projectId ?? null, target: executionTarget,
|
||||
primaryWorkspaceId: executionWorkspace.workspaceId, primaryBranchName: executionWorkspace.branchName,
|
||||
sandboxKey: workFolderSandboxKey(activeEnvironmentLease.lease) });
|
||||
if (sandboxWorkFolders.identityChanged) { taskSessionForRun = null; previousSessionParams = null; }
|
||||
executionTarget.workFolderHome = sandboxWorkFolders.home;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createReadStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { assets, issueAttachments, projectWorkspaces, taskRepositoryBindings, workFileOperations, workFolderRuns, workFolders, type Db } from "@paperclipai/db";
|
||||
import { agents, assets, companyMemberships, heartbeatRuns, issues, projects, issueAttachments, projectWorkspaces, taskRepositoryBindings, workFileOperations, workFolderRuns, workFolders, type Db } from "@paperclipai/db";
|
||||
import { WORK_FOLDER_SCOPES, type SandboxWorkFolderManifest, type WorkFolderScope } from "@paperclipai/shared";
|
||||
import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { StorageProvider } from "../storage/types.js";
|
||||
@@ -15,6 +15,7 @@ import { workFolderService } from "./work-folders.js";
|
||||
import { workFolderPaths, workFolderTransport, type WorkTreeEntry } from "./work-folder-transport.js";
|
||||
import { workFolderRepositoryService } from "./work-folder-repositories.js";
|
||||
import { startWorkFolderCheckpointer } from "./work-folder-checkpointer.js";
|
||||
import { assertWorkFolderAccess } from "./work-folder-access.js";
|
||||
|
||||
function signature(entry: WorkTreeEntry | undefined) {
|
||||
return entry ? JSON.stringify([entry.kind, entry.sha256, entry.executable]) : "missing";
|
||||
@@ -28,10 +29,43 @@ function repoName(value: string, id: string) {
|
||||
export async function prepareSandboxWorkFolders(input: {
|
||||
db: Db; companyId: string; runId: string; agentId: string; responsibleUserId: string | null;
|
||||
taskId: string | null; projectId: string | null; target: AdapterSandboxExecutionTarget;
|
||||
primaryWorkspaceId?: string | null; primaryBranchName?: string | null;
|
||||
storage?: StorageProvider; sandboxKey?: string;
|
||||
}) {
|
||||
const { db, target } = input;
|
||||
if (!target.runner || !target.leaseId) throw new Error("Sandbox file transport is unavailable");
|
||||
async function assertBindings() {
|
||||
const memberships: Array<{ companyId: string; membershipRole: string | null; status: string }> = [];
|
||||
const deny = () => { throw new Error("Sandbox work-folder access is no longer authorized; working files were retained"); };
|
||||
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
if (!run || run.agentId !== input.agentId || run.responsibleUserId !== input.responsibleUserId) deny();
|
||||
const [agent] = await db.select({ id: agents.id }).from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId)));
|
||||
if (!agent) deny();
|
||||
if (input.taskId) {
|
||||
const [task] = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, input.taskId), eq(issues.companyId, input.companyId)));
|
||||
if (!task) deny();
|
||||
}
|
||||
if (input.projectId) {
|
||||
const [project] = await db.select({ id: projects.id }).from(projects).where(and(eq(projects.id, input.projectId), eq(projects.companyId, input.companyId)));
|
||||
if (!project) deny();
|
||||
}
|
||||
if (input.responsibleUserId) {
|
||||
const [membership] = await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, input.companyId),
|
||||
eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, input.responsibleUserId), eq(companyMemberships.status, "active")));
|
||||
if (!membership || membership.membershipRole === "viewer") deny();
|
||||
if (membership) memberships.push({ companyId: membership.companyId, membershipRole: membership.membershipRole, status: membership.status });
|
||||
}
|
||||
for (const [scope, ownerId] of [["task", input.taskId], ["agent", input.agentId], ["project", input.projectId]] as const) {
|
||||
if (!ownerId) continue;
|
||||
await assertWorkFolderAccess(db, { type: "agent", source: "agent_jwt", companyId: input.companyId,
|
||||
agentId: input.agentId, runId: input.runId, onBehalfOfUserId: input.responsibleUserId, onBehalfOfMemberships: memberships },
|
||||
{ companyId: input.companyId, scope, ownerId }, true);
|
||||
}
|
||||
}
|
||||
// Host-side transfers do not go through HTTP authorization middleware. Check
|
||||
// the authoritative bindings here too, including after membership revocation.
|
||||
// An ended heartbeat may still flush; its immutable identity must still match.
|
||||
await assertBindings();
|
||||
const storage = input.storage ?? createStorageProviderFromConfig(loadConfig());
|
||||
const svc = workFolderService(db, storage);
|
||||
const transport = workFolderTransport(target.runner);
|
||||
@@ -77,11 +111,11 @@ export async function prepareSandboxWorkFolders(input: {
|
||||
const [seeded] = await db.select().from(workFileOperations).where(and(eq(workFileOperations.folderId, folders.task.id),
|
||||
eq(workFileOperations.operationId, operationId)));
|
||||
if (seeded) continue;
|
||||
const original = (asset.originalFilename ?? attachment.id).split(/[\\/]/).at(-1)!.replace(/[\x00-\x1f\x7f]/g, "_") || attachment.id;
|
||||
let filename = original;
|
||||
try { await svc.get(folders.task, filename); filename = `${original}-${attachment.id}`; } catch (error) {
|
||||
if ((error as { status?: number }).status !== 404) throw error;
|
||||
}
|
||||
const original = (asset.originalFilename ?? "attachment").split(/[\\/]/).at(-1)!.replace(/[\x00-\x1f\x7f]/g, "_").slice(0, 180) || "attachment";
|
||||
// The ID makes the destination independent of concurrent uploads and
|
||||
// earlier seeding attempts. Even dot/reserved filenames become safe.
|
||||
const extension = path.posix.extname(original);
|
||||
const filename = `${original.slice(0, original.length - extension.length)}-${attachment.id}${extension}`;
|
||||
const result = await storage.getObject({ objectKey: asset.objectKey });
|
||||
try { await svc.write(folders.task, { path: filename, body: result.stream, contentType: asset.contentType, operationId, onlyIfMissing: true }); }
|
||||
finally { result.stream.destroy(); }
|
||||
@@ -103,7 +137,7 @@ export async function prepareSandboxWorkFolders(input: {
|
||||
if (stat.isDirectory()) {
|
||||
if (relative) await svc.write(folder, { path: relative, kind: "directory", operationId: `import:${relative}`, onlyIfMissing: true });
|
||||
for (const name of (await fs.readdir(path.join(root, relative))).sort()) {
|
||||
if ([".codex", ".claude", ".cache", ".config", ".local", ".git", ".paperclip-runtime"].includes(name)) continue;
|
||||
if ([".codex", ".claude", ".cache", ".config", ".local", ".git", ".paperclip-runtime", ".ssh", ".aws", ".azure", ".netrc", ".git-credentials", ".npmrc", ".npm", "node_modules", ".venv"].includes(name)) continue;
|
||||
await visit(relative ? `${relative}/${name}` : name);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
@@ -192,6 +226,7 @@ export async function prepareSandboxWorkFolders(input: {
|
||||
const names = new Set(existing.map((binding) => binding.name));
|
||||
const resolveGitAuth = createGitRemoteAuthProvider(db, input.companyId, { responsibleUserId: input.responsibleUserId, agentId: input.agentId, issueId: input.taskId, heartbeatRunId: input.runId });
|
||||
for (const workspace of workspaces.filter((entry) => entry.repoUrl)) {
|
||||
const primary = input.primaryWorkspaceId ? workspace.id === input.primaryWorkspaceId : workspace.isPrimary;
|
||||
let binding = existing.find((entry) => entry.workspaceId === workspace.id);
|
||||
if (!binding) {
|
||||
const baseName = repoName(workspace.repoUrl!.split(/[/:]/).at(-1) ?? workspace.name, workspace.id);
|
||||
@@ -222,6 +257,18 @@ export async function prepareSandboxWorkFolders(input: {
|
||||
const checkout = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", binding.repoRef, "--"], bypassSession: true, timeoutMs: 60_000 });
|
||||
if (checkout.exitCode !== 0 || checkout.timedOut) throw new Error(`Required repository ${binding.name} ref could not be checked out`);
|
||||
}
|
||||
if (primary && input.primaryBranchName) {
|
||||
const branch = input.primaryBranchName;
|
||||
const valid = await target.runner!.execute({ command: "git", args: ["check-ref-format", "--branch", branch], bypassSession: true, timeoutMs: 10_000 });
|
||||
if (valid.exitCode !== 0 || valid.stdout.trim() !== branch) throw new Error(`Required repository ${binding.name} branch is invalid`);
|
||||
// Honor the task's existing branch policy on the initial clone.
|
||||
// Restores and warm starts keep the saved HEAD and index untouched.
|
||||
const checkout = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", branch, "--"], bypassSession: true, timeoutMs: 60_000 });
|
||||
if (checkout.exitCode !== 0) {
|
||||
const create = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", "-b", branch], bypassSession: true, timeoutMs: 60_000 });
|
||||
if (create.exitCode !== 0 || create.timedOut) throw new Error(`Required repository ${binding.name} task branch could not be created`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const init = await target.runner!.execute({ command: "git", args: ["-C", temporary, "init"], bypassSession: true, timeoutMs: 10_000 });
|
||||
if (init.exitCode !== 0) throw new Error(`Repository ${binding.name} could not be restored`);
|
||||
@@ -236,7 +283,7 @@ export async function prepareSandboxWorkFolders(input: {
|
||||
}
|
||||
await db.update(taskRepositoryBindings).set({ setupComplete: true, retiredAt: null }).where(eq(taskRepositoryBindings.id, binding.id));
|
||||
bindings.push({ binding, root });
|
||||
manifest.repositories.push({ bindingId: binding.id, workspaceId: workspace.id, name: binding.name, primary: workspace.isPrimary });
|
||||
manifest.repositories.push({ bindingId: binding.id, workspaceId: workspace.id, name: binding.name, primary });
|
||||
await saveState("starting");
|
||||
}
|
||||
for (const old of existing) if (!workspaces.some((workspace) => workspace.id === old.workspaceId)) {
|
||||
@@ -263,6 +310,7 @@ export async function prepareSandboxWorkFolders(input: {
|
||||
}
|
||||
const checkpointer = startWorkFolderCheckpointer({
|
||||
async checkpoint() {
|
||||
await assertBindings();
|
||||
await saveState("saving");
|
||||
for (const scope of WORK_FOLDER_SCOPES) await outgoing(scope);
|
||||
for (const { binding, root } of bindings) await repositories.checkpoint(binding, root);
|
||||
@@ -280,6 +328,7 @@ export async function prepareSandboxWorkFolders(input: {
|
||||
if (run?.refreshRequested) {
|
||||
// The agent has stopped. The successful final flush above protects its
|
||||
// edits before accepting incoming shared files at this safe boundary.
|
||||
await assertBindings();
|
||||
for (const scope of WORK_FOLDER_SCOPES) await incoming(scope);
|
||||
await db.update(workFolderRuns).set({ refreshRequested: false, baselines, updatedAt: new Date() })
|
||||
.where(eq(workFolderRuns.runId, input.runId));
|
||||
|
||||
@@ -56,8 +56,9 @@ export function workFolderRepositoryService(db: Db, storage: StorageProvider, tr
|
||||
for (let offset = 0; offset < published.length; offset += 1000) {
|
||||
await tx.update(workFolderObjects).set({ deleteAfter: null }).where(inArray(workFolderObjects.objectKey, published.slice(offset, offset + 1000)));
|
||||
}
|
||||
await tx.update(taskRepositoryBindings).set({ checkpointKey, checkpointSha256: digest, checkpointAt: new Date() })
|
||||
.where(and(eq(taskRepositoryBindings.id, binding.id), eq(taskRepositoryBindings.companyId, binding.companyId)));
|
||||
const updated = await tx.update(taskRepositoryBindings).set({ checkpointKey, checkpointSha256: digest, checkpointAt: new Date() })
|
||||
.where(and(eq(taskRepositoryBindings.id, binding.id), eq(taskRepositoryBindings.companyId, binding.companyId))).returning({ id: taskRepositoryBindings.id });
|
||||
if (!updated.length) throw new Error("Repository owner was deleted during checkpoint");
|
||||
});
|
||||
knownByBinding.set(binding.id, new Set(files.flatMap((file) => file.objectKey ? [file.objectKey] : [])));
|
||||
binding.checkpointKey = checkpointKey;
|
||||
|
||||
Reference in new issue
Block a user