#!/usr/bin/env node import { spawn } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { closeSync, constants as fsConstants, fsyncSync, lstatSync, openSync, realpathSync } from "node:fs"; import { access, lstat, mkdir, open, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { execFile } from "node:child_process"; import http from "node:http"; import { buildSafeEnvironment } from "./p1-acceptance.mjs"; const execFileAsync = promisify(execFile); const modulePath = fileURLToPath(import.meta.url); const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../..")); const HOST = "127.0.0.1"; const BACKEND_PORT = 8791; const FRONTEND_PORT = 8792; const HEX64 = /^[0-9a-f]{64}$/; const OWNERSHIP_DIGEST = "ownership.sha256"; function resolveSystemExecutable(name) { for (const candidate of [`/usr/bin/${name}`, `/bin/${name}`, `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`]) { try { const resolved = realpathSync(candidate); if (lstatSync(resolved).isFile()) return resolved; } catch {} } throw new Error(`required executable not found: ${name}`); } function resolveExecutables(repositoryRoot) { const repo = realpathSync(repositoryRoot); const thtPath = join(repo, "harness", ".venv", "bin", "tht"); if (!lstatSync(thtPath).isFile()) throw new Error("required executable not found: tht"); return { gitPath: resolveSystemExecutable("git"), pythonPath: resolveSystemExecutable("python3"), thtPath: realpathSync(thtPath) }; } function nowIso() { return new Date().toISOString(); } function fixedManualRoot(repositoryRoot = defaultRepositoryRoot) { return join(realpathSync(repositoryRoot), ".artifacts", "manual-acceptance", "p11"); } function below(parent, child) { const rel = relative(parent, child); return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel); } function noSymlinkExisting(repo, target) { const rel = relative(repo, target); if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("root leaves repository"); let cursor = repo; for (const part of rel.split(sep).filter(Boolean)) { cursor = join(cursor, part); if (!lstatSync(cursor, { throwIfNoEntry: false })) break; if (lstatSync(cursor).isSymbolicLink()) throw new Error("owned path contains a symlink"); } } async function atomicWrite(path, bytes, mode = 0o600) { await mkdir(dirname(path), { recursive: true }); const staging = join(dirname(path), `.${basename(path)}.${randomBytes(12).toString("hex")}.tmp`); let handle; try { handle = await open(staging, "wx", mode); await handle.writeFile(bytes); await handle.sync(); await handle.close(); handle = undefined; await rename(staging, path); const directory = openSync(dirname(path), fsConstants.O_RDONLY); try { fsyncSync(directory); } finally { closeSync(directory); } } catch (error) { if (handle) await handle.close().catch(() => {}); await rm(staging, { force: true }).catch(() => {}); throw error; } } function ownershipDigest(bytes) { return createHash("sha256").update(bytes).digest("hex"); } async function writeManualOwnership(root, value) { const body = `${JSON.stringify(value, null, 2)}\n`; await atomicWrite(join(root, "ownership.json"), body); await atomicWrite(join(root, OWNERSHIP_DIGEST), `${ownershipDigest(body)}\n`); } async function git(executable, argv, options = {}) { const result = await execFileAsync(executable, argv, { cwd: options.cwd, env: options.env, timeout: options.timeoutMs ?? 30_000, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" }); return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; } function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); } function baseWorkspace(id, evidenceSource) { return { workspace: { schema_version: 3, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" }, dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] }, semantic_index: { vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, }, llm_policy: { allowed: ["zai/glm-5.2"] }, evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } }, }; } function descriptors() { return [ baseWorkspace("p11-filesystem", { type: "filesystem", uri: "p11-filesystem/evidence", patterns: ["**/*.md"], max_bytes: 10485760 }), baseWorkspace("p11-http", { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "signed_urls_file", connect_timeout_ms: 1250, read_timeout_ms: 30001, max_bytes: 12345, max_redirects: 2, allow_private_hosts: false, max_cache_bytes: 67890 }), baseWorkspace("p11-s3", { type: "s3", uri: "s3://p11-evidence/published/", endpoint_url: "https://s3.example.test/", region: "eu-west-1", credentials: "static_files", trusted_endpoint: true, allow_private_endpoint: false, allow_insecure_endpoint: false, max_bytes: 12345, max_objects: 33, max_pages: 4, page_size: 5 }), ]; } function catalog(entries) { return { schema_version: 1, workspaces: entries.map(({ workspace }) => ({ id: workspace.id, name: workspace.name, description: workspace.description })) }; } function quote(value) { return `'${String(value).replaceAll("'", `'"'"'`)}'`; } function requestFixtures(items) { const fixtures = { "status.json": { method: "GET", path: "/workspace-registry/status" }, "pull.json": { method: "POST", path: "/workspace-registry/pull" } }; for (const workspace of items) { const id = workspace.workspace.id; fixtures[`validate-${id}.json`] = { workspace }; fixtures[`publish-${id}.json`] = { action: "create", workspace }; fixtures[`read-${id}.json`] = { method: "GET", path: `/workspaces/${id}` }; fixtures[`export-${id}.json`] = { method: "GET", path: `/workspaces/${id}/export` }; } fixtures["negative-invalid-uri.json"] = { workspace: { ...items[0], evidence: { ...items[0].evidence, source: { ...items[0].evidence.source, uri: "/etc/passwd" } } } }; fixtures["negative-secret-field.json"] = { workspace: { ...items[2], evidence: { ...items[2].evidence, source: { ...items[2].evidence.source, access_key: "CANARY-MUST-BE-REJECTED" } } } }; return fixtures; } function curlGet(url, output) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; } function curlPost(url, output, body) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --request POST --header 'content-type: application/json' --data-binary @${quote(body)} --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; } function curlPostEmpty(url, output) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --request POST --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; } function publishCurl(root, id, previousResponse) { const descriptor = join(root, "requests", `publish-${id}.json`); const response = join(root, "responses", `publish-${id}.json`); return `#!/usr/bin/env bash\nset -euo pipefail\nbase_commit=$(node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));console.log(value.head ?? value.revision?.commit ?? "");' ${quote(previousResponse)})\nnode -e 'const fs=require("node:fs");const body=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));body.baseCommit=process.argv[2];fs.writeFileSync(process.argv[1],JSON.stringify(body,null,2)+"\\n");' ${quote(descriptor)} "$base_commit"\ncurl --fail-with-body --silent --show-error --request POST --header 'content-type: application/json' --data-binary @${quote(descriptor)} --output ${quote(response)} --write-out 'HTTP %{http_code}\\n' 'http://${HOST}:${BACKEND_PORT}/workspaces/publish'\n`; } function renderCommand(repo, root, observation) { const readResponse = join(root, "responses", "read-p11-filesystem.json"); const output = join(root, "rendered", `runtime-${observation}.yaml`); return `#!/usr/bin/env bash\nset -euo pipefail\nread_snapshot=$(node -e 'const fs=require("node:fs");const read=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const path=read.revision.snapshotPath;const manifest=JSON.parse(fs.readFileSync(require("node:path").join(require("node:path").dirname(path),"snapshot.json"),"utf8"));const name=require("node:path").basename(path);console.log(JSON.stringify({snapshot:path,digest:manifest.files[name]}));' ${quote(readResponse)})\nsnapshot=$(node -e 'const value=JSON.parse(process.argv[1]);console.log(value.snapshot)' "$read_snapshot")\ndigest=$(node -e 'const value=JSON.parse(process.argv[1]);console.log(value.digest)' "$read_snapshot")\nnode ${quote(join(repo, "backend", "scripts", "p11-render-snapshot.mjs"))} --ownership ${quote(join(root, "ownership.json"))} --snapshot "$snapshot" --output ${quote(output)} --snapshot-sha256 "$digest"\n`; } function guide(root) { return `# P1.1 manual acceptance guide 1. Inspect ${join(root, "ownership.json")}, ${join(root, "author", "thoth-workspaces.yaml")}, nested workspace directories, evidence tree, and fixture secret paths without printing secret bytes. 2. Run ./scripts/p11-manual-acceptance.sh serve and confirm only ${HOST}:${BACKEND_PORT} and ${HOST}:${FRONTEND_PORT} are listening for this lab. 3. Run commands/http-01-status.sh and inspect responses/status.json plus GET /workspaces for configuration_required slots. 4. Run the validate and publish scripts once per slot in numeric order. 5. Inspect Git object IDs for thoth-workspaces.yaml, /workspace.yaml, /evidence, and workspace-docs/. 6. Retry create/update/delete and verify refusal plus unchanged object IDs. 7. In ${join(root, "author")}, edit p11-filesystem/workspace.yaml and thoth-workspaces.yaml together, commit, push, then run commands/http-08-pull.sh and verify the API activated curator bytes without rewriting the descriptor. 8. Make an evidence-only commit under p11-filesystem/evidence, push, pull, and inspect the new revision commit with unchanged descriptor blob. 9. In the UI at http://${HOST}:${FRONTEND_PORT}, confirm ready workspaces are read-only and bootstrap-only slots are editable before creation. 10. Export/import only under bootstrap rules. 11. Run commands/render-1.sh and commands/render-2.sh, diff rendered/runtime-1.yaml rendered/runtime-2.yaml, then run tht config check -c on both outputs. 12. Run the negative validate scripts and a bounded secret scan outside fixture-secrets. 13. Run ./scripts/p11-manual-acceptance.sh stop, verify cleanup of both listeners, write VERDICT.md yourself, and run cleanup only when evidence is no longer needed. `; } function ownershipValue(root, repositoryRoot, nonce, extras = {}) { return { schemaVersion: 1, kind: "p11-manual-acceptance", nonce, repositoryRoot, root, createdAt: nowIso(), status: "PENDING", listeners: { backend: { host: HOST, port: BACKEND_PORT }, frontend: { host: HOST, port: FRONTEND_PORT }, }, resources: [root, join(root, "remote.git"), join(root, "author"), join(root, "fixture-secrets")], ...extras, }; } export async function readManualOwnership({ repositoryRoot = defaultRepositoryRoot } = {}) { const repo = realpathSync(repositoryRoot); const root = fixedManualRoot(repo); noSymlinkExisting(repo, root); const rootEntry = await lstat(root); const ownershipPath = join(root, "ownership.json"); const digestPath = join(root, OWNERSHIP_DIGEST); const ownershipEntry = await lstat(ownershipPath); const digestEntry = await lstat(digestPath); if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink() || !ownershipEntry.isFile() || ownershipEntry.isSymbolicLink() || !digestEntry.isFile() || digestEntry.isSymbolicLink()) throw new Error("manual ownership is unsafe"); const ownershipBytes = await readFile(ownershipPath, "utf8"); const recordedDigest = (await readFile(digestPath, "utf8")).trim(); if (!HEX64.test(recordedDigest) || recordedDigest !== ownershipDigest(ownershipBytes)) throw new Error("manual ownership digest mismatch"); const value = JSON.parse(ownershipBytes); if (value?.schemaVersion !== 1 || value.kind !== "p11-manual-acceptance" || !HEX64.test(value.nonce ?? "") || value.repositoryRoot !== repo || value.root !== root) { throw new Error("manual ownership identity mismatch"); } return value; } async function ensureRootAbsent(root) { try { await lstat(root); throw new Error("manual acceptance root already exists"); } catch (error) { if (error.code !== "ENOENT") throw error; } } async function waitForHttp(url, timeoutMs = 15_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { await new Promise((resolvePromise, reject) => { const request = http.get(url, (response) => { response.resume(); response.statusCode && response.statusCode < 500 ? resolvePromise() : reject(new Error("not ready")); }); request.on("error", reject); }); return; } catch { await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); } } throw new Error(`timed out waiting for ${url}`); } function live(pid) { try { process.kill(pid, 0); return true; } catch { return false; } } async function writeCommands(repo, root) { const commands = [ ["http-01-status.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspace-registry/status`, join(root, "responses", "status.json"))], ["http-02-validate-p11-filesystem.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-filesystem.json"), join(root, "requests", "validate-p11-filesystem.json"))], ["http-03-validate-p11-http.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-http.json"), join(root, "requests", "validate-p11-http.json"))], ["http-04-validate-p11-s3.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-s3.json"), join(root, "requests", "validate-p11-s3.json"))], ["http-05-publish-p11-filesystem.sh", publishCurl(root, "p11-filesystem", join(root, "responses", "status.json"))], ["http-06-publish-p11-http.sh", publishCurl(root, "p11-http", join(root, "responses", "publish-p11-filesystem.json"))], ["http-07-publish-p11-s3.sh", publishCurl(root, "p11-s3", join(root, "responses", "publish-p11-http.json"))], ["http-08-pull.sh", curlPostEmpty(`http://${HOST}:${BACKEND_PORT}/workspace-registry/pull`, join(root, "responses", "pull.json"))], ["http-09-read-p11-filesystem.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspaces/p11-filesystem`, join(root, "responses", "read-p11-filesystem.json"))], ["http-10-export-p11-filesystem.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspaces/p11-filesystem/export`, join(root, "exports", "raw", "p11-filesystem.zip"))], ["http-11-negative-invalid-uri.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "negative-invalid-uri.json"), join(root, "requests", "negative-invalid-uri.json"))], ["http-12-negative-secret-field.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "negative-secret-field.json"), join(root, "requests", "negative-secret-field.json"))], ["render-1.sh", renderCommand(repo, root, 1)], ["render-2.sh", renderCommand(repo, root, 2)], ]; for (const [name, body] of commands) { const path = join(root, "commands", name); await atomicWrite(path, body, 0o700); } } export async function prepareManual({ repositoryRoot = defaultRepositoryRoot } = {}) { const repo = realpathSync(repositoryRoot); const root = fixedManualRoot(repo); noSymlinkExisting(repo, root); await ensureRootAbsent(root); await mkdir(join(repo, ".artifacts", "manual-acceptance"), { recursive: true, mode: 0o700 }); await mkdir(root, { mode: 0o700 }); const executables = resolveExecutables(repo); const nonce = randomBytes(32).toString("hex"); await writeManualOwnership(root, ownershipValue(root, repo, nonce)); for (const path of ["fixture-secrets", "requests", "responses", "commands", "rendered", "logs", "exports/raw", "exports/extracted", "installation/registry", "installation/data", "installation/runtime"]) { await mkdir(join(root, path), { recursive: true, mode: path === "fixture-secrets" ? 0o700 : 0o755 }); } const env = buildSafeEnvironment({ ambient: process.env, fixture: { PATH: dirname(executables.gitPath) } }); await git(executables.gitPath, ["init", "--bare", "--initial-branch=main", join(root, "remote.git")], { cwd: root, env }); await git(executables.gitPath, ["clone", join(root, "remote.git"), join(root, "author")], { cwd: root, env }); await git(executables.gitPath, ["config", "user.name", "P1 Fixture Curator"], { cwd: join(root, "author"), env }); await git(executables.gitPath, ["config", "user.email", "p1-curator@example.invalid"], { cwd: join(root, "author"), env }); const items = descriptors(); await atomicWrite(join(root, "author", "thoth-workspaces.yaml"), `${JSON.stringify(catalog(items), null, 2)}\n`, 0o644); await mkdir(join(root, "author", "p11-filesystem", "evidence", "domain"), { recursive: true }); await atomicWrite(join(root, "author", "p11-filesystem", "evidence", "guide.md"), "# P1.1 curated Evidence\n", 0o644); await atomicWrite(join(root, "author", "p11-filesystem", "evidence", "domain", "table.md"), "# Curated table\n", 0o644); await git(executables.gitPath, ["add", "thoth-workspaces.yaml"], { cwd: join(root, "author"), env }); await git(executables.gitPath, ["add", "-A", "p11-filesystem/evidence"], { cwd: join(root, "author"), env }); await git(executables.gitPath, ["commit", "-m", "Bootstrap curated P1 content"], { cwd: join(root, "author"), env }); await git(executables.gitPath, ["push", "origin", "main"], { cwd: join(root, "author"), env }); const secrets = { dwh: join(root, "fixture-secrets", "dwh-password"), signed: join(root, "fixture-secrets", "evidence-signed-urls.json"), access: join(root, "fixture-secrets", "evidence-access"), secret: join(root, "fixture-secrets", "evidence-secret"), session: join(root, "fixture-secrets", "evidence-session"), }; await atomicWrite(secrets.dwh, "manual-dwh-secret", 0o600); await atomicWrite(secrets.signed, JSON.stringify(["https://evidence.example.test/guide.md?token=manual"]), 0o600); await atomicWrite(secrets.access, "manual-access", 0o600); await atomicWrite(secrets.secret, "manual-secret", 0o600); await atomicWrite(secrets.session, "manual-session", 0o600); const bindings = {}; for (const workspace of items) { const prefix = `THT_WS_${namespace(workspace.workspace.id)}`; Object.assign(bindings, { [`${prefix}_DWH_TRANSPORT`]: "postgres_direct", [`${prefix}_DWH_HOST`]: "dwh.invalid", [`${prefix}_DWH_PORT`]: "5432", [`${prefix}_DWH_USER`]: "reader", [`${prefix}_DWH_PASSWORD_FILE`]: secrets.dwh, }); } Object.assign(bindings, { THT_WS_P11_HTTP_EVIDENCE_SIGNED_URLS_FILE: secrets.signed, THT_WS_P11_S3_EVIDENCE_ACCESS_KEY_FILE: secrets.access, THT_WS_P11_S3_EVIDENCE_SECRET_KEY_FILE: secrets.secret, THT_WS_P11_S3_EVIDENCE_SESSION_TOKEN_FILE: secrets.session, }); await atomicWrite(join(root, "installation", "bindings.env"), `${Object.entries(bindings).map(([key, value]) => `${key}=${value}`).join("\n")}\n`); await atomicWrite(join(root, "installation", "runtime", "base.yaml"), "{}\n"); for (const [name, value] of Object.entries(requestFixtures(items))) await atomicWrite(join(root, "requests", name), `${JSON.stringify(value, null, 2)}\n`, 0o600); await writeCommands(repo, root); await atomicWrite(join(root, "GUIDE.md"), guide(root), 0o600); await atomicWrite(join(root, "logs", "backend.log"), "", 0o600); const current = await readManualOwnership({ repositoryRoot: repo }); current.status = "PENDING"; current.requestFixtures = Object.keys(requestFixtures(items)); current.commandScripts = (await readdir(join(root, "commands"))).sort(); await writeManualOwnership(root, current); return root; } export async function serveManual({ repositoryRoot = defaultRepositoryRoot } = {}) { const repo = realpathSync(repositoryRoot); const root = fixedManualRoot(repo); const owned = await readManualOwnership({ repositoryRoot: repo }); if (owned.status === "RUNNING") throw new Error("manual acceptance is already serving"); await access(join(repo, "backend", "dist", "server.js")); await access(join(repo, "frontend", "dist", "index.html")); const executables = resolveExecutables(repo); const logHandle = await open(join(root, "logs", "backend.log"), fsConstants.O_WRONLY | fsConstants.O_APPEND); const homeDir = join(root, "installation", "runtime", "home"); const tmpDir = join(root, "installation", "runtime", "tmp"); await mkdir(homeDir, { recursive: true, mode: 0o700 }); await mkdir(tmpDir, { recursive: true, mode: 0o700 }); const fixtureEnv = { PATH: `${dirname(executables.gitPath)}:${dirname(executables.pythonPath)}:${dirname(executables.thtPath)}:/usr/bin:/bin`, HOME: homeDir, TMPDIR: tmpDir, HOST, PORT: String(BACKEND_PORT), AUTH_MODE: "none", THT_BIN: executables.thtPath, THT_HARNESS_DIR: join(repo, "harness"), THT_DATA_ROOT: join(root, "installation", "data"), SETTINGS_FILE: join(root, "installation", "data", "settings.json"), MAINTENANCE_STATE_FILE: join(root, "installation", "data", "maintenance.json"), THT_WORKSPACE_REGISTRY_ROOT: join(root, "installation", "registry"), THT_WORKSPACE_GIT_REMOTE: join(root, "remote.git"), THT_WORKSPACE_GIT_BRANCH: "main", THT_WORKSPACE_GIT_AUTHOR_NAME: "P1 API Publisher", THT_WORKSPACE_GIT_AUTHOR_EMAIL: "p1-api@example.invalid", THT_WORKSPACE_INSTALLATION_ID: "p11-manual-acceptance", THT_WORKSPACE_SECRET_ROOTS: join(root, "fixture-secrets"), THT_HOME: join(root, "installation", "runtime", "tht-home"), PYTHONDONTWRITEBYTECODE: "1", PYTHONNOUSERSITE: "1", }; const bindingEnv = Object.fromEntries((await readFile(join(root, "installation", "bindings.env"), "utf8")).trim().split(/\n+/).map((line) => line.split(/=(.+)/))); const env = buildSafeEnvironment({ ambient: process.env, fixture: { ...fixtureEnv, ...bindingEnv } }); const backend = spawn(process.execPath, [join(repo, "backend", "dist", "server.js")], { cwd: repo, env, stdio: ["ignore", logHandle.fd, logHandle.fd], detached: true }); const frontend = spawn(executables.pythonPath, ["-m", "http.server", String(FRONTEND_PORT), "--bind", HOST, "--directory", join(repo, "frontend", "dist")], { cwd: repo, env, stdio: ["ignore", "ignore", "ignore"], detached: true }); backend.unref(); frontend.unref(); await waitForHttp(`http://${HOST}:${BACKEND_PORT}/health`); await waitForHttp(`http://${HOST}:${FRONTEND_PORT}/`); await logHandle.close(); owned.status = "RUNNING"; owned.backend = { pid: backend.pid, port: BACKEND_PORT, command: [process.execPath, join(repo, "backend", "dist", "server.js")] }; owned.frontend = { pid: frontend.pid, port: FRONTEND_PORT, command: [executables.pythonPath, "-m", "http.server", String(FRONTEND_PORT)] }; await writeManualOwnership(root, owned); return owned; } async function processCommandMatches(pid, expectedCommand) { if (!Array.isArray(expectedCommand) || expectedCommand.length === 0) return false; let output; try { const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }); output = stdout.trim(); } catch { return false; } if (output.length === 0) return false; // The recorded command is the argv array used to spawn the process; verify every token appears // in the current command line in order, so a reused PID with unrelated command is refused. let cursor = 0; for (const token of expectedCommand) { if (token.length === 0) continue; const index = output.indexOf(token, cursor); if (index < 0) return false; cursor = index + token.length; } return true; } export async function stopManual({ repositoryRoot = defaultRepositoryRoot } = {}) { const repo = realpathSync(repositoryRoot); const root = fixedManualRoot(repo); const owned = await readManualOwnership({ repositoryRoot: repo }); if (owned.status !== "RUNNING" || !owned.backend?.pid || !owned.frontend?.pid) throw new Error("manual acceptance is not running"); for (const pid of [owned.backend.pid, owned.frontend.pid]) { try { process.kill(-pid, "SIGTERM"); } catch (error) { if (error?.code !== "ESRCH") throw error; } } const deadline = Date.now() + 15_000; while (Date.now() < deadline && (live(owned.backend.pid) || live(owned.frontend.pid))) await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); owned.status = "STOPPED"; await writeManualOwnership(root, owned); return owned; } export async function cleanupManual({ repositoryRoot = defaultRepositoryRoot } = {}) { const repo = realpathSync(repositoryRoot); const root = fixedManualRoot(repo); const owned = await readManualOwnership({ repositoryRoot: repo }); if (owned.status === "RUNNING") throw new Error("manual acceptance is still live"); if (owned.backend?.pid && live(owned.backend.pid)) throw new Error("backend process is still live"); if (owned.frontend?.pid && live(owned.frontend.pid)) throw new Error("frontend process is still live"); const parent = dirname(root); const tombstone = join(parent, `.deleting-p11-${owned.nonce.slice(0, 16)}`); await rename(root, tombstone); await rm(tombstone, { recursive: true, force: false }); } export async function main(argv = process.argv.slice(2)) { if (argv.length !== 1 || !["prepare", "serve", "stop", "cleanup"].includes(argv[0])) throw new Error("usage: p11-manual-acceptance.mjs prepare|serve|stop|cleanup"); switch (argv[0]) { case "prepare": await prepareManual(); break; case "serve": await serveManual(); break; case "stop": await stopManual(); break; case "cleanup": await cleanupManual(); break; } } if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) { try { await main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } }