mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
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:
1 parent
392ab26b1e
commit
a13bb6b395
51 files changed
+176079
-37
No files matched your search
@@ -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");
|
||||
+43424
File diff suppressed because it is too large.
Load diff
+43431
File diff suppressed because it is too large.
Load diff
+43438
File diff suppressed because it is too large.
Load diff
+43516
File diff suppressed because it is too large.
Load diff
+28
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user