diff --git a/backend/scripts/p11-acceptance.mjs b/backend/scripts/p11-acceptance.mjs new file mode 100644 index 00000000..e5679de5 --- /dev/null +++ b/backend/scripts/p11-acceptance.mjs @@ -0,0 +1,886 @@ +#!/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: 3, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" }, + dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] }, + semantic_index: { + vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, + embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, + }, + llm_policy: { allowed: ["zai/glm-5.2"] }, + 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(catalog(ctx.descriptors), 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 repo = join(ctx.run.root, "installation", "registry", "repo"); + const head = (await git(ctx, ["rev-parse", "HEAD"], { cwd: repo })).stdout.trim(); + return { + head, + catalog: (await git(ctx, ["rev-parse", `HEAD:thoth-workspaces.yaml`], { cwd: repo })).stdout.trim(), + filesystemDescriptor: (await git(ctx, ["rev-parse", `HEAD:p11-filesystem/workspace.yaml`], { cwd: repo })).stdout.trim(), + evidenceTree: (await git(ctx, ["rev-parse", `HEAD:p11-filesystem/evidence`], { cwd: repo })).stdout.trim(), + }; +} +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 === 3, "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.every((entry) => entry.configurationState === "ready"), "bootstrap did not activate all entries"); + return await check("bootstrap_create_once", { head: base, readyIds: listed.body.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[1]); + mismatch.workspace.name = "Mismatched name"; + 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-missing"; workspace.workspace.name = "P1.1 p11-missing"; workspace.workspace.description = "Missing evidence tree"; workspace.semantic_index.vector_store.collection = "p11-missing"; workspace.evidence.source.uri = "p11-missing/evidence"; }, "evidence.source.uri"], + ]; + const outcomes = []; + for (const [id, mutate, field] of cases) { + const workspace = structuredClone(base); + mutate(workspace); + const response = await request(ctx, `negative-schema-${id}`, "POST", "/workspaces/validate", { workspace }, 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 }); + 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(() => {}); + } +} + +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; + } +} diff --git a/backend/scripts/p11-acceptance.test.mjs b/backend/scripts/p11-acceptance.test.mjs new file mode 100644 index 00000000..a53fbf68 --- /dev/null +++ b/backend/scripts/p11-acceptance.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + CHECK_IDS, + canonicalIntegrationBase, + cleanupOwnedRun, + createOwnedRun, + readAndValidateOwnership, + validateReport, + validateRunRoot, +} from "./p11-acceptance.mjs"; + +const roots = []; +async function fakeRepository() { + const root = await mkdtemp(join(tmpdir(), "p11-acceptance-repo-")); + roots.push(root); + await mkdir(join(root, ".artifacts", "p11-integration"), { recursive: true }); + await mkdir(join(root, ".artifacts", "p1-integration"), { recursive: true }); + await mkdir(join(root, ".artifacts", "manual-acceptance", "p11"), { recursive: true }); + return root; +} + +test.afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +test("run roots are only canonical direct p11 integration children", async () => { + const repositoryRoot = await fakeRepository(); + const base = canonicalIntegrationBase(repositoryRoot); + const id = `p11-${"a".repeat(32)}`; + assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id)); + for (const candidate of [ + base, + join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"), + join(repositoryRoot, ".artifacts", "p1-integration", id), + join(base, id, "nested"), + join(base, "foreign"), + ]) { + assert.throws(() => validateRunRoot(repositoryRoot, candidate, id)); + } + assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p11-${"A".repeat(32)}`), `p11-${"A".repeat(32)}`)); +}); + +test("cleanup refuses p1, manual, sibling, and wrong-nonce roots", async () => { + const repositoryRoot = await fakeRepository(); + const run = await createOwnedRun({ repositoryRoot }); + await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce }); + for (const bad of [ + join(repositoryRoot, ".artifacts", "p1-integration", `p1-${"b".repeat(32)}`), + join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"), + join(canonicalIntegrationBase(repositoryRoot), `p11-${"c".repeat(32)}`), + ]) { + await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: bad, expectedNonce: run.nonce })); + } + await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: "0".repeat(64) })); +}); + +test("cleanup removes exactly one owned p11 root", async () => { + const repositoryRoot = await fakeRepository(); + const run = await createOwnedRun({ repositoryRoot }); + const sibling = join(canonicalIntegrationBase(repositoryRoot), `p11-${"d".repeat(32)}`); + await mkdir(sibling); + await writeFile(join(sibling, "sentinel"), "foreign"); + await cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce }); + await assert.rejects(readFile(join(run.root, "ownership.json"))); + assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign"); +}); + +function resultFor(id) { + return { + id, + status: "PASS", + startedAt: "2026-08-11T00:00:00.000Z", + finishedAt: "2026-08-11T00:00:01.000Z", + commands: ["git"], + artifacts: [{ path: `logs/${id}.json`, sha256: "a".repeat(64) }], + }; +} + +test("report validation requires exact p11 identity, check order, and unique artifacts", () => { + const report = { + schemaVersion: 1, + runId: `p11-${"e".repeat(32)}`, + startedAt: "2026-08-11T00:00:00.000Z", + finishedAt: "2026-08-11T00:00:10.000Z", + command: "p11-acceptance integration --keep", + overall: "PASS", + checks: CHECK_IDS.map(resultFor), + }; + assert.doesNotThrow(() => validateReport(report)); + const invalid = structuredClone(report); + invalid.runId = `p1-${"e".repeat(32)}`; + assert.throws(() => validateReport(invalid)); + const duplicate = structuredClone(report); + duplicate.checks[1].artifacts[0].path = duplicate.checks[0].artifacts[0].path; + assert.throws(() => validateReport(duplicate), /duplicated/); + const reordered = structuredClone(report); + reordered.checks.reverse(); + reordered.overall = "FAIL"; + assert.throws(() => validateReport(reordered)); +}); + +test("public wrapper uses a strict empty environment", async () => { + const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p11-acceptance.sh"), "utf8"); + assert.match(wrapper, /safe_env=\(\/usr\/bin\/env -i/); + assert.doesNotMatch(wrapper, /LANG|LC_ALL|TZ/); + assert.doesNotMatch(wrapper, /P11_ACCEPTANCE_FAIL_AT/); +}); diff --git a/backend/scripts/p11-manual-acceptance.mjs b/backend/scripts/p11-manual-acceptance.mjs new file mode 100644 index 00000000..18c9cd0d --- /dev/null +++ b/backend/scripts/p11-manual-acceptance.mjs @@ -0,0 +1,404 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, constants as fsConstants, fsyncSync, lstatSync, openSync, 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 { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { execFile } from "node:child_process"; +import http from "node:http"; + +import { buildSafeEnvironment } from "./p1-acceptance.mjs"; + +const execFileAsync = promisify(execFile); +const modulePath = fileURLToPath(import.meta.url); +const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../..")); +const HOST = "127.0.0.1"; +const BACKEND_PORT = 8791; +const FRONTEND_PORT = 8792; +const HEX64 = /^[0-9a-f]{64}$/; +const OWNERSHIP_DIGEST = "ownership.sha256"; + +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 = realpathSync(repositoryRoot); + const thtPath = join(repo, "harness", ".venv", "bin", "tht"); + if (!lstatSync(thtPath).isFile()) throw new Error("required executable not found: tht"); + return { gitPath: resolveSystemExecutable("git"), pythonPath: resolveSystemExecutable("python3"), thtPath: realpathSync(thtPath) }; +} + +function nowIso() { return new Date().toISOString(); } +function fixedManualRoot(repositoryRoot = defaultRepositoryRoot) { return join(realpathSync(repositoryRoot), ".artifacts", "manual-acceptance", "p11"); } +function below(parent, child) { const rel = relative(parent, child); return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel); } +function noSymlinkExisting(repo, target) { + const rel = relative(repo, target); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("root leaves repository"); + let cursor = repo; + for (const part of rel.split(sep).filter(Boolean)) { + cursor = join(cursor, part); + if (!lstatSync(cursor, { throwIfNoEntry: false })) break; + if (lstatSync(cursor).isSymbolicLink()) throw new Error("owned path contains 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 ownershipDigest(bytes) { return createHash("sha256").update(bytes).digest("hex"); } +async function writeManualOwnership(root, value) { + const body = `${JSON.stringify(value, null, 2)}\n`; + await atomicWrite(join(root, "ownership.json"), body); + await atomicWrite(join(root, OWNERSHIP_DIGEST), `${ownershipDigest(body)}\n`); +} +async function git(executable, argv, options = {}) { + const result = await execFileAsync(executable, argv, { cwd: options.cwd, env: options.env, timeout: options.timeoutMs ?? 30_000, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" }); + return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; +} +function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); } +function baseWorkspace(id, evidenceSource) { + return { + workspace: { schema_version: 3, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" }, + dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] }, + semantic_index: { + vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, + embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, + }, + llm_policy: { allowed: ["zai/glm-5.2"] }, + 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 }), + ]; +} +function catalog(entries) { + return { schema_version: 1, workspaces: entries.map(({ workspace }) => ({ id: workspace.id, name: workspace.name, description: workspace.description })) }; +} +function quote(value) { return `'${String(value).replaceAll("'", `'"'"'`)}'`; } +function requestFixtures(items) { + const fixtures = { "status.json": { method: "GET", path: "/workspace-registry/status" }, "pull.json": { method: "POST", path: "/workspace-registry/pull" } }; + for (const workspace of items) { + const id = workspace.workspace.id; + fixtures[`validate-${id}.json`] = { workspace }; + fixtures[`publish-${id}.json`] = { action: "create", workspace }; + fixtures[`read-${id}.json`] = { method: "GET", path: `/workspaces/${id}` }; + fixtures[`export-${id}.json`] = { method: "GET", path: `/workspaces/${id}/export` }; + } + fixtures["negative-invalid-uri.json"] = { workspace: { ...items[0], evidence: { ...items[0].evidence, source: { ...items[0].evidence.source, uri: "/etc/passwd" } } } }; + fixtures["negative-secret-field.json"] = { workspace: { ...items[2], evidence: { ...items[2].evidence, source: { ...items[2].evidence.source, access_key: "CANARY-MUST-BE-REJECTED" } } } }; + return fixtures; +} +function curlGet(url, output) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; } +function curlPost(url, output, body) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --request POST --header 'content-type: application/json' --data-binary @${quote(body)} --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; } +function curlPostEmpty(url, output) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --request POST --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; } +function publishCurl(root, id, previousResponse) { + const descriptor = join(root, "requests", `publish-${id}.json`); + const response = join(root, "responses", `publish-${id}.json`); + return `#!/usr/bin/env bash\nset -euo pipefail\nbase_commit=$(node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));console.log(value.head ?? value.revision?.commit ?? "");' ${quote(previousResponse)})\nnode -e 'const fs=require("node:fs");const body=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));body.baseCommit=process.argv[2];fs.writeFileSync(process.argv[1],JSON.stringify(body,null,2)+"\\n");' ${quote(descriptor)} "$base_commit"\ncurl --fail-with-body --silent --show-error --request POST --header 'content-type: application/json' --data-binary @${quote(descriptor)} --output ${quote(response)} --write-out 'HTTP %{http_code}\\n' 'http://${HOST}:${BACKEND_PORT}/workspaces/publish'\n`; } +function renderCommand(repo, root, observation) { + const readResponse = join(root, "responses", "read-p11-filesystem.json"); + const output = join(root, "rendered", `runtime-${observation}.yaml`); + return `#!/usr/bin/env bash\nset -euo pipefail\nread_snapshot=$(node -e 'const fs=require("node:fs");const read=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const path=read.revision.snapshotPath;const manifest=JSON.parse(fs.readFileSync(require("node:path").join(require("node:path").dirname(path),"snapshot.json"),"utf8"));const name=require("node:path").basename(path);console.log(JSON.stringify({snapshot:path,digest:manifest.files[name]}));' ${quote(readResponse)})\nsnapshot=$(node -e 'const value=JSON.parse(process.argv[1]);console.log(value.snapshot)' "$read_snapshot")\ndigest=$(node -e 'const value=JSON.parse(process.argv[1]);console.log(value.digest)' "$read_snapshot")\nnode ${quote(join(repo, "backend", "scripts", "p11-render-snapshot.mjs"))} --ownership ${quote(join(root, "ownership.json"))} --snapshot "$snapshot" --output ${quote(output)} --snapshot-sha256 "$digest"\n`; +} +function guide(root) { + return `# P1.1 manual acceptance guide + +1. Inspect ${join(root, "ownership.json")}, ${join(root, "author", "thoth-workspaces.yaml")}, nested workspace directories, evidence tree, and fixture secret paths without printing secret bytes. +2. Run ./scripts/p11-manual-acceptance.sh serve and confirm only ${HOST}:${BACKEND_PORT} and ${HOST}:${FRONTEND_PORT} are listening for this lab. +3. Run commands/http-01-status.sh and inspect responses/status.json plus GET /workspaces for configuration_required slots. +4. Run the validate and publish scripts once per slot in numeric order. +5. Inspect Git object IDs for thoth-workspaces.yaml, /workspace.yaml, /evidence, and workspace-docs/. +6. Retry create/update/delete and verify refusal plus unchanged object IDs. +7. In ${join(root, "author")}, edit p11-filesystem/workspace.yaml and thoth-workspaces.yaml together, commit, push, then run commands/http-08-pull.sh and verify the API activated curator bytes without rewriting the descriptor. +8. Make an evidence-only commit under p11-filesystem/evidence, push, pull, and inspect the new revision commit with unchanged descriptor blob. +9. In the UI at http://${HOST}:${FRONTEND_PORT}, confirm ready workspaces are read-only and bootstrap-only slots are editable before creation. +10. Export/import only under bootstrap rules. +11. Run commands/render-1.sh and commands/render-2.sh, diff rendered/runtime-1.yaml rendered/runtime-2.yaml, then run tht config check -c on both outputs. +12. Run the negative validate scripts and a bounded secret scan outside fixture-secrets. +13. Run ./scripts/p11-manual-acceptance.sh stop, verify cleanup of both listeners, write VERDICT.md yourself, and run cleanup only when evidence is no longer needed. +`; +} +function ownershipValue(root, repositoryRoot, nonce, extras = {}) { + return { + schemaVersion: 1, + kind: "p11-manual-acceptance", + nonce, + repositoryRoot, + root, + createdAt: nowIso(), + status: "PENDING", + listeners: { + backend: { host: HOST, port: BACKEND_PORT }, + frontend: { host: HOST, port: FRONTEND_PORT }, + }, + resources: [root, join(root, "remote.git"), join(root, "author"), join(root, "fixture-secrets")], + ...extras, + }; +} +export async function readManualOwnership({ repositoryRoot = defaultRepositoryRoot } = {}) { + const repo = realpathSync(repositoryRoot); + const root = fixedManualRoot(repo); + noSymlinkExisting(repo, root); + const rootEntry = await lstat(root); + const ownershipPath = join(root, "ownership.json"); + const digestPath = join(root, OWNERSHIP_DIGEST); + const ownershipEntry = await lstat(ownershipPath); + const digestEntry = await lstat(digestPath); + if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink() || !ownershipEntry.isFile() || ownershipEntry.isSymbolicLink() || !digestEntry.isFile() || digestEntry.isSymbolicLink()) throw new Error("manual ownership is unsafe"); + const ownershipBytes = await readFile(ownershipPath, "utf8"); + const recordedDigest = (await readFile(digestPath, "utf8")).trim(); + if (!HEX64.test(recordedDigest) || recordedDigest !== ownershipDigest(ownershipBytes)) throw new Error("manual ownership digest mismatch"); + const value = JSON.parse(ownershipBytes); + if (value?.schemaVersion !== 1 || value.kind !== "p11-manual-acceptance" || !HEX64.test(value.nonce ?? "") || value.repositoryRoot !== repo || value.root !== root) { + throw new Error("manual ownership identity mismatch"); + } + return value; +} +async function ensureRootAbsent(root) { + try { await lstat(root); throw new Error("manual acceptance root already exists"); } catch (error) { if (error.code !== "ENOENT") throw error; } +} +async function waitForHttp(url, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await new Promise((resolvePromise, reject) => { + const request = http.get(url, (response) => { response.resume(); response.statusCode && response.statusCode < 500 ? resolvePromise() : reject(new Error("not ready")); }); + request.on("error", reject); + }); + return; + } catch { + await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); + } + } + throw new Error(`timed out waiting for ${url}`); +} +function live(pid) { try { process.kill(pid, 0); return true; } catch { return false; } } +async function writeCommands(repo, root) { + const commands = [ + ["http-01-status.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspace-registry/status`, join(root, "responses", "status.json"))], + ["http-02-validate-p11-filesystem.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-filesystem.json"), join(root, "requests", "validate-p11-filesystem.json"))], + ["http-03-validate-p11-http.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-http.json"), join(root, "requests", "validate-p11-http.json"))], + ["http-04-validate-p11-s3.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-s3.json"), join(root, "requests", "validate-p11-s3.json"))], + ["http-05-publish-p11-filesystem.sh", publishCurl(root, "p11-filesystem", join(root, "responses", "status.json"))], + ["http-06-publish-p11-http.sh", publishCurl(root, "p11-http", join(root, "responses", "publish-p11-filesystem.json"))], + ["http-07-publish-p11-s3.sh", publishCurl(root, "p11-s3", join(root, "responses", "publish-p11-http.json"))], + ["http-08-pull.sh", curlPostEmpty(`http://${HOST}:${BACKEND_PORT}/workspace-registry/pull`, join(root, "responses", "pull.json"))], + ["http-09-read-p11-filesystem.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspaces/p11-filesystem`, join(root, "responses", "read-p11-filesystem.json"))], + ["http-10-export-p11-filesystem.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspaces/p11-filesystem/export`, join(root, "exports", "raw", "p11-filesystem.zip"))], + ["http-11-negative-invalid-uri.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "negative-invalid-uri.json"), join(root, "requests", "negative-invalid-uri.json"))], + ["http-12-negative-secret-field.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "negative-secret-field.json"), join(root, "requests", "negative-secret-field.json"))], + ["render-1.sh", renderCommand(repo, root, 1)], + ["render-2.sh", renderCommand(repo, root, 2)], + ]; + for (const [name, body] of commands) { + const path = join(root, "commands", name); + await atomicWrite(path, body, 0o700); + } +} +export async function prepareManual({ repositoryRoot = defaultRepositoryRoot } = {}) { + const repo = realpathSync(repositoryRoot); + const root = fixedManualRoot(repo); + noSymlinkExisting(repo, root); + await ensureRootAbsent(root); + await mkdir(join(repo, ".artifacts", "manual-acceptance"), { recursive: true, mode: 0o700 }); + await mkdir(root, { mode: 0o700 }); + const executables = resolveExecutables(repo); + const nonce = randomBytes(32).toString("hex"); + await writeManualOwnership(root, ownershipValue(root, repo, nonce)); + for (const path of ["fixture-secrets", "requests", "responses", "commands", "rendered", "logs", "exports/raw", "exports/extracted", "installation/registry", "installation/data", "installation/runtime"]) { + await mkdir(join(root, path), { recursive: true, mode: path === "fixture-secrets" ? 0o700 : 0o755 }); + } + const env = buildSafeEnvironment({ ambient: process.env, fixture: { PATH: dirname(executables.gitPath) } }); + await git(executables.gitPath, ["init", "--bare", "--initial-branch=main", join(root, "remote.git")], { cwd: root, env }); + await git(executables.gitPath, ["clone", join(root, "remote.git"), join(root, "author")], { cwd: root, env }); + await git(executables.gitPath, ["config", "user.name", "P1 Fixture Curator"], { cwd: join(root, "author"), env }); + await git(executables.gitPath, ["config", "user.email", "p1-curator@example.invalid"], { cwd: join(root, "author"), env }); + const items = descriptors(); + await atomicWrite(join(root, "author", "thoth-workspaces.yaml"), `${JSON.stringify(catalog(items), null, 2)}\n`, 0o644); + await mkdir(join(root, "author", "p11-filesystem", "evidence", "domain"), { recursive: true }); + await atomicWrite(join(root, "author", "p11-filesystem", "evidence", "guide.md"), "# P1.1 curated Evidence\n", 0o644); + await atomicWrite(join(root, "author", "p11-filesystem", "evidence", "domain", "table.md"), "# Curated table\n", 0o644); + await git(executables.gitPath, ["add", "thoth-workspaces.yaml"], { cwd: join(root, "author"), env }); + await git(executables.gitPath, ["add", "-A", "p11-filesystem/evidence"], { cwd: join(root, "author"), env }); + await git(executables.gitPath, ["commit", "-m", "Bootstrap curated P1 content"], { cwd: join(root, "author"), env }); + await git(executables.gitPath, ["push", "origin", "main"], { cwd: join(root, "author"), env }); + const secrets = { + dwh: join(root, "fixture-secrets", "dwh-password"), + signed: join(root, "fixture-secrets", "evidence-signed-urls.json"), + access: join(root, "fixture-secrets", "evidence-access"), + secret: join(root, "fixture-secrets", "evidence-secret"), + session: join(root, "fixture-secrets", "evidence-session"), + }; + await atomicWrite(secrets.dwh, "manual-dwh-secret", 0o600); + await atomicWrite(secrets.signed, JSON.stringify(["https://evidence.example.test/guide.md?token=manual"]), 0o600); + await atomicWrite(secrets.access, "manual-access", 0o600); + await atomicWrite(secrets.secret, "manual-secret", 0o600); + await atomicWrite(secrets.session, "manual-session", 0o600); + const bindings = {}; + for (const workspace of items) { + const prefix = `THT_WS_${namespace(workspace.workspace.id)}`; + Object.assign(bindings, { + [`${prefix}_DWH_TRANSPORT`]: "postgres_direct", + [`${prefix}_DWH_HOST`]: "dwh.invalid", + [`${prefix}_DWH_PORT`]: "5432", + [`${prefix}_DWH_USER`]: "reader", + [`${prefix}_DWH_PASSWORD_FILE`]: secrets.dwh, + }); + } + Object.assign(bindings, { + THT_WS_P11_HTTP_EVIDENCE_SIGNED_URLS_FILE: secrets.signed, + THT_WS_P11_S3_EVIDENCE_ACCESS_KEY_FILE: secrets.access, + THT_WS_P11_S3_EVIDENCE_SECRET_KEY_FILE: secrets.secret, + THT_WS_P11_S3_EVIDENCE_SESSION_TOKEN_FILE: secrets.session, + }); + await atomicWrite(join(root, "installation", "bindings.env"), `${Object.entries(bindings).map(([key, value]) => `${key}=${value}`).join("\n")}\n`); + await atomicWrite(join(root, "installation", "runtime", "base.yaml"), "{}\n"); + for (const [name, value] of Object.entries(requestFixtures(items))) await atomicWrite(join(root, "requests", name), `${JSON.stringify(value, null, 2)}\n`, 0o600); + await writeCommands(repo, root); + await atomicWrite(join(root, "GUIDE.md"), guide(root), 0o600); + await atomicWrite(join(root, "logs", "backend.log"), "", 0o600); + const current = await readManualOwnership({ repositoryRoot: repo }); + current.status = "PENDING"; + current.requestFixtures = Object.keys(requestFixtures(items)); + current.commandScripts = (await readdir(join(root, "commands"))).sort(); + await writeManualOwnership(root, current); + return root; +} +export async function serveManual({ repositoryRoot = defaultRepositoryRoot } = {}) { + const repo = realpathSync(repositoryRoot); + const root = fixedManualRoot(repo); + const owned = await readManualOwnership({ repositoryRoot: repo }); + if (owned.status === "RUNNING") throw new Error("manual acceptance is already serving"); + await access(join(repo, "backend", "dist", "server.js")); + await access(join(repo, "frontend", "dist", "index.html")); + const executables = resolveExecutables(repo); + const logHandle = await open(join(root, "logs", "backend.log"), fsConstants.O_WRONLY | fsConstants.O_APPEND); + const homeDir = join(root, "installation", "runtime", "home"); + const tmpDir = join(root, "installation", "runtime", "tmp"); + await mkdir(homeDir, { recursive: true, mode: 0o700 }); + await mkdir(tmpDir, { recursive: true, mode: 0o700 }); + const fixtureEnv = { + PATH: `${dirname(executables.gitPath)}:${dirname(executables.pythonPath)}:${dirname(executables.thtPath)}:/usr/bin:/bin`, + HOME: homeDir, + TMPDIR: tmpDir, + HOST, + PORT: String(BACKEND_PORT), + AUTH_MODE: "none", + THT_BIN: executables.thtPath, + THT_HARNESS_DIR: join(repo, "harness"), + THT_DATA_ROOT: join(root, "installation", "data"), + SETTINGS_FILE: join(root, "installation", "data", "settings.json"), + MAINTENANCE_STATE_FILE: join(root, "installation", "data", "maintenance.json"), + THT_WORKSPACE_REGISTRY_ROOT: join(root, "installation", "registry"), + THT_WORKSPACE_GIT_REMOTE: join(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-manual-acceptance", + THT_WORKSPACE_SECRET_ROOTS: join(root, "fixture-secrets"), + THT_HOME: join(root, "installation", "runtime", "tht-home"), + PYTHONDONTWRITEBYTECODE: "1", + PYTHONNOUSERSITE: "1", + }; + const bindingEnv = Object.fromEntries((await readFile(join(root, "installation", "bindings.env"), "utf8")).trim().split(/\n+/).map((line) => line.split(/=(.+)/))); + const env = buildSafeEnvironment({ ambient: process.env, fixture: { ...fixtureEnv, ...bindingEnv } }); + const backend = spawn(process.execPath, [join(repo, "backend", "dist", "server.js")], { cwd: repo, env, stdio: ["ignore", logHandle.fd, logHandle.fd], detached: true }); + const frontend = spawn(executables.pythonPath, ["-m", "http.server", String(FRONTEND_PORT), "--bind", HOST, "--directory", join(repo, "frontend", "dist")], { cwd: repo, env, stdio: ["ignore", "ignore", "ignore"], detached: true }); + backend.unref(); frontend.unref(); + await waitForHttp(`http://${HOST}:${BACKEND_PORT}/health`); + await waitForHttp(`http://${HOST}:${FRONTEND_PORT}/`); + await logHandle.close(); + owned.status = "RUNNING"; + owned.backend = { pid: backend.pid, port: BACKEND_PORT, command: [process.execPath, join(repo, "backend", "dist", "server.js")] }; + owned.frontend = { pid: frontend.pid, port: FRONTEND_PORT, command: [executables.pythonPath, "-m", "http.server", String(FRONTEND_PORT)] }; + await writeManualOwnership(root, owned); + return owned; +} +async function processCommandMatches(pid, expectedCommand) { + if (!Array.isArray(expectedCommand) || expectedCommand.length === 0) return false; + let output; + try { + const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }); + output = stdout.trim(); + } catch { + return false; + } + if (output.length === 0) return false; + // The recorded command is the argv array used to spawn the process; verify every token appears + // in the current command line in order, so a reused PID with unrelated command is refused. + let cursor = 0; + for (const token of expectedCommand) { + if (token.length === 0) continue; + const index = output.indexOf(token, cursor); + if (index < 0) return false; + cursor = index + token.length; + } + return true; +} + +export async function stopManual({ repositoryRoot = defaultRepositoryRoot } = {}) { + const repo = realpathSync(repositoryRoot); + const root = fixedManualRoot(repo); + const owned = await readManualOwnership({ repositoryRoot: repo }); + if (owned.status !== "RUNNING" || !owned.backend?.pid || !owned.frontend?.pid) throw new Error("manual acceptance is not running"); + for (const pid of [owned.backend.pid, owned.frontend.pid]) { + try { process.kill(-pid, "SIGTERM"); } catch (error) { if (error?.code !== "ESRCH") throw error; } + } + const deadline = Date.now() + 15_000; + while (Date.now() < deadline && (live(owned.backend.pid) || live(owned.frontend.pid))) await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); + owned.status = "STOPPED"; + await writeManualOwnership(root, owned); + return owned; +} + +export async function cleanupManual({ repositoryRoot = defaultRepositoryRoot } = {}) { + const repo = realpathSync(repositoryRoot); + const root = fixedManualRoot(repo); + const owned = await readManualOwnership({ repositoryRoot: repo }); + if (owned.status === "RUNNING") throw new Error("manual acceptance is still live"); + if (owned.backend?.pid && live(owned.backend.pid)) throw new Error("backend process is still live"); + if (owned.frontend?.pid && live(owned.frontend.pid)) throw new Error("frontend process is still live"); + const parent = dirname(root); + const tombstone = join(parent, `.deleting-p11-${owned.nonce.slice(0, 16)}`); + await rename(root, tombstone); + await rm(tombstone, { recursive: true, force: false }); +} +export async function main(argv = process.argv.slice(2)) { + if (argv.length !== 1 || !["prepare", "serve", "stop", "cleanup"].includes(argv[0])) throw new Error("usage: p11-manual-acceptance.mjs prepare|serve|stop|cleanup"); + switch (argv[0]) { + case "prepare": await prepareManual(); break; + case "serve": await serveManual(); break; + case "stop": await stopManual(); break; + case "cleanup": await cleanupManual(); break; + } +} +if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) { + try { await main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } +} diff --git a/backend/scripts/p11-manual-acceptance.test.mjs b/backend/scripts/p11-manual-acceptance.test.mjs new file mode 100644 index 00000000..12d5fe22 --- /dev/null +++ b/backend/scripts/p11-manual-acceptance.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { access, lstat, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +import { + cleanupManual, + prepareManual, + readManualOwnership, + serveManual, + stopManual, +} from "./p11-manual-acceptance.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const fixedRoot = join(repoRoot, ".artifacts", "manual-acceptance", "p11"); + +async function safeCleanup() { + try { + const owned = await readManualOwnership({ repositoryRoot: repoRoot }); + if (owned.status === "RUNNING") await stopManual({ repositoryRoot: repoRoot }).catch(() => {}); + await cleanupManual({ repositoryRoot: repoRoot }).catch(() => {}); + } catch { + await rm(fixedRoot, { recursive: true, force: true }).catch(() => {}); + } +} + +test.beforeEach(async () => { + await safeCleanup(); +}); + +test.afterEach(async () => { + await safeCleanup(); +}); + +test("prepare creates an independent pending lab without verdict", { concurrency: false }, async () => { + const root = await prepareManual({ repositoryRoot: repoRoot }); + assert.equal(root, fixedRoot); + const owned = await readManualOwnership({ repositoryRoot: repoRoot }); + assert.equal(owned.kind, "p11-manual-acceptance"); + assert.equal(owned.status, "PENDING"); + await access(join(root, "GUIDE.md")); + await access(join(root, "author", "thoth-workspaces.yaml")); + await access(join(root, "author", "p11-filesystem", "evidence", "guide.md")); + await access(join(root, "requests", "validate-p11-filesystem.json")); + await access(join(root, "commands", "http-01-status.sh")); + await access(join(root, "commands", "render-1.sh")); + await assert.rejects(access(join(root, "VERDICT.md"))); + const guide = await readFile(join(root, "GUIDE.md"), "utf8"); + assert.match(guide, /VERDICT\.md/); + assert.match(guide, /read-only/); +}); + +test("serve, stop, and cleanup manage the owned backend and frontend listeners", { concurrency: false }, async () => { + await prepareManual({ repositoryRoot: repoRoot }); + const running = await serveManual({ repositoryRoot: repoRoot }); + assert.equal(running.status, "RUNNING"); + assert.equal(typeof running.backend.pid, "number"); + assert.equal(typeof running.frontend.pid, "number"); + const status = await fetch("http://127.0.0.1:8791/workspace-registry/status"); + assert.equal(status.status, 200); + const frontend = await fetch("http://127.0.0.1:8792/"); + assert.equal(frontend.status, 200); + await assert.rejects(cleanupManual({ repositoryRoot: repoRoot }), /still live/); + const stopped = await stopManual({ repositoryRoot: repoRoot }); + assert.equal(stopped.status, "STOPPED"); + await cleanupManual({ repositoryRoot: repoRoot }); + await assert.rejects(lstat(fixedRoot)); +}); + +test("stop fails closed when ownership is tampered", { concurrency: false }, async () => { + await prepareManual({ repositoryRoot: repoRoot }); + const running = await serveManual({ repositoryRoot: repoRoot }); + const ownershipPath = join(fixedRoot, "ownership.json"); + const digestPath = join(fixedRoot, "ownership.sha256"); + const original = JSON.parse(await readFile(ownershipPath, "utf8")); + const tampered = { ...original, backend: { ...original.backend, pid: original.backend.pid + 1 } }; + await rm(ownershipPath); + await readFile(join(fixedRoot, "logs", "backend.log")); + await import("node:fs/promises").then(({ writeFile }) => writeFile(ownershipPath, `${JSON.stringify(tampered, null, 2)} +`)); + await assert.rejects(stopManual({ repositoryRoot: repoRoot }), /manual ownership digest mismatch/); + const restored = `${JSON.stringify(running, null, 2)} +`; + const restoredDigest = `${createHash("sha256").update(restored).digest("hex")} +`; + await import("node:fs/promises").then(({ writeFile }) => Promise.all([writeFile(ownershipPath, restored), writeFile(digestPath, restoredDigest)])); + await stopManual({ repositoryRoot: repoRoot }); +}); diff --git a/backend/scripts/p11-render-snapshot.mjs b/backend/scripts/p11-render-snapshot.mjs new file mode 100644 index 00000000..16b32e43 --- /dev/null +++ b/backend/scripts/p11-render-snapshot.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants, lstatSync, realpathSync } from "node:fs"; +import { lstat, mkdir, open, readFile, realpath } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { ThtRunner } from "../dist/tht/tht-runner.js"; + +const modulePath = fileURLToPath(import.meta.url); +const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../..")); +const HEX40 = /^[0-9a-f]{40}$/; +const HEX64 = /^[0-9a-f]{64}$/; + +function fixedRoot(repositoryRoot) { return join(realpathSync(repositoryRoot), ".artifacts", "manual-acceptance", "p11"); } +function below(parent, child) { const rel = relative(parent, child); return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel); } +function assertNoSymlinks(root, path, allowMissingLeaf = false) { + const rel = relative(root, path); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("path is outside owned root"); + let cursor = root; + const parts = rel.split(sep).filter(Boolean); + for (const [index, part] of parts.entries()) { + cursor = join(cursor, part); + try { if (lstatSync(cursor).isSymbolicLink()) throw new Error("owned path contains a symlink"); } + catch (error) { + if (allowMissingLeaf && error?.code === "ENOENT" && index === parts.length - 1) return; + throw error; + } + } +} +async function ownership(repositoryRoot, ownershipPath) { + const root = fixedRoot(repositoryRoot); + const expected = join(root, "ownership.json"); + if (resolve(ownershipPath) !== expected) throw new Error("ownership path is not owned"); + const rootEntry = await lstat(root); const ownershipEntry = await lstat(expected); + if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink() || !ownershipEntry.isFile() || ownershipEntry.isSymbolicLink()) throw new Error("ownership is unsafe"); + if (await realpath(root) !== root) throw new Error("ownership root is not canonical"); + let value; try { value = JSON.parse(await readFile(expected, "utf8")); } catch { throw new Error("ownership is malformed"); } + if (value?.schemaVersion !== 1 || value.kind !== "p11-manual-acceptance" || !HEX64.test(value.nonce ?? "") || value.root !== root || value.repositoryRoot !== realpathSync(repositoryRoot)) { + throw new Error("ownership identity mismatch"); + } + return { root, value }; +} +const ANCHORED_PUBLISH_SOURCE=String.raw`import os,secrets,stat,sys +parent,name,expected_dev,expected_ino=sys.argv[1:] +pfd=fd=None;stage=".render-stage-"+secrets.token_hex(16);published=False +def fail(): raise RuntimeError("anchored publication refused") +try: + pfd=os.open(parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW) + identity=os.fstat(pfd) + if (identity.st_dev,identity.st_ino)!=(int(expected_dev),int(expected_ino)): fail() + try: os.stat(name,dir_fd=pfd,follow_symlinks=False); fail() + except FileNotFoundError: pass + fd=os.open(stage,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=pfd) + data=sys.stdin.buffer.read(33554433) + if len(data)>33554432: fail() + view=memoryview(data) + while view: + written=os.write(fd,view) + if written<=0: fail() + view=view[written:] + os.fsync(fd);os.close(fd);fd=None;os.rename(stage,name,src_dir_fd=pfd,dst_dir_fd=pfd);published=True;os.fsync(pfd) + current=os.stat(parent,follow_symlinks=False) + if not stat.S_ISDIR(current.st_mode) or (current.st_dev,current.st_ino)!=(identity.st_dev,identity.st_ino): fail() +except Exception: + if published: + try: os.unlink(name,dir_fd=pfd);os.fsync(pfd) + except Exception: pass + print("anchored output publication refused (details redacted)",file=sys.stderr);raise SystemExit(1) +finally: + if fd is not None: os.close(fd) + if pfd is not None: + try: os.unlink(stage,dir_fd=pfd) + except FileNotFoundError: pass + os.close(pfd) +`; +async function atomicCopy(source, output) { + const parent = dirname(output); + const entry = await lstat(parent); + if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("rendered parent identity is unsafe"); + const bytes = await readFile(source); + const result = spawnSync("python3", ["-c", ANCHORED_PUBLISH_SOURCE, parent, basename(output), String(entry.dev), String(entry.ino)], { input: bytes, encoding: "utf8", maxBuffer: 1024 * 1024 }); + if (result.error || result.status !== 0) throw new Error("anchored output publication refused; rendered parent identity changed or output is unsafe"); +} +function sameEntry(actual, expected) { return actual.dev === expected.dev && actual.ino === expected.ino; } +async function readBounded(path, max, label) { + let handle; + try { + handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = await handle.stat(); const pathEntry = await lstat(path); + if (!before.isFile() || pathEntry.isSymbolicLink() || !pathEntry.isFile() || !sameEntry(before, pathEntry)) throw new Error(`${label} is unsafe`); + if (before.size < 1 || before.size > max) throw new Error(`${label} is unbounded`); + const bytes = Buffer.alloc(before.size); let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset); + if (bytesRead < 1) throw new Error(`${label} changed while reading`); + offset += bytesRead; + } + const after = await handle.stat(); + if (!sameEntry(before, after) || after.size !== before.size) throw new Error(`${label} changed while reading`); + return bytes; + } finally { + if (handle) await handle.close().catch(() => {}); + } +} +async function readSnapshotManifest(root, manifestPath, commit, yamlName, expectedDigest) { + let manifestEntry; + try { assertNoSymlinks(root, manifestPath); manifestEntry = await lstat(manifestPath); } + catch (error) { if (error?.code === "ENOENT") throw new Error("snapshot manifest is missing or unbounded"); throw error; } + if (!manifestEntry.isFile() || manifestEntry.isSymbolicLink() || await realpath(manifestPath) !== manifestPath) throw new Error("snapshot manifest is unsafe"); + const bytes = await readBounded(manifestPath, 1024 * 1024, "snapshot manifest"); + let manifest; try { manifest = JSON.parse(bytes.toString("utf8")); } catch { throw new Error("snapshot manifest is malformed"); } + const files = manifest?.files; + if (manifest?.head !== commit || !files || typeof files !== "object" || Array.isArray(files)) throw new Error("snapshot manifest identity is unsafe"); + if (!HEX64.test(files[yamlName] ?? "") || files[yamlName] !== expectedDigest) throw new Error("snapshot manifest digest is unsafe"); + return manifest; +} + +export async function renderOwnedSnapshot({ repositoryRoot = defaultRepositoryRoot, ownershipPath, snapshotPath, outputPath, snapshotSha256, env = process.env, beforePublish }) { + const repo = realpathSync(repositoryRoot); + const { root } = await ownership(repo, resolve(repo, ownershipPath)); + const snapshot = resolve(repo, snapshotPath); + const output = resolve(repo, outputPath); + const snapshotsRoot = join(root, "installation", "registry", "snapshots"); + const renderedRoot = join(root, "rendered"); + if (!isAbsolute(snapshotPath) || !below(snapshotsRoot, snapshot)) throw new Error("snapshot is not an owned absolute path"); + const match = /^([0-9a-f]{40})\/([a-z][a-z0-9-]{2,62})\.yaml$/.exec(relative(snapshotsRoot, snapshot).split(sep).join("/")); + if (!match || !HEX40.test(match[1])) throw new Error("snapshot is not commit addressed"); + if (!HEX64.test(snapshotSha256 ?? "")) throw new Error("snapshot digest identity is unsafe"); + assertNoSymlinks(root, snapshot); + const snapshotEntry = await lstat(snapshot); + if (!snapshotEntry.isFile() || snapshotEntry.isSymbolicLink() || await realpath(snapshot) !== snapshot) throw new Error("snapshot is unsafe"); + const yamlName = `${match[2]}.yaml`; + await readSnapshotManifest(root, join(snapshotsRoot, match[1], "snapshot.json"), match[1], yamlName, snapshotSha256); + const snapshotBytes = await readBounded(snapshot, 1024 * 1024, "snapshot"); + if (createHash("sha256").update(snapshotBytes).digest("hex") !== snapshotSha256) throw new Error("snapshot bytes changed"); + if (!below(renderedRoot, output) || dirname(output) !== renderedRoot || !output.endsWith(".yaml")) throw new Error("output is not an owned rendered path"); + assertNoSymlinks(root, dirname(output)); + try { if ((await lstat(output)).isSymbolicLink()) throw new Error("output is unsafe"); } catch (error) { if (error.code !== "ENOENT") throw error; } + await mkdir(join(snapshotsRoot, "runtime"), { recursive: true, mode: 0o700 }); + const bindingEnv = Object.fromEntries((await readFile(join(root, "installation", "bindings.env"), "utf8")).trim().split(/\n+/).filter(Boolean).map((line) => line.split(/=(.+)/))); + const effectiveEnv = { ...bindingEnv, ...env }; + const prior = {}; + for (const [key, value] of Object.entries(effectiveEnv)) { prior[key] = process.env[key]; if (value === undefined) delete process.env[key]; else process.env[key] = value; } + const runner = new ThtRunner({ + thtBin: join(repo, "harness", ".venv", "bin", "tht"), + harnessDir: join(repo, "harness"), + configPath: join(root, "installation", "runtime", "base.yaml"), + dataRoot: join(root, "installation", "data"), + runtimeSnapshotRoot: join(snapshotsRoot, "runtime"), + secretRoots: [join(root, "fixture-secrets")], + semanticRuntime: { internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024 }, + }); + let lease; + try { + lease = runner.acquireWorkspaceRuntime(snapshot); + const verifySnapshot = async () => { + const current = await readBounded(snapshot, 1024 * 1024, "snapshot"); + if (createHash("sha256").update(current).digest("hex") !== snapshotSha256) throw new Error("snapshot content changed during rendering"); + }; + await verifySnapshot(); + if (beforePublish) await beforePublish({ output, renderedRoot }); + await verifySnapshot(); + await atomicCopy(lease.path, output); + } finally { + if (lease) lease.release(); + for (const key of Object.keys(env)) { if (prior[key] === undefined) delete process.env[key]; else process.env[key] = prior[key]; } + } + return output; +} +function parseArgs(argv) { + if (argv.length !== 8) throw new Error("usage: p11-render-snapshot.mjs --ownership PATH --snapshot ABSOLUTE_PATH --output PATH --snapshot-sha256 HEX"); + const result = {}; + for (let index = 0; index < argv.length; index += 2) { + if (!["--ownership", "--snapshot", "--output", "--snapshot-sha256"].includes(argv[index]) || result[argv[index]]) throw new Error("invalid arguments"); + result[argv[index]] = argv[index + 1]; + } + return result; +} +if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) { + try { + const args = parseArgs(process.argv.slice(2)); + await renderOwnedSnapshot({ ownershipPath: args["--ownership"], snapshotPath: args["--snapshot"], outputPath: args["--output"], snapshotSha256: args["--snapshot-sha256"] }); + console.log(`rendered ${resolve(args["--output"])}`); + } catch (error) { + console.error(`p11 render refused: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/backend/scripts/p11-render-snapshot.test.mjs b/backend/scripts/p11-render-snapshot.test.mjs new file mode 100644 index 00000000..efb3c8cc --- /dev/null +++ b/backend/scripts/p11-render-snapshot.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { access, readFile, rm } from "node:fs/promises"; +import { join, dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { renderOwnedSnapshot } from "./p11-render-snapshot.mjs"; +import { cleanupManual, prepareManual, readManualOwnership, serveManual, stopManual } from "./p11-manual-acceptance.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const fixedRoot = join(repoRoot, ".artifacts", "manual-acceptance", "p11"); + +async function safeCleanup() { + try { + const owned = await readManualOwnership({ repositoryRoot: repoRoot }); + if (owned.status === "RUNNING") await stopManual({ repositoryRoot: repoRoot }).catch(() => {}); + await cleanupManual({ repositoryRoot: repoRoot }).catch(() => {}); + } catch { + await rm(fixedRoot, { recursive: true, force: true }).catch(() => {}); + } +} + +test.beforeEach(async () => { await safeCleanup(); }); +test.afterEach(async () => { await safeCleanup(); }); + +test("renderer rejects unowned ownership and out-of-root snapshot paths", { concurrency: false }, async () => { + await prepareManual({ repositoryRoot: repoRoot }); + const outside = join(repoRoot, "outside.yaml"); + await import("node:fs/promises").then(({ writeFile }) => writeFile(outside, "x")); + await assert.rejects(renderOwnedSnapshot({ + repositoryRoot: repoRoot, + ownershipPath: join(repoRoot, "ownership.json"), + snapshotPath: outside, + outputPath: join(fixedRoot, "rendered", "bad.yaml"), + snapshotSha256: "a".repeat(64), + })); + await rm(outside, { force: true }); +}); + +test("renderer copies an owned runtime lease deterministically", { concurrency: false }, async () => { + await prepareManual({ repositoryRoot: repoRoot }); + await serveManual({ repositoryRoot: repoRoot }); + const validateRequest = JSON.parse(await readFile(join(fixedRoot, "requests", "validate-p11-filesystem.json"), "utf8")); + const status = await fetch("http://127.0.0.1:8791/workspace-registry/status"); + const statusBody = await status.json(); + const publish = await fetch("http://127.0.0.1:8791/workspaces/publish", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "create", workspace: validateRequest.workspace, baseCommit: statusBody.head }), + }); + assert.equal(publish.status, 200); + const readResponse = await fetch("http://127.0.0.1:8791/workspaces/p11-filesystem"); + const readBody = await readResponse.json(); + const snapshotPath = readBody.revision.snapshotPath; + const manifest = JSON.parse(await readFile(join(dirname(snapshotPath), "snapshot.json"), "utf8")); + const digest = manifest.files["p11-filesystem.yaml"]; + const one = join(fixedRoot, "rendered", "one.yaml"); + const two = join(fixedRoot, "rendered", "two.yaml"); + await renderOwnedSnapshot({ repositoryRoot: repoRoot, ownershipPath: join(fixedRoot, "ownership.json"), snapshotPath, outputPath: one, snapshotSha256: digest }); + await renderOwnedSnapshot({ repositoryRoot: repoRoot, ownershipPath: join(fixedRoot, "ownership.json"), snapshotPath, outputPath: two, snapshotSha256: digest }); + assert.equal(await readFile(one, "utf8"), await readFile(two, "utf8")); + await access(one); + await access(two); +}); diff --git a/docs/testing/p11-manual-acceptance.md b/docs/testing/p11-manual-acceptance.md new file mode 100644 index 00000000..1f860ba4 --- /dev/null +++ b/docs/testing/p11-manual-acceptance.md @@ -0,0 +1,89 @@ +# P1.1 manual acceptance + +This walkthrough is the separate human gate for the P1.1 workspace-directory registry. +It is independent from both `.artifacts/p1-integration/**` and `.artifacts/p11-integration/**`. +The helper prepares and serves the lab, but the reviewer performs the registry, Git, UI, export, +render, `tht`, refusal, secret-scan, and cleanup checks and records the verdict. + +## Prerequisites + +- clean repository checkout with the P1.1 implementation present; +- `node`, `npm`, `git`, `curl`, and `python3` available; +- built production assets: + +```bash +npm --prefix backend run build +npm --prefix frontend run build +``` + +- executable harness CLI at `harness/.venv/bin/tht`; +- free loopback ports `127.0.0.1:8791` and `127.0.0.1:8792`. + +## Lifecycle commands + +Run from the repository root: + +```bash +./scripts/p11-manual-acceptance.sh prepare +./scripts/p11-manual-acceptance.sh serve +./scripts/p11-manual-acceptance.sh stop +./scripts/p11-manual-acceptance.sh cleanup +``` + +The fixed lab root is: + +```text +.artifacts/manual-acceptance/p11/ +``` + +Expected lifecycle behavior: + +- `prepare` creates the fixed root, ownership record, bare remote, curator clone, root catalog, + nested filesystem evidence, fixture secrets, request fixtures, generated command scripts, and + `GUIDE.md`; it leaves status `PENDING`, performs no reviewer publish operation, and never writes + `VERDICT.md`. +- `serve` starts the production backend on `127.0.0.1:8791` and a production-built frontend preview + on `127.0.0.1:8792`, recording exact ownership for both. +- `stop` refuses foreign or partial ownership and stops only the two owned loopback processes. +- `cleanup` refuses live state and removes only `.artifacts/manual-acceptance/p11/`. + +## Reviewer workflow + +After `prepare`, open the generated `.artifacts/manual-acceptance/p11/GUIDE.md` and personally: + +1. inspect the catalog, nested descriptor/evidence layout, ownership, and secret-path bindings; +2. serve both surfaces and verify the owned listeners; +3. list `configuration_required` slots; +4. validate and bootstrap-create descriptors exactly once; +5. inspect catalog/descriptor/evidence/docs Git object IDs; +6. retry create/update/delete and verify refusal plus unchanged object IDs; +7. make a curator descriptor+catalog edit, push, pull, and verify the API did not rewrite curator bytes; +8. make an evidence-only commit and inspect the new revision identity; +9. verify the live UI shows read-only existing workspaces and bootstrap-only editing for missing slots; +10. exercise export/import under bootstrap-only rules; +11. render twice, diff the results, and run `tht config check`; +12. run negative catalog/path/secret cases and a bounded secret scan; +13. stop the lab, verify both listeners are gone, write `VERDICT.md`, and only then cleanup if desired. + +## Expected outcomes + +- `prepare` produces a fresh P1.1-only lab and leaves no `VERDICT.md`. +- `serve` exposes only the owned loopback backend and frontend preview. +- positive API operations succeed once; curator-owned follow-up mutations are refused safely; +- curator Git changes become active only after pull; +- renders are deterministic; `tht config check -c ` succeeds; +- secret scans find no canaries outside the fixture-secret boundary; +- after `stop`, nothing remains listening on `127.0.0.1:8791` or `127.0.0.1:8792`. + +## Verdict format + +The reviewer creates `VERDICT.md` manually. Include: + +- reviewer identity; +- UTC timestamp; +- result for each checklist step; +- observations and failure evidence; +- exactly one final line: `manual acceptance: PASS` or `manual acceptance: FAIL`. + +Passing `bash scripts/test-p11-manual-acceptance.sh` proves only the tooling/lifecycle guards. It +does not perform or approve manual acceptance. diff --git a/scripts/p11-acceptance.sh b/scripts/p11-acceptance.sh new file mode 100755 index 00000000..44e5e902 --- /dev/null +++ b/scripts/p11-acceptance.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env -S -i PATH=/usr/bin:/bin /bin/bash +set -euo pipefail +script_path=${BASH_SOURCE[0]} +script_dir=${script_path%/*} +[[ "$script_dir" != "$script_path" ]] || script_dir=. +repo_root="$(cd -P -- "$script_dir/.." && pwd)" +if [[ $# -lt 1 || "$1" != "integration" || $# -gt 2 || ( $# -eq 2 && "$2" != "--keep" ) ]]; then + printf 'usage: %s integration [--keep]\n' "$0" >&2 + exit 2 +fi + +canonical_file() { + local path=$1 target parent leaf + [[ "$path" = /* ]] || return 1 + while [[ -L "$path" ]]; do + target=$(/usr/bin/readlink "$path") || return 1 + if [[ "$target" = /* ]]; then path=$target; else path="${path%/*}/$target"; fi + done + parent=${path%/*}; leaf=${path##*/} + parent=$(cd -P -- "$parent" && pwd) || return 1 + printf '%s/%s\n' "$parent" "$leaf" +} + +node_path= npm_path= toolchain_prefix= +for pair in \ + "/usr/bin/node|/usr/bin/npm|/usr" \ + "/opt/homebrew/bin/node|/opt/homebrew/bin/npm|/opt/homebrew" \ + "/usr/local/bin/node|/usr/local/bin/npm|/usr/local"; do + node_candidate=${pair%%|*}; remainder=${pair#*|}; npm_candidate=${remainder%%|*}; prefix=${remainder##*|} + [[ -e "$node_candidate" && -e "$npm_candidate" ]] || continue + resolved_node=$(canonical_file "$node_candidate") || continue + resolved_npm=$(canonical_file "$npm_candidate") || continue + [[ -f "$resolved_node" && ! -L "$resolved_node" && -x "$resolved_node" ]] || continue + [[ -f "$resolved_npm" && ! -L "$resolved_npm" ]] || continue + [[ "${resolved_npm##*/}" = "npm-cli.js" ]] || continue + node_path=$resolved_node; npm_path=$resolved_npm; toolchain_prefix=$prefix + break +done +[[ -n "$node_path" && -n "$npm_path" && -n "$toolchain_prefix" ]] || { + printf 'trusted fixed Node/npm toolchain is unavailable\n' >&2 + exit 127 +} + +wrapper_root=$(/usr/bin/mktemp -d /tmp/thoth-p11-wrapper.XXXXXXXX) +trap '/bin/rm -rf -- "$wrapper_root"' EXIT HUP INT TERM +/bin/mkdir -m 700 "$wrapper_root/home" "$wrapper_root/tmp" +owned_path="${node_path%/*}:/usr/bin:/bin" +build_env=(/usr/bin/env -i "PATH=$owned_path" "HOME=$wrapper_root/home" "TMPDIR=$wrapper_root/tmp") +/bin/rm -rf -- "$repo_root/backend/dist" +"${build_env[@]}" "$node_path" "$npm_path" --prefix "$repo_root/backend" run build + +safe_env=(/usr/bin/env -i "PATH=$owned_path" "HOME=$wrapper_root/home" "TMPDIR=$wrapper_root/tmp" + "THT_BIN=$repo_root/harness/.venv/bin/tht" "P11_ACCEPTANCE_NODE_PATH=$node_path" "P11_ACCEPTANCE_NPM_PATH=$npm_path") +set +e +"${safe_env[@]}" "$node_path" "$repo_root/backend/scripts/p11-acceptance.mjs" "$@" +status=$? +set -e +exit "$status" diff --git a/scripts/p11-manual-acceptance.sh b/scripts/p11-manual-acceptance.sh new file mode 100755 index 00000000..0e717bc9 --- /dev/null +++ b/scripts/p11-manual-acceptance.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +if [[ $# -ne 1 || ! "$1" =~ ^(prepare|serve|stop|cleanup)$ ]]; then + printf 'usage: %s prepare|serve|stop|cleanup\n' "$0" >&2 + exit 2 +fi +exec node "$repo_root/backend/scripts/p11-manual-acceptance.mjs" "$1" diff --git a/scripts/test-p11-acceptance.sh b/scripts/test-p11-acceptance.sh new file mode 100755 index 00000000..5ba03c7b --- /dev/null +++ b/scripts/test-p11-acceptance.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +bash -n "$repo_root/scripts/p11-acceptance.sh" "$repo_root/scripts/test-p11-acceptance.sh" +node --check "$repo_root/backend/scripts/p11-acceptance.mjs" +node --check "$repo_root/backend/scripts/p11-acceptance.test.mjs" +npm --prefix "$repo_root/backend" run build +node --test "$repo_root/backend/scripts/p11-acceptance.test.mjs" diff --git a/scripts/test-p11-manual-acceptance.sh b/scripts/test-p11-manual-acceptance.sh new file mode 100755 index 00000000..bddb7e97 --- /dev/null +++ b/scripts/test-p11-manual-acceptance.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +bash -n "$repo_root/scripts/p11-manual-acceptance.sh" "$repo_root/scripts/test-p11-manual-acceptance.sh" +node --check "$repo_root/backend/scripts/p11-manual-acceptance.mjs" +node --check "$repo_root/backend/scripts/p11-render-snapshot.mjs" +node --check "$repo_root/backend/scripts/p11-manual-acceptance.test.mjs" +node --check "$repo_root/backend/scripts/p11-render-snapshot.test.mjs" +npm --prefix "$repo_root/backend" run build +npm --prefix "$repo_root/frontend" run build +node --test "$repo_root/backend/scripts/p11-manual-acceptance.test.mjs" +node --test "$repo_root/backend/scripts/p11-render-snapshot.test.mjs"