Files
ThothII/backend/scripts/p11-manual-acceptance.mjs
T

400 lines
26 KiB
JavaScript

#!/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: 4, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" },
dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] },
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; }
}