Files
PaperClipAI/tests/runner-e2e/deployed-stack.ts
T
DottaandPaperclip 639d8875bb Build staging migrator artifacts and align native sandbox sessions with HOME
Use object-storage artifact URLs and Actions build artifacts without creating releases. Fix both native session entry points, preserve the primary workspace environment, and declare upload headers in OpenAPI. Tests cover sandbox home confinement, session dispatch, and generated headers.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-07 12:56:29 -05:00

81 lines
4.7 KiB
TypeScript

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;
excludedAdapters?: Array<{ adapterType: string; reason: 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}`);
if (manifest.excludedAdapters !== undefined) {
assert(Array.isArray(manifest.excludedAdapters), "Invalid adapter exclusions");
for (const exclusion of manifest.excludedAdapters) {
assert(typeof exclusion.adapterType === "string" && typeof exclusion.reason === "string" && exclusion.reason.trim().length > 0, "Exclusions require an explicit reason");
}
}
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>;
}
}