merge: integrate thoth authentication

This commit is contained in:
2026-08-19 17:56:03 +02:00
690 changed files with 138457 additions and 7950 deletions
@@ -0,0 +1,66 @@
# Task 9 quality audit — final 5
**Scope:** the two blocking findings from `task9-quality-audit-final4.md` — unbound production
module graph at manual serve, and commit-addressed snapshots accepted without content identity at
render. Manual acceptance remains **PENDING**; no `VERDICT.md` was created.
## Verdict: APPROVED for the two final integrity blockers
### 1. Manual serve binds the complete `backend/dist` module graph, not only `server.js`
`prepare` now builds a post-build manifest of every regular `backend/dist` file
(relative path, size, SHA-256, device, inode) and writes it as an exclusive `0600` record
(`installation/runtime/backend-dist.manifest.json`) inside the owned root; `ownership.json`
records that record's path/device/inode/size/SHA-256. `serve` revalidates the manifest record
identity and bytes, revalidates every distribution file against it (no-follow, single inode,
size and digest), and refuses before spawning. The manifest descriptor is passed to the child on
fd 4 together with the entrypoint on fd 3. The immutable preload parses the manifest, verifies
the entrypoint cross-digest, reads and hash-verifies **every** file at startup, caches the
verified bytes, and its load hook serves **only** those cached bytes for any import below
`backend/dist` (entry URL still served from the bound fd-3 bytes). A same-path regular
replacement of any imported dependency is therefore refused before `RUNNING` (serve-time
validation), refused at child startup (startup verification), or rendered harmless (cached
bytes), and the parent revalidates the full manifest at `RUNNING` publication and at `stop`.
### 2. Renderer binds snapshot content to its commit identity
The generated render command validates the bounded saved read/publish revisions, the
commit-addressed owned snapshot path, the installed Git HEAD, and the bounded
`snapshot.json` manifest of that commit: `head` equals the commit, `files[<id>.yaml]` is the
SHA-256 of the snapshot bytes, the manifest revision binds commit/blob/snapshot path, the saved
revision blob equals the manifest blob, and `git rev-parse <commit>:workspaces/<id>.yaml` plus
`git hash-object` of the snapshot bytes both equal that blob. It passes the expected digest as
`--snapshot-sha256`. The renderer re-reads the bounded `snapshot.json` (`head`,
`files[<id>.yaml]` must equal the carried digest), opens the snapshot once with no-follow
semantics and bounded reads, renders only the digest-verified bytes, re-verifies around lease
publication, releases the lease in `finally`, and publishes no output on any refusal.
## Deterministic regressions added
- static regular replacement of an imported production dependency after `prepare` is refused,
no marker, no accepted PID record, no orphan;
- deterministic dependency check/load swap (`beforeSpawn` rename) is refused by the child's
startup verification, no marker, no PID record, no orphan;
- after `RUNNING`, a same-path regular dependency replacement is never executed: the loader
serves the verified cached bytes (health-visible source stays the original) and the marker is
absent;
- renderer refuses a same-path regular snapshot byte replacement against the carried digest and
manifest, with lease release and no output;
- renderer refuses manifest `head`, `files` digest, expected-digest, missing, and malformed
cases, with lease release and no output;
- wrapper refuses missing manifest, manifest head/digest/revision tampering, saved-revision blob
mismatch, Git blob mismatch, and snapshot-vs-Git-bytes mismatch, and passes the exact
`--snapshot-sha256` on the valid path (stub renderer records arguments).
## Verification
- `bash scripts/test-p1-manual-acceptance.sh` (backend build + both suites): **59 tests, 59
pass, 0 fail**; no `8791/8792` listener and no `--p1-manual-nonce` process remain.
- `npx tsc --noEmit -p .` (backend): PASS.
- Real-repository `prepare` + `cleanup` cycle: 39 distribution files bound, entrypoint
cross-digest verified, owned root fully removed afterwards.
- Diff check: only the seven Task 9 paths are touched; no Task 8 file was modified.
- This report and the implementation contain no fixture secret or canary values.
Manual acceptance remains **PENDING** by design; the walkthrough and human verdict are
unchanged.
+282
View File
@@ -0,0 +1,282 @@
{
"schema": "thothii-task4-certification-v1",
"generated_on": "2026-08-18",
"started_at_utc": "2026-08-18T14:16:40Z",
"ended_at_utc": "2026-08-18T14:20:10Z",
"source_commit": "2a9359071257f9b8a71d36ec2bbb25b161003f81",
"source_immutability": {
"status": "PASS",
"tracked_changes_after_freeze": false,
"allowed_untracked": [".playwright-cli/", ".thothctl/"]
},
"source_commits": {
"task4_candidate": "b31b27e5845ffd3adf311429367319beaba263c7",
"task1": "d43738eeae6d14bb5e470093058b069a983f5372",
"task2": "5f9a3ae066a060b43a11a959b60a1efadd1c2425",
"task3": "0d8e707533fada938c99eb06f8457150e7ef2b40",
"task3_follow_up": "b31b27e5845ffd3adf311429367319beaba263c7",
"fix_round_1_source": "10cd66fe6a5b484a4dc569326a228c1c5484a5d4",
"fix_round_2_source": "2a9359071257f9b8a71d36ec2bbb25b161003f81",
"historical_task15_final": "74b062f1a737103524cbe706346cfd65f87cdfd1"
},
"versions": {
"node_contract": "v24.16.0",
"node_host_default": "v25.6.1",
"go": "go1.26.5",
"pi": "0.80.3"
},
"retained_report": ".superpowers/sdd/2026-08-16-thothii-authentication/task-15-report.md",
"task4_report": ".superpowers/sdd/2026-08-18-thothii-authentication-remediation/task-4-report.md",
"fix_round_2_report": ".superpowers/sdd/2026-08-18-thothii-authentication-remediation/fix-round-2-report.md",
"workflow": {
"run_id": "32147345625",
"url": "https://github.com/mptyl/ThothII/actions/runs/32147345625",
"event": "workflow_dispatch",
"head_sha": "2a9359071257f9b8a71d36ec2bbb25b161003f81",
"status": "completed",
"conclusion": "failure",
"windows_job": {
"name": "Windows clone and Compose contract",
"job_id": "95744249248",
"url": "https://github.com/mptyl/ThothII/actions/runs/32147345625/job/95744249248",
"conclusion": "failure",
"native_step": "Run native Windows retained-capability tests",
"native_step_conclusion": "success",
"command": "go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1",
"requested_packages": ["internal/safeio", "internal/backup", "internal/authstorage"],
"executed_packages": ["internal/safeio", "internal/backup", "internal/authstorage"],
"not_executed_packages": [],
"package_results": {
"internal/safeio": "PASS (22.058s)",
"internal/backup": "PASS (7.161s)",
"internal/authstorage": "PASS (16.088s)"
},
"failed_step": "Verify Windows clone contract",
"failure_category": "baseline_powershell_parser",
"failure_detail": "scripts/test-windows-clone-contract.ps1:208 parses $remoteYaml: as an invalid variable reference"
},
"lf_compose_docs_typescript_job": {
"name": "LF, Compose, docs, and TypeScript",
"job_id": "95744249458",
"url": "https://github.com/mptyl/ThothII/actions/runs/32147345625/job/95744249458",
"conclusion": "failure",
"failed_step": "Verify Compose and installation contracts",
"category": "baseline_ci_contract",
"detail": "unified Compose contract passed; test-no-deployment-coupling-scope.sh stopped on TMPDIR: unbound variable",
"downstream_steps": "skipped"
},
"linux_docker_job": {
"name": "Linux Docker deployment and rollback",
"job_id": "95744249354",
"url": "https://github.com/mptyl/ThothII/actions/runs/32147345625/job/95744249354",
"conclusion": "failure",
"failed_step": "Run unified deployment smoke",
"category": "infrastructure_prerequisite",
"detail": "Task 13 smoke failed before deployment because rg is required",
"cleanup": "PASS",
"image_manifest": "not_generated"
},
"windows_docker_startup_job": {
"name": "Native Windows Docker Desktop/WSL2 startup",
"job_id": "95744250450",
"url": "https://github.com/mptyl/ThothII/actions/runs/32147345625/job/95744250450",
"status": "NOT_RUN",
"classification": "BLOCKED",
"workflow_conclusion": "skipped",
"reason": "workflow conditions skipped the job; no Windows Docker Desktop/WSL2 command executed"
}
},
"docker_image_evidence": {
"authentication_smoke": {
"status": "PASS",
"docker_images": [],
"reason": "no_docker_images_exercised"
},
"unified_docker_smoke": {
"status": "FAIL",
"source_commit": "2a9359071257f9b8a71d36ec2bbb25b161003f81",
"run_id": "32147345625",
"workflow_job_id": "95744249354",
"manifest": ".artifacts/task-15/unified-docker-images.json",
"reason": "workflow attempt stopped before deployment because rg is required",
"cleanup": "PASS",
"images": 0,
"historical": {
"status": "PASS",
"source_commit": "74b062f1a737103524cbe706346cfd65f87cdfd1",
"run_id": "20260818070637-66409-30058",
"manifest_sha256": "9c8dec4546909fd93799dbcf374bcb3a89bc46cfe0fd482472c0cbe757ddf5b6",
"images": 5,
"cleanup": "PASS"
}
}
},
"gates": {
"posix_registry_ownership": {
"status": "PASS",
"source_commit": "b31b27e5845ffd3adf311429367319beaba263c7",
"evidence": "backend Node 24 full suite including local-registry ownership coverage"
},
"stagearchive_unix_retained_capability": {
"status": "PASS",
"source_commit": "b31b27e5845ffd3adf311429367319beaba263c7",
"evidence": "focused safeio/backup tests, Go race suite, and Unix ancestor-swap coverage"
},
"windows_stagearchive_retained_capability": {
"status": "PASS",
"source_commit": "2a9359071257f9b8a71d36ec2bbb25b161003f81",
"evidence": "native Windows backup package passed, including the two-file shared retained-root staging test"
},
"windows_claim_retained_capability": {
"status": "PASS",
"source_commit": "2a9359071257f9b8a71d36ec2bbb25b161003f81",
"evidence": "native Windows safeio and authstorage packages passed concurrent claim/consume coverage"
},
"workflow_lf_compose_docs_typescript": {
"status": "FAIL",
"classification": "baseline_ci_contract",
"reason": "TMPDIR was unset after the unified Compose contract passed"
},
"workflow_linux_docker": {
"status": "FAIL",
"classification": "infrastructure_prerequisite",
"reason": "runner did not provide rg; cleanup proof passed and no image manifest was generated"
},
"go_security_build": {
"status": "PASS",
"source_commit": "2a9359071257f9b8a71d36ec2bbb25b161003f81",
"focused_packages": 3,
"race_packages": 18,
"focused_test": "PASS",
"race": "PASS",
"vet": "PASS",
"host_build": "PASS"
},
"windows_cross_compile": {
"status": "PASS",
"source_commit": "2a9359071257f9b8a71d36ec2bbb25b161003f81",
"focused_test_packages": 3,
"cli_build": "PASS",
"execution": "cross_compile_only_not_native_execution"
},
"backend_node24": {
"status": "PASS",
"source_commit": "b31b27e5845ffd3adf311429367319beaba263c7",
"node": "v24.16.0",
"files": 76,
"tests": 1092,
"typecheck": "PASS",
"build": "PASS",
"note": "an initial full run had one workspace-registry timeout; focused rerun and complete rerun passed"
},
"frontend_node24": {
"status": "PASS",
"source_commit": "b31b27e5845ffd3adf311429367319beaba263c7",
"node": "v24.16.0",
"files": 61,
"tests": 444,
"typecheck": "PASS",
"build": "PASS"
},
"authentication_and_f1_smoke": {
"status": "PASS",
"source_commit": "b31b27e5845ffd3adf311429367319beaba263c7",
"node": "v24.16.0",
"filtered_e2e": "1 passed",
"sentinel_leak_scan": "PASS"
},
"harness_pytest": {
"status": "FAIL",
"source_commit": "b31b27e5845ffd3adf311429367319beaba263c7",
"passed": 951,
"failed": 1,
"skipped": 4,
"subtests": 232,
"failure": "test_column_decisions::test_f4_emits_column_types: workflow.yaml not found from harness test cwd"
},
"authentication_docs": {
"status": "PASS",
"source_commit": "b31b27e5845ffd3adf311429367319beaba263c7"
},
"shell_syntax": {
"status": "PASS",
"source_commit": "b31b27e5845ffd3adf311429367319beaba263c7"
},
"authentication_smoke_runtime": {
"status": "PASS",
"node": "v24.16.0",
"sentinel_leak_scan": "PASS"
},
"compose_default": {
"status": "FAIL",
"reason": "required THT_WORKSPACE_GIT_REMOTE was not available"
},
"compose_unified": {
"status": "FAIL",
"reason": "compose.unified.yaml is absent from the frozen source"
},
"unified_docker_smoke": {
"status": "FAIL",
"source_commit": "2a9359071257f9b8a71d36ec2bbb25b161003f81",
"workflow_run_id": "32147345625",
"reason": "remote workflow attempted the smoke but stopped before deployment because rg is required",
"cleanup": "PASS",
"image_manifest": "not_generated"
},
"ruff": {
"status": "FAIL",
"errors": 192,
"classification": "known_baseline"
},
"mkdocs_strict": {
"status": "NOT_RUN",
"classification": "BLOCKED",
"historical_status": "FAIL",
"historical_warnings": 69
},
"canonical_install_docs": {
"status": "NOT_RUN",
"classification": "BLOCKED",
"historical_status": "FAIL"
},
"workspace_install_docs": {
"status": "NOT_RUN",
"classification": "BLOCKED",
"historical_status": "FAIL"
},
"pi_user_auth_compose": {
"status": "NOT_RUN",
"classification": "BLOCKED",
"historical_status": "FAIL"
},
"deployment_coupling": {
"status": "NOT_RUN",
"classification": "BLOCKED",
"historical_status": "FAIL"
},
"l2": {
"status": "PENDING",
"reason": "configured secret layout unavailable; gate not run after stop"
},
"manual_psd": {
"status": "PENDING",
"reason": "approved real identity/access unavailable; gate not run after stop"
},
"provider_readiness": {
"status": "PENDING",
"reason": "provider prerequisite unavailable; gate not run after stop"
}
},
"review": {
"original_important_findings_resolved": 3,
"fix_round_2_important_lifecycle": "ADDRESSED",
"fix_round_2_minor_windows_diagnostics": "ADDRESSED",
"verdict": "PASS",
"reason": "the lifecycle controller is bounded and cancellation-aware with cancel, bounded join, and lock-release proof; the temporary Windows diagnostic matrix is removed; exact-source native safeio, backup, and authstorage all pass"
},
"remediation_status": "PASS",
"release_complete": false,
"authentication_implementation_complete": true,
"release_readiness": "FAIL",
"release_readiness_pending_external_gates": true
}
@@ -0,0 +1,54 @@
{
"gate": "unified-deployment-smoke",
"status": "pass",
"source_commit": "74b062f1a737103524cbe706346cfd65f87cdfd1",
"run_id": "20260818070637-66409-30058",
"images": [
{
"id": "sha256:2d7b19491c7eb8c119c3cedb390aaeb2ff5593f6fc43ab66c317565560da6d7d",
"roles": [
"compose-runtime",
"fixture-runtime"
],
"repo_digests": [
"sha256:2d7b19491c7eb8c119c3cedb390aaeb2ff5593f6fc43ab66c317565560da6d7d"
]
},
{
"id": "sha256:3b6c31a5d8f8fc58fa3233391b6175bd2fbc793eebb44d5e285ecc6e02e9e687",
"roles": [
"compose-runtime"
],
"repo_digests": [
"sha256:3b6c31a5d8f8fc58fa3233391b6175bd2fbc793eebb44d5e285ecc6e02e9e687"
]
},
{
"id": "sha256:57f573b47f1f71ebb445789f279fe3e596a8beab182f7cf486db9205bad87c5a",
"roles": [
"compose-runtime"
],
"repo_digests": [
"sha256:57f573b47f1f71ebb445789f279fe3e596a8beab182f7cf486db9205bad87c5a"
]
},
{
"id": "sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c",
"roles": [
"compose-runtime"
],
"repo_digests": [
"sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c"
]
},
{
"id": "sha256:c3cbe1cc1aa588a64951ac6286e0df7b27fe2e6324b1001c619bb358770c0178",
"roles": [
"rollback-candidate"
],
"repo_digests": [
"sha256:c3cbe1cc1aa588a64951ac6286e0df7b27fe2e6324b1001c619bb358770c0178"
]
}
]
}
+19 -5
View File
@@ -1,17 +1,31 @@
# build artefacts & deps
# Build artefacts, local configuration, and runtime data never enter an image context.
**/node_modules
**/.venv
**/__pycache__
**/.pytest_cache
**/dist
**/*.pyc
harness/.env
harness/workspaces/psd.yaml
deploy/thothii.env
.git
.worktrees
.thothctl
.gitignore
**/.env
**/.env.*
!.env.example
!deploy/env/*.env.example
deploy/thothii.env
deploy/secrets/
harness/workspaces/*.yaml
!harness/workspaces/local.yaml
!harness/workspaces/tht.example.yaml
!harness/workspaces/tht-test.yaml
**/*.log
**/.DS_Store
tht-workspace-psd
coverage/
.coverage
.artifacts/
data/
sessions/
workspace-registry/
# docs/site (mkdocs build) — non necessari nelle immagini
docs/superpowers/plans
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
[*.ps1]
end_of_line = crlf
+10 -28
View File
@@ -1,31 +1,13 @@
# ThothII Compose defaults. Copy this file to .env in the repository root.
# The root .env is loaded automatically by Docker Compose; do not put secrets here.
# Common non-secret Compose values. Select local.env or server.env with --env-file.
# Run Compose with both files explicitly, for example:
# docker compose --env-file deploy/env/local.env -f compose.yaml -f deploy/compose.local.yaml up -d --build
COMPOSE_FILE=compose.yaml
COMPOSE_PROFILES=
THT_SECRETS_FILE=deploy/secrets/thothii.secrets
THOTH_HTTP_PORT=8080
AUTH_MODE=none
THOTH_PUBLIC_EXPOSURE=false
MAX_PI_PROCESSES=4
PI_PROVIDER=
PI_MODEL=
PI_THINKING=
PI_AUTH_FILE=${HOME}/.pi/agent/auth.json
THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git
THT_WORKSPACE_GIT_BRANCH=main
THT_WORKSPACE_GIT_AUTHOR_NAME="Thoth Workspace Registry"
THT_WORKSPACE_GIT_AUTHOR_EMAIL=thoth-workspace-registry@example.invalid
# Set these for the selected DWH/vector/embedding adapters.
THT_DB_NAME=
THT_DWH_REST_URL=
THT_VEC_REST_URL=
THT_VEC_WRITE_REST_URL=
THT_OLLAMA_URL=
THT_DOCS_ROOT=/data/workspaces/example/evidence-source
THT_PROFILE=server
# Local-vector defaults (used by the optional local-vector overlay).
THT_VECTOR_DATABASE=thoth
THT_VECTOR_BOOTSTRAP_USER=postgres
THT_VECTOR_MIGRATOR_USER=thoth_vector_migrator
THT_VECTOR_READER_USER=thoth_vector_reader
THT_VECTOR_WRITER_USER=thoth_vector_writer
THT_DB_NAME=warehouse
THT_DWH_REST_URL=https://dwh.example.invalid
THT_LLM_URL=https://llm.example.invalid
+12
View File
@@ -0,0 +1,12 @@
* text=auto
*.sh text eol=lf
Dockerfile* text eol=lf
*.Dockerfile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.py text eol=lf
*.md text eol=lf
*.ps1 text eol=crlf
+174
View File
@@ -0,0 +1,174 @@
name: Deployment release gate
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
inputs:
windows_docker_startup:
description: Run the native self-hosted Windows Docker Desktop/WSL2 release gate
required: false
type: boolean
default: false
permissions:
contents: read
concurrency:
group: deployment-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deterministic:
name: LF, Compose, docs, and TypeScript
runs-on: ubuntu-24.04
timeout-minutes: 25
env:
PYTHONDONTWRITEBYTECODE: "1"
steps:
- name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.16.0"
package-manager-cache: false
- name: Verify shell syntax and LF policy
run: |
git ls-files -z '*.sh' | xargs -0 -n1 bash -n
bash scripts/verify-line-endings.sh
- name: Verify Compose and installation contracts
run: |
bash scripts/test-unified-compose.sh
bash scripts/test-no-deployment-coupling-scope.sh
bash scripts/test-compose-secret-policy.sh
bash scripts/test-no-deployment-coupling.sh
bash scripts/test-preprocess-compose-config.sh
bash scripts/test-verify-workspace-install-docs.sh
git diff --check
- name: Assert clean checkout before release trust bootstrap
run: |
git diff --exit-code
git diff --cached --exit-code
test -z "$(git ls-files --others --exclude-standard)"
- name: Verify schema-v3-only release gate
run: bash scripts/verify-schema-v3-only-release.sh
- name: Verify Task 13 clean-install and runtime fixtures
run: |
bash scripts/test-server-pi-state-topology.sh
bash scripts/unified-deployment-smoke.sh --self-test
- name: Test and type-check backend
working-directory: backend
run: |
npx vitest run
npx tsc --noEmit -p .
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Test and type-check frontend
working-directory: frontend
run: |
npx vitest run
npx tsc -b
authentication-browser:
name: Hermetic authentication browser gate
needs: deterministic
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.16.0"
package-manager-cache: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26.5"
cache-dependency-path: tools/tht/go.sum
- name: Install backend dependencies
working-directory: backend
run: npm ci
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Install Chromium for Playwright
working-directory: frontend
run: npx playwright install --with-deps chromium
- name: Run authentication and authenticated F1 browser smoke
run: bash scripts/authentication-smoke.sh
linux-docker:
name: Linux Docker deployment and rollback
runs-on: ubuntu-24.04
timeout-minutes: 100
steps:
- name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run unified deployment smoke
run: timeout --signal=TERM --kill-after=45s 32m bash scripts/unified-deployment-smoke.sh
- name: Run tht update smoke
run: timeout --signal=TERM --kill-after=45s 32m bash scripts/tht-update-smoke.sh
- name: Run Linux server deployment smoke
run: timeout --signal=TERM --kill-after=45s 32m bash scripts/server-deployment-smoke.sh
windows-clone:
name: Windows clone and Compose contract
runs-on: windows-2025
timeout-minutes: 20
steps:
- name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.16.0"
package-manager-cache: false
- name: Install backend dependencies
working-directory: backend
run: npm ci
- name: Verify clean backend distribution
working-directory: backend
run: node --test --test-concurrency=1 scripts/clean-dist.test.mjs
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26.5"
cache-dependency-path: tools/tht/go.sum
- name: Run native Windows retained-capability tests
working-directory: tools/tht
run: go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1
- name: Verify Windows clone contract
shell: pwsh
run: ./scripts/test-windows-clone-contract.ps1
windows-docker-release:
name: Native Windows Docker Desktop/WSL2 startup
if: github.event_name == 'workflow_dispatch' && inputs.windows_docker_startup
runs-on: [self-hosted, Windows, X64, docker-desktop]
timeout-minutes: 45
steps:
- name: Check out source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26.5"
cache-dependency-path: tools/tht/go.sum
- name: Run spaced-path Windows Docker release gate
shell: pwsh
run: ./scripts/test-windows-clone-contract.ps1 -DockerStartup
+13
View File
@@ -8,6 +8,7 @@ Thoth/
# === Visual companion brainstorming artifacts (local-only) ===
.superpowers/
.worktrees/
.tht/
# === Python ===
__pycache__/
@@ -28,6 +29,7 @@ tools/replay/web/
# === Secrets — NEVER commit ===
.env
harness/.env
harness/workspaces/psd.yaml
deploy/thothii.env
*.pem
ca-chain.pem
@@ -35,12 +37,20 @@ config/ca-chain.pem
# ThothII deployment configuration and secret values (keep only the README tracked)
deploy/.env
deploy/compose.connector-secrets.local.yaml
deploy/compose.psd-local.yaml
deploy/workspaces/psd.yaml
deploy/secrets/*
!deploy/secrets/README.md
!deploy/secrets/*.example
# Per-installation configuration generated by `tht setup` (examples stay tracked).
deploy/*/thothii-installation.yaml
deploy/*/operator.env
deploy/*/secrets/*
!deploy/*/secrets/.gitkeep
!deploy/*/secrets/*.example
# === Runtime data (sessions contain PII; indexes are derived) ===
harness/sessions/
harness/indexes/
@@ -67,3 +77,6 @@ site/
# Generated container inventory / SBOM-equivalent verification artifacts
.artifacts/
# === PrimeAgent local project settings (per-user, not shared) ===
.prime/
@@ -0,0 +1,105 @@
# Task 3 — Diagnostic contract remediation report
Date: 2026-08-04
## Scope
This remediation is limited to the four approved review findings for the workspace diagnostic
extension. It does not add registry routes, change workspace publication, alter session startup,
or expand transport support.
## Changes
1. `RuntimeBindings` now has an explicit `vectorWriter` binding. The new
`resolveRuntimeBindings()` resolves DWH, vector reader, vector writer, and embedding bindings
together. The diagnoser takes the writer credential only from `bindings.vectorWriter`, never
from vector-reader values.
2. Direct PostgreSQL and SSH-tunnelled direct probes accept an absent CA binding while retaining
certificate verification through the runtime system trust store. A supplied CA still uses
verified private-CA trust. REST private-CA refusal is unchanged.
3. A reversible vector probe now requires an authenticated POST declaration with a response map
containing `operation`. The adapter requires the successful JSON response to echo `create` or
`remove` respectively, so an arbitrary 2xx or an upsert-only response cannot activate the
write probe.
4. For DWH and vector REST diagnostics declared with `auth: none`, the resolver no longer
requires an API-key file and the adapter sends no credential. Credential-backed diagnostics
continue to require their local secret file.
## TDD evidence
The first focused RED run failed for the intended missing behavior:
- `resolveRuntimeBindings is not a function` for unauthenticated resolver bindings;
- schema accepted a reversible probe without a response contract; and
- existing diagnostic fixtures rejected the new `response` declaration until schema support was
implemented.
The focused GREEN run passed `43/43` tests across:
- `test/workspaces-bindings.test.ts`
- `test/workspaces-schema.test.ts`
- `test/workspaces-diagnostics.test.ts`
The regression coverage includes resolver-to-diagnoser writer propagation without manually
inserting the writer key into vector-reader bindings, no-CA direct/SSH system-trust requests,
operation-echo validation for create/remove, and `auth: none` bindings without secret files.
## Documentation and design
- `docs/workspace-diagnostic-protocol.md` now documents the verified system-trust fallback,
no-secret `auth: none` behavior, and required reversible response contract.
- `docs/superpowers/specs/2026-08-03-git-workspace-registry-design.md` now records the same
response, CA, SSH, and authentication rules.
## Final verification
The initial sandboxed full suite could not bind its local SSE listener (`listen EPERM:
operation not permitted 127.0.0.1`). It was rerun unchanged with local-listener permission.
```text
backend: npx vitest run
31 test files passed; 329 tests passed
backend: npx tsc --noEmit -p .
exit 0
repository: git diff --check
exit 0
```
Expected test harness stderr from existing Pi/process failure-path tests remained present; no test
failed and no diagnostic secret was emitted.
## Blockers
None.
## Round 2 remediation
The final review found two remaining contract gaps. The binding resolver already treated
`auth: none` as credential-free, but the runtime renderer and diagnostic connector still required
the API-key file. Rendering and connector construction now make that requirement conditional on
the declared REST authentication mode, so a DWH/vector `auth: none` workspace passes resolver,
runtime rendering, and diagnostics with no API-key file.
SSH forwarding previously changed the PostgreSQL connection host to `127.0.0.1` without retaining
the original target for TLS hostname validation. Forwarded probes now carry `SSH_TARGET_HOST` as
`tlsServername` into the PostgreSQL TLS options; private CA and verified system trust behavior are
unchanged.
TDD RED: the new end-to-end no-key test failed at the unconditional runtime
`API_KEY_FILE` requirement, while the SSH test showed no `tlsServername` on the loopback probe or
database-client request. TDD GREEN: the focused backend workspace tests passed `40/40`.
Round 2 final verification:
```text
backend: npx vitest run
31 test files passed; 332 tests passed
backend: npx tsc --noEmit -p .
exit 0
repository: git diff --check
exit 0
```
@@ -0,0 +1,101 @@
# Task 7 report — revision-pinned sessions
## Delivered
- New-session requests may carry `workspaceId`, provider, model, and thinking. The backend
resolves the active operational registry revision, enforces its LLM policy, and persists the
workspace ID/revision with the selected LLM settings.
- The harness manifest and `tht session new` support the optional, backward-compatible
`workspace_id` and `workspace_revision` fields.
- Resume resolves the manifest's retained snapshot, including after later registry publication.
A missing retained revision returns a sanitized `workspace_revision_unavailable` response.
Legacy manifests retain the prior workspace behavior and are marked with a visible warning on
`GET /sessions/:id`.
- `/settings` is now a non-mutating compatibility endpoint: installation defaults remain
readable, while anonymous workspace/provider/model/thinking selections are no longer written
to backend settings or principal preferences.
## TDD evidence
- RED: `npx vitest run test/routes-sessions.test.ts test/routes-settings.test.ts` failed for the
new immutable-snapshot and no-settings-mutation assertions; the manifest test failed because
`new_session_manifest` did not accept workspace revision fields.
- GREEN: `npx vitest run test/tht-runner.test.ts test/routes-sessions.test.ts test/routes-settings.test.ts && npx tsc --noEmit -p .`
completed with 97 passing tests and a clean type check.
- GREEN: `THT_HOME=/private/tmp/thothii-task7-home .venv/bin/pytest tests/test_session_documents.py tests/test_session_mutations.py -q`
completed with 22 passing tests.
- `git diff --check` completed cleanly.
## Review fixes — round 3
- The active registry snapshot that located a session now remains the authorization and mutation
config for response, steer, events, close/delete, archive/group/rename, documents, and detail.
A pruned historical revision cannot block an already-located session's active lifecycle.
- Only Resume resolves the retained pinned descriptor because Pi needs that immutable config to
restart safely. A pruned pin therefore returns the existing sanitized
`workspace_revision_unavailable` 409 solely for Resume.
### Round 3 verification
- RED: with a manifest found through an active registry snapshot and `readPinned` forced to fail,
`POST /sessions/:id/response` returned 409 instead of forwarding the active gate response.
- GREEN: `npx vitest run test/routes-sessions.test.ts test/tht-runner.test.ts test/routes-settings.test.ts && npx tsc --noEmit -p .`
— 102 tests passed with a clean type check. The regression confirms response, close, and delete
use the locating snapshot without calling `readPinned`, while Resume returns a sanitized 409.
- `git diff --check` completed cleanly.
## Review fixes — round 2
- Lifecycle authorization no longer selects the installation-default workspace. The backend now
finds each session by querying every operational registry snapshot with the authenticated
principal, preserving RLS ownership concealment.
- After locating the manifest, durable pinned sessions resolve their retained descriptor before
any lifecycle mutation/reopen. Legacy sessions continue using the locating registry snapshot.
- Session listing aggregates the owner-visible rows from all operational registry snapshots;
detail, response, steer, resume, events, documents, and lifecycle mutations use the same
server-side locator. No route depends on browser-local workspace state.
### Round 2 verification
- RED: the new cross-workspace route integration test created a B session while installation
default A was selected, then demonstrated that `GET /sessions` returned an empty list.
- GREEN: `npx vitest run test/routes-sessions.test.ts test/tht-runner.test.ts test/routes-settings.test.ts && npx tsc --noEmit -p .`
— 101 tests passed with a clean type check. The integration test covers create B, list, detail,
response, and resume through B's pinned descriptor while default A remains configured.
- Full backend suite: 342 tests passed. The remaining 7 tests require binding `127.0.0.1` and
fail in this sandbox with `listen EPERM: operation not permitted`; no application assertion
failed. The focused typecheck above passed.
- `git diff --check` completed cleanly.
## Verification note
The unscoped backend suite was also run. The Task 7 code regressions in `test/tht-runner.test.ts`
were fixed; the remaining failures were existing sandbox restrictions on tests that listen on
`127.0.0.1` (`listen EPERM: operation not permitted` in SSE/e2e health tests), not application
assertions.
## Review fixes — round 1
- Every new session now resolves `workspaceId` through the registry; an omitted value uses the
configured installation default and persists both the resolved ID and revision. Callers cannot
bypass revision pinning by supplying a workspace ID.
- Browser-local preferences now migrate once from the read-only legacy settings response and hold
workspace, provider, model, and thinking. Session creation includes those selections, including
direct entry points that run before the composer mounts. The frontend no longer `PUT`s shared
settings.
- The settings compatibility endpoint honors a stored installation workspace before falling back
to the first workspace configuration.
- Resume rejects finalized and archived sessions before looking up any pinned snapshot, preserving
the read-only response even when a historical snapshot is unavailable.
### Review verification
- RED: the added backend tests failed for omitted-default pinning, read-only resume ordering, and
stored-default precedence; the added frontend preference tests failed because preferences were
neither stored nor included in session requests.
- GREEN: `npx vitest run test/tht-runner.test.ts test/routes-sessions.test.ts test/routes-settings.test.ts && npx tsc --noEmit -p .`
— 100 tests passed with a clean type check.
- GREEN: `npx vitest run && npx tsc -b` — 332 frontend tests passed with a clean type check.
- GREEN: `THT_HOME=/private/tmp/thothii-task7-home .venv/bin/pytest tests/test_session_documents.py tests/test_session_mutations.py -q`
— 22 tests passed (one existing testcontainers deprecation warning).
- `git diff --check` completed cleanly.
@@ -0,0 +1,73 @@
# Task 9 report — Workspace Management CRUD page
## Delivered
- Added the Workspace management dialog, launched from the persistent right sidebar and the
Model activity header without touching live-session/SSE state.
- Added a workspace list/detail editor for General, DWH, Semantic index, LLM policy,
Installation requirements, and Git status/history.
- Added browser-only New, Edit, Duplicate, Save draft, and Delete-draft workflows. A deletion
draft stores only ID and immutable revision references; publication remains a Task 10 action.
- Used closed native controls for languages, engines, transports, distance metrics, embedding
providers, and selectable default models. Free values have client-side, accessible errors.
- Made semantic-index dimensions atomic: one editor field always writes the same value to the
vector-store and embedding contracts.
- Added Validate and Test-on-this-installation actions. They display sanitized code/message
diagnostics only; neither action exposes or stores credentials, secrets, or raw response bodies.
- Explicitly excluded publish, pull, import, and export user flows from this task.
## TDD evidence
- RED: `npx vitest run src/shell/WorkspaceManager.test.tsx src/shell/WorkspaceEditor.test.tsx`
failed because the manager and editor modules did not exist.
- GREEN: focused manager/editor/AppShell coverage passed after the implementation.
- RED: a deletion-draft persistence regression failed with
`Cannot read properties of undefined (reading 'save')` before the sanitized draft store was added.
- GREEN: the draft-store and manager tests passed once deletion intent persisted locally.
## Verification
Executed from `frontend/`:
```text
npx vitest run
50 test files passed, 358 tests passed
npx tsc -b
exit 0
```
`git diff --check` passed before commit. No workspace secret value, secret-file path, raw
diagnostic body, publish call, import flow, or export flow was introduced.
## Fix round 1
### Root causes and fixes
- The original duplicate proposal appended `-copy` and then truncated at 63 characters. For an
already-maximal ID, truncation could remove the suffix and reproduce the immutable source ID.
The proposal now reserves suffix space and falls back to a distinct `-2` suffix when a maximal
source already ends in `-copy`.
- `dwh.timeout_ms` was rendered as a positive numeric field but was absent from the client
validation map. It now has the same immediate accessible error treatment as other numeric
fields, so a rejected save never reaches the manager’s saved-draft toast.
- Registry status, workspace list, and selected-detail React Query failures were rendered as
loading, empty, or unselected states. Each now has a named alert and a retry control, distinct
from its corresponding loading and empty state.
### TDD evidence
- RED: max-length duplication retained the original 63-character ID; the timeout field produced
no alert; and each of the three failed queries had no accessible retry control.
- GREEN: the focused manager/editor tests passed **12/12**, covering a valid changed duplicate
proposal, rejected zero timeout with no save toast, and status/list/detail retry recovery.
### Verification
Executed from `frontend/`:
```text
npx vitest run
50 test files passed, 364 tests passed
npx tsc -b
exit 0
```
@@ -0,0 +1,180 @@
# Task 11 report
Status: completed on 2026-08-08.
## Scope delivered
- Updated operator-facing documentation for the internal Qdrant + Ollama architecture.
- Tightened documentation contract tests to require the current four-service-plus-init topology,
CPU-first/GPU-override guidance, fixed internal model/dimensions, schema-v3 migration wording,
one-collection-per-workspace ownership, and Qdrant backup/restore safety.
- Updated stable repo guidance in `AGENTS.md` and the current snapshot in `PROJECT_STATE.md`.
- Rewrote the workspace diagnostic protocol to the schema-v3/internal-semantic-service contract.
- Updated the memory guide to describe Qdrant as the derived persistent index.
- Updated the runtime secret-bundle guide to remove active vector/embedding secret guidance.
## Files changed
- `README.md`
- `AGENTS.md`
- `PROJECT_STATE.md`
- `docs/install/local-workspace-registry.md`
- `docs/install/server-workspace-registry.md`
- `docs/installazione-docker-4-contesti.md`
- `docs/workspace-diagnostic-protocol.md`
- `docs/gestione-memory.md`
- `deploy/secrets/README.md`
- `scripts/verify-workspace-install-docs.sh`
- `scripts/test-verify-workspace-install-docs.sh`
## Verification
Fresh successful runs:
```sh
./scripts/test-verify-workspace-install-docs.sh
./scripts/verify-workspace-install-docs.sh --fixtures-only
git diff --check
```
Key outcomes:
- internal semantic infrastructure documentation contract passed
- all existing install/manual fixture contracts still passed
- diff hygiene passed with no whitespace/errors
## Self-review notes
- The updated docs now match the code-backed Compose topology: `frontend`, `core`, `qdrant`,
`embedding`, and `embedding-model-init`.
- Active manuals no longer instruct operators to configure external vector or embedding runtime
endpoints/secrets.
- Qdrant backup/restore wording now matches the helper scripts' exact confirmation and rollback
behavior.
- Legacy descriptor handling is documented as explicit schema-v3 migration only; no silent
semantic-data migration is claimed.
## Residual concerns
- The broader repository still contains historical design/spec material that references older
pgvector/external-embedding architecture; this task intentionally updated operator/current-state
documentation and the corresponding contract tests, not historical planning documents.
## Fix round 1/5 — 2026-08-08
Addressed reviewer findings:
- Moved superseded rollout/state blocks in `PROJECT_STATE.md` behind an explicit
`## Historical snapshots and archived reference notes` boundary.
- Renamed superseded snapshot headings so historical notes no longer present as active `LIVE`
state.
- Added a current-state regression that rejects contradictory active blocks (for example:
schema-v2 operational, two-service active stack, or external vector/embedding runtime claims
before the historical boundary).
- Refactored new internal-semantic doc checks away from exact-sentence coupling:
- parse `compose.yaml` structurally with YAML;
- parse workspace examples structurally with YAML;
- inspect backup/restore stable usage interface;
- keep targeted forbidden-term checks for active docs while allowing historical sections;
- use regex/concept checks for prose.
Evidence:
```sh
./scripts/test-verify-workspace-install-docs.sh
./scripts/verify-workspace-install-docs.sh --fixtures-only
git diff --check
```
Observed RED before the fix:
```text
PROJECT_STATE.md: missing Historical snapshots boundary
```
## Fix round 2/5 — 2026-08-08
Addressed reviewer findings:
- Renamed every historical `PROJECT_STATE.md` heading after the historical boundary so no heading
level uses `LIVE` or current-state semantics there.
- Strengthened the historical-boundary regression to reject any Markdown heading level
(`#` through `######`) containing `LIVE` or current-state wording after the boundary.
- Added a fixture with a `### ... — LIVE ...` historical heading to prove RED then GREEN.
- Replaced remaining exact phrase checks with concept/semantic validation for:
- one-workspace/one-collection ownership;
- external boundary (DWH/LLM external; vector/embedding internal);
- the Italian compact install note.
- Added paraphrase fixtures that pass and omission/inversion fixtures that fail.
Evidence:
```sh
./scripts/test-verify-workspace-install-docs.sh
./scripts/verify-workspace-install-docs.sh --fixtures-only
git diff --check
```
## Fix round 4/5 — 2026-08-08
Addressed reviewer finding:
- Eliminated semantic-index verifier/test contract drift by extracting the production
semantic-index ownership row matcher into `semantic_index_relationship_spec` and reusing it in
the fixture-level paraphrase, omission, and scattered-token checks.
- Kept the relationship constrained to one structured Markdown table row via
`verify_markdown_table_relationships`; the scattered-token fixture still removes the row and
appends the same words outside the table, where it must be rejected.
- Added a direct regression that copies the repository docs into an isolated root, applies the
accepted paraphrase “A workspace keeps exactly one Qdrant collection reserved for itself”, and
runs that root's actual `scripts/verify-workspace-install-docs.sh --fixtures-only` instead of a
separate temporary spec.
Observed RED before the fix:
```text
production verifier rejected the accepted semantic-index paraphrase
local workspace manual: missing relationship in 'Semantic index ownership contract': {'scope': 'workspace semantic index', 'ownership rule': '(each|one|single).*(workspace).*(single|one).*(Qdrant).*(collection)|(each workspace reserves a single qdrant collection)', 'isolation rule': 'schema.*evidence.*memory.*(one|that).*(collection).*(kind|payload)'}
```
Evidence:
```sh
./scripts/test-verify-workspace-install-docs.sh
./scripts/verify-workspace-install-docs.sh --fixtures-only
git diff --check
```
Observed RED during this round:
```text
PROJECT_STATE.md: historical section still contains active/live heading markers
compact manual paraphrase lacks required pattern: (esterni solo|solo esterni|restano esterni)
```
## Fix round 3/5 — 2026-08-08
Addressed reviewer findings:
- Added table-driven historical-heading fixtures for every Markdown heading level `#` through
`######`; all are rejected after the historical boundary when they contain `LIVE`/current-state
semantics.
- Added small structured ownership tables to the active local/server manuals and to the compact
Italian operator note.
- Added small structured semantic-index ownership tables to the active local/server manuals.
- Replaced the remaining scattered-token relationship checks with explicit structured-section
parsing:
- architecture ownership rows map DWH → external, LLM → external, Qdrant → internal,
Ollama embedding → internal;
- semantic-index ownership rows localize the one-workspace/one-collection contract and the
schema/Evidence/Memory isolation rule.
- Added adversarial fixtures that fail when the same tokens are merely scattered in free text.
- Added structured paraphrase fixtures that pass and omission/inversion fixtures that fail.
Evidence:
```sh
./scripts/test-verify-workspace-install-docs.sh
./scripts/verify-workspace-install-docs.sh --fixtures-only
git diff --check
```
@@ -0,0 +1,43 @@
# Task 12 Report — Remove unreachable pgvector runtime code
Status: completed
Summary:
- Proved the retired pgvector runtime had no remaining operational adapter call sites after migration by re-running the required grep; only the packaging assertion still mentions `migrations/vector`.
- Removed the obsolete pgvector/HTTP/direct vector runtime modules, vector SQL migrations, and their affected runtime tests.
- Kept the operational semantic path on Qdrant and migrated the remaining runtime callers to that path.
- Kept `psycopg2-binary` because DWH direct PostgreSQL and session PostgreSQL code still depend on it.
Implementation notes:
- Extracted shared collection/kind validation into `harness/tht/adapters/vector/_shared.py` so `QdrantVectorStore` no longer depends on the deleted pgvector module.
- Simplified `build_vector_store()` to return only `QdrantVectorStore`.
- Migrated vector/evidence/memory CLI paths away from legacy pgvector loaders and REST vector clients.
- Updated packaging coverage so the built wheel asserts session SQL migrations are present and vector SQL migrations are absent.
Verification:
- `cd harness && .venv/bin/pytest tests/test_qdrant_vector_store.py tests/test_vector_port_contract.py tests/test_semantic_kind_isolation.py tests/test_vector_migration_packaging.py -q`
- `cd harness && .venv/bin/pytest tests/test_adapter_factory.py tests/test_solved_search_cli.py -q`
- `cd harness && .venv/bin/python -c "import tht.cli, tht.adapters.factory, tht.adapters.vector, tht.vectorstore.reader"`
- `cd harness && uv build`
- `harness/.venv/bin/ruff check harness/tests/test_adapter_factory.py harness/tests/test_solved_search_cli.py harness/tests/test_vector_migration_packaging.py harness/tests/test_vector_port_contract.py harness/tht/adapters/factory.py harness/tht/adapters/vector/__init__.py harness/tht/adapters/vector/_shared.py harness/tht/adapters/vector/qdrant.py harness/tht/cli/evidence_cmd.py harness/tht/cli/memory_cmd.py harness/tht/cli/search_cmd.py harness/tht/cli/vector_cmd.py harness/tht/solved.py harness/tht/vectorstore/reader.py`
- `git diff --check`
Notes / concerns:
- Repository-wide `harness/.venv/bin/ruff check .` still reports many pre-existing findings outside this task’s touched files; it is not clean on this branch baseline.
- Some legacy config compatibility parsing still exists outside the deleted runtime path. This task removed the unreachable runtime/migration code without broad config-schema refactoring.
## Fix round 1 evidence
Changes:
- Removed dead `vector migrate` registration from `harness/tht/cli/__init__.py` and deleted `harness/tht/cli/vector_migrate_cmd.py`.
- Added CLI regressions proving `vector migrate` is absent while `vector init` and `vector index-schema` remain available.
- Restored the accidentally removed non-vector regressions by moving report coverage into `harness/tests/test_report.py` and restoring the taskdoc promoted-table slicing check in `harness/tests/test_taskdoc.py`.
- Reworded surviving active help/docstrings away from pgvector-specific wording in the touched Qdrant-backed command surface.
Verification:
- `cd harness && .venv/bin/pytest tests/test_qdrant_cli_commands.py tests/test_report.py tests/test_taskdoc.py tests/test_vector_migration_packaging.py -q`
- `cd harness && .venv/bin/python -c "from typer.testing import CliRunner; from tht.cli import app; r=CliRunner().invoke(app, ['vector','--help']); assert r.exit_code == 0, r.output; assert 'migrate' not in r.output; r=CliRunner().invoke(app, ['vector','migrate','--help']); assert r.exit_code != 0, r.output; print('cli-help-ok')"`
- `cd harness && .venv/bin/python -c "import tht.cli, tht.cli.vector_cmd, tht.report, tht.taskdoc; print('imports-ok')"`
- `cd harness && uv build`
- `harness/.venv/bin/ruff check harness/tests/test_qdrant_cli_commands.py harness/tests/test_report.py harness/tests/test_taskdoc.py harness/tests/test_vector_migration_packaging.py harness/tht/cli/__init__.py harness/tht/cli/search_cmd.py harness/tht/cli/vector_cmd.py harness/tht/cli/memory_cmd.py harness/tht/solved.py`
- `git diff --check`
@@ -0,0 +1,175 @@
# Task 13 Implementation Report
## Status
DONE_WITH_CONCERNS
## Changes
- Updated stale harness/backend/frontend tests and fixtures to the Task 13 internal Qdrant/Ollama contract.
- Made `deploy/workspaces/psd.yaml.example` generic while preserving schema-v3 Qdrant/Ollama shape.
- Fixed `scripts/workspace-registry-smoke.sh` to pass the required legacy migration `--collection` and prove exact Docker cleanup, including its smoke image.
- Updated `PROJECT_STATE.md` with only evidence observed in this run.
Changed files:
- `PROJECT_STATE.md`
- `backend/test/routes-workspaces.test.ts`
- `backend/test/workspace-runtime-handoff.test.ts`
- `backend/test/workspaces-contracts.test.ts`
- `backend/test/workspaces-git-repository.test.ts`
- `deploy/workspaces/psd.yaml.example`
- `frontend/src/shell/NewSessionDialog.test.tsx`
- `harness/tests/test_adapter_command_regressions.py`
- `harness/tests/test_workspace.py`
- `scripts/task13-runtime-fixture-check.ts`
- `scripts/test-verify-workspace-install-docs.sh`
- `scripts/workspace-registry-smoke.sh`
## Verification
Deterministic gates:
- `cd harness && .venv/bin/pytest -q && .venv/bin/ruff check .`
- Initial red: 2 harness pytest failures.
- After fixture fixes: harness pytest passed `819 passed, 4 deselected, 74 warnings in 27.73s`.
- Ruff still failed with `Found 220 errors`; treated as existing unrelated debt.
- Touched harness files verified clean with `cd harness && .venv/bin/ruff check tests/test_adapter_command_regressions.py tests/test_workspace.py && .venv/bin/pytest -q tests/test_adapter_command_regressions.py::test_solved_index_writes_through_writer_only_factory_store tests/test_workspace.py::test_load_workspace_expands_env_vars`: `All checks passed!` and `2 passed, 2 warnings in 0.14s`.
- `cd backend && npx vitest run && npx tsc --noEmit -p . && npm run build`
- Initial red: 4 backend Vitest failures.
- After fixes: `Test Files 39 passed (39)`, `Tests 464 passed (464)`, TypeScript passed, build passed.
- `cd frontend && npx vitest run && npx tsc -b && npm run build`
- Initial red: 1 frontend Vitest failure.
- After fix: frontend Vitest passed `374/374`, TypeScript passed, build passed with Vite `built in 6.55s`.
- `git diff --check`
- Passed with no output.
Focused reruns:
- `cd backend && npx vitest run test/workspaces-migrate-legacy.test.ts test/workspaces-contracts.test.ts test/routes-workspaces.test.ts test/workspace-runtime-handoff.test.ts test/workspaces-git-repository.test.ts && cd .. && ./scripts/test-no-deployment-coupling.sh && ./scripts/verify-workspace-install-docs.sh --fixtures-only && git diff --check`
- `Test Files 5 passed (5)`, `Tests 35 passed (35)`.
- Coupling guard passed: `no active retired deployment or external semantic coupling found.`
- Install docs fixtures passed through `relative secret-source fixture rejected passed`.
Deployment contracts:
- `./scripts/test-default-compose.sh && ./scripts/test-unified-compose.sh && ./scripts/test-internal-semantic-compose.sh && ./scripts/test-no-deployment-coupling.sh && ./scripts/test-compose-secret-policy.sh && ./scripts/verify-workspace-install-docs.sh --fixtures-only`
- Passed. Output included:
- `default Compose contract passed.`
- `unified Compose contract passed.`
- `internal semantic Compose/script contracts passed.`
- `no active retired deployment or external semantic coupling found.`
- `Compose secret policy passed.`
- install-doc fixture checks through `relative secret-source fixture rejected passed`.
Docker smokes:
- `/usr/bin/time -p ./scripts/internal-semantic-smoke.sh`
- Passed: `Task 13 internal semantic smoke passed.`
- Cleanup proof: `no labeled containers, volumes, networks, or images remain for 20260808200245-83368-17823.`
- Duration: `real 217.34`.
- `/usr/bin/time -p ./scripts/workspace-registry-smoke.sh`
- Initial red: `usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root> --collection <qdrant-collection> [--id <workspace-id>]`.
- After fix: `workspace registry smoke passed`.
- Cleanup proof: `no compose containers, volumes, networks, or image remain for thoth-workspace-registry-smoke-89671.`
- Duration: `real 9.93`.
- `/usr/bin/time -p ./scripts/unified-deployment-smoke.sh`
- Passed: `Task 13 full deployment smoke passed.`
- Cleanup proof: `no labeled containers, volumes, networks, or images remain for 20260808200706-85638-13391.`
- Duration: `real 125.57`.
- `/usr/bin/time -p ./scripts/thothctl-update-smoke.sh`
- Passed: `Task 13 update deployment smoke passed.`
- Cleanup proof: `no labeled containers, volumes, networks, or images remain for 20260808200918-87340-10404.`
- Duration: `real 85.40`.
- `/usr/bin/time -p ./scripts/server-deployment-smoke.sh`
- Passed: `Task 13 Linux server deployment smoke passed.`
- Cleanup proof: `no labeled containers, volumes, networks, or images remain for 20260808201047-88645-20675.`
- Duration: `real 55.99`.
Final audit:
- `rg -n "pgvector|local-vector|THT_VECTOR_|EMBEDDING_BASE_URL|openai_compatible|ollama_compatible" . --glob '!docs/plans/**' --glob '!docs/superpowers/**' --glob '!**/node_modules/**' --glob '!**/.venv/**' --glob '!**/.git/**'`
- Returned matches in legacy schema-v1/v2 support, migration tests, negative guards, historical notes, and older harness docs/code.
- This remains a concern: the audit is not clean under the brief's strict expected outcome.
- `git status --short`
- Before report/commit, contained only intentional Task 13 changes.
## Image and Host Evidence
- Host CPU: `Apple M4 Pro`.
- Host OS: `Darwin MacProM4-di-Marco.local 25.5.0 Darwin Kernel Version 25.5.0: Tue Jun 9 22:28:34 PDT 2026; root:xnu-12377.121.10~1/RELEASE_ARM64_T6041 arm64`.
- Docker server: `29.6.2 linux/arm64`.
- Verified pinned images:
- `qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c`.
- `ollama/ollama:0.32.0@sha256:57f573b47f1f71ebb445789f279fe3e596a8beab182f7cf486db9205bad87c5a`.
- Workspace registry smoke ephemeral image:
- Manifest list: `sha256:4d056bf2cb38d0e8ede91fbf121df1f9f18caee0d401581618ccef9ed8a55e73`.
- Config: `sha256:613f8fb28c0517adee4085f41bc447f2c3813b0fdbb7b26624bfb4cb192b6fd8`.
- Removed during cleanup.
## Manual Gates
- GPU exposure gate (`THOTH_ENABLE_EMBEDDING_GPU=1` on Linux): not executed in this run.
- Windows Docker Desktop startup/manual job: not executed in this run.
## Commits
- `4e810af` (`test: align qdrant ollama verification fixtures`)
- `7c09b98` (`docs: record qdrant ollama verification`)
## Known Limitations
- Broad harness Ruff remains existing unrelated debt: `Found 220 errors`.
- Final active-reference audit is not clean; it still finds legacy/negative-guard references outside explicit migration fixture files.
- Ephemeral Task 13 core/frontend image IDs from `internal-semantic-smoke.sh`, `unified-deployment-smoke.sh`, `thothctl-update-smoke.sh`, and `server-deployment-smoke.sh` were removed by exact cleanup and were not emitted in stdout; pinned Qdrant/Ollama digests and the workspace-registry smoke image digest were captured.
## Fix Round 1 — reviewer findings
Status: DONE
Changes:
- `scripts/workspace-registry-smoke.sh` now derives the smoke image reference from the already unique Compose project instead of using the global tag `thothii-workspace-registry-smoke:local`.
- The workspace-registry cleanup helpers remove and verify only the exact per-run image reference, plus Compose resources labeled with the exact project.
- Added deterministic self-test coverage in `backend/test/workspaces-migrate-legacy.test.ts` via `WORKSPACE_REGISTRY_SMOKE_SELF_TEST=image-cleanup-identity`; it stubs Docker and fails if cleanup touches same-repository foreign tags such as `:local` or another project tag.
- Updated active harness/testing/PRD docs and Python comments that still described the current semantic store as pgvector/vectordb. Preserved schema-v1/v2 and harness legacy compatibility fixtures.
- Updated `PROJECT_STATE.md` with fix-round smoke evidence and a precise, non-overclaiming audit limitation.
Focused verification:
- `cd backend && npx vitest run test/workspaces-migrate-legacy.test.ts`
- Passed: `7 passed`.
- `cd harness && .venv/bin/pytest -q tests/test_memory_save_one.py tests/test_adapter_command_regressions.py tests/test_solved_search_cli.py tests/test_search_pack.py`
- Passed: `22 passed, 14 warnings`.
- `cd harness && .venv/bin/ruff check tht/memory.py tht/search/__init__.py tht/workspace.py tht/vectorstore/store.py tests/test_memory_save_one.py tests/test_adapter_command_regressions.py tests/test_solved_search_cli.py`
- Passed: `All checks passed!`
- `bash -n scripts/workspace-registry-smoke.sh && WORKSPACE_REGISTRY_SMOKE_SELF_TEST=image-cleanup-identity bash scripts/workspace-registry-smoke.sh`
- Passed: `workspace registry smoke image cleanup identity self-test passed`.
- `./scripts/test-no-deployment-coupling.sh`
- Passed: `no active retired deployment or external semantic coupling found.`
- `./scripts/verify-workspace-install-docs.sh --fixtures-only`
- Passed through `relative secret-source fixture rejected passed`.
- `cd backend && npx tsc --noEmit -p .`
- Passed with no output.
- `/usr/bin/time -p ./scripts/workspace-registry-smoke.sh`
- Passed: `workspace registry smoke passed`.
- Built exact per-run tag: `thothii-workspace-registry-smoke:thoth-workspace-registry-smoke-thoth-workspace-registry-smoke-10vi3a-19157`.
- Manifest list: `sha256:715b943057929418cad4aa71806d9edbaf823555d19bda6b875297617463fd4a`.
- Config: `sha256:a566521981e08958aae9a12bfc7803bb5f3f835536b4bb8c39df8fcf26063161`.
- Cleanup proof: `no compose containers, volumes, networks, or image remain for thoth-workspace-registry-smoke-thoth-workspace-registry-smoke-10vi3a-19157.`
- Duration: `real 42.06`.
Fix-round audit command:
- `rg -n "pgvector|local-vector|THT_VECTOR_|EMBEDDING_BASE_URL|openai_compatible|ollama_compatible" . --glob '!docs/plans/**' --glob '!docs/superpowers/**' --glob '!**/node_modules/**' --glob '!**/.venv/**' --glob '!**/.git/**'`
Categorized remaining hits:
- Backend legacy parser/migration compatibility, kept deliberately non-operational for schema-v1/v2 descriptors: `backend/src/workspaces/schema.ts`, `types.ts`, `migrate-legacy.ts`, `runtime-renderer.ts`, `bindings.ts`, `contracts.ts`, `diagnostics.ts`.
- Backend negative guards and legacy fixture tests: `backend/test/workspaces-schema.test.ts`, `workspaces-migrate-v2-qdrant.test.ts`, `workspace-registry.test.ts`, `workspace-runtime-renderer.test.ts`, `workspaces-bindings.test.ts`, `workspaces-contracts.test.ts`, `workspaces-diagnostics.test.ts`, `workspaces-git-repository.test.ts`, `routes-workspaces.test.ts`, `routes-sessions.test.ts`, `provider-credentials.test.ts`.
- Secret/env scrub guards for retired variables: `backend/src/config.ts`, `backend/src/config/secret-bundle.ts`, `backend/src/pi/provider-credentials.ts`, `scripts/compose-with-preflight.sh`, `scripts/test-external-compose-lifecycle.sh`.
- Deployment negative guards and fixture-scope tests: `scripts/test-no-deployment-coupling.sh`, `scripts/test-no-deployment-coupling-scope.sh`, `scripts/test-preprocess-compose-config.sh`, `scripts/test-verify-workspace-install-docs.sh`, `scripts/verify-workspace-install-docs.sh`, `scripts/vector-rotate-bootstrap-password.sh`.
- Harness legacy config compatibility and fixtures: `harness/tht/config.py`, `harness/tht/config_compat.py`, `harness/tests/test_config_resources.py`, `harness/tests/l2/test_session_ablazione.py`, `harness/workspaces/tht.example.yaml`, `harness/workspaces/tht-test.yaml`.
- Retained off-repository migration SQL fixtures: `harness/scripts/create_vector_reader_rpc.sql`, `harness/scripts/create_vector_writer_rpc.sql`.
- Historical/reference notes, not active operator contracts: `brain/codebase/datamart-builder-deployment-gotchas.md`, `PROJECT_STATE.md`.
- Gitignored task report self-reference: `.superpowers/sdd/2026-08-08-internal-qdrant-ollama/task-13-implementation.md`.
@@ -0,0 +1,165 @@
Task 2 report — Make collection ownership unique in the Git registry
Summary
- Implemented unique Qdrant collection ownership enforcement during registry snapshot activation.
- Registry session revision leases now reject `migration_required` descriptors.
- Legacy migration now requires an explicit target collection and emits schema v3 descriptors.
- Preserved active snapshot rollback behavior on invalid pulled snapshots.
RED evidence
Focused RED command from the brief:
```bash
cd backend
npx vitest run test/workspace-registry.test.ts test/workspaces-migrate-legacy.test.ts \
-t "collection|migration_required"
```
Observed failures before implementation:
- `rejects duplicate schema v3 collection ownership and keeps the previous active snapshot`
- `registry.pull()` resolved instead of rejecting.
- `does not acquire a session revision lease for a migration_required workspace`
- `acquireSessionRevision()` resolved instead of rejecting.
- `migrates a legacy descriptor only with an explicit target collection into schema v3`
- received schema version `1` instead of `3`.
- `requires an explicit target collection for legacy migration`
- migration did not throw without a collection.
GREEN evidence
Focused GREEN command from the brief:
```bash
cd backend
npx vitest run test/workspace-registry.test.ts test/workspaces-migrate-legacy.test.ts \
-t "collection|migration_required"
```
Fresh result after implementation:
- 2 files passed
- 4 tests passed
- 0 failures
Additional verification run after final cleanup:
```bash
cd backend
npx vitest run test/routes-workspaces.test.ts
npx vitest run
npx tsc --noEmit -p .
git diff --check
```
Fresh results:
- `test/routes-workspaces.test.ts`: 7 passed
- full backend Vitest: 39 files passed, 454 tests passed
- backend typecheck: passed
- `git diff --check`: passed
Changed files
- `backend/src/workspaces/registry.ts`
- `backend/src/workspaces/migrate-legacy.ts`
- `backend/test/workspace-registry.test.ts`
- `backend/test/workspaces-migrate-legacy.test.ts`
- `backend/test/routes-workspaces.test.ts`
Why one extra file changed
- `backend/test/routes-workspaces.test.ts` needed updating because Task 1 made schema v3 the only operational descriptor shape, and the route test still assumed the old pre-Task-3 runtime behavior. Updating that expectation was necessary to keep the required backend suite verification meaningful.
Implementation notes
- Duplicate collection detection is enforced only for operational schema v3 descriptors by tracking `collection -> workspaceId` during activation.
- Duplicate failures are sanitized back to `workspace_invalid` / `Workspace repository content is invalid`.
- `acquireSessionRevision()` now fails closed for `migration_required` revisions.
- Legacy migration CLI now requires `--collection <qdrant-collection>`.
- Legacy migration output is schema v3 with the fixed internal semantic contract:
- `vector_store.engine = qdrant`
- explicit `collection`
- embedding provider `ollama_internal`
- embedding model `qwen3-embedding:0.6b`
self-review
- Confirmed invalid pulled snapshots do not replace the previous active snapshot.
- Confirmed duplicate collection enforcement does not affect legacy migration-required descriptors.
- Confirmed create/update publication tests still pass with unique per-workspace collections.
- Confirmed no JSON stdout contract regressions in the migration CLI.
- Kept runtime/data mutation scope descriptor-only; no user workspace repo or Qdrant data changes.
Concerns
- No code concerns remaining for Task 2.
- One deliberate scope exception: a route test was updated to align with the already-established Task 1 / Task 3 fail-closed contract.
Fix round 1
Scope
- Restored meaningful route-level diagnoser coverage without reopening schema-v3 semantic runtime paths.
- Added direct schema-v2 registry coverage for `migration_required` listing and lease rejection.
Covering test files
- `backend/test/routes-workspaces.test.ts`
- `backend/test/workspace-registry.test.ts`
RED command and output
Command:
```bash
cd backend
npx vitest run test/routes-workspaces.test.ts test/workspace-registry.test.ts
```
Observed result on top of `76bc94d` after adding the restored/new assertions:
- 2 files passed
- 37 tests passed
- 0 failures
Why no RED appeared:
- The review items exposed missing/weakened coverage, not a production behavior bug.
- `/workspaces/:id/test` already reaches the diagnoser for resolvable legacy v2 descriptors.
- Schema-v3 `/workspaces/:id/test` already fails closed before diagnoser entry.
- Schema-v2 descriptors were already listed as `migration_required` and already rejected by `acquireSessionRevision()`.
GREEN command and output
Command:
```bash
cd backend
npx vitest run test/routes-workspaces.test.ts test/workspace-registry.test.ts
npx tsc --noEmit -p .
```
Fresh results:
- covering tests: 2 files passed, 37 tests passed
- backend typecheck: passed
Changed files
- `backend/test/routes-workspaces.test.ts`
- `backend/test/workspace-registry.test.ts`
- `.superpowers/sdd/2026-08-08-internal-qdrant-ollama/task-2-report.md`
What changed
- Split route coverage so `POST /workspaces/validate` still checks canonical validation independently.
- Restored route-level diagnoser coverage through a migration-required schema-v2 descriptor with resolvable legacy bindings.
- Added an explicit schema-v3 fail-closed regression for `POST /workspaces/:id/test`.
- Added a direct schema-v2 registry regression proving `list()` returns `migration_required` and `acquireSessionRevision()` rejects it.
Concerns
- No production concerns. This round only tightened coverage and corrected the weakened test expectation.
@@ -0,0 +1,132 @@
# Task 3 report — Remove external semantic bindings and render internal endpoints
Date: 2026-08-08
## Scope
Implemented backend-owned schema-v3 semantic runtime rendering so workspace descriptors and installation contracts remain free of external Qdrant/Ollama endpoints and credentials, while DWH bindings stay unchanged.
## RED evidence
Focused RED command:
`cd backend && npx vitest run test/workspaces-contracts.test.ts test/workspaces-bindings.test.ts test/workspace-runtime-renderer.test.ts test/config.test.ts`
Observed failures before implementation:
- `config.test.ts`
- missing `internalQdrantUrl`
- missing `internalEmbeddingUrl`
- `workspaces-bindings.test.ts`
- schema v3 semantic binding resolution threw unsupported errors
- `workspace-runtime-renderer.test.ts`
- schema v3 runtime rendering threw `Schema version 3 runtime rendering is unsupported until the internal semantic runtime is implemented`
## GREEN evidence
Focused GREEN command:
`cd backend && npx vitest run test/workspaces-contracts.test.ts test/workspaces-bindings.test.ts test/workspace-runtime-renderer.test.ts test/config.test.ts`
Result:
- 4 test files passed
- 36 tests passed
Typecheck:
`cd backend && npx tsc --noEmit -p .`
Result:
- passed
Hygiene:
- `git diff --check` passed
## Files changed
Listed-task files changed:
- `backend/src/config.ts`
- `backend/src/workspaces/bindings.ts`
- `backend/src/workspaces/runtime-renderer.ts`
- `backend/test/config.test.ts`
- `backend/test/workspace-runtime-renderer.test.ts`
- `backend/test/workspaces-bindings.test.ts`
- `backend/test/workspaces-contracts.test.ts`
Listed-task files inspected but not changed:
- `backend/src/workspaces/contracts.ts`
Unavoidable additional wiring changes:
- `backend/src/app.ts`
- `backend/src/tht/tht-runner.ts`
Reason: the new typed internal semantic runtime config had to flow from backend config into ephemeral harness config rendering at runtime.
## Behavior delivered
- schema-v3 installation contract exposes DWH bindings only
- schema-v3 binding resolution ignores external semantic env vars instead of sourcing runtime semantics from them
- runtime rendering for schema v3 emits backend-owned internal semantic endpoints:
- Qdrant: `http://qdrant:6333`
- Embedding: `http://embedding:11434`
- Model: `qwen3-embedding:0.6b`
- Dimensions: `1024`
- internal semantic URLs are validated to allow only `qdrant` / `embedding` / `localhost` / loopback hosts
- DWH transport/runtime behavior remains unchanged
## Self-review
- Confirmed schema-v3 contracts/docs no longer advertise VECTOR or EMBEDDING installation variables.
- Confirmed schema-v3 runtime output ignores injected external semantic endpoints from env bindings.
- Confirmed semantic endpoints are rendered only in the ephemeral backend-owned harness config path.
- Confirmed type wiring is explicit from `AppConfig` → `ThtRunner` → runtime renderer.
## Concerns
- Host validation currently permits both `http` and `https` on the allowed internal hosts. That keeps the configuration flexible, but if the installation contract intended `http` only, that restriction is not enforced here.
## Fix round 1/5
Scope:
- moved schema-v3 internal embeddings under `resources.embeddings`
- enforced `http`-only internal semantic URLs
RED evidence:
`cd backend && npx vitest run test/workspace-runtime-renderer.test.ts test/config.test.ts`
Observed failures on `bc8afe0`:
- `workspace-runtime-renderer.test.ts`
- schema-v3 output omitted `resources.embeddings`
- schema-v3 still exposed top-level `embeddings`
- `config.test.ts`
- `https://qdrant:6333` was accepted
GREEN evidence:
`cd backend && npx vitest run test/workspace-runtime-renderer.test.ts test/config.test.ts`
Result:
- 2 test files passed
- 16 tests passed
Typecheck:
`cd backend && npx tsc --noEmit -p .`
Result:
- passed
Updated concerns:
- none for this round beyond future tightening if exact-port rejection is later requested explicitly.
@@ -0,0 +1,149 @@
# Task 4 Report — Narrow harness embedding configuration to internal Ollama
## Status
Implemented on 2026-08-08 in `/Users/mp/projects/ThothII/.worktrees/git-workspace-registry`.
## RED evidence
Command:
```bash
cd harness
./.venv/bin/pytest tests/test_internal_embeddings.py tests/test_config_resources.py -q
```
Observed before implementation:
- exit code `1`
- `10 failed, 10 passed`
- failures proved the missing `OllamaInternalEmbeddings` client and missing internal-only config validation
Representative failures:
- `ImportError: cannot import name 'OllamaInternalEmbeddings'`
- `AttributeError: 'EmbeddingsConfig' object has no attribute 'provider'`
- config tests `DID NOT RAISE ConfigError` for external provider, API key, and non-private base URL
## GREEN evidence
Focused behavior suite:
```bash
cd harness
./.venv/bin/pytest tests/test_internal_embeddings.py tests/test_config_resources.py -q
```
- exit code `0`
- `20 passed`
Relevant harness verification:
```bash
cd harness
./.venv/bin/pytest tests/test_internal_embeddings.py tests/test_config_resources.py tests/test_ollama_ensure.py -q
```
- exit code `0`
- `36 passed, 2 warnings`
Changed-file lint:
```bash
cd harness
./.venv/bin/ruff check tht/config.py tht/config_compat.py tht/vectorstore/embeddings.py tht/cli/ollama_cmd.py tests/test_config_resources.py tests/test_internal_embeddings.py
```
- exit code `0`
- `All checks passed!`
Patch hygiene:
```bash
git diff --check
```
- exit code `0`
## What changed
- translated schema-v3 `resources.embeddings` into the harness-compatible embedding config view
- validated the internal embedding contract only for that runtime-owned `resources.embeddings` path:
- provider must be `ollama_internal`
- model must be `qwen3-embedding:0.6b`
- dimensions must be `1024`
- base URL must be `http://embedding:11434` or loopback HTTP on port `11434`
- extra fields like `api_key` are rejected
- replaced the active embed client with `OllamaInternalEmbeddings`, using one bounded `/api/embed` request per batch
- removed task/query prefix rewriting from the active embedding path
- validated response count, vector dimension, and finite numeric values before returning embeddings
- kept `tht ollama ensure --json` stdout pristine while warming through the internal client
## Self-review
- kept changes inside the brief-listed files
- preserved DWH and session-persistence behavior
- preserved the legacy `OllamaEmbeddings` import path as an alias to avoid unrelated call-site churn
## Concerns
- the focused harness verification still emits two pre-existing warnings:
- `DeprecationWarning` from `testcontainers.postgres`
- `FutureWarning` because `resources` currently flows through the legacy config translation path
## Fix round 1 — 2026-08-08
### Findings addressed
- HIGH: external top-level `embeddings` remained an operational fallback and could still load
- MEDIUM: non-object embed JSON payloads escaped as raw `AttributeError`
### RED evidence
Command:
```bash
cd harness
./.venv/bin/pytest tests/test_internal_embeddings.py tests/test_config_resources.py tests/test_ollama_ensure.py -q
```
Observed before the fix:
- exit code `1`
- `2 failed, 36 passed, 2 warnings`
Representative failures:
- `AttributeError: 'list' object has no attribute 'get'` from `response.json()` returning a JSON array
- `Failed: DID NOT RAISE ConfigError` for top-level external `embeddings.provider=openai_compatible`
### GREEN evidence
Command:
```bash
cd harness
./.venv/bin/pytest tests/test_internal_embeddings.py tests/test_config_resources.py tests/test_ollama_ensure.py -q
```
Observed after the fix:
- exit code `0`
- `38 passed, 2 warnings`
Touched-file lint:
```bash
cd harness
./.venv/bin/ruff check tht/config.py tht/vectorstore/embeddings.py tests/test_internal_embeddings.py tests/test_config_resources.py
```
- exit code `0`
- `All checks passed!`
### Minimal fix
- validated the final active `cfg.embeddings` contract after config loading, so legacy top-level
embedding inputs now fail explicitly unless they exactly match the internal Ollama contract
- converted non-mapping embed JSON payloads into controlled `EmbeddingsError` failures with
sanitized diagnostics instead of raw attribute errors
@@ -0,0 +1,170 @@
# Task 5 Report — Implement the Qdrant VectorStore adapter
## Status
Implemented on 2026-08-08 in `/Users/mp/projects/ThothII/.worktrees/git-workspace-registry`.
## RED evidence
Command:
```bash
cd harness
./.venv/bin/pytest tests/test_qdrant_vector_store.py tests/test_vector_port_contract.py -q
```
Observed before implementation:
- exit code `2`
- collection failed during import because the adapter did not exist yet
Representative failures:
- `ModuleNotFoundError: No module named 'tht.adapters.vector.qdrant'`
## GREEN evidence
Focused behavior suite:
```bash
cd harness
./.venv/bin/pytest tests/test_qdrant_vector_store.py tests/test_vector_port_contract.py -q
```
- exit code `0`
- `31 passed, 1 warning`
Touched-file lint:
```bash
cd harness
./.venv/bin/ruff check tht/adapters/vector/qdrant.py tht/adapters/vector/__init__.py \
tht/ports/vector.py tht/vectorstore/records.py tht/vectorstore/store.py \
tests/test_qdrant_vector_store.py tests/test_vector_port_contract.py
```
- exit code `0`
- `All checks passed!`
Patch hygiene:
```bash
git diff --check
```
- exit code `0`
## What changed
- added `QdrantVectorStore` with direct `requests`-based REST calls for:
- `GET /collections/{collection}`
- `PUT /collections/{collection}`
- `PUT /collections/{collection}/index`
- `PUT /collections/{collection}/points?wait=true`
- `POST /collections/{collection}/points/query`
- `POST /collections/{collection}/points/scroll`
- `POST /collections/{collection}/points/delete?wait=true`
- implemented idempotent collection provisioning for `1024` dimensions and `Cosine` distance
- created deterministic UUIDv5 point IDs from workspace, semantic kind, and canonical record key
- preserved canonical record identity and only upserted/deleted points matching the exact workspace
and generation filters
- added Qdrant payload helpers so stored payloads carry:
- `workspace_id`
- grouped semantic `kind` (`schema`, `evidence`, `memory`)
- original `record_kind`
- canonical `record_key`
- `content_hash`
- existing Thoth metadata fields
- mapped Qdrant payloads back into existing `VectorHit` objects without losing the original
Thoth kind
- exported the new adapter from the public vector adapter package and added focused contract tests
- sanitized timeout and malformed-response failures so CLI-facing callers do not leak raw endpoint
details
## Self-review
- confirmed collection mismatch fails without any delete/recreate path
- confirmed every query/scroll/delete operation includes a workspace filter
- confirmed the adapter never deletes or rewrites unrelated Qdrant points
- added keyword payload indexes for all filter-critical fields used here, including `document_id`
for exact Evidence filtering
## Concerns
- the requested `adversarial-review` skill could not run its full external reviewer flow in this
environment because the skill’s referenced `brain/` files are missing at
`/Users/mp/.agents/skills/adversarial-review`; I performed a manual adversarial self-review
instead
- the focused suite still emits one pre-existing warning from `testcontainers.postgres`
## Fix round 1 — 2026-08-08
### Findings addressed
- IMPORTANT: metadata collisions could override canonical Qdrant payload identity fields and break
workspace isolation
- IMPORTANT: scroll-based operations only read the first page and did not follow
`next_page_offset`, making `existing_hashes`, `list_evidence_generations`, and delete counts
inexact beyond one page
### RED evidence
Command:
```bash
cd harness
./.venv/bin/pytest tests/test_qdrant_vector_store.py tests/test_vector_port_contract.py -q
```
Observed before the fix:
- exit code `1`
- `2 failed, 31 passed, 1 warning`
Representative failures:
- `assert payload["workspace_id"] == "demo"` failed because colliding `record.metadata`
overwrote canonical payload fields
- paginated scroll test missed later pages, so `existing_hashes` and generation cleanup counts
were incomplete
### GREEN evidence
Command:
```bash
cd harness
./.venv/bin/pytest tests/test_qdrant_vector_store.py tests/test_vector_port_contract.py -q
```
Observed after the fix:
- exit code `0`
- `33 passed, 1 warning`
Touched-file lint:
```bash
cd harness
./.venv/bin/ruff check tht/adapters/vector/qdrant.py tht/vectorstore/records.py \
tests/test_qdrant_vector_store.py tests/test_vector_port_contract.py
```
- exit code `0`
- `All checks passed!`
Patch hygiene:
```bash
git diff --check
```
- exit code `0`
### Minimal fix
- made `qdrant_payload` apply canonical fields after `record.metadata` so workspace ID, semantic
kind, original record kind, canonical record key, and content hash cannot be overridden by
metadata collisions
- paginated `_scroll` until `next_page_offset` is absent, sent the returned `offset` back on the
next request, and reject repeated offsets as malformed to avoid infinite loops
@@ -0,0 +1,144 @@
# Task 6 Report
Date: 2026-08-08
Status: implemented and verified
Summary:
- Added schema-v3 Qdrant runtime support to the harness config/resource layer and vector factory.
- Made Qdrant payloads carry `workspace_id` and `workspace_revision` on every point.
- Routed schema and memory bulk indexing through the transport-neutral vector port with canonical hash-based dedup.
- Kept Evidence canonical on filesystem and Memory canonical in JSONL; Qdrant remains derived/rebuildable.
- Added focused tests for semantic-kind isolation, shared identity fields, search-pack kind boundaries, and the schema-v3 factory/config path.
Files changed:
- `harness/tht/config.py`
- `harness/tht/config_compat.py`
- `harness/tht/adapters/factory.py`
- `harness/tht/adapters/vector/qdrant.py`
- `harness/tht/vectorstore/records.py`
- `harness/tht/cli/vector_cmd.py`
- `harness/tht/cli/memory_cmd.py`
- `harness/tests/test_semantic_kind_isolation.py`
- `harness/tests/test_memory_save_one.py`
- `harness/tests/test_search_pack.py`
- `harness/tests/test_qdrant_vector_store.py`
- `harness/tests/test_adapter_factory.py`
- `harness/tests/test_config_resources.py`
Verification:
- Focused RED/GREEN task suite:
- `cd harness && .venv/bin/pytest tests/test_semantic_kind_isolation.py tests/test_memory_save_one.py tests/test_search_pack.py -q`
- Relevant harness suite:
- `cd harness && .venv/bin/pytest tests/test_semantic_kind_isolation.py tests/test_memory_save_one.py tests/test_search_pack.py tests/test_qdrant_vector_store.py tests/test_adapter_factory.py tests/test_config_resources.py tests/test_vector_port_contract.py tests/test_corpus_pipeline.py -q`
- Result: `131 passed`
- Changed-file Ruff:
- `cd harness && .venv/bin/ruff check tht/vectorstore/records.py tht/adapters/vector/qdrant.py tht/config_compat.py tht/config.py tht/adapters/factory.py tht/cli/vector_cmd.py tht/cli/memory_cmd.py tests/test_memory_save_one.py tests/test_search_pack.py tests/test_semantic_kind_isolation.py tests/test_qdrant_vector_store.py tests/test_adapter_factory.py tests/test_config_resources.py`
- Result: clean
Concerns / follow-up:
- `memory clear` still retains its older direct-vector assumptions and was not expanded in this task because the brief focused on canonical builders and schema/evidence/memory routing through the active Qdrant path.
- The relevant suite still emits pre-existing warnings (legacy config deprecation in older fixtures, plus existing Pydantic serializer warnings in corpus tests), but they are not introduced by this task.
## Fix round 1 (2026-08-08)
Scope:
- Fixed qdrant-only schema-v3 command gating for `vector index-schema`, `memory promote`, and `memory index`.
- Replaced `memory clear`'s direct-pgvector-only path with vector-port deletion by kind.
- Added focused qdrant-only CLI regression tests and refreshed older CLI fixtures to the enforced internal embedding contract.
RED evidence:
- `cd harness && .venv/bin/pytest tests/test_qdrant_cli_commands.py -q`
- Initial result against commit `5e39cfa`: `4 failed`
- Failure signatures:
- `ERRORE: sezioni mancanti nel workspace yaml: vector_db o vector_write_rest.`
- `ERRORE: sezioni mancanti nel workspace yaml: vector_db.`
GREEN evidence:
- Focused fix suite:
- `cd harness && .venv/bin/pytest tests/test_qdrant_cli_commands.py tests/test_qdrant_vector_store.py tests/test_adapter_factory.py tests/test_config_resources.py tests/test_memory_save_one.py tests/test_search_pack.py -q`
- Result: `51 passed`
- Relevant broader vector/memory/schema/search suite:
- `cd harness && .venv/bin/pytest tests/test_qdrant_cli_commands.py tests/test_qdrant_vector_store.py tests/test_adapter_factory.py tests/test_config_resources.py tests/test_memory_save_one.py tests/test_search_pack.py tests/test_vector_port_contract.py tests/test_adapter_command_regressions.py tests/test_solved_search_cli.py tests/test_schema_introspect_guard.py tests/test_semantic_kind_isolation.py tests/test_corpus_pipeline.py -q`
- Result: `154 passed`
- Ruff on the fix surface:
- `cd harness && .venv/bin/ruff check tht/ports/vector.py tht/adapters/vector/qdrant.py tht/adapters/vector/pgvector.py tht/adapters/vector/thoth_http.py tht/vectorstore/rest_client.py tht/cli/vector_cmd.py tht/cli/memory_cmd.py tests/test_qdrant_cli_commands.py tests/test_solved_search_cli.py`
- Result: clean
Notes:
- `memory clear` now deletes derived `kind=memory` points through the configured writable vector store, while leaving the JSONL registry as the source of truth until the registry file is removed by the command.
- The broader suite still carries the same pre-existing warnings noted above; this fix round did not add new warnings or failures.
## Fix round 2 (2026-08-08)
Scope:
- Removed the accidental HTTP writer `delete_kinds` capability expansion from `ThothHttpVectorStore` and `VectorRestClient`.
- Reworked `memory clear` so schema-v3 Qdrant uses scoped `kind=memory` deletion, while legacy transports keep the pre-task direct-sync path instead of advertising a nonexistent RPC.
- Tightened the qdrant-only memory-clear regression to assert the exact `("memory", ["memory"])` delete scope.
RED evidence:
- Re-review found a transport contract mismatch in fix round 1:
- `ThothHttpVectorStore` exposed `delete_kinds(...)`
- `VectorRestClient` exposed `delete_kinds(...)`
- but the legacy HTTP writer migration only allowlists `delete_vector_generation`, not `delete_vector_kinds`
- The new regressions added in this round capture that mismatch and the missing qdrant delete-scope assertion:
- `tests/test_vector_port_contract.py::test_http_store_supports_writer_without_reader`
- `tests/l0/test_vector_adapter_parity.py::test_http_rest_client_does_not_advertise_nonexistent_delete_kinds_rpc`
- `tests/test_qdrant_cli_commands.py::test_memory_clear_accepts_qdrant_only_runtime_config`
GREEN evidence:
- Focused regression suite:
- `cd harness && .venv/bin/pytest tests/test_qdrant_cli_commands.py tests/test_vector_port_contract.py tests/l0/test_vector_adapter_parity.py tests/test_adapter_command_regressions.py -q`
- Result: `53 passed`
- Broader relevant vector/memory/search suite:
- `cd harness && .venv/bin/pytest tests/test_qdrant_cli_commands.py tests/test_adapter_command_regressions.py tests/test_vector_port_contract.py tests/l0/test_vector_adapter_parity.py tests/test_solved_search_cli.py tests/test_qdrant_vector_store.py tests/test_search_similar_kinds.py tests/test_corpus_pipeline.py -q`
- Result: `135 passed`
- Ruff on the changed fix surface:
- `cd harness && .venv/bin/ruff check tht/cli/memory_cmd.py tht/ports/vector.py tht/adapters/vector/thoth_http.py tht/vectorstore/rest_client.py tests/test_qdrant_cli_commands.py tests/test_vector_port_contract.py tests/l0/test_vector_adapter_parity.py`
- Result: clean
Notes:
- Legacy HTTP/vector-rest deployments do not gain a new destructive RPC surface from this fix; they keep their previous behavior and continue to fail closed for unsupported cleanup.
- The broader suite still emits the same pre-existing deprecation and serializer warnings already noted above; this round did not introduce new warnings.
## Fix round 3 (2026-08-08)
Scope:
- Added an adapter-level Qdrant regression for mixed semantic kinds within one workspace plus a second workspace memory point.
- Verified that `delete_kinds("memory", ["memory"])` emits the real adapter filter with both `workspace_id=demo` and `record_kind=memory`.
- Verified that non-memory semantic kinds in the same workspace and memory from another workspace survive the delete.
RED evidence:
- Re-review identified a test gap rather than a confirmed runtime bug:
- existing coverage asserted only the CLI mock call shape for qdrant memory clear
- there was no adapter-level regression proving the real Qdrant delete filter and resulting fake-Qdrant state across mixed semantic kinds/workspaces
- Added regression:
- `tests/test_qdrant_vector_store.py::test_delete_kinds_is_workspace_scoped_and_preserves_other_semantic_kinds`
GREEN evidence:
- Requested focused suite:
- `cd harness && .venv/bin/pytest tests/test_qdrant_vector_store.py tests/test_qdrant_cli_commands.py tests/test_semantic_kind_isolation.py -q`
- Result: `18 passed`
- Ruff on changed files:
- `cd harness && .venv/bin/ruff check tests/test_qdrant_vector_store.py`
- Result: clean
Notes:
- This round required no production change; the new adapter regression passed against the existing Qdrant implementation.
- The focused suite still emits the same pre-existing `testcontainers.postgres` deprecation warning from `tests/conftest.py`; no new warnings were introduced.
@@ -0,0 +1,98 @@
# Task 7 report — mandatory Qdrant and Ollama Compose services
Date: 2026-08-08
Status: completed
Summary:
- Added mandatory private `qdrant`, `embedding`, and `embedding-model-init` services to the base Compose stack.
- Pinned Qdrant `v1.18.2` and Ollama `0.32.0` by immutable multi-arch digest.
- Persisted Qdrant storage in `qdrant-data` and Ollama model cache in `embedding-models`.
- Wired `core` to fixed internal semantic endpoints:
- `THT_INTERNAL_QDRANT_URL=http://qdrant:6333`
- `THT_INTERNAL_EMBEDDING_URL=http://embedding:11434`
- `THT_INTERNAL_EMBEDDING_MODEL=qwen3-embedding:0.6b`
- `THT_INTERNAL_EMBEDDING_DIMENSIONS=1024`
- Removed external vector / embedding endpoint requirements from the local and server env examples.
- Added an idempotent Ollama model bootstrap script that:
- waits up to a bounded deadline for `/api/tags`
- skips `ollama pull` when the model is already cached
- pulls `qwen3-embedding:0.6b` only when needed
- verifies the model appears in `/api/tags` after pull
- Added optional GPU override file `deploy/compose.embedding-gpu.yaml`; base Compose remains CPU-only.
- Updated `scripts/run-stack.sh` so the GPU override is included only when `THOTH_ENABLE_EMBEDDING_GPU=1`.
Verification:
- RED confirmed before implementation:
- `./scripts/test-default-compose.sh` failed on missing required services.
- `./scripts/test-unified-compose.sh` failed on missing required services.
- `./scripts/test-internal-semantic-compose.sh` failed because the GPU override file did not exist.
- GREEN after implementation:
- `./scripts/test-default-compose.sh`
- `./scripts/test-unified-compose.sh`
- `./scripts/test-internal-semantic-compose.sh`
- `git diff --check`
- Additional shell verification:
- `scripts/run-stack.sh --wait` includes only base + local Compose files by default.
- `THOTH_ENABLE_EMBEDDING_GPU=1 scripts/run-stack.sh --wait` adds `deploy/compose.embedding-gpu.yaml`.
Resolved image digests:
- `qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c`
- `ollama/ollama:0.32.0@sha256:57f573b47f1f71ebb445789f279fe3e596a8beab182f7cf486db9205bad87c5a`
Self-review:
- The first bootstrap-script draft depended on tools not guaranteed inside the Ollama image. This was corrected after image inspection; the final script uses only confirmed image tools (`bash`, `ollama`, `grep`) plus raw HTTP over `/dev/tcp`.
- The server overlay intentionally replaces most named core volumes with bind mounts, so the unified contract was tightened to require named semantic-cache volumes there while preserving the local/base named-volume checks.
Concerns:
- The model bootstrap waits for Ollama readiness and verifies cache state, but the first real cold-start will still take time to download `qwen3-embedding:0.6b`.
- The GPU override requests generic Docker GPU capability only; actual GPU availability remains host/runtime dependent and intentionally stays opt-in.
## Fix round 1 / 5 — 2026-08-08
Rulings applied:
- Kept the Task 1 boundary intact: schema-v3 remains the only operational workspace descriptor shape.
- Did not restore any external semantic fallback for schema-v2 live sessions.
- Treated `PROJECT_STATE.md` as stale documentation for this point, not runtime truth.
Focused schema-v2 evidence:
- Re-ran the existing targeted registry test:
- `cd backend && npx vitest run test/workspace-registry.test.ts -t "lists a schema v2 descriptor as migration_required and refuses to acquire it"`
- Result: pass.
- Evidence from that test:
- schema-v2 descriptors list as `migration_required`
- `acquireSessionRevision("psd-clinical")` rejects with `code: "workspace_invalid"`
- Conclusion: schema-v2 acquisition remains blocked; no external semantic fallback was reintroduced.
Contract consistency fixes:
- Updated `harness/tests/test_local_compose_contract.py` to assert the mandatory internal semantic stack, fixed internal core semantic env, private-service topology, persistent volumes, and Ollama health/dependency contract.
- Updated shell Compose contracts to require:
- Ollama healthcheck on `embedding`
- `embedding-model-init` dependency on `embedding: service_healthy`
- Updated `scripts/unified-deployment-smoke.sh` rendered-contract helper to expect the mandatory internal semantic topology and internal semantic env names, and to reject retired external semantic bindings.
- Updated `scripts/test-task13-runtime-fixtures.sh` to exercise `task13_assert_rendered_contract` for both local and server fixture renders.
Fix round 1 verification:
- RED before implementation:
- `cd harness && .venv/bin/pytest tests/test_local_compose_contract.py -q` failed because `embedding` had no healthcheck.
- `./scripts/test-default-compose.sh` failed because `embedding` had no healthcheck.
- `./scripts/test-unified-compose.sh` failed because `embedding` had no healthcheck.
- `./scripts/test-task13-runtime-fixtures.sh local` failed because `unified-deployment-smoke.sh` still expected `core,frontend`.
- GREEN after implementation:
- `./scripts/test-default-compose.sh`
- `./scripts/test-unified-compose.sh`
- `./scripts/test-internal-semantic-compose.sh`
- `cd harness && .venv/bin/pytest tests/test_local_compose_contract.py -q`
- `./scripts/test-task13-runtime-fixtures.sh local`
- `./scripts/test-task13-runtime-fixtures.sh server`
- `cd backend && npx vitest run test/workspace-registry.test.ts -t "lists a schema v2 descriptor as migration_required and refuses to acquire it"`
- `docker compose --env-file deploy/env/local.env.example -f compose.yaml -f deploy/compose.local.yaml config --format json`
@@ -0,0 +1,65 @@
Status: completed on August 8, 2026.
Summary:
- Updated the frontend workspace contract from schema v2 editing to schema v3 publishing.
- Kept only `semantic_index.vector_store.collection` editable; rendered qdrant / internal Ollama semantic values as fixed read-only architecture values.
- Removed external vector transport / endpoint / credential / embedding diagnostics branches from frontend draft sanitization, conflict parsing, and editor UI.
- Added a migration-required banner in workspace management and blocked `migration_required` workspaces from new-session selection.
- Aligned the example workspace YAML comments with the fixed internal qdrant/Ollama architecture.
Files changed:
- `frontend/src/api/workspaces.ts`
- `frontend/src/api/workspaces.test.ts`
- `frontend/src/workspaces/drafts.ts`
- `frontend/src/workspaces/drafts.test.ts`
- `frontend/src/shell/WorkspaceEditor.tsx`
- `frontend/src/shell/WorkspaceEditor.test.tsx`
- `frontend/src/shell/WorkspaceManager.tsx`
- `frontend/src/shell/WorkspaceManager.test.tsx`
- `frontend/src/shell/WorkspacePublishDialog.test.tsx`
- `frontend/src/api/sessions.ts`
- `frontend/src/shell/SteerInput.tsx`
- `frontend/src/shell/SteerInput.test.tsx`
- `deploy/workspaces/example.yaml`
- `deploy/workspaces/psd.yaml.example`
Verification:
- `cd frontend && npx vitest run src/shell/SteerInput.test.tsx src/shell/WorkspaceEditor.test.tsx src/shell/WorkspaceManager.test.tsx src/shell/WorkspacePublishDialog.test.tsx src/workspaces/drafts.test.ts src/api/workspaces.test.ts`
- Result: 6 files passed, 59 tests passed.
- `cd frontend && npx tsc -b`
- Result: passed.
- `git diff --check`
- Result: passed.
Self-review:
- The frontend now publishes the exact schema v3 semantic shape and no longer persists legacy semantic transport/credential branches.
- Migration-required workspaces are visible in management with an explicit banner and are excluded from the composer workspace selector.
- One dependent test file outside the original brief list (`WorkspacePublishDialog.test.tsx`) and the composer/session-selection path (`api/sessions.ts`, `SteerInput.tsx`, related test) were updated because they were directly coupled to the old v2 semantic/edit-selection behavior.
Concerns:
- The composer still retains backward-compatible behavior for summaries that omit `revision` entirely; only explicit `revision.state === "migration_required"` is blocked. That matches the current mixed-test environment, but once summary responses are guaranteed to include `revision`, that fallback may be removable.
Fix round 1/5 — August 8, 2026
Summary:
- Made missing or invalid workspace summaries fail safe in frontend session creation and composer selection instead of falling open as legacy.
- Added an actionable unavailable message in workspace management for incomplete summaries with no canonical revision.
- Replaced the old runtime-oriented example descriptor files with exact backend WorkspaceV3 descriptor YAML.
Additional files changed:
- `frontend/src/api/sessions.test.ts`
- `backend/test/workspaces-schema.test.ts`
Fix-round verification:
- `cd frontend && npx vitest run src/api/sessions.test.ts src/shell/SteerInput.test.tsx src/shell/WorkspaceManager.test.tsx src/shell/WorkspaceEditor.test.tsx src/shell/WorkspacePublishDialog.test.tsx src/workspaces/drafts.test.ts src/api/workspaces.test.ts`
- Result: 7 files passed, 73 tests passed.
- `cd frontend && npx tsc -b`
- Result: passed.
- `cd backend && npx vitest run test/workspaces-schema.test.ts`
- Result: 1 file passed, 17 tests passed.
- `git diff --check`
- Result: passed.
Notes:
- Missing `revision` in a workspace summary now fails with the same session/composer safety posture as `migration_required`, using the existing safe workspace-policy error for session creation and an explicit unavailable message in workspace management.
- The committed example files now validate as actual schema-v3 descriptors instead of deployment/runtime templates with forbidden semantic endpoint fields.
@@ -0,0 +1,83 @@
# Task 5 Report — `tht setup` lifecycle orchestration
## Status
Completed. `tht setup` now validates the checkout and host prerequisites, creates or validates
the non-secret installation files, validates Compose, and by default builds, starts, health-checks,
and verifies the installation. `tht setup --configure-only` stops immediately after successful
Compose rendering.
## Implementation
- Added `setup.Run`, with an ordered host preflight: project/worktree discovery, Docker Engine,
Docker Compose, supported architecture, and LF line-ending checks.
- Reused `config.Installation.ComposeArgs` for all Compose calls and added a narrow
`compose.InstallationRunner` adapter for Pi diagnostics; no shell command construction was added
to the top-level CLI parser.
- Default setup performs `compose build`, `compose up --detach --remove-orphans`, bounded polling
for `core`, `frontend`, `qdrant`, `embedding`, and `embedding-model-init`, then aggregate volume
diagnostics and `pi.Doctor`.
- Health timeout errors identify the last failing service and preserve containers for diagnosis,
with `tht logs <service>` and `tht status` guidance.
- Completion output includes the frontend URL, selected descriptor, and next action.
## TDD evidence
The initial focused test run failed because `setup.Run` did not exist. Tests were then written
against a fake Compose runner before the orchestration was implemented. They cover the complete
ordered flow, configure-only stop, preflight failure before writing configuration, health retry,
timeout guidance, and CLI default versus `--configure-only` dispatch.
## Verification
Executed from `tools/tht`:
```bash
go test ./internal/setup ./internal/compose ./cmd/tht -run 'TestRun|TestSetupCommand|TestInstallationRunner' -count=1
go test ./internal/setup ./internal/compose ./cmd/tht -count=1
go test ./...
git diff --check
```
All commands passed. No actual Docker build, container start, live-stack restart, system
installation, Pi configuration edit, or documentation rewrite was performed.
## Commit
`feat(setup): build start and verify ThothII` (this report is included in that commit).
## Concerns
- The bounded health wait is verified with fakes only, as required for this task. Real Docker
lifecycle verification belongs to the later live acceptance task.
- The existing aggregate `tht doctor` command remains a separate implementation; Task 5 performs
its equivalent setup-time prerequisite checks plus `pi.Doctor` without invoking a nested CLI
process.
## Fix round 1
The independent review identified three gaps. All were reproduced with RED tests before the
production change:
- A rendered Compose document containing any one volume was accepted. `requireVolumes` now
requires `settings`, `pi-state`, `workspace-registry`, `workspace-secrets`, `sessions`,
`qdrant-data`, and `embedding-models`; tests reject each individual omission and an
unrelated-only volume set.
- Failures after `compose up` could return without recovery instructions. A single recovery
wrapper now preserves the underlying error while adding the retained-container, `tht logs
<service>`, and `tht status` guidance for failed `up`, health, aggregate doctor, and Pi doctor
phases. Focused tests also prove build failure stops before attempting startup.
- LF inspection previously walked the full checkout. It now inspects only `compose.yaml`,
`deploy/`, and `docker/`; a test proves CRLF content under `node_modules/` is ignored.
Verification added for this round:
```bash
go test ./internal/setup -run 'TestRequireVolumes|TestRun(BuildFailure|UpFailure|AggregateDoctorFailure|PiDoctorFailure|IgnoresIrrelevant|TimesOut)' -count=1
go test ./internal/setup -count=1
```
Both passed before the final full-suite verification. No Docker or live operation was run.
Implementation commit evidence: `ea70cc95b04532043744a9de6c5912e30a214595` —
`fix(setup): harden verification and recovery`.
@@ -0,0 +1,55 @@
# Task 6 — Version, aggregate doctor, and build-aware start
Status: complete.
Implemented the host-side `tht version`, aggregate `tht doctor [--json]`, and `tht start [--build]` contracts.
- `version` is descriptor-free and reports semantic version, commit, build time, OS, and architecture.
- `doctor` emits typed, redacted checks for descriptor state, Docker/Compose, rendered volumes, file permissions, service health, workspace registry, the container-local workflow doctor, and Pi doctor. Its JSON mode writes exactly one JSON document to stdout.
- The Python workflow doctor is invoked only as `docker compose exec -T core tht doctor --json` after core is running.
- `start` uses the shared lifecycle service: default `up → health`; `--build` is `build → up → health`.
- `setup` now reuses the shared lifecycle and aggregate diagnostics rather than keeping parallel health/volume implementations.
Verification performed without live Docker/container commands:
```bash
cd tools/tht
go test ./internal/version ./internal/doctor ./internal/service ./cmd/tht \
-run 'TestVersion|TestDoctor|TestStart|TestCurrent|TestRun' -count=1
go test ./internal/setup -count=1 -run 'TestRun' -v
go test ./... -count=1
git diff --check
```
All completed successfully. The intentionally fake runner coverage includes unavailable Docker,
stopped/running core, workflow failure redaction, pristine JSON output, and start ordering.
Concerns: no live Docker validation or host installation was run, by explicit task constraint.
## Fix round 1
Completed the independent-review follow-up without live Docker operations.
- `workspace-registry` now executes a container-local, read-only Node validation of
`/data/workspace-registry/state/active.json` and every declared snapshot descriptor. It no
longer passes merely because Compose declares a volume.
- Host file permissions are checked before Docker/Compose availability and therefore remain
visible as failures when Docker is unavailable.
- Separate typed, bounded HTTP probes verify core (`curl --max-time 5`) and frontend
(`wget -T 5`) reachability, independently of Compose health. The probe is injectable in tests.
- The successful report tests assert the stable full checklist:
`descriptor`, `files`, `docker`, `compose`, `configuration`, `services`, `core-http`,
`frontend-http`, `workspace-registry`, `workflow`, `pi`.
Additional verification:
```bash
cd tools/tht
go test ./internal/doctor -run 'TestRun(ChecksUnsafeFilesEvenWhenDockerIsUnavailable|FailsAnInvalidContainerLocalRegistryState|ReportsEachHTTPReachabilityProbeFailure|UsesOnlyContainerLocalWorkflowAndPiDiagnosticsWhenCoreRuns)' -count=1 -v
go test ./internal/doctor ./internal/setup ./internal/service ./cmd/tht -count=1
go test ./... -count=1
git diff --check
```
All passed with fake runners/probes only. No live container, HTTP endpoint, or host installation
was touched.
@@ -0,0 +1,163 @@
# Task 15 retained release-gate report — fix round 5 (sanitized)
## Final-review fix-round-2 addendum — frozen source `2a9359071257f9b8a71d36ec2bbb25b161003f81`
This addendum supersedes the fix-round-1 addendum for current authentication remediation status
while preserving the fix-round-5 material below as historical provenance.
- Authentication remediation status: `PASS`. The three original remediation Important findings
remain `RESOLVED`; the fix-round-2 fully bounded lifecycle Important is `ADDRESSED`; and the
temporary Windows diagnostic-matrix Minor is `ADDRESSED`.
- Overall branch/release readiness is separately `FAIL`, with unavailable external/manual gates
`PENDING`.
- Completed exact-source workflow run `32147345625` concluded `failure` on baseline release jobs.
Its `Windows clone and Compose contract` job (`95744249248`) executed the unfiltered command
`go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1`; the native step
passed all three packages: safeio `22.058s`, backup `7.161s`, authstorage `16.088s`.
- The Windows job failed only afterward in the baseline clone-contract script at
`scripts/test-windows-clone-contract.ps1:208`, where PowerShell rejects the undelimited
`$remoteYaml:` variable reference.
- `LF, Compose, docs, and TypeScript` job `95744249458` reproduced the baseline unset-`TMPDIR`
failure after unified Compose passed. Linux Docker job `95744249354` reproduced the missing-`rg`
prerequisite failure; cleanup passed and no image manifest was generated.
- The skipped Windows Docker Desktop/WSL2 job is recorded as `NOT_RUN` / `BLOCKED`, not FAIL.
Downstream commands skipped after executed baseline failures use the same classification. The
matrix contains an explicit native `windows_stagearchive_retained_capability` PASS row.
- Historical Node/auth/browser/docs PASS and harness/Ruff/Compose FAIL evidence remains bound to
its recorded source where not rerun. L2, real PSD/manual acceptance, and provider readiness
remain `PENDING`.
- Current machine-readable evidence and the requested Task 4 report are recorded in
`.artifacts/task-15/automated-gates.json` and
`.superpowers/sdd/2026-08-18-thothii-authentication-remediation/task-4-report.md`.
- The full fix-round-2 RED/GREEN and finding disposition is recorded in
`.superpowers/sdd/2026-08-18-thothii-authentication-remediation/fix-round-2-report.md`.
- Current automated-gates SHA-256:
`6c516db5c2064c4a4a2e5f25961b993cd4a8fe020bbbb822fbac7faa0c119599`.
- Historical unified Docker manifest SHA-256: `9c8dec4546909fd93799dbcf374bcb3a89bc46cfe0fd482472c0cbe757ddf5b6`.
The complete sanitized Task 4 matrix and the separate remediation/release verdicts are in the
requested Task 4 report.
- Final tested source commit: `74b062f1a737103524cbe706346cfd65f87cdfd1`.
- Historical retained source commits: fix-round-2 `fe190e7046acc173f510dddcb32f46ed142858c1`,
maintenance follow-up `4d230b87afdcd24f02264f8f937c8628b92db05a`, prior final Docker
source `e20bf33e2a00102192e5be66b178037aeca3a7b1`, and fix-round-4 streamed
archive privacy `54698e73400a54ce7c3e6c10099e14eb471ce8b9`.
- Versions: Node contract `v24.16.0`; host default Node `v25.6.1`; Go `go1.26.5`;
Pi `0.80.3`.
- Historical automated gate artifact: `.artifacts/task-15/automated-gates.json`;
SHA-256 `7d9ec93af15510605f1aa7179b26a7ee46d78122f647854300f7a9922057a63f`.
- Docker image manifest: `.artifacts/task-15/unified-docker-images.json`;
SHA-256 `9c8dec4546909fd93799dbcf374bcb3a89bc46cfe0fd482472c0cbe757ddf5b6`.
## Fix-round-5 evidence
- PASS, RED then GREEN: `TestCreateCanonicalNewPrivateFileUsesPinnedParentAfterAncestorSwap`
first failed because the creator had not retained its parent before creation. It now opens every
Unix ancestor once, creates the leaf with `openat(O_NOFOLLOW|O_CREAT|O_EXCL)`, applies and checks
`0600` by descriptor (`fchmod`/`fstat`), and uses `unlinkat` for creator failure cleanup. The
deterministic test moves the opened parent, replaces its lexical name with an outside symlink,
validates the archive under the moved original parent, and proves no outside archive was written.
- PASS: the Windows implementation uses NT `RootDirectory`-relative traversal for every component
after the volume root and for final file creation. The retained final parent receives only the
required child-create right (`FILE_WRITE_DATA` for a file, `FILE_APPEND_DATA` for a directory),
reparse points are rejected, and the owner-only protected DACL is installed in the same
`NtCreateFile` operation. The native-Windows test attempts the pre-create parent swap and calls
`safeio.ValidatePrivateRegular`; it is compiled but not executed on this host.
- PASS: `go test ./internal/safeio ./internal/backup -count=1`, `go test -race ./...` across
`18` packages, `go vet ./...`, and a native host `tht` CLI build. Existing StageArchive
capacity, lifecycle, rollback, streaming, and cleanup tests remain passing.
- PASS, compile-only: Windows amd64 static test/build compilation across `18` packages, including
the retained-handle Windows tests. No Windows executable was run; native execution remains
PENDING and is not inferred from compilation.
- PASS on Node `v24.16.0`: the hermetic OIDC/F1 authentication browser smoke passed all current
`8` checks in `frontend/e2e/auth.spec.ts` and `frontend/e2e/f1.spec.ts`; the runtime sentinel
leak scan passed.
- PASS: shell syntax, unified-smoke safety self-test, default Compose contract, unified Compose
contract, and Compose secret-policy contract.
- PASS: final unified Docker deployment smoke run `20260818070637-66409-30058`, bound exactly to
source `74b062f1a737103524cbe706346cfd65f87cdfd1`. It exercised maintenance-auth isolation,
restore, registry lifecycle, bad-candidate rollback, image revalidation, and task-scoped cleanup.
## Sanitized final unified Docker output
```text
== Build and start isolated local Compose distribution ==
== Recreate offline and retain the validated registry snapshot ==
== Pull a valid catalog+descriptor metadata update ==
== Pull a content-only Git Evidence update ==
== Reject catalog/descriptor metadata mismatch and retain the valid snapshot ==
== Reject orphan descriptor directories not listed in the catalog ==
== Reject the retired flat workspace layout and retain the valid snapshot ==
== Inject a bad pinned Pi candidate and prove automatic rollback ==
Task 13 full deployment smoke passed.
Task 13 cleanup proof: no labeled containers, volumes, networks, or images remain for 20260818070637-66409-30058.
```
## Sanitized Docker image identities
- `sha256:2d7b19491c7eb8c119c3cedb390aaeb2ff5593f6fc43ab66c317565560da6d7d`;
roles `compose-runtime`, `fixture-runtime`.
- `sha256:3b6c31a5d8f8fc58fa3233391b6175bd2fbc793eebb44d5e285ecc6e02e9e687`;
role `compose-runtime`.
- `sha256:57f573b47f1f71ebb445789f279fe3e596a8beab182f7cf486db9205bad87c5a`;
role `compose-runtime`.
- `sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c`;
role `compose-runtime`.
- `sha256:c3cbe1cc1aa588a64951ac6286e0df7b27fe2e6324b1001c619bb358770c0178`;
role `rollback-candidate`.
For each image, the retained repository-digest component equals the listed image digest. Registry
names and credentials are deliberately omitted.
## Complete observed matrix
- PASS: Task 13 lifecycle carry-ins; retained-handle owner-private restore staging; provider fixture
round-one `6/6`; backend Node 24 round-one suite `75 files / 1081 tests`; frontend Node 24
round-one suite `61 files / 444 tests`; current Node 24 authentication/F1 browser smoke `8/8`;
final-source Go race/build `18 packages`; Windows static cross-compile `18 packages`; harness
round-one suite `921 passed / 4 L2 deselected`; authentication docs round-one gate; shell/Compose
contracts; final unified Docker smoke; five-image traceability; and Docker cleanup.
- FAIL: Ruff `192` known-baseline errors; MkDocs strict `69` known-baseline warnings; existing
canonical/workspace install wording checks; existing Pi model-policy check; deployment-coupling
scan against preserved ignored private material.
- PENDING: native Windows execution because required host prerequisites are unavailable; L2 because
the configured secret layout is unavailable; real PSD/manual acceptance because no real
identity/access is available; isolated provider readiness because an unrelated host port is
occupied.
## Final Task 15 review after fix round 5
The fresh Terra review verdict is **CHANGES REQUIRED**. The five-round breaker is exhausted; no
sixth implementation round was started. Two Important findings remain:
- `StageArchive` does not retain the opaque parent/directory capability through the complete
stream and `Close` lifecycle. Staging-directory creation and final cleanup still use pathname
operations, so an ancestor swap after creation can strand the secret-bearing archive or redirect
cleanup. Deterministic StageArchive swap-and-cleanup coverage is still required on Unix and
native Windows.
- Windows claim removal closes its validated retained parent handles before calling pathname-based
`DeleteFile`. Removal must instead remain handle-relative (or delete through the opened handle),
with a native-Windows ancestor-swap test.
The focused/full Go, cross-compile, Node 24, browser, Compose, Docker lifecycle, image-traceability,
and cleanup results above remain valid evidence for source `74b062f1a737103524cbe706346cfd65f87cdfd1`.
They do not override the final code-review verdict. Native Windows execution remains PENDING.
The authentication feature is **not implementation-complete or release-complete** while these code
findings and the required FAIL/PENDING gates remain. No secret values, real identities, internal
endpoints, or registry names are retained.
## Final whole-branch review
The final read-only Terra review of `351361f..39b5453` also returned **CHANGES REQUIRED** and found
one additional Important issue: the POSIX local-user registry validates file type, link count, and
mode for `users.yaml` and its parent directory, but does not require ownership by the effective UID.
A foreign-owned `0600` registry inside a runtime-owned `0700` directory can remain writable by the
foreign owner and be used to alter credentials or grant the administrator role. The registry must
enforce effective-UID ownership on every POSIX `lstat`/`fstat` path and add foreign-owner rejection
coverage.
No new Critical issue or load-bearing Minor issue was found. The branch is **not ready to merge**:
this ownership defect and the two retained-capability cleanup defects above require fixes and renewed
review, independently of the remaining FAIL/PENDING release gates.
@@ -0,0 +1,162 @@
# Final-review fix round 1 report (sanitized)
## Verdict
- Base: `fa499a9bdd37011833691b0f447470d8b7e8a3a6`.
- Final frozen source: `10cd66fe6a5b484a4dc569326a228c1c5484a5d4` on
`feat/thoth-auth`.
- Authentication remediation: **PASS / ADDRESSED**. All four final-review Important findings are
resolved relative to the remediation brief.
- Terra Minor evidence corrections: **ADDRESSED**.
- Branch/release readiness: **FAIL**. The completed exact-source workflow still contains executed
baseline clone-contract, LF/Compose, and Linux Docker failures. Unavailable external/manual
gates remain **PENDING**.
- Source and evidence remain separate commits. No workflow was dispatched from the evidence-only
phase.
## Finding disposition
| Finding | Disposition | Evidence |
|---|---|---|
| Important 1 — exhaustive Windows cleanup | RESOLVED | Cleanup now attempts close/delete/validation operations in deterministic order and returns sanitized `ErrUnsafeFile` after aggregating failures. `TestWindowsPrivateRegularCleanupClosesAfterDeleteDispositionFailure` and `TestWindowsClaimCleanupAttemptsLaterOperationsAfterEarlierFailure` cover the non-short-circuit contract. Global no-delete sharing remains unchanged. |
| Important 2 — usable native Windows authority | RESOLVED | Owner-only descriptors use the current user SID, protected/non-defaulted DACL semantics, valid NT attributes/access masks, self-relative creation descriptors, and semantic full-control validation. Equal-or-stronger Windows fixture adaptations retain no-delete handles instead of weakening ACL/identity checks. The final native three-package gate passes. |
| Important 3 — restore-test deadlock | RESOLVED | Lifecycle-stage release observes the buffered worker outcome, uses a bounded/cancellable release, reports premature completion directly, and never waits indefinitely on `done`. `TestReleaseLifecycleStageReturnsPrematureWorkerOutcome` and the lifecycle-lock terminal-cleanup test are green. |
| Important 4 — complete native package gate | RESOLVED | Workflow and remediation plan both use the exact unfiltered command `go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1`. Final logs prove all three packages executed natively. |
| Minor — non-executed gate classification | RESOLVED | Non-executed/skipped commands are `NOT_RUN` / `BLOCKED`; `FAIL` is reserved for commands that ran and failed. Historical results remain separately labelled. |
| Minor — explicit Windows StageArchive row | RESOLVED | `.artifacts/task-15/automated-gates.json` contains `windows_stagearchive_retained_capability` = PASS, bound to the final source and native backup result. |
Additional failures exposed by the required unfiltered gate were fixed without narrowing the
workflow: Windows secret-bearing archive reservation is protected before use; StageArchive shares
one retained root capability across both staged files; claim/consume transitions serialize the
complete public validation and retained-handle operation while preserving ACL, hard-link identity,
reparse rejection, and no-delete invariants.
## RED → GREEN record
### Initial RED
- Run `32122302381`:
https://github.com/mptyl/ThothII/actions/runs/32122302381
- Source: `b31b27e5845ffd3adf311429367319beaba263c7`.
- Windows job: `95665197885`.
- Result: native `safeio`/`backup` failure, including the 10-minute restore lifecycle timeout;
`authstorage` was absent from the command. This established the RED for Important 2–4 and the
required native authority.
- Cleanup failure-injection tests added for Important 1 first exposed the short-circuit behavior
before the implementation was changed.
### Final concurrency RED
- Run `32140481263`:
https://github.com/mptyl/ThothII/actions/runs/32140481263
- Source: `b48e9e9189dd0e8083db9bd0378704524e670edb`.
- Windows job: `95721724645`.
- Native results: backup PASS (`20.757s`), authstorage PASS (`104.180s`), safeio FAIL
(`63.502s`). The only failures were:
- `TestCanonicalPrivateClaimWaitsForRetainedRemoveOperation`: the concurrent claim returned
`false, unsafe file` before retained removal completed;
- `TestCanonicalPrivateClaimConsumeHasOneConcurrentWinner`: iteration 8 returned `unsafe file`.
- Diagnosis: the process mutex started below `validateClaimPaths`; a concurrent caller could fail
while reopening the retained no-delete directory before reaching the lock.
### GREEN implementation and local gates
The lock boundary was moved to the three public claim/read/remove APIs, covering validation,
relative operation, and handle close. The Unix implementation uses a no-op boundary and retains its
existing descriptor-relative semantics.
Final-source local commands passed:
```text
go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1
go test -race ./...
go vet ./...
go build -o /tmp/thothii-tht-host ./cmd/tht
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o /tmp/thothii-tht-windows.exe ./cmd/tht
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ... ./internal/{safeio,backup,authstorage}
```
- Focused host package times: safeio `8.750s`, backup `8.378s`, authstorage `8.854s`.
- Race suite and vet: PASS.
- Host CLI: Mach-O arm64; Windows CLI and all three Windows test binaries: PE32+ x86-64.
- Cross-compilation remains compile-only and is not used as native proof.
## Exact-source native certification
- Run: `32141428407`
- URL: https://github.com/mptyl/ThothII/actions/runs/32141428407
- Event/status/conclusion: `workflow_dispatch` / `completed` / `failure`.
- Head SHA: `10cd66fe6a5b484a4dc569326a228c1c5484a5d4` — exact final source match.
- Windows job: `Windows clone and Compose contract`, job `95724751282`:
https://github.com/mptyl/ThothII/actions/runs/32141428407/job/95724751282
- Native step: `Run native Windows retained-capability tests` — **PASS**.
- Exact command: `go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1`.
- Native package results:
- safeio PASS (`8.230s`);
- backup PASS (`5.195s`);
- authstorage PASS (`8.383s`).
- Job conclusion: `failure` only because the following `Verify Windows clone contract` baseline
step failed with a PowerShell `ParserError` at
`scripts/test-windows-clone-contract.ps1:208`; `$remoteYaml:` is not delimited before `:`.
## Remaining branch/release blockers
| Gate | Classification | Exact outcome |
|---|---|---|
| Windows native authentication packages | PASS | All three required packages executed on final source. |
| Windows clone contract | FAIL / baseline | Executed after native PASS; PowerShell parser error at line 208. |
| LF, Compose, docs, and TypeScript | FAIL / baseline CI contract | Job `95724751205`; unified Compose passed, then `test-no-deployment-coupling-scope.sh` failed because `TMPDIR` was unset. Downstream skipped commands are `NOT_RUN` / `BLOCKED`. |
| Linux Docker deployment and rollback | FAIL / infrastructure prerequisite | Job `95724751356`; executed smoke stopped because `rg` was unavailable. Cleanup proof passed; no new image manifest was generated. |
| Native Windows Docker Desktop/WSL2 startup | NOT_RUN / BLOCKED | Job `95724752028` was skipped by workflow conditions; no Docker/WSL2 command executed. |
| Harness/Ruff/other historical baseline gates | FAIL | Retained with their recorded source and results; not rewritten as final-source proof. |
| L2, real PSD/manual acceptance, provider readiness | PENDING | Required secrets, identity/access, or provider prerequisites remain unavailable. |
The historical Docker image manifest remains bound to source
`74b062f1a737103524cbe706346cfd65f87cdfd1`; it was not reused as proof for the final source.
## Principal source commits
- `cd5f505` — exhaustive cleanup, Windows authority foundation, restore deadlock tests/fix, and
complete workflow/plan package command.
- `a0e05ad` through `b6396e6` — effective full-control DACL semantics, valid NT attributes/access,
self-relative descriptors, retained no-delete fixture ordering, and Windows installation fixture
protection.
- `824245d` — preserve existing lifecycle ACL trees instead of mutating inherited authority.
- `455fffb`, `2d1670e`, `c01482c`, `9fc1a15` — concurrent claim/consume and settled-loss handling.
- `6474118` — one retained StageArchive root capability shared across staged files.
- `feee4ee` — unified Windows path wrappers on the retained primitive.
- `b261dd4` — bounded private-root sharing contention handling.
- `b48e9e9` — deterministic retained-remove concurrency regression and claim-operation lock.
- `10cd66f` — final lock boundary includes public path validation; frozen source.
## Files changed
Source changes relative to the fix-round base:
- `.github/workflows/deployment.yml`;
- `docs/superpowers/plans/2026-08-18-thothii-authentication-remediation.md`;
- `tools/tht/internal/authstorage/storage_test.go`;
- `tools/tht/internal/backup/{create.go,create_test.go,fixture_security_unix_test.go,fixture_security_windows_test.go,preflight.go,preflight_test.go,preflight_windows_test.go,restore.go,restore_test.go}`;
- `tools/tht/internal/safeio/{claim_unix.go,claim_windows.go,claim_windows_test.go,files.go,files_test.go,private_root_windows.go,private_windows.go,private_windows_test.go}`.
Evidence/status changes are restricted to:
- `.artifacts/task-15/automated-gates.json`;
- `.superpowers/sdd/2026-08-18-thothii-authentication-remediation/task-4-report.md`;
- `.superpowers/sdd/2026-08-18-thothii-authentication-remediation/fix-round-1-report.md`;
- `.superpowers/sdd/2026-08-16-thothii-authentication/task-15-report.md`;
- `PROJECT_STATE.md`.
Machine-readable evidence SHA-256:
`5c110b7b2607693de078def441b10290c5a29024c83b7e5a0ced894b72b7507f`.
## Git and protection status
- The evidence commit contains only the five evidence/status files listed above; no source is
changed after frozen source `10cd66fe6a5b484a4dc569326a228c1c5484a5d4`.
- After the evidence commit and push, the intended status is synchronized
`feat/thoth-auth...origin/feat/thoth-auth` with only protected untracked `.playwright-cli/` and
`.thothctl/`.
- `AGENTS.md`, `CLAUDE.md`, and `docs/agents/` are untouched. No generated `tools/tht/tht` exists.
- Evidence commit SHA is reported externally after commit creation because a commit cannot contain
its own final hash.
@@ -0,0 +1,133 @@
# Final-review fix round 2 report (sanitized)
## Verdict
- Base evidence head: `0f762ad6b67675356389cc546421a1c46ad5a736`.
- Frozen source: `2a9359071257f9b8a71d36ec2bbb25b161003f81` on `feat/thoth-auth`.
- Authentication remediation: **PASS**.
- Three original remediation Important findings: **RESOLVED**.
- Fix-round-2 bounded lifecycle Important: **ADDRESSED**.
- Fix-round-2 temporary Windows diagnostics Minor: **ADDRESSED**.
- Release readiness: **FAIL** for executed unrelated baseline gates, with unavailable
external/manual gates separately **PENDING**.
- Source and evidence are separate commits. The evidence-only phase changed no source or tests and
dispatched no workflow.
## Finding disposition
| Finding | Disposition | Evidence |
|---|---|---|
| Original Important — POSIX local-registry ownership | RESOLVED | Effective-UID ownership enforcement and its Node 24 coverage remain green at their recorded source. Fix round 2 did not alter this boundary. |
| Original Important — retained-capability StageArchive lifecycle | RESOLVED | Native Windows `internal/backup` passed on the exact source, preserving the retained-root staging and cleanup coverage. |
| Original Important — handle-relative Windows claim removal | RESOLVED | Native Windows `internal/safeio` and `internal/authstorage` passed on the exact source, including retained claim/consume coverage. |
| Fix-round-2 Important — fully bounded restore lifecycle test | ADDRESSED | Gate publication and release are context-aware; stage, outcome, admission, checkpoint, and verification waits are bounded; aborts cancel, safely release, bounded-join, then assert lock-free. The deterministic withheld-gate test proves prompt timeout/cancellation, worker join, and eventual lock release. |
| Fix-round-2 Minor — temporary Windows diagnostic matrix | ADDRESSED | `windowsRelativeOpenMatrix` and its diagnostic-only call/import were removed. Owner-only DACL shape, NT access normalization, full-control, cleanup, and retained no-delete tests remain. |
The round-1 restore lifecycle finding was broadened by the scoped round-2 review: bounded release
alone was insufficient while stage publication, gate waits, and nearby outcome/admission waits
could still outlive a controller abort. The round-2 implementation closes that broader test
orchestration gap without changing production authentication semantics.
## RED → GREEN record
### RED
The deterministic withheld-gate regression was introduced first and run without relying on a
global ten-minute package timeout:
```text
go test ./internal/backup -run '^TestRestoreLifecycleCancellationJoinsWithWithheldGate$' -count=1
```
It failed in approximately `0.64s` with:
```text
cancelled restore worker did not join within the bounded deadline
```
This proved that cancellation did not yet unblock and join a worker retained at the lifecycle
gate.
### GREEN and refactor
- The gate uses a cancellation source shared by controller and worker. Both publication and
release are `select`-based and cancellation-aware.
- Shared bounded helpers cover stage, outcome, error, signal, release, and admission waits.
- Abort cleanup is ordered: cancel, cancel the controller gate when distinct, safely release a
pending gate, bounded-join the worker, then prove the lifecycle lock is free.
- Premature worker outcomes retain and surface their original error.
- The existing success, recovery, maintenance-barrier, stale-checkpoint, and verification
assertions remain active.
Final local gates on the frozen source:
```text
go test ./internal/backup -run '^(TestRestoreLifecycleCancellationJoinsWithWithheldGate|TestReleaseLifecycleStage|TestRestoreLifecycleLockExcludesCompetingTransactionsUntilTerminalCleanup|TestRestoreCannotApplyAStaleCheckpointOverAnInterleavedRestore|TestRestoreKeepsAdmissionBarrierActiveUntilVerificationCommits)$' -count=1
go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1
go test ./... -count=1
go test -race ./...
go vet ./...
go build -o /tmp/thothii-tht-host-fix-round-2 ./cmd/tht
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ./internal/safeio -o /tmp/tht-safeio-fix-round-2-windows.test.exe
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ./internal/backup -o /tmp/tht-backup-fix-round-2-windows.test.exe
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ./internal/authstorage -o /tmp/tht-authstorage-fix-round-2-windows.test.exe
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o /tmp/thothii-tht-fix-round-2-windows.exe ./cmd/tht
```
All commands passed. The final focused lifecycle run completed in `0.672s`; the full security
package run passed safeio, backup, and authstorage; race, vet, host build, Windows test-package
cross-compiles, and Windows CLI cross-compile also passed. Cross-compilation is recorded only as
compile evidence and is not used as native authority.
## Exact-source native certification
- Controller-authorized run: `32147345625` —
https://github.com/mptyl/ThothII/actions/runs/32147345625.
- Event/status/conclusion: `workflow_dispatch` / `completed` / `failure`.
- Head SHA: `2a9359071257f9b8a71d36ec2bbb25b161003f81`, exactly matching the frozen source.
- Windows job: `Windows clone and Compose contract`, job `95744249248` —
https://github.com/mptyl/ThothII/actions/runs/32147345625/job/95744249248.
- Native step: `Run native Windows retained-capability tests` — **PASS**.
- Exact unfiltered command:
`go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1`.
- Native package results:
- `internal/safeio` PASS (`22.058s`);
- `internal/backup` PASS (`7.161s`);
- `internal/authstorage` PASS (`16.088s`).
The Windows job failed only in the following baseline clone-contract step. PowerShell reported a
parser error at `scripts/test-windows-clone-contract.ps1:208` because `$remoteYaml:` is not a
delimited variable reference. This later failure does not alter the successful native Go step.
## Separate release-readiness verdict
| Gate | Classification | Exact outcome |
|---|---|---|
| Authentication remediation | PASS | Source and exact-source native three-package authority are green. |
| Windows clone contract | FAIL / baseline | Job `95744249248`; parser error at `scripts/test-windows-clone-contract.ps1:208`, after native PASS. |
| LF, Compose, docs, and TypeScript | FAIL / baseline CI contract | Job `95744249458`; unified Compose passed, then the existing unset-`TMPDIR` failure stopped the contract step. Downstream commands were skipped. |
| Linux Docker deployment and rollback | FAIL / infrastructure prerequisite | Job `95744249354`; the existing missing-`rg` prerequisite stopped the smoke before deployment. Cleanup passed and no new image manifest was generated. |
| Native Windows Docker Desktop/WSL2 startup | NOT_RUN / BLOCKED | Job `95744250450` was skipped by workflow conditions; no native Docker/WSL2 command ran. |
| L2, real PSD/manual acceptance, provider readiness | PENDING | Required secrets, identity/access, or provider prerequisites remain unavailable. |
Executed failures remain `FAIL`; skipped commands are `NOT_RUN` / `BLOCKED`; unavailable external
gates remain `PENDING`. Therefore remediation PASS does not imply release readiness PASS.
## Evidence and protection status
- Machine-readable evidence: `.artifacts/task-15/automated-gates.json`; SHA-256
`6c516db5c2064c4a4a2e5f25961b993cd4a8fe020bbbb822fbac7faa0c119599`.
- Current Task 4 report:
`.superpowers/sdd/2026-08-18-thothii-authentication-remediation/task-4-report.md`.
- Retained Task 15 report:
`.superpowers/sdd/2026-08-16-thothii-authentication/task-15-report.md`.
- Project snapshot: `PROJECT_STATE.md`.
- Historical Docker evidence remains bound to its recorded older source and is not reused as proof
for `2a9359071257f9b8a71d36ec2bbb25b161003f81`.
- `.playwright-cli/` and `.thothctl/` remain protected and untracked. No source/test file,
instruction file, workflow, or `docs/agents/` content changed in this evidence phase.
- The separate evidence commit SHA is reported after commit creation because a commit cannot
contain its own final hash.
No credentials, tokens, internal endpoints, identities, registry names, raw environments, or
browser traces are retained in this report.
@@ -0,0 +1,131 @@
# Task 4 authentication remediation recertification (sanitized)
## Fix-round-2 recertification — remediation PASS
- Exact source: `2a9359071257f9b8a71d36ec2bbb25b161003f81` on `feat/thoth-auth`.
- Authorized workflow: completed run `32147345625`,
https://github.com/mptyl/ThothII/actions/runs/32147345625, exact matching head SHA.
- Native job: `Windows clone and Compose contract`, job `95744249248`.
- Required native step: `Run native Windows retained-capability tests` — **PASS**.
- Exact unfiltered command:
`go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1`.
- Package evidence: `internal/safeio` PASS (`22.058s`), `internal/backup` PASS (`7.161s`),
`internal/authstorage` PASS (`16.088s`). This includes explicit native Windows
StageArchive retained-capability and concurrent claim-consume coverage.
- The later `Verify Windows clone contract` step failed independently at
`scripts/test-windows-clone-contract.ps1:208`: PowerShell parsed `$remoteYaml:` as an invalid
variable reference. This baseline deployment-contract failure does not change the native Go
package result.
- The optional `Native Windows Docker Desktop/WSL2 startup` job was skipped by workflow
conditions. It is `NOT_RUN` / `BLOCKED`, because no Docker Desktop/WSL2 command executed.
- The workflow reached `completed` with conclusion `failure`: the native authentication step is
PASS, while the later clone-contract, LF/Compose, and Linux Docker baseline steps are FAIL.
- Existing LF/Compose job `95744249458` and Linux Docker job `95744249354` failures repeated
before downstream work. Skipped commands are `NOT_RUN` / `BLOCKED`, not executed failures.
External L2/PSD/provider gates remain `PENDING`.
Finding disposition is explicit: the three original remediation Important findings remain
**RESOLVED**; the fix-round-2 lifecycle Important is **ADDRESSED**; and the temporary Windows
diagnostic-matrix Minor is **ADDRESSED**. Authentication remediation is **PASS**. This does not
change overall release readiness: executed baseline gates remain **FAIL**, while unavailable
external/manual gates remain **PENDING**.
The section below is retained as historical evidence for the pre-fix frozen source.
## Historical pre-fix result
- Frozen source under test: `b31b27e5845ffd3adf311429367319beaba263c7` on `feat/thoth-auth`.
- Freeze check: PASS. No tracked source changed during certification. The only untracked paths
retained are `.playwright-cli/` and `.thothctl/`.
- Certification window: `2026-08-18T09:26Z` to `2026-08-18T09:48:36Z` (UTC; the start marker is
minute-precision because no earlier second-level operator timestamp was captured).
- Overall result: `FAIL` / `CHANGES_REQUIRED`. The three Important findings are not closed and
authentication is not implementation-complete or release-complete.
## Local gate matrix
| Gate | Result | Sanitized evidence |
|---|---|---|
| Go focused security tests | PASS | `safeio`, `backup`, and `authstorage`; 3 packages |
| Go race/vet/host build | PASS | 18 race-tested packages; vet and host CLI build exit 0 |
| Windows amd64 cross-compile | PASS | focused safeio/backup test binaries and CLI build; compile-only |
| POSIX registry ownership | PASS | Node 24 backend suite includes local-registry ownership coverage |
| Unix StageArchive retained capability | PASS | focused safeio/backup and race coverage passed on host |
| Backend Node 24 | PASS | 76 files / 1092 tests; typecheck and build passed |
| Frontend Node 24 | PASS | 61 files / 444 tests; typecheck and build passed |
| Authentication/F1 browser smoke | PASS | Node `v24.16.0`; filtered E2E 1 passed; sentinel scan passed |
| Harness pytest | FAIL | 951 passed, 1 failed, 4 skipped, 232 subtests; `test_f4_emits_column_types` could not find `workflow.yaml` from its test cwd |
| Ruff | FAIL | 192 errors; known baseline |
| Authentication docs smoke | PASS | required-term and forbidden-word checks passed |
| Shell syntax | PASS | `bash -n scripts/*.sh` |
| Default Compose contract | FAIL | required `THT_WORKSPACE_GIT_REMOTE` was unavailable |
| Unified Compose contract | FAIL | `compose.unified.yaml` is absent from the frozen source |
| Unified Docker smoke | FAIL | workflow attempted it on the frozen SHA but stopped before deployment because `rg` was unavailable; cleanup proof passed and no new image manifest was generated |
| L2 / PSD manual / provider readiness | PENDING | required external secrets, identities/access, or provider prerequisites unavailable/not reached |
The first full backend Vitest attempt had one workspace-registry timeout. The focused test and a
fresh complete rerun passed, so the current backend result above is the fresh complete rerun.
## Native Windows authority
The authorized dispatch was bound to the frozen SHA:
- Run: `32122302381`
- URL: https://github.com/mptyl/ThothII/actions/runs/32122302381
- Head SHA: `b31b27e5845ffd3adf311429367319beaba263c7`
- Workflow conclusion: `failure`
- Job: `Windows clone and Compose contract`, job `95665197885`
- Job URL: https://github.com/mptyl/ThothII/actions/runs/32122302381/job/95665197885
- Native step: `Run native Windows retained-capability tests` — `failure`
- Executed command: `go test ./internal/safeio ./internal/backup -count=1`
- Observed focused failures include `TestRemoveCanonicalPrivateClaimRetainsParentDuringDeletion`
and `TestRemoveCanonicalPrivateClaimPreservesOrphan`.
- The backup package timed out in
`TestRestoreLifecycleLockExcludesCompetingTransactionsUntilTerminalCleanup` after `10m0s`.
- Additional backup failures included retained-staging `unsafe file` results, Windows temporary-file
cleanup reporting that a file was still in use, and fixture cases that could not read external
secret declarations. The first two categories are remediation/security-boundary failures; the
fixture declaration failures are recorded as an accompanying CI-fixture issue.
- `internal/authstorage` was not requested by the frozen workflow step and therefore has no native
Windows execution evidence. Cross-compilation does not substitute for this gate.
This native failure is the blocking gate. No source fix was attempted, and no later Docker smoke
was run locally after the failure.
## Other workflow failures
- `LF, Compose, docs, and TypeScript` (job `95665197839`) failed in
`Verify Compose and installation contracts` after the unified Compose contract itself passed.
`test-no-deployment-coupling-scope.sh` aborted on `TMPDIR: unbound variable`; this is classified
as a baseline/CI contract prerequisite, and later docs/TypeScript steps were skipped.
- `Linux Docker deployment and rollback` (job `95665197846`) failed before deployment because the
runner did not provide `rg` (`Task 13 smoke failed: rg is required`). The sanitized cleanup proof
passed and no Docker image manifest was generated. This is classified as an infrastructure
prerequisite failure, not as evidence of a remediation regression.
## Evidence and provenance
- Current machine-readable matrix: `.artifacts/task-15/automated-gates.json`; SHA-256
`6c516db5c2064c4a4a2e5f25961b993cd4a8fe020bbbb822fbac7faa0c119599`.
- Current requested report: this file (SHA-256 recorded after the evidence commit if needed for
external indexing).
- Current fix-round report:
`.superpowers/sdd/2026-08-18-thothii-authentication-remediation/fix-round-2-report.md`.
- Historical Docker image manifest: `.artifacts/task-15/unified-docker-images.json`, unchanged
because no new immutable-source Docker smoke ran. Its retained historical SHA-256 is
`9c8dec4546909fd93799dbcf374bcb3a89bc46cfe0fd482472c0cbe757ddf5b6`, bound to historical source
`74b062f1a737103524cbe706346cfd65f87cdfd1`, not to this Task 4 candidate.
- The historical Task 15 report remains provenance for earlier source SHAs; its current addendum
records this recertification separately.
No credentials, tokens, internal endpoints, provider identities, registry names, raw environments,
or browser traces are retained here.
## Separate verdicts
- Three Important findings: `CHANGES_REQUIRED`. Native Windows retained-capability authority
failed, and the frozen workflow omits the required `authstorage` package from its native command.
- Overall release readiness: `FAIL` with additional `PENDING` gates. The native Windows remediation
gate failed; the remote Docker attempt failed on a missing runner prerequisite; existing
Ruff/harness/Compose failures and external/manual prerequisites remain unresolved; and no
successful new unified Docker image evidence exists.
+11 -6
View File
@@ -11,10 +11,14 @@ detail. Design history lives in `docs/superpowers/specs/` and `docs/superpowers/
## Commands
The repo has three independently-built layers. Run the **full stack** (real Pi + DWH, needs
VPN + `harness/.env` + `pi` on PATH) with `./scripts/run-stack.sh` (frontend :5173 → backend :8787).
The repo has three independently-built layers. Run the local Docker stack with `./scripts/run-stack.sh` after creating `deploy/env/local.env`; it starts the base+local Compose profile with `frontend`, `core`, `qdrant`, `embedding`, and the one-shot `embedding-model-init`. The core image contains Pi. Qdrant and Ollama are internal Compose services; DWH and LLM remain external configuration endpoints.
**harness/** (Python `tht` CLI + Pi gate extension)
**Native host CLI `tht`** (`tools/tht/`)
- Operator surface: `setup`, `start`, `stop`, `status`, `doctor`, `auth`, `workspace`, and `pi`.
- Use `tht --installation <absolute-path>/thothii-installation.yaml <command>` for installation,
authentication, diagnostics, lifecycle, and workspace operations.
**harness/** (Python workflow `tht` CLI + Pi gate extension)
- Install: `cd harness && python -m venv .venv && pip install -e ".[dev]"` (puts `tht` on PATH)
- Test: `.venv/bin/pytest -q` — `l2` (real GLM + remote DB) is opt-in via `addopts = -m 'not l2'`; `l0` (testcontainers) needs Docker
- Single test: `.venv/bin/pytest tests/test_session_mutations.py::test_set_name -v` (or `-k <pattern>`); include e2e with `-m l2`
@@ -38,8 +42,8 @@ No ESLint on the TS layers — `tsc` is the gate. Tests use vitest + MSW (no net
frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness → DWH (read-only)
```
- **The harness owns the workflow and all persistence.** `tht` (Python) is a deterministic
CLI; `harness/.pi/extensions/tht-gate.js` is a Pi extension that drives an **8-phase
- **The harness owns the workflow and all persistence.** The Python workflow CLI `tht` inside
`core` is deterministic; `harness/.pi/extensions/tht-gate.js` is a Pi extension that drives an **8-phase
NL→SQL workflow**. The single source of workflow truth is `harness/workflow.yaml`; the
orchestration rules the model must follow are `harness/.pi/skills/tht-sessione/SKILL.md`.
"Current phase" is computed by folding the decision ledger (`harness/tht/phase.py`), not
@@ -52,7 +56,8 @@ frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness →
There is no verbatim transcript store. A resumed Pi process rebuilds context from
`tht session show <id>` + the on-disk artifacts.
- **The backend is a thin bridge with no database.** `ThtRunner` shells `tht` subcommands;
- **The backend is a thin bridge with no database.** `ThtRunner` shells the Python workflow `tht`
subcommands inside `core`;
`PiProcessManager` runs one Pi child per session and bridges its RPC stream;
`SessionBridge` maps Pi RPC events → client events (`ui_request`/`text_delta`/`info`);
`SseHub` fans them out over SSE to the browser. App settings live in a JSON file
+529 -14
View File
@@ -1,9 +1,524 @@
# ThothII — Project State
> Starting-point snapshot for new sessions. Last updated: 2026-07-23 (session summary redesign live).
> Starting-point snapshot for new sessions.
> **Requisito finale del progetto (owner, 2026-08-11):** al termine dell'ultima fase tecnica deve
> essere prodotto un documento unico che guidi l'utente passo-passo su (1) come preparare il
> repository dei workspace su Git secondo le regole del progetto, (2) come usare gli strumenti di
> ThothII per il repository (app + CLI `tht`), (3) come usare l'applicazione ThothII di base
> (sessioni, domande, gate). Il documento userà parole semplici ed esempi; i dettagli tecnici
> resteranno nei contratti esistenti. Esempio pratico completo: Policlinico San Donato.
> Last updated: 2026-08-18 (final-review fix round 2 recorded; native Windows authentication gate
> passed, remediation is complete, and unrelated release gates remain open).
> Point a fresh session here ("read PROJECT_STATE.md") before substantial work.
## Session summary redesign — LIVE 2026-07-23
### Authentication final-review fix round 2 — remediation PASS, release gates remain (2026-08-18)
- Frozen source is `2a9359071257f9b8a71d36ec2bbb25b161003f81` on `feat/thoth-auth`.
Source and evidence are separate commits; generated local runtime-state directories remain
untracked and must not be staged.
- Local PASS on the frozen source: exact `safeio`/`backup`/`authstorage` tests, full Go race suite,
`go vet`, macOS host build, Windows amd64 package cross-compiles, and Windows CLI build.
- The lifecycle tests now use context-aware gate publication/release, bounded waits for stages,
outcomes and admission, and cancel plus bounded worker join before lock-release assertions. A
deterministic withheld-gate case proves timeout, cancellation, join, and eventual lock release.
The temporary Windows relative-open diagnostic matrix was removed without reducing DACL, NT
normalization, or retained no-delete assertions.
- Authorized exact-source workflow run `32147345625` completed on the exact frozen SHA and
executed the unfiltered native command
`go test ./internal/safeio ./internal/backup ./internal/authstorage -count=1`. The required step
passed: safeio `22.058s`, backup `7.161s`, authstorage `16.088s`. Native Windows StageArchive and
concurrent claim-consume evidence are therefore PASS, not inferred from cross-compilation.
- The same Windows job later failed the unrelated clone-contract script at
`scripts/test-windows-clone-contract.ps1:208` because `$remoteYaml:` is not a valid PowerShell
variable reference. LF/Compose and Linux Docker baseline failures also repeated. The optional
Windows Docker startup job was skipped without executing and is `NOT_RUN` / `BLOCKED`; the
overall completed run conclusion is `failure` because the baseline jobs remain red.
- Historical Node/auth/browser/docs PASS and harness/Ruff/Compose FAIL evidence remains bound to
its recorded source where not rerun. L2, PSD/manual, and provider prerequisites remain
`PENDING`; no new Docker image manifest was generated.
- Durable evidence: `.artifacts/task-15/automated-gates.json`,
`.superpowers/sdd/2026-08-18-thothii-authentication-remediation/task-4-report.md`, and
`.superpowers/sdd/2026-08-18-thothii-authentication-remediation/fix-round-2-report.md`.
- Current automated-gates SHA-256 is
`6c516db5c2064c4a4a2e5f25961b993cd4a8fe020bbbb822fbac7faa0c119599`; the historical Docker
manifest remains bound to its recorded older source and was not reused for this candidate.
- **State:** the three original remediation Important findings remain `RESOLVED`; the fix-round-2
lifecycle Important is `ADDRESSED`; the Windows diagnostics Minor is `ADDRESSED`; authentication
remediation is `PASS`. Separately, release readiness remains `FAIL`, with L2, PSD/manual, and
provider gates `PENDING`, until unrelated deployment, runner, baseline, and external gates close.
### P3 effective configuration and `.tht-dwh` — implementation complete, automated PASS, manual PASS (2026-08-13)
- **Scope:** P3 (PRD D3): a versioned shared canonicalizer produces the non-secret effective
DWH/preprocessing configuration and a stable logical identity
(`workspace://<id>@v1:<sha256>`), used identically by the application sessions and the operator
CLI. `OWNER.json` writes are versioned; legacy roots remain readable; content-only/Evidence-only
changes keep the identity (no forced reconfiguration), while DWH-affecting changes fail closed
(never silently reusing the old generation).
- **Memory:** explicit workspace-global `paths.memory` root with a guarded migration command
(`tht memory migrate`) that copies and verifies exactly one legacy JSONL under the workspace
lock and fails closed on conflicts.
- **Revision-scoped records:** schema and Evidence Qdrant point IDs, payloads and queries include
`workspace_revision`; memory/solved stay workspace-wide.
- **Operator contract:** `tht` now carries `effectiveConfigIdentity`/`configFingerprint`/
`inputFingerprint` in results; the operator config lease path is deterministic for the same
revision+identity.
- **Retained evidence:** `.artifacts/p3-integration/p3-da9428d84f152fe059d41a89436496b7/`
(15/15 checks PASS), bound to clean source commit
`3b0726472e15c157…`.
- **Manual gate:** P3 walkthrough in `docs/testing/p2-p6-manual-verification.md`; decision
**PASS** (owner approval 2026-08-13).
### P4 Qdrant collection lifecycle — implementation complete, automated PASS, manual PASS (2026-08-13)
- **Scope:** P4 (PRD D4): one shared TypeScript collection manager owns the Qdrant collection
and payload-index contract; session admission self-heals a missing collection (1024/cosine +
the 8 required keyword payload indexes) and adds missing indexes, but never mutates an
incompatible collection (`semantic_index_incompatible`); the operator path keeps
`require_existing` semantics.
- **Host CLI:** `tht workspace vector inspect` (read-only contract report) and
`tht workspace vector rebuild --workspace <id> --collection <name> --confirm <name> --destroy`
(guarded delete/recreate of only the descriptor-owned collection, with durable state before
deletion and verification after recreation; mismatched confirmation or missing `--destroy`
→ exit 2).
- **Key files:** `backend/src/workspaces/qdrant-collection.ts` (+test), `backend/src/tht/tht-runner.ts`
(`qdrantEnsure` self-heal for admission; default `require_existing` elsewhere),
`backend/src/workspaces/runtime-config-lease.ts` (lease exposes `semanticQdrantUrl`),
`backend/src/workspace-maintenance.ts` + `preprocessing-service.ts` (`vector-inspect`/`vector-rebuild`
operator commands), `tools/tht/internal/workspaceops/operations.go` (+tests).
- **Automated acceptance:** PASS 11/11 (run `p4-466bbfdea9ef3111f36baa99fc2d64aa`,
report `.artifacts/p4-integration/p4-466bbfdea9ef3111f36baa99fc2d64aa/` retained via `--keep`,
bound to clean source commit `e056c19e6214254a9e3b2390e24c389920b84e95`): preflight, clean_state,
ownership, qdrant_up, self_heal_create_missing, self_heal_repairs_missing_index,
incompatible_refused, require_existing_refused, rebuild_recreates_contract, secret_scan,
cleanup_confinement.
- **Gates:** backend 666/666 + tsc clean; Go build+test 9/9; p4 runner unit tests 3/3; harness
841 passed (only the two pre-existing debt failures unchanged).
- **Manual acceptance:** PASS (owner approval 2026-08-13) — walkthrough section P4 in
`docs/testing/p2-p6-manual-verification.md`.
### P5 curated FK annotations in Git — implementation complete, automated PASS, manual PASS (2026-08-13)
- **Scope:** P5 (PRD D5): the canonical curated FK file is `<workspace-id>/schema/annotations.yaml`,
a regular Git blob at the same commit as the descriptor. Absence is compatible (empty canonical set
+ warning); symlinks, trees/gitlinks, cross-namespace paths, oversized (>16 MiB), non-UTF-8, and
malformed objects are refused at activation. Activation synchronizes the blob to the immutable
revision root `/data/sessions/<id>/revisions/<commit>/artifacts/mschema/annotations.yaml` with a
restrictive mode and an adjacent ownership manifest (`workspace`, `commit`, `blobId`,
`contentDigest`, `destination`); re-sync is idempotent and re-verifies, and tampered destinations
fail closed.
- **Runtime root:** the backend renders `paths.annotations_root` for the pinned revision while
`paths.artifacts`/`indexes`/`memory`/`sessions` stay workspace-global (the binding-keyed DWH cache
at `artifacts.parent` is untouched); the harness resolves annotations from `annotations_root` with a
legacy fallback.
- **Review primitive:** `tht ... workspace schema accept --run <id> --yes` is the only human FK
review path. It validates the current synced Git blob with the harness parser and records
`{ reviewedCandidatesDigest, annotationsDigest, workspaceRevision, blobId }`. Missing `--yes`, an
unknown run, an empty/malformed blob, or a non-matching candidate fails closed (`annotation_invalid`)
without recording a review. The P2 host-file `schema check --annotations --reviewed-candidates`
review write is superseded (read-only validation only).
- **Continuation gate:** `preprocess run` continues only when the accepted review's blob digest equals
the current revision's synced annotations digest and the DWH binding is compatible; otherwise it
records a new `manual_review_required` checkpoint.
- **Key files:** `backend/src/workspaces/annotations-sync.ts` (+test), `backend/src/workspaces/
annotations.ts`, `backend/src/workspaces/git-repository.ts` (`annotationsObject`),
`backend/src/workspaces/registry.ts` (activation validation + sync), `backend/src/workspaces/
preprocessing-service.ts` (`acceptSchema` + continuation gate), `backend/src/workspace-maintenance.ts`
(`schema-accept`), `tools/tht/internal/workspaceops/operations.go` (+tests), `harness/tht/
config.py` + `cli/schema_cmd.py` (`paths.annotations_root`), `docs/contracts/
workspace-preprocessing-cli.md`.
- **Gates:** backend **689/689** + tsc clean; Go build+test 9/9; harness focused schema/annotations
52 passed. Full-suite re-run and the clean-state process goal are recorded at the acceptance gate.
- **Automated acceptance:** PASS 10/10 (run `p5-66b1f1e74f147a23c0a4bff04e6d2a4c`, report
`.artifacts/p5-integration/p5-66b1f1e74f147a23c0a4bff04e6d2a4c/` retained via `--keep`, bound to
clean source commit `9db0299063d5068198c05dc467d7f86dc34de85b`): preflight, clean_state, ownership,
activation_sync, accept_happy_path, revision_isolation, accept_negatives, continuation_gate,
secret_scan, cleanup_confinement. Runner: `scripts/p5-acceptance.sh` /
`backend/scripts/p5-acceptance.mjs` (+unit test `scripts/test-p5-acceptance.sh`).
- **Manual acceptance:** PASS (owner approval 2026-08-13) — walkthrough section P5 in
`docs/testing/p2-p6-manual-verification.md`.
### P6 commit-addressed Evidence materialization — implementation complete, automated PASS, manual PASS (2026-08-13)
- **Scope:** P6 (PRD D6): filesystem Evidence `<id>/evidence` is materialized from the exact pinned
Git commit into the immutable revision content root `<registry>/snapshots/<commit>/<id>/evidence`
at activation, with a sibling bounded manifest `<id>/evidence.manifest.json` whose digest is chained
into `snapshot.json`.
- **Safety:** fixed Git plumbing (`ls-tree -r -z` + `cat-file blob`), no shell, no mobile checkout;
symlinks/gitlinks at any depth, traversal/absolute/duplicate/cross-namespace paths, and non-regular
modes are refused. Installation-local bounds (defaults): 4096 entries, 64 MiB total, 8 MiB per
file, 4096 path bytes, 1 MiB manifest; a size-sum preflight runs before writing and no partial root
is published. Re-activation reuses a valid root and fails closed on a tampered manifest.
- **Engine:** `evidencePolicy` no longer stops filesystem sources (`evidence_materialization_required`
retired); `preprocess evidence`/`preprocess run` operate on the materialized root. Evidence Qdrant
records remain revision-scoped; corpus ACTIVE is revision-qualified. HTTP/S3 Evidence is unchanged.
- **Retention:** materialized roots live inside the commit-addressed snapshot directory, so they are
retained while pinned and removed by the existing snapshot retention scan when unreferenced.
- **Key files:** `backend/src/workspaces/evidence-materialization.ts` (+test),
`backend/src/workspaces/git-repository.ts` (`evidenceTreeObjects`/`evidenceTreeId`/
`evidenceBlobBytes`/`gitObjectSize`), `backend/src/workspaces/registry.ts` (activation staging +
integrity chain), `backend/src/workspaces/preprocessing-service.ts` (stop removal),
`backend/src/workspaces/types.ts` + `config.ts` (limits), `docs/contracts/
workspace-preprocessing-cli.md`.
- **Gates:** backend **698/698** + tsc clean; Go build+test 9/9 (unchanged); harness focused suites
pass. Full-suite re-run and the clean-state process goal recorded at the acceptance gate.
- **Automated acceptance:** PASS 10/10 (run `p6-7a4c4d0ebb63399cfa9f674738b9e8fc`, report
`.artifacts/p6-integration/p6-7a4c4d0ebb63399cfa9f674738b9e8fc/` retained via `--keep`, bound to
clean source commit `124891bbfe8dc8270e8b58c4206150eb8bebeaa7`): preflight, clean_state, ownership,
activation_materialization, evidence_preprocess, revision_isolation, unsafe_tree_refused,
bound_refused, secret_scan, cleanup_confinement. Runner: `scripts/p6-acceptance.sh` /
`backend/scripts/p6-acceptance.mjs` (+unit test `scripts/test-p6-acceptance.sh`).
- **Manual acceptance:** PASS (owner approval 2026-08-13) — walkthrough section P6 in
`docs/testing/p2-p6-manual-verification.md`.
### P7 PSD migration — plan + repository restructured + local validation PASS; owner-gated (2026-08-13)
- **Plan:** `docs/superpowers/plans/2026-08-13-p7-psd-migration.md`.
- **Done (autonomous):** `/Users/mp/projects/tht-workspace-psd` restructured to the P1.1 layout and
committed (`thoth-workspaces.yaml` + `psd-clinical/workspace.yaml` schema v3 + `psd-clinical/
evidence/` 36 `.md` + `psd-clinical/schema/annotations.yaml` 42 KB); legacy runtime dirs gitignored
and the old flat `psd.yaml` retired. A local `WorkspaceRegistry.bootstrap()` against a scratch bare
clone **activated `psd-clinical`** (descriptor valid, 36 Evidence materialized + manifest, 42 KB
annotations synced, `workspace-docs` generated) with no DWH/secret access.
- **Templates:** `deploy/psd/{workspace-bindings,operator,thothii-installation}.env.example` +
gitignored `secrets/`; operator checklist in `docs/install/psd-workspace-setup.md` (registered in
MkDocs nav).
- **Published (2026-08-13):** private repo `https://github.com/mptyl/tht-workspace-psd` (main =
`d4f9185`), consumed via SSH deploy key `thothii-psd` (read-write, passphrase-less, generated in
`deploy/psd/secrets/`). Real operator config is wired (gitignored): `deploy/psd/operator.env`,
`workspace-bindings.env`, `thothii-installation.yaml`, `connector-secrets.yaml` + `secrets/`
(DWH X-API-Key reused from the legacy `.env`; no CA — the DWH REST is public HTTPS).
- **Stack live:** started via `tht start` (project `thothii-70417a3e30ea`), all services
healthy, `qwen3-embedding:0.6b` present; the registry cloned + activated `psd-clinical`
(`ready`); `tht workspace inspect` returns `ok` with descriptor/catalog/runtime identities.
Gotcha recorded: `tht` uses a per-descriptor Compose project name, so the stack must be
started with `tht start` (not a raw `compose-with-preflight.sh up`).
- **Preprocessing live (2026-08-13):** with VPN active, `tht workspace preprocess run
--workspace psd-clinical` **succeeded** against the real PSD DWH — DWH introspection + LSH
(163 tables / 2275 columns), FK review (no new candidates: the 42 KB curated annotations are
authoritative), schema index (2438 records) and filesystem Evidence index (36 docs / 43 chunks).
Qdrant `psd-clinical` now holds **2482 revision-scoped points** (`schema_table` 164,
`schema_column` 2275, `evidence` 43; all carry `workspace_revision`). Rerun is idempotent
(Evidence `unchanged: 36`).
- **Fixes shipped during the live run** (real-DWH scale revealed them): (1) pruned ~95 GB of orphaned
acceptance-run Docker volumes; (2) raised `workspace-maintenance` tmpfs `/tmp` 64 MiB → 1 GiB
(PSD LSH snapshot is ~105 MB); (3) `vector rebuild` now recreates the 8 keyword payload indexes
(it only created dimensions/distance); (4) Qdrant upserts are chunked (256 points/batch) — a 2438-
record schema batch exceeded Qdrant's 32 MiB JSON limit; (5) frozen Evidence metadata lists now
stay lists (`FrozenList`) instead of tuples, preserving JSON shape; (6) embedding timeout 30 s →
300 s and batch 32 → 16 for large CPU corpora.
- **Remaining:** live session smoke on `psd-clinical` (P8 L2) — create a session with a real
natural-language question and reach the first reviewer gate.
### Final aggregate P2–P6 verification — automated PASS, manual PENDING (2026-08-13)
- **Aggregate process goal:** one clean-state run exercises the complete DWH → FK → schema →
filesystem Evidence chain through `tht`/the operator surface, proves idempotency and
revision isolation, proves a second installation consumes the same Git workspace with its own
state, exercises unsafe-tree and bound negatives, and cleans only owned resources.
- **Automated acceptance:** PASS 12/12 (run `p2p6-ee542112c526ef0d4c25ddf6c8bc164b`, report
`.artifacts/p2p6-integration/p2p6-ee542112c526ef0d4c25ddf6c8bc164b/` retained via `--keep`, bound
to clean source commit `1dcf4051b0d9db8ae163e4d7c53871564ca3c564`): preflight, clean_state,
ownership, activation_materialization, dwh_chain, fk_schema_evidence_chain, revision_isolation,
second_installation, unsafe_tree_refused, bound_refused, secret_scan, cleanup_confinement. Runner:
`scripts/p2p6-acceptance.sh` / `backend/scripts/p2p6-acceptance.mjs` (+unit test).
- **Full suites + builds (design §10):** harness **873 passed / 4 deselected** (with color disabled;
the forced-color environment splits `--help` flags and trips the gate-CLI consistency test only);
backend **698/698** + tsc + build; frontend **364/364** + `tsc -b` + build; `tht` Go
build+test **9/9**; `git diff --check` clean.
- **Manual acceptance:** PENDING — "Final aggregate P2–P6 verification" in
`docs/testing/p2-p6-manual-verification.md`.
### User-guide deliverable (owner requirement) — written, review PENDING (2026-08-13)
- **`docs/guida-utente.md`** (Italian, simple words + examples) covers: (1) preparing the workspace
Git repository (catalog + schema-v3 descriptor + Evidence + curated annotations), (2) using the
ThothII tools for the repository (`tht` commands + read-only workspace management), and (3)
using the base ThothII application (sessions, questions, gates). It ends with a complete
Policlinico San Donato walkthrough and links to the technical contracts.
- Registered in the MkDocs nav (`mkdocs.yml`). Owner review PENDING.
### P2 host preprocessing CLI — implementation complete, automated PASS, manual PENDING (2026-08-11)
- **Scope:** P2 (PRD D2, based on the P1.1 registry contract): the installed native `tht`
binary is the only host interface for workspace preprocessing. Commands: `workspace inspect`,
`preprocess dwh`, `schema suggest-fks`, `schema check`, `index-schema`, `preprocess evidence`,
`preprocess run`, with the exact grammar, file-ingress bounds, result contract and exit codes in
`docs/contracts/workspace-preprocessing-cli.md`.
- **Operator:** `workspace-maintenance` is a profile-gated Compose service sharing the core image,
with no Pi auth/state, no backend/Pi/frontend listener, no Git credentials, and a compiled Node
entrypoint (`backend/src/workspace-maintenance.ts`) driving the existing harness engine through
pristine JSON machine interfaces (`schema_cmd.py`, `vector_cmd.py`, `preprocess_cmd.py`).
- **Boundaries honored:** FK review is digest-bound (candidate digest == persisted artifact; a
review accepted for the same candidate content counts); Qdrant collections are never created by
the product path (`require_existing` + pre-provisioned fixture, P4 owns lifecycle); filesystem
Evidence stops with `evidence_materialization_required` (P6); HTTP Evidence enforces an
installation private-host allowlist; `ssh_tunnel` stays fail-closed (P10); cross-revision DWH
reuse is explicitly P3.
- **Retained evidence:** `.artifacts/p2-integration/p2-b109757b26388a5ed6b1d173dee86584/`
(11/11 checks PASS), bound to clean source commit
`de5de36f9a4edfd4fbebf277822090871ccdd61f`.
- **Manual gate:** P2 walkthrough in `docs/testing/p2-p6-manual-verification.md`; the owner
approved P2 on 2026-08-11 (manual acceptance PASS). P3 and later start only after an explicit
new authorization.
### P1.1 workspace-directory registry — automated integration PASS, manual PENDING (2026-08-11)
- **Scope:** P1 correction (not preprocessing). Root curator-owned catalog `thoth-workspaces.yaml`;
one self-contained directory per workspace (`<id>/workspace.yaml`, optional `<id>/evidence/**`);
generated docs stay API-owned under `workspace-docs/<id>`; internal immutable snapshots remain
flat (`<snapshots>/<commit>/<id>.yaml`) to preserve session pins and runtime trust.
- **Ownership:** the API may create a descriptor once when its catalog slot exists and the
descriptor Git object is absent at the exact base commit. Existing descriptors and curated
content are curator-owned and change only through Git commit/push then installation pull.
Update/delete publish payloads are refused as HTTP 409 `workspace_curator_owned`. Catalog and
Evidence are never written/staged/cleaned by the API. Explicit pull may produce one deterministic
docs-only follow-up commit that never touches curator bytes.
- **Schema/UI:** schema v3 remains the only descriptor schema; filesystem Evidence URI is exactly
`<id>/evidence`. Browser workspace management is read-only for ready workspaces (Pull/Sync,
Validate, installation Test, Export, Evidence summary, curator Git guidance) and offers an
editable bootstrap form only for `configuration_required` catalog slots.
- **Retained evidence:** `.artifacts/p11-integration/p11-ac0b047024fb09eeca218512526a6b23/`
(`report.json` sha256 `44250145fede36de5de941262beb833c920e8c73366d987cdd738856aac6f6d6`),
19/19 checks PASS, bound to clean source commit
`eac472011e465c24572d9a6bae14de0fb3e246c0` / tree `4fd15ec28d3b7967b7b8757158013307fb20f3b9`.
- **Verification:** backend Vitest **634 passed / 41 files** + tsc + build; frontend Vitest
**364 passed / 54 files** + tsc + build; harness focused Evidence/config pytest **39 passed**;
install-docs and schema-v3-only gates PASS; `workspace-registry-smoke.sh` and
`unified-deployment-smoke.sh` full Docker runs PASS with exact cleanup.
- **Known limitations:** P2–P6 plans/designs are unchanged and their old source paths are
inventoried for a later owner-approved adaptation plan. Windows Docker startup and native
PowerShell contract were not executed on a Windows host. P1's accepted historical evidence and
process artifacts remain untouched; the old P1 process commands are not rerunnable against the
superseding P1.1 repository contract.
- **Manual gate:** `.artifacts/manual-acceptance/p11/` prepared for the reviewer;
follow `docs/testing/p11-manual-acceptance.md`. The owner reviewed the walkthrough and
approved the implementation on 2026-08-11.
```text
P1.1 automated integration: PASS
P1.1 manual acceptance: PASS (owner approval 2026-08-11)
```
# P1 configuration process — ACCEPTED 2026-08-10
- Retained evidence: `.artifacts/p1-integration/p1-038bf31360180dc831220b33fbadcfe6/report.md`
- Final report hashes: `report.json` `f07d49097966de6f0307490089fdb2ae61379c04b7fc7177d3c44cf001e1b46a`; `report.md` `09b6a9e9ad9eed2b049e286af452e12fa1f3174ea8d633253542604470890c6c`.
- automated integration: PASS
- manual acceptance: PASS — explicitly approved by the project reviewer on 2026-08-10.
- The retained run is bound to clean source commit
`c7338969d7c7c1c396d9099b7ab2d309b70ab6cf` and tree
`5f7013904806054b9f587230be89f34cbc80f5fc`. Its hash-bound provenance contains exact
43-file backend source and 39-file compiled `dist` manifests (manifest SHA-256
`eb6d6c77c78d14c798b50d0be430ad124b8fd8965afbc4bb07d358089d23f49` and `9f9e8899f8aca882ff08d49ec6cd00caeea75691c8280095a9b88bc74939a30a`).
- The retained audit has exactly 15 PASS checks and 134 unique declared artifacts whose final
bytes match every SHA-256 declaration. It records 749 PASS command events and 1,664 production
child/network events, with listener shutdown and refusal checks recorded in the final ownership
artifact. Raw Git rejects configured executable diff drivers and other helper-bearing state.
- Manual production acceptance binds every regular compiled distribution file through an immutable
manifest and cached verified module bytes; imported dependency replacement is refused before
RUNNING. Snapshot rendering validates the bounded `snapshot.json`, expected digest, and Git blob
identity, refusing regular source replacement without publishing output.
- Final Task 8/9 focused suites pass (48/48 Task 8; 59/59 manual acceptance and renderer checks),
backend TypeScript/build pass, frontend tests/build pass. Historical harness pytest/Ruff debt
remains unrelated to this P1 work.
## Internal Qdrant + Ollama semantic infrastructure — LIVE 2026-08-08
- **Compose topology.** The mandatory application stack is `frontend`, `core`, `qdrant`,
`embedding`, and the one-shot `embedding-model-init`. Startup is CPU-first by default; Linux
hosts may opt into GPU exposure with `THOTH_ENABLE_EMBEDDING_GPU=1`. Qdrant is private on the
Compose network and persists `/qdrant/storage` in `qdrant-data`. Ollama persists its local model
cache in `embedding-models`, and `embedding-model-init` blocks `core` until
`qwen3-embedding:0.6b` is present.
<!-- workspace-descriptor-contract:start -->
- **Semantic contract.** Internal semantic indexing is fixed to `qwen3-embedding:0.6b`,
`1024` dimensions, and cosine distance. Schema v3 is the only accepted workspace descriptor.
Schema v1 and v2 workspace descriptors are rejected before activation. Candidate snapshot
validation makes activation or a pull fail atomically and leaves the prior valid snapshot active;
there is no in-product migrator or automatic conversion. One workspace owns one Qdrant
collection, and
schema, Evidence, and Memory records coexist inside that collection with payload `kind`
separation.
<!-- workspace-descriptor-contract:end -->
- **Final review runtime barriers.** Operational routes, retained session pins, and runtime
rendering now require schema version 3 before resolving bindings, readiness, diagnostics, or
Pi. Session admission verifies the exact internal Qdrant collection (dimensions, cosine
distance, and required keyword payload indexes) before Ollama and before manifest persistence.
The Qdrant adapter binds every search/list/delete filter to its constructed workspace identity
and rejects conflicting caller namespaces.
- **Boundary and persistence.** Only DWH and LLM remain external runtime application endpoints.
There are no active external vector or embedding endpoint instructions, bindings, or secrets in
the supported operator manuals. Qdrant remains a derived but persistent semantic index: the
canonical sources of truth stay the workspace Git descriptors, phase artifacts, and memory
registry/ledger. The Ollama model cache is recoverable for offline startup but is not the
canonical source of semantic content.
- **Backup and recovery.** `./scripts/vector-backup.sh --project-name <name> --output <file>`
archives exactly one labeled `<project>_qdrant-data` volume and preserves the prior `qdrant`
running state. `./scripts/vector-restore.sh --project-name <name> --input <file>
--confirm-project <name>` requires the exact repeated project confirmation, validates manifest
and archive safety before stopping `qdrant`, stages rollback content, restores semantic storage
in place, and restarts `qdrant` only if it was previously running. Recovery requires the registry
to already hold a reviewed v3 descriptor revision compatible with the restored collection; the
helper does not restore descriptors, rename collections, or repair a semantic-index
incompatibility. Backup and restore share one atomic Docker-daemon lock per Compose
project/Qdrant volume; contenders fail before volume resolution, and cleanup removes the lock
only when its ownership labels still match.
- **Verification recorded for Task 13 final audit.** On Apple M4 Pro
(`Darwin 25.5.0`, Docker Server `29.6.2 linux/arm64`), harness pytest passed
**827 passed / 4 deselected**; backend Vitest passed **477/477** plus TypeScript and build;
frontend Vitest passed **374/374** plus TypeScript and build; `git diff --check` passed.
Deployment contracts passed:
`test-default-compose.sh`, `test-unified-compose.sh`, `test-internal-semantic-compose.sh`,
`test-no-deployment-coupling.sh`, `test-compose-secret-policy.sh`, and
`verify-workspace-install-docs.sh --fixtures-only`.
- **Task 13 Docker smoke evidence.** CPU semantic smoke passed in **217.34s** and proved
offline Qdrant/Ollama persistence plus exact cleanup. Workspace registry smoke passed in
**42.06s** in fix round 1 with a per-run image tag derived from the unique Compose project,
and proves exact cleanup of compose containers, volumes, networks, and only that smoke image.
Unified deployment smoke passed in **125.57s**; update-only rollback smoke
passed in **85.40s**; Linux server deployment smoke passed in **55.99s**. The previously
observed `tht` rollback failure did not recur.
- **Task 13 image and manual-gate notes.** Verified pinned runtime images:
`qdrant/qdrant:v1.18.2@sha256:75eab8c4ba42096724fdcfde8b4de0b5713d529dde32f285a1f86fdcb2c9e50c`
and
`ollama/ollama:0.32.0@sha256:57f573b47f1f71ebb445789f279fe3e596a8beab182f7cf486db9205bad87c5a`.
The workspace-registry smoke fix-round image used tag
`thothii-workspace-registry-smoke:thoth-workspace-registry-smoke-thoth-workspace-registry-smoke-10vi3a-19157`,
built manifest list `sha256:715b943057929418cad4aa71806d9edbaf823555d19bda6b875297617463fd4a`
with config `sha256:a566521981e08958aae9a12bfc7803bb5f3f835536b4bb8c39df8fcf26063161`,
and removed that exact reference during cleanup. Local GPU exposure (`THOTH_ENABLE_EMBEDDING_GPU=1`) and
Windows Docker Desktop startup were not manually executed in this run.
- **Task 13 known limitations.** Broad harness Ruff remains existing unrelated debt
(**220 errors**); touched harness files were verified Ruff-clean. The final active-reference
audit remains non-empty only in deterministic negative guards, retained off-repository migration
SQL, L2 compatibility fixtures, gitignored task notes, and historical reference notes. No active
schema-v3 operator manual or supported runtime deployment path retains external vector or
embedding endpoint coupling.
- **Final review fix verification.** Backend Vitest passed **477/477** plus TypeScript and build;
harness pytest passed **827 passed / 4 deselected** with the existing 74 warnings; touched Python
files are Ruff-clean. The complete `tht` Go suite, deterministic backup/restore safety test,
internal semantic Compose contract, no-deployment-coupling gate, CPU/offline semantic smoke, and
unified deployment smoke all pass after the final fix. The intermittent `tht` rollback failure was
traced to Docker Desktop alternating equivalent bind sources between `/private/...` and
`/host_mnt/private/...`. Exact state-v4 source hashes remain unchanged; only fresh bind
observations made by a Darwin `tht` carry non-serialized aliases for the rollback
comparison, so pre-fix recovery state remains readable and Linux `/host_mnt` paths remain
distinct. The rollback-only smoke passed twice consecutively after each fix revision, and the
subsequent full unified smoke passed with exact cleanup.
# Historical archive
## Historical snapshots and archived reference notes
### Historical snapshot — Unified deployment release gate, Task 13 (2026-08-05)
- **Release coverage.** `scripts/unified-deployment-smoke.sh` gates the two-service render/build,
frontend-to-core routing, embedded pinned Pi, Git registry bootstrap, offline recreation, valid
update, invalid-update retention, and the four persistent stores. `scripts/tht-update-smoke.sh`
independently exercises the bad-Pi update and automatic rollback path.
`scripts/server-deployment-smoke.sh` starts the server plus required session overlays with the
same smoke-built core/frontend images, disposable bind roots/secrets/session configuration,
upstream-auth checks, and fail-closed unavailable-session behavior.
- **Isolation and disclosure boundary.** Every run generates a unique temporary root, Compose
project, container/image names, transaction image tags, and run label. The rollback fixture uses
an immutable `hello-world` digest whose preflight exits successfully, guaranteeing the stopped
core state required by `tht` compensation. Cleanup includes stopped project containers in
its final ownership check immediately before teardown and removes only exact containers,
Compose resources, image references, control state, and temporary files. There is no global
prune. Failure diagnostics are bounded and sanitized, and all credentials/endpoints used by the
smokes are disposable fixtures rather than operator or repository secrets. Every public smoke
also has an internal 30-minute process-group supervisor with TERM/KILL of the complete group.
- **Cross-platform CI contract.** `.github/workflows/deployment.yml` uses immutable action commits,
pinned supported Node and Go versions, runs LF/Compose/secret/coupling/docs/TypeScript gates on
Linux, runs each Linux Docker smoke once under its own outer timeout, and copies the Windows
source into a path containing spaces before building/invoking native `tht` and rendering
Compose. The optional `windows_docker_startup` dispatch targets a labelled self-hosted Windows
Docker Desktop/WSL2 runner and performs bounded two-service startup and exact cleanup. No local
Windows or Windows Docker execution is claimed until that manual job is recorded.
- **Validation status.** Deterministic Phase A gates, backend **434/434** plus TypeScript,
frontend **386/386** plus TypeScript, and harness **862 passed / 5 L2 deselected** are green.
Review round 1 ran each Docker smoke exactly once without retry. Unified (`103.86s`) and
update-only (`46.45s`) passed build/start, core/Pi/registry/persistence setup and the stopped
candidate preflight, but `tht` stopped before mutation at its active-session inventory gate.
Round 2 replaces presence-only fixture checks with generated Compose renders plus the production
workspace resolver; this found and fixed missing explicit direct transport selections. The
clean-server preflight now atomically initializes the three hidden Pi-agent targets under the
writable parent bind while protected/tracked sources remain separate read-only mounts. Clean
empty-root render/setup and wrong-service/value/mount mutations are green. The corrected server
one-shot built and started both healthy services from an empty Pi-state root, then stopped at an
incorrectly addressed authenticated frontend hop. Fix round 3 adds the exact fourth private
non-admin claim and proves its nginx/backend transformation in a focused auth test. It also
centralizes schema-v2 registry descriptor resolution and secret-safe runtime rendering in
`ThtRunner`, preserving canonical revision identity and durable session roots for inventory,
create/resume/show, SQL, and Pi calls. The fresh update-only one-shot now passes mutation,
automatic `rolled_back` compensation, exact prior-image restoration, unchanged registry head
and mount identities, all four persistence sentinels, post-rollback doctor/workspace checks,
and exact labeled-resource cleanup. The one authorized server invocation was blocked at its
first Docker readiness call by the execution sandbox's socket permission before any Compose
resource could be created, so authenticated workspace/fail-closed session behavior remains an
explicit release gate. Native Windows PowerShell/Docker execution also remains pending.
### Historical snapshot — Portable deployment decoupling (superseded 2026-08-08)
- **Mandatory stack.** The supported Compose stack is exactly `frontend` plus `core`; use the
base file with `deploy/compose.local.yaml`, or with `deploy/compose.server.yaml` plus the
required public-server session overlay. `run-stack.sh`
invokes the base+local Compose command and the core image provides Pi, so no host Pi binary is
part of the launch contract.
- **External boundaries.** DWH, vector DB, embedding, LLM, and reverse-proxy services are
external configurable endpoints even when deployed on the same infrastructure. The two
superseded PSD/portal deployment overlays were removed. Workspace descriptors and migration
utilities remain separate from deployment runtime configuration.
- **Legacy PSD deployment ruling.** The PSD bootstrap was deleted because it generated the
retired overlay and was therefore deployment machinery, not a data migration utility. Its
remaining live contract checks were renamed for the generic local Compose profile. The coupling
gate rejects stale active deployment filenames and content while deliberately excluding
historical plans/specs, canonical workspace descriptors, and non-runtime migration helpers.
- **Fresh provider and secret contract.** Local, server, and standalone development mount the
protected Pi auth JSON plus tracked declarative model/settings files read-only under
`/home/thoth/.pi/agent`. The existing strict application bundle is a core-only Docker secret at
`/run/secrets/thothii.secrets`; operator env files contain only its absolute source path.
Provider readiness is exercised from a fresh Compose volume through model listing, configuration,
and sanitized credential status.
- **Install and scan closure.** Superseded copied one-service installation examples and the
provider-owned-network test are retired. Active manuals use the canonical base plus local/server
and optional overrides, while the category-based coupling scan covers runtime, Docker smoke,
install, operator, and positive deployment-test contracts and propagates scanner errors.
### Historical snapshot — Portable Git workspace registry, pre-schema-v3 (superseded 2026-08-08)
- **Source of truth and scope.** The canonical workspace repository is a generic Git remote,
configured only by `THT_WORKSPACE_GIT_REMOTE` and `THT_WORKSPACE_GIT_BRANCH` (there is no
committed PSD/Chirone remote or branch default). Both a local Docker installation and a server
persist its checkout, validated snapshots, state, and locks at `/data/workspace-registry`.
Connector endpoints, transport choices, and secret-file paths remain local bindings; secret
contents are never stored in Git, API responses, browser storage, diagnostics, or bundles.
- **Migration and session safety.** Schema-v2 descriptors are operational; legacy descriptors are
visible as `migration_required` until migrated by the documented operator workflow. New sessions
acquire a persistent revision lease before readiness and persist workspace ID plus immutable Git
revision. Retention hands that lease off only after an authoritative scan observes the manifest,
so a stale concurrent scan cannot prune the pinned snapshot. Resume resolves that historical
snapshot, while retention preserves every revision referenced by an open, closed, or failed
unarchived manifest.
Reconciliation runs only with a complete local installation list or an administrator's complete
server list, never from a remote user's partial view.
- **SSH connector boundary.** The current OpenSSH forward is owned by one bounded diagnostic and is
always cleaned up afterward. DWH/vector `ssh_tunnel` bindings therefore return
`workspace_not_activatable`, and new-session creation rejects them before persistence. Direct and
REST runtime connectors remain supported; Git remote access over SSH is unaffected.
- **Operator manuals.** Follow [the local manual](docs/install/local-workspace-registry.md) for
macOS/Windows/Linux Docker Desktop deployment and [the server manual](docs/install/server-workspace-registry.md)
for Gitea-compatible remotes, reverse proxy, migration, backup, and recovery. The release
workflow is Git review/push → installation pull → validate → local diagnostic test → browser-local
workspace/model/reasoning selection → revision-pinned session.
- **Verification recorded for this source branch.** `git diff --check` passed; backend Vitest
**371/371** and TypeScript passed; frontend Vitest **398/398** and TypeScript passed; the
harness document regression passed **10/10**. `./scripts/workspace-registry-smoke.sh` and the
executable installation-manual fixture verifier passed with Docker. A final unrestricted full
harness run remains a release command for the deployment environment; the earlier local
long-running harness run was intentionally cancelled before it produced a final result.
### Historical snapshot — Session summary redesign (2026-07-23)
- Session documents are projected at read time in outcome-first order: original question,
final SQL, persisted data preview, revised question, assumptions, one memory list, then
@@ -26,7 +541,7 @@
`sha256:8311ca1308b459ece7236bf143da7b1a226ff4082fed924e1b5a207c24b6ca29`
is running. Frontend and `/api/health` both returned HTTP 200.
## Local Pi user auth + startup failure handling — LIVE 2026-07-21
### Historical snapshot — Local Pi user auth + startup failure handling (2026-07-21)
- The PSD Docker profile now bind-mounts the configurable host `PI_AUTH_FILE` read-only at
`/home/thoth/.pi/agent/auth.json`; on this Mac it resolves to the real user profile
@@ -48,7 +563,7 @@
`a390c8b8-0a91-4a37-967b-ce7ff9be9797`, `a2f974b2-4c48-4967-b4b6-afdbc2b2d541`, and
`f66e1959-3c71-4b10-8aa1-606992046b7e` (API delete 204, subsequent lookup 404 for each).
## User-owned sessions cutover — prepared, manual gate pending (2026-07-16)
### Historical snapshot — User-owned sessions cutover (2026-07-16)
- **Target contract:** the public server runs `AUTH_MODE=upstream` with Task 4 portal identity
forwarding and Task 5 principal enforcement deployed together. Its session source of truth is
@@ -70,7 +585,7 @@
release; never re-enable filesystem persistence, restore the archive into production, or
dual-write during rollback.
## Deployment — Docker locale (Profile A, co-located) — LIVE 2026-07-12
### Historical snapshot — Docker locale deployment, Profile A (superseded 2026-08-05)
ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal** a `https://aritmolab.policlinicosandonato.it/datamart-builder` (backend invisibile, tutto same-origin via nginx del portale).
@@ -83,7 +598,7 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal
- **Standalone/dev**: `docker-compose.dev.yml` (rete propria, porte host 8787/8090) + `scripts/docker-smoke.sh`.
- Piano dettagliato: `docs/superpowers/plans/2026-07-12-local-docker-deploy-implementation.md`.
### Runtime incident fixes — LIVE 2026-07-13
### Archived snapshot — Runtime incident fixes (2026-07-13)
- The bind-mounted Pi profile came from host paths and did not trust `/app/harness`.
Pi 0.80 consequently loaded **zero** project extensions, prompts and skills, silently
@@ -102,7 +617,7 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal
resumed directly at F1, ran `tht session show`, and completed `tht search pack`
(12 tables, 0 evidence, 2 solved) without repository exploration or adapter errors.
### Workflow/UI regression fixes — LIVE 2026-07-14
### Archived snapshot — Workflow/UI regression fixes (2026-07-14)
- **F1 Model Activity restored.** Session create/resume now preserves configured/persisted
thinking instead of forcing `off`. Pi's nested `thinking_delta` is bridged to a dedicated
@@ -127,7 +642,7 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal
Running image ids: core `sha256:55acef2f12151ea97144c2f5e9164d63f2ca734bc2746fef553df94849e3fb3f`;
frontend `sha256:1043f79392420149655cc63d70461e2ca2005b2290a1e3e21dcf845ec3bd1c81`.
### Pi-enabled model selector — LIVE 2026-07-14
### Archived snapshot — Pi-enabled model selector (2026-07-14)
- **Pi is the allowlist authority.** `/models` reads the mounted Pi `enabledModels`, intersects
it with models currently available from Pi, and preserves the configured order. Enumeration
@@ -148,7 +663,7 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal
ID and running container image ID both equal
`sha256:577f99754fd0731251c8ddd8608b1b8baee09d02fad66c759b23f8221083e676`.
### Qwen connectivity + state-aware Resume recovery — LIVE 2026-07-14
### Archived snapshot — Qwen connectivity + state-aware Resume recovery (2026-07-14)
- **Pi turns have an explicit lifecycle.** The bridge tracks `idle`, `running`, `waiting`,
and `failed`; a reviewer gate is `waiting`, responses/steering return to `running`, and an
@@ -183,7 +698,7 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal
exited 0 with `controller.abort()` in cleanup, preserving the gate, session cleanup, and exact
settings-restoration evidence.
### Complete activity timeline + CTE spacing — LIVE 2026-07-15
### Archived snapshot — Complete activity timeline + CTE spacing (2026-07-15)
- **Model activity is complete from F1.** The left panel now records the submitted prompt before
session creation completes, then projects thinking, assistant output, sanitized tool lifecycle,
@@ -216,7 +731,7 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal
field crossed SSE. Cleanup closed with 200, deleted only that session with 204, restored the
exact settings object, and left no Pi runtime or smoke session.
### Filtered Model activity projection — LIVE 2026-07-15
### Archived snapshot — Filtered Model activity projection (2026-07-15)
- **Resolved contract.** `activityLog` still folds the complete in-memory prompt, thinking,
assistant, sanitized tool, reviewer-gate, status, and turn-lifecycle history. The left panel now
@@ -239,7 +754,7 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal
four public fields. The probe accepted the close response, deleted only that session with 204,
restored the exact saved settings object, confirmed the session absent, and left no Pi runtime.
### Central live log + compact CTE density — LIVE 2026-07-15
### Archived snapshot — Central activity log + compact CTE density (2026-07-15)
- **Resolved UI contract.** The central working body now renders every chronological non-blank
assistant transcript line in one bounded accessible log, without user-entry echoes,
@@ -269,7 +784,7 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal
unrelated Pi runtime existed to disturb. The count-only frontend sensitive/error pattern scan
was **0**. No live model smoke was run, and settings and sessions were intentionally untouched.
### Resizable activity split + compact CTE rows — LIVE 2026-07-15
### Archived snapshot — Resizable activity split + compact CTE rows (2026-07-15)
- **Resolved UI contract.** `activityLog` remains the complete in-memory chronological fold. The
left Model activity panel default-denies every kind except prompt, thinking, and assistant,
@@ -303,7 +818,7 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal
sensitive/error pattern scan was **0**. No live model smoke was run; settings and sessions were
intentionally untouched.
### Final activity-split fix — LIVE 2026-07-15
### Archived snapshot — Final activity-split fix (2026-07-15)
- **Source and verification (`2026-07-15T15:56:57+02:00`).** Deployed source commit
`1f540fcb78ac9e552e56a21e47edf66e9872b323` (`1f540fc`). Frontend Vitest passed **298/298**
+206 -95
View File
@@ -2,64 +2,110 @@
ThothII is a human-reviewed NL-to-SQL workflow with a React frontend and a Fastify/Pi/`tht`
core. The portable deployment runs exactly two application services; data services remain
external in this profile.
external in this profile, except for the mandatory internal semantic services bundled in Compose.
## Docker Compose: one-command startup
Authentication is configured through the single host CLI tht: see the [local authentication guide](docs/install/authentication-local.md),
[generic OIDC guide](docs/install/authentication-oidc.md), and [manual acceptance matrix](docs/testing/authentication-manual-acceptance.md).
Requirements: Docker Engine with Compose v2. The default project starts only the two
application images; DWH, vector and embedding services can be remote or supplied by an
optional overlay.
## Docker Compose: local startup
Requirements: Docker Engine with Compose v2. The mandatory stack is `frontend`, `core`, `qdrant`, `embedding`, and the one-shot `embedding-model-init`. DWH and LLM remain external,
configurable endpoints—even when they are co-located with ThothII.
From a fresh clone, run these commands from the repository root:
```sh
cp .env.example .env
cp deploy/secrets/thothii.secrets.example deploy/secrets/thothii.secrets
chmod 600 deploy/secrets/thothii.secrets
# Edit .env (non-secret endpoints) and deploy/secrets/thothii.secrets (KEY=VALUE lines).
docker compose up --build -d
cp deploy/env/local.env.example deploy/env/local.env
# Edit deploy/env/local.env, including PI_AUTH_FILE, THT_SECRETS_FILE, and external endpoints.
docker compose --env-file deploy/env/local.env \
-f compose.yaml -f deploy/compose.local.yaml up --build -d
```
The root `.env` is loaded automatically by Compose. It defaults to `compose.yaml`, an empty
profile, and `THT_SECRETS_FILE=deploy/secrets/thothii.secrets`; no `--env-file`, `-f`, or
`--profile` flag is required for the normal installation. Add or edit YAML workspace descriptors
under `deploy/workspaces/`; they are mounted read-only and relative `roots` resolve beneath
`/data/workspaces/<workspace-name>`. Open <http://127.0.0.1:8080> (set `THOTH_HTTP_PORT` in
`.env` to choose another loopback port).
`./scripts/run-stack.sh` runs this same base+local command in the foreground. The core image
contains its Pi runtime; no host `pi` executable is used. For a server installation:
The bundle contains only values, one per line (`THT_MODEL_API_KEY=...`, DWH/vector keys, and
the optional local-vector passwords). It is ignored by Git and never copied into either image.
Do not put credentials in `.env`, workspace YAML, URLs, or Compose interpolation values.
### Optional overlays
Overlays are selected in `.env`, so the operational command remains the same. On Unix-like
systems use `:` between files; on Windows use `;`:
```dotenv
# Remote DWH/vector/embedding services with authenticated reverse proxy:
COMPOSE_FILE=compose.yaml:deploy/compose.production.yaml
COMPOSE_PROFILES=
# Local pgvector (Mac/Windows or a standalone application server):
COMPOSE_FILE=compose.yaml:deploy/compose.local-vector.yaml
COMPOSE_PROFILES=local-vector
```sh
cp deploy/env/server.env.example deploy/env/server.env
# Edit all absolute storage, Pi/secret/session files, and endpoint paths.
sudo scripts/prepare-server-pi-state.sh /srv/thothii/pi-state 10001 10001
docker compose --env-file deploy/env/server.env \
-f compose.yaml -f deploy/compose.server.yaml \
-f deploy/compose.session-server.yaml.example up --build -d
```
After changing `.env`, apply the selected configuration with `docker compose up --build -d`.
Preprocessing is an explicit opt-in preset: append
`deploy/compose.preprocess.yaml:deploy/compose.preprocess-local-vector.yaml` and set
`COMPOSE_PROFILES=local-vector,preprocess`; then run the job with
`docker compose run --rm preprocess-evidence` or `preprocess-dwh`.
The initializer is required for an empty or restored server Pi-state bind. It atomically creates
the three regular targets hidden below the writable parent bind; protected Pi auth and tracked
model/settings sources remain separate read-only mounts. See the server manual before substituting
a root other than `/srv/thothii/pi-state`.
Application state, including settings, sessions, artifacts, and indexes, lives in the named
`thoth_data` volume mounted at `/data`. `docker compose down` keeps that volume. Only an
explicit destructive command such as `docker compose down --volumes` removes it.
Workspace descriptors come from the Git remote configured by `THT_WORKSPACE_GIT_REMOTE`; their
runtime endpoint and secret bindings remain installation-local. Open
<http://127.0.0.1:8080> (set `THOTH_HTTP_PORT` in `deploy/env/local.env` to choose another
loopback port).
Credentials and certificates are local protected files. Do not put them in environment examples,
workspace YAML, URLs, or Compose interpolation values.
Application state is split across the named `settings`, `pi-state`, `workspace-registry`,
`sessions`, `qdrant-data`, and `embedding-models` volumes. `docker compose down` keeps them.
`qdrant-data` is a derived but persistent index store; `embedding-models` is an Ollama model
cache for `qwen3-embedding:0.6b` with fixed `1024`-dimension embeddings. Only an explicit destructive command such as `docker compose
down --volumes` removes them.
The frontend depends on the core health check and proxies `/health` and `/api/*` to it. The
application health endpoint intentionally checks process readiness only; external dependency
diagnostics are exposed by `tht doctor` and do not prevent the UI from starting.
## Git-backed workspace repository
Workspace descriptors are shared through a validated Git repository while endpoint bindings and
secret files remain installation-local. Use the [local Mac/PC installation manual](docs/install/local-workspace-registry.md)
for Docker Desktop or a local engine, the [server installation manual](docs/install/server-workspace-registry.md)
for the Gitea, reverse-proxy, backup, upgrade, and recovery workflow, and the
[P1→P1.1 migration guide](docs/migrations/p1-to-p1-1-registry-layout.md) before upgrading an
older flat-layout registry.
The curator-owned repository layout is:
```text
thoth-workspaces.yaml
<id>/workspace.yaml
<id>/evidence/**
```
`thoth-workspaces.yaml` uses the `schema_version` value `1` and the ordered `workspaces` list of
`{id, name, description?}` entries. It is authoritative for workspace ID, name, description, and
display order. Every catalog entry must have a matching descriptor in the same commit; otherwise
the complete candidate is rejected. Descriptors remain curator-owned and change only through a
Git commit and push from a separate authoring clone, followed by an installation pull. ThothII
never writes any workspace repository content.
The operator workflow is: curate catalog/descriptor/Evidence changes in Git, commit and push,
**Update workspace repository** from each ThothII installation, select the workspace, complete its
write-only runtime-secret fields, run **Validate workspace source** and **Test workspace
connections**, then select the workspace locally before creating sessions. Each new session
pins the Git revision it used; a later pull cannot change a Resume. Snapshot cleanup retains every
revision referenced by an open, closed, or failed unarchived session. It reconciles from the
single local installation list or from a server administrator's complete session list, never from
a remote user's partial list. The isolated deployment exercise is
`./scripts/workspace-registry-smoke.sh`; both manuals are checked with
`./scripts/verify-workspace-install-docs.sh --profile local` or `--profile server`.
<!-- workspace-descriptor-contract:start -->
Schema v3 is the only accepted workspace descriptor. Schema v1 and v2 workspace descriptors are
rejected before activation. Candidate snapshot validation therefore makes activation or a pull fail
atomically while the prior valid snapshot remains active. There is no in-product migrator or
automatic conversion. A repository must already contain reviewed v3 descriptors. One workspace
owns one Qdrant collection;
schema, Evidence, and Memory records share that collection and stay separated by indexed payload
`kind`.
<!-- workspace-descriptor-contract:end -->
Connector `ssh_tunnel` bindings are diagnostic-only in this release: their bounded probe always
cleans up the loopback forward and returns `workspace_not_activatable`; session creation is rejected
before persistence. Git registry access over SSH is unaffected. Use direct or REST connector
transport for runtime sessions.
`docker-compose.dev.yml` is deliberately local: both published ports bind to `127.0.0.1`,
`THT_SESSION_STORAGE=local`, and `THT_HOME=/data/local-home`. Do not set
`THOTH_PUBLIC_EXPOSURE=true` for that profile; the backend rejects that public/local combination
@@ -79,28 +125,97 @@ volume afterward. It never targets the fixed `thothii` operator project or its v
`KEEP_SMOKE_RESOURCES=1` to retain that smoke project's resources for inspection; remove them
later with `docker compose --project-name "$SMOKE_PROJECT" down --volumes`.
## Optional local pgvector and recovery
## Unified deployment release gates
The local-vector overlay reads `THT_VECTOR_BOOTSTRAP_PASSWORD`,
`THT_VECTOR_MIGRATOR_PASSWORD`, `THT_VECTOR_READER_PASSWORD`, and
`THT_VECTOR_WRITER_PASSWORD` from the same bundle. Its `vector_data` volume is independent of
application state; passwords are selected at runtime and are never passed as URL arguments.
Task 13 adds no-secret release gates around the canonical local and server Compose profiles. Its
deterministic safety check does not contact the Docker daemon:
```sh
bash scripts/unified-deployment-smoke.sh --self-test
```
The three Linux Docker smokes are separate release commands. Each creates a unique Compose project, temporary
Git workspace remote, fixture provider, image names, and run label. Its exit trap removes only
resources carrying that exact run identity and never performs a global Docker prune. Cleanup
enumerates running and stopped project containers immediately before `compose down` and refuses
the teardown if any container, volume, or network has a foreign run label.
```sh
bash scripts/unified-deployment-smoke.sh
bash scripts/tht-update-smoke.sh
bash scripts/server-deployment-smoke.sh
```
The unified smoke builds and starts `frontend` and `core`, verifies the embedded Pi and internal
registry, recreates with the Git remote offline, activates a valid Git update, rejects invalid Git
content while retaining the valid snapshot, and checks the four persistence volumes. The unified
and update-only smokes
inject a digest-pinned non-core candidate under a deliberately mismatched Pi version and require
`tht pi update` to roll back while preserving settings, sessions, Pi state, registry revision,
and mount identity. The rollback candidate is the digest-pinned `hello-world` executable: a
preflight proves that it exits successfully, so the failed replacement core satisfies
`tht`'s stopped-core compensation precondition. The server smoke uses the same smoke-built
core/frontend images with the server and required session overlays, disposable bind roots and
secret files, upstream-auth checks, and a fail-closed `503` assertion for its deliberately
unavailable disposable session endpoint. No real provider, database credential, or repository
secret is required.
For a clean server bind, `scripts/prepare-server-pi-state.sh` creates the hidden regular
`agent/auth.json`, `agent/models.json`, and `agent/settings.json` mount targets atomically before
Compose. The server smoke starts from an empty Pi-state root and applies this same preflight; the
real protected/tracked sources remain separate read-only mounts. Deterministic fixture tests render
both profiles, verify that bindings stay on `core`, check mount readability, and run the production
workspace resolver. Wrong-service, wrong-value, and broken-secret-mount mutations must fail.
Each public smoke has its own 30-minute process-group supervisor with TERM/KILL cleanup; CI retains
an independent 32-minute outer timeout and does not retry a failed command.
Current release status (2026-08-05): clean-root render/setup and the production runtime-binding
resolver contracts are green. The server fixture supplies all four private trusted claims,
including exact non-admin value `0`, and a focused test proves nginx normalization produces the
accepted non-admin backend principal. Canonical schema-v3 registry descriptors now pass through
one backend-owned, secret-safe runtime handoff for inventory and session execution; canonical
identity and durable session/artifact/index roots are retained. The fresh update-only smoke passed
bad-candidate mutation, automatic `rolled_back` compensation, exact prior-image restoration,
unchanged registry/mount identity, all four sentinels, post-rollback doctor/workspace checks, and
exact cleanup. The one authorized server-smoke invocation was denied access to the Docker socket
by its execution sandbox before startup, so the complete authenticated workspace and fail-closed
session assertions still require a fresh authorized release run. Native Windows Docker
Desktop/WSL2 remains a separate manual/self-hosted gate.
The deterministic native Windows contract is:
```powershell
.\scripts\test-windows-clone-contract.ps1
```
It checks Git's CRLF/LF attributes and bytes, copies tracked source into a temporary path containing
spaces, builds and invokes native Windows `tht` there, and renders exactly `core` plus
`frontend` without starting containers. On a supported self-hosted Windows Docker Desktop/WSL2
runner, dispatch the deployment workflow with `windows_docker_startup=true`; that job executes:
```powershell
.\scripts\test-windows-clone-contract.ps1 -DockerStartup
```
Startup mode adds bounded image build/two-service health startup, installation-aware `tht`
status, stopped-container-aware ownership checks, and exact cleanup. The ordinary hosted Windows
job remains deterministic and does not claim Docker startup.
## Preprocessing jobs and S3 Evidence
The included job workspaces target the local-vector profile. Put the four local-vector password
keys in the bundle, set `THT_OLLAMA_URL`, mount Evidence at `/data/source/evidence`, then select
the preprocessing preset in `.env`:
The included preprocessing services reuse the internal Qdrant/Ollama stack. Mount Evidence at
`/data/source/evidence`, then run the explicit preprocessing preset:
```dotenv
COMPOSE_FILE=compose.yaml:deploy/compose.local-vector.yaml:deploy/compose.preprocess.yaml:deploy/compose.preprocess-local-vector.yaml
COMPOSE_PROFILES=local-vector,preprocess
```sh
docker compose --env-file deploy/env/local.env \
-f compose.yaml -f deploy/compose.local.yaml \
-f deploy/compose.preprocess.yaml --profile preprocess run --rm preprocess-evidence
```
Run `docker compose run --rm preprocess-evidence` or
`docker compose run --rm preprocess-dwh`. The overlay makes each job wait for the vector
database health check, role reconciliation, and a successful migration; no separate database
startup or migration command is required.
Replace the final service with `preprocess-dwh` when required. The overlay makes each job wait for the internal Qdrant
service health checks and embedding model initialization; no separate semantic-service startup is
required.
S3 Evidence uses the optional `tht[s3]` dependency and canonical `s3://bucket/key` provenance.
AWS endpoints are used when no custom URL is supplied. Every custom endpoint is an explicit egress
@@ -111,39 +226,39 @@ egress policy. Store access key, secret key, and session token as secret referen
deployment configuration—never in Compose environment values or source URIs. Discovery and reads
are bounded by configured page, object, and byte limits.
Create a versioned PostgreSQL custom-format backup (the filename is operator-controlled, so use
an immutable timestamp or release identifier):
Create a versioned Qdrant volume backup for one exact Compose project (the filename is
operator-controlled, so use an immutable timestamp or release identifier):
```sh
./scripts/vector-backup.sh \
--host 127.0.0.1 --port 5432 --database thoth --user thoth_backup \
--password-file /secure/thoth/vector-backup-password \
--output /secure/backups/thoth-vectors-2026-07-12.dump
--project-name thothii \
--output /secure/backups/thoth-qdrant-2026-08-08.tar
```
The dump contains the three allowlisted `vectors` tables, their data and ACLs, plus the
`public.tht_vector_migrations` ledger. Login roles and passwords are deliberately not copied:
provision/reconcile the approved role names on the target first, and install the `vector`
extension in its `vectors` schema. The target must otherwise contain no vector tables or ledger.
The script resolves exactly one Docker volume with the labels
`com.docker.compose.project=<project>` and `com.docker.compose.volume=qdrant-data`, stops the
`qdrant` service if it is running, archives that volume's persistent contents, then restores the
prior service state. It never performs global Docker cleanup and refuses to overwrite an existing
archive path.
Restore always names both the currently active source and a target on a physically distinct
PostgreSQL cluster. The script compares PostgreSQL system identity, so host aliases or a different
database in the active cluster cannot bypass the guard. It refuses a non-empty target unless
`--force-nonempty` is explicit, and the clean restore is one transaction:
Restore targets that same exact project-scoped `qdrant-data` volume. Because restore replaces the
persistent Qdrant data in place, it requires an explicit confirmation that exactly repeats the
Compose project name by passing `--confirm-project`:
```sh
./scripts/vector-restore.sh \
--active-host vector-db --active-database thoth --active-user thoth_backup \
--active-password-file /secure/thoth/vector-active-password \
--target-host vector-db-restore --target-database thoth --target-user thoth_restore \
--target-password-file /secure/thoth/vector-restore-password \
--input /secure/backups/thoth-vectors-2026-07-12.dump
--project-name thothii \
--input /secure/backups/thoth-qdrant-2026-08-08.tar \
--confirm-project thothii
```
After restore, run `tht vector migrate --status --json`, adapter health, and a known retrieval
query against the target before changing any deployment endpoint. Never test recovery against the
active `vector_data` volume. `./scripts/local-vector-smoke.sh --backup-restore` performs this drill
with disposable source and target volumes.
The restore script stops `qdrant`, validates the exact labeled target, stages the current volume
contents for rollback, extracts the requested archive into the volume, and then returns the
service to its prior running state. It restores semantic storage only. Before reopening write
traffic, the workspace registry must already be at a reviewed v3 descriptor revision compatible
with the restored collection; then run backend health checks and a known retrieval query. The
helper does not restore descriptors, rename collections, or reconcile an incompatible collection
contract.
## Production trust boundary and secrets
@@ -161,15 +276,10 @@ with the organization's reviewed identity proxy. `AUTH_MODE=upstream` trusts thi
rejects requests without the identity header. Setting `THOTH_PUBLIC_EXPOSURE=true` with any other
auth mode fails during core startup.
Production credentials use the one Compose secret bundle, not `.env`. Put the required keys in
`deploy/secrets/thothii.secrets` and select the production overlay in `.env`:
```dotenv
THT_MODEL_API_KEY=replace-me
THT_DWH_API_KEY=replace-me
THT_VEC_API_KEY=replace-me
THT_VEC_WRITE_API_KEY=replace-me
```
Production credentials use the existing Compose secret-bundle contract, never environment values.
Copy `deploy/secrets/thothii.secrets.example` to a protected host file, include only the required
keys, and set its absolute path as `THT_SECRETS_FILE` in the operator env. Keep Pi's native
provider auth in the separate protected file named by `PI_AUTH_FILE`.
The bundle is mounted read-only as `/run/secrets/thothii.secrets` and must be mode `0600` or
`0400` on the host. Docker's runtime `0444` mode is accepted only beneath `/run/secrets`; see
@@ -201,9 +311,9 @@ still scrubbed. Supporting them requires a future dedicated provider-specific co
The server profile stores sessions and per-user preferences directly in PostgreSQL schema
`thoth_sessions`; it does not use PostgREST, browser storage, a shared session directory, or a
dual write. Start from [`deploy/compose.session-server.yaml.example`](deploy/compose.session-server.yaml.example)
and copy [`deploy/workspaces/server-sessions.yaml.example`](deploy/workspaces/server-sessions.yaml.example)
to the untracked `deploy/workspaces/server-sessions.yaml` mounted into the core container.
dual write. Use [`deploy/compose.session-server.yaml.example`](deploy/compose.session-server.yaml.example)
with the canonical base+server files and set `THT_SERVER_WORKSPACE_CONFIG` to an absolute,
protected copy of [`deploy/workspaces/server-sessions.yaml.example`](deploy/workspaces/server-sessions.yaml.example).
The runtime login needs membership in the no-login database role `thoth_sessions_runtime` only.
The distinct, one-shot migrator login needs migration authority and uses
@@ -233,13 +343,14 @@ session store without upstream authentication, direct DB host/name/runtime user/
The migrator independently rejects every other TLS mode before reading its password secret or
constructing a database URL.
Perform the cutover in one maintenance window, with the Task 4 portal proxy headers and Task 5
backend principal parser deployed together. Neither change is safe to deploy independently: Task
4 clears the legacy identity header and Task 5 rejects it. Drain/stop active Pi work, enable a
maintenance response at the portal, then run the migrator once and inspect its pristine JSON:
Perform the cutover in one maintenance window, with the upstream identity-proxy headers and
backend principal parser deployed together. Neither change is safe to deploy independently: the
proxy clears the legacy identity header and the backend rejects it. Drain/stop active Pi work,
enable a maintenance response at the proxy, then run the migrator once and inspect its pristine JSON:
```sh
docker compose -f compose.yaml -f deploy/compose.session-server.yaml \
docker compose --env-file deploy/env/server.env \
-f compose.yaml -f deploy/compose.server.yaml -f deploy/compose.session-server.yaml.example \
--profile session-migrate run --rm session-migrate
```
+330 -17
View File
@@ -6,11 +6,18 @@
"": {
"name": "thothii-backend",
"dependencies": {
"@fastify/cookie": "11.1.2",
"@fastify/cors": "^11.2.0",
"fastify": "^5.0.0"
"@fastify/rate-limit": "11.2.0",
"@types/pg": "^8.20.3",
"fastify": "^5.0.0",
"openid-client": "6.8.5",
"pg": "^8.22.0",
"yaml": "^2.9.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/node": "24.13.3",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
@@ -479,6 +486,55 @@
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/cookie": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz",
"integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^2.0.0",
"fastify-plugin": "^6.0.0"
}
},
"node_modules/@fastify/cookie/node_modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@fastify/cookie/node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/cors": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
@@ -589,6 +645,44 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/rate-limit": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.0.tgz",
"integrity": "sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^6.0.0",
"ip-address": "^10.2.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/rate-limit/node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -596,6 +690,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -960,13 +1063,23 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.20.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
"dev": true,
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~7.18.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.3",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.3.tgz",
"integrity": "sha512-4Tvg+HO6+oQaAkpT8GTYoSExzpGGZz532GXgbbCElWJQeQdMozBWxEKNBhJJpHFjWXsMxqPbyypvj/89FWNoSQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@vitest/expect": {
@@ -1362,9 +1475,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -1436,9 +1549,9 @@
}
},
"node_modules/find-my-way": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -1464,6 +1577,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/ip-address": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
@@ -1473,6 +1595,15 @@
"node": ">= 10"
}
},
"node_modules/jose": {
"version": "6.2.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz",
"integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
@@ -1578,6 +1709,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.7",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz",
"integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -1587,6 +1727,19 @@
"node": ">=14.0.0"
}
},
"node_modules/openid-client": {
"version": "6.8.5",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.5.tgz",
"integrity": "sha512-jNGC/5wnTYwCcEUe2ss0IRUmVRQcgxM0A1nLb3eX/9llqNbMWOQd2xd+qDAgfVCpA5Qh96Y1cdnkfbva6+bSdA==",
"license": "MIT",
"dependencies": {
"jose": "^6.2.8",
"oauth4webapi": "^3.8.7"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@@ -1604,6 +1757,95 @@
"node": ">= 14.16"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1677,6 +1919,45 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@@ -2006,10 +2287,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
"node_modules/vite": {
@@ -2607,6 +2887,39 @@
"engines": {
"node": ">=8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+12 -3
View File
@@ -4,16 +4,25 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"prebuild": "node scripts/clean-dist.mjs",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"start": "node dist/server.js"
"start": "node dist/server.js",
"test:schema-v3-verifier": "python3 -I -B scripts/test_revision_state_policy.py && node --test scripts/verify-workspace-descriptor-files.test.mjs scripts/revision-state-policy.test.mjs"
},
"dependencies": {
"@fastify/cookie": "11.1.2",
"@fastify/cors": "^11.2.0",
"fastify": "^5.0.0"
"@fastify/rate-limit": "11.2.0",
"@types/pg": "^8.20.3",
"fastify": "^5.0.0",
"openid-client": "6.8.5",
"pg": "^8.22.0",
"yaml": "^2.9.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/node": "24.13.3",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
+157
View File
@@ -0,0 +1,157 @@
/** Shared Bash heredoc word parser for descriptor extraction and policy masking. */
function physicalLines(source) {
const rawLines = source.match(/[^\n]*\n|[^\n]+$/gu) ?? [];
if (rawLines.length === 0) rawLines.push("");
let offset = 0;
return rawLines.map((raw) => {
const record = { raw, text: raw.replace(/\n$/u, "").replace(/\r$/u, ""), start: offset };
offset += raw.length;
return record;
});
}
function heredocOperator(line) {
let quote = null;
let arithmeticDepth = 0;
for (let index = 0; index < line.length - 1; index += 1) {
const character = line[index];
if (quote !== null) {
if (character === quote) quote = null;
else if (quote === '"' && character === "\\") index += 1;
continue;
}
if (character === "'" || character === '"') { quote = character; continue; }
if (character === "\\") { index += 1; continue; }
if (character === "#" && (index === 0 || /[ \t;|&()]/u.test(line[index - 1]))) break;
if (character === "(" && line[index + 1] === "(") { arithmeticDepth += 1; index += 1; continue; }
if (character === ")" && line[index + 1] === ")" && arithmeticDepth > 0) { arithmeticDepth -= 1; index += 1; continue; }
if (arithmeticDepth > 0 || character !== "<" || line[index + 1] !== "<") continue;
if (line[index - 1] === "<" || line[index + 2] === "<") { index += 1; continue; }
return index;
}
return -1;
}
function endsWithBashContinuation(line) {
let quote = null;
for (let index = 0; index < line.length; index += 1) {
const character = line[index];
if (quote === null && character === "`") { index += 1; continue; }
if (quote === "'") { if (character === "'") quote = null; continue; }
if (character === '"') { if (quote === '"') quote = null; else if (quote === null) quote = '"'; continue; }
if (character !== "\\") continue;
if (index === line.length - 1) return true;
if (quote === null || (quote === '"' && '$`"\\'.includes(line[index + 1]))) index += 1;
}
return false;
}
function bashLogicalLine(lines, start) {
let line = lines[start];
let end = start;
while (endsWithBashContinuation(line)) {
if (end + 1 >= lines.length) break;
line = `${line.slice(0, -1)}${lines[end + 1]}`;
end += 1;
}
return { line, end };
}
function bashHeredocOpener(line, operator, label, lineNumber) {
let cursor = operator + 2;
let stripTabs = false;
if (line[cursor] === "-") { stripTabs = true; cursor += 1; }
while (line[cursor] === " " || line[cursor] === "\t") cursor += 1;
const unsupported = () => { throw new Error(`${label}:${lineNumber}: unsupported Bash heredoc opener`); };
if (cursor >= line.length || line[cursor] === "#") unsupported();
let delimiter = "";
let quotedDelimiter = false;
while (cursor < line.length) {
const character = line[cursor];
if (character === " " || character === "\t" || ";|&<>".includes(character)) break;
if (character === "'" || character === '"') {
quotedDelimiter = true;
const quote = character;
cursor += 1;
let closed = false;
while (cursor < line.length) {
const quoted = line[cursor];
if (quoted === quote) { closed = true; cursor += 1; break; }
if (quote === '"' && quoted === "\\") {
cursor += 1;
if (cursor >= line.length) unsupported();
const escaped = line[cursor];
delimiter += '$`"\\'.includes(escaped) ? escaped : `\\${escaped}`;
cursor += 1;
continue;
}
delimiter += quoted;
cursor += 1;
}
if (!closed) unsupported();
continue;
}
if (character === "\\") {
quotedDelimiter = true;
cursor += 1;
if (cursor >= line.length) unsupported();
delimiter += line[cursor];
cursor += 1;
continue;
}
if (character === "$" || character === "`" || "(){}[]*?".includes(character)) unsupported();
delimiter += character;
cursor += 1;
}
if (delimiter.length === 0) unsupported();
if (heredocOperator(line.slice(cursor)) >= 0) unsupported();
return { delimiter, stripTabs, expandable: !quotedDelimiter };
}
function parsedBashHeredocs(source, label) {
const records = physicalLines(source);
const lines = records.map((record) => record.text);
const extracted = [];
for (let index = 0; index < lines.length; index += 1) {
const logical = bashLogicalLine(lines, index);
const operator = heredocOperator(logical.line);
if (operator < 0) { index = logical.end; continue; }
const opener = index;
const { delimiter, stripTabs, expandable } = bashHeredocOpener(logical.line, operator, label, index + 1);
index = logical.end;
const body = [];
const startLine = index + 2;
const bodyStart = records[index + 1]?.start ?? source.length;
let closed = false;
for (index += 1; index < lines.length; index += 1) {
const candidate = stripTabs ? lines[index].replace(/^\t+/u, "") : lines[index];
if (candidate === delimiter) { closed = true; break; }
body.push(candidate);
}
const bodyEnd = closed ? records[index].start : source.length;
extracted.push({
source: `${body.join("\n")}\n`, label: `${label}:${startLine} Bash heredoc${closed ? "" : " (unclosed)"}`,
expandable, closed, bodyStart, bodyEnd, path: label,
rawBlock: records.slice(opener, Math.min(index + 1, records.length)).map((record) => record.raw).join(""),
});
}
return extracted;
}
function extractBashDocuments(source, label) {
return parsedBashHeredocs(source, label).map(({ bodyStart: _start, bodyEnd: _end, closed: _closed, ...document }) => document);
}
function literalBashHeredocBodyRanges(source, label) {
const ranges = [];
for (const heredoc of parsedBashHeredocs(source, label)) {
if (!heredoc.expandable) {
if (!heredoc.closed) throw new Error(`${label}: revision-state policy found an unclosed literal Bash heredoc`);
ranges.push({ start: heredoc.bodyStart, end: heredoc.bodyEnd });
}
}
return ranges;
}
export { extractBashDocuments, literalBashHeredocBodyRanges };
+13
View File
@@ -0,0 +1,13 @@
import { rm } from "node:fs/promises";
import { basename, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const backendRoot = resolve(scriptDirectory, "..");
const target = resolve(backendRoot, "dist");
if (dirname(target) !== backendRoot || basename(target) !== "dist") {
throw new Error(`Refusing to clean non-dist target: ${target}`);
}
await rm(target, { recursive: true, force: true });
+147
View File
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
access, cp, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import test from "node:test";
const execFileAsync = promisify(execFile);
const backendRoot = fileURLToPath(new URL("../", import.meta.url));
const ownedRoots = [];
function packageBuildInvocation(platform = process.platform, environment = process.env) {
if (platform === "win32") {
const comspec = environment.ComSpec ?? environment.COMSPEC;
if (!comspec) throw new Error("ComSpec is required to run npm on Windows.");
return { executable: comspec, args: ["/d", "/s", "/c", "npm.cmd run build"] };
}
return { executable: "npm", args: ["run", "build"] };
}
async function isMissing(path) {
try {
await access(path);
return false;
} catch (error) {
if (error?.code === "ENOENT") return true;
throw error;
}
}
async function createOwnedRoot(prefix) {
const root = await mkdtemp(join(tmpdir(), prefix));
ownedRoots.push(root);
return root;
}
async function copyCleaner(fixtureRoot) {
await mkdir(join(fixtureRoot, "scripts"), { recursive: true });
const cleaner = join(fixtureRoot, "scripts", "clean-dist.mjs");
await cp(join(backendRoot, "scripts", "clean-dist.mjs"), cleaner);
return cleaner;
}
async function createBackendFixture() {
const fixtureRoot = await createOwnedRoot("thoth-backend-clean-dist-");
await Promise.all([
cp(join(backendRoot, "package.json"), join(fixtureRoot, "package.json")),
cp(join(backendRoot, "tsconfig.json"), join(fixtureRoot, "tsconfig.json")),
cp(join(backendRoot, "src"), join(fixtureRoot, "src"), { recursive: true }),
copyCleaner(fixtureRoot),
]);
const dependencyRoot = join(backendRoot, "node_modules");
const dependencyEntry = await lstat(dependencyRoot);
if (!dependencyEntry.isDirectory() || dependencyEntry.isSymbolicLink()) {
throw new Error("Backend node_modules must be a real directory.");
}
await symlink(
dependencyRoot,
join(fixtureRoot, "node_modules"),
process.platform === "win32" ? "junction" : "dir",
);
return fixtureRoot;
}
async function removeOwnedRoot(root) {
for (const childName of ["node_modules", "dist"]) {
const child = join(root, childName);
try {
const entry = await lstat(child);
if (entry.isSymbolicLink()) {
await rm(child, { recursive: true, force: true });
} else if (childName === "node_modules") {
throw new Error(`Refusing to clean fixture with a non-link node_modules: ${root}`);
}
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
await rm(root, { recursive: true, force: true });
}
test.afterEach(async () => {
for (const root of ownedRoots.splice(0)) await removeOwnedRoot(root);
});
test("Windows package builds use ComSpec instead of executing npm.cmd directly", () => {
assert.deepEqual(
packageBuildInvocation("win32", { ComSpec: "C:\\Windows\\System32\\cmd.exe" }),
{
executable: "C:\\Windows\\System32\\cmd.exe",
args: ["/d", "/s", "/c", "npm.cmd run build"],
},
);
assert.throws(() => packageBuildInvocation("win32", {}), /ComSpec is required/);
});
test("cleaner is idempotent and removes a dist link without following it", async () => {
const fixtureRoot = await createOwnedRoot("thoth-backend-cleaner-");
const cleaner = await copyCleaner(fixtureRoot);
const fixtureDist = join(fixtureRoot, "dist");
await execFileAsync(process.execPath, [cleaner], { cwd: fixtureRoot });
assert.equal(await isMissing(fixtureDist), true);
await execFileAsync(process.execPath, [cleaner], { cwd: fixtureRoot });
assert.equal(await isMissing(fixtureDist), true);
const outsideRoot = await createOwnedRoot("thoth-backend-cleaner-outside-");
const outsideSentinel = join(outsideRoot, "sentinel.txt");
await writeFile(outsideSentinel, "outside-owned-data\n", "utf8");
await symlink(outsideRoot, fixtureDist, process.platform === "win32" ? "junction" : "dir");
await execFileAsync(process.execPath, [cleaner], { cwd: fixtureRoot });
assert.equal(await isMissing(fixtureDist), true, "dist link survived cleaner");
assert.equal(await readFile(outsideSentinel, "utf8"), "outside-owned-data\n");
});
test("package build replaces the complete backend distribution in an owned fixture", async () => {
const fixtureRoot = await createBackendFixture();
const copiedPackage = JSON.parse(await readFile(join(fixtureRoot, "package.json"), "utf8"));
assert.equal(copiedPackage.scripts.prebuild, "node scripts/clean-dist.mjs");
const workspacesDist = join(fixtureRoot, "dist", "workspaces");
const staleModules = [
"stale-build-sentinel.js",
"migrate-legacy.js",
"migrate-v2-qdrant.js",
].map((name) => join(workspacesDist, name));
await mkdir(workspacesDist, { recursive: true });
await Promise.all(staleModules.map((path) => writeFile(path, "export const stale = true;\n", "utf8")));
const { executable, args } = packageBuildInvocation();
await execFileAsync(executable, args, { cwd: fixtureRoot });
for (const path of staleModules) {
assert.equal(await isMissing(path), true, `stale module survived the package build: ${path}`);
}
assert.equal(
await isMissing(join(fixtureRoot, "dist", "server.js")),
false,
"server output was not compiled",
);
});
+2243
View File
File diff suppressed because it is too large Load Diff
+958
View File
@@ -0,0 +1,958 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
chmod, cp, lstat, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { createServer, connect } from "node:net";
import dgram from "node:dgram";
import { Worker } from "node:worker_threads";
import test from "node:test";
import {
canonicalIntegrationBase,
CHECK_IDS,
buildSafeEnvironment,
collectRepositoryProvenance,
installExternalFetchGuard,
installNetworkGuard,
installProductionSurfaceGuard,
resolveProductionExecutables,
negativeRequestEvidence,
cleanupOwnedRun,
createOwnedRun,
deriveOverall,
executeChecks,
exportArchiveEvidencePath,
readAndValidateOwnership,
runCommand,
runIntegration,
scalarSecretBytes,
scanSecrets,
validateReport,
validateRunRoot,
} from "./p1-acceptance.mjs";
const execFileAsync = promisify(execFile);
const roots = [];
async function fakeRepository() {
const root = await mkdtemp(join(tmpdir(), "p1 acceptance repository with spaces-"));
roots.push(root);
await mkdir(join(root, ".artifacts", "p1-integration"), { recursive: true });
return await realpath(root);
}
test.afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("run roots are only canonical direct integration children", async () => {
const repositoryRoot = await fakeRepository();
const base = canonicalIntegrationBase(repositoryRoot);
const id = `p1-${"a".repeat(32)}`;
assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id));
for (const candidate of [
base,
join(repositoryRoot, ".artifacts", "manual-acceptance", id),
join(base, id, "nested"),
join(base, "foreign"),
join(dirname(base), id),
]) assert.throws(() => validateRunRoot(repositoryRoot, candidate, id));
assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p1-${"A".repeat(32)}`), `p1-${"A".repeat(32)}`));
});
test("cleanup refuses every unowned or ambiguous root", async () => {
const repositoryRoot = await fakeRepository();
const base = canonicalIntegrationBase(repositoryRoot);
const cases = [
["missing ownership", async (run) => rm(join(run.root, "ownership.json"))],
["malformed ownership", async (run) => writeFile(join(run.root, "ownership.json"), "{")],
["mismatched root", async (run) => {
const value = JSON.parse(await readFile(join(run.root, "ownership.json"), "utf8"));
value.root = join(base, `p1-${"b".repeat(32)}`);
await writeFile(join(run.root, "ownership.json"), JSON.stringify(value));
}],
["mismatched pid", async (run) => {
const value = JSON.parse(await readFile(join(run.root, "ownership.json"), "utf8"));
value.pid += 1;
await writeFile(join(run.root, "ownership.json"), JSON.stringify(value));
}],
["wrong resource list", async (run) => {
const value = JSON.parse(await readFile(join(run.root, "ownership.json"), "utf8"));
value.resources.push(join(repositoryRoot, "foreign"));
await writeFile(join(run.root, "ownership.json"), JSON.stringify(value));
}],
];
for (const [, mutate] of cases) {
const run = await createOwnedRun({ repositoryRoot });
await mutate(run);
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce }));
assert.equal((await lstat(run.root)).isDirectory(), true);
}
const wrongNonce = await createOwnedRun({ repositoryRoot });
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: wrongNonce.root, expectedNonce: "0".repeat(64) }));
const symlinkRun = await createOwnedRun({ repositoryRoot });
const target = `${symlinkRun.root}-target`;
await rm(symlinkRun.root, { recursive: true });
await mkdir(target);
await symlink(target, symlinkRun.root);
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: symlinkRun.root, expectedNonce: symlinkRun.nonce }));
for (const bad of [base, join(repositoryRoot, ".artifacts", "manual-acceptance"), join(base, "foreign")]) {
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: bad, expectedNonce: "0".repeat(64) }));
}
});
test("cleanup atomically removes one owned root and preserves siblings", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const sibling = join(canonicalIntegrationBase(repositoryRoot), `p1-${"c".repeat(32)}`);
await mkdir(sibling);
await writeFile(join(sibling, "sentinel"), "foreign");
await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
await cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
await assert.rejects(lstat(run.root));
assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign");
});
function resultFor(id) {
return {
id, status: "PASS", startedAt: "2026-08-09T00:00:00.000Z",
finishedAt: "2026-08-09T00:00:01.000Z", commands: ["git"],
artifacts: [{ path: `logs/${id}.json`, sha256: "a".repeat(64) }],
};
}
function validReport(checks = CHECK_IDS.map(resultFor)) {
return {
schemaVersion: 1, runId: `p1-${"d".repeat(32)}`, startedAt: "2026-08-09T00:00:00.000Z",
finishedAt: "2026-08-09T00:00:02.000Z", command: "p1-acceptance integration --keep",
overall: deriveOverall(checks), checks,
};
}
test("report validation enforces uniqueness, derivation, safe evidence, hashes, times, and commands", () => {
assert.doesNotThrow(() => validateReport(validReport()));
const mutations = [
(r) => r.checks.push(structuredClone(r.checks[0])),
(r) => { r.checks[0].attempt = 1; },
(r) => { r.checks[0].artifacts[0].path = "../secret"; },
(r) => { r.checks[0].artifacts[0].sha256 = "bad"; },
(r) => { r.checks[0].startedAt = "today"; },
(r) => { r.checks[0].commands = ["git status"]; },
(r) => { r.overall = "PASS"; r.checks[0].status = "FAIL"; },
(r) => { r.nested = { retries: 2 }; },
];
for (const mutate of mutations) {
const report = validReport(); mutate(report); assert.throws(() => validateReport(report));
}
});
function exactScenarios(run = async () => ({ commands: [], artifacts: [] })) {
return CHECK_IDS.map((id) => ({ id, run: () => run(id) }));
}
test("injected failure executes once, retains a complete ordered diagnostic report, and returns nonzero", async () => {
const repositoryRoot = await fakeRepository();
const calls = [];
const failAt = CHECK_IDS[3];
const result = await runIntegration({
repositoryRoot, keep: false, failAt,
checks: exactScenarios(async (id) => { calls.push(id); return { commands: [], artifacts: [] }; }),
});
assert.equal(result.exitCode, 1);
assert.deepEqual(calls, CHECK_IDS.slice(0, 4));
assert.equal((await lstat(result.runRoot)).isDirectory(), true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.deepEqual(report.checks.map(({ id }) => id), CHECK_IDS);
assert.equal(report.checks.filter((check) => check.status === "FAIL").length, CHECK_IDS.length - 3);
assert.equal(report.checks[3].error, "Acceptance scenario failed safely.");
assert.equal(report.checks[4].error, "Not executed after earlier failure.");
});
test("failed scenario retains partial request and response evidence with observed commands", async () => {
const partial = {
commands: ["git"],
artifacts: [
{ path: "requests/partial.json", sha256: "a".repeat(64) },
{ path: "responses/partial.json", sha256: "b".repeat(64) },
],
};
const checks = exactScenarios(async (id) => {
if (id === CHECK_IDS[4]) {
const error = new Error("HTTP scenario failed after response persistence");
error.acceptancePartial = partial;
throw error;
}
return {};
});
const results = await executeChecks({ checks });
assert.deepEqual(results[4].commands, partial.commands);
assert.deepEqual(results[4].artifacts, partial.artifacts);
assert.equal(results[4].error, "Acceptance scenario failed safely.");
});
test("executeChecks never repeats or executes after first failure but emits the exact check set", async () => {
const calls = new Map();
const result = await executeChecks({
checks: exactScenarios(async (id) => { calls.set(id, (calls.get(id) ?? 0) + 1); return {}; }),
failAt: CHECK_IDS[1],
});
assert.deepEqual(result.map(({ id }) => id), CHECK_IDS);
assert.deepEqual(Object.fromEntries(calls), Object.fromEntries(CHECK_IDS.slice(0, 2).map((id) => [id, 1])));
assert.equal(result[1].status, "FAIL");
assert(result.slice(2).every(({ status, error }) => status === "FAIL" && error === "Not executed after earlier failure."));
assert.throws(() => validateReport(validReport(CHECK_IDS.slice(0, -1).map(resultFor))));
await assert.rejects(executeChecks({ checks: exactScenarios().reverse() }));
});
test("owned setup failure still writes one safe result for every exact check", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({
repositoryRoot, keep: false,
setup: async () => { throw new Error("fixture setup raw failure"); },
});
assert.equal(result.exitCode, 1);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.deepEqual(report.checks.map(({ id }) => id), CHECK_IDS);
assert.equal(report.checks[0].error, "Acceptance setup failed safely.");
assert(report.checks.slice(1).every(({ error }) => error === "Not executed after earlier failure."));
});
test("scalar fixture secret files contain no harness-invalid whitespace", () => {
const bytes = scalarSecretBytes("CANARY-secret-value-123456");
assert.equal(bytes.toString("utf8"), "CANARY-secret-value-123456");
assert.equal([...bytes].some((byte) => /\s/.test(String.fromCharCode(byte))), false);
assert.throws(() => scalarSecretBytes("bad secret"));
});
test("secret scanner excludes only the direct fixture-secrets subtree", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const canary = "CANARY-secret-value-123456";
await mkdir(join(run.root, "fixture-secrets"));
await writeFile(join(run.root, "fixture-secrets", "allowed"), canary);
const paths = [
"logs/a.log", "responses/a.json", "rendered/a.yaml", "exports/raw/a.zip",
"exports/extracted/a.md", "requests/a.json", "report-preview.md", "nested/fixture-secrets/not-excluded",
];
for (const path of paths) {
await mkdir(dirname(join(run.root, path)), { recursive: true });
await writeFile(join(run.root, path), `prefix ${canary} suffix`);
}
const findings = await scanSecrets({ runRoot: run.root, forbiddenValues: [canary], expectedGitRepositories: [] });
assert.deepEqual(new Set(findings.map((finding) => finding.path)), new Set(paths));
});
test("secret scanner examines reachable Git blobs, not just loose file bytes", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const canary = "GIT-CANARY-secret-value-987654";
const gitRoot = join(run.root, "author");
await mkdir(gitRoot);
await execFileAsync("git", ["init", "--initial-branch=main"], { cwd: gitRoot });
await execFileAsync("git", ["config", "user.name", "Scanner Test"], { cwd: gitRoot });
await execFileAsync("git", ["config", "user.email", "scanner@example.invalid"], { cwd: gitRoot });
await writeFile(join(gitRoot, "secret.txt"), canary);
await execFileAsync("git", ["add", "secret.txt"], { cwd: gitRoot });
await execFileAsync("git", ["commit", "-m", "secret blob"], { cwd: gitRoot });
await execFileAsync("git", ["rm", "secret.txt"], { cwd: gitRoot });
await execFileAsync("git", ["commit", "-m", "remove worktree copy"], { cwd: gitRoot });
const findings = await scanSecrets({ runRoot: run.root, forbiddenValues: [canary], expectedGitRepositories: ["author"] });
assert.equal(findings.some((finding) => finding.path.startsWith("git-object:")), true);
});
test("successful lifecycle honors keep and cleanup", async () => {
const repositoryRoot = await fakeRepository();
const checks = exactScenarios();
const kept = await runIntegration({ repositoryRoot, keep: true, checks });
assert.equal(kept.exitCode, 0);
assert.equal((await lstat(kept.runRoot)).isDirectory(), true);
const cleaned = await runIntegration({ repositoryRoot, keep: false, checks });
assert.equal(cleaned.exitCode, 0);
await assert.rejects(lstat(cleaned.runRoot));
});
test("command helper accepts only executable plus separate argv", async () => {
await assert.rejects(runCommand("git status"));
await assert.rejects(runCommand({ executable: "/bin/echo", argv: "hello" }));
await assert.rejects(runCommand({ executable: "/bin/echo", argv: [], shell: true }));
await assert.rejects(runCommand({ executable: "git status; rm -rf /", argv: [] }));
await assert.rejects(runCommand({ executable: "/tmp/git", argv: ["--version"] }), /command executable is not allowlisted/);
await assert.rejects(runCommand({ executable: "tht", argv: ["config", "check"] }), /command executable is invalid/);
const scratchRoot = await fakeRepository();
const executable = join(scratchRoot, "executable with spaces");
await writeFile(executable, "#!/bin/sh\nprintf '%s' \"$1\"\n", { mode: 0o700 });
await chmod(executable, 0o700);
await assert.rejects(runCommand({ executable, argv: ["literal;not-a-shell"] }), /command executable is not allowlisted/);
const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
const { gitPath } = await resolveProductionExecutables({ repositoryRoot });
const result = await runCommand({ executable: gitPath, argv: ["--version"] });
assert.match(result.stdout, /^git version /);
assert.equal(result.code, 0);
});
test("raw runCommand rejects a configured clean filter before exact Git add", async () => {
const repositoryRoot = await fakeRepository();
const content = join(repositoryRoot, "workspace-content");
const helper = join(repositoryRoot, "clean-helper");
const marker = join(repositoryRoot, "clean-helper-ran");
await execFileAsync("/usr/bin/git", ["init", "--initial-branch=main"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["config", "user.name", "P1 Fixture Curator"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["config", "user.email", "p1-curator@example.invalid"], { cwd: repositoryRoot });
await mkdir(content);
await writeFile(join(content, "guide.md"), "content\n");
await writeFile(join(repositoryRoot, ".gitattributes"), "workspace-content/** filter=bad\n");
await writeFile(helper, `#!/bin/sh\nprintf ran > '${marker}'\ncat\n`, { mode: 0o700 });
await execFileAsync("/usr/bin/git", ["config", "filter.bad.clean", `'${helper}'`], { cwd: repositoryRoot });
const { gitPath } = await resolveProductionExecutables({
repositoryRoot: await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")),
});
await assert.rejects(
runCommand({ executable: gitPath, argv: ["add", "workspace-content"], cwd: repositoryRoot, env: process.env }),
/unsafe Git repository state/,
);
await assert.rejects(lstat(marker));
});
test("raw runCommand rejects a diff driver textconv before exact Git show", async () => {
const repositoryRoot = await fakeRepository();
const marker = join(repositoryRoot, "textconv-helper-ran");
const helper = join(repositoryRoot, "textconv-helper");
await execFileAsync("/usr/bin/git", ["init", "--initial-branch=main"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["config", "user.name", "P1 Fixture Curator"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["config", "user.email", "p1-curator@example.invalid"], { cwd: repositoryRoot });
await writeFile(join(repositoryRoot, ".gitattributes"), "file diff=evil\n");
await execFileAsync("/usr/bin/git", ["add", ".gitattributes"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["commit", "-m", "attributes"], { cwd: repositoryRoot });
await writeFile(join(repositoryRoot, "file"), "v1\n");
await execFileAsync("/usr/bin/git", ["add", "file"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["commit", "-m", "v1"], { cwd: repositoryRoot });
await writeFile(join(repositoryRoot, "file"), "v2\n");
await execFileAsync("/usr/bin/git", ["add", "file"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["commit", "-m", "v2"], { cwd: repositoryRoot });
await writeFile(helper, `#!/bin/sh\nprintf ran > '${marker}'\nexit 0\n`, { mode: 0o700 });
await execFileAsync("/usr/bin/git", ["config", "diff.evil.textconv", `'${helper}'`], { cwd: repositoryRoot });
const emptyHooks = join(repositoryRoot, "registry", "locks", "empty-hooks");
await mkdir(emptyHooks, { recursive: true });
const { gitPath } = await resolveProductionExecutables({
repositoryRoot: await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")),
});
await assert.rejects(
runCommand({ executable: gitPath, argv: ["-c", `core.hooksPath=${emptyHooks}`, "show", "HEAD"], cwd: repositoryRoot, env: process.env }),
/unsafe Git repository state/,
);
await assert.rejects(lstat(marker));
});
test("safe environment rejects ambient THT and keeps only strict process allowlist plus fixture values", () => {
const safe = buildSafeEnvironment({
ambient: { PATH: "/safe/bin", HOME: "/home/test", LANG: "C", THT_SECRETS_FILE: "/real/secrets", AWS_SECRET_ACCESS_KEY: "real" },
fixture: { THT_BIN: "/fixture/tht", THT_WORKSPACE_SECRET_ROOTS: "/fixture/secrets" },
});
assert.deepEqual(safe, {
LANG: "C", THT_BIN: "/fixture/tht", THT_WORKSPACE_SECRET_ROOTS: "/fixture/secrets",
});
});
test("secret scan fails closed when Git enumeration fails", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
await mkdir(join(run.root, "remote.git"));
await assert.rejects(scanSecrets({ runRoot: run.root, forbiddenValues: ["CANARY-value-123"] }), /Git secret scan failed closed/);
});
test("negative request evidence persists only case label and expected input field", () => {
const value = negativeRequestEvidence("credential-field", "evidence.source.password");
assert.deepEqual(value, { case: "credential-field", expectedInputField: "evidence.source.password" });
assert.equal(JSON.stringify(value).includes("body"), false);
});
test("external fetch guard permits only the owned loopback API and records external attempts", async () => {
const called = [];
const guard = installExternalFetchGuard("http://127.0.0.1:12345", async (url) => { called.push(String(url)); return { ok: true }; });
await guard.fetch("http://127.0.0.1:12345/workspaces");
await assert.rejects(guard.fetch("https://evidence.example.test/guide.md"), /external fetch prohibited/);
await assert.rejects(guard.fetch("http://127.0.0.1:9999/health"), /external fetch prohibited/);
assert.deepEqual(called, ["http://127.0.0.1:12345/workspaces"]);
assert.equal(guard.externalAttempts.length, 2);
});
test("export archive evidence path matches the persisted binary request id", () => {
assert.equal(exportArchiveEvidencePath("export-p1-filesystem"), "exports/raw/export-p1-filesystem.zip");
});
test("announce callback observes PASS and manual pending before non-keep cleanup", async () => {
const repositoryRoot = await fakeRepository();
let observed;
const result = await runIntegration({
repositoryRoot, keep: false, checks: exactScenarios(),
announce: async ({ report, runRoot }) => {
observed = { overall: report.overall, manual: "PENDING", rootExists: (await lstat(runRoot)).isDirectory() };
},
});
assert.deepEqual(observed, { overall: "PASS", manual: "PENDING", rootExists: true });
assert.equal(result.retained, false);
});
test("public wrapper replaces ambient environment before invoking the runner", async () => {
const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p1-acceptance.sh"), "utf8");
assert.match(wrapper, /safe_env=\(\/usr\/bin\/env -i/);
assert.doesNotMatch(wrapper, /P1_ACCEPTANCE_FAIL_AT|LANG|LC_ALL|TZ/);
assert.doesNotMatch(wrapper, /export THT_BIN/);
});
test("network guard is installed globally, rejects non-loopback sockets, and permits one owned listener", async () => {
const server = createServer((socket) => socket.end("ok"));
await new Promise((resolvePromise, reject) => server.listen(0, "127.0.0.1", (error) => error ? reject(error) : resolvePromise()));
const address = server.address();
assert(address && typeof address === "object");
const guard = installNetworkGuard();
try {
guard.addOwnedOrigin(`http://127.0.0.1:${address.port}`);
const contents = await new Promise((resolvePromise, reject) => {
const socket = connect({ host: "127.0.0.1", port: address.port });
let value = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => { value += chunk; });
socket.on("end", () => resolvePromise(value));
socket.on("error", reject);
});
assert.equal(contents, "ok");
assert.throws(() => connect({ host: "example.com", port: 80 }), /external network connection prohibited/);
await assert.rejects(globalThis.fetch("https://example.com/"), /external network connection prohibited/);
assert.equal(guard.externalAttempts.length, 2);
} finally {
guard.restore();
await new Promise((resolvePromise) => server.close(resolvePromise));
}
});
test("report validation rejects duplicate artifact paths across checks", () => {
const report = validReport();
report.checks[1].artifacts[0].path = report.checks[0].artifacts[0].path;
assert.throws(() => validateReport(report), /report artifact path is duplicated/);
});
test("virtual report leakage yields a minimal sanitized exact-15 FAIL report", async () => {
const repositoryRoot = await fakeRepository();
const canary = "VIRTUAL-CANARY-12345678";
const checks = exactScenarios(async (id) => ({
commands: [],
artifacts: id === CHECK_IDS[0] ? [{ path: `logs/${canary}.json`, sha256: "a".repeat(64) }] : [],
}));
const result = await runIntegration({
repositoryRoot,
checks,
setup: async (_run, _repositoryRoot, _env, ctx) => {
ctx.forbiddenValues = [canary];
return ctx;
},
});
assert.equal(result.exitCode, 1);
const bytes = await readFile(join(result.runRoot, "report.json"));
assert.equal(bytes.includes(Buffer.from(canary)), false);
const report = JSON.parse(bytes);
assert.deepEqual(report.checks.map(({ id }) => id), CHECK_IDS);
assert(report.checks.every(({ status, commands, artifacts }) => status === "FAIL" && commands.length === 0 && artifacts.length === 0));
});
test("partial setup preserves forbidden values and never writes secret-bearing report bytes", async () => {
const repositoryRoot = await fakeRepository();
const canary = "PARTIAL-SETUP-CANARY-12345678";
const result = await runIntegration({
repositoryRoot,
setup: async (run, _repositoryRoot, _env, ctx) => {
ctx.forbiddenValues = [canary];
await mkdir(join(run.root, "logs"), { recursive: true });
await writeFile(join(run.root, "logs", "partial-setup.log"), canary);
throw new Error(`unsafe ${canary}`);
},
});
assert.equal(result.exitCode, 1);
const bytes = await readFile(join(result.runRoot, "report.json"));
assert.equal(bytes.includes(Buffer.from(canary)), false);
const report = JSON.parse(bytes);
assert.equal(report.checks.length, 15);
assert(report.checks.every(({ status }) => status === "FAIL"));
});
test("secret scan fails closed when either expected Git repository is missing", async () => {
for (const missing of ["remote.git", "author"]) {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const present = missing === "remote.git" ? "author" : "remote.git";
await mkdir(join(run.root, present));
await execFileAsync("git", present === "remote.git" ? ["init", "--bare", join(run.root, present)] : ["init", join(run.root, present)]);
await assert.rejects(scanSecrets({ runRoot: run.root, forbiddenValues: ["CANARY-value-123"] }), new RegExp(`missing expected Git repository: ${missing.replace(".", "\\.")}`));
}
});
test("runIntegration fails closed when a later duplicate overwrites stale artifact evidence", async () => {
const repositoryRoot = await fakeRepository();
const checks = exactScenarios(async (id) => {
if (id === CHECK_IDS[0]) {
await mkdir(join(repositoryRoot, ".artifacts", "p1-integration", "scratch"), { recursive: true });
}
return { commands: [], artifacts: [] };
});
const result = await runIntegration({
repositoryRoot, keep: true,
setup: async (run, _repositoryRoot, _env, ctx) => {
const path = join(run.root, "logs", "overwritten.json");
await mkdir(dirname(path), { recursive: true });
await writeFile(path, "first");
const stale = { path: "logs/overwritten.json", sha256: "a7937b64b8caa58f03721bb6bacf9e92a2c78987f5d1692a065a4698e006c4ca" };
checks[0].run = async () => ({ commands: [], artifacts: [stale] });
checks[1].run = async () => {
await writeFile(path, "second");
return { commands: [], artifacts: [{ path: stale.path, sha256: "16367aacb67a4a017c8da8ab95682ccb389c61bb315f3425e2f2666f2476d1ce" }] };
};
return ctx;
},
checks,
});
assert.equal(result.exitCode, 1);
assert.equal(result.report.checks.length, 15);
assert(result.report.checks.every(({ status, artifacts }) => status === "FAIL" && artifacts.length === 0));
});
test("production surface guard rejects and records UDP, Worker, git ls-remote, and unexpected python", async () => {
const runRoot = await fakeRepository();
const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
const executables = await resolveProductionExecutables({ repositoryRoot });
const guard = installProductionSurfaceGuard({
...executables, runRoot, environment: { ...process.env }, originalFetch: globalThis.fetch,
});
try {
assert.throws(() => dgram.createSocket("udp4"), /prohibited production surface/);
assert.throws(() => new Worker("", { eval: true }), /prohibited production surface/);
await assert.rejects(runCommand({ executable: executables.gitPath, argv: ["ls-remote", "https://example.com/repo.git"] }), /Git command is prohibited/);
const childProcess = await import("node:child_process");
assert.throws(() => childProcess.spawn(executables.pythonPath, ["-c", "print('unexpected')"]), /child command is prohibited/);
assert.deepEqual(new Set(guard.events.filter(({ outcome }) => outcome === "REJECTED").map(({ surface }) => surface)),
new Set(["dgram", "worker_threads", "child_process"]));
} finally {
guard.restore();
}
});
test("listener close rejection retains listening truth and forces exact-15 FAIL", async () => {
const repositoryRoot = await fakeRepository();
const server = createServer();
await new Promise((resolvePromise, reject) => server.listen(0, "127.0.0.1", (error) => error ? reject(error) : resolvePromise()));
const address = server.address();
assert(address && typeof address === "object");
const result = await runIntegration({
repositoryRoot, keep: false, checks: exactScenarios(),
setup: async (run, _repositoryRoot, _env, ctx) => {
ctx.services = [{ name: "primary", baseUrl: `http://127.0.0.1:${address.port}`, app: { close: async () => { throw new Error("close rejected"); } } }];
const value = JSON.parse(await readFile(join(run.root, "ownership.json"), "utf8"));
value.listeners[0] = { name: "primary", kind: "fastify", host: "127.0.0.1", requestedPort: 0, actualPort: address.port, pid: process.pid, state: "listening" };
await writeFile(join(run.root, "ownership.json"), `${JSON.stringify(value, null, 2)}\n`);
return ctx;
},
});
assert.equal(result.exitCode, 1);
assert.equal(result.retained, true);
const owner = JSON.parse(await readFile(join(result.runRoot, "ownership.json"), "utf8"));
assert.notEqual(owner.listeners[0].state, "closed");
assert(result.report.checks.every(({ status }) => status === "FAIL"));
await new Promise((resolvePromise) => server.close(resolvePromise));
});
test("ownership close write failure forces retained exact-15 FAIL", async () => {
const repositoryRoot = await fakeRepository();
const server = createServer();
await new Promise((resolvePromise, reject) => server.listen(0, "127.0.0.1", (error) => error ? reject(error) : resolvePromise()));
const address = server.address();
assert(address && typeof address === "object");
const result = await runIntegration({
repositoryRoot, keep: false, checks: exactScenarios(),
ownershipWriter: async (_run, update) => { if (update?.state === "closed") throw new Error("owned write rejected"); },
setup: async (_run, _repositoryRoot, _env, ctx) => {
ctx.services = [{ name: "primary", baseUrl: `http://127.0.0.1:${address.port}`, app: { close: async () => await new Promise((resolvePromise) => server.close(resolvePromise)) } }];
return ctx;
},
});
assert.equal(result.exitCode, 1);
assert.equal(result.retained, true);
assert(result.report.checks.every(({ status }) => status === "FAIL"));
});
test("nested runIntegration is rejected before process-global mutation and outer restoration remains owned", async () => {
const repositoryRoot = await fakeRepository();
const originalFetch = globalThis.fetch;
const originalPath = process.env.PATH;
let nestedError;
const result = await runIntegration({
repositoryRoot, keep: true, checks: exactScenarios(),
setup: async (_run, _repositoryRoot, _env, ctx) => {
try { await runIntegration({ repositoryRoot, keep: true, checks: exactScenarios() }); } catch (error) { nestedError = error; }
assert.equal(globalThis.fetch, originalFetch);
assert.equal(process.env.PATH, originalPath);
return ctx;
},
});
assert.match(nestedError?.message ?? "", /already active/);
assert.equal(result.exitCode, 0);
assert.equal(globalThis.fetch, originalFetch);
assert.equal(process.env.PATH, originalPath);
});
test("environment tampering fails the audit and restores the caller environment", async () => {
const repositoryRoot = await fakeRepository();
const before = { ...process.env };
const checks = exactScenarios(async (id) => {
if (id === CHECK_IDS[0]) process.env.P1_ACCEPTANCE_UNOWNED = "tampered";
return { commands: [], artifacts: [] };
});
const result = await runIntegration({
repositoryRoot, keep: true, checks,
setup: async (_run, _repositoryRoot, _env, ctx) => { ctx.env = { P1_ACCEPTANCE_OWNED: "yes" }; return ctx; },
});
assert.equal(result.exitCode, 1);
assert(result.report.checks.every(({ status }) => status === "FAIL"));
assert.deepEqual({ ...process.env }, before);
});
test("production guard detects global tampering and restores without stranding patches", async () => {
const runRoot = await fakeRepository();
const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
const executables = await resolveProductionExecutables({ repositoryRoot });
const originalFetch = globalThis.fetch;
const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env }, originalFetch });
globalThis.fetch = originalFetch;
assert.throws(() => guard.restore(), /ownership restoration failed/);
assert.equal(globalThis.fetch, originalFetch);
const childProcess = await import("node:child_process");
assert.doesNotThrow(() => childProcess.spawn);
});
test("Git grammar rejects helper, config, alias, and network-capable spellings with one event each", async () => {
const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
const runRoot = await fakeRepository();
const executables = await resolveProductionExecutables({ repositoryRoot, thtBin: "/tmp/hostile-tht" });
const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } });
const source = join(runRoot, "source.git");
const destination = join(runRoot, "destination");
const marker = join(runRoot, "helper-ran");
await execFileAsync(executables.gitPath, ["init", "--bare", source]);
const helper = join(runRoot, "upload-helper");
await writeFile(helper, `#!/bin/sh\nprintf ran > "${marker}"\nexit 99\n`, { mode: 0o700 });
const prohibited = [
["clone", `--upload-pack=${helper}`, source, destination],
["clone", "--receive-pack=/tmp/helper", source, destination],
["--exec-path=/tmp", "status"],
["-c", "alias.status=!touch /tmp/pwn", "status"],
["-c", "core.hooksPath=/tmp/hooks", "status"],
["-c", "diff.external=/tmp/helper", "status"],
["config", "filter.bad.clean", "/tmp/helper"],
["ls-remote", "https://example.com/repo.git"],
];
try {
for (const argv of prohibited) {
const before = guard.events.length;
await assert.rejects(runCommand({ executable: executables.gitPath, argv }), /Git command is prohibited/);
assert.equal(guard.events.length - before, 1);
assert.equal(guard.events.at(-1).outcome, "REJECTED");
}
await assert.rejects(lstat(marker));
} finally { guard.restore(); }
});
test("production executables ignore ambient THT and bind the generated tht entrypoint to reviewed source", async () => {
const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
const hostile = join(await fakeRepository(), "tht");
await writeFile(hostile, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
const executables = await resolveProductionExecutables({ repositoryRoot, thtBin: hostile });
assert.equal(executables.thtPath, join(repositoryRoot, "harness", ".venv", "bin", "tht"));
assert.equal(executables.thtIdentity.sourceRoot, join(repositoryRoot, "harness", "tht"));
assert.equal(executables.thtIdentity.sourceStatus, "git-index-byte-identical");
assert.equal(executables.thtIdentity.entrypoint, "generated-console-script");
assert.match(executables.thtIdentity.pythonPath, /python3(?:\.\d+)?$/);
});
test("tht accepts only config check for one owned rendered yaml", async () => {
const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
const runRoot = await fakeRepository();
const rendered = join(runRoot, "rendered", "workspace.yaml");
await mkdir(dirname(rendered), { recursive: true });
await writeFile(rendered, "profile: acceptance\n");
const executables = await resolveProductionExecutables({ repositoryRoot });
const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } });
const childProcess = await import("node:child_process");
try {
for (const argv of [
["config", "check"], ["config", "check", "-c", "/tmp/unowned.yaml"],
["doctor"], ["config", "check", "-c", rendered, "--extra"],
]) {
const before = guard.events.length;
assert.throws(() => childProcess.execFile(executables.thtPath, argv), /THT command is prohibited/);
assert.equal(guard.events.length - before, 1);
}
} finally { guard.restore(); }
});
test("production guard installation rolls back every patch and owner on every injected patch failure", async () => {
const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
const runRoot = await fakeRepository();
const executables = await resolveProductionExecutables({ repositoryRoot });
const childProcess = await import("node:child_process");
const originalSpawn = childProcess.spawn;
const originalDgram = dgram.createSocket;
const originalFetch = globalThis.fetch;
for (let failPatchAt = 1; failPatchAt <= 12; failPatchAt += 1) {
assert.throws(() => installProductionSurfaceGuard({
...executables, runRoot, environment: { ...process.env }, failPatchAt,
}), /injected production patch failure/);
assert.equal(childProcess.spawn, originalSpawn);
assert.equal(dgram.createSocket, originalDgram);
assert.equal(globalThis.fetch, originalFetch);
const reacquired = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } });
reacquired.restore();
}
});
test("command bounds reject zero, negative, fractional, and nonnumeric timeouts with one sanitized event", async () => {
const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
const runRoot = await fakeRepository();
const executables = await resolveProductionExecutables({ repositoryRoot });
const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } });
try {
for (const timeoutMs of [0, -1, 1.5, NaN]) {
const before = guard.events.length;
await assert.rejects(runCommand({ executable: executables.gitPath, argv: ["--version"], timeoutMs }), /command bounds are invalid/);
assert.equal(guard.events.length - before, 1);
}
} finally { guard.restore(); }
});
test("public wrapper has no ambient command resolution and isolates the build and runner", async () => {
const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p1-acceptance.sh"), "utf8");
assert.doesNotMatch(wrapper, /command\s+-v/);
assert.doesNotMatch(wrapper, /\b(?:node|npm)\s+--prefix/);
assert.match(wrapper, /env -i/);
assert.match(wrapper, /npm-cli\.js/);
assert.match(wrapper, /"\$node_path" "\$npm_path"/);
assert.match(wrapper, /\/bin\/rm -rf -- "\$repo_root\/backend\/dist"/);
});
test("hostile PATH Node npm and THT substitutes never execute at the public wrapper boundary", async () => {
const hostileRoot = await fakeRepository();
const marker = join(hostileRoot, "ambient-tool-ran");
for (const name of ["node", "npm", "tht"]) {
const path = join(hostileRoot, name);
await writeFile(path, `#!/bin/sh\nprintf '%s' '${name}' >> '${marker}'\nexit 97\n`, { mode: 0o700 });
await chmod(path, 0o700);
}
const wrapper = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p1-acceptance.sh");
await assert.rejects(execFileAsync(wrapper, ["invalid"], {
env: { ...process.env, PATH: hostileRoot, THT_BIN: join(hostileRoot, "tht") }, timeout: 30_000,
}));
await assert.rejects(lstat(marker));
});
async function fakeTrustedThtRepository() {
const repositoryRoot = await fakeRepository();
const realRepository = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
const harness = join(repositoryRoot, "harness");
const sourceRoot = join(harness, "tht");
await mkdir(harness, { recursive: true });
await cp(join(realRepository, "harness", "tht"), sourceRoot, {
recursive: true, filter: (path) => !path.split("/").includes("__pycache__") && !path.endsWith(".pyc"),
});
await cp(join(realRepository, "harness", "pyproject.toml"), join(harness, "pyproject.toml"));
const realExecutables = await resolveProductionExecutables({ repositoryRoot: realRepository });
const pythonName = realExecutables.thtIdentity.pythonPath.split("/").at(-1);
const venvBin = join(harness, ".venv", "bin");
const sitePackages = join(harness, ".venv", "lib", pythonName, "site-packages");
await mkdir(venvBin, { recursive: true });
await mkdir(sitePackages, { recursive: true });
await symlink(realExecutables.thtIdentity.pythonCanonicalPath, join(venvBin, "python"));
await symlink(realExecutables.thtIdentity.pythonCanonicalPath, join(venvBin, pythonName));
const entrypoint = `#!${join(venvBin, pythonName)}\nimport sys\nfrom tht.cli import app\nif __name__ == '__main__':\n if sys.argv[0].endswith('.exe'):\n sys.argv[0] = sys.argv[0][:-4]\n sys.exit(app())\n`;
await writeFile(join(venvBin, "tht"), entrypoint, { mode: 0o700 });
const realSite = join(realRepository, "harness", ".venv", "lib", pythonName, "site-packages");
const realFinderName = (await import("node:fs/promises")).readdir(realSite).then((entries) => entries.find((name) => /^__editable___tht_.*_finder\.py$/.test(name)));
const finderName = await realFinderName;
const realFinder = await readFile(join(realSite, finderName), "utf8");
const finder = realFinder.replaceAll(join(realRepository, "harness", "tht"), sourceRoot);
await writeFile(join(sitePackages, finderName), finder);
const moduleName = finderName.slice(0, -3);
await writeFile(join(sitePackages, "__editable__.tht-0.1.0.pth"), `import ${moduleName}; ${moduleName}.install()`);
await execFileAsync("/usr/bin/git", ["init", "--initial-branch=main"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["config", "user.name", "P1 Test"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["config", "user.email", "p1-test@example.invalid"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["add", "harness/tht", "harness/pyproject.toml"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["commit", "-m", "trusted source"], { cwd: repositoryRoot });
return { repositoryRoot, sourceRoot, sitePackages, finderName };
}
test("Git rejects configured upload-pack, clean filter, and hook state before exact allowed operations", async () => {
const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", ".."));
const runRoot = await fakeRepository();
const remote = join(runRoot, "remote.git");
const author = join(runRoot, "author");
await execFileAsync("/usr/bin/git", ["init", "--bare", "--initial-branch=main", remote]);
await execFileAsync("/usr/bin/git", ["init", "--initial-branch=main", author]);
await execFileAsync("/usr/bin/git", ["config", "user.name", "P1 Fixture Curator"], { cwd: author });
await execFileAsync("/usr/bin/git", ["config", "user.email", "p1-curator@example.invalid"], { cwd: author });
await writeFile(join(author, "seed"), "seed\n");
await execFileAsync("/usr/bin/git", ["add", "seed"], { cwd: author });
await execFileAsync("/usr/bin/git", ["commit", "-m", "seed"], { cwd: author });
await execFileAsync("/usr/bin/git", ["remote", "add", "origin", remote], { cwd: author });
await execFileAsync("/usr/bin/git", ["push", "origin", "main"], { cwd: author });
const executables = await resolveProductionExecutables({ repositoryRoot });
const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } });
try {
for (const [kind, configure, argv] of [
["upload", async (helper) => execFileAsync("/usr/bin/git", ["config", "remote.origin.uploadpack", helper], { cwd: author }), ["fetch", "origin", "main"]],
["filter", async (helper) => {
await mkdir(join(author, "workspace-content"), { recursive: true });
await writeFile(join(author, ".gitattributes"), "workspace-content/** filter=bad\n");
await execFileAsync("/usr/bin/git", ["config", "filter.bad.clean", helper], { cwd: author });
}, ["add", "workspace-content"]],
["hook", async (helper) => { await cp(helper, join(author, ".git", "hooks", "pre-commit")); }, ["commit", "-m", "Bootstrap curated P1 content"]],
]) {
await execFileAsync("/usr/bin/git", ["config", "--unset-all", "remote.origin.uploadpack"], { cwd: author }).catch(() => {});
await execFileAsync("/usr/bin/git", ["config", "--remove-section", "filter.bad"], { cwd: author }).catch(() => {});
await rm(join(author, ".gitattributes"), { force: true });
await rm(join(author, ".git", "hooks", "pre-commit"), { force: true });
const marker = join(runRoot, `${kind}-marker`);
const helper = join(runRoot, `${kind}-helper`);
await writeFile(helper, `#!/bin/sh\nprintf ran > '${marker}'\nexec /usr/bin/git-upload-pack \"$@\"\n`, { mode: 0o700 });
await configure(helper);
const before = guard.events.length;
await assert.rejects(runCommand({ executable: executables.gitPath, argv, cwd: author, env: { ...process.env } }), /unsafe Git repository state/);
assert.equal(guard.events.length - before, 1);
assert.equal(guard.events.at(-1).outcome, "REJECTED");
await assert.rejects(lstat(marker));
}
} finally { guard.restore(); }
});
test("trusted tht rejects executable finder code and Git-hidden source changes", async () => {
const maliciousFinder = await fakeTrustedThtRepository();
const finderPath = join(maliciousFinder.sitePackages, maliciousFinder.finderName);
await writeFile(finderPath, `open('${join(maliciousFinder.repositoryRoot, "finder-marker")}', 'w').write('ran')\n${await readFile(finderPath, "utf8")}`);
await assert.rejects(resolveProductionExecutables({ repositoryRoot: maliciousFinder.repositoryRoot }), /editable binding is invalid/);
const ignoredPyc = await fakeTrustedThtRepository();
await mkdir(join(ignoredPyc.sitePackages, "__pycache__"));
await writeFile(join(ignoredPyc.sitePackages, "__pycache__", `${ignoredPyc.finderName.slice(0, -3)}.cpython-313.pyc`), "malicious bytecode");
await assert.rejects(resolveProductionExecutables({ repositoryRoot: ignoredPyc.repositoryRoot }), /import startup override/);
const hiddenSource = await fakeTrustedThtRepository();
const sourcePath = join(hiddenSource.sourceRoot, "cli", "__init__.py");
await execFileAsync("/usr/bin/git", ["update-index", "--assume-unchanged", "harness/tht/cli/__init__.py"], { cwd: hiddenSource.repositoryRoot });
await writeFile(sourcePath, `${await readFile(sourcePath, "utf8")}\n# malicious hidden swap\n`);
await assert.rejects(resolveProductionExecutables({ repositoryRoot: hiddenSource.repositoryRoot }), /source bytes differ from Git/);
});
test("trusted tht guard rejects and records post-resolution entrypoint finder and source swaps at spawn", async () => {
for (const target of ["entrypoint", "finder", "source"]) {
const fixture = await fakeTrustedThtRepository();
const executables = await resolveProductionExecutables({ repositoryRoot: fixture.repositoryRoot });
const runRoot = await fakeRepository();
const configPath = join(runRoot, "rendered", "workspace.yaml");
await mkdir(dirname(configPath), { recursive: true });
await writeFile(configPath, "profile: acceptance\n");
const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } });
try {
const path = target === "entrypoint" ? executables.thtPath
: target === "finder" ? join(fixture.sitePackages, fixture.finderName)
: join(fixture.sourceRoot, "cli", "__init__.py");
await writeFile(path, `${await readFile(path, "utf8")}\n# post-resolution swap\n`, target === "entrypoint" ? { mode: 0o700 } : undefined);
const childProcess = await import("node:child_process");
assert.throws(() => childProcess.execFile(executables.thtPath, ["config", "check", "-c", configPath], {
cwd: join(fixture.repositoryRoot, "harness"), env: { ...process.env },
}), /trusted THT identity changed/);
assert.equal(guard.events.at(-1).outcome, "REJECTED");
} finally { guard.restore(); }
}
});
test("secret scan fails closed on a recoverable symlink outside fixture-secrets", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const canary = "CANARY-symlink-secret-123456";
await mkdir(join(run.root, "fixture-secrets"));
await writeFile(join(run.root, "fixture-secrets", "token"), canary);
await mkdir(join(run.root, "responses"));
await symlink(join(run.root, "fixture-secrets", "token"), join(run.root, "responses", "leak"));
await assert.rejects(scanSecrets({ runRoot: run.root, forbiddenValues: [canary], expectedGitRepositories: [] }), /symlink outside fixture-secrets/);
});
test("direct public wrapper clears startup files and exported functions before Bash starts", async () => {
const root = await fakeRepository();
const bashStartup = join(root, "bash-startup");
const envStartup = join(root, "env-startup");
const bashMarker = join(root, "bash-env-ran");
const envMarker = join(root, "env-ran");
const functionMarker = join(root, "exported-function-ran");
await writeFile(bashStartup, `printf sourced > '${bashMarker}'\n`);
await writeFile(envStartup, `printf sourced > '${envMarker}'\n`);
const wrapper = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p1-acceptance.sh");
await assert.rejects(execFileAsync(wrapper, ["invalid"], {
env: {
...process.env,
BASH_ENV: bashStartup,
ENV: envStartup,
"BASH_FUNC_cd%%": `() { printf function > '${functionMarker}'; builtin cd "$@"; }`,
},
}));
for (const marker of [bashMarker, envMarker, functionMarker]) await assert.rejects(lstat(marker));
assert.match(await readFile(wrapper, "utf8"), /^#!\/usr\/bin\/env -S -i PATH=\/usr\/bin:\/bin \/bin\/bash\n/);
});
test("final listener ownership state is a declared hash-bound report artifact", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: true, checks: exactScenarios() });
const artifact = result.report.checks.flatMap(({ artifacts }) => artifacts).find(({ path }) => path === "logs/final-ownership.json");
assert(artifact);
const bytes = await readFile(join(result.runRoot, artifact.path));
const { createHash } = await import("node:crypto");
assert.equal(createHash("sha256").update(bytes).digest("hex"), artifact.sha256);
const value = JSON.parse(bytes);
assert.deepEqual(value.listeners.map(({ state }) => state), ["not_started", "not_started"]);
});
test("repository provenance binds clean HEAD tree and backend source/dist manifests and rejects dirty state", async () => {
const repositoryRoot = await fakeRepository();
await mkdir(join(repositoryRoot, "backend", "src"), { recursive: true });
await mkdir(join(repositoryRoot, "backend", "scripts"), { recursive: true });
await mkdir(join(repositoryRoot, "backend", "dist"), { recursive: true });
await writeFile(join(repositoryRoot, "backend", "src", "app.ts"), "export const value = 1;\n");
await writeFile(join(repositoryRoot, "backend", "scripts", "p1-acceptance.mjs"), "export {};\n");
await writeFile(join(repositoryRoot, "backend", "dist", "app.js"), "export const value = 1;\n");
await writeFile(join(repositoryRoot, "backend", "package.json"), "{}\n");
await writeFile(join(repositoryRoot, "backend", "package-lock.json"), "{}\n");
await writeFile(join(repositoryRoot, "backend", "tsconfig.json"), "{}\n");
await execFileAsync("/usr/bin/git", ["init", "--initial-branch=main"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["config", "user.name", "P1 Test"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["config", "user.email", "p1-test@example.invalid"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["add", "backend"], { cwd: repositoryRoot });
await execFileAsync("/usr/bin/git", ["commit", "-m", "clean tree"], { cwd: repositoryRoot });
const provenance = await collectRepositoryProvenance({ repositoryRoot, gitPath: "/usr/bin/git" });
assert.match(provenance.head, /^[0-9a-f]{40}$/);
assert.match(provenance.tree, /^[0-9a-f]{40}$/);
assert.equal(provenance.clean, true);
assert.equal(provenance.backendSource.files.some(({ path }) => path === "src/app.ts"), true);
assert.equal(provenance.backendDist.files.some(({ path }) => path === "dist/app.js"), true);
await writeFile(join(repositoryRoot, "backend", "src", "app.ts"), "dirty\n");
await assert.rejects(collectRepositoryProvenance({ repositoryRoot, gitPath: "/usr/bin/git" }), /repository is not clean/);
});
+609
View File
@@ -0,0 +1,609 @@
#!/usr/bin/env node
import { execFile, spawn } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, realpathSync } from "node:fs";
import { access, chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
import http from "node:http";
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 directorySync(path){const fd=openSync(path,constants.O_RDONLY);try{fsyncSync(fd);}finally{closeSync(fd);}}
async function exclusiveRecord(path,value,label){const bytes=`${JSON.stringify(value,null,2)}\n`;let handle,createdEntry;try{handle=await open(path,"wx",0o600);createdEntry=await handle.stat();await handle.chmod(0o600);await handle.writeFile(bytes);await handle.sync();await handle.close();handle=undefined;directorySync(dirname(path));return{path,bytes,dev:createdEntry.dev,ino:createdEntry.ino};}catch(error){if(handle)await handle.close().catch(()=>{});if(createdEntry)try{const current=await lstat(path);if(current.dev===createdEntry.dev&&current.ino===createdEntry.ino)await rm(path);}catch{}if(error.code==="EEXIST")throw new Error(`${label} already exists; operator inspection required`);throw error;}}
async function requireExactRecord(record){const entry=await lstat(record.path);if(!entry.isFile()||entry.isSymbolicLink()||(entry.mode&0o777)!==0o600||(record.dev!==undefined&&(entry.dev!==record.dev||entry.ino!==record.ino)))throw new Error("owned lifecycle record is unsafe");if(await readFile(record.path,"utf8")!==record.bytes)throw new Error("owned lifecycle record changed; operator inspection required");const after=await lstat(record.path);if(after.dev!==entry.dev||after.ino!==entry.ino)throw new Error("owned lifecycle record changed; operator inspection required");return after;}
async function removeExactRecord(record){await requireExactRecord(record);await requireExactRecord(record);await rm(record.path);directorySync(dirname(record.path));}
async function replaceExactRecord(record,value){await requireExactRecord(record);const bytes=`${JSON.stringify(value,null,2)}\n`,staging=join(dirname(record.path),`.${basename(record.path)}.${randomBytes(12).toString("hex")}.tmp`);let handle;try{handle=await open(staging,"wx",0o600);await handle.chmod(0o600);await handle.writeFile(bytes);await handle.sync();await handle.close();handle=undefined;await requireExactRecord(record);await rename(staging,record.path);const entry=await lstat(record.path);directorySync(dirname(record.path));return{path:record.path,bytes,dev:entry.dev,ino:entry.ino};}finally{if(handle)await handle.close().catch(()=>{});await rm(staging,{force:true}).catch(()=>{});}}
function sameEntry(actual,expected){return actual.dev===expected.dev&&actual.ino===expected.ino;}
async function requirePathIdentity(path,expected,label){let entry;try{entry=await lstat(path);}catch{throw new Error(`${label} identity changed`);}if(entry.isSymbolicLink()||!sameEntry(entry,expected))throw new Error(`${label} identity changed`);return entry;}
async function acquireLifecycle(repo,operation){
const lockPath=join(repo,".p1-manual-acceptance.lifecycle.lock"),artifacts=join(repo,".artifacts"),manualParent=join(artifacts,"manual-acceptance"),root=fixedManualRoot(repo);
noSymlinkExisting(repo,manualParent);await mkdir(manualParent,{recursive:true,mode:0o700});noSymlinkExisting(repo,manualParent);
const repoEntry=await lstat(repo),artifactsEntry=await lstat(artifacts),parentEntry=await lstat(manualParent);
if(!repoEntry.isDirectory()||!artifactsEntry.isDirectory()||!parentEntry.isDirectory())throw new Error("lifecycle namespace identity is unsafe");
const lifecycleNonce=randomBytes(32).toString("hex"),record=await exclusiveRecord(lockPath,{schemaVersion:1,kind:"p1-manual-lifecycle",operation,lifecycleNonce,root,repositoryRoot:repo},"external lifecycle lock"),entry=await requireExactRecord(record);
return{...record,dev:entry.dev,ino:entry.ino,repoPath:repo,repoEntry,artifactsPath:artifacts,artifactsEntry,parentPath:manualParent,parentEntry,rootEntry:undefined};
}
async function requireLifecycleContext(lifecycle,{root=false}={}){
await requireExactRecord(lifecycle);await requirePathIdentity(lifecycle.repoPath,lifecycle.repoEntry,"repository root");await requirePathIdentity(lifecycle.artifactsPath,lifecycle.artifactsEntry,"artifact root");await requirePathIdentity(lifecycle.parentPath,lifecycle.parentEntry,"manual acceptance parent");
if(root&&lifecycle.rootEntry)await requirePathIdentity(join(lifecycle.parentPath,"p1"),lifecycle.rootEntry,"manual acceptance root");
}
async function bindLifecycleRoot(lifecycle,root){const entry=await lstat(root);if(!entry.isDirectory()||entry.isSymbolicLink())throw new Error("manual acceptance root identity is unsafe");lifecycle.rootEntry=entry;await requireLifecycleContext(lifecycle,{root:true});return entry;}
async function findRootByIdentity(repo,identity){
const artifacts=join(repo,".artifacts");let count=0;
for(const parent of await readdir(artifacts,{withFileTypes:true})){if(++count>1024)throw new Error("manual cleanup search bound exceeded");if(!parent.isDirectory()||parent.isSymbolicLink())continue;const candidate=join(artifacts,parent.name,"p1");try{const entry=await lstat(candidate);if(entry.isDirectory()&&!entry.isSymbolicLink()&&sameEntry(entry,identity))return candidate;}catch{}
}return undefined;
}
async function cleanupFailedPrepare(repo,lifecycle){if(!lifecycle.rootEntry)return;const candidate=await findRootByIdentity(repo,lifecycle.rootEntry);if(!candidate)return;const entry=await lstat(candidate);if(!sameEntry(entry,lifecycle.rootEntry)||entry.isSymbolicLink())throw new Error("failed prepare root identity changed");await rm(candidate,{recursive:true});}
function legacySupervisorPath(root){return join(root,"installation/runtime/p1-backend-supervisor.mjs");}
const CONTROL_PORT=8792;
const PRELOAD_SOURCE=`import net from "node:net";
import { createHash } from "node:crypto";
import { closeSync, constants, fstatSync, openSync, readFileSync, readSync, realpathSync } from "node:fs";
import { registerHooks } from "node:module";
import { dirname, join, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HOST="127.0.0.1",PORT=8792,HTTP_PORT=8791,HEX=/^[0-9a-f]{64}$/;
const argv=process.argv.slice(2),noncePrefix="--p1-manual-nonce=",rootPrefix="--p1-root=",controlPrefix="--p1-control-nonce=",shaPrefix="--p1-entry-sha256=",devPrefix="--p1-entry-dev=",inoPrefix="--p1-entry-ino=";
const prefixes=[noncePrefix,rootPrefix,controlPrefix,shaPrefix,devPrefix,inoPrefix];
if(argv.length!==6||argv.some((value,index)=>!value.startsWith(prefixes[index])))throw new Error("manual control identity arguments refused");
const nonce=argv[0].slice(noncePrefix.length),root=argv[1].slice(rootPrefix.length),controlNonce=argv[2].slice(controlPrefix.length),entrySha=argv[3].slice(shaPrefix.length),entryDev=argv[4].slice(devPrefix.length),entryIno=argv[5].slice(inoPrefix.length);
if(!HEX.test(nonce)||!root.startsWith("/")||!HEX.test(controlNonce)||!HEX.test(entrySha)||!/^[0-9]+$/.test(entryDev)||!/^[0-9]+$/.test(entryIno))throw new Error("manual control identity refused");
if(!Number.isInteger(constants.O_NOFOLLOW))throw new Error("manual distribution no-follow protection is unavailable");
const entryStat=fstatSync(3),entrySource=readFileSync(3);if(!entryStat.isFile()||String(entryStat.dev)!==entryDev||String(entryStat.ino)!==entryIno||createHash("sha256").update(entrySource).digest("hex")!==entrySha)throw new Error("manual entrypoint FD identity refused");
const manifestStat=fstatSync(4);if(!manifestStat.isFile()||manifestStat.size<1||manifestStat.size>8388608)throw new Error("manual distribution manifest FD identity refused");
let manifest;try{manifest=JSON.parse(readFileSync(4));}catch{throw new Error("manual distribution manifest is malformed");}
const entryPath=realpathSync(process.argv[1]),distRoot=dirname(entryPath);
if(manifest?.schemaVersion!==1||manifest.kind!=="p1-manual-dist-manifest"||manifest.root!==distRoot||!manifest.files||typeof manifest.files!=="object"||Array.isArray(manifest.files))throw new Error("manual distribution manifest identity refused");
const distEntries=Object.entries(manifest.files);if(distEntries.length<1||distEntries.length>20000)throw new Error("manual distribution manifest identity refused");
const distBytes=new Map();
for(const[rel,file]of distEntries){
if(typeof rel!=="string"||!rel||rel.startsWith("/")||rel.startsWith("..")||rel.includes("\\\\")||rel.includes("/./")||rel.endsWith("/")||!Number.isSafeInteger(file?.size)||file.size<1||file.size>33554432||!HEX.test(file?.sha256??"")||!/^[0-9]+$/.test(String(file?.dev))||!/^[0-9]+$/.test(String(file?.ino)))throw new Error("manual distribution manifest is malformed");
const path=join(distRoot,rel),fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW);
try{
const before=fstatSync(fd);
if(!before.isFile()||before.nlink!==1||String(before.dev)!==String(file.dev)||String(before.ino)!==String(file.ino)||before.size!==file.size)throw new Error("manual distribution module identity changed");
const bytes=Buffer.alloc(before.size);let offset=0;
while(offset<bytes.length){const n=readSync(fd,bytes,offset,bytes.length-offset,offset);if(n<1)throw new Error("manual distribution module changed while binding");offset+=n;}
const after=fstatSync(fd);
if(after.dev!==before.dev||after.ino!==before.ino||after.size!==before.size)throw new Error("manual distribution module changed while binding");
if(createHash("sha256").update(bytes).digest("hex")!==file.sha256)throw new Error("manual distribution module bytes changed");
distBytes.set(rel,bytes);
}finally{closeSync(fd);}
}
if(!distBytes.has("server.js")||createHash("sha256").update(entrySource).digest("hex")!==manifest.files["server.js"].sha256)throw new Error("manual entrypoint manifest identity refused");
const entryUrl=pathToFileURL(entryPath).href,distPrefix=distRoot+sep;
registerHooks({load(url,context,nextLoad){if(url===entryUrl)return{format:"module",shortCircuit:true,source:entrySource};let pathname;try{pathname=fileURLToPath(url);}catch{return nextLoad(url,context);}if(pathname.startsWith(distPrefix)){const rel=pathname.slice(distPrefix.length);const bytes=distBytes.get(rel);if(!bytes)throw new Error("manual distribution module refused");return{format:rel.endsWith(".json")?"json":"module",shortCircuit:true,source:bytes};}return nextLoad(url,context);}});
let state="STARTING",stopping=false,ownedListener,listenGeneration=0;
const listenerIdentity=()=>{const address=ownedListener?.listening?ownedListener.address():undefined;return{listening:Boolean(ownedListener?.listening&&address&&address.address===HOST&&address.port===HTTP_PORT),host:address?.address,port:address?.port,generation:listenGeneration};};
const originalListen=net.Server.prototype.listen;net.Server.prototype.listen=function(...args){const candidate=this;candidate.once("listening",()=>{const address=candidate.address();if(address&&address.address===HOST&&address.port===HTTP_PORT){ownedListener=candidate;listenGeneration++;}});candidate.on("close",()=>{if(ownedListener===candidate){ownedListener=undefined;if(state==="READY")state="LISTENER_CLOSED";}});return originalListen.apply(candidate,args);};
const identity=()=>({status:state,pid:process.pid,nonce,controlNonce,root,control:{host:HOST,port:PORT},listener:listenerIdentity()});
const control=net.createServer(socket=>{let bytes="";socket.setEncoding("utf8");socket.on("data",chunk=>{bytes+=chunk;if(bytes.length>2048)socket.destroy();});socket.on("end",()=>{let request;try{request=JSON.parse(bytes);}catch{socket.end();return;}if(request?.nonce!==controlNonce){socket.end();return;}if(request.action==="status"){socket.end(JSON.stringify(identity())+"\\n");return;}if(request.action==="ready"&&!stopping&&listenerIdentity().listening){state="READY";clearTimeout(watchdog);socket.end(JSON.stringify(identity())+"\\n");return;}if(request.action==="stop"&&!stopping){stopping=true;state="STOPPING";clearTimeout(watchdog);socket.end(JSON.stringify(identity())+"\\n",()=>{control.close();if(ownedListener?.listening)ownedListener.close(()=>process.exit(0));else setImmediate(()=>process.exit(0));});return;}socket.end(JSON.stringify(identity())+"\\n");});});
await new Promise((resolve,reject)=>{control.once("error",reject);control.listen({host:HOST,port:PORT,exclusive:true},resolve);});
const watchdog=setTimeout(()=>{if(state!=="STARTING")return;console.error("manual backend readiness watchdog expired");control.close(()=>process.exit(1));setTimeout(()=>process.exit(1),100).unref();},8000);
`;
const PRELOAD=`data:text/javascript;base64,${Buffer.from(PRELOAD_SOURCE,"utf8").toString("base64")}`;
async function controlRequest(control,payload){if(control?.host!==HOST||!Number.isSafeInteger(control?.port)||control.port<1||control.port>65535)throw new Error("backend control identity mismatch");return await new Promise((resolvePromise,reject)=>{const socket=net.createConnection({host:control.host,port:control.port}),timer=setTimeout(()=>socket.destroy(new Error("backend control timeout")),2000);let bytes="";socket.setEncoding("utf8");socket.on("connect",()=>socket.end(JSON.stringify(payload)));socket.on("data",chunk=>{bytes+=chunk;if(bytes.length>2048)socket.destroy(new Error("backend control response too large"));});socket.on("error",reject);socket.on("close",()=>{clearTimeout(timer);let value;try{value=JSON.parse(bytes);}catch{return reject(new Error("backend control response is malformed"));}resolvePromise(value);});});}
function ownedValue(repo,root,nonce,{backendLog=null,entrypoint,distManifest=null,stage="PREPARING",createdAt=new Date().toISOString()}={}){return{schemaVersion:1,kind:"p1-manual-acceptance",nonce,repositoryRoot:repo,root,status:"PENDING",stage,createdAt,listener:{host:HOST,port:PORT,state:"stopped"},backendLog,entrypoint,distManifest,resources:[root,{kind:"fastify",host:HOST,port:PORT}]};}
function validEntrypoint(value,repo){return value?.path===join(repo,"backend/dist/server.js")&&Number.isSafeInteger(value.dev)&&Number.isSafeInteger(value.ino)&&Number.isSafeInteger(value.size)&&value.size>0&&HEX64.test(value.sha256??"");}
async function readBoundEntrypoint(repo){
if(!Number.isInteger(constants.O_NOFOLLOW))throw new Error("production entrypoint no-follow protection is unavailable");const path=join(repo,"backend/dist/server.js");let handle;
try{handle=await open(path,constants.O_RDONLY|constants.O_NOFOLLOW);const before=await handle.stat(),pathEntry=await lstat(path);if(!before.isFile()||before.nlink!==1||pathEntry.isSymbolicLink()||!pathEntry.isFile()||!sameEntry(before,pathEntry))throw new Error("production server identity is unsafe");if(before.size<1||before.size>33554432)throw new Error("production entrypoint is unbounded");const bytes=Buffer.alloc(before.size);let offset=0;while(offset<bytes.length){const{bytesRead}=await handle.read(bytes,offset,bytes.length-offset,offset);if(bytesRead<1)throw new Error("production entrypoint changed while binding");offset+=bytesRead;}const after=await handle.stat();if(!sameEntry(before,after)||before.size!==bytes.length||after.size!==before.size)throw new Error("production entrypoint changed while binding");return{handle,identity:{path,dev:before.dev,ino:before.ino,size:before.size,sha256:createHash("sha256").update(bytes).digest("hex")},bytes};}catch(error){if(handle)await handle.close().catch(()=>{});throw error;}
}
async function requireEntrypointPathIdentity(entrypoint){const entry=await lstat(entrypoint.path);if(!entry.isFile()||entry.isSymbolicLink()||entry.nlink!==1||entry.dev!==entrypoint.dev||entry.ino!==entrypoint.ino||entry.size!==entrypoint.size)throw new Error("production entrypoint identity changed");const bytes=await readFile(entrypoint.path);if(bytes.length!==entrypoint.size||createHash("sha256").update(bytes).digest("hex")!==entrypoint.sha256)throw new Error("production entrypoint bytes changed");return entry;}
function validDistManifest(value,root){return value?.path===join(root,"installation","runtime","backend-dist.manifest.json")&&Number.isSafeInteger(value.dev)&&Number.isSafeInteger(value.ino)&&Number.isSafeInteger(value.size)&&value.size>0&&HEX64.test(value.sha256??"");}
function parseDistManifest(bytes,distRoot){let value;try{value=JSON.parse(bytes.toString("utf8"));}catch{throw new Error("production distribution manifest is malformed");}const files=value?.files;if(value?.schemaVersion!==1||value.kind!=="p1-manual-dist-manifest"||value.root!==distRoot||!files||typeof files!=="object"||Array.isArray(files))throw new Error("production distribution manifest is malformed");const entries=Object.entries(files);if(entries.length<1||entries.length>20000)throw new Error("production distribution manifest is malformed");for(const[rel,file]of entries){if(!/^[^./\\][^/\\]*(?:\/[^./\\][^/\\]*)*$/.test(rel)||!Number.isSafeInteger(file?.size)||file.size<1||file.size>33554432||!HEX64.test(file?.sha256??"")||!Number.isSafeInteger(file?.dev)||!Number.isSafeInteger(file?.ino))throw new Error("production distribution manifest is malformed");}return{value,files};}
async function buildDistManifest(repo){const dist=join(repo,"backend","dist"),files={};let count=0,total=0;async function walk(dir){for(const entry of await readdir(dir,{withFileTypes:true})){const path=join(dir,entry.name);if(entry.isSymbolicLink())throw new Error("production distribution contains a symlink");if(entry.isDirectory()){await walk(path);continue;}if(!entry.isFile())throw new Error("production distribution contains a nonregular entry");if(++count>20000)throw new Error("production distribution is unbounded");const rel=relative(dist,path).split(sep).join("/");let handle;try{handle=await open(path,constants.O_RDONLY|constants.O_NOFOLLOW);const before=await handle.stat();if(!before.isFile()||before.nlink!==1||before.size<1||before.size>33554432)throw new Error("production distribution module is unsafe");total+=before.size;if(total>536870912)throw new Error("production distribution is unbounded");const bytes=Buffer.alloc(before.size);let offset=0;while(offset<bytes.length){const{bytesRead}=await handle.read(bytes,offset,bytes.length-offset,offset);if(bytesRead<1)throw new Error("production distribution module changed while binding");offset+=bytesRead;}const after=await handle.stat();if(!sameEntry(before,after)||after.size!==before.size)throw new Error("production distribution module changed while binding");files[rel]={size:before.size,sha256:createHash("sha256").update(bytes).digest("hex"),dev:before.dev,ino:before.ino};}finally{if(handle)await handle.close().catch(()=>{});}}}await walk(dist);return{schemaVersion:1,kind:"p1-manual-dist-manifest",root:dist,files};}
async function readBoundDistManifest(repo,owned){
if(!Number.isInteger(constants.O_NOFOLLOW))throw new Error("production distribution manifest no-follow protection is unavailable");const distManifest=owned.distManifest;let handle;
try{handle=await open(distManifest.path,constants.O_RDONLY|constants.O_NOFOLLOW);const before=await handle.stat(),pathEntry=await lstat(distManifest.path);if(!before.isFile()||before.nlink!==1||pathEntry.isSymbolicLink()||!pathEntry.isFile()||!sameEntry(before,pathEntry)||before.dev!==distManifest.dev||before.ino!==distManifest.ino||before.size!==distManifest.size)throw new Error("production distribution manifest identity changed");if(before.size<1||before.size>8388608)throw new Error("production distribution manifest is unbounded");const bytes=Buffer.alloc(before.size);let offset=0;while(offset<bytes.length){const{bytesRead}=await handle.read(bytes,offset,bytes.length-offset,offset);if(bytesRead<1)throw new Error("production distribution manifest changed while binding");offset+=bytesRead;}const after=await handle.stat();if(!sameEntry(before,after)||before.size!==bytes.length||after.size!==before.size)throw new Error("production distribution manifest changed while binding");if(createHash("sha256").update(bytes).digest("hex")!==distManifest.sha256)throw new Error("production distribution manifest bytes changed");const{files}=parseDistManifest(bytes,join(repo,"backend","dist"));if(files["server.js"]?.sha256!==owned.entrypoint.sha256)throw new Error("production distribution manifest does not bind the entrypoint");return{handle,files};}catch(error){if(handle)await handle.close().catch(()=>{});throw error;}
}
async function validateDistFiles(repo,files){const dist=join(repo,"backend","dist");for(const[rel,file]of Object.entries(files)){const path=join(dist,...rel.split("/"));let handle;try{handle=await open(path,constants.O_RDONLY|constants.O_NOFOLLOW);const before=await handle.stat(),pathEntry=await lstat(path);if(!before.isFile()||before.nlink!==1||pathEntry.isSymbolicLink()||!pathEntry.isFile()||!sameEntry(before,pathEntry)||before.dev!==file.dev||before.ino!==file.ino||before.size!==file.size)throw new Error("production distribution module identity changed");const bytes=Buffer.alloc(before.size);let offset=0;while(offset<bytes.length){const{bytesRead}=await handle.read(bytes,offset,bytes.length-offset,offset);if(bytesRead<1)throw new Error("production distribution module changed while binding");offset+=bytesRead;}const after=await handle.stat();if(!sameEntry(before,after)||after.size!==before.size)throw new Error("production distribution module changed while binding");if(createHash("sha256").update(bytes).digest("hex")!==file.sha256)throw new Error("production distribution module bytes changed");}finally{if(handle)await handle.close().catch(()=>{});}}}
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 baseValid=value.schemaVersion===1&&value.kind==="p1-manual-acceptance"&&HEX64.test(value.nonce??"")&&value.repositoryRoot===repo&&value.root===root&&value.status==="PENDING"&&["PREPARING","READY"].includes(value.stage)&&value.listener?.host===HOST&&value.listener?.port===PORT&&value.listener?.state==="stopped"&&typeof value.createdAt==="string"&&validEntrypoint(value.entrypoint,repo)&&validDistManifest(value.distManifest,root)&&JSON.stringify(value.resources)===JSON.stringify([root,{kind:"fastify",host:HOST,port:PORT}]);const readyLog=value.backendLog?.path===join(root,"logs/backend.log")&&Number.isSafeInteger(value.backendLog?.dev)&&Number.isSafeInteger(value.backendLog?.ino);if(!baseValid||(value.stage==="READY"?!readyLog:value.backendLog!==null))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","python3"]){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 { createHash } from "node:crypto";
import { readFile, realpath, stat } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, 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 HEX40=/^[0-9a-f]{40}$/,HEX64=/^[0-9a-f]{64}$/,REVISION_KEYS=["blob","commit","id","snapshotPath"];
const bounded=async(path,label="saved response")=>{let s;try{s=await stat(path);}catch{throw new Error(label+" is missing or unbounded");}if(!s.isFile()||s.size<2||s.size>1048576)throw new Error(label+" is missing or unbounded");let v;try{v=JSON.parse(await readFile(path,"utf8"));}catch{throw new Error(label+" is malformed JSON");}return v;};
const boundedBytes=async(path)=>{let s;try{s=await stat(path);}catch{throw new Error("saved snapshot is missing or unbounded");}if(!s.isFile()||s.size<2||s.size>1048576)throw new Error("saved snapshot is missing or unbounded");return await readFile(path);};
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(!HEX40.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 id=basename(canonical).slice(0,-".yaml".length);if(!/^[a-z][a-z0-9-]{2,62}$/.test(id))throw new Error("snapshot workspace identity is invalid");
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 manifest=await bounded(join(snapshots,commit,"snapshot.json"),"snapshot manifest");const files=manifest?.files,revisions=manifest?.revisions;
if(manifest?.head!==commit||!files||typeof files!=="object"||Array.isArray(files))throw new Error("snapshot manifest identity is invalid");
const expected=files[id+".yaml"];if(!HEX64.test(expected??""))throw new Error("snapshot manifest digest is invalid");
const snapshotBytes=await boundedBytes(canonical);if(createHash("sha256").update(snapshotBytes).digest("hex")!==expected)throw new Error("snapshot bytes differ from manifest digest");
const entry=Array.isArray(revisions)?revisions.find(candidate=>candidate?.id===id):undefined;
const exactEntry=entry&&typeof entry==="object"&&!Array.isArray(entry)&&Object.keys(entry).sort().every((key,index)=>key===REVISION_KEYS[index])&&Object.keys(entry).length===REVISION_KEYS.length;
if(!exactEntry||entry.id!==id||entry.commit!==commit||typeof entry.blob!=="string"||!HEX40.test(entry.blob)||entry.snapshotPath!==canonical)throw new Error("snapshot manifest revision is invalid");
if(!HEX40.test(revision?.blob??"")||revision.blob!==entry.blob)throw new Error("saved revision blob differs from snapshot manifest");
const blobCheck=spawnSync("git",["-C",checkout,"rev-parse",commit+":workspaces/"+id+".yaml"],{encoding:"utf8"});
if(blobCheck.status!==0||blobCheck.stdout.trim()!==entry.blob)throw new Error("snapshot blob differs from installed Git commit");
const hashObject=spawnSync("git",["hash-object","--stdin"],{input:snapshotBytes,encoding:"utf8"});
if(hashObject.status!==0||hashObject.stdout.trim()!==entry.blob)throw new Error("snapshot bytes differ from Git blob");
const child=spawnSync(process.execPath,[renderer,"--ownership",resolve(root,"ownership.json"),"--snapshot",canonical,"--output",output,"--snapshot-sha256",expected],{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. Every lifecycle action uses the stable repository-root \`.p1-manual-acceptance.lifecycle.lock\`; successful prepare has advanced its ownership-first recovery record from \`PREPARING\` to \`READY\`.
1. Inspect \`${root}/ownership.json\`, including the bound production entrypoint identity and the complete \`backend/dist\` module manifest identity, 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 one production Node PID owns both \`${HOST}:${PORT}\` and its authenticated \`${HOST}:${CONTROL_PORT}\` control listener (for example, use \`lsof -nP -iTCP:${PORT} -sTCP:LISTEN\` and repeat for port ${CONTROL_PORT}). Serve executes the ownership-bound production entrypoint and complete verified \`backend/dist\` module graph from opened no-follow descriptors and publishes \`RUNNING\` only after the same authenticated child acknowledges its owned HTTP listener and passes bounded health checks.
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 exports/raw/p1-filesystem.zip exports/extracted/p1-filesystem p1-filesystem\`, then the equivalent exact commands for \`p1-http\` and \`p1-s3\`; verify each manifest and descriptor identity, 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\`. The render commands bind the snapshot bytes to the commit\'s \`snapshot.json\` digest and Git blob identity; the renderer revalidates that digest and renders only the verified bytes through one opened no-follow \`rendered\` directory identity, refusing an ancestor swap.
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 only the direct \`fixture-secrets\` payload directory, scans bounded filesystem content and name/path bytes, discovers every bounded arbitrary \`.git\` repository plus the owned bare remote, and checks loose-ref names plus raw bounded bytes from every blob, commit, tree, and tag object, including unreachable objects. Findings and operational errors redact secret-bearing paths and values.
12. Run \`commands/absence-check.sh\`; confirm no file or directory represents preprocessing, Evidence materialization (including \`artifacts/evidence\`), embedding, Qdrant, ACTIVE, or retention state.
13. Run \`${repo}/scripts/p1-manual-acceptance.sh stop\`; confirm \`backend.pid\` and both listeners on ports ${PORT} and ${CONTROL_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.
`;}
function extractCommand(repo,root){return `#!/usr/bin/env bash
set -euo pipefail
zip=\${1:?zip required}; out=\${2:?new output required}; expected=\${3:?expected workspace id required}
python3 - "$zip" "$out" "$expected" ${quote(root)} ${quote(join(repo,"backend/package.json"))} <<'PY'
import hashlib
import io
import json
import os
import re
import secrets
import stat
import subprocess
import sys
import zipfile
zip_path, output_path, expected, root, package_json = sys.argv[1:]
allowed = {"p1-filesystem", "p1-http", "p1-s3"}
required = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"]
base = os.path.join(root, "exports", "extracted")
base_fd = None
archive_fd = None
stage_fd = None
archive_stage = None
extract_stage = None
published = False
def fail(message):
raise RuntimeError(message)
def exact(value, keys):
return isinstance(value, dict) and set(value) == set(keys)
def write_all(fd, data):
view = memoryview(data)
while view:
written = os.write(fd, view)
if written <= 0:
fail("anchored extraction write failed")
view = view[written:]
def read_exact_fd(fd, expected_size, limit, label):
if expected_size < 1 or expected_size > limit:
fail(label + " is unbounded")
chunks = []
remaining = expected_size
while remaining:
chunk = os.read(fd, min(1024 * 1024, remaining))
if not chunk:
fail(label + " changed while staging")
chunks.append(chunk)
remaining -= len(chunk)
if os.read(fd, 1):
fail(label + " changed while staging")
return b"".join(chunks)
def require_base_identity():
try:
current = os.stat(base, follow_symlinks=False)
except OSError:
fail("owned extraction root identity changed")
if (not stat.S_ISDIR(current.st_mode) or current.st_dev != base_identity.st_dev
or current.st_ino != base_identity.st_ino or os.path.realpath(base) != base):
fail("owned extraction root identity changed")
def remove_anchored_directory(name):
child_fd = None
try:
child_fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=base_fd)
for entry in os.listdir(child_fd):
if entry not in required:
fail("anchored extraction cleanup found an unexpected entry")
os.unlink(entry, dir_fd=child_fd)
os.fsync(child_fd)
except FileNotFoundError:
return
finally:
if child_fd is not None:
os.close(child_fd)
os.rmdir(name, dir_fd=base_fd)
os.fsync(base_fd)
try:
for flag in ("O_DIRECTORY", "O_NOFOLLOW"):
if not hasattr(os, flag):
fail("anchored extraction is unavailable on this platform")
if expected not in allowed:
fail("expected workspace identity is invalid")
if os.path.realpath(root) != root or os.path.dirname(output_path) != base:
fail("unsafe owned extraction root or output path")
output_name = os.path.basename(output_path)
if not output_name or output_name.startswith(".") or os.sep in output_name:
fail("unsafe output path")
base_fd = os.open(base, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
base_identity = os.fstat(base_fd)
if not stat.S_ISDIR(base_identity.st_mode):
fail("unsafe owned extraction root")
require_base_identity()
try:
os.stat(output_name, dir_fd=base_fd, follow_symlinks=False)
fail("unsafe output path")
except FileNotFoundError:
pass
# Open the caller's source exactly once, then consume only an owned staged copy.
source_fd = os.open(zip_path, os.O_RDONLY | os.O_NOFOLLOW)
try:
source_identity = os.fstat(source_fd)
if not stat.S_ISREG(source_identity.st_mode):
fail("source ZIP is unsafe")
source_bytes = read_exact_fd(source_fd, source_identity.st_size, 33554432, "source ZIP")
source_after = os.fstat(source_fd)
if (source_after.st_dev, source_after.st_ino, source_after.st_size) != (source_identity.st_dev, source_identity.st_ino, source_identity.st_size):
fail("source ZIP changed during staging")
finally:
os.close(source_fd)
archive_sha = hashlib.sha256(source_bytes).digest()
archive_stage = ".zip-stage-" + secrets.token_hex(16) + ".zip"
archive_fd = os.open(archive_stage, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=base_fd)
write_all(archive_fd, source_bytes)
os.fsync(archive_fd)
del source_bytes
os.lseek(archive_fd, 0, os.SEEK_SET)
staged_bytes = read_exact_fd(archive_fd, os.fstat(archive_fd).st_size, 33554432, "staged archive")
if hashlib.sha256(staged_bytes).digest() != archive_sha:
fail("staged archive SHA mismatch")
with zipfile.ZipFile(io.BytesIO(staged_bytes), "r") as archive:
infos = archive.infolist()
names = [entry.filename for entry in infos]
if len(names) != 4 or len(set(names)) != 4 or set(names) != set(required):
fail("unsafe-zip entries")
for entry in infos:
mode = (entry.external_attr >> 16) & 0xFFFF
if not stat.S_ISREG(mode) or entry.flag_bits & 1:
fail("ZIP contains a symlink or nonregular entry")
if entry.file_size < 1 or entry.file_size > 10485760:
fail("extracted file is unsafe")
payloads = {name: archive.read(name) for name in required}
if any(len(payloads[entry.filename]) != entry.file_size for entry in infos):
fail("extracted file size mismatch")
# Exercise the documented unzip prerequisite against the exact staged descriptor, not a path.
listing = subprocess.run(
["unzip", "-Z1", "/dev/fd/" + str(archive_fd)], pass_fds=(archive_fd,),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20, check=False,
)
if listing.returncode != 0 or listing.stdout.decode("utf8", "strict").splitlines() != names:
fail("unsafe-zip entries")
os.lseek(archive_fd, 0, os.SEEK_SET)
revalidated = read_exact_fd(archive_fd, len(staged_bytes), 33554432, "staged archive")
if hashlib.sha256(revalidated).digest() != archive_sha or revalidated != staged_bytes:
fail("staged archive SHA mismatch")
require_base_identity()
randomized = re.compile(br"(?:DWH|SIGNED|ACCESS|SECRET|SESSION)-[0-9a-f]{32}")
fixed = b"-".join([b"CANARY", b"MUST", b"BE", b"REJECTED"])
for data in payloads.values():
if b"P1 manually curated Evidence" in data or b"P1 curated table" in data or randomized.search(data) or fixed in data:
fail("export contains Evidence or secret canary bytes")
try:
manifest = json.loads(payloads["manifest.json"].decode("utf8"))
except Exception:
fail("export manifest schema mismatch")
hashed = required[1:]
files = manifest.get("files") if isinstance(manifest, dict) else None
if (not exact(manifest, ["schema_version", "workspace_id", "files"])
or manifest.get("schema_version") != 1 or manifest.get("workspace_id") != expected
or not exact(files, hashed)
or any(not isinstance(files[name], str) or not re.fullmatch(r"[0-9a-f]{64}", files[name]) for name in hashed)):
fail("export manifest workspace identity or schema mismatch")
for name in hashed:
if hashlib.sha256(payloads[name]).hexdigest() != files[name]:
fail("manifest hash mismatch")
yaml_helper = 'const fs=require("node:fs"),{createRequire}=require("node:module");try{const YAML=createRequire(process.argv[1])("yaml"),v=YAML.parse(fs.readFileSync(0,"utf8"));process.stdout.write(JSON.stringify(v?.workspace?.id??null));}catch{process.exit(2)}'
parsed = subprocess.run(["node", "-e", yaml_helper, package_json], input=payloads["workspace.yaml"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, check=False)
try:
descriptor_id = json.loads(parsed.stdout.decode("utf8")) if parsed.returncode == 0 else None
except Exception:
descriptor_id = None
if descriptor_id != expected:
fail("export descriptor workspace identity mismatch")
extract_stage = ".extract-stage-" + secrets.token_hex(16)
os.mkdir(extract_stage, 0o700, dir_fd=base_fd)
stage_fd = os.open(extract_stage, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=base_fd)
for name in required:
fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=stage_fd)
try:
write_all(fd, payloads[name])
os.fsync(fd)
finally:
os.close(fd)
os.fsync(stage_fd)
os.close(stage_fd)
stage_fd = None
require_base_identity()
try:
os.stat(output_name, dir_fd=base_fd, follow_symlinks=False)
fail("unsafe output path")
except FileNotFoundError:
pass
os.rename(extract_stage, output_name, src_dir_fd=base_fd, dst_dir_fd=base_fd)
extract_stage = None
published = True
os.fsync(base_fd)
require_base_identity()
except Exception as error:
if isinstance(error, RuntimeError):
print(str(error), file=sys.stderr)
else:
print("extraction operational failure (details redacted)", file=sys.stderr)
sys.exit_code = 1
finally:
if stage_fd is not None:
os.close(stage_fd)
if extract_stage is not None and base_fd is not None:
try:
remove_anchored_directory(extract_stage)
except Exception:
sys.exit_code = 1
if published and getattr(sys, "exit_code", 0) and base_fd is not None:
try:
remove_anchored_directory(output_name)
except Exception:
pass
if archive_fd is not None:
os.close(archive_fd)
if archive_stage is not None and base_fd is not None:
try:
os.unlink(archive_stage, dir_fd=base_fd)
os.fsync(base_fd)
except FileNotFoundError:
pass
if base_fd is not None:
os.close(base_fd)
if getattr(sys, "exit_code", 0):
raise SystemExit(sys.exit_code)
PY
`;}
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",extractCommand(repo,root)],["secret-scan.sh",`#!/usr/bin/env bash
set -euo pipefail
root=${quote(root)}
node --input-type=module - "$root" <<'NODE'
import { spawnSync } from "node:child_process";import { constants } from "node:fs";import { lstat, open, readdir } from "node:fs/promises";import { join, relative } from "node:path";
const root=process.argv[2],randomized=/(?:DWH|SIGNED|ACCESS|SECRET|SESSION)-[0-9a-f]{32}/,fixed=["CANARY","MUST","BE","REJECTED"].join("-");let found=false,filesystemCount=0,filesystemTotal=0,nameTotal=0;const gitDirs=new Set([join(root,"remote.git")]);const containsCanary=value=>randomized.test(value)||value.includes(fixed);const finding=kind=>{console.error("secret canary found in "+kind+" (path and value redacted)");found=true;};
async function maybeGitDir(path){try{const head=await lstat(join(path,"HEAD")),objects=await lstat(join(path,"objects"));if(head.isFile()&&objects.isDirectory()&&!head.isSymbolicLink()&&!objects.isSymbolicLink())gitDirs.add(path);}catch{}}
async function walk(path){for(const entry of await readdir(path,{withFileTypes:true})){if(++filesystemCount>200000)throw Error("bound");const child=join(path,entry.name),rel=relative(root,child),nameBytes=Buffer.from(entry.name),pathBytes=Buffer.from(rel);if(nameBytes.length>255||pathBytes.length>4096||(nameTotal+=nameBytes.length+pathBytes.length)>67108864)throw Error("bound");if(containsCanary(nameBytes.toString("latin1"))||containsCanary(pathBytes.toString("latin1")))finding("filesystem name bytes");if(entry.isSymbolicLink()){console.error("unsafe symlink during secret scan (path redacted)");found=true;continue;}if(entry.isDirectory()){if(entry.name===".git")await maybeGitDir(child);if(rel==="fixture-secrets")continue;await walk(child);continue;}if(!entry.isFile())throw Error("unsupported");let handle;try{handle=await open(child,constants.O_RDONLY|constants.O_NOFOLLOW);const before=await handle.stat();if(!before.isFile()||before.size>33554432)throw Error("bound");filesystemTotal+=before.size;if(filesystemTotal>1073741824)throw Error("bound");const bytes=await handle.readFile(),after=await handle.stat();if(bytes.length!==before.size||after.dev!==before.dev||after.ino!==before.ino||after.size!==before.size)throw Error("changed");const value=bytes.toString("latin1"),allowedRequest=rel==="requests/invalid-credential.json"&&value.includes(fixed)&&!randomized.test(value);if(containsCanary(value)&&!allowedRequest)finding("filesystem bytes");}finally{if(handle)await handle.close();}}}
function gitRun(args,options={}){const result=spawnSync("git",args,{...options,stdio:[options.input===undefined?"ignore":"pipe","pipe","pipe"]});if(result.error||result.status!==0)throw Error("git");return result.stdout;}
function scanGit(gitDir){const listing=gitRun(["--git-dir",gitDir,"cat-file","--batch-all-objects","--unordered","--batch-check=%(objectname) %(objecttype) %(objectsize)"],{encoding:"utf8",maxBuffer:16*1024*1024}).trim(),objects=listing?listing.split("\\n"):[];if(objects.length>100000)throw Error("bound");let total=0;for(const line of objects){const match=line.match(/^([0-9a-f]{40,64}) (blob|commit|tree|tag) (\\d+)$/);if(!match)throw Error("git");const[,oid,type,sizeText]=match,size=Number(sizeText);total+=size;if(!Number.isSafeInteger(size)||size>33554432||total>536870912)throw Error("bound");const raw=gitRun(["--git-dir",gitDir,"cat-file",type,oid],{maxBuffer:Math.max(1024,size+1)});if(raw.length!==size)throw Error("changed");if(containsCanary(raw.toString("latin1")))finding(type==="blob"?"Git blob":"Git object");}}
try{await walk(root);for(const gitDir of gitDirs)scanGit(gitDir);if(found)process.exitCode=1;else console.log("no fixture secret canary outside fixture-secrets or in any bounded Git object");}catch{console.error("secret scan operational failure (details redacted)");process.exitCode=2;}
NODE
`],["absence-check.sh",`#!/usr/bin/env bash
set -euo pipefail
root=${quote(root)}
node --input-type=module - "$root" <<'NODE'
import { readdir } from "node:fs/promises";import { join,relative } from "node:path";
const root=process.argv[2];let count=0,rejected=false;const artifactName=name=>name.toUpperCase()==="ACTIVE"||/(?:materiali[sz](?:e|ed|ation)|preprocess|embedding|qdrant|retention)/i.test(name);
async function walk(path){for(const entry of await readdir(path,{withFileTypes:true})){if(++count>200000)throw Error("bound");const child=join(path,entry.name),parts=relative(root,child).split("/");if(parts.some((part,index)=>part==="artifacts"&&parts[index+1]==="evidence")||artifactName(entry.name))rejected=true;if(entry.isSymbolicLink())continue;if(entry.isDirectory()&&entry.name!==".git")await walk(child);}}
try{await walk(root);if(rejected){console.error("unexpected out-of-scope P2+ artifact (path redacted)");process.exitCode=1;}else console.log("no out-of-scope runtime artifact found");}catch{console.error("out-of-scope artifact check failed safely (details redacted)");process.exitCode=2;}
NODE
`]]){await atomicWrite(join(commands,name),body,0o700);await chmod(join(commands,name),0o700);}}
export async function prepareManual(options={}){
const unknown=Object.keys(options).filter(key=>!["repositoryRoot","skipBuild"].includes(key));if(unknown.length)throw new Error(`unknown or automated-run prepare input: ${unknown.join(", ")}`);
const{repositoryRoot=defaultRepositoryRoot,skipBuild=false}=options,repo=realpathSync(repositoryRoot),root=fixedManualRoot(repo),lifecycle=await acquireLifecycle(repo,"prepare");let entryBinding,ownershipCreated=false;
try{
await requireLifecycleContext(lifecycle);await checkPrerequisites(repo);if(!skipBuild)await run("npm",["--prefix",join(repo,"backend"),"run","build"]);await requireLifecycleContext(lifecycle);
entryBinding=await readBoundEntrypoint(repo);const entrypoint=entryBinding.identity;await entryBinding.handle.close();entryBinding=undefined;
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;}await bindLifecycleRoot(lifecycle,root);
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 requireLifecycleContext(lifecycle,{root:true});}
const distManifestValue=await buildDistManifest(repo),distManifestRecord=await exclusiveRecord(join(root,"installation/runtime/backend-dist.manifest.json"),distManifestValue,"production distribution manifest"),distManifest={path:distManifestRecord.path,dev:distManifestRecord.dev,ino:distManifestRecord.ino,size:distManifestRecord.bytes.length,sha256:createHash("sha256").update(distManifestRecord.bytes).digest("hex")};await requireLifecycleContext(lifecycle,{root:true});
const nonce=randomBytes(32).toString("hex"),createdAt=new Date().toISOString();await exclusiveRecord(join(root,"ownership.json"),ownedValue(repo,root,nonce,{entrypoint,distManifest,createdAt}),"manual ownership");ownershipCreated=true;await requireLifecycleContext(lifecycle,{root:true});
const backendLogPath=join(root,"logs/backend.log"),backendLogHandle=await open(backendLogPath,"wx",0o600);let backendLogEntry;try{await backendLogHandle.chmod(0o600);await backendLogHandle.sync();backendLogEntry=await backendLogHandle.stat();}finally{await backendLogHandle.close();}directorySync(dirname(backendLogPath));const backendLog={path:backendLogPath,dev:backendLogEntry.dev,ino:backendLogEntry.ino};
await requireLifecycleContext(lifecycle,{root:true});
try{await initializeGit(root);}catch(error){try{await requireLifecycleContext(lifecycle,{root:true});}catch{await cleanupFailedPrepare(repo,lifecycle);throw new Error("manual acceptance parent or root identity changed during prepare");}throw error;}await requireLifecycleContext(lifecycle,{root:true});
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);await requireLifecycleContext(lifecycle,{root:true});await atomicWrite(join(root,"ownership.json"),`${JSON.stringify(ownedValue(repo,root,nonce,{backendLog,entrypoint,distManifest,stage:"READY",createdAt}),null,2)}\n`);await requireLifecycleContext(lifecycle,{root:true});return{repositoryRoot:repo,root,nonce};
}catch(error){
if(entryBinding)await entryBinding.handle.close().catch(()=>{});
if(ownershipCreated){try{await requireLifecycleContext(lifecycle,{root:true});}catch{await cleanupFailedPrepare(repo,lifecycle).catch(()=>{});throw new Error("manual acceptance parent or root identity changed during prepare");}}
throw error;
}finally{await removeExactRecord(lifecycle);}
}
function portAvailable(port,label=`${HOST}:${port}`){return new Promise((resolvePromise,reject)=>{const server=net.createServer();server.once("error",error=>error.code==="EADDRINUSE"?reject(new Error(`${label} is occupied`)):reject(error));server.listen({host:HOST,port,exclusive:true},()=>server.close(()=>resolvePromise()));});}
async function requireCanonicalDirectory(path,label){const entry=await lstat(path);if(!entry.isDirectory()||entry.isSymbolicLink()||await realpath(path)!==path)throw new Error(`${label} directory identity is unsafe`);return entry;}
async function requireAbsent(path,label){try{await lstat(path);throw new Error(`${label} is legacy or unsafe`);}catch(error){if(error.code!=="ENOENT")throw error;}}
async function validateServeFilesystem(repo,root,owned){
if(root!==fixedManualRoot(repo))throw new Error("owned root identity is unsafe");
for(const [path,label] of [
[repo,"repository root"],[join(repo,".artifacts"),"artifact root"],[join(repo,".artifacts/manual-acceptance"),"manual root ancestor"],[root,"owned root"],
[join(root,"installation"),"owned installation"],[join(root,"installation/runtime"),"owned runtime"],[join(root,"installation/data"),"owned data"],
[join(root,"installation/registry"),"owned registry"],[join(root,"fixture-secrets"),"owned secrets"],[join(root,"logs"),"owned logs"],
[join(repo,"backend"),"backend root"],[join(repo,"backend/dist"),"backend distribution"],
])await requireCanonicalDirectory(path,label);
await requireAbsent(legacySupervisorPath(root),"legacy supervisor");
if(owned.stage!=="READY")throw new Error("manual acceptance preparation is incomplete");
const script=join(repo,"backend/dist/server.js");await requireEntrypointPathIdentity(owned.entrypoint);const manifestRecord=await readBoundDistManifest(repo,owned);try{await validateDistFiles(repo,manifestRecord.files);}finally{await manifestRecord.handle.close();}
const logPath=join(root,"logs/backend.log");
if(owned.backendLog?.path!==logPath)throw new Error("backend log ownership identity is unsafe");
return{script,logPath};
}
function openOwnedBackendLog(owned,logPath){
if(!Number.isInteger(constants.O_NOFOLLOW))throw new Error("backend log no-follow protection is unavailable");
let fd;
try{
fd=openSync(logPath,constants.O_WRONLY|constants.O_APPEND|constants.O_NOFOLLOW);
const entry=fstatSync(fd),pathEntry=lstatSync(logPath);
if(!entry.isFile()||(entry.mode&0o777)!==0o600||entry.nlink!==1||entry.dev!==owned.backendLog.dev||entry.ino!==owned.backendLog.ino||pathEntry.isSymbolicLink()||!pathEntry.isFile()||pathEntry.dev!==entry.dev||pathEntry.ino!==entry.ino)throw new Error("backend log identity is unsafe");
return fd;
}catch(error){if(fd!==undefined)closeSync(fd);throw error;}
}
async function ensureRuntimeDirectory(path){try{await mkdir(path,{mode:0o700});}catch(error){if(error.code!=="EEXIST")throw error;}const entry=await requireCanonicalDirectory(path,"owned runtime child");if((entry.mode&0o077)!==0)throw new Error("owned runtime child mode is unsafe");}
async function processStart(pid){return (await run("ps",["-p",String(pid),"-o","lstart="])).stdout.trim();}
async function processArgs(pid){return (await run("ps",["-ww","-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()||(entry.mode&0o777)!==0o600)throw new Error("backend PID record is unsafe");const bytes=await readFile(path,"utf8");let value;try{value=JSON.parse(bytes);}catch{throw new Error("backend PID record is malformed");}return{path,bytes,value,dev:entry.dev,ino:entry.ino};}
async function validateProcess(repo,root,owned,pidRecord){
const script=join(repo,"backend/dist/server.js"),entrypoint=owned.entrypoint;
if(pidRecord.schemaVersion!==1||pidRecord.kind!=="p1-manual-backend"||pidRecord.status!=="RUNNING"||!Number.isSafeInteger(pidRecord.pid)||pidRecord.pid<2||!HEX64.test(pidRecord.reservationNonce??"")||pidRecord.nonce!==owned.nonce||pidRecord.root!==root||pidRecord.repositoryRoot!==repo||pidRecord.executable!==process.execPath||pidRecord.preload!==PRELOAD||pidRecord.script!==script||JSON.stringify(pidRecord.entrypoint)!==JSON.stringify(entrypoint)||!pidRecord.startIdentity||pidRecord.control?.host!==HOST||pidRecord.control?.port!==CONTROL_PORT)throw new Error("backend process identity mismatch; refusing cooperative control");
await requireEntrypointPathIdentity(entrypoint);const manifestRecord=await readBoundDistManifest(repo,owned);try{await validateDistFiles(repo,manifestRecord.files);}finally{await manifestRecord.handle.close();}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)]);
const expectedArgs=[pidRecord.executable,"--import",pidRecord.preload,pidRecord.script,`--p1-manual-nonce=${owned.nonce}`,`--p1-root=${root}`,`--p1-control-nonce=${pidRecord.reservationNonce}`,`--p1-entry-sha256=${entrypoint.sha256}`,`--p1-entry-dev=${entrypoint.dev}`,`--p1-entry-ino=${entrypoint.ino}`].join(" ");
if(start!==pidRecord.startIdentity||cwd!==repo||executable!==realpathSync(pidRecord.executable)||args!==expectedArgs)throw new Error("backend process identity mismatch; refusing cooperative control");return true;
}
async function waitForChildExit(child,milliseconds){if(!child||child.exitCode!==null||child.signalCode!==null)return true;return await Promise.race([new Promise(resolvePromise=>child.once("exit",()=>resolvePromise(true))),new Promise(resolvePromise=>setTimeout(()=>resolvePromise(child.exitCode!==null||child.signalCode!==null),milliseconds))]);}
async function healthStatus(){return await new Promise((resolvePromise,reject)=>{const request=http.get({host:HOST,port:PORT,path:"/health",timeout:500},response=>{const status=response.statusCode;response.resume();response.once("end",()=>resolvePromise(status));});request.once("timeout",()=>request.destroy(new Error("backend health readiness timeout")));request.once("error",reject);});}
function exactControlIdentity(answer,child,owned,root,reservationNonce){return answer?.pid===child.pid&&answer?.nonce===owned.nonce&&answer?.controlNonce===reservationNonce&&answer?.root===root&&answer?.control?.host===HOST&&answer?.control?.port===CONTROL_PORT;}
function exactOwnedListener(answer,generation){return answer?.listener?.listening===true&&answer.listener.host===HOST&&answer.listener.port===PORT&&Number.isSafeInteger(answer.listener.generation)&&answer.listener.generation>0&&(generation===undefined||answer.listener.generation===generation);}
export async function serveManual({repositoryRoot=defaultRepositoryRoot,beforeSpawn}={}){
const repo=realpathSync(repositoryRoot),lifecycle=await acquireLifecycle(repo,"serve");let pidRecord,child,controlObserved=false,logFd,entryBinding,manifestBinding;
try{
const owned=await readManualOwnership({repositoryRoot:repo}),root=owned.root;await bindLifecycleRoot(lifecycle,root);
if(owned.listener.host!==HOST||owned.listener.port!==PORT)throw new Error("non-loopback or unexpected bind refused");
const{script,logPath}=await validateServeFilesystem(repo,root,owned);await requireLifecycleContext(lifecycle,{root:true});
logFd=openOwnedBackendLog(owned,logPath);entryBinding=await readBoundEntrypoint(repo);if(JSON.stringify(entryBinding.identity)!==JSON.stringify(owned.entrypoint))throw new Error("production entrypoint identity changed");manifestBinding=await readBoundDistManifest(repo,owned);
const reservationNonce=randomBytes(32).toString("hex");pidRecord=await exclusiveRecord(join(root,"backend.pid"),{schemaVersion:1,kind:"p1-manual-backend-reservation",status:"RESERVED",reservationNonce,nonce:owned.nonce,root,repositoryRoot:repo},"backend PID record");
await Promise.all([portAvailable(PORT),portAvailable(CONTROL_PORT,`${HOST}:${CONTROL_PORT} control port`)]);await requireLifecycleContext(lifecycle,{root:true});
await ensureRuntimeDirectory(join(root,"installation/runtime/home"));await ensureRuntimeDirectory(join(root,"installation/runtime/tmp"));await ensureRuntimeDirectory(join(root,"installation/runtime/tht-home"));await requireLifecycleContext(lifecycle,{root:true});
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")};
if(beforeSpawn)await beforeSpawn({script,entrypoint:{...owned.entrypoint}});await requireLifecycleContext(lifecycle,{root:true});
const entryArgs=[`--p1-entry-sha256=${owned.entrypoint.sha256}`,`--p1-entry-dev=${owned.entrypoint.dev}`,`--p1-entry-ino=${owned.entrypoint.ino}`];
child=spawn(process.execPath,["--import",PRELOAD,script,`--p1-manual-nonce=${owned.nonce}`,`--p1-root=${root}`,`--p1-control-nonce=${reservationNonce}`,...entryArgs],{cwd:repo,env,detached:true,stdio:["ignore",logFd,logFd,entryBinding.handle.fd,manifestBinding.handle.fd]});
await entryBinding.handle.close();entryBinding=undefined;await manifestBinding.handle.close();manifestBinding=undefined;closeSync(logFd);logFd=undefined;
let start="";for(let n=0;n<80;n++){if(child.exitCode!==null)break;try{start=await processStart(child.pid);if(start)break;}catch{}await new Promise(r=>setTimeout(r,25));}
if(!start)throw new Error("backend failed before process identity could be recorded");await requireLifecycleContext(lifecycle,{root:true});
pidRecord=await replaceExactRecord(pidRecord,{schemaVersion:1,kind:"p1-manual-backend-reservation",status:"STARTING",pid:child.pid,reservationNonce,nonce:owned.nonce,root,repositoryRoot:repo,executable:process.execPath,preload:PRELOAD,script,entrypoint:owned.entrypoint,startIdentity:start,control:{host:HOST,port:CONTROL_PORT}});
const deadline=Date.now()+7000;let readyAnswer,listenerGeneration;
while(Date.now()<deadline&&child.exitCode===null){
let status;try{status=await controlRequest({host:HOST,port:CONTROL_PORT},{action:"status",nonce:reservationNonce});}catch{await new Promise(r=>setTimeout(r,50));continue;}
if(!exactControlIdentity(status,child,owned,root,reservationNonce)||status.status!=="STARTING")throw new Error("backend control status identity mismatch");controlObserved=true;
if(!exactOwnedListener(status)){await new Promise(r=>setTimeout(r,50));continue;}listenerGeneration=status.listener.generation;
await requireLifecycleContext(lifecycle,{root:true});await requireEntrypointPathIdentity(owned.entrypoint);
let httpCode;try{httpCode=await healthStatus();}catch{await new Promise(r=>setTimeout(r,50));continue;}if(!Number.isSafeInteger(httpCode)||httpCode<200||httpCode>=300)throw new Error(`backend health readiness returned HTTP ${httpCode}`);
readyAnswer=await controlRequest({host:HOST,port:CONTROL_PORT},{action:"ready",nonce:reservationNonce});if(!exactControlIdentity(readyAnswer,child,owned,root,reservationNonce)||readyAnswer.status!=="READY"||!exactOwnedListener(readyAnswer,listenerGeneration))throw new Error("backend READY listener acknowledgement identity mismatch");
const finalHealth=await healthStatus();if(!Number.isSafeInteger(finalHealth)||finalHealth<200||finalHealth>=300)throw new Error("backend final health readiness failed");
const finalStatus=await controlRequest({host:HOST,port:CONTROL_PORT},{action:"status",nonce:reservationNonce});if(!exactControlIdentity(finalStatus,child,owned,root,reservationNonce)||finalStatus.status!=="READY"||!exactOwnedListener(finalStatus,listenerGeneration))throw new Error("backend final listener identity mismatch");readyAnswer=finalStatus;break;
}
if(!readyAnswer)throw new Error("backend readiness failed; inspect owned backend log and starting PID record");
const runningValue={schemaVersion:1,kind:"p1-manual-backend",status:"RUNNING",pid:child.pid,reservationNonce,nonce:owned.nonce,root,repositoryRoot:repo,executable:process.execPath,preload:PRELOAD,script,entrypoint:owned.entrypoint,startIdentity:start,control:{host:HOST,port:CONTROL_PORT},listener:{host:HOST,port:PORT,generation:listenerGeneration}};
await requireLifecycleContext(lifecycle,{root:true});await validateProcess(repo,root,owned,runningValue);if(child.exitCode!==null||!alive(child.pid))throw new Error("backend exited before RUNNING publication");pidRecord=await replaceExactRecord(pidRecord,runningValue);await requireLifecycleContext(lifecycle,{root:true});
const publishedStatus=await controlRequest(runningValue.control,{action:"status",nonce:reservationNonce});if(!exactControlIdentity(publishedStatus,child,owned,root,reservationNonce)||publishedStatus.status!=="READY"||!exactOwnedListener(publishedStatus,listenerGeneration)){await removeExactRecord(pidRecord);throw new Error("backend listener changed during RUNNING publication");}
child.unref();return child.pid;
}catch(error){
if(entryBinding)await entryBinding.handle.close().catch(()=>{});if(manifestBinding)await manifestBinding.handle.close().catch(()=>{});if(logFd!==undefined){closeSync(logFd);logFd=undefined;}
if(child&&controlObserved){try{const value=pidRecord?JSON.parse(pidRecord.bytes):undefined;await controlRequest({host:HOST,port:CONTROL_PORT},{action:"stop",nonce:value?.reservationNonce});}catch{}await waitForChildExit(child,3000);}else if(child)await waitForChildExit(child,8500);
if(pidRecord&&(!child||child.exitCode!==null||child.signalCode!==null||!alive(child.pid)))await removeExactRecord(pidRecord).catch(()=>{});throw error;
}finally{if(entryBinding)await entryBinding.handle.close().catch(()=>{});if(manifestBinding)await manifestBinding.handle.close().catch(()=>{});if(logFd!==undefined)closeSync(logFd);await removeExactRecord(lifecycle);}
}
export async function stopManual({repositoryRoot=defaultRepositoryRoot}={}){const repo=realpathSync(repositoryRoot),lifecycle=await acquireLifecycle(repo,"stop");try{const owned=await readManualOwnership({repositoryRoot:repo}),root=owned.root;if(owned.stage!=="READY")throw new Error("owned backend was never prepared");await bindLifecycleRoot(lifecycle,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 requireLifecycleContext(lifecycle,{root:true});await validateProcess(repo,root,owned,record.value);const answer=await controlRequest(record.value.control,{action:"stop",nonce:record.value.reservationNonce});if(answer.status!=="STOPPING"||answer.pid!==record.value.pid||answer.nonce!==owned.nonce||answer.controlNonce!==record.value.reservationNonce||answer.root!==root||answer.control?.host!==HOST||answer.control?.port!==CONTROL_PORT)throw new Error("backend cooperative stop acknowledgement mismatch; PID record retained");for(let n=0;n<100;n++){if(!alive(record.value.pid)){await requireLifecycleContext(lifecycle,{root:true});await removeExactRecord(record);await requireLifecycleContext(lifecycle,{root:true});return;}await new Promise(r=>setTimeout(r,100));}throw new Error("owned backend did not stop cooperatively; operator must intervene; PID record retained");}finally{await removeExactRecord(lifecycle);}}
const ANCHORED_REMOVE_SOURCE=String.raw`import os,stat,sys
parent,parent_dev,parent_ino,root_dev,root_ino,tomb=sys.argv[1:]
pfd=rfd=None
def die(): raise RuntimeError("anchored cleanup refused")
def clear(fd):
names=os.listdir(fd)
if len(names)>200000: die()
for name in names:
if name in (".",".."): die()
item=os.stat(name,dir_fd=fd,follow_symlinks=False)
if stat.S_ISDIR(item.st_mode):
child=os.open(name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd)
try: clear(child)
finally: os.close(child)
os.rmdir(name,dir_fd=fd)
elif stat.S_ISREG(item.st_mode) or stat.S_ISLNK(item.st_mode): os.unlink(name,dir_fd=fd)
else: die()
try:
pfd=os.open(parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
ps=os.fstat(pfd)
if (ps.st_dev,ps.st_ino)!=(int(parent_dev),int(parent_ino)): die()
rfd=os.open("p1",os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=pfd)
rs=os.fstat(rfd)
if (rs.st_dev,rs.st_ino)!=(int(root_dev),int(root_ino)): die()
try: os.stat(tomb,dir_fd=pfd,follow_symlinks=False); die()
except FileNotFoundError: pass
os.rename("p1",tomb,src_dir_fd=pfd,dst_dir_fd=pfd);os.fsync(pfd)
clear(rfd);os.close(rfd);rfd=None;os.rmdir(tomb,dir_fd=pfd);os.fsync(pfd)
except Exception:
print("anchored cleanup refused (details redacted)",file=sys.stderr);raise SystemExit(1)
finally:
if rfd is not None: os.close(rfd)
if pfd is not None: os.close(pfd)
`;
async function anchoredRemoveOwnedRoot(lifecycle,owned){const tomb=`.deleting-p1-${owned.nonce.slice(0,16)}`;try{await run("python3",["-c",ANCHORED_REMOVE_SOURCE,lifecycle.parentPath,String(lifecycle.parentEntry.dev),String(lifecycle.parentEntry.ino),String(lifecycle.rootEntry.dev),String(lifecycle.rootEntry.ino),tomb]);}catch{throw new Error("anchored cleanup refused; owned identities changed");}}
export async function cleanupManual({repositoryRoot=defaultRepositoryRoot}={}){const repo=realpathSync(repositoryRoot),lifecycle=await acquireLifecycle(repo,"cleanup");try{const owned=await readManualOwnership({repositoryRoot:repo}),root=owned.root;await bindLifecycleRoot(lifecycle,root);try{const record=await readPid(root);if(record.value.status==="RUNNING"&&alive(record.value.pid)){await validateProcess(repo,root,owned,record.value);throw new Error("owned backend is live; run stop first");}throw new Error("stale or starting 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");await requireLifecycleContext(lifecycle,{root:true});await anchoredRemoveOwnedRoot(lifecycle,owned);await requireLifecycleContext(lifecycle);}finally{await removeExactRecord(lifecycle);}}
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();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,787 @@
import assert from "node:assert/strict";
import { execFile, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, 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 symlink(new URL("../node_modules", import.meta.url).pathname, join(root, "backend", "node_modules"), "dir");
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("public wrapper exposes only four actions and rejects automated-run prepare input", async () => {
const wrapper=new URL("../../scripts/p1-manual-acceptance.sh",import.meta.url),source=await readFile(wrapper,"utf8");
assert.match(source,/prepare\|serve\|stop\|cleanup/); assert.doesNotMatch(source,/integration\|automated|prepare\|serve\|stop\|cleanup\|/);
await assert.rejects(execFileAsync("bash",[wrapper.pathname,"prepare",".artifacts/p1-integration/run"]),error=>error.code===2&&/usage:/.test(error.stderr));
});
test("prepare rejects unknown automated-run input before creating its root", async () => {
const repo = await fakeRepo();
await assert.rejects(
prepareManual({ repositoryRoot: repo, skipBuild: true, automatedRun: join(repo, ".artifacts", "p1-integration") }),
/unknown|automated/i,
);
await assert.rejects(lstat(fixedManualRoot(repo)));
});
test("prepare requires the non-Task-8 tht prerequisite before creating state", async () => {
const repo = await fakeRepo(); await rm(join(repo, "harness", ".venv", "bin", "tht"));
await assert.rejects(prepareManual({ repositoryRoot: repo, skipBuild: true }), /missing prerequisite.*tht/);
await assert.rejects(lstat(fixedManualRoot(repo)));
});
test("prepare and permanent docs declare the python3 extractor prerequisite", async () => {
const source=await readFile(new URL("./p1-manual-acceptance.mjs",import.meta.url),"utf8"),docs=await readFile(new URL("../../docs/testing/p1-manual-acceptance.md",import.meta.url),"utf8");
assert.match(source,/for\(const command of \[.*["']python3["']/s); assert.match(docs,/python3/);
});
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", "logs/backend.log", "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); for(const id of ["p1-filesystem","p1-http","p1-s3"])assert.match(guide,new RegExp(`extract-export\\.sh[^\\n]+${id}`));
for(const contract of [/stable repository-root/,/ownership-first recovery/,/one production Node PID owns both/,/opened no-follow descriptor/,/arbitrary `.git` repository/,/name\/path bytes/,/artifacts\/evidence/,/opened no-follow `rendered` directory/])assert.match(guide,contract);
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,/batch-all-objects/); assert.match(scan,/cat-file/); assert.match(scan,/maybeGitDir/); 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, { startupDelay = 0, healthStatus = 200, marker } = {}) {
await writeFile(join(repo, "backend", "dist", "server.js"), `import http from "node:http";
${marker ? `import { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(marker)}, "executed");` : ""}
const server=http.createServer((req,res)=>{res.statusCode=req.url==="/health"?${healthStatus}:200;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}));});
setTimeout(()=>server.listen(Number(process.env.PORT),process.env.HOST),${startupDelay});
`);
}
async function matchingManualServerPids(root, nonce) {
const { stdout } = await execFileAsync("ps", ["ax", "-o", "pid=,command="]);
const nonceArg = `--p1-manual-nonce=${nonce}`, rootArg = `--p1-root=${root}`;
return stdout.split("\n").filter(line => line.includes(nonceArg) && line.includes(rootArg))
.map(line => Number(line.trim().match(/^(\d+)/)?.[1])).filter(Number.isSafeInteger);
}
async function listenerPids() {
try {
const { stdout } = await execFileAsync("lsof", ["-nP", "-t", "-iTCP:8791", "-sTCP:LISTEN"]);
return [...new Set(stdout.trim().split("\n").filter(Boolean).map(Number))];
} catch (error) {
if (error.code === 1) return [];
throw error;
}
}
test("prepare and cleanup share one external lifecycle lock for the whole transaction", { concurrency: false }, async () => {
const repo=await fakeRepo(),realGit=(await execFileAsync("which",["git"])).stdout.trim();
const bin=join(repo,"blocking-bin"),entered=join(repo,"prepare-entered"),release=join(repo,"prepare-release");
await mkdir(bin); await writeFile(join(bin,"git"),`#!/bin/sh
if [ "$1" = init ] && [ ! -e ${JSON.stringify(entered)} ]; then
: > ${JSON.stringify(entered)}
n=0
while [ ! -e ${JSON.stringify(release)} ] && [ "$n" -lt 250 ]; do sleep 0.02; n=$((n + 1)); done
[ -e ${JSON.stringify(release)} ] || exit 99
fi
exec ${JSON.stringify(realGit)} "$@"
`,{mode:0o700});
const priorPath=process.env.PATH; process.env.PATH=`${bin}:${priorPath}`;
try {
const preparing=prepareManual({repositoryRoot:repo,skipBuild:true});
for(let n=0;n<200;n++){try{await lstat(entered);break;}catch{} await new Promise(r=>setTimeout(r,10));}
await lstat(entered);
const lock=join(repo,".p1-manual-acceptance.lifecycle.lock");
const lockEntry=await lstat(lock); assert.equal(lockEntry.isFile(),true); assert.equal(lockEntry.mode&0o777,0o600);
const lockBytes=await readFile(lock,"utf8"),lockValue=JSON.parse(lockBytes);
assert.deepEqual(Object.keys(lockValue).sort(),["kind","lifecycleNonce","operation","repositoryRoot","root","schemaVersion"].sort());
assert.equal(lockValue.kind,"p1-manual-lifecycle"); assert.equal(lockValue.operation,"prepare");
assert.match(lockValue.lifecycleNonce,/^[0-9a-f]{64}$/); assert.equal(lockValue.repositoryRoot,repo); assert.equal(lockValue.root,fixedManualRoot(repo));
assert.equal(lockBytes,`${JSON.stringify(lockValue,null,2)}\n`);
await assert.rejects(cleanupManual({repositoryRoot:repo}),/lifecycle lock.*exists|operator inspection/i);
await writeFile(release,"go"); const run=await preparing;
await readManualOwnership({repositoryRoot:repo}); await assert.rejects(lstat(lock));
await cleanupManual({repositoryRoot:repo}); await assert.rejects(lstat(run.root));
} finally { process.env.PATH=priorPath; await writeFile(release,"go").catch(()=>{}); }
});
test("the external lifecycle lock prevents old-root/new-root ABA and ownership is read only under lock", async () => {
const source=await readFile(new URL("./p1-manual-acceptance.mjs",import.meta.url),"utf8");
assert.match(source,/\.p1-manual-acceptance\.lifecycle\.lock/);
const repo=await fakeRepo(),run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const lockPath=join(repo,".p1-manual-acceptance.lifecycle.lock"),nonce="f".repeat(64),bytes=`${nonce}\n`;
const handle=await open(lockPath,"wx",0o600); await handle.writeFile(bytes); await handle.sync();
try {
const old=JSON.parse(await readFile(join(run.root,"ownership.json"),"utf8"));
await rm(run.root,{recursive:true}); await mkdir(run.root,{recursive:true});
await writeFile(join(run.root,"ownership.json"),JSON.stringify({...old,nonce:"e".repeat(64)}),{mode:0o600});
for(const operation of [serveManual,stopManual,cleanupManual]){
await assert.rejects(operation({repositoryRoot:repo}),/lifecycle lock.*exists|operator inspection/i);
assert.equal((await lstat(run.root)).isDirectory(),true);
}
} finally { await handle.close(); await rm(lockPath,{force:true}); }
});
test("external lifecycle lock release preserves an exact-byte inode replacement", { concurrency: false }, async () => {
const repo=await fakeRepo(),realGit=(await execFileAsync("which",["git"])).stdout.trim();
const bin=join(repo,"replacement-bin"),entered=join(repo,"replacement-entered"),release=join(repo,"replacement-release");
await mkdir(bin); await writeFile(join(bin,"git"),`#!/bin/sh
if [ "$1" = init ] && [ ! -e ${JSON.stringify(entered)} ]; then
: > ${JSON.stringify(entered)}
n=0
while [ ! -e ${JSON.stringify(release)} ] && [ "$n" -lt 250 ]; do sleep 0.02; n=$((n + 1)); done
[ -e ${JSON.stringify(release)} ] || exit 99
fi
exec ${JSON.stringify(realGit)} "$@"
`,{mode:0o700});
const priorPath=process.env.PATH; process.env.PATH=`${bin}:${priorPath}`; let preparing;
try {
preparing=prepareManual({repositoryRoot:repo,skipBuild:true});
for(let n=0;n<200;n++){try{await lstat(entered);break;}catch{} await new Promise(r=>setTimeout(r,10));}
await lstat(entered);
const lock=join(repo,".p1-manual-acceptance.lifecycle.lock"),bytes=await readFile(lock);
const original=await lstat(lock),replacement=join(dirname(lock),".replacement-lifecycle-lock");
await writeFile(replacement,bytes,{mode:0o600}); const replacementEntry=await lstat(replacement);
assert.notEqual(replacementEntry.ino,original.ino); await rename(replacement,lock); await writeFile(release,"go");
await assert.rejects(preparing,/lifecycle record.*unsafe|lifecycle record.*changed|operator inspection/i); preparing=undefined;
const retained=await lstat(lock); assert.equal(retained.dev,replacementEntry.dev); assert.equal(retained.ino,replacementEntry.ino);
assert.deepEqual(await readFile(lock),bytes);
} finally {
process.env.PATH=priorPath; await writeFile(release,"go").catch(()=>{}); if(preparing)await preparing.catch(()=>{});
}
});
test("prepare records one regular 0600 backend log and no generated supervisor", async () => {
const repo=await fakeRepo(),run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const owned=await readManualOwnership({repositoryRoot:repo}),entry=await lstat(join(run.root,"logs/backend.log"));
assert.equal(entry.isFile(),true); assert.equal(entry.isSymbolicLink(),false); assert.equal(entry.mode&0o777,0o600);
assert.deepEqual(owned.backendLog,{path:join(run.root,"logs/backend.log"),dev:entry.dev,ino:entry.ino});
await assert.rejects(lstat(join(run.root,"installation/runtime/p1-backend-supervisor.mjs")));
});
// A delayed real listener leaves the pre-fix port-check/spawn window open long enough for every
// overlapping call. The backend.pid reservation, rather than scheduler timing, must pick one owner.
test("concurrent serves reserve one exact process and leave no orphan after stop", { concurrency: false }, async () => {
const repo=await fakeRepo(); await installFakeServer(repo,{startupDelay:400});
const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const owned=await readManualOwnership({repositoryRoot:repo}); let winner;
try {
const results=await Promise.allSettled(Array.from({length:12},()=>serveManual({repositoryRoot:repo})));
const fulfilled=results.filter(result=>result.status==="fulfilled");
assert.equal(fulfilled.length,1,`one serve fulfills: ${results.map(result=>result.status).join(",")}`);
assert.equal(results.filter(result=>result.status==="rejected").length,11);
winner=fulfilled[0].value;
const pidPath=join(run.root,"backend.pid"),record=JSON.parse(await readFile(pidPath,"utf8")),entry=await lstat(pidPath);
assert.equal(entry.mode&0o777,0o600); assert.equal(record.status,"RUNNING");
assert.match(record.reservationNonce,/^[0-9a-f]{64}$/); assert.equal(record.pid,winner);
assert.equal(record.nonce,owned.nonce); assert.equal(record.root,run.root); assert.equal(record.repositoryRoot,repo);
assert.deepEqual(await matchingManualServerPids(run.root,owned.nonce),[winner]);
assert.deepEqual(await listenerPids(),[winner]); assert.doesNotThrow(()=>process.kill(winner,0));
await stopManual({repositoryRoot:repo});
await assert.rejects(lstat(pidPath)); assert.deepEqual(await matchingManualServerPids(run.root,owned.nonce),[]);
assert.deepEqual(await listenerPids(),[]); assert.throws(()=>process.kill(winner,0));
await cleanupManual({repositoryRoot:repo}); await assert.rejects(lstat(run.root));
} finally {
for(const pid of await matchingManualServerPids(run.root,owned.nonce))try{process.kill(pid,"SIGTERM");}catch{}
await new Promise(resolvePromise=>setTimeout(resolvePromise,50));
for(const pid of await matchingManualServerPids(run.root,owned.nonce))try{process.kill(pid,"SIGKILL");}catch{}
await rm(run.root,{recursive:true,force:true});
}
});
test("cooperative stop is serialized and production never sends a numeric terminating signal", { concurrency: false }, async () => {
const source=await readFile(new URL("./p1-manual-acceptance.mjs",import.meta.url),"utf8");
assert.doesNotMatch(source,/process\.kill\([^,]+,\s*["']SIG(?:TERM|KILL|INT)/);
const repo=await fakeRepo(); await installFakeServer(repo); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const owned=await readManualOwnership({repositoryRoot:repo}); const pid=await serveManual({repositoryRoot:repo});
const record=JSON.parse(await readFile(join(run.root,"backend.pid"),"utf8")); assert.equal(record.control.host,"127.0.0.1");
const unauthorized=await new Promise((resolvePromise,reject)=>{const socket=net.createConnection(record.control),timer=setTimeout(()=>socket.destroy(new Error("control timeout")),1000);let bytes="";socket.setEncoding("utf8");socket.on("connect",()=>socket.end(JSON.stringify({action:"stop",nonce:"0".repeat(64)})));socket.on("data",chunk=>bytes+=chunk);socket.on("error",reject);socket.on("close",()=>{clearTimeout(timer);resolvePromise(bytes);});});
assert.equal(unauthorized,""); assert.doesNotThrow(()=>process.kill(pid,0));
const stopped=await Promise.allSettled([stopManual({repositoryRoot:repo}),stopManual({repositoryRoot:repo})]);
assert.equal(stopped.filter(result=>result.status==="fulfilled").length,1);
assert.equal(stopped.filter(result=>result.status==="rejected").length,1);
await assert.rejects(lstat(join(run.root,"backend.pid"))); assert.deepEqual(await listenerPids(),[]);
assert.deepEqual(await matchingManualServerPids(run.root,owned.nonce),[]); assert.throws(()=>process.kill(pid,0));
await cleanupManual({repositoryRoot:repo});
});
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 requires a 2xx HTTP health check and leaves no orphan on 503", { concurrency: false }, async () => {
const repo=await fakeRepo(); await installFakeServer(repo,{healthStatus:503}); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const owned=await readManualOwnership({repositoryRoot:repo});
await assert.rejects(serveManual({repositoryRoot:repo}),/health|readiness/i);
await assert.rejects(lstat(join(run.root,"backend.pid")));
assert.deepEqual(await listenerPids(),[]); assert.deepEqual(await matchingManualServerPids(run.root,owned.nonce),[]);
});
test("serve launches exact server.js with immutable preload and fixed owned control port", { concurrency: false }, async () => {
const repo=await fakeRepo(); await installFakeServer(repo); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const pid=await serveManual({repositoryRoot:repo}),record=JSON.parse(await readFile(join(run.root,"backend.pid"),"utf8"));
assert.equal(record.pid,pid); assert.equal(record.script,join(repo,"backend/dist/server.js"));
assert.equal(record.control.host,"127.0.0.1"); assert.equal(record.control.port,8792);
assert.match(record.preload,/^data:text\/javascript;base64,/);
const args=(await execFileAsync("ps",["-ww","-p",String(pid),"-o","command="])).stdout.trim();
assert.equal(args,[process.execPath,"--import",record.preload,record.script,`--p1-manual-nonce=${record.nonce}`,`--p1-root=${run.root}`,`--p1-control-nonce=${record.reservationNonce}`,`--p1-entry-sha256=${record.entrypoint.sha256}`,`--p1-entry-dev=${record.entrypoint.dev}`,`--p1-entry-ino=${record.entrypoint.ino}`].join(" "));
await stopManual({repositoryRoot:repo});
});
test("serve refuses legacy supervisor, runtime, server and log substitutions before code or outside writes", { concurrency: false }, async () => {
for(const kind of ["legacy-supervisor","runtime-symlink","server-symlink","log-symlink","log-replaced"]){
const repo=await fakeRepo(),marker=join(repo,`outside-${kind}.marker`); await installFakeServer(repo,{marker});
const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),outside=join(repo,`outside-${kind}`); await mkdir(outside);
if(kind==="legacy-supervisor")await symlink(join(outside,"outside.mjs"),join(run.root,"installation/runtime/p1-backend-supervisor.mjs"));
if(kind==="runtime-symlink"){await rm(join(run.root,"installation/runtime"),{recursive:true});await symlink(outside,join(run.root,"installation/runtime"));}
if(kind==="server-symlink"){
const external=join(outside,"server.js"); await writeFile(external,`import {writeFileSync} from "node:fs";writeFileSync(${JSON.stringify(marker)},"bad");`);
await rm(join(repo,"backend/dist/server.js")); await symlink(external,join(repo,"backend/dist/server.js"));
}
if(kind==="log-symlink"){await rm(join(run.root,"logs/backend.log"));await symlink(join(outside,"captured.log"),join(run.root,"logs/backend.log"));}
if(kind==="log-replaced"){await rm(join(run.root,"logs/backend.log"));await writeFile(join(run.root,"logs/backend.log"),"",{mode:0o600});}
await assert.rejects(serveManual({repositoryRoot:repo}),/unsafe|identity|symlink|legacy|realpath|log/i,kind);
await assert.rejects(lstat(marker),undefined,`${kind} must refuse before server execution`);
assert.deepEqual(await readdir(outside),kind==="server-symlink"?["server.js"]:[]);
await assert.rejects(lstat(join(run.root,"backend.pid")));
await rm(run.root,{recursive:true,force:true});
}
});
test("serve refuses an occupied fixed control port before spawning", { concurrency: false }, async () => {
const repo=await fakeRepo(),marker=join(repo,"server-executed"); await installFakeServer(repo,{marker}); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const blocker=net.createServer(); await new Promise((resolvePromise,reject)=>blocker.once("error",reject).listen(8792,"127.0.0.1",resolvePromise));
try { await assert.rejects(serveManual({repositoryRoot:repo}),/8792.*occupied|control.*occupied/); } finally { await new Promise(resolvePromise=>blocker.close(resolvePromise)); }
await assert.rejects(lstat(marker)); await assert.rejects(lstat(join(run.root,"backend.pid")));
});
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"}),{mode:0o600});
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("serve refuses non-loopback ownership without creating process state", async () => {
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const ownershipPath=join(run.root,"ownership.json"),owned=JSON.parse(await readFile(ownershipPath,"utf8"));
owned.listener.host="0.0.0.0"; await writeFile(ownershipPath,JSON.stringify(owned));
await assert.rejects(serveManual({repositoryRoot:repo}),/identity|loopback|bind/);
await assert.rejects(lstat(join(run.root,"backend.pid")));
});
test("cleanup refuses a correctly owned live server until guarded stop", { concurrency: false }, async () => {
const repo=await fakeRepo(); await installFakeServer(repo); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const pid=await serveManual({repositoryRoot:repo});
try {
await assert.rejects(cleanupManual({repositoryRoot:repo}),/owned backend is live|stop first/);
assert.doesNotThrow(()=>process.kill(pid,0));
} finally {
try { await stopManual({repositoryRoot:repo}); } catch { try { process.kill(pid,"SIGTERM"); } catch {} }
}
await cleanupManual({repositoryRoot:repo}); await assert.rejects(lstat(run.root));
});
async function processStartIdentity(pid) {
return (await execFileAsync("ps",["-p",String(pid),"-o","lstart="])).stdout.trim();
}
async function stopTestProcess(child) {
if (child.exitCode === null) child.kill("SIGTERM");
if (child.exitCode === null) await new Promise(resolvePromise=>child.once("exit",resolvePromise));
}
test("live foreign executable, cwd, start and args mismatches are never signaled", async () => {
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const owned=JSON.parse(await readFile(join(run.root,"ownership.json"),"utf8"));
const script=join(run.root,"installation/runtime/p1-backend-supervisor.mjs"),nonceArg=`--p1-manual-nonce=${owned.nonce}`,rootArg=`--p1-root=${run.root}`,reservationNonce="a".repeat(64),controlArg=`--p1-control-nonce=${reservationNonce}`;
await writeFile(script,"setInterval(()=>{},1000);\n",{mode:0o600});
const cases=[
["executable",()=>spawn("bash",["-c","while :; do sleep 1; done",script,nonceArg,rootArg,controlArg],{cwd:repo,stdio:"ignore"}),{}],
["cwd",()=>spawn(process.execPath,[script,nonceArg,rootArg,controlArg],{cwd:tmpdir(),stdio:"ignore"}),{}],
["start",()=>spawn(process.execPath,[script,nonceArg,rootArg,controlArg],{cwd:repo,stdio:"ignore"}),{startIdentity:"foreign-start"}],
["args",()=>spawn(process.execPath,[script],{cwd:repo,stdio:"ignore"}),{}],
];
for(const [name,start,override] of cases){
const child=start();
try {
let actualStart=""; for(let n=0;n<50&&!actualStart;n++){try{actualStart=await processStartIdentity(child.pid);}catch{} if(!actualStart)await new Promise(r=>setTimeout(r,20));}
assert.ok(actualStart,`live ${name} process started`);
const record={schemaVersion:1,kind:"p1-manual-backend",status:"RUNNING",pid:child.pid,reservationNonce,nonce:owned.nonce,root:run.root,repositoryRoot:repo,executable:process.execPath,script,startIdentity:actualStart,control:{host:"127.0.0.1",port:1},...override};
await writeFile(join(run.root,"backend.pid"),JSON.stringify(record),{mode:0o600});
await assert.rejects(stopManual({repositoryRoot:repo}),/process identity mismatch|refusing cooperative control/);
assert.doesNotThrow(()=>process.kill(child.pid,0));
await rm(join(run.root,"backend.pid"));
} finally { await stopTestProcess(child); await rm(join(run.root,"backend.pid"),{force: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));
});
const renderSnapshotYaml=`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}
`;
test("generated render command binds snapshot bytes to the commit manifest and Git blob end to end", async () => {
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
const author=join(run.root,"author"); await mkdir(join(author,"workspaces"),{recursive:true});
await writeFile(join(author,"workspaces","p1-filesystem.yaml"),renderSnapshotYaml);
await execFileAsync("git",["add","workspaces"],{cwd:author}); await execFileAsync("git",["commit","-m","publish p1"],{cwd:author}); await execFileAsync("git",["push","origin","main"],{cwd:author});
const commit=(await execFileAsync("git",["rev-parse","HEAD"],{cwd:author})).stdout.trim();
const blob=(await execFileAsync("git",["rev-parse","HEAD:workspaces/p1-filesystem.yaml"],{cwd:author})).stdout.trim();
await execFileAsync("git",["clone",join(run.root,"remote.git"),join(run.root,"installation/registry/repo")]);
const commitDir=join(run.root,"installation/registry/snapshots",commit); await mkdir(commitDir,{recursive:true});
const snapshot=join(commitDir,"p1-filesystem.yaml"),snapshotPath=snapshot,snapshotSha=sha256(renderSnapshotYaml);
await writeFile(snapshot,renderSnapshotYaml);
const readPath=join(run.root,"responses/read-p1-filesystem.json"),pullPath=join(run.root,"responses/pull.json"),script=join(run.root,"commands/render-1.sh"),script2=join(run.root,"commands/render-2.sh"),output=join(run.root,"rendered/runtime-1.yaml"),output2=join(run.root,"rendered/runtime-2.yaml");
const rendererStub=join(repo,"backend/scripts/p1-render-snapshot.mjs"),stubArgs=join(run.root,"rendered/stub-args.json");
await writeFile(rendererStub,`import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(stubArgs)}, JSON.stringify(process.argv.slice(2)));\n`);
const revision={id:"p1-filesystem",commit,blob,snapshotPath};
const manifest=(entry=revision)=>({head:commit,revisions:[entry],files:{"p1-filesystem.yaml":snapshotSha}});
await writeFile(readPath,JSON.stringify({revision})); await writeFile(pullPath,JSON.stringify({head:commit}));
await assert.rejects(execFileAsync("bash",[script],{cwd:repo}),/snapshot manifest.*(missing|unbounded)/i);
await assert.rejects(lstat(output));
const legacyRevision={...revision}; legacyRevision[["st","ate"].join("")]=["oper","ational"].join("");
await writeFile(join(commitDir,"snapshot.json"),JSON.stringify(manifest(legacyRevision)));
await assert.rejects(execFileAsync("bash",[script],{cwd:repo}),/snapshot manifest revision is invalid/);
await writeFile(join(commitDir,"snapshot.json"),JSON.stringify(manifest({...revision,unexpected:"field"})));
await assert.rejects(execFileAsync("bash",[script],{cwd:repo}),/snapshot manifest revision is invalid/);
await writeFile(join(commitDir,"snapshot.json"),JSON.stringify(manifest()));
await execFileAsync("bash",[script],{cwd:repo});
assert.deepEqual(JSON.parse(await readFile(stubArgs,"utf8")),["--ownership",join(run.root,"ownership.json"),"--snapshot",snapshot,"--output",output,"--snapshot-sha256",snapshotSha]);
await writeFile(snapshot,renderSnapshotYaml.replace("max_chunk_chars: 4000","max_chunk_chars: 3999"));
await assert.rejects(execFileAsync("bash",[script2],{cwd:repo}),/snapshot bytes differ from manifest digest/);
await assert.rejects(lstat(output2));
await writeFile(snapshot,renderSnapshotYaml);
await writeFile(join(commitDir,"snapshot.json"),JSON.stringify({...manifest(),head:"c".repeat(40)}));
await assert.rejects(execFileAsync("bash",[script2],{cwd:repo}),/snapshot manifest identity is invalid/);
await assert.rejects(lstat(output2));
for(const malformed of [
{id:"p1-filesystem",commit,blob},
{...revision,id:"p1-http"},
{...revision,commit:"c".repeat(40)},
{...revision,blob:"f".repeat(39)},
{...revision,blob:[blob]},
{...revision,snapshotPath:join(commitDir,"wrong.yaml")},
]){
await writeFile(join(commitDir,"snapshot.json"),JSON.stringify(manifest(malformed)));
await assert.rejects(execFileAsync("bash",[script2],{cwd:repo}),/snapshot manifest revision is invalid/);
}
await writeFile(join(commitDir,"snapshot.json"),JSON.stringify(manifest({...revision,blob:"f".repeat(40)})));
await assert.rejects(execFileAsync("bash",[script2],{cwd:repo}),/saved revision blob differs from snapshot manifest/);
await assert.rejects(lstat(output2));
await writeFile(join(commitDir,"snapshot.json"),JSON.stringify(manifest()));
await writeFile(readPath,JSON.stringify({revision:{...revision,blob:"f".repeat(40)}}));
await assert.rejects(execFileAsync("bash",[script2],{cwd:repo}),/saved revision blob differs/);
await assert.rejects(lstat(output2));
});
const sha256=bytes=>createHash("sha256").update(bytes).digest("hex");
async function makeExportZip(directory,name,{payloads={},manifest,workspaceId="p1-filesystem",extra=false,symlinkReadme=false}={}) {
const source=join(directory,`${name}-source`),zip=join(directory,`${name}.zip`); await mkdir(source,{recursive:true});
const files={"workspace.yaml":`workspace:\n id: ${workspaceId}\n`,"contract.env.example":"SAFE=path\n","README.md":"# Safe\n",...payloads};
const value=manifest??{schema_version:1,workspace_id:workspaceId,files:Object.fromEntries(Object.entries(files).map(([n,b])=>[n,sha256(b)]))};
await writeFile(join(source,"manifest.json"),JSON.stringify(value));
for(const [file,bytes] of Object.entries(files))if(!(symlinkReadme&&file==="README.md"))await writeFile(join(source,file),bytes);
if(symlinkReadme)await symlink("workspace.yaml",join(source,"README.md"));
if(extra)await writeFile(join(source,"extra.txt"),"extra");
const names=["manifest.json","workspace.yaml","contract.env.example","README.md",...(extra?["extra.txt"]:[])];
await execFileAsync("zip",["-q",...(symlinkReadme?["-y"]:[]),zip,...names],{cwd:source}); return zip;
}
test("generated ZIP verifier enforces exact manifest mapping, hashes, entries and regular files", async () => {
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),extract=join(run.root,"commands/extract-export.sh");
const invoke=async(name,options={})=>execFileAsync("bash",[extract,await makeExportZip(run.root,name,options),join(run.root,"exports/extracted",name),"p1-filesystem"],{cwd:repo});
await invoke("valid");
const safe={"workspace.yaml":"workspace:\n id: p1-filesystem\n","contract.env.example":"SAFE=path\n","README.md":"# Safe\n"};
const hashes=Object.fromEntries(Object.entries(safe).map(([n,b])=>[n,sha256(b)]));
await assert.rejects(invoke("missing-map",{manifest:{schema_version:1,workspace_id:"p1-filesystem",files:{"workspace.yaml":hashes["workspace.yaml"],"contract.env.example":hashes["contract.env.example"]}}}),/manifest/i);
await assert.rejects(invoke("short-hash",{manifest:{schema_version:1,workspace_id:"p1-filesystem",files:{...hashes,"README.md":"abc"}}}),/manifest/i);
await assert.rejects(invoke("extra-entry",{extra:true}),/unsafe-zip/);
await assert.rejects(invoke("nonregular",{symlinkReadme:true}),/symlink|nonregular/);
});
test("generated ZIP verifier binds identity, stages source once, and rejects symlink output ancestry", async () => {
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),extract=join(run.root,"commands/extract-export.sh");
for(const id of ["p1-filesystem","p1-http","p1-s3"]){
const zip=await makeExportZip(run.root,`valid-${id}`,{workspaceId:id});
await execFileAsync("bash",[extract,zip,join(run.root,"exports/extracted",id),id],{cwd:repo});
}
const wrong=await makeExportZip(run.root,"wrong-valid-id",{workspaceId:"p1-http"});
await assert.rejects(execFileAsync("bash",[extract,wrong,join(run.root,"exports/extracted/wrong-id"),"p1-s3"],{cwd:repo}),/workspace.*identity|workspace_id/i);
const descriptorMismatch=await makeExportZip(run.root,"descriptor-mismatch",{workspaceId:"p1-http",payloads:{"workspace.yaml":"workspace:\n id: p1-s3\n"}});
await assert.rejects(execFileAsync("bash",[extract,descriptorMismatch,join(run.root,"exports/extracted/descriptor-mismatch"),"p1-http"],{cwd:repo}),/workspace.*identity|descriptor/i);
const outside=join(repo,"outside-extract"); await mkdir(outside); await rm(join(run.root,"exports/extracted"),{recursive:true}); await symlink(outside,join(run.root,"exports/extracted"));
const safe=await makeExportZip(run.root,"symlink-parent");
await assert.rejects(execFileAsync("bash",[extract,safe,join(run.root,"exports/extracted/escape"),"p1-filesystem"],{cwd:repo}),/symlink|owned|unsafe/i);
assert.deepEqual(await readdir(outside),[]); await rm(join(run.root,"exports/extracted")); await mkdir(join(run.root,"exports/extracted"));
const original=await makeExportZip(run.root,"replace-original"),replacement=await makeExportZip(run.root,"replace-malicious",{extra:true});
const bin=join(run.root,"swap-bin"),markerPath=join(run.root,"swap-once"); await mkdir(bin);
const realUnzip=(await execFileAsync("which",["unzip"])).stdout.trim();
await writeFile(join(bin,"unzip"),`#!/bin/sh
if [ ! -e "$P1_SWAP_MARKER" ]; then cp "$P1_SWAP_REPLACEMENT" "$P1_SWAP_ORIGINAL"; : > "$P1_SWAP_MARKER"; fi
exec ${realUnzip} "$@"
`,{mode:0o700});
await execFileAsync("bash",[extract,original,join(run.root,"exports/extracted/staged-source"),"p1-filesystem"],{cwd:repo,env:{...process.env,PATH:`${bin}:${process.env.PATH}`,P1_SWAP_MARKER:markerPath,P1_SWAP_REPLACEMENT:replacement,P1_SWAP_ORIGINAL:original}});
await lstat(markerPath); await lstat(join(run.root,"exports/extracted/staged-source/manifest.json"));
const generated=await readFile(extract,"utf8"); assert.match(generated,/source_fd = os\.open\(zip_path/); assert.match(generated,/dir_fd=base_fd/); assert.match(generated,/O_NOFOLLOW/); assert.match(generated,/staged archive SHA mismatch/);
});
test("generated ZIP verifier anchors output when the extraction base is swapped on first unzip", async () => {
const repo=await fakeRepo(),run=await prepareManual({repositoryRoot:repo,skipBuild:true}),extract=join(run.root,"commands/extract-export.sh");
const zip=await makeExportZip(run.root,"ancestor-swap"),base=join(run.root,"exports/extracted"),moved=join(run.root,"exports/extracted-original"),outside=join(repo,"outside-extraction-race");
const bin=join(repo,"unzip-swap-bin"),marker=join(repo,"unzip-swapped"),realUnzip=(await execFileAsync("which",["unzip"])).stdout.trim();
await mkdir(bin); await mkdir(outside);
await writeFile(join(bin,"unzip"),`#!/bin/sh
if [ ! -e "$P1_SWAP_MARKER" ]; then
mv "$P1_SWAP_BASE" "$P1_SWAP_MOVED"
ln -s "$P1_SWAP_OUTSIDE" "$P1_SWAP_BASE"
: > "$P1_SWAP_MARKER"
fi
exec ${realUnzip} "$@"
`,{mode:0o700});
await assert.rejects(execFileAsync("bash",[extract,zip,join(base,"escaped"),"p1-filesystem"],{cwd:repo,env:{...process.env,PATH:`${bin}:${process.env.PATH}`,P1_SWAP_MARKER:marker,P1_SWAP_BASE:base,P1_SWAP_MOVED:moved,P1_SWAP_OUTSIDE:outside}}),/owned extraction root|identity|changed|unsafe/i);
await lstat(marker); assert.deepEqual(await readdir(outside),[]);
});
test("generated ZIP verifier scans all four extracted byte streams for Evidence and canaries", async () => {
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),extract=join(run.root,"commands/extract-export.sh");
const markers=["P1 manually curated Evidence","DWH-"+"d".repeat(32),"CANARY-MUST-BE-REJECTED"];
for(const marker of markers)for(const target of ["manifest.json","workspace.yaml","contract.env.example","README.md"]){
const name=`scan-${markers.indexOf(marker)}-${target.replaceAll(".","-")}`,payloads=target==="manifest.json"?{}:{[target]:marker};
const files={"workspace.yaml":"workspace:\n id: p1-filesystem\n","contract.env.example":"SAFE=path\n","README.md":"# Safe\n",...payloads};
const manifest={schema_version:1,workspace_id:target==="manifest.json"?marker:"p1-filesystem",files:Object.fromEntries(Object.entries(files).map(([n,b])=>[n,sha256(b)]))};
const zip=await makeExportZip(run.root,name,{payloads,manifest});
await assert.rejects(execFileAsync("bash",[extract,zip,join(run.root,"exports/extracted",name),"p1-filesystem"],{cwd:repo}),error=>/Evidence|canary/.test(error.stderr)&&!error.stderr.includes(marker),`${target} must reject ${marker.slice(0,8)}`);
}
});
test("generated secret scan excludes only the exact request fixture and hides fixed canary", async () => {
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}); const installed=join(run.root,"installation/registry/repo"),scan=join(run.root,"commands/secret-scan.sh"),canary="CANARY-MUST-BE-REJECTED";
await execFileAsync("git",["clone",join(run.root,"remote.git"),installed]); await execFileAsync("bash",[scan],{cwd:repo});
const leak=join(run.root,"responses/requests/invalid-credential.json"); await mkdir(dirname(leak),{recursive:true}); await writeFile(leak,canary);
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>/secret canary found/.test(error.stderr)&&!error.stderr.includes(canary));
});
test("generated secret scan reads Git metadata and arbitrary dot-git directories without printing values", async () => {
for(const rel of ["author/.git/manual-leak","responses/.git/leak"]){
const canary="SECRET-"+"a".repeat(32),repo=await fakeRepo(),run=await prepareManual({repositoryRoot:repo,skipBuild:true}),scan=join(run.root,"commands/secret-scan.sh");
await execFileAsync("git",["clone",join(run.root,"remote.git"),join(run.root,"installation/registry/repo")]);
await mkdir(dirname(join(run.root,rel)),{recursive:true}); await writeFile(join(run.root,rel),canary);
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>/secret canary found/.test(error.stderr)&&!error.stderr.includes(canary),`${rel} must be scanned with a redacted finding`);
await rm(run.root,{recursive:true,force:true});
}
});
test("generated secret scan reads raw dangling commit, tag, and tree objects without printing values", async () => {
for(const kind of ["commit","tag","tree"]){
const canary="SESSION-"+({commit:"b",tag:"c",tree:"d"}[kind]).repeat(32),repo=await fakeRepo(),run=await prepareManual({repositoryRoot:repo,skipBuild:true}),author=join(run.root,"author"),scan=join(run.root,"commands/secret-scan.sh");
await execFileAsync("git",["clone",join(run.root,"remote.git"),join(run.root,"installation/registry/repo")]);
if(kind==="commit"){
await execFileAsync("git",["commit","--allow-empty","-m",canary],{cwd:author}); await execFileAsync("git",["reset","--hard","HEAD^"],{cwd:author});
}else if(kind==="tag"){
await execFileAsync("git",["tag","-a","temporary-canary-tag","-m",canary],{cwd:author}); await execFileAsync("git",["tag","-d","temporary-canary-tag"],{cwd:author});
}else{
await writeFile(join(author,canary),"safe tree payload\n"); await execFileAsync("git",["add",canary],{cwd:author}); await execFileAsync("git",["write-tree"],{cwd:author}); await execFileAsync("git",["reset","--hard","HEAD"],{cwd:author});
}
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>/Git object/.test(error.stderr)&&!error.stderr.includes(canary),`${kind} raw bytes must be scanned with a redacted finding`);
await rm(run.root,{recursive:true,force:true});
}
});
test("generated secret scan checks unreachable blobs and dangling commits without printing values", async () => {
for(const kind of ["unreachable-blob","dangling-commit"]){
const value=kind==="unreachable-blob"?"SECRET-"+"e".repeat(32):"SECRET-"+"f".repeat(32);
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}); const author=join(run.root,"author"),installed=join(run.root,"installation/registry/repo"),scan=join(run.root,"commands/secret-scan.sh");
await execFileAsync("git",["clone",join(run.root,"remote.git"),installed]); const file=join(author,"dangling-secret"); await writeFile(file,value);
if(kind==="unreachable-blob"){await execFileAsync("git",["hash-object","-w",file],{cwd:author}); await rm(file);}
else {await execFileAsync("git",["add","dangling-secret"],{cwd:author}); await execFileAsync("git",["commit","-m","dangling secret"],{cwd:author}); await execFileAsync("git",["reset","--hard","HEAD^"],{cwd:author});}
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>/Git blob/.test(error.stderr)&&!error.stderr.includes(value),`${kind} must be scanned`);
await rm(run.root,{recursive:true,force:true});
}
});
test("generated secret scan checks randomized and fixed canaries in reachable Git without printing values", async () => {
for(const canary of ["DWH-"+"c".repeat(32),"CANARY-MUST-BE-REJECTED"]){
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 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=>/Git blob/.test(error.stderr)&&!error.stderr.includes(canary));
await rm(run.root,{recursive:true,force:true});
}
});
test("prepare publishes cleanable ownership before lab population", { concurrency: false }, async () => {
const repo=await fakeRepo(),realGit=(await execFileAsync("which",["git"])).stdout.trim(),bin=join(repo,"failing-bin");
await installFakeServer(repo); await mkdir(bin);
await writeFile(join(bin,"git"),`#!/bin/sh
if [ "$1" = init ]; then exit 71; fi
exec ${JSON.stringify(realGit)} "$@"
`,{mode:0o700});
const prior=process.env.PATH; process.env.PATH=`${bin}:${prior}`;
try { await assert.rejects(prepareManual({repositoryRoot:repo,skipBuild:true})); }
finally { process.env.PATH=prior; }
const owned=await readManualOwnership({repositoryRoot:repo});
assert.equal(owned.stage,"PREPARING");
await cleanupManual({repositoryRoot:repo});
await assert.rejects(lstat(fixedManualRoot(repo)));
});
test("stable repo-root lifecycle namespace survives manual-parent rename and cleans partial prepare", { concurrency: false }, async () => {
const repo=await fakeRepo(),realGit=(await execFileAsync("which",["git"])).stdout.trim();
const bin=join(repo,"rename-lock-bin"),entered=join(repo,"rename-entered"),release=join(repo,"rename-release");
await installFakeServer(repo); await mkdir(bin);
await writeFile(join(bin,"git"),`#!/bin/sh
if [ "$1" = init ] && [ ! -e ${JSON.stringify(entered)} ]; then
: > ${JSON.stringify(entered)}
while [ ! -e ${JSON.stringify(release)} ]; do sleep 0.01; done
fi
exec ${JSON.stringify(realGit)} "$@"
`,{mode:0o700});
const prior=process.env.PATH; process.env.PATH=`${bin}:${prior}`; let preparing;
const parent=join(repo,".artifacts/manual-acceptance"),moved=join(repo,".artifacts/manual-acceptance-moved");
try {
preparing=prepareManual({repositoryRoot:repo,skipBuild:true});
for(let n=0;n<300;n++){try{await lstat(entered);break;}catch{} await new Promise(r=>setTimeout(r,10));}
await lstat(entered); await rename(parent,moved); await mkdir(parent,{recursive:true}); await writeFile(join(parent,"public-sibling"),"keep"); await writeFile(join(moved,"moved-sibling"),"keep");
await assert.rejects(cleanupManual({repositoryRoot:repo}),/lifecycle lock|operator inspection/i);
await writeFile(release,"go"); await assert.rejects(preparing,/identity|changed|unsafe|manual/i); preparing=undefined;
assert.equal(await readFile(join(parent,"public-sibling"),"utf8"),"keep");
assert.equal(await readFile(join(moved,"moved-sibling"),"utf8"),"keep");
await assert.rejects(lstat(join(moved,"p1")));
await assert.rejects(lstat(join(repo,".p1-manual-acceptance.lifecycle.lock")));
} finally { process.env.PATH=prior; await writeFile(release,"go").catch(()=>{}); if(preparing)await preparing.catch(()=>{}); }
});
test("all four lifecycle operations serialize on the stable repo-root lock", async () => {
const repo=await fakeRepo(); await installFakeServer(repo); await prepareManual({repositoryRoot:repo,skipBuild:true});
const lock=join(repo,".p1-manual-acceptance.lifecycle.lock"); await writeFile(lock,"foreign",{mode:0o600});
try {
for(const operation of [prepareManual,serveManual,stopManual,cleanupManual])
await assert.rejects(operation({repositoryRoot:repo,skipBuild:true}),/lifecycle lock|operator inspection/i);
} finally { await rm(lock,{force:true}); }
});
test("prepare binds production entry bytes and serve rejects a regular replacement", { concurrency: false }, async () => {
const repo=await fakeRepo(),malicious=join(repo,"malicious-executed"); await installFakeServer(repo);
const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),owned=await readManualOwnership({repositoryRoot:repo}),script=join(repo,"backend/dist/server.js");
const prepared=await readFile(script); assert.equal(owned.entrypoint.sha256,sha256(prepared));
await rm(script); await writeFile(script,`import {writeFileSync} from "node:fs";writeFileSync(${JSON.stringify(malicious)},"bad");setInterval(()=>{},1000);`);
await assert.rejects(serveManual({repositoryRoot:repo}),/entrypoint|production server.*identity/i);
await assert.rejects(lstat(malicious)); await assert.rejects(lstat(join(run.root,"backend.pid")));
});
test("serve rejects replacement of an imported production dependency", { concurrency: false }, async () => {
const repo=await fakeRepo(),malicious=join(repo,"dependency-executed"); await installFakeServer(repo);
const server=join(repo,"backend/dist/server.js"), original=await readFile(server); await writeFile(join(repo,"backend/dist/dep.js"),"export const dependency = true;\n"); await writeFile(server,`import \"./dep.js\";\n${original}`);
const run=await prepareManual({repositoryRoot:repo,skipBuild:true}); const before=await readFile(server); await writeFile(join(repo,"backend/dist/dep.js"),`import {writeFileSync} from \"node:fs\"; writeFileSync(${JSON.stringify(malicious)},\"bad\");\n`);
assert.deepEqual(await readFile(server),before); await assert.rejects(serveManual({repositoryRoot:repo}),/distribution|identity|manifest/i); await assert.rejects(lstat(malicious)); await assert.rejects(lstat(join(run.root,"backend.pid")));
});
test("serve refuses a replaced backend dist dependency after prepare", { concurrency: false }, async () => {
const repo=await fakeRepo(); await installFakeServer(repo);
const dependency=join(repo,"backend/dist/dependency.js"); await writeFile(dependency,"export const value = 1;\n");
const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),marker=join(repo,"dependency-replaced-executed");
await writeFile(dependency,`import { writeFileSync } from "node:fs";writeFileSync(${JSON.stringify(marker)},"bad");export const value = 2;\n`);
await assert.rejects(serveManual({repositoryRoot:repo}),/distribution|manifest|identity/i);
await assert.rejects(lstat(marker)); await assert.rejects(lstat(join(run.root,"backend.pid")));
});
test("serve refuses a deterministic dependency check/load swap before execution", { concurrency: false }, async () => {
const repo=await fakeRepo(),marker=join(repo,"dep-swap-executed");
await writeFile(join(repo,"backend/dist/dep.js"),`export function start(){}\n`);
await writeFile(join(repo,"backend/dist/server.js"),`import http from "node:http";\nimport { start } from "./dep.js";\nstart();\nconst server=http.createServer((req,res)=>{res.statusCode=req.url==="/health"?200:200;res.setHeader("content-type","application/json");res.end(JSON.stringify({status:"ok"}));});\nserver.listen(Number(process.env.PORT),process.env.HOST);\n`);
const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),owned=await readManualOwnership({repositoryRoot:repo}),replacement=join(repo,"dep-replacement.js");
await writeFile(replacement,`import {writeFileSync} from "node:fs";writeFileSync(${JSON.stringify(marker)},"executed");export function start(){}\n`);
await assert.rejects(serveManual({repositoryRoot:repo,beforeSpawn:async()=>rename(replacement,join(repo,"backend/dist/dep.js"))}),/identity|changed|refused|distribution|module|readiness|failed/i);
await assert.rejects(lstat(marker)); await assert.rejects(lstat(join(run.root,"backend.pid")));
assert.deepEqual(await matchingManualServerPids(run.root,owned.nonce),[]); assert.deepEqual(await listenerPids(),[]);
});
test("immutable loader serves verified cached dependency bytes after a same-path regular replacement", { concurrency: false }, async () => {
const repo=await fakeRepo(),marker=join(repo,"dep-replacement-executed");
await writeFile(join(repo,"backend/dist/dep.js"),`export function mark(){ globalThis.__depSource = "original"; }\n`);
await writeFile(join(repo,"backend/dist/server.js"),`import http from "node:http";\nlet n = 0;\nconst server=http.createServer(async (req,res)=>{ if(req.url==="/load"){ await import(\`./dep.js?v=\${++n}\`).then(m=>m.mark()); } res.setHeader("content-type","application/json"); res.end(JSON.stringify({status:"ok",dep:globalThis.__depSource??"unset"})); });\nserver.listen(Number(process.env.PORT),process.env.HOST);\n`);
const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),owned=await readManualOwnership({repositoryRoot:repo});
const pid=await serveManual({repositoryRoot:repo});
try {
const health=async()=>(await (await fetch("http://127.0.0.1:8791/health")).json());
const load=async()=>{ await fetch("http://127.0.0.1:8791/load"); return (await health()).dep; };
for(let n=0;n<60;n++){try{if((await health()).status==="ok")break;}catch{}await new Promise(r=>setTimeout(r,50));}
assert.equal(await load(),"original");
await writeFile(join(repo,"backend/dist/dep.js"),`import {writeFileSync} from "node:fs";writeFileSync(${JSON.stringify(marker)},"executed");export function mark(){ globalThis.__depSource = "replaced"; }\n`);
assert.equal(await load(),"original"); await assert.rejects(lstat(marker));
await writeFile(join(repo,"backend/dist/dep.js"),`export function mark(){ globalThis.__depSource = "original"; }\n`);
} finally {
try { await stopManual({repositoryRoot:repo}); } catch { try { process.kill(pid,"SIGTERM"); } catch {} }
}
await cleanupManual({repositoryRoot:repo});
});
test("opened production FD prevents deterministic check-spawn replacement execution", { concurrency: false }, async () => {
const repo=await fakeRepo(),safe=join(repo,"safe-executed"),malicious=join(repo,"malicious-executed"); await installFakeServer(repo,{marker:safe});
const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),script=join(repo,"backend/dist/server.js"),replacement=join(repo,"replacement-server.js"),owned=await readManualOwnership({repositoryRoot:repo});
await writeFile(replacement,`import {writeFileSync} from "node:fs";writeFileSync(${JSON.stringify(malicious)},"bad");setInterval(()=>{},1000);`);
await assert.rejects(serveManual({repositoryRoot:repo,beforeSpawn:async()=>rename(replacement,script)}),/entrypoint|identity|changed|readiness|failed|distribution|module/i);
await assert.rejects(lstat(malicious)); await assert.rejects(lstat(join(run.root,"backend.pid")));
assert.deepEqual(await matchingManualServerPids(run.root,owned.nonce),[]);
});
test("foreign 8791 health can never make a delayed authenticated child RUNNING", { concurrency: false }, async () => {
const repo=await fakeRepo(),entered=join(repo,"entry-loaded"); await installFakeServer(repo,{startupDelay:700,marker:entered});
const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),owned=await readManualOwnership({repositoryRoot:repo});
const serving=serveManual({repositoryRoot:repo});
for(let n=0;n<300;n++){try{await lstat(entered);break;}catch{} await new Promise(r=>setTimeout(r,5));}
await lstat(entered); const foreign=net.createServer((socket)=>socket.end("HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\nforeign"));
await new Promise((resolvePromise,reject)=>foreign.once("error",reject).listen(8791,"127.0.0.1",resolvePromise));
try {
await assert.rejects(serving,/readiness|listener|entrypoint|backend failed/i);
await assert.rejects(lstat(join(run.root,"backend.pid")));
assert.equal((await listenerPids()).includes(process.pid),true);
assert.deepEqual(await matchingManualServerPids(run.root,owned.nonce),[]);
} finally { await new Promise(resolvePromise=>foreign.close(resolvePromise)); }
});
test("absence gate rejects evidence and every P2 materialization artifact name", async () => {
for(const rel of ["artifacts/evidence/generation/chunk.md","responses/materialization","responses/preprocess-state","responses/embedding-cache","responses/qdrant-state","responses/ACTIVE","responses/retention-policy"]){
const repo=await fakeRepo(); await installFakeServer(repo); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),check=join(run.root,"commands/absence-check.sh"),target=join(run.root,rel);
if(rel.endsWith("materialization"))await mkdir(target,{recursive:true}); else {await mkdir(dirname(target),{recursive:true}); await writeFile(target,"safe");}
await assert.rejects(execFileAsync("bash",[check],{cwd:repo}),/out-of-scope/i,rel);
await rm(run.root,{recursive:true,force:true});
}
});
test("secret scan discovers every arbitrary git repository including unreachable objects", async () => {
const repo=await fakeRepo(); await installFakeServer(repo); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),gitdir=join(run.root,"responses/.git"),scan=join(run.root,"commands/secret-scan.sh"),canary="SECRET-"+"9".repeat(32);
await execFileAsync("git",["init","--bare",gitdir]); const blob=join(run.root,"responses/canary-blob"); await writeFile(blob,canary); await execFileAsync("git",["--git-dir",gitdir,"hash-object","-w",blob]); await rm(blob);
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>/Git object|secret canary/.test(error.stderr)&&!error.stderr.includes(canary));
});
test("secret scan bounds and scans filesystem names plus loose ref names", async () => {
for(const kind of ["file","directory","loose-ref"]){
const repo=await fakeRepo(); await installFakeServer(repo); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),scan=join(run.root,"commands/secret-scan.sh"),canary="SESSION-"+"8".repeat(32);
if(kind==="file")await writeFile(join(run.root,"responses",canary),"safe");
if(kind==="directory")await mkdir(join(run.root,"responses",canary));
if(kind==="loose-ref"){const ref=join(run.root,"author/.git/refs/heads",canary);await mkdir(dirname(ref),{recursive:true});await writeFile(ref,"0".repeat(40)+"\n");}
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>/secret canary/.test(error.stderr)&&!error.stderr.includes(canary),kind);
await rm(run.root,{recursive:true,force:true});
}
});
test("extractor and scanner operational diagnostics redact canary-bearing paths", async () => {
const repo=await fakeRepo(); await installFakeServer(repo); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),canary="SECRET-"+"7".repeat(32),extract=join(run.root,"commands/extract-export.sh"),scan=join(run.root,"commands/secret-scan.sh");
const missing=join(run.root,"exports/raw",`${canary}.zip`),output=join(run.root,"exports/extracted",canary);
await assert.rejects(execFileAsync("bash",[extract,missing,output,"p1-filesystem"],{cwd:repo}),error=>!error.stderr.includes(canary)&&/redacted|refused|unsafe/i.test(error.stderr));
const oversized=join(run.root,"responses","oversized"); const handle=await open(oversized,"w"); await handle.truncate(33554433); await handle.close();
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>!error.stderr.includes(canary)&&/redacted|failed|bound/i.test(error.stderr));
});
test("public prepare build is inside the stable lifecycle transaction", async()=>{
const wrapper=await readFile(new URL("../../scripts/p1-manual-acceptance.sh",import.meta.url),"utf8"),source=await readFile(new URL("./p1-manual-acceptance.mjs",import.meta.url),"utf8");
assert.doesNotMatch(wrapper,/npm .*run build/); assert.match(source,/action==="prepare"\)await prepareManual\(\)/);
});
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { constants, lstatSync, realpathSync } from "node:fs";
import { lstat, mkdir, open, readFile, realpath } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
// 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 };
}
const ANCHORED_PUBLISH_SOURCE=String.raw`import os,secrets,stat,sys
parent,name,expected_dev,expected_ino=sys.argv[1:]
pfd=fd=None;stage=".render-stage-"+secrets.token_hex(16);published=False
def fail(): raise RuntimeError("anchored publication refused")
try:
pfd=os.open(parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
identity=os.fstat(pfd)
if (identity.st_dev,identity.st_ino)!=(int(expected_dev),int(expected_ino)): fail()
try: os.stat(name,dir_fd=pfd,follow_symlinks=False); fail()
except FileNotFoundError: pass
fd=os.open(stage,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=pfd)
data=sys.stdin.buffer.read(33554433)
if len(data)>33554432: fail()
view=memoryview(data)
while view:
written=os.write(fd,view)
if written<=0: fail()
view=view[written:]
os.fsync(fd);os.close(fd);fd=None;os.rename(stage,name,src_dir_fd=pfd,dst_dir_fd=pfd);published=True;os.fsync(pfd)
current=os.stat(parent,follow_symlinks=False)
if not stat.S_ISDIR(current.st_mode) or (current.st_dev,current.st_ino)!=(identity.st_dev,identity.st_ino): fail()
except Exception:
if published:
try: os.unlink(name,dir_fd=pfd);os.fsync(pfd)
except Exception: pass
print("anchored output publication refused (details redacted)",file=sys.stderr);raise SystemExit(1)
finally:
if fd is not None: os.close(fd)
if pfd is not None:
try: os.unlink(stage,dir_fd=pfd)
except FileNotFoundError: pass
os.close(pfd)
`;
async function atomicCopy(source,output) {
const parent=dirname(output),entry=await lstat(parent);if(!entry.isDirectory()||entry.isSymbolicLink())throw new Error("rendered parent identity is unsafe");const bytes=await readFile(source);
const result=spawnSync("python3",["-c",ANCHORED_PUBLISH_SOURCE,parent,basename(output),String(entry.dev),String(entry.ino)],{input:bytes,encoding:"utf8",maxBuffer:1024*1024});
if(result.error||result.status!==0)throw new Error("anchored output publication refused; rendered parent identity changed or output is unsafe");
}
function sameEntry(actual, expected) { return actual.dev === expected.dev && actual.ino === expected.ino; }
async function readBounded(path, max, label) {
let handle;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const before = await handle.stat(), pathEntry = await lstat(path);
if (!before.isFile() || pathEntry.isSymbolicLink() || !pathEntry.isFile() || !sameEntry(before, pathEntry)) throw new Error(`${label} is unsafe`);
if (before.size < 1 || before.size > max) throw new Error(`${label} is unbounded`);
const bytes = Buffer.alloc(before.size); let offset = 0;
while (offset < bytes.length) {
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
if (bytesRead < 1) throw new Error(`${label} changed while reading`);
offset += bytesRead;
}
const after = await handle.stat();
if (!sameEntry(before, after) || after.size !== before.size) throw new Error(`${label} changed while reading`);
return bytes;
} finally {
if (handle) await handle.close().catch(() => {});
}
}
async function readSnapshotManifest(root, manifestPath, commit, yamlName, expectedDigest) {
let manifestEntry;
try { assertNoSymlinks(root, manifestPath); manifestEntry = await lstat(manifestPath); }
catch (error) { if (error?.code === "ENOENT") throw new Error("snapshot manifest is missing or unbounded"); throw error; }
if (!manifestEntry.isFile() || manifestEntry.isSymbolicLink() || await realpath(manifestPath) !== manifestPath) throw new Error("snapshot manifest is unsafe");
const bytes = await readBounded(manifestPath, 1048576, "snapshot manifest");
let manifest; try { manifest = JSON.parse(bytes.toString("utf8")); } catch { throw new Error("snapshot manifest is malformed"); }
const files = manifest?.files;
if (manifest?.head !== commit || !files || typeof files !== "object" || Array.isArray(files)) throw new Error("snapshot manifest identity is unsafe");
if (!HEX64.test(files[yamlName] ?? "") || files[yamlName] !== expectedDigest) throw new Error("snapshot manifest digest is unsafe");
return manifest;
}
export async function renderOwnedSnapshot({ repositoryRoot = defaultRepositoryRoot, ownershipPath, snapshotPath, outputPath, snapshotSha256, env = process.env, beforePublish }) {
const repo = realpathSync(repositoryRoot); const { root } = await ownership(repo, resolve(repo, ownershipPath));
const snapshot = resolve(repo, snapshotPath); const output = resolve(repo, outputPath);
const snapshotsRoot = join(root, "installation", "registry", "snapshots");
const renderedRoot = join(root, "rendered");
if (!isAbsolute(snapshotPath) || !below(snapshotsRoot, snapshot)) throw new Error("snapshot is not an owned absolute path");
const match = /^([0-9a-f]{40})\/([a-z][a-z0-9-]{2,62})\.yaml$/.exec(relative(snapshotsRoot, snapshot).split(sep).join("/"));
if (!match || !HEX40.test(match[1])) throw new Error("snapshot is not commit addressed");
if (!HEX64.test(snapshotSha256 ?? "")) throw new Error("snapshot digest identity is unsafe");
assertNoSymlinks(root, snapshot); const snapshotEntry = await lstat(snapshot);
if (!snapshotEntry.isFile() || snapshotEntry.isSymbolicLink() || await realpath(snapshot) !== snapshot) throw new Error("snapshot is unsafe");
const yamlName = `${match[2]}.yaml`;
const manifestPath = join(snapshotsRoot, match[1], "snapshot.json");
await readSnapshotManifest(root, manifestPath, match[1], yamlName, snapshotSha256);
const snapshotBytes = await readBounded(snapshot, 1048576, "snapshot");
if (createHash("sha256").update(snapshotBytes).digest("hex") !== snapshotSha256) throw new Error("snapshot bytes changed");
if (!below(renderedRoot, output) || dirname(output) !== renderedRoot || !output.endsWith(".yaml")) throw new Error("output is not an owned rendered path");
assertNoSymlinks(root, dirname(output));
try { if ((await lstat(output)).isSymbolicLink()) throw new Error("output is unsafe"); } catch (error) { if (error.code !== "ENOENT") throw error; }
await mkdir(join(snapshotsRoot, "runtime"), { recursive: true, mode: 0o700 });
const 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);
const verifySnapshot = async () => {
const current = await readBounded(snapshot, 1048576, "snapshot");
if (createHash("sha256").update(current).digest("hex") !== snapshotSha256) throw new Error("snapshot content changed during rendering");
};
await verifySnapshot();
if(beforePublish)await beforePublish({output,renderedRoot});
await verifySnapshot();
await atomicCopy(lease.path, output);
}
finally {
if (lease) lease.release();
for (const key of Object.keys(env)) { if (prior[key] === undefined) delete process.env[key]; else process.env[key] = prior[key]; }
}
return output;
}
function parseArgs(argv) {
if (argv.length !== 8) throw new Error("usage: p1-render-snapshot.mjs --ownership PATH --snapshot ABSOLUTE_PATH --output PATH --snapshot-sha256 HEX");
const result = {}; for (let i=0;i<argv.length;i+=2) { if (!["--ownership","--snapshot","--output","--snapshot-sha256"].includes(argv[i]) || result[argv[i]]) throw new Error("invalid arguments"); result[argv[i]]=argv[i+1]; }
if (!result["--ownership"] || !result["--snapshot"] || !result["--output"] || !result["--snapshot-sha256"]) 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"], snapshotSha256:args["--snapshot-sha256"] }); console.log(`rendered ${resolve(args["--output"])}`); }
catch(error) { console.error(`p1 render refused: ${error.message}`); process.exitCode=1; }
}
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, rename, 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=[];
const sha256=bytes=>createHash("sha256").update(bytes).digest("hex");
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 snapshotBytes=await readFile(snapshot); const snapshotSha256=sha256(snapshotBytes);
const manifestPath=join(dirname(snapshot),"snapshot.json");
await writeFile(manifestPath,JSON.stringify({head:commit,revisions:[{id:"p1-filesystem",commit,blob:"0".repeat(40),snapshotPath:snapshot}],files:{"p1-filesystem.yaml":snapshotSha256}}));
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,snapshotSha256,manifestPath,env};
}
test.afterEach(async()=>Promise.all(roots.splice(0).map(r=>rm(r,{recursive:true,force:true}))));
const call=(f,extra={})=>renderOwnedSnapshot({repositoryRoot:f.repo,ownershipPath:join(f.root,"ownership.json"),snapshotPath:f.snapshot,snapshotSha256:f.snapshotSha256,env:{...process.env,...f.env},...extra});
const runtimeLeases=async f=>await (await import("node:fs/promises")).readdir(join(f.root,"installation/registry/snapshots/runtime"));
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 call(f,{outputPath:one}); await call(f,{outputPath:two}); assert.deepEqual(await readFile(one),await readFile(two)); assert.equal((await lstat(one)).mode&0o777,0o600); assert.deepEqual(await runtimeLeases(f),[]); });
test("renderer rejects unowned, symlink, out-of-root and missing-digest 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"),snapshotSha256:f.snapshotSha256,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"),snapshotSha256:f.snapshotSha256,env:f.env}),/snapshot|symlink/); await assert.rejects(renderOwnedSnapshot({repositoryRoot:f.repo,ownershipPath:join(f.root,"ownership.json"),snapshotPath:f.snapshot,outputPath:outside,snapshotSha256:f.snapshotSha256,env:f.env}),/output/); await assert.rejects(renderOwnedSnapshot({repositoryRoot:f.repo,ownershipPath:join(f.root,"ownership.json"),snapshotPath:f.snapshot,outputPath:join(f.root,"rendered/x.yaml"),env:f.env}),/snapshot digest identity/); });
test("renderer releases its acquired lease when atomic output copy fails",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/existing.yaml"); await mkdir(output); await assert.rejects(call(f,{outputPath:output}),/anchored|publication|unsafe/); assert.deepEqual(await runtimeLeases(f),[]); });
test("renderer refuses a same-path regular snapshot byte replacement against manifest and expected digest",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/replaced.yaml"); const replaced=(await readFile(f.snapshot,"utf8")).replace("max_chunk_chars: 4000","max_chunk_chars: 3999"); await writeFile(f.snapshot,replaced); await assert.rejects(call(f,{outputPath:output}),/snapshot bytes changed/); await assert.rejects(lstat(output)); assert.deepEqual(await runtimeLeases(f),[]); });
test("renderer refuses snapshot manifest head, digest, and expected-digest tampering",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/tampered.yaml"); const manifest=JSON.parse(await readFile(f.manifestPath,"utf8"));
await writeFile(f.manifestPath,JSON.stringify({...manifest,head:"c".repeat(40)})); await assert.rejects(call(f,{outputPath:output}),/snapshot manifest identity/);
await writeFile(f.manifestPath,JSON.stringify({...manifest,files:{"p1-filesystem.yaml":"d".repeat(64)}})); await assert.rejects(call(f,{outputPath:output}),/snapshot manifest digest/);
await writeFile(f.manifestPath,JSON.stringify(manifest)); await assert.rejects(call(f,{outputPath:output,snapshotSha256:"e".repeat(64)}),/snapshot manifest digest/);
await assert.rejects(lstat(output)); assert.deepEqual(await runtimeLeases(f),[]); });
test("renderer refuses a missing or malformed snapshot manifest",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/nomanifest.yaml"); await rm(f.manifestPath); await assert.rejects(call(f,{outputPath:output}),/snapshot manifest.*(missing|unbounded|unsafe)/); await writeFile(f.manifestPath,"{not json"); await assert.rejects(call(f,{outputPath:output}),/snapshot manifest.*malformed/); await assert.rejects(lstat(output)); assert.deepEqual(await runtimeLeases(f),[]); });
test("renderer rejects a regular snapshot replacement against its manifest",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/replaced.yaml"); await assert.rejects(call(f,{outputPath:output,beforePublish:async()=>{await writeFile(f.snapshot,"workspace:\n schema_version: 3\n id: p1-filesystem\n name: replaced\n")}}),/snapshot content changed/); await assert.rejects(lstat(output)); });
test("renderer anchors publication when rendered parent is concurrently swapped", async()=>{
const f=await fixture(),output=join(f.root,"rendered/raced.yaml"),moved=join(f.root,"rendered-moved"),outside=join(f.repo,"outside-rendered"); await mkdir(outside);
await assert.rejects(call(f,{outputPath:output,beforePublish:async()=>{await rename(join(f.root,"rendered"),moved);await symlink(outside,join(f.root,"rendered"));}}),/identity|changed|unsafe|publication/i);
assert.deepEqual(await (await import("node:fs/promises")).readdir(outside),[]);
});
+905
View File
@@ -0,0 +1,905 @@
#!/usr/bin/env node
import { createHash, randomBytes } from "node:crypto";
import { closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync } from "node:fs";
import { access, lstat, mkdir, open, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import {
buildSafeEnvironment,
collectRepositoryProvenance,
deriveOverall,
scanSecrets,
} from "./p1-acceptance.mjs";
const execFileAsync = promisify(execFile);
const modulePath = fileURLToPath(import.meta.url);
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
const RUN_ID = /^p11-[0-9a-f]{32}$/;
const HEX40 = /^[0-9a-f]{40}$/;
const HEX64 = /^[0-9a-f]{64}$/;
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const ZIP_FILES = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"];
function resolveSystemExecutable(name) {
for (const candidate of [`/usr/bin/${name}`, `/bin/${name}`, `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`]) {
try {
const resolved = realpathSync(candidate);
if (lstatSync(resolved).isFile()) return resolved;
} catch {}
}
throw new Error(`required executable not found: ${name}`);
}
function resolveExecutables(repositoryRoot) {
const repo = canonicalRoot(repositoryRoot);
const thtPath = join(repo, "harness", ".venv", "bin", "tht");
if (!existsSync(thtPath)) throw new Error("required executable not found: tht");
return { gitPath: resolveSystemExecutable("git"), pythonPath: resolveSystemExecutable("python3"), thtPath: realpathSync(thtPath) };
}
const TOPOLOGY = [
"remote.git", "author", "installation/registry", "installation/data", "installation/runtime",
"fixture-secrets", "fixtures/descriptors", "fixtures/requests", "requests", "responses",
"exports/raw", "exports/extracted", "rendered", "logs",
];
export const CHECK_IDS = Object.freeze([
"preflight",
"clean_state",
"ownership",
"catalog_bootstrap",
"catalog_only_listing",
"bootstrap_create_once",
"api_curator_boundary",
"curator_descriptor_update",
"content_only_revision",
"docs_only_reconciliation",
"same_revision_git_objects",
"snapshot_and_export",
"runtime_render_determinism",
"tht_config_check",
"negative_catalog_layout_cases",
"negative_schema_context_cases",
"no_p2_scope_artifacts",
"secret_scan",
"cleanup_confinement",
]);
function nowIso() { return new Date().toISOString(); }
function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
function assert(condition, message) { if (!condition) throw new Error(message); }
function scalarSecretBytes(value) {
if (typeof value !== "string" || value.length === 0 || /\s|\0/.test(value)) throw new Error("scalar fixture secret is invalid");
return Buffer.from(value);
}
function canonicalRoot(repositoryRoot) { return realpathSync(repositoryRoot); }
export function canonicalIntegrationBase(repositoryRoot = defaultRepositoryRoot) {
return join(canonicalRoot(repositoryRoot), ".artifacts", "p11-integration");
}
export function validateRunRoot(repositoryRoot, runRoot, runId) {
if (!RUN_ID.test(runId)) throw new Error("invalid owned run id");
const base = canonicalIntegrationBase(repositoryRoot);
const lexical = resolve(runRoot);
if (dirname(lexical) !== base || basename(lexical) !== runId) throw new Error("run root is not a direct integration child");
return lexical;
}
function validateNoSymlinkAncestors(repositoryRoot, target) {
const repo = canonicalRoot(repositoryRoot);
const rel = relative(repo, target);
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("path leaves repository");
let cursor = repo;
for (const part of rel.split(sep).filter(Boolean)) {
cursor = join(cursor, part);
if (!existsSync(cursor)) break;
const entry = lstatSync(cursor);
if (entry.isSymbolicLink()) throw new Error("owned path ancestor is a symlink");
}
}
async function atomicWrite(path, bytes, mode = 0o600) {
await mkdir(dirname(path), { recursive: true });
const staging = join(dirname(path), `.${basename(path)}.${randomBytes(12).toString("hex")}.tmp`);
let handle;
try {
handle = await open(staging, "wx", mode);
await handle.writeFile(bytes);
await handle.sync();
await handle.close();
handle = undefined;
await rename(staging, path);
const directory = openSync(dirname(path), fsConstants.O_RDONLY);
try { fsyncSync(directory); } finally { closeSync(directory); }
} catch (error) {
if (handle) await handle.close().catch(() => {});
await rm(staging, { force: true }).catch(() => {});
throw error;
}
}
function exactOwnedResources(run) {
return [
run.root,
join(run.root, "remote.git"),
join(run.root, "author"),
join(run.root, "installation", "registry"),
join(run.root, "installation", "data"),
join(run.root, "installation", "runtime"),
];
}
function initialListeners(pid) {
return [{ name: "primary", kind: "fastify", host: "127.0.0.1", requestedPort: 0, pid, state: "not_started" }];
}
function ownershipValue(run, listeners = run.listeners) {
return {
schemaVersion: 1,
kind: "p11-acceptance",
runId: run.runId,
runNonce: run.nonce,
root: run.root,
repositoryRoot: run.repositoryRoot,
startedAt: run.startedAt,
pid: run.pid,
listeners,
resources: exactOwnedResources(run),
};
}
async function writeOwnership(run, listenerUpdate) {
const listeners = listenerUpdate
? run.listeners.map((listener) => listener.name === listenerUpdate.name ? listenerUpdate : listener)
: run.listeners;
await atomicWrite(join(run.root, "ownership.json"), `${JSON.stringify(ownershipValue(run, listeners), null, 2)}\n`);
run.listeners = listeners;
}
export async function createOwnedRun({ repositoryRoot = defaultRepositoryRoot, runId, nonce, now, pid } = {}) {
const repo = canonicalRoot(repositoryRoot);
const base = canonicalIntegrationBase(repo);
validateNoSymlinkAncestors(repo, base);
await mkdir(join(repo, ".artifacts"), { mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST") throw error; });
await mkdir(base, { mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST") throw error; });
const id = runId ?? `p11-${randomBytes(16).toString("hex")}`;
const root = validateRunRoot(repo, join(base, id), id);
const run = {
repositoryRoot: repo,
root,
runId: id,
nonce: nonce ?? randomBytes(32).toString("hex"),
startedAt: now ?? nowIso(),
pid: pid ?? process.pid,
listeners: initialListeners(pid ?? process.pid),
};
if (!HEX64.test(run.nonce) || !ISO_UTC.test(run.startedAt)) throw new Error("invalid ownership identity");
await mkdir(root, { mode: 0o700 });
await writeOwnership(run);
return run;
}
function strictOwnership(value, run, expectedNonce) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("ownership is malformed");
const listener = value.listeners?.[0];
const validListener = Array.isArray(value.listeners) && value.listeners.length === 1
&& listener?.name === "primary" && listener.kind === "fastify" && listener.host === "127.0.0.1"
&& listener.requestedPort === 0 && listener.pid === process.pid
&& ["not_started", "listening", "closed", "close_failed"].includes(listener.state)
&& (listener.state === "not_started" ? !("actualPort" in listener)
: Number.isInteger(listener.actualPort) && listener.actualPort >= 1 && listener.actualPort <= 65535);
if (value.schemaVersion !== 1 || value.kind !== "p11-acceptance" || value.runId !== run.runId || value.runNonce !== expectedNonce
|| value.root !== run.root || value.repositoryRoot !== run.repositoryRoot || value.pid !== process.pid
|| !ISO_UTC.test(value.startedAt ?? "") || !validListener
|| JSON.stringify(value.resources) !== JSON.stringify(exactOwnedResources(run))) throw new Error("ownership identity mismatch");
return value;
}
export async function readAndValidateOwnership({ repositoryRoot = defaultRepositoryRoot, runRoot, expectedNonce }) {
const repo = canonicalRoot(repositoryRoot);
const id = basename(resolve(runRoot));
const lexical = validateRunRoot(repo, runRoot, id);
const rootEntry = await lstat(lexical);
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) throw new Error("owned run root is not a directory");
const ownershipPath = join(lexical, "ownership.json");
const ownershipEntry = await lstat(ownershipPath);
if (!ownershipEntry.isFile() || ownershipEntry.isSymbolicLink()) throw new Error("ownership file is unsafe");
let value;
try { value = JSON.parse(await readFile(ownershipPath, "utf8")); } catch { throw new Error("ownership is malformed"); }
return strictOwnership(value, {
repositoryRoot: repo,
root: lexical,
runId: id,
nonce: expectedNonce,
startedAt: value.startedAt,
pid: process.pid,
}, expectedNonce);
}
export async function cleanupOwnedRun({ repositoryRoot = defaultRepositoryRoot, runRoot, expectedNonce }) {
const value = await readAndValidateOwnership({ repositoryRoot, runRoot, expectedNonce });
const base = canonicalIntegrationBase(repositoryRoot);
const tombstone = join(base, `.deleting-${value.runId}-${expectedNonce.slice(0, 16)}`);
await rename(runRoot, tombstone);
await rm(tombstone, { recursive: true, force: false });
}
async function finalizeOwnedRun({ run, success, keep }) {
if (!success || keep) return false;
await cleanupOwnedRun({ repositoryRoot: run.repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
return true;
}
function sanitizeForEvidence(value, forbiddenValues = []) {
const forbidden = forbiddenValues.filter((item) => typeof item === "string" && item.length > 0);
const redactString = (input) => forbidden.reduce((text, secret) => text.split(secret).join("[REDACTED]"), input);
if (typeof value === "string") return redactString(value);
if (Array.isArray(value)) return value.map((item) => sanitizeForEvidence(item, forbiddenValues));
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeForEvidence(item, forbiddenValues)]));
return value;
}
async function fileArtifact(root, relativePath) {
const bytes = await readFile(join(root, relativePath));
return { path: relativePath.split(sep).join("/"), sha256: sha256(bytes) };
}
async function evidence(run, relativePath, value, forbiddenValues = []) {
await atomicWrite(join(run.root, relativePath), `${JSON.stringify(sanitizeForEvidence(value, forbiddenValues), null, 2)}\n`);
return await fileArtifact(run.root, relativePath);
}
async function writeJson(path, value) {
await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
}
async function walkFiles(root) {
const files = [];
async function visit(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) await visit(path);
else if (entry.isFile()) files.push({ path, rel: relative(root, path).split(sep).join("/") });
}
}
if (existsSync(root)) await visit(root);
return files.sort((a, b) => a.rel.localeCompare(b.rel));
}
async function snapshotDigest(root) {
const result = {};
for (const file of await walkFiles(root)) result[file.rel] = sha256(await readFile(file.path));
return result;
}
function assertByteIdentical(left, right, label) {
if (JSON.stringify(left) !== JSON.stringify(right)) throw new Error(`${label} changed unexpectedly`);
}
async function writeReportFiles({ run, report }) {
validateReport(report);
await writeJson(join(run.root, "report.json"), report);
const lines = [
`# P1.1 acceptance report`,
"",
`Run ID: ${report.runId}`,
`Overall: ${report.overall}`,
"",
...report.checks.map((check) => `- ${check.id}: ${check.status}`),
"",
`report.json sha256: ${sha256(await readFile(join(run.root, "report.json")))}`,
`P1.1 automated integration: ${report.overall}`,
"P1.1 manual acceptance: PENDING",
];
await atomicWrite(join(run.root, "report.md"), `${lines.join("\n")}\n`);
}
export function validateReport(report) {
if (!report || typeof report !== "object" || Array.isArray(report)) throw new Error("report is malformed");
if (report.schemaVersion !== 1 || !RUN_ID.test(report.runId ?? "") || !ISO_UTC.test(report.startedAt ?? "")
|| !ISO_UTC.test(report.finishedAt ?? "") || report.command !== "p11-acceptance integration --keep") throw new Error("report identity is invalid");
if (report.overall !== deriveOverall(report.checks ?? [])) throw new Error("report overall is not derived");
if (!Array.isArray(report.checks) || report.checks.length !== CHECK_IDS.length) throw new Error("report checks are incomplete");
const ids = report.checks.map((check) => check.id);
if (JSON.stringify(ids) !== JSON.stringify(CHECK_IDS)) throw new Error("report checks are not exact");
const artifactPaths = new Set();
for (const check of report.checks) {
if (!["PASS", "FAIL"].includes(check.status) || !ISO_UTC.test(check.startedAt ?? "") || !ISO_UTC.test(check.finishedAt ?? "")) {
throw new Error("report check metadata is invalid");
}
if (!Array.isArray(check.commands) || check.commands.some((command) => typeof command !== "string" || !/^[A-Za-z0-9._+-]+$/.test(command))) {
throw new Error("report command is invalid");
}
if (!Array.isArray(check.artifacts)) throw new Error("report artifacts are invalid");
for (const artifact of check.artifacts) {
if (typeof artifact.path !== "string" || artifact.path.startsWith("/") || artifact.path.includes("..") || !/^[A-Za-z0-9._/-]+$/.test(artifact.path)) {
throw new Error("report artifact path is invalid");
}
if (!HEX64.test(artifact.sha256 ?? "")) throw new Error("report artifact hash is invalid");
if (artifactPaths.has(artifact.path)) throw new Error("report artifact path is duplicated");
artifactPaths.add(artifact.path);
}
}
}
async function execCommand(executable, argv, { cwd, env, timeoutMs = 30_000, stdin } = {}) {
if (!Array.isArray(argv) || argv.some((value) => typeof value !== "string")) throw new Error("command argv must be a string array");
const result = await execFileAsync(executable, argv, {
cwd,
env,
timeout: timeoutMs,
maxBuffer: 16 * 1024 * 1024,
encoding: "utf8",
...(stdin === undefined ? {} : { input: stdin }),
});
return { code: 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}
async function git(ctx, argv, options = {}) {
return await execCommand(ctx.executables.gitPath, argv, { ...options, env: ctx.env });
}
async function tht(ctx, argv, options = {}) {
try {
return await execCommand(ctx.executables.thtPath, argv, { ...options, env: ctx.env });
} catch (error) {
if (typeof error?.code === "number") return { code: error.code, stdout: error.stdout ?? "", stderr: error.stderr ?? "" };
throw error;
}
}
function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); }
function baseWorkspace(id, evidenceSource) {
return {
workspace: { schema_version: 3, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" },
dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] },
semantic_index: {
vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" },
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
},
llm_policy: { allowed: ["zai/glm-5.2"] },
evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } },
};
}
function descriptors() {
return [
baseWorkspace("p11-filesystem", { type: "filesystem", uri: "p11-filesystem/evidence", patterns: ["**/*.md"], max_bytes: 10485760 }),
baseWorkspace("p11-http", { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "signed_urls_file", connect_timeout_ms: 1250, read_timeout_ms: 30001, max_bytes: 12345, max_redirects: 2, allow_private_hosts: false, max_cache_bytes: 67890 }),
baseWorkspace("p11-s3", { type: "s3", uri: "s3://p11-evidence/published/", endpoint_url: "https://s3.example.test/", region: "eu-west-1", credentials: "static_files", trusted_endpoint: true, allow_private_endpoint: false, allow_insecure_endpoint: false, max_bytes: 12345, max_objects: 33, max_pages: 4, page_size: 5 }),
];
}
async function createTopology(run) {
for (const path of TOPOLOGY) await mkdir(join(run.root, path), { recursive: true, mode: path === "fixture-secrets" ? 0o700 : 0o755 });
}
async function setupSecrets(ctx) {
const secretDir = join(ctx.run.root, "fixture-secrets");
const values = {
dwh: `DWH-${randomBytes(12).toString("hex")}`,
signed: `SIGNED-${randomBytes(12).toString("hex")}`,
access: `ACCESS-${randomBytes(12).toString("hex")}`,
secret: `SECRET-${randomBytes(12).toString("hex")}`,
session: `SESSION-${randomBytes(12).toString("hex")}`,
rejected: `REJECTED-${randomBytes(12).toString("hex")}`,
};
ctx.forbiddenValues = Object.values(values);
ctx.secretValues = values;
const paths = {
dwh: join(secretDir, "dwh-password"),
signed: join(secretDir, "evidence-signed-urls.json"),
access: join(secretDir, "evidence-access"),
secret: join(secretDir, "evidence-secret"),
session: join(secretDir, "evidence-session"),
};
await atomicWrite(paths.dwh, scalarSecretBytes(values.dwh));
await atomicWrite(paths.signed, JSON.stringify([`https://evidence.example.test/guide.md?token=${values.signed}`]));
await atomicWrite(paths.access, scalarSecretBytes(values.access));
await atomicWrite(paths.secret, scalarSecretBytes(values.secret));
await atomicWrite(paths.session, scalarSecretBytes(values.session));
const env = {};
for (const workspace of ctx.descriptors) {
const prefix = `THT_WS_${namespace(workspace.workspace.id)}`;
Object.assign(env, {
[`${prefix}_DWH_TRANSPORT`]: "postgres_direct",
[`${prefix}_DWH_HOST`]: "dwh.invalid",
[`${prefix}_DWH_PORT`]: "5432",
[`${prefix}_DWH_USER`]: "reader",
[`${prefix}_DWH_PASSWORD_FILE`]: paths.dwh,
});
}
Object.assign(env, {
THT_WS_P11_HTTP_EVIDENCE_SIGNED_URLS_FILE: paths.signed,
THT_WS_P11_S3_EVIDENCE_ACCESS_KEY_FILE: paths.access,
THT_WS_P11_S3_EVIDENCE_SECRET_KEY_FILE: paths.secret,
THT_WS_P11_S3_EVIDENCE_SESSION_TOKEN_FILE: paths.session,
});
Object.assign(ctx.env, env);
await atomicWrite(join(ctx.run.root, "installation", "bindings.env"), `${Object.entries(env).map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
await atomicWrite(join(ctx.run.root, "installation", "runtime", "base.yaml"), "{}\n");
}
function catalog(entries = ctxDescriptors) {
return { schema_version: 1, workspaces: entries.map(({ workspace }) => ({ id: workspace.id, name: workspace.name, description: workspace.description })) };
}
const ctxDescriptors = descriptors();
async function initializeGit(ctx) {
const author = join(ctx.run.root, "author");
await git(ctx, ["init", "--bare", "--initial-branch=main", join(ctx.run.root, "remote.git")], { cwd: ctx.run.root });
await git(ctx, ["clone", join(ctx.run.root, "remote.git"), author], { cwd: ctx.run.root });
await git(ctx, ["config", "user.name", "P1 Fixture Curator"], { cwd: author });
await git(ctx, ["config", "user.email", "p1-curator@example.invalid"], { cwd: author });
const catalogBytes = `${JSON.stringify({
schema_version: 1,
workspaces: [
...catalog(ctx.descriptors).workspaces,
{ id: "p11-pending", name: "P1.1 pending", description: "Catalog-only slot awaiting bootstrap" },
],
}, null, 2)}\n`;
await atomicWrite(join(author, "thoth-workspaces.yaml"), catalogBytes, 0o644);
const evidenceRoot = join(author, "p11-filesystem", "evidence");
await mkdir(join(evidenceRoot, "domain"), { recursive: true });
await atomicWrite(join(evidenceRoot, "guide.md"), "# P1.1 curated Evidence\n", 0o644);
await atomicWrite(join(evidenceRoot, "domain", "table.md"), "# Curated table\n", 0o644);
await git(ctx, ["add", "thoth-workspaces.yaml"], { cwd: author });
await git(ctx, ["add", "p11-filesystem/evidence/guide.md"], { cwd: author });
await git(ctx, ["add", "-A", "p11-filesystem/evidence"], { cwd: author });
await git(ctx, ["commit", "-m", "Bootstrap curated P1 content"], { cwd: author });
await git(ctx, ["push", "origin", "main"], { cwd: author });
ctx.bootstrapCommit = (await git(ctx, ["rev-parse", "HEAD"], { cwd: author })).stdout.trim();
ctx.catalogBlobBefore = (await git(ctx, ["rev-parse", `HEAD:thoth-workspaces.yaml`], { cwd: author })).stdout.trim();
ctx.evidenceTreeBefore = (await git(ctx, ["rev-parse", `HEAD:p11-filesystem/evidence`], { cwd: author })).stdout.trim();
}
async function loadProductionBackend() {
const [{ loadConfig }, { buildApp }, { WorkspaceRegistry }, { ThtRunner }] = await Promise.all([
import("../dist/config.js"),
import("../dist/app.js"),
import("../dist/workspaces/registry.js"),
import("../dist/tht/tht-runner.js"),
]);
return { loadConfig, buildApp, WorkspaceRegistry, ThtRunner };
}
async function startBackend(ctx) {
const { loadConfig, buildApp, WorkspaceRegistry, ThtRunner } = await loadProductionBackend();
const config = loadConfig(ctx.env);
const registry = new WorkspaceRegistry(config.workspaceRegistry);
const thtRunner = new ThtRunner({
thtBin: config.thtBin,
harnessDir: config.harnessDir,
configPath: join(ctx.run.root, "installation", "runtime", "base.yaml"),
dataRoot: config.dataRoot,
runtimeSnapshotRoot: join(config.workspaceRegistry.root, "snapshots", "runtime"),
secretRoots: config.workspaceRegistry.secretRoots,
secretsFile: config.secretsFile,
secretFiles: config.secretFiles,
semanticRuntime: {
internalQdrantUrl: config.internalQdrantUrl,
internalEmbeddingUrl: config.internalEmbeddingUrl,
internalEmbeddingModel: config.internalEmbeddingModel,
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
},
});
const app = buildApp(config, { thtRunner, workspaceRegistry: registry });
const address = await app.listen({ host: "127.0.0.1", port: 0 });
const baseUrl = `http://127.0.0.1:${new URL(address).port}`;
ctx.registry = registry;
ctx.thtRunner = thtRunner;
ctx.app = app;
ctx.baseUrl = baseUrl;
await writeOwnership(ctx.run, {
name: "primary", kind: "fastify", host: "127.0.0.1", requestedPort: 0,
actualPort: Number(new URL(address).port), pid: process.pid, state: "listening",
});
}
async function stopBackend(ctx) {
if (ctx.app) {
await ctx.app.close().catch(() => {});
await writeOwnership(ctx.run, {
name: "primary", kind: "fastify", host: "127.0.0.1", requestedPort: 0,
actualPort: Number(new URL(ctx.baseUrl).port), pid: process.pid, state: "closed",
}).catch(() => {});
}
}
async function request(ctx, id, method, path, body, binary = false, safeInput) {
const requestSummary = safeInput === undefined
? { method, path, ...(body === undefined ? {} : { body: sanitizeForEvidence(body, ctx.forbiddenValues) }) }
: { method, path, input: safeInput };
await evidence(ctx.run, `requests/${id}.json`, requestSummary, ctx.forbiddenValues);
const response = await fetch(`${ctx.baseUrl}${path}`, {
method,
headers: body === undefined ? {} : { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(15_000),
});
if (binary) {
const bytes = Buffer.from(await response.arrayBuffer());
await atomicWrite(join(ctx.run.root, `exports/raw/${id}.zip`), bytes);
await evidence(ctx.run, `responses/${id}.json`, { status: response.status, bytes: bytes.length, contentType: response.headers.get("content-type") });
return { status: response.status, bytes };
}
const text = await response.text();
let parsed;
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = { invalidJson: true, raw: text }; }
await evidence(ctx.run, `responses/${id}.json`, { status: response.status, body: sanitizeForEvidence(parsed, ctx.forbiddenValues) }, ctx.forbiddenValues);
return { status: response.status, body: parsed };
}
async function extractZip(ctx, id, bytes) {
const yauzl = (await import("yauzl")).default;
const output = join(ctx.run.root, "exports", "extracted", id);
await mkdir(output, { recursive: true });
const files = await new Promise((resolvePromise, reject) => {
yauzl.fromBuffer(bytes, { lazyEntries: true, strictFileNames: true, validateEntrySizes: true }, (error, zip) => {
if (error || !zip) return reject(error ?? new Error("zip open failed"));
const collected = new Map();
zip.on("error", reject);
zip.on("entry", (entry) => {
if (!ZIP_FILES.includes(entry.fileName) || entry.fileName.includes("..") || entry.fileName.startsWith("/") || entry.fileName.endsWith("/")) return reject(new Error("unsafe export entry"));
zip.openReadStream(entry, (streamError, stream) => {
if (streamError || !stream) return reject(streamError ?? new Error("zip stream failed"));
const chunks = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("error", reject);
stream.on("end", async () => {
const buffer = Buffer.concat(chunks);
collected.set(entry.fileName, buffer);
await atomicWrite(join(output, entry.fileName), buffer);
zip.readEntry();
});
});
});
zip.on("end", () => resolvePromise(collected));
zip.readEntry();
});
});
assert(files.size === ZIP_FILES.length, "export bundle entry mismatch");
return JSON.parse(files.get("manifest.json").toString("utf8"));
}
function checkResult(id, startedAt, status, artifacts = [], commands = [], error) {
return { id, status, startedAt, finishedAt: nowIso(), artifacts, commands, ...(error ? { error } : {}) };
}
async function executeChecks({ checks }) {
const results = [];
let stopped = false;
for (const scenario of checks) {
const startedAt = nowIso();
if (stopped) {
results.push(checkResult(scenario.id, startedAt, "FAIL", [], [], "Not executed after earlier failure."));
continue;
}
try {
const output = await scenario.run();
results.push(checkResult(scenario.id, startedAt, "PASS", output.artifacts ?? [], output.commands ?? []));
} catch (error) {
const partial = error?.acceptancePartial ?? {};
results.push(checkResult(scenario.id, startedAt, "FAIL", partial.artifacts ?? [], partial.commands ?? [], "Acceptance scenario failed safely."));
stopped = true;
}
}
return results;
}
async function registryState(ctx) {
const statePath = join(ctx.run.root, "installation", "registry", "state", "active.json");
const active = JSON.parse(await readFile(statePath, "utf8"));
return {
head: active.head,
revisions: active.revisions.map((revision) => ({ id: revision.id, commit: revision.commit, blob: revision.blob })),
catalog: active.catalog ?? null,
};
}
function safeErrorEnvelope(response, code, status) {
assert(response.status === status, `expected ${status}`);
assert(response.body?.code === code, `expected error code ${code}`);
assert(Object.keys(response.body).sort().join(",") === "code,message", "error envelope is not exact");
}
async function productionChecks(ctx) {
const check = async (id, value, commands = []) => ({ commands, artifacts: [await evidence(ctx.run, `logs/${id}.json`, value, ctx.forbiddenValues)] });
return [
{ id: "preflight", run: async () => check("preflight", { node: process.version, repositoryHead: ctx.provenance.head, repositoryTree: ctx.provenance.tree, clean: ctx.provenance.clean, thtExecutable: true }) },
{ id: "clean_state", run: async () => check("clean_state", { runId: ctx.run.runId, reused: false }) },
{ id: "ownership", run: async () => { await readAndValidateOwnership({ repositoryRoot: ctx.repositoryRoot, runRoot: ctx.run.root, expectedNonce: ctx.run.nonce }); return await check("ownership", { valid: true }); } },
{ id: "catalog_bootstrap", run: async () => {
await initializeGit(ctx);
for (const workspace of ctx.descriptors) await atomicWrite(join(ctx.run.root, "fixtures", "descriptors", `${workspace.workspace.id}.json`), `${JSON.stringify(workspace, null, 2)}\n`);
return {
commands: ["git"],
artifacts: [
await evidence(ctx.run, "logs/catalog-bootstrap.json", { bootstrapCommit: ctx.bootstrapCommit, catalogOnly: true }),
await fileArtifact(ctx.run.root, "author/thoth-workspaces.yaml"),
await fileArtifact(ctx.run.root, "author/p11-filesystem/evidence/guide.md"),
],
};
} },
{ id: "catalog_only_listing", run: async () => {
await startBackend(ctx);
const status = await request(ctx, "registry-status", "GET", "/workspace-registry/status");
assert(status.status === 200 && status.body.head === ctx.bootstrapCommit, "status head mismatch");
const listed = await request(ctx, "workspace-list-initial", "GET", "/workspaces");
assert(listed.status === 200 && listed.body.length === 4, "catalog listing failed");
assert(listed.body.every((entry) => entry.configurationState === "configuration_required"), "catalog entries were not configuration_required");
ctx.baseCommit = status.body.head;
return await check("catalog_only_listing", { head: status.body.head, ids: listed.body.map((entry) => entry.id), allConfigurationRequired: true });
} },
{ id: "bootstrap_create_once", run: async () => {
let base = ctx.baseCommit;
ctx.bootstrapResponses = {};
for (const workspace of ctx.descriptors) {
const validated = await request(ctx, `validate-${workspace.workspace.id}`, "POST", "/workspaces/validate", { workspace });
assert(validated.status === 200, `validate failed ${workspace.workspace.id}`);
const published = await request(ctx, `publish-${workspace.workspace.id}`, "POST", "/workspaces/publish", { action: "create", workspace, baseCommit: base });
assert(published.status === 200 && HEX40.test(published.body.revision.commit), `publish failed ${workspace.workspace.id}`);
ctx.bootstrapResponses[workspace.workspace.id] = published.body;
base = published.body.revision.commit;
}
ctx.publishHead = base;
const listed = await request(ctx, "workspace-list-ready", "GET", "/workspaces");
assert(listed.body.filter((entry) => entry.configurationState === "ready").length === 3, "bootstrap did not activate all published entries");
assert(listed.body.find((entry) => entry.id === "p11-pending")?.configurationState === "configuration_required", "pending slot was not left unconfigured");
return await check("bootstrap_create_once", { head: base, readyIds: listed.body.filter((entry) => entry.configurationState === "ready").map((entry) => entry.id) });
} },
{ id: "api_curator_boundary", run: async () => {
const author = join(ctx.run.root, "author");
const catalogAfter = (await git(ctx, ["rev-parse", `HEAD:thoth-workspaces.yaml`], { cwd: author })).stdout.trim();
const evidenceAfter = (await git(ctx, ["rev-parse", `HEAD:p11-filesystem/evidence`], { cwd: author })).stdout.trim();
assert(catalogAfter === ctx.catalogBlobBefore, "catalog blob changed during bootstrap");
assert(evidenceAfter === ctx.evidenceTreeBefore, "evidence tree changed during bootstrap");
ctx.apiBoundaryState = await registryState(ctx);
return await check("api_curator_boundary", { catalogUnchanged: true, evidenceUnchanged: true, state: ctx.apiBoundaryState }, ["git"]);
} },
{ id: "curator_descriptor_update", run: async () => {
const author = join(ctx.run.root, "author");
await git(ctx, ["fetch", "origin", "main"], { cwd: author });
await git(ctx, ["reset", "--hard", "origin/main"], { cwd: author });
const workspace = structuredClone(ctx.descriptors[0]);
workspace.workspace.name = "P1.1 Curated Filesystem";
workspace.workspace.description = "Curator updated descriptor and catalog metadata";
ctx.curatedWorkspace = workspace;
const updatedCatalog = catalog([workspace, ctx.descriptors[1], ctx.descriptors[2]]);
await atomicWrite(join(author, "thoth-workspaces.yaml"), `${JSON.stringify(updatedCatalog, null, 2)}\n`, 0o644);
await atomicWrite(join(author, "p11-filesystem", "workspace.yaml"), `${(await import("yaml")).stringify(workspace)}`, 0o644);
await git(ctx, ["add", "thoth-workspaces.yaml"], { cwd: author });
await git(ctx, ["add", "--", "p11-filesystem/workspace.yaml"], { cwd: author });
await git(ctx, ["commit", "-m", "Publish workspace p1-filesystem"], { cwd: author });
await git(ctx, ["push", "origin", "main"], { cwd: author });
ctx.curatorCommit = (await git(ctx, ["rev-parse", "HEAD"], { cwd: author })).stdout.trim();
ctx.curatorDescriptorBlob = (await git(ctx, ["rev-parse", `HEAD:p11-filesystem/workspace.yaml`], { cwd: author })).stdout.trim();
const pulled = await request(ctx, "pull-after-curator-update", "POST", "/workspace-registry/pull");
assert(pulled.status === 200 && HEX40.test(pulled.body.head), "pull after curator update failed");
ctx.docsFollowupHead = pulled.body.head;
const read = await request(ctx, "read-after-curator-update", "GET", "/workspaces/p11-filesystem");
assert(read.status === 200 && read.body.workspace.workspace.name === workspace.workspace.name, "curator update did not activate");
assert(read.body.revision.blob === ctx.curatorDescriptorBlob, "api rewrote curator descriptor bytes");
return await check("curator_descriptor_update", { curatorCommit: ctx.curatorCommit, activeHead: ctx.docsFollowupHead, descriptorBlob: ctx.curatorDescriptorBlob }, ["git"]);
} },
{ id: "content_only_revision", run: async () => {
const author = join(ctx.run.root, "author");
await git(ctx, ["fetch", "origin", "main"], { cwd: author });
await git(ctx, ["reset", "--hard", "origin/main"], { cwd: author });
await atomicWrite(join(author, "p11-filesystem", "evidence", "guide.md"), "# P1.1 curated Evidence v2\n", 0o644);
await git(ctx, ["add", "p11-filesystem/evidence/guide.md"], { cwd: author });
await git(ctx, ["commit", "-m", "Update curated Evidence only"], { cwd: author });
await git(ctx, ["push", "origin", "main"], { cwd: author });
ctx.contentCommit = (await git(ctx, ["rev-parse", "HEAD"], { cwd: author })).stdout.trim();
const pulled = await request(ctx, "pull-after-content-update", "POST", "/workspace-registry/pull");
assert(pulled.status === 200 && pulled.body.head === ctx.contentCommit, "content pull head mismatch");
const read = await request(ctx, "read-after-content-update", "GET", "/workspaces/p11-filesystem");
assert(read.body.revision.commit === ctx.contentCommit, "content commit did not activate");
assert(read.body.revision.blob === ctx.curatorDescriptorBlob, "descriptor blob changed on content-only update");
ctx.currentRead = read.body;
return await check("content_only_revision", { commit: ctx.contentCommit, descriptorBlobUnchanged: true }, ["git"]);
} },
{ id: "docs_only_reconciliation", run: async () => {
const repo = join(ctx.run.root, "installation", "registry", "repo");
const diff = (await git(ctx, ["show", "--name-only", "--format=", ctx.docsFollowupHead], { cwd: repo })).stdout.trim().split(/\n+/).filter(Boolean);
assert(diff.length > 0 && diff.every((path) => path.startsWith("workspace-docs/")), "docs follow-up touched non-doc paths");
const finalDescriptor = (await git(ctx, ["rev-parse", `${ctx.docsFollowupHead}:p11-filesystem/workspace.yaml`], { cwd: repo })).stdout.trim();
assert(finalDescriptor === ctx.curatorDescriptorBlob, "docs follow-up rewrote descriptor");
return await check("docs_only_reconciliation", { head: ctx.docsFollowupHead, files: diff, descriptorBlobPreserved: true }, ["git"]);
} },
{ id: "same_revision_git_objects", run: async () => {
const repo = join(ctx.run.root, "installation", "registry", "repo");
const revision = ctx.currentRead.revision;
const manifestPath = join(dirname(revision.snapshotPath), "snapshot.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const catalogBlob = (await git(ctx, ["rev-parse", `${revision.commit}:thoth-workspaces.yaml`], { cwd: repo })).stdout.trim();
const descriptorBlob = (await git(ctx, ["rev-parse", `${revision.commit}:p11-filesystem/workspace.yaml`], { cwd: repo })).stdout.trim();
const evidenceTree = (await git(ctx, ["rev-parse", `${revision.commit}:p11-filesystem/evidence`], { cwd: repo })).stdout.trim();
assert(manifest.head === revision.commit, "snapshot manifest head mismatch");
assert(descriptorBlob === revision.blob, "descriptor blob mismatch");
ctx.snapshotManifest = manifest;
return {
commands: ["git"],
artifacts: [
await evidence(ctx.run, "logs/same-revision-git-objects.json", { commit: revision.commit, catalogBlob, descriptorBlob, evidenceTree, snapshotHead: manifest.head }),
await fileArtifact(ctx.run.root, relative(ctx.run.root, revision.snapshotPath)),
await fileArtifact(ctx.run.root, relative(ctx.run.root, manifestPath)),
],
};
} },
{ id: "snapshot_and_export", run: async () => {
ctx.exportManifests = {};
const artifacts = [];
for (const workspace of ctx.descriptors) {
const id = workspace.workspace.id;
const exported = await request(ctx, `export-${id}`, "GET", `/workspaces/${id}/export`, undefined, true);
assert(exported.status === 200, `export failed ${id}`);
ctx.exportManifests[id] = await extractZip(ctx, id, exported.bytes);
artifacts.push(await fileArtifact(ctx.run.root, `exports/raw/export-${id}.zip`));
for (const name of ZIP_FILES) artifacts.push(await fileArtifact(ctx.run.root, `exports/extracted/${id}/${name}`));
}
return { commands: [], artifacts: [await evidence(ctx.run, "logs/snapshot-and-export.json", { exported: Object.keys(ctx.exportManifests), files: ZIP_FILES }), ...artifacts] };
} },
{ id: "runtime_render_determinism", run: async () => {
const YAML = await import("yaml");
ctx.configChecks = [];
const artifacts = [];
for (const workspace of ctx.descriptors) {
const revision = (await request(ctx, `read-render-${workspace.workspace.id}`, "GET", `/workspaces/${workspace.workspace.id}`)).body.revision;
const renders = [];
for (let n = 1; n <= 2; n += 1) {
const lease = ctx.thtRunner.acquireWorkspaceRuntime(revision.snapshotPath);
try {
const bytes = await readFile(lease.path);
renders.push(bytes);
await atomicWrite(join(ctx.run.root, "rendered", `${workspace.workspace.id}-${n}.yaml`), bytes);
const checked = await tht(ctx, ["config", "check", "-c", lease.path], { cwd: ctx.env.THT_HARNESS_DIR, timeoutMs: 30_000 });
ctx.configChecks.push({ id: workspace.workspace.id, observation: n, code: checked.code });
} finally {
lease.release();
}
artifacts.push(await fileArtifact(ctx.run.root, `rendered/${workspace.workspace.id}-${n}.yaml`));
}
assert(renders[0].equals(renders[1]), `render was nondeterministic ${workspace.workspace.id}`);
const rendered = YAML.parse(renders[0].toString("utf8"));
assert(rendered.runtime_identity.workspace_revision === revision.commit, `runtime identity mismatch ${workspace.workspace.id}`);
}
return { commands: ["tht"], artifacts: [await evidence(ctx.run, "logs/runtime-render-determinism.json", { deterministic: true, checks: ctx.configChecks }), ...artifacts] };
} },
{ id: "tht_config_check", run: async () => {
assert(ctx.configChecks.length === ctx.descriptors.length * 2 && ctx.configChecks.every((item) => item.code === 0), "tht config checks failed");
return await check("tht-config-check", ctx.configChecks, ["tht"]);
} },
{ id: "negative_catalog_layout_cases", run: async () => {
const baseline = await registryState(ctx);
const author = join(ctx.run.root, "author");
const current = (await request(ctx, "current-list-before-negatives", "GET", "/workspaces")).body;
const secondCreate = await request(ctx, "second-create", "POST", "/workspaces/publish", { action: "create", workspace: ctx.descriptors[0], baseCommit: baseline.head });
safeErrorEnvelope(secondCreate, "workspace_curator_owned", 409);
const update = await request(ctx, "legacy-update", "POST", "/workspaces/publish", { action: "update", workspace: ctx.descriptors[0], baseCommit: baseline.head, baseBlob: ctx.curatorDescriptorBlob });
safeErrorEnvelope(update, "workspace_curator_owned", 409);
const deletion = await request(ctx, "legacy-delete", "POST", "/workspaces/publish", { action: "delete", id: "p11-filesystem", baseCommit: baseline.head, baseBlob: ctx.curatorDescriptorBlob });
safeErrorEnvelope(deletion, "workspace_curator_owned", 409);
const unknown = structuredClone(ctx.descriptors[0]);
unknown.workspace.id = "p11-unknown";
const unknownPublish = await request(ctx, "unknown-catalog-id", "POST", "/workspaces/publish", { action: "create", workspace: unknown, baseCommit: baseline.head });
safeErrorEnvelope(unknownPublish, "workspace_invalid", 400);
const mismatch = structuredClone(ctx.descriptors[0]);
mismatch.workspace.id = "p11-pending";
mismatch.workspace.name = "Mismatched pending name";
mismatch.semantic_index.vector_store.collection = "p11-pending";
const mismatchPublish = await request(ctx, "catalog-metadata-mismatch", "POST", "/workspaces/publish", { action: "create", workspace: mismatch, baseCommit: baseline.head });
safeErrorEnvelope(mismatchPublish, "workspace_invalid", 400);
const after = await registryState(ctx);
assertByteIdentical(after, baseline, "registry state after curator-owned refusals");
assert(JSON.stringify((await request(ctx, "current-list-after-negatives", "GET", "/workspaces")).body) === JSON.stringify(current), "workspace listing mutated after negative cases");
await git(ctx, ["fetch", "origin", "main"], { cwd: author });
await git(ctx, ["reset", "--hard", "origin/main"], { cwd: author });
await mkdir(join(author, "workspaces"), { recursive: true });
await atomicWrite(join(author, "workspaces", "legacy.yaml"), "workspace: bad\n", 0o644);
await git(ctx, ["add", "--", "workspaces/legacy.yaml"], { cwd: author });
await git(ctx, ["commit", "-m", "Invalid contextual Evidence state"], { cwd: author });
await git(ctx, ["push", "origin", "HEAD:main"], { cwd: author });
const rejectedPull = await request(ctx, "invalid-layout-pull", "POST", "/workspace-registry/pull");
safeErrorEnvelope(rejectedPull, "workspace_invalid", 400);
const afterInvalidPull = await registryState(ctx);
assertByteIdentical(afterInvalidPull, baseline, "registry state after invalid pull");
return await check("negative_catalog_layout_cases", { secondCreate: true, update: true, delete: true, unknownCatalogId: true, metadataMismatch: true, oldLayoutRejected: true }, ["git"]);
} },
{ id: "negative_schema_context_cases", run: async () => {
const base = structuredClone(ctx.descriptors[0]);
const cases = [
["invalid-uri", (workspace) => { workspace.evidence.source.uri = "/etc/passwd"; }, "evidence.source.uri"],
["invalid-secret-field", (workspace) => { workspace.evidence.source.password = ctx.secretValues.rejected; }, "evidence.source.password"],
["missing-evidence-tree", (workspace) => { workspace.workspace.id = "p11-pending"; workspace.workspace.name = "P1.1 pending"; workspace.workspace.description = "Catalog-only slot awaiting bootstrap"; workspace.semantic_index.vector_store.collection = "p11-pending"; workspace.evidence.source.uri = "p11-pending/evidence"; }, "evidence.source.uri"],
];
const outcomes = [];
for (const [id, mutate, field] of cases) {
const workspace = structuredClone(base);
mutate(workspace);
const endpoint = id === "missing-evidence-tree" ? "/workspaces/publish" : "/workspaces/validate";
const payload = id === "missing-evidence-tree" ? { action: "create", workspace, baseCommit: ctx.publishHead } : { workspace };
const response = await request(ctx, `negative-schema-${id}`, "POST", endpoint, payload, false, { case: id, expectedInputField: field });
safeErrorEnvelope(response, "workspace_invalid", 400);
outcomes.push({ case: id, status: response.status, field });
}
return await check("negative_schema_context_cases", outcomes);
} },
{ id: "no_p2_scope_artifacts", run: async () => {
const forbidden = ["artifacts/evidence", "materialized", "qdrant", "embedding", "ACTIVE", "retention"];
const present = forbidden.filter((path) => existsSync(join(ctx.run.root, path)));
assert(present.length === 0, "p2 scope artifacts present");
return await check("no_p2_scope_artifacts", { absent: forbidden });
} },
{ id: "secret_scan", run: async () => {
const findings = await scanSecrets({ runRoot: ctx.run.root, forbiddenValues: ctx.forbiddenValues, expectedGitRepositories: ["remote.git", "author"] });
assert(findings.length === 0, "secret scan found leaked secret material");
return await check("secret_scan", { findings: 0 });
} },
{ id: "cleanup_confinement", run: async () => {
const parent = canonicalIntegrationBase(ctx.repositoryRoot);
const siblings = (await readdir(parent)).filter((name) => name !== ctx.run.runId);
return await check("cleanup_confinement", { listenerState: ctx.run.listeners[0].state, siblingCount: siblings.length });
} },
];
}
async function setupContext({ repositoryRoot = defaultRepositoryRoot, env = process.env } = {}) {
const run = await createOwnedRun({ repositoryRoot });
const provenance = await collectRepositoryProvenance({ repositoryRoot });
const executables = resolveExecutables(repositoryRoot);
const harnessDir = realpathSync(join(repositoryRoot, "harness"));
const ownedHome = join(run.root, "installation", "runtime", "acceptance-home");
const ownedTmp = join(run.root, "installation", "runtime", "tmp");
await mkdir(ownedHome, { recursive: true, mode: 0o700 });
await mkdir(ownedTmp, { recursive: true, mode: 0o700 });
const executablePath = [...new Set([dirname(executables.gitPath), dirname(executables.pythonPath), dirname(executables.thtPath)])].join(":");
const fixtureEnv = {
PATH: executablePath,
HOME: ownedHome,
TMPDIR: ownedTmp,
HOST: "127.0.0.1",
PORT: "0",
AUTH_MODE: "none",
THT_BIN: executables.thtPath,
THT_HARNESS_DIR: harnessDir,
THT_DATA_ROOT: join(run.root, "installation", "data"),
SETTINGS_FILE: join(run.root, "installation", "data", "settings.json"),
MAINTENANCE_STATE_FILE: join(run.root, "installation", "data", "maintenance.json"),
THT_WORKSPACE_REGISTRY_ROOT: join(run.root, "installation", "registry"),
THT_WORKSPACE_GIT_REMOTE: join(run.root, "remote.git"),
THT_WORKSPACE_GIT_BRANCH: "main",
THT_WORKSPACE_GIT_AUTHOR_NAME: "P1 API Publisher",
THT_WORKSPACE_GIT_AUTHOR_EMAIL: "p1-api@example.invalid",
THT_WORKSPACE_INSTALLATION_ID: "p11-acceptance",
THT_WORKSPACE_SECRET_ROOTS: join(run.root, "fixture-secrets"),
THT_HOME: join(run.root, "installation", "runtime", "tht-home"),
PYTHONDONTWRITEBYTECODE: "1",
PYTHONNOUSERSITE: "1",
};
const ctx = {
run,
repositoryRoot: canonicalRoot(repositoryRoot),
provenance,
executables,
descriptors: descriptors(),
env: buildSafeEnvironment({ ambient: env, fixture: fixtureEnv }),
forbiddenValues: [],
};
await createTopology(run);
await setupSecrets(ctx);
return ctx;
}
export async function runIntegration({ repositoryRoot = defaultRepositoryRoot, keep = false, env = process.env, announce } = {}) {
const ctx = await setupContext({ repositoryRoot, env });
const priorEnv = {};
for (const [key, value] of Object.entries(ctx.env)) {
priorEnv[key] = process.env[key];
process.env[key] = value;
}
let success = false;
try {
const checks = await productionChecks(ctx);
const results = await executeChecks({ checks });
const report = {
schemaVersion: 1,
runId: ctx.run.runId,
startedAt: ctx.run.startedAt,
finishedAt: nowIso(),
command: "p11-acceptance integration --keep",
overall: deriveOverall(results),
checks: results,
};
await writeReportFiles({ run: ctx.run, report });
success = report.overall === "PASS";
if (announce) await announce({ report, runRoot: ctx.run.root });
return { exitCode: success ? 0 : 1, runRoot: ctx.run.root, retained: !(await finalizeOwnedRun({ run: ctx.run, success, keep })) };
} finally {
await stopBackend(ctx).catch(() => {});
for (const [key, value] of Object.entries(ctx.env)) {
if (priorEnv[key] === undefined) delete process.env[key];
else process.env[key] = priorEnv[key];
}
}
}
export async function main(argv = process.argv.slice(2), env = process.env) {
if (argv.length < 1 || argv[0] !== "integration" || argv.length > 2 || (argv[1] && argv[1] !== "--keep")) {
throw new Error("usage: p11-acceptance.mjs integration [--keep]");
}
const result = await runIntegration({ keep: argv.includes("--keep"), env });
return result.exitCode;
}
if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) {
try {
const code = await main();
process.exitCode = code;
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+113
View File
@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CHECK_IDS,
canonicalIntegrationBase,
cleanupOwnedRun,
createOwnedRun,
readAndValidateOwnership,
validateReport,
validateRunRoot,
} from "./p11-acceptance.mjs";
const roots = [];
async function fakeRepository() {
const root = await mkdtemp(join(tmpdir(), "p11-acceptance-repo-"));
roots.push(root);
await mkdir(join(root, ".artifacts", "p11-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p1-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "manual-acceptance", "p11"), { recursive: true });
return root;
}
test.afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("run roots are only canonical direct p11 integration children", async () => {
const repositoryRoot = await fakeRepository();
const base = canonicalIntegrationBase(repositoryRoot);
const id = `p11-${"a".repeat(32)}`;
assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id));
for (const candidate of [
base,
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(repositoryRoot, ".artifacts", "p1-integration", id),
join(base, id, "nested"),
join(base, "foreign"),
]) {
assert.throws(() => validateRunRoot(repositoryRoot, candidate, id));
}
assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p11-${"A".repeat(32)}`), `p11-${"A".repeat(32)}`));
});
test("cleanup refuses p1, manual, sibling, and wrong-nonce roots", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
for (const bad of [
join(repositoryRoot, ".artifacts", "p1-integration", `p1-${"b".repeat(32)}`),
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(canonicalIntegrationBase(repositoryRoot), `p11-${"c".repeat(32)}`),
]) {
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: bad, expectedNonce: run.nonce }));
}
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: "0".repeat(64) }));
});
test("cleanup removes exactly one owned p11 root", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const sibling = join(canonicalIntegrationBase(repositoryRoot), `p11-${"d".repeat(32)}`);
await mkdir(sibling);
await writeFile(join(sibling, "sentinel"), "foreign");
await cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
await assert.rejects(readFile(join(run.root, "ownership.json")));
assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign");
});
function resultFor(id) {
return {
id,
status: "PASS",
startedAt: "2026-08-11T00:00:00.000Z",
finishedAt: "2026-08-11T00:00:01.000Z",
commands: ["git"],
artifacts: [{ path: `logs/${id}.json`, sha256: "a".repeat(64) }],
};
}
test("report validation requires exact p11 identity, check order, and unique artifacts", () => {
const report = {
schemaVersion: 1,
runId: `p11-${"e".repeat(32)}`,
startedAt: "2026-08-11T00:00:00.000Z",
finishedAt: "2026-08-11T00:00:10.000Z",
command: "p11-acceptance integration --keep",
overall: "PASS",
checks: CHECK_IDS.map(resultFor),
};
assert.doesNotThrow(() => validateReport(report));
const invalid = structuredClone(report);
invalid.runId = `p1-${"e".repeat(32)}`;
assert.throws(() => validateReport(invalid));
const duplicate = structuredClone(report);
duplicate.checks[1].artifacts[0].path = duplicate.checks[0].artifacts[0].path;
assert.throws(() => validateReport(duplicate), /duplicated/);
const reordered = structuredClone(report);
reordered.checks.reverse();
reordered.overall = "FAIL";
assert.throws(() => validateReport(reordered));
});
test("public wrapper uses a strict empty environment", async () => {
const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p11-acceptance.sh"), "utf8");
assert.match(wrapper, /safe_env=\(\/usr\/bin\/env -i/);
assert.doesNotMatch(wrapper, /LANG|LC_ALL|TZ/);
assert.doesNotMatch(wrapper, /P11_ACCEPTANCE_FAIL_AT/);
});
+404
View File
@@ -0,0 +1,404 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { closeSync, constants as fsConstants, fsyncSync, lstatSync, openSync, realpathSync } from "node:fs";
import { access, lstat, mkdir, open, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import http from "node:http";
import { buildSafeEnvironment } from "./p1-acceptance.mjs";
const execFileAsync = promisify(execFile);
const modulePath = fileURLToPath(import.meta.url);
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
const HOST = "127.0.0.1";
const BACKEND_PORT = 8791;
const FRONTEND_PORT = 8792;
const HEX64 = /^[0-9a-f]{64}$/;
const OWNERSHIP_DIGEST = "ownership.sha256";
function resolveSystemExecutable(name) {
for (const candidate of [`/usr/bin/${name}`, `/bin/${name}`, `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`]) {
try {
const resolved = realpathSync(candidate);
if (lstatSync(resolved).isFile()) return resolved;
} catch {}
}
throw new Error(`required executable not found: ${name}`);
}
function resolveExecutables(repositoryRoot) {
const repo = realpathSync(repositoryRoot);
const thtPath = join(repo, "harness", ".venv", "bin", "tht");
if (!lstatSync(thtPath).isFile()) throw new Error("required executable not found: tht");
return { gitPath: resolveSystemExecutable("git"), pythonPath: resolveSystemExecutable("python3"), thtPath: realpathSync(thtPath) };
}
function nowIso() { return new Date().toISOString(); }
function fixedManualRoot(repositoryRoot = defaultRepositoryRoot) { return join(realpathSync(repositoryRoot), ".artifacts", "manual-acceptance", "p11"); }
function below(parent, child) { const rel = relative(parent, child); return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel); }
function noSymlinkExisting(repo, target) {
const rel = relative(repo, target);
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("root leaves repository");
let cursor = repo;
for (const part of rel.split(sep).filter(Boolean)) {
cursor = join(cursor, part);
if (!lstatSync(cursor, { throwIfNoEntry: false })) break;
if (lstatSync(cursor).isSymbolicLink()) throw new Error("owned path contains a symlink");
}
}
async function atomicWrite(path, bytes, mode = 0o600) {
await mkdir(dirname(path), { recursive: true });
const staging = join(dirname(path), `.${basename(path)}.${randomBytes(12).toString("hex")}.tmp`);
let handle;
try {
handle = await open(staging, "wx", mode);
await handle.writeFile(bytes);
await handle.sync();
await handle.close();
handle = undefined;
await rename(staging, path);
const directory = openSync(dirname(path), fsConstants.O_RDONLY);
try { fsyncSync(directory); } finally { closeSync(directory); }
} catch (error) {
if (handle) await handle.close().catch(() => {});
await rm(staging, { force: true }).catch(() => {});
throw error;
}
}
function ownershipDigest(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
async function writeManualOwnership(root, value) {
const body = `${JSON.stringify(value, null, 2)}\n`;
await atomicWrite(join(root, "ownership.json"), body);
await atomicWrite(join(root, OWNERSHIP_DIGEST), `${ownershipDigest(body)}\n`);
}
async function git(executable, argv, options = {}) {
const result = await execFileAsync(executable, argv, { cwd: options.cwd, env: options.env, timeout: options.timeoutMs ?? 30_000, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" });
return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}
function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); }
function baseWorkspace(id, evidenceSource) {
return {
workspace: { schema_version: 3, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" },
dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] },
semantic_index: {
vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" },
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
},
llm_policy: { allowed: ["zai/glm-5.2"] },
evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } },
};
}
function descriptors() {
return [
baseWorkspace("p11-filesystem", { type: "filesystem", uri: "p11-filesystem/evidence", patterns: ["**/*.md"], max_bytes: 10485760 }),
baseWorkspace("p11-http", { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "signed_urls_file", connect_timeout_ms: 1250, read_timeout_ms: 30001, max_bytes: 12345, max_redirects: 2, allow_private_hosts: false, max_cache_bytes: 67890 }),
baseWorkspace("p11-s3", { type: "s3", uri: "s3://p11-evidence/published/", endpoint_url: "https://s3.example.test/", region: "eu-west-1", credentials: "static_files", trusted_endpoint: true, allow_private_endpoint: false, allow_insecure_endpoint: false, max_bytes: 12345, max_objects: 33, max_pages: 4, page_size: 5 }),
];
}
function catalog(entries) {
return { schema_version: 1, workspaces: entries.map(({ workspace }) => ({ id: workspace.id, name: workspace.name, description: workspace.description })) };
}
function quote(value) { return `'${String(value).replaceAll("'", `'"'"'`)}'`; }
function requestFixtures(items) {
const fixtures = { "status.json": { method: "GET", path: "/workspace-registry/status" }, "pull.json": { method: "POST", path: "/workspace-registry/pull" } };
for (const workspace of items) {
const id = workspace.workspace.id;
fixtures[`validate-${id}.json`] = { workspace };
fixtures[`publish-${id}.json`] = { action: "create", workspace };
fixtures[`read-${id}.json`] = { method: "GET", path: `/workspaces/${id}` };
fixtures[`export-${id}.json`] = { method: "GET", path: `/workspaces/${id}/export` };
}
fixtures["negative-invalid-uri.json"] = { workspace: { ...items[0], evidence: { ...items[0].evidence, source: { ...items[0].evidence.source, uri: "/etc/passwd" } } } };
fixtures["negative-secret-field.json"] = { workspace: { ...items[2], evidence: { ...items[2].evidence, source: { ...items[2].evidence.source, access_key: "CANARY-MUST-BE-REJECTED" } } } };
return fixtures;
}
function curlGet(url, output) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; }
function curlPost(url, output, body) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --request POST --header 'content-type: application/json' --data-binary @${quote(body)} --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; }
function curlPostEmpty(url, output) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --request POST --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; }
function publishCurl(root, id, previousResponse) {
const descriptor = join(root, "requests", `publish-${id}.json`);
const response = join(root, "responses", `publish-${id}.json`);
return `#!/usr/bin/env bash\nset -euo pipefail\nbase_commit=$(node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));console.log(value.head ?? value.revision?.commit ?? "");' ${quote(previousResponse)})\nnode -e 'const fs=require("node:fs");const body=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));body.baseCommit=process.argv[2];fs.writeFileSync(process.argv[1],JSON.stringify(body,null,2)+"\\n");' ${quote(descriptor)} "$base_commit"\ncurl --fail-with-body --silent --show-error --request POST --header 'content-type: application/json' --data-binary @${quote(descriptor)} --output ${quote(response)} --write-out 'HTTP %{http_code}\\n' 'http://${HOST}:${BACKEND_PORT}/workspaces/publish'\n`; }
function renderCommand(repo, root, observation) {
const readResponse = join(root, "responses", "read-p11-filesystem.json");
const output = join(root, "rendered", `runtime-${observation}.yaml`);
return `#!/usr/bin/env bash\nset -euo pipefail\nread_snapshot=$(node -e 'const fs=require("node:fs");const read=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const path=read.revision.snapshotPath;const manifest=JSON.parse(fs.readFileSync(require("node:path").join(require("node:path").dirname(path),"snapshot.json"),"utf8"));const name=require("node:path").basename(path);console.log(JSON.stringify({snapshot:path,digest:manifest.files[name]}));' ${quote(readResponse)})\nsnapshot=$(node -e 'const value=JSON.parse(process.argv[1]);console.log(value.snapshot)' "$read_snapshot")\ndigest=$(node -e 'const value=JSON.parse(process.argv[1]);console.log(value.digest)' "$read_snapshot")\nnode ${quote(join(repo, "backend", "scripts", "p11-render-snapshot.mjs"))} --ownership ${quote(join(root, "ownership.json"))} --snapshot "$snapshot" --output ${quote(output)} --snapshot-sha256 "$digest"\n`;
}
function guide(root) {
return `# P1.1 manual acceptance guide
1. Inspect ${join(root, "ownership.json")}, ${join(root, "author", "thoth-workspaces.yaml")}, nested workspace directories, evidence tree, and fixture secret paths without printing secret bytes.
2. Run ./scripts/p11-manual-acceptance.sh serve and confirm only ${HOST}:${BACKEND_PORT} and ${HOST}:${FRONTEND_PORT} are listening for this lab.
3. Run commands/http-01-status.sh and inspect responses/status.json plus GET /workspaces for configuration_required slots.
4. Run the validate and publish scripts once per slot in numeric order.
5. Inspect Git object IDs for thoth-workspaces.yaml, <id>/workspace.yaml, <id>/evidence, and workspace-docs/<id>.
6. Retry create/update/delete and verify refusal plus unchanged object IDs.
7. In ${join(root, "author")}, edit p11-filesystem/workspace.yaml and thoth-workspaces.yaml together, commit, push, then run commands/http-08-pull.sh and verify the API activated curator bytes without rewriting the descriptor.
8. Make an evidence-only commit under p11-filesystem/evidence, push, pull, and inspect the new revision commit with unchanged descriptor blob.
9. In the UI at http://${HOST}:${FRONTEND_PORT}, confirm ready workspaces are read-only and bootstrap-only slots are editable before creation.
10. Export/import only under bootstrap rules.
11. Run commands/render-1.sh and commands/render-2.sh, diff rendered/runtime-1.yaml rendered/runtime-2.yaml, then run tht config check -c on both outputs.
12. Run the negative validate scripts and a bounded secret scan outside fixture-secrets.
13. Run ./scripts/p11-manual-acceptance.sh stop, verify cleanup of both listeners, write VERDICT.md yourself, and run cleanup only when evidence is no longer needed.
`;
}
function ownershipValue(root, repositoryRoot, nonce, extras = {}) {
return {
schemaVersion: 1,
kind: "p11-manual-acceptance",
nonce,
repositoryRoot,
root,
createdAt: nowIso(),
status: "PENDING",
listeners: {
backend: { host: HOST, port: BACKEND_PORT },
frontend: { host: HOST, port: FRONTEND_PORT },
},
resources: [root, join(root, "remote.git"), join(root, "author"), join(root, "fixture-secrets")],
...extras,
};
}
export async function readManualOwnership({ repositoryRoot = defaultRepositoryRoot } = {}) {
const repo = realpathSync(repositoryRoot);
const root = fixedManualRoot(repo);
noSymlinkExisting(repo, root);
const rootEntry = await lstat(root);
const ownershipPath = join(root, "ownership.json");
const digestPath = join(root, OWNERSHIP_DIGEST);
const ownershipEntry = await lstat(ownershipPath);
const digestEntry = await lstat(digestPath);
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink() || !ownershipEntry.isFile() || ownershipEntry.isSymbolicLink() || !digestEntry.isFile() || digestEntry.isSymbolicLink()) throw new Error("manual ownership is unsafe");
const ownershipBytes = await readFile(ownershipPath, "utf8");
const recordedDigest = (await readFile(digestPath, "utf8")).trim();
if (!HEX64.test(recordedDigest) || recordedDigest !== ownershipDigest(ownershipBytes)) throw new Error("manual ownership digest mismatch");
const value = JSON.parse(ownershipBytes);
if (value?.schemaVersion !== 1 || value.kind !== "p11-manual-acceptance" || !HEX64.test(value.nonce ?? "") || value.repositoryRoot !== repo || value.root !== root) {
throw new Error("manual ownership identity mismatch");
}
return value;
}
async function ensureRootAbsent(root) {
try { await lstat(root); throw new Error("manual acceptance root already exists"); } catch (error) { if (error.code !== "ENOENT") throw error; }
}
async function waitForHttp(url, timeoutMs = 15_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await new Promise((resolvePromise, reject) => {
const request = http.get(url, (response) => { response.resume(); response.statusCode && response.statusCode < 500 ? resolvePromise() : reject(new Error("not ready")); });
request.on("error", reject);
});
return;
} catch {
await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
}
}
throw new Error(`timed out waiting for ${url}`);
}
function live(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }
async function writeCommands(repo, root) {
const commands = [
["http-01-status.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspace-registry/status`, join(root, "responses", "status.json"))],
["http-02-validate-p11-filesystem.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-filesystem.json"), join(root, "requests", "validate-p11-filesystem.json"))],
["http-03-validate-p11-http.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-http.json"), join(root, "requests", "validate-p11-http.json"))],
["http-04-validate-p11-s3.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-s3.json"), join(root, "requests", "validate-p11-s3.json"))],
["http-05-publish-p11-filesystem.sh", publishCurl(root, "p11-filesystem", join(root, "responses", "status.json"))],
["http-06-publish-p11-http.sh", publishCurl(root, "p11-http", join(root, "responses", "publish-p11-filesystem.json"))],
["http-07-publish-p11-s3.sh", publishCurl(root, "p11-s3", join(root, "responses", "publish-p11-http.json"))],
["http-08-pull.sh", curlPostEmpty(`http://${HOST}:${BACKEND_PORT}/workspace-registry/pull`, join(root, "responses", "pull.json"))],
["http-09-read-p11-filesystem.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspaces/p11-filesystem`, join(root, "responses", "read-p11-filesystem.json"))],
["http-10-export-p11-filesystem.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspaces/p11-filesystem/export`, join(root, "exports", "raw", "p11-filesystem.zip"))],
["http-11-negative-invalid-uri.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "negative-invalid-uri.json"), join(root, "requests", "negative-invalid-uri.json"))],
["http-12-negative-secret-field.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "negative-secret-field.json"), join(root, "requests", "negative-secret-field.json"))],
["render-1.sh", renderCommand(repo, root, 1)],
["render-2.sh", renderCommand(repo, root, 2)],
];
for (const [name, body] of commands) {
const path = join(root, "commands", name);
await atomicWrite(path, body, 0o700);
}
}
export async function prepareManual({ repositoryRoot = defaultRepositoryRoot } = {}) {
const repo = realpathSync(repositoryRoot);
const root = fixedManualRoot(repo);
noSymlinkExisting(repo, root);
await ensureRootAbsent(root);
await mkdir(join(repo, ".artifacts", "manual-acceptance"), { recursive: true, mode: 0o700 });
await mkdir(root, { mode: 0o700 });
const executables = resolveExecutables(repo);
const nonce = randomBytes(32).toString("hex");
await writeManualOwnership(root, ownershipValue(root, repo, nonce));
for (const path of ["fixture-secrets", "requests", "responses", "commands", "rendered", "logs", "exports/raw", "exports/extracted", "installation/registry", "installation/data", "installation/runtime"]) {
await mkdir(join(root, path), { recursive: true, mode: path === "fixture-secrets" ? 0o700 : 0o755 });
}
const env = buildSafeEnvironment({ ambient: process.env, fixture: { PATH: dirname(executables.gitPath) } });
await git(executables.gitPath, ["init", "--bare", "--initial-branch=main", join(root, "remote.git")], { cwd: root, env });
await git(executables.gitPath, ["clone", join(root, "remote.git"), join(root, "author")], { cwd: root, env });
await git(executables.gitPath, ["config", "user.name", "P1 Fixture Curator"], { cwd: join(root, "author"), env });
await git(executables.gitPath, ["config", "user.email", "p1-curator@example.invalid"], { cwd: join(root, "author"), env });
const items = descriptors();
await atomicWrite(join(root, "author", "thoth-workspaces.yaml"), `${JSON.stringify(catalog(items), null, 2)}\n`, 0o644);
await mkdir(join(root, "author", "p11-filesystem", "evidence", "domain"), { recursive: true });
await atomicWrite(join(root, "author", "p11-filesystem", "evidence", "guide.md"), "# P1.1 curated Evidence\n", 0o644);
await atomicWrite(join(root, "author", "p11-filesystem", "evidence", "domain", "table.md"), "# Curated table\n", 0o644);
await git(executables.gitPath, ["add", "thoth-workspaces.yaml"], { cwd: join(root, "author"), env });
await git(executables.gitPath, ["add", "-A", "p11-filesystem/evidence"], { cwd: join(root, "author"), env });
await git(executables.gitPath, ["commit", "-m", "Bootstrap curated P1 content"], { cwd: join(root, "author"), env });
await git(executables.gitPath, ["push", "origin", "main"], { cwd: join(root, "author"), env });
const secrets = {
dwh: join(root, "fixture-secrets", "dwh-password"),
signed: join(root, "fixture-secrets", "evidence-signed-urls.json"),
access: join(root, "fixture-secrets", "evidence-access"),
secret: join(root, "fixture-secrets", "evidence-secret"),
session: join(root, "fixture-secrets", "evidence-session"),
};
await atomicWrite(secrets.dwh, "manual-dwh-secret", 0o600);
await atomicWrite(secrets.signed, JSON.stringify(["https://evidence.example.test/guide.md?token=manual"]), 0o600);
await atomicWrite(secrets.access, "manual-access", 0o600);
await atomicWrite(secrets.secret, "manual-secret", 0o600);
await atomicWrite(secrets.session, "manual-session", 0o600);
const bindings = {};
for (const workspace of items) {
const prefix = `THT_WS_${namespace(workspace.workspace.id)}`;
Object.assign(bindings, {
[`${prefix}_DWH_TRANSPORT`]: "postgres_direct",
[`${prefix}_DWH_HOST`]: "dwh.invalid",
[`${prefix}_DWH_PORT`]: "5432",
[`${prefix}_DWH_USER`]: "reader",
[`${prefix}_DWH_PASSWORD_FILE`]: secrets.dwh,
});
}
Object.assign(bindings, {
THT_WS_P11_HTTP_EVIDENCE_SIGNED_URLS_FILE: secrets.signed,
THT_WS_P11_S3_EVIDENCE_ACCESS_KEY_FILE: secrets.access,
THT_WS_P11_S3_EVIDENCE_SECRET_KEY_FILE: secrets.secret,
THT_WS_P11_S3_EVIDENCE_SESSION_TOKEN_FILE: secrets.session,
});
await atomicWrite(join(root, "installation", "bindings.env"), `${Object.entries(bindings).map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
await atomicWrite(join(root, "installation", "runtime", "base.yaml"), "{}\n");
for (const [name, value] of Object.entries(requestFixtures(items))) await atomicWrite(join(root, "requests", name), `${JSON.stringify(value, null, 2)}\n`, 0o600);
await writeCommands(repo, root);
await atomicWrite(join(root, "GUIDE.md"), guide(root), 0o600);
await atomicWrite(join(root, "logs", "backend.log"), "", 0o600);
const current = await readManualOwnership({ repositoryRoot: repo });
current.status = "PENDING";
current.requestFixtures = Object.keys(requestFixtures(items));
current.commandScripts = (await readdir(join(root, "commands"))).sort();
await writeManualOwnership(root, current);
return root;
}
export async function serveManual({ repositoryRoot = defaultRepositoryRoot } = {}) {
const repo = realpathSync(repositoryRoot);
const root = fixedManualRoot(repo);
const owned = await readManualOwnership({ repositoryRoot: repo });
if (owned.status === "RUNNING") throw new Error("manual acceptance is already serving");
await access(join(repo, "backend", "dist", "server.js"));
await access(join(repo, "frontend", "dist", "index.html"));
const executables = resolveExecutables(repo);
const logHandle = await open(join(root, "logs", "backend.log"), fsConstants.O_WRONLY | fsConstants.O_APPEND);
const homeDir = join(root, "installation", "runtime", "home");
const tmpDir = join(root, "installation", "runtime", "tmp");
await mkdir(homeDir, { recursive: true, mode: 0o700 });
await mkdir(tmpDir, { recursive: true, mode: 0o700 });
const fixtureEnv = {
PATH: `${dirname(executables.gitPath)}:${dirname(executables.pythonPath)}:${dirname(executables.thtPath)}:/usr/bin:/bin`,
HOME: homeDir,
TMPDIR: tmpDir,
HOST,
PORT: String(BACKEND_PORT),
AUTH_MODE: "none",
THT_BIN: executables.thtPath,
THT_HARNESS_DIR: join(repo, "harness"),
THT_DATA_ROOT: join(root, "installation", "data"),
SETTINGS_FILE: join(root, "installation", "data", "settings.json"),
MAINTENANCE_STATE_FILE: join(root, "installation", "data", "maintenance.json"),
THT_WORKSPACE_REGISTRY_ROOT: join(root, "installation", "registry"),
THT_WORKSPACE_GIT_REMOTE: join(root, "remote.git"),
THT_WORKSPACE_GIT_BRANCH: "main",
THT_WORKSPACE_GIT_AUTHOR_NAME: "P1 API Publisher",
THT_WORKSPACE_GIT_AUTHOR_EMAIL: "p1-api@example.invalid",
THT_WORKSPACE_INSTALLATION_ID: "p11-manual-acceptance",
THT_WORKSPACE_SECRET_ROOTS: join(root, "fixture-secrets"),
THT_HOME: join(root, "installation", "runtime", "tht-home"),
PYTHONDONTWRITEBYTECODE: "1",
PYTHONNOUSERSITE: "1",
};
const bindingEnv = Object.fromEntries((await readFile(join(root, "installation", "bindings.env"), "utf8")).trim().split(/\n+/).map((line) => line.split(/=(.+)/)));
const env = buildSafeEnvironment({ ambient: process.env, fixture: { ...fixtureEnv, ...bindingEnv } });
const backend = spawn(process.execPath, [join(repo, "backend", "dist", "server.js")], { cwd: repo, env, stdio: ["ignore", logHandle.fd, logHandle.fd], detached: true });
const frontend = spawn(executables.pythonPath, ["-m", "http.server", String(FRONTEND_PORT), "--bind", HOST, "--directory", join(repo, "frontend", "dist")], { cwd: repo, env, stdio: ["ignore", "ignore", "ignore"], detached: true });
backend.unref(); frontend.unref();
await waitForHttp(`http://${HOST}:${BACKEND_PORT}/health`);
await waitForHttp(`http://${HOST}:${FRONTEND_PORT}/`);
await logHandle.close();
owned.status = "RUNNING";
owned.backend = { pid: backend.pid, port: BACKEND_PORT, command: [process.execPath, join(repo, "backend", "dist", "server.js")] };
owned.frontend = { pid: frontend.pid, port: FRONTEND_PORT, command: [executables.pythonPath, "-m", "http.server", String(FRONTEND_PORT)] };
await writeManualOwnership(root, owned);
return owned;
}
async function processCommandMatches(pid, expectedCommand) {
if (!Array.isArray(expectedCommand) || expectedCommand.length === 0) return false;
let output;
try {
const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
output = stdout.trim();
} catch {
return false;
}
if (output.length === 0) return false;
// The recorded command is the argv array used to spawn the process; verify every token appears
// in the current command line in order, so a reused PID with unrelated command is refused.
let cursor = 0;
for (const token of expectedCommand) {
if (token.length === 0) continue;
const index = output.indexOf(token, cursor);
if (index < 0) return false;
cursor = index + token.length;
}
return true;
}
export async function stopManual({ repositoryRoot = defaultRepositoryRoot } = {}) {
const repo = realpathSync(repositoryRoot);
const root = fixedManualRoot(repo);
const owned = await readManualOwnership({ repositoryRoot: repo });
if (owned.status !== "RUNNING" || !owned.backend?.pid || !owned.frontend?.pid) throw new Error("manual acceptance is not running");
for (const pid of [owned.backend.pid, owned.frontend.pid]) {
try { process.kill(-pid, "SIGTERM"); } catch (error) { if (error?.code !== "ESRCH") throw error; }
}
const deadline = Date.now() + 15_000;
while (Date.now() < deadline && (live(owned.backend.pid) || live(owned.frontend.pid))) await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
owned.status = "STOPPED";
await writeManualOwnership(root, owned);
return owned;
}
export async function cleanupManual({ repositoryRoot = defaultRepositoryRoot } = {}) {
const repo = realpathSync(repositoryRoot);
const root = fixedManualRoot(repo);
const owned = await readManualOwnership({ repositoryRoot: repo });
if (owned.status === "RUNNING") throw new Error("manual acceptance is still live");
if (owned.backend?.pid && live(owned.backend.pid)) throw new Error("backend process is still live");
if (owned.frontend?.pid && live(owned.frontend.pid)) throw new Error("frontend process is still live");
const parent = dirname(root);
const tombstone = join(parent, `.deleting-p11-${owned.nonce.slice(0, 16)}`);
await rename(root, tombstone);
await rm(tombstone, { recursive: true, force: false });
}
export async function main(argv = process.argv.slice(2)) {
if (argv.length !== 1 || !["prepare", "serve", "stop", "cleanup"].includes(argv[0])) throw new Error("usage: p11-manual-acceptance.mjs prepare|serve|stop|cleanup");
switch (argv[0]) {
case "prepare": await prepareManual(); break;
case "serve": await serveManual(); break;
case "stop": await stopManual(); break;
case "cleanup": await cleanupManual(); break;
}
}
if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) {
try { await main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }
}
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { access, lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import {
cleanupManual,
prepareManual,
readManualOwnership,
serveManual,
stopManual,
} from "./p11-manual-acceptance.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const fixedRoot = join(repoRoot, ".artifacts", "manual-acceptance", "p11");
async function safeCleanup() {
try {
const owned = await readManualOwnership({ repositoryRoot: repoRoot });
if (owned.status === "RUNNING") await stopManual({ repositoryRoot: repoRoot }).catch(() => {});
await cleanupManual({ repositoryRoot: repoRoot }).catch(() => {});
} catch {
await rm(fixedRoot, { recursive: true, force: true }).catch(() => {});
}
}
test.beforeEach(async () => {
await safeCleanup();
});
test.afterEach(async () => {
await safeCleanup();
});
test("prepare creates an independent pending lab without verdict", { concurrency: false }, async () => {
const root = await prepareManual({ repositoryRoot: repoRoot });
assert.equal(root, fixedRoot);
const owned = await readManualOwnership({ repositoryRoot: repoRoot });
assert.equal(owned.kind, "p11-manual-acceptance");
assert.equal(owned.status, "PENDING");
await access(join(root, "GUIDE.md"));
await access(join(root, "author", "thoth-workspaces.yaml"));
await access(join(root, "author", "p11-filesystem", "evidence", "guide.md"));
await access(join(root, "requests", "validate-p11-filesystem.json"));
await access(join(root, "commands", "http-01-status.sh"));
await access(join(root, "commands", "render-1.sh"));
await assert.rejects(access(join(root, "VERDICT.md")));
const guide = await readFile(join(root, "GUIDE.md"), "utf8");
assert.match(guide, /VERDICT\.md/);
assert.match(guide, /read-only/);
});
test("serve, stop, and cleanup manage the owned backend and frontend listeners", { concurrency: false }, async () => {
await prepareManual({ repositoryRoot: repoRoot });
const running = await serveManual({ repositoryRoot: repoRoot });
assert.equal(running.status, "RUNNING");
assert.equal(typeof running.backend.pid, "number");
assert.equal(typeof running.frontend.pid, "number");
const status = await fetch("http://127.0.0.1:8791/workspace-registry/status");
assert.equal(status.status, 200);
const frontend = await fetch("http://127.0.0.1:8792/");
assert.equal(frontend.status, 200);
await assert.rejects(cleanupManual({ repositoryRoot: repoRoot }), /still live/);
const stopped = await stopManual({ repositoryRoot: repoRoot });
assert.equal(stopped.status, "STOPPED");
await cleanupManual({ repositoryRoot: repoRoot });
await assert.rejects(lstat(fixedRoot));
});
test("stop fails closed when ownership is tampered", { concurrency: false }, async () => {
await prepareManual({ repositoryRoot: repoRoot });
const running = await serveManual({ repositoryRoot: repoRoot });
const ownershipPath = join(fixedRoot, "ownership.json");
const digestPath = join(fixedRoot, "ownership.sha256");
const original = JSON.parse(await readFile(ownershipPath, "utf8"));
const tampered = { ...original, backend: { ...original.backend, pid: original.backend.pid + 1 } };
await rm(ownershipPath);
await readFile(join(fixedRoot, "logs", "backend.log"));
await import("node:fs/promises").then(({ writeFile }) => writeFile(ownershipPath, `${JSON.stringify(tampered, null, 2)}
`));
await assert.rejects(stopManual({ repositoryRoot: repoRoot }), /manual ownership digest mismatch/);
const restored = `${JSON.stringify(running, null, 2)}
`;
const restoredDigest = `${createHash("sha256").update(restored).digest("hex")}
`;
await import("node:fs/promises").then(({ writeFile }) => Promise.all([writeFile(ownershipPath, restored), writeFile(digestPath, restoredDigest)]));
await stopManual({ repositoryRoot: repoRoot });
});
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { constants, lstatSync, realpathSync } from "node:fs";
import { lstat, mkdir, open, readFile, realpath } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { ThtRunner } from "../dist/tht/tht-runner.js";
const modulePath = fileURLToPath(import.meta.url);
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
const HEX40 = /^[0-9a-f]{40}$/;
const HEX64 = /^[0-9a-f]{64}$/;
function fixedRoot(repositoryRoot) { return join(realpathSync(repositoryRoot), ".artifacts", "manual-acceptance", "p11"); }
function below(parent, child) { const rel = relative(parent, child); return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel); }
function assertNoSymlinks(root, path, allowMissingLeaf = false) {
const rel = relative(root, path);
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("path is outside owned root");
let cursor = root;
const parts = rel.split(sep).filter(Boolean);
for (const [index, part] of parts.entries()) {
cursor = join(cursor, part);
try { if (lstatSync(cursor).isSymbolicLink()) throw new Error("owned path contains a symlink"); }
catch (error) {
if (allowMissingLeaf && error?.code === "ENOENT" && index === parts.length - 1) return;
throw error;
}
}
}
async function ownership(repositoryRoot, ownershipPath) {
const root = fixedRoot(repositoryRoot);
const expected = join(root, "ownership.json");
if (resolve(ownershipPath) !== expected) throw new Error("ownership path is not owned");
const rootEntry = await lstat(root); const ownershipEntry = await lstat(expected);
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink() || !ownershipEntry.isFile() || ownershipEntry.isSymbolicLink()) throw new Error("ownership is unsafe");
if (await realpath(root) !== root) throw new Error("ownership root is not canonical");
let value; try { value = JSON.parse(await readFile(expected, "utf8")); } catch { throw new Error("ownership is malformed"); }
if (value?.schemaVersion !== 1 || value.kind !== "p11-manual-acceptance" || !HEX64.test(value.nonce ?? "") || value.root !== root || value.repositoryRoot !== realpathSync(repositoryRoot)) {
throw new Error("ownership identity mismatch");
}
return { root, value };
}
const ANCHORED_PUBLISH_SOURCE=String.raw`import os,secrets,stat,sys
parent,name,expected_dev,expected_ino=sys.argv[1:]
pfd=fd=None;stage=".render-stage-"+secrets.token_hex(16);published=False
def fail(): raise RuntimeError("anchored publication refused")
try:
pfd=os.open(parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
identity=os.fstat(pfd)
if (identity.st_dev,identity.st_ino)!=(int(expected_dev),int(expected_ino)): fail()
try: os.stat(name,dir_fd=pfd,follow_symlinks=False); fail()
except FileNotFoundError: pass
fd=os.open(stage,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=pfd)
data=sys.stdin.buffer.read(33554433)
if len(data)>33554432: fail()
view=memoryview(data)
while view:
written=os.write(fd,view)
if written<=0: fail()
view=view[written:]
os.fsync(fd);os.close(fd);fd=None;os.rename(stage,name,src_dir_fd=pfd,dst_dir_fd=pfd);published=True;os.fsync(pfd)
current=os.stat(parent,follow_symlinks=False)
if not stat.S_ISDIR(current.st_mode) or (current.st_dev,current.st_ino)!=(identity.st_dev,identity.st_ino): fail()
except Exception:
if published:
try: os.unlink(name,dir_fd=pfd);os.fsync(pfd)
except Exception: pass
print("anchored output publication refused (details redacted)",file=sys.stderr);raise SystemExit(1)
finally:
if fd is not None: os.close(fd)
if pfd is not None:
try: os.unlink(stage,dir_fd=pfd)
except FileNotFoundError: pass
os.close(pfd)
`;
async function atomicCopy(source, output) {
const parent = dirname(output);
const entry = await lstat(parent);
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("rendered parent identity is unsafe");
const bytes = await readFile(source);
const result = spawnSync("python3", ["-c", ANCHORED_PUBLISH_SOURCE, parent, basename(output), String(entry.dev), String(entry.ino)], { input: bytes, encoding: "utf8", maxBuffer: 1024 * 1024 });
if (result.error || result.status !== 0) throw new Error("anchored output publication refused; rendered parent identity changed or output is unsafe");
}
function sameEntry(actual, expected) { return actual.dev === expected.dev && actual.ino === expected.ino; }
async function readBounded(path, max, label) {
let handle;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const before = await handle.stat(); const pathEntry = await lstat(path);
if (!before.isFile() || pathEntry.isSymbolicLink() || !pathEntry.isFile() || !sameEntry(before, pathEntry)) throw new Error(`${label} is unsafe`);
if (before.size < 1 || before.size > max) throw new Error(`${label} is unbounded`);
const bytes = Buffer.alloc(before.size); let offset = 0;
while (offset < bytes.length) {
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
if (bytesRead < 1) throw new Error(`${label} changed while reading`);
offset += bytesRead;
}
const after = await handle.stat();
if (!sameEntry(before, after) || after.size !== before.size) throw new Error(`${label} changed while reading`);
return bytes;
} finally {
if (handle) await handle.close().catch(() => {});
}
}
async function readSnapshotManifest(root, manifestPath, commit, yamlName, expectedDigest) {
let manifestEntry;
try { assertNoSymlinks(root, manifestPath); manifestEntry = await lstat(manifestPath); }
catch (error) { if (error?.code === "ENOENT") throw new Error("snapshot manifest is missing or unbounded"); throw error; }
if (!manifestEntry.isFile() || manifestEntry.isSymbolicLink() || await realpath(manifestPath) !== manifestPath) throw new Error("snapshot manifest is unsafe");
const bytes = await readBounded(manifestPath, 1024 * 1024, "snapshot manifest");
let manifest; try { manifest = JSON.parse(bytes.toString("utf8")); } catch { throw new Error("snapshot manifest is malformed"); }
const files = manifest?.files;
if (manifest?.head !== commit || !files || typeof files !== "object" || Array.isArray(files)) throw new Error("snapshot manifest identity is unsafe");
if (!HEX64.test(files[yamlName] ?? "") || files[yamlName] !== expectedDigest) throw new Error("snapshot manifest digest is unsafe");
return manifest;
}
export async function renderOwnedSnapshot({ repositoryRoot = defaultRepositoryRoot, ownershipPath, snapshotPath, outputPath, snapshotSha256, env = process.env, beforePublish }) {
const repo = realpathSync(repositoryRoot);
const { root } = await ownership(repo, resolve(repo, ownershipPath));
const snapshot = resolve(repo, snapshotPath);
const output = resolve(repo, outputPath);
const snapshotsRoot = join(root, "installation", "registry", "snapshots");
const renderedRoot = join(root, "rendered");
if (!isAbsolute(snapshotPath) || !below(snapshotsRoot, snapshot)) throw new Error("snapshot is not an owned absolute path");
const match = /^([0-9a-f]{40})\/([a-z][a-z0-9-]{2,62})\.yaml$/.exec(relative(snapshotsRoot, snapshot).split(sep).join("/"));
if (!match || !HEX40.test(match[1])) throw new Error("snapshot is not commit addressed");
if (!HEX64.test(snapshotSha256 ?? "")) throw new Error("snapshot digest identity is unsafe");
assertNoSymlinks(root, snapshot);
const snapshotEntry = await lstat(snapshot);
if (!snapshotEntry.isFile() || snapshotEntry.isSymbolicLink() || await realpath(snapshot) !== snapshot) throw new Error("snapshot is unsafe");
const yamlName = `${match[2]}.yaml`;
await readSnapshotManifest(root, join(snapshotsRoot, match[1], "snapshot.json"), match[1], yamlName, snapshotSha256);
const snapshotBytes = await readBounded(snapshot, 1024 * 1024, "snapshot");
if (createHash("sha256").update(snapshotBytes).digest("hex") !== snapshotSha256) throw new Error("snapshot bytes changed");
if (!below(renderedRoot, output) || dirname(output) !== renderedRoot || !output.endsWith(".yaml")) throw new Error("output is not an owned rendered path");
assertNoSymlinks(root, dirname(output));
try { if ((await lstat(output)).isSymbolicLink()) throw new Error("output is unsafe"); } catch (error) { if (error.code !== "ENOENT") throw error; }
await mkdir(join(snapshotsRoot, "runtime"), { recursive: true, mode: 0o700 });
const bindingEnv = Object.fromEntries((await readFile(join(root, "installation", "bindings.env"), "utf8")).trim().split(/\n+/).filter(Boolean).map((line) => line.split(/=(.+)/)));
const effectiveEnv = { ...bindingEnv, ...env };
const prior = {};
for (const [key, value] of Object.entries(effectiveEnv)) { prior[key] = process.env[key]; if (value === undefined) delete process.env[key]; else process.env[key] = value; }
const runner = new ThtRunner({
thtBin: join(repo, "harness", ".venv", "bin", "tht"),
harnessDir: join(repo, "harness"),
configPath: join(root, "installation", "runtime", "base.yaml"),
dataRoot: join(root, "installation", "data"),
runtimeSnapshotRoot: join(snapshotsRoot, "runtime"),
secretRoots: [join(root, "fixture-secrets")],
semanticRuntime: { internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024 },
});
let lease;
try {
lease = runner.acquireWorkspaceRuntime(snapshot);
const verifySnapshot = async () => {
const current = await readBounded(snapshot, 1024 * 1024, "snapshot");
if (createHash("sha256").update(current).digest("hex") !== snapshotSha256) throw new Error("snapshot content changed during rendering");
};
await verifySnapshot();
if (beforePublish) await beforePublish({ output, renderedRoot });
await verifySnapshot();
await atomicCopy(lease.path, output);
} finally {
if (lease) lease.release();
for (const key of Object.keys(env)) { if (prior[key] === undefined) delete process.env[key]; else process.env[key] = prior[key]; }
}
return output;
}
function parseArgs(argv) {
if (argv.length !== 8) throw new Error("usage: p11-render-snapshot.mjs --ownership PATH --snapshot ABSOLUTE_PATH --output PATH --snapshot-sha256 HEX");
const result = {};
for (let index = 0; index < argv.length; index += 2) {
if (!["--ownership", "--snapshot", "--output", "--snapshot-sha256"].includes(argv[index]) || result[argv[index]]) throw new Error("invalid arguments");
result[argv[index]] = argv[index + 1];
}
return result;
}
if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) {
try {
const args = parseArgs(process.argv.slice(2));
await renderOwnedSnapshot({ ownershipPath: args["--ownership"], snapshotPath: args["--snapshot"], outputPath: args["--output"], snapshotSha256: args["--snapshot-sha256"] });
console.log(`rendered ${resolve(args["--output"])}`);
} catch (error) {
console.error(`p11 render refused: ${error.message}`);
process.exitCode = 1;
}
}
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { access, readFile, rm } from "node:fs/promises";
import { join, dirname, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { renderOwnedSnapshot } from "./p11-render-snapshot.mjs";
import { cleanupManual, prepareManual, readManualOwnership, serveManual, stopManual } from "./p11-manual-acceptance.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const fixedRoot = join(repoRoot, ".artifacts", "manual-acceptance", "p11");
async function safeCleanup() {
try {
const owned = await readManualOwnership({ repositoryRoot: repoRoot });
if (owned.status === "RUNNING") await stopManual({ repositoryRoot: repoRoot }).catch(() => {});
await cleanupManual({ repositoryRoot: repoRoot }).catch(() => {});
} catch {
await rm(fixedRoot, { recursive: true, force: true }).catch(() => {});
}
}
test.beforeEach(async () => { await safeCleanup(); });
test.afterEach(async () => { await safeCleanup(); });
test("renderer rejects unowned ownership and out-of-root snapshot paths", { concurrency: false }, async () => {
await prepareManual({ repositoryRoot: repoRoot });
const outside = join(repoRoot, "outside.yaml");
await import("node:fs/promises").then(({ writeFile }) => writeFile(outside, "x"));
await assert.rejects(renderOwnedSnapshot({
repositoryRoot: repoRoot,
ownershipPath: join(repoRoot, "ownership.json"),
snapshotPath: outside,
outputPath: join(fixedRoot, "rendered", "bad.yaml"),
snapshotSha256: "a".repeat(64),
}));
await rm(outside, { force: true });
});
test("renderer copies an owned runtime lease deterministically", { concurrency: false }, async () => {
await prepareManual({ repositoryRoot: repoRoot });
await serveManual({ repositoryRoot: repoRoot });
const validateRequest = JSON.parse(await readFile(join(fixedRoot, "requests", "validate-p11-filesystem.json"), "utf8"));
const status = await fetch("http://127.0.0.1:8791/workspace-registry/status");
const statusBody = await status.json();
const publish = await fetch("http://127.0.0.1:8791/workspaces/publish", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "create", workspace: validateRequest.workspace, baseCommit: statusBody.head }),
});
assert.equal(publish.status, 200);
const readResponse = await fetch("http://127.0.0.1:8791/workspaces/p11-filesystem");
const readBody = await readResponse.json();
const snapshotPath = readBody.revision.snapshotPath;
const manifest = JSON.parse(await readFile(join(dirname(snapshotPath), "snapshot.json"), "utf8"));
const digest = manifest.files["p11-filesystem.yaml"];
const one = join(fixedRoot, "rendered", "one.yaml");
const two = join(fixedRoot, "rendered", "two.yaml");
await renderOwnedSnapshot({ repositoryRoot: repoRoot, ownershipPath: join(fixedRoot, "ownership.json"), snapshotPath, outputPath: one, snapshotSha256: digest });
await renderOwnedSnapshot({ repositoryRoot: repoRoot, ownershipPath: join(fixedRoot, "ownership.json"), snapshotPath, outputPath: two, snapshotSha256: digest });
assert.equal(await readFile(one, "utf8"), await readFile(two, "utf8"));
await access(one);
await access(two);
});
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CHECK_IDS,
canonicalIntegrationBase,
cleanupOwnedRun,
createOwnedRun,
readAndValidateOwnership,
runIntegration,
validateReport,
validateRunRoot,
} from "./p2-acceptance.mjs";
const roots = [];
async function fakeRepository() {
const root = await mkdtemp(join(tmpdir(), "p2-acceptance-repo-"));
roots.push(root);
await mkdir(join(root, ".artifacts", "p2-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p11-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p1-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "manual-acceptance", "p11"), { recursive: true });
return root;
}
test.afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("run roots are only canonical direct p2 integration children", async () => {
const repositoryRoot = await fakeRepository();
const base = canonicalIntegrationBase(repositoryRoot);
const id = `p2-${"a".repeat(32)}`;
assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id));
for (const candidate of [
base,
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(repositoryRoot, ".artifacts", "p1-integration", id),
join(repositoryRoot, ".artifacts", "p11-integration", id),
join(base, id, "nested"),
join(base, "foreign"),
]) {
assert.throws(() => validateRunRoot(repositoryRoot, candidate, id));
}
assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p2-${"A".repeat(32)}`), `p2-${"A".repeat(32)}`));
});
test("cleanup refuses p1, p11, manual, sibling, and wrong-nonce roots", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
for (const bad of [
join(repositoryRoot, ".artifacts", "p1-integration", `p1-${"b".repeat(32)}`),
join(repositoryRoot, ".artifacts", "p11-integration", `p11-${"c".repeat(32)}`),
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(canonicalIntegrationBase(repositoryRoot), `p2-${"d".repeat(32)}`),
]) {
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: bad, expectedNonce: run.nonce }));
}
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: "0".repeat(64) }));
});
test("cleanup removes exactly one owned p2 root", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const sibling = join(canonicalIntegrationBase(repositoryRoot), `p2-${"e".repeat(32)}`);
await mkdir(sibling);
await writeFile(join(sibling, "sentinel"), "foreign");
await cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
await assert.rejects(readFile(join(run.root, "ownership.json")));
assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign");
});
function resultFor(id) {
return {
id,
status: "PASS",
startedAt: "2026-08-12T00:00:00.000Z",
finishedAt: "2026-08-12T00:00:01.000Z",
commands: ["node"],
artifacts: [{ path: `logs/${id}.json`, sha256: "a".repeat(64) }],
};
}
test("report validation requires exact p2 identity, check order, and unique artifacts", () => {
const report = {
schemaVersion: 1,
runId: `p2-${"f".repeat(32)}`,
startedAt: "2026-08-12T00:00:00.000Z",
finishedAt: "2026-08-12T00:00:10.000Z",
command: "p2-acceptance integration --keep",
overall: "PASS",
checks: CHECK_IDS.map(resultFor),
};
assert.doesNotThrow(() => validateReport(report));
const invalid = structuredClone(report);
invalid.runId = `p11-${"f".repeat(32)}`;
assert.throws(() => validateReport(invalid));
const duplicate = structuredClone(report);
duplicate.checks[1].artifacts[0].path = duplicate.checks[0].artifacts[0].path;
assert.throws(() => validateReport(duplicate), /duplicated/);
const reordered = structuredClone(report);
reordered.checks.reverse();
reordered.overall = "FAIL";
assert.throws(() => validateReport(reordered));
});
test("public wrapper uses a strict empty environment", async () => {
const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p2-acceptance.sh"), "utf8");
assert.match(wrapper, /safe_env=\(\/usr\/bin\/env -i/);
assert.doesNotMatch(wrapper, /LANG|LC_ALL|TZ/);
assert.doesNotMatch(wrapper, /P2_ACCEPTANCE_FAIL_AT/);
});
test("synthetic integration cleans up successful non-kept runs", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: false, env: { P2_ACCEPTANCE_SYNTHETIC: "1" } });
assert.equal(result.exitCode, 0);
assert.equal(result.retained, false);
await assert.rejects(readFile(join(result.runRoot, "ownership.json")));
});
test("synthetic integration retains kept runs with bounded reports", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: true, env: { P2_ACCEPTANCE_SYNTHETIC: "1" } });
assert.equal(result.exitCode, 0);
assert.equal(result.retained, true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.equal(report.overall, "PASS");
const reportMd = await readFile(join(result.runRoot, "report.md"), "utf8");
assert.match(reportMd, /P2 automated integration: PASS/);
assert.match(reportMd, /P2 manual acceptance: PENDING/);
const reportJsonStat = await stat(join(result.runRoot, "report.json"));
const reportMdStat = await stat(join(result.runRoot, "report.md"));
assert.ok(reportJsonStat.size <= 64 * 1024, `report.json too large: ${reportJsonStat.size}`);
assert.ok(reportMdStat.size <= 32 * 1024, `report.md too large: ${reportMdStat.size}`);
});
test("synthetic injected failure retains the owned run and records a single failed report", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({
repositoryRoot,
keep: false,
env: { P2_ACCEPTANCE_SYNTHETIC: "1", P2_ACCEPTANCE_FAIL_AT: CHECK_IDS[2] },
});
assert.equal(result.exitCode, 1);
assert.equal(result.retained, true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.equal(report.overall, "FAIL");
const failed = report.checks.find((check) => check.id === CHECK_IDS[2]);
assert.equal(failed.status, "FAIL");
const roots = await readFile(join(result.runRoot, "ownership.json"), "utf8");
assert.match(roots, /p2-acceptance/);
});
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CHECK_IDS,
canonicalIntegrationBase,
cleanupOwnedRun,
createOwnedRun,
readAndValidateOwnership,
runIntegration,
validateReport,
validateRunRoot,
} from "./p2p6-acceptance.mjs";
const roots = [];
async function fakeRepository() {
const root = await mkdtemp(join(tmpdir(), "p2p6-acceptance-repo-"));
roots.push(root);
await mkdir(join(root, ".artifacts", "p2p6-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p2-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p1-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "manual-acceptance", "p11"), { recursive: true });
return root;
}
test.afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("run roots are only canonical direct p2p6 integration children", async () => {
const repositoryRoot = await fakeRepository();
const base = canonicalIntegrationBase(repositoryRoot);
const id = `p2p6-${"a".repeat(32)}`;
assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id));
for (const candidate of [
base,
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(repositoryRoot, ".artifacts", "p1-integration", id),
join(repositoryRoot, ".artifacts", "p2-integration", id),
join(base, id, "nested"),
join(base, "foreign"),
]) {
assert.throws(() => validateRunRoot(repositoryRoot, candidate, id));
}
assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p2p6-${"A".repeat(32)}`), `p2p6-${"A".repeat(32)}`));
});
test("cleanup refuses p1, p2, p11, manual, sibling, and wrong-nonce roots", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
for (const bad of [
join(repositoryRoot, ".artifacts", "p1-integration", `p1-${"b".repeat(32)}`),
join(repositoryRoot, ".artifacts", "p2-integration", `p2-${"c".repeat(32)}`),
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(canonicalIntegrationBase(repositoryRoot), `p2p6-${"d".repeat(32)}`),
]) {
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: bad, expectedNonce: run.nonce }));
}
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: "0".repeat(64) }));
});
test("cleanup removes exactly one owned p2p6 root", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const sibling = join(canonicalIntegrationBase(repositoryRoot), `p2p6-${"e".repeat(32)}`);
await mkdir(sibling);
await writeFile(join(sibling, "sentinel"), "foreign");
await cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
await assert.rejects(readFile(join(run.root, "ownership.json")));
assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign");
});
function resultFor(id) {
return {
id,
status: "PASS",
startedAt: "2026-08-12T00:00:00.000Z",
finishedAt: "2026-08-12T00:00:01.000Z",
commands: ["node"],
artifacts: [{ path: `logs/${id}.json`, sha256: "a".repeat(64) }],
};
}
test("report validation requires exact p2p6 identity, check order, and unique artifacts", () => {
const report = {
schemaVersion: 1,
runId: `p2p6-${"f".repeat(32)}`,
startedAt: "2026-08-12T00:00:00.000Z",
finishedAt: "2026-08-12T00:00:10.000Z",
command: "p2p6-acceptance integration --keep",
overall: "PASS",
checks: CHECK_IDS.map(resultFor),
};
assert.doesNotThrow(() => validateReport(report));
const invalid = structuredClone(report);
invalid.runId = `p2-${"f".repeat(32)}`;
assert.throws(() => validateReport(invalid));
const duplicate = structuredClone(report);
duplicate.checks[1].artifacts[0].path = duplicate.checks[0].artifacts[0].path;
assert.throws(() => validateReport(duplicate), /duplicated/);
const reordered = structuredClone(report);
reordered.checks.reverse();
reordered.overall = "FAIL";
assert.throws(() => validateReport(reordered));
});
test("public wrapper uses a strict empty environment", async () => {
const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p2p6-acceptance.sh"), "utf8");
assert.match(wrapper, /safe_env=\(\/usr\/bin\/env -i/);
assert.doesNotMatch(wrapper, /LANG|LC_ALL|TZ/);
assert.doesNotMatch(wrapper, /P2P6_ACCEPTANCE_FAIL_AT/);
});
test("synthetic integration cleans up successful non-kept runs", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: false, env: { P2P6_ACCEPTANCE_SYNTHETIC: "1" } });
assert.equal(result.exitCode, 0);
assert.equal(result.retained, false);
await assert.rejects(readFile(join(result.runRoot, "ownership.json")));
});
test("synthetic integration retains kept runs with bounded reports", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: true, env: { P2P6_ACCEPTANCE_SYNTHETIC: "1" } });
assert.equal(result.exitCode, 0);
assert.equal(result.retained, true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.equal(report.overall, "PASS");
const reportMd = await readFile(join(result.runRoot, "report.md"), "utf8");
assert.match(reportMd, /P2P6 automated integration: PASS/);
assert.match(reportMd, /P2P6 manual acceptance: PENDING/);
const reportJsonStat = await stat(join(result.runRoot, "report.json"));
const reportMdStat = await stat(join(result.runRoot, "report.md"));
assert.ok(reportJsonStat.size <= 64 * 1024, `report.json too large: ${reportJsonStat.size}`);
assert.ok(reportMdStat.size <= 32 * 1024, `report.md too large: ${reportMdStat.size}`);
});
test("synthetic injected failure retains the owned run and records a single failed report", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({
repositoryRoot,
keep: false,
env: { P2P6_ACCEPTANCE_SYNTHETIC: "1", P2P6_ACCEPTANCE_FAIL_AT: CHECK_IDS[2] },
});
assert.equal(result.exitCode, 1);
assert.equal(result.retained, true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.equal(report.overall, "FAIL");
const failed = report.checks.find((check) => check.id === CHECK_IDS[2]);
assert.equal(failed.status, "FAIL");
const roots = await readFile(join(result.runRoot, "ownership.json"), "utf8");
assert.match(roots, /p2p6-acceptance/);
});
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CHECK_IDS,
canonicalIntegrationBase,
cleanupOwnedRun,
createOwnedRun,
readAndValidateOwnership,
runIntegration,
validateReport,
validateRunRoot,
} from "./p3-acceptance.mjs";
const roots = [];
async function fakeRepository() {
const root = await mkdtemp(join(tmpdir(), "p3-acceptance-repo-"));
roots.push(root);
await mkdir(join(root, ".artifacts", "p3-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p2-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p1-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "manual-acceptance", "p11"), { recursive: true });
return root;
}
test.afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("run roots are only canonical direct p3 integration children", async () => {
const repositoryRoot = await fakeRepository();
const base = canonicalIntegrationBase(repositoryRoot);
const id = `p3-${"a".repeat(32)}`;
assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id));
for (const candidate of [
base,
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(repositoryRoot, ".artifacts", "p1-integration", id),
join(repositoryRoot, ".artifacts", "p2-integration", id),
join(base, id, "nested"),
join(base, "foreign"),
]) {
assert.throws(() => validateRunRoot(repositoryRoot, candidate, id));
}
assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p3-${"A".repeat(32)}`), `p3-${"A".repeat(32)}`));
});
test("cleanup refuses p1, p2, p11, manual, sibling, and wrong-nonce roots", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
for (const bad of [
join(repositoryRoot, ".artifacts", "p1-integration", `p1-${"b".repeat(32)}`),
join(repositoryRoot, ".artifacts", "p2-integration", `p2-${"c".repeat(32)}`),
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(canonicalIntegrationBase(repositoryRoot), `p3-${"d".repeat(32)}`),
]) {
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: bad, expectedNonce: run.nonce }));
}
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: "0".repeat(64) }));
});
test("cleanup removes exactly one owned p3 root", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const sibling = join(canonicalIntegrationBase(repositoryRoot), `p3-${"e".repeat(32)}`);
await mkdir(sibling);
await writeFile(join(sibling, "sentinel"), "foreign");
await cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
await assert.rejects(readFile(join(run.root, "ownership.json")));
assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign");
});
function resultFor(id) {
return {
id,
status: "PASS",
startedAt: "2026-08-12T00:00:00.000Z",
finishedAt: "2026-08-12T00:00:01.000Z",
commands: ["node"],
artifacts: [{ path: `logs/${id}.json`, sha256: "a".repeat(64) }],
};
}
test("report validation requires exact p3 identity, check order, and unique artifacts", () => {
const report = {
schemaVersion: 1,
runId: `p3-${"f".repeat(32)}`,
startedAt: "2026-08-12T00:00:00.000Z",
finishedAt: "2026-08-12T00:00:10.000Z",
command: "p3-acceptance integration --keep",
overall: "PASS",
checks: CHECK_IDS.map(resultFor),
};
assert.doesNotThrow(() => validateReport(report));
const invalid = structuredClone(report);
invalid.runId = `p2-${"f".repeat(32)}`;
assert.throws(() => validateReport(invalid));
const duplicate = structuredClone(report);
duplicate.checks[1].artifacts[0].path = duplicate.checks[0].artifacts[0].path;
assert.throws(() => validateReport(duplicate), /duplicated/);
const reordered = structuredClone(report);
reordered.checks.reverse();
reordered.overall = "FAIL";
assert.throws(() => validateReport(reordered));
});
test("public wrapper uses a strict empty environment", async () => {
const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p3-acceptance.sh"), "utf8");
assert.match(wrapper, /safe_env=\(\/usr\/bin\/env -i/);
assert.doesNotMatch(wrapper, /LANG|LC_ALL|TZ/);
assert.doesNotMatch(wrapper, /P3_ACCEPTANCE_FAIL_AT/);
});
test("synthetic integration cleans up successful non-kept runs", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: false, env: { P3_ACCEPTANCE_SYNTHETIC: "1" } });
assert.equal(result.exitCode, 0);
assert.equal(result.retained, false);
await assert.rejects(readFile(join(result.runRoot, "ownership.json")));
});
test("synthetic integration retains kept runs with bounded reports", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: true, env: { P3_ACCEPTANCE_SYNTHETIC: "1" } });
assert.equal(result.exitCode, 0);
assert.equal(result.retained, true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.equal(report.overall, "PASS");
const reportMd = await readFile(join(result.runRoot, "report.md"), "utf8");
assert.match(reportMd, /P3 automated integration: PASS/);
assert.match(reportMd, /P3 manual acceptance: PENDING/);
const reportJsonStat = await stat(join(result.runRoot, "report.json"));
const reportMdStat = await stat(join(result.runRoot, "report.md"));
assert.ok(reportJsonStat.size <= 64 * 1024, `report.json too large: ${reportJsonStat.size}`);
assert.ok(reportMdStat.size <= 32 * 1024, `report.md too large: ${reportMdStat.size}`);
});
test("synthetic injected failure retains the owned run and records a single failed report", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({
repositoryRoot,
keep: false,
env: { P3_ACCEPTANCE_SYNTHETIC: "1", P3_ACCEPTANCE_FAIL_AT: CHECK_IDS[2] },
});
assert.equal(result.exitCode, 1);
assert.equal(result.retained, true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.equal(report.overall, "FAIL");
const failed = report.checks.find((check) => check.id === CHECK_IDS[2]);
assert.equal(failed.status, "FAIL");
const roots = await readFile(join(result.runRoot, "ownership.json"), "utf8");
assert.match(roots, /p3-acceptance/);
});
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env node
// P4 automated integration acceptance: Qdrant collection lifecycle (self-heal + guarded rebuild).
import { createHash, randomBytes } from "node:crypto";
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { createServer as createNetServer } from "node:net";
import process from "node:process";
import { stringify as yamlStringify } from "yaml";
import { buildSafeEnvironment, deriveOverall, scanSecrets } from "./p1-acceptance.mjs";
const execFileAsync = promisify(execFile);
const modulePath = fileURLToPath(import.meta.url);
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
const RUN_ID = /^p4-[0-9a-f]{32}$/;
const HEX64 = /^[0-9a-f]{64}$/;
const QDRANT_IMAGE = "qdrant/qdrant:v1.18.2";
export const CHECK_IDS = Object.freeze([
"preflight",
"clean_state",
"ownership",
"qdrant_up",
"self_heal_create_missing",
"self_heal_repairs_missing_index",
"incompatible_refused",
"require_existing_refused",
"rebuild_recreates_contract",
"secret_scan",
"cleanup_confinement",
]);
const TOPOLOGY = ["installation", "fixtures", "logs", "qdrant-volumes"];
const MAX_REPORT_JSON_BYTES = 64 * 1024;
const MAX_REPORT_MD_BYTES = 32 * 1024;
function resolveSystemExecutable(name) {
for (const candidate of [`/usr/bin/${name}`, `/bin/${name}`, `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`, `/usr/local/sbin/${name}`]) {
try {
const resolved = realpathSync(candidate);
if (statSync(resolved).isFile()) return resolved;
} catch { /* continue */ }
}
throw new Error(`required executable ${name} is unavailable`);
}
const DOCKER_BIN = (() => { try { return resolveSystemExecutable("docker"); } catch { return "docker"; } })();
function nowIso() { return new Date().toISOString(); }
function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
function assert(condition, message) { if (!condition) throw new Error(message); }
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function canonicalRoot(repositoryRoot = defaultRepositoryRoot) {
return realpathSync(repositoryRoot);
}
export function canonicalIntegrationBase(repositoryRoot = defaultRepositoryRoot) {
return join(canonicalRoot(repositoryRoot), ".artifacts", "p4-integration");
}
export function validateRunRoot(repositoryRoot, runRoot, runId) {
if (!RUN_ID.test(runId)) throw new Error("invalid owned run id");
const base = canonicalIntegrationBase(repositoryRoot);
const lexical = resolve(runRoot);
if (dirname(lexical) !== base || basename(lexical) !== runId) throw new Error("run root is not a direct integration child");
return lexical;
}
function validateNoSymlinkAncestors(repositoryRoot, target) {
const repo = canonicalRoot(repositoryRoot);
const rel = relative(repo, target);
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("target escapes the repository");
let cursor = repo;
for (const part of rel.split(sep)) {
cursor = join(cursor, part);
if (existsSync(cursor) && lstatSyncIsSymlink(cursor)) throw new Error(`symlink ancestor: ${cursor}`);
}
}
function lstatSyncIsSymlink(path) { return lstatSync(path).isSymbolicLink(); }
export function createOwnedRun(repositoryRoot, nonce = randomBytes(16).toString("hex")) {
const runId = `p4-${nonce}`;
if (!RUN_ID.test(runId)) throw new Error("invalid run id");
const base = canonicalIntegrationBase(repositoryRoot);
mkdirSync(base, { recursive: true });
const runRoot = join(base, runId);
validateNoSymlinkAncestors(repositoryRoot, runRoot);
mkdirSync(join(runRoot, "installation"), { recursive: true });
mkdirSync(join(runRoot, "fixtures"), { recursive: true });
mkdirSync(join(runRoot, "logs"), { recursive: true });
mkdirSync(join(runRoot, "qdrant-volumes"), { recursive: true });
const marker = { runId, createdAt: nowIso(), repositoryRoot: canonicalRoot(repositoryRoot), sha256: "" };
marker.sha256 = sha256(JSON.stringify(marker) + "\n");
writeFileSync(join(runRoot, "run.json"), JSON.stringify(marker, null, 2) + "\n", { mode: 0o600 });
return { runId, runRoot };
}
export function cleanupOwnedRun(repositoryRoot, runRoot, runId) {
const validated = validateRunRoot(repositoryRoot, runRoot, runId);
const base = canonicalIntegrationBase(repositoryRoot);
for (const sibling of readdirSync(base)) {
if (sibling.startsWith("p4-") && sibling !== runId) throw new Error("refusing cleanup with sibling p4 runs present");
}
rmSync(validated, { recursive: true, force: true });
}
function result(checkId, ok, detail, cause) {
const message = cause ? `${String(detail)} :: ${String(cause)}` : String(detail);
return { checkId, status: ok ? "PASS" : "FAIL", ok: !!ok, detail: ok ? "PASS" : message.slice(0, 500) };
}
function execCapture(command, args, options = {}) {
const spawned = execFileSync(command, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...options });
return String(spawned ?? "");
}
async function waitForQdrant(baseUrl, timeoutMs = 120000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseUrl}/readyz`, { signal: AbortSignal.timeout(3000) });
if (res.ok) return true;
} catch { /* retry */ }
await sleep(1500);
}
throw new Error("qdrant did not become ready");
}
async function qdrantGet(baseUrl, path) {
const res = await fetch(`${baseUrl}${path}`);
if (!res.ok) throw new Error(`qdrant GET ${path} -> ${res.status}`);
return (await res.json()).result;
}
async function qdrantPut(baseUrl, path, body) {
const payload = { ...body };
if (payload.vectors && typeof payload.vectors.distance === "string" && payload.vectors.distance.length > 0) {
payload.vectors = { ...payload.vectors, distance: payload.vectors.distance.charAt(0).toUpperCase() + payload.vectors.distance.slice(1) };
}
const res = await fetch(`${baseUrl}${path}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok && res.status !== 409) throw new Error(`qdrant PUT ${path} -> ${res.status}`);
return res.ok || res.status === 409;
}
async function qdrantDelete(baseUrl, path) {
const res = await fetch(`${baseUrl}${path}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) throw new Error(`qdrant DELETE ${path} -> ${res.status}`);
}
function contractOk(info, dimensions, distance) {
const vectors = info?.config?.params?.vectors;
const schema = info?.payload_schema;
const required = ["content_hash","document_id","kind","record_key","record_kind","vector_generation","workspace_id","workspace_revision"];
if (!vectors || vectors.size !== dimensions || String(vectors.distance).toLowerCase() !== distance) return false;
if (!schema || typeof schema !== "object") return false;
return required.every((field) => schema[field]?.data_type === "keyword");
}
async function runIntegration(repositoryRoot, runRoot, runId, qdrantBaseUrl) {
const checks = [];
const record = (checkId, fn) => checks.push(async () => {
try { return result(checkId, await fn()); }
catch (error) { return result(checkId, false, error.message, error.cause?.message ?? error.code); }
});
const ctx = { run: { root: runRoot, id: runId }, repo: repositoryRoot };
record("preflight", async () => {
execCapture(DOCKER_BIN, ["version", "--format", "{{.Server.Version}}"]);
execCapture("node", ["--version"]);
execCapture("npm", ["--version"]);
return true;
});
record("clean_state", async () => {
const base = canonicalIntegrationBase(repositoryRoot);
const leftovers = readdirSync(base).filter((entry) => entry.startsWith("p4-") && entry !== runId);
if (leftovers.length > 0) throw new Error(`leftover p4 runs: ${leftovers.join(", ")}`);
return true;
});
record("ownership", async () => {
const marker = JSON.parse(await readFile(join(runRoot, "run.json"), "utf8"));
if (marker.runId !== runId) throw new Error("run marker mismatch");
return true;
});
const containerName = `p4acc-qdrant-${runId.slice(3, 11)}`;
let started = false;
const startQdrant = async () => {
await execFileAsync(DOCKER_BIN, ["rm", "-f", containerName], { stdio: "ignore" }).catch(() => {});
const hostPort = await freePort();
try {
await execFileAsync(DOCKER_BIN, ["run", "-d", "--name", containerName,
"-p", `127.0.0.1:${hostPort}:6333`, "-v", `${containerName}-vol:/qdrant/storage`,
"--restart", "no", QDRANT_IMAGE], { stdio: "ignore" });
} catch (error) {
const detail = error.stderr ?? error.message;
throw new Error(`docker run qdrant failed: ${String(detail).slice(0, 300)}`);
}
started = true;
return `http://127.0.0.1:${hostPort}`;
};
const stopQdrant = async () => {
if (!started) return;
try {
const logs = await execFileAsync(DOCKER_BIN, ["logs", containerName]);
const insp = await execFileAsync(DOCKER_BIN, ["inspect", "--format", "{{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}", containerName]).catch(() => ({ stdout: "inspect failed" }));
await writeFile(join(runRoot, "qdrant.log"), `INSPECT: ${String(insp.stdout).trim()}\n` + String(logs.stdout).slice(-3000) + "\n---STDERR---\n" + String(logs.stderr).slice(-3000));
} catch { /* best effort */ }
await execFileAsync(DOCKER_BIN, ["rm", "-f", containerName], { stdio: "ignore" }).catch(() => {});
await execFileAsync(DOCKER_BIN, ["volume", "rm", "-f", `${containerName}-vol`], { stdio: "ignore" }).catch(() => {});
};
function freePort() {
return new Promise((resolve, reject) => {
const server = createNetServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
server.close(() => resolve(port));
});
});
}
async function dockerPortRetry(containerName, attempts = 20) {
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const inspect = await execFileAsync(DOCKER_BIN, ["port", containerName, "6333"]);
const line = String(inspect.stdout).trim();
const hostPort = line.split("\n")[0].split(":")[1];
if (hostPort) return `http://127.0.0.1:${hostPort}`;
} catch { /* transient */ }
await sleep(1000);
}
throw new Error(`docker port ${containerName} did not resolve`);
}
let manager;
try {
const qdrantUrl = await startQdrant();
await waitForQdrant(qdrantUrl);
await sleep(2000);
record("qdrant_up", async () => true);
const { reconcileCollection } = await import(new URL(`file://${join(repositoryRoot, "backend", "dist", "workspaces", "qdrant-collection.js")}`).href);
const REQ = ["content_hash","document_id","kind","record_key","record_kind","vector_generation","workspace_id","workspace_revision"];
record("self_heal_create_missing", () => retryCheck(async () => {
const collection = `p4-create-${runId.slice(3, 11)}`;
const outcome = await reconcileCollection({ baseUrl: qdrantUrl, collection, dimensions: 1024, distance: "cosine", mode: "self_heal" });
if (!outcome.ok) throw new Error(`unexpected ${outcome.code}`);
const info = await qdrantGet(qdrantUrl, `/collections/${collection}`);
if (!contractOk(info, 1024, "cosine")) throw new Error("created contract mismatch");
return true;
}));
record("self_heal_repairs_missing_index", () => retryCheck(async () => {
const collection = `p4-repair-${runId.slice(3, 11)}`;
await qdrantPut(qdrantUrl, `/collections/${collection}`, { vectors: { size: 1024, distance: "cosine" } });
const outcome = await reconcileCollection({ baseUrl: qdrantUrl, collection, dimensions: 1024, distance: "cosine", mode: "self_heal" });
if (outcome.ok !== true || outcome.state !== "repaired") throw new Error(`expected repaired, got ${JSON.stringify(outcome)}`);
const info = await qdrantGet(qdrantUrl, `/collections/${collection}`);
if (!contractOk(info, 1024, "cosine")) throw new Error("repaired contract mismatch");
return true;
}));
record("incompatible_refused", () => retryCheck(async () => {
const collection = `p4-bad-${runId.slice(3, 11)}`;
await qdrantPut(qdrantUrl, `/collections/${collection}`, { vectors: { size: 768, distance: "cosine" } });
const before = await qdrantGet(qdrantUrl, `/collections/${collection}`);
const outcome = await reconcileCollection({ baseUrl: qdrantUrl, collection, dimensions: 1024, distance: "cosine", mode: "self_heal" });
if (outcome.ok !== false || outcome.code !== "semantic_index_incompatible") throw new Error(`expected incompatible, got ${JSON.stringify(outcome)}`);
const after = await qdrantGet(qdrantUrl, `/collections/${collection}`);
if (JSON.stringify(before) !== JSON.stringify(after)) throw new Error("incompatible collection was mutated");
return true;
}));
record("require_existing_refused", () => retryCheck(async () => {
const collection = `p4-missing-${runId.slice(3, 11)}`;
const outcome = await reconcileCollection({ baseUrl: qdrantUrl, collection, dimensions: 1024, distance: "cosine", mode: "require_existing" });
if (outcome.ok !== false || outcome.code !== "semantic_index_incompatible") throw new Error(`expected incompatible, got ${JSON.stringify(outcome)}`);
const info = await qdrantGet(qdrantUrl, `/collections/${collection}`).catch(() => undefined);
if (info !== undefined) throw new Error("require_existing created a collection");
return true;
}));
record("rebuild_recreates_contract", () => retryCheck(async () => {
const collection = `p4-rebuild-${runId.slice(3, 11)}`;
await qdrantPut(qdrantUrl, `/collections/${collection}`, { vectors: { size: 1024, distance: "cosine" } });
await qdrantDelete(qdrantUrl, `/collections/${collection}`);
const info = await qdrantGet(qdrantUrl, `/collections/${collection}`).catch(() => undefined);
if (info !== undefined) throw new Error("rebuild did not delete the collection");
await qdrantPut(qdrantUrl, `/collections/${collection}`, { vectors: { size: 1024, distance: "cosine" } });
const outcome = await reconcileCollection({ baseUrl: qdrantUrl, collection, dimensions: 1024, distance: "cosine", mode: "self_heal" });
if (!outcome.ok) throw new Error(`recreate verify failed ${JSON.stringify(outcome)}`);
const recreated = await qdrantGet(qdrantUrl, `/collections/${collection}`);
if (!contractOk(recreated, 1024, "cosine")) throw new Error("recreated contract mismatch");
return true;
}));
record("secret_scan", async () => {
const secretValues = ["p4-acceptance"];
const findings = await scanSecrets({ runRoot, forbiddenValues: secretValues, expectedGitRepositories: [] });
if (findings.length > 0) throw new Error(`secret findings: ${findings.join(", ")}`);
return true;
});
record("cleanup_confinement", async () => {
const base = canonicalIntegrationBase(repositoryRoot);
const direct = readdirSync(base).filter((entry) => entry.startsWith("p4-"));
if (direct.length !== 1 || direct[0] !== runId) throw new Error("run confinement violated");
return true;
});
const settledChecks = await runChecks(checks);
return settledChecks;
} finally {
await stopQdrant();
}
}
async function retryCheck(fn, attempts = 3) {
let lastError;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try { return await fn(); } catch (error) { lastError = error; await sleep(3000); }
}
try {
const ps = await execFileAsync(DOCKER_BIN, ["ps", "-a", "--filter", "name=p4acc-qdrant", "--format", "{{.Names}} {{.Status}} {{.Ports}}"]);
lastError = new Error(`${lastError.message} | containers: ${String(ps.stdout).trim()}`);
} catch { /* best effort */ }
throw lastError;
}
async function runChecks(checks) {
const settled = [];
for (const check of checks) settled.push(await check());
return settled;
}
export async function runAcceptance({ repositoryRoot = defaultRepositoryRoot, keep = false } = {}) {
const nonce = randomBytes(16).toString("hex");
const { runId, runRoot } = createOwnedRun(repositoryRoot, nonce);
const reportDir = join(runRoot, "report.md");
const reportJsonDir = join(runRoot, "report.json");
try {
await execFileAsync("npm", ["--prefix", join(repositoryRoot, "backend"), "run", "build"], { stdio: "ignore" });
const checks = await runIntegration(repositoryRoot, runRoot, runId, "");
const overall = deriveOverall(checks);
const summary = {
schemaVersion: 1,
runId,
phase: "p4",
checks,
overall,
boundCommit: execCapture("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot }).trim(),
};
await writeFile(reportJsonDir, JSON.stringify(summary, null, 2) + "\n");
const rows = checks.map((c) => `- [${c.ok ? "x" : " "}] ${c.checkId}: ${c.detail}`).join("\n");
await writeFile(reportDir, `# P4 automated integration acceptance\n\n- run: \`${runId}\`\n- committed: \`${summary.boundCommit}\`\n\n${rows}\n\n**Overall: ${overall}**\n`);
if (overall === "PASS") {
if (!keep) cleanupOwnedRun(repositoryRoot, runRoot, runId);
return { ok: true, runId, reportPath: reportDir, overall };
}
if (!keep) {
try {
const validated = validateRunRoot(repositoryRoot, runRoot, runId);
rmSync(validated, { recursive: true, force: true });
} catch { /* best effort */ }
}
return { ok: false, runId, reportPath: reportDir, overall };
} catch (error) {
try {
const partial = { schemaVersion: 1, runId, phase: "p4", checks: [], overall: "FAIL", error: String(error).slice(0, 500) };
await writeFile(reportJsonDir, JSON.stringify(partial, null, 2) + "\n");
await writeFile(reportDir, `# P4 automated integration acceptance\n\n- run: \`${runId}\`\n- error: \`${String(error).slice(0, 500)}\`\n\n**Overall: FAIL**\n`);
} catch { /* best effort */ }
if (keep) return { ok: false, runId, reportPath: reportDir, overall: "FAIL" };
try {
const validated = validateRunRoot(repositoryRoot, runRoot, runId);
rmSync(validated, { recursive: true, force: true });
} catch { /* best effort */ }
throw error;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const keep = args.includes("--keep");
runAcceptance({ keep }).then((outcome) => {
process.stdout.write(`P4 automated integration: ${outcome.overall}\nrun: ${outcome.runId}\nreport: ${outcome.reportPath}\n`);
process.exit(outcome.ok ? 0 : 1);
}).catch((error) => {
process.stderr.write(`P4 automated integration: FAIL\n${String(error)}\n`);
process.exit(1);
});
}
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CHECK_IDS,
canonicalIntegrationBase,
cleanupOwnedRun,
createOwnedRun,
validateRunRoot,
} from "./p4-acceptance.mjs";
const roots = [];
async function fakeRepository() {
const root = await mkdtemp(join(tmpdir(), "p4-acceptance-repo-"));
roots.push(root);
await mkdir(join(root, ".artifacts", "p4-integration"), { recursive: true });
return root;
}
test.afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("check ids are stable and unique", () => {
assert.equal(new Set(CHECK_IDS).size, CHECK_IDS.length);
assert.ok(CHECK_IDS.includes("self_heal_create_missing"));
assert.ok(CHECK_IDS.includes("rebuild_recreates_contract"));
});
test("run roots are only canonical direct p4 integration children", async () => {
const repositoryRoot = await fakeRepository();
const base = canonicalIntegrationBase(repositoryRoot);
const id = `p4-${"a".repeat(32)}`;
assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id));
for (const candidate of [base, join(repositoryRoot, ".artifacts", "p1-integration", id), join(base, id, "nested")]) {
assert.throws(() => validateRunRoot(repositoryRoot, candidate, id));
}
assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p4-${"A".repeat(32)}`), `p4-${"A".repeat(32)}`));
});
test("createOwnedRun writes a canonical marker and cleanup refuses foreign roots", async () => {
const repositoryRoot = await fakeRepository();
const { runId, runRoot } = createOwnedRun(repositoryRoot);
assert.match(runId, /^p4-[0-9a-f]{32}$/);
const marker = JSON.parse(await readFile(join(runRoot, "run.json"), "utf8"));
assert.equal(marker.runId, runId);
assert.throws(() => cleanupOwnedRun(repositoryRoot, join(repositoryRoot, "tmp"), runId));
cleanupOwnedRun(repositoryRoot, runRoot, runId);
});
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CHECK_IDS,
canonicalIntegrationBase,
cleanupOwnedRun,
createOwnedRun,
readAndValidateOwnership,
runIntegration,
validateReport,
validateRunRoot,
} from "./p5-acceptance.mjs";
const roots = [];
async function fakeRepository() {
const root = await mkdtemp(join(tmpdir(), "p5-acceptance-repo-"));
roots.push(root);
await mkdir(join(root, ".artifacts", "p5-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p2-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p1-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "manual-acceptance", "p11"), { recursive: true });
return root;
}
test.afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("run roots are only canonical direct p5 integration children", async () => {
const repositoryRoot = await fakeRepository();
const base = canonicalIntegrationBase(repositoryRoot);
const id = `p5-${"a".repeat(32)}`;
assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id));
for (const candidate of [
base,
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(repositoryRoot, ".artifacts", "p1-integration", id),
join(repositoryRoot, ".artifacts", "p2-integration", id),
join(base, id, "nested"),
join(base, "foreign"),
]) {
assert.throws(() => validateRunRoot(repositoryRoot, candidate, id));
}
assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p5-${"A".repeat(32)}`), `p5-${"A".repeat(32)}`));
});
test("cleanup refuses p1, p2, p11, manual, sibling, and wrong-nonce roots", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
for (const bad of [
join(repositoryRoot, ".artifacts", "p1-integration", `p1-${"b".repeat(32)}`),
join(repositoryRoot, ".artifacts", "p2-integration", `p2-${"c".repeat(32)}`),
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(canonicalIntegrationBase(repositoryRoot), `p5-${"d".repeat(32)}`),
]) {
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: bad, expectedNonce: run.nonce }));
}
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: "0".repeat(64) }));
});
test("cleanup removes exactly one owned p5 root", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const sibling = join(canonicalIntegrationBase(repositoryRoot), `p5-${"e".repeat(32)}`);
await mkdir(sibling);
await writeFile(join(sibling, "sentinel"), "foreign");
await cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
await assert.rejects(readFile(join(run.root, "ownership.json")));
assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign");
});
function resultFor(id) {
return {
id,
status: "PASS",
startedAt: "2026-08-12T00:00:00.000Z",
finishedAt: "2026-08-12T00:00:01.000Z",
commands: ["node"],
artifacts: [{ path: `logs/${id}.json`, sha256: "a".repeat(64) }],
};
}
test("report validation requires exact p5 identity, check order, and unique artifacts", () => {
const report = {
schemaVersion: 1,
runId: `p5-${"f".repeat(32)}`,
startedAt: "2026-08-12T00:00:00.000Z",
finishedAt: "2026-08-12T00:00:10.000Z",
command: "p5-acceptance integration --keep",
overall: "PASS",
checks: CHECK_IDS.map(resultFor),
};
assert.doesNotThrow(() => validateReport(report));
const invalid = structuredClone(report);
invalid.runId = `p2-${"f".repeat(32)}`;
assert.throws(() => validateReport(invalid));
const duplicate = structuredClone(report);
duplicate.checks[1].artifacts[0].path = duplicate.checks[0].artifacts[0].path;
assert.throws(() => validateReport(duplicate), /duplicated/);
const reordered = structuredClone(report);
reordered.checks.reverse();
reordered.overall = "FAIL";
assert.throws(() => validateReport(reordered));
});
test("public wrapper uses a strict empty environment", async () => {
const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p5-acceptance.sh"), "utf8");
assert.match(wrapper, /safe_env=\(\/usr\/bin\/env -i/);
assert.doesNotMatch(wrapper, /LANG|LC_ALL|TZ/);
assert.doesNotMatch(wrapper, /P5_ACCEPTANCE_FAIL_AT/);
});
test("synthetic integration cleans up successful non-kept runs", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: false, env: { P5_ACCEPTANCE_SYNTHETIC: "1" } });
assert.equal(result.exitCode, 0);
assert.equal(result.retained, false);
await assert.rejects(readFile(join(result.runRoot, "ownership.json")));
});
test("synthetic integration retains kept runs with bounded reports", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: true, env: { P5_ACCEPTANCE_SYNTHETIC: "1" } });
assert.equal(result.exitCode, 0);
assert.equal(result.retained, true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.equal(report.overall, "PASS");
const reportMd = await readFile(join(result.runRoot, "report.md"), "utf8");
assert.match(reportMd, /P5 automated integration: PASS/);
assert.match(reportMd, /P5 manual acceptance: PENDING/);
const reportJsonStat = await stat(join(result.runRoot, "report.json"));
const reportMdStat = await stat(join(result.runRoot, "report.md"));
assert.ok(reportJsonStat.size <= 64 * 1024, `report.json too large: ${reportJsonStat.size}`);
assert.ok(reportMdStat.size <= 32 * 1024, `report.md too large: ${reportMdStat.size}`);
});
test("synthetic injected failure retains the owned run and records a single failed report", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({
repositoryRoot,
keep: false,
env: { P5_ACCEPTANCE_SYNTHETIC: "1", P5_ACCEPTANCE_FAIL_AT: CHECK_IDS[2] },
});
assert.equal(result.exitCode, 1);
assert.equal(result.retained, true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.equal(report.overall, "FAIL");
const failed = report.checks.find((check) => check.id === CHECK_IDS[2]);
assert.equal(failed.status, "FAIL");
const roots = await readFile(join(result.runRoot, "ownership.json"), "utf8");
assert.match(roots, /p5-acceptance/);
});
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CHECK_IDS,
canonicalIntegrationBase,
cleanupOwnedRun,
createOwnedRun,
readAndValidateOwnership,
runIntegration,
validateReport,
validateRunRoot,
} from "./p6-acceptance.mjs";
const roots = [];
async function fakeRepository() {
const root = await mkdtemp(join(tmpdir(), "p6-acceptance-repo-"));
roots.push(root);
await mkdir(join(root, ".artifacts", "p6-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p2-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "p1-integration"), { recursive: true });
await mkdir(join(root, ".artifacts", "manual-acceptance", "p11"), { recursive: true });
return root;
}
test.afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("run roots are only canonical direct p6 integration children", async () => {
const repositoryRoot = await fakeRepository();
const base = canonicalIntegrationBase(repositoryRoot);
const id = `p6-${"a".repeat(32)}`;
assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id));
for (const candidate of [
base,
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(repositoryRoot, ".artifacts", "p1-integration", id),
join(repositoryRoot, ".artifacts", "p2-integration", id),
join(base, id, "nested"),
join(base, "foreign"),
]) {
assert.throws(() => validateRunRoot(repositoryRoot, candidate, id));
}
assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p6-${"A".repeat(32)}`), `p6-${"A".repeat(32)}`));
});
test("cleanup refuses p1, p2, p11, manual, sibling, and wrong-nonce roots", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
for (const bad of [
join(repositoryRoot, ".artifacts", "p1-integration", `p1-${"b".repeat(32)}`),
join(repositoryRoot, ".artifacts", "p2-integration", `p2-${"c".repeat(32)}`),
join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"),
join(canonicalIntegrationBase(repositoryRoot), `p6-${"d".repeat(32)}`),
]) {
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: bad, expectedNonce: run.nonce }));
}
await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: "0".repeat(64) }));
});
test("cleanup removes exactly one owned p6 root", async () => {
const repositoryRoot = await fakeRepository();
const run = await createOwnedRun({ repositoryRoot });
const sibling = join(canonicalIntegrationBase(repositoryRoot), `p6-${"e".repeat(32)}`);
await mkdir(sibling);
await writeFile(join(sibling, "sentinel"), "foreign");
await cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
await assert.rejects(readFile(join(run.root, "ownership.json")));
assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign");
});
function resultFor(id) {
return {
id,
status: "PASS",
startedAt: "2026-08-12T00:00:00.000Z",
finishedAt: "2026-08-12T00:00:01.000Z",
commands: ["node"],
artifacts: [{ path: `logs/${id}.json`, sha256: "a".repeat(64) }],
};
}
test("report validation requires exact p6 identity, check order, and unique artifacts", () => {
const report = {
schemaVersion: 1,
runId: `p6-${"f".repeat(32)}`,
startedAt: "2026-08-12T00:00:00.000Z",
finishedAt: "2026-08-12T00:00:10.000Z",
command: "p6-acceptance integration --keep",
overall: "PASS",
checks: CHECK_IDS.map(resultFor),
};
assert.doesNotThrow(() => validateReport(report));
const invalid = structuredClone(report);
invalid.runId = `p2-${"f".repeat(32)}`;
assert.throws(() => validateReport(invalid));
const duplicate = structuredClone(report);
duplicate.checks[1].artifacts[0].path = duplicate.checks[0].artifacts[0].path;
assert.throws(() => validateReport(duplicate), /duplicated/);
const reordered = structuredClone(report);
reordered.checks.reverse();
reordered.overall = "FAIL";
assert.throws(() => validateReport(reordered));
});
test("public wrapper uses a strict empty environment", async () => {
const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p6-acceptance.sh"), "utf8");
assert.match(wrapper, /safe_env=\(\/usr\/bin\/env -i/);
assert.doesNotMatch(wrapper, /LANG|LC_ALL|TZ/);
assert.doesNotMatch(wrapper, /P6_ACCEPTANCE_FAIL_AT/);
});
test("synthetic integration cleans up successful non-kept runs", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: false, env: { P6_ACCEPTANCE_SYNTHETIC: "1" } });
assert.equal(result.exitCode, 0);
assert.equal(result.retained, false);
await assert.rejects(readFile(join(result.runRoot, "ownership.json")));
});
test("synthetic integration retains kept runs with bounded reports", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({ repositoryRoot, keep: true, env: { P6_ACCEPTANCE_SYNTHETIC: "1" } });
assert.equal(result.exitCode, 0);
assert.equal(result.retained, true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.equal(report.overall, "PASS");
const reportMd = await readFile(join(result.runRoot, "report.md"), "utf8");
assert.match(reportMd, /P6 automated integration: PASS/);
assert.match(reportMd, /P6 manual acceptance: PENDING/);
const reportJsonStat = await stat(join(result.runRoot, "report.json"));
const reportMdStat = await stat(join(result.runRoot, "report.md"));
assert.ok(reportJsonStat.size <= 64 * 1024, `report.json too large: ${reportJsonStat.size}`);
assert.ok(reportMdStat.size <= 32 * 1024, `report.md too large: ${reportMdStat.size}`);
});
test("synthetic injected failure retains the owned run and records a single failed report", async () => {
const repositoryRoot = await fakeRepository();
const result = await runIntegration({
repositoryRoot,
keep: false,
env: { P6_ACCEPTANCE_SYNTHETIC: "1", P6_ACCEPTANCE_FAIL_AT: CHECK_IDS[2] },
});
assert.equal(result.exitCode, 1);
assert.equal(result.retained, true);
const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8"));
assert.equal(report.overall, "FAIL");
const failed = report.checks.find((check) => check.id === CHECK_IDS[2]);
assert.equal(failed.status, "FAIL");
const roots = await readFile(join(result.runRoot, "ownership.json"), "utf8");
assert.match(roots, /p6-acceptance/);
});
+943
View File
@@ -0,0 +1,943 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { literalBashHeredocBodyRanges } from "./bash-heredoc.mjs";
import { isMap, isScalar, isSeq, parseAllDocuments } from "yaml";
/**
* Revision-state absence policy by source dialect.
* JS/TS syntax uses the TypeScript parser and YAML structure uses the installed YAML parser.
* Shell active consumers are executable code/expansions and jq filter arguments for bare or
* path-qualified jq, optionally through command or env. Quoted heredoc bodies are literal.
* PowerShell analyzes executable code and nested $() in expandable strings. Python policy is
* batched through the isolated stdlib AST helper. jq filters use a bounded path lexer after
* shell argv/wrapper resolution. Offset-preserving transformations keep AST spans stable.
*/
const revisionIdentifiers = new Set(["revision", "workspaceRevision", "selectedWorkspace"]);
function unwrapExpression(node) {
let current = node;
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) ||
ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current) ||
ts.isSatisfiesExpression(current)) {
current = current.expression;
}
return current;
}
function isRevisionName(value, caseInsensitive) {
if (typeof value !== "string") return false;
if (!caseInsensitive) return revisionIdentifiers.has(value);
const lower = value.toLowerCase();
return lower === "revision" || lower === "workspacerevision" || lower === "selectedworkspace";
}
function isRevisionExpression(node, caseInsensitive = false) {
const unwrapped = unwrapExpression(node);
if (ts.isIdentifier(unwrapped)) {
const normalized = unwrapped.text.startsWith("$") && !unwrapped.text.startsWith("$$") ? unwrapped.text.slice(1) : unwrapped.text;
return isRevisionName(normalized, caseInsensitive);
}
if (ts.isPropertyAccessExpression(unwrapped)) return isRevisionName(unwrapped.name.text, caseInsensitive);
if (ts.isElementAccessExpression(unwrapped) && unwrapped.argumentExpression) {
return isRevisionName(staticStringValue(unwrapped.argumentExpression), caseInsensitive);
}
return false;
}
function staticStringValue(node) {
const expression = unwrapExpression(node);
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text;
if (ts.isTemplateExpression(expression)) {
let value = expression.head.text;
for (const span of expression.templateSpans) {
const part = staticStringValue(span.expression);
if (part === undefined) return undefined;
value += part + span.literal.text;
}
return value;
}
if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.PlusToken) {
const left = staticStringValue(expression.left);
const right = staticStringValue(expression.right);
return left === undefined || right === undefined ? undefined : left + right;
}
return undefined;
}
function propertyNameText(name, caseInsensitive = false) {
if (!name) return undefined;
let value;
if (ts.isComputedPropertyName(name)) value = staticStringValue(name.expression);
else if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name) || ts.isNumericLiteral(name)) value = name.text;
else value = staticStringValue(name);
return caseInsensitive && typeof value === "string" ? value.toLowerCase() : value;
}
function objectBindingHasState(pattern, caseInsensitive) {
return pattern.elements.some((element) => {
if (element.dotDotDotToken) return false;
return propertyNameText(element.propertyName ?? element.name, caseInsensitive) === "state";
});
}
function objectLiteralHasState(object, caseInsensitive) {
return object.properties.some((property) =>
!ts.isSpreadAssignment(property) && propertyNameText(property.name, caseInsensitive) === "state");
}
function scriptKindFor(path) {
const lower = path.toLowerCase();
if (lower.endsWith(".tsx")) return ts.ScriptKind.TSX;
if (lower.endsWith(".jsx")) return ts.ScriptKind.JSX;
if (/\.(?:ts|mts|cts)$/u.test(lower)) return ts.ScriptKind.TS;
if (/\.(?:js|mjs|cjs)$/u.test(lower)) return ts.ScriptKind.JS;
return undefined;
}
function maskRange(output, source, start, end, keepEnds = false) {
for (let cursor = start; cursor < end; cursor += 1) {
if (source[cursor] === "\n" || source[cursor] === "\r") continue;
if (keepEnds && (cursor === start || cursor === end - 1)) continue;
output[cursor] = " ";
}
}
function lineEnd(source, start) {
const end = source.indexOf("\n", start);
return end < 0 ? source.length : end;
}
function quotedEnd(source, start, delimiter, escapes = "\\") {
for (let cursor = start + delimiter.length; cursor < source.length; cursor += 1) {
if (escapes.includes(source[cursor])) {
cursor += 1;
continue;
}
if (source.startsWith(delimiter, cursor)) return cursor + delimiter.length;
}
return source.length;
}
function balancedEnd(source, openIndex, opener, closer, escapes = "\\`") {
let depth = 1;
for (let cursor = openIndex + 1; cursor < source.length; cursor += 1) {
if (escapes.includes(source[cursor])) {
cursor += 1;
continue;
}
if (source[cursor] === "'" || source[cursor] === '"' || source[cursor] === "`") {
cursor = quotedEnd(source, cursor, source[cursor], escapes) - 1;
continue;
}
if (source[cursor] === opener) depth += 1;
else if (source[cursor] === closer && --depth === 0) return cursor;
}
return source.length - 1;
}
function restoreMasked(output, offset, masked) {
for (let cursor = 0; cursor < masked.length; cursor += 1) output[offset + cursor] = masked[cursor];
}
function exposeDollarSubexpressions(output, source, start, end, dialect) {
for (let cursor = start; cursor + 1 < end; cursor += 1) {
if (!source.startsWith("$(", cursor) || source[cursor - 1] === "`") continue;
const close = balancedEnd(source, cursor + 1, "(", ")");
output[cursor] = " ";
output[cursor + 1] = "(";
restoreMasked(output, cursor + 2, dialect === "shell" ? maskShellSource(source.slice(cursor + 2, close)) : maskPowerShellSource(source.slice(cursor + 2, close)));
if (close < source.length) output[close] = ")";
cursor = close;
}
}
function shellCommentStart(source, index) {
return source[index] === "#" && (index === 0 || /[ \t\r\n;|&()]/u.test(source[index - 1]));
}
function canonicalRevisionName(name) {
const lower = name.toLowerCase();
if (lower === "revision") return "revision";
if (lower === "workspacerevision") return "workspaceRevision";
return "selectedWorkspace";
}
function normalizePowerShellVariables(source) {
const output = source.split("");
const patterns = [
{ expression: /\$\{(?:[A-Za-z_][A-Za-z0-9_]*:)?(revision|workspaceRevision|selectedWorkspace)\}/giu, dollar: false },
{ expression: /\$(?:[A-Za-z_][A-Za-z0-9_]*:)(revision|workspaceRevision|selectedWorkspace)\b/giu, dollar: false },
{ expression: /\$(revision|workspaceRevision|selectedWorkspace)\b/giu, dollar: true },
];
for (const { expression, dollar } of patterns) {
for (const match of source.matchAll(expression)) {
const name = canonicalRevisionName(match[1]);
const replacement = `${dollar ? "$" : ""}${name}`.padEnd(match[0].length, " ");
for (let offset = 0; offset < match[0].length; offset += 1) output[match.index + offset] = replacement[offset];
}
}
let normalized = output.join("");
normalized = normalized.replace(/\.\s*state\b/giu, (match) => match.replace(/state/iu, "state"));
normalized = normalized.replace(/(["'])state\1/giu, (_match, quote) => `${quote}state${quote}`);
return normalized;
}
function maskShellSource(source) {
return maskShellFamilySource(source, false);
}
function maskPowerShellSource(source) {
return normalizePowerShellVariables(maskShellFamilySource(source, true));
}
function maskShellFamilySource(source, powershell) {
const output = source.split("");
let squareDepth = 0;
for (let index = 0; index < source.length; index += 1) {
if (powershell && source.startsWith("<#", index)) {
const close = source.indexOf("#>", index + 2);
const end = close < 0 ? source.length : close + 2;
maskRange(output, source, index, end);
index = end - 1;
continue;
}
if (powershell ? source[index] === "#" : shellCommentStart(source, index)) {
const end = lineEnd(source, index);
maskRange(output, source, index, end);
index = end - 1;
continue;
}
if (powershell && source[index] === "`") {
maskRange(output, source, index, Math.min(index + 2, source.length));
index += 1;
continue;
}
if (!powershell && source[index] === "`") {
const close = source.indexOf("`", index + 1);
const end = close < 0 ? source.length : close + 1;
maskRange(output, source, index, end);
restoreMasked(output, index + 1, maskShellSource(source.slice(index + 1, close < 0 ? source.length : close)));
index = end - 1;
continue;
}
const quote = source[index];
if (quote === "'" || quote === '"') {
const escapes = powershell ? "`" : quote === "'" ? "" : "\\";
const end = quotedEnd(source, index, quote, escapes);
const preserveKey = powershell && squareDepth > 0;
if (!preserveKey) maskRange(output, source, index, end, false);
if (quote === '"') {
exposeDollarSubexpressions(output, source, index + 1, end - 1, powershell ? "powershell" : "shell");
if (!powershell) {
for (let cursor = index + 1; cursor < end - 1; cursor += 1) {
if (source[cursor] !== "`" || source[cursor - 1] === "\\") continue;
const close = source.indexOf("`", cursor + 1);
if (close < 0 || close >= end) break;
restoreMasked(output, cursor + 1, maskShellSource(source.slice(cursor + 1, close)));
cursor = close;
}
}
}
index = end - 1;
continue;
}
if (source.startsWith("$(", index)) output[index] = " ";
if (source[index] === "[") squareDepth += 1;
else if (source[index] === "]" && squareDepth > 0) squareDepth -= 1;
}
return output.join("");
}
function maskUnknownSource(source) {
const output = source.split("");
let squareDepth = 0;
for (let index = 0; index < source.length; index += 1) {
if (source.startsWith("/*", index)) {
const close = source.indexOf("*/", index + 2);
const end = close < 0 ? source.length : close + 2;
maskRange(output, source, index, end);
index = end - 1;
continue;
}
if (source[index] === "#" || source.startsWith("//", index)) {
const end = lineEnd(source, index);
maskRange(output, source, index, end);
index = end - 1;
continue;
}
const quote = source[index];
if (quote === "'" || quote === '"' || quote === "`") {
const end = quotedEnd(source, index, quote, "\\");
let after = end;
while (/[ \t]/u.test(source[after] ?? "")) after += 1;
if (!(squareDepth > 0 || source[after] === ":")) maskRange(output, source, index, end, true);
index = end - 1;
continue;
}
if (source[index] === "[") squareDepth += 1;
else if (source[index] === "]" && squareDepth > 0) squareDepth -= 1;
}
return output.join("");
}
function maskQuotedShellHeredocBodies(source, label = "<shell>") {
const output = source.split("");
for (const range of literalBashHeredocBodyRanges(source, label)) maskRange(output, source, range.start, range.end);
return output.join("");
}
function shellAssociativeRevisionAccess(source) {
let quote;
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (character === "\\") { index += 1; continue; }
if (quote === "'") { if (character === "'") quote = undefined; continue; }
if (character === "'") { quote = "'"; continue; }
if (character === '"') { quote = quote === '"' ? undefined : '"'; continue; }
if (character !== "$" || source[index + 1] !== "{") continue;
const close = source.indexOf("}", index + 2);
if (close < 0) break;
const expansion = source.slice(index, close + 1);
if (/^\$\{[ \t]*(?:revision|workspaceRevision|selectedWorkspace)[ \t]*\[[ \t]*(?:["']state["']|state)[ \t]*\][^}]*\}$/u.test(expansion)) return true;
index = close;
}
return false;
}
const shellCommandPrefixes = new Set(["if", "then", "elif", "else", "while", "until", "do"]);
const shellCommandClosers = new Set(["fi", "done", "esac"]);
const shellControlCharacters = new Set([";", "|", "&", "(", ")", "{", "}", "`"]);
function shellQuotedSubstitutionEnd(source, start, depth, budget) {
for (let index = start + 1; index < source.length; index += 1) {
budget.characters += 1;
if (budget.characters > 100_000) throw new Error("revision-state shell substitution size limit exceeded");
if (source[index] === "\\") { index += 1; continue; }
if (source[index] === '"') return index + 1;
if (source.startsWith("$(", index) || source.startsWith("<(", index) || source.startsWith(">(", index)) {
index = shellParenthesizedEnd(source, index + 1, depth + 1, budget) - 1;
} else if (source[index] === "`") {
const end = quotedEnd(source, index, "`", "\\");
if (end - 1 <= index || source[end - 1] !== "`") throw new Error("revision-state shell substitution has an unclosed backtick");
index = end - 1;
}
}
throw new Error("revision-state shell substitution has an unclosed quote");
}
function shellParenthesizedEnd(source, openIndex, depth, budget) {
if (depth > 64) throw new Error("revision-state shell substitution nesting limit exceeded");
for (let index = openIndex + 1; index < source.length; index += 1) {
budget.characters += 1;
if (budget.characters > 100_000) throw new Error("revision-state shell substitution size limit exceeded");
if (source[index] === "\\") { index += 1; continue; }
if (source[index] === "'") {
const end = quotedEnd(source, index, "'", "");
if (end - 1 <= index || source[end - 1] !== "'") throw new Error("revision-state shell substitution has an unclosed quote");
index = end - 1;
continue;
}
if (source[index] === '"') { index = shellQuotedSubstitutionEnd(source, index, depth, budget) - 1; continue; }
if (source[index] === "`") {
const end = quotedEnd(source, index, "`", "\\");
if (end - 1 <= index || source[end - 1] !== "`") throw new Error("revision-state shell substitution has an unclosed backtick");
index = end - 1;
continue;
}
if (source[index] === "#" && (index === openIndex + 1 || /[ \t\r\n;|&()]/u.test(source[index - 1]))) {
index = lineEnd(source, index);
continue;
}
if (source[index] === "(") { index = shellParenthesizedEnd(source, index, depth + 1, budget) - 1; continue; }
if (source[index] === ")") return index + 1;
}
throw new Error("revision-state shell process substitution is unbalanced");
}
function shellProcessSubstitutionEnd(source, start) {
if (!(source.startsWith("<(", start) || source.startsWith(">(", start))) return undefined;
return shellParenthesizedEnd(source, start + 1, 1, { characters: 0 });
}
function shellRedirectionAt(source, start) {
const match = source.slice(start).match(/^(?:&>>|&>|(?:[0-9]+|\{[A-Za-z_][A-Za-z0-9_]*\})?(?:<<<|<<-|<<|>>|<>|>\||<&|>&|<|>))/u);
if (!match) return undefined;
let end = start + match[0].length;
while (end < source.length && !/\s/u.test(source[end]) && !shellControlCharacters.has(source[end]) &&
source[end] !== "<" && source[end] !== ">" && source[end] !== "'" && source[end] !== '"') end += 1;
return { value: source.slice(start, end), end, needsOperand: end === start + match[0].length };
}
function shellLexTokens(source) {
const tokens = [];
const push = (value, start, end, type = "word") => {
tokens.push({ value, start, end, type });
if (tokens.length > 50_000) throw new Error("revision-state shell token limit exceeded");
};
for (let index = 0; index < source.length;) {
if (source[index] === "\n" || source[index] === "\r") { push(source[index], index, index + 1, "control"); index += 1; continue; }
if (/\s/u.test(source[index])) { index += 1; continue; }
if (source[index] === "#") { index = lineEnd(source, index); continue; }
const processEnd = shellProcessSubstitutionEnd(source, index);
if (processEnd !== undefined) {
push(source.slice(index, processEnd), index, processEnd);
index = processEnd;
continue;
}
const redirection = shellRedirectionAt(source, index);
if (redirection) {
push(redirection.value, index, redirection.end, "redirection");
tokens.at(-1).needsOperand = redirection.needsOperand;
index = redirection.end;
continue;
}
if (shellControlCharacters.has(source[index]) || source[index] === "!" && (index === 0 || /\s/u.test(source[index - 1]))) {
const start = index;
let value = source[index++];
if ((value === ";" || value === "|" || value === "&") && source[index] === value) value += source[index++];
push(value, start, index, "control");
continue;
}
const start = index;
let value = "";
while (index < source.length && !/\s/u.test(source[index]) && !shellControlCharacters.has(source[index]) && source[index] !== "<" && source[index] !== ">") {
const quote = source[index];
if (quote === "'" || quote === '"') {
const end = quotedEnd(source, index, quote, "\\");
value += source.slice(index + 1, end - 1);
index = end;
} else if (source[index] === "\\" && index + 1 < source.length) {
value += source[index + 1];
index += 2;
} else {
value += source[index++];
}
}
push(value, start, index);
}
return tokens;
}
function shellCommandWords(source) {
const commands = [];
let words = [];
const finish = () => { if (words.length > 0) commands.push(words); words = []; };
for (const token of shellLexTokens(source)) {
if (token.type === "control") {
finish();
continue;
}
if (token.type === "word" && words.length === 0 && shellCommandPrefixes.has(token.value)) continue;
if (token.type === "word" && words.length === 0 && shellCommandClosers.has(token.value)) continue;
words.push(token);
}
finish();
return commands;
}
function shellExecutable(word) {
return word?.split("/").pop();
}
const shellWrapperSpecs = new Map([
["command", { kind: "options", operandOptions: new Set() }],
["env", { kind: "env", operandOptions: new Set(["-u", "--unset", "-C", "--chdir"]) }],
["sudo", { kind: "options", operandOptions: new Set(["-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-C", "--close-from", "-D", "--chdir"]) }],
["nice", { kind: "options", operandOptions: new Set(["-n", "--adjustment"]) }],
["time", { kind: "options", operandOptions: new Set(["-o", "--output", "-f", "--format"]) }],
["xargs", { kind: "options", operandOptions: new Set(["-I", "--replace", "-n", "--max-args", "-L", "--max-lines", "-P", "--max-procs", "-s", "--max-chars", "-d", "--delimiter"]) }],
["timeout", { kind: "timeout", operandOptions: new Set(["-k", "--kill-after", "-s", "--signal"]) }],
["stdbuf", { kind: "stdbuf", operandOptions: new Set(["-i", "--input", "-o", "--output", "-e", "--error"]) }],
["nohup", { kind: "options", operandOptions: new Set() }],
["exec", { kind: "options", operandOptions: new Set(["-a"]) }],
["coproc", { kind: "coproc", operandOptions: new Set() }],
]);
function skipShellMetadata(words, start) {
let index = start;
while (index < words.length) {
const token = words[index];
if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(token.value)) { index += 1; continue; }
if (token.type === "redirection") { index += token.needsOperand ? 2 : 1; continue; }
break;
}
return index;
}
function skipWrapperOptions(words, start, spec) {
let index = start;
while (index < words.length) {
const word = words[index].value;
if (word === "--") return index + 1;
if (spec.operandOptions.has(word)) { index += 2; continue; }
if (spec.kind === "stdbuf" && /^-(?:i|o|e).+/u.test(word)) { index += 1; continue; }
if (word.startsWith("-")) { index += 1; continue; }
break;
}
return index;
}
function shellJqArguments(words) {
let index = skipShellMetadata(words, 0);
let wrappers = 0;
while (index < words.length) {
const spec = shellWrapperSpecs.get(shellExecutable(words[index]?.value));
if (!spec) break;
if (wrappers >= 16) throw new Error("revision-state shell wrapper nesting exceeds policy limit");
wrappers += 1;
index = skipWrapperOptions(words, index + 1, spec);
if (spec.kind === "env") {
while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[index]?.value ?? "")) index += 1;
} else if (spec.kind === "timeout") {
if (index >= words.length) return undefined;
index += 1;
} else if (spec.kind === "coproc") {
index = skipShellMetadata(words, index);
const current = shellExecutable(words[index]?.value);
if (current !== "jq" && !shellWrapperSpecs.has(current) && /^[A-Za-z_][A-Za-z0-9_]*$/u.test(words[index]?.value ?? "")) {
const afterName = skipShellMetadata(words, index + 1);
const command = shellExecutable(words[afterName]?.value);
if (command === "jq" || shellWrapperSpecs.has(command)) index = afterName;
}
}
index = skipShellMetadata(words, index);
}
return shellExecutable(words[index]?.value) === "jq" ? words.slice(index + 1) : undefined;
}
const jqOptionOperands = new Map([
["--arg", 2], ["--argjson", 2], ["--slurpfile", 2], ["--rawfile", 2], ["--argfile", 2],
["-L", 1], ["--library-path", 1], ["--indent", 1],
["-f", 1], ["--from-file", 1],
]);
const jqFileFilterOptions = new Set(["-f", "--from-file"]);
function withoutShellRedirections(arguments_) {
const semantic = [];
for (let index = 0; index < arguments_.length; index += 1) {
const token = arguments_[index];
if (token.type === "redirection") { if (token.needsOperand) index += 1; continue; }
semantic.push(token);
}
return semantic;
}
function jqInvocation(arguments_) {
const semantic = withoutShellRedirections(arguments_);
let fromFile = false;
for (let index = 0; index < semantic.length; index += 1) {
const argument = semantic[index].value;
if (argument === "--") return { filter: fromFile ? undefined : semantic[index + 1], arguments_ };
const operands = jqOptionOperands.get(argument);
if (operands !== undefined) {
if (jqFileFilterOptions.has(argument)) fromFile = true;
index += operands;
continue;
}
if (argument.startsWith("-")) continue;
return { filter: fromFile ? undefined : semantic[index], arguments_ };
}
return { filter: undefined, arguments_ };
}
function maskShellJqLiteralArguments(source) {
const output = source.split("");
for (const words of shellCommandWords(source)) {
const arguments_ = shellJqArguments(words);
if (!arguments_) continue;
const invocation = jqInvocation(arguments_);
for (const argument of invocation.arguments_) {
if (argument === invocation.filter) continue;
const raw = source.slice(argument.start, argument.end);
if (!raw.includes("$") && !raw.includes("`")) maskRange(output, source, argument.start, argument.end);
}
}
return output.join("");
}
function jqStringEnd(source, start) {
for (let index = start + 1; index < source.length; index += 1) {
if (source[index] === "\\") { index += 1; continue; }
if (source[index] === '"') return index;
}
return source.length;
}
function jqInterpolationEnd(source, start) {
let depth = 1;
for (let index = start; index < source.length; index += 1) {
if (source[index] === '"') { index = jqStringEnd(source, index); continue; }
if (source[index] === "(") depth += 1;
else if (source[index] === ")" && --depth === 0) return index;
}
return source.length;
}
function jqTokens(source, budget = { tokens: 0, depth: 0 }) {
if (budget.depth >= 64) throw new Error("jq filter exceeds policy nesting limit");
budget.depth += 1;
const tokens = [];
for (let index = 0; index < source.length; index += 1) {
budget.tokens += 1;
if (budget.tokens >= 10_000) throw new Error("jq filter exceeds policy token limit");
if (/\s/u.test(source[index])) continue;
if (source[index] === "#") { index = lineEnd(source, index); continue; }
if (source[index] === '"') {
const end = jqStringEnd(source, index);
const raw = source.slice(index, Math.min(end + 1, source.length));
let value;
if (!raw.includes("\\(")) {
try { value = JSON.parse(raw); } catch { value = undefined; }
}
tokens.push({ type: "string", value });
for (let cursor = index + 1; cursor < end; cursor += 1) {
if (source[cursor] === "\\" && source[cursor + 1] === "(") {
const close = jqInterpolationEnd(source, cursor + 2);
tokens.push(...jqTokens(source.slice(cursor + 2, close), budget));
cursor = close;
} else if (source[cursor] === "\\") cursor += 1;
}
index = end;
continue;
}
const variable = source.slice(index).match(/^\$([A-Za-z_][A-Za-z0-9_]*)/u);
if (variable) { tokens.push({ type: "variable", value: variable[1] }); index += variable[0].length - 1; continue; }
const identifier = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_]*/u);
if (identifier) { tokens.push({ type: "identifier", value: identifier[0] }); index += identifier[0].length - 1; continue; }
const punctuation = { ".": "dot", "[": "open", "]": "close" }[source[index]];
tokens.push({ type: punctuation ?? "other", value: source[index] });
}
budget.depth -= 1;
return tokens;
}
function jqStaticString(tokens, cursor, depth = 0) {
if (depth >= 64) throw new Error("revision-state jq static-key nesting exceeds policy limit");
let index = cursor;
let value;
if (tokens[index]?.type === "string" && typeof tokens[index].value === "string") {
value = tokens[index].value;
index += 1;
} else if (tokens[index]?.type === "other" && tokens[index].value === "(") {
const nested = jqStaticString(tokens, index + 1, depth + 1);
if (!nested || tokens[nested.next]?.type !== "other" || tokens[nested.next].value !== ")") return undefined;
value = nested.value;
index = nested.next + 1;
} else return undefined;
while (tokens[index]?.type === "other" && tokens[index].value === "+") {
const right = jqStaticString(tokens, index + 1, depth + 1);
if (!right) return undefined;
value += right.value;
index = right.next;
}
return { value, next: index };
}
function jqBracketSegment(tokens, cursor) {
if (tokens[cursor]?.type !== "open") return undefined;
const expression = jqStaticString(tokens, cursor + 1);
return expression && tokens[expression.next]?.type === "close" ?
{ value: expression.value, next: expression.next + 1 } : undefined;
}
function jqPathSegment(tokens, cursor, allowBareBracket = true) {
if (tokens[cursor]?.type === "variable") return { value: tokens[cursor].value, next: cursor + 1 };
let index = cursor;
if (tokens[index]?.type === "dot") {
index += 1;
if (tokens[index]?.type === "identifier" || tokens[index]?.type === "string") return { value: tokens[index].value, next: index + 1 };
}
return allowBareBracket ? jqBracketSegment(tokens, index) : undefined;
}
function jqIdentityPipelineEnd(tokens, cursor) {
let index = cursor;
while (tokens[index]?.type === "other" && tokens[index].value === "(") index += 1;
if (tokens[index]?.type !== "dot") return undefined;
index += 1;
while (tokens[index]?.type === "other" && tokens[index].value === ")") index += 1;
return tokens[index]?.type === "other" && tokens[index].value === "|" ? index + 1 : undefined;
}
function jqTargetGrammarSupported(tokens) {
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token.type === "identifier" && tokens[index - 1]?.type !== "dot") return false;
if (token.type === "open" && !jqBracketSegment(tokens, index)) return false;
if (token.type !== "other") continue;
if (["?", "(", ")", "|"].includes(token.value)) continue;
if (token.value === "+" && (tokens[index - 1]?.type === "string" || tokens[index - 1]?.value === ")") &&
(tokens[index + 1]?.type === "string" || tokens[index + 1]?.value === "(")) continue;
return false;
}
return true;
}
function jqContainsActiveTarget(tokens) {
for (let index = 0; index < tokens.length; index += 1) {
if (tokens[index].type === "variable" && revisionIdentifiers.has(tokens[index].value)) return true;
if (tokens[index].type === "dot" && (tokens[index + 1]?.type === "identifier" || tokens[index + 1]?.type === "string") &&
revisionIdentifiers.has(tokens[index + 1].value)) return true;
if (tokens[index].type === "open" && (tokens[index - 1]?.type === "dot" || tokens[index - 1]?.type === "close" || tokens[index - 1]?.type === "identifier")) {
const key = jqStaticString(tokens, index + 1);
if (key && revisionIdentifiers.has(key.value)) return true;
}
}
return false;
}
function jqRevisionAnalysis(filter) {
const tokens = jqTokens(filter);
let activeTarget = jqContainsActiveTarget(tokens);
for (let index = 0; index < tokens.length; index += 1) {
if (tokens[index].type !== "dot" && tokens[index].type !== "variable") continue;
const segments = [];
let cursor = index;
let pipelineBoundary = false;
while (cursor < tokens.length) {
if (pipelineBoundary && (tokens[cursor]?.type === "open" || tokens[cursor]?.type === "string")) {
segments.length = 0;
break;
}
if (pipelineBoundary && tokens[cursor]?.type === "variable") segments.length = 0;
const segment = jqPathSegment(tokens, cursor, !pipelineBoundary);
if (!segment) break;
pipelineBoundary = false;
segments.push(segment.value);
cursor = segment.next;
while (tokens[cursor]?.type === "other" && tokens[cursor].value === "?") cursor += 1;
while (tokens[cursor]?.type === "other" && tokens[cursor].value === ")") cursor += 1;
if (tokens[cursor]?.type === "other" && tokens[cursor].value === "|") {
cursor += 1;
while (tokens[cursor]?.type === "other" && tokens[cursor].value === "(") cursor += 1;
let identityEnd;
while ((identityEnd = jqIdentityPipelineEnd(tokens, cursor)) !== undefined) cursor = identityEnd;
pipelineBoundary = true;
}
}
if (segments.some((segment) => revisionIdentifiers.has(segment))) activeTarget = true;
for (let position = 0; position + 1 < segments.length; position += 1) {
if (revisionIdentifiers.has(segments[position]) && segments[position + 1] === "state") return "violation";
}
}
if (!activeTarget) return "safe";
return jqTargetGrammarSupported(tokens) ? "safe" : "unsupported";
}
function shellExecutableSubstitutionBodies(source, arithmeticContext = false) {
const bodies = [];
const addParenthesized = (start, kind) => {
const end = shellParenthesizedEnd(source, start + 1, 1, { characters: 0 });
bodies.push({ kind, start: start + 2, end: end - 1, source: source.slice(start + 2, end - 1) });
return end;
};
const addBacktick = (start) => {
const end = quotedEnd(source, start, "`", "\\");
if (end - 1 <= start || source[end - 1] !== "`") throw new Error("revision-state shell substitution has an unclosed backtick");
bodies.push({ kind: "backtick", start: start + 1, end: end - 1, source: source.slice(start + 1, end - 1) });
return end;
};
for (let index = 0; index < source.length; index += 1) {
if (source[index] === "\\") { index += 1; continue; }
if (source[index] === "#" && (index === 0 || /[ \t\r\n;|&()]/u.test(source[index - 1]))) { index = lineEnd(source, index); continue; }
if (source[index] === "'") {
const end = quotedEnd(source, index, "'", "");
if (end - 1 <= index || source[end - 1] !== "'") throw new Error(`revision-state shell policy found an unclosed quote at offset ${index}`);
index = end - 1;
continue;
}
if (source[index] === '"') {
for (let cursor = index + 1; cursor < source.length; cursor += 1) {
if (source[cursor] === "\\") { cursor += 1; continue; }
if (source[cursor] === '"') { index = cursor; break; }
if (source.startsWith("$(", cursor)) {
const end = addParenthesized(cursor, source.startsWith("$((", cursor) ? "arithmetic" : "command");
cursor = end - 1;
} else if (source[cursor] === "`") {
cursor = addBacktick(cursor) - 1;
}
if (cursor + 1 >= source.length) throw new Error(`revision-state shell policy found an unclosed double quote at offset ${index}`);
}
continue;
}
if (!arithmeticContext && (source.startsWith("<(", index) || source.startsWith(">(", index))) {
index = addParenthesized(index, "process") - 1;
continue;
}
if (source.startsWith("$(", index)) {
const arithmetic = source.startsWith("$((", index);
index = addParenthesized(index, arithmetic ? "arithmetic" : "command") - 1;
continue;
}
if (source[index] === "`") index = addBacktick(index) - 1;
}
return bodies;
}
function removeBacktickBodyEscapes(source) {
let result = "";
for (let index = 0; index < source.length; index += 1) {
if (source[index] === "\\" && index + 1 < source.length && ["$", "`", "\\", "\n"].includes(source[index + 1])) {
if (source[index + 1] !== "\n") result += source[index + 1];
index += 1;
} else {
result += source[index];
}
}
return result;
}
function shellJqRevisionAccess(source, budget = { characters: 0 }, depth = 0, arithmeticContext = false) {
if (depth > 32) throw new Error("revision-state executable shell substitution nesting limit exceeded");
budget.characters += source.length;
if (budget.characters > 500_000) throw new Error("revision-state executable shell substitution size limit exceeded");
if (!arithmeticContext) {
for (const words of shellCommandWords(source)) {
const arguments_ = shellJqArguments(words);
const filter = arguments_ && jqInvocation(arguments_).filter;
if (filter) {
const analysis = jqRevisionAnalysis(filter.value);
if (analysis === "violation") return true;
if (analysis === "unsupported") throw new Error("revision-state jq target grammar is unsupported");
}
}
}
for (const body of shellExecutableSubstitutionBodies(source, arithmeticContext)) {
const nestedSource = body.kind === "backtick" ? removeBacktickBodyEscapes(body.source) : body.source;
if (shellJqRevisionAccess(nestedSource, budget, depth + 1, body.kind === "arithmetic")) return true;
}
return false;
}
function nonJsAnalysisSource(source, label) {
const lower = label.toLowerCase();
if (lower.endsWith(".sh")) return maskShellSource(maskShellJqLiteralArguments(maskQuotedShellHeredocBodies(source, label)));
if (lower.endsWith(".ps1")) return maskPowerShellSource(source);
return maskUnknownSource(source);
}
function revisionStateAstNodes(source, label) {
const knownKind = scriptKindFor(label);
const caseInsensitive = label.toLowerCase().endsWith(".ps1");
const analyzed = knownKind === undefined ? nonJsAnalysisSource(source, label) : source;
const file = ts.createSourceFile(label, analyzed, ts.ScriptTarget.Latest, true, knownKind ?? ts.ScriptKind.TS);
const matches = [];
function visit(node) {
if (ts.isPropertyAccessExpression(node) && node.name.text === "state" && isRevisionExpression(node.expression, caseInsensitive)) {
matches.push(node);
} else if (ts.isElementAccessExpression(node) && isRevisionExpression(node.expression, caseInsensitive) &&
node.argumentExpression && propertyNameText(node.argumentExpression, caseInsensitive) === "state") {
matches.push(node);
} else if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) && node.initializer &&
isRevisionExpression(node.initializer, caseInsensitive) && ts.isObjectBindingPattern(node.name) &&
objectBindingHasState(node.name, caseInsensitive)) {
matches.push(node);
} else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
isRevisionExpression(node.right, caseInsensitive)) {
const assignmentTarget = unwrapExpression(node.left);
if (ts.isObjectLiteralExpression(assignmentTarget) && objectLiteralHasState(assignmentTarget, caseInsensitive)) matches.push(node);
} else if (ts.isPropertyAssignment(node) && propertyNameText(node.name, caseInsensitive) === "revision" &&
ts.isObjectLiteralExpression(node.initializer) && objectLiteralHasState(node.initializer, caseInsensitive)) {
matches.push(node);
}
ts.forEachChild(node, visit);
}
visit(file);
return matches;
}
function yamlScalarRevisionAccess(value) {
return /(?:^|[\s;=,(])(?:revision|workspaceRevision|selectedWorkspace)\s*(?:\.\s*state|\[\s*["']?state["']?\s*\])(?:$|[\s;,)])/u.test(value);
}
function validateYamlRevisionState(source, label) {
const documents = parseAllDocuments(source, { uniqueKeys: true, merge: true });
for (const document of documents) {
if (document.errors.length > 0) throw new Error(`${label}: revision-state policy cannot parse YAML`);
const walkAst = (node) => {
if (isScalar(node)) {
if (node.type === "PLAIN" && typeof node.value === "string" && yamlScalarRevisionAccess(node.value)) throw new Error(`${label}: forbidden revision-state access`);
return;
}
if (isSeq(node)) { for (const item of node.items) walkAst(item); return; }
if (isMap(node)) { for (const pair of node.items) walkAst(pair.value); }
};
walkAst(document.contents);
let resolved;
try { resolved = document.toJS({ mapAsMap: true, maxAliasCount: 50 }); }
catch { throw new Error(`${label}: revision-state YAML alias resolution failed`); }
const seen = new WeakSet();
const walkResolved = (value) => {
if (!value || typeof value !== "object" || seen.has(value)) return;
seen.add(value);
if (value instanceof Map) {
for (const [key, child] of value) {
if (revisionIdentifiers.has(String(key)) && child instanceof Map && child.has("state")) throw new Error(`${label}: forbidden revision-state access`);
walkResolved(child);
}
} else if (Array.isArray(value)) { for (const child of value) walkResolved(child); }
};
walkResolved(resolved);
}
}
function validateRevisionState(source, label) {
const lower = label.toLowerCase();
if (/\.(?:yaml|yml)(?:\.example)?$/u.test(lower)) {
validateYamlRevisionState(source, label);
return;
}
if (lower.endsWith(".sh")) {
const active = maskQuotedShellHeredocBodies(source, label);
try {
if (shellJqRevisionAccess(active) || shellAssociativeRevisionAccess(active)) throw new Error("forbidden revision-state access");
} catch (error) {
throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (lower.endsWith(".py") || lower.endsWith(".pyw")) throw new Error(`${label}: revision-state Python input was not batched`);
const matches = revisionStateAstNodes(source, label);
if (matches.length === 0) return;
const historical = 'revision.state !== "operational"';
const historicalCount = source.split(historical).length - 1;
const match = matches[0];
if (label === "backend/src/workspaces/registry.ts" && matches.length === 1 &&
match.getText() === "revision.state" && match.parent?.getText() === historical &&
historicalCount === 1) return;
throw new Error(`${label}: forbidden revision-state access`);
}
const pythonHelper = fileURLToPath(new URL("./revision_state_policy.py", import.meta.url));
function validatePythonRevisionStates(records) {
if (!Array.isArray(records) || records.length === 0) return;
let stdout;
try {
stdout = execFileSync("python3", ["-I", "-B", pythonHelper], {
input: JSON.stringify(records), encoding: "utf8", timeout: 5_000, maxBuffer: 4 * 1024 * 1024,
env: {
PATH: process.env.PATH ?? "/usr/bin:/bin",
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8",
PYTHONDONTWRITEBYTECODE: "1",
},
stdio: ["pipe", "pipe", "pipe"],
});
} catch (error) {
const detail = error?.stderr?.toString().trim();
throw new Error(`revision-state helper failed${detail ? `: ${detail}` : ""}`);
}
let result;
try { result = JSON.parse(stdout); }
catch { throw new Error("revision-state helper failed: invalid JSON output"); }
if (!result || !Array.isArray(result.violations) || result.violations.some((label) => typeof label !== "string")) throw new Error("revision-state helper failed: invalid result shape");
if (result.violations.length > 0) throw new Error(`${result.violations[0]}: forbidden revision-state access`);
}
export { validatePythonRevisionStates, validateRevisionState };
@@ -0,0 +1,273 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { validatePythonRevisionStates, validateRevisionState } from "./revision-state-policy.mjs";
function rejects(source, path) {
assert.throws(() => validateRevisionState(source, path), /revision-state/, source);
}
function passes(source, path) {
assert.doesNotThrow(() => validateRevisionState(source, path));
}
test("PowerShell scoped and braced revision variables remain executable", () => {
rejects('${revision}.state', "scripts/direct.ps1");
rejects('${workspaceRevision}["state"]', "scripts/bracket.ps1");
rejects('Write-Output "$(${selectedWorkspace}.state)"', "scripts/subexpression.ps1");
rejects('${script:revision}.state', "scripts/scoped.ps1");
rejects('${global:workspaceRevision}["state"]', "scripts/global.ps1");
for (const source of [
'$REVISION.STATE',
'${Revision}.state',
'$WORKSPACEREVISION["STATE"]',
'${GLOBAL:SELECTEDWORKSPACE}.State',
'$REVISION["ST" + "ATE"]',
'${Revision}[("sT" + "AtE")]',
'$record.REVISION.STATE',
'$record.WORKSPACEREVISION["STATE"]',
'$record["REVISION"].STATE',
]) rejects(source, "scripts/case.ps1");
passes('REVISION.STATE; revision.STATE; revision["ST" + "ATE"]; record.REVISION.STATE; record["REVISION"].state', "backend/src/case-sensitive.ts");
});
test("Bash jq command forms and associative revision parameters are active", () => {
for (const source of [
"value=$(jq -r '.revision.state' snapshot.json)",
"value=$(command jq -r '.workspaceRevision.state' snapshot.json)",
"/usr/bin/jq --arg x y '.selectedWorkspace.state' snapshot.json",
"env -i MODE=x jq -- '.revision.state' snapshot.json",
"env -u MODE /opt/tools/jq -r '.workspaceRevision.state' snapshot.json",
"echo safe\nvalue=`jq -r '.selectedWorkspace.state' snapshot.json`",
"sudo -u nobody /usr/bin/jq -r '.revision.state' snapshot.json",
"nice -n 5 jq -r '.workspaceRevision.state' snapshot.json",
"time jq -r '.selectedWorkspace.state' snapshot.json",
"printf x | xargs -n 1 jq -r '.revision.state'",
"timeout -k 2 5 jq -r '.revision.state' snapshot.json",
`stdbuf -o L jq -r '.["workspaceRevision"].state' snapshot.json`,
`stdbuf -oL jq -r '.["selectedWorkspace"]["state"]' snapshot.json`,
`nohup jq -r '.revision["state"]' snapshot.json`,
"< snapshot.json jq -r '.workspaceRevision.state'",
"sudo MODE=x jq -r '.selectedWorkspace.state' snapshot.json",
String.raw`jq -r '"x \(.revision.state)"' snapshot.json`,
"jq < snapshot.json -r '.revision.state'",
"jq -r < snapshot.json '.workspaceRevision.state'",
"jq --arg note safe < snapshot.json '.selectedWorkspace.state'",
"jq -r '.revision?.state' snapshot.json",
`jq -r '.["workspaceRevision"]?["state"]' snapshot.json`,
`jq -r '.["revision"]?.["state"]' snapshot.json`,
`jq -r '."revision".state' snapshot.json`,
`jq -r '."workspaceRevision"."state"' snapshot.json`,
"jq -r '$revision.state' snapshot.json",
"jq -r '($selectedWorkspace).state' snapshot.json",
`${"env ".repeat(17)}jq -r '.revision.state' snapshot.json`,
"jq<input.json -r '.revision.state'",
"jq 2>/dev/null -r '.workspaceRevision.state' snapshot.json",
"{ jq -r '.selectedWorkspace.state' snapshot.json; }",
"! jq -r '.revision.state' snapshot.json",
"if jq -r '.workspaceRevision.state' snapshot.json; then :; fi",
"if false; then :; elif jq -r '.selectedWorkspace.state' snapshot.json; then :; fi",
"while false; do jq -r '.revision.state' snapshot.json; done",
"until false; do jq -r '.workspaceRevision.state' snapshot.json; done",
"jq -r '.revision | .state' snapshot.json",
"jq -r '(.workspaceRevision | .state)' snapshot.json",
`jq -r '.["revi" + "sion"].state' snapshot.json`,
`jq -r '.["workspace" + "Revision"]["st" + "ate"]' snapshot.json`,
"jq 2>&1 -r '.revision.state' snapshot.json",
"jq 2>&- -r '.workspaceRevision.state' snapshot.json",
"jq 0<&3 -r '.selectedWorkspace.state' snapshot.json",
"jq &>/dev/null -r '.revision.state' snapshot.json",
"jq &>>log -r '.workspaceRevision.state' snapshot.json",
"jq >|output -r '.selectedWorkspace.state' snapshot.json",
"jq {fd}>output -r '.revision.state' snapshot.json",
"exec jq -r '.workspaceRevision.state' snapshot.json",
"coproc jq -r '.selectedWorkspace.state' snapshot.json",
"coproc worker jq -r '.revision.state' snapshot.json",
"coproc worker >out jq -r '.workspaceRevision.state' snapshot.json",
"coproc worker 2>/dev/null jq -r '.selectedWorkspace.state' snapshot.json",
"coproc worker VAR=x jq -r '.revision.state' snapshot.json",
`jq -r '.["revi" + ("sion")].state' snapshot.json`,
"jq -r '.revision | . | .state' snapshot.json",
"jq -r '(.workspaceRevision | (.) | .state)' snapshot.json",
"jq -r '.revision | select(.) | .state' snapshot.json",
"jq -r '.workspaceRevision | {value:.state}' snapshot.json",
"jq -r '.selectedWorkspace | [.state]' snapshot.json",
"jq -r '.revision + .state' snapshot.json",
"jq < <(cat snapshot.json) -r '.revision.state'",
"jq < <(cat <(printf snapshot.json)) -r '.workspaceRevision.state'",
"jq > >(cat >/dev/null) -r '.selectedWorkspace.state' snapshot.json",
`jq < <(printf '%s\n' "$((1 + (2)))") -r '.revision.state'`,
"jq < <(cat snapshot.json -r '.revision.state'",
`${"<(".repeat(65)}echo snapshot${")".repeat(65)} jq -r '.workspaceRevision.state'`,
"cat <(jq -r '.revision.state' snapshot.json)",
"cat snapshot.json > >(jq -r '.workspaceRevision.state')",
`echo "$(jq -r '.selectedWorkspace.state' snapshot.json)"`,
"value=$(jq -r '.revision.state' snapshot.json)",
`echo "\`jq -r '.workspaceRevision.state' snapshot.json\`"`,
`echo "$(cat <(jq -r '.selectedWorkspace.state' snapshot.json))"`,
`${"$(".repeat(33)}jq -r '.revision.state' snapshot.json${")".repeat(33)}`,
`echo "$(( $(jq -r '.revision.state' snapshot.json) + 0 ))"`,
"echo \"$(( `jq -r '.workspaceRevision.state' snapshot.json` + 0 ))\"",
"echo `echo \\`jq -r '.selectedWorkspace.state' snapshot.json\\``",
"echo \"`echo \\`jq -r '.revision.state' snapshot.json\\``\"",
`${"$(( ".repeat(33)}$(jq -r '.workspaceRevision.state' snapshot.json)${" + 0 ))".repeat(33)}`,
'old=${revision["state"]}',
"old=${workspaceRevision[state]}",
"old=${revision[state]:-missing}",
"old=${workspaceRevision['state']:=missing}",
"old=${selectedWorkspace[state]:1:2}",
]) rejects(source, "scripts/policy.sh");
const jqFilters = [
".revision?.state", '.["revision"]?.["state"]', '."revision".state',
'."workspaceRevision"."state"', "(.revision).state", "$revision.state",
".revision | .state", "(.workspaceRevision | .state)",
'.["revi" + "sion"].state', '.["revi" + ("sion")].state',
".revision | . | .state", "(.workspaceRevision | (.) | .state)",
".revision | select(.) | .state", ".workspaceRevision | {value:.state}",
'.revision | ["state"]', '(.workspaceRevision | (["state"]))', ".selectedWorkspace | $state",
];
for (const filter of jqFilters) {
const compiled = spawnSync("jq", ["-n", "--argjson", "revision", "{}", "--arg", "state", "x", filter], { encoding: "utf8" });
if (compiled.error?.code !== "ENOENT") assert.equal(compiled.status, 0, `${filter}: ${compiled.stderr}`);
}
passes("cat <<'EOF'\nrevision.state\nEOF\n", "scripts/literal.sh");
passes("echo '${revision[state]}'\n", "scripts/single-quoted-parameter.sh");
passes(`echo "<(jq '.revision.state')"\n`, "scripts/literal-process-text.sh");
passes(`echo "ordinary jq '.workspaceRevision.state' text"\n`, "scripts/literal-jq-text.sh");
passes(`echo '$(jq -r ".selectedWorkspace.state")'\n`, "scripts/single-quoted-command-text.sh");
passes(`# profile's harmless note
printf 'ok\n'
`, "scripts/comment-apostrophe.sh");
passes(`cat <( # profile's harmless note
printf 'snapshot\n'
)
`, "scripts/substitution-comment-apostrophe.sh");
passes(`echo "$(( 1 + (2 * 3) ))"\n`, "scripts/literal-arithmetic.sh");
passes(`echo $(( jq + revision + state ))\n`, "scripts/arithmetic-identifiers.sh");
passes("echo \\`jq -r '.revision.state' snapshot.json\\`\n", "scripts/escaped-literal-backticks.sh");
passes("echo \"\\`jq -r '.workspaceRevision.state' snapshot.json\\`\"\n", "scripts/double-quoted-literal-backticks.sh");
passes("echo `printf '%s' '\\`jq -r \".selectedWorkspace.state\" snapshot.json\\`'`\n", "scripts/quoted-nonexecuting-nested-backticks.sh");
passes("jq --arg note 'revision.state' '.' file\n", "scripts/jq-arg.sh");
passes(`jq --argjson note '"revision.state"' '.' file
`, "scripts/jq-argjson.sh");
passes("jq -r '.' revision.state.json\n", "scripts/jq-file.sh");
passes("jq -r '.revision.id' snapshot.json\n", "scripts/jq-simple-non-state.sh");
passes("jq -f revision.state.jq snapshot.json\n", "scripts/jq-from-file.sh");
passes("jq --from-file workspaceRevision.state.jq snapshot.json\n", "scripts/jq-long-from-file.sh");
passes(`jq -r '"revision.state"' snapshot.json
`, "scripts/jq-string.sh");
passes(`jq -r '{note:"selectedWorkspace.state"}' snapshot.json
`, "scripts/jq-object.sh");
passes(`jq -r '.revision | "state"' snapshot.json
`, "scripts/jq-pipe-literal-right.sh");
passes(`jq -r '"revision" | .state' snapshot.json
`, "scripts/jq-pipe-literal-left.sh");
passes(`jq -r '.revision | ["state"]' snapshot.json
`, "scripts/jq-pipe-array.sh");
passes(`jq -r '(.workspaceRevision | (["state"]))' snapshot.json
`, "scripts/jq-pipe-parenthesized-array.sh");
passes(`jq --arg state x '.selectedWorkspace | $state' snapshot.json
`, "scripts/jq-pipe-variable.sh");
for (const opener of ["'E'OF", "E'OF'", "E\\OF"]) {
passes(`cat <<${opener}
revision.state
EOF
`, "scripts/partial-quoted-heredoc.sh");
}
rejects("cat <<'E'OF\nrevision.state\nEOF\nworkspaceRevision.state\n", "scripts/after-heredoc.sh");
rejects("cat <<'EOF'\nrevision.state\n", "scripts/unclosed-heredoc.sh");
rejects(`echo "<<'EOF'"
jq -r '.revision.state' snapshot.json
`, "scripts/quoted-opener.sh");
});
test("Python helper resolves active AST expressions and static format bindings", () => {
const rejectsPython = (source) => assert.throws(
() => validatePythonRevisionStates([{ source, label: "backend/scripts/policy.py" }]),
/revision-state/,
);
for (const source of [
"old = revision.state",
'old = workspaceRevision["state"]',
'old = record["selectedWorkspace"].state',
'old = f"{revision.state}"',
'"{revision.state}".format(value)',
'"{0.state}".format(revision)',
'"{0[state]}".format(workspaceRevision)',
'"{item.state}".format(item=selectedWorkspace)',
'"{item[state]}".format_map({"item": revision})',
'("{0.state}").format(revision)',
'"{0:{1.state}}".format(value, revision)',
'old = revision["st" + "ate"]',
'old = record["revi" + "sion"].state',
'old = revision[f"state"]',
'old = record[f"revision"].state',
`old = revision[f"st{'a'}te"]`,
`old = revision[f"{'state'}"]`,
`old = record[f"revi{'sion'}"].state`,
`old = revision[f"{'st' + 'ate'}"]`,
`old = record[f"{'revi' + 'sion'}"].state`,
`old = revision[f"{'state':s}"]`,
'"{0.state}".format(*[revision])',
'"{0[state]}".format(*(revision,))',
'"{1[state]}".format(*[other, workspaceRevision])',
'"{item.state}".format(**{"item": selectedWorkspace})',
'"{item[state]}".format_map({**{"item": revision}})',
'"{.state}".format(revision)',
'"{[state]}".format(revision)',
'"{:{.state}}".format(value, revision)',
'"{.name} {[state]}".format(other, revision)',
'"{item.state}".format(item=revision, **values)',
]) rejectsPython(source);
validatePythonRevisionStates([
{ source: 'text = "{revision.state}"', label: "backend/scripts/literal.py" },
{ source: 'text = "{{revision.state}}".format(value)', label: "backend/scripts/escaped.py" },
{ source: 'text = "{0.state}".format(other)', label: "backend/scripts/unrelated.py" },
{ source: 'old = revision[f"st{suffix}"]', label: "backend/scripts/dynamic-key.py" },
{ source: 'text = "{.name} {[state]}".format(other, other)', label: "backend/scripts/multi-auto.py" },
{ source: 'text = "{item.state}".format(**values)', label: "backend/scripts/dynamic-map.py" },
]);
const hostile = mkdtempSync(join(tmpdir(), "revision-policy-hostile-"));
writeFileSync(join(hostile, "json.py"), "raise RuntimeError('shadowed')\n");
const previousPythonPath = process.env.PYTHONPATH;
try {
process.env.PYTHONPATH = hostile;
validatePythonRevisionStates([{ source: "value = 1", label: "backend/scripts/isolated.py" }]);
} finally {
if (previousPythonPath === undefined) delete process.env.PYTHONPATH;
else process.env.PYTHONPATH = previousPythonPath;
rmSync(hostile, { recursive: true, force: true });
}
assert.throws(
() => validatePythonRevisionStates([{ source: 'revision[f"{1:.1000000000f}"]', label: "backend/scripts/oversized.py" }]),
/revision-state helper failed/,
);
validatePythonRevisionStates([{ source: 'revision[f"{1:04d}"]', label: "backend/scripts/small-format.py" }]);
assert.throws(
() => validatePythonRevisionStates([{ source: "def broken(", label: "backend/scripts/invalid.py" }]),
/revision-state helper failed/,
);
});
test("YAML mappings and only active plain scalar expressions are rejected", () => {
for (const source of [
"value: { revision: { state: old } }\n",
"value:\n workspaceRevision:\n state: old\n",
'items:\n - "selectedWorkspace":\n "state": old\n',
"old: selectedWorkspace.state\n",
"url: https://host/x; old: selectedWorkspace.state\n",
"saved: &saved { state: old }\nvalue: { revision: *saved }\n",
"defaults: &defaults { workspaceRevision: { state: old } }\nvalue: { <<: *defaults }\n",
]) rejects(source, "scripts/policy.yaml");
rejects("a: &a [x,x,x,x,x,x,x,x,x]\nb: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]\nc: [*b,*b,*b,*b,*b,*b,*b,*b,*b]\n", "scripts/alias-bomb.yaml");
rejects("value: [\n", "scripts/invalid.yaml");
for (const source of [
"value: |\n revision.state\n",
"value: >\n workspaceRevision.state\n",
'value: "selectedWorkspace.state"\n',
"url: https://host/revision.state\n",
]) passes(source, "scripts/literal.yaml");
});
+318
View File
@@ -0,0 +1,318 @@
"""Semantic Python revision-state policy helper.
Reads one JSON array of ``{"label": str, "source": str}`` records from stdin and
writes ``{"violations": [label, ...]}``. Invalid input or Python source is fatal.
"""
from __future__ import annotations
import ast
import json
import re
import string
import sys
from itertools import pairwise
from typing import Any
TARGETS = frozenset({"revision", "workspaceRevision", "selectedWorkspace"})
_FORMATTER = string.Formatter()
MAX_STATIC_TEXT = 4_096
MAX_FORMAT_SPEC = 256
MAX_STATIC_DEPTH = 64
_UNRESOLVED = object()
def _bounded_text(value: str) -> str:
if len(value) > MAX_STATIC_TEXT:
raise ValueError("static text exceeds revision policy limit")
return value
def _static_scalar(node: ast.expr, depth: int) -> object:
if depth > MAX_STATIC_DEPTH:
raise ValueError("static expression nesting exceeds revision policy limit")
if isinstance(node, ast.Constant) and type(node.value) in {
str,
int,
float,
complex,
bool,
type(None),
}:
if isinstance(node.value, str):
_bounded_text(node.value)
if isinstance(node.value, int) and node.value.bit_length() > MAX_STATIC_TEXT * 4:
raise ValueError("static integer exceeds revision policy limit")
return node.value
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
left = _static_scalar(node.left, depth + 1)
right = _static_scalar(node.right, depth + 1)
if left is _UNRESOLVED or right is _UNRESOLVED:
return _UNRESOLVED
try:
result = left + right
except TypeError:
return _UNRESOLVED
if type(result) not in {str, int, float, complex, bool}:
return _UNRESOLVED
if isinstance(result, str):
_bounded_text(result)
if isinstance(result, int) and result.bit_length() > MAX_STATIC_TEXT * 4:
raise ValueError("static integer exceeds revision policy limit")
return result
if isinstance(node, ast.JoinedStr):
result = _static_key(node, depth + 1)
return _UNRESOLVED if result is None else result
return _UNRESOLVED
def _validate_format_spec(format_spec: str) -> None:
if len(format_spec) > MAX_FORMAT_SPEC:
raise ValueError("static format specification exceeds revision policy limit")
for digits in re.findall(r"[0-9]+", format_spec):
if len(digits) > 6 or int(digits) > MAX_STATIC_TEXT:
raise ValueError("static format width or precision exceeds revision policy limit")
def _static_key(node: ast.expr, depth: int = 0) -> str | None:
if depth > MAX_STATIC_DEPTH:
raise ValueError("static key nesting exceeds revision policy limit")
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return _bounded_text(node.value)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
left = _static_key(node.left, depth + 1)
right = _static_key(node.right, depth + 1)
return None if left is None or right is None else _bounded_text(left + right)
if isinstance(node, ast.JoinedStr):
pieces = []
length = 0
for value in node.values:
if isinstance(value, ast.Constant) and isinstance(value.value, str):
piece = value.value
elif isinstance(value, ast.FormattedValue):
scalar = _static_scalar(value.value, depth + 1)
if scalar is _UNRESOLVED:
return None
format_spec = "" if value.format_spec is None else _static_key(value.format_spec, depth + 1)
if format_spec is None:
return None
_validate_format_spec(format_spec)
try:
if value.conversion == ord("s"):
scalar = str(scalar)
elif value.conversion == ord("r"):
scalar = repr(scalar)
elif value.conversion == ord("a"):
scalar = ascii(scalar)
elif value.conversion != -1:
return None
piece = format(scalar, format_spec)
except (TypeError, ValueError):
return None
else:
return None
length += len(piece)
if length > MAX_STATIC_TEXT:
raise ValueError("static formatted key exceeds revision policy limit")
pieces.append(piece)
return "".join(pieces)
return None
def _is_revision_expr(node: ast.expr) -> bool:
if isinstance(node, ast.Name):
return node.id in TARGETS
if isinstance(node, ast.Attribute):
return node.attr in TARGETS
if isinstance(node, ast.Subscript):
return _static_key(node.slice) in TARGETS
return False
def _is_state_access(node: ast.AST) -> bool:
if isinstance(node, ast.Attribute):
return node.attr == "state" and _is_revision_expr(node.value)
if isinstance(node, ast.Subscript):
return _static_key(node.slice) == "state" and _is_revision_expr(node.value)
return False
def _static_sequence(node: ast.expr) -> list[ast.expr] | None:
if not isinstance(node, (ast.List, ast.Tuple)):
return None
result: list[ast.expr] = []
for element in node.elts:
if isinstance(element, ast.Starred):
nested = _static_sequence(element.value)
if nested is None:
return None
result.extend(nested)
else:
result.append(element)
return result
def _static_mapping(node: ast.expr) -> dict[str, ast.expr] | None:
if not isinstance(node, ast.Dict):
return None
result: dict[str, ast.expr] = {}
for key, value in zip(node.keys, node.values, strict=True):
if key is None:
nested = _static_mapping(value)
if nested is None:
return None
result.update(nested)
elif (name := _static_key(key)) is not None:
result[name] = value
else:
return None
return result
def _format_bindings(call: ast.Call, method: str) -> dict[str | int, ast.expr]:
if method == "format":
bindings: dict[str | int, ast.expr] = {}
position = 0
positional_known = True
for argument in call.args:
if isinstance(argument, ast.Starred):
expanded = _static_sequence(argument.value)
if expanded is None:
positional_known = False
continue
if positional_known:
for value in expanded:
bindings[position] = value
position += 1
elif positional_known:
bindings[position] = argument
position += 1
for keyword in call.keywords:
if keyword.arg is not None:
# An explicit keyword remains bound even beside **dynamic; a duplicate is TypeError.
bindings[keyword.arg] = keyword.value
else:
expanded = _static_mapping(keyword.value)
if expanded is not None:
bindings.update(expanded)
return bindings
if len(call.args) != 1 or call.keywords:
return {}
return _static_mapping(call.args[0]) or {}
def _field_accesses_state(
field_name: str, bindings: dict[str | int, ast.expr], automatic_index: int | None = None
) -> bool:
root_match = re.match(r"(?:[0-9]+|[A-Za-z_][A-Za-z0-9_]*)", field_name)
if root_match is None:
if automatic_index is None or not field_name.startswith((".", "[")):
return False
root: str | int = automatic_index
cursor = 0
else:
root_text = root_match.group(0)
root = int(root_text) if root_text.isdigit() else root_text
cursor = root_match.end()
steps: list[tuple[bool, str]] = []
while cursor < len(field_name):
if field_name[cursor] == ".":
match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", field_name[cursor + 1 :])
if match is None:
return False
steps.append((True, match.group(0)))
cursor += len(match.group(0)) + 1
elif field_name[cursor] == "[":
close = field_name.find("]", cursor + 1)
if close < 0:
return False
steps.append((False, field_name[cursor + 1 : close]))
cursor = close + 1
else:
return False
if steps:
first_step = str(steps[0][1])
if str(root) in TARGETS and first_step == "state":
return True
bound = bindings.get(root)
if bound is not None and _is_revision_expr(bound) and first_step == "state":
return True
names = [str(root), *(str(key) for _is_attr, key in steps)]
return any(left in TARGETS and right == "state" for left, right in pairwise(names))
def _format_call_violation(node: ast.Call) -> bool:
function = node.func
if not isinstance(function, ast.Attribute) or function.attr not in {"format", "format_map"}:
return False
if not isinstance(function.value, ast.Constant) or not isinstance(function.value.value, str):
return False
bindings = _format_bindings(node, function.attr)
numbering: dict[str, int | str | None] = {"next": 0, "mode": None}
visited = 0
def analyze_template(template: str) -> bool:
nonlocal visited
visited += 1
if visited > 1_000:
raise ValueError("format specification nesting exceeds policy limit")
for _literal, field_name, format_spec, _conversion in _FORMATTER.parse(template):
automatic_index = None
if field_name is not None:
root_match = re.match(r"(?:[0-9]+|[A-Za-z_][A-Za-z0-9_]*)", field_name)
automatic = field_name == "" or root_match is None and field_name.startswith((".", "["))
manual = root_match is not None and root_match.group(0).isdigit()
if automatic:
if numbering["mode"] == "manual":
raise ValueError("cannot switch from manual to automatic field numbering")
numbering["mode"] = "automatic"
automatic_index = int(numbering["next"])
numbering["next"] = automatic_index + 1
elif manual:
if numbering["mode"] == "automatic":
raise ValueError("cannot switch from automatic to manual field numbering")
numbering["mode"] = "manual"
if _field_accesses_state(field_name, bindings, automatic_index):
return True
if format_spec and analyze_template(format_spec):
return True
return False
return analyze_template(function.value.value)
def has_revision_state(source: str, label: str = "<unknown>") -> bool:
tree = ast.parse(source, filename=label, mode="exec")
return any(_is_state_access(node) or (isinstance(node, ast.Call) and _format_call_violation(node)) for node in ast.walk(tree))
def analyze_batch(records: Any) -> list[str]:
if not isinstance(records, list):
raise TypeError("input must be a JSON array")
violations = []
for record in records:
if not isinstance(record, dict) or set(record) != {"label", "source"}:
raise TypeError("each record must contain exactly label and source")
label, source = record["label"], record["source"]
if not isinstance(label, str) or not isinstance(source, str):
raise TypeError("label and source must be strings")
if has_revision_state(source, label):
violations.append(label)
return violations
def main() -> int:
try:
records = json.load(sys.stdin)
json.dump({"violations": analyze_batch(records)}, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
return 0
except Exception as error: # noqa: BLE001 - protocol boundary must fail closed
print(f"python revision-state helper failed: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,90 @@
import importlib.util
import tracemalloc
import unittest
from pathlib import Path
from unittest.mock import patch
_HELPER = Path(__file__).with_name("revision_state_policy.py")
_SPEC = importlib.util.spec_from_file_location("revision_state_policy", _HELPER)
assert _SPEC is not None and _SPEC.loader is not None
_MODULE = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_MODULE)
analyze_batch = _MODULE.analyze_batch
has_revision_state = _MODULE.has_revision_state
class RevisionStatePolicyTests(unittest.TestCase):
def test_ast_access_and_f_strings(self):
for source in (
"old = revision.state",
'old = workspaceRevision["state"]',
'old = record["selectedWorkspace"].state',
'old = f"{revision.state}"',
'old = revision["st" + "ate"]',
'old = record["revi" + "sion"].state',
'old = revision[f"state"]',
'old = record[f"revision"].state',
"old = revision[f\"st{'a'}te\"]",
"old = revision[f\"{'state'}\"]",
"old = record[f\"revi{'sion'}\"].state",
"old = revision[f\"{'st' + 'ate'}\"]",
"old = record[f\"{'revi' + 'sion'}\"].state",
"old = revision[f\"{'state':s}\"]",
):
with self.subTest(source=source):
self.assertTrue(has_revision_state(source))
def test_static_format_bindings(self):
for source in (
'"{0.state}".format(revision)',
'"{0[state]}".format(workspaceRevision)',
'"{item.state}".format(item=selectedWorkspace)',
'"{item[state]}".format_map({"item": revision})',
'("{0.state}").format(revision)',
'"{0:{1.state}}".format(value, revision)',
'"{0.state}".format(*[revision])',
'"{0[state]}".format(*(revision,))',
'"{1[state]}".format(*[other, workspaceRevision])',
'"{item.state}".format(**{"item": selectedWorkspace})',
'"{item[state]}".format(**{"outer": other, **{"item": revision}})',
'"{item.state}".format_map({**{"item": workspaceRevision}})',
'"{.state}".format(revision)',
'"{[state]}".format(revision)',
'"{:{.state}}".format(value, revision)',
'"{.name} {[state]}".format(other, revision)',
'"{item.state}".format(item=revision, **values)',
):
with self.subTest(source=source):
self.assertTrue(has_revision_state(source))
self.assertFalse(has_revision_state('"{0.state}".format(other)'))
# Dynamic unpacking is intentionally unresolved rather than guessed.
self.assertFalse(has_revision_state('"{0.state}".format(*values)'))
self.assertFalse(has_revision_state('"{.name} {[state]}".format(other, other)'))
self.assertFalse(has_revision_state('"{item.state}".format(**values)'))
# FormattedValue keys are dynamic and are not treated as static strings.
self.assertFalse(has_revision_state('revision[f"st{suffix}"]'))
def test_literals_are_not_active(self):
self.assertFalse(has_revision_state('text = "{revision.state}"'))
self.assertFalse(has_revision_state('text = "{{revision.state}}".format(value)'))
def test_oversized_static_format_fails_before_formatting(self):
tracemalloc.start()
with patch("builtins.format") as format_mock:
with self.assertRaisesRegex(ValueError, "width or precision"):
has_revision_state('revision[f"{1:.1000000000f}"]')
format_mock.assert_not_called()
_current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
self.assertLess(peak, 1_000_000)
self.assertFalse(has_revision_state('revision[f"{1:04d}"]'))
def test_batch_contract(self):
self.assertEqual(
analyze_batch([{"label": "one.py", "source": "revision.state"}]),
["one.py"],
)
if __name__ == "__main__":
unittest.main()
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { lstat, readFile, realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { isMap, isScalar, parseAllDocuments } from "yaml";
import { extractBashDocuments } from "./bash-heredoc.mjs";
import { validatePythonRevisionStates, validateRevisionState } from "./revision-state-policy.mjs";
import { parseWorkspaceYaml } from "../dist/workspaces/schema.js";
const scriptPath = fileURLToPath(import.meta.url);
const allowedKinds = new Set(["policy_text", "workspace_descriptor", "deployment_script"]);
// Exact-content trust exceptions. Each digest covers the raw UTF-8 bytes from the
// opener line through the closer line (including physical line endings). These
// blocks are reviewed non-workspace runtime/config generation, not semantic proof.
const reviewedExpandableBlocks = new Map([
["scripts/preprocess-smoke.sh", [
{ sha256: "fc530dc721c946644ab6552bbd46b7918d6c5f11f06f3495b6ea1fcda819b38d", rationale: "Generates the reviewed preprocess Compose override." },
]],
["scripts/test-server-pi-state-topology.sh", [
{ sha256: "6f746f7e8442b0a6ea0e216607a6a923d94b24cd8fa17fa2d1dac56e6f14f7ef", rationale: "Generates the isolated server topology test environment." },
]],
["scripts/test-vector-backup-restore-safety.sh", [
{ sha256: "40b8a10a3c06aaa98e324fbf688b7d1f5cead330d7ba7eef98e06256d412a85a", rationale: "Generates the reviewed restore safety manifest." },
]],
["scripts/test-windows-clone-contract.ps1", [
{ sha256: "80f4880576a0679cb58e7b92600e7a90550c93c254553a2d4b299539f9ff0bcf", rationale: "Generates reviewed Windows clone test configuration." },
{ sha256: "6166294bdc8a8bf6436ad402bcbf7cae0f3b67dc6051cecfcca79267a62b082c", rationale: "Same reviewed block in the repository-required CRLF checkout representation." },
{ sha256: "3216201d59400ed7d1ec23e536634b8235a2e78b336e45b4dc598624920f0057", rationale: "Generates reviewed Windows clone test configuration." },
{ sha256: "a4044bb38b27e8120e90d65a0695fe0afd7757c067ae8dd67f170edf569a1de0", rationale: "Same reviewed block in the repository-required CRLF checkout representation." },
{ sha256: "5d0d1a3fc45e99b3aacaf4ee5dd09a6bee1937784375dfe4bcfaa4ae32cfb9de", rationale: "Generates reviewed Windows clone test configuration." },
{ sha256: "b903e5dae953ae1372f1a5276f12a92ed3dd632b897f3afe5e00c646d90a1b42", rationale: "Same reviewed block in the repository-required CRLF checkout representation." },
]],
["scripts/unified-deployment-smoke.sh", [
{ sha256: "ca0c17d9ff8dc0fbe018fc1c5510eb33bc667a936fbe44a9be2d311390576825", rationale: "Generates reviewed Task 13 runtime configuration." },
{ sha256: "31ec00cc315b52da4a3bb6e3fba2d40aef29cdcd090bbc5d14c31f1aebbcfd04", rationale: "Generates reviewed Task 13 runtime configuration." },
{ sha256: "d92822815357ce3424e1a6eb43923df2b37b4fd93a3b5465ee9dfc69559ab0ed", rationale: "Generates reviewed Task 13 runtime configuration." },
{ sha256: "d6b8b7b951936c0452a485e9ee3b18a61251556581d6f7a2ce66f994b5700695", rationale: "Generates reviewed Task 13 runtime configuration." },
]],
["scripts/vector-backup.sh", [
{ sha256: "571899db49dfdcec8107fbe1e0a86a61e7581979d3c4c248c20546843e275bcf", rationale: "Generates the reviewed backup manifest inside the helper command." },
]],
["scripts/vector-restore.sh", [
{ sha256: "f04d872e556a7323583c6e620b25814fb6a8e2568a9a555623978185b473a49d", rationale: "Feeds reviewed parsed manifest values to read loops." },
{ sha256: "c6053ed44abae71ae4821b68f9a513f8070947350e30d89ae0f65bf4a48f66fd", rationale: "Feeds reviewed parsed manifest values to read loops." },
]],
]);
function blockDigest(rawBlock) {
return createHash("sha256").update(rawBlock, "utf8").digest("hex");
}
function reviewedExpandableBlock(path, rawBlock) {
const digest = blockDigest(rawBlock);
return (reviewedExpandableBlocks.get(path) ?? []).some((review) => review.sha256 === digest);
}
function hasAmbiguousExpansion(source, path) {
const powershell = path.endsWith(".ps1");
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (powershell && character === "`") {
index += 1;
continue;
}
if (!powershell && character === "\\") {
index += 1;
continue;
}
if (character === "$" || (!powershell && character === "`")) return true;
}
return false;
}
function physicalLines(source) {
const rawLines = source.match(/[^\n]*\n|[^\n]+$/gu) ?? [];
if (rawLines.length === 0) rawLines.push("");
return rawLines.map((raw) => ({ raw, text: raw.replace(/\n$/u, "").replace(/\r$/u, "") }));
}
const prescribedSymbols = [
"WorkspaceV1", "WorkspaceV2", "DeprecatedV2Descriptor", "LegacyMigrationResult",
"LegacyMigrationOptions", "WorkspaceV2MigrationInput", "migrateLegacyWorkspace",
"writeMigratedWorkspace", "migrateWorkspaceV1ToV2", "migrateWorkspaceV2ToV3",
];
const migrationMarkers = ["migration_required", "deprecated-v2-descriptor", "migrate-legacy", "migrate-v2-qdrant"];
function isPolicyImplementationException(label, category) {
const implementations = new Set([
"scripts/verify-schema-v3-only.sh",
"scripts/test-verify-schema-v3-only.sh",
"backend/scripts/verify-workspace-descriptor-files.mjs",
"backend/scripts/verify-workspace-descriptor-files.test.mjs",
"backend/scripts/revision-state-policy.mjs",
"backend/scripts/revision-state-policy.test.mjs",
"backend/scripts/bash-heredoc.mjs",
"backend/scripts/revision_state_policy.py",
"backend/scripts/test_revision_state_policy.py",
]);
if (implementations.has(label)) return true;
if (category === "migration-marker" && new Set([
"scripts/workspace_descriptor_doc_contract.py",
"scripts/test_workspace_descriptor_doc_contract.py",
"backend/scripts/clean-dist.test.mjs",
]).has(label)) return true;
return false;
}
function validatePolicySource(source, label) {
if (!isPolicyImplementationException(label, "prescribed-symbol")) {
for (const symbol of prescribedSymbols) {
if (source.toLowerCase().includes(symbol.toLowerCase())) throw new Error(`${label}: forbidden prescribed-symbol substring: ${symbol}`);
}
}
if (!isPolicyImplementationException(label, "migration-marker")) {
for (const marker of migrationMarkers) {
if (source.toLowerCase().includes(marker.toLowerCase())) throw new Error(`${label}: forbidden migration-marker substring: ${marker}`);
}
}
if (!isPolicyImplementationException(label, "legacy-workspace")) {
for (const match of source.matchAll(/legacyworkspace/giu)) {
if (match[0] !== "legacyWorkspace") throw new Error(`${label}: forbidden legacy-workspace spelling: ${match[0]}`);
}
}
if (!/\.pyw?$/iu.test(label) && !isPolicyImplementationException(label, "revision-state")) validateRevisionState(source, label);
}
function documentShape(document) {
const shape = { workspacePresent: false, workspaceMapping: false };
if (!isMap(document.contents)) return shape;
for (const pair of document.contents.items) {
if (!isScalar(pair.key)) continue;
if (pair.key.value === "workspace") {
shape.workspacePresent = true;
if (isMap(pair.value)) shape.workspaceMapping = true;
}
}
return shape;
}
function documents(source) {
try {
return parseAllDocuments(source, { uniqueKeys: true });
} catch (error) {
throw new Error(`YAML parser failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
function validateWorkspaceSource(source, label, { requireWorkspace, expandable = false, path, rawBlock }) {
const parsed = documents(source);
const shapes = parsed.map(documentShape);
if (requireWorkspace) {
if (!shapes.some((shape) => shape.workspacePresent)) {
throw new Error(`${label}: expected a top-level workspace mapping`);
}
if (!shapes.some((shape) => shape.workspaceMapping)) {
throw new Error(`${label}: top-level workspace must be a mapping`);
}
} else {
if (expandable && hasAmbiguousExpansion(source, path) && !reviewedExpandableBlock(path, rawBlock)) {
throw new Error(`${label}: expandable block interpolation is not in the exact-content reviewed allowlist`);
}
if (shapes.some((shape) => shape.workspaceMapping)) {
throw new Error(`${label}: embedded workspace descriptor is forbidden; use a tracked workspace fixture`);
}
return false;
}
try {
parseWorkspaceYaml(source);
} catch (error) {
throw new Error(`${label}: workspace descriptor is not valid schema v3: ${error instanceof Error ? error.message : String(error)}`);
}
return true;
}
function deploymentScriptDialect(path) {
if (path.endsWith(".sh")) return "bash";
if (path.endsWith(".ps1")) return "powershell";
throw new Error(`${path}: unknown deployment script dialect`);
}
function powerShellHereStringOpener(line, state) {
let quote = null;
for (let index = 0; index < line.length; index += 1) {
if (state.blockComment) {
const close = line.indexOf("#>", index);
if (close < 0) return null;
state.blockComment = false;
index = close + 1;
continue;
}
const character = line[index];
if (quote === null && character === "`") {
index += 1;
continue;
}
if (quote === "'") {
if (character === "'" && line[index + 1] === "'") index += 1;
else if (character === "'") quote = null;
continue;
}
if (quote === '"') {
if (character === "`") index += 1;
else if (character === '"') quote = null;
continue;
}
if (character === "#") return null;
if (character === "<" && line[index + 1] === "#") {
state.blockComment = true;
index += 1;
continue;
}
if (character === "@" && (line[index + 1] === "'" || line[index + 1] === '"') && /^[ \t]*$/u.test(line.slice(index + 2))) return line[index + 1];
if (character === "'" || character === '"') quote = character;
}
return null;
}
function extractPowerShellDocuments(source, label) {
const records = physicalLines(source);
const lines = records.map((record) => record.text);
const extracted = [];
const state = { blockComment: false };
for (let index = 0; index < lines.length; index += 1) {
const quote = powerShellHereStringOpener(lines[index], state);
if (quote === null) continue;
const delimiter = `${quote}@`;
const opener = index;
const body = [];
const start = index + 2;
let closed = false;
for (index += 1; index < lines.length; index += 1) {
if (lines[index].trimEnd() === delimiter) {
closed = true;
break;
}
body.push(lines[index]);
}
extracted.push({
source: `${body.join("\n")}\n`,
label: `${label}:${start} PowerShell here-string${closed ? "" : " (unclosed)"}`,
expandable: quote === '"',
path: label,
rawBlock: records.slice(opener, Math.min(index + 1, records.length)).map((record) => record.raw).join(""),
});
}
return extracted;
}
export function extractScriptDocuments(source, label = "deployment script") {
const dialect = deploymentScriptDialect(label);
if (dialect === "bash") return extractBashDocuments(source, label);
return extractPowerShellDocuments(source, label);
}
async function safeFile(root, path) {
if (typeof path !== "string" || path.length === 0 || isAbsolute(path) || path.includes("\\")) {
throw new Error(`unsafe verifier path: ${JSON.stringify(path)}`);
}
const segments = path.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new Error(`unsafe verifier path: ${JSON.stringify(path)}`);
}
const absolute = resolve(root, ...segments);
const fromRoot = relative(root, absolute);
if (fromRoot.startsWith(`..${sep}`) || fromRoot === ".." || isAbsolute(fromRoot)) {
throw new Error(`verifier path escapes root: ${JSON.stringify(path)}`);
}
const entry = await lstat(absolute);
if (!entry.isFile() || entry.isSymbolicLink()) {
throw new Error(`verifier input is not a regular file: ${path}`);
}
const canonical = await realpath(absolute);
const canonicalRelative = relative(root, canonical);
if (canonicalRelative.startsWith(`..${sep}`) || canonicalRelative === ".." || isAbsolute(canonicalRelative)) {
throw new Error(`verifier input resolves outside root: ${path}`);
}
return absolute;
}
export async function verifyEntries({ root, entries }) {
const canonicalRoot = await realpath(root);
const seen = new Set();
const pythonPolicies = [];
for (const entry of entries) {
if (!entry || !allowedKinds.has(entry.kind) || typeof entry.path !== "string") {
throw new Error("workspace verifier manifest contains an invalid entry");
}
const identity = `${entry.kind}\0${entry.path}`;
if (seen.has(identity)) throw new Error(`workspace verifier manifest duplicates: ${entry.path}`);
seen.add(identity);
const absolute = await safeFile(canonicalRoot, entry.path);
const bytes = await readFile(absolute);
let source;
try {
source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw new Error(`${entry.path}: input is not valid UTF-8`);
}
if (source.includes("\0")) throw new Error(`${entry.path}: NUL byte is forbidden`);
if (entry.kind === "policy_text") {
validatePolicySource(source, entry.path);
if (/\.pyw?$/iu.test(entry.path) && !isPolicyImplementationException(entry.path, "revision-state")) {
pythonPolicies.push({ label: entry.path, source });
}
continue;
}
if (entry.kind === "workspace_descriptor") {
validateWorkspaceSource(source, entry.path, { requireWorkspace: true });
continue;
}
for (const candidate of extractScriptDocuments(source, entry.path)) {
validateWorkspaceSource(candidate.source, candidate.label, {
requireWorkspace: false,
expandable: candidate.expandable,
path: entry.path,
rawBlock: candidate.rawBlock,
});
}
}
validatePythonRevisionStates(pythonPolicies);
}
export function decodeManifest(bytes) {
const fields = bytes.toString("utf8").split("\0");
if (fields.at(-1) !== "") throw new Error("workspace verifier manifest is not NUL-terminated");
fields.pop();
if (fields.length % 2 !== 0) throw new Error("workspace verifier manifest has an incomplete record");
const entries = [];
for (let index = 0; index < fields.length; index += 2) {
entries.push({ kind: fields[index], path: fields[index + 1] });
}
return entries;
}
function cliArguments(argv) {
let root;
let manifest;
for (let index = 0; index < argv.length; index += 1) {
const option = argv[index];
const value = argv[index + 1];
if ((option === "--root" || option === "--manifest") && value !== undefined) {
if (option === "--root" && root === undefined) root = value;
else if (option === "--manifest" && manifest === undefined) manifest = value;
else throw new Error(`duplicate or invalid option: ${option}`);
index += 1;
} else {
throw new Error(`unknown or incomplete option: ${option}`);
}
}
if (root === undefined || manifest === undefined) {
throw new Error("usage: verify-workspace-descriptor-files.mjs --root ROOT --manifest NUL_FILE");
}
return { root, manifest };
}
async function main(argv) {
const { root, manifest } = cliArguments(argv);
const manifestEntry = await lstat(manifest);
if (!manifestEntry.isFile() || manifestEntry.isSymbolicLink()) {
throw new Error("workspace verifier manifest is not a regular file");
}
const entries = decodeManifest(await readFile(manifest));
await verifyEntries({ root, entries });
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
main(process.argv.slice(2)).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
@@ -0,0 +1,992 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { extractScriptDocuments, verifyEntries } from "./verify-workspace-descriptor-files.mjs";
const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url));
const canonicalDescriptor = await readFile(join(repositoryRoot, "deploy/workspaces/example.yaml"), "utf8");
async function fixture(t) {
const root = await mkdtemp(join(tmpdir(), "thoth-workspace-yaml-verifier-"));
t.after(() => rm(root, { recursive: true, force: true }));
return root;
}
async function put(root, path, content) {
await mkdir(dirname(join(root, path)), { recursive: true });
await writeFile(join(root, path), content);
}
function entry(kind, path) {
return { kind, path };
}
function bashN(root, path) {
execFileSync("/bin/bash", ["-n", join(root, path)], { stdio: "pipe" });
}
function replaceWorkspaceKeys(source, workspaceKey, schemaLine) {
return source
.replace(/^workspace:$/m, workspaceKey)
.replace(/^ schema_version: 3$/m, schemaLine);
}
test("production parser accepts semantic v3 with quoted Unicode/tagged keys and spacing", async (t) => {
const root = await fixture(t);
const unicode = replaceWorkspaceKeys(
canonicalDescriptor,
'"\\u0077orkspace" :',
' "\\u0073chema_version" : 3',
);
const tagged = replaceWorkspaceKeys(
canonicalDescriptor,
"!!str workspace :",
" !!str schema_version : 3",
);
await put(root, "deploy/workspaces/unicode.yaml", unicode);
await put(root, "deploy/workspaces/tagged.yaml", tagged);
await verifyEntries({
root,
entries: [
entry("workspace_descriptor", "deploy/workspaces/unicode.yaml"),
entry("workspace_descriptor", "deploy/workspaces/tagged.yaml"),
],
});
});
test("production parser rejects fancy keys with every non-v3 or ambiguous value", async (t) => {
const invalid = [
["unicode-v2", '"\\u0077orkspace" :', ' "\\u0073chema_version" : 2'],
["tagged-leading-zero", "!!str workspace :", " !!str schema_version : 02"],
["hexadecimal", "workspace :", " schema_version : 0x2"],
["multiline", "workspace :", " schema_version : >\n 3"],
["duplicate", "workspace :", " schema_version : 3\n schema_version: 3"],
["inline", "workspace: { schema_version: 3 }", " schema_version: 3"],
];
for (const [name, workspaceKey, schemaLine] of invalid) {
await t.test(name, async () => {
const root = await mkdtemp(join(tmpdir(), `thoth-workspace-yaml-${name}-`));
try {
const source = replaceWorkspaceKeys(canonicalDescriptor, workspaceKey, schemaLine);
const path = `deploy/workspaces/${name}.yaml`;
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("workspace_descriptor", path)] }),
/workspace descriptor/i,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
});
test("Bash embedded workspace mappings are rejected while tracked-fixture-only bundles pass", async (t) => {
const root = await fixture(t);
const validScript = [
"#!/usr/bin/env bash",
"cat <<'WORKSPACE_YAML'",
canonicalDescriptor.trimEnd(),
"WORKSPACE_YAML",
"cat <<'BUNDLE_YAML'",
"bundle:",
" name: deploy",
"schema_version: 1",
"job:",
" state: operational",
"BUNDLE_YAML",
"",
].join("\n");
await put(root, "scripts/operator-smoke.sh", validScript);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", "scripts/operator-smoke.sh")] }),
/embedded workspace descriptor/i,
);
const bundleScript = validScript.replace(canonicalDescriptor.trimEnd(), "job:\n name: deploy");
await put(root, "scripts/operator-smoke.sh", bundleScript);
await verifyEntries({
root,
entries: [entry("deployment_script", "scripts/operator-smoke.sh")],
});
});
test("PowerShell embedded workspace mappings are rejected while bundle-only strings pass", async (t) => {
const root = await fixture(t);
const source = [
"$workspace = @'",
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 0x2").trimEnd(),
"'@",
'$bundle = @"',
"bundle:",
" schema_version: 1",
'"@',
"",
].join("\n");
await put(root, "scripts/operator.ps1", source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", "scripts/operator.ps1")] }),
/workspace descriptor/i,
);
});
test("workspace descriptor family entries require a top-level workspace", async (t) => {
const root = await fixture(t);
await put(root, "scripts/fixtures/workspace-registry-future.yaml", "bundle:\n schema_version: 3\n");
await assert.rejects(
verifyEntries({
root,
entries: [entry("workspace_descriptor", "scripts/fixtures/workspace-registry-future.yaml")],
}),
/top-level workspace/i,
);
});
test("script scalar workspace remains a bundle even with descriptor-like siblings", async (t) => {
const root = await fixture(t);
const path = "scripts/job-smoke.sh";
const job = [
"#!/usr/bin/env bash",
"cat <<'JOB-YAML'",
"job: refresh",
"workspace: analytics",
"schema_version: 2",
"state: operational",
"JOB-YAML",
"",
].join("\n");
await put(root, path, job);
bashN(root, path);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
const bundles = [
job.replace("job: refresh", "dwh:\n engine: postgres"),
job.replace("job: refresh", "evidence:\n source: bundle"),
];
for (const bundle of bundles) {
await put(root, path, bundle);
bashN(root, path);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
}
});
test("standalone descriptor files require workspace to be a mapping", async (t) => {
const root = await fixture(t);
const path = "scripts/fixtures/workspace-registry-scalar.yaml";
await put(root, path, "workspace: analytics\nschema_version: 3\n");
await assert.rejects(
verifyEntries({ root, entries: [entry("workspace_descriptor", path)] }),
/workspace.*mapping/i,
);
});
test("Bash extractor supports hyphen, digit, escaped delimiters, and tab stripping", async (t) => {
const root = await fixture(t);
const cases = [
{
name: "hyphen-v2",
opener: "cat <<'WORKSPACE-YAML'",
delimiter: "WORKSPACE-YAML",
descriptor: canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2"),
rejected: true,
},
{
name: "digit-v3",
opener: "cat <<2YAML",
delimiter: "2YAML",
descriptor: canonicalDescriptor,
rejected: true,
},
{
name: "escaped-v2",
opener: "cat <<WORKSPACE\\-YAML",
delimiter: "WORKSPACE-YAML",
descriptor: canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2"),
rejected: true,
},
{
name: "tab-strip-v3",
opener: "cat <<-'TAB-YAML'",
delimiter: "\tTAB-YAML",
descriptor: canonicalDescriptor.split("\n").map((line) => `\t${line}`).join("\n"),
rejected: true,
},
];
for (const item of cases) {
await t.test(item.name, async () => {
const path = `scripts/${item.name}-smoke.sh`;
const source = ["#!/usr/bin/env bash", item.opener, item.descriptor.trimEnd(), item.delimiter, ""].join("\n");
await put(root, path, source);
bashN(root, path);
const verification = verifyEntries({ root, entries: [entry("deployment_script", path)] });
if (item.rejected) await assert.rejects(verification, /workspace descriptor/i);
else await verification;
});
}
});
test("unsupported Bash heredoc opener fails closed while a bundle heredoc stays allowed", async (t) => {
const root = await fixture(t);
const unsupportedPath = "scripts/unsupported-smoke.sh";
const unsupported = [
"#!/usr/bin/env bash",
"cat <<$DELIMITER",
canonicalDescriptor.trimEnd(),
"$DELIMITER",
"",
].join("\n");
await put(root, unsupportedPath, unsupported);
bashN(root, unsupportedPath);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", unsupportedPath)] }),
/unsupported Bash heredoc opener/i,
);
const bundlePath = "scripts/bundle-smoke.sh";
const bundle = [
"#!/usr/bin/env bash",
"cat <<'BUNDLE-YAML'",
"job: refresh",
"workspace: analytics",
"schema_version: 1",
"state: operational",
"BUNDLE-YAML",
"",
].join("\n");
await put(root, bundlePath, bundle);
bashN(root, bundlePath);
await verifyEntries({ root, entries: [entry("deployment_script", bundlePath)] });
});
test("non-stripping heredoc close requires an exact physical delimiter line", async (t) => {
const root = await fixture(t);
const path = "scripts/trailing-close-smoke.sh";
const source = [
"#!/usr/bin/env bash",
"cat <<'---'",
"--- ",
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
"---",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/workspace descriptor/i,
);
});
test("delimiter-like body lines remain content until a real exact close", async (t) => {
const root = await fixture(t);
const path = "scripts/delimiter-content-smoke.sh";
const source = [
"#!/usr/bin/env bash",
"cat <<'END'",
"END ",
" END",
"job: refresh",
"workspace: analytics",
"schema_version: 1",
"END",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
const [candidate] = extractScriptDocuments(source, path);
assert.match(candidate.source, /^END \n END\n/u);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("double-quoted non-special backslash is preserved in the delimiter", async (t) => {
const root = await fixture(t);
const path = "scripts/double-quoted-nonspecial-smoke.sh";
const source = [
"#!/usr/bin/env bash",
'cat <<"\\---"',
"---",
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
"\\---",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
assert.match(execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }), /schema_version: 2/u);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/workspace descriptor/i,
);
});
test("double-quoted delimiter quote removal matches Bash special escapes", async (t) => {
const root = await fixture(t);
const cases = [
["dollar", 'cat <<"DOL\\$LAR"', "DOL$LAR"],
["backtick", 'cat <<"TIC\\`K"', "TIC`K"],
["quote", 'cat <<"QUO\\\"TE"', 'QUO"TE'],
["backslash", 'cat <<"SLA\\\\SH"', "SLA\\SH"],
["newline", 'cat <<"LINE\\\nBREAK"', "LINEBREAK"],
["nonspecial", 'cat <<"NON\\-SPECIAL"', "NON\\-SPECIAL"],
];
for (const [name, opener, close] of cases) {
const path = `scripts/double-quoted-${name}-smoke.sh`;
const source = ["#!/usr/bin/env bash", opener, "job: refresh", close, ""].join("\n");
await put(root, path, source);
bashN(root, path);
assert.equal(execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }), "job: refresh\n");
assert.equal(extractScriptDocuments(source, path)[0].source, "job: refresh\n");
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
}
});
test("split heredoc operator continuation cannot bypass v2 validation", async (t) => {
const root = await fixture(t);
const path = "scripts/split-operator-smoke.sh";
const source = [
"#!/usr/bin/env bash",
"cat <\\",
"<'YAML'",
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
"YAML",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
assert.match(execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }), /schema_version: 2/u);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/workspace descriptor/i,
);
});
test("multiple opener continuations are joined before heredoc discovery", async (t) => {
const root = await fixture(t);
const path = "scripts/multiple-continuation-smoke.sh";
const source = [
"#!/usr/bin/env bash",
"cat \\",
"<\\",
"<'YAML'",
"job: refresh",
"workspace: analytics",
"YAML",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
assert.equal(
execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }),
"job: refresh\nworkspace: analytics\n",
);
const [candidate] = extractScriptDocuments(source, path);
assert.equal(candidate.label, `${path}:5 Bash heredoc`);
assert.equal(candidate.source, "job: refresh\nworkspace: analytics\n");
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("backslash-newline inside single quotes is not removed", async (t) => {
const root = await fixture(t);
const path = "scripts/single-quoted-noncontinuation-smoke.sh";
const source = [
"#!/usr/bin/env bash",
"printf '%s' 'literal\\",
"continued'",
"cat <<'YAML'",
"job: refresh",
"workspace: analytics",
"YAML",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
assert.equal(
execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }),
"literal\\\ncontinuedjob: refresh\nworkspace: analytics\n",
);
const [candidate] = extractScriptDocuments(source, path);
assert.equal(candidate.label, `${path}:5 Bash heredoc`);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("PowerShell comment backslash cannot hide a following v2 here-string", async (t) => {
const root = await fixture(t);
const path = "scripts/powershell-comment-smoke.ps1";
const source = [
"# harmless PowerShell comment \\",
"$workspace = @'",
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
"'@",
"",
].join("\n");
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/workspace descriptor/i,
);
});
test("PowerShell dialect accepts normal v3 and non-workspace bundle here-strings", async (t) => {
const root = await fixture(t);
const path = "scripts/powershell-valid-smoke.ps1";
const source = [
"$workspace = @'",
canonicalDescriptor.trimEnd(),
"'@",
"$bundle = @'",
"evidence:",
" source: bundle",
"schema_version: 2",
"'@",
"",
].join("\n");
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/embedded workspace descriptor/i,
);
const bundleOnly = [
"$bundle = @'",
"evidence:",
" source: bundle",
"schema_version: 2",
"'@",
"",
].join("\n");
await put(root, path, bundleOnly);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("unknown deployment script dialect fails closed", async (t) => {
const root = await fixture(t);
const path = "scripts/operator-smoke.cmd";
await put(root, path, "echo harmless\n");
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/unknown deployment script dialect/i,
);
});
test("PowerShell cast and concatenation openers cannot hide embedded descriptors", async (t) => {
const root = await fixture(t);
for (const [name, opener] of [["cast", "[string]@'"], ["concat", "+@'"]]) {
const path = `scripts/powershell-${name}-smoke.ps1`;
const source = [opener, canonicalDescriptor.trimEnd(), "'@", ""].join("\n");
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/embedded workspace descriptor/i,
);
}
});
test("expandable YAML interpolation that can hide a workspace descriptor fails closed", async (t) => {
const root = await fixture(t);
const cases = [
["braced-key", "${key}:\n schema_version: 3"],
["plain-key", "$key:\n schema_version: 3"],
["quoted-key", '"$key" :\n schema_version: 3'],
["subexpression-key", "$($key):\n schema_version: 3"],
["version", "workspace:\n schema_version: $version"],
];
for (const [name, body] of cases) {
const path = `scripts/powershell-interpolation-${name}.ps1`;
await put(root, path, [`$yaml = @\"`, body, `\"@`, ""].join("\n"));
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/interpolation|embedded workspace descriptor/i,
);
}
});
test("Bash heredoc discovery ignores quoted, comment, here-string, and arithmetic tokens", async (t) => {
const root = await fixture(t);
const path = "scripts/bash-lexer-smoke.sh";
const source = [
"#!/usr/bin/env bash",
`printf '%s\\n' \"cat <<'QUOTED'\"`,
`printf '%s\\n' 'cat <<\"SINGLE\"'`,
"# cat <<'COMMENT'",
"value=$((1 << 2))",
`cat <<< \"not a heredoc\"`,
"cat <<'YAML'",
"job: refresh",
"YAML",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
const extracted = extractScriptDocuments(source, path);
assert.equal(extracted.length, 1);
assert.equal(extracted[0].source, "job: refresh\n");
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("UTF-8 decoding is fatal but literal replacement characters are valid text", async (t) => {
const root = await fixture(t);
const validPath = "deploy/workspaces/replacement.yaml";
await put(root, validPath, `${canonicalDescriptor}# literal replacement: �\n`);
await verifyEntries({ root, entries: [entry("workspace_descriptor", validPath)] });
const invalidPath = "deploy/workspaces/malformed.yaml";
await mkdir(dirname(join(root, invalidPath)), { recursive: true });
await writeFile(join(root, invalidPath), Buffer.concat([Buffer.from(canonicalDescriptor), Buffer.from([0xff])]));
await assert.rejects(
verifyEntries({ root, entries: [entry("workspace_descriptor", invalidPath)] }),
/valid UTF-8/i,
);
});
test("unmarked expandable Bash YAML cannot generate descriptor keys or values at runtime", async (t) => {
const root = await fixture(t);
const cases = [
["quoted", '"$key" :'],
["command", "$(printf workspace):"],
["braced", "${key}:"],
["plain", "$key:"],
];
for (const [name, generatedKey] of cases) {
const path = `scripts/bash-dynamic-${name}.sh`;
const source = [
"#!/usr/bin/env bash",
"key=workspace",
"cat <<YAML",
generatedKey,
" schema_version: 3",
"YAML",
"",
].join("\n");
await put(root, path, source);
bashN(root, path);
assert.match(execFileSync("/bin/bash", [join(root, path)], { encoding: "utf8" }), /workspace/u);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/exact-content reviewed allowlist/i,
);
}
const valuePath = "scripts/bash-dynamic-value.sh";
const valueSource = [
"#!/usr/bin/env bash",
"version=3",
"cat <<YAML",
"workspace:",
" schema_version: $version",
"YAML",
"",
].join("\n");
await put(root, valuePath, valueSource);
bashN(root, valuePath);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", valuePath)] }),
/exact-content reviewed allowlist/i,
);
});
test("an in-band marker cannot authorize expandable content", async (t) => {
const root = await fixture(t);
for (const [path, source] of [
["scripts/fake-marker.sh", [
"#!/usr/bin/env bash",
"# schema-v3-only: expandable-nonworkspace",
"cat <<YAML",
"${DESCRIPTOR}",
"YAML",
"",
].join("\n")],
["scripts/fake-marker.ps1", [
"# schema-v3-only: expandable-nonworkspace",
'$yaml = @"',
"$descriptor",
'"@',
"",
].join("\n")],
]) {
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/exact-content reviewed allowlist/,
);
}
});
test("current exact reviewed expandable blocks pass only at their trusted paths", async (t) => {
const reviewedPaths = [
"scripts/preprocess-smoke.sh",
"scripts/test-server-pi-state-topology.sh",
"scripts/test-vector-backup-restore-safety.sh",
"scripts/test-windows-clone-contract.ps1",
"scripts/unified-deployment-smoke.sh",
"scripts/vector-backup.sh",
"scripts/vector-restore.sh",
];
await verifyEntries({
root: repositoryRoot,
entries: reviewedPaths.map((path) => entry("deployment_script", path)),
});
const root = await fixture(t);
const original = await readFile(join(repositoryRoot, "scripts/preprocess-smoke.sh"), "utf8");
await put(root, "scripts/copied-preprocess.sh", original);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", "scripts/copied-preprocess.sh")] }),
/exact-content reviewed allowlist/,
);
await put(root, "scripts/preprocess-smoke.sh", original.replace('$tmp/smoke.yaml', '$tmp/other.yaml'));
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", "scripts/preprocess-smoke.sh")] }),
/exact-content reviewed allowlist/,
);
});
test("PowerShell tokenizer ignores opener text in comments and ordinary strings", async (t) => {
const root = await fixture(t);
const path = "scripts/powershell-lexical-context.ps1";
const source = [
"# example @'",
'\"example @\'\"',
"'example @\"'",
"<# block @'",
"still @\" #>",
"$cast = [string]@'",
"job: cast",
"'@",
"$concat = $cast +@'",
"job: concat",
"'@",
"",
].join("\n");
await put(root, path, source);
const extracted = extractScriptDocuments(source, path);
assert.equal(extracted.length, 2);
assert.deepEqual(extracted.map((item) => item.source), ["job: cast\n", "job: concat\n"]);
await verifyEntries({ root, entries: [entry("deployment_script", path)] });
});
test("policy text rejects NUL and prescribed symbol substrings but permits lower-camel legacy identifiers", async (t) => {
const root = await fixture(t);
await put(root, "backend/src/nul.ts", Buffer.from("safe\0WorkspaceV2"));
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", "backend/src/nul.ts")] }), /NUL byte/);
for (const [name, text] of [
["compat", "type X = WorkspaceV2Compat;"],
["mixed-prescribed", "type X = wOrKsPaCeV2;"],
["lower-deprecated", "type X = deprecatedV2Descriptor;"],
["upper-function", "WRITEMIGRATEDWORKSPACE(value);"],
["adapter", "type X = LegacyWorkspaceAdapter;"],
["lower", "type X = legacyworkspace;"],
["mixed", "type X = LeGaCyWoRkSpAcE;"],
]) {
const path = `backend/src/${name}.ts`;
await put(root, path, text);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /forbidden/);
}
await put(root, "backend/src/allowed.ts", "const legacyWorkspacePath = value;");
await verifyEntries({ root, entries: [entry("policy_text", "backend/src/allowed.ts")] });
});
test("revision-state structural scan permits only the exact historical decoder occurrence", async (t) => {
const root = await fixture(t);
const registry = "backend/src/workspaces/registry.ts";
await put(root, registry, 'if (revision.state !== "operational") return;\n');
await verifyEntries({ root, entries: [entry("policy_text", registry)] });
const variants = [
'if (revision.state !== "operational") return;\nif (revision["state"] === value) return;\n',
'if (workspaceRevision\n .state === value) return;\n',
"if (selectedWorkspace [ 'state' ] === value) return;\n",
];
for (let index = 0; index < variants.length; index += 1) {
const path = index === 0 ? registry : `frontend/src/revision-${index}.ts`;
await put(root, path, variants[index]);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/);
}
});
test("complete descriptors supplied only through Bash or PowerShell variables require exact review", async (t) => {
const root = await fixture(t);
const cases = [
["scripts/variable-descriptor.sh", ["#!/usr/bin/env bash", "cat <<YAML", "${DESCRIPTOR}", "YAML", ""].join("\n")],
["scripts/variable-descriptor.ps1", ['$yaml = @"', "$descriptor", '"@', ""].join("\n")],
];
for (const [path, source] of cases) {
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/exact-content reviewed allowlist/,
);
}
});
test("all Bash and PowerShell positional or special dollar expansions fail without exact review", async (t) => {
const root = await fixture(t);
const cases = [
["scripts/positional.sh", "cat <<YAML\n$1\nYAML\n"],
["scripts/all-args.sh", "cat <<YAML\n$@\nYAML\n"],
["scripts/positional.ps1", '$yaml = @"\n$1\n"@\n'],
];
for (const [path, source] of cases) {
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/exact-content reviewed allowlist/,
);
}
});
test("PowerShell backtick escapes hash and quote tokens without hiding a later real here-string", async (t) => {
const root = await fixture(t);
for (const [name, prefix] of [
["escaped-hash", "Write-Output `# harmless"],
["escaped-quote", 'Write-Output `" harmless'],
]) {
const path = `scripts/${name}.ps1`;
const source = [prefix, "$yaml = @'", "workspace:", " schema_version: 2", "'@", ""].join("\n");
await put(root, path, source);
assert.equal(extractScriptDocuments(source, path).length, 1);
await assert.rejects(
verifyEntries({ root, entries: [entry("deployment_script", path)] }),
/embedded workspace descriptor/,
);
}
});
test("TypeScript AST rejects comment-separated and destructured revision state", async (t) => {
const root = await fixture(t);
for (const [index, source] of [
"const value = revision /*legacy*/ . state;",
"const { state } = revision;",
"const { state: oldState } = selectedWorkspace;",
].entries()) {
const path = `frontend/src/ast-revision-${index}.ts`;
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/);
}
const registry = "backend/src/workspaces/registry.ts";
await put(root, registry, 'if (revision.state !== "operational") return;\nconst { state } = revision;\n');
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", registry)] }), /revision-state/);
await put(root, "backend/src/unrelated.ts", "const { state } = lease; const jobState = job.state;");
await verifyEntries({ root, entries: [entry("policy_text", "backend/src/unrelated.ts")] });
});
test("AST recognizes semantic state keys in every revision destructuring form", async (t) => {
const root = await fixture(t);
const cases = [
["backend/src/computed.mts", 'const { ["state"]: oldState } = revision;'],
["frontend/src/renamed.cts", 'const { "state": oldState = fallback } = workspaceRevision;'],
["backend/scripts/template.TS", 'const { [`state`]: oldState } = selectedWorkspace;'],
["scripts/parameter.txt", 'function read({ state: oldState = fallback } = revision) {}'],
["scripts/assignment.sh", '({ state } = workspaceRevision);'],
["scripts/computed-assignment.data", '({ ["state"]: oldState = fallback } = selectedWorkspace);'],
];
for (const [path, source] of cases) {
await put(root, path, source);
await assert.rejects(
verifyEntries({ root, entries: [entry("policy_text", path)] }),
/revision-state/,
path,
);
}
const registry = "backend/src/workspaces/registry.ts";
await put(root, registry, [
'if (revision.state !== "operational") return;',
'function read({ ["state"]: oldState } = revision) {}',
"",
].join("\n"));
await assert.rejects(
verifyEntries({ root, entries: [entry("policy_text", registry)] }),
/revision-state/,
);
});
test("tolerant all-suffix AST scan ignores strings/comments and unrelated state", async (t) => {
const root = await fixture(t);
const path = "scripts/arbitrary.weird";
await put(root, path, [
'// const { state } = revision;',
'"revision.state";',
"'({ [\\\"state\\\"]: oldState } = selectedWorkspace)';",
"const { state } = lease;",
"const jobState = job.state;",
"record.state = 'ready';",
"",
].join("\n"));
await verifyEntries({ root, entries: [entry("policy_text", path)] });
});
test("computed revision destructuring keys fold parentheses assertions templates and string concatenation", async (t) => {
const root = await fixture(t);
const cases = [
["backend/src/paren.ts", 'const { [("state")]: oldState } = revision;'],
["backend/src/concat.ts", 'const { ["st" + "ate"]: oldState } = workspaceRevision;'],
["frontend/src/template.ts", 'const { [`st${"ate"}`]: oldState } = selectedWorkspace;'],
["scripts/assertion.data", 'const { [("st" as string) + (`ate` satisfies string)]: oldState } = revision;'],
["scripts/assignment.txt", '({ ["st" + "ate"]: oldState } = selectedWorkspace);'],
];
for (const [path, source] of cases) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/, path);
}
const registry = "backend/src/workspaces/registry.ts";
for (const injected of [
'const { [("state")]: oldState } = revision;',
'({ ["st" + "ate"]: oldState } = revision);',
]) {
await put(root, registry, `if (revision.state !== "operational") return;\n${injected}\n`);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", registry)] }), /revision-state/);
}
});
test("polyglot masking and JSX syntax prevent comment and string false positives", async (t) => {
const root = await fixture(t);
const passing = [
["backend/scripts/comment.py", '# revision.state\nvalue = "revision.state"\ntext = """selectedWorkspace.state"""\n'],
["scripts/comment.ps1", '# revision.state\n<# workspaceRevision.state #>\n$value = "revision.state"\n'],
["scripts/comment.sh", '# revision.state\nprintf \'%s\\n\' "selectedWorkspace.state"\n'],
["frontend/src/content.tsx", 'export const view = <div>revision.state</div>;'],
["frontend/src/attribute.tsx", 'export const view = <div title="revision.state" />;'],
["frontend/src/expression.tsx", 'export const view = <div>{"revision.state"}</div>;'],
["scripts/arbitrary.data", 'title: "revision.state"\n# const { state } = revision\nlease:\n state: ready\n'],
];
for (const [path, source] of passing) {
await put(root, path, source);
await verifyEntries({ root, entries: [entry("policy_text", path)] });
}
for (const [path, source] of [
["scripts/code.txt", "const { state } = revision;"],
["scripts/code.data", '({ ["st" + "ate"]: oldState } = workspaceRevision);'],
]) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/);
}
});
test("rest bindings and dynamic computed keys are not semantic state-property access", async (t) => {
const root = await fixture(t);
const cases = [
["backend/src/rest.ts", "const { ...state } = revision;"],
["frontend/src/renamed.ts", "const { other: state } = workspaceRevision;"],
["scripts/dynamic.txt", "const { [state]: value } = selectedWorkspace;"],
["scripts/dynamic-assignment.data", "({ [state]: value } = revision);"],
["scripts/spread-assignment.data", "({ ...state } = workspaceRevision);"],
];
for (const [path, source] of cases) {
await put(root, path, source);
await verifyEntries({ root, entries: [entry("policy_text", path)] });
}
});
test("polyglot code remains structural across shell Python PowerShell YAML TSX and JSX", async (t) => {
const root = await fixture(t);
const failing = [
["scripts/code.sh", "value=revision.state\n"],
["scripts/code.ps1", "$value = workspaceRevision.state\n"],
["backend/scripts/code.py", "value = selectedWorkspace.state\n"],
["scripts/code.yaml", "value: revision.state\n"],
["frontend/src/code.tsx", "export const view = <div>{revision.state}</div>;"],
["frontend/src/code.jsx", "export const view = <div>{workspaceRevision.state}</div>;"],
];
for (const [path, source] of failing) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/, path);
}
});
test("PowerShell executable subexpressions expose dollar-prefixed revision access", async (t) => {
const root = await fixture(t);
const failing = [
["scripts/ps-property.ps1", 'Write-Output "revision: $($revision.state)"\n'],
["scripts/ps-element.ps1", 'Write-Output "$($workspaceRevision[\'state\'])"\n'],
["scripts/ps-workspace.ps1", '$value = $workspaceRevision.state\n'],
["scripts/ps-nested.ps1", 'Write-Output "$($($revision.state))"\n'],
];
for (const [path, source] of failing) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/, path);
}
const passing = [
'# $revision.state\nWrite-Output "revision.state"\n',
"Write-Output '$selectedWorkspace[\"state\"]'\n",
];
for (let index = 0; index < passing.length; index += 1) {
const path = `scripts/ps-literal-${index}.ps1`;
await put(root, path, passing[index]);
await verifyEntries({ root, entries: [entry("policy_text", path)] });
}
});
test("Python f-string fields expose revision access while literal text remains masked", async (t) => {
const root = await fixture(t);
const failing = [
["backend/scripts/f-property.py", 'value = f"{revision.state}"\n'],
["backend/scripts/fr-element.py", 'value = fr"{workspaceRevision[\'state\']}"\n'],
["backend/scripts/rf-element.py", 'value = rf"prefix {selectedWorkspace[\"state\"]}"\n'],
];
for (const [path, source] of failing) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/, path);
}
const passing = [
'value = f"revision.state"\n',
'value = f"{{revision.state}}"\n',
'value = "revision.state"\n',
'value = r"workspaceRevision.state"\n',
'value = """selectedWorkspace.state"""\n',
'value = r"""revision.state"""\n',
];
for (let index = 0; index < passing.length; index += 1) {
const path = `backend/scripts/python-literal-${index}.py`;
await put(root, path, passing[index]);
await verifyEntries({ root, entries: [entry("policy_text", path)] });
}
});
test("Bash masking preserves parameter trimming and executable command consumers", async (t) => {
const root = await fixture(t);
const failing = [
["scripts/trim.sh", "trimmed=${value#prefix}; old=revision.state\n"],
["scripts/base.sh", "base=${path##*/}; old=workspaceRevision.state\n"],
["scripts/backtick.sh", "old=`echo revision.state`\n"],
["scripts/quoted-backtick.sh", 'echo "old: `echo revision.state`"\n'],
["scripts/jq.sh", "jq '.revision.state' snapshot.json\n"],
["scripts/substitution.sh", 'echo "$(echo revision.state)"\n'],
];
for (const [path, source] of failing) {
await put(root, path, source);
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/, path);
}
await put(root, "scripts/echo.sh", 'echo "revision.state"\n# workspaceRevision.state\n');
await verifyEntries({ root, entries: [entry("policy_text", "scripts/echo.sh")] });
await put(root, "scripts/literal.yaml", '# revision.state\nvalue: "selectedWorkspace.state"\n');
await verifyEntries({ root, entries: [entry("policy_text", "scripts/literal.yaml")] });
});
test("YAML keeps URL slashes as data rather than a false line comment", async (t) => {
const root = await fixture(t);
const path = "scripts/url.yaml";
await put(root, path, "url: https://host/x; old: selectedWorkspace.state\n");
await assert.rejects(verifyEntries({ root, entries: [entry("policy_text", path)] }), /revision-state/);
});
+291 -41
View File
@@ -1,19 +1,42 @@
import Fastify, { type FastifyInstance } from "fastify";
import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { AppConfig } from "./config.js";
import { ThtRunner } from "./tht/tht-runner.js";
import { PiProcessManager } from "./pi/pi-process-manager.js";
import { SseHub } from "./sse/sse-hub.js";
import { authPreHandler } from "./auth/auth.js";
import { getPrincipal } from "./auth/auth.js";
import { authenticateSession, captureAuthConfigSnapshot, configuredOrigin } from "./auth/auth.js";
import type { PrincipalContext } from "./auth/principal.js";
import type { LoadedAuthConfig } from "./auth/types.js";
import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./auth/local-registry.js";
import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessionStore, type AuthSessionValidity } from "./auth/session-store.js";
import type { WindowsAuthStorageBridge } from "./auth/windows-auth-storage.js";
import { registerAuthRoutes } from "./auth/routes.js";
import { createOidcProtocol, type OidcProtocol, type OidcProtocolOptions } from "./auth/oidc-client.js";
import { createConfiguredAuthDiagnoser } from "./auth/diagnostic-command.js";
import type { AuthDiagnoser } from "./auth/diagnostics.js";
import { isUsableAuthenticationSecret } from "./auth/secret-policy.js";
import { secretValue } from "./config/secret-bundle.js";
import { sessionRoutes } from "./routes/sessions.js";
import { sqlRoutes } from "./routes/sql.js";
import { metaRoutes, type ListModelsFn } from "./routes/meta.js";
import { settingsRoutes, effectiveSettings } from "./routes/settings.js";
import { createPiModelLister } from "./pi/list-models.js";
import { loadSettings, saveSettings, type Settings } from "./settings/settings-store.js";
import { createPiManagement, type PiManagementService } from "./pi/management.js";
import { loadSettings, type Settings } from "./settings/settings-store.js";
import { ReadinessManager } from "./runtime/readiness-manager.js";
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
import { WorkspaceRegistry } from "./workspaces/registry.js";
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
import { piManagementRoutes } from "./routes/pi-management.js";
import { supportsSessionRuntime } from "./workspaces/bindings.js";
import { resolveRuntimeBindingsWithWorkspaceSecrets } from "./workspaces/secret-requirements.js";
import type { WorkspaceDescriptor } from "./workspaces/schema.js";
import { WorkspaceSecretStore } from "./workspaces/secret-store.js";
export interface BuildAppDeps {
thtRunner?: ThtRunner;
@@ -23,28 +46,101 @@ export interface BuildAppDeps {
getSettings?: (principal?: PrincipalContext) => Settings | Promise<Settings>;
readiness?: ReadinessManager;
hub?: SseHub;
workspaceRegistry?: WorkspaceRegistry;
workspaceDiagnoser?: WorkspaceDiagnoser;
workspaceSecretStore?: WorkspaceSecretStore;
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
maintenanceBarrier?: MaintenanceBarrier;
piManagement?: PiManagementService;
localUserRegistry?: LocalUserRegistry;
authSessionStore?: AuthSessionStore;
/** Explicit test-only transport seam; production always invokes the hidden tht bridge. */
authStorageBridgeForTest?: WindowsAuthStorageBridge;
oidcProtocol?: OidcProtocol;
authDiagnoser?: AuthDiagnoser;
/** Explicit test seam; production uses the provider-neutral OIDC constructor. */
oidcProtocolFactory?: (options: OidcProtocolOptions) => OidcProtocol;
}
export interface AppWithAuthSessionStore extends FastifyInstance {
thothiiAuthSessionStore?: AuthSessionStore;
}
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true });
// Allow any origin in dev/e2e; tighten in production via config if needed.
app.register(cors, {
origin: true,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
app.decorateRequest("authConfigSnapshot", undefined);
app.decorateRequest("authConfigSnapshotCaptured", false);
app.decorateRequest("authConfigSnapshotUnavailable", false);
const isolatedTestRoot = process.env.VITEST === "true"
? join(tmpdir(), `thothii-workspace-secrets-vitest-${process.pid}`)
: undefined;
const workspaceSecretStore = deps?.workspaceSecretStore ?? new WorkspaceSecretStore({
root: isolatedTestRoot ?? config.workspaceSecretStoreRoot,
runtimeRoot: isolatedTestRoot === undefined
? config.workspaceSecretRuntimeRoot
: join(isolatedTestRoot, "runtime"),
installationId: config.workspaceRegistry.installationId,
});
const cookieAuth = config.authMode === "local" || config.authMode === "oidc";
app.register(cors, {
// The delegator runs at CORS's onRequest hook. It owns the one request-scoped config load
// which subsequent auth hooks and routes consume, including preflights that end here.
delegator: (request, callback) => {
const snapshot = captureAuthConfigSnapshot(request, config.authentication);
const origin = configuredOrigin(snapshot);
const snapshotUsesCookies = snapshot?.value.mode === "local" || snapshot?.value.mode === "oidc";
callback(null, {
origin: snapshotUsesCookies && origin ? corsOrigin(request, origin) : cookieAuth ? false : true,
credentials: snapshotUsesCookies,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
});
},
});
// Cookie parsing and the rate-limit plugin must precede every auth/application route.
app.register(cookie);
app.register(rateLimit, { global: false });
const tht = deps?.thtRunner ?? new ThtRunner({
thtBin: config.thtBin,
harnessDir: config.harnessDir,
configPath: process.env.THT_CONFIG ?? "config/tht.yaml",
dataRoot: config.dataRoot,
runtimeSnapshotRoot: join(config.workspaceRegistry.root, "snapshots", "runtime"),
secretRoots: config.workspaceRegistry.secretRoots,
secretsFile: config.secretsFile,
secretFiles: config.secretFiles,
workspaceSecretStore,
semanticRuntime: {
internalQdrantUrl: config.internalQdrantUrl,
internalEmbeddingUrl: config.internalEmbeddingUrl,
internalEmbeddingModel: config.internalEmbeddingModel,
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
},
});
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
const hub = deps?.hub ?? new SseHub();
const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry);
const workspaceDiagnoser = deps?.workspaceDiagnoser
?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, {
internalQdrantUrl: config.internalQdrantUrl,
internalEmbeddingUrl: config.internalEmbeddingUrl,
internalEmbeddingModel: config.internalEmbeddingModel,
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
});
const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => {
const lease = resolveRuntimeBindingsWithWorkspaceSecrets(
workspace,
process.env,
config.workspaceRegistry.secretRoots,
workspaceSecretStore,
);
try {
return supportsSessionRuntime(lease.bindings);
} finally {
lease.release();
}
});
const readiness = deps?.readiness ?? new ReadinessManager(
tht as ThtRunner,
Math.round(config.ollamaEnsureTimeoutMs / 1000),
@@ -62,45 +158,199 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
};
const getSettings = async (principal: PrincipalContext): Promise<Settings> => {
if (deps?.getSettings) return await deps.getSettings(principal);
const runner = runnerFor(principal);
// The real runner persists preferences through the harness repository. The file fallback
// only keeps older isolated route tests and externally injected runners compatible.
if (typeof runner.preferencesGet === "function") {
const stored = await runner.preferencesGet() as Settings;
if (Object.keys(stored).length === 0) {
const seeded = effectiveSettings(config, loadSettings(config));
await runner.preferencesSet(seeded);
return seeded;
const stored = loadSettings(config);
const effective = effectiveSettings(config, stored);
// In the registry system the legacy `harness/workspaces/*.yaml` default is obsolete: when no
// installation workspace is pinned, default to the first active registry workspace.
if (!stored.workspace) {
try {
const revisions = await workspaceRegistry.list();
if (revisions.length > 0) effective.workspace = revisions[0].id;
} catch {
// Registry not bootstrapped yet; keep the legacy fallback.
}
return effectiveSettings(config, stored);
}
return effectiveSettings(config, loadSettings(config));
};
const saveUserSettings = async (principal: PrincipalContext, settings: Settings): Promise<void> => {
const runner = runnerFor(principal);
if (typeof runner.preferencesSet === "function") {
await runner.preferencesSet(settings);
return;
}
saveSettings(config, settings);
return effective;
};
const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels });
const authenticate = authPreHandler(config.authMode);
app.addHook("preHandler", async (req, reply) => {
// Process readiness is intentionally unauthenticated for local container/proxy probes.
if (req.url === "/health" || req.url === "/health/dwh") return;
return authenticate(req, reply);
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile);
const localRegistryResolver = deps?.localUserRegistry === undefined
? createCurrentLocalUserRegistryResolver()
: undefined;
const resolveLocalUserRegistry = (loaded: LoadedAuthConfig) => {
return deps?.localUserRegistry ?? localRegistryResolver?.resolve(loaded);
};
const localUserForSnapshot = async (loaded: LoadedAuthConfig, subject: string) => {
try {
if (loaded.value.mode !== "local") return { revision: loaded.revision, user: undefined };
const registry = resolveLocalUserRegistry(loaded);
if (!registry) throw new AuthSessionOperationalError();
const user = await registry.findBySubject(subject);
return {
revision: loaded.revision,
user: user === undefined ? undefined : {
enabled: user.enabled,
authRevision: user.authRevision,
roles: user.roles,
},
};
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
throw new AuthSessionOperationalError();
}
};
const sessionValidityForSnapshot = (loaded: LoadedAuthConfig): AuthSessionValidity => ({
currentAuthConfigRevision: () => loaded.revision,
currentLocalUser: (subject) => localUserForSnapshot(loaded, subject),
});
const resolveOidcProtocol = (loaded: LoadedAuthConfig): OidcProtocol | undefined => {
if (deps?.oidcProtocol) return deps.oidcProtocol;
if (loaded.value.mode !== "oidc") return undefined;
try {
const clientSecret = secretValue(config, loaded.value.oidc.clientSecretRef);
if (!isUsableAuthenticationSecret("THT_OIDC_CLIENT_SECRET", clientSecret)) return undefined;
return (deps?.oidcProtocolFactory ?? createOidcProtocol)({
issuer: loaded.value.oidc.issuer,
clientId: loaded.value.oidc.clientId,
clientSecret,
callbackUrl: new URL("/api/auth/oidc/callback", loaded.value.publicUrl).href,
scopes: loaded.value.oidc.scopes,
groupsClaim: loaded.value.oidc.groupsClaim,
});
} catch {
return undefined;
}
};
const authDiagnoser = deps?.authDiagnoser ?? createConfiguredAuthDiagnoser(config, {
localUserRegistry: resolveLocalUserRegistry,
oidcProtocol: resolveOidcProtocol,
});
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
? createFileAuthSessionStore(config.authStateRoot, {
currentAuthConfigRevision: () => {
try {
return config.authentication?.current().revision ?? "";
} catch {
throw new AuthSessionOperationalError();
}
},
currentLocalUser: async (subject) => {
try {
const loaded = config.authentication?.current();
if (!loaded) return { revision: "", user: undefined };
return await localUserForSnapshot(loaded, subject);
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
throw new AuthSessionOperationalError();
}
},
}, deps?.authStorageBridgeForTest === undefined
? undefined
: process.platform === "win32"
? { windowsStorageBridge: deps.authStorageBridgeForTest }
: { posixStorageBridge: deps.authStorageBridgeForTest })
: undefined);
(app as AppWithAuthSessionStore).thothiiAuthSessionStore = authSessionStore;
const authenticate = authenticateSession({
mode: config.authMode,
publicExposure: config.publicExposure,
authentication: config.authentication,
sessionStore: authSessionStore,
sessionValidityForSnapshot,
});
app.addHook("preHandler", (req, reply, done) => {
if (isMaintenanceControl(req.url)) {
if (!isLoopback(req.ip)) {
reply.code(403).send({ error: "loopback maintenance control required" });
}
}
done();
});
app.addHook("preHandler", authenticate);
app.get("/health", async () => ({ status: "ok" }));
app.get("/health/dwh", async () => tht.dbPing());
app.get("/me", async (req) => getPrincipal(req));
sessionRoutes(app, {
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels,
dwhPrecheck: config.dwhPrecheck,
app.get("/health/dwh", async () => {
// In the registry system there is no single legacy DWH config: ping the first active
// workspace's rendered runtime config. If the registry is not bootstrapped yet, do not
// block the app — per-workspace diagnostics and the session precheck own reachability.
try {
const revisions = await workspaceRegistry.list();
if (revisions.length > 0) {
return await tht.dbPing(revisions[0].snapshotPath);
}
} catch {
// fall through
}
return { ok: true, detail: "workspace diagnostics own DWH reachability" };
});
sqlRoutes(app, { tht: tht as ThtRunner, getSettings });
registerAuthRoutes(app, {
authMode: config.authMode,
authentication: config.authentication,
sessionStore: authSessionStore,
localUserRegistry: deps?.localUserRegistry,
resolveLocalUserRegistry,
resolveOidcProtocol,
});
sessionRoutes(app, {
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
dwhPrecheck: config.dwhPrecheck,
legacyWorkspaceMode: config.legacyWorkspaceMode,
workspaceRuntimeSupport,
maintenanceBarrier,
});
app.post("/internal/maintenance/activate", async (req, reply) => {
try {
await maintenanceBarrier.activate();
return maintenanceBarrier.status();
} catch {
return reply.code(500).send({
...maintenanceBarrier.status(),
code: "maintenance_durability_failed",
error: "maintenance activation durability was not acknowledged",
});
}
});
app.post("/internal/maintenance/deactivate", async (req, reply) => {
try {
maintenanceBarrier.deactivate();
return maintenanceBarrier.status();
} catch {
return reply.code(500).send({
...maintenanceBarrier.status(),
code: "maintenance_durability_failed",
error: "maintenance deactivation durability was not acknowledged",
});
}
});
app.get("/internal/maintenance/status", async (req, reply) => {
return maintenanceBarrier.status();
});
sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry });
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
settingsRoutes(app, { cfg: config, listModels, getSettings, saveSettings: saveUserSettings });
workspaceRoutes(app, {
registry: workspaceRegistry,
config: config.workspaceRegistry,
diagnose: workspaceDiagnoser,
authDiagnoser,
secretStore: workspaceSecretStore,
});
settingsRoutes(app, { cfg: config, listModels, getSettings });
piManagementRoutes(app, { service: piManagement });
return app;
}
function corsOrigin(request: FastifyRequest, expectedOrigin: string): string | false {
const supplied = request.headers.origin;
if (typeof supplied !== "string") return false;
try {
return new URL(supplied).origin === expectedOrigin ? expectedOrigin : false;
} catch {
return false;
}
}
function isLoopback(ip: string): boolean { return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; }
function isMaintenanceControl(url: string): boolean {
return /^\/internal\/maintenance\/(?:activate|deactivate|status)(?:\?|$)/.test(url);
}
+203 -5
View File
@@ -1,17 +1,64 @@
import type { FastifyRequest, FastifyReply } from "fastify";
import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from "fastify";
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
import { rolesToPermissions } from "./config.js";
import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord, LoadedAuthConfig } from "./types.js";
import { AuthSessionOperationalError, type AuthSessionStore, type AuthSessionValidity } from "./session-store.js";
import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js";
import { requireSameOriginOrNonBrowser } from "./authorization.js";
declare module "fastify" {
interface FastifyRequest { principal?: PrincipalContext }
interface FastifyRequest {
principal?: PrincipalContext;
authSession?: AuthSessionRecord;
/** Internal only: never serialize or write this opaque cookie token to logs. */
authSessionToken?: string;
authPublicOrigin?: string;
/** One immutable configuration load for the whole request, including CORS. */
authConfigSnapshot?: LoadedAuthConfig;
authConfigSnapshotCaptured?: boolean;
authConfigSnapshotUnavailable?: boolean;
}
}
export function authPreHandler(mode: "none" | "mock" | "upstream") {
const SESSION_COOKIE = "thothii_session";
const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/;
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
export interface AuthDependencies {
mode: AuthMode;
publicExposure?: boolean;
authentication?: AuthenticationConfigProvider;
sessionStore?: AuthSessionStore;
sessionValidityForSnapshot?: (snapshot: LoadedAuthConfig) => AuthSessionValidity;
}
/** Capture the authentication configuration once; CORS calls this before every other hook. */
export function captureAuthConfigSnapshot(
request: FastifyRequest,
authentication: AuthenticationConfigProvider | undefined,
): LoadedAuthConfig | undefined {
if (request.authConfigSnapshotCaptured) return request.authConfigSnapshot;
request.authConfigSnapshotCaptured = true;
try {
request.authConfigSnapshot = authentication?.current();
} catch {
request.authConfigSnapshotUnavailable = true;
}
return request.authConfigSnapshot;
}
export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposure = false) {
return async (req: FastifyRequest, reply: FastifyReply) => {
if (mode === "none") {
req.principal = localPrincipal();
req.principal = localPrincipal(publicExposure);
} else if (mode === "mock") {
const subject = typeof req.headers["x-mock-user"] === "string" ? req.headers["x-mock-user"].trim() : "mock";
req.principal = { issuer: "mock", subject: subject || "mock", displayName: subject || "mock", isAdmin: false };
const elevated = req.headers["x-thoth-is-admin"] === "1" || req.headers["x-thoth-is-admin"] === "true";
const roles = elevated ? ["admin"] as const : ["user"] as const;
req.principal = {
issuer: "mock", subject: subject || "mock", displayName: subject || "mock", roles,
permissions: rolesToPermissions(roles), isAdmin: elevated,
};
} else {
const principal = upstreamPrincipal(req.headers);
if (!principal) {
@@ -22,6 +69,157 @@ export function authPreHandler(mode: "none" | "mock" | "upstream") {
};
}
/**
* The one application boundary for principal resolution. Auth protocol endpoints are the only
* public exceptions; all other routes get either a resolved principal or a sanitized denial.
*/
export function authenticateSession(deps: AuthDependencies): preHandlerHookHandler {
const legacy = deps.mode === "none" || deps.mode === "mock" || deps.mode === "upstream"
? authPreHandler(deps.mode, deps.publicExposure)
: undefined;
const handle = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
if (isPublicRoute(request)) return;
if (legacy) {
await legacy(request, reply);
if (reply.sent || !STATE_CHANGING_METHODS.has(request.method)) return;
return requireSameOriginOrNonBrowser(request, reply);
}
const origin = configuredOrigin(snapshot);
if (!snapshot || !origin || !deps.sessionStore) {
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
}
const token = readSessionCookie(request);
if (token === undefined || token === false) return authenticationRequired(reply);
let session: AuthSessionRecord | undefined;
try {
session = await deps.sessionStore.resolve(token, undefined, deps.sessionValidityForSnapshot?.(snapshot));
if (session && session.authConfigRevision !== snapshot.revision) {
try { await deps.sessionStore.revoke(token); } catch { /* the mismatch remains denied */ }
return authenticationRequired(reply);
}
if (session) await deps.sessionStore.touch(token);
} catch (error) {
if (error instanceof AuthSessionOperationalError) {
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
}
return authenticationRequired(reply);
}
if (!session) return authenticationRequired(reply);
request.authSession = session;
request.authSessionToken = token;
request.authPublicOrigin = origin;
request.principal = {
issuer: session.issuer,
subject: session.subject,
...(session.displayName === undefined ? {} : { displayName: session.displayName }),
roles: session.roles,
permissions: session.permissions,
isAdmin: session.roles.includes("admin"),
};
if (STATE_CHANGING_METHODS.has(request.method)) {
requireCsrf(request, reply);
return;
}
};
return (request, reply, done) => {
void handle(request, reply).then(
() => done(),
() => {
if (!reply.sent) reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
done();
},
);
};
}
export function requireCsrf(request: FastifyRequest, reply: FastifyReply): true | FastifyReply {
const expectedOrigin = request.authPublicOrigin;
const token = request.authSessionToken;
if (!expectedOrigin || !token) return authenticationRequired(reply);
if (!matchesOrigin(request, expectedOrigin)) return csrfFailed(reply);
const header = singleHeader(request.headers["x-thothii-csrf"]);
const supplied = header === false || header === undefined || !SESSION_TOKEN.test(header) ? undefined : header;
let expected = "";
try {
expected = deriveCsrfToken(token);
} catch {
return authenticationRequired(reply);
}
if (!csrfTokensEqual(expected, supplied)) return csrfFailed(reply);
return true;
}
/** Require an exact configured public origin and browser Fetch Metadata when supplied. */
export function requireExactOrigin(
request: FastifyRequest,
reply: FastifyReply,
expectedOrigin: string,
): true | FastifyReply {
return matchesOrigin(request, expectedOrigin) ? true : csrfFailed(reply);
}
export function sessionCookieName(): string { return SESSION_COOKIE; }
function authenticationRequired(reply: FastifyReply): FastifyReply {
return reply.code(401).send({ code: "authentication_required", error: "Authentication is required" });
}
function csrfFailed(reply: FastifyReply): FastifyReply {
return reply.code(403).send({ code: "csrf_failed", error: "Request origin validation failed" });
}
export function configuredOrigin(snapshot: LoadedAuthConfig | undefined): string | undefined {
try {
const publicUrl = snapshot?.value.publicUrl;
return publicUrl ? new URL(publicUrl).origin : undefined;
} catch {
return undefined;
}
}
function readSessionCookie(request: FastifyRequest): string | false | undefined {
const raw = request.headers.cookie;
if (raw === undefined) return undefined;
if (Array.isArray(raw) || typeof raw !== "string" || raw.length > 4096) return false;
const values = raw.split(";").filter((part) => /^\s*thothii_session(?:=|\s*$)/.test(part));
if (values.length !== 1) return values.length === 0 ? undefined : false;
const match = /^\s*thothii_session=([A-Za-z0-9_-]{43})\s*$/.exec(values[0]);
return match?.[1] ?? false;
}
function singleHeader(value: string | string[] | undefined): string | false | undefined {
if (value === undefined) return undefined;
if (Array.isArray(value) || typeof value !== "string" || value.includes(",")) return false;
return value;
}
function matchesOrigin(request: FastifyRequest, expectedOrigin: string): boolean {
const origin = singleHeader(request.headers.origin);
try {
if (origin === undefined || origin === false || new URL(origin).origin !== expectedOrigin) return false;
} catch {
return false;
}
const fetchSite = singleHeader(request.headers["sec-fetch-site"]);
return fetchSite === undefined || fetchSite === "same-origin";
}
function isPublicRoute(request: FastifyRequest): boolean {
const rawUrl = request.raw.url ?? request.url;
const query = rawUrl.indexOf("?");
const pathname = query === -1 ? rawUrl : rawUrl.slice(0, query);
return (request.method === "GET" && (pathname === "/health" || pathname === "/auth/config"
|| pathname === "/auth/oidc/login" || pathname === "/auth/oidc/callback"))
|| (request.method === "POST" && pathname === "/auth/local/login");
}
export function getPrincipal(req: FastifyRequest): PrincipalContext {
if (!req.principal) throw new Error("principal missing after authentication");
return req.principal;
+236
View File
@@ -0,0 +1,236 @@
import type { AuthDiagnostic, GroupCatalog } from "./group-catalog.js";
import { isUsableAuthenticationSecret } from "./secret-policy.js";
import { parseConfiguredTransportUrl } from "./url-policy.js";
const MAX_RESPONSE_BYTES = 1024 * 1024;
const REQUEST_TIMEOUT_MS = 5_000;
export interface AuthentikGroupCatalogOptions {
baseUrl: string;
apiToken: string;
fetch?: typeof globalThis.fetch;
}
function diagnostic(
code: AuthDiagnostic["code"],
message: string,
field?: string,
): AuthDiagnostic {
return { level: "error", code, message, ...(field === undefined ? {} : { field }) };
}
function catalogUnreachable(): AuthDiagnostic {
return diagnostic("oidc_group_catalog_unreachable", "The configured group catalog is unavailable.");
}
function catalogUnauthorized(): AuthDiagnostic {
return diagnostic("oidc_group_catalog_unauthorized", "The configured group catalog credentials were rejected.");
}
function missing(name: string): AuthDiagnostic {
return diagnostic("oidc_mapped_group_missing", "A configured authorization group does not exist.", name);
}
function ambiguous(name: string): AuthDiagnostic {
return diagnostic("oidc_mapped_group_ambiguous", "A configured authorization group is ambiguous.", name);
}
function stableCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function abortReason(signal: AbortSignal): unknown {
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
}
function cancelResponse(response: Response): void {
try {
const cancelled = response.body?.cancel();
if (cancelled) void cancelled.catch(() => undefined);
} catch { /* cancellation is advisory and never changes the diagnostic */ }
}
function cancelReader(reader: ReadableStreamDefaultReader<Uint8Array>): void {
try {
const cancelled = reader.cancel();
void cancelled.catch(() => undefined);
} catch { /* cancellation is advisory and never changes the diagnostic */ }
}
function awaitWithAbort<T>(
operation: Promise<T>,
signal: AbortSignal,
onLateResolution?: (value: T) => void,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false;
const abort = () => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", abort);
reject(abortReason(signal));
};
if (signal.aborted) {
abort();
return;
}
signal.addEventListener("abort", abort, { once: true });
operation.then(
(value) => {
if (settled) {
try { onLateResolution?.(value); } catch { /* best-effort cleanup only */ }
return;
}
settled = true;
signal.removeEventListener("abort", abort);
resolve(value);
},
(error: unknown) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", abort);
reject(error);
},
);
});
}
function validContentLength(response: Response): boolean {
const value = response.headers.get("content-length");
if (value === null) return true;
if (!/^\d+$/.test(value)) return false;
const length = Number(value);
return Number.isSafeInteger(length) && length <= MAX_RESPONSE_BYTES;
}
async function readBounded(response: Response, signal: AbortSignal): Promise<Uint8Array | undefined> {
if (!validContentLength(response)) {
cancelResponse(response);
return undefined;
}
const reader = response.body?.getReader();
if (!reader) return new Uint8Array();
const chunks: Uint8Array[] = [];
let size = 0;
let complete = false;
try {
while (true) {
const { done, value } = await awaitWithAbort(reader.read(), signal);
if (done) break;
if (value.byteLength > MAX_RESPONSE_BYTES - size) return undefined;
chunks.push(value);
size += value.byteLength;
}
complete = true;
const body = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return body;
} finally {
if (!complete) cancelReader(reader);
try { reader.releaseLock(); } catch { /* reader may already be unusable */ }
}
}
type GroupResult = "present" | "missing" | "ambiguous" | "unauthorized" | "unreachable";
function exactResult(name: string, parsed: unknown): GroupResult {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "unreachable";
const record = parsed as { results?: unknown; pagination?: unknown };
if (!Array.isArray(record.results) || record.results.length > 2
|| !record.pagination || typeof record.pagination !== "object"
|| Array.isArray(record.pagination)) return "unreachable";
if (!Object.prototype.hasOwnProperty.call(record.pagination, "next")) return "unreachable";
const next = (record.pagination as { next: unknown }).next;
if (next !== null) {
if (typeof next !== "string" || next.length === 0 || next.length > 2048 || /\p{Cc}/u.test(next)) return "unreachable";
try {
const continuation = new URL(next);
if (continuation.protocol !== "https:" || continuation.username || continuation.password || continuation.hash) return "unreachable";
} catch {
return "unreachable";
}
return "ambiguous";
}
const resultNames: string[] = [];
for (const result of record.results) {
if (!result || typeof result !== "object" || Array.isArray(result)
|| typeof (result as { name?: unknown }).name !== "string") return "unreachable";
resultNames.push((result as { name: string }).name);
}
const exactMatches = resultNames.filter((candidate) => candidate === name).length;
if (exactMatches === 0) return "missing";
return exactMatches === 1 ? "present" : "ambiguous";
}
export function createAuthentikGroupCatalog(options: AuthentikGroupCatalogOptions): GroupCatalog {
const origin = parseConfiguredTransportUrl(options.baseUrl, { allowLoopbackHttp: false, originOnly: true });
const fetchImplementation = options.fetch ?? globalThis.fetch;
const valid = origin !== undefined
&& isUsableAuthenticationSecret("THT_AUTHENTIK_API_TOKEN", options.apiToken)
&& typeof fetchImplementation === "function";
async function verify(name: string, signal: AbortSignal): Promise<GroupResult> {
if (!origin || !valid || signal.aborted) return "unreachable";
const target = new URL("/api/v3/core/groups/", origin);
target.searchParams.set("name", name);
target.searchParams.set("include_users", "false");
target.searchParams.set("page_size", "2");
const timeout = new AbortController();
const timer = setTimeout(() => timeout.abort(), REQUEST_TIMEOUT_MS);
timer.unref();
const requestSignal = AbortSignal.any([signal, timeout.signal]);
try {
const response = await awaitWithAbort(
Promise.resolve().then(() => fetchImplementation(target, {
headers: { accept: "application/json", authorization: `Bearer ${options.apiToken}` },
redirect: "error",
signal: requestSignal,
})),
requestSignal,
cancelResponse,
);
if (response.redirected || response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
cancelResponse(response);
return "unreachable";
}
if (response.status === 401 || response.status === 403) {
cancelResponse(response);
return "unauthorized";
}
if (!response.ok) {
cancelResponse(response);
return "unreachable";
}
const body = await readBounded(response, requestSignal);
if (body === undefined) return "unreachable";
try {
return exactResult(name, JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)));
} catch {
return "unreachable";
}
} catch {
return "unreachable";
} finally {
clearTimeout(timer);
}
}
return {
async verifyConfiguredGroups(names, signal) {
const diagnostics: AuthDiagnostic[] = [];
for (const name of [...new Set(names)].sort(stableCompare)) {
const outcome = await verify(name, signal);
if (outcome === "present") continue;
if (outcome === "missing") diagnostics.push(missing(name));
else if (outcome === "ambiguous") diagnostics.push(ambiguous(name));
else if (outcome === "unauthorized") return [catalogUnauthorized()];
else return [catalogUnreachable()];
}
return diagnostics;
},
};
}
+54
View File
@@ -0,0 +1,54 @@
import type { FastifyReply, FastifyRequest } from "fastify";
import type { Permission } from "./types.js";
import { getPrincipal } from "./auth.js";
import type { PrincipalContext } from "./principal.js";
export function hasPermission(principal: PrincipalContext, permission: Permission): boolean {
return principal.permissions.includes(permission);
}
export function isPrincipalContext(
value: PrincipalContext | FastifyReply,
): value is PrincipalContext {
return "issuer" in value;
}
export function requirePermission(
request: FastifyRequest,
reply: FastifyReply,
permission: Permission,
): PrincipalContext | FastifyReply {
const principal = getPrincipal(request);
if (hasPermission(principal, permission)) return principal;
return reply.code(403).send({ code: "auth_forbidden", error: "This operation is not permitted" });
}
/**
* A resolved cookie session is populated only by the central auth boundary, after its
* request-snapshot Origin and CSRF checks. Route-specific legacy guards must not reinterpret
* the internal transport host/protocol for that already-authorized browser request.
*/
export function hasCookieBackedAuthSession(request: FastifyRequest): boolean {
return request.authSession !== undefined;
}
/** Permit non-browser clients and browsers whose declared origin matches the request host. */
export function requireSameOriginOrNonBrowser(
request: FastifyRequest,
reply: FastifyReply,
): FastifyReply | undefined {
if (hasCookieBackedAuthSession(request)) return undefined;
const origin = request.headers.origin;
if (origin === undefined) return undefined;
if (typeof origin !== "string" || typeof request.headers.host !== "string") {
return reply.code(403).send({ code: "auth_forbidden", error: "This operation is not permitted" });
}
try {
const supplied = new URL(origin);
const expected = new URL(`${request.protocol}://${request.headers.host}`);
if (supplied.origin === expected.origin) return undefined;
} catch {
// Invalid browser origins are forbidden below.
}
return reply.code(403).send({ code: "auth_forbidden", error: "This operation is not permitted" });
}
+320
View File
@@ -0,0 +1,320 @@
import { createHash } from "node:crypto";
import {
closeSync,
constants,
fstatSync,
lstatSync,
openSync,
readSync,
realpathSync,
} from "node:fs";
import type { Stats } from "node:fs";
import { dirname, isAbsolute, normalize } from "node:path";
import { parseDocument } from "yaml";
import { z } from "zod";
import type {
AuthenticationConfig,
AuthenticationConfigProvider,
AuthMode,
LoadedAuthConfig,
Permission,
Role,
} from "./types.js";
import { parseConfiguredTransportUrl } from "./url-policy.js";
import { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge } from "./windows-auth-storage.js";
export type {
AuthenticationConfig,
AuthenticationConfigProvider,
AuthMode,
LoadedAuthConfig,
Permission,
Role,
} from "./types.js";
const MAX_AUTH_CONFIG_BYTES = 1024 * 1024;
// Keep live catalog work within the same deterministic bound as the mandatory direct groups claim.
const MAX_MAPPED_GROUPS = 128;
const ROLES = ["user", "admin"] as const;
export const PERMISSION_CATALOG: readonly Permission[] = [
"session.use", "session.read_all", "session.manage_all", "settings.manage",
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
];
const invalid = (): Error => new Error("authentication configuration is invalid");
const nonEmptyText = z.string().min(1).max(512).refine(
(value) => value.trim() === value && !/[\u0000-\u001f\u007f]/.test(value),
);
const positiveSeconds = z.number().int().min(1).max(365 * 24 * 60 * 60);
const sessionSchema = z.strictObject({
regularTtlSeconds: positiveSeconds.default(43_200),
regularIdleSeconds: positiveSeconds.default(7_200),
rememberTtlSeconds: positiveSeconds.default(2_592_000),
rememberIdleSeconds: positiveSeconds.default(604_800),
oidcTtlSeconds: positiveSeconds.default(28_800),
});
const roleSchema = z.enum(ROLES);
const groupNameSchema = nonEmptyText.max(256);
const groupRolesSchema = z.record(groupNameSchema, z.array(roleSchema).min(1))
.refine((value) => Object.keys(value).length <= MAX_MAPPED_GROUPS);
const localSchema = z.strictObject({
version: z.literal(1), mode: z.literal("local"), publicUrl: nonEmptyText, session: sessionSchema.optional(),
local: z.strictObject({ usersFile: nonEmptyText.max(255) }),
});
const oidcSchema = z.strictObject({
version: z.literal(1), mode: z.literal("oidc"), publicUrl: nonEmptyText, session: sessionSchema.optional(),
oidc: z.strictObject({
issuer: nonEmptyText, clientId: nonEmptyText, clientSecretRef: z.literal("THT_OIDC_CLIENT_SECRET"),
scopes: z.array(nonEmptyText).min(1).max(16), groupsClaim: z.literal("groups"),
}),
groupCatalog: z.strictObject({
driver: z.literal("authentik"), baseUrl: nonEmptyText, apiTokenRef: z.literal("THT_AUTHENTIK_API_TOKEN"),
}),
authorization: z.strictObject({ groupRoles: groupRolesSchema }),
});
interface FileIdentity {
dev: number;
ino: number;
uid: number;
size: number;
mtimeMs: number;
ctimeMs: number;
mode: number;
nlink: number;
}
interface DirectoryIdentity {
dev: number;
ino: number;
uid: number;
mode: number;
ctimeMs: number;
}
interface StorageIdentity {
file: FileIdentity;
directory: DirectoryIdentity;
}
export interface AuthenticationConfigLoadOptions {
/** Test seam; production creates the existing bounded internal tht auth-storage bridge. */
windowsStorageBridge?: Pick<WindowsAuthStorageBridge, "readAuthConfig">;
}
function validateCanonicalPath(path: string): void {
if (typeof path !== "string" || path.length === 0 || path.trim() !== path
|| path.includes("\0") || !isAbsolute(path) || normalize(path) !== path
|| realpathSync(path) !== path || realpathSync(dirname(path)) !== dirname(path)) throw invalid();
}
function runtimeOwner(): number {
if (process.platform === "win32" || typeof process.geteuid !== "function") throw invalid();
const owner = process.geteuid();
if (!Number.isSafeInteger(owner) || owner < 0) throw invalid();
return owner;
}
function fileMetadata(info: Stats): FileIdentity {
const mode = info.mode & 0o7777;
if (!info.isFile() || info.uid !== runtimeOwner() || info.nlink !== 1 || mode !== 0o600
|| info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid();
return {
dev: info.dev, ino: info.ino, uid: info.uid, size: info.size,
mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, mode, nlink: info.nlink,
};
}
function directoryMetadata(info: Stats): DirectoryIdentity {
const mode = info.mode & 0o7777;
if (!info.isDirectory() || info.uid !== runtimeOwner() || mode !== 0o700) throw invalid();
return { dev: info.dev, ino: info.ino, uid: info.uid, mode, ctimeMs: info.ctimeMs };
}
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid
&& left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs
&& left.mode === right.mode && left.nlink === right.nlink;
}
function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid
&& left.mode === right.mode && left.ctimeMs === right.ctimeMs;
}
function sameIdentity(left: StorageIdentity, right: StorageIdentity): boolean {
return sameFileIdentity(left.file, right.file) && sameDirectoryIdentity(left.directory, right.directory);
}
function storageIdentity(path: string): StorageIdentity {
try {
validateCanonicalPath(path);
return {
file: fileMetadata(lstatSync(path) as Stats),
directory: directoryMetadata(lstatSync(dirname(path)) as Stats),
};
} catch {
throw invalid();
}
}
function openDirectoryDescriptor(path: string): number {
return openSync(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
| (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
}
function readBoundedConfig(path: string): { source: string; identity: StorageIdentity } {
let directoryDescriptor: number | undefined;
let fd: number | undefined;
try {
const before = storageIdentity(path);
directoryDescriptor = openDirectoryDescriptor(dirname(path));
const openedDirectory = directoryMetadata(fstatSync(directoryDescriptor) as Stats);
if (!sameDirectoryIdentity(before.directory, openedDirectory)) throw invalid();
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const opened = fileMetadata(fstatSync(fd) as Stats);
if (!sameFileIdentity(before.file, opened)) throw invalid();
const buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1);
let offset = 0;
while (offset < buffer.length) {
const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, null);
if (bytesRead === 0) break;
offset += bytesRead;
}
if (offset > MAX_AUTH_CONFIG_BYTES) throw invalid();
const afterFile = fileMetadata(fstatSync(fd) as Stats);
const afterPath = storageIdentity(path);
const afterOpenedDirectory = directoryMetadata(fstatSync(directoryDescriptor) as Stats);
if (!sameFileIdentity(opened, afterFile) || !sameFileIdentity(afterFile, afterPath.file)
|| !sameDirectoryIdentity(before.directory, afterPath.directory)
|| !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid();
validateCanonicalPath(path);
return {
source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)),
identity: afterPath,
};
} catch {
throw invalid();
} finally {
if (fd !== undefined) try { closeSync(fd); } catch { /* sanitized by design */ }
if (directoryDescriptor !== undefined) try { closeSync(directoryDescriptor); } catch { /* sanitized by design */ }
}
}
function validOrigin(value: string, httpLoopbackAllowed: boolean): boolean {
return parseConfiguredTransportUrl(value, { allowLoopbackHttp: httpLoopbackAllowed, originOnly: true }) !== undefined;
}
function validIssuer(value: string): boolean {
return parseConfiguredTransportUrl(value, { allowLoopbackHttp: false }) !== undefined;
}
function validUsersFile(value: string): boolean {
return /^[A-Za-z0-9][A-Za-z0-9._-]*\.yaml$/.test(value);
}
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
.map(([key, nested]) => [key, canonicalize(nested)]));
}
return value;
}
function canonicalRevision(value: AuthenticationConfig): string {
return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
}
function parseAuthenticationConfig(source: string): AuthenticationConfig {
try {
const document = parseDocument(source, { uniqueKeys: true });
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
const parsed = document.toJSON();
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw invalid();
const config = parsed as Record<string, unknown>;
const schema = config.mode === "local" ? localSchema : config.mode === "oidc" ? oidcSchema : undefined;
if (!schema) throw invalid();
const validated = schema.parse(config);
const session = sessionSchema.parse(validated.session ?? {});
if (!validOrigin(validated.publicUrl, true)) throw invalid();
if (validated.mode === "local") {
if (!validUsersFile(validated.local.usersFile)) throw invalid();
return { ...validated, session };
}
if (!validIssuer(validated.oidc.issuer) || !validOrigin(validated.groupCatalog.baseUrl, false)) throw invalid();
if (!validated.oidc.scopes.includes("openid")) throw invalid();
const mappings = Object.entries(validated.authorization.groupRoles);
if (mappings.length === 0 || mappings.filter(([, roles]) => roles.includes("admin")).length !== 1) throw invalid();
return { ...validated, session };
} catch { throw invalid(); }
}
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } {
const read = readBoundedConfig(path);
const value = parseAuthenticationConfig(read.source);
return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity };
}
function loadWindowsAuthenticationConfig(
path: string,
bridge: Pick<WindowsAuthStorageBridge, "readAuthConfig">,
): LoadedAuthConfig {
try {
const contents = bridge.readAuthConfig(path);
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_AUTH_CONFIG_BYTES) throw invalid();
const source = new TextDecoder("utf-8", { fatal: true }).decode(contents);
const value = parseAuthenticationConfig(source);
return { value, revision: canonicalRevision(value), sourcePath: path };
} catch {
throw invalid();
}
}
export function loadAuthenticationConfig(path: string, options: AuthenticationConfigLoadOptions = {}): LoadedAuthConfig {
if (process.platform === "win32") {
return loadWindowsAuthenticationConfig(path, options.windowsStorageBridge ?? createWindowsAuthStorageBridge());
}
return loadAuthenticationConfigWithIdentity(path).loaded;
}
export function createAuthenticationConfigProvider(
path: string,
options: AuthenticationConfigLoadOptions = {},
): AuthenticationConfigProvider {
if (process.platform === "win32") {
const bridge = options.windowsStorageBridge ?? createWindowsAuthStorageBridge();
return { current: () => loadWindowsAuthenticationConfig(path, bridge) };
}
let cached: { identity: StorageIdentity; loaded: LoadedAuthConfig } | undefined;
return { current(): LoadedAuthConfig {
const before = storageIdentity(path);
if (cached && sameIdentity(cached.identity, before)) return cached.loaded;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const { loaded, identity } = loadAuthenticationConfigWithIdentity(path);
if (sameIdentity(identity, storageIdentity(path))) {
cached = { identity, loaded };
return loaded;
}
} catch { /* retry one concurrent atomic replacement, then fail closed */ }
}
throw invalid();
} };
}
export function rolesToPermissions(roles: readonly Role[]): readonly Permission[] {
const requested = new Set<Role>();
for (const role of roles) {
if (!ROLES.includes(role)) throw invalid();
requested.add(role);
}
if (requested.has("admin")) return PERMISSION_CATALOG;
return requested.has("user") ? ["session.use"] : [];
}
export function isPermission(value: string): value is Permission {
return PERMISSION_CATALOG.includes(value as Permission);
}
+13
View File
@@ -0,0 +1,13 @@
import { timingSafeEqual } from "node:crypto";
import { deriveCsrfToken as deriveStoredCsrfToken } from "./session-store.js";
export { deriveStoredCsrfToken as deriveCsrfToken };
/** Compare a client-supplied CSRF value without exposing a useful length timing oracle. */
export function csrfTokensEqual(expectedToken: string, suppliedToken: string | undefined): boolean {
const expected = Buffer.from(expectedToken, "utf8");
const supplied = Buffer.from(suppliedToken ?? "", "utf8");
const padded = Buffer.alloc(expected.length);
supplied.copy(padded, 0, 0, expected.length);
return timingSafeEqual(expected, padded) && supplied.length === expected.length;
}
+338
View File
@@ -0,0 +1,338 @@
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import {
closeSync, constants, fstatSync, lstatSync, openSync, readFileSync,
} from "node:fs";
import { loadConfig, type AppConfig } from "../config.js";
import { loadSecretBundle, secretValue } from "../config/secret-bundle.js";
import { createAuthentikGroupCatalog } from "./authentik-group-catalog.js";
import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./local-registry.js";
import { createOidcProtocol, OidcDeviceFlowUnavailableError, type OidcProtocol } from "./oidc-client.js";
import { createAuthDiagnoser, type AuthDiagnoser, type AuthDiagnostic, type AuthDiagnostics } from "./diagnostics.js";
import { decodeAuthDiagnostics, type GroupCatalog } from "./group-catalog.js";
import type { LoadedAuthConfig } from "./types.js";
const AUTH_SECRET_REFERENCES = ["THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN"] as const;
const MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES = 64 * 1024;
const MAX_DIAGNOSTIC_SECRET_VALUES = 4096;
const MAX_DIAGNOSTIC_SECRET_DEPTH = 32;
function unavailableSecretCorpus(): Error {
return new Error("diagnostic secret corpus is unavailable");
}
function readMountedSecretSource(file: string): string {
let fd: number | undefined;
try {
if (!file || file.trim() !== file || file.includes("\0")) throw unavailableSecretCorpus();
const before = lstatSync(file);
if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES) {
throw unavailableSecretCorpus();
}
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
const opened = fstatSync(fd);
if (!opened.isFile() || opened.size > MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES
|| before.dev !== opened.dev || before.ino !== opened.ino) {
throw unavailableSecretCorpus();
}
const value = readFileSync(fd, "utf8");
if (Buffer.byteLength(value, "utf8") > MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES) {
throw unavailableSecretCorpus();
}
return value;
} catch {
throw unavailableSecretCorpus();
} finally {
if (fd !== undefined) try { closeSync(fd); } catch { /* fixed failure surface above */ }
}
}
function parsedSecretValues(raw: string, requireJson: boolean): readonly string[] {
const trimmed = raw.trim();
if (!trimmed) return [];
const values = new Set<string>([raw.replace(/[\r\n]+$/u, "")]);
const looksJson = trimmed.startsWith("{") || trimmed.startsWith("[");
if (!looksJson) {
if (requireJson) throw unavailableSecretCorpus();
return [...values];
}
let document: unknown;
try { document = JSON.parse(trimmed); } catch { throw unavailableSecretCorpus(); }
if (requireJson && (!document || typeof document !== "object" || Array.isArray(document))) {
throw unavailableSecretCorpus();
}
const pending: Array<{ value: unknown; depth: number }> = [{ value: document, depth: 0 }];
let scalarCount = 0;
while (pending.length > 0) {
const current = pending.pop()!;
if (current.depth > MAX_DIAGNOSTIC_SECRET_DEPTH) throw unavailableSecretCorpus();
if (Array.isArray(current.value)) {
for (const item of current.value) pending.push({ value: item, depth: current.depth + 1 });
} else if (current.value && typeof current.value === "object") {
for (const item of Object.values(current.value as Record<string, unknown>)) {
pending.push({ value: item, depth: current.depth + 1 });
}
} else {
scalarCount += 1;
if (scalarCount > 1024) throw unavailableSecretCorpus();
if (typeof current.value === "string" && current.value.length > 0) values.add(current.value);
}
}
return [...values];
}
export function configuredSecretValues(config: AppConfig): readonly string[] {
try {
const values = new Set<string>();
if (config.secretsFile) {
for (const value of loadSecretBundle(config.secretsFile).values()) values.add(value);
}
const legacyFiles = new Set(Object.values(config.secretFiles).filter(
(file): file is string => file !== undefined,
));
for (const file of legacyFiles) {
for (const value of parsedSecretValues(readMountedSecretSource(file), false)) values.add(value);
}
if (config.piAuthFile) {
for (const value of parsedSecretValues(readMountedSecretSource(config.piAuthFile), true)) values.add(value);
}
if (values.size > MAX_DIAGNOSTIC_SECRET_VALUES) throw unavailableSecretCorpus();
return [...values];
} catch {
throw unavailableSecretCorpus();
}
}
export interface ConfiguredAuthDiagnoserOptions {
localUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined;
oidcProtocol?: (loaded: LoadedAuthConfig) => OidcProtocol | undefined;
groupCatalog?: (loaded: LoadedAuthConfig) => GroupCatalog | undefined;
sessionRootValidator?: (root: string) => void | Promise<void>;
}
/** Builds the one shared auth diagnostic implementation used by app routes and the one-shot CLI. */
export function createConfiguredAuthDiagnoser(
config: AppConfig,
options: ConfiguredAuthDiagnoserOptions = {},
): AuthDiagnoser {
const localResolver = options.localUserRegistry === undefined
? createCurrentLocalUserRegistryResolver()
: undefined;
const secretValues = (): ReadonlyMap<string, string> => {
const values = new Map<string, string>();
for (const reference of AUTH_SECRET_REFERENCES) {
try {
const value = secretValue(config, reference);
if (value !== undefined) values.set(reference, value);
} catch {
// The shared diagnoser emits the fixed missing-secret diagnostic below.
}
}
return values;
};
const loaded = (): LoadedAuthConfig | undefined => {
try { return config.authentication?.current(); } catch { return undefined; }
};
return {
async inspect(request): Promise<AuthDiagnostics> {
const current = loaded();
const protocol = current?.value.mode === "oidc"
? options.oidcProtocol?.(current) ?? (() => {
try {
const clientSecret = secretValues().get("THT_OIDC_CLIENT_SECRET");
if (!clientSecret) return undefined;
return createOidcProtocol({
issuer: current.value.oidc.issuer,
clientId: current.value.oidc.clientId,
clientSecret,
callbackUrl: new URL("/api/auth/oidc/callback", current.value.publicUrl).href,
scopes: current.value.oidc.scopes,
groupsClaim: current.value.oidc.groupsClaim,
});
} catch { return undefined; }
})()
: undefined;
const groupCatalog = current?.value.mode === "oidc" ? options.groupCatalog?.(current) ?? (() => {
try {
const token = secretValues().get("THT_AUTHENTIK_API_TOKEN");
return token === undefined ? undefined : createAuthentikGroupCatalog({
baseUrl: current.value.groupCatalog.baseUrl,
apiToken: token,
});
} catch { return undefined; }
})() : undefined;
const report = await createAuthDiagnoser({
authMode: config.authMode,
authStateRoot: config.authStateRoot,
...(options.sessionRootValidator === undefined ? {} : { sessionRootValidator: options.sessionRootValidator }),
authentication: config.authentication,
secrets: secretValues(),
localUserRegistry: current?.value.mode === "local"
? options.localUserRegistry?.(current) ?? localResolver?.resolve(current)
: undefined,
oidcProtocol: protocol,
groupCatalog,
}).inspect(request);
if (!request.interactive || !report.ready) return report;
if (current?.value.mode !== "oidc" || !protocol?.verifyDeviceFlow || !request.presentDeviceCode) {
return {
ready: false,
mode: report.mode,
checks: [{
level: "error",
code: "oidc_device_flow_unavailable",
message: "Interactive authentication diagnostics require OIDC device authorization.",
}],
};
}
try {
const identity = await protocol.verifyDeviceFlow(
request.signal ?? AbortSignal.timeout(10 * 60_000), request.presentDeviceCode,
);
// Exact names only: unrelated provider groups are neither emitted nor retained.
const mappedRoles = new Set<string>();
for (const [configuredGroup, roles] of Object.entries(current.value.authorization.groupRoles)) {
if (!identity.groups.includes(configuredGroup)) continue;
for (const role of roles) mappedRoles.add(role);
}
if (mappedRoles.size === 0) {
return {
ready: false,
mode: "oidc",
checks: [{
level: "error",
code: "oidc_groups_claim_invalid",
message: "The OIDC device-flow identity could not be validated.",
}],
};
}
return report;
} catch (error) {
return {
ready: false,
mode: "oidc",
checks: [{
level: "error",
code: error instanceof OidcDeviceFlowUnavailableError
? "oidc_device_flow_unavailable"
: "oidc_groups_claim_invalid",
message: error instanceof OidcDeviceFlowUnavailableError
? "OIDC device authorization is unavailable."
: "The OIDC device-flow identity could not be validated.",
}],
};
}
},
};
}
export interface DiagnosticCommandDependencies {
diagnoser: AuthDiagnoser;
secretValues?: readonly string[];
stdout: (line: string) => void;
stderr: (line: string) => void;
}
function genericFailure(): AuthDiagnostics {
return {
ready: false,
mode: "none",
checks: [{ level: "error", code: "auth_config_invalid", message: "Authentication configuration is unavailable." }],
};
}
function redact(value: string, secrets: readonly string[]): string {
let result = value;
for (const secret of [...secrets].filter(Boolean).sort((left, right) => right.length - left.length)) {
result = result.replaceAll(secret, "[REDACTED]");
}
return result;
}
function redactedReport(report: AuthDiagnostics, secrets: readonly string[]): AuthDiagnostics {
return {
...report,
checks: report.checks.map((check): AuthDiagnostic => ({
...check,
message: redact(check.message, secrets),
...(check.field === undefined ? {} : { field: redact(check.field, secrets) }),
})),
};
}
function parseArguments(args: readonly string[]): { json: true; interactive: boolean } | undefined {
let json = false;
let interactive = false;
for (const arg of args) {
if (arg === "--json" && !json) json = true;
else if (arg === "--interactive" && !interactive) interactive = true;
else return undefined;
}
return json ? { json: true, interactive } : undefined;
}
/** A bounded machine command: stdout receives exactly one final report and no progress text. */
export async function runDiagnosticCommand(
args: readonly string[],
dependencies: DiagnosticCommandDependencies,
): Promise<number> {
const options = parseArguments(args);
if (!options) {
dependencies.stderr("usage: diagnostic-command.js --json [--interactive]");
return 2;
}
let report: AuthDiagnostics;
const secrets = dependencies.secretValues ?? [];
try {
report = await dependencies.diagnoser.inspect({
live: true,
...(options.interactive ? {
interactive: true,
presentDeviceCode: (uri: string, code: string) => dependencies.stderr(
redact(`Open ${uri} and enter code ${code}`, secrets),
),
} : {}),
});
} catch {
report = genericFailure();
}
const decoded = decodeAuthDiagnostics(report) ?? genericFailure();
const safe = decodeAuthDiagnostics(redactedReport(decoded, secrets)) ?? genericFailure();
dependencies.stdout(`${JSON.stringify(safe)}\n`);
return safe.ready ? 0 : 1;
}
async function main(): Promise<void> {
const exitCode = await runConfiguredDiagnosticCommand(
process.argv.slice(2), process.env,
(line) => process.stdout.write(line),
(line) => process.stderr.write(`${line}\n`),
);
process.exitCode = exitCode;
}
export async function runConfiguredDiagnosticCommand(
args: readonly string[],
env: Record<string, string | undefined>,
stdout: (line: string) => void,
stderr: (line: string) => void,
): Promise<number> {
let diagnoser: AuthDiagnoser = { inspect: async () => genericFailure() };
let secretValues: readonly string[] | undefined;
try {
const config = loadConfig(env);
// Complete this preflight before constructing a diagnoser that may forward a device prompt.
secretValues = configuredSecretValues(config);
diagnoser = createConfiguredAuthDiagnoser(config);
} catch { /* turn startup or corpus faults into the closed report below */ }
return runDiagnosticCommand(args, {
diagnoser,
...(secretValues === undefined ? {} : { secretValues }),
stdout,
stderr,
});
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
void main();
}
+213
View File
@@ -0,0 +1,213 @@
import type { AuthenticationConfigProvider, AuthMode } from "./types.js";
import type { LocalUserRegistry } from "./local-registry.js";
import { OidcIssuerMismatchError, OidcJwksUnavailableError, type OidcProtocol } from "./oidc-client.js";
import {
createPosixAuthStorageBridge,
createWindowsAuthStorageBridge,
type WindowsAuthStorageBridge,
} from "./windows-auth-storage.js";
import { isUsableAuthenticationSecret, type AuthenticationSecretReference } from "./secret-policy.js";
import type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics, GroupCatalog } from "./group-catalog.js";
export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./group-catalog.js";
const LIVE_DIAGNOSTIC_TIMEOUT_MS = 30_000;
export interface AuthDiagnoser {
inspect(options: {
live: boolean;
interactive?: boolean;
signal?: AbortSignal;
/** Device-code presentation is transient operator output, never persisted diagnostic state. */
presentDeviceCode?: (uri: string, code: string) => void;
}): Promise<AuthDiagnostics>;
}
export interface AuthDiagnoserDependencies {
authMode: AuthMode;
authStateRoot: string;
/** Platform integrations may inject an equivalent side-effect-free owner/ACL validator. */
sessionRootValidator?: (root: string) => void | Promise<void>;
windowsStorageBridge?: Pick<WindowsAuthStorageBridge, "validateRoot">;
posixStorageBridge?: Pick<WindowsAuthStorageBridge, "validateRoot">;
authentication?: AuthenticationConfigProvider;
secrets?: ReadonlyMap<string, string>;
localUserRegistry?: LocalUserRegistry;
oidcProtocol?: OidcProtocol;
groupCatalog?: GroupCatalog;
}
function check(code: AuthDiagnosticCode, message: string, field?: string): AuthDiagnostic {
return { level: "error", code, message, ...(field === undefined ? {} : { field }) };
}
function ordered(checks: readonly AuthDiagnostic[]): readonly AuthDiagnostic[] {
const unique = new Map<string, AuthDiagnostic>();
for (const item of checks) unique.set(`${item.code}\u0000${item.field ?? ""}`, item);
return [...unique.values()].sort((left, right) => {
const leftKey = `${left.code}\u0000${left.field ?? ""}`;
const rightKey = `${right.code}\u0000${right.field ?? ""}`;
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
});
}
function stableCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function abortReason(signal: AbortSignal): unknown {
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
}
function awaitWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false;
const abort = () => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", abort);
reject(abortReason(signal));
};
if (signal.aborted) abort();
else signal.addEventListener("abort", abort, { once: true });
operation.then(
(value) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", abort);
resolve(value);
},
(error: unknown) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", abort);
reject(error);
},
);
});
}
function startBeforeAbort<T>(signal: AbortSignal, operation: () => Promise<T>): Promise<T> {
return Promise.resolve().then(() => {
if (signal.aborted) throw abortReason(signal);
return operation();
});
}
async function localRegistryIsUsable(deps: AuthDiagnoserDependencies): Promise<AuthDiagnostic | undefined> {
try {
if (!deps.localUserRegistry) {
return check("local_user_registry_invalid", "The local user registry is unavailable.");
}
if (!await deps.localUserRegistry.hasEnabledAdmin()) {
return check("local_admin_missing", "No enabled local administrator is configured.");
}
return undefined;
} catch {
return check("local_user_registry_invalid", "The local user registry is invalid.");
}
}
export function createAuthDiagnoser(deps: AuthDiagnoserDependencies): AuthDiagnoser {
const validateSessionRoot = deps.sessionRootValidator ?? (process.platform === "win32"
? (root: string) => (deps.windowsStorageBridge ?? createWindowsAuthStorageBridge()).validateRoot(root)
: (root: string) => (deps.posixStorageBridge ?? createPosixAuthStorageBridge()).validateRoot(root));
return {
async inspect(options): Promise<AuthDiagnostics> {
const checks: AuthDiagnostic[] = [];
const signal = options.signal ?? new AbortController().signal;
try {
await validateSessionRoot(deps.authStateRoot);
} catch {
checks.push(check("auth_session_store_invalid", "The authentication session store is invalid."));
}
if (deps.authMode === "none" || deps.authMode === "mock") {
const result = ordered(checks);
return result.length === 0
? { ready: true, mode: deps.authMode, checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }] }
: { ready: false, mode: deps.authMode, checks: result };
}
if (deps.authMode === "upstream") {
checks.push(check("auth_config_incomplete", "The deprecated upstream authentication mode is not certifiable."));
return { ready: false, mode: deps.authMode, checks: ordered(checks) };
}
let loaded;
try {
if (!deps.authentication) throw new Error("missing authentication configuration");
loaded = deps.authentication.current();
} catch {
checks.push(check(deps.authentication ? "auth_config_invalid" : "auth_config_incomplete", "Authentication configuration is unavailable."));
return { ready: false, mode: deps.authMode, checks: ordered(checks) };
}
if (loaded.value.mode !== deps.authMode) {
checks.push(check("auth_config_invalid", "Authentication mode does not match its configuration."));
return { ready: false, mode: deps.authMode, checks: ordered(checks) };
}
if (loaded.value.mode === "local") {
const local = await localRegistryIsUsable(deps);
if (local) checks.push(local);
const result = ordered(checks);
return result.length === 0
? { ready: true, mode: "local", checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }] }
: { ready: false, mode: "local", checks: result };
}
const requiredSecrets: readonly AuthenticationSecretReference[] = ["THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN"];
if (requiredSecrets.some((name) => !isUsableAuthenticationSecret(name, deps.secrets?.get(name)))) {
checks.push(check("oidc_secret_missing", "A required OIDC or group catalog secret is unavailable."));
}
if (!options.live || checks.length > 0) {
const result = ordered(checks);
return result.length === 0
? { ready: true, mode: "oidc", checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }] }
: { ready: false, mode: "oidc", checks: result };
}
const mappedGroupNames = Object.keys(loaded.value.authorization.groupRoles).sort(stableCompare);
const deadline = new AbortController();
const deadlineTimer = setTimeout(() => deadline.abort(), LIVE_DIAGNOSTIC_TIMEOUT_MS);
deadlineTimer.unref();
const liveSignal = AbortSignal.any([signal, deadline.signal]);
try {
if (!deps.oidcProtocol) {
checks.push(check("oidc_discovery_unreachable", "The OIDC provider is unavailable."));
} else {
try {
await awaitWithAbort(startBeforeAbort(liveSignal, () => deps.oidcProtocol!.diagnose(liveSignal)), liveSignal);
} catch (error) {
checks.push(check(
error instanceof OidcIssuerMismatchError
? "oidc_issuer_mismatch"
: error instanceof OidcJwksUnavailableError
? "oidc_jwks_unreachable"
: "oidc_discovery_unreachable",
"The OIDC provider could not be validated.",
));
}
}
if (!liveSignal.aborted) {
if (!deps.groupCatalog) {
checks.push(check("oidc_group_catalog_unreachable", "The configured group catalog cannot be certified."));
} else {
try {
checks.push(...await awaitWithAbort(startBeforeAbort(liveSignal, () => deps.groupCatalog!.verifyConfiguredGroups(
mappedGroupNames, liveSignal,
)), liveSignal));
} catch {
checks.push(check("oidc_group_catalog_unreachable", "The configured group catalog is unavailable."));
}
}
}
} finally {
clearTimeout(deadlineTimer);
}
const result = ordered(checks);
return result.length === 0
? { ready: true, mode: "oidc", checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }] }
: { ready: false, mode: "oidc", checks: result };
},
};
}
+96
View File
@@ -0,0 +1,96 @@
/** The fixed machine contract shared by the Authentik catalog and auth diagnostics. */
export type AuthDiagnosticCode =
| "auth_ready"
| "auth_config_incomplete"
| "auth_config_invalid"
| "auth_session_store_invalid"
| "local_user_registry_invalid"
| "local_admin_missing"
| "oidc_secret_missing"
| "oidc_discovery_unreachable"
| "oidc_issuer_mismatch"
| "oidc_jwks_unreachable"
| "oidc_group_catalog_unreachable"
| "oidc_group_catalog_unauthorized"
| "oidc_mapped_group_missing"
| "oidc_mapped_group_ambiguous"
| "oidc_groups_claim_invalid"
| "oidc_device_flow_unavailable";
export interface AuthDiagnostic {
level: "error" | "info";
code: AuthDiagnosticCode;
message: string;
field?: string;
}
export interface AuthDiagnostics {
ready: boolean;
mode: "local" | "oidc" | "upstream" | "none" | "mock";
checks: readonly AuthDiagnostic[];
}
const diagnosticCodes = new Set<AuthDiagnosticCode>([
"auth_ready", "auth_config_incomplete", "auth_config_invalid", "auth_session_store_invalid",
"local_user_registry_invalid", "local_admin_missing", "oidc_secret_missing",
"oidc_discovery_unreachable", "oidc_issuer_mismatch", "oidc_jwks_unreachable",
"oidc_group_catalog_unreachable", "oidc_group_catalog_unauthorized", "oidc_mapped_group_missing",
"oidc_mapped_group_ambiguous", "oidc_groups_claim_invalid", "oidc_device_flow_unavailable",
]);
const diagnosticModes = new Set<AuthDiagnostics["mode"]>(["local", "oidc", "upstream", "none", "mock"]);
const fieldCodes = new Set<AuthDiagnosticCode>(["oidc_mapped_group_missing", "oidc_mapped_group_ambiguous"]);
function exactObject(value: unknown, keys: readonly string[]): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const source = value as Record<string, unknown>;
const actual = Object.keys(source);
return actual.length === keys.length && actual.every((key) => keys.includes(key)) ? source : undefined;
}
function safeText(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= 512
&& value.trim() === value && !/\p{Cc}/u.test(value);
}
/** Strict decoder for the machine contract shared with tht and the frontend. */
export function decodeAuthDiagnostics(value: unknown): AuthDiagnostics | undefined {
const source = exactObject(value, ["ready", "mode", "checks"]);
if (!source || typeof source.ready !== "boolean" || typeof source.mode !== "string"
|| !diagnosticModes.has(source.mode as AuthDiagnostics["mode"])
|| !Array.isArray(source.checks) || source.checks.length === 0 || source.checks.length > 129) return undefined;
const seen = new Set<string>();
const checks: AuthDiagnostic[] = [];
for (const value of source.checks) {
const raw = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
const check = raw && exactObject(raw, raw.field === undefined
? ["level", "code", "message"]
: ["level", "code", "message", "field"]);
if (!check || (check.level !== "error" && check.level !== "info")
|| typeof check.code !== "string" || !diagnosticCodes.has(check.code as AuthDiagnosticCode)
|| !safeText(check.message) || (check.field !== undefined && !safeText(check.field))) return undefined;
const code = check.code as AuthDiagnosticCode;
if (check.field !== undefined && !fieldCodes.has(code)) return undefined;
const key = `${code}\u0000${check.field ?? ""}`;
if (seen.has(key)) return undefined;
seen.add(key);
checks.push({
level: check.level,
code,
message: check.message,
...(check.field === undefined ? {} : { field: check.field }),
});
}
if (source.ready) {
if (checks.length !== 1 || checks[0].level !== "info" || checks[0].code !== "auth_ready"
|| checks[0].field !== undefined) return undefined;
} else if (!checks.some(({ level }) => level === "error")
|| checks.some(({ code }) => code === "auth_ready")) return undefined;
return { ready: source.ready, mode: source.mode as AuthDiagnostics["mode"], checks };
}
/** A provider-specific proof that only the configured authorization groups exist. */
export interface GroupCatalog {
verifyConfiguredGroups(names: readonly string[], signal: AbortSignal): Promise<readonly AuthDiagnostic[]>;
}
+305
View File
@@ -0,0 +1,305 @@
import {
closeSync,
constants,
fstatSync,
lstatSync,
openSync,
readSync,
realpathSync,
} from "node:fs";
import type { Stats } from "node:fs";
import { dirname, isAbsolute, join, normalize } from "node:path";
import { parseDocument } from "yaml";
import { z } from "zod";
import { isValidPasswordHash, verifyPassword, verifyWithDummy } from "./password.js";
import type { LoadedAuthConfig, Role } from "./types.js";
import { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge } from "./windows-auth-storage.js";
const MAX_USERS_YAML_BYTES = 1 << 20;
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/;
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const ROLES = ["user", "admin"] as const;
const invalid = (): Error => new Error("local_user_registry_invalid");
function runtimeOwner(): number {
if (process.platform === "win32" || typeof process.geteuid !== "function") throw invalid();
const owner = process.geteuid();
if (!Number.isSafeInteger(owner) || owner < 0) throw invalid();
return owner;
}
export interface LocalUserRecord {
id: string;
username: string;
normalizedUsername: string;
displayName?: string;
passwordHash: string;
roles: readonly Role[];
enabled: boolean;
authRevision: number;
}
export interface LocalUserRegistry {
/** Safe production diagnostic probe; never returns user records or hashes. */
hasEnabledAdmin(): Promise<boolean>;
findByUsername(username: string): Promise<LocalUserRecord | undefined>;
findBySubject(id: string): Promise<LocalUserRecord | undefined>;
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
}
/** Keeps only the registry named by the current coherent authentication-config snapshot. */
export interface CurrentLocalUserRegistryResolver {
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined;
}
/** Native Windows obtains protected registry bytes only from the hidden tht bridge. */
export interface LocalUserRegistryOptions {
windowsStorageBridge?: Pick<WindowsAuthStorageBridge, "readLocalUsers">;
}
interface FileIdentity {
dev: number;
ino: number;
uid: number;
size: number;
mtimeMs: number;
}
interface DirectoryIdentity {
dev: number;
ino: number;
uid: number;
mode: number;
}
interface RegistryIdentity {
file: FileIdentity;
directory: DirectoryIdentity;
}
const roleSchema = z.enum(ROLES);
const userSchema = z.strictObject({
id: z.string().regex(UUID_V4_PATTERN),
username: z.string().regex(USERNAME_PATTERN),
displayName: z.string().optional().refine((value) => value === undefined || !/\p{Cc}/u.test(value)),
passwordHash: z.string().refine(isValidPasswordHash),
roles: z.array(roleSchema).min(1).superRefine((roles, context) => {
if (new Set(roles).size !== roles.length) context.addIssue({ code: "custom", message: "duplicate role" });
}),
enabled: z.boolean(),
authRevision: z.number().int().positive().safe(),
});
const registrySchema = z.strictObject({ version: z.literal(1), users: z.array(userSchema).min(1) });
function normalizeUsername(username: string): string {
return username.replace(/[A-Z]/g, (character) => character.toLowerCase());
}
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid
&& left.size === right.size && left.mtimeMs === right.mtimeMs;
}
function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode;
}
function sameIdentity(left: RegistryIdentity, right: RegistryIdentity): boolean {
return sameFileIdentity(left.file, right.file) && sameDirectoryIdentity(left.directory, right.directory);
}
function validateCanonicalPath(path: string): void {
if (typeof path !== "string" || path.length === 0 || path.includes("\0") || !isAbsolute(path) || normalize(path) !== path) throw invalid();
const parent = dirname(path);
if (realpathSync(parent) !== parent) throw invalid();
}
function fileMetadata(info: Stats, owner: number): FileIdentity {
if (!info.isFile() || info.uid !== owner || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) throw invalid();
if (info.size < 0 || info.size > MAX_USERS_YAML_BYTES) throw invalid();
return { dev: info.dev, ino: info.ino, uid: info.uid, size: info.size, mtimeMs: info.mtimeMs };
}
function directoryMetadata(info: Stats, owner: number): DirectoryIdentity {
if (!info.isDirectory() || info.uid !== owner || (info.mode & 0o7777) !== 0o700) throw invalid();
return { dev: info.dev, ino: info.ino, uid: info.uid, mode: info.mode & 0o7777 };
}
function directoryIdentity(path: string, owner: number): DirectoryIdentity {
const parent = dirname(path);
if (realpathSync(parent) !== parent) throw invalid();
return directoryMetadata(lstatSync(parent) as Stats, owner);
}
function registryIdentity(path: string, owner: number): RegistryIdentity {
validateCanonicalPath(path);
const info = lstatSync(path);
return { file: fileMetadata(info as Stats, owner), directory: directoryIdentity(path, owner) };
}
function openDirectoryDescriptor(path: string): number | undefined {
if (process.platform === "win32") return undefined;
const flags = constants.O_RDONLY
| (constants.O_DIRECTORY ?? 0)
| (constants.O_NOFOLLOW ?? 0)
| (constants.O_NONBLOCK ?? 0);
return openSync(path, flags);
}
function readBounded(path: string, owner: number): { source: string; identity: RegistryIdentity } {
validateCanonicalPath(path);
const beforeDirectory = directoryIdentity(path, owner);
const beforePath = lstatSync(path);
const before = fileMetadata(beforePath as Stats, owner);
let directoryDescriptor: number | undefined;
let descriptor: number | undefined;
try {
directoryDescriptor = openDirectoryDescriptor(dirname(path));
const openedDirectory = directoryDescriptor === undefined
? beforeDirectory
: directoryMetadata(fstatSync(directoryDescriptor) as Stats, owner);
if (!sameDirectoryIdentity(beforeDirectory, openedDirectory)) throw invalid();
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const opened = fileMetadata(fstatSync(descriptor) as Stats, owner);
if (!sameFileIdentity(before, opened)) throw invalid();
const buffer = Buffer.allocUnsafe(MAX_USERS_YAML_BYTES + 1);
let offset = 0;
while (offset < buffer.length) {
const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, null);
if (bytesRead === 0) break;
offset += bytesRead;
}
if (offset > MAX_USERS_YAML_BYTES) throw invalid();
const after = fileMetadata(fstatSync(descriptor) as Stats, owner);
const afterPath = fileMetadata(lstatSync(path) as Stats, owner);
const afterDirectory = directoryMetadata(lstatSync(dirname(path)) as Stats, owner);
const afterOpenedDirectory = directoryDescriptor === undefined
? afterDirectory
: directoryMetadata(fstatSync(directoryDescriptor) as Stats, owner);
if (!sameFileIdentity(opened, after) || !sameFileIdentity(after, afterPath)
|| !sameDirectoryIdentity(beforeDirectory, afterDirectory)
|| !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid();
const source = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
return { source, identity: { file: after, directory: afterDirectory } };
} catch {
throw invalid();
} finally {
if (descriptor !== undefined) {
try { closeSync(descriptor); } catch { /* sanitized by design */ }
}
if (directoryDescriptor !== undefined) {
try { closeSync(directoryDescriptor); } catch { /* sanitized by design */ }
}
}
}
function parseRegistry(source: string): LocalUserRecord[] {
try {
const document = parseDocument(source, { uniqueKeys: true });
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
const parsed = registrySchema.parse(document.toJSON());
const ids = new Set<string>();
const usernames = new Set<string>();
const records = parsed.users.map((user) => {
const normalizedUsername = normalizeUsername(user.username);
if (ids.has(user.id) || usernames.has(normalizedUsername)) throw invalid();
ids.add(user.id);
usernames.add(normalizedUsername);
return Object.freeze({
id: user.id,
username: user.username,
normalizedUsername,
...(user.displayName === undefined ? {} : { displayName: user.displayName }),
passwordHash: user.passwordHash,
roles: Object.freeze([...user.roles]) as readonly Role[],
enabled: user.enabled,
authRevision: user.authRevision,
});
});
return records;
} catch {
throw invalid();
}
}
function load(path: string, owner: number): { records: LocalUserRecord[]; identity: RegistryIdentity } {
const read = readBounded(path, owner);
return { records: parseRegistry(read.source), identity: read.identity };
}
export function createLocalUserRegistry(usersPath: string, options: LocalUserRegistryOptions = {}): LocalUserRegistry {
let cached: { records: LocalUserRecord[]; identity: RegistryIdentity } | undefined;
const windowsStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: undefined;
function currentPosix(): LocalUserRecord[] {
try {
const owner = runtimeOwner();
const before = registryIdentity(usersPath, owner);
if (cached && sameIdentity(cached.identity, before)) return cached.records;
for (let attempt = 0; attempt < 2; attempt += 1) {
const loaded = load(usersPath, owner);
if (sameIdentity(loaded.identity, registryIdentity(usersPath, owner))) {
cached = loaded;
return loaded.records;
}
}
} catch {
throw invalid();
}
throw invalid();
}
async function current(): Promise<LocalUserRecord[]> {
if (process.platform !== "win32") return currentPosix();
try {
if (!windowsStorage) throw invalid();
const contents = await windowsStorage.readLocalUsers(usersPath);
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_USERS_YAML_BYTES) throw invalid();
return parseRegistry(new TextDecoder("utf-8", { fatal: true }).decode(contents));
} catch {
throw invalid();
}
}
async function operationalRecords(): Promise<LocalUserRecord[]> {
const records = await current();
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
return records;
}
return {
async hasEnabledAdmin(): Promise<boolean> {
return (await current()).some((user) => user.enabled && user.roles.includes("admin"));
},
async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
const normalized = normalizeUsername(username);
return (await operationalRecords()).find((user) => user.normalizedUsername === normalized);
},
async findBySubject(id: string): Promise<LocalUserRecord | undefined> {
return (await operationalRecords()).find((user) => user.id === id);
},
async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> {
if (!user || !user.enabled) {
await verifyWithDummy(password);
return false;
}
return await verifyPassword(password, user.passwordHash);
},
};
}
export function createCurrentLocalUserRegistryResolver(options: LocalUserRegistryOptions = {}): CurrentLocalUserRegistryResolver {
let current: { usersPath: string; registry: LocalUserRegistry } | undefined;
return {
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined {
if (loaded.value.mode !== "local") return undefined;
const usersPath = join(dirname(loaded.sourcePath), loaded.value.local.usersFile);
if (current?.usersPath === usersPath) return current.registry;
const registry = createLocalUserRegistry(usersPath, options);
current = { usersPath, registry };
return registry;
},
};
}
+658
View File
@@ -0,0 +1,658 @@
import {
authorizationCodeGrant,
buildAuthorizationUrl,
calculatePKCECodeChallenge,
customFetch,
discovery,
initiateDeviceAuthorization,
pollDeviceAuthorizationGrant,
type Configuration,
type CustomFetch,
} from "openid-client";
import { constants, createPublicKey, verify as verifySignature } from "node:crypto";
import { parseConfiguredTransportUrl } from "./url-policy.js";
import { isUsableAuthenticationSecret } from "./secret-policy.js";
export interface OidcIdentity {
issuer: string;
subject: string;
displayName?: string;
groups: readonly string[];
tokenExpiresAt: Date;
}
export interface OidcProtocol {
authorizationUrl(input: { state: string; nonce: string; codeVerifier: string }): Promise<URL>;
callback(input: { currentUrl: URL; state: string; nonce: string; codeVerifier: string }): Promise<OidcIdentity>;
diagnose(signal: AbortSignal): Promise<void>;
verifyDeviceFlow?(signal: AbortSignal, present: (uri: string, code: string) => void): Promise<OidcIdentity>;
}
export class OidcProtocolError extends Error {
constructor(message = "oidc_protocol_invalid") {
super(message);
this.name = "OidcProtocolError";
}
}
/** A safe operational distinction for callers and diagnostics; provider details never cross this boundary. */
export class OidcProviderUnavailableError extends OidcProtocolError {
constructor() {
super("oidc_provider_unavailable");
this.name = "OidcProviderUnavailableError";
}
}
/** The discovery document resolved, but its signed-token key set could not be certified. */
export class OidcJwksUnavailableError extends OidcProtocolError {
constructor() {
super("oidc_jwks_unreachable");
this.name = "OidcJwksUnavailableError";
}
}
/** Discovery completed with metadata for a different issuer than the configured trust anchor. */
export class OidcIssuerMismatchError extends OidcProtocolError {
constructor() {
super("oidc_issuer_mismatch");
this.name = "OidcIssuerMismatchError";
}
}
/** Device authorization is optional OIDC metadata and must never fall back to a browser flow. */
export class OidcDeviceFlowUnavailableError extends OidcProtocolError {
constructor() {
super("oidc_device_flow_unavailable");
this.name = "OidcDeviceFlowUnavailableError";
}
}
export interface OidcProtocolOptions {
issuer: string;
clientId: string;
clientSecret: string;
callbackUrl: string;
scopes: readonly string[];
groupsClaim: string;
fetch?: typeof globalThis.fetch;
httpTimeoutMs?: number;
jwksTimeoutMs?: number;
}
const MAX_GROUPS = 128;
const MAX_GROUP_LENGTH = 256;
const MAX_ID_TOKEN_LENGTH = 16 * 1024;
const MAX_OIDC_RESPONSE_BYTES = 1024 * 1024;
const DEFAULT_HTTP_TIMEOUT_MS = 5_000;
const MAX_HTTP_TIMEOUT_MS = 30_000;
const DEFAULT_JWKS_TIMEOUT_MS = 5_000;
const MAX_JWKS_TIMEOUT_MS = 30_000;
const MAX_DEVICE_FLOW_TIMEOUT_MS = 10 * 60_000;
const text = (value: unknown, maximum = 2048): value is string =>
typeof value === "string" && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value);
function discoveryStringList(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.length > 0 && value.length <= 128
&& value.every((item) => text(item, 128));
}
function schemaValidDiscoveryMetadata(value: unknown): value is Record<string, unknown> & { issuer: string } {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const metadata = value as Record<string, unknown>;
const issuer = metadata.issuer;
const authorizationEndpoint = metadata.authorization_endpoint;
const tokenEndpoint = metadata.token_endpoint;
const jwksUri = metadata.jwks_uri;
if (!text(issuer, 2048) || !text(authorizationEndpoint, 2048)
|| !text(tokenEndpoint, 2048) || !text(jwksUri, 2048)
|| !discoveryStringList(metadata.response_types_supported)
|| !discoveryStringList(metadata.subject_types_supported)
|| !discoveryStringList(metadata.id_token_signing_alg_values_supported)) return false;
try {
configuredHttpsUrl(issuer);
httpsEndpoint(authorizationEndpoint);
httpsEndpoint(tokenEndpoint);
httpsEndpoint(jwksUri);
return true;
} catch {
return false;
}
}
function configuredHttpsUrl(value: string): URL {
const url = parseConfiguredTransportUrl(value, { allowLoopbackHttp: false });
if (!url) throw new OidcProtocolError();
return url;
}
function configuredCallbackUrl(value: string): URL {
const url = parseConfiguredTransportUrl(value, { allowLoopbackHttp: true });
if (!url) throw new OidcProtocolError();
return url;
}
function httpsEndpoint(value: unknown): URL {
if (!text(value, 2048)) throw new OidcProtocolError();
let url: URL;
try {
url = new URL(value);
} catch {
throw new OidcProtocolError();
}
if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new OidcProtocolError();
return url;
}
function groupsFromClaims(claims: Record<string, unknown>, name: string): string[] {
const indirect = claims._claim_names;
if ((indirect && typeof indirect === "object" && !Array.isArray(indirect)
&& Object.prototype.hasOwnProperty.call(indirect, name))
|| claims.hasgroups === true) throw new OidcProtocolError();
const raw = claims[name];
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_GROUPS) throw new OidcProtocolError();
const groups: string[] = [];
const unique = new Set<string>();
for (const group of raw) {
if (!text(group, MAX_GROUP_LENGTH) || group.trim().length === 0 || unique.has(group)) throw new OidcProtocolError();
unique.add(group);
groups.push(group);
}
return groups;
}
function identityFromClaims(claims: Record<string, unknown>, options: OidcProtocolOptions): OidcIdentity {
if (claims.iss !== options.issuer || !text(claims.sub, 512)) throw new OidcProtocolError();
const audience = claims.aud;
if (!(audience === options.clientId || (Array.isArray(audience) && audience.includes(options.clientId)))) {
throw new OidcProtocolError();
}
if (typeof claims.exp !== "number" || !Number.isSafeInteger(claims.exp) || claims.exp * 1000 <= Date.now()) {
throw new OidcProtocolError();
}
const tokenExpiresAt = new Date(claims.exp * 1000);
if (Number.isNaN(tokenExpiresAt.getTime())) throw new OidcProtocolError();
return {
issuer: options.issuer,
subject: claims.sub,
...(text(claims.name, 256) ? { displayName: claims.name } : {}),
groups: groupsFromClaims(claims, options.groupsClaim),
tokenExpiresAt,
};
}
function jsonPart(part: string): Record<string, unknown> {
if (!/^[A-Za-z0-9_-]+$/.test(part) || part.length > MAX_ID_TOKEN_LENGTH) throw new OidcProtocolError();
try {
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(part, "base64url")));
if (!value || typeof value !== "object" || Array.isArray(value)) throw new OidcProtocolError();
return value as Record<string, unknown>;
} catch {
throw new OidcProtocolError();
}
}
function signatureAlgorithm(algorithm: string): {
digest: string | null;
pss?: boolean;
saltLength?: number;
ecdsaPartLength?: number;
} {
switch (algorithm) {
case "RS256": return { digest: "RSA-SHA256" };
case "RS384": return { digest: "RSA-SHA384" };
case "RS512": return { digest: "RSA-SHA512" };
case "PS256": return { digest: "sha256", pss: true, saltLength: 32 };
case "PS384": return { digest: "sha384", pss: true, saltLength: 48 };
case "PS512": return { digest: "sha512", pss: true, saltLength: 64 };
case "ES256": return { digest: "sha256", ecdsaPartLength: 32 };
case "ES384": return { digest: "sha384", ecdsaPartLength: 48 };
case "ES512": return { digest: "sha512", ecdsaPartLength: 66 };
case "EdDSA": return { digest: null };
default: throw new OidcProtocolError();
}
}
function derLength(length: number): Buffer {
if (length < 128) return Buffer.from([length]);
if (length < 256) return Buffer.from([0x81, length]);
throw new OidcProtocolError();
}
function derInteger(raw: Buffer): Buffer {
let start = 0;
while (start < raw.length - 1 && raw[start] === 0) start += 1;
let value = raw.subarray(start);
if ((value[0] & 0x80) !== 0) value = Buffer.concat([Buffer.from([0]), value]);
return Buffer.concat([Buffer.from([0x02]), derLength(value.length), value]);
}
function joseEcdsaSignatureToDer(signature: Buffer, partLength: number): Buffer {
if (signature.length !== partLength * 2) throw new OidcProtocolError();
const sequence = Buffer.concat([
derInteger(signature.subarray(0, partLength)),
derInteger(signature.subarray(partLength)),
]);
return Buffer.concat([Buffer.from([0x30]), derLength(sequence.length), sequence]);
}
class OidcTransportError extends Error {
constructor(readonly availability: boolean) {
super(availability ? "oidc_provider_unavailable" : "oidc_provider_response_invalid");
this.name = "OidcTransportError";
}
}
interface BoundedOidcTransport {
customFetch: CustomFetch;
request(
input: RequestInfo | URL,
init?: RequestInit,
options?: { timeoutMs?: number; requireSuccess?: boolean },
): Promise<Response>;
}
function cancelReaderBestEffort(reader: ReadableStreamDefaultReader<Uint8Array>): void {
try {
void Promise.resolve(reader.cancel()).catch(() => undefined);
} catch {
// Cancellation is advisory; the deadline and rejection remain authoritative.
}
}
function cancelResponseBestEffort(response: Response): void {
if (!response.body) return;
try {
void Promise.resolve(response.body.cancel()).catch(() => undefined);
} catch {
// A late response is never allowed to turn an already-bounded request into an unhandled rejection.
}
}
function abortedReason(signal: AbortSignal): unknown {
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
}
async function awaitWithAbort<T>(
operation: Promise<T>,
signal: AbortSignal,
onLateResolution?: (value: T) => void,
): Promise<T> {
return await new Promise<T>((resolve, reject) => {
let settled = signal.aborted;
const cleanup = () => signal.removeEventListener("abort", aborted);
const aborted = () => {
if (settled) return;
settled = true;
cleanup();
reject(abortedReason(signal));
};
if (signal.aborted) reject(abortedReason(signal));
else signal.addEventListener("abort", aborted, { once: true });
operation.then(
(value) => {
if (settled) {
try { onLateResolution?.(value); } catch { /* best-effort late cleanup only */ }
return;
}
settled = true;
cleanup();
resolve(value);
},
(error: unknown) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
},
);
});
}
function providerRequestUrl(input: RequestInfo | URL): URL {
const value = input instanceof URL ? input.href : input instanceof Request ? input.url : input;
return httpsEndpoint(value);
}
function declaredResponseLength(response: Response): number | undefined {
const declared = response.headers.get("content-length");
if (declared === null) return undefined;
if (!/^\d+$/.test(declared)) throw new OidcTransportError(false);
const length = Number(declared);
if (!Number.isSafeInteger(length) || length > MAX_OIDC_RESPONSE_BYTES) throw new OidcTransportError(false);
return length;
}
function safeBufferedResponse(response: Response, body: Buffer): Response {
const noBodyStatus = response.status === 204 || response.status === 205 || response.status === 304;
// Node accepts Buffer as a fetch body; the DOM declaration in this project does not model it.
return new Response(noBodyStatus ? null : body as unknown as BodyInit, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
async function boundedResponse(
response: Response,
signal: AbortSignal,
requireSuccess: boolean,
): Promise<Response> {
const reader = response.body?.getReader();
const chunks: Buffer[] = [];
let total = 0;
let completed = false;
try {
if (response.redirected || response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
throw new OidcTransportError(false);
}
if (requireSuccess && !response.ok) throw new OidcTransportError(false);
declaredResponseLength(response);
if (!reader) {
completed = true;
return safeBufferedResponse(response, Buffer.alloc(0));
}
while (true) {
const { done, value } = await awaitWithAbort(reader.read(), signal);
if (done) break;
if (value.byteLength > MAX_OIDC_RESPONSE_BYTES - total) throw new OidcTransportError(false);
total += value.byteLength;
chunks.push(Buffer.from(value));
}
completed = true;
return safeBufferedResponse(response, Buffer.concat(chunks, total));
} finally {
try {
if (!completed && reader) cancelReaderBestEffort(reader);
} finally {
try { reader?.releaseLock(); } catch { /* cancellation already made the response unusable */ }
}
}
}
function createBoundedOidcTransport(
fetchImplementation: typeof globalThis.fetch,
defaultTimeoutMs: number,
): BoundedOidcTransport {
const request: BoundedOidcTransport["request"] = async (input, init, requestOptions) => {
let target: URL;
try {
target = providerRequestUrl(input);
} catch {
throw new OidcTransportError(false);
}
const timeoutMs = requestOptions?.timeoutMs ?? defaultTimeoutMs;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
timeout.unref();
const signal = init?.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
try {
const requestInit: RequestInit = { ...init, redirect: "manual", signal };
const response = await awaitWithAbort(
Promise.resolve().then(() => fetchImplementation(target, requestInit)),
signal,
cancelResponseBestEffort,
);
return await boundedResponse(response, signal, requestOptions?.requireSuccess === true);
} catch (error) {
if (error instanceof OidcTransportError) throw error;
if (signal.aborted) throw new OidcTransportError(true);
throw new OidcTransportError(true);
} finally {
clearTimeout(timeout);
}
};
return {
request,
// openid-client's FetchBody is Fetch-compatible, but comes from a distinct declaration graph.
customFetch: (url, options) => request(url, options as unknown as RequestInit),
};
}
function availabilityFailure(error: unknown): boolean {
let current = error;
const seen = new Set<object>();
for (let depth = 0; depth < 8; depth += 1) {
if (current instanceof OidcTransportError) return current.availability;
if (!current || typeof current !== "object" || seen.has(current)) return false;
seen.add(current);
current = (current as { cause?: unknown }).cause;
}
return false;
}
function protocolFailure(error: unknown): OidcProtocolError {
let current = error;
const seen = new Set<object>();
for (let depth = 0; depth < 8; depth += 1) {
if (current instanceof OidcProtocolError) return current;
if (!current || typeof current !== "object" || seen.has(current)) break;
seen.add(current);
current = (current as { cause?: unknown }).cause;
}
return availabilityFailure(error) ? new OidcProviderUnavailableError() : new OidcProtocolError();
}
async function verifyIdTokenSignature(
idToken: unknown,
config: Configuration,
transport: BoundedOidcTransport,
jwksTimeoutMs: number,
): Promise<void> {
if (!text(idToken, MAX_ID_TOKEN_LENGTH)) throw new OidcProtocolError();
const [protectedPart, payloadPart, signaturePart, extra] = idToken.split(".");
if (!protectedPart || !payloadPart || !signaturePart || extra) throw new OidcProtocolError();
const header = jsonPart(protectedPart);
if (!text(header.alg, 16) || !text(header.kid, 256)) throw new OidcProtocolError();
const metadata = config.serverMetadata();
if (!Array.isArray(metadata.id_token_signing_alg_values_supported)
|| !metadata.id_token_signing_alg_values_supported.includes(header.alg)
|| !text(metadata.jwks_uri, 2048)) throw new OidcProtocolError();
const jwksUrl = httpsEndpoint(metadata.jwks_uri);
try {
const response = await transport.request(
jwksUrl,
{ headers: { accept: "application/json" }, redirect: "manual" },
{ timeoutMs: jwksTimeoutMs, requireSuccess: true },
);
const body = new TextDecoder("utf-8", { fatal: true }).decode(await response.arrayBuffer());
const parsed = JSON.parse(body) as { keys?: unknown };
if (!Array.isArray(parsed.keys) || parsed.keys.length === 0 || parsed.keys.length > 16) throw new OidcProtocolError();
const matching = parsed.keys.filter((key): key is Record<string, unknown> =>
Boolean(key) && typeof key === "object" && !Array.isArray(key) && key.kid === header.kid);
if (matching.length !== 1) throw new OidcProtocolError();
const key = matching[0];
if (key.use !== undefined && key.use !== "sig") throw new OidcProtocolError();
if (key.alg !== undefined && key.alg !== header.alg) throw new OidcProtocolError();
const algorithm = signatureAlgorithm(header.alg);
if (!/^[A-Za-z0-9_-]+$/.test(signaturePart)) throw new OidcProtocolError();
const signature = Buffer.from(signaturePart, "base64url");
if (signature.length === 0) throw new OidcProtocolError();
const publicKey = createPublicKey({ key: key as never, format: "jwk" });
const normalizedSignature = algorithm.ecdsaPartLength
? joseEcdsaSignatureToDer(signature, algorithm.ecdsaPartLength)
: signature;
const verified = algorithm.pss
? verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), {
key: publicKey, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: algorithm.saltLength,
}, normalizedSignature)
: verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), publicKey, normalizedSignature);
if (!verified) throw new OidcProtocolError();
} catch (error) {
throw protocolFailure(error);
}
}
async function verifyJwksAvailability(
config: Configuration,
transport: BoundedOidcTransport,
jwksTimeoutMs: number,
signal: AbortSignal,
): Promise<void> {
const metadata = config.serverMetadata();
if (!text(metadata.jwks_uri, 2048)) throw new OidcProtocolError();
const response = await transport.request(
httpsEndpoint(metadata.jwks_uri),
{ headers: { accept: "application/json" }, redirect: "manual", signal },
{ timeoutMs: jwksTimeoutMs, requireSuccess: true },
);
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await response.arrayBuffer()));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)
|| !Array.isArray((parsed as { keys?: unknown }).keys)) throw new OidcProtocolError();
}
export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
const issuerUrl = configuredHttpsUrl(options.issuer);
const callbackUrl = configuredCallbackUrl(options.callbackUrl);
if (!text(options.clientId, 512) || !isUsableAuthenticationSecret("THT_OIDC_CLIENT_SECRET", options.clientSecret)
|| !text(options.groupsClaim, 128) || options.scopes.length === 0 || options.scopes.length > 16
|| options.scopes.some((scope) => !text(scope, 128))
|| (options.httpTimeoutMs !== undefined && (!Number.isSafeInteger(options.httpTimeoutMs)
|| options.httpTimeoutMs < 1 || options.httpTimeoutMs > MAX_HTTP_TIMEOUT_MS))
|| (options.jwksTimeoutMs !== undefined && (!Number.isSafeInteger(options.jwksTimeoutMs)
|| options.jwksTimeoutMs < 1 || options.jwksTimeoutMs > MAX_JWKS_TIMEOUT_MS))) throw new OidcProtocolError();
const httpTimeoutMs = options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
const jwksTimeoutMs = options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS;
const transport = createBoundedOidcTransport(options.fetch ?? globalThis.fetch, httpTimeoutMs);
let discovered: Promise<Configuration> | undefined;
const configuration = async (): Promise<Configuration> => {
if (!discovered) {
discovered = (async () => {
let certifiedIssuerMismatch = false;
const issuerCheckingFetch: CustomFetch = async (input, init) => {
const response = await transport.customFetch(input, init);
if (!response.ok) return response;
try {
const metadata: unknown = await response.clone().json();
if (schemaValidDiscoveryMetadata(metadata) && metadata.issuer !== options.issuer) {
certifiedIssuerMismatch = true;
const headers = new Headers(response.headers);
headers.delete("content-length");
return new Response(JSON.stringify({ ...metadata, issuer: options.issuer }), {
status: response.status,
statusText: response.statusText,
headers,
});
}
} catch {
// The OIDC library owns malformed discovery-document classification.
}
return response;
};
try {
const config = await discovery(
issuerUrl,
options.clientId,
{ client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] },
undefined,
{ [customFetch]: issuerCheckingFetch, timeout: httpTimeoutMs / 1000 },
);
const metadata = config.serverMetadata();
if (metadata.issuer !== options.issuer) throw new OidcProtocolError();
httpsEndpoint(metadata.authorization_endpoint);
httpsEndpoint(metadata.token_endpoint);
httpsEndpoint(metadata.jwks_uri);
if (certifiedIssuerMismatch) throw new OidcIssuerMismatchError();
return config;
} catch (error) {
throw protocolFailure(error);
}
})();
}
return await discovered;
};
return {
async authorizationUrl(input) {
try {
const challenge = await calculatePKCECodeChallenge(input.codeVerifier);
return buildAuthorizationUrl(await configuration(), {
response_type: "code",
redirect_uri: callbackUrl.href,
scope: options.scopes.join(" "),
state: input.state,
nonce: input.nonce,
code_challenge: challenge,
code_challenge_method: "S256",
});
} catch (error) {
throw protocolFailure(error);
}
},
async callback(input) {
if (input.currentUrl.origin !== callbackUrl.origin || input.currentUrl.pathname !== callbackUrl.pathname) {
throw new OidcProtocolError();
}
try {
const config = await configuration();
const tokens = await authorizationCodeGrant(config, input.currentUrl, {
expectedState: input.state,
expectedNonce: input.nonce,
pkceCodeVerifier: input.codeVerifier,
idTokenExpected: true,
});
await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs);
const claims = tokens.claims();
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
return identityFromClaims(claims as Record<string, unknown>, options);
} catch (error) {
throw protocolFailure(error);
}
},
async diagnose(signal) {
signal.throwIfAborted();
const config = await configuration();
signal.throwIfAborted();
try {
await verifyJwksAvailability(config, transport, jwksTimeoutMs, signal);
} catch {
throw new OidcJwksUnavailableError();
}
signal.throwIfAborted();
},
async verifyDeviceFlow(signal, present) {
const maximumDeadline = AbortSignal.timeout(MAX_DEVICE_FLOW_TIMEOUT_MS);
const operationSignal = AbortSignal.any([signal, maximumDeadline]);
let config: Configuration;
try {
operationSignal.throwIfAborted();
config = await configuration();
operationSignal.throwIfAborted();
const endpoint = config.serverMetadata().device_authorization_endpoint;
httpsEndpoint(endpoint);
} catch (error) {
if (error instanceof OidcIssuerMismatchError || error instanceof OidcProviderUnavailableError) throw error;
throw new OidcDeviceFlowUnavailableError();
}
try {
operationSignal.throwIfAborted();
const device = await awaitWithAbort(
initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") }),
operationSignal,
);
if (!text(device.verification_uri, 2048) || !text(device.user_code, 256)) {
throw new OidcDeviceFlowUnavailableError();
}
const providerLifetimeMs = device.expires_in * 1000;
if (!Number.isSafeInteger(providerLifetimeMs) || providerLifetimeMs <= 0) {
throw new OidcDeviceFlowUnavailableError();
}
const deviceSignal = AbortSignal.any([
signal,
maximumDeadline,
AbortSignal.timeout(Math.min(providerLifetimeMs, MAX_DEVICE_FLOW_TIMEOUT_MS)),
]);
const verificationUri = httpsEndpoint(device.verification_uri);
present(verificationUri.href, device.user_code);
const tokens = await pollDeviceAuthorizationGrant(config, device, undefined, { signal: deviceSignal });
await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs);
const claims = tokens.claims();
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
return identityFromClaims(claims as Record<string, unknown>, options);
} catch (error) {
if (error instanceof OidcDeviceFlowUnavailableError) throw error;
throw protocolFailure(error);
}
},
};
}
+157
View File
@@ -0,0 +1,157 @@
import { argon2, timingSafeEqual } from "node:crypto";
const MAXIMUM_PHC_BYTES = 256;
const ARGON2_MEMORY_KIB = 65_536;
const ARGON2_PASSES = 3;
const ARGON2_PARALLELISM = 1;
const ARGON2_SALT_BYTES = 16;
const ARGON2_KEY_BYTES = 32;
const MINIMUM_PASSWORD_BYTES = 12;
const MAXIMUM_PASSWORD_BYTES = 1024;
interface Argon2Parameters {
memory: number;
passes: number;
parallelism: number;
salt: Buffer;
digest: Buffer;
}
/** Internal-only signal for unavailable native Argon2 work; never expose its cause. */
export class LocalPasswordVerificationError extends Error {
constructor() {
super("local_password_verification_failed");
}
}
function parseDecimal(value: string, maximum: number): number | undefined {
if (!/^\d+$/.test(value) || (value.length > 1 && value[0] === "0") || value.length > 10) return undefined;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed <= maximum ? parsed : undefined;
}
function decodeRawBase64(value: string, minimum: number, maximum: number): Buffer | undefined {
if (!/^[A-Za-z0-9+/]+$/.test(value)) return undefined;
const decoded = Buffer.from(value, "base64");
if (decoded.length < minimum || decoded.length > maximum) {
decoded.fill(0);
return undefined;
}
if (decoded.toString("base64").replace(/=+$/, "") !== value) {
decoded.fill(0);
return undefined;
}
return decoded;
}
function parsePHC(encoded: string): Argon2Parameters | undefined {
if (typeof encoded !== "string" || encoded.length === 0 || Buffer.byteLength(encoded, "utf8") > MAXIMUM_PHC_BYTES) return undefined;
const parts = encoded.split("$");
if (parts.length !== 6 || parts[0] !== "" || parts[1] !== "argon2id" || parts[2] !== "v=19") return undefined;
const parameterParts = parts[3].split(",");
if (parameterParts.length !== 3 || !parameterParts[0].startsWith("m=") || !parameterParts[1].startsWith("t=") || !parameterParts[2].startsWith("p=")) return undefined;
const memory = parseDecimal(parameterParts[0].slice(2), ARGON2_MEMORY_KIB);
const passes = parseDecimal(parameterParts[1].slice(2), ARGON2_PASSES);
const parallelism = parseDecimal(parameterParts[2].slice(2), ARGON2_PARALLELISM);
if (memory !== ARGON2_MEMORY_KIB || passes !== ARGON2_PASSES || parallelism !== ARGON2_PARALLELISM) return undefined;
const salt = decodeRawBase64(parts[4], ARGON2_SALT_BYTES, ARGON2_SALT_BYTES);
const digest = decodeRawBase64(parts[5], ARGON2_KEY_BYTES, ARGON2_KEY_BYTES);
if (!salt || !digest) {
salt?.fill(0);
digest?.fill(0);
return undefined;
}
return { memory, passes, parallelism, salt, digest };
}
function hasValidPasswordBytes(password: string): boolean {
if (typeof password !== "string") return false;
const typedPassword = password as string & { isWellFormed?: () => boolean };
if (typeof typedPassword.isWellFormed === "function") {
if (!typedPassword.isWellFormed()) return false;
} else if (/[\uD800-\uDFFF]/.test(password)) {
return false;
}
const byteLength = Buffer.byteLength(password, "utf8");
return byteLength >= MINIMUM_PASSWORD_BYTES && byteLength <= MAXIMUM_PASSWORD_BYTES;
}
function passwordBytes(password: string): Buffer | undefined {
return hasValidPasswordBytes(password) ? Buffer.from(password, "utf8") : undefined;
}
function clearParameters(parameters: Argon2Parameters): void {
parameters.salt.fill(0);
parameters.digest.fill(0);
}
function deriveArgon2(message: Buffer, parameters: Argon2Parameters): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
let settled = false;
const complete = (error: Error | null, derived?: Buffer): void => {
if (settled) {
derived?.fill(0);
return;
}
settled = true;
if (error || !derived) {
derived?.fill(0);
reject(error ?? new Error("argon2_failed"));
return;
}
resolve(derived);
};
try {
argon2("argon2id", {
message,
nonce: parameters.salt,
memory: parameters.memory,
passes: parameters.passes,
parallelism: parameters.parallelism,
tagLength: parameters.digest.length,
}, complete);
} catch (error) {
if (!settled) {
settled = true;
reject(error);
}
}
});
}
export function isValidPasswordHash(encoded: string): boolean {
const parameters = parsePHC(encoded);
if (!parameters) return false;
clearParameters(parameters);
return true;
}
export async function verifyPassword(password: string, encoded: string): Promise<boolean> {
const parameters = parsePHC(encoded);
const message = passwordBytes(password);
if (!message || !parameters) {
message?.fill(0);
if (parameters) clearParameters(parameters);
return false;
}
let derived: Buffer | undefined;
try {
derived = await deriveArgon2(message, parameters);
return derived.length === parameters.digest.length && timingSafeEqual(derived, parameters.digest);
} catch {
throw new LocalPasswordVerificationError();
} finally {
message.fill(0);
derived?.fill(0);
clearParameters(parameters);
}
}
const DUMMY_PASSWORD = "thothii-process-local-dummy-password";
const DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=1$ABEiM0RVZneImaq7zN3u/w$/YuK14HV5biXcVIYqaJs07meu0nRgo+d62KnM02MSoU";
export async function verifyWithDummy(password: string): Promise<void> {
await verifyPassword(hasValidPasswordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH);
}
+35 -7
View File
@@ -2,16 +2,21 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { isPermission, rolesToPermissions } from "./config.js";
import type { Permission, Role } from "./types.js";
export interface PrincipalContext {
issuer: string;
subject: string;
displayName?: string;
roles: readonly Role[];
permissions: readonly Permission[];
isAdmin: boolean;
}
const principalEnvKeys = [
"THT_PRINCIPAL_ISSUER", "THT_PRINCIPAL_SUBJECT", "THT_PRINCIPAL_DISPLAY_NAME", "THT_PRINCIPAL_IS_ADMIN",
"THT_PRINCIPAL_PERMISSIONS",
] as const;
export function clearPrincipalEnvironment(env: NodeJS.ProcessEnv): void {
@@ -42,6 +47,19 @@ function optional(value: unknown): string | undefined {
return required(value);
}
function principal(
issuer: string, subject: string, roles: readonly Role[], displayName?: string,
): PrincipalContext {
return {
issuer,
subject,
...(displayName ? { displayName } : {}),
roles,
permissions: rolesToPermissions(roles),
isAdmin: roles.includes("admin"),
};
}
export function upstreamPrincipal(headers: Record<string, unknown>): PrincipalContext | undefined {
const issuer = required(headers["x-thoth-principal-issuer"]);
const subject = required(headers["x-thoth-principal-subject"]);
@@ -49,10 +67,15 @@ export function upstreamPrincipal(headers: Record<string, unknown>): PrincipalCo
const adminHeader = headers["x-thoth-is-admin"];
if (!issuer || !subject || (headers["x-thoth-principal-display-name"] !== undefined && !displayName)) return undefined;
if (adminHeader !== "0" && adminHeader !== "1" && adminHeader !== "true" && adminHeader !== "false") return undefined;
return { issuer, subject, displayName, isAdmin: adminHeader === "1" || adminHeader === "true" };
return principal(
issuer,
subject,
adminHeader === "1" || adminHeader === "true" ? ["user", "admin"] : ["user"],
displayName,
);
}
export function localPrincipal(): PrincipalContext {
export function localPrincipal(publicExposure = false): PrincipalContext {
const home = expandLocalHome(process.env.THT_HOME ?? join(homedir(), ".thothii"));
const identityPath = join(home, "identity.json");
mkdirSync(home, { recursive: true, mode: 0o700 });
@@ -61,29 +84,34 @@ export function localPrincipal(): PrincipalContext {
const stored = JSON.parse(readFileSync(identityPath, "utf8"));
if (stored?.issuer === "local" && typeof stored.subject === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(stored.subject)) {
harden(identityPath, 0o600);
return { issuer: "local", subject: stored.subject, isAdmin: false };
return principal("local", stored.subject, publicExposure ? ["user"] : ["admin"]);
}
throw new Error("invalid local identity");
} catch (error: any) {
if (error?.code !== "ENOENT") throw error;
const principal = { issuer: "local", subject: randomUUID() };
const created = { issuer: "local", subject: randomUUID() };
try {
writeFileSync(identityPath, JSON.stringify(principal) + "\n", { mode: 0o600, flag: "wx" });
writeFileSync(identityPath, JSON.stringify(created) + "\n", { mode: 0o600, flag: "wx" });
harden(identityPath, 0o600);
return { ...principal, isAdmin: false };
return principalContext("local", created.subject, publicExposure);
} catch (writeError: any) {
// Another local request won the identity creation race; always converge on its UUID.
if (writeError?.code === "EEXIST") return localPrincipal();
if (writeError?.code === "EEXIST") return localPrincipal(publicExposure);
throw writeError;
}
}
}
function principalContext(issuer: string, subject: string, publicExposure: boolean): PrincipalContext {
return principal(issuer, subject, publicExposure ? ["user"] : ["admin"]);
}
export function principalEnvironment(principal: PrincipalContext): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
THT_PRINCIPAL_ISSUER: principal.issuer,
THT_PRINCIPAL_SUBJECT: principal.subject,
THT_PRINCIPAL_IS_ADMIN: principal.isAdmin ? "true" : "false",
THT_PRINCIPAL_PERMISSIONS: principal.permissions.filter(isPermission).join(","),
};
if (principal.displayName) env.THT_PRINCIPAL_DISPLAY_NAME = principal.displayName;
return env;
+659
View File
@@ -0,0 +1,659 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import type {
AuthenticationConfigProvider,
LoadedAuthConfig,
OidcAuthenticationConfig,
OidcStateRecord,
OidcTransactionTransport,
Role,
} from "./types.js";
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js";
import { rolesToPermissions } from "./config.js";
import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
import { requirePermission, isPrincipalContext } from "./authorization.js";
import { deriveCsrfToken } from "./csrf.js";
import { verifyWithDummy } from "./password.js";
import type { OidcProtocol } from "./oidc-client.js";
import { parseConfiguredTransportUrl } from "./url-policy.js";
const TEN_MINUTES_MS = 10 * 60 * 1000;
const REMEMBER_COOKIE_SECONDS = 2_592_000;
const MAX_USERNAME_LENGTH = 64;
const MAX_PASSWORD_LENGTH = 1024;
const MAX_LIMIT_ENTRIES = 10_000;
const MAX_OIDC_INITIATIONS_PER_ADDRESS = 20;
const MAX_OIDC_CALLBACK_QUERY_LENGTH = 4096;
const OIDC_CALLBACK_PATH = "/api/auth/oidc/callback";
const OIDC_TRANSACTION_COOKIE_SECONDS = TEN_MINUTES_MS / 1000;
const OIDC_VALUE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
interface OidcTransactionCookieProfile {
transport: OidcTransactionTransport;
name: string;
secure: boolean;
}
const OIDC_TRANSACTION_COOKIE_PROFILES: Readonly<Record<OidcTransactionTransport, OidcTransactionCookieProfile>> = {
https: { transport: "https", name: "__Host-thothii_oidc_tx", secure: true },
loopback_http: { transport: "loopback_http", name: "thothii_oidc_tx", secure: false },
};
export interface AuthRouteDependencies {
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
authentication?: AuthenticationConfigProvider;
sessionStore?: AuthSessionStore;
/** Test-only compatibility seam; production resolves from each loaded config snapshot. */
localUserRegistry?: LocalUserRegistry;
resolveLocalUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined;
oidcProtocol?: OidcProtocol;
resolveOidcProtocol?: (loaded: LoadedAuthConfig) => OidcProtocol | undefined;
}
interface LoginPayload {
username: string;
password: string;
remember: boolean;
}
function countActiveAttempts(
bucket: ReadonlyMap<string, readonly number[]>,
key: string,
now: number,
): number {
const attempts = bucket.get(key);
if (!attempts) return 0;
const earliest = now - TEN_MINUTES_MS;
let count = 0;
for (const timestamp of attempts) {
if (timestamp > earliest) count += 1;
}
return count;
}
function pruneExpiredAttempts(bucket: Map<string, number[]>, now: number): void {
const earliest = now - TEN_MINUTES_MS;
for (const [key, attempts] of bucket) {
const active = attempts.filter((timestamp) => timestamp > earliest);
if (active.length === 0) bucket.delete(key);
else if (active.length !== attempts.length) bucket.set(key, active);
}
}
function canRecordAttempt(
bucket: ReadonlyMap<string, readonly number[]>,
key: string,
limit: number,
maximumEntries: number,
): boolean {
return (bucket.get(key)?.length ?? 0) < limit
&& (bucket.has(key) || bucket.size < maximumEntries);
}
function appendAttempt(bucket: Map<string, number[]>, key: string, now: number): void {
bucket.set(key, [...(bucket.get(key) ?? []), now]);
}
export class LoginFailureLimiter {
private readonly usernames = new Map<string, number[]>();
private readonly addresses = new Map<string, number[]>();
private readonly maximumEntries: number;
constructor(options: { maximumEntries?: number } = {}) {
this.maximumEntries = options.maximumEntries ?? MAX_LIMIT_ENTRIES;
}
isLimited(username: string, address: string, now = Date.now()): boolean {
return countActiveAttempts(this.usernames, username, now) >= 10
|| countActiveAttempts(this.addresses, address, now) >= 20;
}
recordFailure(username: string, address: string, now = Date.now()): boolean {
pruneExpiredAttempts(this.usernames, now);
pruneExpiredAttempts(this.addresses, now);
if (!canRecordAttempt(this.usernames, username, 10, this.maximumEntries)
|| !canRecordAttempt(this.addresses, address, 20, this.maximumEntries)) return false;
appendAttempt(this.usernames, username, now);
appendAttempt(this.addresses, address, now);
return true;
}
}
class OidcInitiationLimiter {
private readonly addresses = new Map<string, number[]>();
consume(address: string, now = Date.now()): boolean {
pruneExpiredAttempts(this.addresses, now);
if (!canRecordAttempt(
this.addresses,
address,
MAX_OIDC_INITIATIONS_PER_ADDRESS,
MAX_LIMIT_ENTRIES,
)) return false;
appendAttempt(this.addresses, address, now);
return true;
}
}
class VerificationGate {
private active = 0;
async run(operation: () => Promise<boolean>): Promise<boolean | undefined> {
if (this.active >= 2) return undefined;
this.active += 1;
try {
return await operation();
} finally {
this.active -= 1;
}
}
}
async function unavailableAfterDummy(
gate: VerificationGate,
password: string,
reply: FastifyReply,
): Promise<FastifyReply> {
try {
const completed = await gate.run(async () => {
await verifyWithDummy(password);
return true;
});
if (completed === undefined) return loginLimited(reply);
} catch {
// Preserve the sanitized operational outcome below.
}
return unavailable(reply);
}
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void {
const limiter = new LoginFailureLimiter();
const oidcInitiationLimiter = new OidcInitiationLimiter();
const verificationGate = new VerificationGate();
app.get("/auth/config", async (request, reply) => {
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
if (!snapshot) return unavailable(reply);
const mode = snapshot.value.mode;
return reply.send({ mode, localLogin: mode === "local", oidcLogin: mode === "oidc" });
});
app.post("/auth/local/login", async (request, reply) => {
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
const configured = currentLocalConfig(snapshot, deps);
if (configured.kind === "unavailable") {
return unavailableAfterDummy(verificationGate, argon2SafePassword(loginPayload(request).password), reply);
}
if (configured.kind !== "local" || !deps.sessionStore) return unavailable(reply);
const originCheck = requireExactOrigin(request, reply, configured.origin);
if (originCheck !== true) return originCheck;
const payload = loginPayload(request);
const safePassword = argon2SafePassword(payload.password);
const normalizedUsername = payload.username.replace(/[A-Z]/g, (character) => character.toLowerCase());
const sourceAddress = boundedAddress(request.ip);
if (limiter.isLimited(normalizedUsername, sourceAddress)) return loginLimited(reply);
let user: LocalUserRecord | undefined;
try {
if (payload.username.length > 0) user = await configured.registry.findByUsername(payload.username);
} catch {
return unavailableAfterDummy(verificationGate, safePassword, reply);
}
let verified: boolean | undefined;
try {
verified = await verificationGate.run(async () =>
configured.registry.verify(user, safePassword));
} catch {
return unavailable(reply);
}
if (verified === undefined) return loginLimited(reply);
if (!verified || !user || !user.enabled) {
if (!limiter.recordFailure(normalizedUsername, sourceAddress)) return loginLimited(reply);
return invalidCredentials(reply);
}
try {
const created = await deps.sessionStore.create({
principal: {
issuer: "local",
subject: user.id,
displayName: user.displayName ?? user.username,
roles: user.roles,
permissions: rolesToPermissions(user.roles),
isAdmin: user.roles.includes("admin"),
},
method: "local",
remembered: payload.remember,
userAuthRevision: user.authRevision,
authConfigRevision: configured.revision,
idleTtlMs: (payload.remember ? configured.session.rememberIdleSeconds : configured.session.regularIdleSeconds) * 1000,
absoluteTtlMs: (payload.remember ? configured.session.rememberTtlSeconds : configured.session.regularTtlSeconds) * 1000,
});
reply.setCookie(sessionCookieName(), created.token, cookieOptions(snapshot, payload.remember));
return reply.send({});
} catch {
return unavailable(reply);
}
});
app.get("/auth/oidc/login", async (request, reply) => {
if (!oidcInitiationLimiter.consume(boundedAddress(request.ip))) return loginLimited(reply);
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
const configured = currentOidcConfig(loaded, deps);
const transactionProfile = configured === undefined
? undefined
: oidcTransactionCookieProfile(configured.loaded);
if (!configured || !transactionProfile || !deps.sessionStore) {
clearOidcTransactionCookies(reply);
return unavailable(reply);
}
const nonce = randomOidcValue();
const codeVerifier = randomOidcValue();
const browserTransaction = randomOidcValue();
try {
const created = await deps.sessionStore.createOidcState({
nonce,
codeVerifier,
returnTo: "/",
authConfigRevision: configured.loaded.revision,
issuer: configured.config.oidc.issuer,
browserTransactionDigest: oidcTransactionDigest(browserTransaction).toString("hex"),
browserTransactionTransport: transactionProfile.transport,
});
try {
const location = await configured.protocol.authorizationUrl({ state: created.state, nonce, codeVerifier });
clearOidcTransactionCookie(reply, otherOidcTransactionCookieProfile(transactionProfile));
reply.setCookie(transactionProfile.name, browserTransaction, oidcTransactionCookieOptions(transactionProfile));
return reply.redirect(location.href);
} catch {
await deps.sessionStore.consumeOidcState(created.state).catch(() => undefined);
clearOidcTransactionCookies(reply, transactionProfile);
return unavailable(reply);
}
} catch (error) {
clearOidcTransactionCookies(reply, transactionProfile);
if (error instanceof OidcStateCapacityError) return loginLimited(reply);
return unavailable(reply);
}
});
app.get("/auth/oidc/callback", async (request, reply) => {
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
const callback = oidcCallbackUrl(request, loaded?.value.publicUrl);
if (!deps.sessionStore || !callback.state) {
clearOidcTransactionCookies(reply);
return oidcCallbackFailed(reply);
}
let state: OidcStateRecord | undefined;
try {
state = await deps.sessionStore.consumeOidcState(callback.state);
} catch {
clearOidcTransactionCookies(reply);
return oidcCallbackFailed(reply);
}
const stateTransactionProfile = oidcTransactionCookieProfileForTransport(state?.browserTransactionTransport);
clearOidcTransactionCookies(reply, stateTransactionProfile);
const configured = currentOidcConfig(loaded, deps);
const configuredTransactionProfile = configured === undefined
? undefined
: oidcTransactionCookieProfile(configured.loaded);
const transaction = readOidcTransactionCookie(request, stateTransactionProfile);
const transactionMatches = oidcTransactionMatches(transaction, state?.browserTransactionDigest ?? "");
if (!callback.currentUrl || !configured || !configuredTransactionProfile || !state || !stateTransactionProfile
|| state.returnTo !== "/" || !transactionMatches
|| state.authConfigRevision !== configured.loaded.revision
|| state.issuer !== configured.config.oidc.issuer
|| stateTransactionProfile.transport !== configuredTransactionProfile.transport) {
return oidcCallbackFailed(reply);
}
try {
const identity = await configured.protocol.callback({
currentUrl: callback.currentUrl,
state: callback.state,
nonce: state.nonce,
codeVerifier: state.codeVerifier,
});
if (identity.issuer !== configured.config.oidc.issuer || !Array.isArray(identity.groups)
|| identity.groups.length === 0) return oidcCallbackFailed(reply);
const roles = oidcRoles(identity.groups, configured.config);
const now = new Date();
const absoluteTtlMs = Math.min(
configured.config.session.oidcTtlSeconds * 1000,
identity.tokenExpiresAt.getTime() - now.getTime(),
);
if (!Number.isSafeInteger(absoluteTtlMs) || absoluteTtlMs <= 0) return oidcCallbackFailed(reply);
const created = await deps.sessionStore.create({
principal: {
issuer: identity.issuer,
subject: identity.subject,
...(identity.displayName === undefined ? {} : { displayName: identity.displayName }),
roles,
permissions: rolesToPermissions(roles),
isAdmin: roles.includes("admin"),
},
method: "oidc",
remembered: false,
authConfigRevision: configured.loaded.revision,
idleTtlMs: configured.config.session.regularIdleSeconds * 1000,
absoluteTtlMs,
}, now);
reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.loaded, false));
return reply.redirect(state.returnTo);
} catch {
return oidcCallbackFailed(reply);
}
});
app.post("/auth/logout", async (request, reply) => {
const token = request.authSessionToken;
if (!token || !deps.sessionStore) return unavailable(reply);
try {
await deps.sessionStore.revoke(token);
reply.clearCookie(sessionCookieName(), cookieOptions(request.authConfigSnapshot, false));
return reply.code(204).send();
} catch {
return unavailable(reply);
}
});
app.get("/me", async (request, reply) => {
const principal = requirePermission(request, reply, "session.use");
if (!isPrincipalContext(principal)) return principal;
const session = request.authSession;
const token = request.authSessionToken;
if (!session || !token) {
return {
issuer: principal.issuer,
subject: principal.subject,
...(principal.displayName === undefined ? {} : { displayName: principal.displayName }),
roles: principal.roles,
permissions: principal.permissions,
isAdmin: principal.isAdmin,
csrfToken: null,
session: null,
};
}
try {
return {
issuer: principal.issuer,
subject: principal.subject,
...(principal.displayName === undefined ? {} : { displayName: principal.displayName }),
roles: principal.roles,
permissions: principal.permissions,
isAdmin: principal.isAdmin,
csrfToken: deriveCsrfToken(token),
session: {
method: session.method,
remembered: session.remembered,
idleExpiresAt: session.idleExpiresAt,
absoluteExpiresAt: session.absoluteExpiresAt,
},
};
} catch {
return unavailable(reply);
}
});
}
function currentLocalConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies):
| {
revision: string;
origin: string;
session: { regularTtlSeconds: number; regularIdleSeconds: number; rememberTtlSeconds: number; rememberIdleSeconds: number };
registry: LocalUserRegistry;
kind: "local";
}
| { kind: "not_local" }
| { kind: "unavailable" } {
try {
if (!loaded) return { kind: "unavailable" };
if (loaded.value.mode !== "local") return { kind: "not_local" };
const registry = deps.resolveLocalUserRegistry?.(loaded) ?? deps.localUserRegistry;
if (!registry) return { kind: "unavailable" };
const url = new URL(loaded.value.publicUrl);
return {
kind: "local",
revision: loaded.revision,
origin: url.origin,
session: loaded.value.session,
registry,
};
} catch {
return { kind: "unavailable" };
}
}
function cookieOptions(snapshot: LoadedAuthConfig | undefined, remembered: boolean) {
let secure = false;
try {
secure = snapshot !== undefined && new URL(snapshot.value.publicUrl).protocol === "https:";
} catch {
// Invalid auth configurations are rejected before they can reach this route.
}
return {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure,
...(remembered ? { maxAge: REMEMBER_COOKIE_SECONDS } : {}),
};
}
function currentOidcConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies):
| { loaded: LoadedAuthConfig; config: OidcAuthenticationConfig; protocol: OidcProtocol }
| undefined {
if (!loaded || loaded.value.mode !== "oidc") return undefined;
const protocol = deps.resolveOidcProtocol?.(loaded) ?? deps.oidcProtocol;
return protocol ? { loaded, config: loaded.value, protocol } : undefined;
}
function randomOidcValue(): string {
return randomBytes(32).toString("base64url");
}
function oidcTransactionDigest(value: string): Buffer {
return createHash("sha256").update(value, "utf8").digest();
}
function oidcTransactionMatches(value: string | undefined, expectedDigest: string): boolean {
const canonical = typeof value === "string" && OIDC_VALUE_PATTERN.test(value);
const expectedCanonical = /^[a-f0-9]{64}$/.test(expectedDigest);
const supplied = oidcTransactionDigest(canonical ? value : "");
const expected = expectedCanonical ? Buffer.from(expectedDigest, "hex") : Buffer.alloc(32);
const matches = timingSafeEqual(supplied, expected);
return canonical && expectedCanonical && matches;
}
function oidcTransactionCookieProfile(loaded: LoadedAuthConfig): OidcTransactionCookieProfile | undefined {
try {
if (loaded.value.mode !== "oidc") return undefined;
const publicUrl = parseConfiguredTransportUrl(loaded.value.publicUrl, {
allowLoopbackHttp: true,
originOnly: true,
});
if (!publicUrl) return undefined;
if (publicUrl.protocol === "https:") return OIDC_TRANSACTION_COOKIE_PROFILES.https;
if (publicUrl.protocol === "http:") return OIDC_TRANSACTION_COOKIE_PROFILES.loopback_http;
return undefined;
} catch {
return undefined;
}
}
function oidcTransactionCookieProfileForTransport(
transport: OidcTransactionTransport | undefined,
): OidcTransactionCookieProfile | undefined {
return transport === "https" || transport === "loopback_http"
? OIDC_TRANSACTION_COOKIE_PROFILES[transport]
: undefined;
}
function otherOidcTransactionCookieProfile(
profile: OidcTransactionCookieProfile,
): OidcTransactionCookieProfile {
return profile.transport === "https"
? OIDC_TRANSACTION_COOKIE_PROFILES.loopback_http
: OIDC_TRANSACTION_COOKIE_PROFILES.https;
}
function oidcTransactionCookieOptions(profile: OidcTransactionCookieProfile) {
return {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure: profile.secure,
maxAge: OIDC_TRANSACTION_COOKIE_SECONDS,
};
}
function clearOidcTransactionCookie(reply: FastifyReply, profile: OidcTransactionCookieProfile): void {
reply.clearCookie(profile.name, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: profile.secure,
});
}
function clearOidcTransactionCookies(reply: FastifyReply, preferred?: OidcTransactionCookieProfile): void {
if (preferred) {
clearOidcTransactionCookie(reply, preferred);
clearOidcTransactionCookie(reply, otherOidcTransactionCookieProfile(preferred));
return;
}
clearOidcTransactionCookie(reply, OIDC_TRANSACTION_COOKIE_PROFILES.https);
clearOidcTransactionCookie(reply, OIDC_TRANSACTION_COOKIE_PROFILES.loopback_http);
}
/**
* Browser parsers choose one duplicate cookie value differently. Parse just our two fixed names
* from the raw header and reject duplicates or a cross-transport sibling before hashing.
*/
function readOidcTransactionCookie(
request: FastifyRequest,
expected: OidcTransactionCookieProfile | undefined,
): string | undefined {
const raw = request.headers.cookie;
if (!expected || typeof raw !== "string" || raw.length > 4096 || Array.isArray(raw)) return undefined;
let transactionCookies = 0;
let expectedCookies = 0;
let expectedValue: string | undefined;
for (const part of raw.split(";")) {
const match = /^\s*(__Host-thothii_oidc_tx|thothii_oidc_tx)(?:=([^;]*))?\s*$/.exec(part);
if (!match) continue;
transactionCookies += 1;
if (match[1] !== expected.name) continue;
expectedCookies += 1;
expectedValue = match[2];
}
return transactionCookies === 1 && expectedCookies === 1 ? expectedValue : undefined;
}
function oidcCallbackUrl(
request: FastifyRequest,
publicUrl: string | undefined,
): { currentUrl?: URL; state?: string } {
if (request.url.length > MAX_OIDC_CALLBACK_QUERY_LENGTH) {
return { state: oversizedOidcCallbackState(request.url) };
}
let supplied: URL;
try {
supplied = new URL(request.url, "http://callback.invalid");
} catch {
return {};
}
if (supplied.pathname !== "/auth/oidc/callback") return {};
const allowed = new Set(["code", "state", "error", "error_description", "error_uri", "iss"]);
const copied = new URLSearchParams();
let state: string | undefined;
let valid = true;
for (const [key, value] of supplied.searchParams) {
if (key === "state" && state === undefined && OIDC_VALUE_PATTERN.test(value)) state = value;
if (!allowed.has(key) || value.length > 2048 || /\p{Cc}/u.test(value) || copied.has(key)) {
valid = false;
continue;
}
copied.set(key, value);
}
if (!state || !valid || copied.get("state") !== state || publicUrl === undefined) return { state };
let target: URL;
try {
target = new URL(OIDC_CALLBACK_PATH, publicUrl);
} catch {
return { state };
}
target.search = copied.toString();
return { currentUrl: target, state };
}
function oversizedOidcCallbackState(rawUrl: string): string | undefined {
const prefix = "/auth/oidc/callback?";
if (!rawUrl.startsWith(prefix)) return undefined;
const boundedQuery = rawUrl.slice(prefix.length, MAX_OIDC_CALLBACK_QUERY_LENGTH);
let offset = 0;
while (offset < boundedQuery.length) {
const separator = boundedQuery.indexOf("&", offset);
const end = separator === -1 ? boundedQuery.length : separator;
const parameter = boundedQuery.slice(offset, end);
if (parameter.startsWith("state=")) {
const value = parameter.slice("state=".length);
if (OIDC_VALUE_PATTERN.test(value)) return value;
}
if (separator === -1) break;
offset = separator + 1;
}
return undefined;
}
function oidcCallbackFailed(reply: FastifyReply) {
return reply.code(401).send({ code: "oidc_callback_failed", error: "OIDC sign-in could not be completed" });
}
function oidcRoles(groups: readonly string[], config: OidcAuthenticationConfig): Role[] {
const roles = new Set<Role>();
for (const group of groups) {
for (const role of config.authorization.groupRoles[group] ?? []) roles.add(role);
}
return [...roles];
}
function loginPayload(request: FastifyRequest): LoginPayload {
const body = request.body;
if (!body || typeof body !== "object" || Array.isArray(body)) return { username: "", password: "", remember: false };
const input = body as Record<string, unknown>;
return {
username: typeof input.username === "string" && input.username.length <= MAX_USERNAME_LENGTH ? input.username : "",
password: typeof input.password === "string" && input.password.length <= MAX_PASSWORD_LENGTH ? input.password : "",
remember: input.remember === true,
};
}
function boundedAddress(address: string): string {
return typeof address === "string" && address.length > 0 && address.length <= 128 ? address : "unknown";
}
function argon2SafePassword(value: string): string {
const typed = value as string & { isWellFormed?: () => boolean };
const wellFormed = typeof typed.isWellFormed === "function"
? typed.isWellFormed()
: !/[\uD800-\uDFFF]/.test(value);
const bytes = Buffer.byteLength(value, "utf8");
if (wellFormed && bytes >= 12 && bytes <= MAX_PASSWORD_LENGTH) return value;
// A per-attempt random value preserves the Argon2 work without turning an invalid input into
// a reusable password that could happen to match a user's configured secret.
return randomBytes(32).toString("base64url");
}
function invalidCredentials(reply: FastifyReply): FastifyReply {
return reply.code(401).send({ code: "invalid_credentials", error: "Invalid username or password" });
}
function loginLimited(reply: FastifyReply): FastifyReply {
return reply.code(429).send({ code: "login_rate_limited", error: "Too many login attempts" });
}
function unavailable(reply: FastifyReply): FastifyReply {
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
}
+19
View File
@@ -0,0 +1,19 @@
export const AUTHENTICATION_SECRET_LIMITS = Object.freeze({
THT_OIDC_CLIENT_SECRET: 4096,
THT_AUTHENTIK_API_TOKEN: 16 * 1024,
} as const);
export type AuthenticationSecretReference = keyof typeof AUTHENTICATION_SECRET_LIMITS;
export function isAuthenticationSecretReference(value: string): value is AuthenticationSecretReference {
return Object.prototype.hasOwnProperty.call(AUTHENTICATION_SECRET_LIMITS, value);
}
/** One policy shared by bundle loading, runtime adapters, and static diagnostics. */
export function isUsableAuthenticationSecret(
name: AuthenticationSecretReference,
value: unknown,
): value is string {
return typeof value === "string" && value.length > 0
&& value.length <= AUTHENTICATION_SECRET_LIMITS[name] && !/\p{Cc}/u.test(value);
}
+754
View File
@@ -0,0 +1,754 @@
import { createHash, hkdfSync, randomBytes } from "node:crypto";
import { z } from "zod";
import type { PrincipalContext } from "./principal.js";
import type {
AuthSessionRecord,
OidcStateRecord,
OidcTransactionTransport,
Permission,
Role,
} from "./types.js";
import {
createPosixAuthStorageBridge,
createWindowsAuthStorageBridge,
type WindowsAuthStorageBridge,
} from "./windows-auth-storage.js";
const TOKEN_BYTES = 32;
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const DIGEST_FILENAME_PATTERN = /^[a-f0-9]{64}\.json$/;
const CLAIM_FILENAME_PATTERN = /^[a-f0-9]{64}\.claim$/;
const OIDC_SLOT_FILENAME_PATTERN = /^slot-(\d{2})\.json$/;
const MAX_SESSION_RECORD_BYTES = 16 * 1024;
const MAX_OIDC_STATE_RECORD_BYTES = 8 * 1024;
const MAX_OIDC_SLOT_RECORD_BYTES = 512;
const MAX_TTL_MS = 365 * 24 * 60 * 60 * 1000;
const OIDC_STATE_TTL_MS = 10 * 60 * 1000;
const OIDC_STATE_CAPACITY = 64;
const MAX_OIDC_STORAGE_ENTRIES = OIDC_STATE_CAPACITY * 3;
const MAX_SESSION_PRUNE_ENTRIES = 512;
const TOUCH_INTERVAL_MS = 5 * 60 * 1000;
const CSRF_CONTEXT = Buffer.from("thothii-csrf-v1", "utf8");
const EMPTY_HKDF_SALT = Buffer.alloc(0);
const ROLES = ["user", "admin"] as const;
const PERMISSIONS = [
"session.use", "session.read_all", "session.manage_all", "settings.manage",
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
] as const satisfies readonly Permission[];
const invalid = (): Error => new Error("auth_session_store_invalid");
export interface SessionCreateInput {
principal: PrincipalContext;
method: "local" | "oidc" | "upstream";
remembered: boolean;
userAuthRevision?: number;
authConfigRevision: string;
idleTtlMs: number;
absoluteTtlMs: number;
}
export interface CreatedAuthSession {
token: string;
csrfToken: string;
record: AuthSessionRecord;
}
export interface OidcStateCreateInput {
nonce: string;
codeVerifier: string;
returnTo: "/";
authConfigRevision: string;
issuer: string;
browserTransactionDigest: string;
browserTransactionTransport: OidcTransactionTransport;
}
export interface CreatedOidcState {
state: string;
record: OidcStateRecord;
}
export interface LocalSessionUser {
enabled: boolean;
authRevision: number;
roles: readonly Role[];
}
export interface CurrentLocalSessionUser {
revision: string;
user: LocalSessionUser | undefined;
}
/** Operational validity-source failures must not masquerade as revoked credentials. */
export class AuthSessionOperationalError extends Error {
constructor() {
super("auth_session_operational_error");
}
}
export class OidcStateCapacityError extends Error {
constructor() {
super("auth_oidc_state_capacity");
}
}
/**
* The route layer supplies the current installation revision and local-registry lookup.
* Supplying this hook makes every resolve an authorization-generation check.
*/
export interface AuthSessionValidity {
currentAuthConfigRevision(): string | Promise<string>;
findLocalUser?(subject: string): LocalSessionUser | undefined | Promise<LocalSessionUser | undefined>;
currentLocalUser?(subject: string): CurrentLocalSessionUser | Promise<CurrentLocalSessionUser>;
}
export interface AuthSessionStore {
create(input: SessionCreateInput, now?: Date): Promise<CreatedAuthSession>;
resolve(token: string, now?: Date, validity?: AuthSessionValidity): Promise<AuthSessionRecord | undefined>;
touch(token: string, now?: Date): Promise<void>;
revoke(token: string): Promise<void>;
prune(now?: Date): Promise<number>;
createOidcState(input: OidcStateCreateInput, now?: Date): Promise<CreatedOidcState>;
consumeOidcState(state: string, now?: Date): Promise<OidcStateRecord | undefined>;
}
/** Narrow test seams for the native tht-backed storage adaptors. */
export interface FileAuthSessionStoreOptions {
windowsStorageBridge?: WindowsAuthStorageBridge;
/** Test seam; production uses the bounded hidden tht bridge for every POSIX record operation. */
posixStorageBridge?: WindowsAuthStorageBridge;
/** Test-only capacity seam; production always uses the fixed 64-state bound. */
oidcStateCapacity?: number;
}
interface SessionDirectoryPage {
entries: string[];
more: boolean;
}
const text = z.string().min(1).max(512).refine((value) => !/[\u0000-\u001f\u007f]/.test(value));
const timestamp = z.string().length(24).refine((value) => {
const parsed = Date.parse(value);
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
});
const role = z.enum(ROLES);
const permission = z.enum(PERMISSIONS);
const distinct = <T>(items: readonly T[]): boolean => new Set(items).size === items.length;
const sessionRecordSchema = z.strictObject({
version: z.literal(1),
issuer: text,
subject: text,
displayName: text.optional(),
method: z.enum(["local", "oidc", "upstream"]),
roles: z.array(role).max(ROLES.length).refine(distinct),
permissions: z.array(permission).max(PERMISSIONS.length).refine(distinct),
userAuthRevision: z.number().int().positive().safe().optional(),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
remembered: z.boolean(),
createdAt: timestamp,
lastSeenAt: timestamp,
idleExpiresAt: timestamp,
absoluteExpiresAt: timestamp,
}).superRefine((record, context) => {
const createdAt = Date.parse(record.createdAt);
const lastSeenAt = Date.parse(record.lastSeenAt);
const idleExpiresAt = Date.parse(record.idleExpiresAt);
const absoluteExpiresAt = Date.parse(record.absoluteExpiresAt);
if (lastSeenAt < createdAt || idleExpiresAt < lastSeenAt || idleExpiresAt > absoluteExpiresAt
|| absoluteExpiresAt < createdAt || absoluteExpiresAt - createdAt > MAX_TTL_MS) {
context.addIssue({ code: "custom", message: "invalid session lifetime" });
}
if (record.method === "local" && record.userAuthRevision === undefined) {
context.addIssue({ code: "custom", message: "local revision is required" });
}
if (record.method !== "local" && record.userAuthRevision !== undefined) {
context.addIssue({ code: "custom", message: "non-local revision is forbidden" });
}
});
const oidcStateRecordSchema = z.strictObject({
version: z.literal(1),
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
returnTo: z.literal("/"),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
// Existing ten-minute records from before this field was introduced can be consumed and
// rejected by the route. New records always receive the required input field below.
browserTransactionTransport: z.enum(["https", "loopback_http"]).optional(),
capacitySlot: z.number().int().min(0).max(OIDC_STATE_CAPACITY - 1).optional(),
createdAt: timestamp,
expiresAt: timestamp,
}).superRefine((record, context) => {
const lifetime = Date.parse(record.expiresAt) - Date.parse(record.createdAt);
if (lifetime <= 0 || lifetime > OIDC_STATE_TTL_MS) {
context.addIssue({ code: "custom", message: "invalid OIDC state lifetime" });
}
});
const oidcSlotRecordSchema = z.strictObject({
version: z.literal(1),
stateFilename: z.string().regex(DIGEST_FILENAME_PATTERN),
expiresAt: timestamp,
});
const sessionInputSchema = z.strictObject({
principal: z.strictObject({
issuer: text,
subject: text,
displayName: text.optional(),
roles: z.array(role).max(ROLES.length).refine(distinct),
permissions: z.array(permission).max(PERMISSIONS.length).refine(distinct),
isAdmin: z.boolean(),
}),
method: z.enum(["local", "oidc", "upstream"]),
remembered: z.boolean(),
userAuthRevision: z.number().int().positive().safe().optional(),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
idleTtlMs: z.number().int().min(1).max(MAX_TTL_MS),
absoluteTtlMs: z.number().int().min(1).max(MAX_TTL_MS),
}).superRefine((input, context) => {
if (input.principal.isAdmin !== input.principal.roles.includes("admin")) {
context.addIssue({ code: "custom", message: "principal roles disagree" });
}
if (input.method === "local" && input.userAuthRevision === undefined) {
context.addIssue({ code: "custom", message: "local revision is required" });
}
if (input.method !== "local" && input.userAuthRevision !== undefined) {
context.addIssue({ code: "custom", message: "non-local revision is forbidden" });
}
});
const oidcStateInputSchema = z.strictObject({
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
returnTo: z.literal("/"),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
browserTransactionTransport: z.enum(["https", "loopback_http"]),
});
function canonicalRawValue(value: string): boolean {
if (typeof value !== "string" || !TOKEN_PATTERN.test(value)) return false;
try {
const bytes = Buffer.from(value, "base64url");
return bytes.length === TOKEN_BYTES && bytes.toString("base64url") === value;
} catch {
return false;
}
}
function digestFilename(rawValue: string): string {
return `${createHash("sha256").update(rawValue).digest("hex")}.json`;
}
function claimFilename(filename: string): string {
if (!DIGEST_FILENAME_PATTERN.test(filename)) throw invalid();
return filename.slice(0, -".json".length) + ".claim";
}
function oidcSlotFilename(index: number): string {
if (!Number.isInteger(index) || index < 0 || index >= OIDC_STATE_CAPACITY) throw invalid();
return `slot-${String(index).padStart(2, "0")}.json`;
}
function oidcSlotIndex(filename: string): number | undefined {
const match = OIDC_SLOT_FILENAME_PATTERN.exec(filename);
if (!match) return undefined;
const index = Number(match[1]);
return Number.isInteger(index) && index >= 0 && index < OIDC_STATE_CAPACITY ? index : undefined;
}
function parseSessionRecord(source: string): AuthSessionRecord {
try {
return sessionRecordSchema.parse(JSON.parse(source)) as AuthSessionRecord;
} catch {
throw invalid();
}
}
function parseOidcStateRecord(source: string): OidcStateRecord {
try {
return oidcStateRecordSchema.parse(JSON.parse(source)) as OidcStateRecord;
} catch {
throw invalid();
}
}
type OidcSlotRecord = z.infer<typeof oidcSlotRecordSchema>;
function parseOidcSlotRecord(source: string): OidcSlotRecord {
try {
return oidcSlotRecordSchema.parse(JSON.parse(source));
} catch {
throw invalid();
}
}
function parseWindowsRecord<T>(contents: Buffer, maximumBytes: number, parse: (source: string) => T): T {
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > maximumBytes) throw invalid();
try {
return parse(new TextDecoder("utf-8", { fatal: true }).decode(contents));
} catch {
throw invalid();
}
}
interface StoredOidcSlot {
filename: string;
index: number;
record: OidcSlotRecord;
}
function serialize(record: AuthSessionRecord | OidcStateRecord | OidcSlotRecord, maximumBytes: number): Buffer {
const contents = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
if (contents.length > maximumBytes) throw invalid();
return contents;
}
function dateMilliseconds(now: Date): number {
if (!(now instanceof Date) || !Number.isFinite(now.getTime())) throw invalid();
return now.getTime();
}
function isoAt(milliseconds: number): string {
if (!Number.isSafeInteger(milliseconds) || !Number.isFinite(milliseconds)) throw invalid();
try {
return new Date(milliseconds).toISOString();
} catch {
throw invalid();
}
}
function sessionExpired(record: AuthSessionRecord, nowMs: number): boolean {
return nowMs >= Date.parse(record.idleExpiresAt) || nowMs >= Date.parse(record.absoluteExpiresAt);
}
function oidcStateExpired(record: OidcStateRecord, nowMs: number): boolean {
return nowMs >= Date.parse(record.expiresAt);
}
function equalRoleSets(left: readonly Role[], right: readonly Role[]): boolean {
if (!distinct(left) || !distinct(right) || left.length !== right.length) return false;
if (!left.every((value) => ROLES.includes(value)) || !right.every((value) => ROLES.includes(value))) return false;
return [...left].sort().every((value, index) => value === [...right].sort()[index]);
}
function validLocalUser(user: LocalSessionUser | undefined, record: AuthSessionRecord): boolean {
return user !== undefined && user.enabled === true && Number.isSafeInteger(user.authRevision)
&& user.authRevision > 0 && user.authRevision === record.userAuthRevision
&& equalRoleSets(user.roles, record.roles);
}
async function recordIsCurrent(record: AuthSessionRecord, validity: AuthSessionValidity | undefined): Promise<boolean> {
// A root-only store remains useful for creation/diagnostics, but is intentionally incapable
// of authenticating a principal. Task 8 must supply config and local-registry dependencies.
if (!validity) return false;
if (record.method === "local" && validity.currentLocalUser) {
const current = await validity.currentLocalUser(record.subject);
return typeof current.revision === "string" && current.revision === record.authConfigRevision
&& validLocalUser(current.user, record);
}
const revision = await validity.currentAuthConfigRevision();
if (typeof revision !== "string" || revision !== record.authConfigRevision) return false;
if (record.method !== "local") return true;
return validity.findLocalUser === undefined ? false : validLocalUser(await validity.findLocalUser(record.subject), record);
}
const locks = new Map<string, Promise<void>>();
async function withLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
const previous = locks.get(key) ?? Promise.resolve();
let release: (() => void) | undefined;
const current = new Promise<void>((resolve) => { release = resolve; });
locks.set(key, current);
await previous;
try {
return await operation();
} finally {
release?.();
if (locks.get(key) === current) locks.delete(key);
}
}
function lockKey(root: string, directory: "sessions" | "oidc", filename: string): string {
return `${root}\0${directory}\0${filename}`;
}
/** Derive a one-way, domain-separated 256-bit CSRF value without persisting it. */
export function deriveCsrfToken(sessionToken: string): string {
if (!canonicalRawValue(sessionToken)) throw invalid();
try {
const sessionBytes = Buffer.from(sessionToken, "base64url");
return Buffer.from(hkdfSync("sha256", sessionBytes, EMPTY_HKDF_SALT, CSRF_CONTEXT, TOKEN_BYTES))
.toString("base64url");
} catch {
throw invalid();
}
}
export function createFileAuthSessionStore(
root: string,
validity?: AuthSessionValidity,
options: FileAuthSessionStoreOptions = {},
): AuthSessionStore {
const oidcStateCapacity = options.oidcStateCapacity ?? OIDC_STATE_CAPACITY;
if (!Number.isInteger(oidcStateCapacity) || oidcStateCapacity < 1 || oidcStateCapacity > OIDC_STATE_CAPACITY) {
throw invalid();
}
const rawStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: options.posixStorageBridge ?? createPosixAuthStorageBridge();
// A malformed or failed helper must be indistinguishable from any other storage failure to
// callers. This also keeps narrow test seams from accidentally exposing transport details.
const storage: WindowsAuthStorageBridge = {
validateRoot: async (value) => { try { await rawStorage.validateRoot(value); } catch { throw invalid(); } },
ensureLayout: async (value) => { try { await rawStorage.ensureLayout(value); } catch { throw invalid(); } },
readAuthConfig: (value) => { try { return rawStorage.readAuthConfig(value); } catch { throw invalid(); } },
readLocalUsers: async (value) => { try { return await rawStorage.readLocalUsers(value); } catch { throw invalid(); } },
create: async (...args) => { try { return await rawStorage.create(...args); } catch { throw invalid(); } },
read: async (...args) => { try { return await rawStorage.read(...args); } catch { throw invalid(); } },
replace: async (...args) => { try { await rawStorage.replace(...args); } catch { throw invalid(); } },
remove: async (...args) => { try { return await rawStorage.remove(...args); } catch { throw invalid(); } },
list: async (...args) => { try { return await rawStorage.list(...args); } catch { throw invalid(); } },
listPage: async (...args) => { try { return await rawStorage.listPage(...args); } catch { throw invalid(); } },
claimConsume: async (...args) => { try { return await rawStorage.claimConsume(...args); } catch { throw invalid(); } },
readClaim: async (...args) => { try { return await rawStorage.readClaim(...args); } catch { throw invalid(); } },
removeClaim: async (...args) => { try { return await rawStorage.removeClaim(...args); } catch { throw invalid(); } },
};
let sessionPruneCursor: string | undefined;
function requiredStorage(): WindowsAuthStorageBridge {
if (!storage) throw invalid();
return storage;
}
async function ordinarySessionPage(after: string | undefined): Promise<SessionDirectoryPage> {
const page = await requiredStorage().listPage(root, "sessions", after, MAX_SESSION_PRUNE_ENTRIES);
if (!page || !Array.isArray(page.entries) || typeof page.more !== "boolean") throw invalid();
return { entries: page.entries.map((entry) => entry.name), more: page.more };
}
function nextSessionPruneCursor(page: SessionDirectoryPage, after: string | undefined): string | undefined {
if (!Array.isArray(page.entries) || typeof page.more !== "boolean"
|| page.entries.length > MAX_SESSION_PRUNE_ENTRIES) throw invalid();
const seen = new Set<string>();
let previous = after;
for (const filename of page.entries) {
if (!DIGEST_FILENAME_PATTERN.test(filename) || seen.has(filename)
|| (previous !== undefined && filename <= previous)) throw invalid();
seen.add(filename);
previous = filename;
}
if (!page.more) return undefined;
if (page.entries.length !== MAX_SESSION_PRUNE_ENTRIES || previous === undefined || previous === after) throw invalid();
return previous;
}
async function pruneOrdinarySessions(nowMs: number): Promise<number> {
const after = sessionPruneCursor;
const page = await ordinarySessionPage(after);
const next = nextSessionPruneCursor(page, after);
let removed = 0;
const bridge = requiredStorage();
for (const filename of page.entries) {
const contents = await bridge.read(root, "sessions", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", filename)) removed += 1;
}
sessionPruneCursor = next;
return removed;
}
async function oidcStorageEntries(): Promise<string[]> {
const entries = (await requiredStorage().list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES)).map((entry) => entry.name);
if (entries.length > MAX_OIDC_STORAGE_ENTRIES) throw invalid();
if (entries.some((entry) => !DIGEST_FILENAME_PATTERN.test(entry)
&& !CLAIM_FILENAME_PATTERN.test(entry) && oidcSlotIndex(entry) === undefined)) throw invalid();
return entries;
}
async function storedOidcSlots(suppliedEntries?: string[]): Promise<StoredOidcSlot[]> {
const entries = suppliedEntries ?? await oidcStorageEntries();
const slots: StoredOidcSlot[] = [];
const stateFilenames = new Set<string>();
for (const filename of entries) {
const index = oidcSlotIndex(filename);
if (index === undefined) continue;
const contents = await requiredStorage().read(root, "oidc", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
if (stateFilenames.has(record.stateFilename)) throw invalid();
stateFilenames.add(record.stateFilename);
slots.push({ filename, index, record });
}
return slots;
}
async function removeOidcSlot(slot: StoredOidcSlot): Promise<void> {
const current = await requiredStorage().read(root, "oidc", slot.filename);
if (!current) return;
const record = parseWindowsRecord(current, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
if (record.stateFilename !== slot.record.stateFilename || record.expiresAt !== slot.record.expiresAt
|| !await requiredStorage().remove(root, "oidc", slot.filename)) throw invalid();
}
async function releaseOidcSlot(index: number | undefined, stateFilename: string): Promise<void> {
if (index === undefined) return;
const filename = oidcSlotFilename(index);
const slot = (await storedOidcSlots([filename]))[0];
if (!slot || slot.record.stateFilename !== stateFilename) throw invalid();
await removeOidcSlot(slot);
}
async function reserveOidcSlot(stateFilename: string, expiresAt: string): Promise<number> {
const entries = await oidcStorageEntries();
const slots = await storedOidcSlots(entries);
const representedStates = new Set(slots.map((slot) => slot.record.stateFilename));
const legacyStates = new Set<string>();
for (const entry of entries) {
const filename = CLAIM_FILENAME_PATTERN.test(entry)
? `${entry.slice(0, -".claim".length)}.json`
: entry;
if (DIGEST_FILENAME_PATTERN.test(filename) && !representedStates.has(filename)) legacyStates.add(filename);
}
const availableSlotCount = oidcStateCapacity - legacyStates.size;
if (availableSlotCount <= 0) throw new OidcStateCapacityError();
const occupied = new Set(slots.map((slot) => slot.index));
const record: OidcSlotRecord = { version: 1, stateFilename, expiresAt };
const contents = serialize(record, MAX_OIDC_SLOT_RECORD_BYTES);
for (let index = 0; index < availableSlotCount; index += 1) {
if (occupied.has(index)) continue;
const filename = oidcSlotFilename(index);
const created = await requiredStorage().create(root, "oidc", filename, contents);
if (created) return index;
}
throw new OidcStateCapacityError();
}
async function createSession(input: SessionCreateInput, now = new Date()): Promise<CreatedAuthSession> {
const nowMs = dateMilliseconds(now);
let validated: z.infer<typeof sessionInputSchema>;
try {
validated = sessionInputSchema.parse(input);
} catch {
throw invalid();
}
const absoluteExpiresMs = nowMs + validated.absoluteTtlMs;
const idleExpiresMs = Math.min(nowMs + validated.idleTtlMs, absoluteExpiresMs);
if (!Number.isSafeInteger(absoluteExpiresMs) || !Number.isSafeInteger(idleExpiresMs)) throw invalid();
const record: AuthSessionRecord = {
version: 1,
issuer: validated.principal.issuer,
subject: validated.principal.subject,
...(validated.principal.displayName === undefined ? {} : { displayName: validated.principal.displayName }),
method: validated.method,
roles: [...validated.principal.roles],
permissions: [...validated.principal.permissions],
...(validated.userAuthRevision === undefined ? {} : { userAuthRevision: validated.userAuthRevision }),
authConfigRevision: validated.authConfigRevision,
remembered: validated.remembered,
createdAt: isoAt(nowMs),
lastSeenAt: isoAt(nowMs),
idleExpiresAt: isoAt(idleExpiresMs),
absoluteExpiresAt: isoAt(absoluteExpiresMs),
};
const contents = serialize(record, MAX_SESSION_RECORD_BYTES);
const bridge = requiredStorage();
for (let attempt = 0; attempt < 8; attempt += 1) {
const token = randomBytes(TOKEN_BYTES).toString("base64url");
const filename = digestFilename(token);
if (await bridge.create(root, "sessions", filename, contents)) {
return { token, csrfToken: deriveCsrfToken(token), record };
}
}
throw invalid();
}
async function resolveSession(
token: string,
now = new Date(),
requestValidity = validity,
): Promise<AuthSessionRecord | undefined> {
if (!canonicalRawValue(token)) return undefined;
const nowMs = dateMilliseconds(now);
const filename = digestFilename(token);
return withLock(lockKey(root, "sessions", filename), async () => {
const bridge = requiredStorage();
const contents = await bridge.read(root, "sessions", filename);
if (!contents) return undefined;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs)) {
await bridge.remove(root, "sessions", filename);
return undefined;
}
try {
if (await recordIsCurrent(record, requestValidity)) return record;
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
await bridge.remove(root, "sessions", filename);
throw invalid();
}
await bridge.remove(root, "sessions", filename);
return undefined;
});
}
async function touchSession(token: string, now = new Date()): Promise<void> {
if (!canonicalRawValue(token)) return;
const nowMs = dateMilliseconds(now);
const filename = digestFilename(token);
await withLock(lockKey(root, "sessions", filename), async () => {
const bridge = requiredStorage();
const contents = await bridge.read(root, "sessions", filename);
if (!contents) return;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs)) {
await bridge.remove(root, "sessions", filename);
return undefined;
}
const lastSeenMs = Date.parse(record.lastSeenAt);
if (nowMs <= lastSeenMs || nowMs - lastSeenMs < TOUCH_INTERVAL_MS) return;
const idleWindowMs = Date.parse(record.idleExpiresAt) - lastSeenMs;
if (idleWindowMs <= 0 || idleWindowMs > MAX_TTL_MS) throw invalid();
const touched: AuthSessionRecord = {
...record,
lastSeenAt: isoAt(nowMs),
idleExpiresAt: isoAt(Math.min(nowMs + idleWindowMs, Date.parse(record.absoluteExpiresAt))),
};
await bridge.replace(root, "sessions", filename, serialize(touched, MAX_SESSION_RECORD_BYTES));
});
}
async function revokeSession(token: string): Promise<void> {
if (!canonicalRawValue(token)) return;
const filename = digestFilename(token);
await withLock(lockKey(root, "sessions", filename), async () => {
await requiredStorage().remove(root, "sessions", filename);
});
}
async function pruneOidcStates(nowMs: number): Promise<number> {
const bridge = requiredStorage();
const oidcEntries = await bridge.list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES);
if (oidcEntries.length > MAX_OIDC_STORAGE_ENTRIES) throw invalid();
const stateNames = new Set(oidcEntries
.filter((entry) => DIGEST_FILENAME_PATTERN.test(entry.name))
.map((entry) => entry.name));
const claimEntries = new Map(oidcEntries
.filter((entry) => CLAIM_FILENAME_PATTERN.test(entry.name))
.map((entry) => [entry.name, entry]));
const slotEntries = oidcEntries.filter((entry) => oidcSlotIndex(entry.name) !== undefined);
if (stateNames.size + claimEntries.size + slotEntries.length !== oidcEntries.length) throw invalid();
let removed = 0;
for (const filename of stateNames) {
const claim = claimFilename(filename);
const contents = claimEntries.has(claim)
? await bridge.readClaim(root, filename)
: await bridge.read(root, "oidc", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
if (oidcStateExpired(record, nowMs)) {
const didRemove = claimEntries.has(claim)
? await bridge.removeClaim(root, filename)
: await bridge.remove(root, "oidc", filename);
if (didRemove) removed += 1;
}
}
for (const [claim, entry] of claimEntries) {
const filename = `${claim.slice(0, -".claim".length)}.json`;
if (stateNames.has(filename)) continue;
if (nowMs >= entry.modifiedUnixMs + OIDC_STATE_TTL_MS
&& await bridge.remove(root, "oidc", claim)) removed += 1;
}
for (const entry of slotEntries) {
const contents = await bridge.read(root, "oidc", entry.name);
if (!contents) continue;
const slot = parseWindowsRecord(contents, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
if (nowMs < Date.parse(slot.expiresAt)) continue;
const claim = claimFilename(slot.stateFilename);
const stateContents = claimEntries.has(claim)
? await bridge.readClaim(root, slot.stateFilename)
: await bridge.read(root, "oidc", slot.stateFilename);
if (stateContents) {
const state = parseWindowsRecord(stateContents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
if (!oidcStateExpired(state, nowMs)) throw invalid();
const didRemove = claimEntries.has(claim)
? await bridge.removeClaim(root, slot.stateFilename)
: await bridge.remove(root, "oidc", slot.stateFilename);
if (didRemove) removed += 1;
}
if (!await bridge.remove(root, "oidc", entry.name)) throw invalid();
}
return removed;
}
async function createOidcState(input: OidcStateCreateInput, now = new Date()): Promise<CreatedOidcState> {
const nowMs = dateMilliseconds(now);
let validated: z.infer<typeof oidcStateInputSchema>;
try {
validated = oidcStateInputSchema.parse(input);
} catch {
throw invalid();
}
const expiresMs = nowMs + OIDC_STATE_TTL_MS;
if (!Number.isSafeInteger(expiresMs)) throw invalid();
return withLock(lockKey(root, "oidc", "capacity"), async () => {
await pruneOidcStates(nowMs);
for (let attempt = 0; attempt < 8; attempt += 1) {
const state = randomBytes(TOKEN_BYTES).toString("base64url");
const filename = digestFilename(state);
const capacitySlot = await reserveOidcSlot(filename, isoAt(expiresMs));
const record: OidcStateRecord = {
version: 1,
nonce: validated.nonce,
codeVerifier: validated.codeVerifier,
returnTo: validated.returnTo,
authConfigRevision: validated.authConfigRevision,
issuer: validated.issuer,
browserTransactionDigest: validated.browserTransactionDigest,
browserTransactionTransport: validated.browserTransactionTransport,
capacitySlot,
createdAt: isoAt(nowMs),
expiresAt: isoAt(expiresMs),
};
const contents = serialize(record, MAX_OIDC_STATE_RECORD_BYTES);
const created = await requiredStorage().create(root, "oidc", filename, contents);
if (created) return { state, record };
await releaseOidcSlot(capacitySlot, filename);
}
throw invalid();
});
}
async function consumeOidcState(state: string, now = new Date()): Promise<OidcStateRecord | undefined> {
if (!canonicalRawValue(state)) return undefined;
const nowMs = dateMilliseconds(now);
const filename = digestFilename(state);
return withLock(lockKey(root, "oidc", filename), async () => {
const contents = await requiredStorage().claimConsume(root, filename);
if (!contents) return undefined;
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
await releaseOidcSlot(record.capacitySlot, filename);
return oidcStateExpired(record, nowMs) ? undefined : record;
});
}
async function prune(now = new Date()): Promise<number> {
const nowMs = dateMilliseconds(now);
// Cursor advancement is process-local, so concurrent timer/manual invocations must not
// observe the same page and strand a later page forever.
return await withLock(lockKey(root, "sessions", "maintenance"), async () =>
(await pruneOrdinarySessions(nowMs)) + (await pruneOidcStates(nowMs)));
}
return {
create: createSession,
resolve: resolveSession,
touch: touchSession,
revoke: revokeSession,
prune,
createOidcState,
consumeOidcState,
};
}
+93
View File
@@ -0,0 +1,93 @@
export type AuthMode = "local" | "oidc" | "upstream" | "none" | "mock";
export type Role = "user" | "admin";
export type Permission =
| "session.use" | "session.read_all" | "session.manage_all"
| "settings.manage" | "workspace.manage" | "workspace.secrets.manage"
| "pi.manage" | "auth.diagnostics.read";
export interface AuthenticationSessionConfig {
regularTtlSeconds: number;
regularIdleSeconds: number;
rememberTtlSeconds: number;
rememberIdleSeconds: number;
oidcTtlSeconds: number;
}
export interface LocalAuthenticationConfig {
version: 1;
mode: "local";
publicUrl: string;
session: AuthenticationSessionConfig;
local: { usersFile: string };
}
export interface OidcAuthenticationConfig {
version: 1;
mode: "oidc";
publicUrl: string;
session: AuthenticationSessionConfig;
oidc: {
issuer: string;
clientId: string;
clientSecretRef: "THT_OIDC_CLIENT_SECRET";
scopes: readonly string[];
groupsClaim: "groups";
};
groupCatalog: {
driver: "authentik";
baseUrl: string;
apiTokenRef: "THT_AUTHENTIK_API_TOKEN";
};
authorization: { groupRoles: Readonly<Record<string, readonly Role[]>> };
}
export type AuthenticationConfig = LocalAuthenticationConfig | OidcAuthenticationConfig;
export interface LoadedAuthConfig {
value: AuthenticationConfig;
revision: string;
sourcePath: string;
}
export interface AuthenticationConfigProvider {
current(): LoadedAuthConfig;
}
/** The only browser transport modes accepted for an OIDC transaction cookie. */
export type OidcTransactionTransport = "https" | "loopback_http";
/** Durable, server-side representation of an opaque browser session. */
export interface AuthSessionRecord {
version: 1;
issuer: string;
subject: string;
displayName?: string;
method: "local" | "oidc" | "upstream";
roles: readonly Role[];
permissions: readonly Permission[];
userAuthRevision?: number;
authConfigRevision: string;
remembered: boolean;
createdAt: string;
lastSeenAt: string;
idleExpiresAt: string;
absoluteExpiresAt: string;
}
/** Server-side OIDC callback material keyed by a separately generated opaque state value. */
export interface OidcStateRecord {
version: 1;
nonce: string;
codeVerifier: string;
returnTo: "/";
authConfigRevision: string;
issuer: string;
browserTransactionDigest: string;
/** Optional only to safely consume and reject a short-lived pre-transport legacy state. */
browserTransactionTransport?: OidcTransactionTransport;
capacitySlot?: number;
createdAt: string;
expiresAt: string;
}
+42
View File
@@ -0,0 +1,42 @@
export interface ConfiguredTransportUrlOptions {
allowLoopbackHttp: boolean;
originOnly?: boolean;
}
function canonicalLoopbackAuthority(value: string): boolean {
const match = /^http:\/\/([^/?#]+)(?:[/?#]|$)/.exec(value);
if (!match) return false;
const authority = match[1];
let port: string | undefined;
if (authority.startsWith("[")) {
const ipv6 = /^(\[::1\])(?::([^:]+))?$/.exec(authority);
if (!ipv6) return false;
port = ipv6[2];
} else {
const ipv4 = /^([^:]+)(?::([^:]+))?$/.exec(authority);
if (!ipv4) return false;
const octets = ipv4[1].split(".");
if (octets.length !== 4 || octets.some((octet) => !/^(?:0|[1-9]\d{0,2})$/.test(octet)
|| Number(octet) > 255) || Number(octets[0]) !== 127) return false;
port = ipv4[2];
}
return port === undefined || (/^(?:0|[1-9]\d{0,4})$/.test(port) && Number(port) <= 65_535);
}
export function parseConfiguredTransportUrl(
value: string,
options: ConfiguredTransportUrlOptions,
): URL | undefined {
let url: URL;
try {
url = new URL(value);
} catch {
return undefined;
}
if (url.username || url.password || url.search || url.hash || (options.originOnly && url.pathname !== "/")) {
return undefined;
}
if (url.protocol === "https:") return url;
if (options.allowLoopbackHttp && url.protocol === "http:" && canonicalLoopbackAuthority(value)) return url;
return undefined;
}
+639
View File
@@ -0,0 +1,639 @@
import { spawn, spawnSync } from "node:child_process";
import { posix, win32 } from "node:path";
import type { Readable, Writable } from "node:stream";
import { z } from "zod";
const PROTOCOL_VERSION = 1;
const MAX_PROTOCOL_BYTES = 64 * 1024;
const MAX_RESPONSE_BYTES = 64 * 1024;
const MAX_AUTH_CONFIG_BYTES = 1024 * 1024;
const MAX_AUTH_CONFIG_BASE64_BYTES = 4 * Math.ceil(MAX_AUTH_CONFIG_BYTES / 3);
const MAX_AUTH_CONFIG_RESPONSE_BYTES = MAX_AUTH_CONFIG_BASE64_BYTES + 1024;
const MAX_SESSION_BYTES = 16 * 1024;
const MAX_OIDC_BYTES = 8 * 1024;
const DEFAULT_MAX_ENTRIES = 256;
const MAX_ENTRIES = 512;
const TIMEOUT_MS = 5_000;
const TERMINATION_GRACE_MS = 100;
const FINAL_SETTLEMENT_MS = 750;
const DIGEST_FILENAME = /^[a-f0-9]{64}\.json$/;
const CLAIM_FILENAME = /^[a-f0-9]{64}\.claim$/;
const OIDC_SLOT_FILENAME = /^slot-(?:[0-5][0-9]|6[0-3])\.json$/;
const AUTH_CONFIG_FILENAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,249}\.yaml$/;
const invalid = (): Error => new Error("auth_session_store_invalid");
export type WindowsAuthStorageDirectory = "sessions" | "oidc";
export interface WindowsAuthStorageEntry {
name: string;
modifiedUnixMs: number;
}
export interface WindowsAuthStoragePage {
entries: WindowsAuthStorageEntry[];
more: boolean;
}
/** Internal adapter boundary for the file-session store's native Windows path. */
export interface WindowsAuthStorageBridge {
validateRoot(root: string): Promise<void>;
ensureLayout(root: string): Promise<void>;
readAuthConfig(path: string): Buffer;
readLocalUsers(path: string): Promise<Buffer>;
create(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<boolean>;
read(root: string, directory: WindowsAuthStorageDirectory, filename: string): Promise<Buffer | undefined>;
replace(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<void>;
remove(root: string, directory: WindowsAuthStorageDirectory, filename: string): Promise<boolean>;
list(
root: string,
directory: WindowsAuthStorageDirectory,
maximumEntries?: number,
): Promise<WindowsAuthStorageEntry[]>;
listPage(
root: string,
directory: "sessions",
afterName: string | undefined,
maximumEntries: number,
): Promise<WindowsAuthStoragePage>;
claimConsume(root: string, filename: string): Promise<Buffer | undefined>;
readClaim(root: string, filename: string): Promise<Buffer | undefined>;
removeClaim(root: string, filename: string): Promise<boolean>;
}
export interface WindowsAuthStorageInvocation {
executable: string;
args: readonly string[];
input: Buffer;
timeoutMs: number;
maximumOutputBytes: number;
}
export interface WindowsAuthStorageInvocationResult {
code: number;
stdout: Buffer;
stderr: Buffer;
}
interface WindowsAuthStorageChild {
readonly stdin: Writable | null;
readonly stdout: Readable | null;
readonly stderr: Readable | null;
kill(signal?: NodeJS.Signals | number): boolean;
unref?(): void;
on(event: "error", listener: (error: Error) => void): this;
once(event: "error", listener: (error: Error) => void): this;
once(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
removeListener?(event: "error" | "close", listener: (...args: any[]) => void): this;
}
type WindowsAuthStorageSpawn = (
executable: string,
args: readonly string[],
options: { shell: false; windowsHide: true; stdio: ["pipe", "pipe", "pipe"]; env: NodeJS.ProcessEnv },
) => WindowsAuthStorageChild;
export interface WindowsAuthStorageBridgeOptions {
/** Test-only transport seam. Production always uses the no-shell child-process invocation. */
invoke?: (invocation: WindowsAuthStorageInvocation) => Promise<WindowsAuthStorageInvocationResult>;
/** Test-only synchronous seam used by the synchronous authentication-config provider. */
invokeSync?: (invocation: WindowsAuthStorageInvocation) => WindowsAuthStorageInvocationResult;
/** Optional configured tht path. Defaults to THT_BIN, then the safe bare command `tht`. */
thtExecutable?: string;
/** Test-only child-launch seam; production uses the fixed no-shell Node child-process launcher. */
spawnChild?: WindowsAuthStorageSpawn;
/** Test-only input scheduling seam for real child-process lifecycle tests. */
beforeInputForTest?: () => Promise<void>;
/** Test-only bounded lifecycle timings. Production always uses the fixed deadlines below. */
deadlinesForTest?: {
timeoutMs?: number;
terminationGraceMs?: number;
finalSettlementMs?: number;
};
}
type AuthStoragePathStyle = "posix" | "windows";
const responseSchema = z.strictObject({
version: z.literal(PROTOCOL_VERSION),
ok: z.literal(true),
created: z.boolean().optional(),
replaced: z.boolean().optional(),
removed: z.boolean().optional(),
found: z.boolean().optional(),
contentBase64: z.string().max(MAX_AUTH_CONFIG_BASE64_BYTES).optional(),
entries: z.array(z.strictObject({
name: z.string().max(128),
modifiedUnixMs: z.number().int().safe().nonnegative(),
})).max(MAX_ENTRIES).optional(),
more: z.boolean().optional(),
validated: z.boolean().optional(),
prepared: z.boolean().optional(),
});
type BridgeResponse = z.infer<typeof responseSchema>;
interface BridgeRequest {
version: typeof PROTOCOL_VERSION;
operation: "validate-root" | "ensure-layout" | "read-auth-config" | "read-local-users" | "create" | "read" | "replace" | "remove" | "list" | "claim-consume" | "read-claim" | "remove-claim";
root: string;
directory?: WindowsAuthStorageDirectory;
filename?: string;
contentBase64?: string;
maximumEntries?: number;
afterName?: string;
continuation?: true;
}
function directoryMaximum(directory: WindowsAuthStorageDirectory): number {
return directory === "sessions" ? MAX_SESSION_BYTES : MAX_OIDC_BYTES;
}
function canonicalBase64(value: string, maximum: number): Buffer {
if (typeof value !== "string" || value.length > Math.ceil(maximum / 3) * 4) throw invalid();
try {
const decoded = Buffer.from(value, "base64");
if (decoded.length === 0 || decoded.length > maximum || decoded.toString("base64") !== value) throw invalid();
return decoded;
} catch {
throw invalid();
}
}
function validateRoot(root: string, pathStyle: AuthStoragePathStyle): void {
const paths = pathStyle === "windows" ? win32 : posix;
if (typeof root !== "string" || root.length === 0 || /[\u0000-\u001f\u007f]/.test(root)
|| !paths.isAbsolute(root) || paths.normalize(root) !== root) throw invalid();
}
function validateFilename(filename: string, allowClaim = false, allowOidcSlot = false): void {
if (typeof filename !== "string" || (!DIGEST_FILENAME.test(filename)
&& !(allowClaim && CLAIM_FILENAME.test(filename))
&& !(allowOidcSlot && OIDC_SLOT_FILENAME.test(filename)))) throw invalid();
}
function safeThtExecutable(value: string | undefined, pathStyle: AuthStoragePathStyle): string {
const executable = value ?? process.env.THT_AUTH_STORAGE_BIN ?? process.env.THT_BIN ?? "tht";
if (typeof executable !== "string" || executable.length === 0 || /[\u0000-\u001f\u007f]/.test(executable)) throw invalid();
if (executable === "tht" || (pathStyle === "windows" && executable === "tht.exe")) return executable;
const paths = pathStyle === "windows" ? win32 : posix;
if (paths.isAbsolute(executable) && paths.normalize(executable) === executable
&& (pathStyle === "posix" || /\.exe$/i.test(executable))) return executable;
throw invalid();
}
function parseResponse(result: WindowsAuthStorageInvocationResult, maximumOutputBytes: number): BridgeResponse {
if (!Number.isInteger(result.code) || result.code !== 0 || !Buffer.isBuffer(result.stdout)
|| !Buffer.isBuffer(result.stderr) || result.stderr.length > MAX_RESPONSE_BYTES
|| result.stdout.length === 0 || result.stdout.length > maximumOutputBytes) {
throw invalid();
}
try {
const source = new TextDecoder("utf-8", { fatal: true }).decode(result.stdout);
return responseSchema.parse(JSON.parse(source));
} catch {
throw invalid();
}
}
function encodedRequest(request: BridgeRequest, pathStyle: AuthStoragePathStyle): Buffer {
validateRoot(request.root, pathStyle);
if (request.operation === "validate-root" || request.operation === "ensure-layout") {
if (request.directory !== undefined || request.filename !== undefined || request.contentBase64 !== undefined
|| request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid();
} else if (request.operation === "read-auth-config" || request.operation === "read-local-users") {
if (request.directory !== undefined || request.contentBase64 !== undefined || request.maximumEntries !== undefined
|| request.afterName !== undefined || request.continuation !== undefined
|| request.filename === undefined || !AUTH_CONFIG_FILENAME.test(request.filename)) throw invalid();
} else if (request.operation === "list") {
if (request.directory === undefined) throw invalid();
if (request.filename !== undefined || request.contentBase64 !== undefined) throw invalid();
if (request.maximumEntries !== undefined && (!Number.isInteger(request.maximumEntries)
|| request.maximumEntries < 1 || request.maximumEntries > MAX_ENTRIES)) throw invalid();
if (request.continuation === true) {
if (request.directory !== "sessions" || (request.afterName !== undefined && !DIGEST_FILENAME.test(request.afterName))) {
throw invalid();
}
} else if (request.afterName !== undefined || request.continuation !== undefined) {
throw invalid();
}
} else {
if (request.directory === undefined) throw invalid();
if (request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid();
if (request.filename === undefined) throw invalid();
const allowClaim = request.operation === "remove" && request.directory === "oidc";
const allowOidcSlot = request.directory === "oidc"
&& (request.operation === "create" || request.operation === "read" || request.operation === "remove");
validateFilename(request.filename, allowClaim, allowOidcSlot);
if (request.contentBase64 !== undefined && request.operation !== "create" && request.operation !== "replace") throw invalid();
}
if ((request.operation === "claim-consume" || request.operation === "read-claim" || request.operation === "remove-claim")
&& request.directory !== "oidc") throw invalid();
if (request.contentBase64 !== undefined) {
if (request.directory === undefined) throw invalid();
canonicalBase64(request.contentBase64, directoryMaximum(request.directory));
}
const encoded = Buffer.from(JSON.stringify(request), "utf8");
if (encoded.length === 0 || encoded.length > MAX_PROTOCOL_BYTES) throw invalid();
return encoded;
}
function environmentForBridge(): NodeJS.ProcessEnv {
const path = process.env.PATH;
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
return {
...(path === undefined ? {} : { PATH: path }),
...(systemRoot === undefined ? {} : { SystemRoot: systemRoot }),
};
}
const spawnTht: WindowsAuthStorageSpawn = (executable, args, options) => spawn(executable, [...args], options);
function invokeThtSync(invocation: WindowsAuthStorageInvocation): WindowsAuthStorageInvocationResult {
try {
const result = spawnSync(invocation.executable, [...invocation.args], {
shell: false,
windowsHide: true,
env: environmentForBridge(),
input: invocation.input,
timeout: invocation.timeoutMs,
maxBuffer: invocation.maximumOutputBytes,
encoding: "buffer",
});
if (result.error || result.signal !== null || typeof result.status !== "number"
|| !Buffer.isBuffer(result.stdout) || !Buffer.isBuffer(result.stderr)) throw invalid();
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
} catch {
throw invalid();
}
}
async function invokeTht(
invocation: WindowsAuthStorageInvocation,
spawnChild: WindowsAuthStorageSpawn = spawnTht,
beforeInputForTest?: () => Promise<void>,
terminationGraceMs = TERMINATION_GRACE_MS,
finalSettlementMs = FINAL_SETTLEMENT_MS,
): Promise<WindowsAuthStorageInvocationResult> {
return new Promise((resolve, reject) => {
let settled = false;
let aborted = false;
let timeout: NodeJS.Timeout | undefined;
let terminationTimer: NodeJS.Timeout | undefined;
let finalSettlementTimer: NodeJS.Timeout | undefined;
let lateErrorReleaseTimer: NodeJS.Timeout | undefined;
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let child: WindowsAuthStorageChild | undefined;
let stdin: Writable | undefined;
let stdoutStream: Readable | undefined;
let stderrStream: Readable | undefined;
const swallowChildError = (): void => undefined;
const swallowStreamError = (): void => undefined;
const releaseQuarantine = (): void => {
if (lateErrorReleaseTimer !== undefined) clearTimeout(lateErrorReleaseTimer);
lateErrorReleaseTimer = undefined;
removeChildListener("error", swallowChildError);
removeChildListener("close", releaseQuarantine);
removeStreamListener(stdin, "error", swallowStreamError);
removeStreamListener(stdoutStream, "error", swallowStreamError);
removeStreamListener(stderrStream, "error", swallowStreamError);
};
const quarantineLateErrors = (): void => {
// A final-deadline settlement can precede a broken ChildProcess object's terminal events.
// Keep only no-capture listeners for a bounded grace period so a late EventEmitter error
// cannot become uncaught, including after an already-observed close event.
child?.on("error", swallowChildError);
child?.once("close", releaseQuarantine);
stdin?.on("error", swallowStreamError);
stdoutStream?.on("error", swallowStreamError);
stderrStream?.on("error", swallowStreamError);
lateErrorReleaseTimer = setTimeout(releaseQuarantine, finalSettlementMs);
lateErrorReleaseTimer.unref?.();
};
const removeChildListener = (event: "error" | "close", listener: (...args: any[]) => void): void => {
try { child?.removeListener?.(event, listener); } catch { /* the helper is already terminal */ }
};
const removeStreamListener = (stream: Writable | Readable | undefined, event: "data" | "error", listener: (...args: any[]) => void): void => {
try { stream?.removeListener(event, listener); } catch { /* the helper is already terminal */ }
};
const stopStream = (stream: Writable | Readable | null | undefined): void => {
try { stream?.destroy(); } catch { /* abort is already fail-closed */ }
};
const onChildError = (): void => abort();
const onStdinError = (): void => abort();
const onStdoutError = (): void => abort();
const onStderrError = (): void => abort();
const onStdoutData = (chunk: Buffer): void => {
if (aborted || settled) return;
stdoutBytes += chunk.length;
if (stdoutBytes > invocation.maximumOutputBytes) {
abort();
return;
}
stdout.push(Buffer.from(chunk));
};
const onStderrData = (chunk: Buffer): void => {
if (aborted || settled) return;
stderrBytes += chunk.length;
if (stderrBytes > MAX_RESPONSE_BYTES) {
abort();
return;
}
stderr.push(Buffer.from(chunk));
};
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return;
if (aborted || code === null || !Number.isInteger(code) || signal !== null) {
settle(() => reject(invalid()), true);
return;
}
settle(() => resolve({
code,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
}));
};
const cleanup = (quarantine = false): void => {
if (timeout !== undefined) clearTimeout(timeout);
if (terminationTimer !== undefined) clearTimeout(terminationTimer);
if (finalSettlementTimer !== undefined) clearTimeout(finalSettlementTimer);
removeChildListener("error", onChildError);
removeChildListener("close", onClose as (...args: any[]) => void);
removeStreamListener(stdin, "error", onStdinError);
removeStreamListener(stdoutStream, "data", onStdoutData);
removeStreamListener(stdoutStream, "error", onStdoutError);
removeStreamListener(stderrStream, "data", onStderrData);
removeStreamListener(stderrStream, "error", onStderrError);
if (quarantine) quarantineLateErrors();
};
const settle = (callback: () => void, quarantine = false): void => {
if (settled) return;
settled = true;
cleanup(quarantine);
callback();
};
const abort = (): void => {
if (aborted || settled) return;
aborted = true;
if (timeout !== undefined) clearTimeout(timeout);
if (child !== undefined) {
stopStream(stdin);
stopStream(stdoutStream);
stopStream(stderrStream);
try { child.kill("SIGTERM"); } catch { /* final settlement still owns completion */ }
try { child.unref?.(); } catch { /* the bounded timers still own completion */ }
}
terminationTimer = setTimeout(() => {
if (settled || child === undefined) return;
try { child.kill("SIGKILL"); } catch { /* final settlement still owns completion */ }
}, terminationGraceMs);
finalSettlementTimer = setTimeout(() => {
settle(() => reject(invalid()), true);
}, finalSettlementMs);
};
try {
child = spawnChild(invocation.executable, invocation.args, {
shell: false,
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
env: environmentForBridge(),
});
} catch {
settle(() => reject(invalid()));
return;
}
child.once("close", onClose);
child.on("error", onChildError);
if (!child.stdin || !child.stdout || !child.stderr) {
abort();
return;
}
stdin = child.stdin;
stdoutStream = child.stdout;
stderrStream = child.stderr;
timeout = setTimeout(() => {
abort();
}, invocation.timeoutMs);
stdoutStream.on("data", onStdoutData);
stdoutStream.once("error", onStdoutError);
stderrStream.on("data", onStderrData);
stderrStream.once("error", onStderrError);
stdin.once("error", onStdinError);
const writeInput = (): void => {
if (aborted || settled) return;
try {
stdin.end(invocation.input);
} catch {
abort();
}
};
if (beforeInputForTest === undefined) {
writeInput();
} else {
void Promise.resolve().then(beforeInputForTest).then(writeInput, abort);
}
});
}
function contentFrom(response: BridgeResponse, maximum: number): Buffer | undefined {
if (response.found !== true) {
if (response.contentBase64 !== undefined) throw invalid();
return undefined;
}
if (response.contentBase64 === undefined) throw invalid();
return canonicalBase64(response.contentBase64, maximum);
}
function listedEntries(
response: BridgeResponse,
directory: WindowsAuthStorageDirectory,
maximumEntries: number,
): WindowsAuthStorageEntry[] {
if (response.entries === undefined || response.entries.length > maximumEntries) throw invalid();
const names = new Set<string>();
for (const entry of response.entries) {
if (!DIGEST_FILENAME.test(entry.name) && !(directory === "oidc"
&& (CLAIM_FILENAME.test(entry.name) || OIDC_SLOT_FILENAME.test(entry.name)))) throw invalid();
if (names.has(entry.name)) throw invalid();
names.add(entry.name);
}
return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs }));
}
function createAuthStorageBridge(
pathStyle: AuthStoragePathStyle,
options: WindowsAuthStorageBridgeOptions = {},
): WindowsAuthStorageBridge {
const executable = safeThtExecutable(options.thtExecutable, pathStyle);
const testDeadlines = options.deadlinesForTest;
const timeoutMs = testDeadlines?.timeoutMs ?? TIMEOUT_MS;
const terminationGraceMs = testDeadlines?.terminationGraceMs ?? TERMINATION_GRACE_MS;
const finalSettlementMs = testDeadlines?.finalSettlementMs ?? FINAL_SETTLEMENT_MS;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > TIMEOUT_MS
|| !Number.isSafeInteger(terminationGraceMs) || terminationGraceMs < 1 || terminationGraceMs > TIMEOUT_MS
|| !Number.isSafeInteger(finalSettlementMs) || finalSettlementMs <= terminationGraceMs || finalSettlementMs > TIMEOUT_MS) {
throw invalid();
}
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(
invocation,
options.spawnChild,
options.beforeInputForTest,
terminationGraceMs,
finalSettlementMs,
));
const invokeSync = options.invokeSync ?? invokeThtSync;
const request = async (value: BridgeRequest): Promise<BridgeResponse> => {
try {
const maximumOutputBytes = value.operation === "read-local-users"
? MAX_AUTH_CONFIG_RESPONSE_BYTES
: MAX_RESPONSE_BYTES;
const response = await invoke({
executable,
args: ["_auth-storage"],
input: encodedRequest(value, pathStyle),
timeoutMs,
maximumOutputBytes,
});
return parseResponse(response, maximumOutputBytes);
} catch {
throw invalid();
}
};
const syncRequest = (value: BridgeRequest): BridgeResponse => {
try {
const response = invokeSync({
executable,
args: ["_auth-storage"],
input: encodedRequest(value, pathStyle),
timeoutMs,
maximumOutputBytes: MAX_AUTH_CONFIG_RESPONSE_BYTES,
});
return parseResponse(response, MAX_AUTH_CONFIG_RESPONSE_BYTES);
} catch {
throw invalid();
}
};
const recordRequest = (operation: "create" | "read" | "replace" | "remove" | "claim-consume" | "read-claim" | "remove-claim", root: string, directory: WindowsAuthStorageDirectory, filename: string, contents?: Buffer): BridgeRequest => ({
version: PROTOCOL_VERSION,
operation,
root,
directory,
filename,
...(contents === undefined ? {} : { contentBase64: contents.toString("base64") }),
});
return {
async validateRoot(root) {
const response = await request({ version: PROTOCOL_VERSION, operation: "validate-root", root });
if (response.validated !== true
|| Object.keys(response).some((key) => !["version", "ok", "validated"].includes(key))) throw invalid();
},
async ensureLayout(root) {
const response = await request({ version: PROTOCOL_VERSION, operation: "ensure-layout", root });
if (response.prepared !== true
|| Object.keys(response).some((key) => !["version", "ok", "prepared"].includes(key))) throw invalid();
},
readAuthConfig(path) {
const paths = pathStyle === "windows" ? win32 : posix;
if (typeof path !== "string" || path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
|| !paths.isAbsolute(path) || paths.normalize(path) !== path) throw invalid();
const root = paths.dirname(path);
const filename = paths.basename(path);
if (!AUTH_CONFIG_FILENAME.test(filename) || paths.join(root, filename) !== path) throw invalid();
const response = syncRequest({ version: PROTOCOL_VERSION, operation: "read-auth-config", root, filename });
if (Object.keys(response).some((key) => !["version", "ok", "found", "contentBase64"].includes(key))) throw invalid();
const contents = contentFrom(response, MAX_AUTH_CONFIG_BYTES);
if (contents === undefined) throw invalid();
return contents;
},
async readLocalUsers(path) {
const paths = pathStyle === "windows" ? win32 : posix;
if (typeof path !== "string" || path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
|| !paths.isAbsolute(path) || paths.normalize(path) !== path) throw invalid();
const root = paths.dirname(path);
const filename = paths.basename(path);
if (!AUTH_CONFIG_FILENAME.test(filename) || paths.join(root, filename) !== path) throw invalid();
const response = await request({ version: PROTOCOL_VERSION, operation: "read-local-users", root, filename });
if (Object.keys(response).some((key) => !["version", "ok", "found", "contentBase64"].includes(key))) throw invalid();
const contents = contentFrom(response, MAX_AUTH_CONFIG_BYTES);
if (contents === undefined) throw invalid();
return contents;
},
async create(root, directory, filename, contents) {
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > directoryMaximum(directory)) throw invalid();
const response = await request(recordRequest("create", root, directory, filename, contents));
if (response.created === undefined) throw invalid();
return response.created;
},
async read(root, directory, filename) {
return contentFrom(await request(recordRequest("read", root, directory, filename)), directoryMaximum(directory));
},
async replace(root, directory, filename, contents) {
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > directoryMaximum(directory)) throw invalid();
const response = await request(recordRequest("replace", root, directory, filename, contents));
if (response.replaced !== true) throw invalid();
},
async remove(root, directory, filename) {
const response = await request(recordRequest("remove", root, directory, filename));
return response.removed === true;
},
async list(root, directory, maximumEntries = DEFAULT_MAX_ENTRIES) {
if (!Number.isInteger(maximumEntries) || maximumEntries < 1 || maximumEntries > MAX_ENTRIES) throw invalid();
const response = await request({
version: PROTOCOL_VERSION,
operation: "list",
root,
directory,
maximumEntries,
});
if (response.more !== undefined) throw invalid();
return listedEntries(response, directory, maximumEntries);
},
async listPage(root, directory, afterName, maximumEntries) {
if (directory !== "sessions" || !Number.isInteger(maximumEntries)
|| maximumEntries < 1 || maximumEntries > MAX_ENTRIES
|| (afterName !== undefined && !DIGEST_FILENAME.test(afterName))) throw invalid();
const response = await request({
version: PROTOCOL_VERSION,
operation: "list",
root,
directory,
maximumEntries,
continuation: true,
...(afterName === undefined ? {} : { afterName }),
});
if (response.more === undefined) throw invalid();
const entries = listedEntries(response, directory, maximumEntries);
let previous = afterName;
for (const entry of entries) {
if (previous !== undefined && entry.name <= previous) throw invalid();
previous = entry.name;
}
if (response.more && entries.length !== maximumEntries) throw invalid();
if (response.more && (previous === undefined || previous === afterName)) throw invalid();
return { entries, more: response.more };
},
async claimConsume(root, filename) {
return contentFrom(await request(recordRequest("claim-consume", root, "oidc", filename)), MAX_OIDC_BYTES);
},
async readClaim(root, filename) {
return contentFrom(await request(recordRequest("read-claim", root, "oidc", filename)), MAX_OIDC_BYTES);
},
async removeClaim(root, filename) {
const response = await request(recordRequest("remove-claim", root, "oidc", filename));
return response.removed === true;
},
};
}
export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
return createAuthStorageBridge("windows", options);
}
/** POSIX uses the same single hidden tht protocol and bounds, with native canonical path rules. */
export function createPosixAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
return createAuthStorageBridge("posix", options);
}
+15 -1
View File
@@ -1,5 +1,19 @@
import type { RpcClient } from "../rpc/rpc-client.js";
const GENERIC_MODEL_FAILURE =
"Model request failed. Check provider connectivity, then Resume the session.";
const SUBSCRIPTION_MODEL_FAILURE =
"The selected model is unavailable for the current subscription. Choose another model and start a new session.";
function safeModelFailure(error: unknown): string {
const detail = typeof error === "string" ? error : "";
const isSubscriptionFailure =
/\b429\b/.test(detail) &&
/(subscription plan|code["':\s]+1311|does not yet include access)/i.test(detail);
return isSubscriptionFailure ? SUBSCRIPTION_MODEL_FAILURE : GENERIC_MODEL_FAILURE;
}
export type ToolActivity = {
kind: "tool";
toolCallId: string;
@@ -53,7 +67,7 @@ export class SessionBridge {
this.fan({
type: "info",
level: "error",
text: "Model request failed. Check provider connectivity, then Resume the session.",
text: safeModelFailure(m.message.errorMessage),
});
}
} else if (m.type === "extension_ui_request" && m.method === "notify") {
+261 -13
View File
@@ -1,8 +1,19 @@
import path from "node:path";
import { statSync } from "node:fs";
import {
createAuthenticationConfigProvider,
type AuthenticationConfigProvider,
type AuthMode,
} from "./auth/config.js";
import type { WorkspaceRegistryConfig } from "./workspaces/types.js";
export interface AppConfig {
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
authMode: "none" | "mock" | "upstream";
authMode: AuthMode;
authConfigFile: string;
authStateRoot: string;
authentication?: AuthenticationConfigProvider;
publicExposure: boolean;
sessionStorage: {
mode: "local" | "postgres";
host?: string; port?: number; database?: string; runtimeUser?: string;
@@ -11,9 +22,12 @@ export interface AppConfig {
defaults: { provider?: string; model?: string; thinking?: string };
maxPiProcesses: number;
settingsFile: string;
maintenanceFile: string;
dataRoot?: string;
ollamaEnsureTimeoutMs: number;
piManagementTimeoutMs: number;
secretsFile?: string;
piAuthFile?: string;
secretFiles: Readonly<Record<string, string | undefined>>;
modelApiKeyFile?: string;
/**
@@ -22,22 +36,186 @@ export interface AppConfig {
* pay the probe; the local dev launcher (run-stack.sh) opts in via THT_DWH_PRECHECK.
*/
dwhPrecheck: boolean;
/** Explicit compatibility mode for old loopback clients that send `workspace` in POST /sessions. */
legacyWorkspaceMode: boolean;
workspaceDiagnosticTimeoutMs: number;
workspaceRegistry: WorkspaceRegistryConfig;
workspaceSecretStoreRoot: string;
workspaceSecretRuntimeRoot: string;
internalQdrantUrl: string;
internalEmbeddingUrl: string;
internalEmbeddingModel: string;
internalEmbeddingDimensions: number;
}
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
const authMode = env.AUTH_MODE ?? "none";
if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) {
throw new Error(`unsupported AUTH_MODE=${authMode}; use none, mock, or upstream`);
export const MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS = 10_000;
function requiredRegistryValue(value: string, label: string): string {
if (value.length === 0 || value.trim() !== value || value.includes("\0")) {
throw new Error(`workspace registry ${label} configuration is invalid`);
}
if (env.THOTH_PUBLIC_EXPOSURE === "true" && authMode !== "upstream") {
throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy");
return value;
}
function absoluteRegistryPath(value: string, label: string): string {
const pathValue = requiredRegistryValue(value, label);
if (!path.isAbsolute(pathValue)) {
throw new Error(`workspace registry ${label} must be absolute`);
}
return pathValue;
}
function absoluteAuthPath(value: string, label: string): string {
if (value.length === 0 || value.trim() !== value || value.includes("\0") || !path.isAbsolute(value)) {
throw new Error(`authentication ${label} configuration is invalid`);
}
return value;
}
function authConfigFileExists(file: string): boolean {
try {
if (!statSync(file).isFile()) throw new Error("authentication configuration is invalid");
return true;
} catch (error: any) {
if (error?.code === "ENOENT") return false;
throw new Error("authentication configuration is invalid");
}
}
function refSafeGitBranch(value: string): string {
const branch = requiredRegistryValue(value, "branch");
if (
branch === "@"
|| branch === "HEAD"
|| branch.startsWith("-")
|| branch.startsWith("/")
|| branch.endsWith("/")
|| branch.endsWith(".")
|| branch.includes("..")
|| branch.includes("@{")
|| branch.includes("//")
|| branch.split("/").some((component) => component.startsWith(".") || component.endsWith(".lock"))
|| /[\p{Cc} ~^:?*\[\\]/u.test(branch)
) {
throw new Error("workspace registry branch configuration is invalid");
}
return branch;
}
function safeInstallationId(value: string): string {
const installationId = requiredRegistryValue(value, "installation ID");
if (/\p{Cc}/u.test(installationId)) {
throw new Error("workspace registry installation ID configuration is invalid");
}
return installationId;
}
function positiveImportLimit(value: string | undefined, fallback: number): number {
const limit = Number(value ?? fallback);
if (!Number.isSafeInteger(limit) || limit <= 0) {
throw new Error("workspace limit configuration is invalid");
}
return limit;
}
function diagnosticTimeout(value: string | undefined): number {
const timeout = Number(value ?? 5_000);
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS) {
throw new Error("workspace diagnostic timeout configuration is invalid");
}
return timeout;
}
function piManagementTimeout(value: string | undefined): number {
const timeout = Number(value ?? 8_000);
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30_000) {
throw new Error("Pi management timeout configuration is invalid");
}
return timeout;
}
function loopbackHost(host: string): boolean {
return host === "::1"
|| host === "127.0.0.1"
|| /^127(?:\.\d{1,3}){3}$/.test(host);
}
function internalServiceUrl(
value: string | undefined,
fallback: string,
label: string,
allowedHosts: readonly string[],
): string {
const raw = value ?? fallback;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`${label} configuration is invalid`);
}
if (
parsed.protocol !== "http:"
|| parsed.username.length > 0
|| parsed.password.length > 0
|| parsed.pathname !== "/"
|| parsed.search.length > 0
|| parsed.hash.length > 0
|| (!allowedHosts.includes(parsed.hostname) && !loopbackHost(parsed.hostname))
) {
throw new Error(`${label} configuration is invalid`);
}
return parsed.toString().replace(/\/$/, "");
}
function positiveDimension(value: string | undefined, fallback: number): number {
const parsed = Number(value ?? fallback);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error("internal embedding dimensions configuration is invalid");
}
return parsed;
}
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
const authConfigFile = absoluteAuthPath(env.THT_AUTH_CONFIG_FILE ?? "/run/thothii-auth/auth.yaml", "file");
const authStateRoot = absoluteAuthPath(env.THT_AUTH_STATE_ROOT ?? "/data/auth", "state root");
const hasAuthenticationConfig = authConfigFileExists(authConfigFile);
if (hasAuthenticationConfig && env.AUTH_MODE !== undefined) {
throw new Error("authentication configuration and AUTH_MODE cannot both be set");
}
const authentication = hasAuthenticationConfig ? createAuthenticationConfigProvider(authConfigFile) : undefined;
let authMode: AuthMode;
if (authentication) {
authMode = authentication.current().value.mode;
} else {
const requestedMode = env.AUTH_MODE ?? "none";
if (!(["none", "mock", "upstream"] as const).includes(requestedMode as "none" | "mock" | "upstream")) {
throw new Error(`unsupported AUTH_MODE=${requestedMode}; use none, mock, or upstream`);
}
const nodeEnvironment = env.NODE_ENV ?? process.env.NODE_ENV;
if ((requestedMode === "none" || requestedMode === "mock")
&& nodeEnvironment !== "development" && nodeEnvironment !== "test") {
throw new Error("production requires auth.yaml or AUTH_MODE=upstream");
}
authMode = requestedMode as "none" | "mock" | "upstream";
}
const publicExposure = env.THOTH_PUBLIC_EXPOSURE === "true";
if (publicExposure && authMode !== "oidc" && authMode !== "upstream") {
throw new Error("public exposure requires AUTH_MODE=upstream or configured OIDC behind a trusted proxy");
}
const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local";
if (sessionStorageMode !== "local" && sessionStorageMode !== "postgres") {
throw new Error("session storage configuration is invalid");
}
if (sessionStorageMode === "local" && env.THOTH_PUBLIC_EXPOSURE === "true") {
if (sessionStorageMode === "local" && publicExposure) {
throw new Error("local session storage requires loopback-only deployment");
}
const legacyWorkspaceMode = env.THT_LEGACY_WORKSPACE_MODE;
if (legacyWorkspaceMode !== undefined && legacyWorkspaceMode !== "local") {
throw new Error("legacy workspace mode configuration is invalid");
}
if (legacyWorkspaceMode === "local" && sessionStorageMode !== "local") {
throw new Error("legacy workspace mode requires local session storage");
}
const sessionStorage: AppConfig["sessionStorage"] = { mode: sessionStorageMode };
if (sessionStorageMode === "postgres") {
const host = env.THT_SESSION_DB_HOST;
@@ -48,15 +226,15 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
const sslrootcert = env.THT_SESSION_DB_SSLROOTCERT;
const port = Number(env.THT_SESSION_DB_PORT ?? 5432);
if (
authMode !== "upstream"
(authMode !== "upstream" && authMode !== "oidc")
|| !host || !database || !runtimeUser
|| !runtimePasswordFile || !path.isAbsolute(runtimePasswordFile)
|| (sslmode !== "verify-ca" && sslmode !== "verify-full")
|| !sslrootcert || !path.isAbsolute(sslrootcert)
|| !Number.isInteger(port) || port < 1 || port > 65535
) {
if (authMode !== "upstream") {
throw new Error("server session storage requires AUTH_MODE=upstream");
if (authMode !== "upstream" && authMode !== "oidc") {
throw new Error("server session storage requires AUTH_MODE=upstream or configured OIDC");
}
throw new Error("server session storage configuration is invalid");
}
@@ -82,6 +260,11 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
secretsFile.trim() !== secretsFile || secretsFile.length === 0 || secretsFile.includes("\0")
|| !path.isAbsolute(secretsFile)
)) throw new Error("secret bundle configuration is invalid");
const piAuthFile = env.THT_PI_AUTH_FILE;
if (piAuthFile !== undefined && (
piAuthFile.trim() !== piAuthFile || piAuthFile.length === 0 || piAuthFile.includes("\0")
|| !path.isAbsolute(piAuthFile)
)) throw new Error("Pi authentication source configuration is invalid");
const secretFiles: Record<string, string | undefined> = {};
for (const name of [
"THT_MODEL_API_KEY_SECRET_FILE", "THT_DWH_API_KEY_SECRET_FILE", "THT_VEC_API_KEY_SECRET_FILE",
@@ -89,22 +272,87 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
"THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE", "THT_VECTOR_READER_PASSWORD_SECRET_FILE",
"THT_VECTOR_WRITER_PASSWORD_SECRET_FILE",
]) secretFiles[name] = env[name];
const registryRoot = absoluteRegistryPath(
env.THT_WORKSPACE_REGISTRY_ROOT ?? "/data/workspace-registry",
"root",
);
const registryBranch = refSafeGitBranch(env.THT_WORKSPACE_GIT_BRANCH ?? "main");
const installationId = safeInstallationId(
env.THT_WORKSPACE_INSTALLATION_ID ?? "local",
);
const remoteUrl = env.THT_WORKSPACE_GIT_REMOTE === undefined
? undefined
: requiredRegistryValue(env.THT_WORKSPACE_GIT_REMOTE, "remote");
const secretRoots = (env.THT_WORKSPACE_SECRET_ROOTS ?? "")
.split(",")
.filter((root) => root.length > 0)
.map((root) => absoluteRegistryPath(root, "secret root"));
const workspaceRegistry: WorkspaceRegistryConfig = {
root: registryRoot,
remoteUrl,
branch: registryBranch,
installationId,
secretRoots,
dataRoot: env.THT_DATA_ROOT,
maxEvidenceEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_ENTRIES, 4096),
maxEvidenceBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_BYTES, 64 * 1024 * 1024),
maxEvidenceFileBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_FILE_BYTES, 8 * 1024 * 1024),
maxEvidencePathBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_PATH_BYTES, 4096),
maxEvidenceManifestBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_MANIFEST_BYTES, 1024 * 1024),
};
const workspaceSecretStoreRoot = absoluteRegistryPath(
env.THT_WORKSPACE_SECRET_STORE_ROOT ?? path.join(path.dirname(registryRoot), "workspace-secrets"),
"secret store root",
);
const workspaceSecretRuntimeRoot = absoluteRegistryPath(
env.THT_WORKSPACE_SECRET_RUNTIME_ROOT ?? "/tmp/thothii-workspace-secrets",
"secret runtime root",
);
const settingsFile = env.SETTINGS_FILE ?? "data/settings.json";
const internalQdrantUrl = internalServiceUrl(
env.THT_INTERNAL_QDRANT_URL,
"http://qdrant:6333",
"internal Qdrant URL",
["qdrant", "localhost"],
);
const internalEmbeddingUrl = internalServiceUrl(
env.THT_INTERNAL_EMBEDDING_URL,
"http://embedding:11434",
"internal embedding URL",
["embedding", "localhost"],
);
return {
host: env.HOST ?? "127.0.0.1",
port: Number(env.PORT ?? 8787),
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
thtBin: env.THT_BIN ?? "tht",
piBin: env.PI_BIN ?? "pi",
authMode: authMode as AppConfig["authMode"],
authMode,
authConfigFile,
authStateRoot,
authentication,
publicExposure,
sessionStorage,
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
settingsFile: env.SETTINGS_FILE ?? "data/settings.json",
settingsFile,
maintenanceFile: env.THT_MAINTENANCE_FILE ?? path.join(path.dirname(settingsFile), "maintenance.json"),
dataRoot: env.THT_DATA_ROOT,
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS),
secretsFile,
piAuthFile,
secretFiles,
modelApiKeyFile,
dwhPrecheck: env.THT_DWH_PRECHECK === "true" || env.THT_DWH_PRECHECK === "1",
legacyWorkspaceMode: legacyWorkspaceMode === "local",
workspaceDiagnosticTimeoutMs: diagnosticTimeout(env.THT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS),
workspaceRegistry,
workspaceSecretStoreRoot,
workspaceSecretRuntimeRoot,
internalQdrantUrl,
internalEmbeddingUrl,
internalEmbeddingModel: env.THT_INTERNAL_EMBEDDING_MODEL ?? "qwen3-embedding:0.6b",
internalEmbeddingDimensions: positiveDimension(env.THT_INTERNAL_EMBEDDING_DIMENSIONS, 1024),
};
}
+12 -2
View File
@@ -2,12 +2,18 @@ import {
closeSync, constants, fstatSync, lstatSync, openSync, readFileSync,
type Stats,
} from "node:fs";
import {
AUTHENTICATION_SECRET_LIMITS,
isAuthenticationSecretReference,
isUsableAuthenticationSecret,
} from "../auth/secret-policy.js";
/** Keys accepted by the deployment bundle. Keep this list intentionally explicit. */
export const SECRET_BUNDLE_KEYS = Object.freeze([
"THT_MODEL_API_KEY", "THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY",
"THT_CA", "THT_SSL_CA", "THT_VECTOR_BOOTSTRAP_PASSWORD", "THT_VECTOR_MIGRATOR_PASSWORD",
"THT_VECTOR_READER_PASSWORD", "THT_VECTOR_WRITER_PASSWORD", "PI_PROVIDER_API_KEY",
"THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN",
] as const);
const ALLOWED = new Set<string>(SECRET_BUNDLE_KEYS);
@@ -25,7 +31,10 @@ const LEGACY_FILES: Readonly<Record<string, string>> = {
};
const MAX_BUNDLE_BYTES = 64 * 1024;
const MAX_LINE_BYTES = 16 * 1024;
const MAX_LINE_BYTES = Math.max(
16 * 1024,
...Object.entries(AUTHENTICATION_SECRET_LIMITS).map(([name, maximum]) => name.length + 1 + maximum),
);
export interface SecretBundleConfig {
secretsFile?: string;
@@ -97,7 +106,8 @@ function parseBundle(text: string): ReadonlyMap<string, string> {
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
if (!match) throw unavailable();
const [, key, value] = match;
if (!ALLOWED.has(key) || values.has(key) || value.length === 0 || /[\r\n]/.test(value)) {
if (!ALLOWED.has(key) || values.has(key) || value.length === 0 || /[\r\n]/.test(value)
|| isAuthenticationSecretReference(key) && !isUsableAuthenticationSecret(key, value)) {
throw unavailable();
}
values.set(key, value);
+135
View File
@@ -0,0 +1,135 @@
/**
* Installation-scoped host operations that must not impersonate an HTTP administrator.
* This command runs only through `docker compose exec core`; it never accepts credentials,
* headers, paths, or arbitrary code from the caller.
*/
import { pathToFileURL } from "node:url";
import { join } from "node:path";
import { loadConfig, type AppConfig } from "./config.js";
import { rolesToPermissions } from "./auth/config.js";
import type { PrincipalContext } from "./auth/principal.js";
import { createPiModelLister } from "./pi/list-models.js";
import { createPiManagement } from "./pi/management.js";
import { effectiveSettings } from "./routes/settings.js";
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
import { loadSettings } from "./settings/settings-store.js";
import { ThtRunner, type SessionRow } from "./tht/tht-runner.js";
import { WorkspaceRegistry } from "./workspaces/registry.js";
import { WorkspaceSecretStore } from "./workspaces/secret-store.js";
type OperatorAction = "maintenance-activate" | "maintenance-deactivate" | "maintenance-status"
| "session-inventory" | "workflow-doctor" | "workspace-integrity"
| "pi-options" | "pi-test" | "effective-settings";
const lifecyclePrincipal: PrincipalContext = {
issuer: "tht-operator-command",
subject: "installation-lifecycle",
displayName: "Installation lifecycle",
roles: ["admin"],
permissions: rolesToPermissions(["admin"]),
isAdmin: true,
};
function operatorRunner(config: AppConfig): ThtRunner {
const workspaceSecretStore = new WorkspaceSecretStore({
root: config.workspaceSecretStoreRoot,
runtimeRoot: config.workspaceSecretRuntimeRoot,
installationId: config.workspaceRegistry.installationId,
});
return new ThtRunner({
thtBin: config.thtBin,
harnessDir: config.harnessDir,
configPath: process.env.THT_CONFIG ?? "config/tht.yaml",
dataRoot: config.dataRoot,
runtimeSnapshotRoot: join(config.workspaceRegistry.root, "snapshots", "runtime"),
secretRoots: config.workspaceRegistry.secretRoots,
secretsFile: config.secretsFile,
secretFiles: config.secretFiles,
workspaceSecretStore,
semanticRuntime: {
internalQdrantUrl: config.internalQdrantUrl,
internalEmbeddingUrl: config.internalEmbeddingUrl,
internalEmbeddingModel: config.internalEmbeddingModel,
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
},
}).withPrincipal(lifecyclePrincipal);
}
async function sessionInventory(config: AppConfig): Promise<Array<Pick<SessionRow, "status" | "archived">>> {
const registry = new WorkspaceRegistry(config.workspaceRegistry);
const revisions = await registry.listRetainedSnapshots();
const runner = operatorRunner(config);
const sessions = new Map<string, SessionRow>();
for (const revision of revisions) {
for (const session of await runner.sessionList(revision.snapshotPath)) sessions.set(session.id, session);
}
return [...sessions.values()].map(({ status, archived }) => ({ status, archived: archived === true }));
}
async function workflowDiagnostics(config: AppConfig): Promise<{ ready: true; workspaces: number }> {
const registry = new WorkspaceRegistry(config.workspaceRegistry);
const revisions = await registry.listRetainedSnapshots();
if (revisions.length === 0) throw new Error("workflow diagnostics unavailable");
const runner = operatorRunner(config);
for (const revision of revisions) {
const result = await runner.run(["doctor", "--json"], revision.snapshotPath);
let payload: unknown;
try {
payload = JSON.parse(result.stdout);
} catch {
throw new Error("workflow diagnostics failed");
}
if (
result.code !== 0 || !payload || typeof payload !== "object"
|| (payload as { ok?: unknown }).ok !== true
) throw new Error("workflow diagnostics failed");
}
return { ready: true, workspaces: revisions.length };
}
async function workspaceIntegrity(config: AppConfig): Promise<{
ready: true;
state: "uninitialized" | "active";
workspaces: number;
fingerprint: string;
}> {
const integrity = await new WorkspaceRegistry(config.workspaceRegistry).verifyStoredState();
return { ready: true, ...integrity };
}
export async function runOperatorAction(
action: OperatorAction,
config: AppConfig,
): Promise<unknown> {
if (action.startsWith("maintenance-")) {
const barrier = new MaintenanceBarrier(config.maintenanceFile);
if (action === "maintenance-activate") await barrier.activate();
if (action === "maintenance-deactivate") barrier.deactivate();
return barrier.status();
}
if (action === "session-inventory") return await sessionInventory(config);
if (action === "workflow-doctor") return await workflowDiagnostics(config);
if (action === "workspace-integrity") return await workspaceIntegrity(config);
if (action === "effective-settings") return effectiveSettings(config, loadSettings(config));
const service = createPiManagement(config, { listModels: createPiModelLister(config) });
if (action === "pi-options") return await service.options();
if (action === "pi-test") return await service.test();
throw new Error("unsupported operator action");
}
async function main(): Promise<void> {
const action = process.argv[2] as OperatorAction | undefined;
if (!action || ![
"maintenance-activate", "maintenance-deactivate", "maintenance-status", "session-inventory",
"workflow-doctor", "workspace-integrity", "pi-options", "pi-test", "effective-settings",
].includes(action)) throw new Error("invalid operator action");
const result = await runOperatorAction(action, loadConfig(process.env));
process.stdout.write(`${JSON.stringify(result)}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
void main().catch(() => {
process.stderr.write("operator command failed\n");
process.exitCode = 2;
});
}
+10
View File
@@ -6,6 +6,10 @@ import {
loadPiEnabledModels,
type PiEnabledModelsResult,
} from "./enabled-models.js";
import {
readConfiguredPiAgentFile,
validateDeclarativePiConfig,
} from "./managed-config.js";
export interface PiModel {
provider: string;
@@ -23,6 +27,7 @@ interface Opts {
ttlMs?: number;
nowMs?: () => number;
loadEnabledModels?: () => PiEnabledModelsResult;
readModelsStore?: () => string | undefined;
warn?: (message: string) => void;
}
@@ -39,6 +44,11 @@ export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Prom
let cache: { at: number; models: PiModel[] } | null = null;
return async function listModels(): Promise<PiModel[]> {
const managedModels = opts.readModelsStore
? opts.readModelsStore()
: readConfiguredPiAgentFile("models.json", true);
if (managedModels !== undefined) validateDeclarativePiConfig(managedModels);
if (cache && now() - cache.at < ttlMs) return cache.models;
const enabled = (opts.loadEnabledModels
+161
View File
@@ -0,0 +1,161 @@
import {
chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync,
readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync, type Dirent,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
const MAX_AGENT_CONFIG_BYTES = 1024 * 1024;
export const PI_MANAGED_CONFIG_ERROR_CODE = "PI_MANAGED_CONFIG_INVALID";
export const PI_MANAGED_CONFIG_ERROR_MESSAGE = "Pi provider/model configuration is invalid";
export class PiManagedConfigError extends Error {
readonly code = PI_MANAGED_CONFIG_ERROR_CODE;
constructor() {
super(PI_MANAGED_CONFIG_ERROR_MESSAGE);
}
}
export function isPiManagedConfigError(error: unknown): boolean {
return Boolean(
error && typeof error === "object"
&& (error as { code?: unknown }).code === PI_MANAGED_CONFIG_ERROR_CODE,
);
}
export function parsePiConfigJson(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
throw new PiManagedConfigError();
}
}
/** Reject every Pi shell-backed configuration value, including unknown future nested fields. */
export function assertDeclarativePiConfig(value: unknown): void {
const pending: unknown[] = [value];
while (pending.length > 0) {
const current = pending.pop();
if (typeof current === "string") {
if (current.startsWith("!")) throw new PiManagedConfigError();
continue;
}
if (Array.isArray(current)) {
for (const nested of current) pending.push(nested);
continue;
}
if (current && typeof current === "object") {
for (const nested of Object.values(current as Record<string, unknown>)) pending.push(nested);
}
}
}
export function validateDeclarativePiConfig(raw: string): void {
assertDeclarativePiConfig(parsePiConfigJson(raw));
}
function configuredPiAgentDir(): string {
return resolve(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"));
}
function readPiAgentFile(
configuredAgentDir: string,
name: "auth.json" | "models.json",
optional: boolean,
): string | undefined {
const path = join(configuredAgentDir, name);
let fd: number | undefined;
try {
const before = lstatSync(path);
if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_AGENT_CONFIG_BYTES) {
throw new PiManagedConfigError();
}
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const opened = fstatSync(fd);
if (!opened.isFile() || opened.size > MAX_AGENT_CONFIG_BYTES
|| before.dev !== opened.dev || before.ino !== opened.ino) {
throw new PiManagedConfigError();
}
return readFileSync(fd, "utf8");
} catch (error) {
if (optional && (error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
throw new PiManagedConfigError();
} finally {
if (fd !== undefined) {
try { closeSync(fd); } catch { /* preserve the stable validation outcome */ }
}
}
}
export function readConfiguredPiAgentFile(name: "auth.json"): string;
export function readConfiguredPiAgentFile(name: "auth.json", optional: true): string | undefined;
export function readConfiguredPiAgentFile(name: "models.json", optional: true): string | undefined;
export function readConfiguredPiAgentFile(
name: "auth.json" | "models.json",
optional = false,
): string | undefined {
return readPiAgentFile(configuredPiAgentDir(), name, optional);
}
export interface PiRuntimeAgentSnapshot {
agentDir: string;
sessionDir: string;
cleanup: () => void;
}
/**
* Bind a session Pi process to the exact managed auth/model bytes validated at spawn time.
* Other agent resources remain live through symlinks, while session storage stays persistent.
*/
export function createPiRuntimeAgentSnapshot(): PiRuntimeAgentSnapshot {
const sourceAgentDir = configuredPiAgentDir();
const auth = readPiAgentFile(sourceAgentDir, "auth.json", true);
const models = readPiAgentFile(sourceAgentDir, "models.json", true);
if (auth !== undefined) validateDeclarativePiConfig(auth);
if (models !== undefined) validateDeclarativePiConfig(models);
let snapshotDir: string | undefined;
try {
snapshotDir = mkdtempSync(join(tmpdir(), "thoth-pi-runtime-agent-"));
chmodSync(snapshotDir, 0o700);
let entries: Dirent[];
try {
entries = readdirSync(sourceAgentDir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error;
entries = [];
}
for (const entry of entries) {
if (entry.name === "auth.json" || entry.name === "models.json") continue;
symlinkSync(
join(sourceAgentDir, entry.name),
join(snapshotDir, entry.name),
entry.isDirectory() ? (process.platform === "win32" ? "junction" : "dir") : "file",
);
}
if (auth !== undefined) {
writeFileSync(join(snapshotDir, "auth.json"), auth, { flag: "wx", mode: 0o600 });
}
if (models !== undefined) {
writeFileSync(join(snapshotDir, "models.json"), models, { flag: "wx", mode: 0o600 });
}
} catch {
if (snapshotDir !== undefined) {
try { rmSync(snapshotDir, { recursive: true, force: true }); } catch { /* sanitized */ }
}
throw new PiManagedConfigError();
}
let cleaned = false;
return {
agentDir: snapshotDir,
sessionDir: process.env.PI_CODING_AGENT_SESSION_DIR || join(sourceAgentDir, "sessions"),
cleanup: () => {
if (cleaned) return;
cleaned = true;
try { rmSync(snapshotDir, { recursive: true, force: true }); } catch { /* sanitized */ }
},
};
}
+341
View File
@@ -0,0 +1,341 @@
import { execFile as nodeExecFile } from "node:child_process";
import { promisify } from "node:util";
import type { AppConfig } from "../config.js";
import { secretValue } from "../config/secret-bundle.js";
import {
loadSettings,
saveSettings,
type Settings,
} from "../settings/settings-store.js";
import type { PiModel } from "./list-models.js";
import {
PI_MANAGED_CONFIG_ERROR_MESSAGE,
isPiManagedConfigError,
} from "./managed-config.js";
import { createPiProviderSmoke, type PiProviderSmoke } from "./provider-smoke.js";
import { loadPiAuthProviders } from "./auth-providers.js";
import {
piProviderCredentialStatus,
type PiCredentialStatus,
} from "./provider-credentials.js";
const execFile = promisify(nodeExecFile);
const REASONING_CHOICES = ["low", "medium", "high"] as const;
const VERSION_PATTERN = /^(?:pi(?:\s+version)?\s+)?v?(\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?)$/;
const MAX_LOG_LINES = 200;
const MAX_LOG_LINE_LENGTH = 4_096;
const MAX_EXEC_OUTPUT_BYTES = 64 * 1024;
export type PiReasoning = typeof REASONING_CHOICES[number];
export interface PiInstallationConfig {
provider?: string;
model?: string;
reasoning?: PiReasoning;
}
export interface PiStatus {
version?: string;
ready: boolean;
credentials: PiCredentialStatus;
config: PiInstallationConfig;
checkedAt: string;
message?: string;
}
export interface PiOptions {
providers: string[];
models: Array<{ provider: string; id: string }>;
reasoning: PiReasoning[];
checkedAt: string;
}
export interface PiTestResult {
ready: boolean;
checkedAt: string;
message?: string;
}
export interface PiLogs {
lines: string[];
checkedAt: string;
}
export interface PiExecFileOptions {
timeout: number;
maxBuffer: number;
}
export type PiExecFile = (
command: string,
args: string[],
options: PiExecFileOptions,
) => Promise<{ stdout: string; stderr: string }>;
export interface PiManagementService {
status(): Promise<PiStatus>;
options(): Promise<PiOptions>;
configure(value: PiInstallationConfig): Promise<PiInstallationConfig & { updatedAt: string }>;
test(): Promise<PiTestResult>;
logs(): Promise<PiLogs>;
}
export class PiManagementError extends Error {
constructor(
public readonly code: "pi_management_invalid_config" | "pi_management_unavailable" | "pi_management_write_failed",
message: string,
) {
super(message);
}
}
interface PiManagementDeps {
execute?: PiExecFile;
listModels: () => Promise<PiModel[]>;
smokeProvider?: PiProviderSmoke;
readSettings?: () => Settings;
saveSettings?: (settings: Settings) => Settings;
readLogs?: () => string | Promise<string>;
credentialStatus?: (provider: string | undefined) => PiCredentialStatus;
now?: () => Date;
}
export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService {
const now = deps.now ?? (() => new Date());
const diagnostics: string[] = [];
const addDiagnostic = (message: string): void => {
diagnostics.push(`${now().toISOString()} ${redact(message)}`);
if (diagnostics.length > MAX_LOG_LINES) diagnostics.splice(0, diagnostics.length - MAX_LOG_LINES);
};
const execute = deps.execute ?? defaultExecFile;
const readSettings = deps.readSettings ?? (() => loadSettings(config));
const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings));
const readLogs = deps.readLogs ?? (() => diagnostics.join("\n"));
const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config);
const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => {
try {
return piProviderCredentialStatus({
provider,
authProviders: loadPiAuthProviders(),
resolveCredentialValue: () => secretValue(config, "THT_MODEL_API_KEY"),
credentialFile: config.modelApiKeyFile,
});
} catch {
return "missing";
}
});
const closedOptions = async (): Promise<Omit<PiOptions, "checkedAt">> => {
let listed: PiModel[];
try {
listed = await deps.listModels();
} catch (error) {
if (isPiManagedConfigError(error)) {
throw new PiManagementError("pi_management_unavailable", PI_MANAGED_CONFIG_ERROR_MESSAGE);
}
throw new PiManagementError("pi_management_unavailable", "Pi model choices are unavailable");
}
const models: Array<{ provider: string; id: string }> = [];
const providers: string[] = [];
const seenModels = new Set<string>();
const seenProviders = new Set<string>();
for (const model of listed) {
if (!isChoice(model?.provider) || !isChoice(model?.id)) continue;
const key = `${model.provider}\u0000${model.id}`;
if (seenModels.has(key)) continue;
seenModels.add(key);
models.push({ provider: model.provider, id: model.id });
if (!seenProviders.has(model.provider)) {
seenProviders.add(model.provider);
providers.push(model.provider);
}
}
return { providers, models, reasoning: [...REASONING_CHOICES] };
};
const version = async (timeoutMs = config.piManagementTimeoutMs): Promise<string> => {
let output: { stdout: string; stderr: string };
try {
// The Pi executable and every argument are installation-owned constants. Do not add a shell.
output = await execute(config.piBin, ["--version"], {
timeout: timeoutMs,
maxBuffer: MAX_EXEC_OUTPUT_BYTES,
});
} catch (error) {
if (isTimeout(error)) {
throw new PiManagementError("pi_management_unavailable", "Pi smoke check timed out");
}
throw new PiManagementError("pi_management_unavailable", "Pi runtime is unavailable");
}
const matched = VERSION_PATTERN.exec(output.stdout.trim());
if (!matched) throw new PiManagementError("pi_management_unavailable", "Pi runtime returned an invalid version");
return matched[1];
};
const installationConfig = (): PiInstallationConfig => {
const settings = readSettings();
const provider = config.defaults.provider ?? settings.provider;
const model = config.defaults.model ?? settings.model;
const reasoning = config.defaults.thinking ?? settings.thinking;
return {
...(isChoice(provider) ? { provider } : {}),
...(isChoice(model) ? { model } : {}),
...(isReasoning(reasoning) ? { reasoning } : {}),
};
};
return {
async status(): Promise<PiStatus> {
const checkedAt = now().toISOString();
const current = installationConfig();
const credentials = credentialStatus(current.provider);
try {
const currentVersion = await version();
addDiagnostic("Pi version probe succeeded");
return { version: currentVersion, ready: true, credentials, config: current, checkedAt };
} catch (error) {
const message = stableMessage(error, "Pi runtime is unavailable");
addDiagnostic(message);
return { ready: false, credentials, config: current, checkedAt, message };
}
},
async options(): Promise<PiOptions> {
const choices = await closedOptions();
return { ...choices, checkedAt: now().toISOString() };
},
async configure(value: PiInstallationConfig): Promise<PiInstallationConfig & { updatedAt: string }> {
if (!isInstallationConfig(value)) {
throw new PiManagementError("pi_management_invalid_config", "Pi installation configuration is invalid");
}
const choices = await closedOptions();
if (!choices.models.some((model) => model.provider === value.provider && model.id === value.model)) {
throw new PiManagementError("pi_management_invalid_config", "Pi provider and model must be selected from available choices");
}
try {
persistSettings({ ...readSettings(), provider: value.provider, model: value.model, thinking: value.reasoning });
} catch {
throw new PiManagementError("pi_management_write_failed", "Pi installation configuration could not be saved");
}
addDiagnostic("Pi installation defaults updated");
return { ...value, updatedAt: now().toISOString() };
},
async test(): Promise<PiTestResult> {
const checkedAt = now().toISOString();
const deadline = Date.now() + config.piManagementTimeoutMs;
let timer: NodeJS.Timeout | undefined;
try {
const check = async (): Promise<void> => {
await version(remainingBudget(deadline));
const current = installationConfig();
if (!current.provider || !current.model || !current.reasoning) {
throw new PiManagementError(
"pi_management_unavailable",
"Pi installation configuration is incomplete",
);
}
await smokeProvider({
provider: current.provider,
model: current.model,
reasoning: current.reasoning,
timeoutMs: remainingBudget(deadline),
});
};
await Promise.race([
check(),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new PiManagementError("pi_management_unavailable", "Pi smoke check timed out")),
config.piManagementTimeoutMs,
);
}),
]);
addDiagnostic("Pi smoke check succeeded");
return { ready: true, checkedAt };
} catch (error) {
return smokeFailure(stableMessage(error, "Pi provider smoke check failed"), checkedAt, addDiagnostic);
} finally {
if (timer) clearTimeout(timer);
}
},
async logs(): Promise<PiLogs> {
let source = "";
try {
source = await readLogs();
} catch {
source = "Pi diagnostics are unavailable";
}
const lines = source
.split(/\r?\n/u)
.filter((line) => line.length > 0)
.slice(-MAX_LOG_LINES)
.map((line) => redact(line.slice(0, MAX_LOG_LINE_LENGTH)));
return { lines, checkedAt: now().toISOString() };
},
};
}
async function defaultExecFile(command: string, args: string[], options: PiExecFileOptions) {
const result = await execFile(command, args, {
timeout: options.timeout,
maxBuffer: options.maxBuffer,
windowsHide: true,
});
return { stdout: String(result.stdout), stderr: String(result.stderr) };
}
function isChoice(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= 128 && value.trim() === value
&& /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(value);
}
function isReasoning(value: unknown): value is PiReasoning {
return typeof value === "string" && (REASONING_CHOICES as readonly string[]).includes(value);
}
function isInstallationConfig(value: unknown): value is Required<PiInstallationConfig> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const candidate = value as Record<string, unknown>;
if (Object.keys(candidate).length !== 3 || Object.keys(candidate).some((key) => !["provider", "model", "reasoning"].includes(key))) {
return false;
}
return isChoice(candidate.provider) && isChoice(candidate.model) && isReasoning(candidate.reasoning);
}
function isTimeout(error: unknown): boolean {
return Boolean(
error && typeof error === "object" && (
(error as { code?: unknown }).code === "ETIMEDOUT"
|| (error as { killed?: unknown }).killed === true
),
);
}
function stableMessage(error: unknown, fallback: string): string {
if (isPiManagedConfigError(error)) return PI_MANAGED_CONFIG_ERROR_MESSAGE;
return error instanceof PiManagementError ? error.message : fallback;
}
function smokeFailure(
message: string,
checkedAt: string,
addDiagnostic: (message: string) => void,
): PiTestResult {
addDiagnostic(message);
return { ready: false, message, checkedAt };
}
export function redact(value: string): string {
return value
.replace(/(\bauthorization\b\s*:\s*Bearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
.replace(/((?:["']?)[A-Za-z0-9_-]*(?:api[_-]?key|token|password|secret|authorization)[A-Za-z0-9_-]*(?:["']?)\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;}]+)/giu, "$1[REDACTED]")
.replace(/(\bBearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
.replace(/(\w+:\/\/[^:/\s]+:)[^@/\s]+@/gu, "$1[REDACTED]@");
}
function remainingBudget(deadline: number): number {
return Math.max(1, deadline - Date.now());
}
+95 -44
View File
@@ -2,17 +2,19 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:ch
import type { AppConfig } from "../config.js";
import { RpcClient } from "../rpc/rpc-client.js";
import { SessionBridge } from "../bridge/session-bridge.js";
import type { ThtRunner } from "../tht/tht-runner.js";
import type { RuntimeConfigLease, ThtRunner } from "../tht/tht-runner.js";
import { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js";
import { loadPiAuthProviders } from "./auth-providers.js";
import { secretValue } from "../config/secret-bundle.js";
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
import { createPiRuntimeAgentSnapshot } from "./managed-config.js";
export interface SessionRuntime {
rpc: RpcClient;
bridge: SessionBridge;
child: ChildProcessWithoutNullStreams;
ownerKey?: string;
releaseRuntimeConfig?: () => void;
}
export interface RuntimeOptions {
@@ -23,6 +25,7 @@ export interface RuntimeOptions {
question?: string;
mode?: "new" | "resume";
principal?: PrincipalContext;
runtimeConfig?: RuntimeConfigLease;
}
/** Injectable child-process boundary; callbacks may ignore arguments in simpler tests. */
@@ -34,67 +37,94 @@ type SpawnFn = (
export class PiProcessManager {
private runtimes = new Map<string, SessionRuntime>();
private agentSnapshotCleanups = new WeakMap<ChildProcessWithoutNullStreams, () => void>();
private spawnFn: (
sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext,
runtimeConfigPath?: string,
) => ChildProcessWithoutNullStreams;
private loadAuthProviders: () => ReadonlySet<string>;
private loadAuthProviders: (agentDir: string) => ReadonlySet<string>;
constructor(
private cfg: AppConfig,
opts?: { spawnFn?: SpawnFn; authProviders?: () => ReadonlySet<string> },
opts?: { spawnFn?: SpawnFn; authProviders?: (agentDir: string) => ReadonlySet<string> },
) {
this.loadAuthProviders = opts?.authProviders ?? (() => loadPiAuthProviders());
this.loadAuthProviders = opts?.authProviders
?? ((agentDir) => loadPiAuthProviders({ agentDir }));
if (opts?.spawnFn) {
this.spawnFn = (sessionId, author, provider, principal) =>
this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal);
this.spawnFn = (sessionId, author, provider, principal, runtimeConfigPath) =>
this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal, runtimeConfigPath);
} else {
this.spawnFn = (sessionId, author, provider, principal) =>
this.spawnPi(nodeSpawn, sessionId, author, provider, principal);
this.spawnFn = (sessionId, author, provider, principal, runtimeConfigPath) =>
this.spawnPi(nodeSpawn, sessionId, author, provider, principal, runtimeConfigPath);
}
}
private cleanupAgentSnapshot(child: ChildProcessWithoutNullStreams): void {
const cleanup = this.agentSnapshotCleanups.get(child);
if (!cleanup) return;
this.agentSnapshotCleanups.delete(child);
cleanup();
}
private spawnPi(
spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext,
spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined,
principal?: PrincipalContext, runtimeConfigPath?: string,
): ChildProcessWithoutNullStreams {
const env = buildPiChildEnv({
provider,
authProviders: this.loadAuthProviders(),
credentialValue: secretValue(this.cfg, "THT_MODEL_API_KEY"),
credentialFile: this.cfg.modelApiKeyFile,
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
});
clearPrincipalEnvironment(env);
if (principal) Object.assign(env, principalEnvironment(principal));
// The Thoth gate executes the deterministic `tht` CLI as a Pi tool. Give only
// this managed session process the adapter values already loaded by the core
// entrypoint; the generic provider helper continues to scrub them by default.
for (const name of [
"THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY",
] as const) {
const value = secretValue(this.cfg, name) ?? process.env[name];
if (value !== undefined) env[name] = value;
}
const ca = secretValue(this.cfg, "THT_SSL_CA")
?? secretValue(this.cfg, "THT_CA")
?? process.env.THT_SSL_CA
?? process.env.THT_CA;
if (ca !== undefined) {
env.THT_CA = ca;
env.THT_SSL_CA = ca;
}
delete env.THT_DATA_ROOT;
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
// pi 0.73 removed `--approve`: rpc mode is headless and its argv is intentionally minimal.
const child = spawnFn(this.cfg.piBin, ["--mode", "rpc"], {
cwd: this.cfg.harnessDir,
env,
});
// This is the final shared boundary for createFor(), spawnFor(), and resume(). Validate
// before auth-provider inspection, then make Pi consume the exact copied bytes rather than
// reopening mutable mounted auth/models files after this check.
const agent = createPiRuntimeAgentSnapshot();
let child: ChildProcessWithoutNullStreams | undefined;
try {
const env = buildPiChildEnv({
provider,
authProviders: this.loadAuthProviders(agent.agentDir),
credentialValue: secretValue(this.cfg, "THT_MODEL_API_KEY"),
credentialFile: this.cfg.modelApiKeyFile,
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
});
env.PI_CODING_AGENT_DIR = agent.agentDir;
env.PI_CODING_AGENT_SESSION_DIR = agent.sessionDir;
clearPrincipalEnvironment(env);
if (principal) Object.assign(env, principalEnvironment(principal));
// The Thoth gate executes the deterministic `tht` CLI as a Pi tool. Give only
// this managed session process the adapter values already loaded by the core
// entrypoint; the generic provider helper continues to scrub them by default.
for (const name of [
"THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY",
] as const) {
const value = secretValue(this.cfg, name) ?? process.env[name];
if (value !== undefined) env[name] = value;
}
const ca = secretValue(this.cfg, "THT_SSL_CA")
?? secretValue(this.cfg, "THT_CA")
?? process.env.THT_SSL_CA
?? process.env.THT_CA;
if (ca !== undefined) {
env.THT_CA = ca;
env.THT_SSL_CA = ca;
}
delete env.THT_DATA_ROOT;
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
if (runtimeConfigPath !== undefined) env.THT_CONFIG = runtimeConfigPath;
// pi 0.73 removed `--approve`: rpc mode is headless and its argv is intentionally minimal.
child = spawnFn(this.cfg.piBin, ["--mode", "rpc"], {
cwd: this.cfg.harnessDir,
env,
});
this.agentSnapshotCleanups.set(child, agent.cleanup);
child.once("exit", () => this.cleanupAgentSnapshot(child!));
child.once("close", () => this.cleanupAgentSnapshot(child!));
// Log stderr for debugging (was silently drained)
child.stderr.on("data", (d: Buffer) => console.error(`[pi:${sessionId}] stderr:`, d.toString().trim()));
return child;
} catch (error) {
try { child.kill(); } catch { /* preserve the initialization error */ }
if (child) {
try { child.kill(); } catch { /* preserve the initialization error */ }
this.cleanupAgentSnapshot(child);
} else {
agent.cleanup();
}
throw error;
}
}
@@ -118,15 +148,31 @@ export class PiProcessManager {
// SIGTERM to the in-flight Pi process and lose its pending gate.
const existing = this.runtimes.get(sessionId);
if (existing) {
o.runtimeConfig?.release();
throw new Error(`session runtime already active: ${sessionId}`);
}
if (o.principal) this.teardownForPrincipal(o.principal);
if (this.runtimes.size >= this.cfg.maxPiProcesses) {
o.runtimeConfig?.release();
throw new Error("max Pi processes reached");
}
const author = o.author ?? "dev@local";
const provider = canonicalPiProvider(o.provider ?? this.cfg.defaults.provider);
const child = this.spawnFn(sessionId, author, provider, o.principal);
let child: ChildProcessWithoutNullStreams;
try {
child = this.spawnFn(sessionId, author, provider, o.principal, o.runtimeConfig?.path);
} catch (error) {
o.runtimeConfig?.release();
throw error;
}
let runtimeConfigReleased = false;
const releaseRuntimeConfig = () => {
if (runtimeConfigReleased) return;
runtimeConfigReleased = true;
o.runtimeConfig?.release();
};
child.once("exit", releaseRuntimeConfig);
child.once("close", releaseRuntimeConfig);
let rt: SessionRuntime | undefined;
try {
const rpc = new RpcClient(child);
@@ -136,6 +182,7 @@ export class PiProcessManager {
bridge,
child,
ownerKey: o.principal ? `${o.principal.issuer}\0${o.principal.subject}` : undefined,
...(o.runtimeConfig ? { releaseRuntimeConfig } : {}),
};
rt = runtime;
bridge.beginTurn();
@@ -170,7 +217,9 @@ export class PiProcessManager {
return runtime;
} catch (error) {
if (rt && this.runtimes.get(sessionId) === rt) this.runtimes.delete(sessionId);
releaseRuntimeConfig();
try { child.kill(); } catch { /* preserve the initialization error */ }
this.cleanupAgentSnapshot(child);
throw error;
}
}
@@ -237,7 +286,9 @@ export class PiProcessManager {
// Delete before signalling the child so its asynchronous exit cannot be mistaken for a
// crash, and so a replacement installed by a later lifecycle operation is never targeted.
this.runtimes.delete(id);
expected.releaseRuntimeConfig?.();
expected.child.kill();
this.cleanupAgentSnapshot(expected.child);
return true;
}
}
+28
View File
@@ -53,6 +53,8 @@ export function canonicalPiProvider(provider: string | undefined): string | unde
return value;
}
export type PiCredentialStatus = "present" | "missing";
export interface CredentialFsOps {
lstat(path: string): Stats;
open(path: string, flags: number): number;
@@ -172,3 +174,29 @@ export function buildPiChildEnv(opts: {
}
return env;
}
/** Report only whether the selected hosted provider has a usable credential source. */
export function piProviderCredentialStatus(opts: {
provider?: string;
credentialFile?: string;
resolveCredentialValue?: () => string | undefined;
authProviders?: ReadonlySet<string>;
fsOps?: CredentialFsOps;
}): PiCredentialStatus {
const provider = canonicalPiProvider(opts.provider);
if (!provider || LOCAL_PROVIDERS.has(provider)) return "missing";
if (opts.authProviders?.has(provider)) return "present";
try {
buildPiChildEnv({
ambient: {},
provider,
credentialFile: opts.credentialFile,
credentialValue: opts.resolveCredentialValue?.(),
authProviders: opts.authProviders,
fsOps: opts.fsOps,
});
return "present";
} catch {
return "missing";
}
}
+274
View File
@@ -0,0 +1,274 @@
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AppConfig } from "../config.js";
import { secretValue } from "../config/secret-bundle.js";
import { clearPrincipalEnvironment } from "../auth/principal.js";
import { RpcClient } from "../rpc/rpc-client.js";
import { loadPiAuthProviders } from "./auth-providers.js";
import { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js";
import type { PiReasoning } from "./management.js";
import {
PiManagedConfigError,
isPiManagedConfigError,
parsePiConfigJson,
readConfiguredPiAgentFile,
validateDeclarativePiConfig,
} from "./managed-config.js";
const SMOKE_PROMPT = "Provider health check. Reply with exactly OK.";
const SMOKE_ARGS = [
"--mode", "rpc",
"--no-session",
"--no-tools",
"--no-extensions",
"--no-skills",
"--no-prompt-templates",
"--no-themes",
"--no-context-files",
"--no-approve",
] as const;
export interface PiProviderSmokeRequest {
provider: string;
model: string;
reasoning: PiReasoning;
timeoutMs: number;
}
export type PiProviderSmoke = (request: PiProviderSmokeRequest) => Promise<void>;
interface ProviderSmokeOptions {
spawnFn?: (
command: string,
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
) => ChildProcessWithoutNullStreams;
authProviders?: () => ReadonlySet<string>;
readAuthStore?: () => string;
readModelsStore?: () => string | undefined;
}
export function createPiProviderSmoke(
config: AppConfig,
options: ProviderSmokeOptions = {},
): PiProviderSmoke {
const spawnFn = options.spawnFn ?? nodeSpawn;
const authProviders = options.authProviders ?? (() => loadPiAuthProviders());
return async ({ provider, model, reasoning, timeoutMs }): Promise<void> => {
let child: ChildProcessWithoutNullStreams | undefined;
let isolatedRoot: string | undefined;
let timer: NodeJS.Timeout | undefined;
try {
const canonicalProvider = canonicalPiProvider(provider);
if (!canonicalProvider || timeoutMs <= 0) throw providerFailure();
const configuredAuthProviders = authProviders();
const env = buildPiChildEnv({
provider: canonicalProvider,
authProviders: configuredAuthProviders,
credentialValue: secretValue(config, "THT_MODEL_API_KEY"),
credentialFile: config.modelApiKeyFile,
});
clearPrincipalEnvironment(env);
delete env.THT_DATA_ROOT;
delete env.THT_SESSION;
delete env.THT_AUTHOR;
delete env.THT_CONFIG;
delete env.PI_CODING_AGENT_SESSION_DIR;
isolatedRoot = mkdtempSync(join(tmpdir(), "thothii-pi-smoke-"));
const isolatedCwd = join(isolatedRoot, "work");
const isolatedAgentDir = join(isolatedRoot, "agent");
mkdirSync(isolatedCwd, { mode: 0o700 });
mkdirSync(isolatedAgentDir, { mode: 0o700 });
if (configuredAuthProviders.has(canonicalProvider)) {
const authStore = selectedProviderAuthStore(
options.readAuthStore?.() ?? readConfiguredPiAgentFile("auth.json"),
canonicalProvider,
);
writeDeclarativeAgentConfig(join(isolatedAgentDir, "auth.json"), authStore);
}
const configuredModels = options.readModelsStore
? options.readModelsStore()
: readConfiguredPiAgentFile("models.json", true);
if (configuredModels !== undefined) {
const modelsStore = selectedProviderModelsStore(
configuredModels,
canonicalProvider,
model,
);
if (modelsStore !== undefined) {
writeDeclarativeAgentConfig(join(isolatedAgentDir, "models.json"), modelsStore);
}
}
env.PI_CODING_AGENT_DIR = isolatedAgentDir;
child = spawnFn(config.piBin, [...SMOKE_ARGS], { cwd: isolatedCwd, env });
child.stderr.resume();
const rpc = new RpcClient(child);
const capabilityGuard = failOnUnexpectedCapabilities(rpc);
const turn = async (): Promise<void> => {
requireSuccessfulResponse(await rpc.request({
type: "set_model", provider: canonicalProvider, modelId: model,
} as object & { type: string }));
requireSuccessfulResponse(await rpc.request({
type: "set_thinking_level", level: reasoning,
} as object & { type: string }));
await waitForProviderTurn(rpc, child!);
};
await Promise.race([
turn(),
capabilityGuard,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(providerTimeout()), timeoutMs);
}),
]);
} catch (error) {
if (isProviderTimeout(error)) throw providerTimeout();
if (isPiManagedConfigError(error)) throw new PiManagedConfigError();
throw providerFailure();
} finally {
if (timer) clearTimeout(timer);
if (child) {
try { child.kill(); } catch { /* preserve the sanitized smoke outcome */ }
}
if (isolatedRoot) {
try { rmSync(isolatedRoot, { recursive: true, force: true, maxRetries: 2 }); } catch {
/* preserve the sanitized smoke outcome; the OS temp directory remains isolated */
}
}
}
};
}
function failOnUnexpectedCapabilities(rpc: RpcClient): Promise<never> {
return new Promise((_resolve, reject) => {
rpc.on("event", (event) => {
if (isUnexpectedCapabilityEvent(event)) reject(providerFailure());
});
});
}
function isUnexpectedCapabilityEvent(event: any): boolean {
const type = typeof event?.type === "string" ? event.type : "";
if (type.startsWith("tool_") || type.startsWith("toolcall_") || type.startsWith("extension_")) {
return true;
}
const updateType = event?.assistantMessageEvent?.type;
if (typeof updateType === "string" && updateType.startsWith("toolcall_")) return true;
if (Array.isArray(event?.toolResults) && event.toolResults.length > 0) return true;
if (messageUsesTool(event?.message)) return true;
return Array.isArray(event?.messages) && event.messages.some(messageUsesTool);
}
function messageUsesTool(message: any): boolean {
return message?.role === "toolResult" || message?.stopReason === "toolUse"
|| (Array.isArray(message?.content)
&& message.content.some((content: any) => content?.type === "toolCall"));
}
function writeDeclarativeAgentConfig(path: string, raw: string): void {
validateDeclarativePiConfig(raw);
writeFileSync(path, raw, { mode: 0o600, flag: "wx" });
}
function selectedProviderAuthStore(raw: string, provider: string): string {
const parsed = parsePiConfigJson(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new PiManagedConfigError();
}
const entry = Object.entries(parsed as Record<string, unknown>)
.find(([key]) => key.trim().toLowerCase() === provider);
if (!entry) throw new PiManagedConfigError();
return JSON.stringify({ [entry[0]]: entry[1] });
}
const PROVIDER_CONFIG_FIELDS = [
"name", "baseUrl", "apiKey", "api", "headers", "compat", "authHeader",
] as const;
function selectedProviderModelsStore(raw: string, provider: string, model: string): string | undefined {
const parsed = parsePiConfigJson(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new PiManagedConfigError();
}
const providers = (parsed as { providers?: unknown }).providers;
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
throw new PiManagedConfigError();
}
const entry = Object.entries(providers as Record<string, unknown>)
.find(([key]) => key.trim().toLowerCase() === provider);
if (!entry) return undefined;
const providerConfig = entry[1];
if (!providerConfig || typeof providerConfig !== "object" || Array.isArray(providerConfig)) {
throw new PiManagedConfigError();
}
const source = providerConfig as Record<string, unknown>;
const selected: Record<string, unknown> = {};
for (const field of PROVIDER_CONFIG_FIELDS) {
if (Object.hasOwn(source, field)) selected[field] = source[field];
}
if (Object.hasOwn(source, "models")) {
if (!Array.isArray(source.models)) throw new PiManagedConfigError();
let selectedModel: unknown;
for (const candidate of source.models) {
if (candidate && typeof candidate === "object" && !Array.isArray(candidate)
&& (candidate as { id?: unknown }).id === model) {
selectedModel = candidate;
}
}
if (selectedModel !== undefined) selected.models = [selectedModel];
}
if (Object.hasOwn(source, "modelOverrides")) {
const overrides = source.modelOverrides;
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) {
throw new PiManagedConfigError();
}
if (Object.hasOwn(overrides, model)) {
selected.modelOverrides = { [model]: (overrides as Record<string, unknown>)[model] };
}
}
return JSON.stringify({ providers: { [entry[0]]: selected } });
}
function waitForProviderTurn(rpc: RpcClient, child: ChildProcessWithoutNullStreams): Promise<void> {
return new Promise((resolve, reject) => {
let failed = false;
rpc.on("event", (event) => {
if (event?.type === "message_end" && event.message?.role === "assistant"
&& event.message.stopReason === "error") {
failed = true;
reject(providerFailure());
return;
}
if (event?.type === "agent_end") {
const messages = Array.isArray(event.messages) ? event.messages : [];
const eventFailed = messages.some((message: any) => (
message?.role === "assistant" && message?.stopReason === "error"
));
if (failed || eventFailed) reject(providerFailure());
else resolve();
}
});
child.once("exit", () => reject(providerFailure()));
rpc.send({ type: "prompt", message: SMOKE_PROMPT });
});
}
function requireSuccessfulResponse(response: any): void {
if (!response || response.success !== true) throw providerFailure();
}
function providerFailure(): Error {
return new Error("Pi provider smoke check failed");
}
function providerTimeout(): Error {
return Object.assign(new Error("Pi smoke check timed out"), { code: "ETIMEDOUT" });
}
function isProviderTimeout(error: unknown): boolean {
return Boolean(error && typeof error === "object" && (error as { code?: unknown }).code === "ETIMEDOUT");
}
+3 -5
View File
@@ -2,6 +2,7 @@ import { readdirSync } from "node:fs";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import type { PiModel } from "../pi/list-models.js";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
export type ListModelsFn = () => Promise<PiModel[]>;
@@ -26,11 +27,8 @@ export function metaRoutes(
app: FastifyInstance,
deps: { harnessDir: string; listModels?: ListModelsFn },
): void {
app.get("/workspaces", async () => {
return listWorkspaces(deps.harnessDir);
});
app.get("/models", async () => {
app.get("/models", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply;
const fn = deps.listModels ?? (async () => []);
try {
return { models: await fn() };
+50
View File
@@ -0,0 +1,50 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import {
isPrincipalContext,
requirePermission,
requireSameOriginOrNonBrowser,
} from "../auth/authorization.js";
import { PiManagementError, type PiManagementService } from "../pi/management.js";
export function piManagementRoutes(
app: FastifyInstance,
deps: { service: PiManagementService },
): void {
app.get("/pi-management/status", async (request, reply) => run(request, reply, deps, () => deps.service.status()));
app.get("/pi-management/options", async (request, reply) => run(request, reply, deps, () => deps.service.options()));
app.put("/pi-management/config", async (request, reply) => run(
request,
reply,
deps,
() => deps.service.configure((request.body ?? {}) as Record<string, unknown>),
));
app.post("/pi-management/test", async (request, reply) => run(request, reply, deps, () => deps.service.test()));
app.get("/pi-management/logs", async (request, reply) => run(request, reply, deps, () => deps.service.logs()));
}
async function run<T>(
request: FastifyRequest,
reply: FastifyReply,
deps: { service: PiManagementService },
action: () => Promise<T>,
): Promise<T | FastifyReply> {
const principal = requirePermission(request, reply, "pi.manage");
if (!isPrincipalContext(principal)) return principal;
if (principal.issuer === "local" && isWrite(request.method)) {
const csrfDenied = requireSameOriginOrNonBrowser(request, reply);
if (csrfDenied) return csrfDenied;
}
try {
return await action();
} catch (error) {
if (error instanceof PiManagementError) {
const statusCode = error.code === "pi_management_invalid_config" ? 400 : 503;
return reply.code(statusCode).send({ code: error.code, error: error.message });
}
return reply.code(503).send({ code: "pi_management_unavailable", error: "Pi management is unavailable" });
}
}
function isWrite(method: string): boolean {
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
}
+417 -154
View File
@@ -7,6 +7,10 @@ import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
import type { ReadinessManager } from "../runtime/readiness-manager.js";
import type { ListModelsFn } from "./meta.js";
import type { WorkspaceRegistry } from "../workspaces/registry.js";
import { validateOperationalWorkspace, type WorkspaceDescriptor } from "../workspaces/schema.js";
import type { MaintenanceBarrier } from "../runtime/maintenance-gate.js";
import { hasPermission, isPrincipalContext, requirePermission } from "../auth/authorization.js";
const BOOTSTRAP_FAILURE_MESSAGE =
"Session startup failed. Check configuration and connectivity, then Resume the session.";
@@ -18,6 +22,8 @@ const DWH_UNREACHABLE_MESSAGE =
"Cannot start a session: the database is unreachable. Check the VPN connection and try again.";
const MODEL_UNAVAILABLE_MESSAGE =
"Selected model is unavailable. Check Pi authentication and model settings, then try again.";
const WORKSPACE_REVISION_UNAVAILABLE_MESSAGE =
"Session workspace configuration is unavailable. Check configuration and try again.";
export function sessionRoutes(
app: FastifyInstance,
@@ -26,8 +32,14 @@ export function sessionRoutes(
getSettings: (principal: PrincipalContext) => Promise<Settings>;
readiness: ReadinessManager;
listModels: ListModelsFn;
workspaceRegistry: WorkspaceRegistry;
/** Local-only guard: probe DWH reachability before creating a session (run-stack.sh). */
dwhPrecheck?: boolean;
/** Explicit loopback-only compatibility path for old clients that send `workspace`. */
legacyWorkspaceMode?: boolean;
/** Fail-closed installation/runtime transport capability check. */
workspaceRuntimeSupport: (workspace: WorkspaceDescriptor) => boolean;
maintenanceBarrier: MaintenanceBarrier;
},
) {
const lifecycleTails = new Map<string, Promise<void>>();
@@ -65,26 +77,137 @@ export function sessionRoutes(
const runner = d.tht as any;
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
};
const ownershipPrincipal = (principal: PrincipalContext, permission: "session.read_all" | "session.manage_all") => ({
...principal,
// The harness transition remains isAdmin-based, but only the relevant all-session
// permission can enable its RLS bypass.
isAdmin: hasPermission(principal, permission),
});
const optionsWithRuntimeConfig = (runner: any, workspaceConfigPath: string | undefined, options: any) => (
workspaceConfigPath && typeof runner.acquireWorkspaceRuntime === "function"
? { ...options, runtimeConfig: runner.acquireWorkspaceRuntime(workspaceConfigPath) }
: options
);
const maintenanceReply = (reply: any) => reply.code(503).send({
code: "maintenance",
error: "Session admission is temporarily paused for maintenance. Try again shortly.",
});
const admissionLeases = new WeakMap<object, () => void>();
app.addHook("preHandler", async (req, reply) => {
if (req.method !== "POST" || !(req.url === "/sessions" || /^\/sessions\/[^/]+\/resume(?:\?|$)/.test(req.url))) return;
const release = d.maintenanceBarrier.acquire();
if (!release) return maintenanceReply(reply);
admissionLeases.set(req, release);
});
app.addHook("preHandler", async (req, reply) => {
const pathname = req.url.split("?", 1)[0];
if (pathname === "/runtime/prewarm" || pathname === "/sessions" || pathname.startsWith("/sessions/")) {
const principal = requirePermission(req, reply, "session.use");
if (!isPrincipalContext(principal)) return principal;
}
});
app.addHook("onResponse", async (req) => { admissionLeases.get(req)?.(); });
/** Include retained historical descriptors so removed workspaces remain resumable. */
const sessionRevisions = async () => {
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
if (typeof registry.listRetainedSnapshots === "function") {
return await registry.listRetainedSnapshots();
}
return await d.workspaceRegistry.list();
};
const isNotFound = (error: unknown) =>
/not found|non trovata|inesistente|404/i.test(error instanceof Error ? error.message : String(error));
/** RLS makes a foreign session indistinguishable from a missing one. */
const authorize = async (principal: PrincipalContext, id: string, workspace?: string): Promise<any | undefined> => {
type LocatedSession = {
manifest: any;
workspaceConfigPath: string;
workspace?: WorkspaceDescriptor;
};
const workspaceRevisionUnavailable = () => Object.assign(
new Error("workspace revision unavailable"), { code: "workspace_revision_unavailable" },
);
const unavailableWorkspaceReply = (reply: any) => reply.code(409).send({
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
code: "workspace_revision_unavailable",
});
/**
* Find a session by asking every active registry snapshot, never by using the installation
* default. `tht` applies RLS for the supplied principal, so a foreign ID remains a 404.
*/
const locateSession = async (
principal: PrincipalContext, id: string, permission: "session.read_all" | "session.manage_all" = "session.read_all",
): Promise<LocatedSession | undefined> => {
const runner = runnerFor(ownershipPrincipal(principal, permission));
// Dependency-injected runners in legacy route tests may model only the mutation under test.
if (typeof runner.sessionShow !== "function") return { manifest: {}, workspaceConfigPath: "" };
const legacySession = async (): Promise<LocatedSession | undefined> => {
try {
const manifest = await runner.sessionShow(id);
return manifest && !manifest.workspace_id && !manifest.workspace_revision
? { manifest, workspaceConfigPath: "" }
: undefined;
} catch (error) {
if (isNotFound(error)) return undefined;
throw error;
}
};
let revisions: Awaited<ReturnType<typeof d.workspaceRegistry.list>>;
try {
const runner = runnerFor(principal);
// Dependency-injected runners in legacy route tests may model only the mutation under
// test. Production ThtRunner always exposes sessionShow; keep that test seam harmless.
if (typeof runner.sessionShow !== "function") return {};
const manifest = await runner.sessionShow(id, workspace);
return manifest ?? undefined;
} catch (error) {
if (isNotFound(error)) return undefined;
throw error;
revisions = await sessionRevisions();
} catch (registryError) {
// Sessions created before revision pinning still live under the installation's legacy
// default config. Keep that compatibility path available when a fresh installation has
// no registry snapshot yet; a pinned session remains fail-closed below.
const legacy = await legacySession();
if (legacy) return legacy;
throw registryError;
}
for (const revision of revisions) {
try {
const manifest = await runner.sessionShow(id, revision.snapshotPath);
if (manifest) return { manifest, workspaceConfigPath: revision.snapshotPath };
} catch (error) {
if (isNotFound(error)) continue;
throw error;
}
}
return await legacySession();
};
/** Read the durable pinned descriptor only after the owner-visible manifest is located. */
const resolveSessionWorkspace = async (located: LocatedSession): Promise<LocatedSession> => {
const saved = located.manifest as { workspace_id?: string; workspace_revision?: string };
if (!saved.workspace_id || !saved.workspace_revision) return located;
try {
const pinned = await d.workspaceRegistry.readPinned(saved.workspace_id, saved.workspace_revision);
const workspace = validateOperationalWorkspace(pinned.workspace);
return {
...located,
workspace,
workspaceConfigPath: pinned.workspaceConfigPath ?? (pinned as any).revision?.snapshotPath,
};
} catch {
throw workspaceRevisionUnavailable();
}
};
/** RLS makes a foreign session indistinguishable from a missing one. */
const authorize = async (
principal: PrincipalContext, id: string, permission: "session.read_all" | "session.manage_all" = "session.read_all",
): Promise<LocatedSession | undefined> => await locateSession(principal, id, permission);
const storageFailure = (reply: any) => reply.code(503).send({ error: "session storage is unavailable" });
const lifecycleFailure = (reply: any, error: unknown) =>
(error as { code?: string } | undefined)?.code === "workspace_revision_unavailable"
? unavailableWorkspaceReply(reply)
: storageFailure(reply);
const releaseIfFinalized = async (
id: string, rt: ReturnType<PiProcessManager["createFor"]>,
@@ -195,97 +318,201 @@ export function sessionRoutes(
});
app.post("/sessions", async (req, reply) => {
const b = req.body as { question: string; name?: string };
const b = req.body as {
question: string; name?: string; workspace?: string; workspaceId?: string;
provider?: string; model?: string; thinking?: string;
};
const principal = getPrincipal(req);
let s: Settings;
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
const runner = runnerFor(principal);
// A persisted session is resumable without keeping Pi alive. New work replaces every
// runtime owned by this principal, while runtimes belonging to other users remain intact.
// Optional chaining preserves the deliberately narrow manager stubs used by route tests.
for (const id of d.mgr.teardownForPrincipal?.(principal) ?? []) boundRuntimes.delete(id);
const ensure = await d.readiness.ensure(s.workspace ?? "", principal);
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
// Local-only: verify the DWH is reachable BEFORE creating the session, so a dropped
// VPN surfaces as an up-front alert instead of a session that spawns Pi and then dies
// in bootstrap retrieval. `code` lets the client show a specific message.
if (d.dwhPrecheck) {
const ping = await runner.dbPing(s.workspace);
if (!ping.ok) {
console.error(`[dwh-precheck] refusing new session — DWH unreachable: ${ping.detail}`);
return reply.code(503).send({ error: DWH_UNREACHABLE_MESSAGE, code: "dwh_unreachable" });
}
}
if (s.provider && s.model) {
let available: Awaited<ReturnType<ListModelsFn>>;
try {
available = await d.listModels();
} catch {
return reply.code(503).send({
error: MODEL_UNAVAILABLE_MESSAGE,
code: "model_unavailable",
});
}
const selectedAvailable = available.some(
(candidate) => candidate.provider === s.provider && candidate.id === s.model,
);
if (!selectedAvailable) {
return reply.code(503).send({
error: MODEL_UNAVAILABLE_MESSAGE,
code: "model_unavailable",
});
}
}
// Settings (global) supply workspace/provider/model/thinking. The new-question
// form sends only the question text. `workspace` selects the tht `-c <config>`.
let id: string;
try {
({ id } = await runner.sessionNew({
question: b.question, name: b.name, workspace: s.workspace,
provider: s.provider, model: s.model, thinking: s.thinking,
}));
} catch { return storageFailure(reply); }
const options = {
provider: s.provider,
model: s.model,
thinking: s.thinking,
author: principal.displayName ?? principal.subject,
principal,
question: b.question,
};
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
try {
rt = d.mgr.createFor(id, options);
bindRuntime(id, rt, runner, s.workspace);
} catch (error) {
if (rt) d.mgr.teardownIfCurrent(id, rt);
console.error(
`[pi:${id}] runtime construction failed:`,
error instanceof Error ? error.message : "unknown error",
);
await runner.failSession(id, s.workspace).catch((persistenceError: unknown) => {
console.error(`[session:${id}] failSession persistence failed:`, persistenceError);
// `workspace` was the legacy request field before browser-local registry preferences.
// It is available only through the explicit loopback-only compatibility mode; every normal
// new session must resolve and pin an immutable registry revision.
const legacyWorkspaceRequest = d.legacyWorkspaceMode
&& typeof b.workspace === "string" && b.workspace.length > 0;
const requestedWorkspaceId = b.workspaceId ?? (legacyWorkspaceRequest ? undefined : s.workspace);
if (!requestedWorkspaceId && !legacyWorkspaceRequest) {
return reply.code(409).send({
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
code: "workspace_revision_unavailable",
});
return reply.code(503).send({ error: BOOTSTRAP_FAILURE_MESSAGE });
}
info(id, "Session created");
bootstrap(
id, rt, runner, s.workspace, d.mgr.configure(rt, options),
runner.searchPack(b.question, id, s.workspace),
() => d.mgr.start(id, rt, options),
);
return { id };
let revisionLease: Awaited<ReturnType<WorkspaceRegistry["acquireSessionRevision"]>> | undefined;
let manifestPersisted = false;
try {
let workspaceConfigPath: string | undefined;
let workspaceId: string | undefined;
let workspaceRevision: string | undefined;
let workspaceDescriptor: WorkspaceDescriptor | undefined;
let allowedModels: readonly string[] | undefined;
if (requestedWorkspaceId) {
try {
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
const resolved = typeof registry.acquireSessionRevision === "function"
? await registry.acquireSessionRevision.call(d.workspaceRegistry, requestedWorkspaceId)
: await d.workspaceRegistry.read(requestedWorkspaceId);
if ("markPersisted" in resolved && "abort" in resolved) {
revisionLease = resolved as Awaited<ReturnType<WorkspaceRegistry["acquireSessionRevision"]>>;
}
if (!d.workspaceRuntimeSupport(resolved.workspace)) {
return reply.code(409).send({
error: "This workspace transport is not available to runtime sessions.",
code: "workspace_not_activatable",
});
}
workspaceConfigPath = resolved.revision.snapshotPath;
workspaceId = resolved.revision.id;
workspaceRevision = resolved.revision.commit;
workspaceDescriptor = resolved.workspace;
allowedModels = resolved.workspace.llm_policy.allowed;
} catch {
return reply.code(409).send({
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
code: "workspace_revision_unavailable",
});
}
}
const provider = b.provider ?? s.provider;
const model = b.model ?? s.model;
const thinking = b.thinking ?? s.thinking;
if (allowedModels && provider && model && !allowedModels.includes(`${provider}/${model}`)) {
return reply.code(400).send({ error: "Selected model is not allowed by this workspace." });
}
// A persisted session is resumable without keeping Pi alive. New work replaces every
// runtime owned by this principal, while runtimes belonging to other users remain intact.
// Optional chaining preserves the deliberately narrow manager stubs used by route tests.
for (const id of d.mgr.teardownForPrincipal?.(principal) ?? []) boundRuntimes.delete(id);
const ensure = await d.readiness.ensure(
workspaceConfigPath ?? "", principal, workspaceDescriptor,
);
if (!ensure.ok) return reply.code(503).send({
error: READINESS_FAILURE_MESSAGE,
...(ensure.code ? { code: ensure.code } : {}),
});
// Local-only: verify the DWH is reachable BEFORE creating the session, so a dropped
// VPN surfaces as an up-front alert instead of a session that spawns Pi and then dies
// in bootstrap retrieval. `code` lets the client show a specific message.
if (d.dwhPrecheck) {
const ping = await runner.dbPing(workspaceConfigPath);
if (!ping.ok) {
console.error(`[dwh-precheck] refusing new session — DWH unreachable: ${ping.detail}`);
return reply.code(503).send({ error: DWH_UNREACHABLE_MESSAGE, code: "dwh_unreachable" });
}
}
if (provider && model) {
let available: Awaited<ReturnType<ListModelsFn>>;
try {
available = await d.listModels();
} catch {
return reply.code(503).send({
error: MODEL_UNAVAILABLE_MESSAGE,
code: "model_unavailable",
});
}
const selectedAvailable = available.some(
(candidate) => candidate.provider === provider && candidate.id === model,
);
if (!selectedAvailable) {
return reply.code(503).send({
error: MODEL_UNAVAILABLE_MESSAGE,
code: "model_unavailable",
});
}
}
// Browser choices are copied to the persisted manifest together with the immutable
// registry snapshot. The legacy fallback stays available for sessions created before
// the browser-local preference migration.
let id: string;
try {
({ id } = await runner.sessionNew({
question: b.question, name: b.name, workspaceConfigPath,
workspaceId, workspaceRevision, provider, model, thinking,
}));
manifestPersisted = true;
if (revisionLease) {
await revisionLease.markPersisted().catch((error: unknown) => {
console.error(
`[session:${id}] revision lease hand-off failed:`,
error instanceof Error ? error.message : "unknown error",
);
});
}
} catch { return storageFailure(reply); }
const options = {
provider, model, thinking,
author: principal.displayName ?? principal.subject,
principal,
question: b.question,
};
let runtimeOptions = options;
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
try {
runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
rt = d.mgr.createFor(id, runtimeOptions);
bindRuntime(id, rt, runner, workspaceConfigPath);
} catch (error) {
if (rt) d.mgr.teardownIfCurrent(id, rt);
console.error(
`[pi:${id}] runtime construction failed:`,
error instanceof Error ? error.message : "unknown error",
);
await runner.failSession(id, workspaceConfigPath).catch((persistenceError: unknown) => {
console.error(`[session:${id}] failSession persistence failed:`, persistenceError);
});
return reply.code(503).send({ error: BOOTSTRAP_FAILURE_MESSAGE });
}
info(id, "Session created");
bootstrap(
id, rt, runner, workspaceConfigPath, d.mgr.configure(rt, runtimeOptions),
runner.searchPack(b.question, id, workspaceConfigPath),
() => d.mgr.start(id, rt, runtimeOptions),
);
return { id };
} finally {
if (revisionLease && !manifestPersisted) {
await revisionLease.abort().catch((error: unknown) => {
console.error(
"[session] revision lease cleanup failed:",
error instanceof Error ? error.message : "unknown error",
);
});
}
}
});
app.get("/sessions", async (req, reply) => {
const principal = getPrincipal(req);
const scope = (req.query as { scope?: string }).scope ?? "mine";
if (scope !== "mine" && scope !== "all") return reply.code(400).send({ error: "scope must be mine or all" });
if (scope === "all" && !principal.isAdmin) return reply.code(403).send({ error: "admin scope required" });
if (scope === "all") {
const allPrincipal = requirePermission(req, reply, "session.read_all");
if (!isPrincipalContext(allPrincipal)) return allPrincipal;
}
try {
const settings = await d.getSettings(principal);
// Admin RLS is deliberately disabled for a normal 'mine' listing.
const scopedPrincipal = scope === "mine" ? { ...principal, isAdmin: false } : principal;
const list: SessionRow[] = await runnerFor(scopedPrincipal).sessionList(settings.workspace);
const scopedPrincipal = scope === "mine"
? { ...principal, isAdmin: false }
: ownershipPrincipal(principal, "session.read_all");
const runner = runnerFor(scopedPrincipal);
const revisions = await sessionRevisions();
const lists = await Promise.all(revisions
.map((revision) => runner.sessionList(revision.snapshotPath) as Promise<SessionRow[]>));
const sessions = new Map<string, SessionRow>();
for (const row of lists.flat()) {
if (!sessions.has(row.id)) sessions.set(row.id, row);
}
const list = [...sessions.values()];
// Only an administrator-visible complete list (or the single local principal) is safe
// input for retention. A remote per-user view can never discard another principal's pin.
const reconcileSnapshotRetention = (d.workspaceRegistry as Partial<WorkspaceRegistry>).reconcileSnapshotRetention;
const hasCompleteRetentionView = (scope === "all" || principal.issuer === "local")
&& hasPermission(principal, "session.read_all");
if (hasCompleteRetentionView && typeof reconcileSnapshotRetention === "function") {
const retained = [...new Set(list
.filter((row) => row.status !== "finalized" && !row.archived && typeof row.workspace_revision === "string")
.map((row) => row.workspace_revision!))];
await reconcileSnapshotRetention.call(d.workspaceRegistry, retained);
}
// Annotate each row with whether a live Pi runtime is currently bound. The client
// opens an `active` session straight into its live view (reconnecting to its pending
// gate), while a cold session keeps its explicit Resume affordance — so a mere click
@@ -296,18 +523,20 @@ export function sessionRoutes(
app.get("/sessions/:id", async (req, reply) => {
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
const manifest = await authorize(principal, (req.params as any).id, settings.workspace);
return manifest ?? reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
const session = await authorize(principal, (req.params as any).id);
if (!session) return reply.code(404).send({ error: "session not found" });
const manifest = session.manifest;
return (!manifest.workspace_id || !manifest.workspace_revision)
? { ...manifest, warning: "Legacy session: this session is not pinned to a workspace revision." }
: manifest;
} catch (error) { return lifecycleFailure(reply, error); }
});
app.post("/sessions/:id/response", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
if (!await authorize(principal, id, "session.manage_all")) return reply.code(404).send({ error: "session not found" });
} catch (error) { return lifecycleFailure(reply, error); }
const rt = d.mgr.get(id);
if (!rt) return reply.code(404).send({ error: "sessione non attiva" });
if (!rt.bridge.respond((req.body as any).ui_response)) {
@@ -319,9 +548,8 @@ export function sessionRoutes(
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
if (!await authorize(principal, id, "session.manage_all")) return reply.code(404).send({ error: "session not found" });
} catch (error) { return lifecycleFailure(reply, error); }
const rt = d.mgr.get(id);
if (!rt) return reply.code(404).send({ error: "sessione non attiva" });
rt.bridge.steer((req.body as any).text);
@@ -332,18 +560,31 @@ export function sessionRoutes(
const principal = getPrincipal(req);
return withSessionLifecycle(id, async () => {
let settings: Settings;
let manifest: any;
let located: LocatedSession | undefined;
try {
settings = await d.getSettings(principal);
manifest = await authorize(principal, id, settings.workspace);
located = await locateSession(principal, id, "session.manage_all");
} catch { return storageFailure(reply); }
if (!manifest) return reply.code(404).send({ error: "session not found" });
if (!located) return reply.code(404).send({ error: "session not found" });
const manifest = located.manifest;
const runner = runnerFor(principal);
// Read-only contract FIRST: a finalized/archived session must refuse resume even
// when a lingering runtime still looks active — the manifest is the truth.
// Read-only contract FIRST: finalized or archived sessions never attempt compatibility
// resolution, even when their historical snapshot was subsequently pruned.
if (manifest?.status === "finalized" || manifest?.archived) {
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
}
const saved = manifest as {
provider?: string; model?: string; thinking?: string;
workspace_id?: string; workspace_revision?: string;
};
let workspaceConfigPath: string;
let workspaceDescriptor: WorkspaceDescriptor | undefined;
try {
const resolved = await resolveSessionWorkspace(located);
workspaceConfigPath = resolved.workspaceConfigPath;
workspaceDescriptor = resolved.workspace;
}
catch { return unavailableWorkspaceReply(reply); }
try { settings = await d.getSettings(principal); } catch { return storageFailure(reply); }
// This check belongs inside the per-session lock: a preceding cold Resume may have
// installed a running runtime while this request was waiting.
const existing = d.mgr.get(id);
@@ -353,9 +594,13 @@ export function sessionRoutes(
return reply.code(200).send({ id, alreadyActive: true });
}
}
const ensure = await d.readiness.ensure(settings.workspace ?? "", principal);
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
const saved = manifest as { provider?: string; model?: string; thinking?: string } | null;
const ensure = await d.readiness.ensure(
workspaceConfigPath ?? "", principal, workspaceDescriptor,
);
if (!ensure.ok) return reply.code(503).send({
error: READINESS_FAILURE_MESSAGE,
...(ensure.code ? { code: ensure.code } : {}),
});
const options = {
provider: saved?.provider,
model: saved?.model,
@@ -364,11 +609,12 @@ export function sessionRoutes(
principal,
mode: "resume" as const,
};
let runtimeOptions = options;
// Reopening is validation, not the transport commit point. Keep the old hub intact if
// persistence cannot be reopened.
try {
await runner.reopenSession(id, settings.workspace);
await runner.reopenSession(id, workspaceConfigPath);
} catch {
return reply.code(503).send({ error: RESUME_FAILURE_MESSAGE });
}
@@ -393,8 +639,9 @@ export function sessionRoutes(
if (boundRuntimes.get(id) === current) boundRuntimes.delete(id);
d.mgr.teardownIfCurrent(id, current);
}
rt = d.mgr.createFor(id, options);
bindRuntime(id, rt, runner, settings.workspace);
runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
rt = d.mgr.createFor(id, runtimeOptions);
bindRuntime(id, rt, runner, workspaceConfigPath);
} catch {
// A created-but-unbound runtime is not usable. The old hub remains attached because
// clear() has not happened yet.
@@ -409,7 +656,11 @@ export function sessionRoutes(
// immediately before the first event produced by the new Resume.
d.hub.clear(id);
info(id, "Resuming session");
bootstrap(id, rt, runner, settings.workspace, d.mgr.configure(rt, options), null, () => d.mgr.start(id, rt, options));
bootstrap(
id, rt, runner, workspaceConfigPath,
d.mgr.configure(rt, runtimeOptions), null,
() => d.mgr.start(id, rt, runtimeOptions),
);
return reply.code(200).send({ id, alreadyActive: false });
});
});
@@ -417,20 +668,20 @@ export function sessionRoutes(
const id = (req.params as { id: string }).id;
const principal = getPrincipal(req);
return withSessionLifecycle(id, async () => {
let settings: Settings;
let session: LocatedSession | undefined;
try {
settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
} catch (error) { return lifecycleFailure(reply, error); }
// Invalidate the live generation before persistence can yield. Otherwise its deferred
// bootstrap may start Pi while Close is already in progress.
const current = d.mgr.get(id);
boundRuntimes.delete(id);
if (current) d.mgr.teardownIfCurrent(id, current);
try {
await runnerFor(principal).closeSession(id, settings.workspace);
} catch {
return storageFailure(reply);
await runnerFor(principal).closeSession(id, session.workspaceConfigPath);
} catch (error) {
return lifecycleFailure(reply, error);
} finally {
// clear, NOT forget: a closed session can be reopened, and the per-session seq
// monotonicity is what keeps a browser's old cursor detectable. The buffer is
@@ -444,20 +695,32 @@ export function sessionRoutes(
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
if (!await authorize(principal, id)) return reply.code(404).send({ error: "session not found" });
} catch (error) { return lifecycleFailure(reply, error); }
const rt = d.mgr.get(id);
// Add CORS headers manually: reply.raw.writeHead bypasses Fastify's onSend hooks
// (where @fastify/cors injects headers), so we must set them explicitly here.
const origin = (req.headers.origin as string | undefined) ?? "*";
// reply.raw.writeHead bypasses Fastify's CORS hook. Only a cookie-authenticated request
// from the exact configured public origin receives credentialed SSE CORS headers.
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
let isConfiguredOrigin = false;
try {
isConfiguredOrigin = req.authPublicOrigin !== undefined
&& origin !== undefined
&& new URL(origin).origin === req.authPublicOrigin;
} catch {
isConfiguredOrigin = false;
}
const corsHeaders = isConfiguredOrigin
? {
"Access-Control-Allow-Origin": req.authPublicOrigin,
"Access-Control-Allow-Credentials": "true",
}
: {};
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
Connection: "keep-alive",
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
...corsHeaders,
});
// Send the handshake immediately. Without this, Node waits for the first event body and
// proxies/clients cannot establish an idle SSE subscription or inspect its headers.
@@ -481,58 +744,58 @@ export function sessionRoutes(
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).setName(id, (req.body as any).name, settings.workspace);
} catch { return storageFailure(reply); }
const session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).setName(id, (req.body as any).name, session.workspaceConfigPath);
} catch (error) { return lifecycleFailure(reply, error); }
return reply.code(204).send();
});
app.post("/sessions/:id/group", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).setGroup(id, (req.body as any).group, settings.workspace);
} catch { return storageFailure(reply); }
const session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).setGroup(id, (req.body as any).group, session.workspaceConfigPath);
} catch (error) { return lifecycleFailure(reply, error); }
return reply.code(204).send();
});
app.post("/sessions/:id/archive", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).archive(id, settings.workspace);
} catch { return storageFailure(reply); }
const session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).archive(id, session.workspaceConfigPath);
} catch (error) { return lifecycleFailure(reply, error); }
return reply.code(204).send();
});
app.post("/sessions/:id/unarchive", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).unarchive(id, settings.workspace);
} catch { return storageFailure(reply); }
const session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).unarchive(id, session.workspaceConfigPath);
} catch (error) { return lifecycleFailure(reply, error); }
return reply.code(204).send();
});
app.delete("/sessions/:id", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
return withSessionLifecycle(id, async () => {
let settings: Settings;
let session: LocatedSession | undefined;
try {
settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
} catch (error) { return lifecycleFailure(reply, error); }
const current = d.mgr.get(id);
boundRuntimes.delete(id);
if (current) d.mgr.teardownIfCurrent(id, current);
try {
await runnerFor(principal).deleteSession(id, settings.workspace);
} catch {
return storageFailure(reply);
await runnerFor(principal).deleteSession(id, session.workspaceConfigPath);
} catch (error) {
return lifecycleFailure(reply, error);
}
d.hub.forget(id);
return reply.code(204).send();
@@ -542,9 +805,9 @@ export function sessionRoutes(
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
return await runnerFor(principal).documents(id, settings.workspace);
} catch { return storageFailure(reply); }
const session = await authorize(principal, id);
if (!session) return reply.code(404).send({ error: "session not found" });
return await runnerFor(principal).documents(id, session.workspaceConfigPath);
} catch (error) { return lifecycleFailure(reply, error); }
});
}
+14 -16
View File
@@ -1,18 +1,18 @@
import type { FastifyInstance } from "fastify";
import type { AppConfig } from "../config.js";
import { loadSettings, saveSettings, type Settings } from "../settings/settings-store.js";
import type { Settings } from "../settings/settings-store.js";
import { listWorkspaces, type ListModelsFn } from "./meta.js";
import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
/** Merge stored settings over env/first-workspace defaults. */
export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
const workspaces = listWorkspaces(cfg.harnessDir);
return {
workspace: stored.workspace ?? (workspaces[0]?.name),
provider: stored.provider ?? cfg.defaults.provider,
model: stored.model ?? cfg.defaults.model,
thinking: stored.thinking ?? cfg.defaults.thinking,
workspace: stored.workspace ?? workspaces[0]?.name,
provider: cfg.defaults.provider ?? stored.provider,
model: cfg.defaults.model ?? stored.model,
thinking: cfg.defaults.thinking ?? stored.thinking,
};
}
@@ -21,18 +21,21 @@ export function settingsRoutes(
deps: {
cfg: AppConfig; listModels: ListModelsFn;
getSettings: (principal: PrincipalContext) => Promise<Settings>;
saveSettings: (principal: PrincipalContext, settings: Settings) => Promise<void>;
},
): void {
app.get("/settings", async (req, reply) => {
const principal = requirePermission(req, reply, "session.use");
if (!isPrincipalContext(principal)) return principal;
try {
return await deps.getSettings(getPrincipal(req));
return await deps.getSettings(principal);
} catch {
return reply.code(503).send({ error: "settings storage is unavailable" });
}
});
app.put("/settings", async (req, reply) => {
const principal = requirePermission(req, reply, "settings.manage");
if (!isPrincipalContext(principal)) return principal;
const b = (req.body ?? {}) as Settings;
if (b.model) {
let available: { provider: string; id: string }[] = [];
@@ -50,15 +53,10 @@ export function settingsRoutes(
});
}
}
const next: Settings = {
workspace: b.workspace,
provider: b.provider,
model: b.model,
thinking: b.thinking,
};
try {
await deps.saveSettings(getPrincipal(req), next);
return effectiveSettings(deps.cfg, next);
// Retain this endpoint as a validating compatibility surface for older clients, but do
// not write anonymous users' choices to shared server storage.
return await deps.getSettings(principal);
} catch {
return reply.code(503).send({ error: "settings storage is unavailable" });
}
+53 -16
View File
@@ -1,24 +1,55 @@
import type { FastifyInstance } from "fastify";
import type { ThtRunner } from "../tht/tht-runner.js";
import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
import type { Settings } from "../settings/settings-store.js";
import type { WorkspaceRegistry } from "../workspaces/registry.js";
import { hasPermission, isPrincipalContext, requirePermission } from "../auth/authorization.js";
export function sqlRoutes(app: FastifyInstance, deps: {
tht: ThtRunner; getSettings: (principal: PrincipalContext) => Promise<Settings>;
workspaceRegistry: WorkspaceRegistry;
}): void {
const runnerFor = (principal: PrincipalContext): any => {
const runner = deps.tht as any;
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
};
const authorize = async (principal: PrincipalContext, id: string, workspace?: string) => {
try {
const runner = runnerFor(principal);
if (typeof runner.sessionShow !== "function") return {};
return await runner.sessionShow(id, workspace);
const readPrincipal = (principal: PrincipalContext): PrincipalContext => ({
...principal,
// The harness still consumes this compatibility bit; it must never exceed session.read_all.
isAdmin: hasPermission(principal, "session.read_all"),
});
const isNotFound = (error: unknown) => /not found|non trovata|inesistente|404/i.test(
error instanceof Error ? error.message : String(error),
);
const locate = async (principal: PrincipalContext, id: string, legacyWorkspace?: string) => {
const runner = runnerFor(readPrincipal(principal));
if (typeof runner.sessionShow !== "function") return { manifest: {}, workspace: legacyWorkspace };
const registry = deps.workspaceRegistry as Partial<WorkspaceRegistry>;
const revisions = typeof registry.listRetainedSnapshots === "function"
? await registry.listRetainedSnapshots.call(deps.workspaceRegistry)
: await deps.workspaceRegistry.list();
for (const revision of revisions) {
try {
const manifest = await runner.sessionShow(id, revision.snapshotPath);
if (!manifest) continue;
const saved = manifest as { workspace_id?: string; workspace_revision?: string };
if (saved.workspace_id && saved.workspace_revision) {
const pinned = await deps.workspaceRegistry.readPinned(saved.workspace_id, saved.workspace_revision);
return {
manifest,
workspace: pinned.workspaceConfigPath ?? (pinned as any).revision?.snapshotPath,
};
}
return { manifest, workspace: revision.snapshotPath };
} catch (error) {
if (!isNotFound(error)) throw error;
}
}
catch (error) {
if (/not found|non trovata|inesistente|404/i.test(error instanceof Error ? error.message : String(error))) return undefined;
try {
const manifest = await runner.sessionShow(id, legacyWorkspace);
return manifest ? { manifest, workspace: legacyWorkspace } : undefined;
} catch (error) {
if (isNotFound(error)) return undefined;
throw error;
}
};
@@ -28,15 +59,18 @@ export function sqlRoutes(app: FastifyInstance, deps: {
let principal: PrincipalContext;
let workspace: string | undefined;
try {
principal = getPrincipal(req);
const authorized = requirePermission(req, reply, "session.use");
if (!isPrincipalContext(authorized)) return authorized;
principal = authorized;
const settings = await deps.getSettings(principal);
workspace = settings.workspace;
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
const located = await locate(principal, id, settings.workspace);
if (!located) return reply.code(404).send({ error: "session not found" });
workspace = located.workspace;
} catch {
return reply.code(503).send({ error: "session storage is unavailable" });
}
try {
return await runnerFor(principal).sqlPreview(id, { limit, offset }, workspace);
return await runnerFor(readPrincipal(principal)).sqlPreview(id, { limit, offset }, workspace);
} catch (error: any) {
return reply.code(500).send({ error: error.message ?? String(error) });
}
@@ -47,15 +81,18 @@ export function sqlRoutes(app: FastifyInstance, deps: {
let principal: PrincipalContext;
let workspace: string | undefined;
try {
principal = getPrincipal(req);
const authorized = requirePermission(req, reply, "session.use");
if (!isPrincipalContext(authorized)) return authorized;
principal = authorized;
const settings = await deps.getSettings(principal);
workspace = settings.workspace;
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
const located = await locate(principal, id, settings.workspace);
if (!located) return reply.code(404).send({ error: "session not found" });
workspace = located.workspace;
} catch {
return reply.code(503).send({ error: "session storage is unavailable" });
}
try {
return await runnerFor(principal).sqlExport(id, workspace);
return await runnerFor(readPrincipal(principal)).sqlExport(id, workspace);
} catch (error: any) {
return reply.code(500).send({ error: error.message ?? String(error) });
}

Some files were not shown because too many files have changed in this diff Show More