diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 33e661ea..cd3a04f4 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -3,6 +3,12 @@ > Starting-point snapshot for new sessions. Last updated: 2026-08-08 (final verification). > Point a fresh session here ("read PROJECT_STATE.md") before substantial work. +## P1 configuration-process automated integration — PASS 2026-08-09 + +- Retained evidence: `.artifacts/p1-integration/p1-846a4d90b815a382b916d01d354fa8cd/report.md` +- automated integration: PASS +- manual acceptance: PENDING + ## Internal Qdrant + Ollama semantic infrastructure — LIVE 2026-08-08 - **Compose topology.** The mandatory application stack is `frontend`, `core`, `qdrant`, diff --git a/backend/scripts/p1-acceptance.mjs b/backend/scripts/p1-acceptance.mjs new file mode 100755 index 00000000..04612e06 --- /dev/null +++ b/backend/scripts/p1-acceptance.mjs @@ -0,0 +1,697 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { + closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, openSync, realpathSync, +} from "node:fs"; +import { + access, chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, symlink, writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { + basename, dirname, isAbsolute, join, relative, resolve, sep, +} from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const RUN_ID = /^p1-[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 SAFE_RELATIVE = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\\)[A-Za-z0-9._/-]+$/; +const COMMAND = /^[A-Za-z0-9._+-]+$/; +const CHECK_IDS = [ + "preflight", "clean_state", "ownership", "local_git_bootstrap", + "http_validate_publish_pull_read_export", "same_revision_git_objects", + "content_only_revision", "snapshot_and_docs", "runtime_render_determinism", + "tht_config_check", "negative_schema_cases", "negative_context_case", + "no_p1_scope_artifacts", "secret_scan", "cleanup_confinement", +]; +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", +]; +const ZIP_FILES = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"]; +const MAX_OUTPUT = 1024 * 1024; +const modulePath = fileURLToPath(import.meta.url); +const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../..")); + +function nowIso() { return new Date().toISOString(); } +function sha256(value) { return createHash("sha256").update(value).digest("hex"); } +export 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) { + return join(canonicalRoot(repositoryRoot), ".artifacts", "p1-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(16).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 }); + throw error; + } +} +function ownership(run, listener = run.listener) { + return { + schemaVersion: 1, runId: run.runId, runNonce: run.nonce, root: run.root, + repositoryRoot: run.repositoryRoot, startedAt: run.startedAt, pid: run.pid, + listener, + resources: [run.root, { kind: "fastify", host: "127.0.0.1", requestedPort: 0, pid: run.pid }], + }; +} +async function writeOwnership(run, listener = run.listener) { + run.listener = listener; + await atomicWrite(join(run.root, "ownership.json"), `${JSON.stringify(ownership(run), null, 2)}\n`); +} +export async function createOwnedRun({ repositoryRoot, 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; }); + validateNoSymlinkAncestors(repo, base); + const id = runId ?? `p1-${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, + listener: { kind: "fastify", host: "127.0.0.1", requestedPort: 0, pid: pid ?? process.pid, state: "not_started" }, + }; + 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 expected = ownership(run, value.listener); + if (value.schemaVersion !== 1 || value.runId !== run.runId || value.runNonce !== expectedNonce + || value.root !== run.root || value.repositoryRoot !== run.repositoryRoot || value.pid !== process.pid + || !ISO_UTC.test(value.startedAt ?? "") || !value.listener || value.listener.kind !== "fastify" + || value.listener.host !== "127.0.0.1" || value.listener.requestedPort !== 0 || value.listener.pid !== process.pid + || JSON.stringify(value.resources) !== JSON.stringify(expected.resources)) throw new Error("ownership identity mismatch"); + return value; +} +export async function readAndValidateOwnership({ repositoryRoot, 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"); + if (await realpath(lexical) !== lexical) throw new Error("owned run root is not canonical"); + 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, listener: value.listener, + }, expectedNonce); +} +export async function cleanupOwnedRun({ repositoryRoot, 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 }); +} +export async function finalizeOwnedRun({ run, success, keep }) { + if (!success || keep) return false; + await cleanupOwnedRun({ repositoryRoot: run.repositoryRoot, runRoot: run.root, expectedNonce: run.nonce }); + return true; +} + +export async function runCommand(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) throw new Error("command requires an options object"); + const allowed = new Set(["executable", "argv", "cwd", "env", "timeoutMs", "stdin", "maxOutputBytes"]); + for (const key of Object.keys(options)) if (!allowed.has(key)) throw new Error(`unsupported command option ${key}`); + const { executable, argv, cwd, env, timeoutMs = 30_000, stdin, maxOutputBytes = MAX_OUTPUT } = options; + if (typeof executable !== "string" || executable.length === 0 || /[;&|`$><\n\r]/.test(executable)) throw new Error("command executable is invalid"); + if (!Array.isArray(argv) || argv.some((value) => typeof value !== "string")) throw new Error("command argv must be a string array"); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) throw new Error("command timeout is invalid"); + return await new Promise((resolvePromise, reject) => { + const child = execFile(executable, argv, { cwd, env, timeout: timeoutMs, maxBuffer: maxOutputBytes, encoding: "utf8" }, (error, stdout, stderr) => { + const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0; + const result = { code, stdout: stdout ?? "", stderr: stderr ?? "" }; + if (error) Object.assign(error, { result }); + error ? reject(error) : resolvePromise(result); + }); + if (stdin !== undefined) { child.stdin.end(stdin); } + }); +} +async function git(argv, options = {}) { return await runCommand({ executable: "git", argv, ...options }); } +async function tht(executable, argv, options = {}) { return await runCommand({ executable, argv, ...options }); } +function safeArtifactPath(path) { + if (typeof path !== "string" || !SAFE_RELATIVE.test(path) || path.startsWith(".") || path.includes("//")) throw new Error("unsafe artifact path"); + return path; +} +async function fileArtifact(runRoot, path) { + safeArtifactPath(path); + return { path, sha256: sha256(await readFile(join(runRoot, path))) }; +} +function forbiddenKey(value) { + if (!value || typeof value !== "object") return false; + if (Array.isArray(value)) return value.some(forbiddenKey); + for (const [key, nested] of Object.entries(value)) { + if (/^(attempt|attempts|retry|retries)$/i.test(key) || forbiddenKey(nested)) return true; + } + return false; +} +export function deriveOverall(checks) { return checks.length > 0 && checks.every(({ status }) => status === "PASS") ? "PASS" : "FAIL"; } +export function validateReport(report) { + if (!report || report.schemaVersion !== 1 || !RUN_ID.test(report.runId ?? "") || !ISO_UTC.test(report.startedAt ?? "") + || !ISO_UTC.test(report.finishedAt ?? "") || typeof report.command !== "string" || forbiddenKey(report) + || !Array.isArray(report.checks) || report.checks.length === 0) throw new Error("report is invalid"); + const ids = new Set(); + for (const check of report.checks) { + if (!check || !/^[a-z0-9_]+$/.test(check.id ?? "") || ids.has(check.id) || !["PASS", "FAIL"].includes(check.status) + || !ISO_UTC.test(check.startedAt ?? "") || !ISO_UTC.test(check.finishedAt ?? "") + || !Array.isArray(check.commands) || check.commands.some((name) => !COMMAND.test(name)) + || !Array.isArray(check.artifacts) || check.artifacts.some(({ path, sha256 }) => { + try { safeArtifactPath(path); } catch { return true; } + return !HEX64.test(sha256 ?? ""); + })) throw new Error("report check is invalid"); + ids.add(check.id); + } + if (report.overall !== deriveOverall(report.checks)) throw new Error("report overall is not derived"); + return report; +} +function renderReportMarkdown(report) { + validateReport(report); + const rows = report.checks.map((check) => `| ${check.id} | ${check.status} |`).join("\n"); + return `# P1 automated integration\n\nRun: \`${report.runId}\`\n\n| Check | Status |\n|---|---|\n${rows}\n\nautomated integration: ${report.overall}\nmanual acceptance: PENDING\n`; +} +function containsAny(bytes, forbiddenValues) { + return forbiddenValues.some((value) => value && bytes.includes(Buffer.from(value))); +} +async function walkFiles(root, current = root, out = []) { + for (const entry of await readdir(current, { withFileTypes: true })) { + const path = join(current, entry.name); + const rel = relative(root, path).split(sep).join("/"); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (rel === "fixture-secrets") continue; + await walkFiles(root, path, out); + } else if (entry.isFile()) out.push({ path, rel }); + } + return out; +} +async function gitObjectFindings(runRoot, forbiddenValues) { + const findings = []; + for (const directory of [join(runRoot, "remote.git"), join(runRoot, "author")]) { + if (!existsSync(directory)) continue; + const args = basename(directory) === "remote.git" ? ["--git-dir", directory] : ["-C", directory]; + let objects; + try { objects = (await git([...args, "rev-list", "--objects", "--all"])).stdout.trim().split("\n").filter(Boolean); } catch { continue; } + for (const line of objects) { + const oid = line.split(" ", 1)[0]; + const type = (await git([...args, "cat-file", "-t", oid])).stdout.trim(); + if (type !== "blob") continue; + const bytes = Buffer.from((await git([...args, "cat-file", "blob", oid], { maxOutputBytes: 16 * 1024 * 1024 })).stdout); + if (containsAny(bytes, forbiddenValues)) findings.push({ path: `git-object:${basename(directory)}:${oid}` }); + } + } + return findings; +} +export async function scanSecrets({ runRoot, forbiddenValues, virtualFiles = [] }) { + const values = forbiddenValues.filter((value) => typeof value === "string" && value.length >= 8); + const findings = []; + for (const file of await walkFiles(runRoot)) if (containsAny(await readFile(file.path), values)) findings.push({ path: file.rel }); + for (const file of virtualFiles) if (containsAny(Buffer.from(file.bytes), values)) findings.push({ path: file.path }); + findings.push(...await gitObjectFindings(runRoot, values)); + return findings; +} +function sanitizeForEvidence(value, forbiddenValues = []) { + if (typeof value === "string") { + let safe = value; + for (const forbidden of forbiddenValues) if (forbidden) safe = safe.split(forbidden).join("[REDACTED]"); + return safe.length > 16_384 ? `${safe.slice(0, 16_384)}[TRUNCATED]` : safe; + } + if (Array.isArray(value)) return value.map((item) => sanitizeForEvidence(item, forbiddenValues)); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, sanitizeForEvidence(nested, forbiddenValues)])); + return value; +} +async function evidence(run, path, value, forbiddenValues = []) { + const safe = sanitizeForEvidence(value, forbiddenValues); + await atomicWrite(join(run.root, path), `${JSON.stringify(safe, null, 2)}\n`); + return await fileArtifact(run.root, path); +} +export async function executeChecks({ checks, failAt, recorder } = {}) { + const results = []; + const ids = new Set(); + for (const scenario of checks) { + if (ids.has(scenario.id)) throw new Error("duplicate scenario id"); + ids.add(scenario.id); + const startedAt = nowIso(); + let result; + try { + const output = await scenario.run(); + if (scenario.id === failAt) throw new Error("injected acceptance failure"); + result = { id: scenario.id, status: "PASS", startedAt, finishedAt: nowIso(), commands: output.commands ?? [], artifacts: output.artifacts ?? [] }; + } catch (error) { + result = { id: scenario.id, status: "FAIL", startedAt, finishedAt: nowIso(), commands: [], artifacts: [], error: "Acceptance scenario failed safely." }; + } + results.push(result); + if (recorder) await recorder(result); + if (result.status === "FAIL") break; + } + return results; +} + +function baseWorkspace(id, evidenceSource) { + return { + workspace: { schema_version: 3, id, name: `P1 ${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("p1-filesystem", { type: "filesystem", uri: "workspace-content/p1-filesystem/evidence", patterns: ["**/*.md"], max_bytes: 10485760 }), + baseWorkspace("p1-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("p1-s3", { type: "s3", uri: "s3://p1-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 namespace(id) { return id.toUpperCase().replaceAll("-", "_"); } +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(16).toString("hex")}`, + signed: `SIGNED-${randomBytes(16).toString("hex")}`, + access: `ACCESS-${randomBytes(16).toString("hex")}`, + secret: `SECRET-${randomBytes(16).toString("hex")}`, + session: `SESSION-${randomBytes(16).toString("hex")}`, + rejected: `REJECTED-${randomBytes(16).toString("hex")}`, + }; + 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)); + ctx.forbiddenValues = Object.values(values); + ctx.secretValues = values; + const env = {}; + for (const workspace of ctx.descriptors) { + const ns = namespace(workspace.workspace.id); const prefix = `THT_WS_${ns}`; + 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_P1_HTTP_EVIDENCE_SIGNED_URLS_FILE: paths.signed, + THT_WS_P1_S3_EVIDENCE_ACCESS_KEY_FILE: paths.access, + THT_WS_P1_S3_EVIDENCE_SECRET_KEY_FILE: paths.secret, + THT_WS_P1_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", "base.yaml"), "{}\n"); +} +async function initializeGit(ctx) { + await git(["init", "--bare", "--initial-branch=main", join(ctx.run.root, "remote.git")], { cwd: ctx.run.root }); + await git(["clone", join(ctx.run.root, "remote.git"), join(ctx.run.root, "author")], { cwd: ctx.run.root }); + await git(["config", "user.name", "P1 Fixture Curator"], { cwd: join(ctx.run.root, "author") }); + await git(["config", "user.email", "p1-curator@example.invalid"], { cwd: join(ctx.run.root, "author") }); + const evidenceRoot = join(ctx.run.root, "author", "workspace-content", "p1-filesystem", "evidence"); + await mkdir(join(evidenceRoot, "domain"), { recursive: true }); + await writeFile(join(evidenceRoot, "guide.md"), "# P1 curated Evidence\n"); + await writeFile(join(evidenceRoot, "domain", "table.md"), "# Curated table\n"); + await git(["add", "workspace-content"], { cwd: join(ctx.run.root, "author") }); + await git(["commit", "-m", "Bootstrap curated P1 content"], { cwd: join(ctx.run.root, "author") }); + await git(["push", "origin", "main"], { cwd: join(ctx.run.root, "author") }); + ctx.bootstrapCommit = (await git(["rev-parse", "HEAD"], { cwd: join(ctx.run.root, "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); + ctx.registryConfig = config.workspaceRegistry; + ctx.registry = new WorkspaceRegistry(ctx.registryConfig); + ctx.thtRunner = new ThtRunner({ + thtBin: config.thtBin, harnessDir: config.harnessDir, configPath: join(ctx.run.root, "installation", "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 }, + }); + ctx.app = buildApp(config, { thtRunner: ctx.thtRunner, workspaceRegistry: ctx.registry }); + const address = await ctx.app.listen({ host: "127.0.0.1", port: 0 }); + const url = new URL(address); ctx.baseUrl = `http://127.0.0.1:${url.port}`; + await writeOwnership(ctx.run, { kind: "fastify", host: "127.0.0.1", requestedPort: 0, actualPort: Number(url.port), pid: process.pid, state: "listening" }); +} +async function request(ctx, id, method, path, body, binary = false) { + const requestSummary = { method, path, ...(body === undefined ? {} : { body: sanitizeForEvidence(body, ctx.forbiddenValues) }) }; + 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, contentType: response.headers.get("content-type"), bytes: bytes.length }); + return { status: response.status, bytes }; + } + const text = await response.text(); let parsed; + try { parsed = text ? JSON.parse(text) : null; } catch { parsed = { invalidJson: true }; } + const safe = sanitizeForEvidence(parsed, ctx.forbiddenValues); + await evidence(ctx.run, `responses/${id}.json`, { status: response.status, body: safe }, 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(); let total = 0; + zip.on("error", reject); zip.on("end", () => resolvePromise(collected)); + zip.on("entry", (entry) => { + const type = (entry.externalFileAttributes >>> 16) & 0o170000; + if (!ZIP_FILES.includes(entry.fileName) || entry.fileName.includes("\\") || entry.fileName.includes("..") || entry.fileName.startsWith("/") || entry.fileName.endsWith("/") || type === 0o120000 || collected.has(entry.fileName) || entry.uncompressedSize > 2_000_000) 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) => { total += chunk.length; if (total > 8_000_000) reject(new Error("export too large")); else chunks.push(chunk); }); + stream.on("end", () => { collected.set(entry.fileName, Buffer.concat(chunks)); zip.readEntry(); }); stream.on("error", reject); + }); + }); + zip.readEntry(); + }); + }); + assert(files.size === ZIP_FILES.length, "export file allowlist mismatch"); + const manifest = JSON.parse(files.get("manifest.json").toString("utf8")); + assert(manifest.schema_version === 1 && manifest.workspace_id === id, "export manifest identity mismatch"); + for (const name of ZIP_FILES.slice(1)) assert(sha256(files.get(name)) === manifest.files[name], `export hash mismatch ${name}`); + for (const [name, contents] of files) await atomicWrite(join(output, name), contents, 0o600); + return manifest; +} +function assert(condition, message) { if (!condition) throw new Error(message); } +async function snapshotDigest(path) { + const files = await walkFiles(path); const result = {}; + for (const file of files) result[file.rel] = sha256(await readFile(file.path)); + return result; +} +async function setupContext(run, repositoryRoot, env) { + const thtBin = realpathSync(env.THT_BIN ?? join(repositoryRoot, "harness", ".venv", "bin", "tht")); + const harnessDir = realpathSync(join(repositoryRoot, "harness")); + const ctx = { run, repositoryRoot, descriptors: descriptors(), forbiddenValues: [], env: { + ...env, HOST: "127.0.0.1", PORT: "0", AUTH_MODE: "none", THT_BIN: thtBin, + 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: "p1-acceptance", THT_WORKSPACE_SECRET_ROOTS: join(run.root, "fixture-secrets"), + THT_HOME: join(run.root, "installation", "runtime", "tht-home"), + } }; + await createTopology(run); await setupSecrets(ctx); + for (const [name, value] of Object.entries(ctx.env)) process.env[name] = value; + return ctx; +} +function productionChecks(ctx) { + const log = async (id, value) => ({ commands: [], artifacts: [await evidence(ctx.run, `logs/${id}.json`, value, ctx.forbiddenValues)] }); + return [ + { id: "preflight", run: async () => { + const gitVersion = await git(["--version"]); await access(ctx.env.THT_BIN, fsConstants.X_OK); + return await log("preflight", { git: gitVersion.stdout.trim(), node: process.version, thtExecutable: true }); + } }, + { id: "clean_state", run: async () => { + assert(RUN_ID.test(ctx.run.runId), "run identity invalid"); + return await log("clean_state", { exclusiveRoot: true, reused: false }); + } }, + { id: "ownership", run: async () => { + await readAndValidateOwnership({ repositoryRoot: ctx.repositoryRoot, runRoot: ctx.run.root, expectedNonce: ctx.run.nonce }); + return await log("ownership", { valid: true, listener: "not_started" }); + } }, + { id: "local_git_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`); + assert(!existsSync(join(ctx.run.root, "author", "workspaces")), "fixture authored a descriptor"); + return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/local_git_bootstrap.json", { bootstrapCommit: ctx.bootstrapCommit, descriptorEmpty: true })] }; + } }, + { id: "http_validate_publish_pull_read_export", 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, "empty registry status failed"); + let base = status.body.head; + for (const workspace of ctx.descriptors) { + const id = workspace.workspace.id; + const validated = await request(ctx, `validate-${id}`, "POST", "/workspaces/validate", { workspace }); + assert(validated.status === 200 && validated.body.workspace.workspace.id === id, `validation failed ${id}`); + const published = await request(ctx, `publish-${id}`, "POST", "/workspaces/publish", { action: "create", workspace, baseCommit: base }); + assert(published.status === 200 && HEX40.test(published.body.revision.commit), `publication failed ${id}`); + base = published.body.revision.commit; + } + ctx.publicationHead = base; + const pulled = await request(ctx, "registry-pull", "POST", "/workspace-registry/pull"); assert(pulled.status === 200 && pulled.body.head === base, "pull failed"); + const listed = await request(ctx, "workspace-list", "GET", "/workspaces"); assert(listed.status === 200 && listed.body.length === 3, "list failed"); + ctx.reads = {}; + for (const workspace of ctx.descriptors) { + const id = workspace.workspace.id; const read = await request(ctx, `read-${id}`, "GET", `/workspaces/${id}`); + assert(read.status === 200, `read failed ${id}`); ctx.reads[id] = read.body; + const exported = await request(ctx, `export-${id}`, "GET", `/workspaces/${id}/export`, undefined, true); + assert(exported.status === 200, `export failed ${id}`); await extractZip(ctx, id, exported.bytes); + } + return await log("http_flow", { workspaceIds: Object.keys(ctx.reads), head: base, realListener: true, fetch: true }); + } }, + { id: "same_revision_git_objects", run: async () => { + const read = await request(ctx, "read-filesystem-identity", "GET", "/workspaces/p1-filesystem"); + const revision = read.body.revision; ctx.oldRevision = revision; ctx.oldSnapshotDigest = await snapshotDigest(dirname(revision.snapshotPath)); + const checkoutHead = (await git(["rev-parse", "HEAD"], { cwd: join(ctx.run.root, "installation", "registry", "repo") })).stdout.trim(); + const manifest = JSON.parse(await readFile(join(dirname(revision.snapshotPath), "snapshot.json"), "utf8")); + const lease = ctx.thtRunner.acquireWorkspaceRuntime(revision.snapshotPath); let rendered; + try { rendered = (await import("yaml")).parse(await readFile(lease.path, "utf8")); } finally { lease.release(); } + const identities = [revision.commit, checkoutHead, manifest.head, rendered.runtime_identity.workspace_revision]; + assert(new Set(identities).size === 1, "revision identities diverged"); + const repo = join(ctx.run.root, "installation", "registry", "repo"); + await git(["cat-file", "-e", `${revision.commit}:workspaces/p1-filesystem.yaml`], { cwd: repo }); + await git(["cat-file", "-e", `${revision.commit}:workspace-content/p1-filesystem/evidence/guide.md`], { cwd: repo }); + const type = (await git(["cat-file", "-t", `${revision.commit}:workspace-content/p1-filesystem/evidence`], { cwd: repo })).stdout.trim(); + assert(type === "tree", "Evidence object is not a tree"); + assert(!existsSync(join(dirname(revision.snapshotPath), "workspace-content")), "snapshot materialized workspace-content"); + return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/git-object-proof.json", { commit: revision.commit, checkoutHead, manifestHead: manifest.head, runtimeRevision: rendered.runtime_identity.workspace_revision, evidenceType: type })] }; + } }, + { id: "content_only_revision", run: async () => { + const author = join(ctx.run.root, "author"); + await git(["fetch", "origin", "main"], { cwd: author }); await git(["reset", "--hard", "origin/main"], { cwd: author }); + const descriptorBefore = (await git(["rev-parse", "HEAD:workspaces/p1-filesystem.yaml"], { cwd: author })).stdout.trim(); + await writeFile(join(author, "workspace-content", "p1-filesystem", "evidence", "guide.md"), "# P1 curated Evidence v2\n"); + await git(["add", "workspace-content/p1-filesystem/evidence/guide.md"], { cwd: author }); await git(["commit", "-m", "Update curated Evidence only"], { cwd: author }); await git(["push", "origin", "main"], { cwd: author }); + ctx.contentCommit = (await git(["rev-parse", "HEAD"], { cwd: author })).stdout.trim(); + const pulled = await request(ctx, "content-only-pull", "POST", "/workspace-registry/pull"); assert(pulled.status === 200 && pulled.body.head === ctx.contentCommit, "content pull failed"); + const current = (await request(ctx, "read-filesystem-content", "GET", "/workspaces/p1-filesystem")).body.revision; + const descriptorAfter = (await git(["rev-parse", "HEAD:workspaces/p1-filesystem.yaml"], { cwd: author })).stdout.trim(); + assert(current.commit === ctx.contentCommit && current.blob === ctx.oldRevision.blob && descriptorAfter === descriptorBefore, "content revision identity failed"); + assert(JSON.stringify(await snapshotDigest(dirname(ctx.oldRevision.snapshotPath))) === JSON.stringify(ctx.oldSnapshotDigest), "old snapshot changed"); + ctx.currentRevision = current; + return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/content-only-revision.json", { oldCommit: ctx.oldRevision.commit, newCommit: current.commit, descriptorBlob: current.blob, oldSnapshotImmutable: true })] }; + } }, + { id: "snapshot_and_docs", run: async () => { + for (const id of ctx.descriptors.map((item) => item.workspace.id)) { + const extracted = join(ctx.run.root, "exports", "extracted", id); + for (const name of ZIP_FILES) assert((await lstat(join(extracted, name))).isFile(), `missing extracted ${name}`); + const read = (await request(ctx, `read-${id}-snapshot`, "GET", `/workspaces/${id}`)).body; + for (const suffix of [".yaml", ".env.example", ".md", "snapshot.json"]) { + const file = suffix === "snapshot.json" ? join(dirname(read.revision.snapshotPath), suffix) : join(dirname(read.revision.snapshotPath), `${id}${suffix}`); + assert(existsSync(file), `snapshot artifact absent ${file}`); + } + } + return await log("snapshot_and_docs", { exactBundleFiles: ZIP_FILES, generatedDocs: true, immutableSnapshots: true }); + } }, + { id: "runtime_render_determinism", run: async () => { + ctx.configChecks = []; + const YAML = await import("yaml"); + for (const id of ctx.descriptors.map((item) => item.workspace.id)) { + const read = (await request(ctx, `read-${id}-runtime`, "GET", `/workspaces/${id}`)).body; + const bytes = []; + for (let n = 1; n <= 2; n += 1) { + const lease = ctx.thtRunner.acquireWorkspaceRuntime(read.revision.snapshotPath); + try { + const contents = await readFile(lease.path); bytes.push(contents); + await atomicWrite(join(ctx.run.root, "rendered", `${id}-${n}.yaml`), contents); + const checked = await tht(ctx.env.THT_BIN, ["config", "check", "-c", lease.path], { cwd: ctx.env.THT_HARNESS_DIR, env: ctx.env, timeoutMs: 30_000 }); + ctx.configChecks.push({ id, observation: n, code: checked.code }); + } finally { lease.release(); } + const runtimeDir = join(ctx.run.root, "installation", "registry", "snapshots", "runtime"); + if (existsSync(runtimeDir)) assert((await readdir(runtimeDir)).length === 0, "runtime lease leaked"); + } + assert(bytes[0].equals(bytes[1]), `render nondeterministic ${id}`); + const parsed = YAML.parse(bytes[0].toString("utf8")); + assert(parsed.runtime_identity.workspace_id === id && parsed.runtime_identity.workspace_revision === read.revision.commit, "render identity mismatch"); + } + return { commands: ["tht"], artifacts: [await evidence(ctx.run, "logs/runtime-render.json", { deterministic: true, released: true, workspaces: ctx.descriptors.map((item) => item.workspace.id) })] }; + } }, + { id: "tht_config_check", run: async () => { + assert(ctx.configChecks.length === 6 && ctx.configChecks.every(({ code }) => code === 0), "tht config checks incomplete"); + return { commands: ["tht"], artifacts: [await evidence(ctx.run, "logs/tht-config-check.json", ctx.configChecks)] }; + } }, + { id: "negative_schema_cases", run: async () => { + const baselineHead = (await git(["rev-parse", "HEAD"], { cwd: join(ctx.run.root, "installation", "registry", "repo") })).stdout.trim(); + const base = structuredClone(ctx.descriptors[0]); + const cases = [ + ["absolute", (w) => { w.evidence.source.uri = "/tmp/evidence"; }, "evidence.source.uri"], + ["traversal", (w) => { w.evidence.source.uri = "workspace-content/p1-filesystem/../evidence"; }, "evidence.source.uri"], + ["backslash", (w) => { w.evidence.source.uri = "workspace-content\\p1-filesystem\\evidence"; }, "evidence.source.uri"], + ["cross-workspace", (w) => { w.evidence.source.uri = "workspace-content/other/evidence"; }, "evidence.source.uri"], + ["unsupported-source", (w) => { w.evidence.source.type = "ftp"; w.evidence.source.uri = "ftp://example.test/file"; }, "evidence.source.type"], + ["credential-field", (w) => { w.evidence.source.password = ctx.secretValues.rejected; }, "evidence.source.password"], + ["http-userinfo-query", (w) => { w.evidence.source = { type: "http", uris: [`https://user:${ctx.secretValues.rejected}@evidence.example.test/guide?x=${ctx.secretValues.rejected}`], authentication: "none" }; }, "evidence.source.uris"], + ["malformed-policy", (w) => { w.evidence.policy.max_chunk_chars = 0; }, "evidence.policy.max_chunk_chars"], + ]; + const outcomes = []; + for (const [id, mutate, field] of cases) { + const workspace = structuredClone(base); mutate(workspace); + await evidence(ctx.run, `fixtures/requests/negative-${id}.json`, { case: id, expectedField: field, rawCredentialPersisted: false }); + const response = await request(ctx, `negative-${id}`, "POST", "/workspaces/validate", { workspace }); + assert(response.status === 400 && response.body.code === "workspace_invalid", `negative accepted ${id}`); + assert(JSON.stringify(response.body).includes(ctx.secretValues.rejected) === false, `negative leaked ${id}`); + const currentHead = (await git(["rev-parse", "HEAD"], { cwd: join(ctx.run.root, "installation", "registry", "repo") })).stdout.trim(); + assert(currentHead === baselineHead, `negative mutated head ${id}`); outcomes.push({ id, status: 400, code: "workspace_invalid", field }); + } + return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/negative-schema.json", outcomes, ctx.forbiddenValues)] }; + } }, + { id: "negative_context_case", run: async () => { + const { WorkspaceRegistry } = await loadProductionBackend(); const author = join(ctx.run.root, "author"); + await git(["checkout", "-b", "invalid-context", ctx.contentCommit], { cwd: author }); await git(["push", "-u", "origin", "invalid-context"], { cwd: author }); + const isolatedRoot = join(ctx.run.root, "installation", "registry-context"); + const registry = new WorkspaceRegistry({ ...ctx.registryConfig, root: isolatedRoot, branch: "invalid-context" }); + await registry.bootstrap(); const before = await registry.read("p1-filesystem"); + const missing = baseWorkspace("missing-context", { type: "filesystem", uri: "workspace-content/missing-context/evidence", patterns: ["**/*.md"], max_bytes: 100 }); + await assertRejectsCode(() => registry.publish({ action: "create", workspace: missing, baseCommit: ctx.contentCommit }), "workspace_invalid"); + await rm(join(author, "workspace-content", "p1-filesystem", "evidence"), { recursive: true }); await git(["add", "-A", "workspace-content/p1-filesystem/evidence"], { cwd: author }); await git(["commit", "-m", "Invalid contextual Evidence state"], { cwd: author }); await git(["push", "origin", "invalid-context"], { cwd: author }); + await assertRejectsCode(() => registry.pull(), "workspace_invalid"); const after = await registry.read("p1-filesystem"); + assert(after.revision.commit === before.revision.commit, "isolated active snapshot changed"); + const primary = (await request(ctx, "primary-after-context", "GET", "/workspaces/p1-filesystem")).body; + assert(primary.revision.commit === ctx.contentCommit, "primary state changed"); + return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/negative-context.json", { missingCreateRejected: true, invalidPullRejected: true, lastValidCommit: after.revision.commit, primaryCommit: primary.revision.commit })] }; + } }, + { id: "no_p1_scope_artifacts", run: async () => { + const forbidden = ["artifacts/evidence", "corpus/ACTIVE", "embeddings", "qdrant-records", "preprocessing-invocation"]; + const present = (await walkFiles(ctx.run.root)).map(({ rel }) => rel).filter((path) => forbidden.some((part) => path.includes(part))); + assert(present.length === 0, "P6 scope artifact created"); return await log("no-p1-scope-artifacts", { absent: forbidden }); + } }, + { id: "secret_scan", run: async () => { + const findings = await scanSecrets({ runRoot: ctx.run.root, forbiddenValues: ctx.forbiddenValues }); assert(findings.length === 0, "secret canary found outside exclusion"); + return await log("secret-scan", { scanned: true, excluded: "fixture-secrets", findings: [] }); + } }, + { id: "cleanup_confinement", run: async () => { + const fakeRepo = join(ctx.run.root, "fixtures", "cleanup-repository"); await mkdir(join(fakeRepo, ".artifacts", "p1-integration"), { recursive: true }); + const synthetic = await createOwnedRun({ repositoryRoot: fakeRepo }); const sibling = join(fakeRepo, ".artifacts", "p1-integration", `p1-${"e".repeat(32)}`); await mkdir(sibling); await writeFile(join(sibling, "sentinel"), "foreign"); + await cleanupOwnedRun({ repositoryRoot: fakeRepo, runRoot: synthetic.root, expectedNonce: synthetic.nonce }); + assert(await readFile(join(sibling, "sentinel"), "utf8") === "foreign", "cleanup removed sibling"); + return await log("cleanup-confinement", { ownedRemoved: true, siblingPreserved: true }); + } }, + ]; +} +async function assertRejectsCode(fn, code) { + try { await fn(); } catch (error) { if (error?.code === code) return; throw error; } + throw new Error(`expected ${code}`); +} + +export async function runIntegration({ repositoryRoot = defaultRepositoryRoot, keep = false, env = process.env, failAt = env.P1_ACCEPTANCE_FAIL_AT, checks } = {}) { + const savedEnv = { ...process.env }; let run; let ctx; let results = []; let fatal; + try { + run = await createOwnedRun({ repositoryRoot }); + if (checks === undefined) { ctx = await setupContext(run, repositoryRoot, env); checks = productionChecks(ctx); } + results = await executeChecks({ checks, failAt }); + } catch (error) { + fatal = error; + if (run && results.length === 0) results = [{ id: "preflight", status: "FAIL", startedAt: run.startedAt, finishedAt: nowIso(), commands: [], artifacts: [], error: "Acceptance setup failed safely." }]; + } finally { + if (ctx?.app) { + await ctx.app.close().catch(() => {}); + await writeOwnership(run, { ...run.listener, state: "closed" }).catch(() => {}); + } + for (const key of Object.keys(process.env)) if (!(key in savedEnv)) delete process.env[key]; + Object.assign(process.env, savedEnv); + } + if (!run) throw fatal; + const success = !fatal && results.length === checks.length && results.every(({ status }) => status === "PASS"); + const report = { + schemaVersion: 1, runId: run.runId, startedAt: run.startedAt, finishedAt: nowIso(), + command: `p1-acceptance integration${keep ? " --keep" : ""}`, overall: success ? "PASS" : "FAIL", checks: results, + }; + validateReport(report); + let jsonBytes = Buffer.from(`${JSON.stringify(report, null, 2)}\n`); let mdBytes = Buffer.from(renderReportMarkdown(report)); + if (ctx?.forbiddenValues) { + const findings = await scanSecrets({ runRoot: run.root, forbiddenValues: ctx.forbiddenValues, virtualFiles: [{ path: "report.json", bytes: jsonBytes }, { path: "report.md", bytes: mdBytes }] }); + if (findings.length) { + report.overall = "FAIL"; + const secret = report.checks.find(({ id }) => id === "secret_scan"); if (secret) secret.status = "FAIL"; + else report.checks.push({ id: "secret_scan", status: "FAIL", startedAt: nowIso(), finishedAt: nowIso(), commands: [], artifacts: [], error: "Secret scan found protected content." }); + jsonBytes = Buffer.from(`${JSON.stringify(report, null, 2)}\n`); mdBytes = Buffer.from(renderReportMarkdown(report)); + } + } + await atomicWrite(join(run.root, "report.json"), jsonBytes); await atomicWrite(join(run.root, "report.md"), mdBytes); + const finalSuccess = report.overall === "PASS"; + const removed = await finalizeOwnedRun({ run, success: finalSuccess, keep }); + return { exitCode: finalSuccess ? 0 : 1, runRoot: run.root, retained: !removed, report }; +} + +export async function main(argv = process.argv.slice(2), env = process.env) { + if (argv.length < 1 || argv[0] !== "integration" || argv.length > 2 || (argv.length === 2 && argv[1] !== "--keep")) { + console.error("usage: p1-acceptance integration [--keep]"); return 2; + } + try { + const result = await runIntegration({ repositoryRoot: defaultRepositoryRoot, keep: argv.includes("--keep"), env }); + if (result.retained) console.log(result.runRoot); + return result.exitCode; + } catch (error) { + console.error("P1 acceptance failed before owning a reportable run."); return 1; + } +} +if (resolve(process.argv[1] ?? "") === modulePath) process.exitCode = await main(); diff --git a/backend/scripts/p1-acceptance.test.mjs b/backend/scripts/p1-acceptance.test.mjs new file mode 100644 index 00000000..253f2545 --- /dev/null +++ b/backend/scripts/p1-acceptance.test.mjs @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + chmod, lstat, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; + +import { + canonicalIntegrationBase, + cleanupOwnedRun, + createOwnedRun, + deriveOverall, + executeChecks, + readAndValidateOwnership, + runCommand, + runIntegration, + scalarSecretBytes, + scanSecrets, + validateReport, + validateRunRoot, +} from "./p1-acceptance.mjs"; + +const execFileAsync = promisify(execFile); +const roots = []; +async function fakeRepository() { + const root = await mkdtemp(join(tmpdir(), "p1 acceptance repository with spaces-")); + roots.push(root); + await mkdir(join(root, ".artifacts", "p1-integration"), { recursive: true }); + return await realpath(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 integration children", async () => { + const repositoryRoot = await fakeRepository(); + const base = canonicalIntegrationBase(repositoryRoot); + const id = `p1-${"a".repeat(32)}`; + assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id)); + for (const candidate of [ + base, + join(repositoryRoot, ".artifacts", "manual-acceptance", id), + join(base, id, "nested"), + join(base, "foreign"), + join(dirname(base), id), + ]) assert.throws(() => validateRunRoot(repositoryRoot, candidate, id)); + assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p1-${"A".repeat(32)}`), `p1-${"A".repeat(32)}`)); +}); + +test("cleanup refuses every unowned or ambiguous root", async () => { + const repositoryRoot = await fakeRepository(); + const base = canonicalIntegrationBase(repositoryRoot); + const cases = [ + ["missing ownership", async (run) => rm(join(run.root, "ownership.json"))], + ["malformed ownership", async (run) => writeFile(join(run.root, "ownership.json"), "{")], + ["mismatched root", async (run) => { + const value = JSON.parse(await readFile(join(run.root, "ownership.json"), "utf8")); + value.root = join(base, `p1-${"b".repeat(32)}`); + await writeFile(join(run.root, "ownership.json"), JSON.stringify(value)); + }], + ["mismatched pid", async (run) => { + const value = JSON.parse(await readFile(join(run.root, "ownership.json"), "utf8")); + value.pid += 1; + await writeFile(join(run.root, "ownership.json"), JSON.stringify(value)); + }], + ["wrong resource list", async (run) => { + const value = JSON.parse(await readFile(join(run.root, "ownership.json"), "utf8")); + value.resources.push(join(repositoryRoot, "foreign")); + await writeFile(join(run.root, "ownership.json"), JSON.stringify(value)); + }], + ]; + for (const [, mutate] of cases) { + const run = await createOwnedRun({ repositoryRoot }); + await mutate(run); + await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce })); + assert.equal((await lstat(run.root)).isDirectory(), true); + } + const wrongNonce = await createOwnedRun({ repositoryRoot }); + await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: wrongNonce.root, expectedNonce: "0".repeat(64) })); + const symlinkRun = await createOwnedRun({ repositoryRoot }); + const target = `${symlinkRun.root}-target`; + await rm(symlinkRun.root, { recursive: true }); + await mkdir(target); + await symlink(target, symlinkRun.root); + await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: symlinkRun.root, expectedNonce: symlinkRun.nonce })); + for (const bad of [base, join(repositoryRoot, ".artifacts", "manual-acceptance"), join(base, "foreign")]) { + await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: bad, expectedNonce: "0".repeat(64) })); + } +}); + +test("cleanup atomically removes one owned root and preserves siblings", async () => { + const repositoryRoot = await fakeRepository(); + const run = await createOwnedRun({ repositoryRoot }); + const sibling = join(canonicalIntegrationBase(repositoryRoot), `p1-${"c".repeat(32)}`); + await mkdir(sibling); + await writeFile(join(sibling, "sentinel"), "foreign"); + await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce }); + await cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce }); + await assert.rejects(lstat(run.root)); + assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign"); +}); + +function validReport(checks = [{ + id: "preflight", status: "PASS", startedAt: "2026-08-09T00:00:00.000Z", + finishedAt: "2026-08-09T00:00:01.000Z", commands: ["git"], + artifacts: [{ path: "logs/preflight.json", sha256: "a".repeat(64) }], +}]) { + return { + schemaVersion: 1, runId: `p1-${"d".repeat(32)}`, startedAt: "2026-08-09T00:00:00.000Z", + finishedAt: "2026-08-09T00:00:02.000Z", command: "p1-acceptance integration --keep", + overall: deriveOverall(checks), checks, + }; +} + +test("report validation enforces uniqueness, derivation, safe evidence, hashes, times, and commands", () => { + assert.doesNotThrow(() => validateReport(validReport())); + const mutations = [ + (r) => r.checks.push(structuredClone(r.checks[0])), + (r) => { r.checks[0].attempt = 1; }, + (r) => { r.checks[0].artifacts[0].path = "../secret"; }, + (r) => { r.checks[0].artifacts[0].sha256 = "bad"; }, + (r) => { r.checks[0].startedAt = "today"; }, + (r) => { r.checks[0].commands = ["git status"]; }, + (r) => { r.overall = "PASS"; r.checks[0].status = "FAIL"; }, + (r) => { r.nested = { retries: 2 }; }, + ]; + for (const mutate of mutations) { + const report = validReport(); mutate(report); assert.throws(() => validateReport(report)); + } +}); + +test("injected failure executes once, retains diagnostics, and returns nonzero", async () => { + const repositoryRoot = await fakeRepository(); + let calls = 0; + const result = await runIntegration({ + repositoryRoot, keep: false, failAt: "sample", + checks: [{ id: "sample", run: async () => { calls += 1; return { commands: [], artifacts: [] }; } }], + }); + assert.equal(result.exitCode, 1); + assert.equal(calls, 1); + assert.equal((await lstat(result.runRoot)).isDirectory(), true); + const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8")); + assert.equal(report.checks.filter((check) => check.status === "FAIL").length, 1); + assert.equal(report.checks[0].id, "sample"); +}); + +test("executeChecks never repeats a scenario", async () => { + const calls = new Map(); + const result = await executeChecks({ + checks: ["one", "two"].map((id) => ({ id, run: async () => { calls.set(id, (calls.get(id) ?? 0) + 1); return {}; } })), + failAt: "two", + }); + assert.equal(result.length, 2); + assert.deepEqual(Object.fromEntries(calls), { one: 1, two: 1 }); + assert.equal(result[1].status, "FAIL"); +}); + +test("scalar fixture secret files contain no harness-invalid whitespace", () => { + const bytes = scalarSecretBytes("CANARY-secret-value-123456"); + assert.equal(bytes.toString("utf8"), "CANARY-secret-value-123456"); + assert.equal([...bytes].some((byte) => /\s/.test(String.fromCharCode(byte))), false); + assert.throws(() => scalarSecretBytes("bad secret")); +}); + +test("secret scanner excludes only the direct fixture-secrets subtree", async () => { + const repositoryRoot = await fakeRepository(); + const run = await createOwnedRun({ repositoryRoot }); + const canary = "CANARY-secret-value-123456"; + await mkdir(join(run.root, "fixture-secrets")); + await writeFile(join(run.root, "fixture-secrets", "allowed"), canary); + const paths = [ + "logs/a.log", "responses/a.json", "rendered/a.yaml", "exports/raw/a.zip", + "exports/extracted/a.md", "requests/a.json", "report-preview.md", "nested/fixture-secrets/not-excluded", + ]; + for (const path of paths) { + await mkdir(dirname(join(run.root, path)), { recursive: true }); + await writeFile(join(run.root, path), `prefix ${canary} suffix`); + } + const findings = await scanSecrets({ runRoot: run.root, forbiddenValues: [canary] }); + assert.deepEqual(new Set(findings.map((finding) => finding.path)), new Set(paths)); +}); + +test("secret scanner examines reachable Git blobs, not just loose file bytes", async () => { + const repositoryRoot = await fakeRepository(); + const run = await createOwnedRun({ repositoryRoot }); + const canary = "GIT-CANARY-secret-value-987654"; + const gitRoot = join(run.root, "author"); + await mkdir(gitRoot); + await execFileAsync("git", ["init", "--initial-branch=main"], { cwd: gitRoot }); + await execFileAsync("git", ["config", "user.name", "Scanner Test"], { cwd: gitRoot }); + await execFileAsync("git", ["config", "user.email", "scanner@example.invalid"], { cwd: gitRoot }); + await writeFile(join(gitRoot, "secret.txt"), canary); + await execFileAsync("git", ["add", "secret.txt"], { cwd: gitRoot }); + await execFileAsync("git", ["commit", "-m", "secret blob"], { cwd: gitRoot }); + await execFileAsync("git", ["rm", "secret.txt"], { cwd: gitRoot }); + await execFileAsync("git", ["commit", "-m", "remove worktree copy"], { cwd: gitRoot }); + const findings = await scanSecrets({ runRoot: run.root, forbiddenValues: [canary] }); + assert.equal(findings.some((finding) => finding.path.startsWith("git-object:")), true); +}); + +test("successful lifecycle honors keep and cleanup", async () => { + const repositoryRoot = await fakeRepository(); + const check = [{ id: "sample", run: async () => ({ commands: [], artifacts: [] }) }]; + const kept = await runIntegration({ repositoryRoot, keep: true, checks: check }); + assert.equal(kept.exitCode, 0); + assert.equal((await lstat(kept.runRoot)).isDirectory(), true); + const cleaned = await runIntegration({ repositoryRoot, keep: false, checks: check }); + assert.equal(cleaned.exitCode, 0); + await assert.rejects(lstat(cleaned.runRoot)); +}); + +test("command helper accepts only executable plus separate argv", async () => { + await assert.rejects(runCommand("git status")); + await assert.rejects(runCommand({ executable: "/bin/echo", argv: "hello" })); + await assert.rejects(runCommand({ executable: "/bin/echo", argv: [], shell: true })); + await assert.rejects(runCommand({ executable: "git status; rm -rf /", argv: [] })); + const repositoryRoot = await fakeRepository(); + const executable = join(repositoryRoot, "executable with spaces"); + await writeFile(executable, "#!/bin/sh\nprintf '%s' \"$1\"\n", { mode: 0o700 }); + await chmod(executable, 0o700); + const result = await runCommand({ executable, argv: ["literal;not-a-shell"] }); + assert.equal(result.stdout, "literal;not-a-shell"); + assert.equal(result.code, 0); +}); diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index 3c18c011..71df45bb 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -498,9 +498,6 @@ export class WorkspaceRegistry { private async activate(commit: string): Promise { const safeHead = safeCommit(commit); const files = await this.repository.workspacePaths(); - if (files.length === 0) { - throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains no workspaces"); - } const snapshots: Array<{ id: string; @@ -643,7 +640,7 @@ export class WorkspaceRegistry { private assertActiveState(state: ActiveState): void { safeCommit(state.head); - if (!Array.isArray(state.revisions) || state.revisions.length === 0) throw new Error("bad state"); + if (!Array.isArray(state.revisions)) throw new Error("bad state"); const ids = new Set(); for (const revision of state.revisions) { safeCommit(revision.commit); diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 6eb096b2..c79f360e 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -207,6 +207,29 @@ async function fixture(workspaceSource = validYaml): Promise<{ return { root, remote, source, initialCommit: stdout.trim() }; } +async function contentOnlyFixture(): Promise<{ + root: string; remote: string; source: string; initialCommit: string; +}> { + const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-empty-")); + temporaryRoots.push(root); + const remote = join(root, "remote.git"); + const source = join(root, "source"); + await git(root, ["init", "--bare", "--initial-branch=main", remote]); + mkdirSync(source); + await git(source, ["init", "--initial-branch=main"]); + await git(source, ["config", "user.name", "Workspace Registry Test"]); + await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); + const evidence = join(source, "workspace-content", "p1-filesystem", "evidence"); + mkdirSync(evidence, { recursive: true }); + writeFileSync(join(evidence, "guide.md"), "curated content\n"); + await git(source, ["add", "workspace-content"]); + await git(source, ["commit", "-m", "Bootstrap curated content"]); + await git(source, ["remote", "add", "origin", remote]); + await git(source, ["push", "origin", "main"]); + const initialCommit = await gitOutput(source, ["rev-parse", "HEAD"]); + return { root, remote, source, initialCommit }; +} + async function multiWorkspaceFixture(workspaces: Record): Promise<{ root: string; remote: string; source: string; initialCommit: string; }> { @@ -318,6 +341,29 @@ function persistPreStateManifest(root: string, commit: string): void { writeFileSync(snapshotPath, JSON.stringify(manifest)); } +test("allows first API publication and delete-last from a content-only registry base", async () => { + const remote = await contentOnlyFixture(); + const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); + + await expect(registry.bootstrap()).resolves.toMatchObject({ head: remote.initialCommit }); + await expect(registry.list()).resolves.toEqual([]); + + const created = await registry.publish({ + action: "create", + workspace: filesystemWorkspace("p1-filesystem"), + baseCommit: remote.initialCommit, + }); + expect(created).toMatchObject({ id: "p1-filesystem" }); + + await expect(registry.publish({ + action: "delete", + id: "p1-filesystem", + baseCommit: created!.commit, + baseBlob: created!.blob, + })).resolves.toBeUndefined(); + await expect(registry.list()).resolves.toEqual([]); +}); + test("bootstraps a checkout and activates a validated immutable snapshot", async () => { const remote = await fixture(); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); diff --git a/scripts/p1-acceptance.sh b/scripts/p1-acceptance.sh new file mode 100755 index 00000000..de2e0aa1 --- /dev/null +++ b/scripts/p1-acceptance.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +if [[ $# -lt 1 || "$1" != "integration" || $# -gt 2 || ( $# -eq 2 && "$2" != "--keep" ) ]]; then + printf 'usage: %s integration [--keep]\n' "$0" >&2 + exit 2 +fi +for command in node npm git; do command -v "$command" >/dev/null || { printf 'missing command: %s\n' "$command" >&2; exit 127; }; done +THT_BIN="${THT_BIN:-$repo_root/harness/.venv/bin/tht}" +[[ "$THT_BIN" = /* && -x "$THT_BIN" ]] || { printf 'THT_BIN must be an absolute executable path\n' >&2; exit 127; } +export THT_BIN +npm --prefix "$repo_root/backend" run build +set +e +node "$repo_root/backend/scripts/p1-acceptance.mjs" "$@" +status=$? +set -e +exit "$status" diff --git a/scripts/test-p1-acceptance.sh b/scripts/test-p1-acceptance.sh new file mode 100755 index 00000000..fe64ffb0 --- /dev/null +++ b/scripts/test-p1-acceptance.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +npm --prefix "$repo_root/backend" run build +node --test "$repo_root/backend/scripts/p1-acceptance.test.mjs"