Harden work-folder recovery and add deployed staging acceptance tools

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-07 12:28:17 -05:00
parent a13bb6b395
commit 6c7d1bbbf0
23 changed files with 656 additions and 38 deletions

View File

@ -13,6 +13,11 @@ on:
# the new tag ref instead. The tag mapping below keys off github.ref either
# way.
workflow_dispatch:
inputs:
preview_migrator:
description: Publish a commit-specific migrator prerelease for an explicitly pinned staging stack (no npm or fleet promotion)
type: boolean
default: false
permissions:
contents: read
@ -26,6 +31,49 @@ concurrency:
cancel-in-progress: false
jobs:
preview-migrator:
if: github.event_name == 'workflow_dispatch' && inputs.preview_migrator
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Require a non-default branch
env:
REF: ${{ github.ref }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
[[ "$REF" == refs/heads/* && "$REF" != "refs/heads/$DEFAULT_BRANCH" ]]
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 9.15.4
- uses: actions/setup-node@v7
with:
node-version: 24
- run: pnpm install --frozen-lockfile
- name: Build matching migrator artifacts
run: node scripts/build-preview-migrator.mjs "$RUNNER_TEMP/preview-migrator"
- name: Publish immutable candidate assets without promotion
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="preview/$GITHUB_SHA"
# Never overwrite an earlier candidate, including a partially
# published draft. A failed upload stays inspectable for recovery.
if gh release view "$tag" >/dev/null 2>&1; then
echo "Preview release already exists; refusing to replace its assets" >&2
exit 1
fi
gh release create "$tag" --target "$GITHUB_SHA" --draft --prerelease --latest=false \
--title "Staging preview $GITHUB_SHA" \
--notes "Commit-specific Cloud migrator. No npm publish or fleet default promotion. Deploy only to explicitly selected pinned stacks."
gh release upload "$tag" "$RUNNER_TEMP/preview-migrator/preview-migrator.json" \
"$RUNNER_TEMP/preview-migrator/paperclipai-db.tgz" "$RUNNER_TEMP/preview-migrator/paperclipai-shared.tgz"
gh release edit "$tag" --draft=false --prerelease --latest=false
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 60

View File

@ -466,6 +466,12 @@ that file, not as the main completion path for deliverables.
## Default Agent Workspaces
Sandbox execution uses the scoped `$HOME/task`, `agent`, `user`, `project`, and
`repos` directories, with object-storage checkpoints every 180 seconds and at
run completion. See [Sandbox work folders](sandbox-work-folders.md) for the
ownership, recovery, API, and dedicated staging acceptance contract. The local
execution paths below remain unchanged.
When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the instance root:
- `~/.paperclip/instances/default/workspaces/<agent-id>`

View File

@ -109,6 +109,12 @@ in `packages/shared/src/constants.ts`.
- local default: `~/.paperclip/instances/default/data/storage` (`local_disk`)
- cloud: S3-compatible object storage (`s3`)
Sandbox work folders use these same object-storage providers, with company/owner
bindings, current file references, recoverable deletions, and checkpoint state
in PostgreSQL. Legacy and native sandbox execution share the `$HOME` layout and
180-second/final-flush lifecycle described in [Sandbox work folders](sandbox-work-folders.md).
Local execution retains its existing home and workspace behavior.
## 6.3 Background Processing
A lightweight scheduler/worker in the server process handles:

140
doc/sandbox-work-folders.md Normal file
View File

@ -0,0 +1,140 @@
# Sandbox work folders
The deployed acceptance entry point is `pnpm test:e2e:work-folders:deployed`.
Set `PAPERCLIP_DEPLOYED_STACK_MANIFEST` to a JSON manifest matching
`tests/runner-e2e/deployed-stack.ts`, `PAPERCLIP_DEPLOYED_STACK_AUTH` to a private
0600 JSON file containing `baseURL` and a normally authorized `boardApiToken`,
and `PAPERCLIP_DEPLOYED_STACK_EVIDENCE` to an absolute output directory.
The harness never launches a local server. It checks the deployed commit and
adapter inventory, exercises scoped file APIs, and starts real sandbox tasks
for every configured profile. Missing profiles fail the inventory gate.
Credentials are not recorded in Playwright reports. These API checks supplement
the required browser walkthrough, two real 180-second intervals, and recovery
scenarios; passing them alone is not staging acceptance.
Sandbox runs use the operating-system user's home directory. Both legacy
adapters and the native runner enter the same host-owned lifecycle before
dispatch. Local execution keeps its existing workspace and home behavior.
```text
$HOME/
task/ current issue's working files
agent/ current agent's durable files
user/ responsible user's private Paperclip files
project/ current project's shared files
repos/ task-specific project repository checkouts
.codex/ CLI configuration and provider session state
.cache/ disposable caches
```
An absent task, user, or project produces an empty, unbound directory. The
`user/` collection never copies a person's computer home directory. Existing
managed agent workspace files are imported once, excluding CLI homes, caches,
Git metadata, and conventional credential directories. Task attachments become
editable working copies whose filenames include attachment IDs; original
uploads remain unchanged. Task plans and documents are not materialized.
The agent starts in `$HOME`. `PAPERCLIP_PRIMARY_REPO` and the workspace context
identify the repository for project commands. `AGENT_HOME` and
`PAPERCLIP_{TASK,AGENT,USER,PROJECT,REPOS}_DIR` expose the bound directories.
CLI state is separate from the four shared collections. A change of task,
agent, responsible user, or project cannot reuse a sandbox with another binding.
## Storage and synchronization
Postgres stores company/owner bindings, paths, executable bits, current object
references, trash entries, retry receipts, and each sandbox's sync baseline.
Contents use the configured `StorageProvider`: S3 or self-hosted `local_disk`.
The sandbox disk is a working copy, not the durability authority. With local
disk object storage, operators must persist and back up that storage directory
alongside Postgres. S3 recovery needs the database and bucket; it does not need
the original sandbox or application workspace volume.
Startup hydrates only the four bound collections. A warm startup first saves
uncheckpointed local changes and then downloads changed incoming files. There
is no background incoming refresh while an agent edits. Explicit refresh is
queued until the run stops, after a successful final flush.
Outgoing checkpoints run every **180 seconds**, with at most one in flight,
and a final flush when execution stops. File signatures include content and
executable state. Unchanged stale working copies do not overwrite newer shared
files. Changed files use server-accepted last-write-wins. Operation IDs persist
before transfer so a lost response retries the same operation rather than
overwriting a later writer. A failed save remains visible and prevents lease
cleanup from destroying the working copy. Providers with resume support can
recover a retained lease on the next run with the same identity/configuration,
including a lease originally configured as ephemeral.
Deletion moves files to recoverable trash. Restore rejects path collisions.
Explicit purge and permanent owner deletion schedule object cleanup through a
durable deletion journal. Overwritten scoped-file content is not versioned.
Repository checkpoint objects remain retained while their task binding exists.
The scheduler retries object cleanup every three minutes; disabled heartbeat
scheduling also disables this cleanup sweep.
Every file API checks company and owner authorization. User collections are
available only to the current user and their bound, authorized agent run.
Generic company access does not grant user-file access. Background transfers
also recheck the responsible user's active membership before saving.
Paths reject traversal, control characters and reserved runtime segments.
Scoped files cannot be symbolic links or hard links. Linux transport pins
parent directory descriptors and uses no-follow opens. Repository symlinks
must stay inside their checkout, outside `.git`. Transfers stream in bounded
chunks; individual files are limited to 1 GiB, scans to 100,000 entries, and UI
previews to 8 MiB.
## Repositories
Each task owns independent clones of all project workspaces with a repository
URL. Names derive from repository names, with stable workspace-ID suffixes on
collisions. Initial clones use existing Git credentials and starting-ref policy;
the primary clone also honors the task's configured branch. Warm starts never
reset branches, clean edits, or rerun completed setup. Added repositories are
prepared at the next startup; removed bindings retain saved work.
A complete repository checkpoint includes Git objects, refs, HEAD and index,
tracked working files, and nonignored untracked files. It excludes dependencies
and generated ignored caches, Git credentials/configuration, hooks, and private
runtime state. Checkpoints reject in-progress Git locks and a tree that changes
during scanning. The database pointer advances only after all required objects
and the manifest have been saved. Restores verify ownership and hashes, then
publish the restored directory atomically. Git origin configuration is recreated
from the host binding. Linked worktrees and submodules using external `.git`
directories are not supported by this checkpoint format.
## API and UI
The task, agent, project, and current-user pages expose a Files dialog using the
shared file tree and viewer. It supports uploads, folder creation, previews,
downloads, deletion, trash restore/purge, and sync state with the last save time.
All routes start at
`/api/companies/:companyId/work-folders/:scope/:ownerId`:
- `GET /`: paginated active files or trash (`trash=true`).
- `GET /content?path=...`: confined download/preview stream.
- `PUT /content?path=...`: raw `application/octet-stream` upload; supports
`Idempotency-Key`, `X-File-Content-Type`, and `X-File-Executable`.
- `POST /operations`: idempotent mkdir, delete, restore, or purge.
- `GET /sync`: save state, last successful save, errors, and refresh state.
- `POST /refresh`: request refresh at the active run's safe boundary.
## Acceptance gate
Automated tests do not qualify a deployed runner image. Before merging, use a
new pinned staging stack with the branch's Cloud image and matching migrator.
The deployed harness must target that tenant URL without launching a local
server. Enumerate every sandbox-capable adapter/engine and native profile
exposed by the stack; missing credentials or skipped required profiles block
acceptance. Record real browser operations, two actual 180-second intervals,
short-run flushes, independent task checkouts, identity/privacy boundaries,
interrupted saves, and recovery without the original sandbox or app volume.
`Docker` workflow's optional `preview_migrator` input builds a commit-specific
GitHub prerelease containing DB/shared tarballs and an integrity manifest. It
does not publish to npm or advance release channels. Cloud resolves
`preview:<full SHA>`, verifies artifact identity, migration coverage and the
dependency lockfile, and permits deployment only to explicitly selected pinned
stacks. Preview artifacts cannot become the fleet default. A failed or existing
preview release is never silently overwritten.

View File

@ -71,6 +71,7 @@
"test:e2e:runner:history:publish": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/history-publish.ts",
"test:e2e:runner:unit": "vitest run --config tests/runner-e2e/vitest.config.ts",
"test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json",
"test:e2e:work-folders:deployed": "playwright test --config tests/runner-e2e/playwright.deployed.config.ts",
"test:e2e:runner:report": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/report.ts",
"test:runner-workflow-evals": "pnpm --filter @paperclipai/paperclip-eval-kernel build && pnpm --filter @paperclipai/paperclip-runner test:runner-workflow-evals",
"test:e2e:mcp-user-stories": "node scripts/e2e-mcp-user-stories.mjs",

View File

@ -15,5 +15,6 @@ describe("external sandbox work-folder 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");
expect(() => externalWorkFolderEnvironment({ ...environment, PAPERCLIP_PRIMARY_REPO: `${home}/repos/../../.codex` })).toThrow("Invalid sandbox primary");
});
});

View File

@ -14,6 +14,11 @@ export function externalWorkFolderEnvironment(source: NodeJS.ProcessEnv): NodeJS
}
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;
if (primary) {
if (path.resolve(primary) !== primary || !(primary.startsWith(`${home}/repos/`) || primary === `${home}/task`)) {
throw new Error("Invalid sandbox primary repository path");
}
result.PAPERCLIP_PRIMARY_REPO = primary;
}
return result;
}

View File

@ -2228,6 +2228,7 @@ const plugin = definePlugin({
metadata: { expired: true, workspaceSentinel },
};
}
if (config.autoDeleteInterval === -1) await sandbox.setAutoDeleteInterval(-1);
const shellCommand = await detectSandboxShellCommand(
sandbox,
toTimeoutSeconds(config.timeoutMs),

View File

@ -0,0 +1,63 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { materializePublishManifest, prepareBundledPackage } from "./prepare-bundled-package.mjs";
export function previewIdentity(sha, date, repository = "paperclipai/paperclip") {
if (!/^[a-f0-9]{40}$/.test(sha) || Number.isNaN(date.getTime())) throw new Error("Invalid preview commit");
const day = `${date.getUTCMonth() + 1}${String(date.getUTCDate()).padStart(2, "0")}`;
const second = date.getUTCHours() * 3600 + date.getUTCMinutes() * 60 + date.getUTCSeconds() + 1;
return { tag: `preview/${sha}`, version: `${date.getUTCFullYear()}.${day}.${second}-preview.sha${sha}`,
baseUrl: `https://github.com/${repository}/releases/download/${encodeURIComponent(`preview/${sha}`)}` };
}
export function buildPreviewMigrator(outputDirectory) {
const repo = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim();
const sha = git("rev-parse", "HEAD");
if (process.env.GITHUB_SHA && process.env.GITHUB_SHA !== sha) throw new Error("Preview checkout differs from the workflow commit");
git("diff", "--quiet", "HEAD");
const identity = previewIdentity(sha, new Date(git("show", "-s", "--format=%cI", "HEAD")));
execFileSync("pnpm", ["--filter", "@paperclipai/db...", "build"], { cwd: repo, stdio: "inherit" });
const output = path.resolve(outputDirectory);
mkdirSync(output, { recursive: true });
const temporary = mkdtempSync(path.join(os.tmpdir(), "paperclip-preview-migrator-"));
try {
for (const name of ["shared", "db"]) {
const source = path.join(repo, "packages", name);
const staged = path.join(temporary, name);
if (name === "db") prepareBundledPackage(source, staged);
else { mkdirSync(staged); cpSync(path.join(source, "dist"), path.join(staged, "dist"), { recursive: true }); }
const manifest = name === "db" ? JSON.parse(readFileSync(path.join(staged, "package.json"), "utf8"))
: materializePublishManifest(JSON.parse(readFileSync(path.join(source, "package.json"), "utf8")));
manifest.version = identity.version;
manifest.gitHead = sha;
delete manifest.devDependencies;
delete manifest.scripts;
if (name === "db") manifest.dependencies["@paperclipai/shared"] = `${identity.baseUrl}/paperclipai-shared.tgz`;
writeFileSync(path.join(staged, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
for (const file of ["LICENSE", "README.md"]) {
if (existsSync(path.join(source, file))) cpSync(path.join(source, file), path.join(staged, file));
}
const result = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temporary], { cwd: staged, encoding: "utf8" }));
const destination = path.join(output, `paperclipai-${name}.tgz`);
if (existsSync(destination)) throw new Error("Refusing to overwrite a preview artifact");
renameSync(path.join(temporary, result[0].filename), destination);
}
const integrity = (name) => `sha512-${createHash("sha512").update(readFileSync(path.join(output, `paperclipai-${name}.tgz`))).digest("base64")}`;
const manifest = { version: 1, githubSha: sha, dbPackageVersion: identity.version,
dbPackageIntegrity: integrity("db"), dbPackageTarballUrl: `${identity.baseUrl}/paperclipai-db.tgz`,
sharedPackageIntegrity: integrity("shared"), sharedPackageTarballUrl: `${identity.baseUrl}/paperclipai-shared.tgz` };
writeFileSync(path.join(output, "preview-migrator.json"), `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" });
return manifest;
} finally { rmSync(temporary, { recursive: true, force: true }); }
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
if (!process.argv[2]) throw new Error("Usage: build-preview-migrator.mjs <new output directory>");
console.log(JSON.stringify(buildPreviewMigrator(process.argv[2])));
}

View File

@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { previewIdentity } from "./build-preview-migrator.mjs";
test("preview artifact identity is immutable, namespaced, and ordered by commit time", () => {
const sha = "2f42a4968d5761fd62172e35ecf8188195b8d431";
const identity = previewIdentity(sha, new Date("2026-07-19T09:30:00.000Z"));
assert.equal(identity.version, `2026.719.34201-preview.sha${sha}`);
assert.equal(identity.tag, `preview/${sha}`);
assert.equal(identity.baseUrl, `https://github.com/paperclipai/paperclip/releases/download/preview%2F${sha}`);
assert.throws(() => previewIdentity("master", new Date()), /Invalid preview/);
assert.throws(() => previewIdentity(sha, new Date("invalid")), /Invalid preview/);
});

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readdirSync, statSync } from "node:fs";
import { mkdirSync, mkdtempSync, readdirSync, realpathSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@ -275,7 +275,9 @@ function runVitest(args, label) {
console.log(`\n[test:run] ${label}`);
invocationIndex += 1;
const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp";
const testRoot = mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`));
// /tmp is a symlink on macOS. Workspace confinement intentionally rejects
// aliases, so fixtures must receive the canonical root just as Linux does.
const testRoot = realpathSync(mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`)));
// Keep per-run paths compact so Unix socket fixtures stay under macOS path limits.
const env = {
...process.env,

View File

@ -101,6 +101,17 @@ describe.sequential("cli auth routes", () => {
vi.resetAllMocks();
});
it.each(["session", "cloud_tenant"])("recognizes an authenticated %s user for board CLI approval", async (source) => {
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", requestedAccess: "board", status: "pending" });
const app = await createApp({ type: "board", source, userId: "user-1", companyIds: ["company-1"], isInstanceAdmin: false });
const result = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
expect(result.status).toBe(200);
expect(result.body).toMatchObject({ requiresSignIn: false, canApprove: true, currentUserId: "user-1" });
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", requestedAccess: "instance_admin_required", status: "pending" });
const admin = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
expect(admin.body.canApprove).toBe(false);
});
it.sequential("creates a CLI auth challenge with approval metadata", async () => {
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
challenge: {

View File

@ -1,11 +1,12 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { agents, companies, createDb, heartbeatRuns, issues, environments, environmentLeases, projects, projectWorkspaces, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
import { agents, assets, companyMemberships, issueAttachments, companies, createDb, heartbeatRuns, issues, environments, environmentLeases, projects, projectWorkspaces, taskRepositoryBindings, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
import { prepareSandboxWorkFolders } from "../services/sandbox-work-folders.js";
import { retainUnsavedWorkFolderLease, workFolderSandboxKey } from "../services/work-folder-retention.js";
@ -44,14 +45,14 @@ describe("shared sandbox work-folder lifecycle", () => {
for (const run of active) await run.stop().catch(() => {});
await database?.cleanup(); if (root) await fs.rm(root, { recursive: true, force: true });
});
async function prepare(home: string, leaseId: string, physicalId = leaseId) {
async function prepare(home: string, leaseId: string, physicalId = leaseId, responsibleUserId: string | null = null) {
await fs.mkdir(home, { recursive: true });
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, responsibleUserId, status: "running" });
const lease = { id: leaseId, companyId, environmentId, provider: "test", providerLeaseId: physicalId };
await db.insert(environmentLeases).values({ ...lease, heartbeatRunId: runId }).onConflictDoUpdate({ target: environmentLeases.id, set: { heartbeatRunId: runId } });
const run = await prepareSandboxWorkFolders({ db, companyId, agentId, projectId, taskId, runId,
responsibleUserId: null, storage, sandboxKey: workFolderSandboxKey(lease), target: { kind: "remote", transport: "sandbox", leaseId, remoteCwd: home,
responsibleUserId, storage, sandboxKey: workFolderSandboxKey(lease), target: { kind: "remote", transport: "sandbox", leaseId, remoteCwd: home,
runner: { execute: (input) => localTestWorkFolderRunner.execute({ ...input, env: { ...input.env, HOME: home } }) } } });
active.push(run); return run;
}
@ -123,4 +124,78 @@ describe("shared sandbox work-folder lifecycle", () => {
await resumed.stop(); active.splice(active.indexOf(resumed), 1);
}, 120_000);
it("stops private-file synchronization after responsible-user membership is revoked", async () => {
const userId = randomUUID();
const [membership] = await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "member" }).returning();
const leaseId = randomUUID();
const run = await prepare(path.join(root, "revoked-user"), leaseId, leaseId, userId);
await fs.writeFile(path.join(run.home, "user/private"), "pending private edit");
await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.id, membership!.id));
await expect(run.stop()).rejects.toThrow("no longer authorized");
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(true);
const svc = workFolderService(db, storage);
const folder = await svc.ensure({ companyId, scope: "user", ownerId: userId });
expect((await svc.list(folder)).files).toHaveLength(0);
expect(await fs.readFile(path.join(run.home, "user/private"), "utf8")).toBe("pending private edit");
await db.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.id, membership!.id));
await run.stop(); active.splice(active.indexOf(run), 1);
}, 120_000);
it("does not publish a partial repository checkpoint and retries a failed final save", async () => {
const leaseId = randomUUID();
const run = await prepare(path.join(root, "interrupted-checkpoint"), leaseId);
await run.flush();
const bindingId = run.manifest.repositories[0]!.bindingId;
const [before] = await db.select().from(taskRepositoryBindings).where(eq(taskRepositoryBindings.id, bindingId));
await fs.writeFile(path.join(run.primaryRepo, "new-unsaved-file"), "must survive a failed save");
const put = storage.putObject.bind(storage);
const fail = vi.spyOn(storage, "putObject").mockImplementation(async (input) => {
if (input.objectKey.includes("/checkpoints/")) throw new Error("Injected storage outage");
return put(input);
});
try {
await expect(run.stop()).rejects.toThrow("Injected storage outage");
const [after] = await db.select().from(taskRepositoryBindings).where(eq(taskRepositoryBindings.id, bindingId));
expect(after!.checkpointKey).toBe(before!.checkpointKey);
expect(await retainUnsavedWorkFolderLease(db, { id: leaseId, companyId })).toBe(true);
} finally { fail.mockRestore(); }
await run.stop(); active.splice(active.indexOf(run), 1);
await fs.rm(run.home, { recursive: true });
const recovered = await prepare(path.join(root, "interrupted-recovered"), randomUUID());
expect(await fs.readFile(path.join(recovered.primaryRepo, "new-unsaved-file"), "utf8")).toBe("must survive a failed save");
await recovered.stop(); active.splice(active.indexOf(recovered), 1);
}, 120_000);
it("seeds duplicate and reserved attachment names idempotently without changing original uploads", async () => {
const attachmentIds: string[] = [];
const originalKeys: string[] = [];
const content = Buffer.from("original upload");
for (const originalFilename of ["same.txt", "same.txt", ".", ".paperclip-runtime"]) {
const id = randomUUID();
const objectKey = `${companyId}/attachments/${id}`;
originalKeys.push(objectKey);
await storage.putObject({ objectKey, body: content, contentType: "text/plain", contentLength: content.length });
await db.insert(assets).values({ id, companyId, provider: storage.id, objectKey, contentType: "text/plain", byteSize: content.length,
sha256: createHash("sha256").update(content).digest("hex"), originalFilename });
const attachmentId = randomUUID(); attachmentIds.push(attachmentId);
await db.insert(issueAttachments).values({ id: attachmentId, companyId, issueId: taskId, assetId: id });
}
const leaseId = randomUUID();
const home = path.join(root, "attachment-seeding");
const first = await prepare(home, leaseId);
const files = (await fs.readdir(path.join(home, "task"))).filter((file) => attachmentIds.some((id) => file.includes(id)));
expect(files).toHaveLength(4);
await fs.writeFile(path.join(home, "task", files[0]!), "edited working copy");
await first.stop(); active.splice(active.indexOf(first), 1);
const warm = await prepare(home, randomUUID(), leaseId);
expect(await fs.readFile(path.join(home, "task", files[0]!), "utf8")).toBe("edited working copy");
expect((await fs.readdir(path.join(home, "task"))).filter((file) => attachmentIds.some((id) => file.includes(id)))).toEqual(files);
await warm.stop(); active.splice(active.indexOf(warm), 1);
for (const objectKey of originalKeys) {
const original = await storage.getObject({ objectKey });
const chunks: Buffer[] = [];
for await (const chunk of original.stream) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks)).toEqual(content);
}
}, 120_000);
});

View File

@ -2797,7 +2797,7 @@ export function accessRoutes(
const isSignedInBoardUser =
req.actor.type === "board" &&
(req.actor.source === "session" || isLocalImplicit(req)) &&
(req.actor.source === "session" || req.actor.source === "cloud_tenant" || isLocalImplicit(req)) &&
Boolean(req.actor.userId);
const canApprove =
isSignedInBoardUser &&

View File

@ -1794,6 +1794,10 @@ function createSandboxEnvironmentDriver(
}
const workerConfig = stripSandboxProviderEnvelope(parsed.config);
// A provider reaper must not delete the only copy after a failed final
// checkpoint. Normal release still deletes ephemeral sandboxes after
// the host confirms durability; idle stop/archive remains enabled.
if (boundRun && parsed.config.provider === "daytona") workerConfig.autoDeleteInterval = -1;
const storedConfig = storedParsed.config;
const providerConfigForLease = sandboxConfigForLeaseMetadata(storedConfig);
// Require the reusable-lease capability AND a worker that verifies the
@ -1816,7 +1820,6 @@ function createSandboxEnvironmentDriver(
declaredReusableLeases && capabilityIsVerified("reusableLeases", pluginVerifiedMethods);
const leaseFingerprint =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
@ -1845,13 +1848,12 @@ function createSandboxEnvironmentDriver(
// or terminal rows cannot be matched.
const reusableCandidateLeases =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
? (await environmentsSvc.listLeases(input.environment.id))
.filter((lease) =>
lease.leasePolicy === "reuse_by_environment" &&
((parsed.config.reuseLease && lease.leasePolicy === "reuse_by_environment") || lease.metadata?.workFolderRecoveryRequired === true) &&
reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) &&
lease.executionWorkspaceId === input.executionWorkspaceId &&
lease.metadata?.agentId === input.agentId,
@ -1876,6 +1878,9 @@ function createSandboxEnvironmentDriver(
lease.heartbeatRunId === input.heartbeatRunId,
}),
);
if (reusableCandidateLeases.some((lease) => lease.metadata?.workFolderRecoveryRequired === true && !reusableExistingLeases.includes(lease))) {
throw new Error("Unsaved sandbox work requires recovery with its original run identity and configuration");
}
if (reusableCandidateLeases.length > reusableExistingLeases.length) {
await cleanupObsoleteReusableSandboxLeases({
environment: input.environment,
@ -1885,7 +1890,6 @@ function createSandboxEnvironmentDriver(
}
const reusableProviderLeaseId =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
@ -1982,6 +1986,9 @@ function createSandboxEnvironmentDriver(
});
}
if (!providerLease) {
if (await retainUnsavedWorkFolderLease(db, reusableLease)) {
throw new Error("Saved sandbox could not be resumed; unsaved work was retained for recovery");
}
if (
input.adapterType === "paperclip_runner" &&
!verifyNativeHarnessBackupStamp(
@ -2046,7 +2053,7 @@ function createSandboxEnvironmentDriver(
metadata: acquiredLease.metadata,
schema: pluginProvider.resolved.driver.configSchema as Record<string, unknown> | null | undefined,
});
const reusableScope = resolvedLeasePolicy === "reuse_by_environment"
const reusableScope = supportsReusableLeases && input.heartbeatRunId !== null
? buildReusableSandboxLeaseScope({
responsibleUserId,
issueId: input.issueId,
@ -2186,7 +2193,6 @@ function createSandboxEnvironmentDriver(
const providerConfigForLease = sandboxConfigForLeaseMetadata(parsed.config);
const leaseFingerprint =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
@ -2203,13 +2209,12 @@ function createSandboxEnvironmentDriver(
: null;
const reusableCandidateLeases =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
? (await environmentsSvc.listLeases(input.environment.id))
.filter((lease) =>
lease.leasePolicy === "reuse_by_environment" &&
((parsed.config.reuseLease && lease.leasePolicy === "reuse_by_environment") || lease.metadata?.workFolderRecoveryRequired === true) &&
reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) &&
lease.executionWorkspaceId === input.executionWorkspaceId &&
lease.metadata?.agentId === input.agentId,
@ -2234,6 +2239,9 @@ function createSandboxEnvironmentDriver(
lease.heartbeatRunId === input.heartbeatRunId,
}),
);
if (reusableCandidateLeases.some((lease) => lease.metadata?.workFolderRecoveryRequired === true && !reusableExistingLeases.includes(lease))) {
throw new Error("Unsaved sandbox work requires recovery with its original run identity and configuration");
}
if (reusableCandidateLeases.length > reusableExistingLeases.length) {
await cleanupObsoleteReusableSandboxLeases({
environment: input.environment,
@ -2243,7 +2251,6 @@ function createSandboxEnvironmentDriver(
}
const reusableProviderLeaseId =
supportsReusableLeases &&
parsed.config.reuseLease &&
input.heartbeatRunId !== null &&
input.executionWorkspaceId !== null &&
input.agentId !== null
@ -2290,7 +2297,7 @@ function createSandboxEnvironmentDriver(
const resolvedLeasePolicy = supportsReusableLeases && parsed.config.reuseLease && input.heartbeatRunId !== null
? "reuse_by_environment"
: "ephemeral";
const reusableScope = resolvedLeasePolicy === "reuse_by_environment"
const reusableScope = supportsReusableLeases && input.heartbeatRunId !== null
? buildReusableSandboxLeaseScope({
responsibleUserId,
issueId: input.issueId,

View File

@ -19743,6 +19743,7 @@ export function heartbeatService(
sandboxWorkFolders = await prepareSandboxWorkFolders({ db, companyId: run.companyId, runId: run.id,
agentId: agent.id, responsibleUserId: run.responsibleUserId ?? null,
taskId: issueRef?.id ?? null, projectId: issueRef?.projectId ?? null, target: executionTarget,
primaryWorkspaceId: executionWorkspace.workspaceId, primaryBranchName: executionWorkspace.branchName,
sandboxKey: workFolderSandboxKey(activeEnvironmentLease.lease) });
if (sandboxWorkFolders.identityChanged) { taskSessionForRun = null; previousSessionParams = null; }
executionTarget.workFolderHome = sandboxWorkFolders.home;

View File

@ -3,7 +3,7 @@ import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { and, asc, desc, eq, sql } from "drizzle-orm";
import { assets, issueAttachments, projectWorkspaces, taskRepositoryBindings, workFileOperations, workFolderRuns, workFolders, type Db } from "@paperclipai/db";
import { agents, assets, companyMemberships, heartbeatRuns, issues, projects, issueAttachments, projectWorkspaces, taskRepositoryBindings, workFileOperations, workFolderRuns, workFolders, type Db } from "@paperclipai/db";
import { WORK_FOLDER_SCOPES, type SandboxWorkFolderManifest, type WorkFolderScope } from "@paperclipai/shared";
import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { StorageProvider } from "../storage/types.js";
@ -15,6 +15,7 @@ import { workFolderService } from "./work-folders.js";
import { workFolderPaths, workFolderTransport, type WorkTreeEntry } from "./work-folder-transport.js";
import { workFolderRepositoryService } from "./work-folder-repositories.js";
import { startWorkFolderCheckpointer } from "./work-folder-checkpointer.js";
import { assertWorkFolderAccess } from "./work-folder-access.js";
function signature(entry: WorkTreeEntry | undefined) {
return entry ? JSON.stringify([entry.kind, entry.sha256, entry.executable]) : "missing";
@ -28,10 +29,43 @@ function repoName(value: string, id: string) {
export async function prepareSandboxWorkFolders(input: {
db: Db; companyId: string; runId: string; agentId: string; responsibleUserId: string | null;
taskId: string | null; projectId: string | null; target: AdapterSandboxExecutionTarget;
primaryWorkspaceId?: string | null; primaryBranchName?: string | null;
storage?: StorageProvider; sandboxKey?: string;
}) {
const { db, target } = input;
if (!target.runner || !target.leaseId) throw new Error("Sandbox file transport is unavailable");
async function assertBindings() {
const memberships: Array<{ companyId: string; membershipRole: string | null; status: string }> = [];
const deny = () => { throw new Error("Sandbox work-folder access is no longer authorized; working files were retained"); };
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
if (!run || run.agentId !== input.agentId || run.responsibleUserId !== input.responsibleUserId) deny();
const [agent] = await db.select({ id: agents.id }).from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId)));
if (!agent) deny();
if (input.taskId) {
const [task] = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, input.taskId), eq(issues.companyId, input.companyId)));
if (!task) deny();
}
if (input.projectId) {
const [project] = await db.select({ id: projects.id }).from(projects).where(and(eq(projects.id, input.projectId), eq(projects.companyId, input.companyId)));
if (!project) deny();
}
if (input.responsibleUserId) {
const [membership] = await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, input.companyId),
eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, input.responsibleUserId), eq(companyMemberships.status, "active")));
if (!membership || membership.membershipRole === "viewer") deny();
if (membership) memberships.push({ companyId: membership.companyId, membershipRole: membership.membershipRole, status: membership.status });
}
for (const [scope, ownerId] of [["task", input.taskId], ["agent", input.agentId], ["project", input.projectId]] as const) {
if (!ownerId) continue;
await assertWorkFolderAccess(db, { type: "agent", source: "agent_jwt", companyId: input.companyId,
agentId: input.agentId, runId: input.runId, onBehalfOfUserId: input.responsibleUserId, onBehalfOfMemberships: memberships },
{ companyId: input.companyId, scope, ownerId }, true);
}
}
// Host-side transfers do not go through HTTP authorization middleware. Check
// the authoritative bindings here too, including after membership revocation.
// An ended heartbeat may still flush; its immutable identity must still match.
await assertBindings();
const storage = input.storage ?? createStorageProviderFromConfig(loadConfig());
const svc = workFolderService(db, storage);
const transport = workFolderTransport(target.runner);
@ -77,11 +111,11 @@ export async function prepareSandboxWorkFolders(input: {
const [seeded] = await db.select().from(workFileOperations).where(and(eq(workFileOperations.folderId, folders.task.id),
eq(workFileOperations.operationId, operationId)));
if (seeded) continue;
const original = (asset.originalFilename ?? attachment.id).split(/[\\/]/).at(-1)!.replace(/[\x00-\x1f\x7f]/g, "_") || attachment.id;
let filename = original;
try { await svc.get(folders.task, filename); filename = `${original}-${attachment.id}`; } catch (error) {
if ((error as { status?: number }).status !== 404) throw error;
}
const original = (asset.originalFilename ?? "attachment").split(/[\\/]/).at(-1)!.replace(/[\x00-\x1f\x7f]/g, "_").slice(0, 180) || "attachment";
// The ID makes the destination independent of concurrent uploads and
// earlier seeding attempts. Even dot/reserved filenames become safe.
const extension = path.posix.extname(original);
const filename = `${original.slice(0, original.length - extension.length)}-${attachment.id}${extension}`;
const result = await storage.getObject({ objectKey: asset.objectKey });
try { await svc.write(folders.task, { path: filename, body: result.stream, contentType: asset.contentType, operationId, onlyIfMissing: true }); }
finally { result.stream.destroy(); }
@ -103,7 +137,7 @@ export async function prepareSandboxWorkFolders(input: {
if (stat.isDirectory()) {
if (relative) await svc.write(folder, { path: relative, kind: "directory", operationId: `import:${relative}`, onlyIfMissing: true });
for (const name of (await fs.readdir(path.join(root, relative))).sort()) {
if ([".codex", ".claude", ".cache", ".config", ".local", ".git", ".paperclip-runtime"].includes(name)) continue;
if ([".codex", ".claude", ".cache", ".config", ".local", ".git", ".paperclip-runtime", ".ssh", ".aws", ".azure", ".netrc", ".git-credentials", ".npmrc", ".npm", "node_modules", ".venv"].includes(name)) continue;
await visit(relative ? `${relative}/${name}` : name);
}
} else if (stat.isFile()) {
@ -192,6 +226,7 @@ export async function prepareSandboxWorkFolders(input: {
const names = new Set(existing.map((binding) => binding.name));
const resolveGitAuth = createGitRemoteAuthProvider(db, input.companyId, { responsibleUserId: input.responsibleUserId, agentId: input.agentId, issueId: input.taskId, heartbeatRunId: input.runId });
for (const workspace of workspaces.filter((entry) => entry.repoUrl)) {
const primary = input.primaryWorkspaceId ? workspace.id === input.primaryWorkspaceId : workspace.isPrimary;
let binding = existing.find((entry) => entry.workspaceId === workspace.id);
if (!binding) {
const baseName = repoName(workspace.repoUrl!.split(/[/:]/).at(-1) ?? workspace.name, workspace.id);
@ -222,6 +257,18 @@ export async function prepareSandboxWorkFolders(input: {
const checkout = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", binding.repoRef, "--"], bypassSession: true, timeoutMs: 60_000 });
if (checkout.exitCode !== 0 || checkout.timedOut) throw new Error(`Required repository ${binding.name} ref could not be checked out`);
}
if (primary && input.primaryBranchName) {
const branch = input.primaryBranchName;
const valid = await target.runner!.execute({ command: "git", args: ["check-ref-format", "--branch", branch], bypassSession: true, timeoutMs: 10_000 });
if (valid.exitCode !== 0 || valid.stdout.trim() !== branch) throw new Error(`Required repository ${binding.name} branch is invalid`);
// Honor the task's existing branch policy on the initial clone.
// Restores and warm starts keep the saved HEAD and index untouched.
const checkout = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", branch, "--"], bypassSession: true, timeoutMs: 60_000 });
if (checkout.exitCode !== 0) {
const create = await target.runner!.execute({ command: "git", args: ["-C", temporary, "checkout", "-b", branch], bypassSession: true, timeoutMs: 60_000 });
if (create.exitCode !== 0 || create.timedOut) throw new Error(`Required repository ${binding.name} task branch could not be created`);
}
}
} else {
const init = await target.runner!.execute({ command: "git", args: ["-C", temporary, "init"], bypassSession: true, timeoutMs: 10_000 });
if (init.exitCode !== 0) throw new Error(`Repository ${binding.name} could not be restored`);
@ -236,7 +283,7 @@ export async function prepareSandboxWorkFolders(input: {
}
await db.update(taskRepositoryBindings).set({ setupComplete: true, retiredAt: null }).where(eq(taskRepositoryBindings.id, binding.id));
bindings.push({ binding, root });
manifest.repositories.push({ bindingId: binding.id, workspaceId: workspace.id, name: binding.name, primary: workspace.isPrimary });
manifest.repositories.push({ bindingId: binding.id, workspaceId: workspace.id, name: binding.name, primary });
await saveState("starting");
}
for (const old of existing) if (!workspaces.some((workspace) => workspace.id === old.workspaceId)) {
@ -263,6 +310,7 @@ export async function prepareSandboxWorkFolders(input: {
}
const checkpointer = startWorkFolderCheckpointer({
async checkpoint() {
await assertBindings();
await saveState("saving");
for (const scope of WORK_FOLDER_SCOPES) await outgoing(scope);
for (const { binding, root } of bindings) await repositories.checkpoint(binding, root);
@ -280,6 +328,7 @@ export async function prepareSandboxWorkFolders(input: {
if (run?.refreshRequested) {
// The agent has stopped. The successful final flush above protects its
// edits before accepting incoming shared files at this safe boundary.
await assertBindings();
for (const scope of WORK_FOLDER_SCOPES) await incoming(scope);
await db.update(workFolderRuns).set({ refreshRequested: false, baselines, updatedAt: new Date() })
.where(eq(workFolderRuns.runId, input.runId));

View File

@ -56,8 +56,9 @@ export function workFolderRepositoryService(db: Db, storage: StorageProvider, tr
for (let offset = 0; offset < published.length; offset += 1000) {
await tx.update(workFolderObjects).set({ deleteAfter: null }).where(inArray(workFolderObjects.objectKey, published.slice(offset, offset + 1000)));
}
await tx.update(taskRepositoryBindings).set({ checkpointKey, checkpointSha256: digest, checkpointAt: new Date() })
.where(and(eq(taskRepositoryBindings.id, binding.id), eq(taskRepositoryBindings.companyId, binding.companyId)));
const updated = await tx.update(taskRepositoryBindings).set({ checkpointKey, checkpointSha256: digest, checkpointAt: new Date() })
.where(and(eq(taskRepositoryBindings.id, binding.id), eq(taskRepositoryBindings.companyId, binding.companyId))).returning({ id: taskRepositoryBindings.id });
if (!updated.length) throw new Error("Repository owner was deleted during checkpoint");
});
knownByBinding.set(binding.id, new Set(files.flatMap((file) => file.objectKey ? [file.objectKey] : [])));
binding.checkpointKey = checkpointKey;

View File

@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { isStagingOrigin } from "./deployed-stack.js";
describe("deployed stack target", () => {
it("requires an explicit HTTPS staging tenant and rejects credential-bearing URLs", () => {
expect(isStagingOrigin("https://work-folders-qa.staging.paperclip.app")).toBe(true);
for (const value of ["http://localhost:3100", "https://tenant.paperclip.app", "https://staging.paperclip.app.attacker.test",
"https://token@tenant.staging.paperclip.app", "https://tenant.staging.paperclip.app/path", "https://tenant.staging.paperclip.app?secret=x"]) {
expect(isStagingOrigin(value), value).toBe(false);
}
});
});

View File

@ -0,0 +1,73 @@
import fs from "node:fs";
import assert from "node:assert/strict";
// A separate target contract deliberately has no local-server fallback.
export function isStagingOrigin(value: string) {
try {
const url = new URL(value);
return url.protocol === "https:" && url.hostname.endsWith(".staging.paperclip.app")
&& !url.username && !url.password && url.pathname === "/" && !url.search && !url.hash;
} catch { return false; }
}
export interface DeployedStack {
baseURL: string; stackId: string; commit: string; appImage: string; migratorVersion: string; sandboxImage: string;
companyId: string; taskId: string; agentId: string; projectId: string; userId: string;
profiles: Array<{ id: string; adapterType: string; engine: string; model: string; qualification: string; agentId: string }>;
}
export function loadDeployedStack(): DeployedStack {
const filename = process.env.PAPERCLIP_DEPLOYED_STACK_MANIFEST;
if (!filename) throw new Error("PAPERCLIP_DEPLOYED_STACK_MANIFEST is required");
const manifest = JSON.parse(fs.readFileSync(filename, "utf8")) as DeployedStack;
assert(manifest && typeof manifest === "object", "Invalid manifest");
for (const key of ["baseURL", "stackId", "commit", "appImage", "migratorVersion", "sandboxImage", "companyId", "taskId", "agentId", "projectId", "userId"] as const) {
assert(typeof manifest[key] === "string" && manifest[key].length > 0, `Missing manifest ${key}`);
}
assert(isStagingOrigin(manifest.baseURL), "Use the dedicated HTTPS staging tenant origin");
assert(/^[a-f0-9]{40}$/.test(manifest.commit), "Expected full commit SHA");
assert(/@sha256:[a-f0-9]{64}$/.test(manifest.sandboxImage), "Expected immutable sandbox image");
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
for (const key of ["companyId", "taskId", "agentId", "projectId"] as const) assert(uuid.test(manifest[key]), `Invalid ${key}`);
assert(Array.isArray(manifest.profiles) && manifest.profiles.length >= 7, "The seven baseline profiles are required");
for (const profile of manifest.profiles) {
for (const key of ["id", "adapterType", "engine", "model", "qualification", "agentId"] as const) {
assert(typeof profile[key] === "string" && profile[key].length > 0, `Missing profile ${key}`);
}
assert(uuid.test(profile.agentId), "Invalid profile agent ID");
}
if (new Set(manifest.profiles.map((profile) => profile.id)).size !== manifest.profiles.length) {
throw new Error("Duplicate deployed profile IDs");
}
return manifest;
}
export class DeployedStackApi {
private readonly token: string;
constructor(readonly stack: DeployedStack) {
const filename = process.env.PAPERCLIP_DEPLOYED_STACK_AUTH;
if (!filename || (fs.statSync(filename).mode & 0o077) !== 0) {
throw new Error("PAPERCLIP_DEPLOYED_STACK_AUTH must name a private (0600) credentials file");
}
const auth = JSON.parse(fs.readFileSync(filename, "utf8"));
assert(typeof auth?.baseURL === "string" && typeof auth.boardApiToken === "string" && auth.boardApiToken.length > 0, "Invalid stack credentials");
if (new URL(auth.baseURL).origin !== new URL(stack.baseURL).origin) throw new Error("Credentials belong to another stack");
this.token = auth.boardApiToken;
}
// Node fetch keeps credentials out of Playwright traces, request attachments,
// and serialized reporter configuration. Error bodies may contain run secrets.
async request(path: string, options: RequestInit = {}): Promise<Response> {
if (!path.startsWith("/api/") || path.includes("\\") || path.includes("..")) throw new Error("Invalid tenant API path");
const headers = new Headers(options.headers);
headers.set("Authorization", `Bearer ${this.token}`);
return fetch(new URL(path, this.stack.baseURL), {
...options, headers, redirect: "error", signal: AbortSignal.timeout(60_000),
});
}
async json<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const response = await this.request(path, { method,
...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
});
if (!response.ok) throw new Error(`${method} ${path.split("?")[0]} returned ${response.status}; body withheld`);
return response.json() as Promise<T>;
}
}

View File

@ -0,0 +1,91 @@
import { randomUUID } from "node:crypto";
import { test, expect } from "@playwright/test";
import type { EnvironmentCapabilities } from "../../packages/shared/src/environment-support.js";
import type { WorkFolderListing, WorkFolderSyncStatus } from "../../packages/shared/src/work-folders.js";
import { QUALIFIED_ACPX_PROFILES } from "../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
import { pollUntil } from "./api.js";
import { DeployedStackApi, loadDeployedStack } from "./deployed-stack.js";
const stack = loadDeployedStack();
const api = new DeployedStackApi(stack);
const folder = (scope: string, ownerId: string) => `/api/companies/${stack.companyId}/work-folders/${scope}/${encodeURIComponent(ownerId)}`;
test.describe.configure({ mode: "serial" });
test("deployed candidate and complete supported adapter inventory", async ({}, info) => {
const health = await api.json<{ commit: string }>("/api/health");
expect(health.commit).toBe(stack.commit);
const capabilities = await api.json<EnvironmentCapabilities>(`/api/companies/${stack.companyId}/environments/capabilities`);
expect(capabilities.sandboxProviders.daytona?.supportsRunExecution).toBe(true);
const adapters = await api.json<Array<{ type: string; disabled: boolean; capabilities: { supportsAcp: boolean } }>>("/api/adapters");
const required: string[] = [];
for (const adapter of adapters.filter((entry) => !entry.disabled)) {
if (capabilities.adapters.find((entry) => entry.adapterType === adapter.type)?.drivers.sandbox !== "supported") continue;
if (adapter.type === "paperclip_runner") {
required.push("paperclip_runner:codex", "paperclip_runner:opencode",
...Object.keys(QUALIFIED_ACPX_PROFILES).map((name) => `paperclip_runner:acpx:${name}`));
} else {
required.push(`${adapter.type}:cli`);
if (adapter.capabilities.supportsAcp) required.push(`${adapter.type}:acp`);
}
}
const configured = new Set(stack.profiles.map((profile) => `${profile.adapterType}:${profile.engine}`));
expect(required.filter((key) => !configured.has(key)), "Every exposed sandbox adapter/engine requires a qualified fixture").toEqual([]);
for (const profile of stack.profiles) {
const agent = await api.json<{ adapterType: string; adapterConfig: Record<string, unknown> }>(`/api/agents/${profile.agentId}`);
expect(agent.adapterType).toBe(profile.adapterType);
expect(agent.adapterConfig.model).toBe(profile.model);
}
await info.attach("deployed-candidate-and-inventory", { contentType: "application/json", body: Buffer.from(JSON.stringify({ stack, required }, null, 2)) });
});
for (const [scope, owner] of [["task", stack.taskId], ["agent", stack.agentId], ["project", stack.projectId], ["user", stack.userId]]) {
test(`${scope} nested empty executable files, retry, trash and restoration`, async () => {
const base = folder(scope!, owner!);
const filename = `acceptance/${randomUUID()}/empty.sh`;
const key = randomUUID();
const write = () => api.request(`${base}/content?path=${encodeURIComponent(filename)}`, {
method: "PUT", headers: { "Content-Type": "application/octet-stream", "X-File-Executable": "true", "Idempotency-Key": key }, body: "",
});
expect((await write()).ok).toBe(true);
expect((await write()).ok).toBe(true);
const listing = await api.json<WorkFolderListing>(base);
const file = listing.files.find((entry) => entry.path === filename)!;
expect(file).toMatchObject({ byteSize: 0, executable: true, deletedAt: null });
const download = await api.request(`${base}/content?path=${encodeURIComponent(filename)}`);
expect(download.status).toBe(200); expect((await download.arrayBuffer()).byteLength).toBe(0);
await api.json(`${base}/operations`, "POST", { action: "delete", path: filename });
expect((await api.request(`${base}/content?path=${encodeURIComponent(filename)}`)).status).toBe(404);
const trash = await api.json<WorkFolderListing>(`${base}?trash=true`);
expect(trash.files.some((entry) => entry.id === file.id)).toBe(true);
await api.json(`${base}/operations`, "POST", { action: "restore", fileId: file.id });
expect((await api.request(`${base}/content?path=${encodeURIComponent(filename)}`)).status).toBe(200);
});
}
for (const profile of stack.profiles) {
test(`${profile.id} creates durable work from the actual sandbox home`, async ({}, info) => {
const nonce = randomUUID();
const issue = await api.json<{ id: string; identifier: string }>(`/api/companies/${stack.companyId}/issues`, "POST", {
title: `Work folder acceptance ${profile.id} ${nonce}`, projectId: stack.projectId,
assigneeAgentId: profile.agentId, status: "todo",
description: [
"Perform this sandbox acceptance task using real filesystem tools.",
"Verify cwd equals the operating-system HOME and task, agent, user, project, repos, .codex, .cache are directories beneath it.",
"Verify repos contains at least two independent Git checkouts. Fail the task with the actual error if either assertion fails.",
`Write exactly '${nonce}' without a newline into $HOME/task/acceptance.txt and $HOME/agent/acceptance-${nonce}.txt.`,
"Then complete this task successfully. Do not print credentials or modify unrelated files.",
].join("\n"),
});
const base = folder("task", issue.id);
await pollUntil({ label: `${profile.id} completed run and durable task file`, deadlineAt: Date.now() + 840_000,
intervalMs: 5_000,
load: async () => ({ issue: await api.json<{ status: string }>(`/api/issues/${issue.id}`),
saves: await api.json<WorkFolderSyncStatus[]>(`${base}/sync`) }),
accept: (state) => state.issue.status === "done" && state.saves.some((save) => !save.active && save.state === "saved" && save.lastSavedAt !== null),
reject: (state) => state.saves.some((save) => save.state === "failed") ? "Work-folder save failed" : undefined,
});
const content = await api.request(`${base}/content?path=acceptance.txt`);
expect(content.status).toBe(200); expect(await content.text()).toBe(nonce);
await info.attach("task", { contentType: "application/json", body: Buffer.from(JSON.stringify({ profile: profile.id, ...issue })) });
});
}

View File

@ -0,0 +1,16 @@
import path from "node:path";
import { defineConfig } from "@playwright/test";
import { loadDeployedStack } from "./deployed-stack.js";
const stack = loadDeployedStack();
const output = process.env.PAPERCLIP_DEPLOYED_STACK_EVIDENCE;
if (!output || !path.isAbsolute(output)) throw new Error("PAPERCLIP_DEPLOYED_STACK_EVIDENCE must be an absolute output directory");
export default defineConfig({
testDir: ".", testMatch: "deployed-work-folders.spec.ts",
fullyParallel: false, workers: 1, retries: 0, timeout: 900_000,
use: { baseURL: stack.baseURL, trace: "off", video: "off" },
// No webServer: every operation reaches the deployed tenant and database.
outputDir: path.join(output, "results"),
reporter: [["list"], ["json", { outputFile: path.join(output, "results.json") }]],
});

View File

@ -4,7 +4,6 @@ import { Link, useParams, useSearchParams } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { accessApi } from "../api/access";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys";
export function CliAuthPage() {
@ -18,11 +17,6 @@ export function CliAuthPage() {
[challengeId, token],
);
const sessionQuery = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
const challengeQuery = useQuery({
queryKey: ["cli-auth-challenge", challengeId, token],
queryFn: () => accessApi.getCliAuthChallenge(challengeId, token),
@ -49,7 +43,7 @@ export function CliAuthPage() {
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid CLI auth URL.</div>;
}
if (sessionQuery.isLoading || challengeQuery.isLoading) {
if (challengeQuery.isLoading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading CLI auth challenge...</div>;
}
@ -102,7 +96,9 @@ export function CliAuthPage() {
);
}
if (challenge.requiresSignIn || !sessionQuery.data) {
// The server evaluates both local sessions and Cloud tenant identity.
// Cloud sign-in does not create a separate Better Auth session cookie.
if (challenge.requiresSignIn) {
return (
<div className="mx-auto max-w-xl py-10">
<Card className="block p-6">