Add sandbox work-folder persistence and shared lifecycle
Introduce company-scoped task, agent, user and project files, recoverable trash, object-backed repository checkpoints, and a shared 180-second lifecycle for sandbox execution. Wire file browsing and save status into the owner surfaces. This is an implementation checkpoint; staging acceptance and the complete merge gate remain outstanding.
This commit is contained in:
parent
392ab26b1e
commit
a13bb6b395
|
|
@ -166,6 +166,8 @@ export interface SandboxLeaseAcquisition {
|
|||
}
|
||||
|
||||
export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata {
|
||||
/** Host-selected work-folder home. Absent for local/old unmanaged execution. */
|
||||
workFolderHome?: string;
|
||||
kind: "remote";
|
||||
transport: "sandbox";
|
||||
providerKey?: string | null;
|
||||
|
|
@ -479,6 +481,7 @@ export function overrideAdapterExecutionTargetRemoteCwd(
|
|||
target: AdapterExecutionTarget | null | undefined,
|
||||
remoteCwd: string | null | undefined,
|
||||
): AdapterExecutionTarget | null | undefined {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox" && target.workFolderHome) return target;
|
||||
const nextRemoteCwd = remoteCwd?.trim();
|
||||
if (!target || target.kind !== "remote" || !nextRemoteCwd) {
|
||||
return target;
|
||||
|
|
@ -507,6 +510,7 @@ export function resolveAdapterExecutionTargetCwd(
|
|||
configuredCwd: string | null | undefined,
|
||||
localFallbackCwd: string,
|
||||
): string {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox" && target.workFolderHome) return target.workFolderHome;
|
||||
if (typeof configuredCwd === "string" && configuredCwd.trim().length > 0) {
|
||||
return configuredCwd;
|
||||
}
|
||||
|
|
@ -836,7 +840,7 @@ export async function runAdapterExecutionTargetProcess(
|
|||
const result = await runner.execute({
|
||||
command: execCommand,
|
||||
args: execArgs,
|
||||
cwd: target.remoteCwd,
|
||||
cwd: target.workFolderHome ?? target.remoteCwd,
|
||||
env,
|
||||
stdin: options.stdin,
|
||||
timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined,
|
||||
|
|
@ -1459,14 +1463,15 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
|
|||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
workspaceRemoteDir: input.workspaceRemoteDir,
|
||||
syncWorkspace: input.syncWorkspace,
|
||||
syncWorkspace: target.workFolderHome ? false : input.syncWorkspace,
|
||||
workspaceInboundMode: input.workspaceInboundMode,
|
||||
workspaceDurableSeed: input.workspaceDurableSeed,
|
||||
workspaceBaseline: input.workspaceBaseline,
|
||||
workspaceGitSnapshot: input.workspaceGitSnapshot,
|
||||
workspaceExclude: input.workspaceExclude,
|
||||
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
||||
assets: input.assets,
|
||||
assets: input.assets?.map((asset) => target.workFolderHome && input.adapterKey.includes("codex") && asset.key === "home"
|
||||
? { ...asset, remoteDir: path.posix.join(target.workFolderHome, ".codex") } : asset),
|
||||
additionalSources: input.additionalSources,
|
||||
installCommand: input.installCommand,
|
||||
detectCommand: input.detectCommand,
|
||||
|
|
@ -1754,7 +1759,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
|||
const commandPayload = Buffer.from(JSON.stringify({
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd || target.remoteCwd,
|
||||
cwd: target.transport === "sandbox" ? target.workFolderHome ?? input.cwd ?? target.remoteCwd : input.cwd || target.remoteCwd,
|
||||
// The ACP engine has already projected this launch env from explicit
|
||||
// adapter/runtime inputs and registered contributions. Compare against an
|
||||
// empty inherited baseline so an explicit identity value (notably PATH)
|
||||
|
|
@ -2059,7 +2064,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
|||
const streamCommandPayload = Buffer.from(JSON.stringify({
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd || target.remoteCwd,
|
||||
cwd: target.transport === "sandbox" ? target.workFolderHome ?? input.cwd ?? target.remoteCwd : input.cwd || target.remoteCwd,
|
||||
// Same provenance-clean contract as the polled payload above. Preserve
|
||||
// every explicit identity override even when it equals the host value.
|
||||
env: sanitizeRemoteExecutionEnv(launchEnvForStream, {}),
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@ export interface SandboxManagedRuntimeAssetRestoreContext {
|
|||
export interface SandboxManagedRuntimeAsset {
|
||||
key: string;
|
||||
localDir: string;
|
||||
/** Host-selected conventional CLI directory inside an isolated sandbox. */
|
||||
remoteDir?: string;
|
||||
followSymlinks?: boolean;
|
||||
exclude?: string[];
|
||||
/** Optional inbound provisioning contribution (staged files + extract command). */
|
||||
|
|
@ -1448,7 +1450,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
|||
inboundTasks.push(() =>
|
||||
runStepSpan(`stage.asset.${asset.key}`, async () => {
|
||||
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing runtime assets to environment");
|
||||
const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key);
|
||||
const remoteAssetDir = asset.remoteDir ?? path.posix.join(runtimeRootDir, asset.key);
|
||||
const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
|
||||
// Every asset — default OR custom-provisioned (e.g. an adapter credential
|
||||
// merge) — rides one `syncIn` operation: the asset tar plus any staged
|
||||
|
|
@ -1498,7 +1500,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
|||
files,
|
||||
postUploadCommands: [{ command: postUploadCommand }],
|
||||
sourceRoots: [tempDir],
|
||||
targetRoots: [runtimeRootDir],
|
||||
targetRoots: [runtimeRootDir, remoteAssetDir],
|
||||
progressLabel: asset.key,
|
||||
statusPhase: "config_sync",
|
||||
progressBytes: assetTarSize,
|
||||
|
|
@ -1608,7 +1610,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
|||
});
|
||||
|
||||
const assetDirs = Object.fromEntries(
|
||||
(input.assets ?? []).map((asset) => [asset.key, path.posix.join(runtimeRootDir, asset.key)]),
|
||||
(input.assets ?? []).map((asset) => [asset.key, asset.remoteDir ?? path.posix.join(runtimeRootDir, asset.key)]),
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -1848,7 +1850,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
|||
runStepSpan(`restore.asset.${assetKey}`, async () => {
|
||||
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
|
||||
await assetRestore({
|
||||
assetDir: path.posix.join(runtimeRootDir, assetKey),
|
||||
assetDir: asset.remoteDir ?? path.posix.join(runtimeRootDir, assetKey),
|
||||
readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)),
|
||||
tempDir,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
CREATE TABLE "task_repository_bindings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"task_id" uuid NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"repo_url" text,
|
||||
"repo_ref" text,
|
||||
"setup_complete" boolean DEFAULT false NOT NULL,
|
||||
"retired_at" timestamp with time zone,
|
||||
"checkpoint_key" text,
|
||||
"checkpoint_sha256" text,
|
||||
"checkpoint_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "task_repository_bindings_workspace_uq" UNIQUE("company_id","task_id","workspace_id"),
|
||||
CONSTRAINT "task_repository_bindings_name_uq" UNIQUE("company_id","task_id","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "work_file_operations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"folder_id" uuid NOT NULL,
|
||||
"operation_id" text NOT NULL,
|
||||
"fingerprint" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "work_file_operations_receipt_uq" UNIQUE("folder_id","operation_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "work_files" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"folder_id" uuid NOT NULL,
|
||||
"path" text NOT NULL,
|
||||
"kind" text DEFAULT 'file' NOT NULL,
|
||||
"object_key" text,
|
||||
"byte_size" bigint DEFAULT 0 NOT NULL,
|
||||
"sha256" text,
|
||||
"content_type" text DEFAULT 'application/octet-stream' NOT NULL,
|
||||
"executable" boolean DEFAULT false NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "work_folder_runs" (
|
||||
"run_id" uuid PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"manifest" jsonb NOT NULL,
|
||||
"state" text DEFAULT 'starting' NOT NULL,
|
||||
"last_saved_at" timestamp with time zone,
|
||||
"error" text,
|
||||
"refresh_requested" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "work_folders" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"scope" text NOT NULL,
|
||||
"owner_id" text NOT NULL,
|
||||
"imported_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "work_folders_owner_uq" UNIQUE("company_id","scope","owner_id"),
|
||||
CONSTRAINT "work_folders_company_id_uq" UNIQUE("company_id","id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "task_repository_bindings" ADD CONSTRAINT "task_repository_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_repository_bindings" ADD CONSTRAINT "task_repository_bindings_task_id_issues_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_file_operations" ADD CONSTRAINT "work_file_operations_company_id_folder_id_work_folders_company_id_id_fk" FOREIGN KEY ("company_id","folder_id") REFERENCES "public"."work_folders"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_files" ADD CONSTRAINT "work_files_company_id_folder_id_work_folders_company_id_id_fk" FOREIGN KEY ("company_id","folder_id") REFERENCES "public"."work_folders"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_folder_runs" ADD CONSTRAINT "work_folder_runs_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_folder_runs" ADD CONSTRAINT "work_folder_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "work_folders" ADD CONSTRAINT "work_folders_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "work_files_folder_path_uq" ON "work_files" USING btree ("folder_id","path") WHERE "work_files"."deleted_at" is null;--> statement-breakpoint
|
||||
CREATE INDEX "work_files_company_folder_idx" ON "work_files" USING btree ("company_id","folder_id");--> statement-breakpoint
|
||||
CREATE INDEX "work_folder_runs_company_idx" ON "work_folder_runs" USING btree ("company_id");
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "work_folder_runs" ADD COLUMN "baselines" jsonb DEFAULT '{}'::jsonb NOT NULL;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "work_folder_runs" ADD COLUMN "pending_operations" jsonb DEFAULT '{}'::jsonb NOT NULL;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE "work_folder_objects" (
|
||||
"object_key" text PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"folder_id" uuid,
|
||||
"repository_binding_id" uuid,
|
||||
"provider" text NOT NULL,
|
||||
"delete_after" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "work_folder_objects_cleanup_idx" ON "work_folder_objects" USING btree ("provider","delete_after");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1667,6 +1667,34 @@
|
|||
"when": 1788557575279,
|
||||
"tag": "0239_sturdy_santa_claus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 240,
|
||||
"version": "7",
|
||||
"when": 1788797570316,
|
||||
"tag": "0240_real_virginia_dare",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 241,
|
||||
"version": "7",
|
||||
"when": 1788798215713,
|
||||
"tag": "0241_mixed_crystal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 242,
|
||||
"version": "7",
|
||||
"when": 1788798605168,
|
||||
"tag": "0242_sudden_bedlam",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 243,
|
||||
"version": "7",
|
||||
"when": 1788799543754,
|
||||
"tag": "0243_ordinary_lucky_pierre",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -183,3 +183,4 @@ export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js
|
|||
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
||||
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
||||
export { pluginLogs } from "./plugin_logs.js";
|
||||
export * from "./work_folders.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { bigint, boolean, foreignKey, index, jsonb, pgTable, text, timestamp, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import type { SandboxWorkFolderManifest, WorkFolderScope } from "@paperclipai/shared";
|
||||
import { companies } from "./companies.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
export const workFolders = pgTable("work_folders", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
scope: text("scope").$type<WorkFolderScope>().notNull(),
|
||||
ownerId: text("owner_id").notNull(),
|
||||
importedAt: timestamp("imported_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => [unique("work_folders_owner_uq").on(t.companyId, t.scope, t.ownerId), unique("work_folders_company_id_uq").on(t.companyId, t.id)]);
|
||||
|
||||
export const workFiles = pgTable("work_files", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull(),
|
||||
folderId: uuid("folder_id").notNull(),
|
||||
path: text("path").notNull(),
|
||||
kind: text("kind").$type<"file" | "directory">().notNull().default("file"),
|
||||
objectKey: text("object_key"),
|
||||
byteSize: bigint("byte_size", { mode: "number" }).notNull().default(0),
|
||||
sha256: text("sha256"),
|
||||
contentType: text("content_type").notNull().default("application/octet-stream"),
|
||||
executable: boolean("executable").notNull().default(false),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
foreignKey({ columns: [t.companyId, t.folderId], foreignColumns: [workFolders.companyId, workFolders.id] }).onDelete("cascade"),
|
||||
uniqueIndex("work_files_folder_path_uq").on(t.folderId, t.path).where(sql`${t.deletedAt} is null`),
|
||||
index("work_files_company_folder_idx").on(t.companyId, t.folderId),
|
||||
]);
|
||||
|
||||
/** Content-free receipts prevent an old retry from overwriting a newer edit. */
|
||||
export const workFileOperations = pgTable("work_file_operations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull(),
|
||||
folderId: uuid("folder_id").notNull(),
|
||||
operationId: text("operation_id").notNull(),
|
||||
fingerprint: text("fingerprint").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
foreignKey({ columns: [t.companyId, t.folderId], foreignColumns: [workFolders.companyId, workFolders.id] }).onDelete("cascade"),
|
||||
unique("work_file_operations_receipt_uq").on(t.folderId, t.operationId),
|
||||
]);
|
||||
|
||||
export const taskRepositoryBindings = pgTable("task_repository_bindings", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
taskId: uuid("task_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
// Keep saved work after a project workspace is removed.
|
||||
workspaceId: uuid("workspace_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
repoUrl: text("repo_url"),
|
||||
repoRef: text("repo_ref"),
|
||||
setupComplete: boolean("setup_complete").notNull().default(false),
|
||||
retiredAt: timestamp("retired_at", { withTimezone: true }),
|
||||
checkpointKey: text("checkpoint_key"),
|
||||
checkpointSha256: text("checkpoint_sha256"),
|
||||
checkpointAt: timestamp("checkpoint_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => [unique("task_repository_bindings_workspace_uq").on(t.companyId, t.taskId, t.workspaceId), unique("task_repository_bindings_name_uq").on(t.companyId, t.taskId, t.name)]);
|
||||
|
||||
export const workFolderRuns = pgTable("work_folder_runs", {
|
||||
runId: uuid("run_id").primaryKey().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
manifest: jsonb("manifest").$type<SandboxWorkFolderManifest>().notNull(),
|
||||
baselines: jsonb("baselines").$type<Record<string, Array<{ path: string; kind: "file" | "directory"; byteSize: number; sha256: string | null; executable: boolean }>>>().notNull().default({}),
|
||||
pendingOperations: jsonb("pending_operations").$type<Record<string, { id: string; signature: string }>>().notNull().default({}),
|
||||
state: text("state").$type<"starting" | "saved" | "saving" | "failed">().notNull().default("starting"),
|
||||
lastSavedAt: timestamp("last_saved_at", { withTimezone: true }),
|
||||
error: text("error"),
|
||||
refreshRequested: boolean("refresh_requested").notNull().default(false),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => [index("work_folder_runs_company_idx").on(t.companyId)]);
|
||||
|
||||
/** Upload intents and deferred deletion survive uncertain commits and owner deletion. */
|
||||
export const workFolderObjects = pgTable("work_folder_objects", {
|
||||
objectKey: text("object_key").primaryKey(),
|
||||
companyId: uuid("company_id").notNull(),
|
||||
folderId: uuid("folder_id"),
|
||||
repositoryBindingId: uuid("repository_binding_id"),
|
||||
provider: text("provider").notNull(),
|
||||
deleteAfter: timestamp("delete_after", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => [index("work_folder_objects_cleanup_idx").on(t.provider, t.deleteAfter)]);
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { externalWorkFolderEnvironment } from "../../work-folder-environment.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
constants,
|
||||
|
|
@ -405,6 +406,7 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
|||
};
|
||||
Object.assign(launchEnvironment, {
|
||||
HOME: homeDirectory,
|
||||
...externalWorkFolderEnvironment(input.environment ?? {}),
|
||||
XDG_CONFIG_HOME: configDirectory,
|
||||
XDG_DATA_HOME: dataDirectory,
|
||||
XDG_CACHE_HOME: cacheDirectory,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { externalWorkFolderEnvironment } from "../../work-folder-environment.js";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js";
|
||||
import { githubCredentialEnvironment } from "../../github-credential-environment.js";
|
||||
|
|
@ -229,7 +230,7 @@ export function createSanitizedCodexEnvironment(
|
|||
if (key.includes("PROXY") && proxyContainsCredentials(value)) continue;
|
||||
environment[key] = value;
|
||||
}
|
||||
Object.assign(environment, githubCredentialEnvironment(source));
|
||||
Object.assign(environment, githubCredentialEnvironment(source), externalWorkFolderEnvironment(source));
|
||||
return environment;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { resolve } from "node:path";
|
||||
import { externalWorkFolderEnvironment } from "../../work-folder-environment.js";
|
||||
|
||||
import {
|
||||
githubCredentialEnvironmentKeys,
|
||||
|
|
@ -42,6 +43,9 @@ export function codexCommandEnvironment(
|
|||
const value = source[key];
|
||||
if (value !== undefined) environment[key] = value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(externalWorkFolderEnvironment(source))) {
|
||||
if (value !== undefined) environment[key] = value;
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { externalWorkFolderEnvironment } from "../../work-folder-environment.js";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
|
|
@ -1931,6 +1932,7 @@ async function startRuntime(input: {
|
|||
input.options.environment ?? process.env,
|
||||
{
|
||||
HOME: isolatedHome,
|
||||
...externalWorkFolderEnvironment(input.options.environment ?? {}),
|
||||
XDG_CONFIG_HOME: configHome,
|
||||
XDG_DATA_HOME: dataHome,
|
||||
XDG_CACHE_HOME: cacheHome,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { externalWorkFolderEnvironment } from "./work-folder-environment.js";
|
||||
import { createSanitizedCodexEnvironment } from "./drivers/codex/app-server-transport.js";
|
||||
import { codexCommandEnvironment } from "./drivers/codex/codex-security-config.js";
|
||||
|
||||
describe("external sandbox work-folder environment", () => {
|
||||
const home = "/home/daytona";
|
||||
const environment = { HOME: home, PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1", PAPERCLIP_TASK_DIR: `${home}/task`,
|
||||
PAPERCLIP_AGENT_DIR: `${home}/agent`, PAPERCLIP_USER_DIR: `${home}/user`, PAPERCLIP_PROJECT_DIR: `${home}/project`,
|
||||
PAPERCLIP_REPOS_DIR: `${home}/repos`, PAPERCLIP_PRIMARY_REPO: `${home}/repos/main` };
|
||||
it("preserves natural HOME and scoped paths for both Codex and its shell tools", () => {
|
||||
expect(createSanitizedCodexEnvironment(environment)).toMatchObject(externalWorkFolderEnvironment(environment));
|
||||
expect(codexCommandEnvironment(environment)).toMatchObject(externalWorkFolderEnvironment(environment));
|
||||
});
|
||||
it("leaves local execution unchanged and rejects inconsistent sandbox bindings", () => {
|
||||
expect(externalWorkFolderEnvironment({ ...environment, PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: undefined })).toEqual({});
|
||||
expect(() => externalWorkFolderEnvironment({ ...environment, PAPERCLIP_USER_DIR: "/other/user" })).toThrow("does not match");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import path from "node:path";
|
||||
|
||||
/** Only the host-marked external sandbox may expose the scoped home layout. */
|
||||
export function externalWorkFolderEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const home = source.HOME;
|
||||
if (source.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX !== "1" || !home || !source.PAPERCLIP_TASK_DIR) return {};
|
||||
if (!path.isAbsolute(home) || path.resolve(home) !== home || home === "/") throw new Error("Invalid sandbox work-folder home");
|
||||
const result: NodeJS.ProcessEnv = { HOME: home };
|
||||
for (const scope of ["task", "agent", "user", "project", "repos"] as const) {
|
||||
const key = `PAPERCLIP_${scope.toUpperCase()}_DIR`;
|
||||
const expected = path.join(home, scope);
|
||||
if (source[key] !== expected) throw new Error("Sandbox work-folder environment does not match its home");
|
||||
result[key] = expected;
|
||||
}
|
||||
result.AGENT_HOME = path.join(home, "agent");
|
||||
const primary = source.PAPERCLIP_PRIMARY_REPO;
|
||||
if (primary && (primary.startsWith(`${home}/repos/`) || primary === `${home}/task`)) result.PAPERCLIP_PRIMARY_REPO = primary;
|
||||
return result;
|
||||
}
|
||||
|
|
@ -2728,3 +2728,4 @@ export {
|
|||
rewriteUrlHostToLoopback,
|
||||
} from "./runtime-exposure/loopback-bind.js";
|
||||
export { ACCOUNT_HANDLE_MAX_LENGTH, toAccountHandle } from "./account-handle.js";
|
||||
export * from "./work-folders.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
/** Durable Paperclip files; CLI homes and caches are deliberately not scopes. */
|
||||
export const WORK_FOLDER_SCOPES = ["task", "agent", "user", "project"] as const;
|
||||
export type WorkFolderScope = (typeof WORK_FOLDER_SCOPES)[number];
|
||||
export const WORK_FOLDER_SYNC_INTERVAL_MS = 180_000;
|
||||
|
||||
export interface WorkFolderOwner {
|
||||
companyId: string;
|
||||
scope: WorkFolderScope;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface WorkFile {
|
||||
id: string;
|
||||
path: string;
|
||||
kind: "file" | "directory";
|
||||
byteSize: number;
|
||||
sha256: string | null;
|
||||
executable: boolean;
|
||||
contentType: string;
|
||||
deletedAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkFolderListing {
|
||||
id: string;
|
||||
owner: WorkFolderOwner;
|
||||
files: WorkFile[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface WorkFolderSyncStatus {
|
||||
runId: string;
|
||||
state: "starting" | "saved" | "saving" | "failed";
|
||||
lastSavedAt: string | null;
|
||||
error: string | null;
|
||||
refreshRequested: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface SandboxWorkFolderManifest {
|
||||
version: 1;
|
||||
companyId: string;
|
||||
runId: string;
|
||||
taskId: string | null;
|
||||
agentId: string;
|
||||
responsibleUserId: string | null;
|
||||
projectId: string | null;
|
||||
leaseId: string;
|
||||
sandboxKey: string;
|
||||
home: string;
|
||||
finalCheckpointAt?: string;
|
||||
folders: Record<WorkFolderScope, string | null>;
|
||||
repositories: Array<{ bindingId: string; workspaceId: string; name: string; primary: boolean }>;
|
||||
}
|
||||
|
||||
/** Reject ambiguous paths instead of normalizing traversal into a valid path. */
|
||||
export function validateWorkFilePath(value: string): string {
|
||||
if (!value || value.length > 1024 || /[\\\x00-\x1f\x7f]/.test(value)
|
||||
|| value.split("/").some((part) => !part || part === "." || part === ".." || part === ".paperclip-runtime")) {
|
||||
throw new Error("Invalid work file path");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
|
@ -444,7 +444,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
|||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
reusableSandboxLease: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
responsibleUserId: null,
|
||||
issueId: null,
|
||||
companyId,
|
||||
environmentId: environment.id,
|
||||
executionWorkspaceId,
|
||||
|
|
@ -4637,7 +4639,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
|||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
reusableSandboxLease: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
responsibleUserId: null,
|
||||
issueId: null,
|
||||
companyId,
|
||||
environmentId: environment.id,
|
||||
executionWorkspaceId,
|
||||
|
|
@ -4982,7 +4986,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
|||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
reusableSandboxLease: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
responsibleUserId: null,
|
||||
issueId: null,
|
||||
companyId,
|
||||
environmentId: environment.id,
|
||||
executionWorkspaceId,
|
||||
|
|
@ -5748,7 +5754,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
|||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
reusableSandboxLease: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
responsibleUserId: null,
|
||||
issueId: null,
|
||||
companyId,
|
||||
environmentId: environment.id,
|
||||
executionWorkspaceId,
|
||||
|
|
@ -5903,7 +5911,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
|||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
reusableSandboxLease: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
responsibleUserId: null,
|
||||
issueId: null,
|
||||
companyId,
|
||||
environmentId: environment.id,
|
||||
executionWorkspaceId,
|
||||
|
|
@ -6064,7 +6074,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
|||
timeoutMs: 1234,
|
||||
reuseLease: true,
|
||||
reusableSandboxLease: {
|
||||
version: 1,
|
||||
version: 2,
|
||||
responsibleUserId: null,
|
||||
issueId: null,
|
||||
companyId,
|
||||
environmentId: environment.id,
|
||||
executionWorkspaceId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
const exec = promisify(execFile);
|
||||
export const localTestWorkFolderRunner: CommandManagedRuntimeRunner = {
|
||||
async execute(input) {
|
||||
try {
|
||||
const { stdout, stderr } = await exec(input.command, input.args ?? [], { cwd: input.cwd,
|
||||
env: { ...process.env, ...input.env }, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 });
|
||||
return { stdout, stderr, exitCode: 0, signal: null, timedOut: false };
|
||||
} catch (error) {
|
||||
const value = error as Error & { stdout?: string; stderr?: string };
|
||||
return { stdout: value.stdout ?? "", stderr: value.stderr ?? value.message, exitCode: 1, signal: null, timedOut: false };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { randomUUID } from "node:crypto";
|
||||
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 { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { prepareSandboxWorkFolders } from "../services/sandbox-work-folders.js";
|
||||
import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "../services/work-folder-retention.js";
|
||||
import { workFolderService } from "../services/work-folders.js";
|
||||
import { localTestWorkFolderRunner } from "./helpers/work-folder-runner.js";
|
||||
const exec = promisify(execFile);
|
||||
|
||||
describe("shared sandbox work-folder lifecycle", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
let root: string;
|
||||
let storage: ReturnType<typeof createLocalDiskStorageProvider>;
|
||||
const companyId = randomUUID(), agentId = randomUUID(), projectId = randomUUID(), taskId = randomUUID(), environmentId = randomUUID();
|
||||
const active: Array<Awaited<ReturnType<typeof prepareSandboxWorkFolders>>> = [];
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-sandbox-folders-");
|
||||
db = createDb(database.connectionString);
|
||||
root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-folders-")));
|
||||
storage = createLocalDiskStorageProvider(path.join(root, "bucket"));
|
||||
await db.insert(companies).values({ id: companyId, name: "Sandbox folder tests" });
|
||||
await db.insert(environments).values({ id: environmentId, name: "Test sandbox", driver: "sandbox", config: {} });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Agent" });
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Project" });
|
||||
await db.insert(issues).values({ id: taskId, companyId, projectId, title: "Task", assigneeAgentId: agentId });
|
||||
for (const name of ["repo-one", "repo-two"]) {
|
||||
const source = path.join(root, name);
|
||||
await exec("git", ["init", source]);
|
||||
await fs.writeFile(path.join(source, "tracked"), "initial\n");
|
||||
await fs.symlink("tracked", path.join(source, "link"));
|
||||
await exec("git", ["-C", source, "add", "."]);
|
||||
await exec("git", ["-C", source, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial"]);
|
||||
await db.insert(projectWorkspaces).values({ companyId, projectId, name, repoUrl: source, sourceType: "git_repo", isPrimary: name === "repo-one" });
|
||||
}
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
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) {
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, 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,
|
||||
runner: { execute: (input) => localTestWorkFolderRunner.execute({ ...input, env: { ...input.env, HOME: home } }) } } });
|
||||
active.push(run); return run;
|
||||
}
|
||||
it("reuses clones and restores saved unpushed work, staged changes, and task files after losing the sandbox", async () => {
|
||||
const home = path.join(root, "sandbox");
|
||||
const leaseId = randomUUID();
|
||||
const first = await prepare(home, leaseId);
|
||||
expect(first.home).toBe(home);
|
||||
expect(first.manifest.repositories).toHaveLength(2);
|
||||
expect(first.primaryRepo).toBe(path.join(home, "repos/repo-one"));
|
||||
await fs.writeFile(path.join(home, "task/report.md"), "durable task file");
|
||||
const repo = first.primaryRepo;
|
||||
await fs.writeFile(path.join(repo, "tracked"), "committed\n");
|
||||
await exec("git", ["-C", repo, "add", "."]);
|
||||
await exec("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "unpushed"]);
|
||||
const expectedHead = (await exec("git", ["-C", repo, "rev-parse", "HEAD"])).stdout.trim();
|
||||
await fs.writeFile(path.join(repo, "tracked"), "staged\n");
|
||||
await exec("git", ["-C", repo, "add", "tracked"]);
|
||||
await fs.writeFile(path.join(repo, "tracked"), "unstaged\n");
|
||||
await fs.writeFile(path.join(repo, "untracked"), "untracked\n");
|
||||
await first.stop(); active.splice(active.indexOf(first), 1);
|
||||
const warm = await prepare(home, randomUUID(), leaseId);
|
||||
expect(await fs.readFile(path.join(repo, "tracked"), "utf8")).toBe("unstaged\n");
|
||||
await warm.stop(); active.splice(active.indexOf(warm), 1);
|
||||
await fs.rm(home, { recursive: true });
|
||||
const restored = await prepare(path.join(root, "replacement"), randomUUID());
|
||||
expect(await fs.readFile(path.join(restored.home, "task/report.md"), "utf8")).toBe("durable task file");
|
||||
expect((await exec("git", ["-C", restored.primaryRepo, "rev-parse", "HEAD"])).stdout.trim()).toBe(expectedHead);
|
||||
expect((await exec("git", ["-C", restored.primaryRepo, "show", ":tracked"])).stdout).toBe("staged\n");
|
||||
expect(await fs.readFile(path.join(restored.primaryRepo, "tracked"), "utf8")).toBe("unstaged\n");
|
||||
expect(await fs.readFile(path.join(restored.primaryRepo, "untracked"), "utf8")).toBe("untracked\n");
|
||||
expect(await fs.readlink(path.join(restored.primaryRepo, "link"))).toBe("tracked");
|
||||
await restored.stop(); active.splice(active.indexOf(restored), 1);
|
||||
}, 120_000);
|
||||
it("does not let an unchanged stale shared file overwrite a newer durable value", async () => {
|
||||
const svc = workFolderService(db, storage);
|
||||
const folder = await svc.ensure({ companyId, scope: "project", ownerId: projectId });
|
||||
await svc.write(folder, { path: "shared.md", body: Buffer.from("first"), operationId: randomUUID() });
|
||||
const run = await prepare(path.join(root, "stale-sandbox"), randomUUID());
|
||||
await svc.write(folder, { path: "shared.md", body: Buffer.from("newer"), operationId: randomUUID() });
|
||||
await run.stop(); active.splice(active.indexOf(run), 1);
|
||||
const result = await svc.content(folder, "shared.md");
|
||||
const chunks = []; for await (const chunk of result.stream) chunks.push(Buffer.from(chunk));
|
||||
expect(Buffer.concat(chunks).toString()).toBe("newer");
|
||||
}, 120_000);
|
||||
it("retains the only working copy until a final checkpoint succeeds", async () => {
|
||||
const leaseId = randomUUID();
|
||||
const run = await prepare(path.join(root, "retained-sandbox"), leaseId);
|
||||
await fs.writeFile(path.join(run.home, "task/pending"), "recover me");
|
||||
await run.flush();
|
||||
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(true);
|
||||
await run.stop(); active.splice(active.indexOf(run), 1);
|
||||
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(false);
|
||||
}, 120_000);
|
||||
it("reconciles file-directory replacements and preserves deleted children in trash", async () => {
|
||||
const svc = workFolderService(db, storage);
|
||||
const folder = await svc.ensure({ companyId, scope: "project", ownerId: projectId });
|
||||
await svc.write(folder, { path: "replace/child", body: Buffer.from("child"), operationId: randomUUID() });
|
||||
const leaseId = randomUUID();
|
||||
const home = path.join(root, "replacement-kinds");
|
||||
const run = await prepare(home, leaseId);
|
||||
await fs.rm(path.join(home, "project/replace"), { recursive: true });
|
||||
await fs.writeFile(path.join(home, "project/replace"), "now a file");
|
||||
await run.stop(); active.splice(active.indexOf(run), 1);
|
||||
expect((await svc.list(folder, { trash: true })).files.map((file) => file.path)).toContain("replace/child");
|
||||
await svc.write(folder, { path: "replace", kind: "directory", replaceKind: true, operationId: randomUUID() });
|
||||
const resumed = await prepare(home, randomUUID(), leaseId);
|
||||
expect((await fs.stat(path.join(home, "project/replace"))).isDirectory()).toBe(true);
|
||||
await resumed.stop(); active.splice(active.indexOf(resumed), 1);
|
||||
}, 120_000);
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { startWorkFolderCheckpointer } from "../services/work-folder-checkpointer.js";
|
||||
|
||||
describe("shared work folder checkpoint cadence", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
it("waits 180 seconds, does not overlap, and flushes after the last in-flight save", async () => {
|
||||
vi.useFakeTimers();
|
||||
let finish!: () => void;
|
||||
const checkpoint = vi.fn().mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve; }))
|
||||
.mockResolvedValue(undefined);
|
||||
const sync = startWorkFolderCheckpointer({ checkpoint, onError: vi.fn() });
|
||||
await vi.advanceTimersByTimeAsync(179_999);
|
||||
expect(checkpoint).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(checkpoint).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(360_000);
|
||||
expect(checkpoint).toHaveBeenCalledTimes(1);
|
||||
const stopped = sync.stop();
|
||||
finish();
|
||||
await stopped;
|
||||
expect(checkpoint).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
expect(checkpoint).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("reports periodic errors and fails completion if the final save fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const error = new Error("Storage unavailable");
|
||||
const onError = vi.fn().mockResolvedValue(undefined);
|
||||
const sync = startWorkFolderCheckpointer({ checkpoint: vi.fn().mockRejectedValue(error), onError });
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
await expect(sync.stop()).rejects.toThrow("Storage unavailable");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, heartbeatRuns, startEmbeddedPostgresTestDatabase, workFolderRuns, type Db } from "@paperclipai/db";
|
||||
import { workFolderRoutes } from "../routes/work-folders.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { workFolderService } from "../services/work-folders.js";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import type { AuthorizationActor } from "../services/authorization.js";
|
||||
|
||||
describe("work folder HTTP ownership and streaming", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db, root: string;
|
||||
let storage: ReturnType<typeof createLocalDiskStorageProvider>;
|
||||
const companyId = randomUUID(), otherCompanyId = randomUUID(), ownerId = randomUUID(), otherUserId = randomUUID(), agentId = randomUUID();
|
||||
const base = `/api/companies/${companyId}/work-folders/user/${ownerId}`;
|
||||
const owner: AuthorizationActor = { type: "board", source: "session", userId: ownerId, companyIds: [companyId] };
|
||||
function app(actor: AuthorizationActor) {
|
||||
const server = express();
|
||||
server.use(express.json());
|
||||
server.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); });
|
||||
server.use("/api", workFolderRoutes(db, storage));
|
||||
server.use(errorHandler);
|
||||
return server;
|
||||
}
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-work-folder-routes-"); db = createDb(database.connectionString);
|
||||
root = await mkdtemp(path.join(os.tmpdir(), "paperclip-work-folder-http-")); storage = createLocalDiskStorageProvider(root);
|
||||
await db.insert(companies).values([{ id: companyId, name: "Files", issuePrefix: "FIL" }, { id: otherCompanyId, name: "Other", issuePrefix: "OTH" }]);
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Runner" });
|
||||
for (const id of [ownerId, otherUserId]) {
|
||||
await db.insert(authUsers).values({ id, name: id, email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: id, membershipRole: "owner", status: "active" });
|
||||
}
|
||||
}, 60_000);
|
||||
afterAll(async () => { await database?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); });
|
||||
it("uploads empty files and streams nested content with safe download headers", async () => {
|
||||
await request(app(owner)).put(`${base}/content?path=empty`).set("Content-Type", "application/octet-stream").send(Buffer.alloc(0)).expect(200);
|
||||
const body = Buffer.from("#!/bin/sh\ntrue\n");
|
||||
await request(app(owner)).put(`${base}/content?path=bin/run`).set("Content-Type", "application/octet-stream")
|
||||
.set("X-File-Executable", "true").send(body).expect(200);
|
||||
const listed = await request(app(owner)).get(base).expect(200);
|
||||
expect(listed.body.files.find((file: { path: string }) => file.path === "empty").byteSize).toBe(0);
|
||||
expect(listed.body.files.find((file: { path: string }) => file.path === "bin/run").executable).toBe(true);
|
||||
const downloaded = await request(app(owner)).get(`${base}/content?path=bin/run`).expect(200);
|
||||
expect(downloaded.body).toEqual(body);
|
||||
expect(downloaded.headers["content-security-policy"]).toContain("sandbox");
|
||||
expect(downloaded.headers["cache-control"]).toBe("private, no-store");
|
||||
});
|
||||
it("denies another company member every private-file operation", async () => {
|
||||
const other = app({ type: "board", source: "session", userId: otherUserId, companyIds: [companyId] });
|
||||
await request(other).get(base).expect(404);
|
||||
await request(other).get(`${base}/content?path=empty`).expect(404);
|
||||
await request(other).put(`${base}/content?path=empty`).set("Content-Type", "application/octet-stream").send("changed").expect(404);
|
||||
for (const action of ["delete", "mkdir", "restore", "purge"]) await request(other).post(`${base}/operations`).send({ action, path: "empty", fileId: randomUUID() }).expect(404);
|
||||
await request(other).get(`${base}/sync`).expect(404);
|
||||
await request(other).post(`${base}/refresh`).send({ runId: randomUUID() }).expect(404);
|
||||
const foreign = app({ type: "agent", source: "agent_key", companyId: otherCompanyId, agentId: randomUUID() });
|
||||
await request(foreign).get(base).expect(404);
|
||||
});
|
||||
it("authorizes a bound running agent and immediately honors membership revocation", async () => {
|
||||
const folder = await workFolderService(db, storage).ensure({ companyId, scope: "user", ownerId });
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, responsibleUserId: ownerId, status: "running" });
|
||||
await db.insert(workFolderRuns).values({ runId, companyId, manifest: { version: 1, companyId, runId, agentId,
|
||||
taskId: null, responsibleUserId: ownerId, projectId: null, leaseId: randomUUID(), sandboxKey: randomUUID(), home: "/home/daytona",
|
||||
folders: { task: null, agent: null, user: folder.id, project: null }, repositories: [] } });
|
||||
const runApp = app({ type: "agent", source: "agent_jwt", companyId, agentId, runId, onBehalfOfUserId: ownerId });
|
||||
await request(runApp).get(base).expect(200);
|
||||
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, ownerId)));
|
||||
await request(runApp).get(base).expect(404);
|
||||
await db.update(companyMemberships).set({ status: "active" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, ownerId)));
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
||||
await request(runApp).get(base).expect(404);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { createHash } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { workFolderTransport } from "../services/work-folder-transport.js";
|
||||
import { localTestWorkFolderRunner } from "./helpers/work-folder-runner.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
describe("sandbox work folder transport with real Node and Git", () => {
|
||||
const roots: string[] = [];
|
||||
const transport = workFolderTransport(localTestWorkFolderRunner);
|
||||
async function root() {
|
||||
const dir = await realpath(await mkdtemp(path.join(os.tmpdir(), "work-folder-io-")));
|
||||
roots.push(dir); return dir;
|
||||
}
|
||||
afterEach(async () => { for (const dir of roots.splice(0)) await rm(dir, { recursive: true, force: true }); });
|
||||
it("streams and atomically publishes files larger than a transfer chunk", async () => {
|
||||
const dir = await root();
|
||||
const staging = await root();
|
||||
const body = Buffer.alloc(700_000, "x");
|
||||
const entry = { path: "nested/file", kind: "file" as const, byteSize: body.length,
|
||||
sha256: createHash("sha256").update(body).digest("hex"), executable: true };
|
||||
await transport.write(dir, staging, entry, Readable.from([body]));
|
||||
expect(await readFile(path.join(dir, entry.path))).toEqual(body);
|
||||
const files = await transport.scan(dir);
|
||||
expect(files.find((file) => file.path === entry.path)).toEqual(entry);
|
||||
});
|
||||
it("rejects links out of a work folder on scan and download", async () => {
|
||||
const dir = await root();
|
||||
const outside = await root();
|
||||
await writeFile(path.join(outside, "credential"), "private");
|
||||
await symlink(path.join(outside, "credential"), path.join(dir, "link"));
|
||||
await expect(transport.scan(dir)).rejects.toThrow("symlink_not_allowed");
|
||||
const stream = transport.read(dir, "link", 7);
|
||||
await expect((async () => { for await (const _chunk of stream) { /* consume */ } })()).rejects.toThrow("symlink_not_allowed");
|
||||
});
|
||||
it("includes uncommitted tracked and nonignored files plus Git state, excluding credentials and caches", async () => {
|
||||
const dir = await root();
|
||||
await exec("git", ["init", dir]);
|
||||
await writeFile(path.join(dir, ".gitignore"), "node_modules/\n");
|
||||
await writeFile(path.join(dir, "tracked"), "initial");
|
||||
await exec("git", ["-C", dir, "add", "."]);
|
||||
await exec("git", ["-C", dir, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial"]);
|
||||
await writeFile(path.join(dir, "tracked"), "unstaged");
|
||||
await writeFile(path.join(dir, "untracked"), "new");
|
||||
await mkdir(path.join(dir, "node_modules"));
|
||||
await writeFile(path.join(dir, "node_modules/cache"), "ignored");
|
||||
const paths = (await transport.scan(dir, true)).map((entry) => entry.path);
|
||||
expect(paths).toContain("tracked");
|
||||
expect(paths).toContain("untracked");
|
||||
expect(paths).toContain(".git/index");
|
||||
expect(paths).toContain(".git/HEAD");
|
||||
expect(paths).not.toContain(".git/config");
|
||||
expect(paths.some((entry) => entry.startsWith("node_modules/"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { agents, workFolderObjects, workFolders, companies, createDb, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
|
||||
import { validateWorkFilePath } from "@paperclipai/shared";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { collectWorkFolderGarbage } from "../services/work-folder-garbage.js";
|
||||
import { workFolderService } from "../services/work-folders.js";
|
||||
import { assertWorkFolderAccess } from "../services/work-folder-access.js";
|
||||
|
||||
describe("durable work folders", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
let root: string;
|
||||
let storage: ReturnType<typeof createLocalDiskStorageProvider>;
|
||||
let svc: ReturnType<typeof workFolderService>;
|
||||
const companyId = randomUUID();
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-work-folders-");
|
||||
db = createDb(database.connectionString);
|
||||
await db.insert(companies).values({ id: companyId, name: "Work folders test" });
|
||||
root = await mkdtemp(path.join(os.tmpdir(), "paperclip-work-folders-"));
|
||||
storage = createLocalDiskStorageProvider(root);
|
||||
svc = workFolderService(db, storage);
|
||||
}, 60_000);
|
||||
afterAll(async () => { await database?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); });
|
||||
const folder = async () => {
|
||||
const ownerId = randomUUID();
|
||||
await db.insert(agents).values({ id: ownerId, companyId, name: "File owner" });
|
||||
return svc.ensure({ companyId, scope: "agent", ownerId });
|
||||
};
|
||||
async function textContent(f: Awaited<ReturnType<typeof folder>>, filePath: string) {
|
||||
const { stream } = await svc.content(f, filePath);
|
||||
const buffers: Buffer[] = [];
|
||||
for await (const chunk of stream) buffers.push(Buffer.from(chunk));
|
||||
return Buffer.concat(buffers).toString();
|
||||
}
|
||||
it("streams nested executable and empty files into durable storage", async () => {
|
||||
const f = await folder();
|
||||
await svc.write(f, { path: "bin/run", body: Readable.from(["#!/bin/sh\n", "true\n"]), executable: true, operationId: "first" });
|
||||
await svc.write(f, { path: "empty", body: Buffer.alloc(0), operationId: "empty" });
|
||||
expect(await textContent(f, "bin/run")).toBe("#!/bin/sh\ntrue\n");
|
||||
expect(await textContent(f, "empty")).toBe("");
|
||||
expect((await svc.get(f, "bin/run")).executable).toBe(true);
|
||||
expect((await svc.get(f, "bin")).kind).toBe("directory");
|
||||
});
|
||||
it("does not replay an older accepted write over newer content", async () => {
|
||||
const f = await folder();
|
||||
const first = { path: "memory.md", body: Buffer.from("first"), operationId: "first" };
|
||||
await svc.write(f, first);
|
||||
await svc.write(f, { ...first, body: Buffer.from("second"), operationId: "second" });
|
||||
expect(await svc.write(f, first)).toEqual({ applied: false });
|
||||
expect(await textContent(f, "memory.md")).toBe("second");
|
||||
await expect(svc.write(f, { ...first, body: Buffer.from("different") })).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
it("retains a deleted copy after the same path is recreated", async () => {
|
||||
const f = await folder();
|
||||
await svc.write(f, { path: "note", body: Buffer.from("deleted"), operationId: "one" });
|
||||
await svc.remove(f, "note", "delete");
|
||||
const [trashed] = (await svc.list(f, { trash: true })).files;
|
||||
await svc.write(f, { path: "note", body: Buffer.from("replacement"), operationId: "two" });
|
||||
await expect(svc.restore(f, trashed!.id, "restore")).rejects.toMatchObject({ status: 409 });
|
||||
await svc.remove(f, "note", "delete-replacement");
|
||||
await svc.restore(f, trashed!.id, "restore");
|
||||
expect(await textContent(f, "note")).toBe("deleted");
|
||||
expect((await svc.list(f, { trash: true })).files).toHaveLength(1);
|
||||
});
|
||||
it("restores a deleted directory as one recoverable subtree", async () => {
|
||||
const f = await folder();
|
||||
await svc.write(f, { path: "notes/nested/one", body: Buffer.from("one"), operationId: "seed" });
|
||||
await svc.remove(f, "notes", "remove-subtree");
|
||||
const trash = (await svc.list(f, { trash: true })).files;
|
||||
await svc.restore(f, trash.find((file) => file.path === "notes")!.id, "restore-subtree");
|
||||
expect(await textContent(f, "notes/nested/one")).toBe("one");
|
||||
expect((await svc.list(f, { trash: true })).files).toHaveLength(0);
|
||||
});
|
||||
it("does not publish interrupted or oversized uploads", async () => {
|
||||
const f = await folder();
|
||||
const body = Readable.from((async function* () { yield Buffer.from("partial"); throw new Error("Disconnected"); })());
|
||||
await expect(svc.write(f, { path: "partial", body, operationId: "partial" })).rejects.toThrow("Disconnected");
|
||||
await expect(svc.write(f, { path: "large", body: Buffer.from("large"), maxBytes: 2, operationId: "large" })).rejects.toMatchObject({ status: 413 });
|
||||
expect((await svc.list(f)).files).toHaveLength(0);
|
||||
});
|
||||
it("serializes conflicting parent/file creation", async () => {
|
||||
const f = await folder();
|
||||
const results = await Promise.allSettled([
|
||||
svc.write(f, { path: "parent", body: Buffer.from("file"), operationId: "parent" }),
|
||||
svc.write(f, { path: "parent/child", body: Buffer.from("child"), operationId: "child" }),
|
||||
]);
|
||||
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||
expect(results.filter((r) => r.status === "rejected")).toHaveLength(1);
|
||||
});
|
||||
it("collects overwrites and purged trash but keeps recoverable deleted content", async () => {
|
||||
const f = await folder();
|
||||
await svc.write(f, { path: "note", body: Buffer.from("old"), operationId: "old" });
|
||||
const old = await svc.get(f, "note");
|
||||
await svc.write(f, { path: "note", body: Buffer.from("new"), operationId: "new" });
|
||||
const current = await svc.get(f, "note");
|
||||
await svc.remove(f, "note", "trash");
|
||||
await collectWorkFolderGarbage(db, storage);
|
||||
expect((await storage.headObject({ objectKey: old.objectKey! })).exists).toBe(false);
|
||||
expect((await storage.headObject({ objectKey: current.objectKey! })).exists).toBe(true);
|
||||
await svc.purge(f, current.id, "purge");
|
||||
await collectWorkFolderGarbage(db, storage);
|
||||
expect((await storage.headObject({ objectKey: current.objectKey! })).exists).toBe(false);
|
||||
expect((await svc.list(f, { trash: true })).files).toHaveLength(0);
|
||||
});
|
||||
it("removes scoped files after permanent owner deletion", async () => {
|
||||
const f = await folder();
|
||||
await svc.write(f, { path: "note", body: Buffer.from("private"), operationId: "private" });
|
||||
const file = await svc.get(f, "note");
|
||||
await db.delete(agents).where(eq(agents.id, f.ownerId));
|
||||
await collectWorkFolderGarbage(db, storage);
|
||||
expect(await db.select().from(workFolders).where(eq(workFolders.id, f.id))).toHaveLength(0);
|
||||
expect((await storage.headObject({ objectKey: file.objectKey! })).exists).toBe(false);
|
||||
expect(await db.select().from(workFolderObjects).where(and(eq(workFolderObjects.folderId, f.id), eq(workFolderObjects.companyId, companyId)))).toHaveLength(0);
|
||||
});
|
||||
it("hard-denies private user files to another user or an unbound agent", async () => {
|
||||
const owner = { companyId, scope: "user" as const, ownerId: "owner" };
|
||||
await expect(assertWorkFolderAccess(db, { type: "board", source: "local_implicit", userId: "another" }, owner, false)).rejects.toMatchObject({ status: 404 });
|
||||
await expect(assertWorkFolderAccess(db, { type: "agent", source: "agent_key", companyId, agentId: randomUUID() }, owner, false)).rejects.toMatchObject({ status: 404 });
|
||||
await expect(assertWorkFolderAccess(db, { type: "board", source: "local_implicit", userId: "owner" }, owner, true)).resolves.toBeUndefined();
|
||||
});
|
||||
it.each(["../secret", "/absolute", "a/../../x", "a//b", "a\\b", "a\u0000b", ".paperclip-runtime/secret"])("rejects unsafe path %j", (filePath) => {
|
||||
expect(() => validateWorkFilePath(filePath)).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -46,6 +46,7 @@ import { issueRoutes } from "./routes/issues.js";
|
|||
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
|
||||
import { caseRoutes } from "./routes/cases.js";
|
||||
import { fileResourceRoutes } from "./routes/file-resources.js";
|
||||
import { workFolderRoutes } from "./routes/work-folders.js";
|
||||
import { routineRoutes } from "./routes/routines.js";
|
||||
import { pipelineRoutes } from "./routes/pipelines.js";
|
||||
import { environmentRoutes } from "./routes/environments.js";
|
||||
|
|
@ -521,6 +522,7 @@ export async function createApp(
|
|||
api.use(caseRoutes(db, opts.storageService));
|
||||
api.use(issueTreeControlRoutes(db));
|
||||
api.use(fileResourceRoutes(db));
|
||||
api.use(workFolderRoutes(db));
|
||||
api.use(routineRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(pipelineRoutes(db));
|
||||
api.use(environmentRoutes(db, {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
// OTEL_EXPORTER_OTLP_ENDPOINT is set). startServer() awaits
|
||||
// instrumentationReady before opening DB connections or constructing the
|
||||
// HTTP server, so trace coverage does not depend on incidental timing.
|
||||
import { collectWorkFolderGarbage } from "./services/work-folder-garbage.js";
|
||||
import { createStorageProviderFromConfig } from "./storage/provider-registry.js";
|
||||
import { instrumentationReady, shutdownInstrumentation } from "./instrumentation.js";
|
||||
import { sentryReady, shutdownSentry, captureException } from "./sentry.js";
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
|
|
@ -1097,6 +1099,16 @@ export async function startServer(): Promise<StartedServer> {
|
|||
heartbeatSchedulerInterval = setInterval(callback, config.heartbeatSchedulerIntervalMs);
|
||||
heartbeatSchedulerInterval?.unref?.();
|
||||
};
|
||||
let workFolderCleanupInFlight = false;
|
||||
let nextWorkFolderCleanupAt = 0;
|
||||
const scheduleWorkFolderCleanup = () => {
|
||||
if (heartbeatSchedulerStopped || workFolderCleanupInFlight || Date.now() < nextWorkFolderCleanupAt) return;
|
||||
workFolderCleanupInFlight = true;
|
||||
nextWorkFolderCleanupAt = Date.now() + 180_000;
|
||||
trackHeartbeatSchedulerWork(collectWorkFolderGarbage(db, createStorageProviderFromConfig(config))
|
||||
.catch((err) => logger.error({ err }, "Work folder object cleanup failed; durable deletion journal retained"))
|
||||
.finally(() => { workFolderCleanupInFlight = false; }));
|
||||
};
|
||||
const externalObjects = externalObjectService(db as any, {
|
||||
pluginWorkerManager,
|
||||
enabled: async () => (await instanceSettingsService(db).getExperimental()).enableExternalObjects === true,
|
||||
|
|
@ -1563,6 +1575,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
scheduleAdapterLoginReaperSweep();
|
||||
scheduleSetupTokenReaperSweep();
|
||||
scheduleEnvironmentLeaseCleanupSweep();
|
||||
scheduleWorkFolderCleanup();
|
||||
|
||||
if (heartbeatSchedulerStopped) return;
|
||||
trackHeartbeatSchedulerWork(routines
|
||||
|
|
@ -1718,6 +1731,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
startHeartbeatSchedulerInterval(() => {
|
||||
scheduleExternalObjectRefreshSweep(new Date());
|
||||
scheduleEnvironmentLeaseCleanupSweep();
|
||||
scheduleWorkFolderCleanup();
|
||||
scheduleGitHubConnectionEventPoll();
|
||||
scheduleGitHubConnectionContinuitySweep();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
import { Router } from "express";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { type Db, workFolderRuns, heartbeatRuns } from "@paperclipai/db";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { WORK_FOLDER_SCOPES } from "@paperclipai/shared";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { createStorageProviderFromConfig } from "../storage/provider-registry.js";
|
||||
import type { StorageProvider } from "../storage/types.js";
|
||||
import { assertWorkFolderAccess } from "../services/work-folder-access.js";
|
||||
import { workFolderService } from "../services/work-folders.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
import { getActorInfo } from "./authz.js";
|
||||
import { badRequest, conflict, notFound } from "../errors.js";
|
||||
|
||||
const ownerSchema = z.object({ companyId: z.uuid(), scope: z.enum(WORK_FOLDER_SCOPES), ownerId: z.string().min(1).max(256) })
|
||||
.refine((v) => v.scope === "user" || z.uuid().safeParse(v.ownerId).success, "Invalid folder owner");
|
||||
const querySchema = z.object({ path: z.string().optional(), trash: z.enum(["true", "false"]).optional(),
|
||||
cursor: z.uuid().optional(), limit: z.coerce.number().int().min(1).max(1000).optional() });
|
||||
|
||||
export function workFolderRoutes(db: Db, provider?: StorageProvider) {
|
||||
const router = Router();
|
||||
// Resolve lazily: route registration and tests need not initialize cloud credentials.
|
||||
const service = () => workFolderService(db, provider ?? createStorageProviderFromConfig(loadConfig()));
|
||||
const base = "/companies/:companyId/work-folders/:scope/:ownerId";
|
||||
router.use(base, async (req, _res, next) => {
|
||||
const owner = ownerSchema.parse(req.params);
|
||||
await assertWorkFolderAccess(db, req.actor, owner, !["GET", "HEAD"].includes(req.method));
|
||||
next();
|
||||
});
|
||||
router.get(base, async (req, res) => {
|
||||
const svc = service();
|
||||
const folder = await svc.ensure(ownerSchema.parse(req.params));
|
||||
const query = querySchema.parse(req.query);
|
||||
res.json(await svc.list(folder, { trash: query.trash === "true", cursor: query.cursor, limit: query.limit }));
|
||||
});
|
||||
router.get(`${base}/content`, async (req, res) => {
|
||||
const svc = service();
|
||||
const folder = await svc.ensure(ownerSchema.parse(req.params));
|
||||
const filePath = z.string().parse(req.query.path);
|
||||
const result = await svc.content(folder, filePath);
|
||||
res.set({ "Content-Type": result.file.contentType, "Content-Length": String(result.file.byteSize),
|
||||
"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filePath.split("/").at(-1)!)}` });
|
||||
await pipeline(result.stream, res);
|
||||
});
|
||||
router.put(`${base}/content`, async (req, res) => {
|
||||
const svc = service();
|
||||
const folder = await svc.ensure(ownerSchema.parse(req.params));
|
||||
const filePath = z.string().parse(req.query.path);
|
||||
// Use octet-stream so Express's global JSON parser cannot consume file bytes.
|
||||
if (req.get("Content-Type")?.split(";")[0]?.trim().toLowerCase() !== "application/octet-stream") throw badRequest("Upload files as application/octet-stream");
|
||||
const result = await svc.write(folder, { path: filePath, body: req as Readable,
|
||||
contentType: z.string().max(256).parse(req.get("X-File-Content-Type") ?? "application/octet-stream"),
|
||||
executable: req.get("X-File-Executable") === "true", operationId: req.get("Idempotency-Key") ?? randomUUID() });
|
||||
if (result.applied) await audit(req, folder, "write", { path: filePath });
|
||||
res.json(result);
|
||||
});
|
||||
router.post(`${base}/operations`, async (req, res) => {
|
||||
const svc = service();
|
||||
const folder = await svc.ensure(ownerSchema.parse(req.params));
|
||||
const input = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("mkdir"), path: z.string() }),
|
||||
z.object({ action: z.literal("delete"), path: z.string() }),
|
||||
z.object({ action: z.literal("restore"), fileId: z.uuid() }),
|
||||
z.object({ action: z.literal("purge"), fileId: z.uuid() }),
|
||||
]).parse(req.body);
|
||||
const operationId = req.get("Idempotency-Key") ?? randomUUID();
|
||||
const result = input.action === "purge" ? await svc.purge(folder, input.fileId, operationId)
|
||||
: input.action === "restore" ? await svc.restore(folder, input.fileId, operationId)
|
||||
: input.action === "delete" ? await svc.remove(folder, input.path, operationId)
|
||||
: await svc.write(folder, { path: input.path, kind: "directory", operationId });
|
||||
if (result.applied) await audit(req, folder, input.action, input);
|
||||
res.json({ applied: result.applied });
|
||||
});
|
||||
router.get(`${base}/sync`, async (req, res) => {
|
||||
const owner = ownerSchema.parse(req.params);
|
||||
const folder = await service().ensure(owner);
|
||||
const rows = await db.select({ folderRun: workFolderRuns, status: heartbeatRuns.status }).from(workFolderRuns)
|
||||
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, workFolderRuns.runId))
|
||||
.where(and(eq(workFolderRuns.companyId, owner.companyId),
|
||||
sql`${workFolderRuns.manifest}->'folders'->>${owner.scope} = ${folder.id}`))
|
||||
.orderBy(desc(workFolderRuns.updatedAt)).limit(100);
|
||||
const leases = new Set<string>();
|
||||
let includedCompletedSave = false;
|
||||
res.json(rows.flatMap(({ folderRun: row, status }) => {
|
||||
if (leases.has(row.manifest.sandboxKey)) return [];
|
||||
leases.add(row.manifest.sandboxKey);
|
||||
const active = status === "running" || status === "queued";
|
||||
if (!active && row.state !== "failed") {
|
||||
if (includedCompletedSave) return [];
|
||||
includedCompletedSave = true;
|
||||
}
|
||||
return [{ runId: row.runId, state: row.state, lastSavedAt: row.lastSavedAt, error: row.error,
|
||||
refreshRequested: row.refreshRequested, active }];
|
||||
}));
|
||||
});
|
||||
router.post(`${base}/refresh`, async (req, res) => {
|
||||
const owner = ownerSchema.parse(req.params);
|
||||
const { runId } = z.object({ runId: z.uuid() }).parse(req.body);
|
||||
const [row] = await db.select().from(workFolderRuns).where(and(eq(workFolderRuns.runId, runId), eq(workFolderRuns.companyId, owner.companyId)));
|
||||
const svc = service();
|
||||
const folder = await svc.ensure(owner);
|
||||
if (!row || row.manifest.folders[owner.scope] !== folder.id) throw notFound("Run not found");
|
||||
const [run] = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
if (run?.status !== "running") throw conflict("This run has ended; files will refresh when the next run starts");
|
||||
await db.update(workFolderRuns).set({ refreshRequested: true, updatedAt: new Date() }).where(eq(workFolderRuns.runId, runId));
|
||||
await audit(req, folder, "refresh", { runId });
|
||||
res.status(202).json({ queued: true });
|
||||
});
|
||||
async function audit(req: Parameters<typeof getActorInfo>[0], folder: { id: string; companyId: string }, action: string, details: Record<string, unknown>) {
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, { companyId: folder.companyId, actorType: actor.actorType, actorId: actor.actorId,
|
||||
agentId: actor.agentId, runId: actor.runId, action: `work_folder.${action}`, entityType: "work_folder", entityId: folder.id, details });
|
||||
}
|
||||
return router;
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ import {
|
|||
runWithRuntimeParent,
|
||||
type StartupSpanContext,
|
||||
} from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
|
||||
import { retainUnsavedWorkFolderLease } from "./work-folder-retention.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { verifyNativeHarnessBackupStamp } from "./native-runtime/native-harness-backup-stamp.js";
|
||||
|
|
@ -986,6 +987,8 @@ function buildReusableSandboxLeaseScope(input: {
|
|||
environmentId: string;
|
||||
executionWorkspaceId: string | null;
|
||||
agentId: string | null;
|
||||
responsibleUserId: string | null;
|
||||
issueId: string | null;
|
||||
adapterType: string | null;
|
||||
provider: string;
|
||||
config: Record<string, unknown>;
|
||||
|
|
@ -1000,11 +1003,13 @@ function buildReusableSandboxLeaseScope(input: {
|
|||
? { ...providerMetadata.workspaceSentinel }
|
||||
: null;
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
agentId: input.agentId,
|
||||
responsibleUserId: input.responsibleUserId,
|
||||
issueId: input.issueId,
|
||||
adapterType,
|
||||
provider: input.provider,
|
||||
runtimeFingerprint: reusableRuntimeFingerprint({
|
||||
|
|
@ -1026,6 +1031,8 @@ function reusableSandboxLeaseScopeMatches(input: {
|
|||
environmentId: string;
|
||||
executionWorkspaceId: string | null;
|
||||
agentId: string | null;
|
||||
responsibleUserId: string | null;
|
||||
issueId: string | null;
|
||||
adapterType: string | null;
|
||||
provider: string;
|
||||
config: Record<string, unknown>;
|
||||
|
|
@ -1037,10 +1044,13 @@ function reusableSandboxLeaseScopeMatches(input: {
|
|||
if (!isRecord(scope)) return false;
|
||||
const adapterType = input.adapterType ?? null;
|
||||
const baseScopeMatches =
|
||||
scope.version === 2 &&
|
||||
scope.companyId === input.companyId &&
|
||||
scope.environmentId === input.environmentId &&
|
||||
scope.executionWorkspaceId === input.executionWorkspaceId &&
|
||||
scope.agentId === input.agentId &&
|
||||
scope.responsibleUserId === input.responsibleUserId &&
|
||||
scope.issueId === input.issueId &&
|
||||
scope.adapterType === adapterType &&
|
||||
scope.provider === input.provider;
|
||||
if (!baseScopeMatches) return false;
|
||||
|
|
@ -1744,6 +1754,9 @@ function createSandboxEnvironmentDriver(
|
|||
driver: "sandbox",
|
||||
|
||||
async acquireRunLease(input) {
|
||||
const [boundRun] = input.heartbeatRunId ? await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.heartbeatRunId), eq(heartbeatRuns.companyId, input.companyId))) : [];
|
||||
const responsibleUserId = boundRun?.responsibleUserId ?? null;
|
||||
const storedParsed = parseEnvironmentDriverConfig(input.environment);
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, {
|
||||
issueId: input.issueId,
|
||||
|
|
@ -1847,6 +1860,8 @@ function createSandboxEnvironmentDriver(
|
|||
const reusableExistingLeases = reusableCandidateLeases.filter((lease) =>
|
||||
reusableSandboxLeaseScopeMatches({
|
||||
lease,
|
||||
responsibleUserId,
|
||||
issueId: input.issueId,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environment.id,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
|
|
@ -2033,6 +2048,8 @@ function createSandboxEnvironmentDriver(
|
|||
});
|
||||
const reusableScope = resolvedLeasePolicy === "reuse_by_environment"
|
||||
? buildReusableSandboxLeaseScope({
|
||||
responsibleUserId,
|
||||
issueId: input.issueId,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environment.id,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
|
|
@ -2201,6 +2218,8 @@ function createSandboxEnvironmentDriver(
|
|||
const reusableExistingLeases = reusableCandidateLeases.filter((lease) =>
|
||||
reusableSandboxLeaseScopeMatches({
|
||||
lease,
|
||||
responsibleUserId,
|
||||
issueId: input.issueId,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environment.id,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
|
|
@ -2273,6 +2292,8 @@ function createSandboxEnvironmentDriver(
|
|||
: "ephemeral";
|
||||
const reusableScope = resolvedLeasePolicy === "reuse_by_environment"
|
||||
? buildReusableSandboxLeaseScope({
|
||||
responsibleUserId,
|
||||
issueId: input.issueId,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environment.id,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
|
|
@ -2384,6 +2405,7 @@ function createSandboxEnvironmentDriver(
|
|||
},
|
||||
|
||||
async releaseRunLease(input) {
|
||||
if (await retainUnsavedWorkFolderLease(db, input.lease)) return { ...input.lease, status: "retained", expiresAt: null, failureReason: "work_folder_save_required" };
|
||||
if (input.status === "expired" && input.lease.leasePolicy === "reuse_by_environment") {
|
||||
return await destroyReusableSandboxLease({
|
||||
environment: input.environment,
|
||||
|
|
@ -2438,6 +2460,7 @@ function createSandboxEnvironmentDriver(
|
|||
},
|
||||
|
||||
async retryPendingSandboxTeardown(input) {
|
||||
if (await retainUnsavedWorkFolderLease(db, input.lease)) throw new Error("Sandbox retains unsaved work folders; recover them before teardown");
|
||||
// Resolve the teardown from the immutable orphan lease row, not from the
|
||||
// current environment. The row keeps the provider, the provider lease id,
|
||||
// and the sandbox config in its metadata. A provider change re-points the
|
||||
|
|
@ -3064,6 +3087,7 @@ function createSandboxEnvironmentDriver(
|
|||
lease: EnvironmentLease;
|
||||
failureReason: string;
|
||||
}): Promise<EnvironmentLease | null> {
|
||||
if (await retainUnsavedWorkFolderLease(db, input.lease)) return { ...input.lease, status: "retained", expiresAt: null, failureReason: "work_folder_save_required" };
|
||||
let cleanupStatus: "success" | "failed" = "success";
|
||||
const metadata = input.lease.metadata ?? {};
|
||||
|
||||
|
|
@ -3715,6 +3739,7 @@ export function environmentRuntimeService(
|
|||
if (!environment) continue;
|
||||
|
||||
const leaseSnapshot = toEnvironmentLeaseSnapshot(leaseRow);
|
||||
if (await retainUnsavedWorkFolderLease(db, leaseSnapshot)) continue;
|
||||
if (
|
||||
providerResourceDisposition === "keep_running" &&
|
||||
leaseSnapshot.leasePolicy === "reuse_by_environment"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import fs from "node:fs/promises";
|
||||
import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "./work-folder-retention.js";
|
||||
import { prepareSandboxWorkFolders } from "./sandbox-work-folders.js";
|
||||
import path from "node:path";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
|
@ -18034,6 +18036,9 @@ export function heartbeatService(
|
|||
|
||||
activeRunExecutions.add(run.id);
|
||||
let runScratch: HeartbeatRunScratch | null = null;
|
||||
let sandboxWorkFolders: Awaited<ReturnType<typeof prepareSandboxWorkFolders>> | null = null;
|
||||
let workFolderSaveFailed = false;
|
||||
let workFolderLeaseId: string | null = null;
|
||||
let nativeSessionResumeScheduled = false;
|
||||
let nativeWorkspaceFinalizeScheduled = false;
|
||||
let nativeWorkspaceSync: Awaited<
|
||||
|
|
@ -19001,8 +19006,8 @@ export function heartbeatService(
|
|||
shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset;
|
||||
const sessionResetReason =
|
||||
sessionConfigFreshness.reasons.join("; ") || null;
|
||||
const taskSessionForRun = resetTaskSession ? null : taskSession;
|
||||
const previousSessionParams =
|
||||
let taskSessionForRun = resetTaskSession ? null : taskSession;
|
||||
let previousSessionParams =
|
||||
explicitResumeSessionParams ??
|
||||
(isCanonicalSessionIdForAdapter(
|
||||
agent.adapterType,
|
||||
|
|
@ -19730,6 +19735,31 @@ export function heartbeatService(
|
|||
await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace);
|
||||
const workspaceRealization = realizationResult.workspaceRealization;
|
||||
const executionTarget = realizationResult.executionTarget;
|
||||
if (executionTarget?.kind === "remote" && executionTarget.transport === "sandbox") {
|
||||
// The coordinator owns folder identity, hydration and durability for
|
||||
// both legacy and native dispatch. Local execution never enters here.
|
||||
workFolderSaveFailed = true;
|
||||
workFolderLeaseId = activeEnvironmentLease.lease.id;
|
||||
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,
|
||||
sandboxKey: workFolderSandboxKey(activeEnvironmentLease.lease) });
|
||||
if (sandboxWorkFolders.identityChanged) { taskSessionForRun = null; previousSessionParams = null; }
|
||||
executionTarget.workFolderHome = sandboxWorkFolders.home;
|
||||
executionTarget.remoteCwd = sandboxWorkFolders.primaryRepo;
|
||||
const nextLeaseMetadata = { ...activeEnvironmentLease.lease.metadata, remoteCwd: sandboxWorkFolders.primaryRepo, workFolderHome: sandboxWorkFolders.home };
|
||||
await db.update(environmentLeases).set({ metadata: nextLeaseMetadata, updatedAt: new Date() }).where(eq(environmentLeases.id, activeEnvironmentLease.lease.id));
|
||||
activeEnvironmentLease = { ...activeEnvironmentLease, lease: { ...activeEnvironmentLease.lease, metadata: nextLeaseMetadata } };
|
||||
runtimeConfig = { ...runtimeConfig, env: { ...parseObject(runtimeConfig.env), ...sandboxWorkFolders.env } };
|
||||
context.paperclipWorkFolders = { ...sandboxWorkFolders.manifest, primaryRepo: sandboxWorkFolders.primaryRepo };
|
||||
context.paperclipTaskMarkdown = [readNonEmptyString(context.paperclipTaskMarkdown),
|
||||
"## Sandbox files", `Your starting directory and HOME are ${sandboxWorkFolders.home}.`,
|
||||
"The task/, agent/, user/, and project/ directories contain the files bound to this run. They are writable and save every 180 seconds, plus a final save when the run ends.",
|
||||
`The primary repository is ${sandboxWorkFolders.primaryRepo}. All attached repositories are under ${sandboxWorkFolders.home}/repos/.`,
|
||||
"Use the primary repository for project commands. CLI credentials and caches are separate from shared files.",
|
||||
].filter(Boolean).join("\n\n");
|
||||
workFolderSaveFailed = false;
|
||||
}
|
||||
const remoteExecution = realizationResult.remoteExecution;
|
||||
const dispatchResolvedInteractionContinuationWithAtomicGate = async <T>(
|
||||
dispatch: (markDispatchStarted: () => void) => Promise<T>,
|
||||
|
|
@ -19920,7 +19950,7 @@ export function heartbeatService(
|
|||
: []),
|
||||
];
|
||||
context.paperclipWorkspace = {
|
||||
cwd: executionWorkspace.cwd,
|
||||
cwd: sandboxWorkFolders?.primaryRepo ?? executionWorkspace.cwd,
|
||||
source: executionWorkspace.source,
|
||||
mode: effectiveExecutionWorkspaceMode,
|
||||
strategy: executionWorkspace.strategy,
|
||||
|
|
@ -19932,12 +19962,16 @@ export function heartbeatService(
|
|||
worktreePath: executionWorkspace.worktreePath,
|
||||
realization: workspaceRealization,
|
||||
agentHome: await (async () => {
|
||||
if (sandboxWorkFolders) return sandboxWorkFolders.env.AGENT_HOME;
|
||||
const home = resolveDefaultAgentWorkspaceDir(agent.id);
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
return home;
|
||||
})(),
|
||||
};
|
||||
context.paperclipWorkspaces = buildRunWorkspaceHints(resolvedWorkspace);
|
||||
context.paperclipWorkspaces = sandboxWorkFolders ? sandboxWorkFolders.manifest.repositories.map((repo) => ({
|
||||
workspaceId: repo.workspaceId, projectId: sandboxWorkFolders!.manifest.projectId,
|
||||
cwd: `${sandboxWorkFolders!.home}/repos/${repo.name}`,
|
||||
})) : buildRunWorkspaceHints(resolvedWorkspace);
|
||||
// Emit exactly one requested-vs-synced observability line for the referenced-project set. A run
|
||||
// with no referenced project stays silent, so this adds no noise to the anchor-only default. The
|
||||
// per-drop human warning already rides `runtimeWorkspaceWarnings`; this line carries the counts
|
||||
|
|
@ -20034,6 +20068,11 @@ export function heartbeatService(
|
|||
stripPaperclipSessionMetadataFromSessionParams(runtimeSessionParams),
|
||||
);
|
||||
|
||||
if (sandboxWorkFolders?.identityChanged) {
|
||||
runtimeSessionIdForAdapter = null;
|
||||
runtimeSessionParamsForAdapter = null;
|
||||
previousSessionDisplayId = null;
|
||||
}
|
||||
const sessionCompaction = await evaluateSessionCompaction({
|
||||
agent,
|
||||
sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter,
|
||||
|
|
@ -20349,7 +20388,7 @@ export function heartbeatService(
|
|||
cwd: executionWorkspace.cwd,
|
||||
});
|
||||
const adapterEnv = Object.fromEntries(
|
||||
Object.entries(parseObject(resolvedConfig.env)).filter(
|
||||
Object.entries({ ...parseObject(resolvedConfig.env), ...sandboxWorkFolders?.env }).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||
),
|
||||
|
|
@ -20487,14 +20526,14 @@ export function heartbeatService(
|
|||
actorId: agent.id,
|
||||
immediateRequest: safeWakeCommentContext?.body ?? null,
|
||||
});
|
||||
const taskNativeSessionId = readNonEmptyString(
|
||||
const taskNativeSessionId = sandboxWorkFolders?.identityChanged ? null : readNonEmptyString(
|
||||
taskSessionDecodedParams?.sessionId,
|
||||
);
|
||||
// Compatibility for native retry rows created before same-run restart
|
||||
// recovery existed. Only an entirely unused replacement row may
|
||||
// inherit its source checkpoint; any process/provider evidence on the
|
||||
// replacement makes the ownership ambiguous and therefore ineligible.
|
||||
const legacyRetrySource = run.retryOfRunId
|
||||
const legacyRetrySource = !sandboxWorkFolders?.identityChanged && run.retryOfRunId
|
||||
? await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
|
|
@ -20968,7 +21007,7 @@ export function heartbeatService(
|
|||
})
|
||||
.onConflictDoNothing();
|
||||
});
|
||||
nativeWorkspaceSync = await prepareNativeWorkspaceSync({
|
||||
nativeWorkspaceSync = sandboxWorkFolders ? null : await prepareNativeWorkspaceSync({
|
||||
db,
|
||||
runId: run.id,
|
||||
companyId: agent.companyId,
|
||||
|
|
@ -21562,6 +21601,12 @@ export function heartbeatService(
|
|||
// If recording the barrier itself fails, propagate as a run failure
|
||||
// rather than silently leaving dependents stranded behind a missing
|
||||
// finalize row.
|
||||
if (sandboxWorkFolders) {
|
||||
workFolderSaveFailed = true;
|
||||
await sandboxWorkFolders.stop();
|
||||
sandboxWorkFolders = null;
|
||||
workFolderSaveFailed = false;
|
||||
}
|
||||
if (nativeWorkspaceSync) {
|
||||
await nativeWorkspaceSync.restoreWorkspace();
|
||||
}
|
||||
|
|
@ -22770,6 +22815,10 @@ export function heartbeatService(
|
|||
}
|
||||
}
|
||||
} finally {
|
||||
if (sandboxWorkFolders) {
|
||||
try { await sandboxWorkFolders.stop(); workFolderSaveFailed = false; }
|
||||
catch (error) { workFolderSaveFailed = true; logger.error({ err: error, runId: run.id }, "Work folder save failed; retaining sandbox for recovery"); }
|
||||
}
|
||||
let latestRun = await getRun(run.id).catch(() => null);
|
||||
// Trace capture is debug-only and must settle independently of every
|
||||
// provider outcome. Adapter/setup failures used to skip the success-path
|
||||
|
|
@ -22820,7 +22869,12 @@ export function heartbeatService(
|
|||
latestRun?.status,
|
||||
);
|
||||
if (!nativeSessionResumeScheduled && !nativeWorkspaceFinalizeScheduled) {
|
||||
await releaseEnvironmentLeasesForRun({
|
||||
if (workFolderSaveFailed && workFolderLeaseId) {
|
||||
await retainUnsavedWorkFolderLease(db, { id: workFolderLeaseId, companyId: run.companyId }).catch((error) => {
|
||||
logger.error({ err: error, runId: run.id }, "Could not record work folder retention; lease remains active");
|
||||
});
|
||||
}
|
||||
if (!workFolderSaveFailed) await releaseEnvironmentLeasesForRun({
|
||||
runId: run.id,
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
|
|
|
|||
|
|
@ -6285,7 +6285,9 @@ async function createRunnerdBackendWithinSessionClaim(
|
|||
const remotePersistencePath = (
|
||||
directory: NativeHarnessPersistenceDirectory,
|
||||
): string | null =>
|
||||
directory.location === "runner"
|
||||
remoteTarget?.transport === "sandbox" && remoteTarget.workFolderHome && directory.name === "codex-home"
|
||||
? posix.join(remoteTarget.workFolderHome, ".codex")
|
||||
: directory.location === "runner"
|
||||
? (remoteStateDirectory ?? null)
|
||||
: remoteRunnerFilesystemRoot
|
||||
? posix.join(remoteRunnerFilesystemRoot, directory.name)
|
||||
|
|
@ -7592,7 +7594,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
|||
...input.execution,
|
||||
workspace: {
|
||||
...input.execution.workspace,
|
||||
cwd: remoteTarget.remoteCwd,
|
||||
cwd: remoteTarget.transport === "sandbox" ? remoteTarget.workFolderHome ?? remoteTarget.remoteCwd : remoteTarget.remoteCwd,
|
||||
},
|
||||
}
|
||||
: input.execution;
|
||||
|
|
@ -7606,13 +7608,12 @@ async function createRunnerdBackendWithinSessionClaim(
|
|||
const effectiveRunnerEnvironment: NodeJS.ProcessEnv = remoteRuntimeRoot
|
||||
? {
|
||||
...effectiveRunnerEnvironmentBase,
|
||||
// The provider home is runner-owned state, not the execution workspace.
|
||||
// Codex's permission profile explicitly denies HOME and CODEX_HOME. If
|
||||
// either points at remoteCwd, that deny rule shadows the workspace write
|
||||
// grant and the provider cannot initialize its shell sandbox or edit.
|
||||
HOME: posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
|
||||
CODEX_HOME: posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
|
||||
PAPERCLIP_WORKSPACE_CWD: remoteTarget!.remoteCwd,
|
||||
// External sandboxes use their natural home and the external-sandbox
|
||||
// permission profile. SSH retains its isolated provider home so its
|
||||
// host-home deny rules cannot shadow the assigned workspace.
|
||||
HOME: remoteTarget!.transport === "sandbox" && remoteTarget!.workFolderHome ? remoteTarget!.workFolderHome : posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
|
||||
CODEX_HOME: remoteTarget!.transport === "sandbox" && remoteTarget!.workFolderHome ? posix.join(remoteTarget!.workFolderHome, ".codex") : posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
|
||||
PAPERCLIP_WORKSPACE_CWD: runnerExecution.workspace.cwd,
|
||||
...(remoteTarget!.transport === "sandbox"
|
||||
? { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1" }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,290 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
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 { 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";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { createStorageProviderFromConfig } from "../storage/provider-registry.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import { createGitRemoteAuthProvider } from "./git-credentials.js";
|
||||
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";
|
||||
|
||||
function signature(entry: WorkTreeEntry | undefined) {
|
||||
return entry ? JSON.stringify([entry.kind, entry.sha256, entry.executable]) : "missing";
|
||||
}
|
||||
function repoName(value: string, id: string) {
|
||||
const name = value.replace(/\.git$/, "").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^\.+/, "").slice(0, 80);
|
||||
return name || `repo-${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** Host-owned lifecycle; neither an adapter nor a sandbox can choose its owners. */
|
||||
export async function prepareSandboxWorkFolders(input: {
|
||||
db: Db; companyId: string; runId: string; agentId: string; responsibleUserId: string | null;
|
||||
taskId: string | null; projectId: string | null; target: AdapterSandboxExecutionTarget;
|
||||
storage?: StorageProvider; sandboxKey?: string;
|
||||
}) {
|
||||
const { db, target } = input;
|
||||
if (!target.runner || !target.leaseId) throw new Error("Sandbox file transport is unavailable");
|
||||
const storage = input.storage ?? createStorageProviderFromConfig(loadConfig());
|
||||
const svc = workFolderService(db, storage);
|
||||
const transport = workFolderTransport(target.runner);
|
||||
const repositories = workFolderRepositoryService(db, storage, transport);
|
||||
const home = await transport.home();
|
||||
const paths = workFolderPaths(home);
|
||||
for (const value of Object.values(paths)) await transport.mkdirRoot(value);
|
||||
const staging = paths[".paperclip-work-folders"]!;
|
||||
const owners = { task: input.taskId, agent: input.agentId, user: input.responsibleUserId, project: input.projectId };
|
||||
const folders: Partial<Record<WorkFolderScope, typeof workFolders.$inferSelect>> = {};
|
||||
for (const scope of WORK_FOLDER_SCOPES) {
|
||||
const ownerId = owners[scope];
|
||||
if (ownerId) folders[scope] = await svc.ensure({ companyId: input.companyId, scope, ownerId });
|
||||
}
|
||||
const manifest: SandboxWorkFolderManifest = { version: 1, companyId: input.companyId, runId: input.runId,
|
||||
taskId: input.taskId, agentId: input.agentId, responsibleUserId: input.responsibleUserId,
|
||||
projectId: input.projectId, leaseId: target.leaseId, sandboxKey: input.sandboxKey ?? target.leaseId, home,
|
||||
folders: { task: folders.task?.id ?? null, agent: folders.agent!.id, user: folders.user?.id ?? null, project: folders.project?.id ?? null }, repositories: [] };
|
||||
const [previous] = await db.select().from(workFolderRuns).where(and(eq(workFolderRuns.companyId, input.companyId),
|
||||
sql`coalesce(${workFolderRuns.manifest}->>'sandboxKey', ${workFolderRuns.manifest}->>'leaseId') = ${manifest.sandboxKey}`)).orderBy(desc(workFolderRuns.updatedAt)).limit(1);
|
||||
if (previous && (previous.manifest.taskId !== input.taskId || previous.manifest.agentId !== input.agentId
|
||||
|| previous.manifest.responsibleUserId !== input.responsibleUserId || previous.manifest.projectId !== input.projectId)) {
|
||||
throw new Error("Sandbox file identity changed; acquire a fresh sandbox before continuing");
|
||||
}
|
||||
const [previousTaskRun] = input.taskId ? await db.select({ manifest: workFolderRuns.manifest }).from(workFolderRuns)
|
||||
.where(and(eq(workFolderRuns.companyId, input.companyId), sql`${workFolderRuns.manifest}->>'taskId' = ${input.taskId}`))
|
||||
.orderBy(desc(workFolderRuns.updatedAt)).limit(1) : [];
|
||||
const identityChanged = Boolean(previousTaskRun && (previousTaskRun.manifest.agentId !== input.agentId
|
||||
|| previousTaskRun.manifest.responsibleUserId !== input.responsibleUserId));
|
||||
const baselines: Record<string, WorkTreeEntry[]> = previous?.baselines ?? {};
|
||||
const pendingOperations = previous?.pendingOperations ?? {};
|
||||
await db.insert(workFolderRuns).values({ runId: input.runId, companyId: input.companyId, manifest, baselines, pendingOperations })
|
||||
.onConflictDoUpdate({ target: workFolderRuns.runId, set: { manifest, state: "starting", updatedAt: new Date() } });
|
||||
|
||||
async function seedAttachments() {
|
||||
if (!folders.task || !input.taskId) return;
|
||||
const attached = await db.select({ attachment: issueAttachments, asset: assets }).from(issueAttachments)
|
||||
.innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, issueAttachments.companyId)))
|
||||
.where(and(eq(issueAttachments.issueId, input.taskId), eq(issueAttachments.companyId, input.companyId)))
|
||||
.orderBy(asc(issueAttachments.createdAt), asc(issueAttachments.id));
|
||||
for (const { attachment, asset } of attached) {
|
||||
const operationId = `attachment:${attachment.id}`;
|
||||
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 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(); }
|
||||
}
|
||||
}
|
||||
async function importAgentFiles() {
|
||||
const folder = folders.agent!;
|
||||
if (folder.importedAt) return;
|
||||
const root = resolveDefaultAgentWorkspaceDir(input.agentId);
|
||||
async function visit(relative: string) {
|
||||
if (relative) {
|
||||
const [receipt] = await db.select({ id: workFileOperations.id }).from(workFileOperations).where(and(
|
||||
eq(workFileOperations.folderId, folder.id), eq(workFileOperations.operationId, `import:${relative}`)));
|
||||
// Still descend into previously imported directories after an interrupted import.
|
||||
if (receipt && !(await fs.lstat(path.join(root, relative))).isDirectory()) return;
|
||||
}
|
||||
const stat = await fs.lstat(path.join(root, relative));
|
||||
if (stat.isSymbolicLink()) throw new Error("Managed agent home contains an unsupported symbolic link");
|
||||
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;
|
||||
await visit(relative ? `${relative}/${name}` : name);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
await svc.write(folder, { path: relative, body: createReadStream(path.join(root, relative)), operationId: `import:${relative}`, onlyIfMissing: true, executable: Boolean(stat.mode & 0o111) });
|
||||
} else throw new Error("Managed agent home contains an unsupported file");
|
||||
}
|
||||
try { await visit(""); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
await db.update(workFolders).set({ importedAt: new Date() }).where(eq(workFolders.id, folder.id));
|
||||
}
|
||||
async function outgoing(scope: WorkFolderScope) {
|
||||
const folder = folders[scope];
|
||||
if (!folder) return;
|
||||
const current = await transport.scan(paths[scope]!);
|
||||
const before = new Map((baselines[scope] ?? []).map((entry) => [entry.path, entry]));
|
||||
const after = new Map(current.map((entry) => [entry.path, entry]));
|
||||
async function operation(filePath: string, nextSignature: string, apply: (id: string) => Promise<unknown>, accept: () => void) {
|
||||
const key = `${scope}/${filePath}`;
|
||||
if (pendingOperations[key]?.signature !== nextSignature) pendingOperations[key] = { id: randomUUID(), signature: nextSignature };
|
||||
// Persist the receipt ID BEFORE sending bytes. A process restart or lost
|
||||
// COMMIT response must retry this ID, not overwrite another run's edit.
|
||||
await saveState("saving");
|
||||
await apply(pendingOperations[key]!.id);
|
||||
accept();
|
||||
baselines[scope] = [...before.values()];
|
||||
delete pendingOperations[key];
|
||||
await saveState("saving");
|
||||
}
|
||||
for (const entry of current) {
|
||||
if (signature(before.get(entry.path)) === signature(entry)) continue;
|
||||
const body = entry.kind === "file" ? transport.read(paths[scope]!, entry.path, entry.byteSize) : undefined;
|
||||
try {
|
||||
await operation(entry.path, signature(entry), (operationId) => svc.write(folder, { path: entry.path, body,
|
||||
kind: entry.kind, replaceKind: true, executable: entry.executable, expectedSha256: entry.sha256, operationId }), () => {
|
||||
if (before.get(entry.path)?.kind !== entry.kind) for (const key of before.keys()) {
|
||||
if (key.startsWith(`${entry.path}/`)) before.delete(key);
|
||||
}
|
||||
before.set(entry.path, entry);
|
||||
});
|
||||
} finally { body?.destroy(); }
|
||||
}
|
||||
for (const old of [...before.values()].sort((a, b) => a.path.length - b.path.length)) {
|
||||
if (!after.has(old.path) && before.has(old.path)) {
|
||||
await operation(old.path, "missing", (operationId) => svc.remove(folder, old.path, operationId), () => {
|
||||
for (const key of before.keys()) if (key === old.path || key.startsWith(`${old.path}/`)) before.delete(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
baselines[scope] = [...before.values()];
|
||||
}
|
||||
async function incoming(scope: WorkFolderScope) {
|
||||
const folder = folders[scope];
|
||||
if (!folder) return;
|
||||
const current = new Map((await transport.scan(paths[scope]!)).map((entry) => [entry.path, entry]));
|
||||
const saved: WorkTreeEntry[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await svc.list(folder, { cursor, limit: 1000 });
|
||||
for (const file of page.files) saved.push({ path: file.path, kind: file.kind, byteSize: file.byteSize, sha256: file.sha256, executable: file.executable });
|
||||
cursor = page.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
const desired = new Map(saved.map((entry) => [entry.path, entry]));
|
||||
// Remove stale children before replacing their parent directory with a file.
|
||||
for (const entry of [...current.values()].sort((a, b) => b.path.length - a.path.length)) {
|
||||
if (!desired.has(entry.path) || desired.get(entry.path)!.kind !== entry.kind) {
|
||||
await transport.remove(paths[scope]!, entry.path);
|
||||
current.delete(entry.path);
|
||||
}
|
||||
}
|
||||
for (const entry of saved.sort((a, b) => a.path.length - b.path.length)) {
|
||||
if (signature(current.get(entry.path)) === signature(entry)) continue;
|
||||
if (entry.kind === "directory") await transport.mkdir(paths[scope]!, entry.path);
|
||||
else {
|
||||
const result = await svc.content(folder, entry.path);
|
||||
try { await transport.write(paths[scope]!, staging, entry, result.stream); } finally { result.stream.destroy(); }
|
||||
}
|
||||
}
|
||||
baselines[scope] = saved;
|
||||
}
|
||||
|
||||
const bindings: Array<{ binding: typeof taskRepositoryBindings.$inferSelect; root: string }> = [];
|
||||
async function prepareRepositories() {
|
||||
if (!input.taskId || !input.projectId) return;
|
||||
const workspaces = await db.select().from(projectWorkspaces).where(and(eq(projectWorkspaces.companyId, input.companyId),
|
||||
eq(projectWorkspaces.projectId, input.projectId))).orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt));
|
||||
const existing = await db.select().from(taskRepositoryBindings).where(and(eq(taskRepositoryBindings.companyId, input.companyId), eq(taskRepositoryBindings.taskId, input.taskId)));
|
||||
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)) {
|
||||
let binding = existing.find((entry) => entry.workspaceId === workspace.id);
|
||||
if (!binding) {
|
||||
const baseName = repoName(workspace.repoUrl!.split(/[/:]/).at(-1) ?? workspace.name, workspace.id);
|
||||
const name = names.has(baseName) ? `${baseName}-${workspace.id.slice(0, 8)}` : baseName;
|
||||
names.add(name);
|
||||
[binding] = await db.insert(taskRepositoryBindings).values({ companyId: input.companyId, taskId: input.taskId,
|
||||
workspaceId: workspace.id, name, repoUrl: workspace.repoUrl, repoRef: workspace.repoRef ?? workspace.defaultRef }).returning();
|
||||
}
|
||||
if (!binding) throw new Error("Repository binding could not be created");
|
||||
if (binding.repoUrl !== workspace.repoUrl) throw new Error(`Repository ${binding.name} configuration changed; saved work was retained`);
|
||||
if (binding.repoRef !== (workspace.repoRef ?? workspace.defaultRef)) throw new Error(`Repository ${binding.name} starting ref changed; saved work was retained`);
|
||||
const root = path.posix.join(paths.repos!, binding.name);
|
||||
const probe = await target.runner!.execute({ command: "git", args: ["-C", root, "rev-parse", "--git-dir"], bypassSession: true, timeoutMs: 10_000 });
|
||||
const freshCheckout = probe.exitCode !== 0;
|
||||
if (freshCheckout) {
|
||||
// Publish the checkout directory only after every restore object or
|
||||
// clone step completes. An interrupted attempt cannot masquerade as a
|
||||
// reusable checkout merely because it contains a .git directory.
|
||||
const temporary = path.posix.join(staging, `repo-${binding.id}-${randomUUID()}`);
|
||||
const restored = await repositories.restore(binding, temporary, staging);
|
||||
if (!restored) {
|
||||
const auth = await resolveGitAuth(workspace.repoUrl!);
|
||||
const result = await target.runner!.execute({ command: "git", args: [...(auth?.configArgs ?? []), "clone", "--no-hardlinks",
|
||||
"--", workspace.repoUrl!, temporary],
|
||||
env: { GIT_TERMINAL_PROMPT: "0", ...(auth?.env ?? {}) }, bypassSession: true, timeoutMs: 300_000 });
|
||||
if (result.exitCode !== 0 || result.timedOut) throw new Error(`Required repository ${binding.name} could not be cloned`);
|
||||
if (binding.repoRef) {
|
||||
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`);
|
||||
}
|
||||
} 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`);
|
||||
const remote = await target.runner!.execute({ command: "git", args: ["-C", temporary, "remote", "add", "origin", binding.repoUrl!], bypassSession: true, timeoutMs: 10_000 });
|
||||
if (remote.exitCode !== 0) throw new Error(`Repository ${binding.name} remote could not be restored`);
|
||||
}
|
||||
await transport.moveRoot(temporary, root);
|
||||
}
|
||||
if ((!binding.setupComplete || freshCheckout) && workspace.setupCommand) {
|
||||
const setup = await target.runner!.execute({ command: "sh", args: ["-c", workspace.setupCommand], cwd: root, bypassSession: true, timeoutMs: 300_000 });
|
||||
if (setup.exitCode !== 0 || setup.timedOut) throw new Error(`Repository ${binding.name} setup failed`);
|
||||
}
|
||||
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 });
|
||||
await saveState("starting");
|
||||
}
|
||||
for (const old of existing) if (!workspaces.some((workspace) => workspace.id === old.workspaceId)) {
|
||||
await db.update(taskRepositoryBindings).set({ retiredAt: new Date() }).where(eq(taskRepositoryBindings.id, old.id));
|
||||
}
|
||||
}
|
||||
async function saveState(state: "starting" | "saving" | "saved" | "failed", error: string | null = null) {
|
||||
await db.update(workFolderRuns).set({ state, baselines, pendingOperations, manifest, error, updatedAt: new Date(),
|
||||
...(state === "saved" ? { lastSavedAt: new Date() } : {}) }).where(eq(workFolderRuns.runId, input.runId));
|
||||
}
|
||||
try {
|
||||
await seedAttachments();
|
||||
await importAgentFiles();
|
||||
// A resumed sandbox can hold edits newer than its last completed checkpoint.
|
||||
if (previous) for (const scope of WORK_FOLDER_SCOPES) await outgoing(scope);
|
||||
for (const scope of WORK_FOLDER_SCOPES) await incoming(scope);
|
||||
await prepareRepositories();
|
||||
await saveState("starting");
|
||||
if (previous?.refreshRequested) await db.update(workFolderRuns).set({ refreshRequested: false })
|
||||
.where(eq(workFolderRuns.runId, previous.runId));
|
||||
} catch (error) {
|
||||
await saveState("failed", "Work folder preparation failed; existing files were retained");
|
||||
throw error;
|
||||
}
|
||||
const checkpointer = startWorkFolderCheckpointer({
|
||||
async checkpoint() {
|
||||
await saveState("saving");
|
||||
for (const scope of WORK_FOLDER_SCOPES) await outgoing(scope);
|
||||
for (const { binding, root } of bindings) await repositories.checkpoint(binding, root);
|
||||
await saveState("saved");
|
||||
},
|
||||
async onError() { await saveState("failed", "Files could not be saved; the sandbox must be retained for recovery"); },
|
||||
});
|
||||
return { manifest, home, identityChanged, primaryRepo: bindings.find(({ binding }) => manifest.repositories.some((repo) => repo.bindingId === binding.id && repo.primary))?.root ?? bindings[0]?.root ?? paths.task!,
|
||||
env: { HOME: home, AGENT_HOME: paths.agent!, PAPERCLIP_PRIMARY_REPO: bindings.find(({ binding }) => manifest.repositories.some((repo) => repo.bindingId === binding.id && repo.primary))?.root ?? bindings[0]?.root ?? paths.task!, PAPERCLIP_TASK_DIR: paths.task!, PAPERCLIP_AGENT_DIR: paths.agent!,
|
||||
PAPERCLIP_USER_DIR: paths.user!, PAPERCLIP_PROJECT_DIR: paths.project!, PAPERCLIP_REPOS_DIR: paths.repos! },
|
||||
flush: checkpointer.flush, stop: async () => {
|
||||
await checkpointer.stop();
|
||||
const [run] = await db.select({ refreshRequested: workFolderRuns.refreshRequested }).from(workFolderRuns)
|
||||
.where(eq(workFolderRuns.runId, input.runId));
|
||||
if (run?.refreshRequested) {
|
||||
// The agent has stopped. The successful final flush above protects its
|
||||
// edits before accepting incoming shared files at this safe boundary.
|
||||
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));
|
||||
}
|
||||
manifest.finalCheckpointAt = new Date().toISOString();
|
||||
await saveState("saved");
|
||||
} };
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
// Executed inside a sandbox using its existing Node runtime. No storage secrets
|
||||
// or database credentials enter this process. Each command has bounded output.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const input = JSON.parse(Buffer.from(process.argv[1], "base64").toString("utf8"));
|
||||
const MAX_CHUNK = 256 * 1024;
|
||||
const MAX_ENTRIES = 100_000;
|
||||
function safeRelative(value) {
|
||||
if (typeof value !== "string" || !value || /[\\\x00-\x1f\x7f]/.test(value)
|
||||
|| value.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("unsafe_path");
|
||||
return value;
|
||||
}
|
||||
// Hold every parent directory open while resolving its child. On Linux this
|
||||
// uses procfs descriptor paths, so swapping a parent for a symlink cannot send
|
||||
// an operation into another scope or a CLI credential directory.
|
||||
function withParent(target, create, callback) {
|
||||
const resolved = path.resolve(target);
|
||||
const segments = resolved.split(path.sep).filter(Boolean);
|
||||
const name = segments.pop();
|
||||
if (!name) throw new Error("invalid_root");
|
||||
let current = path.parse(resolved).root;
|
||||
let fd = fs.openSync(current, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
||||
try {
|
||||
for (const segment of segments) {
|
||||
const next = process.platform === "linux" ? `/proc/self/fd/${fd}/${segment}` : path.join(current, segment);
|
||||
if (create) {
|
||||
try { fs.mkdirSync(next, { mode: 0o700 }); } catch (error) { if (error.code !== "EEXIST") throw error; }
|
||||
}
|
||||
const stat = fs.lstatSync(next);
|
||||
if (stat.isSymbolicLink()) throw new Error("symlink_not_allowed");
|
||||
const child = fs.openSync(next, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
||||
fs.closeSync(fd); fd = child;
|
||||
current = path.join(current, segment);
|
||||
}
|
||||
return callback(process.platform === "linux" ? `/proc/self/fd/${fd}/${name}` : path.join(current, name));
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
function checked(target, directory = false) {
|
||||
return withParent(target, false, (anchored) => {
|
||||
if (fs.lstatSync(anchored).isSymbolicLink()) throw new Error("symlink_not_allowed");
|
||||
const fd = fs.openSync(anchored, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (directory ? fs.constants.O_DIRECTORY : 0));
|
||||
const stat = fs.fstatSync(fd);
|
||||
if ((directory && !stat.isDirectory()) || (!directory && !stat.isFile())) { fs.closeSync(fd); throw new Error("unsupported_file"); }
|
||||
if (!directory && stat.nlink !== 1) { fs.closeSync(fd); throw new Error("hardlink_not_allowed"); }
|
||||
return fd;
|
||||
});
|
||||
}
|
||||
function children(target) {
|
||||
const fd = checked(target, true);
|
||||
try { return fs.readdirSync(process.platform === "linux" ? `/proc/self/fd/${fd}` : target).sort(); }
|
||||
finally { fs.closeSync(fd); }
|
||||
}
|
||||
function checksum(target) {
|
||||
const fd = checked(target);
|
||||
try {
|
||||
const hash = createHash("sha256");
|
||||
const buffer = Buffer.alloc(MAX_CHUNK);
|
||||
let count;
|
||||
while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, count));
|
||||
return hash.digest("hex");
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
function ensureDirectory(target) {
|
||||
withParent(target, false, (anchored) => {
|
||||
try { fs.mkdirSync(anchored, { mode: 0o700 }); } catch (error) { if (error.code !== "EEXIST") throw error; }
|
||||
if (fs.lstatSync(anchored).isSymbolicLink()) throw new Error("symlink_not_allowed");
|
||||
const fd = fs.openSync(anchored, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
||||
fs.closeSync(fd);
|
||||
});
|
||||
}
|
||||
function full(relative) { return path.join(input.root, safeRelative(relative)); }
|
||||
function parents(relative) {
|
||||
const parts = safeRelative(relative).split("/");
|
||||
let current = input.root;
|
||||
for (const part of parts.slice(0, -1)) { current = path.join(current, part); ensureDirectory(current); }
|
||||
}
|
||||
function scan() {
|
||||
const results = [];
|
||||
function entry(relative) {
|
||||
if (relative.split("/").includes(".paperclip-runtime")) return;
|
||||
if (relative.startsWith(".git/") && relative.endsWith(".lock")) throw new Error("repository_write_in_progress");
|
||||
if (results.length >= MAX_ENTRIES) throw new Error("too_many_files");
|
||||
const target = full(relative);
|
||||
const stat = withParent(target, false, (anchored) => fs.lstatSync(anchored));
|
||||
if (stat.isSymbolicLink()) {
|
||||
if (!input.repository || relative.startsWith(".git/")) throw new Error("symlink_not_allowed");
|
||||
const linkTarget = withParent(target, false, (anchored) => fs.readlinkSync(anchored));
|
||||
const destination = path.resolve(path.dirname(full(relative)), linkTarget);
|
||||
if (path.isAbsolute(linkTarget) || !destination.startsWith(`${input.root}/`)
|
||||
|| destination === `${input.root}/.git` || destination.startsWith(`${input.root}/.git/`)) throw new Error("symlink_outside_repository");
|
||||
try { if (!fs.realpathSync(target).startsWith(`${input.root}/`)) throw new Error("symlink_outside_repository"); }
|
||||
catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
results.push({ path: relative, kind: "file", byteSize: Buffer.byteLength(linkTarget),
|
||||
sha256: createHash("sha256").update(linkTarget).digest("hex"), executable: false, linkTarget });
|
||||
return;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
const fd = checked(target, true); fs.closeSync(fd);
|
||||
results.push({ path: relative, kind: "directory", byteSize: 0, sha256: null, executable: false });
|
||||
for (const child of children(target)) entry(`${relative}/${child}`);
|
||||
} else if (stat.isFile()) {
|
||||
results.push({ path: relative, kind: "file", byteSize: stat.size, sha256: checksum(target), executable: Boolean(stat.mode & 0o111) });
|
||||
} else throw new Error("unsupported_file");
|
||||
}
|
||||
if (input.repository) {
|
||||
const gitDir = path.join(input.root, ".git");
|
||||
const fd = checked(gitDir, true); fs.closeSync(fd);
|
||||
if (fs.existsSync(path.join(gitDir, "objects/info/alternates"))) throw new Error("repository_is_not_independent");
|
||||
const files = execFileSync("git", ["-C", input.root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }).split("\0").filter(Boolean);
|
||||
for (const relative of [...new Set(files)].sort()) {
|
||||
try { fs.lstatSync(full(relative)); entry(relative); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
}
|
||||
for (const name of children(gitDir)) {
|
||||
if (["config", "config.worktree", "hooks"].includes(name)) continue;
|
||||
if (name.endsWith(".lock")) throw new Error("repository_write_in_progress");
|
||||
entry(`.git/${name}`);
|
||||
}
|
||||
} else {
|
||||
for (const name of children(input.root)) entry(name);
|
||||
}
|
||||
return results.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
let result;
|
||||
if (input.operation === "home") {
|
||||
result = { home: os.homedir() };
|
||||
} else if (input.operation === "mkdir-root") {
|
||||
ensureDirectory(input.root); result = {};
|
||||
} else if (input.operation === "move-root") {
|
||||
const fd = checked(input.source, true); fs.closeSync(fd);
|
||||
withParent(input.source, false, (source) => withParent(input.root, false, (target) => {
|
||||
try { fs.lstatSync(target); throw new Error("repository_destination_already_exists"); }
|
||||
catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
fs.renameSync(source, target);
|
||||
})); result = {};
|
||||
} else {
|
||||
const rootFd = checked(input.root, true); fs.closeSync(rootFd);
|
||||
if (input.operation === "scan") result = scan();
|
||||
else if (input.operation === "read") {
|
||||
const fd = checked(full(input.path));
|
||||
try {
|
||||
const buffer = Buffer.alloc(MAX_CHUNK);
|
||||
const count = fs.readSync(fd, buffer, 0, buffer.length, input.offset);
|
||||
result = { data: buffer.subarray(0, count).toString("base64") };
|
||||
} finally { fs.closeSync(fd); }
|
||||
} else if (input.operation === "mkdir") {
|
||||
parents(input.path); ensureDirectory(full(input.path)); result = {};
|
||||
} else if (input.operation === "write") {
|
||||
parents(input.path);
|
||||
const buffer = Buffer.from(input.data, "base64");
|
||||
if (buffer.length > MAX_CHUNK) throw new Error("chunk_too_large");
|
||||
const target = full(input.path);
|
||||
// Temporary writes happen in a separate host-selected staging root.
|
||||
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW | (input.offset === 0 ? fs.constants.O_EXCL : 0);
|
||||
withParent(target, false, (anchored) => {
|
||||
const fd = fs.openSync(anchored, flags, 0o600);
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (!stat.isFile() || stat.nlink !== 1) throw new Error("unsupported_file");
|
||||
if (stat.size !== input.offset) throw new Error("invalid_chunk_offset");
|
||||
fs.writeSync(fd, buffer, 0, buffer.length, input.offset);
|
||||
} finally { fs.closeSync(fd); }
|
||||
});
|
||||
result = {};
|
||||
} else if (input.operation === "publish") {
|
||||
parents(input.path);
|
||||
const source = path.join(input.stagingRoot, safeRelative(input.stagingPath));
|
||||
if (checksum(source) !== input.sha256) throw new Error("content_changed_during_transfer");
|
||||
const sourceFd = checked(source);
|
||||
try { fs.fchmodSync(sourceFd, input.executable ? 0o700 : 0o600); } finally { fs.closeSync(sourceFd); }
|
||||
const target = full(input.path);
|
||||
try { const fd = checked(target); fs.closeSync(fd); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
withParent(source, false, (from) => withParent(target, false, (to) => fs.renameSync(from, to))); result = {};
|
||||
} else if (input.operation === "symlink") {
|
||||
parents(input.path);
|
||||
const target = full(input.path);
|
||||
if (typeof input.linkTarget !== "string" || /[\x00-\x1f\x7f]/.test(input.linkTarget) || path.isAbsolute(input.linkTarget)
|
||||
|| !path.resolve(path.dirname(target), input.linkTarget).startsWith(`${input.root}/`)) throw new Error("symlink_outside_repository");
|
||||
const temporary = path.join(input.stagingRoot, safeRelative(input.stagingPath));
|
||||
withParent(temporary, false, (from) => {
|
||||
fs.symlinkSync(input.linkTarget, from);
|
||||
withParent(target, false, (to) => fs.renameSync(from, to));
|
||||
}); result = {};
|
||||
} else if (input.operation === "remove") {
|
||||
const target = full(input.path);
|
||||
// Never recursively follow a user-created symlink during deletion.
|
||||
try {
|
||||
const stat = withParent(target, false, (anchored) => fs.lstatSync(anchored));
|
||||
const fd = checked(target, stat.isDirectory()); fs.closeSync(fd);
|
||||
withParent(target, false, (anchored) => { if (stat.isDirectory()) fs.rmdirSync(anchored); else fs.unlinkSync(anchored); });
|
||||
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
result = {};
|
||||
} else throw new Error("unknown_operation");
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { agents, companyMemberships, heartbeatRuns, issues, projects, workFolderRuns, type Db } from "@paperclipai/db";
|
||||
import type { WorkFolderOwner } from "@paperclipai/shared";
|
||||
import { notFound } from "../errors.js";
|
||||
import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js";
|
||||
|
||||
/** Private-file access never inherits the responsible-user shadow-mode bypass. */
|
||||
export async function assertWorkFolderAccess(db: Db, actor: AuthorizationActor, owner: WorkFolderOwner, write: boolean) {
|
||||
const deny = () => { throw notFound("Work folder not found"); };
|
||||
if (actor.type === "none") deny();
|
||||
if (actor.type === "agent" && actor.companyId !== owner.companyId) deny();
|
||||
if (actor.type === "board" && actor.source !== "local_implicit" && !actor.companyIds?.includes(owner.companyId)) deny();
|
||||
const userId = actor.type === "board" ? actor.userId : actor.onBehalfOfUserId;
|
||||
if (actor.source !== "local_implicit" && userId) {
|
||||
const [membership] = await db.select().from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, owner.companyId), eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active")));
|
||||
if (!membership || (write && membership.membershipRole === "viewer")) deny();
|
||||
}
|
||||
if (owner.scope === "user") {
|
||||
if (!userId || userId !== owner.ownerId) deny();
|
||||
if (actor.type === "agent") {
|
||||
if (!actor.runId || !actor.agentId || actor.source !== "agent_jwt") deny();
|
||||
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, actor.runId!),
|
||||
eq(heartbeatRuns.companyId, owner.companyId), eq(heartbeatRuns.agentId, actor.agentId!)));
|
||||
const [binding] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, actor.runId!));
|
||||
if (!run || run.status !== "running" || run.responsibleUserId !== owner.ownerId
|
||||
|| binding?.manifest.responsibleUserId !== owner.ownerId) deny();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let resource: AuthorizationResource;
|
||||
let action: "issue:read" | "issue:mutate" | "agent:read" | "agent_config:update" | "project:read";
|
||||
if (owner.scope === "task") {
|
||||
const [issue] = await db.select().from(issues).where(and(eq(issues.id, owner.ownerId), eq(issues.companyId, owner.companyId)));
|
||||
if (!issue) return deny();
|
||||
resource = { type: "issue", companyId: owner.companyId, issueId: issue.id, projectId: issue.projectId,
|
||||
parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId,
|
||||
originKind: issue.originKind, originId: issue.originId, status: issue.status };
|
||||
action = write ? "issue:mutate" : "issue:read";
|
||||
} else if (owner.scope === "agent") {
|
||||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, owner.ownerId), eq(agents.companyId, owner.companyId)));
|
||||
if (!agent) return deny();
|
||||
if (write && actor.type === "agent" && actor.agentId !== agent.id) deny();
|
||||
resource = { type: "agent", companyId: owner.companyId, agentId: agent.id };
|
||||
action = "agent:read";
|
||||
} else {
|
||||
const [project] = await db.select().from(projects).where(and(eq(projects.id, owner.ownerId), eq(projects.companyId, owner.companyId)));
|
||||
if (!project) return deny();
|
||||
resource = { type: "project", companyId: owner.companyId, projectId: project.id };
|
||||
action = "project:read";
|
||||
}
|
||||
const authz = authorizationService(db);
|
||||
if (!(await authz.decide({ actor, action, resource })).allowed) deny();
|
||||
if (actor.type === "agent" && userId) {
|
||||
const responsibleActor: AuthorizationActor = { type: "board", source: "session", userId,
|
||||
companyIds: [owner.companyId], memberships: actor.onBehalfOfMemberships, ignoreInstanceAdmin: true };
|
||||
if (!(await authz.decide({ actor: responsibleActor, action, resource })).allowed) deny();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { WORK_FOLDER_SYNC_INTERVAL_MS } from "@paperclipai/shared";
|
||||
|
||||
/** One scheduler for both execution generations. A tick never queues a backlog. */
|
||||
export function startWorkFolderCheckpointer(input: {
|
||||
checkpoint(): Promise<void>;
|
||||
onError(error: unknown): Promise<void>;
|
||||
}) {
|
||||
let active: Promise<void> | null = null;
|
||||
let stopped = false;
|
||||
function checkpoint() {
|
||||
const pending = (async () => {
|
||||
try { await input.checkpoint(); }
|
||||
catch (error) { await input.onError(error); throw error; }
|
||||
})();
|
||||
active = pending;
|
||||
void pending.finally(() => { if (active === pending) active = null; }).catch(() => {});
|
||||
return pending;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
if (!stopped && !active) void checkpoint().catch(() => {});
|
||||
}, WORK_FOLDER_SYNC_INTERVAL_MS);
|
||||
timer.unref();
|
||||
// Explicit flushes serialize too. They must checkpoint once more after an
|
||||
// in-flight tick, since the agent may have edited during that tick.
|
||||
let flushTail: Promise<void> = Promise.resolve();
|
||||
function flush() {
|
||||
const pending = flushTail.catch(() => {}).then(async () => {
|
||||
await active?.catch(() => {});
|
||||
await checkpoint();
|
||||
});
|
||||
flushTail = pending;
|
||||
return pending;
|
||||
}
|
||||
return {
|
||||
flush,
|
||||
async stop() { stopped = true; clearInterval(timer); await flush(); },
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { workFolderObjects, workFiles, taskRepositoryBindings, type Db } from "@paperclipai/db";
|
||||
import type { StorageProvider } from "../storage/types.js";
|
||||
|
||||
/** Record an upload before contacting storage, so abandoned bytes remain collectible. */
|
||||
export async function registerWorkFolderObject(db: Db, storage: StorageProvider, input: {
|
||||
objectKey: string; companyId: string; folderId?: string; repositoryBindingId?: string;
|
||||
}) {
|
||||
await db.insert(workFolderObjects).values({ ...input, provider: storage.id,
|
||||
deleteAfter: new Date(Date.now() + 24 * 60 * 60 * 1000) }).onConflictDoUpdate({
|
||||
target: workFolderObjects.objectKey,
|
||||
set: { deleteAfter: sql`case when ${workFolderObjects.deleteAfter} is null then null else ${new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()}::timestamptz end` },
|
||||
});
|
||||
}
|
||||
|
||||
/** Bounded, retryable cleanup. Committed trash stays referenced until explicit purge. */
|
||||
export async function collectWorkFolderGarbage(db: Db, storage: StorageProvider, now = new Date(), limit = 100) {
|
||||
// Polymorphic owners cannot use a single foreign key. Permanent deletion is
|
||||
// detected against the authoritative owner tables, including auth users.
|
||||
await db.execute(sql`delete from work_folders f where
|
||||
(f.scope = 'task' and not exists (select 1 from issues i where i.id::text = f.owner_id and i.company_id = f.company_id)) or
|
||||
(f.scope = 'agent' and not exists (select 1 from agents a where a.id::text = f.owner_id and a.company_id = f.company_id)) or
|
||||
(f.scope = 'project' and not exists (select 1 from projects p where p.id::text = f.owner_id and p.company_id = f.company_id)) or
|
||||
(f.scope = 'user' and not exists (select 1 from "user" u where u.id = f.owner_id))`);
|
||||
let deleted = 0;
|
||||
await db.transaction(async (tx) => {
|
||||
const candidates = await tx.select().from(workFolderObjects).where(and(eq(workFolderObjects.provider, storage.id),
|
||||
sql`(${workFolderObjects.deleteAfter} <= ${now.toISOString()}::timestamptz or (${workFolderObjects.deleteAfter} is null and (
|
||||
(${workFolderObjects.folderId} is not null and not exists (select 1 from ${workFiles} where ${workFiles.objectKey} = ${workFolderObjects.objectKey})) or
|
||||
(${workFolderObjects.repositoryBindingId} is not null and not exists (select 1 from ${taskRepositoryBindings} where ${taskRepositoryBindings.id} = ${workFolderObjects.repositoryBindingId}))
|
||||
)))`,
|
||||
sql`not exists (select 1 from ${workFiles} where ${workFiles.objectKey} = ${workFolderObjects.objectKey})`,
|
||||
)).limit(Math.max(1, Math.min(limit, 1000))).for("update", { skipLocked: true });
|
||||
for (const object of candidates) {
|
||||
const prefix = object.folderId ? `${object.companyId}/work-folders/${object.folderId}/`
|
||||
: `${object.companyId}/task-repositories/${object.repositoryBindingId}/`;
|
||||
if (!object.objectKey.startsWith(prefix)) throw new Error("Work folder garbage ownership mismatch");
|
||||
await storage.deleteObject({ objectKey: object.objectKey });
|
||||
await tx.delete(workFolderObjects).where(eq(workFolderObjects.objectKey, object.objectKey));
|
||||
deleted++;
|
||||
}
|
||||
});
|
||||
return { deleted };
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { taskRepositoryBindings, workFolderObjects, type Db } from "@paperclipai/db";
|
||||
import { validateWorkFilePath } from "@paperclipai/shared";
|
||||
import { z } from "zod";
|
||||
import { registerWorkFolderObject } from "./work-folder-garbage.js";
|
||||
import type { StorageProvider } from "../storage/types.js";
|
||||
import type { WorkFolderTransport, WorkTreeEntry } from "./work-folder-transport.js";
|
||||
|
||||
type Binding = typeof taskRepositoryBindings.$inferSelect;
|
||||
const checkpointSchema = z.object({ version: z.literal(1), bindingId: z.uuid(), files: z.array(z.object({
|
||||
path: z.string(), kind: z.enum(["file", "directory"]), byteSize: z.number().int().min(0).max(1024 ** 3),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(), executable: z.boolean(), objectKey: z.string().nullable(), linkTarget: z.string().max(1024).optional(),
|
||||
})).max(100_000) });
|
||||
|
||||
function signature(entries: WorkTreeEntry[]) {
|
||||
return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
||||
}
|
||||
|
||||
export function workFolderRepositoryService(db: Db, storage: StorageProvider, transport: WorkFolderTransport) {
|
||||
const knownByBinding = new Map<string, Set<string>>();
|
||||
async function checkpoint(binding: Binding, root: string) {
|
||||
const entries = await transport.scan(root, true);
|
||||
const digest = signature(entries);
|
||||
if (binding.checkpointSha256 === digest) return;
|
||||
const prefix = `${binding.companyId}/task-repositories/${binding.id}/`;
|
||||
if (!knownByBinding.has(binding.id) && binding.checkpointKey) await loadManifest(binding);
|
||||
const known = knownByBinding.get(binding.id) ?? new Set<string>();
|
||||
const files: Array<WorkTreeEntry & { objectKey: string | null }> = [];
|
||||
for (const entry of entries) {
|
||||
const objectKey = entry.kind === "file" && !entry.linkTarget ? `${prefix}blobs/${entry.sha256}` : null;
|
||||
if (objectKey && !known.has(objectKey)) await registerWorkFolderObject(db, storage, { objectKey, companyId: binding.companyId, repositoryBindingId: binding.id });
|
||||
if (objectKey && !known.has(objectKey) && !(await storage.headObject({ objectKey })).exists) {
|
||||
const hash = createHash("sha256");
|
||||
const verify = new Transform({ transform(chunk: Buffer, _encoding, callback) { hash.update(chunk); callback(null, chunk); },
|
||||
flush(callback) { callback(hash.digest("hex") === entry.sha256 ? undefined : new Error("Repository changed during checkpoint")); } });
|
||||
const source = transport.read(root, entry.path, entry.byteSize);
|
||||
source.on("error", (error) => verify.destroy(error));
|
||||
try {
|
||||
await storage.putObject({ objectKey, body: source.pipe(verify), contentType: "application/octet-stream", contentLength: entry.byteSize });
|
||||
} finally { source.destroy(); verify.destroy(); }
|
||||
}
|
||||
files.push({ ...entry, objectKey });
|
||||
}
|
||||
// Never publish a torn Git index/worktree snapshot as a completed save.
|
||||
if (signature(await transport.scan(root, true)) !== digest) throw new Error("Repository changed during checkpoint; retry required");
|
||||
const checkpointKey = `${prefix}checkpoints/${randomUUID()}.json`;
|
||||
const body = Buffer.from(JSON.stringify({ version: 1, bindingId: binding.id, files }));
|
||||
await registerWorkFolderObject(db, storage, { objectKey: checkpointKey, companyId: binding.companyId, repositoryBindingId: binding.id });
|
||||
await storage.putObject({ objectKey: checkpointKey, body, contentType: "application/json", contentLength: body.length });
|
||||
await db.transaction(async (tx) => {
|
||||
// Retain every object in this complete binding before making the pointer
|
||||
// visible. Unpublished uploads keep their expiry for later collection.
|
||||
const published = [checkpointKey, ...new Set(files.flatMap((file) => file.objectKey && !known.has(file.objectKey) ? [file.objectKey] : []))];
|
||||
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)));
|
||||
});
|
||||
knownByBinding.set(binding.id, new Set(files.flatMap((file) => file.objectKey ? [file.objectKey] : [])));
|
||||
binding.checkpointKey = checkpointKey;
|
||||
binding.checkpointSha256 = digest;
|
||||
}
|
||||
|
||||
async function loadManifest(binding: Binding) {
|
||||
if (!binding.checkpointKey) throw new Error("Repository checkpoint is missing");
|
||||
const prefix = `${binding.companyId}/task-repositories/${binding.id}/`;
|
||||
if (!binding.checkpointKey.startsWith(prefix)) throw new Error("Repository checkpoint ownership mismatch");
|
||||
const { stream } = await storage.getObject({ objectKey: binding.checkpointKey });
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
for await (const chunk of stream) {
|
||||
length += chunk.length;
|
||||
if (length > 64 * 1024 * 1024) { stream.destroy(); throw new Error("Repository checkpoint manifest exceeds size limit"); }
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const manifest = checkpointSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
||||
if (manifest.bindingId !== binding.id) throw new Error("Repository checkpoint ownership mismatch");
|
||||
for (const entry of manifest.files) {
|
||||
validateWorkFilePath(entry.path);
|
||||
if (entry.objectKey && entry.objectKey !== `${prefix}blobs/${entry.sha256}`) throw new Error("Repository object ownership mismatch");
|
||||
}
|
||||
const entries = manifest.files.map(({ objectKey: _key, ...entry }) => entry);
|
||||
if (signature(entries) !== binding.checkpointSha256) throw new Error("Repository checkpoint integrity mismatch");
|
||||
knownByBinding.set(binding.id, new Set(manifest.files.flatMap((file) => file.objectKey ? [file.objectKey] : [])));
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function restore(binding: Binding, root: string, stagingRoot: string) {
|
||||
if (!binding.checkpointKey) return false;
|
||||
const manifest = await loadManifest(binding);
|
||||
await transport.mkdirRoot(root);
|
||||
for (const entry of manifest.files) {
|
||||
if (entry.kind === "directory") await transport.mkdir(root, entry.path);
|
||||
else if (entry.linkTarget) await transport.symlink(root, stagingRoot, entry);
|
||||
else {
|
||||
if (!entry.objectKey) throw new Error("Repository checkpoint file is missing");
|
||||
const result = await storage.getObject({ objectKey: entry.objectKey });
|
||||
try { await transport.write(root, stagingRoot, entry, result.stream); } finally { result.stream.destroy(); }
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return { checkpoint, restore };
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { environmentLeases, workFolderRuns, type Db } from "@paperclipai/db";
|
||||
|
||||
export function workFolderSandboxKey(lease: { id: string; companyId: string; environmentId: string | null; provider: string | null; providerLeaseId: string | null }) {
|
||||
return lease.providerLeaseId ? createHash("sha256").update(JSON.stringify([lease.companyId, lease.environmentId, lease.provider, lease.providerLeaseId])).digest("hex") : lease.id;
|
||||
}
|
||||
|
||||
/** A periodic checkpoint is not permission to discard edits made after it. */
|
||||
export async function retainUnsavedWorkFolderLease(db: Db, lease: { id: string; companyId: string }) {
|
||||
const [row] = await db.select().from(environmentLeases).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, lease.companyId)));
|
||||
if (!row) return false;
|
||||
const sandboxKey = workFolderSandboxKey(row);
|
||||
const [run] = await db.select({ manifest: workFolderRuns.manifest, state: workFolderRuns.state })
|
||||
.from(workFolderRuns).where(and(eq(workFolderRuns.companyId, lease.companyId),
|
||||
sql`(${workFolderRuns.manifest}->>'sandboxKey' = ${sandboxKey} or ${workFolderRuns.manifest}->>'leaseId' = ${lease.id})`))
|
||||
.orderBy(desc(workFolderRuns.updatedAt)).limit(1);
|
||||
if (!run || (run.state === "saved" && run.manifest.finalCheckpointAt)) return false;
|
||||
await db.update(environmentLeases).set({ status: "retained", expiresAt: null,
|
||||
failureReason: "work_folder_save_required", cleanupStatus: "failed",
|
||||
metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || '{"workFolderRecoveryRequired":true}'::jsonb`,
|
||||
}).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, lease.companyId)));
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
import { validateWorkFilePath } from "@paperclipai/shared";
|
||||
|
||||
const entrySchema = z.object({ path: z.string().refine((value) => { try { validateWorkFilePath(value); return true; } catch { return false; } }),
|
||||
kind: z.enum(["file", "directory"]), byteSize: z.number().int().nonnegative().max(1024 ** 3),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(), executable: z.boolean(), linkTarget: z.string().max(1024).optional() });
|
||||
export type WorkTreeEntry = z.infer<typeof entrySchema>;
|
||||
let source: Promise<string> | undefined;
|
||||
|
||||
export function workFolderTransport(runner: CommandManagedRuntimeRunner) {
|
||||
async function command(input: Record<string, unknown>): Promise<unknown> {
|
||||
source ??= readFile(new URL("./scripts/work-folder-io.mjs", import.meta.url), "utf8");
|
||||
const result = await runner.execute({ command: "node", args: ["--input-type=module", "-e", await source,
|
||||
Buffer.from(JSON.stringify(input)).toString("base64")], bypassSession: true, timeoutMs: 120_000 });
|
||||
if (result.exitCode !== 0 || result.timedOut) throw new Error(`Work folder ${String(input.operation)} failed: ${result.stderr.slice(0, 1500)}`);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
async function home() {
|
||||
const result = z.object({ home: z.string().startsWith("/") }).parse(await command({ operation: "home" }));
|
||||
return result.home;
|
||||
}
|
||||
async function scan(root: string, repository = false) {
|
||||
return z.array(entrySchema).max(100_000).parse(await command({ operation: "scan", root, repository }));
|
||||
}
|
||||
function read(root: string, filePath: string, byteSize: number) {
|
||||
validateWorkFilePath(filePath);
|
||||
return Readable.from((async function* () {
|
||||
for (let offset = 0; offset < byteSize;) {
|
||||
const result = z.object({ data: z.string().max(350_000) }).parse(await command({ operation: "read", root, path: filePath, offset }));
|
||||
const bytes = Buffer.from(result.data, "base64");
|
||||
if (bytes.length === 0 || offset + bytes.length > byteSize) throw new Error("Work file changed during transfer");
|
||||
offset += bytes.length;
|
||||
yield bytes;
|
||||
}
|
||||
})());
|
||||
}
|
||||
async function write(root: string, stagingRoot: string, entry: WorkTreeEntry, body: Readable) {
|
||||
const stagingPath = randomUUID();
|
||||
let offset = 0;
|
||||
for await (const value of body) {
|
||||
const chunk = Buffer.from(value);
|
||||
for (let start = 0; start < chunk.length; start += 256 * 1024) {
|
||||
const bytes = chunk.subarray(start, start + 256 * 1024);
|
||||
await command({ operation: "write", root: stagingRoot, path: stagingPath, offset, data: bytes.toString("base64") });
|
||||
offset += bytes.length;
|
||||
}
|
||||
}
|
||||
if (offset === 0) await command({ operation: "write", root: stagingRoot, path: stagingPath, offset: 0, data: "" });
|
||||
if (offset !== entry.byteSize) throw new Error("Work file size changed during transfer");
|
||||
await command({ operation: "publish", root, stagingRoot, stagingPath, path: entry.path,
|
||||
sha256: entry.sha256, executable: entry.executable });
|
||||
}
|
||||
return { home, scan, read, write,
|
||||
moveRoot: async (source: string, root: string) => { await command({ operation: "move-root", source, root }); },
|
||||
symlink: async (root: string, stagingRoot: string, entry: WorkTreeEntry) => { await command({ operation: "symlink", root, stagingRoot, stagingPath: randomUUID(), path: entry.path, linkTarget: entry.linkTarget }); },
|
||||
mkdirRoot: async (root: string) => { await command({ operation: "mkdir-root", root }); },
|
||||
mkdir: async (root: string, filePath: string) => { await command({ operation: "mkdir", root, path: filePath }); },
|
||||
remove: async (root: string, filePath: string) => { await command({ operation: "remove", root, path: filePath }); },
|
||||
};
|
||||
}
|
||||
export type WorkFolderTransport = ReturnType<typeof workFolderTransport>;
|
||||
export function workFolderPaths(home: string) {
|
||||
return Object.fromEntries(["task", "agent", "user", "project", "repos", ".cache", ".codex", ".paperclip-work-folders"].map((name) => [name, path.posix.join(home, name)]));
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { and, asc, eq, gt, isNull, isNotNull, sql } from "drizzle-orm";
|
||||
import { workFolders, workFiles, workFileOperations, workFolderObjects, type Db } from "@paperclipai/db";
|
||||
import { validateWorkFilePath, type WorkFile, type WorkFolderOwner } from "@paperclipai/shared";
|
||||
import { registerWorkFolderObject } from "./work-folder-garbage.js";
|
||||
import type { StorageProvider } from "../storage/types.js";
|
||||
import { badRequest, conflict, notFound, payloadTooLarge } from "../errors.js";
|
||||
|
||||
export const MAX_WORK_FILE_BYTES = 1024 * 1024 * 1024;
|
||||
type Folder = typeof workFolders.$inferSelect;
|
||||
type FileRow = typeof workFiles.$inferSelect;
|
||||
|
||||
export function workFileDto(row: FileRow): WorkFile {
|
||||
return { id: row.id, path: row.path, kind: row.kind, byteSize: row.byteSize,
|
||||
sha256: row.sha256, executable: row.executable, contentType: row.contentType,
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null, updatedAt: row.updatedAt.toISOString() };
|
||||
}
|
||||
|
||||
function validPath(value: string) {
|
||||
try { return validateWorkFilePath(value); } catch { throw badRequest("Invalid work file path"); }
|
||||
}
|
||||
|
||||
/** The caller must authorize the folder owner before using this host-only service. */
|
||||
export function workFolderService(db: Db, storage: StorageProvider) {
|
||||
async function ensure(owner: WorkFolderOwner): Promise<Folder> {
|
||||
await db.insert(workFolders).values(owner).onConflictDoNothing();
|
||||
const [folder] = await db.select().from(workFolders).where(and(eq(workFolders.companyId, owner.companyId),
|
||||
eq(workFolders.scope, owner.scope), eq(workFolders.ownerId, owner.ownerId)));
|
||||
if (!folder) throw notFound("Work folder not found");
|
||||
return folder;
|
||||
}
|
||||
|
||||
async function list(folder: Folder, options: { trash?: boolean; cursor?: string; limit?: number } = {}) {
|
||||
const limit = Math.max(1, Math.min(1000, options.limit ?? 200));
|
||||
const rows = await db.select().from(workFiles).where(and(eq(workFiles.folderId, folder.id),
|
||||
eq(workFiles.companyId, folder.companyId), options.trash ? isNotNull(workFiles.deletedAt) : isNull(workFiles.deletedAt),
|
||||
options.cursor ? gt(workFiles.id, options.cursor) : undefined)).orderBy(asc(workFiles.id)).limit(limit + 1);
|
||||
return { id: folder.id, owner: { companyId: folder.companyId, scope: folder.scope, ownerId: folder.ownerId },
|
||||
files: rows.slice(0, limit).map(workFileDto), nextCursor: rows.length > limit ? rows[limit - 1]!.id : null };
|
||||
}
|
||||
|
||||
async function get(folder: Folder, filePath: string) {
|
||||
const [row] = await db.select().from(workFiles).where(and(eq(workFiles.companyId, folder.companyId),
|
||||
eq(workFiles.folderId, folder.id), eq(workFiles.path, validPath(filePath)), isNull(workFiles.deletedAt)));
|
||||
if (!row) throw notFound("Work file not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
async function content(folder: Folder, filePath: string) {
|
||||
const row = await get(folder, filePath);
|
||||
if (row.kind !== "file" || !row.objectKey) throw badRequest("Path is a directory");
|
||||
return { file: workFileDto(row), ...(await storage.getObject({ objectKey: row.objectKey })) };
|
||||
}
|
||||
|
||||
async function mutate<T>(folder: Folder, operationId: string, fingerprint: string,
|
||||
apply: (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => Promise<T>) {
|
||||
if (!operationId || operationId.length > 256) throw badRequest("An operation ID is required");
|
||||
return db.transaction(async (tx) => {
|
||||
// Serialize acceptance order across all app processes, including mkdir/delete races.
|
||||
const [locked] = await tx.select().from(workFolders).where(and(eq(workFolders.id, folder.id),
|
||||
eq(workFolders.companyId, folder.companyId))).for("update");
|
||||
if (!locked) throw notFound("Work folder not found");
|
||||
const [receipt] = await tx.select().from(workFileOperations).where(and(
|
||||
eq(workFileOperations.folderId, folder.id), eq(workFileOperations.operationId, operationId)));
|
||||
if (receipt) {
|
||||
if (receipt.fingerprint !== fingerprint) throw conflict("Operation ID was already used for different content");
|
||||
return { applied: false as const };
|
||||
}
|
||||
const result = await apply(tx);
|
||||
await tx.insert(workFileOperations).values({ companyId: folder.companyId, folderId: folder.id, operationId, fingerprint });
|
||||
return { applied: true as const, result };
|
||||
});
|
||||
}
|
||||
|
||||
async function write(folder: Folder, input: {
|
||||
path: string; body?: Readable | Buffer; contentType?: string; executable?: boolean;
|
||||
kind?: "file" | "directory"; operationId: string; maxBytes?: number; expectedSha256?: string | null;
|
||||
replaceKind?: boolean; onlyIfMissing?: boolean;
|
||||
}) {
|
||||
const filePath = validPath(input.path);
|
||||
const kind = input.kind ?? "file";
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "paperclip-work-file-"));
|
||||
const spool = path.join(directory, "content");
|
||||
const hash = createHash("sha256");
|
||||
let byteSize = 0;
|
||||
let objectKey: string | null = null;
|
||||
let discardUpload = false;
|
||||
try {
|
||||
if (kind === "file") {
|
||||
const source = Buffer.isBuffer(input.body) ? Readable.from([input.body]) : input.body ?? Readable.from([]);
|
||||
await pipeline(source, new Transform({ transform(chunk: Buffer, _encoding, callback) {
|
||||
byteSize += chunk.length;
|
||||
if (byteSize > (input.maxBytes ?? MAX_WORK_FILE_BYTES)) return callback(payloadTooLarge("Work file exceeds the size limit"));
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
} }), createWriteStream(spool, { mode: 0o600 }));
|
||||
objectKey = `${folder.companyId}/work-folders/${folder.id}/${randomUUID()}`;
|
||||
await registerWorkFolderObject(db, storage, { objectKey, companyId: folder.companyId, folderId: folder.id });
|
||||
await storage.putObject({ objectKey, body: createReadStream(spool), contentLength: byteSize,
|
||||
contentType: input.contentType ?? "application/octet-stream" });
|
||||
}
|
||||
const sha256 = kind === "file" ? hash.digest("hex") : null;
|
||||
if (input.expectedSha256 && input.expectedSha256 !== sha256) throw conflict("File changed during transfer; retry the checkpoint");
|
||||
const value = { kind, objectKey, byteSize, sha256, executable: input.executable ?? false,
|
||||
contentType: input.contentType ?? "application/octet-stream", updatedAt: new Date() };
|
||||
const fingerprint = JSON.stringify(["write", filePath, kind, sha256, value.executable, value.contentType, Boolean(input.replaceKind), Boolean(input.onlyIfMissing)]);
|
||||
const result = await mutate(folder, input.operationId, fingerprint, async (tx) => {
|
||||
const parts = filePath.split("/");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const parent = parts.slice(0, i).join("/");
|
||||
const [existing] = await tx.select().from(workFiles).where(and(eq(workFiles.folderId, folder.id),
|
||||
eq(workFiles.path, parent), isNull(workFiles.deletedAt)));
|
||||
if (existing && existing.kind !== "directory") throw conflict("A parent path is a file");
|
||||
if (!existing) await tx.insert(workFiles).values({ companyId: folder.companyId, folderId: folder.id,
|
||||
path: parent, kind: "directory" });
|
||||
}
|
||||
const previousRows = await tx.select().from(workFiles).where(and(eq(workFiles.folderId, folder.id),
|
||||
eq(workFiles.path, filePath), isNull(workFiles.deletedAt)));
|
||||
let previous: FileRow | undefined = previousRows[0];
|
||||
if (previous && input.onlyIfMissing) return { oldKey: null, unusedUpload: true };
|
||||
if (previous && previous.kind !== kind) {
|
||||
if (!input.replaceKind) throw conflict("Delete the existing path before changing its kind");
|
||||
const prefix = `${filePath}/`;
|
||||
await tx.update(workFiles).set({ deletedAt: new Date(), updatedAt: new Date() }).where(and(
|
||||
eq(workFiles.folderId, folder.id), isNull(workFiles.deletedAt),
|
||||
sql`(${workFiles.path} = ${filePath} or left(${workFiles.path}, ${prefix.length}) = ${prefix})`));
|
||||
previous = undefined;
|
||||
}
|
||||
if (previous) {
|
||||
await tx.update(workFiles).set(value).where(eq(workFiles.id, previous.id));
|
||||
} else {
|
||||
await tx.insert(workFiles).values({ ...value, companyId: folder.companyId, folderId: folder.id, path: filePath });
|
||||
}
|
||||
if (objectKey) await tx.update(workFolderObjects).set({ deleteAfter: null }).where(eq(workFolderObjects.objectKey, objectKey));
|
||||
if (previous?.objectKey) await tx.update(workFolderObjects).set({ deleteAfter: new Date() }).where(eq(workFolderObjects.objectKey, previous.objectKey));
|
||||
return { oldKey: previous?.objectKey ?? null, unusedUpload: false };
|
||||
});
|
||||
discardUpload = !result.applied || result.result.unusedUpload;
|
||||
// Object keys are private to this service; receipts contain no overwritten content.
|
||||
// Cleanup is journaled in the same transaction as replacement. Storage
|
||||
// outages and lost commit replies cannot orphan the only current object.
|
||||
return { applied: result.applied };
|
||||
} finally {
|
||||
// A lost database response can mean COMMIT succeeded. Retain an uncertain
|
||||
// upload for reconciliation; deleting it here could destroy saved content.
|
||||
if (objectKey && discardUpload) await db.update(workFolderObjects).set({ deleteAfter: new Date() }).where(eq(workFolderObjects.objectKey, objectKey));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(folder: Folder, filePath: string, operationId: string) {
|
||||
const normalized = validPath(filePath);
|
||||
return mutate(folder, operationId, JSON.stringify(["delete", normalized]), async (tx) => {
|
||||
const prefix = `${normalized}/`;
|
||||
await tx.update(workFiles).set({ deletedAt: new Date(), updatedAt: new Date() }).where(and(
|
||||
eq(workFiles.folderId, folder.id), isNull(workFiles.deletedAt),
|
||||
sql`(${workFiles.path} = ${normalized} or left(${workFiles.path}, ${prefix.length}) = ${prefix})`));
|
||||
});
|
||||
}
|
||||
|
||||
async function restore(folder: Folder, fileId: string, operationId: string) {
|
||||
return mutate(folder, operationId, JSON.stringify(["restore", fileId]), async (tx) => {
|
||||
const [deleted] = await tx.select().from(workFiles).where(and(eq(workFiles.folderId, folder.id),
|
||||
eq(workFiles.id, fileId), isNotNull(workFiles.deletedAt)));
|
||||
if (!deleted) throw notFound("Deleted file not found");
|
||||
const prefix = `${deleted.path}/`;
|
||||
const restoreRows = deleted.kind === "directory" ? await tx.select().from(workFiles).where(and(
|
||||
eq(workFiles.folderId, folder.id), eq(workFiles.deletedAt, deleted.deletedAt!),
|
||||
sql`(${workFiles.path} = ${deleted.path} or left(${workFiles.path}, ${prefix.length}) = ${prefix})`)) : [deleted];
|
||||
for (const row of restoreRows) {
|
||||
const [occupied] = await tx.select().from(workFiles).where(and(eq(workFiles.folderId, folder.id),
|
||||
eq(workFiles.path, row.path), isNull(workFiles.deletedAt)));
|
||||
if (occupied) throw conflict("Delete the current file before restoring this deleted copy");
|
||||
}
|
||||
const parts = deleted.path.split("/");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const parent = parts.slice(0, i).join("/");
|
||||
const [existing] = await tx.select().from(workFiles).where(and(eq(workFiles.folderId, folder.id),
|
||||
eq(workFiles.path, parent), isNull(workFiles.deletedAt)));
|
||||
if (existing && existing.kind !== "directory") throw conflict("A parent path is a file");
|
||||
if (!existing) await tx.insert(workFiles).values({ companyId: folder.companyId, folderId: folder.id,
|
||||
path: parent, kind: "directory" });
|
||||
}
|
||||
for (const row of restoreRows) await tx.update(workFiles).set({ deletedAt: null, updatedAt: new Date() }).where(eq(workFiles.id, row.id));
|
||||
});
|
||||
}
|
||||
|
||||
async function purge(folder: Folder, fileId: string, operationId: string) {
|
||||
return mutate(folder, operationId, JSON.stringify(["purge", fileId]), async (tx) => {
|
||||
const [deleted] = await tx.select().from(workFiles).where(and(eq(workFiles.folderId, folder.id),
|
||||
eq(workFiles.id, fileId), isNotNull(workFiles.deletedAt)));
|
||||
if (!deleted) throw notFound("Deleted file not found");
|
||||
const prefix = `${deleted.path}/`;
|
||||
const rows = await tx.delete(workFiles).where(and(eq(workFiles.folderId, folder.id),
|
||||
eq(workFiles.deletedAt, deleted.deletedAt!), deleted.kind === "directory"
|
||||
? sql`(${workFiles.path} = ${deleted.path} or left(${workFiles.path}, ${prefix.length}) = ${prefix})`
|
||||
: eq(workFiles.id, fileId))).returning({ objectKey: workFiles.objectKey });
|
||||
for (const row of rows) if (row.objectKey) await tx.update(workFolderObjects).set({ deleteAfter: new Date() }).where(eq(workFolderObjects.objectKey, row.objectKey));
|
||||
});
|
||||
}
|
||||
return { ensure, list, get, content, write, remove, restore, purge };
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type { WorkFile, WorkFolderListing, WorkFolderOwner, WorkFolderSyncStatus } from "@paperclipai/shared";
|
||||
import { api, ApiError } from "./client";
|
||||
|
||||
function base(owner: WorkFolderOwner) {
|
||||
return `/companies/${encodeURIComponent(owner.companyId)}/work-folders/${owner.scope}/${encodeURIComponent(owner.ownerId)}`;
|
||||
}
|
||||
export const workFoldersApi = {
|
||||
async list(owner: WorkFolderOwner, trash = false) {
|
||||
const files: WorkFile[] = [];
|
||||
let cursor: string | null = null;
|
||||
do {
|
||||
const query = new URLSearchParams({ trash: String(trash), limit: "1000", ...(cursor ? { cursor } : {}) });
|
||||
const page: WorkFolderListing = await api.get(`${base(owner)}?${query}`);
|
||||
files.push(...page.files);
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor && files.length < 100_000);
|
||||
if (cursor) throw new Error("This folder is too large to display in one view");
|
||||
return files;
|
||||
},
|
||||
upload: (owner: WorkFolderOwner, file: File, filePath: string, operationId: string) =>
|
||||
api.putRaw(`${base(owner)}/content?${new URLSearchParams({ path: filePath })}`, file,
|
||||
{ headers: { "X-File-Content-Type": file.type || "application/octet-stream", "Idempotency-Key": operationId } }),
|
||||
operation: (owner: WorkFolderOwner, operation: { action: "mkdir" | "delete"; path: string } | { action: "restore" | "purge"; fileId: string }, operationId: string) =>
|
||||
api.post(`${base(owner)}/operations`, operation, { headers: { "Idempotency-Key": operationId } }),
|
||||
downloadUrl: (owner: WorkFolderOwner, filePath: string) => `/api${base(owner)}/content?${new URLSearchParams({ path: filePath })}`,
|
||||
async preview(owner: WorkFolderOwner, file: WorkFile) {
|
||||
if (file.byteSize > 8 * 1024 * 1024) throw new Error("Download this file to view it; previews are limited to 8 MB");
|
||||
const response = await fetch(this.downloadUrl(owner, file.path), { credentials: "include", cache: "no-store" });
|
||||
if (!response.ok) throw new ApiError("File preview could not be loaded", response.status, null);
|
||||
const contentLength = Number(response.headers.get("Content-Length"));
|
||||
if (!Number.isFinite(contentLength) || contentLength > 8 * 1024 * 1024) { await response.body?.cancel(); throw new Error("File exceeds preview size limit"); }
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("File preview could not be loaded");
|
||||
const parts: ArrayBuffer[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
length += chunk.value.byteLength;
|
||||
if (length > 8 * 1024 * 1024) throw new Error("File exceeds preview size limit");
|
||||
parts.push(chunk.value.slice().buffer);
|
||||
}
|
||||
} finally { await reader.cancel(); reader.releaseLock(); }
|
||||
const blob = new Blob(parts, { type: file.contentType });
|
||||
const image = /^image\/(png|jpeg|gif|webp|avif)$/.test(file.contentType);
|
||||
const text = file.contentType.startsWith("text/") || /\.(md|txt|json|ya?ml|toml|csv|log|[cm]?[jt]sx?|py|sh|rs|go|css|html|xml)$/i.test(file.path) || file.byteSize === 0;
|
||||
if (!image && !text) throw new Error("Preview is unavailable for this file type; download it to view it");
|
||||
const data = image ? await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader(); reader.onerror = () => reject(new Error("Preview could not be read"));
|
||||
reader.onload = () => resolve(String(reader.result).split(",")[1]!); reader.readAsDataURL(blob);
|
||||
}) : await blob.text();
|
||||
return { resource: { title: file.path.split("/").at(-1)!, displayPath: file.path, contentType: file.contentType,
|
||||
previewKind: image ? "image" as const : "text" as const }, content: { encoding: image ? "base64" as const : "utf8" as const, data } };
|
||||
},
|
||||
sync: (owner: WorkFolderOwner) => api.get<WorkFolderSyncStatus[]>(`${base(owner)}/sync`),
|
||||
refresh: (owner: WorkFolderOwner, runId: string) => api.post(`${base(owner)}/refresh`, { runId }),
|
||||
};
|
||||
|
|
@ -109,7 +109,7 @@ function middleTruncatePath(path: string, maxLen = 80): string {
|
|||
return `${head}…${tail}`;
|
||||
}
|
||||
|
||||
function isMarkdownResource(resource: ResolvedWorkspaceResource): boolean {
|
||||
function isMarkdownResource(resource: Pick<ResolvedWorkspaceResource, "title" | "displayPath" | "contentType">): boolean {
|
||||
const contentType = resource.contentType?.toLowerCase() ?? "";
|
||||
if (contentType.includes("markdown")) return true;
|
||||
const path = (resource.displayPath || resource.title).toLowerCase();
|
||||
|
|
@ -231,7 +231,10 @@ export function FileViewerMetadataRow({
|
|||
}
|
||||
|
||||
interface FileContentViewerProps {
|
||||
content: WorkspaceFileContent;
|
||||
content: {
|
||||
resource: Pick<ResolvedWorkspaceResource, "title" | "displayPath" | "contentType" | "previewKind">;
|
||||
content: WorkspaceFileContent["content"];
|
||||
};
|
||||
highlightedLine: number | null;
|
||||
onLoaded?: (summary: string) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Download, FolderOpen, FolderPlus, RefreshCw, RotateCcw, Trash2, Upload } from "lucide-react";
|
||||
import type { WorkFile, WorkFolderOwner } from "@paperclipai/shared";
|
||||
import { workFoldersApi } from "@/api/work-folders";
|
||||
import { FileTree, type FileTreeNode } from "@/components/FileTree";
|
||||
import { FileContentViewer } from "@/components/FileViewerSheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
function tree(files: WorkFile[]) {
|
||||
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
||||
for (const file of files) {
|
||||
let parent = root;
|
||||
const parts = file.path.split("/");
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const name = parts[i]!;
|
||||
let node = parent.children.find((child) => child.name === name);
|
||||
if (!node) {
|
||||
node = { name, path: parts.slice(0, i + 1).join("/"), kind: i < parts.length - 1 || file.kind === "directory" ? "dir" : "file", children: [] };
|
||||
parent.children.push(node);
|
||||
}
|
||||
parent = node;
|
||||
}
|
||||
}
|
||||
function sort(nodes: FileTreeNode[]) { nodes.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name)); nodes.forEach((node) => sort(node.children)); }
|
||||
sort(root.children); return root.children;
|
||||
}
|
||||
|
||||
export function WorkFolderButton({ owner, label = "Files" }: { owner: WorkFolderOwner; label?: string }) {
|
||||
return <Dialog><DialogTrigger asChild><Button variant="outline" size="sm"><FolderOpen aria-hidden />{label}</Button></DialogTrigger>
|
||||
<DialogContent className="flex max-h-screen flex-col sm:max-w-5xl">
|
||||
<DialogHeader><DialogTitle>{label}</DialogTitle><DialogDescription>
|
||||
{owner.scope === "user" ? "Your private Paperclip files in this company." : `Files shared with this ${owner.scope}'s sandbox runs.`} Changes from running agents are saved every three minutes and when a run ends.
|
||||
</DialogDescription></DialogHeader>
|
||||
<WorkFolderBrowser key={`${owner.companyId}:${owner.scope}:${owner.ownerId}`} owner={owner} />
|
||||
</DialogContent></Dialog>;
|
||||
}
|
||||
|
||||
export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOwner; exampleFiles?: WorkFile[] }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [trash, setTrash] = useState(false);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(new Set<string>());
|
||||
const [directory, setDirectory] = useState("");
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const directoryId = useId();
|
||||
const key = ["work-folders", owner.companyId, owner.scope, owner.ownerId];
|
||||
const filesQuery = useQuery({ queryKey: [...key, "files", trash], queryFn: () => workFoldersApi.list(owner, trash),
|
||||
enabled: !exampleFiles, refetchInterval: 15_000, retry: false });
|
||||
const syncQuery = useQuery({ queryKey: [...key, "sync"], queryFn: () => workFoldersApi.sync(owner), enabled: !exampleFiles, refetchInterval: 5000, retry: false });
|
||||
const files = exampleFiles ?? filesQuery.data ?? [];
|
||||
const selected = files.find((file) => file.path === selectedPath);
|
||||
const nodes = useMemo(() => tree(files), [files]);
|
||||
const preview = useQuery({ queryKey: [...key, "preview", selected?.path, selected?.sha256],
|
||||
queryFn: () => workFoldersApi.preview(owner, selected!), enabled: !exampleFiles && !trash && selected?.kind === "file", retry: false });
|
||||
const mutation = useMutation({ mutationFn: async (action: { type: "upload"; files: File[] } | { type: "mkdir" } | { type: "delete"; path: string } | { type: "restore" | "purge"; fileId: string } | { type: "refresh" }) => {
|
||||
if (action.type === "upload") for (const file of action.files) await workFoldersApi.upload(owner, file, directory ? `${directory}/${file.name}` : file.name, crypto.randomUUID());
|
||||
else if (action.type === "mkdir") { await workFoldersApi.operation(owner, { action: "mkdir", path: directory }, crypto.randomUUID()); setExpanded((before) => new Set([...before, directory])); }
|
||||
else if (action.type === "delete") await workFoldersApi.operation(owner, { action: "delete", path: action.path }, crypto.randomUUID());
|
||||
else if (action.type === "restore" || action.type === "purge") await workFoldersApi.operation(owner, { action: action.type, fileId: action.fileId }, crypto.randomUUID());
|
||||
else for (const run of (syncQuery.data ?? []).filter((run) => run.active)) await workFoldersApi.refresh(owner, run.runId);
|
||||
}, onSuccess: async (_data, action) => {
|
||||
setAnnouncement(action.type === "refresh" ? "Refresh requested for the next safe run boundary." : "Files saved.");
|
||||
await queryClient.invalidateQueries({ queryKey: key });
|
||||
} });
|
||||
const statuses = syncQuery.data ?? [];
|
||||
const failed = statuses.find((status) => status.state === "failed");
|
||||
const saving = mutation.isPending || statuses.some((status) => status.state === "saving");
|
||||
const lastSaved = statuses.map((status) => status.lastSavedAt).filter((value): value is string => Boolean(value)).sort().at(-1);
|
||||
const disabled = mutation.isPending || Boolean(exampleFiles);
|
||||
return <div className="flex min-h-0 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={disabled || trash} onClick={() => fileInput.current?.click()}><Upload aria-hidden />Upload</Button>
|
||||
<input ref={fileInput} className="hidden" aria-label="Upload work files" type="file" multiple onChange={(event) => {
|
||||
const chosen = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = "";
|
||||
if (chosen.length) mutation.mutate({ type: "upload", files: chosen });
|
||||
}} />
|
||||
<Button variant={trash ? "secondary" : "outline"} size="sm" onClick={() => { setTrash(!trash); setSelectedPath(null); }}><Trash2 aria-hidden />{trash ? "Back to files" : "Trash"}</Button>
|
||||
<Button variant="outline" size="sm" disabled={disabled || !statuses.some((status) => status.active)} onClick={() => mutation.mutate({ type: "refresh" })}><RefreshCw aria-hidden />Refresh sandbox</Button>
|
||||
<span className="text-xs text-muted-foreground" role="status">{saving ? "Saving…" : failed ? "Save failed" : lastSaved ? `Saved ${new Date(lastSaved).toLocaleTimeString()}` : "Saved files"}</span>
|
||||
</div>
|
||||
{!trash && <div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex-1 space-y-1"><Label htmlFor={directoryId}>Folder path</Label><Input id={directoryId} value={directory} onChange={(event) => setDirectory(event.target.value)} placeholder="Root folder" /></div>
|
||||
<Button variant="outline" size="sm" disabled={disabled || !directory} onClick={() => mutation.mutate({ type: "mkdir" })}><FolderPlus aria-hidden />Create folder</Button>
|
||||
</div>}
|
||||
{[filesQuery.error, syncQuery.error, mutation.error].filter(Boolean).map((error, index) => <p key={index} role="alert" className="text-sm text-destructive">{(error as Error).message}</p>)}
|
||||
{failed && <p role="alert" className="text-sm text-destructive">{failed.error}</p>}
|
||||
<p className="sr-only" aria-live="polite">{announcement}</p>
|
||||
{trash ? <div className="max-h-96 overflow-auto">{files.length === 0 ? <p className="text-sm text-muted-foreground">Trash is empty.</p> : files.map((file) => <div key={file.id} className="flex items-center gap-2 border-b py-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{file.path}</span><Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "restore", fileId: file.id })}><RotateCcw aria-hidden />Restore</Button>
|
||||
<AlertDialog><AlertDialogTrigger asChild><Button size="sm" variant="ghost" disabled={disabled}>Purge…</Button></AlertDialogTrigger>
|
||||
<AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Permanently delete {file.path}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This deleted copy and its deleted children will no longer be recoverable.</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => mutation.mutate({ type: "purge", fileId: file.id })}>Permanently delete</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent></AlertDialog>
|
||||
</div>)}</div> : <div className="grid min-h-0 gap-3 md:grid-cols-3">
|
||||
<div className="max-h-96 overflow-auto rounded-md border"><FileTree nodes={nodes} selectedFile={selectedPath} expandedDirs={expanded}
|
||||
onToggleDir={(filePath) => { setSelectedPath(filePath); setExpanded((before) => { const next = new Set(before); if (next.has(filePath)) next.delete(filePath); else next.add(filePath); return next; }); }}
|
||||
onSelectFile={setSelectedPath} loading={!exampleFiles && filesQuery.isLoading} empty={{ title: "No files yet", description: "Upload files here, or create them during a sandbox run." }} ariaLabel={`${owner.scope} files`} /></div>
|
||||
<div className="flex min-h-0 flex-col gap-2 md:col-span-2">
|
||||
{selected && <div className="flex items-center gap-2"><span className="min-w-0 flex-1 truncate text-sm">{selected.path}</span>
|
||||
{selected.kind === "file" && !exampleFiles && <Button asChild size="sm" variant="outline"><a href={workFoldersApi.downloadUrl(owner, selected.path)} download><Download aria-hidden />Download</a></Button>}
|
||||
<Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "delete", path: selected.path })}><Trash2 aria-hidden />Delete</Button></div>}
|
||||
{preview.isLoading ? <p className="text-sm text-muted-foreground">Loading preview…</p> : preview.error ? <p role="alert" className="text-sm text-muted-foreground">{preview.error.message}</p> : preview.data ?
|
||||
<div className="flex max-h-96 min-h-0 flex-col overflow-auto rounded-md border"><FileContentViewer content={preview.data} highlightedLine={null} /></div> : <p className="text-sm text-muted-foreground">Select a file to preview it.</p>}
|
||||
</div>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ import { cn } from "../lib/utils";
|
|||
import { describeRunRetryState } from "../lib/runRetryState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { AuditFeed } from "./audit/AuditFeed";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -1373,6 +1374,8 @@ export function AgentDetail() {
|
|||
</Tabs>
|
||||
) : null}
|
||||
|
||||
<WorkFolderButton owner={{ companyId: agent.companyId, scope: "agent", ownerId: agent.id }} label="Agent files" />
|
||||
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
{isPendingApproval && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { WorkFolderBrowser } from "@/components/WorkFolderBrowser";
|
||||
import { ServicesList } from "./apps/app-detail/ServicesPanel";
|
||||
import { ComposioProvenanceChip } from "./apps/ComposioProvenanceChip";
|
||||
import type { ComposioServiceRow } from "./apps/composio-services";
|
||||
|
|
@ -482,6 +483,14 @@ export function DesignGuide() {
|
|||
{/* ============================================================ */}
|
||||
{/* COVERAGE */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Work folders">
|
||||
<WorkFolderBrowser owner={{ companyId: "example", scope: "task", ownerId: "example" }} exampleFiles={[
|
||||
{ id: "notes", path: "notes", kind: "directory", byteSize: 0, sha256: null, executable: false, contentType: "application/octet-stream", deletedAt: null, updatedAt: "2026-09-07T00:00:00Z" },
|
||||
{ id: "readme", path: "notes/README.md", kind: "file", byteSize: 24, sha256: null, executable: false, contentType: "text/markdown", deletedAt: null, updatedAt: "2026-09-07T00:00:00Z" },
|
||||
]} />
|
||||
<WorkFolderBrowser owner={{ companyId: "example", scope: "user", ownerId: "example" }} exampleFiles={[]} />
|
||||
</Section>
|
||||
|
||||
<Section title="Component Coverage">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This page should be updated when new UI primitives or app-level patterns ship.
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
|||
import { workModeMetaFor } from "../lib/work-mode-meta";
|
||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
|
||||
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
|
||||
import { IssueOutputSection } from "../components/issue-output/IssueOutputSection";
|
||||
|
|
@ -7469,6 +7470,8 @@ export function IssueDetail() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<WorkFolderButton owner={{ companyId: issue.companyId, scope: "task", ownerId: issue.id }} label="Task files" />
|
||||
|
||||
{taskChatShellEnabled ? null : (
|
||||
<IssueOutputSection
|
||||
workProducts={workProducts}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Card } from "@/components/ui/card";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
|
||||
|
||||
function deriveInitials(name: string) {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
|
|
@ -271,6 +272,7 @@ export function ProfileSettings() {
|
|||
</form>
|
||||
|
||||
<InboxAgentPolicyControl companyId={selectedCompanyId} />
|
||||
{selectedCompanyId && <WorkFolderButton owner={{ companyId: selectedCompanyId, scope: "user", ownerId: sessionQuery.data.user.id }} label="My files" />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { WorkFolderButton } from "@/components/WorkFolderBrowser";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import {
|
||||
|
|
@ -821,6 +822,7 @@ export function ProjectDetail() {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<WorkFolderButton owner={{ companyId: project.companyId, scope: "project", ownerId: project.id }} label="Project files" />
|
||||
<StarToggle
|
||||
size="button"
|
||||
starred={projectStarred}
|
||||
|
|
|
|||
Loading…
Reference in New Issue