test: add P1 manual configuration walkthrough

This commit is contained in:
2026-08-09 21:12:08 +02:00
parent d27be56d5c
commit 74ddb86d13
7 changed files with 460 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env node
import { execFile, spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { closeSync, constants, fsyncSync, lstatSync, openSync, realpathSync } from "node:fs";
import { access, chmod, lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const exec = promisify(execFile); const modulePath=fileURLToPath(import.meta.url); const defaultRepositoryRoot=realpathSync(resolve(dirname(modulePath),"../.."));
const HEX64=/^[0-9a-f]{64}$/; const PORT=8791; const HOST="127.0.0.1";
export function fixedManualRoot(repositoryRoot=defaultRepositoryRoot){return join(realpathSync(repositoryRoot),".artifacts","manual-acceptance","p1");}
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);try{if(lstatSync(cursor).isSymbolicLink())throw new Error("owned root ancestor is a symlink");}catch(error){if(error.code==="ENOENT")break;throw error;}}}
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 h;try{h=await open(staging,"wx",mode);await h.writeFile(bytes);await h.sync();await h.close();h=undefined;await rename(staging,path);const fd=openSync(dirname(path),constants.O_RDONLY);try{fsyncSync(fd);}finally{closeSync(fd);}}finally{if(h)await h.close().catch(()=>{});await rm(staging,{force:true}).catch(()=>{});}}
function ownedValue(repo,root,nonce){return{schemaVersion:1,kind:"p1-manual-acceptance",nonce,repositoryRoot:repo,root,status:"PENDING",createdAt:new Date().toISOString(),listener:{host:HOST,port:PORT,state:"stopped"},resources:[root,{kind:"fastify",host:HOST,port:PORT}]};}
export async function readManualOwnership({repositoryRoot=defaultRepositoryRoot}={}){const repo=realpathSync(repositoryRoot),root=fixedManualRoot(repo);noSymlinkExisting(repo,root);let rootEntry,ownershipEntry;try{rootEntry=await lstat(root);ownershipEntry=await lstat(join(root,"ownership.json"));}catch{throw new Error("manual ownership is missing");}if(!rootEntry.isDirectory()||rootEntry.isSymbolicLink()||await realpath(root)!==root||!ownershipEntry.isFile()||ownershipEntry.isSymbolicLink())throw new Error("manual ownership is unsafe");let value;try{value=JSON.parse(await readFile(join(root,"ownership.json"),"utf8"));}catch{throw new Error("manual ownership is malformed");}const expected={...ownedValue(repo,root,value.nonce),createdAt:value.createdAt,listener:value.listener};if(value.schemaVersion!==1||value.kind!=="p1-manual-acceptance"||!HEX64.test(value.nonce??"")||value.repositoryRoot!==repo||value.root!==root||value.status!=="PENDING"||value.listener?.host!==HOST||value.listener?.port!==PORT||!value.createdAt||JSON.stringify(value.resources)!==JSON.stringify(expected.resources))throw new Error("manual ownership identity mismatch");return value;}
async function run(executable,argv,options={}){return await exec(executable,argv,{...options,maxBuffer:2*1024*1024,encoding:"utf8"});}
function descriptor(id,source){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,policy:{max_chunk_chars:4000,retain_published_generations:3}}};}
function descriptors(){return[descriptor("p1-filesystem",{type:"filesystem",uri:"workspace-content/p1-filesystem/evidence",patterns:["**/*.md"],max_bytes:10485760}),descriptor("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}),descriptor("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 quote(value){return `'${String(value).replaceAll("'",`'"'"'`)}'`;}
async function checkPrerequisites(repo){for(const path of ["scripts/p1-acceptance.sh","scripts/test-p1-acceptance.sh","backend/scripts/p1-acceptance.mjs","backend/dist/server.js"]){try{await access(join(repo,path));}catch{throw new Error(`Task 8 prerequisite is missing: ${path}`);}}for(const command of ["node","npm","git","curl","unzip","zipinfo","lsof"]){try{await run(command,[command==="unzip"||command==="lsof"?"-v":command==="zipinfo"?"-h":"--version"]);}catch{throw new Error(`missing prerequisite: ${command}`);}}const tht=join(repo,"harness",".venv","bin","tht");try{await access(tht,constants.X_OK);}catch{throw new Error("missing prerequisite: harness/.venv/bin/tht");}}
async function initializeGit(root){await run("git",["init","--bare","--initial-branch=main",join(root,"remote.git")],{cwd:root});await run("git",["clone",join(root,"remote.git"),join(root,"author")],{cwd:root});for(const [key,value]of [["user.name","P1 Manual Curator"],["user.email","p1-manual@example.invalid"]])await run("git",["config",key,value],{cwd:join(root,"author")});const evidence=join(root,"author","workspace-content","p1-filesystem","evidence");await mkdir(join(evidence,"domain"),{recursive:true});await writeFile(join(evidence,"guide.md"),"# P1 manually curated Evidence\n");await writeFile(join(evidence,"domain","table.md"),"# P1 curated table\n");await run("git",["add","workspace-content"],{cwd:join(root,"author")});await run("git",["commit","-m","Bootstrap P1 manual Evidence"],{cwd:join(root,"author")});await run("git",["push","origin","main"],{cwd:join(root,"author")});}
function requestFixtures(items){const result={"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;result[`validate-${id}.json`]={workspace};result[`publish-${id}.json`]={action:"create",workspace};result[`read-${id}.json`]={method:"GET",path:`/workspaces/${id}`};result[`export-${id}.json`]={method:"GET",path:`/workspaces/${id}/export`};}Object.assign(result,{"invalid-absolute.json":{workspace:{...items[0],evidence:{...items[0].evidence,source:{...items[0].evidence.source,uri:"/etc"}}}},"invalid-traversal.json":{workspace:{...items[0],evidence:{...items[0].evidence,source:{...items[0].evidence.source,uri:"workspace-content/p1-filesystem/evidence/../../p1-s3/evidence"}}}},"invalid-cross-workspace.json":{workspace:{...items[0],evidence:{...items[0].evidence,source:{...items[0].evidence.source,uri:"workspace-content/p1-s3/evidence"}}}},"invalid-protocol.json":{workspace:{...items[1],evidence:{...items[1].evidence,source:{...items[1].evidence.source,uris:["file:///etc/passwd"]}}}},"invalid-credential.json":{workspace:{...items[2],evidence:{...items[2].evidence,source:{...items[2].evidence.source,access_key:"CANARY-MUST-BE-REJECTED"}}}}});return result;}
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,"fixtures/descriptors",`${id}.json`),body=join(root,"requests",`publish-${id}.concrete.json`),response=join(root,"responses",`publish-${id}.json`);return `#!/usr/bin/env bash
set -euo pipefail
node --input-type=module - ${quote(previousResponse)} ${quote(descriptor)} ${quote(body)} <<'NODE'
import { open, readFile, rename, stat } from "node:fs/promises";import { basename, dirname, join } from "node:path";import { randomBytes } from "node:crypto";
const [priorPath,descriptorPath,output]=process.argv.slice(2);const bounded=async(path)=>{let s;try{s=await stat(path);}catch{throw Error("required saved response is missing");}if(!s.isFile()||s.size<2||s.size>1048576)throw Error("saved response is unbounded");let value;try{value=JSON.parse(await readFile(path,"utf8"));}catch{throw Error("saved response is malformed JSON");}return value;};
const prior=await bounded(priorPath),workspace=await bounded(descriptorPath);const base=prior.head??prior.revision?.commit;if(!/^[0-9a-f]{40}$/.test(base??""))throw Error("saved response has no valid current base commit");const bytes=JSON.stringify({action:"create",workspace,baseCommit:base},null,2)+"\\n",tmp=join(dirname(output),"."+basename(output)+"."+randomBytes(8).toString("hex")+".tmp");const h=await open(tmp,"wx",0o600);try{await h.writeFile(bytes);await h.sync();}finally{await h.close();}await rename(tmp,output);
NODE
curl --fail-with-body --silent --show-error --request POST --header 'content-type: application/json' --data-binary @${quote(body)} --output ${quote(response)} --write-out 'HTTP %{http_code}\n' 'http://127.0.0.1:8791/workspaces/publish'
`;}
function httpCommands(root){const base="http://127.0.0.1:8791",entries=[];entries.push(["http-01-status.sh",curlGet(`${base}/workspace-registry/status`,join(root,"responses/status.json"))]);let n=2;for(const id of ["p1-filesystem","p1-http","p1-s3"])entries.push([`http-0${n++}-validate-${id}.sh`,curlPost(`${base}/workspaces/validate`,join(root,"responses",`validate-${id}.json`),join(root,"requests",`validate-${id}.json`))]);let prior=join(root,"responses/status.json");for(const id of ["p1-filesystem","p1-http","p1-s3"]){entries.push([`http-0${n++}-publish-${id}.sh`,publishCurl(root,id,prior)]);prior=join(root,"responses",`publish-${id}.json`);}entries.push([`http-0${n++}-pull.sh`,curlPostEmpty(`${base}/workspace-registry/pull`,join(root,"responses/pull.json"))]);for(const id of ["p1-filesystem","p1-http","p1-s3"])entries.push([`http-${String(n++).padStart(2,"0")}-read-${id}.sh`,curlGet(`${base}/workspaces/${id}`,join(root,"responses",`read-${id}.json`))]);for(const id of ["p1-filesystem","p1-http","p1-s3"])entries.push([`http-${String(n++).padStart(2,"0")}-export-${id}.sh`,curlGet(`${base}/workspaces/${id}/export`,join(root,"exports/raw",`${id}.zip`))]);for(const kind of ["absolute","traversal","cross-workspace","protocol","credential"])entries.push([`http-${String(n++).padStart(2,"0")}-invalid-${kind}.sh`,curlPost(`${base}/workspaces/validate`,join(root,"responses",`invalid-${kind}.json`),join(root,"requests",`invalid-${kind}.json`))]);return entries;}
function renderCommand(repo,root,n){const output=join(root,"rendered",`runtime-${n}.yaml`),response=join(root,"responses","read-p1-filesystem.json"),published=join(root,"responses","pull.json"),snapshots=join(root,"installation","registry","snapshots"),checkout=join(root,"installation","registry","repo");return `#!/usr/bin/env bash
set -euo pipefail
repo=${quote(repo)}
root=${quote(root)}
set -a
. ${quote(join(root,"installation","bindings.env"))}
set +a
node --input-type=module - "$root" ${quote(response)} ${quote(published)} ${quote(snapshots)} ${quote(checkout)} ${quote(output)} "$repo/backend/scripts/p1-render-snapshot.mjs" <<'NODE'
import { readFile, realpath, stat } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { spawnSync } from "node:child_process";
const [root,readPath,publishPath,snapshots,checkout,output,renderer]=process.argv.slice(2);
const bounded=async(path)=>{let s;try{s=await stat(path);}catch{throw new Error("saved response is missing or unbounded");}if(!s.isFile()||s.size<2||s.size>1048576)throw new Error("saved response is missing or unbounded");let v;try{v=JSON.parse(await readFile(path,"utf8"));}catch{throw new Error("saved response is malformed JSON");}return v;};
const read=await bounded(readPath),published=await bounded(publishPath);const revision=read?.revision,commit=revision?.commit,snapshot=revision?.snapshotPath,publishedCommit=published?.head??published?.revision?.commit;
if(!/^[0-9a-f]{40}$/.test(commit??"")||commit!==publishedCommit)throw new Error("saved read/publish revisions differ");
if(typeof snapshot!=="string"||!isAbsolute(snapshot))throw new Error("snapshot path is not absolute");const canonical=await realpath(snapshot);const rel=relative(snapshots,canonical);if(rel.startsWith("..")||isAbsolute(rel)||dirname(canonical)!==resolve(snapshots,commit))throw new Error("snapshot escapes owned commit root");
const git=spawnSync("git",["-C",checkout,"rev-parse","HEAD"],{encoding:"utf8"});if(git.status!==0||git.stdout.trim()!==commit)throw new Error("saved revision differs from installed Git commit");
const child=spawnSync(process.execPath,[renderer,"--ownership",resolve(root,"ownership.json"),"--snapshot",canonical,"--output",output],{stdio:"inherit",env:process.env});if(child.status!==0)process.exit(child.status??1);
NODE
`;}
function guide(repo,root){const base=`http://${HOST}:${PORT}`;return `# P1 manual configuration walkthrough
Status: **PENDING**. The reviewer, not this helper, performs and judges every step. Never inspect raw secret-file contents.
1. Inspect \`${root}/ownership.json\`, the pre-publication Evidence tree under \`author/workspace-content/p1-filesystem/evidence\`, descriptor fixtures, and binding **paths and modes** in \`installation/bindings.env\`.
2. Run \`${repo}/scripts/p1-manual-acceptance.sh serve\`; verify only \`${HOST}:${PORT}\` listens (for example, \`lsof -nP -iTCP:${PORT} -sTCP:LISTEN\`).
3. Personally run each concrete \`commands/http-01-*.sh\` through \`commands/http-14-*.sh\` script, one at a time in numeric order: real curl status → three validates → three sequential publishes → pull → three reads → three exports against \`${base}\`. Each script saves the exact JSON response under \`responses/\` or ZIP bytes under \`exports/raw/\`; each publish derives its current base from the preceding bounded saved response. Do not advance on a non-2xx response.
4. Only after publish, run \`commands/git-inspect.sh <published-commit>\`: inspect \`git log\`, \`git ls-tree\`, \`git show <published-commit>:workspaces/<id>.yaml\`, and \`git show <published-commit>:workspace-content/<id>/evidence/...\` at that same commit.
5. Inspect generated \`workspace-docs\`, the immutable commit-addressed descriptor snapshot, and its \`snapshot.json\` manifest.
6. Run \`commands/extract-export.sh <zip> <new-output-dir>\` to safely extract the ZIP; verify manifest hashes and absence of Evidence bytes and secret/canary material.
7. After saving \`responses/read-p1-filesystem.json\` and the final API/Git head in \`responses/pull.json\`, run \`commands/render-1.sh\`, \`commands/render-2.sh\`, then \`commands/diff-rendered.sh\`.
8. Inspect runtime identity, absolute reserved filesystem root, Evidence limits, and policy in the rendered YAML; do not inspect secret contents.
9. Personally execute \`${repo}/harness/.venv/bin/tht config check -c ${root}/rendered/runtime-1.yaml\` and the same command for \`runtime-2.yaml\` (or run \`commands/config-check.sh\`).
10. Personally run \`commands/http-15-*.sh\` through \`commands/http-19-*.sh\` to submit the invalid absolute, Evidence-URI traversal, cross-workspace, protocol, and credential validation requests; verify safe rejection, no Git/snapshot mutation, and no rejected canary outside the request fixture.
11. Run \`commands/secret-scan.sh\`; it excludes \`fixture-secrets\` and checks for canary patterns without displaying secret contents.
12. Run \`commands/absence-check.sh\`; confirm no preprocessing, Evidence materialization, embedding, Qdrant, ACTIVE, or retention artifact exists.
13. Run \`${repo}/scripts/p1-manual-acceptance.sh stop\`; confirm \`backend.pid\` and the listener on port ${PORT} are gone.
14. Create \`${root}/VERDICT.md\` yourself with reviewer, UTC time, every checklist result, observations, and exactly either \`manual acceptance: PASS\` or \`manual acceptance: FAIL\`.
Preserve a failed lab by stopping it and leaving the owned root in place. Only \`cleanup\` removes this exact stopped lab.
`;}
async function writeCommands(repo,root){const commands=join(root,"commands");for(const [name,body]of [...httpCommands(root),["render-1.sh",renderCommand(repo,root,1)],["render-2.sh",renderCommand(repo,root,2)],["diff-rendered.sh",`#!/bin/sh\nset -eu\ndiff -u ${quote(join(root,"rendered/runtime-1.yaml"))} ${quote(join(root,"rendered/runtime-2.yaml"))}\n`],["config-check.sh",`#!/bin/sh\nset -eu\n${quote(join(repo,"harness/.venv/bin/tht"))} config check -c ${quote(join(root,"rendered/runtime-1.yaml"))}\n${quote(join(repo,"harness/.venv/bin/tht"))} config check -c ${quote(join(root,"rendered/runtime-2.yaml"))}\n`],["git-inspect.sh",`#!/bin/sh\nset -eu\ncommit=\${1:?published commit required}\ncase "$commit" in *[!0-9a-f]*|'') exit 2;; esac\n[ \${#commit} -eq 40 ] || exit 2\ngit -C ${quote(join(root,"installation/registry/repo"))} log --oneline --decorate -10 "$commit"\ngit -C ${quote(join(root,"installation/registry/repo"))} ls-tree -r "$commit" -- workspaces workspace-content\ngit -C ${quote(join(root,"installation/registry/repo"))} show "$commit:workspaces/p1-filesystem.yaml"\ngit -C ${quote(join(root,"installation/registry/repo"))} show "$commit:workspace-content/p1-filesystem/evidence/guide.md"\n`],["extract-export.sh",`#!/usr/bin/env bash\nset -euo pipefail\nzip=\${1:?zip required}; out=\${2:?new output required}\n[[ "$out" == ${quote(join(root,"exports/extracted"))}/* && ! -e "$out" ]] || { echo unsafe-output >&2; exit 2; }\nentries=$(unzip -Z1 "$zip"); [[ "$entries" == $'README.md\\ncontract.env.example\\nmanifest.json\\nworkspace.yaml' || "$entries" == $'manifest.json\\nworkspace.yaml\\ncontract.env.example\\nREADME.md' ]] || { echo unsafe-zip >&2; exit 2; }\nregular=$(zipinfo -l "$zip" | awk '$1 ~ /^-/ { n += 1 } END { print n + 0 }'); [[ "$regular" == 4 ]] || { echo 'ZIP contains a symlink or nonregular entry' >&2; exit 2; }\nmkdir -m 700 "$out"; unzip -q "$zip" -d "$out"\nnode --input-type=module - "$out" <<'NODE'\nimport {createHash} from 'node:crypto';import {readFile} from 'node:fs/promises';import {join} from 'node:path';const out=process.argv[2],m=JSON.parse(await readFile(join(out,'manifest.json')));for(const [n,h]of Object.entries(m.files)){const b=await readFile(join(out,n));if(createHash('sha256').update(b).digest('hex')!==h)throw Error('manifest hash mismatch');const text=b.toString('latin1');if(text.includes('P1 manually curated Evidence')||text.includes('P1 curated table')||/(?:DWH|SIGNED|ACCESS|SECRET|SESSION)-[0-9a-f]{32}/.test(text))throw Error('export contains Evidence or secret canary bytes');}\nNODE\n`],["secret-scan.sh",`#!/usr/bin/env bash
set -euo pipefail
root=${quote(root)}
node --input-type=module - "$root" <<'NODE'
import { execFileSync } from "node:child_process";import { lstat, readFile, readdir } from "node:fs/promises";import { basename, join, relative } from "node:path";
const root=process.argv[2],pattern=/(?:DWH|SIGNED|ACCESS|SECRET|SESSION)-[0-9a-f]{32}/;let found=false;
async function walk(path){for(const entry of await readdir(path,{withFileTypes:true})){const child=join(path,entry.name),rel=relative(root,child);if(entry.isSymbolicLink()){console.error("unsafe symlink during secret scan: "+rel);found=true;continue;}if(entry.isDirectory()){if(rel==="fixture-secrets"||entry.name===".git")continue;await walk(child);}else if(entry.isFile()){const stat=await lstat(child);if(stat.size>33554432)throw Error("secret scan file too large: "+rel);if(pattern.test((await readFile(child)).toString("latin1"))&&!rel.endsWith("requests/invalid-credential.json")){console.error("secret canary found: "+rel);found=true;}}}}
function git(args,label){const objects=execFileSync("git",[...args,"rev-list","--objects","--all"],{encoding:"utf8",maxBuffer:4*1024*1024}).trim().split("\\n").filter(Boolean);for(const line of objects){const oid=line.split(" ",1)[0],type=execFileSync("git",[...args,"cat-file","-t",oid],{encoding:"utf8"}).trim();if(type!=="blob")continue;const size=Number(execFileSync("git",[...args,"cat-file","-s",oid],{encoding:"utf8"}));if(!Number.isSafeInteger(size)||size>33554432)throw Error("Git blob is too large to scan in "+label);const blob=execFileSync("git",[...args,"cat-file","blob",oid],{maxBuffer:33554433});if(pattern.test(blob.toString("latin1"))){console.error("secret canary found in reachable Git blob: "+label+":"+oid);found=true;}}}
await walk(root);git(["--git-dir",join(root,"remote.git")],"remote.git");git(["-C",join(root,"author")],"author");git(["-C",join(root,"installation/registry/repo")],"installed-registry");if(found)process.exit(1);console.log("no fixture secret canary outside fixture-secrets or in reachable Git blobs");
NODE
`],["absence-check.sh",`#!/usr/bin/env bash\nset -euo pipefail\nroot=${quote(root)}\nif find "$root" -path '*/.git' -prune -o -type f \\( -iname '*preprocess*' -o -iname '*embedding*' -o -iname '*qdrant*' -o -iname '*retention*' -o -iname '*active*' \\) -print | grep .; then echo 'unexpected P1-scope artifact' >&2; exit 1; fi\necho 'no out-of-scope runtime artifact found'\n`]]){await atomicWrite(join(commands,name),body,0o700);await chmod(join(commands,name),0o700);}}
export async function prepareManual({repositoryRoot=defaultRepositoryRoot,skipBuild=false}={}){const repo=realpathSync(repositoryRoot),root=fixedManualRoot(repo);await checkPrerequisites(repo);if(!skipBuild)await run("npm",["--prefix",join(repo,"backend"),"run","build"]);noSymlinkExisting(repo,root);await mkdir(dirname(root),{recursive:true,mode:0o700});noSymlinkExisting(repo,root);try{await mkdir(root,{recursive:false,mode:0o700});}catch(error){if(error.code==="EEXIST")throw new Error("manual acceptance root already exists; stop/cleanup it explicitly");throw error;}const nonce=randomBytes(32).toString("hex");await atomicWrite(join(root,"ownership.json"),`${JSON.stringify(ownedValue(repo,root,nonce),null,2)}\n`);for(const path of ["installation/registry","installation/data","installation/runtime","fixture-secrets","fixtures/descriptors","requests","responses","exports/raw","exports/extracted","rendered","logs","commands"])await mkdir(join(root,path),{recursive:true,mode:path==="fixture-secrets"?0o700:0o755});await initializeGit(root);const items=descriptors();for(const workspace of items)await atomicWrite(join(root,"fixtures/descriptors",`${workspace.workspace.id}.json`),`${JSON.stringify(workspace,null,2)}\n`);const secrets={"dwh-password":`DWH-${randomBytes(16).toString("hex")}`,"evidence-signed-urls.json":JSON.stringify([`https://evidence.example.test/guide.md?token=SIGNED-${randomBytes(16).toString("hex")}`]),"evidence-access":`ACCESS-${randomBytes(16).toString("hex")}`,"evidence-secret":`SECRET-${randomBytes(16).toString("hex")}`,"evidence-session":`SESSION-${randomBytes(16).toString("hex")}`};for(const[name,value]of Object.entries(secrets))await atomicWrite(join(root,"fixture-secrets",name),value,0o600);const env={};for(const workspace of items){const ns=workspace.workspace.id.toUpperCase().replaceAll("-","_"),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`]:join(root,"fixture-secrets/dwh-password")});}Object.assign(env,{THT_WORKSPACE_SECRET_ROOTS:join(root,"fixture-secrets"),THT_WS_P1_HTTP_EVIDENCE_SIGNED_URLS_FILE:join(root,"fixture-secrets/evidence-signed-urls.json"),THT_WS_P1_S3_EVIDENCE_ACCESS_KEY_FILE:join(root,"fixture-secrets/evidence-access"),THT_WS_P1_S3_EVIDENCE_SECRET_KEY_FILE:join(root,"fixture-secrets/evidence-secret"),THT_WS_P1_S3_EVIDENCE_SESSION_TOKEN_FILE:join(root,"fixture-secrets/evidence-session")});await atomicWrite(join(root,"installation/bindings.env"),Object.entries(env).map(([k,v])=>`${k}=${quote(v)}`).join("\n")+"\n");await atomicWrite(join(root,"installation/base.yaml"),"{}\n");for(const[name,value]of Object.entries(requestFixtures(items)))await atomicWrite(join(root,"requests",name),`${JSON.stringify(value,null,2)}\n`);await writeCommands(repo,root);await atomicWrite(join(root,"GUIDE.md"),guide(repo,root),0o600);return{repositoryRoot:repo,root,nonce};}
function portAvailable(){return new Promise((resolvePromise,reject)=>{const server=net.createServer();server.once("error",error=>error.code==="EADDRINUSE"?reject(new Error(`${HOST}:${PORT} is occupied`)):reject(error));server.listen({host:HOST,port:PORT,exclusive:true},()=>server.close(()=>resolvePromise()));});}
async function processStart(pid){return (await run("ps",["-p",String(pid),"-o","lstart="])).stdout.trim();}
async function processArgs(pid){return (await run("ps",["-p",String(pid),"-o","command="])).stdout.trim();}
async function processCwd(pid){try{return await realpath(`/proc/${pid}/cwd`);}catch{try{const out=(await run("lsof",["-a","-p",String(pid),"-d","cwd","-Fn"])).stdout.split("\n").find(x=>x.startsWith("n"));return out?await realpath(out.slice(1)):"";}catch{return"";}}}
async function processExecutable(pid){try{return await realpath(`/proc/${pid}/exe`);}catch{try{const paths=(await run("lsof",["-a","-p",String(pid),"-d","txt","-Fn"])).stdout.split("\n").filter(x=>x.startsWith("n")).map(x=>x.slice(1));for(const path of paths){try{const canonical=await realpath(path);if(canonical===realpathSync(process.execPath))return canonical;}catch{}}return"";}catch{return"";}}}
function alive(pid){try{process.kill(pid,0);return true;}catch{return false;}}
async function readPid(root){const path=join(root,"backend.pid"),entry=await lstat(path);if(!entry.isFile()||entry.isSymbolicLink())throw new Error("backend PID record is unsafe");let value;try{value=JSON.parse(await readFile(path,"utf8"));}catch{throw new Error("backend PID record is malformed");}return value;}
async function validateProcess(repo,root,owned,pidRecord){if(!Number.isSafeInteger(pidRecord.pid)||pidRecord.pid<2||pidRecord.nonce!==owned.nonce||pidRecord.root!==root||pidRecord.repositoryRoot!==repo||pidRecord.executable!==process.execPath||pidRecord.script!==join(repo,"backend/dist/server.js")||!pidRecord.startIdentity)throw new Error("backend PID identity mismatch");if(!alive(pidRecord.pid))throw new Error("backend PID is stale; operator inspection required");const[start,args,cwd,executable]=await Promise.all([processStart(pidRecord.pid),processArgs(pidRecord.pid),processCwd(pidRecord.pid),processExecutable(pidRecord.pid)]);if(start!==pidRecord.startIdentity||cwd!==repo||executable!==realpathSync(pidRecord.executable)||!args.includes(pidRecord.script)||!args.includes(`--p1-manual-nonce=${owned.nonce}`)||!args.includes(`--p1-root=${root}`))throw new Error("backend process identity mismatch; refusing to signal");return true;}
export async function serveManual({repositoryRoot=defaultRepositoryRoot}={}){const repo=realpathSync(repositoryRoot),owned=await readManualOwnership({repositoryRoot:repo}),root=owned.root;if(owned.listener.host!==HOST||owned.listener.port!==PORT)throw new Error("non-loopback or unexpected bind refused");try{await lstat(join(root,"backend.pid"));throw new Error("backend PID record already exists; stale/live identity must be resolved");}catch(error){if(error.code!=="ENOENT")throw error;}await portAvailable();const stdout=openSync(join(root,"logs/backend.stdout.log"),constants.O_WRONLY|constants.O_CREAT|constants.O_APPEND,0o600),stderr=openSync(join(root,"logs/backend.stderr.log"),constants.O_WRONLY|constants.O_CREAT|constants.O_APPEND,0o600);await mkdir(join(root,"installation/runtime/home"),{recursive:true,mode:0o700});await mkdir(join(root,"installation/runtime/tmp"),{recursive:true,mode:0o700});const inherited={};for(const key of ["PATH","LANG","LC_ALL","TZ"])if(process.env[key]!==undefined)inherited[key]=process.env[key];const env={...inherited,HOME:join(root,"installation/runtime/home"),TMPDIR:join(root,"installation/runtime/tmp"),HOST,PORT:String(PORT),AUTH_MODE:"none",THT_BIN:join(repo,"harness/.venv/bin/tht"),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 Manual API Publisher",THT_WORKSPACE_GIT_AUTHOR_EMAIL:"p1-manual-api@example.invalid",THT_WORKSPACE_INSTALLATION_ID:"p1-manual-acceptance",THT_WORKSPACE_SECRET_ROOTS:join(root,"fixture-secrets"),THT_HOME:join(root,"installation/runtime/tht-home")};const child=spawn(process.execPath,[join(repo,"backend/dist/server.js"),`--p1-manual-nonce=${owned.nonce}`,`--p1-root=${root}`],{cwd:repo,env,detached:true,stdio:["ignore",stdout,stderr]});closeSync(stdout);closeSync(stderr);child.unref();let start="";for(let n=0;n<20;n++){try{start=await processStart(child.pid);if(start)break;}catch{}await new Promise(r=>setTimeout(r,50));}if(!start)throw new Error("backend failed before PID identity could be recorded");await atomicWrite(join(root,"backend.pid"),`${JSON.stringify({schemaVersion:1,pid:child.pid,nonce:owned.nonce,root,repositoryRoot:repo,executable:process.execPath,script:join(repo,"backend/dist/server.js"),startIdentity:start},null,2)}\n`);let ready=false;for(let n=0;n<50;n++){if(!alive(child.pid))break;try{const response=await fetch(`http://${HOST}:${PORT}/health`,{signal:AbortSignal.timeout(250)});if(response.ok){ready=true;break;}}catch{}await new Promise(r=>setTimeout(r,100));}if(!ready)throw new Error("backend readiness failed; inspect owned logs and use stop after identity review");return child.pid;}
export async function stopManual({repositoryRoot=defaultRepositoryRoot}={}){const repo=realpathSync(repositoryRoot),owned=await readManualOwnership({repositoryRoot:repo}),root=owned.root;let record;try{record=await readPid(root);}catch(error){if(error.code==="ENOENT")throw new Error("owned backend is not served");throw error;}await validateProcess(repo,root,owned,record);process.kill(record.pid,"SIGTERM");for(let n=0;n<100;n++){if(!alive(record.pid)){await rm(join(root,"backend.pid"));return;}await new Promise(r=>setTimeout(r,100));}throw new Error("owned backend did not stop after TERM; operator must intervene; PID record retained");}
export async function cleanupManual({repositoryRoot=defaultRepositoryRoot}={}){const repo=realpathSync(repositoryRoot),owned=await readManualOwnership({repositoryRoot:repo}),root=owned.root;try{const record=await readPid(root);if(alive(record.pid)){await validateProcess(repo,root,owned,record);throw new Error("owned backend is live; run stop first");}throw new Error("stale backend PID record requires operator inspection and stop validation");}catch(error){if(error.code!=="ENOENT")throw error;}if(root!==fixedManualRoot(repo)||!below(join(repo,".artifacts"),root))throw new Error("cleanup root identity mismatch");const tombstone=join(dirname(root),`.deleting-p1-${owned.nonce.slice(0,16)}`);await rename(root,tombstone);await rm(tombstone,{recursive:true});}
async function main(){const[action,...rest]=process.argv.slice(2);if(rest.length||!["prepare","serve","stop","cleanup"].includes(action??""))throw new Error("usage: p1-manual-acceptance.mjs prepare|serve|stop|cleanup");if(action==="prepare")await prepareManual({skipBuild:true});if(action==="serve")await serveManual();if(action==="stop")await stopManual();if(action==="cleanup")await cleanupManual();console.log(`P1 manual acceptance ${action}: ${action==="prepare"?"PENDING":"complete"}`);}
if(process.argv[1]&&realpathSync(process.argv[1])===modulePath)main().catch(error=>{console.error(`p1 manual acceptance refused: ${error.message}`);process.exitCode=1;});
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
import {
cleanupManual, fixedManualRoot, prepareManual, readManualOwnership, serveManual, stopManual,
} from "./p1-manual-acceptance.mjs";
const roots = [];
async function fakeRepo() {
const root = await realpath(await mkdtemp(join(tmpdir(), "p1-manual-repo-")));
roots.push(root);
for (const path of ["scripts/p1-acceptance.sh", "scripts/test-p1-acceptance.sh", "backend/scripts/p1-acceptance.mjs", "backend/dist/server.js"]) {
await mkdir(dirname(join(root, path)), { recursive: true });
await writeFile(join(root, path), path.endsWith(".sh") ? "#!/bin/sh\n" : "export {};\n", { mode: 0o700 });
}
await mkdir(join(root, "harness", ".venv", "bin"), { recursive: true });
await writeFile(join(root, "harness", ".venv", "bin", "tht"), "#!/bin/sh\n", { mode: 0o700 });
await chmod(join(root, "harness", ".venv", "bin", "tht"), 0o700);
await mkdir(join(root, "harness", "workspaces"), { recursive: true });
return root;
}
test.afterEach(async () => Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))));
test("prepare refuses a pre-existing or symlink fixed root", async () => {
const repo = await fakeRepo(); const root = fixedManualRoot(repo);
await mkdir(root, { recursive: true });
await assert.rejects(prepareManual({ repositoryRoot: repo, skipBuild: true }), /already exists/);
await rm(root, { recursive: true });
const target = `${root}-target`; await mkdir(target, { recursive: true }); await symlink(target, root);
await assert.rejects(prepareManual({ repositoryRoot: repo, skipBuild: true }), /already exists|symlink/);
});
test("prepare requires Task 8 and prerequisites before creating state", async () => {
const repo = await fakeRepo(); await rm(join(repo, "scripts", "p1-acceptance.sh"));
await assert.rejects(prepareManual({ repositoryRoot: repo, skipBuild: true }), /Task 8/);
await assert.rejects(lstat(fixedManualRoot(repo)));
});
test("prepare creates independent pending topology, fixtures, commands and guide without verdict", async () => {
const repo = await fakeRepo(); const run = await prepareManual({ repositoryRoot: repo, skipBuild: true });
assert.equal(run.root, fixedManualRoot(repo));
const owned = await readManualOwnership({ repositoryRoot: repo });
assert.equal(owned.status, "PENDING"); assert.equal(owned.listener.host, "127.0.0.1"); assert.equal(owned.listener.port, 8791);
for (const path of ["remote.git/HEAD", "author/.git", "installation/registry", "fixture-secrets/dwh-password", "fixtures/descriptors/p1-filesystem.json", "requests/status.json", "responses", "exports", "rendered", "logs", "commands/render-1.sh", "commands/render-2.sh", "GUIDE.md"]) await lstat(join(run.root, path));
await assert.rejects(lstat(join(run.root, "VERDICT.md")));
const guide = await readFile(join(run.root, "GUIDE.md"), "utf8");
let previous = -1; for (let n = 1; n <= 14; n++) { const at = guide.indexOf(`${n}. `); assert.ok(at > previous, `step ${n} ordered`); previous = at; }
assert.doesNotMatch(guide, /cat .*fixture-secrets|show.*secret contents/i);
const traversal=JSON.parse(await readFile(join(run.root,"requests","invalid-traversal.json"),"utf8")); assert.match(traversal.workspace.evidence.source.uri,/\.\./);
const bindings=await readFile(join(run.root,"installation","bindings.env"),"utf8"); assert.match(bindings,new RegExp(`^THT_WORKSPACE_SECRET_ROOTS=.*fixture-secrets`,"m")); const scan=await readFile(join(run.root,"commands","secret-scan.sh"),"utf8"),extract=await readFile(join(run.root,"commands","extract-export.sh"),"utf8"); assert.match(scan,/rev-list/); assert.match(scan,/cat-file/); assert.match(scan,/installed-registry/); assert.match(extract,/ZIP contains a symlink or nonregular entry/);
const pubFs=await readFile(join(run.root,"commands","http-05-publish-p1-filesystem.sh"),"utf8"),pubHttp=await readFile(join(run.root,"commands","http-06-publish-p1-http.sh"),"utf8"),pubS3=await readFile(join(run.root,"commands","http-07-publish-p1-s3.sh"),"utf8"); assert.match(pubFs,/responses\/status\.json/); assert.match(pubHttp,/responses\/publish-p1-filesystem\.json/); assert.match(pubS3,/responses\/publish-p1-http\.json/); assert.doesNotMatch(pubFs,/REPLACE_WITH/);
const render = await readFile(join(run.root, "commands", "render-1.sh"), "utf8");
for(const name of await readdir(join(run.root,"commands")))if(name.endsWith(".sh"))await execFileAsync("bash",["-n",join(run.root,"commands",name)]);
assert.match(render, /read-p1-filesystem\.json/); assert.match(render, /responses\/pull\.json/); assert.doesNotMatch(render, /responses\/publish-p1-filesystem\.json/); assert.match(render, /snapshotPath/); assert.match(render, /p1-render-snapshot\.mjs/);
});
test("cleanup rejects unowned, live, mismatched and symlink state and preserves siblings", async () => {
const repo = await fakeRepo(); const integration = join(repo, ".artifacts", "p1-integration"); const sibling = join(repo, ".artifacts", "manual-acceptance", "foreign");
await mkdir(integration, { recursive: true }); await writeFile(join(integration, "sentinel"), "keep");
await mkdir(sibling, { recursive: true }); await writeFile(join(sibling, "sentinel"), "keep");
await assert.rejects(cleanupManual({ repositoryRoot: repo }), /ownership|root/);
const run = await prepareManual({ repositoryRoot: repo, skipBuild: true });
const ownershipPath = join(run.root, "ownership.json"); const owned = JSON.parse(await readFile(ownershipPath)); owned.root += "-wrong"; await writeFile(ownershipPath, JSON.stringify(owned));
await assert.rejects(cleanupManual({ repositoryRoot: repo }), /identity/); assert.equal((await lstat(run.root)).isDirectory(), true);
assert.equal(await readFile(join(integration, "sentinel"), "utf8"), "keep"); assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "keep");
});
test("cleanup removes only the exact stopped owned root and never creates verdict", async () => {
const repo = await fakeRepo(); const run = await prepareManual({ repositoryRoot: repo, skipBuild: true });
await cleanupManual({ repositoryRoot: repo }); await assert.rejects(lstat(run.root));
});
async function installFakeServer(repo) {
await writeFile(join(repo, "backend", "dist", "server.js"), `import http from "node:http";
const server=http.createServer((req,res)=>{res.setHeader("content-type","application/json");res.end(JSON.stringify({status:"ok",ambient:process.env.THT_DWH_API_KEY,maintenance:process.env.MAINTENANCE_STATE_FILE,wrongMaintenance:process.env.THT_MAINTENANCE_STATE_FILE}));});
server.listen(Number(process.env.PORT),process.env.HOST);
process.on("SIGTERM",()=>server.close(()=>process.exit(0)));
`);
}
test("serve binds the one fixed loopback address, refuses a second PID, and guarded stop removes identity", { concurrency: false }, async () => {
const repo=await fakeRepo(); await installFakeServer(repo); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const priorAmbient=process.env.THT_DWH_API_KEY; process.env.THT_DWH_API_KEY="AMBIENT-MUST-NOT-PASS"; const pid=await serveManual({repositoryRoot:repo}); assert.equal(Number.isSafeInteger(pid),true);
const health=await (await fetch("http://127.0.0.1:8791/health")).json(); assert.equal(health.status,"ok"); assert.equal(health.ambient,undefined); assert.equal(health.wrongMaintenance,undefined); assert.equal(health.maintenance,join(run.root,"installation/data/maintenance.json")); if(priorAmbient===undefined)delete process.env.THT_DWH_API_KEY;else process.env.THT_DWH_API_KEY=priorAmbient;
await assert.rejects(serveManual({repositoryRoot:repo}),/PID record/);
await stopManual({repositoryRoot:repo}); await assert.rejects(lstat(join(run.root,"backend.pid")));
await assert.rejects(fetch("http://127.0.0.1:8791/health",{signal:AbortSignal.timeout(200)}));
await cleanupManual({repositoryRoot:repo});
});
test("serve refuses an occupied fixed port and never creates a PID or verdict", { concurrency: false }, async () => {
const repo=await fakeRepo(); await installFakeServer(repo); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const blocker=net.createServer(); await new Promise((resolvePromise,reject)=>blocker.once("error",reject).listen(8791,"127.0.0.1",resolvePromise));
try { await assert.rejects(serveManual({repositoryRoot:repo}),/occupied/); } finally { await new Promise(resolvePromise=>blocker.close(resolvePromise)); }
await assert.rejects(lstat(join(run.root,"backend.pid"))); await assert.rejects(lstat(join(run.root,"VERDICT.md")));
});
test("serve and cleanup refuse stale or mismatched PID records without signaling", async () => {
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
await writeFile(join(run.root,"backend.pid"),JSON.stringify({pid:999999,nonce:"wrong"}));
await assert.rejects(serveManual({repositoryRoot:repo}),/PID record/);
await assert.rejects(stopManual({repositoryRoot:repo}),/identity mismatch/);
await assert.rejects(cleanupManual({repositoryRoot:repo}),/identity|stale/);
assert.equal((await lstat(run.root)).isDirectory(),true);
});
test("generated render command validates saved responses and owned snapshot before renderer", async () => {
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}); const script=join(run.root,"commands/render-1.sh"), output=join(run.root,"rendered/runtime-1.yaml");
const invoke=()=>execFileAsync("bash",[script],{cwd:repo});
await assert.rejects(invoke(),/saved response is missing/);
await writeFile(join(run.root,"responses/read-p1-filesystem.json"),"{"); await writeFile(join(run.root,"responses/pull.json"),"{}");
await assert.rejects(invoke(),/malformed JSON/);
const a="a".repeat(40),b="b".repeat(40),outside=join(repo,"outside.yaml"); await writeFile(outside,"x");
await writeFile(join(run.root,"responses/read-p1-filesystem.json"),JSON.stringify({revision:{commit:a,snapshotPath:outside}})); await writeFile(join(run.root,"responses/pull.json"),JSON.stringify({head:b}));
await assert.rejects(invoke(),/revisions differ/);
await writeFile(join(run.root,"responses/pull.json"),JSON.stringify({head:a})); await assert.rejects(invoke(),/snapshot escapes/);
await assert.rejects(lstat(output));
});
test("generated secret scan checks reachable Git blobs without printing contents", async () => {
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}); const installed=join(run.root,"installation/registry/repo"),author=join(run.root,"author"),scan=join(run.root,"commands/secret-scan.sh");
await execFileAsync("git",["clone",join(run.root,"remote.git"),installed]); await execFileAsync("bash",[scan],{cwd:repo});
const canary="DWH-"+"c".repeat(32); await writeFile(join(author,"temporary-secret"),canary); await execFileAsync("git",["add","temporary-secret"],{cwd:author}); await execFileAsync("git",["commit","-m","temporary canary"],{cwd:author}); await execFileAsync("git",["rm","temporary-secret"],{cwd:author}); await execFileAsync("git",["commit","-m","remove canary"],{cwd:author});
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>/reachable Git blob/.test(error.stderr)&&!error.stderr.includes(canary));
});
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
import { randomBytes } from "node:crypto";
import { closeSync, constants, fsyncSync, lstatSync, openSync, realpathSync } from "node:fs";
import { chmod, lstat, mkdir, open, readFile, realpath, rename, rm } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
// This acceptance-only adapter deliberately imports the built production runner.
import { ThtRunner } from "../dist/tht/tht-runner.js";
const modulePath = fileURLToPath(import.meta.url);
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
const HEX40 = /^[0-9a-f]{40}$/;
const HEX64 = /^[0-9a-f]{64}$/;
function fixedRoot(repositoryRoot) { return join(realpathSync(repositoryRoot), ".artifacts", "manual-acceptance", "p1"); }
function below(parent, child) { const rel = relative(parent, child); return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel); }
function assertNoSymlinks(root, path, allowMissingLeaf = false) {
const rel = relative(root, path);
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("path is outside owned root");
let cursor = root;
for (const [index, part] of rel.split(sep).filter(Boolean).entries()) {
cursor = join(cursor, part);
try { if (lstatSync(cursor).isSymbolicLink()) throw new Error("owned path contains a symlink"); }
catch (error) {
if (allowMissingLeaf && error.code === "ENOENT" && index === rel.split(sep).filter(Boolean).length - 1) return;
throw error;
}
}
}
async function ownership(repositoryRoot, ownershipPath) {
const root = fixedRoot(repositoryRoot);
const expected = join(root, "ownership.json");
if (resolve(ownershipPath) !== expected) throw new Error("ownership path is not owned");
const rootEntry = await lstat(root); const ownershipEntry = await lstat(expected);
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink() || !ownershipEntry.isFile() || ownershipEntry.isSymbolicLink()) throw new Error("ownership is unsafe");
if (await realpath(root) !== root) throw new Error("ownership root is not canonical");
let value; try { value = JSON.parse(await readFile(expected, "utf8")); } catch { throw new Error("ownership is malformed"); }
if (value?.schemaVersion !== 1 || value.kind !== "p1-manual-acceptance" || !HEX64.test(value.nonce ?? "")
|| value.repositoryRoot !== realpathSync(repositoryRoot) || value.root !== root || value.status !== "PENDING"
|| value.listener?.host !== "127.0.0.1" || value.listener?.port !== 8791) throw new Error("ownership identity mismatch");
return { root, value };
}
async function atomicCopy(source, output) {
const staging = join(dirname(output), `.${basename(output)}.${randomBytes(12).toString("hex")}.tmp`);
let handle;
try {
const bytes = await readFile(source);
handle = await open(staging, "wx", 0o600); await handle.writeFile(bytes); await handle.sync(); await handle.close(); handle = undefined;
await chmod(staging, 0o600); await rename(staging, output);
const directory = openSync(dirname(output), constants.O_RDONLY); try { fsyncSync(directory); } finally { closeSync(directory); }
} finally { if (handle) await handle.close().catch(() => {}); await rm(staging, { force: true }).catch(() => {}); }
}
export async function renderOwnedSnapshot({ repositoryRoot = defaultRepositoryRoot, ownershipPath, snapshotPath, outputPath, env = process.env }) {
const repo = realpathSync(repositoryRoot); const { root } = await ownership(repo, resolve(repo, ownershipPath));
const snapshot = resolve(repo, snapshotPath); const output = resolve(repo, outputPath);
const snapshotsRoot = join(root, "installation", "registry", "snapshots");
const renderedRoot = join(root, "rendered");
if (!isAbsolute(snapshotPath) || !below(snapshotsRoot, snapshot)) throw new Error("snapshot is not an owned absolute path");
const match = /^([0-9a-f]{40})\/([a-z][a-z0-9-]{2,62})\.yaml$/.exec(relative(snapshotsRoot, snapshot).split(sep).join("/"));
if (!match || !HEX40.test(match[1])) throw new Error("snapshot is not commit addressed");
assertNoSymlinks(root, snapshot); const snapshotEntry = await lstat(snapshot);
if (!snapshotEntry.isFile() || snapshotEntry.isSymbolicLink() || await realpath(snapshot) !== snapshot) throw new Error("snapshot is unsafe");
if (!below(renderedRoot, output) || dirname(output) !== renderedRoot || !output.endsWith(".yaml")) throw new Error("output is not an owned rendered path");
assertNoSymlinks(root, dirname(output));
try { if ((await lstat(output)).isSymbolicLink()) throw new Error("output is unsafe"); } catch (error) { if (error.code !== "ENOENT") throw error; }
await mkdir(join(snapshotsRoot, "runtime"), { recursive: true, mode: 0o700 });
const prior = {};
for (const [key, value] of Object.entries(env)) { prior[key] = process.env[key]; if (value === undefined) delete process.env[key]; else process.env[key] = value; }
const runner = new ThtRunner({
thtBin: join(repo, "harness", ".venv", "bin", "tht"), harnessDir: join(repo, "harness"),
configPath: join(root, "installation", "base.yaml"), dataRoot: join(root, "installation", "data"),
runtimeSnapshotRoot: join(snapshotsRoot, "runtime"), secretRoots: [join(root, "fixture-secrets")],
semanticRuntime: { internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024 },
});
let lease;
try { lease = runner.acquireWorkspaceRuntime(snapshot); await atomicCopy(lease.path, output); }
finally {
if (lease) lease.release();
for (const key of Object.keys(env)) { if (prior[key] === undefined) delete process.env[key]; else process.env[key] = prior[key]; }
}
return output;
}
function parseArgs(argv) {
if (argv.length !== 6) throw new Error("usage: p1-render-snapshot.mjs --ownership PATH --snapshot ABSOLUTE_PATH --output PATH");
const result = {}; for (let i=0;i<argv.length;i+=2) { if (!["--ownership","--snapshot","--output"].includes(argv[i]) || result[argv[i]]) throw new Error("invalid arguments"); result[argv[i]]=argv[i+1]; }
if (!result["--ownership"] || !result["--snapshot"] || !result["--output"]) throw new Error("missing arguments"); return result;
}
if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) {
try { const args=parseArgs(process.argv.slice(2)); await renderOwnedSnapshot({ ownershipPath:args["--ownership"], snapshotPath:args["--snapshot"], outputPath:args["--output"] }); console.log(`rendered ${resolve(args["--output"])}`); }
catch(error) { console.error(`p1 render refused: ${error.message}`); process.exitCode=1; }
}
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
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 test from "node:test";
import { renderOwnedSnapshot } from "./p1-render-snapshot.mjs";
const roots=[];
async function fixture() {
const repo=await realpath(await mkdtemp(join(tmpdir(),"p1-render-repo-"))); roots.push(repo);
const root=join(repo,".artifacts/manual-acceptance/p1"); const commit="a".repeat(40); const snapshot=join(root,"installation/registry/snapshots",commit,"p1-filesystem.yaml");
for (const p of [dirname(snapshot),join(root,"rendered"),join(root,"installation/registry/snapshots/runtime"),join(root,"installation/data"),join(root,"fixture-secrets"),join(repo,"harness")]) await mkdir(p,{recursive:true,mode:0o700});
await writeFile(join(root,"ownership.json"),JSON.stringify({schemaVersion:1,kind:"p1-manual-acceptance",nonce:"b".repeat(64),repositoryRoot:repo,root,status:"PENDING",listener:{host:"127.0.0.1",port:8791}}));
await writeFile(join(root,"installation/base.yaml"),"{}\n");
const secret=join(root,"fixture-secrets/dwh-password"); await writeFile(secret,"not-inspected",{mode:0o600});
await writeFile(snapshot,`workspace:
schema_version: 3
id: p1-filesystem
name: P1 filesystem
language: en
dwh:
engine: postgres
database: postgres
schema: public
supported_transports: [postgres_direct]
semantic_index:
vector_store: {engine: qdrant, collection: p1-filesystem, dimensions: 1024, distance: cosine}
embedding: {provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024}
llm_policy:
allowed: [zai/glm-5.2]
evidence:
source: {type: filesystem, uri: workspace-content/p1-filesystem/evidence, patterns: ["**/*.md"], max_bytes: 10485760}
policy: {max_chunk_chars: 4000, retain_published_generations: 3}
`);
const env={THT_WS_P1_FILESYSTEM_DWH_TRANSPORT:"postgres_direct",THT_WS_P1_FILESYSTEM_DWH_HOST:"dwh.invalid",THT_WS_P1_FILESYSTEM_DWH_PORT:"5432",THT_WS_P1_FILESYSTEM_DWH_USER:"reader",THT_WS_P1_FILESYSTEM_DWH_PASSWORD_FILE:secret};
return {repo,root,snapshot,env};
}
test.afterEach(async()=>Promise.all(roots.splice(0).map(r=>rm(r,{recursive:true,force:true}))));
test("renderer copies a production lease deterministically with mode 0600 and no leases",async()=>{ const f=await fixture(); const one=join(f.root,"rendered/one.yaml"),two=join(f.root,"rendered/two.yaml"); await renderOwnedSnapshot({repositoryRoot:f.repo,ownershipPath:join(f.root,"ownership.json"),snapshotPath:f.snapshot,outputPath:one,env:{...process.env,...f.env}}); await renderOwnedSnapshot({repositoryRoot:f.repo,ownershipPath:join(f.root,"ownership.json"),snapshotPath:f.snapshot,outputPath:two,env:{...process.env,...f.env}}); assert.deepEqual(await readFile(one),await readFile(two)); assert.equal((await lstat(one)).mode&0o777,0o600); assert.deepEqual(await (await import("node:fs/promises")).readdir(join(f.root,"installation/registry/snapshots/runtime")),[]); });
test("renderer rejects unowned, symlink, and out-of-root paths",async()=>{ const f=await fixture(); const outside=join(f.repo,"outside.yaml"); await writeFile(outside,"x"); await assert.rejects(renderOwnedSnapshot({repositoryRoot:f.repo,ownershipPath:join(f.root,"ownership.json"),snapshotPath:outside,outputPath:join(f.root,"rendered/x.yaml"),env:f.env}),/owned|snapshot/); const link=join(dirname(f.snapshot),"linked.yaml"); await symlink(f.snapshot,link); await assert.rejects(renderOwnedSnapshot({repositoryRoot:f.repo,ownershipPath:join(f.root,"ownership.json"),snapshotPath:link,outputPath:join(f.root,"rendered/x.yaml"),env:f.env}),/snapshot|symlink/); await assert.rejects(renderOwnedSnapshot({repositoryRoot:f.repo,ownershipPath:join(f.root,"ownership.json"),snapshotPath:f.snapshot,outputPath:outside,env:f.env}),/output/); });
test("renderer releases its lease when atomic output fails",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/existing"); await mkdir(output); await assert.rejects(renderOwnedSnapshot({repositoryRoot:f.repo,ownershipPath:join(f.root,"ownership.json"),snapshotPath:f.snapshot,outputPath:output,env:{...process.env,...f.env}})); assert.deepEqual(await (await import("node:fs/promises")).readdir(join(f.root,"installation/registry/snapshots/runtime")),[]); });
+59
View File
@@ -0,0 +1,59 @@
# P1 manual configuration acceptance
This walkthrough is an independent human gate for the P1 workspace configuration process. The
reviewer—not the helper—performs the HTTP, Git, export, rendering, and `tht` checks and judges the
result. Automation never creates `VERDICT.md`, never records PASS, and never consumes or copies
`.artifacts/p1-integration`.
## Prerequisites
From a clean repository checkout, Task 8 must already be implemented. Install Node/npm and Git,
`curl`, `unzip`/`zipinfo`, `lsof`, and the harness development environment so `harness/.venv/bin/tht` is executable.
Port `127.0.0.1:8791` must be free. The helper builds and serves only the production backend; it
does not start Docker or the frontend.
## Lifecycle
Run these commands from the repository root:
```bash
./scripts/p1-manual-acceptance.sh prepare
./scripts/p1-manual-acceptance.sh serve
./scripts/p1-manual-acceptance.sh stop
./scripts/p1-manual-acceptance.sh cleanup
```
`prepare` exclusively creates `.artifacts/manual-acceptance/p1/`, with fresh Git history, fixtures,
secret files, concrete request/inspection commands, and `GUIDE.md`. It leaves status `PENDING` and
the server stopped. It refuses an existing root; use the guarded `stop` and `cleanup` actions rather
than deleting or reusing state manually.
`serve` starts only `node backend/dist/server.js`, bound to `127.0.0.1:8791`, and saves logs and a
guarded PID identity inside the owned root. `stop` sends TERM only after validating the ownership
nonce, executable, command token, repository cwd/root, and recorded process start identity. It never
uses `pkill`. `cleanup` refuses a live or ambiguous process and removes only the exact owned fixed
root. Foreign siblings and automated integration artifacts are outside its cleanup boundary.
After `prepare`, follow the 14 ordered steps in the generated absolute-path `GUIDE.md`. Personally run each generated `http-01` through `http-14` curl script in numeric order; they save the exact status, three validation, three sequential publication, pull, three read responses, and three ZIP exports. Each publication derives its current base commit with a bounded parser from the preceding saved API response, with no placeholder base. Run the five numbered negative validation scripts separately at checklist step 10. The render commands validate the bounded saved read response,
its commit-addressed owned snapshot path, the saved publish commit, and the installed Git HEAD before
calling the acceptance-only production renderer. The renderer imports the built `ThtRunner`, resolves
bindings from environment paths, copies one lease atomically with mode `0600`, and releases it in
`finally`. Do not inspect or print raw secret-file contents; only inspect ownership/mode/path metadata
and canary absence outside `fixture-secrets`.
## Failures and verdict
On failure, run `stop` if the owned server is running and preserve the entire fixed root for review.
Do not run `cleanup` until evidence is no longer needed. A reviewer creates `VERDICT.md` only after the
walkthrough, containing:
- reviewer identity;
- UTC timestamp;
- an explicit result for every one of the 14 generated checklist steps;
- observations and failure evidence;
- exactly `manual acceptance: PASS` or `manual acceptance: FAIL`.
Passing `bash scripts/test-p1-manual-acceptance.sh` proves only that the tooling guards work. It does
not perform or approve manual acceptance and leaves the project-level manual status PENDING.
Expected safe outcomes are one listener on `127.0.0.1:8791`; 2xx positive responses; non-2xx negative validations without Git or snapshot mutation; an empty render diff; two successful `tht config check` calls; no manifest, Evidence/export, secret, or out-of-scope-artifact finding; and no PID or listener after `stop`.
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
if [[ $# -ne 1 || ! "$1" =~ ^(prepare|serve|stop|cleanup)$ ]]; then
printf 'usage: %s prepare|serve|stop|cleanup
' "$0" >&2
exit 2
fi
if [[ "$1" == prepare ]]; then
for command in node npm git curl unzip zipinfo lsof; do
command -v "$command" >/dev/null || { printf 'missing prerequisite: %s
' "$command" >&2; exit 127; }
done
for path in scripts/p1-acceptance.sh scripts/test-p1-acceptance.sh backend/scripts/p1-acceptance.mjs; do
[[ -f "$repo_root/$path" ]] || { printf 'Task 8 prerequisite missing: %s
' "$path" >&2; exit 1; }
done
npm --prefix "$repo_root/backend" run build
fi
exec node "$repo_root/backend/scripts/p1-manual-acceptance.mjs" "$1"
+5
View File
@@ -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-manual-acceptance.test.mjs" "$repo_root/backend/scripts/p1-render-snapshot.test.mjs"