901 lines
49 KiB
JavaScript
901 lines
49 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync } from "node:fs";
|
|
import { access, lstat, mkdir, open, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import {
|
|
buildSafeEnvironment,
|
|
collectRepositoryProvenance,
|
|
deriveOverall,
|
|
scanSecrets,
|
|
} from "./p1-acceptance.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const modulePath = fileURLToPath(import.meta.url);
|
|
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
|
|
const RUN_ID = /^p11-[0-9a-f]{32}$/;
|
|
const HEX40 = /^[0-9a-f]{40}$/;
|
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
const ZIP_FILES = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"];
|
|
|
|
function resolveSystemExecutable(name) {
|
|
for (const candidate of [`/usr/bin/${name}`, `/bin/${name}`, `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`]) {
|
|
try {
|
|
const resolved = realpathSync(candidate);
|
|
if (lstatSync(resolved).isFile()) return resolved;
|
|
} catch {}
|
|
}
|
|
throw new Error(`required executable not found: ${name}`);
|
|
}
|
|
function resolveExecutables(repositoryRoot) {
|
|
const repo = canonicalRoot(repositoryRoot);
|
|
const thtPath = join(repo, "harness", ".venv", "bin", "tht");
|
|
if (!existsSync(thtPath)) throw new Error("required executable not found: tht");
|
|
return { gitPath: resolveSystemExecutable("git"), pythonPath: resolveSystemExecutable("python3"), thtPath: realpathSync(thtPath) };
|
|
}
|
|
const TOPOLOGY = [
|
|
"remote.git", "author", "installation/registry", "installation/data", "installation/runtime",
|
|
"fixture-secrets", "fixtures/descriptors", "fixtures/requests", "requests", "responses",
|
|
"exports/raw", "exports/extracted", "rendered", "logs",
|
|
];
|
|
export const CHECK_IDS = Object.freeze([
|
|
"preflight",
|
|
"clean_state",
|
|
"ownership",
|
|
"catalog_bootstrap",
|
|
"catalog_only_listing",
|
|
"bootstrap_create_once",
|
|
"api_curator_boundary",
|
|
"curator_descriptor_update",
|
|
"content_only_revision",
|
|
"docs_only_reconciliation",
|
|
"same_revision_git_objects",
|
|
"snapshot_and_export",
|
|
"runtime_render_determinism",
|
|
"tht_config_check",
|
|
"negative_catalog_layout_cases",
|
|
"negative_schema_context_cases",
|
|
"no_p2_scope_artifacts",
|
|
"secret_scan",
|
|
"cleanup_confinement",
|
|
]);
|
|
|
|
function nowIso() { return new Date().toISOString(); }
|
|
function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
function assert(condition, message) { if (!condition) throw new Error(message); }
|
|
function scalarSecretBytes(value) {
|
|
if (typeof value !== "string" || value.length === 0 || /\s|\0/.test(value)) throw new Error("scalar fixture secret is invalid");
|
|
return Buffer.from(value);
|
|
}
|
|
function canonicalRoot(repositoryRoot) { return realpathSync(repositoryRoot); }
|
|
export function canonicalIntegrationBase(repositoryRoot = defaultRepositoryRoot) {
|
|
return join(canonicalRoot(repositoryRoot), ".artifacts", "p11-integration");
|
|
}
|
|
export function validateRunRoot(repositoryRoot, runRoot, runId) {
|
|
if (!RUN_ID.test(runId)) throw new Error("invalid owned run id");
|
|
const base = canonicalIntegrationBase(repositoryRoot);
|
|
const lexical = resolve(runRoot);
|
|
if (dirname(lexical) !== base || basename(lexical) !== runId) throw new Error("run root is not a direct integration child");
|
|
return lexical;
|
|
}
|
|
function validateNoSymlinkAncestors(repositoryRoot, target) {
|
|
const repo = canonicalRoot(repositoryRoot);
|
|
const rel = relative(repo, target);
|
|
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("path leaves repository");
|
|
let cursor = repo;
|
|
for (const part of rel.split(sep).filter(Boolean)) {
|
|
cursor = join(cursor, part);
|
|
if (!existsSync(cursor)) break;
|
|
const entry = lstatSync(cursor);
|
|
if (entry.isSymbolicLink()) throw new Error("owned path ancestor is a symlink");
|
|
}
|
|
}
|
|
async function atomicWrite(path, bytes, mode = 0o600) {
|
|
await mkdir(dirname(path), { recursive: true });
|
|
const staging = join(dirname(path), `.${basename(path)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
let handle;
|
|
try {
|
|
handle = await open(staging, "wx", mode);
|
|
await handle.writeFile(bytes);
|
|
await handle.sync();
|
|
await handle.close();
|
|
handle = undefined;
|
|
await rename(staging, path);
|
|
const directory = openSync(dirname(path), fsConstants.O_RDONLY);
|
|
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
} catch (error) {
|
|
if (handle) await handle.close().catch(() => {});
|
|
await rm(staging, { force: true }).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
function exactOwnedResources(run) {
|
|
return [
|
|
run.root,
|
|
join(run.root, "remote.git"),
|
|
join(run.root, "author"),
|
|
join(run.root, "installation", "registry"),
|
|
join(run.root, "installation", "data"),
|
|
join(run.root, "installation", "runtime"),
|
|
];
|
|
}
|
|
function initialListeners(pid) {
|
|
return [{ name: "primary", kind: "fastify", host: "127.0.0.1", requestedPort: 0, pid, state: "not_started" }];
|
|
}
|
|
function ownershipValue(run, listeners = run.listeners) {
|
|
return {
|
|
schemaVersion: 1,
|
|
kind: "p11-acceptance",
|
|
runId: run.runId,
|
|
runNonce: run.nonce,
|
|
root: run.root,
|
|
repositoryRoot: run.repositoryRoot,
|
|
startedAt: run.startedAt,
|
|
pid: run.pid,
|
|
listeners,
|
|
resources: exactOwnedResources(run),
|
|
};
|
|
}
|
|
async function writeOwnership(run, listenerUpdate) {
|
|
const listeners = listenerUpdate
|
|
? run.listeners.map((listener) => listener.name === listenerUpdate.name ? listenerUpdate : listener)
|
|
: run.listeners;
|
|
await atomicWrite(join(run.root, "ownership.json"), `${JSON.stringify(ownershipValue(run, listeners), null, 2)}\n`);
|
|
run.listeners = listeners;
|
|
}
|
|
export async function createOwnedRun({ repositoryRoot = defaultRepositoryRoot, runId, nonce, now, pid } = {}) {
|
|
const repo = canonicalRoot(repositoryRoot);
|
|
const base = canonicalIntegrationBase(repo);
|
|
validateNoSymlinkAncestors(repo, base);
|
|
await mkdir(join(repo, ".artifacts"), { mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST") throw error; });
|
|
await mkdir(base, { mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST") throw error; });
|
|
const id = runId ?? `p11-${randomBytes(16).toString("hex")}`;
|
|
const root = validateRunRoot(repo, join(base, id), id);
|
|
const run = {
|
|
repositoryRoot: repo,
|
|
root,
|
|
runId: id,
|
|
nonce: nonce ?? randomBytes(32).toString("hex"),
|
|
startedAt: now ?? nowIso(),
|
|
pid: pid ?? process.pid,
|
|
listeners: initialListeners(pid ?? process.pid),
|
|
};
|
|
if (!HEX64.test(run.nonce) || !ISO_UTC.test(run.startedAt)) throw new Error("invalid ownership identity");
|
|
await mkdir(root, { mode: 0o700 });
|
|
await writeOwnership(run);
|
|
return run;
|
|
}
|
|
function strictOwnership(value, run, expectedNonce) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("ownership is malformed");
|
|
const listener = value.listeners?.[0];
|
|
const validListener = Array.isArray(value.listeners) && value.listeners.length === 1
|
|
&& listener?.name === "primary" && listener.kind === "fastify" && listener.host === "127.0.0.1"
|
|
&& listener.requestedPort === 0 && listener.pid === process.pid
|
|
&& ["not_started", "listening", "closed", "close_failed"].includes(listener.state)
|
|
&& (listener.state === "not_started" ? !("actualPort" in listener)
|
|
: Number.isInteger(listener.actualPort) && listener.actualPort >= 1 && listener.actualPort <= 65535);
|
|
if (value.schemaVersion !== 1 || value.kind !== "p11-acceptance" || value.runId !== run.runId || value.runNonce !== expectedNonce
|
|
|| value.root !== run.root || value.repositoryRoot !== run.repositoryRoot || value.pid !== process.pid
|
|
|| !ISO_UTC.test(value.startedAt ?? "") || !validListener
|
|
|| JSON.stringify(value.resources) !== JSON.stringify(exactOwnedResources(run))) throw new Error("ownership identity mismatch");
|
|
return value;
|
|
}
|
|
export async function readAndValidateOwnership({ repositoryRoot = defaultRepositoryRoot, runRoot, expectedNonce }) {
|
|
const repo = canonicalRoot(repositoryRoot);
|
|
const id = basename(resolve(runRoot));
|
|
const lexical = validateRunRoot(repo, runRoot, id);
|
|
const rootEntry = await lstat(lexical);
|
|
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) throw new Error("owned run root is not a directory");
|
|
const ownershipPath = join(lexical, "ownership.json");
|
|
const ownershipEntry = await lstat(ownershipPath);
|
|
if (!ownershipEntry.isFile() || ownershipEntry.isSymbolicLink()) throw new Error("ownership file is unsafe");
|
|
let value;
|
|
try { value = JSON.parse(await readFile(ownershipPath, "utf8")); } catch { throw new Error("ownership is malformed"); }
|
|
return strictOwnership(value, {
|
|
repositoryRoot: repo,
|
|
root: lexical,
|
|
runId: id,
|
|
nonce: expectedNonce,
|
|
startedAt: value.startedAt,
|
|
pid: process.pid,
|
|
}, expectedNonce);
|
|
}
|
|
export async function cleanupOwnedRun({ repositoryRoot = defaultRepositoryRoot, runRoot, expectedNonce }) {
|
|
const value = await readAndValidateOwnership({ repositoryRoot, runRoot, expectedNonce });
|
|
const base = canonicalIntegrationBase(repositoryRoot);
|
|
const tombstone = join(base, `.deleting-${value.runId}-${expectedNonce.slice(0, 16)}`);
|
|
await rename(runRoot, tombstone);
|
|
await rm(tombstone, { recursive: true, force: false });
|
|
}
|
|
async function finalizeOwnedRun({ run, success, keep }) {
|
|
if (!success || keep) return false;
|
|
await cleanupOwnedRun({ repositoryRoot: run.repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
|
|
return true;
|
|
}
|
|
|
|
function sanitizeForEvidence(value, forbiddenValues = []) {
|
|
const forbidden = forbiddenValues.filter((item) => typeof item === "string" && item.length > 0);
|
|
const redactString = (input) => forbidden.reduce((text, secret) => text.split(secret).join("[REDACTED]"), input);
|
|
if (typeof value === "string") return redactString(value);
|
|
if (Array.isArray(value)) return value.map((item) => sanitizeForEvidence(item, forbiddenValues));
|
|
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeForEvidence(item, forbiddenValues)]));
|
|
return value;
|
|
}
|
|
async function fileArtifact(root, relativePath) {
|
|
const bytes = await readFile(join(root, relativePath));
|
|
return { path: relativePath.split(sep).join("/"), sha256: sha256(bytes) };
|
|
}
|
|
async function evidence(run, relativePath, value, forbiddenValues = []) {
|
|
await atomicWrite(join(run.root, relativePath), `${JSON.stringify(sanitizeForEvidence(value, forbiddenValues), null, 2)}\n`);
|
|
return await fileArtifact(run.root, relativePath);
|
|
}
|
|
async function writeJson(path, value) {
|
|
await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
async function walkFiles(root) {
|
|
const files = [];
|
|
async function visit(dir) {
|
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) await visit(path);
|
|
else if (entry.isFile()) files.push({ path, rel: relative(root, path).split(sep).join("/") });
|
|
}
|
|
}
|
|
if (existsSync(root)) await visit(root);
|
|
return files.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
}
|
|
async function snapshotDigest(root) {
|
|
const result = {};
|
|
for (const file of await walkFiles(root)) result[file.rel] = sha256(await readFile(file.path));
|
|
return result;
|
|
}
|
|
function assertByteIdentical(left, right, label) {
|
|
if (JSON.stringify(left) !== JSON.stringify(right)) throw new Error(`${label} changed unexpectedly`);
|
|
}
|
|
async function writeReportFiles({ run, report }) {
|
|
validateReport(report);
|
|
await writeJson(join(run.root, "report.json"), report);
|
|
const lines = [
|
|
`# P1.1 acceptance report`,
|
|
"",
|
|
`Run ID: ${report.runId}`,
|
|
`Overall: ${report.overall}`,
|
|
"",
|
|
...report.checks.map((check) => `- ${check.id}: ${check.status}`),
|
|
"",
|
|
`report.json sha256: ${sha256(await readFile(join(run.root, "report.json")))}`,
|
|
`P1.1 automated integration: ${report.overall}`,
|
|
"P1.1 manual acceptance: PENDING",
|
|
];
|
|
await atomicWrite(join(run.root, "report.md"), `${lines.join("\n")}\n`);
|
|
}
|
|
export function validateReport(report) {
|
|
if (!report || typeof report !== "object" || Array.isArray(report)) throw new Error("report is malformed");
|
|
if (report.schemaVersion !== 1 || !RUN_ID.test(report.runId ?? "") || !ISO_UTC.test(report.startedAt ?? "")
|
|
|| !ISO_UTC.test(report.finishedAt ?? "") || report.command !== "p11-acceptance integration --keep") throw new Error("report identity is invalid");
|
|
if (report.overall !== deriveOverall(report.checks ?? [])) throw new Error("report overall is not derived");
|
|
if (!Array.isArray(report.checks) || report.checks.length !== CHECK_IDS.length) throw new Error("report checks are incomplete");
|
|
const ids = report.checks.map((check) => check.id);
|
|
if (JSON.stringify(ids) !== JSON.stringify(CHECK_IDS)) throw new Error("report checks are not exact");
|
|
const artifactPaths = new Set();
|
|
for (const check of report.checks) {
|
|
if (!["PASS", "FAIL"].includes(check.status) || !ISO_UTC.test(check.startedAt ?? "") || !ISO_UTC.test(check.finishedAt ?? "")) {
|
|
throw new Error("report check metadata is invalid");
|
|
}
|
|
if (!Array.isArray(check.commands) || check.commands.some((command) => typeof command !== "string" || !/^[A-Za-z0-9._+-]+$/.test(command))) {
|
|
throw new Error("report command is invalid");
|
|
}
|
|
if (!Array.isArray(check.artifacts)) throw new Error("report artifacts are invalid");
|
|
for (const artifact of check.artifacts) {
|
|
if (typeof artifact.path !== "string" || artifact.path.startsWith("/") || artifact.path.includes("..") || !/^[A-Za-z0-9._/-]+$/.test(artifact.path)) {
|
|
throw new Error("report artifact path is invalid");
|
|
}
|
|
if (!HEX64.test(artifact.sha256 ?? "")) throw new Error("report artifact hash is invalid");
|
|
if (artifactPaths.has(artifact.path)) throw new Error("report artifact path is duplicated");
|
|
artifactPaths.add(artifact.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function execCommand(executable, argv, { cwd, env, timeoutMs = 30_000, stdin } = {}) {
|
|
if (!Array.isArray(argv) || argv.some((value) => typeof value !== "string")) throw new Error("command argv must be a string array");
|
|
const result = await execFileAsync(executable, argv, {
|
|
cwd,
|
|
env,
|
|
timeout: timeoutMs,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
encoding: "utf8",
|
|
...(stdin === undefined ? {} : { input: stdin }),
|
|
});
|
|
return { code: 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
}
|
|
async function git(ctx, argv, options = {}) {
|
|
return await execCommand(ctx.executables.gitPath, argv, { ...options, env: ctx.env });
|
|
}
|
|
async function tht(ctx, argv, options = {}) {
|
|
try {
|
|
return await execCommand(ctx.executables.thtPath, argv, { ...options, env: ctx.env });
|
|
} catch (error) {
|
|
if (typeof error?.code === "number") return { code: error.code, stdout: error.stdout ?? "", stderr: error.stderr ?? "" };
|
|
throw error;
|
|
}
|
|
}
|
|
function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); }
|
|
function baseWorkspace(id, evidenceSource) {
|
|
return {
|
|
workspace: { schema_version: 4, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" },
|
|
dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] },
|
|
evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } },
|
|
};
|
|
}
|
|
function descriptors() {
|
|
return [
|
|
baseWorkspace("p11-filesystem", { type: "filesystem", uri: "p11-filesystem/evidence", patterns: ["**/*.md"], max_bytes: 10485760 }),
|
|
baseWorkspace("p11-http", { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "signed_urls_file", connect_timeout_ms: 1250, read_timeout_ms: 30001, max_bytes: 12345, max_redirects: 2, allow_private_hosts: false, max_cache_bytes: 67890 }),
|
|
baseWorkspace("p11-s3", { type: "s3", uri: "s3://p11-evidence/published/", endpoint_url: "https://s3.example.test/", region: "eu-west-1", credentials: "static_files", trusted_endpoint: true, allow_private_endpoint: false, allow_insecure_endpoint: false, max_bytes: 12345, max_objects: 33, max_pages: 4, page_size: 5 }),
|
|
];
|
|
}
|
|
async function createTopology(run) {
|
|
for (const path of TOPOLOGY) await mkdir(join(run.root, path), { recursive: true, mode: path === "fixture-secrets" ? 0o700 : 0o755 });
|
|
}
|
|
async function setupSecrets(ctx) {
|
|
const secretDir = join(ctx.run.root, "fixture-secrets");
|
|
const values = {
|
|
dwh: `DWH-${randomBytes(12).toString("hex")}`,
|
|
signed: `SIGNED-${randomBytes(12).toString("hex")}`,
|
|
access: `ACCESS-${randomBytes(12).toString("hex")}`,
|
|
secret: `SECRET-${randomBytes(12).toString("hex")}`,
|
|
session: `SESSION-${randomBytes(12).toString("hex")}`,
|
|
rejected: `REJECTED-${randomBytes(12).toString("hex")}`,
|
|
};
|
|
ctx.forbiddenValues = Object.values(values);
|
|
ctx.secretValues = values;
|
|
const paths = {
|
|
dwh: join(secretDir, "dwh-password"),
|
|
signed: join(secretDir, "evidence-signed-urls.json"),
|
|
access: join(secretDir, "evidence-access"),
|
|
secret: join(secretDir, "evidence-secret"),
|
|
session: join(secretDir, "evidence-session"),
|
|
};
|
|
await atomicWrite(paths.dwh, scalarSecretBytes(values.dwh));
|
|
await atomicWrite(paths.signed, JSON.stringify([`https://evidence.example.test/guide.md?token=${values.signed}`]));
|
|
await atomicWrite(paths.access, scalarSecretBytes(values.access));
|
|
await atomicWrite(paths.secret, scalarSecretBytes(values.secret));
|
|
await atomicWrite(paths.session, scalarSecretBytes(values.session));
|
|
const env = {};
|
|
for (const workspace of ctx.descriptors) {
|
|
const prefix = `THT_WS_${namespace(workspace.workspace.id)}`;
|
|
Object.assign(env, {
|
|
[`${prefix}_DWH_TRANSPORT`]: "postgres_direct",
|
|
[`${prefix}_DWH_HOST`]: "dwh.invalid",
|
|
[`${prefix}_DWH_PORT`]: "5432",
|
|
[`${prefix}_DWH_USER`]: "reader",
|
|
[`${prefix}_DWH_PASSWORD_FILE`]: paths.dwh,
|
|
});
|
|
}
|
|
Object.assign(env, {
|
|
THT_WS_P11_HTTP_EVIDENCE_SIGNED_URLS_FILE: paths.signed,
|
|
THT_WS_P11_S3_EVIDENCE_ACCESS_KEY_FILE: paths.access,
|
|
THT_WS_P11_S3_EVIDENCE_SECRET_KEY_FILE: paths.secret,
|
|
THT_WS_P11_S3_EVIDENCE_SESSION_TOKEN_FILE: paths.session,
|
|
});
|
|
Object.assign(ctx.env, env);
|
|
await atomicWrite(join(ctx.run.root, "installation", "bindings.env"), `${Object.entries(env).map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
|
|
await atomicWrite(join(ctx.run.root, "installation", "runtime", "base.yaml"), "{}\n");
|
|
}
|
|
function catalog(entries = ctxDescriptors) {
|
|
return { schema_version: 1, workspaces: entries.map(({ workspace }) => ({ id: workspace.id, name: workspace.name, description: workspace.description })) };
|
|
}
|
|
const ctxDescriptors = descriptors();
|
|
async function initializeGit(ctx) {
|
|
const author = join(ctx.run.root, "author");
|
|
await git(ctx, ["init", "--bare", "--initial-branch=main", join(ctx.run.root, "remote.git")], { cwd: ctx.run.root });
|
|
await git(ctx, ["clone", join(ctx.run.root, "remote.git"), author], { cwd: ctx.run.root });
|
|
await git(ctx, ["config", "user.name", "P1 Fixture Curator"], { cwd: author });
|
|
await git(ctx, ["config", "user.email", "p1-curator@example.invalid"], { cwd: author });
|
|
const catalogBytes = `${JSON.stringify({
|
|
schema_version: 1,
|
|
workspaces: [
|
|
...catalog(ctx.descriptors).workspaces,
|
|
{ id: "p11-pending", name: "P1.1 pending", description: "Catalog-only slot awaiting bootstrap" },
|
|
],
|
|
}, null, 2)}\n`;
|
|
await atomicWrite(join(author, "thoth-workspaces.yaml"), catalogBytes, 0o644);
|
|
const evidenceRoot = join(author, "p11-filesystem", "evidence");
|
|
await mkdir(join(evidenceRoot, "domain"), { recursive: true });
|
|
await atomicWrite(join(evidenceRoot, "guide.md"), "# P1.1 curated Evidence\n", 0o644);
|
|
await atomicWrite(join(evidenceRoot, "domain", "table.md"), "# Curated table\n", 0o644);
|
|
await git(ctx, ["add", "thoth-workspaces.yaml"], { cwd: author });
|
|
await git(ctx, ["add", "p11-filesystem/evidence/guide.md"], { cwd: author });
|
|
await git(ctx, ["add", "-A", "p11-filesystem/evidence"], { cwd: author });
|
|
await git(ctx, ["commit", "-m", "Bootstrap curated P1 content"], { cwd: author });
|
|
await git(ctx, ["push", "origin", "main"], { cwd: author });
|
|
ctx.bootstrapCommit = (await git(ctx, ["rev-parse", "HEAD"], { cwd: author })).stdout.trim();
|
|
ctx.catalogBlobBefore = (await git(ctx, ["rev-parse", `HEAD:thoth-workspaces.yaml`], { cwd: author })).stdout.trim();
|
|
ctx.evidenceTreeBefore = (await git(ctx, ["rev-parse", `HEAD:p11-filesystem/evidence`], { cwd: author })).stdout.trim();
|
|
}
|
|
async function loadProductionBackend() {
|
|
const [{ loadConfig }, { buildApp }, { WorkspaceRegistry }, { ThtRunner }] = await Promise.all([
|
|
import("../dist/config.js"),
|
|
import("../dist/app.js"),
|
|
import("../dist/workspaces/registry.js"),
|
|
import("../dist/tht/tht-runner.js"),
|
|
]);
|
|
return { loadConfig, buildApp, WorkspaceRegistry, ThtRunner };
|
|
}
|
|
async function startBackend(ctx) {
|
|
const { loadConfig, buildApp, WorkspaceRegistry, ThtRunner } = await loadProductionBackend();
|
|
const config = loadConfig(ctx.env);
|
|
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
|
const thtRunner = new ThtRunner({
|
|
thtBin: config.thtBin,
|
|
harnessDir: config.harnessDir,
|
|
configPath: join(ctx.run.root, "installation", "runtime", "base.yaml"),
|
|
dataRoot: config.dataRoot,
|
|
runtimeSnapshotRoot: join(config.workspaceRegistry.root, "snapshots", "runtime"),
|
|
secretRoots: config.workspaceRegistry.secretRoots,
|
|
secretsFile: config.secretsFile,
|
|
secretFiles: config.secretFiles,
|
|
semanticRuntime: {
|
|
internalQdrantUrl: config.internalQdrantUrl,
|
|
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
|
internalEmbeddingModel: config.internalEmbeddingModel,
|
|
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
|
},
|
|
});
|
|
const app = buildApp(config, { thtRunner, workspaceRegistry: registry });
|
|
const address = await app.listen({ host: "127.0.0.1", port: 0 });
|
|
const baseUrl = `http://127.0.0.1:${new URL(address).port}`;
|
|
ctx.registry = registry;
|
|
ctx.thtRunner = thtRunner;
|
|
ctx.app = app;
|
|
ctx.baseUrl = baseUrl;
|
|
await writeOwnership(ctx.run, {
|
|
name: "primary", kind: "fastify", host: "127.0.0.1", requestedPort: 0,
|
|
actualPort: Number(new URL(address).port), pid: process.pid, state: "listening",
|
|
});
|
|
}
|
|
async function stopBackend(ctx) {
|
|
if (ctx.app) {
|
|
await ctx.app.close().catch(() => {});
|
|
await writeOwnership(ctx.run, {
|
|
name: "primary", kind: "fastify", host: "127.0.0.1", requestedPort: 0,
|
|
actualPort: Number(new URL(ctx.baseUrl).port), pid: process.pid, state: "closed",
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
async function request(ctx, id, method, path, body, binary = false, safeInput) {
|
|
const requestSummary = safeInput === undefined
|
|
? { method, path, ...(body === undefined ? {} : { body: sanitizeForEvidence(body, ctx.forbiddenValues) }) }
|
|
: { method, path, input: safeInput };
|
|
await evidence(ctx.run, `requests/${id}.json`, requestSummary, ctx.forbiddenValues);
|
|
const response = await fetch(`${ctx.baseUrl}${path}`, {
|
|
method,
|
|
headers: body === undefined ? {} : { "content-type": "application/json" },
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (binary) {
|
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
await atomicWrite(join(ctx.run.root, `exports/raw/${id}.zip`), bytes);
|
|
await evidence(ctx.run, `responses/${id}.json`, { status: response.status, bytes: bytes.length, contentType: response.headers.get("content-type") });
|
|
return { status: response.status, bytes };
|
|
}
|
|
const text = await response.text();
|
|
let parsed;
|
|
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = { invalidJson: true, raw: text }; }
|
|
await evidence(ctx.run, `responses/${id}.json`, { status: response.status, body: sanitizeForEvidence(parsed, ctx.forbiddenValues) }, ctx.forbiddenValues);
|
|
return { status: response.status, body: parsed };
|
|
}
|
|
async function extractZip(ctx, id, bytes) {
|
|
const yauzl = (await import("yauzl")).default;
|
|
const output = join(ctx.run.root, "exports", "extracted", id);
|
|
await mkdir(output, { recursive: true });
|
|
const files = await new Promise((resolvePromise, reject) => {
|
|
yauzl.fromBuffer(bytes, { lazyEntries: true, strictFileNames: true, validateEntrySizes: true }, (error, zip) => {
|
|
if (error || !zip) return reject(error ?? new Error("zip open failed"));
|
|
const collected = new Map();
|
|
zip.on("error", reject);
|
|
zip.on("entry", (entry) => {
|
|
if (!ZIP_FILES.includes(entry.fileName) || entry.fileName.includes("..") || entry.fileName.startsWith("/") || entry.fileName.endsWith("/")) return reject(new Error("unsafe export entry"));
|
|
zip.openReadStream(entry, (streamError, stream) => {
|
|
if (streamError || !stream) return reject(streamError ?? new Error("zip stream failed"));
|
|
const chunks = [];
|
|
stream.on("data", (chunk) => chunks.push(chunk));
|
|
stream.on("error", reject);
|
|
stream.on("end", async () => {
|
|
const buffer = Buffer.concat(chunks);
|
|
collected.set(entry.fileName, buffer);
|
|
await atomicWrite(join(output, entry.fileName), buffer);
|
|
zip.readEntry();
|
|
});
|
|
});
|
|
});
|
|
zip.on("end", () => resolvePromise(collected));
|
|
zip.readEntry();
|
|
});
|
|
});
|
|
assert(files.size === ZIP_FILES.length, "export bundle entry mismatch");
|
|
return JSON.parse(files.get("manifest.json").toString("utf8"));
|
|
}
|
|
function checkResult(id, startedAt, status, artifacts = [], commands = [], error) {
|
|
return { id, status, startedAt, finishedAt: nowIso(), artifacts, commands, ...(error ? { error } : {}) };
|
|
}
|
|
async function executeChecks({ checks }) {
|
|
const results = [];
|
|
let stopped = false;
|
|
for (const scenario of checks) {
|
|
const startedAt = nowIso();
|
|
if (stopped) {
|
|
results.push(checkResult(scenario.id, startedAt, "FAIL", [], [], "Not executed after earlier failure."));
|
|
continue;
|
|
}
|
|
try {
|
|
const output = await scenario.run();
|
|
results.push(checkResult(scenario.id, startedAt, "PASS", output.artifacts ?? [], output.commands ?? []));
|
|
} catch (error) {
|
|
const partial = error?.acceptancePartial ?? {};
|
|
results.push(checkResult(scenario.id, startedAt, "FAIL", partial.artifacts ?? [], partial.commands ?? [], "Acceptance scenario failed safely."));
|
|
stopped = true;
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
async function registryState(ctx) {
|
|
const statePath = join(ctx.run.root, "installation", "registry", "state", "active.json");
|
|
const active = JSON.parse(await readFile(statePath, "utf8"));
|
|
return {
|
|
head: active.head,
|
|
revisions: active.revisions.map((revision) => ({ id: revision.id, commit: revision.commit, blob: revision.blob })),
|
|
catalog: active.catalog ?? null,
|
|
};
|
|
}
|
|
function safeErrorEnvelope(response, code, status) {
|
|
assert(response.status === status, `expected ${status}`);
|
|
assert(response.body?.code === code, `expected error code ${code}`);
|
|
assert(Object.keys(response.body).sort().join(",") === "code,message", "error envelope is not exact");
|
|
}
|
|
async function productionChecks(ctx) {
|
|
const check = async (id, value, commands = []) => ({ commands, artifacts: [await evidence(ctx.run, `logs/${id}.json`, value, ctx.forbiddenValues)] });
|
|
return [
|
|
{ id: "preflight", run: async () => check("preflight", { node: process.version, repositoryHead: ctx.provenance.head, repositoryTree: ctx.provenance.tree, clean: ctx.provenance.clean, thtExecutable: true }) },
|
|
{ id: "clean_state", run: async () => check("clean_state", { runId: ctx.run.runId, reused: false }) },
|
|
{ id: "ownership", run: async () => { await readAndValidateOwnership({ repositoryRoot: ctx.repositoryRoot, runRoot: ctx.run.root, expectedNonce: ctx.run.nonce }); return await check("ownership", { valid: true }); } },
|
|
{ id: "catalog_bootstrap", run: async () => {
|
|
await initializeGit(ctx);
|
|
for (const workspace of ctx.descriptors) await atomicWrite(join(ctx.run.root, "fixtures", "descriptors", `${workspace.workspace.id}.json`), `${JSON.stringify(workspace, null, 2)}\n`);
|
|
return {
|
|
commands: ["git"],
|
|
artifacts: [
|
|
await evidence(ctx.run, "logs/catalog-bootstrap.json", { bootstrapCommit: ctx.bootstrapCommit, catalogOnly: true }),
|
|
await fileArtifact(ctx.run.root, "author/thoth-workspaces.yaml"),
|
|
await fileArtifact(ctx.run.root, "author/p11-filesystem/evidence/guide.md"),
|
|
],
|
|
};
|
|
} },
|
|
{ id: "catalog_only_listing", run: async () => {
|
|
await startBackend(ctx);
|
|
const status = await request(ctx, "registry-status", "GET", "/workspace-registry/status");
|
|
assert(status.status === 200 && status.body.head === ctx.bootstrapCommit, "status head mismatch");
|
|
const listed = await request(ctx, "workspace-list-initial", "GET", "/workspaces");
|
|
assert(listed.status === 200 && listed.body.length === 4, "catalog listing failed");
|
|
assert(listed.body.every((entry) => entry.configurationState === "configuration_required"), "catalog entries were not configuration_required");
|
|
ctx.baseCommit = status.body.head;
|
|
return await check("catalog_only_listing", { head: status.body.head, ids: listed.body.map((entry) => entry.id), allConfigurationRequired: true });
|
|
} },
|
|
{ id: "bootstrap_create_once", run: async () => {
|
|
let base = ctx.baseCommit;
|
|
ctx.bootstrapResponses = {};
|
|
for (const workspace of ctx.descriptors) {
|
|
const validated = await request(ctx, `validate-${workspace.workspace.id}`, "POST", "/workspaces/validate", { workspace });
|
|
assert(validated.status === 200, `validate failed ${workspace.workspace.id}`);
|
|
const published = await request(ctx, `publish-${workspace.workspace.id}`, "POST", "/workspaces/publish", { action: "create", workspace, baseCommit: base });
|
|
assert(published.status === 200 && HEX40.test(published.body.revision.commit), `publish failed ${workspace.workspace.id}`);
|
|
ctx.bootstrapResponses[workspace.workspace.id] = published.body;
|
|
base = published.body.revision.commit;
|
|
}
|
|
ctx.publishHead = base;
|
|
const listed = await request(ctx, "workspace-list-ready", "GET", "/workspaces");
|
|
assert(listed.body.filter((entry) => entry.configurationState === "ready").length === 3, "bootstrap did not activate all published entries");
|
|
assert(listed.body.find((entry) => entry.id === "p11-pending")?.configurationState === "configuration_required", "pending slot was not left unconfigured");
|
|
return await check("bootstrap_create_once", { head: base, readyIds: listed.body.filter((entry) => entry.configurationState === "ready").map((entry) => entry.id) });
|
|
} },
|
|
{ id: "api_curator_boundary", run: async () => {
|
|
const author = join(ctx.run.root, "author");
|
|
const catalogAfter = (await git(ctx, ["rev-parse", `HEAD:thoth-workspaces.yaml`], { cwd: author })).stdout.trim();
|
|
const evidenceAfter = (await git(ctx, ["rev-parse", `HEAD:p11-filesystem/evidence`], { cwd: author })).stdout.trim();
|
|
assert(catalogAfter === ctx.catalogBlobBefore, "catalog blob changed during bootstrap");
|
|
assert(evidenceAfter === ctx.evidenceTreeBefore, "evidence tree changed during bootstrap");
|
|
ctx.apiBoundaryState = await registryState(ctx);
|
|
return await check("api_curator_boundary", { catalogUnchanged: true, evidenceUnchanged: true, state: ctx.apiBoundaryState }, ["git"]);
|
|
} },
|
|
{ id: "curator_descriptor_update", run: async () => {
|
|
const author = join(ctx.run.root, "author");
|
|
await git(ctx, ["fetch", "origin", "main"], { cwd: author });
|
|
await git(ctx, ["reset", "--hard", "origin/main"], { cwd: author });
|
|
const workspace = structuredClone(ctx.descriptors[0]);
|
|
workspace.workspace.name = "P1.1 Curated Filesystem";
|
|
workspace.workspace.description = "Curator updated descriptor and catalog metadata";
|
|
ctx.curatedWorkspace = workspace;
|
|
const updatedCatalog = catalog([workspace, ctx.descriptors[1], ctx.descriptors[2]]);
|
|
await atomicWrite(join(author, "thoth-workspaces.yaml"), `${JSON.stringify(updatedCatalog, null, 2)}\n`, 0o644);
|
|
await atomicWrite(join(author, "p11-filesystem", "workspace.yaml"), `${(await import("yaml")).stringify(workspace)}`, 0o644);
|
|
await git(ctx, ["add", "thoth-workspaces.yaml"], { cwd: author });
|
|
await git(ctx, ["add", "--", "p11-filesystem/workspace.yaml"], { cwd: author });
|
|
await git(ctx, ["commit", "-m", "Publish workspace p1-filesystem"], { cwd: author });
|
|
await git(ctx, ["push", "origin", "main"], { cwd: author });
|
|
ctx.curatorCommit = (await git(ctx, ["rev-parse", "HEAD"], { cwd: author })).stdout.trim();
|
|
ctx.curatorDescriptorBlob = (await git(ctx, ["rev-parse", `HEAD:p11-filesystem/workspace.yaml`], { cwd: author })).stdout.trim();
|
|
const pulled = await request(ctx, "pull-after-curator-update", "POST", "/workspace-registry/pull");
|
|
assert(pulled.status === 200 && HEX40.test(pulled.body.head), "pull after curator update failed");
|
|
ctx.docsFollowupHead = pulled.body.head;
|
|
const read = await request(ctx, "read-after-curator-update", "GET", "/workspaces/p11-filesystem");
|
|
assert(read.status === 200 && read.body.workspace.workspace.name === workspace.workspace.name, "curator update did not activate");
|
|
assert(read.body.revision.blob === ctx.curatorDescriptorBlob, "api rewrote curator descriptor bytes");
|
|
return await check("curator_descriptor_update", { curatorCommit: ctx.curatorCommit, activeHead: ctx.docsFollowupHead, descriptorBlob: ctx.curatorDescriptorBlob }, ["git"]);
|
|
} },
|
|
{ id: "content_only_revision", run: async () => {
|
|
const author = join(ctx.run.root, "author");
|
|
await git(ctx, ["fetch", "origin", "main"], { cwd: author });
|
|
await git(ctx, ["reset", "--hard", "origin/main"], { cwd: author });
|
|
await atomicWrite(join(author, "p11-filesystem", "evidence", "guide.md"), "# P1.1 curated Evidence v2\n", 0o644);
|
|
await git(ctx, ["add", "p11-filesystem/evidence/guide.md"], { cwd: author });
|
|
await git(ctx, ["commit", "-m", "Update curated Evidence only"], { cwd: author });
|
|
await git(ctx, ["push", "origin", "main"], { cwd: author });
|
|
ctx.contentCommit = (await git(ctx, ["rev-parse", "HEAD"], { cwd: author })).stdout.trim();
|
|
const pulled = await request(ctx, "pull-after-content-update", "POST", "/workspace-registry/pull");
|
|
assert(pulled.status === 200 && pulled.body.head === ctx.contentCommit, "content pull head mismatch");
|
|
const read = await request(ctx, "read-after-content-update", "GET", "/workspaces/p11-filesystem");
|
|
assert(read.body.revision.commit === ctx.contentCommit, "content commit did not activate");
|
|
assert(read.body.revision.blob === ctx.curatorDescriptorBlob, "descriptor blob changed on content-only update");
|
|
ctx.currentRead = read.body;
|
|
return await check("content_only_revision", { commit: ctx.contentCommit, descriptorBlobUnchanged: true }, ["git"]);
|
|
} },
|
|
{ id: "docs_only_reconciliation", run: async () => {
|
|
const repo = join(ctx.run.root, "installation", "registry", "repo");
|
|
const diff = (await git(ctx, ["show", "--name-only", "--format=", ctx.docsFollowupHead], { cwd: repo })).stdout.trim().split(/\n+/).filter(Boolean);
|
|
assert(diff.length > 0 && diff.every((path) => path.startsWith("workspace-docs/")), "docs follow-up touched non-doc paths");
|
|
const finalDescriptor = (await git(ctx, ["rev-parse", `${ctx.docsFollowupHead}:p11-filesystem/workspace.yaml`], { cwd: repo })).stdout.trim();
|
|
assert(finalDescriptor === ctx.curatorDescriptorBlob, "docs follow-up rewrote descriptor");
|
|
return await check("docs_only_reconciliation", { head: ctx.docsFollowupHead, files: diff, descriptorBlobPreserved: true }, ["git"]);
|
|
} },
|
|
{ id: "same_revision_git_objects", run: async () => {
|
|
const repo = join(ctx.run.root, "installation", "registry", "repo");
|
|
const revision = ctx.currentRead.revision;
|
|
const manifestPath = join(dirname(revision.snapshotPath), "snapshot.json");
|
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
const catalogBlob = (await git(ctx, ["rev-parse", `${revision.commit}:thoth-workspaces.yaml`], { cwd: repo })).stdout.trim();
|
|
const descriptorBlob = (await git(ctx, ["rev-parse", `${revision.commit}:p11-filesystem/workspace.yaml`], { cwd: repo })).stdout.trim();
|
|
const evidenceTree = (await git(ctx, ["rev-parse", `${revision.commit}:p11-filesystem/evidence`], { cwd: repo })).stdout.trim();
|
|
assert(manifest.head === revision.commit, "snapshot manifest head mismatch");
|
|
assert(descriptorBlob === revision.blob, "descriptor blob mismatch");
|
|
ctx.snapshotManifest = manifest;
|
|
return {
|
|
commands: ["git"],
|
|
artifacts: [
|
|
await evidence(ctx.run, "logs/same-revision-git-objects.json", { commit: revision.commit, catalogBlob, descriptorBlob, evidenceTree, snapshotHead: manifest.head }),
|
|
await fileArtifact(ctx.run.root, relative(ctx.run.root, revision.snapshotPath)),
|
|
await fileArtifact(ctx.run.root, relative(ctx.run.root, manifestPath)),
|
|
],
|
|
};
|
|
} },
|
|
{ id: "snapshot_and_export", run: async () => {
|
|
ctx.exportManifests = {};
|
|
const artifacts = [];
|
|
for (const workspace of ctx.descriptors) {
|
|
const id = workspace.workspace.id;
|
|
const exported = await request(ctx, `export-${id}`, "GET", `/workspaces/${id}/export`, undefined, true);
|
|
assert(exported.status === 200, `export failed ${id}`);
|
|
ctx.exportManifests[id] = await extractZip(ctx, id, exported.bytes);
|
|
artifacts.push(await fileArtifact(ctx.run.root, `exports/raw/export-${id}.zip`));
|
|
for (const name of ZIP_FILES) artifacts.push(await fileArtifact(ctx.run.root, `exports/extracted/${id}/${name}`));
|
|
}
|
|
return { commands: [], artifacts: [await evidence(ctx.run, "logs/snapshot-and-export.json", { exported: Object.keys(ctx.exportManifests), files: ZIP_FILES }), ...artifacts] };
|
|
} },
|
|
{ id: "runtime_render_determinism", run: async () => {
|
|
const YAML = await import("yaml");
|
|
ctx.configChecks = [];
|
|
const artifacts = [];
|
|
for (const workspace of ctx.descriptors) {
|
|
const revision = (await request(ctx, `read-render-${workspace.workspace.id}`, "GET", `/workspaces/${workspace.workspace.id}`)).body.revision;
|
|
const renders = [];
|
|
for (let n = 1; n <= 2; n += 1) {
|
|
const lease = ctx.thtRunner.acquireWorkspaceRuntime(revision.snapshotPath);
|
|
try {
|
|
const bytes = await readFile(lease.path);
|
|
renders.push(bytes);
|
|
await atomicWrite(join(ctx.run.root, "rendered", `${workspace.workspace.id}-${n}.yaml`), bytes);
|
|
const checked = await tht(ctx, ["config", "check", "-c", lease.path], { cwd: ctx.env.THT_HARNESS_DIR, timeoutMs: 30_000 });
|
|
ctx.configChecks.push({ id: workspace.workspace.id, observation: n, code: checked.code });
|
|
} finally {
|
|
lease.release();
|
|
}
|
|
artifacts.push(await fileArtifact(ctx.run.root, `rendered/${workspace.workspace.id}-${n}.yaml`));
|
|
}
|
|
assert(renders[0].equals(renders[1]), `render was nondeterministic ${workspace.workspace.id}`);
|
|
const rendered = YAML.parse(renders[0].toString("utf8"));
|
|
assert(rendered.runtime_identity.workspace_revision === revision.commit, `runtime identity mismatch ${workspace.workspace.id}`);
|
|
}
|
|
return { commands: ["tht"], artifacts: [await evidence(ctx.run, "logs/runtime-render-determinism.json", { deterministic: true, checks: ctx.configChecks }), ...artifacts] };
|
|
} },
|
|
{ id: "tht_config_check", run: async () => {
|
|
assert(ctx.configChecks.length === ctx.descriptors.length * 2 && ctx.configChecks.every((item) => item.code === 0), "tht config checks failed");
|
|
return await check("tht-config-check", ctx.configChecks, ["tht"]);
|
|
} },
|
|
{ id: "negative_catalog_layout_cases", run: async () => {
|
|
const baseline = await registryState(ctx);
|
|
const author = join(ctx.run.root, "author");
|
|
const current = (await request(ctx, "current-list-before-negatives", "GET", "/workspaces")).body;
|
|
const secondCreate = await request(ctx, "second-create", "POST", "/workspaces/publish", { action: "create", workspace: ctx.descriptors[0], baseCommit: baseline.head });
|
|
safeErrorEnvelope(secondCreate, "workspace_curator_owned", 409);
|
|
const update = await request(ctx, "legacy-update", "POST", "/workspaces/publish", { action: "update", workspace: ctx.descriptors[0], baseCommit: baseline.head, baseBlob: ctx.curatorDescriptorBlob });
|
|
safeErrorEnvelope(update, "workspace_curator_owned", 409);
|
|
const deletion = await request(ctx, "legacy-delete", "POST", "/workspaces/publish", { action: "delete", id: "p11-filesystem", baseCommit: baseline.head, baseBlob: ctx.curatorDescriptorBlob });
|
|
safeErrorEnvelope(deletion, "workspace_curator_owned", 409);
|
|
const unknown = structuredClone(ctx.descriptors[0]);
|
|
unknown.workspace.id = "p11-unknown";
|
|
const unknownPublish = await request(ctx, "unknown-catalog-id", "POST", "/workspaces/publish", { action: "create", workspace: unknown, baseCommit: baseline.head });
|
|
safeErrorEnvelope(unknownPublish, "workspace_invalid", 400);
|
|
const mismatch = structuredClone(ctx.descriptors[0]);
|
|
mismatch.workspace.id = "p11-pending";
|
|
mismatch.workspace.name = "Mismatched pending name";
|
|
mismatch.semantic_index.vector_store.collection = "p11-pending";
|
|
const mismatchPublish = await request(ctx, "catalog-metadata-mismatch", "POST", "/workspaces/publish", { action: "create", workspace: mismatch, baseCommit: baseline.head });
|
|
safeErrorEnvelope(mismatchPublish, "workspace_invalid", 400);
|
|
const after = await registryState(ctx);
|
|
assertByteIdentical(after, baseline, "registry state after curator-owned refusals");
|
|
assert(JSON.stringify((await request(ctx, "current-list-after-negatives", "GET", "/workspaces")).body) === JSON.stringify(current), "workspace listing mutated after negative cases");
|
|
await git(ctx, ["fetch", "origin", "main"], { cwd: author });
|
|
await git(ctx, ["reset", "--hard", "origin/main"], { cwd: author });
|
|
await mkdir(join(author, "workspaces"), { recursive: true });
|
|
await atomicWrite(join(author, "workspaces", "legacy.yaml"), "workspace: bad\n", 0o644);
|
|
await git(ctx, ["add", "--", "workspaces/legacy.yaml"], { cwd: author });
|
|
await git(ctx, ["commit", "-m", "Invalid contextual Evidence state"], { cwd: author });
|
|
await git(ctx, ["push", "origin", "HEAD:main"], { cwd: author });
|
|
const rejectedPull = await request(ctx, "invalid-layout-pull", "POST", "/workspace-registry/pull");
|
|
safeErrorEnvelope(rejectedPull, "workspace_invalid", 400);
|
|
const afterInvalidPull = await registryState(ctx);
|
|
assertByteIdentical(afterInvalidPull, baseline, "registry state after invalid pull");
|
|
return await check("negative_catalog_layout_cases", { secondCreate: true, update: true, delete: true, unknownCatalogId: true, metadataMismatch: true, oldLayoutRejected: true }, ["git"]);
|
|
} },
|
|
{ id: "negative_schema_context_cases", run: async () => {
|
|
const base = structuredClone(ctx.descriptors[0]);
|
|
const cases = [
|
|
["invalid-uri", (workspace) => { workspace.evidence.source.uri = "/etc/passwd"; }, "evidence.source.uri"],
|
|
["invalid-secret-field", (workspace) => { workspace.evidence.source.password = ctx.secretValues.rejected; }, "evidence.source.password"],
|
|
["missing-evidence-tree", (workspace) => { workspace.workspace.id = "p11-pending"; workspace.workspace.name = "P1.1 pending"; workspace.workspace.description = "Catalog-only slot awaiting bootstrap"; workspace.semantic_index.vector_store.collection = "p11-pending"; workspace.evidence.source.uri = "p11-pending/evidence"; }, "evidence.source.uri"],
|
|
];
|
|
const outcomes = [];
|
|
for (const [id, mutate, field] of cases) {
|
|
const workspace = structuredClone(base);
|
|
mutate(workspace);
|
|
const endpoint = id === "missing-evidence-tree" ? "/workspaces/publish" : "/workspaces/validate";
|
|
const payload = id === "missing-evidence-tree" ? { action: "create", workspace, baseCommit: ctx.publishHead } : { workspace };
|
|
const response = await request(ctx, `negative-schema-${id}`, "POST", endpoint, payload, false, { case: id, expectedInputField: field });
|
|
safeErrorEnvelope(response, "workspace_invalid", 400);
|
|
outcomes.push({ case: id, status: response.status, field });
|
|
}
|
|
return await check("negative_schema_context_cases", outcomes);
|
|
} },
|
|
{ id: "no_p2_scope_artifacts", run: async () => {
|
|
const forbidden = ["artifacts/evidence", "materialized", "qdrant", "embedding", "ACTIVE", "retention"];
|
|
const present = forbidden.filter((path) => existsSync(join(ctx.run.root, path)));
|
|
assert(present.length === 0, "p2 scope artifacts present");
|
|
return await check("no_p2_scope_artifacts", { absent: forbidden });
|
|
} },
|
|
{ id: "secret_scan", run: async () => {
|
|
const findings = await scanSecrets({ runRoot: ctx.run.root, forbiddenValues: ctx.forbiddenValues, expectedGitRepositories: ["remote.git", "author"] });
|
|
assert(findings.length === 0, "secret scan found leaked secret material");
|
|
return await check("secret_scan", { findings: 0 });
|
|
} },
|
|
{ id: "cleanup_confinement", run: async () => {
|
|
const parent = canonicalIntegrationBase(ctx.repositoryRoot);
|
|
const siblings = (await readdir(parent)).filter((name) => name !== ctx.run.runId);
|
|
return await check("cleanup_confinement", { listenerState: ctx.run.listeners[0].state, siblingCount: siblings.length });
|
|
} },
|
|
];
|
|
}
|
|
|
|
async function setupContext({ repositoryRoot = defaultRepositoryRoot, env = process.env } = {}) {
|
|
const run = await createOwnedRun({ repositoryRoot });
|
|
const provenance = await collectRepositoryProvenance({ repositoryRoot });
|
|
const executables = resolveExecutables(repositoryRoot);
|
|
const harnessDir = realpathSync(join(repositoryRoot, "harness"));
|
|
const ownedHome = join(run.root, "installation", "runtime", "acceptance-home");
|
|
const ownedTmp = join(run.root, "installation", "runtime", "tmp");
|
|
await mkdir(ownedHome, { recursive: true, mode: 0o700 });
|
|
await mkdir(ownedTmp, { recursive: true, mode: 0o700 });
|
|
const executablePath = [...new Set([dirname(executables.gitPath), dirname(executables.pythonPath), dirname(executables.thtPath)])].join(":");
|
|
const fixtureEnv = {
|
|
PATH: executablePath,
|
|
HOME: ownedHome,
|
|
TMPDIR: ownedTmp,
|
|
HOST: "127.0.0.1",
|
|
PORT: "0",
|
|
AUTH_MODE: "none",
|
|
THT_BIN: executables.thtPath,
|
|
THT_HARNESS_DIR: harnessDir,
|
|
THT_DATA_ROOT: join(run.root, "installation", "data"),
|
|
SETTINGS_FILE: join(run.root, "installation", "data", "settings.json"),
|
|
MAINTENANCE_STATE_FILE: join(run.root, "installation", "data", "maintenance.json"),
|
|
THT_WORKSPACE_REGISTRY_ROOT: join(run.root, "installation", "registry"),
|
|
THT_WORKSPACE_GIT_REMOTE: join(run.root, "remote.git"),
|
|
THT_WORKSPACE_GIT_BRANCH: "main",
|
|
THT_WORKSPACE_GIT_AUTHOR_NAME: "P1 API Publisher",
|
|
THT_WORKSPACE_GIT_AUTHOR_EMAIL: "p1-api@example.invalid",
|
|
THT_WORKSPACE_INSTALLATION_ID: "p11-acceptance",
|
|
THT_WORKSPACE_SECRET_ROOTS: join(run.root, "fixture-secrets"),
|
|
THT_HOME: join(run.root, "installation", "runtime", "tht-home"),
|
|
PYTHONDONTWRITEBYTECODE: "1",
|
|
PYTHONNOUSERSITE: "1",
|
|
};
|
|
const ctx = {
|
|
run,
|
|
repositoryRoot: canonicalRoot(repositoryRoot),
|
|
provenance,
|
|
executables,
|
|
descriptors: descriptors(),
|
|
env: buildSafeEnvironment({ ambient: env, fixture: fixtureEnv }),
|
|
forbiddenValues: [],
|
|
};
|
|
await createTopology(run);
|
|
await setupSecrets(ctx);
|
|
return ctx;
|
|
}
|
|
|
|
export async function runIntegration({ repositoryRoot = defaultRepositoryRoot, keep = false, env = process.env, announce } = {}) {
|
|
const ctx = await setupContext({ repositoryRoot, env });
|
|
const priorEnv = {};
|
|
for (const [key, value] of Object.entries(ctx.env)) {
|
|
priorEnv[key] = process.env[key];
|
|
process.env[key] = value;
|
|
}
|
|
let success = false;
|
|
try {
|
|
const checks = await productionChecks(ctx);
|
|
const results = await executeChecks({ checks });
|
|
const report = {
|
|
schemaVersion: 1,
|
|
runId: ctx.run.runId,
|
|
startedAt: ctx.run.startedAt,
|
|
finishedAt: nowIso(),
|
|
command: "p11-acceptance integration --keep",
|
|
overall: deriveOverall(results),
|
|
checks: results,
|
|
};
|
|
await writeReportFiles({ run: ctx.run, report });
|
|
success = report.overall === "PASS";
|
|
if (announce) await announce({ report, runRoot: ctx.run.root });
|
|
return { exitCode: success ? 0 : 1, runRoot: ctx.run.root, retained: !(await finalizeOwnedRun({ run: ctx.run, success, keep })) };
|
|
} finally {
|
|
await stopBackend(ctx).catch(() => {});
|
|
for (const [key, value] of Object.entries(ctx.env)) {
|
|
if (priorEnv[key] === undefined) delete process.env[key];
|
|
else process.env[key] = priorEnv[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
if (argv.length < 1 || argv[0] !== "integration" || argv.length > 2 || (argv[1] && argv[1] !== "--keep")) {
|
|
throw new Error("usage: p11-acceptance.mjs integration [--keep]");
|
|
}
|
|
const result = await runIntegration({ keep: argv.includes("--keep"), env });
|
|
return result.exitCode;
|
|
}
|
|
|
|
if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) {
|
|
try {
|
|
const code = await main();
|
|
process.exitCode = code;
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|