test: add independent P1.1 acceptance and manual tooling
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
Executable
+58
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+12
@@ -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"
|
||||
Reference in New Issue
Block a user