merge: integrate thoth authentication
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.ps1]
|
||||
end_of_line = crlf
|
||||
+10
-28
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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
@@ -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**
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Generated
+330
-17
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
Executable
+2243
File diff suppressed because it is too large
Load Diff
@@ -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/);
|
||||
});
|
||||
Executable
+609
@@ -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&¤t.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\(\)/);
|
||||
});
|
||||
Executable
+172
@@ -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),[]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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() };
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user