mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Harden work-folder recovery and add deployed staging acceptance tools
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a13bb6b395
commit
6c7d1bbbf0
23 files changed
+656
-38
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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 })) });
|
||||
});
|
||||
}
|
||||
@@ -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") }]],
|
||||
});
|
||||
Reference in new issue
Block a user