test: add independent P1.1 acceptance and manual tooling

This commit is contained in:
2026-08-11 16:04:44 +02:00
parent 930335a804
commit a22d232aa2
11 changed files with 1923 additions and 0 deletions
+886
View File
@@ -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;
}
}
+113
View File
@@ -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/);
});
+404
View File
@@ -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, <id>/workspace.yaml, <id>/evidence, and workspace-docs/<id>.
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; }
}
@@ -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 });
});
+190
View File
@@ -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;
}
}
@@ -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);
});
+89
View File
@@ -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 <file>` 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.
+58
View File
@@ -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"
+8
View File
@@ -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"
+8
View File
@@ -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"
+12
View File
@@ -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"