Merge origin/codex/portable-deployment into feat/docker-local-deploy

Unisce gli internals di Codex (secret-bundle, provider-credentials, auth upstream,
security hardening, CI multiarch) mantenendo le fix portal-specific:
- backend: configPath da THT_CONFIG (fix sessioni) + dataRoot di Codex; authMode 'upstream'
- Docker/compose: TENUTO il mio (verificato live: omics_network+alias, env_file, pi npm-g)
  perche' il compose/Dockerfile/entrypoint di Codex sono accoppiati al suo modello
  secret-bundle (tht doctor inesistente, secret-policy.sh). Adottabile in futuro.
- config.test.ts: preso Codex (superset)
Verificato: tsc clean, 132/132 vitest.
This commit is contained in:
User
2026-07-12 21:13:20 +02:00
211 changed files with 23270 additions and 415 deletions
+30
View File
@@ -0,0 +1,30 @@
# 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.
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=
# 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
+34
View File
@@ -0,0 +1,34 @@
name: Container multi-architecture gate
on:
pull_request:
paths:
- "backend/**"
- "frontend/**"
- "harness/**"
- "docker/**"
- "scripts/verify-container-images.sh"
- ".github/workflows/container-multiarch.yml"
workflow_dispatch:
jobs:
verify:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
platform: [linux/amd64, linux/arm64]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build, smoke, security-check, and inventory
env:
PLATFORM: ${{ matrix.platform }}
run: ./scripts/verify-container-images.sh
- uses: actions/upload-artifact@v4
with:
name: container-inventory-${{ strategy.job-index }}
path: .artifacts/container-images/
+11
View File
@@ -33,6 +33,14 @@ deploy/thothii.env
ca-chain.pem
config/ca-chain.pem
# ThothII deployment configuration and secret values (keep only the README tracked)
deploy/.env
deploy/compose.psd-local.yaml
deploy/workspaces/psd.yaml
deploy/secrets/*
!deploy/secrets/README.md
!deploy/secrets/*.example
# === Runtime data (sessions contain PII; indexes are derived) ===
harness/sessions/
harness/indexes/
@@ -55,3 +63,6 @@ htmlcov/
# === MkDocs build output ===
site/
# Generated container inventory / SBOM-equivalent verification artifacts
.artifacts/
@@ -0,0 +1,137 @@
# Adapter Foundations final-review fix report
Date: 2026-07-11
Branch: `codex/portable-deployment`
Worktree: `/Users/mp/projects/ThothII/.worktrees/portable-deployment`
Binding findings: `.superpowers/sdd/adapter-final-review-findings.md`
## Outcome
All seven final-review findings are addressed as one coherent adapter-foundations change:
1. HTTP vector reader and writer clients are independently optional. Capabilities reflect the
configured side; writer-only new and legacy configurations build successfully for targeted
writes; search without a reader raises public `VectorReadUnavailable`.
2. `VectorHealth` now reports read/write configured and reachable state independently, preserves
side-specific errors, and reports expected/observed embedding dimensions plus compatibility.
HTTP diagnostics cover read-only, write-only, both-up, and writer-down cases. Direct health
exposes its configured expected dimension without adding schema or migration work.
3. `ThothRestDwhAdapter` accepts `DatabaseIdentityConfig`, matching its resource contract.
4. Both vector adapters reject bools, floats, zero, and negative search limits using one exact
positive-integer guard.
5. Port tests explicitly cover public exports and frozen capability records.
6. A real `tht` subprocess test proves one legacy deprecation warning per config load on stderr
while JSON stdout remains parseable and uncontaminated.
7. The adapter plan and SDD progress explicitly constrain `build_vector_loader` to transitional
bulk sync and schedule its removal/migration in the local pgvector plan. Targeted memory and
solved-question writes remain on `build_vector_store(..., require_write=True)`.
No pgvector schema or migration changes were made.
## Files changed
- `harness/tht/ports/vector.py`
- `harness/tht/ports/__init__.py`
- `harness/tht/adapters/vector/thoth_http.py`
- `harness/tht/adapters/vector/legacy_direct.py`
- `harness/tht/adapters/factory.py`
- `harness/tht/adapters/dwh/thoth_rest.py`
- `harness/tests/test_vector_port_contract.py`
- `harness/tests/test_adapter_factory.py`
- `harness/tests/test_config_resources.py`
- `harness/tests/test_config_legacy_compat.py`
- `harness/tests/test_adapter_command_regressions.py`
- `harness/tests/test_dwh_port_contract.py`
- `docs/superpowers/plans/2026-07-11-adapter-foundations.md`
- `.superpowers/sdd/progress.md`
- `.superpowers/sdd/adapter-final-fix-report.md`
## TDD and verification evidence
RED:
```text
cd harness && .venv/bin/pytest tests/test_vector_port_contract.py \
tests/test_adapter_factory.py tests/test_config_resources.py \
tests/test_config_legacy_compat.py -q
```
Result: collection failed as expected because `VectorReadUnavailable` did not exist. After the
initial implementation, the same command exposed two expected contract/test-harness corrections:
dimension mismatch makes aggregate health unhealthy, and the installed CLI entry point is `tht`
rather than `python -m tht.cli`.
GREEN, covering adapter/config/command regressions:
```text
cd harness && .venv/bin/pytest tests/test_vector_port_contract.py \
tests/test_adapter_factory.py tests/test_config_resources.py \
tests/test_config_legacy_compat.py tests/test_adapter_command_regressions.py \
tests/test_dwh_port_contract.py tests/test_memory_save_one.py \
tests/test_solved_question.py tests/test_search_similar_kinds.py \
tests/test_vector_dual_key.py -q
```
Result: `66 passed in 0.45s`.
Docker availability:
```text
docker info --format '{{.ServerVersion}}'
```
Result: `29.4.1` (available; command required Docker socket access).
Full repository-default non-L2 harness suite, with Docker available for L0 tests:
```text
cd harness && .venv/bin/pytest -q
```
Result: `433 passed, 5 deselected, 17 warnings in 9.14s`. The five deselections are the configured
L2/live-service tests. Warnings are existing legacy-workspace `FutureWarning` emissions.
Scoped lint and diff hygiene:
```text
cd harness && .venv/bin/ruff check tht/ports tht/adapters \
tests/test_vector_port_contract.py tests/test_adapter_factory.py \
tests/test_config_resources.py tests/test_config_legacy_compat.py \
tests/test_adapter_command_regressions.py tests/test_dwh_port_contract.py
git diff --check
```
Result: `All checks passed!`; `git diff --check` produced no output.
## Commit
Commit subject: `fix(adapter): close final foundation review`
The report is part of that same final commit. A Git object cannot contain its own SHA without
changing that SHA; the exact resulting commit ID is therefore recorded in the task handoff from
`git rev-parse HEAD` after creation.
## Self-review
- Reader/writer separation is preserved: search dereferences only `_reader`; hashes/upsert only
`_writer`; health probes each configured client independently and never substitutes one result
for the other.
- Writer failure contributes to aggregate `ok=False`, even when the reader succeeds.
- Dimension compatibility is derived only from configured embedding dimension and existing
`list_tables` metadata. Missing metadata remains `None`, not a guessed success/failure.
- The shared limit guard uses `type(limit) is int`, intentionally rejecting Python booleans and
numeric coercions before either adapter reaches its transport.
- Existing JSON/CLI behavior is preserved; the subprocess regression parses stdout as JSON and
counts exactly one deprecation marker on stderr.
- Scope remains adapter foundations. No vector DDL, schema initialization, or migration work was
introduced.
## Concerns / follow-up
- Write reachability uses the existing `list_tables` diagnostic on the separately authenticated
writer client. Deployments must allow that non-mutating diagnostic RPC to the writer credential;
failures are intentionally visible rather than hidden by reader success.
- Existing legacy-workspace tests emit 17 `FutureWarning`s in the full suite. This wave pins the
required production stderr behavior but does not migrate unrelated test fixtures.
- `build_vector_loader` remains transitional technical debt only for bulk sync, explicitly assigned
to `2026-07-11-local-pgvector-profile.md`.
+206
View File
@@ -0,0 +1,206 @@
# Container Packaging Task 3 Report
## Status
Implemented the multi-stage core application image, non-root runtime, pinned Pi installation,
container entrypoint, context exclusions, and an in-image health smoke test.
## TDD / Build Evidence
Initial RED:
```text
docker build -f docker/core.Dockerfile -t thothii-core:test .
ERROR: failed to build: resolve : lstat docker: no such file or directory
```
The first sandboxed attempt could not access the Docker socket; the authorized rerun reached the
builder and failed for the expected reason: the Dockerfile did not exist.
GREEN build:
```text
sh -n docker/core-entrypoint.sh docker/smoke/core-smoke.sh
docker build --progress=plain -f docker/core.Dockerfile -t thothii-core:test .
```
Result: shell syntax exited 0; Docker build exited 0. A final rebuild after tightening
`.dockerignore` also exited 0 and transferred only 17.60 kB of changed context (the initial clean
build transferred 1.02 MB).
## Runtime and Entrypoints
- Runtime user is `10001:10001` (`thoth`), never root.
- Runtime contains Node `v22.19.0` and Python `3.12.13`. Python 3.12 is intentional because the
harness declares `requires-python = ">=3.12"` and also satisfies the deployment floor of 3.11+.
- Pi is installed exactly as `@earendil-works/pi-coding-agent@0.80.3`; its build-time and runtime
version probes both reported `0.80.3`.
- `server` starts `/app/backend/dist/server.js`; `doctor` routes to `tht doctor`; `preprocess`
routes to the future-facing `tht preprocess` command; explicit `tht ...` and arbitrary CLI
arguments route to the installed `tht` binary.
- The gate extension's `typebox` runtime dependency is installed from the harness lockfile.
## Smoke and Diagnostic Results
```text
docker run --rm thothii-core:test doctor
config: error - configuration is invalid or unreadable
data_root: ok
```
Result: expected exit 1 for absent mounted workspace configuration, with no traceback and no
secret-bearing validation detail.
```text
docker run --rm --entrypoint /app/docker/smoke/core-smoke.sh thothii-core:test
backend listening on http://127.0.0.1:8787
v22.19.0
Python 3.12.13
core smoke: ok
```
Result: exit 0. The script asserted non-root execution, `tht --help`, `pi --version`, runtime
version floors, and `GET /health` through curl. Fastify's returned display address was loopback;
the inspected container environment is `HOST=0.0.0.0`, and the compiled server passes that value
to `app.listen`.
```text
docker run --rm thothii-core:test tht --version
0.1.0
```
Result: arbitrary `tht` entrypoint exited 0.
An explicit runtime assertion checked UID 10001, exact Node and Pi versions, Python 3.11+, and the
absence of `/app/harness/.env` and `/app/harness/workspaces`; it exited 0.
## Image Size and Containment Inspection
```text
docker image inspect thothii-core:test --format '{{.Size}} {{json .Config.User}} {{json .Config.Env}}'
221419008 "10001:10001" [...runtime paths and version metadata only...]
```
Image size: **221,419,008 bytes** (about 211.2 MiB).
`docker history --no-trunc thothii-core:test` was inspected. It contains only Dockerfile commands,
the pinned public package name/version, base-image metadata, and non-sensitive runtime variables;
no credentials or customer paths were found. An in-image filename scan found only
`/app/harness/.pi/settings.json` among `.env`, key/certificate, and settings-name candidates; that
tracked Pi file contains theme/startup preferences, not secrets. The build asserts `.env` and
workspace directories are absent.
`.dockerignore` excludes VCS/agent state, all environment files except examples, package-manager
credential files, SSH/private-key and certificate formats, local virtualenvs/node_modules/caches,
backend runtime data, customer workspaces, sessions, artifacts, indexes, corpus, and deployment
mount content.
## Self-review
- `git diff --check` is clean.
- Entrypoint processes use `exec`, preserving container signal handling.
- Backend production dependencies are pruned; TypeScript build tools remain in the build stage.
- The writable `/data` root is owned by UID 10001; application payload remains root-owned and
read-only to the runtime user.
- CA certificates and curl are present for HTTPS integrations and health probing.
- No existing source, customer workspace, secret, or unrelated progress-ledger change is included
in the task commit.
## Concerns
- The `tht preprocess` command is deliberately a future-facing routing contract; its CLI group is
scheduled in the Evidence/preprocessing plan and is not implemented in the current harness.
- Python dependencies are range-resolved because the existing harness has no Python lockfile. The
Pi package, Node runtime, and package-lock-backed Node dependency sets are pinned/reproducible.
- The image was built and smoked on Docker Desktop arm64. The chosen official multi-arch base
images and Pi package are architecture-neutral at the package level, but amd64 still needs a CI
build/smoke before being advertised as verified.
## Reproducibility Review Fix
The original image pinned Pi's direct version in the Dockerfile but resolved its transitives at
build time, and pip resolved all harness dependencies from ranges. Both paths now consume committed
locks.
### Lock generation
Pi uses the minimal `docker/pi-runtime/package.json` and its committed npm v3 lock. It was generated
with:
```text
npm install --package-lock-only --ignore-scripts --no-audit --no-fund \
--prefix docker/pi-runtime
```
The package manifest specifies exact `@earendil-works/pi-coding-agent` version `0.80.3`; a lock
inspection confirmed that same resolved package version. Docker installs it with:
```text
npm ci --omit=dev --ignore-scripts --no-audit --no-fund
```
The Python lock was generated directly from the harness production metadata plus one explicit,
pinned PEP 517 build-backend input—not from a host `pip freeze`:
```text
uv pip compile harness/pyproject.toml docker/python-runtime/build-requirements.in \
--universal \
--python-version 3.12 \
--no-emit-package tht \
--generate-hashes \
--custom-compile-command \
'uv pip compile harness/pyproject.toml docker/python-runtime/build-requirements.in --universal --python-version 3.12 --no-emit-package tht --generate-hashes --output-file docker/python-runtime/requirements.lock' \
--output-file docker/python-runtime/requirements.lock
```
`pytest`, `ruff`, and `testcontainers` are absent. All production direct and transitive packages
are exact and hashed. `setuptools==80.9.0` is explicit so the local harness install can use
`--no-build-isolation` without an unpinned build-time resolution. Refresh instructions are in
`docker/LOCKS.md`.
### No-cache rebuild and verification
Final build command:
```text
docker build --no-cache -f docker/core.Dockerfile -t thothii-core:test .
```
Result: exit 0. The logs showed Pi `0.80.3`, Node `v22.19.0`, a hash-enforced Python dependency
install, explicit `setuptools==80.9.0`, and a non-isolated local `tht` wheel build. No isolated
build-dependency download occurred.
Fresh runtime checks:
```text
docker run --rm --entrypoint /app/docker/smoke/core-smoke.sh thothii-core:test
backend listening on http://127.0.0.1:8787
v22.19.0
Python 3.12.13
core smoke: ok
docker run --rm thothii-core:test tht --version
0.1.0
/opt/venv/bin/pip check
No broken requirements found.
```
An in-container package inspection reconfirmed Pi `0.80.3`. Non-root UID, runtime version floors,
doctor's expected concise exit 1/no traceback, `/health`, and arbitrary `tht` routing all passed.
The full filename containment scan found no `.env`, PEM, private-key, P12, or PFX file in `/app`;
`/app/harness/workspaces` remains absent. Image environment and `docker history --no-trunc` were
re-inspected and contain only public package/build commands and non-sensitive runtime metadata.
Final locked image size:
```text
220003986 10001:10001
```
That is **220,003,986 bytes** (about 209.8 MiB), 1,415,022 bytes smaller than the original image.
Remaining concern: the universal lock is resolved for Python 3.12 and includes hashes/markers for
all supported platforms, but only Linux arm64 has been built and smoked locally; amd64 remains a CI
verification gate.
@@ -0,0 +1,82 @@
# Container Packaging Task 4 Report
## Status
Implemented and verified runtime-configured frontend packaging.
## Changes
- Added the browser runtime contract `window.__THOTHII_CONFIG__.backendBaseUrl`.
- Loaded `/config.js` before the Vite module entrypoint.
- Made runtime configuration take precedence while preserving `VITE_BACKEND_URL` and the
existing `http://localhost:8787` client default for development and tests.
- Added a multi-stage frontend image that builds with Node and serves static assets as
unprivileged UID/GID `101:101` with nginx on port 8080.
- Added startup-time `BACKEND_BASE_URL` substitution (default `/api`).
- Added `/api/` reverse proxying to `core:8787`, SPA fallback, no-cache runtime config,
and SSE-safe proxy settings (`proxy_buffering off`, `proxy_cache off`, one-hour read timeout).
## TDD evidence
- RED: `npx vitest run src/api/runtime-config.test.ts` failed because
`./runtime-config` did not exist.
- GREEN: targeted runtime config suite passed (3 tests after preserving the legacy client
default).
## Verification
- `cd frontend && npx vitest run --reporter=dot && npx tsc -b && npm run build` — exit 0
(40 test files, 185 tests; TypeScript and Vite production build passed).
- `docker build -f docker/frontend.Dockerfile -t thothii-frontend:test .` — success.
- Image metadata reports `USER 101:101`.
- Two-container isolated-network smoke:
- `/config.js` returned `window.__THOTHII_CONFIG__ = { backendBaseUrl: "/api" };`
- `/api/health` proxied to the core image and returned `{"status":"ok"}`.
- an unknown nested route returned the SPA `index.html`.
- active nginx config contained `proxy_buffering off`, `proxy_cache off`, and
`proxy_read_timeout 1h`.
- `/config.js` returned `Cache-Control: no-store`.
- `sh -n docker/frontend-entrypoint.sh` and `git diff --check` — exit 0.
## Secret-leakage inspection
- `.dockerignore` excludes `.env*` (except examples), credentials/key formats, dependency
trees, build outputs, backend data, and deployment data.
- The runtime web root contained no `.env*`, `.pem`, `.key`, `.p12`, or `.pfx` files.
- Image history contained build/package instructions only; no secret build arguments or
credential values were introduced by this task.
## Self-review / concerns
- nginx resolves the `core` hostname at startup, matching the planned Compose service name;
standalone runs therefore need a reachable network alias named `core`.
- Existing frontend test warnings (React refs/act, MSW unmatched incidental requests, Vite
chunk-size warnings) remain; they did not fail the requested gates and are unrelated to
this task.
- `.superpowers/sdd/progress.md` was already modified by the orchestrator and was intentionally
excluded from this task's commit.
## P1 review fixes
Follow-up commit work addressed both review findings:
- Runtime configuration is now produced with `jq -cn --arg`, so `BACKEND_BASE_URL` is encoded
by a real JSON serializer rather than interpolated into JavaScript by `sed`.
- The image includes `frontend-config-smoke`, which strips only the fixed assignment wrapper,
parses the remaining JSON with `jq`, requires exactly the `backendBaseUrl` key, and compares
the decoded value to the environment input.
- The hostile smoke passed with quotes, backslashes, a literal newline, ampersand, pipe, and
`"; globalThis.PWNED=true; //` in the value. A breakout would leave non-JSON trailing input
and fail parsing.
- Added `joinBackendPath`, shared by API fetch and EventSource creation. It removes duplicate
boundary slashes for relative and absolute bases while keeping empty and `/` bases rooted.
Follow-up verification:
- RED: six join cases failed with `joinBackendPath is not a function` before implementation.
- Targeted: runtime config, API client, and EventSource suites — 14 tests passed.
- Full frontend gate — exit 0 (40 test files, 191 tests, TypeScript, Vite build).
- Rebuilt `thothii-frontend:test` successfully.
- Hostile config image smoke — `frontend runtime config smoke: ok`.
- Rebuilt two-container smoke — default `/api` config, proxied `/api/health`, SPA fallback,
and SSE-safe nginx directives all passed.
@@ -0,0 +1,98 @@
# Evidence / Preprocessing Task 1 Report
## Outcome
Implemented the additive Evidence source port and canonical corpus records. Existing evidence,
search, vector, and session runtime code is unchanged.
## Contract
- `EvidenceSource` is a runtime-checkable protocol with `discover` and `acquire` operations.
- `SourceObject` and `AcquiredDocument` are frozen, reject extra fields, use independent metadata
defaults, and restrict metadata to Pydantic `JsonValue` values.
- `CanonicalDocument`, `CanonicalChunk`, and `CorpusManifest` are frozen and reject extra fields.
- Provenance includes stable source IDs, canonical URIs, fingerprints, modification time, and
content hashes.
- Pipeline versions are recorded on documents, chunks, and manifests. Manifests also carry schema
version, optional publish ID/vector generation, and paired embedding model/dimension fields.
- Credential-like metadata keys are rejected recursively. Credentials are not model fields and
therefore cannot enter serialized canonical artifacts through extras.
## TDD evidence
The initial focused run failed during collection because `tht.ports.evidence` and `tht.corpus`
did not exist. After implementation, the focused suite passed.
## Verification
- Focused models/protocol tests: 13 passed.
- Harness excluding Docker-backed L0 and the network-dependent wheel packaging test: 444 passed,
5 deselected.
- Focused Ruff: passed.
- Full-repository Ruff remains blocked by 34 pre-existing findings outside the task files.
- An unrestricted `pytest -q` attempt reached 453 passed and 5 deselected, but reported 47 Docker
setup errors plus 4 Docker parity failures because the sandbox cannot access the Docker socket;
the wheel packaging test also failed because its isolated `uv build` needs unavailable network.
## Concerns / follow-up
- Pydantic's `frozen=True` prevents model field reassignment but does not recursively freeze list
and dict contents. `default_factory` prevents shared mutable defaults. Later pipeline stages should
treat these value objects as immutable and construct replacements rather than mutate collections.
- The adapter and normalization tasks should preserve the credential-free boundary by passing only
these records beyond acquisition.
## Review hardening follow-up
All six binding review areas were addressed in a separate TDD pass:
- JSON metadata is recursively converted to immutable `FrozenDict`/tuple values while retaining
stable object/array JSON serialization. Manifest document and chunk collections are tuples.
- Secret-key matching now normalizes camelCase and punctuation. It rejects credential-specific
names (passwords, API keys, access/refresh tokens, client/private keys, session cookies and
authorization) recursively, while deliberate benign labels such as generic `token` and `secret`
remain valid.
- Canonical URIs require a scheme and reject userinfo or credential-bearing query parameters.
- Namespaced IDs, SHA-256 content hashes, timezone-aware UTC timestamps, embedding/vector
compatibility, unique IDs, chunk referential/provenance integrity, contiguous per-document
ordinals and pipeline-version consistency are validated. Nested Pydantic instances are always
revalidated so `model_copy(update=...)` cannot bypass a manifest boundary.
- Acquired arbitrary bytes have explicit base64 JSON encoding and validation, covered by a JSON
round-trip test.
- `EvidenceSourceError` classifies transient/retryable versus permanent failures and exposes only
recursively immutable, credential-screened JSON details.
Follow-up verification:
- Focused contract suite: 39 passed.
- Focused Ruff: passed.
- Harness excluding Docker-backed L0 and the network-dependent wheel packaging test: 470 passed,
5 deselected.
- Fresh unrestricted harness attempt: 479 passed, 5 deselected; the same environmental boundary
remains (47 Docker socket setup errors, four Docker parity failures, one isolated `uv build`
network failure).
## Final blocker follow-up
The remaining four contract blockers were closed in a third TDD cycle:
- `EvidenceSourceError` now always exposes the fixed public message/`args` value `evidence source
operation failed`; caller diagnostics are not retained. Category, details and args cannot be
reassigned, details remain recursively frozen and credential-screened, and an original exception
is available only when callers use standard exception chaining.
- Canonical document/chunk provenance stores only URI scheme, authority and path. Userinfo is
rejected; query strings and fragments are removed unconditionally, including AWS `X-Amz-*`, SAS
`sig`, and fragment token material.
- Binding model bases override Pydantic's unchecked `model_copy(update=...)`: merged values always
pass full field/model validation, so invalid copied records and top-level manifests fail.
- A canonical document/chunk `content_hash` must equal SHA-256 of the exact stored text encoded as
UTF-8. This establishes the normalization boundary explicitly: line-ending/frontmatter/text
normalization happens before model construction; the canonical models never rewrite content.
Final follow-up verification:
- Focused contract suite: 45 passed.
- Focused Ruff: passed.
- Harness excluding Docker-backed L0 and network-dependent packaging: 476 passed, 5 deselected.
- Fresh unrestricted harness attempt: 486 passed, 5 deselected, with the unchanged environmental
failures (47 Docker setup errors, four Docker parity failures, one isolated `uv build` failure).
@@ -0,0 +1,65 @@
# Evidence Task 2 Report
## Status
Implemented filesystem and explicit-manifest HTTP Evidence source adapters, typed source
configuration with legacy compatibility, and factory construction.
## Delivered behavior
- Filesystem discovery is deterministic and rooted at a strict canonical directory.
- Symlink/path escapes are rejected before content is exposed.
- Discovery hashing and acquisition reads enforce a configurable byte limit.
- Filesystem fingerprints are content SHA-256 values; stable IDs derive from relative paths.
- HTTP accepts only explicit `http`/`https` manifest entries and keeps transport URLs private.
- HTTP provenance strips query strings/fragments, while config and adapter representations hide
signed or secret-bearing transport URLs.
- HTTP acquisition uses separate connect/read timeouts, streaming byte limits, bounded redirects,
private redirect rejection, and safe transient/permanent error classification.
- HTTP fingerprints prefer a deterministic ETag digest, then Last-Modified, then content SHA-256.
- `build_evidence_sources(cfg)` supports both typed `evidence.sources` entries and the legacy
`source_root` plus `evidence_dir` filesystem configuration.
## TDD and verification
- RED: focused tests initially failed during collection because the adapter package did not exist.
- GREEN: `15 passed` for filesystem, HTTP, and resource-config tests.
- Full harness: `548 passed, 5 deselected`.
- Changed-file Ruff: clean.
- Repository-wide Ruff remains non-clean due to 34 pre-existing findings in unrelated test files;
no unrelated lint files were modified.
## Notes
The approved `SourceObject` namespace grammar does not permit raw quoted ETags such as
`etag:"abc"`. The adapter therefore uses `etag:<sha256-of-opaque-etag>`: it preserves ETag-based
change identity without weakening the canonical contract or exposing validator contents.
## Review hardening follow-up
Four review findings were closed in a separate follow-up commit:
- Filesystem access now anchors a persistent descriptor at the canonical root and walks each
component with `openat` semantics (`dir_fd`, `O_NOFOLLOW`, and `O_DIRECTORY`). The regular-file
check, bounded read, metadata, and hash all use the opened descriptor. Acquisition reopens by
the same path-safe mechanism and rejects a changed fingerprint. Deterministic tests swap both a
leaf and an ancestor to symlinks at open time.
- HTTP network policy defaults to public hosts only. Initial URLs and every redirect reject
userinfo, mixed public/private IPv4/IPv6 answers fail closed, and the connected peer must be a
public member of the previously validated DNS answer set before any body bytes are consumed.
Explicit `allow_private_hosts: true` is required for trusted private deployments and local tests.
- Every HTTP response is closed in a `finally` block, including redirects, status failures,
policy failures, oversized bodies, and mid-stream exceptions.
- ETag and Last-Modified values remain adapter-internal. Repeated discovery and acquisition send
conditional headers; a 304 reuses only previously verified cached bytes and identity. The LRU
content cache has an explicit byte bound (`max_cache_bytes`). Validators are not forwarded
across redirect origins.
### Conditional cache binding correction
The conditional cache now binds bytes and validators to both the canonical provenance key and the
exact final effective representation URL. Redirect traversal recomputes request headers per hop:
validators are sent only when that exact URL matches the cached final URL, never merely because a
redirect retains an origin. A same-origin path change therefore downloads and replaces the body.
The adapter accepts 304 only when the exact request carried a bound ETag or Last-Modified validator;
unsolicited and cross-origin 304 responses are permanent protocol errors.
@@ -0,0 +1,59 @@
# Evidence Task 3 — deterministic normalization and chunking
## Outcome
- Added pure `normalize(acquired, pipeline_version)` and `chunk(document, policy)` transforms.
- Normalization enforces UTF-8 (including UTF-8 BOM), a 10 MiB input ceiling, LF line endings,
NFC Unicode, safe YAML frontmatter extraction, canonical provenance URIs, and hashes the exact
canonical UTF-8 text stored on the document.
- Undecodable, unsupported-charset, oversized, and invalid-frontmatter inputs fail explicitly;
byte content is never truncated.
- Chunking uses a versioned immutable policy, paragraph/word boundaries with deterministic
character-count hard splits for long tokens, contiguous ordinals, provenance metadata, exact
per-chunk hashes, and IDs derived from document hash + ordinal + policy version.
- Empty documents produce no chunks. Non-ASCII, CRLF equivalence, repeatability, policy changes,
duplicate-content ordinal collisions, and max-character limits are covered by tests.
## TDD evidence
- Initial focused test run failed during collection because both transform modules were absent.
- The EOF-frontmatter edge test was separately observed failing before its implementation.
- Final focused verification: `12 passed`.
## Verification
- `cd harness && .venv/bin/pytest tests/test_corpus_normalize.py tests/test_corpus_chunk.py -q`
— **12 passed**.
- `cd harness && .venv/bin/pytest -q` — **573 passed, 5 deselected**. The sandboxed attempt could
not access Docker; the approved rerun with local Docker access passed.
- Targeted Ruff over all four implementation/test files — **clean**.
- Full `cd harness && .venv/bin/ruff check .` — reports **34 pre-existing errors** in unrelated
legacy tests (unused imports and existing E702 semicolon lines); none are in Task 3 files.
## Concerns
- The 10 MiB normalization ceiling is deliberately explicit and independent of adapter download
limits. If deployment policy needs a different ceiling, it should become a versioned pipeline
configuration before ingestion is wired.
- Character limits use Python Unicode code points (`len`), not UTF-8 bytes or tokenizer tokens;
this is recorded in the chunk-policy metadata and tested with non-ASCII content.
## Review hardening follow-up
- Chunk IDs now bind the canonical document identity, document content hash, ordinal, chunk hash,
and a canonical SHA-256 fingerprint of every `ChunkPolicy` field. Identical content in separate
documents and same-version policies with different limits cannot collide.
- Boundary-aware slicing now retains separators in the slices. Concatenating every chunk exactly
reconstructs the canonical document for repeated spaces, tabs, blank lines, Markdown hard
breaks, fenced code, whitespace-only input, Unicode, and overlong tokens; every slice remains
within `max_chars`.
- Frontmatter uses a bounded `SafeLoader` variant: duplicate keys, anchors/aliases, structures
deeper than 20 nodes, and documents larger than 1000 composed nodes are rejected. YAML parse,
JSON type, credential-safety, and resulting canonical-model errors attributable to frontmatter
map to `PermanentNormalizationError(reason="invalid_frontmatter")`; invalid pipeline policy
remains a programmer-facing `ValueError`.
- Follow-up TDD evidence: the expanded focused suite first reported 11 expected failures against
the prior implementation, then passed **45/45** across normalization, chunking, and manifest
invariants.
- Follow-up full verification: **586 passed, 5 deselected**. Targeted Ruff is clean. Full Ruff
continues to report the same **34 unrelated pre-existing** violations in legacy tests.
+107
View File
@@ -0,0 +1,107 @@
# Evidence Task 4 — shared job envelope
Status: complete
## Delivered
- Immutable `JobSpec`, `JobRun`, `JobReport`, per-stage state, sanitized error, and UTC
timestamp records.
- `run_job(spec, stages)` with a durable checkpoint at job start, before and after every stage,
and at terminal state. Successful stages are skipped when a prior run is resumed.
- Atomic JSON checkpoint/report replacement using a unique same-directory temporary file,
file `fsync`, atomic `os.replace`, and parent-directory `fsync`.
- Public reports contain fixed operational fields only. Workspace paths, stage return values,
exception messages, source content, credentials, and arbitrary metadata are not serialized.
- `WorkspaceJobLock` uses non-blocking kernel `flock` on a stable workspace/job-specific inode.
Locks are released by the kernel on process exit; lock files are never removed based on PID,
avoiding stale-lock and PID-reuse deletion races. Evidence and DWH use distinct lock files.
- Dry-run intent is immutable in the spec/report and exposed to every stage through `JobContext`.
## TDD evidence
Initial focused collection failed because `tht.jobs` did not exist. Tests then drove:
- failure, sanitized reporting, resume, and idempotent successful-stage skipping;
- corrupt-checkpoint refusal before stage execution;
- JSON schema and path/secret/PII exclusion;
- dry-run propagation and ordered aware timestamps;
- multiprocessing exclusion, distinct Evidence/DWH jobs, traversal rejection, and recovery after
a lock-owning process crashes.
Final focused result:
```text
11 passed in 0.42s
```
## Verification
```text
cd harness && .venv/bin/pytest -q
597 passed, 5 deselected, 17 warnings in 28.45s
cd harness && .venv/bin/ruff check tht/jobs tests/test_job_runner.py tests/test_job_locking.py
All checks passed!
```
The full Ruff invocation was also run. It reports 34 pre-existing violations in unrelated legacy
tests; no Task 4 file is among them. L2 tests remain deselected by the repository configuration.
## Operational notes
- `fcntl.flock` intentionally targets the supported Linux/macOS deployment environments; it is not
a Windows locking implementation.
- The envelope does not publish or mutate an active corpus. Later pipeline stages must use
`JobContext.run_dir` for staging and perform their own final atomic publish only after validation.
- A dry run is an execution mode foundation: the runner exposes and records it; individual stages
remain responsible for suppressing external mutations.
## Review hardening follow-up
Four post-implementation findings were fixed test-first:
1. Resume compatibility is now a canonical SHA-256 fingerprint over checkpoint schema version,
hashed workspace identity, job type, dry-run mode, explicit spec/pipeline versions,
configuration/input fingerprints, and the exact ordered explicit `stage_ids`. Any insertion,
removal, reorder, mode, identity, version, config, or input change rejects resume before a stage
executes. Omitting `resume_run_id` remains the explicit safe path for a new run.
2. Lock traversal now uses directory file descriptors with `O_DIRECTORY` and `O_NOFOLLOW`.
Lock files use `O_NOFOLLOW | O_CLOEXEC`; `fstat` requires a regular file owned by the current
UID with one link, and permissions are forced to `0600` (`0700` for private directories).
Pre-existing lock-file and lock-directory symlinks are rejected.
3. Stage failures now serialize only the fixed safe tuple `internal` / `stage_exception` /
`stage execution failed`. Neither exception class names nor messages are inspected for output;
a hostile exception-name/message regression test proves a terminal failed report is retained.
4. Job/run directory creation is no-follow, owner-checked, private, and durable. Each newly created
parent is fsynced, the run directory is fsynced before the first atomic file write, and the
existing file-fsync → replace → directory-fsync ordering has an explicit regression test.
Follow-up verification:
```text
focused job/lock suite: 27 passed in 0.45s
full harness suite: 613 passed, 5 deselected, 17 warnings in 29.65s
Task 4 scoped Ruff: All checks passed
```
Repository-wide Ruff continues to report the same 34 unrelated pre-existing legacy-test findings.
## Final resume-integrity fix
Resume is now read-only until the source checkpoint proves trustworthy. The runner loads the source
before allocating a new run ID or directory, validates the exact stage state/timestamp/error ledger,
rejects duplicate stage identifiers, and recomputes compatibility from every persisted compatibility
field plus the exact ordered persisted stage IDs. It first requires the stored fingerprint to match
that recomputation, then compares the trusted recomputation with the requested job fingerprint.
Valid-JSON tampering tests cover removed, inserted/duplicated, reordered, and substituted stages;
input-field and stored-fingerprint changes; and invalid stage-state shapes. Every rejection occurs
before stage execution and asserts that the runs directory contains no orphan allocation.
Final verification:
```text
focused job/lock suite: 34 passed in 0.56s
full harness suite: 620 passed, 5 deselected, 17 warnings in 27.42s
Task 4 scoped Ruff: All checks passed
```
@@ -0,0 +1,82 @@
# Evidence Task 5 report
## Outcome
Implemented an incremental Evidence corpus pipeline with immutable materialized generations,
generation-scoped vector records, and an fsynced atomic `ACTIVE` pointer. Runtime Evidence
artifact lookup reads the active canonical manifest and keeps a legacy source-tree fallback only
when no corpus has been published.
The CLI is available as `tht preprocess evidence [--dry-run] [--resume RUN_ID] [--json]`.
JSON success and failure output is pristine and failure details are sanitized.
## Safety and failure model
- A workspace writer lock serializes preprocess writers; readers never take the lock.
- Generation directories, manifests, materialized files, locks, and `ACTIVE` reject symlink/path
escape cases and use owner-only durable writes.
- Vector records use generation-specific keys and metadata. The active manifest maps each active
document to its valid vector generation, allowing unchanged documents to retain their vectors.
- Runtime retrieval admits only active document IDs and their manifest-selected generations.
Removed documents and partial writes from failed generations are therefore unreachable.
- Embedding count and dimension checks occur before vector upsert; vector write count is checked
before staging/publish. Any failure leaves `ACTIVE` unchanged.
- Dry runs perform discovery/fingerprint planning only and never acquire, embed, write vectors, or
publish. Fully unchanged runs return the active generation without creating a replacement.
- Resume can safely retry idempotent generation-scoped upserts and publish an already staged,
compatibility-checked generation after a crash between staging and pointer replacement.
## TDD evidence
Initial focused collection failed because `tht.corpus.pipeline` and `tht.corpus.store` did not
exist. The implemented suite covers incremental skips, removals, model/policy rebuilds, acquire and
partial-vector failures, dry-run isolation, dimension validation, atomic reader snapshots, pointer
validation, symlink defense, and pristine CLI JSON.
Fresh focused verification:
```text
18 passed, 3 warnings in 0.39s
```
Command:
```text
.venv/bin/pytest tests/test_corpus_pipeline.py tests/test_corpus_publish.py \
tests/test_preprocess_cli.py tests/test_search_pack.py tests/test_session_documents.py -q
```
Scoped Ruff: `All checks passed!`
Broader non-Docker/non-packaging run reached `560 passed, 5 deselected`; ten pre-existing HTTP
adapter tests could not bind localhost under the sandbox. The complete suite reached `570 passed,
5 deselected`, with the remaining failures/errors caused by denied Docker socket, localhost bind,
and offline wheel-build access. No task-focused test failed.
## Remaining operational gate
Live pgvector integration needs Docker or an authorized local pgvector endpoint. The compensation
strategy is logical isolation rather than destructive cleanup because the shared `VectorStore`
port intentionally exposes no delete/transaction API; unreachable failed generations can be
garbage-collected by a future maintenance job.
## Review integration wave
Added an enforceable `metadata_filter` vector-port contract and capability flags. Direct pgvector
places exact Evidence generation/document predicates in SQL before `LIMIT`; HTTP sends the same
filter to the RPC and deliberately does not use the legacy 404 fallback. The reader RPC script now
validates and applies that filter. Normal Evidence search and search-pack use an ACTIVE-aware
searcher that groups active documents by generation, executes complete server-filtered searches,
and merges the results.
Added exact-generation Evidence cleanup to direct and HTTP writers plus the allowlisted writer RPC.
Pipeline failures compensate both staged filesystem state and vector writes; cleanup failures stay
sanitized and ACTIVE filtering remains the exposure boundary. Corpus-present session artifact
resolution now fails closed on corrupt/missing ACTIVE rather than falling through to source files.
Focused review-wave verification: 45 passed, scoped Ruff clean. A mocked REST regression proves
the exact filter payload and fail-closed legacy 404 behavior.
Still outstanding from the expanded review request: Task-4 JobRunner stage-by-stage integration,
published-generation retention/garbage collection, same-fd `dirfd` materialized-file reads, and
live local pgvector integration could not be completed in this wave.
@@ -0,0 +1,94 @@
# Evidence Task 5B implementation report
## Status
Integrated Evidence preprocessing with the Task 4 `JobRunner`. The CLI now accepts only a
32-character JobRunner run ID for `--resume`; generation IDs remain outputs. Runs persist the
exact ordered stages `discover`, `acquire_normalize_chunk`, `embed`, `vector_upsert`,
`stage_validate`, `publish`, and `retention_cleanup`.
Successful-stage artifacts are copied into the new resume run before execution, allowing later
stages to continue without rediscovery, acquisition, normalization, chunking, or embedding.
Job compatibility includes workspace, configuration, discovered-input, pipeline, embedding, and
chunk-policy fingerprints. Generation-specific filesystem/vector compensation is retained, and a
compensated generation is rotated before retry. `ACTIVE` is mutated only by `publish`.
Dry-run executes discovery/planning and makes every side-effecting stage a no-op. JSON output is
pristine and includes the JobRunner `run_id`, `resumed_from`, generation, plan, and publish status.
## TDD evidence
- RED: run-ID rejection and resume-artifact tests failed because generation IDs reached
configuration and resume runs had empty artifact directories.
- GREEN: the two regression tests passed after strict CLI validation and durable artifact carryover.
- Added pipeline job-plan and dry-run counting-fake coverage; both passed.
## Fresh verification
- Focused integration/search suite: `62 passed, 4 warnings`.
- Available harness suite excluding sandbox-blocked Docker, loopback HTTP-server, and networked
wheel-build tests: `559 passed, 5 deselected, 18 warnings`.
- Scoped Ruff: `All checks passed!`.
- `git diff --check`: clean.
## Environment limitations and concerns
The literal full harness invocation cannot complete in the managed sandbox: Docker socket access,
loopback HTTP test servers, and the `uv build` dependency resolution path are denied. It reached
`575 passed, 5 deselected` before those environment errors. The available-suite rerun above is
green.
One pre-existing Pydantic serialization warning is exposed by the new end-to-end job test when
canonical metadata contains frozen tuple values; it does not contaminate CLI stdout. Retention is
an explicit stable no-op until a retention policy is configured.
## Review fix wave — crash consistency and artifact integrity
Addressed all five follow-up findings:
- `JobRunner` now supports a test-only post-call/pre-checkpoint fault hook. Each stage seals a
canonical artifact manifest containing required flat filenames, SHA-256, byte size, producer
stage, and the full spec compatibility fingerprint. Resume validates the checkpoint and every
sealed artifact before allocating/copying a new run, rejecting missing, tampered, extra, nested,
or symlinked state. A sealed `running` stage is promoted after a simulated process crash; a
sealed `failed` stage is deliberately retried.
- Vector intent (exact record IDs and content hashes) is sealed before upsert. Execution reconciles
`existing_hashes` and writes only missing/mismatched rows. Crash-after-effect tests prove no
duplicate acquire, embed, or vector upsert.
- Raw upsert, stage, recovery-upsert, recovery-stage, and publish exceptions compensate the exact
generation. Compensation markers survive failed checkpoints; resume rotates the generation,
refreshes generation-bound artifacts, reconciles vectors, and stages idempotently.
- `CorpusStore.publish` is idempotent and failure-atomic. If replace succeeds but directory fsync
fails, it restores the previous `ACTIVE` value (or removes a newly created pointer), fsyncs the
rollback, and re-raises. Pipeline cleanup refuses to discard a generation referenced by ACTIVE.
- Added crash/resume coverage after all seven ordered stages; corrupt/missing plan, manifest, and
embeddings; unsafe extra paths; nonexistent run IDs; raw vector/stage failures; and post-replace
ACTIVE rollback.
Fresh fix-wave verification:
- Focused jobs/corpus/CLI/search suite: `82 passed, 17 warnings`.
- Available harness suite (same sandbox exclusions described above):
`579 passed, 5 deselected, 31 warnings`.
- Scoped Ruff and `git diff --check`: clean.
## Final P1 fix — effect state and checkpoint-bound manifest roots
- Stage checkpoints now distinguish `intent` from `completed`. Vector intent is atomically sealed
and checkpointed before upsert. A process-level `BaseException` after a partial multi-record
write leaves the stage `running/intent`; resume never promotes it and instead reconciles
`existing_hashes`, writing only the missing records. The completed state is persisted only after
reconciliation returns successfully.
- Every stage now persists its completed artifact state while still `running`, before the
post-call fault hook. The checkpoint binds the SHA-256 of canonical `artifact-manifest.json`,
effect state, exact producer stage, and exact required-file mapping. Resume validates this root
and all bindings before promotion or copying.
- Added process-interruption coverage proving the already-written vector record is not submitted
twice, remaining records are written, and publish completes only after reconciliation. Added
coordinated artifact/manifest, spec-binding, and producer-binding tamper rejection tests.
Fresh verification:
- Focused jobs/corpus/CLI/search suite: `86 passed, 18 warnings`.
- Available broad harness suite: `583 passed, 5 deselected, 32 warnings`.
- Scoped Ruff and `git diff --check`: clean.
+188
View File
@@ -0,0 +1,188 @@
# Evidence Task 5C report
## Delivered
- Added `vector.retain_published_generations` (default `3`, validation minimum `1`).
- Retention runs only after publication. It keeps ACTIVE, the newest configured generations,
and generations referenced by running or resumable failed job checkpoints.
- Cleanup deletes the exact Evidence generation from the vector store before removing its
immutable filesystem directory. Vector failures retain filesystem metadata for retry and
produce credential-free partial reports.
- Added idempotent `tht preprocess evidence gc [--dry-run] --json` reconciliation with pristine
JSON output.
- Materialized document reads now open generation/documents components with directory file
descriptors and `O_NOFOLLOW`, require a regular file owned by the process with one link, and
hash the bytes read from the same descriptor against the canonical manifest.
- HTTP generation deletion is pinned to `delete_vector_generation` with exact
table/kind/generation arguments. Legacy 404 responses fail closed with an actionable,
sanitized migration message.
## Evidence
- Focused retention, safe-read, CLI, and HTTP contract tests: `51 passed` (Docker-backed direct
parametrizations excluded from that focused invocation).
- Real Docker pgvector adapter suites: `33 passed`.
- Full harness suite, including Docker-backed tests: `668 passed, 5 deselected`.
- Changed-file Ruff: clean.
- `git diff --check`: clean.
The five deselected tests are the repository's opt-in `l2` tests requiring external services;
they are not local pgvector tests. Test output retains pre-existing Pydantic serialization and
legacy-config deprecation warnings.
## Review fix wave
- Publication is now explicit and durable (`PUBLISHED` marker). Retention candidates require a
valid generation manifest and publication marker (ACTIVE remains backward-compatible), so
staged and malformed directories neither consume retention slots nor become deletion targets.
- The policy retains ACTIVE plus exactly `N-1` newest rollback publications, ordered by durable
publication time and generation id. Running and failed-resumable JobRunner checkpoints protect
every referenced plan generation.
- `VectorStore` now exposes exact Evidence generation inventory. Direct pgvector uses a constrained
`SELECT DISTINCT` over `kind='evidence'` and `metadata.vector_generation`; HTTP uses the
allowlisted `list_evidence_generations` RPC and fails closed on legacy 404. The writer RPC SQL,
revokes, and grants are packaged in `create_vector_writer_rpc.sql`.
- Explicit GC reconciles the union of published filesystem generations and vector-only orphans,
preserving vector-before-filesystem deletion and retry semantics.
- `run_as_job` holds the same corpus writer lock across checkpoint recovery, staging, publish, and
retention. Explicit GC already uses this lock, serializing candidate snapshots with publishers.
- Session artifact consumers no longer receive the corpus source path after validation. They get
an owned, read-only copy atomically written from the bytes read and hash-validated on the same
descriptor.
Fresh verification after the fix wave: full harness `672 passed, 5 deselected`; Docker pgvector,
HTTP parity, and migration suites `43 passed`; exact direct inventory/delete integration `1 passed`;
changed-file Ruff and `git diff --check` clean.
## Final hardening verification
- Canonical generation validation is exact (`^gen:[0-9a-f]{32}$`) before HTTP/direct deletion;
malformed HTTP inventory rows fail closed rather than entering the GC candidate set.
- Added explicit protection coverage for running and failed-resumable JobRunner checkpoints, plus
a second-GC idempotence assertion for vector-only orphan reconciliation.
- Added deterministic concurrent locking coverage: a job paused after discovery retains the corpus
writer lock, explicit GC blocks, then completes after publication without deleting the active run.
- Added a descriptor-race regression: replacing the corpus pathname immediately after `read(2)`
leaves the atomically materialized session-owned copy byte-for-byte equal to the validated ACTIVE
document and its manifest hash.
Final fresh evidence: Docker pgvector/HTTP/migration suites `48 passed`; full harness `680 passed,
5 external L2 deselected`; changed-file Ruff and `git diff --check` clean.
## Integrated Task 5 dependency fixes
- GC now distinguishes filesystem retention from vector dependencies. ACTIVE and the newest
`N-1` published manifests keep their directories; every exact generation in their
`document_generations` maps remains vector-protected even after its old publication directory is
evicted. Job-protected manifests receive the same dependency treatment.
- The real four-publication Docker lifecycle now includes an unchanged document whose vectors come
from the first generation. With retention `N=2`, only the final two publication directories remain
while the first generation's vectors remain searchable from ACTIVE and survive restart/explicit GC.
- Evidence lookup is always wrapped by the ACTIVE-aware searcher. With no corpus/ACTIVE, Evidence
returns no rows and search packs cannot expose legacy vectors; non-Evidence kinds are unchanged.
- Session artifact resolution holds the corpus writer lock, snapshots the active manifest once, and
materializes bytes using that exact `manifest_id`, preventing a concurrent publish/retain-1 GC from
changing or deleting the selected source generation.
Focused unit tests, the updated real Docker lifecycle, changed-file Ruff, and `git diff --check` pass.
The final full harness invocation completed with exit code 0, including the concurrently added DWH
JobRunner tests.
## Final ACTIVE search review fixes
- `ActiveEvidenceSearcher` now treats default (`kinds=None`) and mixed-kind searches as explicit
split queries: non-Evidence kinds are queried separately, while Evidence is queried only with
ACTIVE manifest generation/document predicates applied server-side before every limit.
- Results are merged deterministically by descending similarity then stable id and truncated once
to the caller's global `top_n`. Pure non-Evidence searches retain their original delegate path.
- The corpus writer lock now covers manifest snapshot construction and all corresponding vector
queries, preventing retain-1 publication/GC from switching or deleting generations mid-search.
- Removed the public post-LIMIT `active_evidence_hits` helper; no public Evidence path performs
client filtering after limit.
Focused default/mixed/no-ACTIVE/search-pack tests pass, the real Docker pgvector lifecycle passes,
and the final full harness plus scoped Ruff/diff invocation completed with exit code 0.
## Workspace-scoped Evidence isolation
- Evidence manifests, vector metadata, and record keys now carry the stable JobRunner workspace id
derived from the configured workspace identity (config stem), never credentials or absolute paths.
- Every ACTIVE server-side predicate includes `workspace_id`. Legacy unscoped rows therefore fail
closed and cannot appear in Evidence results.
- Vector generation inventory and deletion require the workspace namespace across the port, direct
pgvector adapter, HTTP client/adapter, and allowlisted RPC SQL. Legacy unscoped RPC overloads are
explicitly dropped during migration; destructive SQL matches collection, kind, generation, and
workspace together.
- GC recovers the persisted namespace from ACTIVE for explicit/restarted cleanup and can only list
or delete that workspace's generations. Real shared-pgvector coverage proves deleting a generation
for workspace A preserves the same generation in workspace B.
- `PipelineResult.model_dump` now serializes fields explicitly instead of `dataclasses.asdict`,
avoiding deepcopy of immutable `FrozenDict` metadata while preserving pristine JSON CLI output.
Final focused verification: `89 passed` across corpus/CLI JSON, direct/HTTP parity, migrations, and
real Docker pgvector lifecycle; scoped Ruff and `git diff --check` clean. A contemporaneous full-suite
run reached unrelated Task 6 immutable-file tamper tests; those files were deliberately not changed.
## Immutable corpus/workspace binding
- A corpus root becomes bound to the workspace id persisted in its ACTIVE manifest. Job, non-job,
explicit GC, and ACTIVE search entry points compare the configured namespace before discovery,
vector access, staging, deletion, or ACTIVE mutation.
- Reusing the same paths after renaming a workspace now fails closed with a typed/sanitized message:
use a new corpus root or perform an intentional explicit rebuild. Unscoped legacy manifests also
fail this ownership check.
- Tests prove unchanged-document reuse cannot silently mix workspace A vectors into a workspace B
manifest, and that mismatched job, GC, and search paths perform no vector/filesystem mutations.
Focused workspace-binding, search-pack, preprocess JSON, and scoped Ruff/diff tests pass.
Compatibility follow-up: direct/internal `CorpusPipeline` instances now distinguish an omitted
workspace identity from an explicit config/job identity. An unbound instance adopts the persisted
ACTIVE owner (or `default` only for a brand-new direct corpus), preserving safe resume/GC tests and
the real pgvector lifecycle. Explicit config/job identities still fail closed on any mismatch. The
two reported regressions, workspace mismatch guards, real Docker lifecycle, scoped Ruff/diff, and
the full harness suite all pass.
Final fail-closed follow-up: persisted ACTIVE ownership is now validated under the corpus lock before
every configured search delegate, including default, mixed, pack, and non-Evidence-only operations.
Malformed or missing `metadata.workspace_id` is intrinsically rejected even for unbound direct
callers; source discovery, vector operations, GC, files, and ACTIVE remain untouched. Focused tests,
real Docker lifecycle, scoped Ruff/diff, and the full harness regression run pass.
Final lock/preflight follow-up: `CorpusPipeline.gc()` now acquires the corpus writer lock itself for
ownership validation through vector/filesystem cleanup. The store lock is thread-reentrant so nested
job retention is safe without weakening cross-thread/process exclusion; the CLI wrapper no longer
double-locks. Search find/pack performs locked corpus ownership preflight immediately after config
load, before DWH leasing, vector/searcher factories, embeddings, or schema work. Focused concurrency
and fail-closed tests, real Docker lifecycle, scoped Ruff/diff, and the full harness pass.
## Compact public Evidence reports
- Public `PipelineResult.model_dump()` is now a bounded operational envelope: terminal status,
run/resume/publication/generation/manifest identifiers, capped changed/unchanged/removed source
identifiers, and aggregate document/chunk counts. Full manifests, bodies, and metadata remain
internal/on disk and are never serialized to CLI stdout.
- `tht preprocess evidence` exits `1` for any durable terminal status other than `succeeded` in
both JSON and text modes. JSON stdout remains one pristine sanitized object; text mode emits one
compact stderr error without traceback, exception identity, evidence content, or credentials.
- Tests cover a real failed acquisition job, sensitive evidence content, capped thousand-item
summaries, bounded report size, and smoke-compatible changed/unchanged fields.
Focused tests and scoped Ruff/diff pass. The contemporaneous full suite reaches an unrelated Task 6
DWH snapshot fixture missing its newly required workspace identity.
### Safe result representation and exact text totals
- `PipelineResult.manifest` is explicitly excluded from dataclass representation and the custom
representation is fixed-size operational data only. It omits manifest ids, documents, chunks,
content, metadata, and errors; `str(result)` inherits the same safe representation.
- Text-mode Evidence success output reads the uncapped aggregate totals from `payload["counts"]`
rather than the intentionally capped identifier arrays.
- Regression coverage builds a thousand-document/chunk manifest containing content and
credential-like metadata secrets, checks bounded `repr`/`str`, and verifies exact totals above
the 100-item public-array cap.
Focused Evidence verification passes (`67 passed`), and scoped Ruff is clean. The full harness run
is not green in this sandbox: Docker-backed tests cannot access the daemon, wheel packaging cannot
use the restricted build environment, and concurrent Task 6 DWH binding changes currently fail two
DWH tests. None of those failures touch the Evidence files in this follow-up.
@@ -0,0 +1,50 @@
# Evidence Task 5D — Real pgvector lifecycle gate
## Status
Complete. The Docker-backed L0 gate uses one persistent `pgvector/pgvector:pg16`
database and the production migrations, direct reader/writer `PgVectorStore`,
`CorpusStore`, `CorpusPipeline.run_as_job`/JobRunner, ACTIVE Evidence retrieval,
search-pack fusion, owned session artifact copy, retention, and explicit GC.
## Lifecycle covered
- Four real corpus publications with retention set to two generations.
- A higher-similarity stale vector proves ACTIVE metadata filtering happens before LIMIT
for normal Evidence retrieval and the search-pack fusion path.
- A removed source is absent from ACTIVE retrieval and cannot be copied to a session.
- An injected process death occurs after one real committed vector upsert. Resume uses the
real run ID, preserves that record, fills the missing records, and produces no duplicate keys.
- Database engines and direct store objects are disposed/recreated before persisted ACTIVE
retrieval is checked again.
- An exact canonical vector-only orphan generation is discovered and removed by explicit GC.
- Filesystem and vector inventories converge exactly to ACTIVE plus one rollback; a second GC
is a no-op.
- Owned session artifact bytes and SHA-256 match the ACTIVE canonical document.
## Production bug found and fixed
Production migration `003_roles.sql` intentionally restricted `vector_writer`, but omitted
the privileges used by the production generation lifecycle: `SELECT(metadata)` for inventory
and `DELETE` for cleanup on `vectors.evidence`. Consequently a real job published successfully
and then failed in `retention_cleanup` on its first run.
Added versioned migration `004_evidence_generation_gc.sql` granting only those two Evidence
generation-management privileges. Runtime application code was not redesigned.
## Verification
- Target lifecycle: `1 passed` (Docker-backed).
- Full harness: `681 passed, 5 deselected`.
- Scoped Ruff: passed.
- `git diff --check`: passed.
The existing Pydantic serialization and legacy-workspace deprecation warnings remain unchanged.
## Follow-up assertion correction
The removal phase now retains the removed canonical document ID/ref before publication and
asserts both fields are absent from post-resume ACTIVE Evidence hits. It reruns the real
search-pack fusion after removal, proves active fourth-generation content is positively
returned in both paths, and proves the removed content remains absent. The owned session
artifact lookup for the retained removed ID remains empty.
@@ -0,0 +1,49 @@
# Evidence Task 6 — final fd-anchored DWH correction
All DWH generation state below `.tht-dwh` is now accessed relative to the directory descriptor
retained by the shared/exclusive generation lease. ACTIVE reads, atomic temp writes, replacement,
fsync, and rollback use `openat`/`replaceat` operations. Generation staging, validation,
reconciliation, resume checks, retention classification, and recursive deletion likewise use owned
root/generations/candidate descriptors with `O_NOFOLLOW`; locked operations no longer reopen
generation paths through `workspace_root`.
Portable reader snapshots are copied from validated generation file descriptors into private 0700
process-owned temporary directories while the shared lease is held. This avoids Linux-only
`/proc/self/fd` paths and prevents a renamed/replaced `.tht-dwh` pathname from redirecting later
schema or LSH reads. Lease-scoped copies are removed on exit and standalone snapshots are removed
at process exit.
Deterministic adversarial tests rename the DWH root after lease acquisition during ACTIVE reads,
ACTIVE publication, and retention cleanup. Each test proves the replacement tree is never read,
written, or deleted; the descriptor-pinned original either completes consistently or fails closed.
Existing owner binding, legacy rejection, crash reconciliation, resume, atomic rollback, retention,
and reader/writer exclusion behavior remains covered.
## Final review correction
Snapshot materialization now reads the manifest and every owned artifact exactly once through the
already-open generation descriptor, validates each hash against those exact bytes, and writes the
same byte objects to the private snapshot. A deterministic second-read mutation test proves hostile
pickle bytes can neither pass validation nor enter the snapshot. Reconciliation closes the ACTIVE
generation descriptor in a `finally` block on matches, mismatches, and exceptions. Pipeline-owned
snapshot directories are removed and deregistered after `run_job` on both successful and failed
runs, preventing repeated pipeline use from accumulating temporary directories or registry entries.
The cleanup boundary now begins immediately after snapshot materialization. Resume checkpoint
validation and `JobSpec` construction are guarded by the same release routine as `run_job`, so
corrupt/mismatched resume state or constructor failure clears the pipeline holder, removes the
private directory, and restores the snapshot registry to its prior state before propagating.
## Shipped preprocessing startup contract
Local-vector preprocessing now uses a dedicated Compose override. Both one-shot jobs depend on a
successfully completed `vector-migrate`, whose transitive chain waits for database health and role
reconciliation. The generic preprocessing overlay remains independently renderable and contains no
local-vector services or password secrets. README commands include the local override and build the
job image before running.
The real clean-project smoke no longer injects dependencies or manually starts, reconciles, or
migrates PostgreSQL. Its first shipped `compose run preprocess-evidence` demonstrably creates the
database, waits for health, runs reconciliation and migration, then runs the Evidence job. Unchanged
rerun, changed-source publish, DWH preprocessing, ACTIVE verification, and injected-failure cleanup
all pass through the same shipped dependency path.
@@ -0,0 +1,93 @@
# Evidence preprocessing Task 7 report
Implemented the S3-compatible Evidence adapter, explicit preprocessing Compose overlay, and
operational gates.
- S3 discovery uses bounded paginator pages, page size, and total objects; acquisition enforces a
byte ceiling and always closes streaming bodies.
- Provenance is canonical `s3://bucket/key`. Versioned objects use `s3-version:<version>`;
unversioned objects use a hashed exact ETag, and acquisition refuses validator drift.
- The adapter uses boto3/botocore rather than custom signing. TLS verification is enabled by
default. Custom HTTP and private endpoints require independent explicit opt-ins; endpoint
userinfo is rejected and public custom endpoints are DNS-policy checked.
- Access, secret, and session credentials support file-secret resolution into masked `SecretStr`
config fields. They are never emitted in provenance, reports, errors, or Compose environment.
- `deploy/compose.preprocess.yaml` provides separate one-shot Evidence and DWH jobs and is inert
unless explicitly included with the `preprocess` profile.
- `scripts/preprocess-smoke.sh` verifies both services render without secret material and pins an
unchanged rerun plus a modified generation through deterministic pipeline tests.
Verification: focused S3/HTTP/filesystem/config tests 34 passed; operational smoke 2 passed; core
image with locked boto3 extra built; full harness 702 passed, 5 deselected; scoped Ruff and diff
checks passed.
Operational risk: custom S3-compatible endpoints remain part of the deployment trust boundary.
Private endpoint access must be explicitly enabled and should be restricted by container egress
policy in production. S3 list consistency semantics are provider-defined; version IDs are preferred
over ETags wherever bucket versioning is available.
## Review correction
The Compose overlay now uses committed, purpose-built Evidence and DWH workspace files with
job-specific dependencies. Its services create their lock roots and mount only the vector secrets
they consume. The operational smoke is a real isolated Compose project: real pgvector migrations,
a deterministic in-project embeddings endpoint, actual Evidence CLI JSON across initial/unchanged/
mutated runs, exact ACTIVE verification, an actual DWH introspection job, and owned cleanup.
S3 custom endpoints now fail closed unless declared trusted; HTTP and private loopback endpoints
need additional independent opt-ins. Boto uses forced path-style addressing. Custom endpoints reject
userinfo, query, fragment, and non-root paths. Buckets use strict DNS syntax; listed keys must remain
under prefix and within the S3 byte bound; validators must be nonempty/bounded. Because
ListObjectsV2 does not provide version IDs, discovery honestly fingerprints the exact ETag and
acquisition rejects ETag drift.
Final correction verification: S3/config focused 20 passed; full harness 721 passed, 5 deselected;
real Compose smoke and image build passed; scoped Ruff, shell syntax, and diff checks passed.
## Final security review correction
Literal non-global IPv4/IPv6 endpoints now require the private-endpoint opt-in without claiming DNS
pinning for hostnames. Pagination uses explicit continuation requests and never fetches page
`max_pages + 1`. IP-shaped buckets, leading-slash prefixes, empty/overlong/control-character keys,
and absent validators fail closed. Acquisition accepts only the exact stored `SourceObject` and
compares the response ETag with the stored discovery validator. The real smoke snapshots generation
directory counts after every run and has an injected-failure cleanup mode; cleanup fails if Compose
down fails or any owned container, volume, or network remains.
The canonical smoke correction counts only root-level `corpus/gen-<32 hex>` directories. It exposed
that the durable job path still published an empty unchanged generation; the pipeline now returns
the existing ACTIVE generation without staging a directory when compatibility and all source
fingerprints are unchanged. The smoke therefore proves directory deltas `+1`, `+0`, `+1`.
Failure injection runs a real exit-97 command after resources exist and reaches the EXIT trap.
Cleanup aggregates Compose-down, residual container/volume/network, and temp-directory failures
while preserving the original failure status. S3 prefixes are validated before any client request
for leading slash, UTF-8 byte length, controls, and DEL.
## Canonical unchanged-run correction
The durable job now persists a deterministic source snapshot keyed by source identity. Each entry
binds canonical URI, exact source fingerprint, UTC modification time, canonical immutable metadata,
and explicit media type and size contract fields. The manifest also binds document-to-source
provenance, supplied config/input fingerprints, compatibility, embedding settings, and pipeline and
chunk-policy versions.
An unchanged run reuses ACTIVE only when ownership, bindings, the complete snapshot, document
provenance, materialized document hashes, and every required vector ID/content hash match exactly.
Snapshot changes rebuild only the affected sources; job input/config changes publish a new manifest
while retaining valid stable vector-generation dependencies. Missing or corrupt legacy contract
metadata, documents, or vectors fails closed and rebuilds. The Compose smoke now explicitly expects
the unchanged no-op to report `published=false` while proving generation deltas `+1`, `+0`, `+1`.
## Corrupt ACTIVE reconstruction correction
ACTIVE reuse now reconstructs each source contract from the persisted discovery snapshot and checks
the deterministic document identity, canonical URI, source fingerprint, UTC modification time,
source metadata, applicable media type, content hash, and pipeline identity against the owned
materialized document. The persisted document-source map carries the same exact binding.
Chunks are recomputed under the current chunk policy and must match the manifest exactly in count,
order, IDs, ordinals, content, hashes, linkage, provenance, and policy metadata. Vector health must
report the configured dimension, and every recomputed chunk must have its generation-scoped vector
ID with the exact content hash. Missing, altered, or extra chunks and corrupt document or vector
contracts therefore disable the no-op and rebuild, while a valid unchanged run still performs no
source acquisition.
@@ -0,0 +1,16 @@
# Model provider credential boundary
The backend accepts only an absolute `THT_MODEL_API_KEY_FILE` reference. `PiProcessManager` reads
and validates it afresh before each hosted-provider spawn, rejects symlinks, non-regular/hard-linked,
empty, whitespace-containing, oversized, unreadable, or permissively-mode files, and accepts Docker
0444 secrets only beneath `/run/secrets`. Failures are sanitized and occur before child creation.
Provider names are normalized and mapped to Pi-recognized variables. The child environment removes
the generic path, deprecated `PI_PROVIDER_API_KEY`, and all unselected known provider keys before
injecting only the selected key. Values never enter argv, settings, health, or diagnostics. Local
providers remain keyless and unknown hosted providers fail closed.
The production Compose overlay mounts `model_api_key` read-only and points the backend at its file;
the deployment render smoke proves the value is absent from rendered configuration. Entrypoint,
root README, Pi configuration guide, environment example, and secrets operator guide document the
new contract and reject the legacy generic value variable.
@@ -0,0 +1,59 @@
# Local pgvector whole-plan final fix report
## Outcome
All four binding final-review findings are closed.
1. `PgVectorStore.health()` checks namespace `USAGE` independently for reader and writer
before inspecting vector types. Real PostgreSQL tests revoke only schema `USAGE`, prove both
health sides false and operations unavailable, then grant it back and prove recovery.
2. Direct reader/writer passwords use workspace `password_file` references. Compose mounts the
two files read-only into core and exposes only `_FILE` paths. Rendered Compose and live
`docker inspect` checks prove secret contents are absent.
3. Direct search failures map to `VectorReadUnavailable`; hash/upsert failures map to
`VectorWriteUnavailable`. Messages are fixed and sanitized, original exceptions remain chained,
and upsert rollback is preserved.
4. The shared secret policy uses Linux `stat -c` with macOS `stat -f` fallback. Host files permit
only `0600`/`0400`; Docker's read-only `0444` is accepted only beneath `/run/secrets`. Tests and
operator docs pin this exact policy.
## TDD evidence
The new config, mode, schema-usage, unavailable-connection, and permission regressions failed
before their implementations. The first live secret-policy run also caught GNU `stat -f` accepting
an incompatible format invocation; detection now tries the native Linux form first. The next live
run caught smoke-generated rotation fixtures at `0644`; fixtures now model the documented host
policy.
## Verification
- Real direct pgvector + HTTP parity: `31 passed`.
- Full harness from `harness/`: `493 passed, 5 deselected`.
- Live `local-vector` rotation, restart persistence, inspect boundary, and backup/restore: pass.
- Core image vector migration discovery/status smoke: pass.
- External and local Compose deployment security contracts: pass.
- Config/port focused suite: `26 passed`.
- Secret policy, bootstrap rotation, and backup/restore safety scripts: pass.
- Changed Python Ruff, shell syntax, and `git diff --check`: pass.
One attempted full-harness invocation from the repository root produced a path-dependent failure
in an existing test that opens `workflow.yaml` relative to CWD. It was immediately rerun using the
documented `cd harness && .venv/bin/pytest -q` command and passed completely.
## Operational notes
Workspace files contain file paths, never direct passwords. Secret contents necessarily exist in
the in-process validated `DatabaseConfig` used to establish PostgreSQL connections, but are not
serialized by doctor/Compose/inspect paths. Docker Desktop file-backed secrets may appear as bind
mounts; the safe runtime exception is therefore based on the read-only service mount location
`/run/secrets`, while source files remain owner-only on the host.
## External-profile regression follow-up
Local pgvector is now an explicit `deploy/compose.local-vector.yaml` overlay. The base Compose and
production external override contain no direct vector password declarations, mounts, or `_FILE`
variables, so external deployments do not resolve or require local password files. A real lifecycle
gate unsets all local secret-file variables, renders external config, builds and starts core, waits
for health, and inspects the live container for absence of local direct-vector secret paths. The
local overlay retains its live inspect assertion (paths present, values absent), rotation, restart
persistence, and transactional backup/restore drill.
@@ -0,0 +1,95 @@
# Local pgvector Task 1 report
## Status
Implemented the direct `PgVectorStore` behind the transport-neutral `VectorStore` port.
The adapter uses separate optional reader and writer database configurations, derives
capabilities from configured authority, validates strict positive search limits, filters kinds
in SQL before limiting, and merges multi-collection results by cosine similarity.
All collection identifiers are selected from the fixed `schema_records`, `evidence`, and
`memory` allowlist and composed with `psycopg2.sql.Identifier`. Values, vectors, kinds, hashes,
and limits remain bound parameters. Collection/kind mismatches fail with `VectorStoreError`.
Upserts preserve the canonical metadata shape, use `record_key` conflict semantics, update the
transport hash and embedding, and leave semantic metadata fields intact. Health probes reader
and writer independently and reports observed `vector(N)` dimensions against the configured
embedding dimension.
## Configuration and factory
`pgvector_direct` now accepts explicit optional `reader` and `writer` `DatabaseConfig` entries.
The former `connection` entry remains supported as a deprecated read-only compatibility path.
`build_vector_store(..., require_write=True)` accepts writer-only direct configurations and
fails early when no explicit writer is present.
The transitional `build_vector_loader` bulk-sync path remains in place. It uses an explicit
direct writer when present, or the legacy `connection`; it deliberately does not treat a new
reader-only credential as writable. No production schema migration was added.
## TDD and verification
- RED: the new tests initially failed at collection because `PgVectorStore` did not exist.
- Docker L0 pgvector tests: `11 passed`.
- Direct + HTTP parity/factory/config focus: `51 passed`.
- Full harness: `461 passed, 5 deselected`.
- Changed-file Ruff lint: clean.
- Changed-file Ruff format check: clean.
- `git diff --check`: clean.
The repository-wide `ruff check .` still reports 34 pre-existing test-file findings outside
Task 1; none are in changed files. The full pytest suite emits 17 existing legacy-config
deprecation warnings.
## Scope and concerns
- Test fixtures create only the three existing vector tables needed to exercise the adapter;
migration/versioning remains Task 2.
- The legacy single `connection` form stays read-only through the public port, matching its
previous adapter behavior, while remaining available to the explicitly documented bulk-loader
transition.
## Review fix wave
The Task 1 review findings were addressed in a follow-up TDD cycle:
- Search now validates requested kinds against the global known-kind set, intersects valid kinds
with each collection, and skips unrelated collections. A direct-versus-HTTP parity test covers
the multi-collection case.
- Health requires all three allowlisted tables, an `embedding vector(N)` column on every table,
the expected dimension on every table, and the appropriate read or write table privileges for
each configured side. Empty and partial schemas return deterministic, credential-free details;
unexpected database failures expose only their exception class.
- The Docker L0 fixture now provisions separate least-privilege reader and writer roles. Tests
prove the reader cannot insert, the writer cannot execute the cosine-search SELECT, and the
adapter still routes search to the reader and upsert/hash operations to the writer. Direct
upsert uses an atomic `INSERT ... ON CONFLICT DO NOTHING` followed by `UPDATE` for an existing
key, avoiding broad SELECT authority while retaining conflict-safe hash/upsert semantics.
Fresh verification after the fix wave:
- Docker L0 + HTTP port/search parity: `42 passed` (earlier checkpoint); the final L0 file has
`16 passed` including the stricter raw-role search denial.
- Expanded focused adapter/config suite: `56 passed`.
- Full harness: `466 passed, 5 deselected`.
- Changed-file Ruff lint/format and `git diff --check`: clean.
## Sequence privilege health follow-up
Writer health now resolves the real serial/identity sequence for the `id` column of every
required collection using `pg_get_serial_sequence`. It requires `USAGE` on each resolved
sequence, which is the privilege used by the adapter's implicit `nextval`; sequence `SELECT` is
not required because no adapter operation reads sequence state.
The Docker fixture includes a writer role with complete table/hash-column authority but no
sequence grant. Its health is deterministically unhealthy and a new-key upsert fails. Granting
only sequence `USAGE` makes health green and the same port upsert succeeds. Sequence discovery is
guarded for partial schemas so a missing `id` column produces the existing sanitized schema
diagnostic instead of a PostgreSQL error.
Fresh verification for this follow-up:
- Docker pgvector L0 after formatting: `17 passed`.
- Expanded focused adapter/config/parity suite: `57 passed`.
- Full harness: `467 passed, 5 deselected`.
- Changed-file Ruff lint/format and `git diff --check`: clean.
@@ -0,0 +1,82 @@
# Local pgvector Task 2 report
## Outcome
Implemented ordered, idempotent production migrations and the `tht vector migrate`
interface, including `tht vector migrate --status --json` with pristine JSON output.
## Implementation
- `001_extensions.sql` installs pgvector.
- `002_schema_tables.sql` creates `vectors.schema_records`, `vectors.evidence`, and
`vectors.memory` with the `VectorWriteRecord` columns and `vector(768)` embeddings.
- `003_roles.sql` creates passwordless `NOLOGIN` reader/writer roles. Deployments inject
credentials (or grant these roles to separately-created login roles); no production secret
is stored in the repository.
- Reader authority is schema usage plus table `SELECT`.
- Writer authority is schema usage, table `INSERT`/`UPDATE`, narrow hash-probe column `SELECT`,
and sequence `USAGE`. It has no `DELETE`, broad row `SELECT`, DDL, or ownership authority.
- The migration runner discovers ordered SQL files, records SHA-256 checksums in
`public.tht_vector_migrations`, serializes runners with a transaction-scoped advisory lock,
and applies the full pending batch in one transaction.
- Status distinguishes applied, pending, and checksum-drifted migrations. Apply refuses drift.
A failed migration rolls back both prior migrations in that batch and ledger writes.
## TDD evidence
RED was observed with a real `pgvector/pgvector:pg16` testcontainer: 6 failures for the missing
module, missing command, and missing schema.
GREEN verification:
- Focused migration + direct adapter integration: `23 passed`.
- Full harness from the documented `harness/` cwd: `473 passed, 5 deselected`.
- Targeted Ruff (`tht` plus the new L0 test): clean.
- `git diff --check`: clean.
The new L0 coverage exercises clean install, idempotent rerun, pristine JSON status, checksum
drift, transaction rollback, exact tables/columns/dimensions, role isolation, sequence authority,
and the real `PgVectorStore.health()` plus `VectorWriteRecord` upsert path.
## Existing repository lint baseline
The requested full `ruff check .` was run. It reports 34 pre-existing violations in unrelated
test files (unused imports and one-line semicolon statements). None are in Task 2 files; changing
them would exceed this task's scope. The complete harness test gate is green.
## Self-review
No unresolved Task 2 correctness concern found. One deliberate contract choice is worth noting:
writer `INSERT` and `UPDATE` are table-level because the approved direct adapter health probe uses
`has_table_privilege` for those authorities. Least privilege is retained by withholding broad
`SELECT`, `DELETE`, DDL, ownership, and credentials.
## Review fix wave
The post-implementation review found four production-boundary gaps. They are fixed as follows:
- Migration SQL now ships inside the `tht` wheel (`tht/migrations/vector`) via explicit
setuptools package-data and is discovered through `importlib.resources`, rather than relying on
a source-checkout-relative directory.
- Both status and apply reject ledger versions absent from the installed manifest, including
nonnumeric future version labels. This treats a binary/database downgrade as drift instead of
silently reporting a healthy state.
- Migration files are ordered by parsed integer version; spellings such as `2` and `02` are
rejected as duplicate versions.
- Every migration transaction pins `search_path` locally to `pg_catalog, pg_temp`; catalog calls
and the ledger are schema-qualified. pgvector is installed into the locked `vectors` schema,
tables use `vectors.vector`, and `PgVectorStore` qualifies vector casts and the cosine operator.
A hostile admin default path with a writable shadow schema cannot redirect migration objects.
- The core image build asserts CLI discovery. Image verification now starts an ephemeral pgvector
database, runs the installed image's migration command, and compares pristine apply/status JSON.
Additional verification after the fix wave:
- Focused migration, adapter, hostile-path, and wheel suite: `27 passed`.
- Full harness: `477 passed, 5 deselected`.
- Production core image build: passed, including build-time CLI discovery.
- Core-image apply/status smoke against `pgvector/pgvector:pg16`: passed.
- Changed production and test files: Ruff clean; `git diff --check` clean.
- Full Ruff remains at the same 34 pre-existing unrelated test-file findings documented above.
No dependency changed, so the committed Python requirements lock did not require regeneration.
+133
View File
@@ -0,0 +1,133 @@
# Task 3 report — optional local pgvector profile
## Status
Implemented and verified the `local-vector` Compose profile.
- `vector-db` uses pgvector 0.8.5 on PostgreSQL 16, pinned to the official multi-arch
manifest digest.
- `vector_data` is a project-scoped named volume and is not shared with application data.
- database readiness gates the packaged one-shot `vector-migrate` job; core declares the
migration completion dependency while remaining usable in the pre-existing external profile.
- bootstrap, migrator, reader, and writer identities are distinct. Bootstrap and migration
credentials are supplied as Compose secrets; the application receives only reader/writer
credentials.
- `deploy/workspaces/local-vector.yaml` selects `pgvector_direct` with separate reader and
writer connections.
- the base loopback port binding, `AUTH_MODE=none`, and `THOTH_PUBLIC_EXPOSURE=false` defaults
are unchanged.
## Red/green evidence
The initial Compose contract did not list `vector-db`, as required by the brief. The first real
smoke then failed migration 002 because bootstrap installed the vector extension in `public`.
The bootstrap was corrected to create the `vectors` schema under the migration owner and install
the extension there. A clean-volume rerun passed.
## Verification
- `./scripts/local-vector-smoke.sh`: PASS
- isolated generated Compose project and credentials
- clean migration plus idempotent status rerun
- reader/writer privilege health
- one-record upsert and similarity search
- restart of both `core` and `vector-db`
- persisted search result after restart
- project-only volume cleanup
- `./scripts/test-container-deployment.sh`: PASS
- `./scripts/test-backend-url-policy.sh`: PASS
- `docker compose --profile local-vector config --quiet`: PASS
- harness: 477 passed, 5 deselected
- backend: 84 passed; TypeScript typecheck PASS
- frontend: 226 passed; TypeScript typecheck PASS
- `git diff --check`: PASS
## Self-review / concerns
- Compose cannot make a dependency required only under one profile. The core dependency uses
`required: false` so the established `external` profile does not activate local infrastructure;
under `local-vector`, `compose up --wait` still fails if `vector-migrate` exits nonzero, and the
smoke verifies that successful migration precedes the healthy stack.
- Reader/writer passwords are injected into core environment variables because Compose service
attributes cannot be conditional by profile. Bootstrap and migrator credentials remain
file-backed secrets and are never exposed to core.
- The smoke intentionally refuses the operator project name `thothii` and removes only its unique
project namespace and volumes.
## Follow-up hardening — credential reconciliation and cleanup ownership
Review findings were resolved in a separate follow-up:
- Replaced fresh-volume-only initialization with `vector-reconcile`, an idempotent one-shot that
runs after database health and before `vector-migrate`. It authenticates with only the bootstrap
admin secret, safely creates missing identities, reconciles role attributes and passwords on
existing volumes, restores memberships/ownership, and leaves vector data untouched.
- The migrator is explicitly `NOSUPERUSER NOCREATEDB NOCREATEROLE`. Schema/database ownership is
sufficient for all packaged migrations because reconciliation creates the two group roles first.
- The live smoke rotates migrator, reader, and writer secrets on the same populated volume, rejects
the old reader credential, reruns migrations, recreates core with the new runtime credentials,
and retrieves the record written before rotation and again after database/core restart.
- Smoke project names are no longer caller-controlled. Each run creates a unique namespace and
ownership token. Containers, networks, and volumes carry the ownership label; preflight refuses
any collision and cleanup verifies every discovered resource before `down --volumes`.
- Added a dynamic fake-Docker contract suite for caller override, collision, and mismatched cleanup
labels, plus a real-Docker collision probe using a unique labeled volume.
Follow-up verification:
- `./scripts/local-vector-smoke.sh`: PASS, including live secret rotation and persisted retrieval
- `./scripts/test-local-vector-smoke-safety.sh`: PASS
- `./scripts/test-local-vector-smoke-live-collision.sh`: PASS
- harness: 477 passed, 5 deselected
- backend: 84 passed; TypeScript typecheck PASS
- frontend: 226 passed; TypeScript typecheck PASS
- Compose security, backend URL, config, shell syntax, and diff checks: PASS
Remaining operational constraint: the bootstrap admin secret must continue to match the PostgreSQL
bootstrap account stored in the volume. Runtime migrator/reader/writer rotation is supported without
data deletion; bootstrap-account password rotation is a distinct database-administration operation.
## Final hardening — bootstrap account rotation
The remaining operational constraint is now covered by
`scripts/vector-rotate-bootstrap-password.sh OLD_SECRET_FILE NEW_SECRET_FILE`:
- It does not rely on `POSTGRES_PASSWORD_FILE` after initialization.
- It pre-stages the deployment-file replacement in the same directory, authenticates to the live
database with the explicit old file, and changes only the authenticated bootstrap role.
- Passwords are passed as connection parameters and rendered with psycopg2 SQL composition, so
shell and SQL metacharacters are not interpolated.
- A second connection must authenticate with the new password before the command succeeds. If that
verification fails, the still-open old connection restores the old database password.
- Only after verified database login does an atomic rename replace the current deployment secret.
Wrong-old authentication and verification failures leave deployment configuration unchanged.
Final live smoke evidence on one existing `vector_data` volume:
- wrong-old bootstrap rotation rejected; current deployment secret unchanged
- bootstrap password with quote characters rotated successfully
- old bootstrap login rejected and new login accepted
- `vector-reconcile`, packaged migrations, and core health passed afterward
- the vector record written before rotation remained searchable after rotation and after a further
database/core restart
Final tests:
- `./scripts/test-vector-bootstrap-rotation.sh`: PASS
- `./scripts/local-vector-smoke.sh`: PASS with negative and positive live bootstrap rotation
- existing local-vector collision/safety and Compose deployment contracts: PASS
## Final identity and secret-policy alignment
- `THT_VECTOR_BOOTSTRAP_USER` is now passed through core as well as vector-db and reconciliation,
so the rotation helper uses the authoritative configured role instead of defaulting to `postgres`.
- Rotation and reconciliation source the same raw-file `secret-policy.sh`: non-empty and no
whitespace, including trailing newlines. Rotation validates both files before Docker,
PostgreSQL, or atomic replacement staging; `test-vector-secret-policy.sh` pins empty, newline,
internal-space, and valid metacharacter cases.
- Fake-Docker tests prove a non-default identity reaches the helper path and whitespace rejection
performs no Docker call and creates no staged replacement.
- The real smoke runs the entire stack as `thoth_bootstrap_smoke`. Its whitespace-negative case
leaves the deployment file unchanged and proves the existing database login still succeeds;
non-default-account bootstrap rotation, reconciliation, migration, core health, restart, and
persisted retrieval all pass.
@@ -0,0 +1,94 @@
# Local pgvector Task 4 report
## Outcome
Implemented adapter parity gates and an operator-safe custom-format backup/restore workflow.
- Direct and HTTP stores now share validation, configured-dimension rejection, and deterministic
similarity ordering with record ID as the tie-break.
- The parity fixture exercises identical records through real pgvector and the HTTP RPC contract:
kind filtering, ordering, hashes, replacement upserts, invalid collection/kind errors, and query
plus write dimensions.
- Backup explicitly allowlists the three vector tables and migration ledger, refuses overwrite,
writes through a partial file, and uses a custom compressed archive.
- Restore requires explicit active-source and target coordinates. It compares PostgreSQL system
identifier plus database OID (robust across DNS aliases), refuses the active database, checks for
an empty target unless force is explicit, and restores with exit-on-error.
- Passwords are accepted only through validated secret files, converted to private temporary
`PGPASSFILE`s, and never placed in command arguments or success/error logs.
- Role passwords/login identities are deliberately not dumped. The target must have the approved
passwordless group roles and pgvector extension reconciled before restore; archived ACLs restore
the reader/writer grants.
## TDD and semantic alignment
The first parity run exposed the intended HTTP differences: it accepted unknown collections and
wrong dimensions. Direct pgvector also had no stable order for equal cosine distance. The adapters
were aligned, and the final focused real-pgvector gate passed: **25 passed**.
The first recovery run caught an incorrect probe username before restore. The second caught an
intersection between `pg_dump --schema` and the explicit public ledger table. The third confirmed
the archive contents but caught missing target group roles. Each defect was corrected and the
complete drill was rerun from a fresh generated project.
## Live recovery smoke
`./scripts/local-vector-smoke.sh --backup-restore`: **PASS**.
- generated/owned source Compose project and source `vector_data`
- distinct restore container and distinct named restore volume
- migration and role health, secret rotation, restart persistence
- real custom backup, then deliberate mutation of the active source record
- same-database identity guard evaluated before restore
- restore into the separate target only
- restored hash equals the pre-mutation backup, proving retrieval parity
- migration ledger has all three applied versions
- all three restored embedding columns report `vectors.vector(768)`
- ownership-checked cleanup; the active operator project/volume is never addressed
## Verification
- parity + direct adapter: 25 passed
- full harness: 485 passed, 5 deselected
- changed Python files: Ruff clean
- shell syntax: clean
- `git diff --check`: clean
- full Ruff: unchanged repository baseline of 34 unrelated pre-existing test-file violations
## Self-review and operational constraints
The restore account must be able to read `pg_control_system()` for the robust cluster-identity
comparison and create/restore the selected objects. This is intentionally an administrative
recovery operation, not a runtime reader/writer action. `--force-nonempty` is explicit but still
uses `pg_restore --clean --if-exists`; operators should prefer a new database/volume and validate
migration status, health, and known retrieval before endpoint cutover.
## Post-review hardening
All five final review findings were addressed in a follow-up commit:
- Restore now requires a physically separate PostgreSQL cluster and refuses any equal
`system_identifier`, independent of database OID or hostname.
- `pg_restore` combines `--single-transaction` with `--exit-on-error`. The live drill creates an
existing vector sentinel, deliberately fails late during a forced restore, and proves the
original sentinel row/hash remains unchanged before performing the successful restore.
- Backup uses a mode-0600 `mktemp` in the output directory, atomically renames it, and cleans only
that owned path. A fake-command test pins symlink-clobber resistance and preserves an adversarial
legacy `.partial` symlink and its target.
- HTTP parity now traverses the real `VectorRestClient` transport boundary. It asserts RPC URL/key
and kinds payloads, legacy 404 fallback, response conversion, malformed metadata tolerance, and
canonical `VectorRestError` to `VectorStoreError` mapping.
- The restored target runs role/secret reconciliation and a real `PgVectorStore` with separate
reader/writer logins. Health, known-record search, writer upsert, hash probe, schema/table/column/
sequence authority, and 768-dimensional compatibility are therefore verified through the
production adapter. Reconciliation now restores group-role schema `USAGE`, which table-selected
archives cannot carry.
### Atomic no-replace backup publication
The final publication review is also closed. The private same-directory archive is published with
an atomic hard-link create rather than rename-overwrite semantics. If any process creates the final
file or symlink after preflight but before publication, `ln` fails with `EEXIST`, the backup exits
nonzero, the concurrent destination remains byte-for-byte intact, and the trap removes only the
randomly named temporary archive owned by this invocation. The fake `pg_dump` safety test creates
that destination immediately before returning and pins the failure and cleanup behavior.
+14
View File
@@ -0,0 +1,14 @@
# Portable deployment SDD progress
Plan: `docs/superpowers/plans/2026-07-11-adapter-foundations.md`
Branch: `codex/portable-deployment`
Worktree: `/Users/mp/projects/ThothII/.worktrees/portable-deployment`
Task 1: complete (commits e02e61e..a4eb6cc, review clean)
Task 1 final-review follow-up: public exports and frozen capability records now have explicit regressions.
Task 2: complete (commits a4eb6cc..f6302b3, review clean after authorized contract correction)
Task 3: complete (commits f6302b3..fe8d70d, review clean after authorized write-envelope correction)
Task 4: complete (commits fe8d70d..1e0911b, review clean)
Task 4 final-review follow-up: a real `tht` subprocess now proves exactly one legacy warning on stderr and pristine JSON stdout.
Task 5: complete (commits 1e0911b..dbbab6d, review clean after two fix waves)
Final adapter review fix wave: complete (`fix(adapter): close final foundation review`). HTTP vector reader/writer endpoints are independently optional; writer-only targeted memory/solved writes are supported. Vector health reports each side separately plus configured/observed embedding dimensions. `build_vector_loader` remains an explicitly tracked bulk-sync-only exception scheduled for the local pgvector migration plan; it is not used by interactive/targeted writes.
+30
View File
@@ -0,0 +1,30 @@
# Task 2 report — root Compose startup
Status: DONE
Implemented the root Compose defaults and the single bundle declaration:
- added `.env.example` with automatic Compose defaults (`COMPOSE_FILE=compose.yaml`, an empty
profile, and the relative `THT_SECRETS_FILE` path);
- removed the mandatory `external` profile from `core` and `frontend`;
- mounted `deploy/secrets/thothii.secrets` at `/run/secrets/thothii.secrets` and passed only the
mounted path into the core container;
- changed the production overlay to inherit that bundle instead of declaring per-secret mounts;
- removed the local overlay's legacy `env_file` dependency;
- added the versioned bundle template and `.gitignore` exception;
- updated deployment security checks and added `scripts/test-default-compose.sh`.
Focused verification:
```text
./scripts/test-default-compose.sh # default Compose contract passed.
./scripts/test-container-deployment.sh # container deployment security contract passed.
./scripts/test-preprocess-compose-config.sh # preprocess compose config: ok
docker compose --env-file .env.example config --quiet
(with a temporary mode-0600 bundle via THT_SECRETS_FILE)
git diff --check
```
The local-vector and preprocess service secret declarations remain for Task 3, which converts
those services to the same bundle helper. Documentation and smoke command migration is reserved
for Task 4.
+59
View File
@@ -0,0 +1,59 @@
# Task 3 report — one secret bundle for local services
## Status
Complete. Local pgvector bootstrap, reconciliation, migration, and preprocess services now
mount only `/run/secrets/thothii.secrets`. `deploy/vector/secret-policy.sh` validates the
whole bundle (allowlist, duplicate/empty/unknown keys, comments/blank lines, mode and symlink
policy) and returns only the requested value. The core entrypoint exposes DWH/vector/CA values
to the harness and materializes short-lived 0600 password files for workspace resolution.
## TDD evidence
- RED: `./scripts/test-preprocess-compose-config.sh` failed on the pre-existing
`vector_reader_password` Compose secret declaration.
- GREEN: the same command passes after the bundle conversion and verifies local-vector
workspace interpolation and shared secret mounts.
- `./scripts/test-vector-secret-policy.sh` covers comments/blank lines and rejects an
unrelated duplicate key.
## Verification
- `./scripts/test-vector-secret-policy.sh` — passed.
- `./scripts/test-preprocess-compose-config.sh` — passed.
- `./scripts/test-vector-backup-restore-safety.sh` — passed.
- `./scripts/test-default-compose.sh` — passed.
- `./scripts/test-container-deployment.sh` — passed.
- `./scripts/local-vector-smoke.sh` — passed with real Docker (bootstrap rotation, role
reconciliation, migration, persistence and restart).
- `./scripts/preprocess-smoke.sh` — passed with real Docker (unchanged rerun, mutation, DWH
job, ACTIVE publication and cleanup).
- `./scripts/preprocess-smoke.sh --cleanup-failure` — passed.
- `git diff --check` and `sh -n` gates — passed.
## Critical review fix
`buildPiChildEnv` now removes `THT_DWH_API_KEY`, `THT_VEC_API_KEY`, `THT_VEC_WRITE_API_KEY`,
`THT_SSL_CA`, `THT_CA`, and their file metadata before spawning Pi. A regression test proves
that neither secret values nor bundle/file metadata are inherited by the Pi child.
## Commits
- `70a19f2 feat(compose): use one secret bundle for local services`
- `d500563 fix(security): scrub deployment secrets from Pi child`
- `8518a73 fix(security): scrub raw deployment secret values`
## Concern
The rotation helper retains its old/new scratch-file CLI contract; smoke tests keep those files
outside Compose and mount only the bundle.
## Whole-branch review fixes
- `core-entrypoint.sh` validates `THT_SECRETS_FILE` fail-closed before optional lookups; malformed,
duplicate, unknown, oversized, or overlong bundles stop startup with sanitized diagnostics.
- Runtime password files are cleaned after child exit via signal forwarding and `wait`, rather
than being orphaned by `exec`.
- The shell loader accepts CRLF bundles (Windows/Notepad) consistently with the TypeScript loader.
- Optional key lookup distinguishes an absent key from an invalid value; present malformed
credentials now stop entrypoint startup instead of being silently ignored.
+55
View File
@@ -0,0 +1,55 @@
# Task 4 report — one-command Docker documentation
## Status
Implemented. The installation documentation now uses the canonical flow:
```sh
cp .env.example .env
cp deploy/secrets/thothii.secrets.example deploy/secrets/thothii.secrets
chmod 600 deploy/secrets/thothii.secrets
docker compose up --build -d
```
Updated:
- `README.md` with root `.env` defaults, one bundle, optional overlay presets, CA limitation,
preprocessing, and migration notes.
- `docs/installazione-docker-4-contesti.md` rewritten with exact files to create/edit and the
four requested contexts (co-located DB/vector, Mac, Windows, and remote DB/Evidence server).
- `docs/index.md` link text for the one-command installation.
- `deploy/secrets/README.md` bundle syntax, permissions, runtime mount verification, CA handling,
and migration guidance.
- `scripts/docker-smoke.sh` now creates a disposable mode-0600 bundle and exercises the default
Compose services without the legacy `external` profile.
- `scripts/test-default-compose.sh` asserts the exact installation command, tracked templates,
and absence of the legacy setup in the guide.
- `scripts/test-container-deployment.sh` now validates the bundle mount and rejects legacy
per-secret references; `.dockerignore` explicitly re-includes only the required vector policy
helper so the Docker build context remains safe.
- The Mac/Windows/local-vector and remote-server snippets now include required DWH/database and
Evidence-root settings. `deploy/env.example` is explicitly deprecated and no longer selects a
different Compose overlay.
The docs explicitly state that a PEM CA chain cannot be put in the strict single-line bundle. A
reviewed Compose override/secret-manager mount is required for `THT_SSL_CA`. Direct PostgreSQL
workspace examples are marked as advanced and require a separate reviewed runtime password mount;
the base bundle mount is the only default mount.
## Verification
- `sh -n scripts/docker-smoke.sh scripts/test-default-compose.sh` — passed.
- `./scripts/test-default-compose.sh` — passed.
- `./scripts/test-container-deployment.sh` — passed after migrating its local-vector assertions
to the single bundle and checking the `.dockerignore` deployment allowlist.
- `git diff --check` — passed.
- `./scripts/test-docker-smoke.sh` — passed after updating its static assertion to the default
no-profile invocation.
- `docker buildx build --file docker/core.Dockerfile --check .` — passed; BuildKit reported no
warnings after the `.dockerignore` parent-directory fix.
## Concerns
The legacy `scripts/vector-rotate-bootstrap-password.sh` maintenance helper still accepts
old/new standalone files. Its output is intentionally documented as a transitional interface;
the resulting value must be copied into the bundle before restarting local-vector services.
+206
View File
@@ -0,0 +1,206 @@
# ThothII
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.
## Docker Compose: one-command startup
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.
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
```
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).
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
```
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`.
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.
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.
Run the end-to-end packaging check with:
```sh
./scripts/docker-smoke.sh
```
The smoke script validates Compose, builds and waits for both services, checks health through
the frontend, verifies SSE response headers, restarts the core, and confirms `/data` survives.
Each run uses a unique Compose project and removes that project's containers, network, and test
volume afterward. It never targets the fixed `thothii` operator project or its volume. Set
`SMOKE_PROJECT` to a different explicit project name for reproducible debugging, and set
`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
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.
## 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`:
```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
```
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.
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
trust-boundary opt-in and uses path-style addressing; private and HTTP endpoints require additional
independent opt-ins. Literal non-global IPv4/IPv6 addresses are classified locally; hostnames are
not DNS-pinned, so trusted custom-endpoint deployments must enforce their destination with network
egress policy. Store access key, secret key, and session token as secret references in
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):
```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
```
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.
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:
```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
```
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.
## Production trust boundary and secrets
ThothII does not implement OIDC. Do not expose its application port directly to a network.
The production pattern is an authenticated host reverse proxy that:
- terminates TLS and authenticates every request;
- removes any client-supplied identity header;
- injects one trusted `X-Authenticated-User` value;
- proxies to the loopback-only ThothII frontend.
[`deploy/nginx-authenticated-proxy.conf.example`](deploy/nginx-authenticated-proxy.conf.example)
shows the contract using nginx `auth_request`; replace the placeholder authentication gateway
with the organization's reviewed identity proxy. `AUTH_MODE=upstream` trusts this boundary and
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
```
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
[`deploy/secrets/README.md`](deploy/secrets/README.md). A PEM CA chain is deliberately not a
bundle value: PEM contains whitespace and is rejected by the strict parser. Keep the CA chain in
the host/secret-manager materialization and add a reviewed Compose override that mounts it at
`/run/secrets/ca-chain.pem` and sets `THT_SSL_CA` when a private CA is required. The base bundle
does not create that mount. The frontend remains on loopback; the authenticated host proxy is the
only public listener.
Set the selected model provider in application settings (or `PI_PROVIDER`). For each Pi spawn the
backend validates and reads `THT_MODEL_API_KEY` from the bundle, then exposes its value only as the provider's
recognized child variable (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or
`ZAI_API_KEY`). Neither the generic file path nor deprecated `PI_PROVIDER_API_KEY` is inherited by
Pi. Local providers such as Ollama require no model key.
`THT_MODEL_API_KEY` supports Pi providers whose authentication is exactly one key:
`ant-ling`, `anthropic`, `cerebras`, `deepseek`, `fireworks`, `github-copilot`, `google`
(including the `gemini` alias), `google-vertex` when using its API-key mode, `groq`,
`huggingface`, `kimi-coding`, `minimax`, `minimax-cn`, `mistral`, `moonshotai`,
`moonshotai-cn`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, `together`,
`vercel-ai-gateway`, `xai`, the four `xiaomi*` providers, `zai`, and `zai-coding-cn`.
Compound providers are deliberately unsupported: `amazon-bedrock`, `azure-openai-responses`,
`cloudflare-workers-ai`, and `cloudflare-ai-gateway` require multiple credential/configuration
values. Selecting one fails before Pi starts; ambient AWS, Azure, and Cloudflare credentials are
still scrubbed. Supporting them requires a future dedicated provider-specific configuration.
## Reproducible image verification
Base images use exact tags and immutable multi-platform manifest digests. Dependency update and
residual OS-repository limitations are documented in [`docker/LOCKS.md`](docker/LOCKS.md).
Run the shared architecture gate with `PLATFORM=linux/amd64` or `PLATFORM=linux/arm64`:
```sh
PLATFORM=linux/arm64 ./scripts/verify-container-images.sh
```
It builds both images, runs common version/runtime/security smokes, and emits an image/package
inventory beneath `.artifacts/container-images/`. CI runs the same script for both architectures.
+7 -1
View File
@@ -34,6 +34,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
thtBin: config.thtBin,
harnessDir: config.harnessDir,
configPath: process.env.THT_CONFIG ?? "config/tht.yaml",
dataRoot: config.dataRoot,
});
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
const hub = new SseHub();
@@ -41,7 +42,12 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
const listModels = deps?.listModels ?? createPiModelLister(config);
const getSettings = deps?.getSettings ?? (() => effectiveSettings(config, loadSettings(config)));
app.addHook("preHandler", authPreHandler(config.authMode));
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") return;
return authenticate(req, reply);
});
app.get("/health", async () => ({ status: "ok" }));
sessionRoutes(app, {
mgr, tht: tht as ThtRunner, hub, getSettings,
+6 -3
View File
@@ -1,6 +1,6 @@
import type { FastifyRequest, FastifyReply } from "fastify";
export function authPreHandler(mode: "none" | "mock" | "oidc") {
export function authPreHandler(mode: "none" | "mock" | "upstream") {
return async (req: FastifyRequest, reply: FastifyReply) => {
if (mode === "none") {
(req as any).user = { id: "dev@local" };
@@ -9,8 +9,11 @@ export function authPreHandler(mode: "none" | "mock" | "oidc") {
id: (req.headers["x-mock-user"] as string) ?? "mock",
};
} else {
reply.code(501);
throw new Error("OIDC non configurato (MVP: usa none/mock)");
const id = req.headers["x-authenticated-user"];
if (typeof id !== "string" || id.trim() === "") {
return reply.code(401).send({ error: "authenticated upstream identity required" });
}
(req as any).user = { id };
}
};
}
+40 -2
View File
@@ -1,22 +1,60 @@
import path from "node:path";
export interface AppConfig {
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
authMode: "none" | "mock" | "oidc";
authMode: "none" | "mock" | "upstream";
defaults: { provider?: string; model?: string; thinking?: string };
maxPiProcesses: number;
settingsFile: string;
dataRoot?: string;
ollamaEnsureTimeoutMs: number;
secretsFile?: string;
secretFiles: Readonly<Record<string, string | undefined>>;
modelApiKeyFile?: string;
}
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`);
}
if (env.THOTH_PUBLIC_EXPOSURE === "true" && authMode !== "upstream") {
throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy");
}
const modelApiKeyFile = env.THT_MODEL_API_KEY_FILE;
if (modelApiKeyFile !== undefined && (
modelApiKeyFile.trim() !== modelApiKeyFile
|| modelApiKeyFile.length === 0
|| modelApiKeyFile.includes("\0")
|| !path.isAbsolute(modelApiKeyFile)
)) {
throw new Error("model credential configuration is invalid");
}
const secretsFile = env.THT_SECRETS_FILE;
if (secretsFile !== undefined && (
secretsFile.trim() !== secretsFile || secretsFile.length === 0 || secretsFile.includes("\0")
|| !path.isAbsolute(secretsFile)
)) throw new Error("secret bundle 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",
"THT_VEC_WRITE_API_KEY_SECRET_FILE", "THT_CA_SECRET_FILE", "THT_VECTOR_BOOTSTRAP_PASSWORD_SECRET_FILE",
"THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE", "THT_VECTOR_READER_PASSWORD_SECRET_FILE",
"THT_VECTOR_WRITER_PASSWORD_SECRET_FILE",
]) secretFiles[name] = env[name];
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: (env.AUTH_MODE as AppConfig["authMode"]) ?? "none",
authMode: authMode as AppConfig["authMode"],
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",
dataRoot: env.THT_DATA_ROOT,
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
secretsFile,
secretFiles,
modelApiKeyFile,
};
}
+137
View File
@@ -0,0 +1,137 @@
import {
closeSync, constants, fstatSync, lstatSync, openSync, readFileSync,
type Stats,
} from "node:fs";
/** 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",
] as const);
const ALLOWED = new Set<string>(SECRET_BUNDLE_KEYS);
const LEGACY_FILES: Readonly<Record<string, string>> = {
THT_MODEL_API_KEY: "THT_MODEL_API_KEY_SECRET_FILE",
THT_DWH_API_KEY: "THT_DWH_API_KEY_SECRET_FILE",
THT_VEC_API_KEY: "THT_VEC_API_KEY_SECRET_FILE",
THT_VEC_WRITE_API_KEY: "THT_VEC_WRITE_API_KEY_SECRET_FILE",
THT_CA: "THT_CA_SECRET_FILE",
THT_SSL_CA: "THT_CA_SECRET_FILE",
THT_VECTOR_BOOTSTRAP_PASSWORD: "THT_VECTOR_BOOTSTRAP_PASSWORD_SECRET_FILE",
THT_VECTOR_MIGRATOR_PASSWORD: "THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE",
THT_VECTOR_READER_PASSWORD: "THT_VECTOR_READER_PASSWORD_SECRET_FILE",
THT_VECTOR_WRITER_PASSWORD: "THT_VECTOR_WRITER_PASSWORD_SECRET_FILE",
};
const MAX_BUNDLE_BYTES = 64 * 1024;
const MAX_LINE_BYTES = 16 * 1024;
export interface SecretBundleConfig {
secretsFile?: string;
secretFiles?: Readonly<Record<string, string | undefined>>;
/** Accepted for callers that pass the raw process environment. */
THT_SECRETS_FILE?: string;
}
/** Injectable filesystem boundary used by the race-condition tests. */
export interface SecretBundleFsOps {
lstat(path: string): Stats;
open(path: string, flags: number): number;
fstat(fd: number): Stats;
read(fd: number): string;
close(fd: number): void;
}
const realFs: SecretBundleFsOps = {
lstat: lstatSync,
open: openSync,
fstat: fstatSync,
read: (fd) => readFileSync(fd, "utf8"),
close: closeSync,
};
function unavailable(): Error { return new Error("secret bundle is unavailable"); }
function secureStat(info: Stats, docker: boolean): boolean {
const mode = info.mode & 0o777;
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > MAX_BUNDLE_BYTES) return false;
if (docker) {
return (info.uid === 0 && mode === 0o444)
|| (info.uid === (process.getuid?.() ?? info.uid) && (mode === 0o400 || mode === 0o600));
}
return info.uid === (process.getuid?.() ?? info.uid) && (mode === 0o400 || mode === 0o600);
}
function readSecure(file: string, fs: SecretBundleFsOps): string {
let fd: number | undefined;
try {
if (!file || file.trim() !== file || file.includes("\0")) throw unavailable();
const docker = file.startsWith("/run/secrets/") && !file.slice("/run/secrets/".length).includes("/");
if (file.startsWith("/run/secrets/") && !docker) throw unavailable();
if (docker) {
const parent = fs.lstat("/run/secrets");
if (!parent.isDirectory() || parent.uid !== 0 || (parent.mode & 0o022) !== 0) throw unavailable();
}
const before = fs.lstat(file);
if (!secureStat(before, docker)) throw unavailable();
fd = fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
const opened = fs.fstat(fd);
if (!secureStat(opened, docker) || before.dev !== opened.dev || before.ino !== opened.ino) throw unavailable();
return fs.read(fd);
} catch {
throw unavailable();
} finally {
if (fd !== undefined) try { fs.close(fd); } catch { /* sanitized by design */ }
}
}
function parseBundle(text: string): ReadonlyMap<string, string> {
const values = new Map<string, string>();
const lines = text.split("\n");
for (const raw of lines) {
if (raw.length > MAX_LINE_BYTES) throw unavailable();
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
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)) {
throw unavailable();
}
values.set(key, value);
}
return values;
}
export function loadSecretBundle(file: string): ReadonlyMap<string, string> {
return loadSecretBundleWithFs(file, realFs);
}
/** Same loader with an injectable filesystem boundary; useful for TOCTOU tests. */
export function loadSecretBundleWithFs(file: string, fs: SecretBundleFsOps): ReadonlyMap<string, string> {
try { return parseBundle(readSecure(file, fs)); } catch { throw unavailable(); }
}
/** Resolve a value from the bundle, with the pre-bundle *_SECRET_FILE fallback. */
export function secretValue(config: SecretBundleConfig, key: string): string | undefined {
const bundlePath = config.secretsFile ?? config.THT_SECRETS_FILE;
if (bundlePath) {
const found = loadSecretBundle(bundlePath).get(key);
if (found !== undefined) return found;
}
const legacyName = LEGACY_FILES[key];
const legacyPath = legacyName
? config.secretFiles?.[legacyName] ?? (() => {
const raw = (config as unknown as Record<string, unknown>)[legacyName];
return typeof raw === "string" ? raw : undefined;
})()
: undefined;
if (!legacyPath) return undefined;
const value = readSecure(legacyPath, realFs);
if (!value || /\s/.test(value)) throw unavailable();
return value;
}
export function legacySecretEnvNames(): Readonly<Record<string, string>> { return LEGACY_FILES; }
+16 -12
View File
@@ -1,7 +1,8 @@
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { join } from "node:path";
import type { AppConfig } from "../config.js";
import { RpcClient } from "../rpc/rpc-client.js";
import { buildPiChildEnv } from "./provider-credentials.js";
import { secretValue } from "../config/secret-bundle.js";
export interface PiModel {
provider: string;
@@ -11,7 +12,11 @@ export interface PiModel {
}
interface Opts {
spawnFn?: () => ChildProcessWithoutNullStreams;
spawnFn?: (
command: string,
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
) => ChildProcessWithoutNullStreams;
ttlMs?: number;
nowMs?: () => number;
}
@@ -24,22 +29,21 @@ interface Opts {
export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise<PiModel[]> {
const ttlMs = opts.ttlMs ?? 60_000;
const now = opts.nowMs ?? (() => Date.now());
const spawnFn =
opts.spawnFn ??
(() => {
const harnessVenvBin = join(cfg.harnessDir, ".venv", "bin");
return nodeSpawn(cfg.piBin, ["--mode", "rpc"], {
cwd: cfg.harnessDir,
env: { ...process.env, PATH: `${harnessVenvBin}:${process.env.PATH ?? ""}` },
}) as ChildProcessWithoutNullStreams;
});
const spawnFn = opts.spawnFn ?? nodeSpawn;
let cache: { at: number; models: PiModel[] } | null = null;
return async function listModels(): Promise<PiModel[]> {
if (cache && now() - cache.at < ttlMs) return cache.models;
const child = spawnFn();
const env = buildPiChildEnv({
provider: cfg.defaults.provider,
credentialValue: secretValue(cfg, "THT_MODEL_API_KEY"),
credentialFile: cfg.modelApiKeyFile,
});
delete env.THT_DATA_ROOT;
if (cfg.dataRoot !== undefined) env.THT_DATA_ROOT = cfg.dataRoot;
const child = spawnFn(cfg.piBin, ["--mode", "rpc"], { cwd: cfg.harnessDir, env });
child.stderr.resume();
const rpc = new RpcClient(child);
try {
+45 -25
View File
@@ -1,9 +1,10 @@
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { join } from "node:path";
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 { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js";
import { secretValue } from "../config/secret-bundle.js";
export interface SessionRuntime {
rpc: RpcClient;
@@ -11,37 +12,56 @@ export interface SessionRuntime {
child: ChildProcessWithoutNullStreams;
}
/** Injected test double signature: produce a child process, no args needed. */
type SpawnFn = () => ChildProcessWithoutNullStreams;
/** Injectable child-process boundary; callbacks may ignore arguments in simpler tests. */
type SpawnFn = (
command: string,
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
) => ChildProcessWithoutNullStreams;
export class PiProcessManager {
private runtimes = new Map<string, SessionRuntime>();
private spawnFn: (sessionId: string, author: string) => ChildProcessWithoutNullStreams;
private spawnFn: (
sessionId: string, author: string, provider: string | undefined,
) => ChildProcessWithoutNullStreams;
constructor(private cfg: AppConfig, opts?: { spawnFn?: SpawnFn }) {
if (opts?.spawnFn) {
this.spawnFn = () => opts.spawnFn!();
this.spawnFn = (sessionId, author, provider) =>
this.spawnPi(opts.spawnFn!, sessionId, author, provider);
} else {
this.spawnFn = (sessionId: string, author: string) => {
const harnessVenvBin = join(cfg.harnessDir, ".venv", "bin");
const env: NodeJS.ProcessEnv = {
...process.env,
THT_SESSION: sessionId,
THT_AUTHOR: author,
PATH: `${harnessVenvBin}:${process.env.PATH ?? ""}`,
};
// pi 0.73 (the @mariozechner rebrand) removed the `--approve` flag: rpc mode is
// headless and runs tools without an approval gate, so passing it makes pi exit
// with "Unknown option: --approve". Args are intentionally just `--mode rpc`.
const child = nodeSpawn(cfg.piBin, ["--mode", "rpc"], {
cwd: cfg.harnessDir,
this.spawnFn = (sessionId, author, provider) =>
this.spawnPi(nodeSpawn, sessionId, author, provider);
}
}
private spawnPi(
spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined,
): ChildProcessWithoutNullStreams {
const env = buildPiChildEnv({
provider,
credentialValue: secretValue(this.cfg, "THT_MODEL_API_KEY"),
credentialFile: this.cfg.modelApiKeyFile,
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
});
// 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", "THT_SSL_CA",
] as const) {
if (process.env[name] !== undefined) env[name] = process.env[name];
}
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,
});
// Drain stderr so the child's stderr buffer never blocks the process.
child.stderr.resume();
child.stderr.resume?.();
return child;
};
}
}
count(): number { return this.runtimes.size; }
@@ -50,7 +70,7 @@ export class PiProcessManager {
async spawnFor(
sessionId: string,
o: { provider?: string; model?: string; thinking?: string; author?: string; mode?: "new" | "resume" },
o: { provider?: string; model?: string; thinking?: string; author?: string; question?: string; mode?: "new" | "resume" },
): Promise<SessionRuntime> {
// Idempotent per session id: tear down any existing runtime for this id
// first (before the cap check) so a resume/respawn neither leaks the old
@@ -64,7 +84,8 @@ export class PiProcessManager {
throw new Error("max Pi processes reached");
}
const author = o.author ?? "dev@local";
const child = this.spawnFn(sessionId, author);
const provider = canonicalPiProvider(o.provider ?? this.cfg.defaults.provider);
const child = this.spawnFn(sessionId, author, provider);
const rpc = new RpcClient(child);
const bridge = new SessionBridge(rpc);
const rt: SessionRuntime = { rpc, bridge, child };
@@ -85,7 +106,6 @@ export class PiProcessManager {
}
});
const provider = o.provider ?? this.cfg.defaults.provider;
const model = o.model ?? this.cfg.defaults.model;
const thinking = o.thinking ?? this.cfg.defaults.thinking;
@@ -98,7 +118,7 @@ export class PiProcessManager {
const message = o.mode === "resume"
? `/riprendi-sessione ${sessionId}`
: `/nuova-domanda "kickoff"`;
: `/nuova-domanda ${JSON.stringify(o.question ?? "")}`;
rpc.send({ type: "prompt", message });
return rt;
}
+162
View File
@@ -0,0 +1,162 @@
import {
closeSync, constants, fstatSync, lstatSync, openSync, readFileSync,
type Stats,
} from "node:fs";
/** Audited against @earendil-works/pi-ai 0.80.3 auth plus its locked AWS credential chain. */
export const PI_0803_CREDENTIAL_ENV_NAMES = Object.freeze([
"AI_GATEWAY_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANT_LING_API_KEY",
"AWS_ACCESS_KEY_ID", "AWS_BEARER_TOKEN_BEDROCK", "AWS_CONFIG_FILE",
"AWS_CONTAINER_AUTHORIZATION_TOKEN", "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
"AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_PROFILE",
"AWS_ROLE_ARN", "AWS_ROLE_SESSION_NAME", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN",
"AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AZURE_OPENAI_API_KEY",
"CEREBRAS_API_KEY", "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY",
"CLOUDFLARE_GATEWAY_ID", "COPILOT_GITHUB_TOKEN", "DEEPSEEK_API_KEY", "FIREWORKS_API_KEY",
"GCLOUD_PROJECT", "GEMINI_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_API_KEY",
"GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_PROJECT", "GROQ_API_KEY", "HF_TOKEN",
"KIMI_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "MISTRAL_API_KEY",
"MOONSHOT_API_KEY", "NVIDIA_API_KEY", "OPENCODE_API_KEY", "OPENAI_API_KEY",
"OPENROUTER_API_KEY", "TOGETHER_API_KEY", "XAI_API_KEY", "XIAOMI_API_KEY",
"XIAOMI_TOKEN_PLAN_AMS_API_KEY", "XIAOMI_TOKEN_PLAN_CN_API_KEY",
"XIAOMI_TOKEN_PLAN_SGP_API_KEY", "ZAI_API_KEY", "ZAI_CODING_CN_API_KEY",
]);
const PROVIDER_KEY_ENV: Readonly<Record<string, string>> = {
"ant-ling": "ANT_LING_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
cerebras: "CEREBRAS_API_KEY",
deepseek: "DEEPSEEK_API_KEY", fireworks: "FIREWORKS_API_KEY",
"github-copilot": "COPILOT_GITHUB_TOKEN", google: "GEMINI_API_KEY",
"google-vertex": "GOOGLE_CLOUD_API_KEY", groq: "GROQ_API_KEY", huggingface: "HF_TOKEN",
"kimi-coding": "KIMI_API_KEY", minimax: "MINIMAX_API_KEY", "minimax-cn": "MINIMAX_CN_API_KEY",
mistral: "MISTRAL_API_KEY", moonshotai: "MOONSHOT_API_KEY", "moonshotai-cn": "MOONSHOT_API_KEY",
nvidia: "NVIDIA_API_KEY", openai: "OPENAI_API_KEY", opencode: "OPENCODE_API_KEY",
"opencode-go": "OPENCODE_API_KEY", openrouter: "OPENROUTER_API_KEY", together: "TOGETHER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY", xai: "XAI_API_KEY", xiaomi: "XIAOMI_API_KEY",
"xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY",
"xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY",
"xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", zai: "ZAI_API_KEY",
"zai-coding-cn": "ZAI_CODING_CN_API_KEY",
};
const COMPOUND_PROVIDERS = new Set([
"amazon-bedrock", "azure-openai-responses", "cloudflare-ai-gateway", "cloudflare-workers-ai",
]);
const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio", "local", "aritmolab", "faux"]);
export function canonicalPiProvider(provider: string | undefined): string | undefined {
const value = provider?.trim().toLowerCase();
if (!value) return undefined;
if (value === "gemini") return "google";
return value;
}
export interface CredentialFsOps {
lstat(path: string): Stats;
open(path: string, flags: number): number;
fstat(fd: number): Stats;
read(fd: number): string;
close(fd: number): void;
}
const realFs: CredentialFsOps = {
lstat: lstatSync, open: openSync, fstat: fstatSync,
read: (fd) => readFileSync(fd, "utf8"), close: closeSync,
};
function validSecretStat(info: Stats, docker: boolean): boolean {
const mode = info.mode & 0o777;
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > 16_384) return false;
if (docker) return info.uid === 0 && mode === 0o444;
return info.uid === process.getuid?.() && (mode === 0o400 || mode === 0o600);
}
function readCredential(file: string, fs: CredentialFsOps): string {
let fd: number | undefined;
try {
const docker = file.startsWith("/run/secrets/") && !file.slice("/run/secrets/".length).includes("/");
if (file.startsWith("/run/secrets/") && !docker) throw new Error();
if (docker) {
const parent = fs.lstat("/run/secrets");
if (!parent.isDirectory() || parent.uid !== 0 || (parent.mode & 0o022) !== 0) throw new Error();
}
const before = fs.lstat(file);
if (!validSecretStat(before, docker)) throw new Error();
fd = fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
const opened = fs.fstat(fd);
if (!validSecretStat(opened, docker) || before.dev !== opened.dev || before.ino !== opened.ino) throw new Error();
const value = fs.read(fd);
if (!value || /\s/.test(value)) throw new Error();
return value;
} catch {
throw new Error("model provider credential is unavailable");
} finally {
if (fd !== undefined) {
try { fs.close(fd); } catch { /* sanitized by design */ }
}
}
}
export function buildPiChildEnv(opts: {
ambient?: NodeJS.ProcessEnv;
provider?: string;
credentialFile?: string;
additions?: NodeJS.ProcessEnv;
credentialValue?: string;
fsOps?: CredentialFsOps;
}): NodeJS.ProcessEnv {
const env = { ...(opts.ambient ?? process.env), ...opts.additions };
delete env.PI_PROVIDER_API_KEY;
delete env.THT_SECRETS_FILE;
delete env.THT_DWH_API_KEY;
delete env.THT_VEC_API_KEY;
delete env.THT_VEC_WRITE_API_KEY;
delete env.THT_MODEL_API_KEY;
delete env.THT_SSL_CA;
delete env.THT_CA;
delete env.THT_VECTOR_BOOTSTRAP_PASSWORD;
delete env.THT_VECTOR_MIGRATOR_PASSWORD;
delete env.THT_VECTOR_READER_PASSWORD;
delete env.THT_VECTOR_WRITER_PASSWORD;
delete env.THT_MODEL_API_KEY_FILE;
delete env.THT_DWH_API_KEY_SECRET_FILE;
delete env.THT_VEC_API_KEY_SECRET_FILE;
delete env.THT_VEC_WRITE_API_KEY_SECRET_FILE;
delete env.THT_CA_SECRET_FILE;
delete env.THT_VECTOR_BOOTSTRAP_PASSWORD_SECRET_FILE;
delete env.THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE;
delete env.THT_VECTOR_READER_PASSWORD_SECRET_FILE;
delete env.THT_VECTOR_WRITER_PASSWORD_SECRET_FILE;
delete env.THT_VECTOR_BOOTSTRAP_PASSWORD_FILE;
delete env.THT_VECTOR_MIGRATOR_PASSWORD_FILE;
delete env.THT_VECTOR_READER_PASSWORD_FILE;
delete env.THT_VECTOR_WRITER_PASSWORD_FILE;
delete env.THT_DWH_API_KEY_FILE;
delete env.THT_VEC_API_KEY_FILE;
delete env.THT_VEC_WRITE_API_KEY_FILE;
delete env.THT_SSL_CA_FILE;
for (const name of PI_0803_CREDENTIAL_ENV_NAMES) delete env[name];
const provider = canonicalPiProvider(opts.provider);
if (provider && COMPOUND_PROVIDERS.has(provider)) {
throw new Error(
"compound credential bundles are unsupported by THT_MODEL_API_KEY_FILE; "
+ "dedicated provider configuration is required",
);
}
if (provider && !LOCAL_PROVIDERS.has(provider)) {
const envName = PROVIDER_KEY_ENV[provider];
if (!envName || (!opts.credentialFile && opts.credentialValue === undefined)) {
throw new Error("model provider credential is unavailable");
}
if (opts.credentialValue !== undefined) {
if (!opts.credentialValue || /\s/.test(opts.credentialValue)) {
throw new Error("model provider credential is unavailable");
}
env[envName] = opts.credentialValue;
}
else if (opts.credentialFile) env[envName] = readCredential(opts.credentialFile, opts.fsOps ?? realFs);
else throw new Error("model provider credential is unavailable");
} else if (opts.credentialFile && !provider) {
throw new Error("model provider credential is unavailable");
}
return env;
}
+8 -4
View File
@@ -29,12 +29,13 @@ export function sessionRoutes(
model: s.model,
thinking: s.thinking,
author: getUser(req).id,
question: b.question,
});
rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e));
return { id };
});
app.get("/sessions", async () => d.tht.sessionList());
app.get("/sessions/:id", async (req) => d.tht.sessionShow((req.params as any).id));
app.get("/sessions", async () => d.tht.sessionList(d.getSettings().workspace));
app.get("/sessions/:id", async (req) => d.tht.sessionShow((req.params as any).id, d.getSettings().workspace));
app.post("/sessions/:id/response", async (req, reply) => {
const id = (req.params as any).id;
const rt = d.mgr.get(id);
@@ -50,7 +51,7 @@ export function sessionRoutes(
});
app.post("/sessions/:id/resume", async (req, reply) => {
const id = (req.params as any).id;
const manifest = (await d.tht.sessionShow(id)) as { status?: string; archived?: boolean } | null;
const manifest = (await d.tht.sessionShow(id, d.getSettings().workspace)) as { status?: string; archived?: boolean } | null;
if (manifest?.status === "finalized" || manifest?.archived) {
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
}
@@ -77,6 +78,9 @@ export function sessionRoutes(
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
});
// 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.
reply.raw.flushHeaders();
const send = (event: string, data: object) => reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null);
req.raw.on("close", off);
@@ -100,7 +104,7 @@ export function sessionRoutes(
app.delete("/sessions/:id", async (req, reply) => {
const id = (req.params as any).id;
d.mgr.teardown(id); // drop any live runtime before deleting on disk
await d.tht.deleteSession(id);
await d.tht.deleteSession(id, d.getSettings().workspace);
return reply.code(204).send();
});
app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id));
+13 -5
View File
@@ -6,6 +6,7 @@ export interface ThtConfig {
thtBin: string;
harnessDir: string;
configPath: string;
dataRoot?: string;
}
export interface SessionRow {
@@ -61,8 +62,12 @@ export class ThtRunner {
run(args: string[], workspace?: string): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.THT_DATA_ROOT;
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
const ch = spawn(this.cfg.thtBin, this.buildArgv(args, workspace), {
cwd: this.cfg.harnessDir,
env,
});
let stdout = "";
let stderr = "";
@@ -104,12 +109,12 @@ export class ThtRunner {
return this.json<{ id: string }>(a, o.workspace);
}
sessionList() {
return this.json<SessionRow[]>(["session", "list", "--json"]);
sessionList(workspace?: string) {
return this.json<SessionRow[]>(["session", "list", "--json"], workspace);
}
sessionShow(id: string) {
return this.json<unknown>(["session", "show", id, "--json"]);
sessionShow(id: string, workspace?: string) {
return this.json<unknown>(["session", "show", id, "--json"], workspace);
}
sqlPreview(id: string, p: { limit?: number; offset?: number }) {
@@ -136,7 +141,10 @@ export class ThtRunner {
setGroup(id: string, group: string) { return this.ok(["session", "set-group", id, "--group", group]); }
archive(id: string) { return this.ok(["session", "archive", id]); }
unarchive(id: string) { return this.ok(["session", "unarchive", id]); }
deleteSession(id: string) { return this.ok(["session", "delete", id]); }
async deleteSession(id: string, workspace?: string) {
const { code, stderr } = await this.run(["session", "delete", id], workspace);
if (code !== 0) throw new Error(`tht session delete exit ${code}: ${stderr.trim()}`);
}
documents(id: string) { return this.json<SessionDocument[]>(["session", "documents", id, "--json"]); }
async ollamaEnsure(workspace: string, timeoutSec: number): Promise<OllamaEnsureResult> {
+14
View File
@@ -22,3 +22,17 @@ test("mode mock legge l'header", async () => {
});
expect(res.json()).toEqual({ id: "alice" });
});
test("upstream mode requires the authenticated proxy identity header", async () => {
const app = Fastify();
app.addHook("preHandler", authPreHandler("upstream"));
app.get("/me", async (req) => getUser(req));
expect((await app.inject({ method: "GET", url: "/me" })).statusCode).toBe(401);
const authenticated = await app.inject({
method: "GET",
url: "/me",
headers: { "x-authenticated-user": "alice@example.test" },
});
expect(authenticated.json()).toEqual({ id: "alice@example.test" });
});
+51 -14
View File
@@ -1,20 +1,57 @@
import { test, expect } from "vitest";
import { expect, test } from "vitest";
import { loadConfig } from "../src/config.js";
test("loadConfig espone host con default dev-safe 127.0.0.1", () => {
expect(loadConfig({})).toMatchObject({ host: "127.0.0.1" });
});
test("loadConfig rispetta HOST esplicito (necessario in container)", () => {
expect(loadConfig({ HOST: "0.0.0.0" })).toMatchObject({ host: "0.0.0.0" });
});
test("loadConfig merge altri campi con l'host", () => {
expect(
loadConfig({ HOST: "0.0.0.0", THT_BIN: "/opt/venv/bin/tht", PORT: "9000" })
).toMatchObject({
test("loadConfig accepts container listening and runtime paths", () => {
expect(loadConfig({
HOST: "0.0.0.0",
PORT: "9000",
THT_HARNESS_DIR: "/app/harness",
THT_BIN: "/opt/venv/bin/tht",
PI_BIN: "/usr/local/bin/pi",
SETTINGS_FILE: "/data/settings/settings.json",
THT_DATA_ROOT: "/data",
})).toMatchObject({
host: "0.0.0.0",
thtBin: "/opt/venv/bin/tht",
port: 9000,
harnessDir: "/app/harness",
thtBin: "/opt/venv/bin/tht",
piBin: "/usr/local/bin/pi",
settingsFile: "/data/settings/settings.json",
dataRoot: "/data",
});
});
test("loadConfig keeps local development defaults", () => {
expect(loadConfig({})).toMatchObject({
host: "127.0.0.1",
port: 8787,
harnessDir: "../harness",
thtBin: "tht",
piBin: "pi",
settingsFile: "data/settings.json",
});
expect(loadConfig({}).dataRoot).toBeUndefined();
});
test("loadConfig rejects unauthenticated public exposure", () => {
expect(() => loadConfig({
THOTH_PUBLIC_EXPOSURE: "true",
AUTH_MODE: "none",
})).toThrow(/public exposure requires AUTH_MODE=upstream/);
});
test("loadConfig accepts an authenticated upstream trust boundary", () => {
expect(loadConfig({
THOTH_PUBLIC_EXPOSURE: "true",
AUTH_MODE: "upstream",
}).authMode).toBe("upstream");
});
test("loadConfig accepts only an absolute generic model key file", () => {
expect(loadConfig({ THT_MODEL_API_KEY_FILE: "/run/secrets/model_api_key" }).modelApiKeyFile)
.toBe("/run/secrets/model_api_key");
expect(() => loadConfig({ THT_MODEL_API_KEY_FILE: "relative/key" }))
.toThrow(/model credential configuration is invalid/);
expect(() => loadConfig({ THT_MODEL_API_KEY_FILE: " /run/secrets/key" }))
.toThrow(/model credential configuration is invalid/);
});
+36 -1
View File
@@ -2,9 +2,44 @@ import { test, expect } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
test("GET /health ritorna ok", async () => {
test("GET /health reports process readiness without external services", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toContain("application/json");
expect(res.json()).toEqual({ status: "ok" });
});
test("GET /health remains available to container probes in upstream auth mode", async () => {
const app = buildApp(loadConfig({
THT_HARNESS_DIR: "/tmp/h",
AUTH_MODE: "upstream",
THOTH_PUBLIC_EXPOSURE: "true",
}));
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok" });
});
test("SSE response headers are flushed before the first event", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
await app.listen({ port: 0, host: "127.0.0.1" });
const port = (app.server.address() as { port: number }).port;
const controller = new AbortController();
try {
const response = await Promise.race([
fetch(`http://127.0.0.1:${port}/sessions/header-probe/events`, {
signal: controller.signal,
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("SSE headers were not flushed")), 250),
),
]);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("cache-control")).toBe("no-cache");
} finally {
controller.abort();
await app.close();
}
});
+135 -1
View File
@@ -1,6 +1,6 @@
import { test, expect } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { chmodSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import path from "node:path";
@@ -51,3 +51,137 @@ test("createPiModelLister caches within ttl (spawns once for two calls)", async
rmSync(path.dirname(script), { recursive: true, force: true });
}
});
test("production model-list spawn preserves PATH and passes the portable data root", async () => {
const script = scriptWith([]);
const calls: any[][] = [];
const previousPath = process.env.PATH;
process.env.PATH = "/usr/local/bin:/usr/bin";
try {
const lister = createPiModelLister(loadConfig({
THT_HARNESS_DIR: "/app/harness",
PI_BIN: "/usr/local/bin/pi",
THT_DATA_ROOT: "/data",
}), {
spawnFn: (...args: any[]) => {
calls.push(args);
return spawn("node", [FAKE, script]) as any;
},
});
await lister();
expect(calls[0][0]).toBe("/usr/local/bin/pi");
expect(calls[0][1]).toEqual(["--mode", "rpc"]);
expect(calls[0][2]).toMatchObject({
cwd: "/app/harness",
env: expect.objectContaining({ PATH: "/usr/local/bin:/usr/bin", THT_DATA_ROOT: "/data" }),
});
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
rmSync(path.dirname(script), { recursive: true, force: true });
}
});
test("model-list spawn scrubs ambient provider credentials and generic secret metadata", async () => {
const script = scriptWith([]);
const calls: any[][] = [];
const previousDataRoot = process.env.THT_DATA_ROOT;
const previousCredential = process.env.PI_PROVIDER_API_KEY;
process.env.THT_DATA_ROOT = "/ambient-must-not-leak";
process.env.PI_PROVIDER_API_KEY = "must-not-leak";
process.env.OPENAI_API_KEY = "must-not-leak";
process.env.AWS_SECRET_ACCESS_KEY = "must-not-leak";
process.env.CLOUDFLARE_ACCOUNT_ID = "must-not-leak";
try {
const lister = createPiModelLister(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), {
spawnFn: (...args: any[]) => {
calls.push(args);
return spawn("node", [FAKE, script]) as any;
},
});
await lister();
expect(calls[0][2].env).not.toHaveProperty("THT_DATA_ROOT");
expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY");
expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY");
expect(calls[0][2].env).not.toHaveProperty("AWS_SECRET_ACCESS_KEY");
expect(calls[0][2].env).not.toHaveProperty("CLOUDFLARE_ACCOUNT_ID");
} finally {
if (previousDataRoot === undefined) delete process.env.THT_DATA_ROOT;
else process.env.THT_DATA_ROOT = previousDataRoot;
if (previousCredential === undefined) delete process.env.PI_PROVIDER_API_KEY;
else process.env.PI_PROVIDER_API_KEY = previousCredential;
delete process.env.OPENAI_API_KEY;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.CLOUDFLARE_ACCOUNT_ID;
rmSync(path.dirname(script), { recursive: true, force: true });
}
});
test("model-list spawn loads only the selected canonical provider credential", async () => {
const script = scriptWith([]);
const secret = join(path.dirname(script), "model-key");
writeFileSync(secret, "selected-secret", { mode: 0o600 });
chmodSync(secret, 0o600);
const calls: any[][] = [];
const lister = createPiModelLister(loadConfig({
PI_PROVIDER: "Gemini",
THT_MODEL_API_KEY_FILE: secret,
}), {
spawnFn: (...args: any[]) => {
calls.push(args);
return spawn("node", [FAKE, script]) as any;
},
});
try {
await lister();
expect(calls[0][2].env.GEMINI_API_KEY).toBe("selected-secret");
expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
expect(JSON.stringify(calls[0].slice(0, 2))).not.toContain("selected-secret");
} finally {
rmSync(path.dirname(script), { recursive: true, force: true });
}
});
test("model-list spawn uses the same single secret bundle as sessions", async () => {
const script = scriptWith([]);
const bundle = join(path.dirname(script), "bundle");
writeFileSync(bundle, "THT_MODEL_API_KEY=selected-bundle-secret\n", { mode: 0o600 });
const calls: any[][] = [];
const lister = createPiModelLister(loadConfig({
PI_PROVIDER: "openai", THT_SECRETS_FILE: bundle,
}), {
spawnFn: (...args: any[]) => {
calls.push(args);
return spawn("node", [FAKE, script]) as any;
},
});
try {
await lister();
expect(calls[0][2].env.OPENAI_API_KEY).toBe("selected-bundle-secret");
expect(calls[0][2].env).not.toHaveProperty("THT_SECRETS_FILE");
} finally {
rmSync(path.dirname(script), { recursive: true, force: true });
}
});
test.each(["amazon-bedrock", "azure-openai-responses", "cloudflare-workers-ai", "cloudflare-ai-gateway"])(
"model listing rejects compound provider %s before spawning Pi", async (provider) => {
const script = scriptWith([]);
const secret = join(path.dirname(script), "model-key");
writeFileSync(secret, "selected-secret", { mode: 0o600 });
let spawns = 0;
const lister = createPiModelLister(loadConfig({
PI_PROVIDER: provider, THT_MODEL_API_KEY_FILE: secret,
}), {
spawnFn: () => { spawns += 1; throw new Error("must not spawn"); },
});
try {
await expect(lister()).rejects.toThrow(
"compound credential bundles are unsupported by THT_MODEL_API_KEY_FILE; dedicated provider configuration is required",
);
expect(spawns).toBe(0);
} finally {
rmSync(path.dirname(script), { recursive: true, force: true });
}
},
);
+227 -1
View File
@@ -1,8 +1,9 @@
import { test, expect } from "vitest";
import { test, expect, vi } from "vitest";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { EventEmitter } from "node:events";
import { chmodSync, writeFileSync } from "node:fs";
import { PiProcessManager } from "../src/pi/pi-process-manager.js";
import { loadConfig } from "../src/config.js";
@@ -125,3 +126,228 @@ test("spawnFor default (new) mode sends /nuova-domanda", async () => {
expect(child._writes.join("")).toContain("/nuova-domanda");
mgr.teardown("sid-10");
});
test("spawnFor new mode forwards the real question instead of kickoff", async () => {
const cfg = loadConfig({});
const child = recordingChild();
const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any });
await mgr.spawnFor("sid-question", { question: "pazienti con cardioversione e ILR" });
const prompt = JSON.parse(child._writes.at(-1)!);
expect(prompt.message).toBe('/nuova-domanda "pazienti con cardioversione e ILR"');
expect(prompt.message).not.toContain("kickoff");
mgr.teardown("sid-question");
});
test("production spawn uses explicit Pi path and passes portable data root without rewriting PATH", async () => {
vi.stubEnv("PATH", "/usr/local/bin:/usr/bin");
vi.stubEnv("PI_PROVIDER_API_KEY", "provider-secret");
vi.stubEnv("NODE_EXTRA_CA_CERTS", "/certs/company-ca.pem");
const calls: any[][] = [];
const child = recordingChild();
child.stderr.resume = () => {};
const spawnFn = (...args: any[]) => { calls.push(args); return child as any; };
const cfg = loadConfig({
THT_HARNESS_DIR: "/app/harness",
PI_BIN: "/usr/local/bin/pi",
THT_DATA_ROOT: "/data",
});
const mgr = new PiProcessManager(cfg, { spawnFn });
try {
await mgr.spawnFor("portable-session", { author: "user@example.test" });
const [bin, args, options] = calls[0];
expect(bin).toBe("/usr/local/bin/pi");
expect(args).toEqual(["--mode", "rpc"]);
expect(options.cwd).toBe("/app/harness");
expect(options.env).toMatchObject({
PATH: "/usr/local/bin:/usr/bin",
NODE_EXTRA_CA_CERTS: "/certs/company-ca.pem",
THT_DATA_ROOT: "/data",
THT_SESSION: "portable-session",
THT_AUTHOR: "user@example.test",
});
expect(options.env).not.toHaveProperty("PI_PROVIDER_API_KEY");
} finally {
mgr.teardown("portable-session");
vi.unstubAllEnvs();
}
});
test("session Pi spawn omits ambient THT_DATA_ROOT when config does not provide one", async () => {
vi.stubEnv("THT_DATA_ROOT", "/ambient-must-not-leak");
vi.stubEnv("PI_PROVIDER_API_KEY", "still-inherited");
const calls: any[][] = [];
const child = recordingChild();
child.stderr.resume = () => {};
const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), {
spawnFn: (...args: any[]) => { calls.push(args); return child as any; },
});
try {
await mgr.spawnFor("no-data-root", {});
expect(calls[0][2].env).not.toHaveProperty("THT_DATA_ROOT");
expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY");
} finally {
mgr.teardown("no-data-root");
vi.unstubAllEnvs();
}
});
test.each([
["anthropic", "ANTHROPIC_API_KEY"],
["OpenAI", "OPENAI_API_KEY"],
["gemini", "GEMINI_API_KEY"],
["google", "GEMINI_API_KEY"],
["deepseek", "DEEPSEEK_API_KEY"],
["zai", "ZAI_API_KEY"],
["openrouter", "OPENROUTER_API_KEY"],
])("injects the generic file credential only as %s provider env", async (provider, expectedName) => {
const secret = path.resolve(__dirname, `.model-key-${process.pid}-${provider}`);
writeFileSync(secret, "provider-secret", { mode: 0o600 });
const calls: any[][] = [];
const child = recordingChild();
child.stderr.resume = () => {};
const mgr = new PiProcessManager(loadConfig({
PI_BIN: "/usr/local/bin/pi", THT_MODEL_API_KEY_FILE: secret,
}), { spawnFn: (...args: any[]) => { calls.push(args); return child as any; } });
try {
await mgr.spawnFor("credential-session", { provider });
const env = calls[0][2].env;
expect(env[expectedName]).toBe("provider-secret");
expect(env).not.toHaveProperty("PI_PROVIDER_API_KEY");
expect(env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
expect(JSON.stringify(calls[0].slice(0, 2))).not.toContain("provider-secret");
} finally {
mgr.teardown("credential-session");
await import("node:fs/promises").then((fs) => fs.unlink(secret));
}
});
test("session Pi spawn reads the single secret bundle and scrubs its path", async () => {
const secret = path.resolve(__dirname, `.bundle-${process.pid}`);
writeFileSync(secret, "THT_MODEL_API_KEY=bundle-secret\n", { mode: 0o600 });
chmodSync(secret, 0o600);
const calls: any[][] = [];
const child = recordingChild();
child.stderr.resume = () => {};
const mgr = new PiProcessManager(loadConfig({
PI_BIN: "/usr/local/bin/pi", THT_SECRETS_FILE: secret,
}), { spawnFn: (...args: any[]) => { calls.push(args); return child as any; } });
try {
await mgr.spawnFor("bundle-session", { provider: "openai" });
expect(calls[0][2].env.OPENAI_API_KEY).toBe("bundle-secret");
expect(calls[0][2].env).not.toHaveProperty("THT_SECRETS_FILE");
} finally {
mgr.teardown("bundle-session");
await import("node:fs/promises").then((fs) => fs.unlink(secret));
}
});
test.each([["OpenAI", "openai"], ["gemini", "google"]])(
"set_model uses canonical packaged provider ID for %s", async (provider, canonical) => {
const secret = path.resolve(__dirname, `.canonical-key-${process.pid}-${provider}`);
writeFileSync(secret, "provider-secret", { mode: 0o600 });
const child = recordingChild();
child.stderr.resume = () => {};
child.stdin.write = (data: unknown) => {
const request = JSON.parse(String(data));
child._writes.push(String(data));
if (request.id) {
queueMicrotask(() => child.stdout.emit("data", `${JSON.stringify({
type: "response", id: request.id, success: true,
})}\n`));
}
return true;
};
const mgr = new PiProcessManager(loadConfig({ THT_MODEL_API_KEY_FILE: secret }), {
spawnFn: () => child as any,
});
try {
await mgr.spawnFor("canonical-provider", { provider, model: "model-id" });
expect(child._writes.join("")).toContain(`\"provider\":\"${canonical}\"`);
} finally {
mgr.teardown("canonical-provider");
await import("node:fs/promises").then((fs) => fs.unlink(secret));
}
},
);
test("local providers spawn without a model key and scrub ambient generic credentials", async () => {
vi.stubEnv("PI_PROVIDER_API_KEY", "ambient-secret");
vi.stubEnv("THT_MODEL_API_KEY_FILE", "/ambient/secret-path");
vi.stubEnv("OPENAI_API_KEY", "unselected-provider-secret");
const calls: any[][] = [];
const child = recordingChild();
child.stderr.resume = () => {};
const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), {
spawnFn: (...args: any[]) => { calls.push(args); return child as any; },
});
try {
await mgr.spawnFor("local-session", { provider: "ollama" });
expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY");
expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY");
} finally {
mgr.teardown("local-session");
vi.unstubAllEnvs();
}
});
test.each(["amazon-bedrock", "azure-openai-responses", "cloudflare-workers-ai", "cloudflare-ai-gateway"])(
"session spawn rejects compound provider %s before spawning Pi", async (provider) => {
const secret = path.resolve(__dirname, `.compound-key-${process.pid}-${provider}`);
writeFileSync(secret, "provider-secret", { mode: 0o600 });
let spawns = 0;
const mgr = new PiProcessManager(loadConfig({ THT_MODEL_API_KEY_FILE: secret }), {
spawnFn: () => { spawns += 1; throw new Error("must not spawn"); },
});
try {
await expect(mgr.spawnFor("compound-provider", { provider })).rejects.toThrow(
"compound credential bundles are unsupported by THT_MODEL_API_KEY_FILE; dedicated provider configuration is required",
);
expect(spawns).toBe(0);
} finally {
await import("node:fs/promises").then((fs) => fs.unlink(secret));
}
},
);
test.each(["missing", "permissive", "unreadable", "directory", "symlink", "unsupported"])(
"hosted provider credential failure is sanitized: %s", async (kind) => {
const target = path.resolve(__dirname, `.bad-model-key-${process.pid}-${kind}`);
if (kind === "permissive") {
writeFileSync(target, "DO_NOT_LEAK", { mode: 0o644 });
chmodSync(target, 0o644);
} else if (kind === "unreadable") {
writeFileSync(target, "DO_NOT_LEAK", { mode: 0o000 });
} else if (kind === "directory") {
await import("node:fs/promises").then((fs) => fs.mkdir(target));
} else if (kind === "symlink") {
const source = `${target}-source`;
writeFileSync(source, "DO_NOT_LEAK", { mode: 0o600 });
await import("node:fs/promises").then((fs) => fs.symlink(source, target));
}
const cfg = loadConfig({ THT_MODEL_API_KEY_FILE: target });
const mgr = new PiProcessManager(cfg, { spawnFn: () => {
throw new Error("spawn must not occur");
} });
try {
const provider = kind === "unsupported" ? "unknown-hosted" : "anthropic";
await expect(mgr.spawnFor("bad-secret", { provider }))
.rejects.toThrow("model provider credential is unavailable");
} finally {
if (kind === "permissive") await import("node:fs/promises").then((fs) => fs.unlink(target));
if (kind === "unreadable") {
chmodSync(target, 0o600);
await import("node:fs/promises").then((fs) => fs.unlink(target));
}
if (kind === "directory") await import("node:fs/promises").then((fs) => fs.rmdir(target));
if (kind === "symlink") {
await import("node:fs/promises").then(async (fs) => {
await fs.unlink(target);
await fs.unlink(`${target}-source`);
});
}
}
},
);
+148
View File
@@ -0,0 +1,148 @@
import { expect, test } from "vitest";
import { readFileSync } from "node:fs";
import path from "node:path";
import {
PI_0803_CREDENTIAL_ENV_NAMES,
buildPiChildEnv,
canonicalPiProvider,
} from "../src/pi/provider-credentials.js";
test("canonical provider aliases resolve to packaged Pi 0.80.3 IDs", () => {
expect(canonicalPiProvider(" OpenAI ")).toBe("openai");
expect(canonicalPiProvider("gemini")).toBe("google");
expect(canonicalPiProvider("Google")).toBe("google");
expect(() => buildPiChildEnv({
ambient: {}, provider: "cohere", credentialFile: "/unused",
})).toThrow("model provider credential is unavailable");
});
test("credential scrub list matches the audited Pi AI 0.80.3 provider definitions", () => {
const lock = JSON.parse(readFileSync(path.resolve("../docker/pi-runtime/package-lock.json"), "utf8"));
expect(lock.packages["node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai"].version)
.toBe("0.80.3");
expect(PI_0803_CREDENTIAL_ENV_NAMES).toEqual([
"AI_GATEWAY_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANT_LING_API_KEY",
"AWS_ACCESS_KEY_ID", "AWS_BEARER_TOKEN_BEDROCK", "AWS_CONFIG_FILE",
"AWS_CONTAINER_AUTHORIZATION_TOKEN", "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
"AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_PROFILE",
"AWS_ROLE_ARN", "AWS_ROLE_SESSION_NAME", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN",
"AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AZURE_OPENAI_API_KEY",
"CEREBRAS_API_KEY", "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY",
"CLOUDFLARE_GATEWAY_ID", "COPILOT_GITHUB_TOKEN", "DEEPSEEK_API_KEY", "FIREWORKS_API_KEY",
"GCLOUD_PROJECT", "GEMINI_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_API_KEY",
"GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_PROJECT", "GROQ_API_KEY", "HF_TOKEN",
"KIMI_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "MISTRAL_API_KEY",
"MOONSHOT_API_KEY", "NVIDIA_API_KEY", "OPENCODE_API_KEY", "OPENAI_API_KEY",
"OPENROUTER_API_KEY", "TOGETHER_API_KEY", "XAI_API_KEY", "XIAOMI_API_KEY",
"XIAOMI_TOKEN_PLAN_AMS_API_KEY", "XIAOMI_TOKEN_PLAN_CN_API_KEY",
"XIAOMI_TOKEN_PLAN_SGP_API_KEY", "ZAI_API_KEY", "ZAI_CODING_CN_API_KEY",
]);
expect(PI_0803_CREDENTIAL_ENV_NAMES).not.toContain("COHERE_API_KEY");
});
test("credential file replacement between lstat and open is rejected before read", () => {
let reads = 0;
const stat = (ino: number) => ({
dev: 7, ino, uid: process.getuid?.() ?? 0, mode: 0o100600, nlink: 1, size: 6,
isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false,
});
expect(() => buildPiChildEnv({
ambient: {}, provider: "openai", credentialFile: "/safe/key",
fsOps: {
lstat: () => stat(1) as any,
open: () => 9,
fstat: () => stat(2) as any,
read: () => { reads += 1; return "secret"; },
close: () => undefined,
},
})).toThrow("model provider credential is unavailable");
expect(reads).toBe(0);
});
test("Docker secrets require a secure root-owned /run/secrets parent and 0444 file", () => {
const stat = (kind: "parent" | "file") => ({
dev: 7, ino: 1, uid: kind === "parent" ? 1000 : 0,
mode: kind === "parent" ? 0o40755 : 0o100444, nlink: 1, size: 6,
isFile: () => kind === "file", isDirectory: () => kind === "parent",
isSymbolicLink: () => false,
});
expect(() => buildPiChildEnv({
ambient: {}, provider: "openai", credentialFile: "/run/secrets/model-key",
fsOps: {
lstat: (file) => stat(file === "/run/secrets" ? "parent" : "file") as any,
open: () => 9, fstat: () => stat("file") as any, read: () => "secret", close: () => undefined,
},
})).toThrow("model provider credential is unavailable");
});
test.each(["amazon-bedrock", "azure-openai-responses", "cloudflare-workers-ai", "cloudflare-ai-gateway"])(
"compound provider %s fails closed before opening the generic secret", (provider) => {
let opens = 0;
expect(() => buildPiChildEnv({
ambient: {}, provider, credentialFile: "/unused",
fsOps: {
lstat: () => { throw new Error("must not inspect file"); },
open: () => { opens += 1; return 9; },
fstat: () => { throw new Error("must not inspect file"); },
read: () => "secret", close: () => undefined,
},
})).toThrow(
"compound credential bundles are unsupported by THT_MODEL_API_KEY_FILE; dedicated provider configuration is required",
);
expect(opens).toBe(0);
},
);
test("single-key providers scrub ambient compound companions before injecting their key", () => {
const stat = {
dev: 7, ino: 1, uid: process.getuid?.() ?? 0, mode: 0o100600, nlink: 1, size: 6,
isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false,
};
const env = buildPiChildEnv({
ambient: {
AWS_ACCESS_KEY_ID: "ambient", AWS_SECRET_ACCESS_KEY: "ambient",
CLOUDFLARE_ACCOUNT_ID: "ambient", CLOUDFLARE_GATEWAY_ID: "ambient",
},
provider: "openai", credentialFile: "/safe/key",
fsOps: {
lstat: () => stat as any, open: () => 9, fstat: () => stat as any,
read: () => "secret", close: () => undefined,
},
});
expect(env.OPENAI_API_KEY).toBe("secret");
expect(env).not.toHaveProperty("AWS_ACCESS_KEY_ID");
expect(env).not.toHaveProperty("AWS_SECRET_ACCESS_KEY");
expect(env).not.toHaveProperty("CLOUDFLARE_ACCOUNT_ID");
expect(env).not.toHaveProperty("CLOUDFLARE_GATEWAY_ID");
});
test("bundle value is injected without exposing bundle metadata to Pi", () => {
const env = buildPiChildEnv({
ambient: {
THT_SECRETS_FILE: "/run/secrets/thothii.secrets", THT_MODEL_API_KEY_FILE: "/run/secrets/model",
THT_DWH_API_KEY: "dwh-secret", THT_VEC_API_KEY: "vec-reader-secret",
THT_VEC_WRITE_API_KEY: "vec-writer-secret", THT_SSL_CA: "/run/secrets/ca.pem",
THT_CA: "/run/secrets/ca.pem", THT_DWH_API_KEY_SECRET_FILE: "/run/secrets/dwh",
THT_MODEL_API_KEY: "model-secret", THT_VECTOR_READER_PASSWORD: "reader-secret",
THT_VECTOR_READER_PASSWORD_FILE: "/tmp/thothii-secrets/reader",
THT_VECTOR_WRITER_PASSWORD_FILE: "/tmp/thothii-secrets/writer",
THT_DWH_API_KEY_FILE: "/run/secrets/dwh", THT_VEC_API_KEY_FILE: "/run/secrets/vec",
},
provider: "openai", credentialValue: "bundle-secret",
});
expect(env.OPENAI_API_KEY).toBe("bundle-secret");
expect(env).not.toHaveProperty("THT_SECRETS_FILE");
expect(env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
expect(env).not.toHaveProperty("THT_DWH_API_KEY");
expect(env).not.toHaveProperty("THT_VEC_API_KEY");
expect(env).not.toHaveProperty("THT_VEC_WRITE_API_KEY");
expect(env).not.toHaveProperty("THT_MODEL_API_KEY");
expect(env).not.toHaveProperty("THT_SSL_CA");
expect(env).not.toHaveProperty("THT_CA");
expect(env).not.toHaveProperty("THT_VECTOR_READER_PASSWORD");
expect(env).not.toHaveProperty("THT_DWH_API_KEY_SECRET_FILE");
expect(env).not.toHaveProperty("THT_VECTOR_READER_PASSWORD_FILE");
expect(env).not.toHaveProperty("THT_VECTOR_WRITER_PASSWORD_FILE");
expect(env).not.toHaveProperty("THT_DWH_API_KEY_FILE");
expect(env).not.toHaveProperty("THT_VEC_API_KEY_FILE");
});
+9 -1
View File
@@ -1,6 +1,8 @@
import { test, expect } from "vitest";
import { spawn as nodeSpawn } from "node:child_process";
import path from "node:path";
import os from "node:os";
import { chmodSync, unlinkSync, writeFileSync } from "node:fs";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
@@ -16,9 +18,14 @@ function mutApp(thtRunner: any) {
}
test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => {
const modelKey = path.join(os.tmpdir(), `thoth-model-key-${process.pid}`);
writeFileSync(modelKey, "test-model-key", { mode: 0o600 });
chmodSync(modelKey, 0o600);
let sessionNewArg: any;
let spawnArg: any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
const app = buildApp(loadConfig({
THT_HARNESS_DIR: "../harness", THT_MODEL_API_KEY_FILE: modelKey,
}), {
thtRunner: {
ollamaEnsure: async () => ({ ok: true }),
sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; },
@@ -36,6 +43,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
expect(sessionNewArg.question).toBe("q");
const list = await app.inject({ method: "GET", url: "/sessions" });
expect(list.json()).toEqual([{ id: "s1" }]);
unlinkSync(modelKey);
});
test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, expect, test } from "vitest";
import { chmodSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadSecretBundle, loadSecretBundleWithFs, secretValue } from "../src/config/secret-bundle.js";
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); });
function bundle(contents: string, mode = 0o600): string {
const dir = mkdtempSync(join(tmpdir(), "thothii-secret-bundle-"));
dirs.push(dir);
const file = join(dir, "bundle");
writeFileSync(file, contents, { mode });
chmodSync(file, mode);
return file;
}
test("parses comments, blank lines and values containing equals", () => {
const file = bundle("# comment\n\nTHT_MODEL_API_KEY=abc=123\nTHT_DWH_API_KEY=dwh\n");
expect(loadSecretBundle(file)).toEqual(new Map([
["THT_MODEL_API_KEY", "abc=123"], ["THT_DWH_API_KEY", "dwh"],
]));
});
test.each([
["duplicate", "THT_MODEL_API_KEY=a\nTHT_MODEL_API_KEY=b\n"],
["unknown", "UNKNOWN_KEY=x\n"],
["empty", "THT_MODEL_API_KEY=\n"],
["syntax", "THT_MODEL_API_KEY\n"],
])("rejects %s bundle lines without exposing values", (_name, contents) => {
expect(() => loadSecretBundle(bundle(contents))).toThrow("secret bundle is unavailable");
expect(() => loadSecretBundle(bundle(contents))).not.toThrow(/abc|dwh/);
});
test("rejects missing and insecure files", () => {
const file = bundle("THT_MODEL_API_KEY=secret\n", 0o644);
expect(() => loadSecretBundle(file)).toThrow("secret bundle is unavailable");
expect(() => loadSecretBundle(join(file, "missing"))).toThrow("secret bundle is unavailable");
});
test("checks inode identity before parsing", () => {
const file = bundle("THT_MODEL_API_KEY=secret\n");
const replacement = `${file}.replacement`;
writeFileSync(replacement, "THT_MODEL_API_KEY=replaced\n", { mode: 0o600 });
// A real replacement is safe because the loader's open/fstat check is the invariant;
// this also ensures the normal post-replacement file remains parseable.
renameSync(replacement, file);
expect(loadSecretBundle(file).get("THT_MODEL_API_KEY")).toBe("replaced");
});
test("rejects inode replacement between lstat and open without reading", () => {
let reads = 0;
const stat = (ino: number) => ({
dev: 7, ino, uid: process.getuid?.() ?? 0, mode: 0o100600, nlink: 1, size: 24,
isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false,
});
expect(() => loadSecretBundleWithFs("/safe/bundle", {
lstat: () => stat(1) as any,
open: () => 9,
fstat: () => stat(2) as any,
read: () => { reads += 1; return "THT_MODEL_API_KEY=secret\n"; },
close: () => undefined,
})).toThrow("secret bundle is unavailable");
expect(reads).toBe(0);
});
test("secretValue prefers bundle and supports the legacy file fallback", () => {
const file = bundle("THT_MODEL_API_KEY=from-bundle\n");
const legacy = bundle("from-legacy");
expect(secretValue({ secretsFile: file, secretFiles: { THT_MODEL_API_KEY_SECRET_FILE: legacy } }, "THT_MODEL_API_KEY"))
.toBe("from-bundle");
expect(secretValue({ secretFiles: { THT_MODEL_API_KEY_SECRET_FILE: legacy } }, "THT_MODEL_API_KEY"))
.toBe("from-legacy");
});
+48
View File
@@ -24,6 +24,54 @@ test("sessionNew parses id from JSON", async () => {
expect(await r.sessionNew({ question: "q" })).toEqual({ id: "2026-06-27-100000-x" });
});
test("run passes configured THT_DATA_ROOT and preserves the remaining environment", async () => {
const previousDataRoot = process.env.THT_DATA_ROOT;
const previousCa = process.env.NODE_EXTRA_CA_CERTS;
process.env.THT_DATA_ROOT = "/ambient";
process.env.NODE_EXTRA_CA_CERTS = "/certs/company-ca.pem";
try {
(spawn as any).mockClear();
const r = new ThtRunner({
thtBin: "/opt/venv/bin/tht",
harnessDir: "/app/harness",
configPath: "config/tht.yaml",
dataRoot: "/configured",
});
await r.run(["session", "list", "--json"]);
const [bin, , options] = (spawn as any).mock.calls[0];
expect(bin).toBe("/opt/venv/bin/tht");
expect(options.env).toMatchObject({
THT_DATA_ROOT: "/configured",
NODE_EXTRA_CA_CERTS: "/certs/company-ca.pem",
});
} finally {
if (previousDataRoot === undefined) delete process.env.THT_DATA_ROOT;
else process.env.THT_DATA_ROOT = previousDataRoot;
if (previousCa === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
else process.env.NODE_EXTRA_CA_CERTS = previousCa;
}
});
test("run omits ambient THT_DATA_ROOT when config does not provide one", async () => {
const previousDataRoot = process.env.THT_DATA_ROOT;
const previousCredential = process.env.PI_PROVIDER_API_KEY;
process.env.THT_DATA_ROOT = "/ambient-must-not-leak";
process.env.PI_PROVIDER_API_KEY = "still-inherited";
try {
(spawn as any).mockClear();
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
await r.run(["session", "list", "--json"]);
const options = (spawn as any).mock.calls[0][2];
expect(options.env).not.toHaveProperty("THT_DATA_ROOT");
expect(options.env.PI_PROVIDER_API_KEY).toBe("still-inherited");
} finally {
if (previousDataRoot === undefined) delete process.env.THT_DATA_ROOT;
else process.env.THT_DATA_ROOT = previousDataRoot;
if (previousCredential === undefined) delete process.env.PI_PROVIDER_API_KEY;
else process.env.PI_PROVIDER_API_KEY = previousCredential;
}
});
test("run with exit != 0 propagates error with stderr", async () => {
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: boom" });
+90
View File
@@ -0,0 +1,90 @@
services:
core:
profiles: [local-vector]
environment:
THT_VECTOR_DATABASE: "${THT_VECTOR_DATABASE:-thoth}"
THT_VECTOR_BOOTSTRAP_USER: "${THT_VECTOR_BOOTSTRAP_USER:-postgres}"
THT_VECTOR_READER_USER: "${THT_VECTOR_READER_USER:-thoth_vector_reader}"
THT_VECTOR_WRITER_USER: "${THT_VECTOR_WRITER_USER:-thoth_vector_writer}"
THT_SECRETS_FILE: /run/secrets/thothii.secrets
secrets: [{source: thothii_secrets, target: thothii.secrets}]
depends_on:
vector-migrate:
condition: service_completed_successfully
frontend:
profiles: [local-vector]
vector-db:
image: pgvector/pgvector:0.8.5-pg16@sha256:1d533553fefe4f12e5d80c7b80622ba0c382abb5758856f52983d8789179f0fb
profiles: [local-vector]
labels: {io.thothii.smoke-owner: "${THOTH_SMOKE_OWNER:-operator}"}
environment:
POSTGRES_DB: "${THT_VECTOR_DATABASE:-thoth}"
POSTGRES_USER: "${THT_VECTOR_BOOTSTRAP_USER:-postgres}"
THT_VECTOR_MIGRATOR_USER: "${THT_VECTOR_MIGRATOR_USER:-thoth_vector_migrator}"
THT_VECTOR_READER_USER: "${THT_VECTOR_READER_USER:-thoth_vector_reader}"
THT_VECTOR_WRITER_USER: "${THT_VECTOR_WRITER_USER:-thoth_vector_writer}"
THT_SECRETS_FILE: /run/secrets/thothii.secrets
secrets: [{source: thothii_secrets, target: thothii.secrets}]
entrypoint: [/opt/thoth/vector-db-entrypoint.sh]
volumes:
- vector_data:/var/lib/postgresql/data
- ./deploy/vector/vector-db-entrypoint.sh:/opt/thoth/vector-db-entrypoint.sh:ro
- ./deploy/vector/secret-policy.sh:/opt/thoth/secret-policy.sh:ro
healthcheck:
test: [CMD-SHELL, "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
restart: unless-stopped
vector-reconcile:
image: pgvector/pgvector:0.8.5-pg16@sha256:1d533553fefe4f12e5d80c7b80622ba0c382abb5758856f52983d8789179f0fb
profiles: [local-vector]
labels: {io.thothii.smoke-owner: "${THOTH_SMOKE_OWNER:-operator}"}
environment:
PGHOST: vector-db
PGPORT: 5432
PGDATABASE: "${THT_VECTOR_DATABASE:-thoth}"
PGUSER: "${THT_VECTOR_BOOTSTRAP_USER:-postgres}"
THT_VECTOR_BOOTSTRAP_USER: "${THT_VECTOR_BOOTSTRAP_USER:-postgres}"
THT_VECTOR_MIGRATOR_USER: "${THT_VECTOR_MIGRATOR_USER:-thoth_vector_migrator}"
THT_VECTOR_READER_USER: "${THT_VECTOR_READER_USER:-thoth_vector_reader}"
THT_VECTOR_WRITER_USER: "${THT_VECTOR_WRITER_USER:-thoth_vector_writer}"
THT_SECRETS_FILE: /run/secrets/thothii.secrets
entrypoint: [/opt/thoth/reconcile-roles.sh]
secrets: [{source: thothii_secrets, target: thothii.secrets}]
volumes:
- ./deploy/vector/reconcile-roles.sh:/opt/thoth/reconcile-roles.sh:ro
- ./deploy/vector/secret-policy.sh:/opt/thoth/secret-policy.sh:ro
depends_on:
vector-db: {condition: service_healthy}
restart: "no"
vector-migrate:
image: thothii-core:local
profiles: [local-vector]
labels: {io.thothii.smoke-owner: "${THOTH_SMOKE_OWNER:-operator}"}
build:
context: .
dockerfile: docker/core.Dockerfile
entrypoint: [sh, -ec]
command:
- |
. /opt/thoth/secret-policy.sh
export PGPASSWORD=$$(read_bundle_secret /run/secrets/thothii.secrets THT_VECTOR_MIGRATOR_PASSWORD)
exec /opt/venv/bin/tht vector migrate --database-url "postgresql+psycopg2://${THT_VECTOR_MIGRATOR_USER:-thoth_vector_migrator}@vector-db:5432/${THT_VECTOR_DATABASE:-thoth}" --json
secrets: [{source: thothii_secrets, target: thothii.secrets}]
environment:
THT_SECRETS_FILE: /run/secrets/thothii.secrets
volumes:
- ./deploy/vector/secret-policy.sh:/opt/thoth/secret-policy.sh:ro
depends_on:
vector-reconcile: {condition: service_completed_successfully}
restart: "no"
volumes:
vector_data:
labels: {io.thothii.smoke-owner: "${THOTH_SMOKE_OWNER:-operator}"}
+6
View File
@@ -0,0 +1,6 @@
services:
core:
environment:
# Non-secret settings come from the root .env interpolation file.
AUTH_MODE: "${AUTH_MODE:-none}"
THT_SECRETS_FILE: /run/secrets/thothii.secrets
@@ -0,0 +1,14 @@
services:
preprocess-evidence:
environment:
THT_SECRETS_FILE: /run/secrets/thothii.secrets
secrets: [{source: thothii_secrets, target: thothii.secrets}]
depends_on:
vector-migrate: {condition: service_completed_successfully}
preprocess-dwh:
environment:
THT_SECRETS_FILE: /run/secrets/thothii.secrets
secrets: [{source: thothii_secrets, target: thothii.secrets}]
depends_on:
vector-migrate: {condition: service_completed_successfully}
+35
View File
@@ -0,0 +1,35 @@
services:
preprocess-evidence:
image: thothii-core:local
profiles: [preprocess]
build:
context: .
dockerfile: docker/core.Dockerfile
entrypoint: [sh, -ec]
command: ["mkdir -p /data/workspaces/preprocess-evidence && exec /app/docker/core-entrypoint.sh preprocess evidence --json -c /app/harness/workspaces/preprocess-evidence.yaml"]
environment:
THT_DATA_ROOT: /data
THT_OLLAMA_URL: "${THT_OLLAMA_URL:-http://host.docker.internal:11434}"
THT_SECRETS_FILE: /run/secrets/thothii.secrets
secrets: [{source: thothii_secrets, target: thothii.secrets}]
volumes:
- thoth_data:/data
- ./deploy/workspaces:/app/harness/workspaces:ro
restart: "no"
preprocess-dwh:
image: thothii-core:local
profiles: [preprocess]
build:
context: .
dockerfile: docker/core.Dockerfile
entrypoint: [sh, -ec]
command: ["mkdir -p /data/workspaces/preprocess-dwh && exec /app/docker/core-entrypoint.sh preprocess dwh --steps introspect --json -c /app/harness/workspaces/preprocess-dwh.yaml"]
environment:
THT_DATA_ROOT: /data
THT_SECRETS_FILE: /run/secrets/thothii.secrets
secrets: [{source: thothii_secrets, target: thothii.secrets}]
volumes:
- thoth_data:/data
- ./deploy/workspaces:/app/harness/workspaces:ro
restart: "no"
+11
View File
@@ -0,0 +1,11 @@
services:
core:
environment:
AUTH_MODE: upstream
THOTH_PUBLIC_EXPOSURE: "true"
THT_DB_NAME: ${THT_DB_NAME:?set THT_DB_NAME}
THT_DWH_REST_URL: ${THT_DWH_REST_URL:?set THT_DWH_REST_URL}
THT_VEC_REST_URL: ${THT_VEC_REST_URL:?set THT_VEC_REST_URL}
THT_OLLAMA_URL: ${THT_OLLAMA_URL:?set THT_OLLAMA_URL}
THT_DOCS_ROOT: ${THT_DOCS_ROOT:-/data/workspaces/example/evidence-source}
THT_SECRETS_FILE: /run/secrets/thothii.secrets
+11
View File
@@ -0,0 +1,11 @@
services:
core:
environment:
THT_DOCS_ROOT: /data/workspaces/psd
extra_hosts:
- host.docker.internal:host-gateway
volumes:
- ./deploy/workspaces/psd.yaml:/app/harness/config/tht.yaml:ro
- type: bind
source: ${THT_PSD_WORKSPACE_HOST_PATH:?set THT_PSD_WORKSPACE_HOST_PATH}
target: /data/workspaces/psd
+27
View File
@@ -0,0 +1,27 @@
# Deprecated compatibility template; it is not loaded by Docker Compose automatically.
# New installations must copy ../.env.example to ../.env and run
# `docker compose up --build -d` from the repository root. Keep this file only for
# staged upgrades that still invoke `--env-file deploy/env.example` explicitly.
# Never put secret values in this file.
COMPOSE_FILE=compose.yaml
COMPOSE_PROFILES=
THT_SECRETS_FILE=deploy/secrets/thothii.secrets
PI_PROVIDER=
PI_MODEL=
PI_THINKING=
MAX_PI_PROCESSES=4
AUTH_MODE=none
THT_DB_NAME=
THT_DWH_REST_URL=
THT_VEC_REST_URL=
THT_OLLAMA_URL=
THT_DOCS_ROOT=/data/workspaces/example/evidence-source
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
@@ -0,0 +1,26 @@
# Host nginx example. The auth service MUST authenticate every request and return a stable
# identity in X-Authenticated-User. ThothII itself remains on 127.0.0.1:8080.
server {
listen 443 ssl;
server_name thoth.example.test;
ssl_certificate /etc/nginx/tls/fullchain.pem;
ssl_certificate_key /etc/nginx/tls/privkey.pem;
location = /_authenticate {
internal;
proxy_pass http://authentication-gateway/verify;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
}
location / {
auth_request /_authenticate;
auth_request_set $authenticated_user $upstream_http_x_authenticated_user;
proxy_set_header X-Authenticated-User $authenticated_user;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:8080;
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"providers": {
"zai": {
"baseUrl": "https://api.z.ai/api/coding/paas/v4",
"api": "openai-completions",
"apiKey": "$ZAI_API_KEY",
"models": [
{
"id": "glm-5.2",
"name": "GLM-5.2",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 131072
}
]
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"defaultProjectTrust": "always"
}
+45
View File
@@ -0,0 +1,45 @@
# Runtime secrets
The canonical deployment secret is the single local file
`deploy/secrets/thothii.secrets`. Copy the tracked template and protect the copy:
```sh
cp deploy/secrets/thothii.secrets.example deploy/secrets/thothii.secrets
chmod 600 deploy/secrets/thothii.secrets
```
The file uses strict `KEY=VALUE` lines (comments and blank lines are allowed). The supported
keys are `THT_MODEL_API_KEY`, `THT_DWH_API_KEY`, `THT_VEC_API_KEY`,
`THT_VEC_WRITE_API_KEY`, and the four `THT_VECTOR_*_PASSWORD` role passwords. Values must be
non-empty and contain no whitespace. Do not put secrets in the root `.env`, workspace YAML,
URLs, logs, or `docker compose config` output.
Compose mounts the bundle read-only as `/run/secrets/thothii.secrets`. The host file must be a
regular non-symlink file with mode `0600` or `0400`; Docker's normal `0444` mode is accepted
only for the runtime mount beneath `/run/secrets`. The core runs as UID 10001. Verify the mount
without printing its contents:
```sh
docker compose run --rm core sh -c 'id && test -r /run/secrets/thothii.secrets'
```
A private CA PEM chain is not a bundle value: PEM whitespace is rejected by the strict parser.
Keep it in the host or secret manager and add a reviewed Compose override that mounts it at
`/run/secrets/ca-chain.pem` and sets `THT_SSL_CA` (or the adapter-specific setting). The base
Compose files intentionally do not create this mount.
## Migration from separate secret files
Older installations used `THT_*_SECRET_FILE` variables and one file per value. Migrate by
copying each value to its bundle key, validating with `docker compose config --quiet`, and only
then deleting the old files. The old variables remain a compatibility path for staged upgrades,
but the documented and tested default is `THT_SECRETS_FILE=deploy/secrets/thothii.secrets`.
The local-vector bootstrap rotation helper still accepts an old/new password file as its
maintenance interface. Run it only with files protected by `0600`, then copy the resulting
password into `THT_VECTOR_BOOTSTRAP_PASSWORD` in the bundle before restarting
`vector-reconcile`/the application. The helper never prints password contents.
Hosted Pi providers must use a single provider key. Compound providers (Bedrock, Azure OpenAI
Responses, Cloudflare Workers AI/Gateway) fail closed until a provider-specific credential
adapter is implemented.
+20
View File
@@ -0,0 +1,20 @@
# Copy to deploy/secrets/thothii.secrets and chmod 600.
# Values are read as literal strings (no shell expansion or command substitution).
# Leave unused keys out of the file.
# Hosted model provider (single-key providers only).
# THT_MODEL_API_KEY=replace-me
# External DWH and vector adapters.
# THT_DWH_API_KEY=replace-me
# THT_VEC_API_KEY=replace-me
# THT_VEC_WRITE_API_KEY=replace-me
# Optional local-vector roles.
# THT_VECTOR_BOOTSTRAP_PASSWORD=replace-me
# THT_VECTOR_MIGRATOR_PASSWORD=replace-me
# THT_VECTOR_READER_PASSWORD=replace-me
# THT_VECTOR_WRITER_PASSWORD=replace-me
# Optional CA material/path understood by the configured adapter.
# THT_CA=/run/secrets/ca-chain.pem
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
set -eu
. /opt/thoth/secret-policy.sh
bundle=${THT_SECRETS_FILE:-/run/secrets/thothii.secrets}
export PGPASSWORD=$(read_bundle_secret "$bundle" THT_VECTOR_BOOTSTRAP_PASSWORD)
migrator_password=$(read_bundle_secret "$bundle" THT_VECTOR_MIGRATOR_PASSWORD)
reader_password=$(read_bundle_secret "$bundle" THT_VECTOR_READER_PASSWORD)
writer_password=$(read_bundle_secret "$bundle" THT_VECTOR_WRITER_PASSWORD)
psql --set=ON_ERROR_STOP=1 \
--set=migrator_user="$THT_VECTOR_MIGRATOR_USER" \
--set=migrator_password="$migrator_password" \
--set=reader_user="$THT_VECTOR_READER_USER" \
--set=reader_password="$reader_password" \
--set=writer_user="$THT_VECTOR_WRITER_USER" \
--set=writer_password="$writer_password" <<'SQL'
SELECT 'CREATE ROLE vector_reader NOLOGIN'
WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'vector_reader') \gexec
SELECT 'CREATE ROLE vector_writer NOLOGIN'
WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'vector_writer') \gexec
ALTER ROLE vector_reader NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
ALTER ROLE vector_writer NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
SELECT format('CREATE ROLE %I LOGIN', :'migrator_user')
WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = :'migrator_user') \gexec
SELECT format('CREATE ROLE %I LOGIN', :'reader_user')
WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = :'reader_user') \gexec
SELECT format('CREATE ROLE %I LOGIN', :'writer_user')
WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = :'writer_user') \gexec
SELECT format(
'ALTER ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION',
:'migrator_user', :'migrator_password'
) \gexec
SELECT format(
'ALTER ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION',
:'reader_user', :'reader_password'
) \gexec
SELECT format(
'ALTER ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION',
:'writer_user', :'writer_password'
) \gexec
SELECT format('GRANT vector_reader TO %I', :'reader_user') \gexec
SELECT format('GRANT vector_writer TO %I', :'writer_user') \gexec
SELECT format('ALTER DATABASE %I OWNER TO %I', current_database(), :'migrator_user') \gexec
SELECT format('CREATE SCHEMA IF NOT EXISTS vectors AUTHORIZATION %I', :'migrator_user') \gexec
SELECT format('ALTER SCHEMA vectors OWNER TO %I', :'migrator_user') \gexec
REVOKE ALL ON SCHEMA vectors FROM PUBLIC;
GRANT USAGE ON SCHEMA vectors TO vector_reader, vector_writer;
CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA vectors;
SQL
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Rotate the initialized PostgreSQL bootstrap role and verify before returning success."""
from __future__ import annotations
import os
import sys
from pathlib import Path
import psycopg2
from psycopg2 import sql
def read_secret(path: str) -> str:
value = Path(path).read_text()
if not value or "\x00" in value or any(character.isspace() for character in value):
raise ValueError("secret must be non-empty and contain no whitespace or NUL bytes")
return value
def connect(password: str):
return psycopg2.connect(
host=os.environ.get("THT_VECTOR_HOST", "vector-db"),
port=int(os.environ.get("THT_VECTOR_PORT", "5432")),
dbname=os.environ.get("THT_VECTOR_DATABASE", "thoth"),
user=os.environ.get("THT_VECTOR_BOOTSTRAP_USER", "postgres"),
password=password,
connect_timeout=5,
)
def alter_current_role(connection, password: str) -> None:
with connection.cursor() as cursor:
cursor.execute("SELECT current_user")
current_user = cursor.fetchone()[0]
expected = os.environ.get("THT_VECTOR_BOOTSTRAP_USER", "postgres")
if current_user != expected:
raise RuntimeError("authenticated role does not match THT_VECTOR_BOOTSTRAP_USER")
cursor.execute(
sql.SQL("ALTER ROLE {} PASSWORD {}").format(
sql.Identifier(current_user), sql.Literal(password)
)
)
connection.commit()
def main() -> int:
if len(sys.argv) != 3:
print("usage: rotate-bootstrap-password.py OLD_SECRET NEW_SECRET", file=sys.stderr)
return 2
try:
old_password = read_secret(sys.argv[1])
new_password = read_secret(sys.argv[2])
if old_password == new_password:
raise ValueError("old and new bootstrap passwords must differ")
old_connection = connect(old_password)
except Exception as exc:
print(f"bootstrap rotation refused before change: {type(exc).__name__}", file=sys.stderr)
return 1
try:
alter_current_role(old_connection, new_password)
try:
verification = connect(new_password)
verification.close()
except Exception as verify_exc:
try:
alter_current_role(old_connection, old_password)
except Exception as restore_exc:
print(
"bootstrap rotation verification failed and password restore failed: "
f"{type(verify_exc).__name__}/{type(restore_exc).__name__}",
file=sys.stderr,
)
return 3
print(
f"bootstrap rotation verification failed; old password restored: "
f"{type(verify_exc).__name__}",
file=sys.stderr,
)
return 1
except Exception as exc:
print(f"bootstrap rotation failed: {type(exc).__name__}", file=sys.stderr)
return 1
finally:
old_connection.close()
print("bootstrap database password rotated and new login verified")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+95
View File
@@ -0,0 +1,95 @@
#!/bin/sh
validate_secret_file() {
secret_path=$1
secret_name=$2
if [ -L "$secret_path" ] || [ ! -f "$secret_path" ] || [ ! -r "$secret_path" ] || [ ! -s "$secret_path" ]; then
echo "$secret_name must be a readable, non-empty regular file" >&2
return 2
fi
if LC_ALL=C grep -q '[[:space:]]' "$secret_path"; then
echo "$secret_name must contain no whitespace" >&2
return 2
fi
mode=$(stat -c '%a' "$secret_path" 2>/dev/null || stat -f '%Lp' "$secret_path" 2>/dev/null) || return 2
case "$secret_path:$mode" in
/run/secrets/*:444|/run/secrets/*:400|/run/secrets/*:600|*:600|*:400) ;;
*) echo "$secret_name must have mode 0600 or stricter (Docker secrets may be 0444)" >&2; return 2 ;;
esac
}
read_secret_file() {
validate_secret_file "$1" "$2" || return
cat "$1"
}
# Validate the bundle without printing any value. Keep this parser aligned with
# the backend loader: comments/blank lines are allowed, while syntax, allowlist,
# duplicates, empty values, file size, and line size are fail-closed.
validate_bundle() {
bundle_path=$1
if [ -L "$bundle_path" ] || [ ! -f "$bundle_path" ] || [ ! -r "$bundle_path" ] || [ ! -s "$bundle_path" ]; then
echo "secret bundle must be a readable, non-empty regular file" >&2
return 2
fi
mode=$(stat -c '%a' "$bundle_path" 2>/dev/null || stat -f '%Lp' "$bundle_path" 2>/dev/null) || return 2
case "$bundle_path:$mode" in
/run/secrets/*:444|/run/secrets/*:400|/run/secrets/*:600|*:600|*:400) ;;
*) echo "secret bundle must have mode 0600 or stricter (Docker secrets may be 0444)" >&2; return 2 ;;
esac
size=$(stat -c '%s' "$bundle_path" 2>/dev/null || stat -f '%z' "$bundle_path" 2>/dev/null) || return 2
if [ "$size" -gt 65536 ]; then
echo "secret bundle exceeds the 64KiB limit" >&2
return 2
fi
awk '
{ sub(/\r$/, "", $0) }
length($0) > 16384 { exit 9 }
/^[[:space:]]*$/ || /^[[:space:]]*#/ { next }
/^[A-Z][A-Z0-9_]*=/ {
key=$0; sub(/=.*/, "", key)
val=$0; sub(/^[^=]*=/, "", val)
if (key !~ /^(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)$/) exit 6
if (val == "" || ++seen[key] > 1) exit 7
next
}
{ exit 4 }
' "$bundle_path" || {
echo "secret bundle syntax is invalid" >&2
return 2
}
}
# Read one value from the deployment bundle without putting the bundle itself in
# a service environment. Values selected for credentials must contain no spaces.
read_bundle_secret() {
bundle_path=$1
bundle_key=$2
validate_bundle "$bundle_path" || return
case "$bundle_key" in
THT_[A-Z0-9_]*|PI_PROVIDER_API_KEY) ;;
*) echo "invalid secret bundle key" >&2; return 2 ;;
esac
value=$(awk -v wanted="$bundle_key" '
{ sub(/\r$/, "", $0) }
/^[[:space:]]*$/ || /^[[:space:]]*#/ { next }
/^[A-Z][A-Z0-9_]*=/ {
key=$0; sub(/=.*/, "", key)
val=$0; sub(/^[^=]*=/, "", val)
if (key == wanted) {
found=1; print val
}
next
}
{ exit 4 }
END { if (!found) exit 5 }
' "$bundle_path") || {
echo "$bundle_key is unavailable in secret bundle" >&2
return 3
}
if [ -z "$value" ] || printf '%s' "$value" | LC_ALL=C grep -q '[[:space:]]'; then
echo "$bundle_key must contain no whitespace" >&2
return 2
fi
printf '%s' "$value"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
. /opt/thoth/secret-policy.sh
bundle=${THT_SECRETS_FILE:-/run/secrets/thothii.secrets}
export POSTGRES_PASSWORD=$(read_bundle_secret "$bundle" THT_VECTOR_BOOTSTRAP_PASSWORD)
unset THT_SECRETS_FILE
exec /usr/local/bin/docker-entrypoint.sh postgres
+69
View File
@@ -0,0 +1,69 @@
language: en
dwh:
type: thoth_rest
database:
database: ${THT_DB_NAME}
schema: datawarehouse
endpoint:
base_url: ${THT_DWH_REST_URL}
api_key: ${THT_DWH_API_KEY}
ssl_ca: ${THT_SSL_CA}
# Relative logical roots are resolved beneath /data/workspaces/example.
roots:
artifacts: artifacts
indexes: indexes
sessions: sessions
examples:
max_per_column: 10
lsh:
signature_size: 64
n_gram: 3
threshold: 0.5
max_values_per_column: 1000
eligibility:
max_declared_len: 128
max_avg_length: 40
max_sampled_len: 200
ignore_columns: [etl_last_update]
evidence:
source_root: ${THT_DOCS_ROOT}
evidence_dir: evidence
embeddings:
base_url: ${THT_OLLAMA_URL}
model: nomic-embed-text-v2-moe
dim: 768
batch_size: 32
vectors:
type: thoth_vector_http
reader:
base_url: ${THT_VEC_REST_URL}
api_key: ${THT_VEC_API_KEY}
ssl_ca: ${THT_SSL_CA}
writer:
base_url: ${THT_VEC_REST_URL}
api_key: ${THT_VEC_WRITE_API_KEY}
ssl_ca: ${THT_SSL_CA}
vector:
max_chunk_chars: 4000
search:
rrf_k: 60
top_schema_tables: 12
schema_chunk_pool: 150
execution:
allow: [cte_test, explain, preview, aggregate, export]
max_preview_rows: 10
max_export_rows: 100000
statement_timeout_ms: 30000
warn_execution_ms: 5000
max_aggregate_cells: 20
+48
View File
@@ -0,0 +1,48 @@
language: en
dwh:
type: thoth_rest
database:
database: ${THT_DB_NAME}
schema: datawarehouse
endpoint:
base_url: ${THT_DWH_REST_URL}
api_key: ${THT_DWH_API_KEY}
vectors:
type: pgvector_direct
reader:
host: vector-db
port: 5432
database: ${THT_VECTOR_DATABASE}
schema: vectors
user: ${THT_VECTOR_READER_USER}
password_file: ${THT_VECTOR_READER_PASSWORD_FILE}
writer:
host: vector-db
port: 5432
database: ${THT_VECTOR_DATABASE}
schema: vectors
user: ${THT_VECTOR_WRITER_USER}
password_file: ${THT_VECTOR_WRITER_PASSWORD_FILE}
roots:
artifacts: artifacts
indexes: indexes
sessions: sessions
evidence:
source_root: ${THT_DOCS_ROOT}
evidence_dir: evidence
embeddings:
base_url: ${THT_OLLAMA_URL}
model: nomic-embed-text-v2-moe
dim: 768
batch_size: 32
execution:
allow: [cte_test, explain, preview, aggregate, export]
max_preview_rows: 10
max_export_rows: 100000
statement_timeout_ms: 30000
+7
View File
@@ -0,0 +1,7 @@
language: en
dwh:
type: postgres_direct
connection:
{host: vector-db, database: thoth, schema: vectors, user: thoth_vector_reader,
password_file: "${THT_VECTOR_READER_PASSWORD_FILE}"}
roots: {artifacts: artifacts, indexes: indexes, sessions: sessions}
@@ -0,0 +1,15 @@
language: en
dwh:
type: postgres_direct
connection: {host: unused, database: unused, schema: public, user: unused, password: unused}
vectors:
type: pgvector_direct
reader:
{host: vector-db, database: thoth, schema: vectors, user: thoth_vector_reader,
password_file: "${THT_VECTOR_READER_PASSWORD_FILE}"}
writer:
{host: vector-db, database: thoth, schema: vectors, user: thoth_vector_writer,
password_file: "${THT_VECTOR_WRITER_PASSWORD_FILE}"}
roots: {artifacts: artifacts, indexes: indexes, sessions: sessions}
evidence: {source_root: /data/source, evidence_dir: evidence}
embeddings: {base_url: "${THT_OLLAMA_URL}", model: smoke, dim: 768, batch_size: 32}
+43
View File
@@ -0,0 +1,43 @@
language: it
dwh:
type: thoth_rest
database:
database: ${THT_DB_NAME}
schema: datawarehouse
endpoint:
base_url: ${THT_DWH_REST_URL}
api_key: ${THT_DWH_API_KEY}
ssl_ca: ${THT_SSL_CA}
vectors:
type: thoth_vector_http
reader:
base_url: ${THT_VEC_REST_URL}
api_key: ${THT_VEC_API_KEY}
ssl_ca: ${THT_SSL_CA}
writer:
base_url: ${THT_VEC_WRITE_REST_URL}
api_key: ${THT_VEC_WRITE_API_KEY}
ssl_ca: ${THT_SSL_CA}
roots:
artifacts: /data/workspaces/psd/runtime-v2/artifacts
indexes: /data/workspaces/psd/runtime-v2/indexes
sessions: /data/workspaces/psd/sessions
evidence:
source_root: ${THT_DOCS_ROOT}
evidence_dir: evidence
embeddings:
base_url: ${THT_OLLAMA_URL}
model: nomic-embed-text-v2-moe
dim: 768
batch_size: 32
execution:
allow: [cte_test, explain, preview, aggregate, export]
max_preview_rows: 10
max_export_rows: 100000
statement_timeout_ms: 30000
+52
View File
@@ -0,0 +1,52 @@
# Core runtime dependency locks
The core image consumes committed, production-only locks for Pi and the Python harness. Refresh
them from the repository root after intentionally changing the corresponding direct dependencies.
## Pi runtime
Keep the exact Pi version in `docker/pi-runtime/package.json`, then regenerate its npm lock:
```sh
npm install --package-lock-only --ignore-scripts --no-audit --no-fund \
--prefix docker/pi-runtime
```
The image installs this tree with `npm ci --omit=dev`; do not replace it with an unpinned global
install.
## Python runtime
Install [uv](https://docs.astral.sh/uv/) and compile the harness's production dependencies for
Python 3.12. `--universal` retains platform markers and hashes for a cross-platform resolution;
the `dev` extra is deliberately absent.
```sh
uv pip compile harness/pyproject.toml docker/python-runtime/build-requirements.in \
--universal \
--python-version 3.12 \
--no-emit-package tht \
--generate-hashes \
--custom-compile-command \
'uv pip compile harness/pyproject.toml docker/python-runtime/build-requirements.in --universal --python-version 3.12 --no-emit-package tht --generate-hashes --output-file docker/python-runtime/requirements.lock' \
--output-file docker/python-runtime/requirements.lock
```
The small input file pins the harness's PEP 517 build backend as well; it is not derived from a
host environment. The image installs the resulting lock with pip's `--require-hashes`, then
installs the local `tht` project with `--no-deps --no-build-isolation`. This prevents both project
metadata and an isolated build environment from resolving unpinned packages.
## Base images
Every `FROM` uses an exact tag plus a multi-platform manifest-list digest. To update one:
1. Choose an exact patch tag that publishes both `linux/amd64` and `linux/arm64`.
2. Inspect it with `docker buildx imagetools inspect <tag>`.
3. Replace both the human-readable tag and `@sha256:...` digest.
4. Run `./scripts/verify-container-images.sh` and the Compose smoke.
5. Review the generated inventory under `.artifacts/container-images/`.
The digest freezes image layers, but `apt-get update` and `apk add` still consume mutable package
repositories during a no-cache rebuild. Full OS-package immutability would require Debian/Alpine
snapshot repositories and is not claimed by this deployment.
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
backend_base_url=${BACKEND_BASE_URL-/api}
if ! /usr/local/bin/validate-backend-url "$backend_base_url"; then
echo "Invalid BACKEND_BASE_URL: use empty/root, /api, or a valid http(s) base without credentials, query, or fragment" >&2
exit 2
fi
runtime_config=$(jq -cn --arg backend_base_url "$backend_base_url" \
'{backendBaseUrl: $backend_base_url}')
printf 'window.__THOTHII_CONFIG__ = %s;\n' "$runtime_config" \
> /usr/share/nginx/html/config.js
if [ "$#" -gt 0 ]; then
exec "$@"
fi
exec nginx -g 'daemon off;'
+36
View File
@@ -0,0 +1,36 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
location = /config.js {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location = /health {
proxy_pass http://core:8787/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_cache off;
}
location /api/ {
proxy_pass http://core:8787/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Trusted only when AUTH_MODE=upstream and this frontend port is reachable solely
# from the authenticated host proxy documented in deploy/.
proxy_set_header X-Authenticated-User $http_x_authenticated_user;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+1833
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{
"name": "thothii-pi-runtime",
"version": "1.0.0",
"private": true,
"description": "Locked Pi runtime dependency for the ThothII core image",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.80.3"
}
}
@@ -0,0 +1,2 @@
# PEP 517 backend used to install the local harness without network-isolated resolution.
setuptools==80.9.0
+942
View File
@@ -0,0 +1,942 @@
# This file was autogenerated by uv via the following command:
# uv pip compile harness/pyproject.toml docker/python-runtime/build-requirements.in --extra s3 --universal --python-version 3.12 --no-emit-package tht --generate-hashes --output-file docker/python-runtime/requirements.lock
annotated-doc==0.0.4 \
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
--hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4
# via typer
annotated-types==0.7.0 \
--hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
--hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
# via pydantic
boto3==1.43.46 \
--hash=sha256:66c0d943b049a46a492ec4ec2ebe73c930b1842c7137bee83aad6d93e95d4d96 \
--hash=sha256:69453e2c1bcb9fd9806527ab99950cacfc2826cb0dce9a3a0414d19270c06c3c
# via tht (harness/pyproject.toml)
botocore==1.43.46 \
--hash=sha256:59f2e1ac3cdc66d191cae91c0804bc41847ce817dc8147cf43eaada8f76a5533 \
--hash=sha256:cb673891e623ae6e6a1bf24d94ef169504f3eb02584adb5d5bee2f6aae819b60
# via
# boto3
# s3transfer
certifi==2026.6.17 \
--hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
--hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
# via requests
charset-normalizer==3.4.9 \
--hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \
--hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \
--hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \
--hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \
--hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \
--hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \
--hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \
--hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \
--hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \
--hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \
--hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \
--hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \
--hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \
--hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \
--hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \
--hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \
--hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \
--hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \
--hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \
--hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \
--hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \
--hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \
--hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \
--hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \
--hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \
--hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \
--hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \
--hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \
--hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \
--hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \
--hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \
--hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \
--hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \
--hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \
--hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \
--hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \
--hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \
--hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \
--hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \
--hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \
--hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \
--hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \
--hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \
--hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \
--hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \
--hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \
--hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \
--hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \
--hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \
--hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \
--hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \
--hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \
--hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \
--hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \
--hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \
--hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \
--hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \
--hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \
--hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \
--hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \
--hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \
--hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \
--hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \
--hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \
--hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \
--hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \
--hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \
--hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \
--hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \
--hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \
--hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \
--hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \
--hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \
--hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \
--hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \
--hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \
--hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \
--hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \
--hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \
--hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \
--hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \
--hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \
--hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \
--hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \
--hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \
--hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \
--hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \
--hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \
--hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \
--hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \
--hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \
--hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \
--hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115
# via requests
click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
# via yake
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via
# click
# tqdm
# typer
datasketch==2.0.0 \
--hash=sha256:aea5ffafcce776e03d085740e78b874e778d779b07ee11ca636ca51b3fef09ed \
--hash=sha256:e0570e170f7e64b8d6fb1cc2e4ce36a9f7036c5100167e50a0770addc50558c2
# via tht (harness/pyproject.toml)
greenlet==3.5.3 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' \
--hash=sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db \
--hash=sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7 \
--hash=sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8 \
--hash=sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc \
--hash=sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da \
--hash=sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8 \
--hash=sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91 \
--hash=sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c \
--hash=sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310 \
--hash=sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3 \
--hash=sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce \
--hash=sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149 \
--hash=sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d \
--hash=sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34 \
--hash=sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be \
--hash=sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2 \
--hash=sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b \
--hash=sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0 \
--hash=sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71 \
--hash=sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d \
--hash=sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04 \
--hash=sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227 \
--hash=sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c \
--hash=sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6 \
--hash=sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8 \
--hash=sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21 \
--hash=sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23 \
--hash=sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605 \
--hash=sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128 \
--hash=sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f \
--hash=sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea \
--hash=sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81 \
--hash=sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2 \
--hash=sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8 \
--hash=sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e \
--hash=sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb \
--hash=sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47 \
--hash=sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8 \
--hash=sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab \
--hash=sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7 \
--hash=sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861 \
--hash=sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702 \
--hash=sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814 \
--hash=sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf \
--hash=sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a \
--hash=sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260 \
--hash=sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1 \
--hash=sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec \
--hash=sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a \
--hash=sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5 \
--hash=sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1 \
--hash=sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4 \
--hash=sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c \
--hash=sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda \
--hash=sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb \
--hash=sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31 \
--hash=sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d \
--hash=sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c \
--hash=sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d \
--hash=sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b \
--hash=sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550 \
--hash=sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c \
--hash=sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357 \
--hash=sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c \
--hash=sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4 \
--hash=sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930 \
--hash=sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8 \
--hash=sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b \
--hash=sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb \
--hash=sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16 \
--hash=sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608 \
--hash=sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f \
--hash=sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc \
--hash=sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d \
--hash=sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44 \
--hash=sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b \
--hash=sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3 \
--hash=sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154 \
--hash=sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117
# via sqlalchemy
idna==3.18 \
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
# via requests
jellyfish==1.2.1 \
--hash=sha256:0028857c5381c9d55e21cc6cb0d7f9545c3a9a7bb7dbca3960fe0a898c691ac2 \
--hash=sha256:01647c12261bc1f7b102e918e7665497176d87f6fc96271439c8855872bc2606 \
--hash=sha256:0368596e176bf548b3be2979ff33e274fb6d5e13b2cebe85137b8b698b002a85 \
--hash=sha256:05be396aebe3dce7a8cb2f97727ecdf99e86457c48e97190775dce33f8b7e39d \
--hash=sha256:07b022412ebece96759006cb015d46b8218d7f896d8b327c6bbee784ddf38ed9 \
--hash=sha256:0b21c1596ce283fd7ee954eb0eeb007d59e480364324bcd91ad55146e91f3936 \
--hash=sha256:1098ce1f84ae3f147f0a18a6803ffb09b9c8cd5fedce42465643ca0b5c9d0224 \
--hash=sha256:10da696747e2de0336180fd5ba77ef769a7c80f9743123545f7fc0251efbbcec \
--hash=sha256:1354b558a0a16597b6032dd0af64bebd24994f7e7484cf14993320eb764b06cb \
--hash=sha256:137cfcc26396d0f2e1265ac61f800bb921921ea722a43dd897e58190f767c474 \
--hash=sha256:13f1ac9caba22af10bfe42f674822643c0266009f882e0fe652079706dc5d13a \
--hash=sha256:14bbb30d988dec1d12183cf5d4621c908f98add2009c72a185e8c3e8d00b804f \
--hash=sha256:15318c13070fe6d9caeb7e10f9cdf89ff47c9d20f05a9a2c0d3b5cb8062a7033 \
--hash=sha256:1a3ccff843822e7f3ad6f91662488a3630724c8587976bce114f3c7238e8ffa1 \
--hash=sha256:1ffeeb6c78c45fbb6d2a22b0173fb8a6af849001d6c26fab49c525136dbd9734 \
--hash=sha256:212aaf177236192a735bbbf5938717aa8518d14a25b08b015e47e783e70be060 \
--hash=sha256:21baa92d4a5112167721156f6d061c2ae105f2995b3a5e19cec6662928f0c439 \
--hash=sha256:2348f698f9c1d72023afc8d39939045421a01da9b7e3078e3029227e35f28419 \
--hash=sha256:29cfa8bfb72aacf2d611a3313b358ed4d4140fa3d3efcffea750c8e7f8acb1aa \
--hash=sha256:2c28a4ae3e201e1c1b7bacacd40e2e76c4068b90c9ae3a0d525e0ac98206f1cc \
--hash=sha256:32581c50b34a09889b2d96796170e53da313a1e7fde32be63c82e50e7e791e3c \
--hash=sha256:32a85b752cb51463face13e2b1797cfa617cd7fb7073f15feaa4020a86a346ce \
--hash=sha256:393f609fd6139ce782e747e22c399483ffc58341009e6a97e39ffe5f5b2c674c \
--hash=sha256:4072e21ad4036af41bd57b447b1dda64fe60aa679cfa8854ba0a0338152439f1 \
--hash=sha256:451ddf4094e108e33d3b86d7817a7e20a2c5e6812d08c34ee22f6a595f38dcca \
--hash=sha256:4a21d7eda5e6996772055f798e3fe1de1b33b3edad7f6cf0567097a21585a812 \
--hash=sha256:4b013876109d91fa6fc871ffa4e0dbfda11820c33dc4ad0e2967b3fc1187f804 \
--hash=sha256:4b28fcefc0c3534277ff0306e6c10672fb050f4784b5f3be7037e80801569fb5 \
--hash=sha256:4b3e3223aaad74e18aacc74775e01815e68af810258ceea6fa6a81b19f384312 \
--hash=sha256:4c5acb213aa75a61bcfc176566e20f2503069667e760d83d403b59e115fef0dd \
--hash=sha256:4e36d9000d4f7e1a35689a74ec7749d27a216dfa6c47cac2e5ad3de8a523bd69 \
--hash=sha256:509355ebedec69a8bf0cc113a6bf9c01820d12fe2eea44f47dfa809faf2d5463 \
--hash=sha256:5335f622458aa105289a8e358bc32ecd1b9634b6ffec3e77ea3577e49c297171 \
--hash=sha256:536c80d8d4ec7f39cbb10b85d926ff96cef3cde4a83ca0991c07cd9835d5dc13 \
--hash=sha256:56da7632e029912af25e25422fae3b6df318400297d552791f4b21da6d815ed6 \
--hash=sha256:5bda2275f31a64adf3483e39f7a4e2107f7dfe3a3f85f0d2c0cb6ae5fbe4a443 \
--hash=sha256:5fa0ba0946f3c274f6a87aaa3c631dc70a363bd46cceea828ce777e8db653b6f \
--hash=sha256:63770120cc3386dcc13bcc4df508ab281a6b14c3b2c0e33586439a6c40ee122f \
--hash=sha256:675ab43840488944899ca87f02d4813c1e32107e56afaba7489705a70214e8aa \
--hash=sha256:68080af234256ef943f0add6fc79816b0c643d8df291c17a85c1b6e45bdfbb96 \
--hash=sha256:68ea3ddd4dae1152a7f7155ef02a7bfad919611158d71b301f9aa167685819af \
--hash=sha256:6a49ce2a580edd3b16b69421137deef464e2f8907f9ef906d49950b1a52908c1 \
--hash=sha256:6c51e565f85ce38cf9388c4f916d53888b0fa34788fcebe3aff3db24948e0960 \
--hash=sha256:6d2bac5982d7a08759ea487bfa00149e6aa8a3be7cd43c4ed1be1e3505425c69 \
--hash=sha256:6e76b23431a667cd485fb562428d1ad29bae9fdd0fcdfb5a51cc8087bae0e88c \
--hash=sha256:72d2fda61b23babe862018729be73c8b0dc12e3e6601f36f6e65d905e249f4db \
--hash=sha256:748dc45a0394fbe9120b8b3b9a39fab0967c7e2d6ecdd5304af018e774f80f96 \
--hash=sha256:7853d2ed7d6929c029312ec849410f1ea7ae76ce72ad1140fb73f6e8a1e6aa4f \
--hash=sha256:80a49eb817eaa6591f43a31e5c93d79904de62537f029907ef88c050d781a638 \
--hash=sha256:91cad49a4fb731b726afc5ae385a3217a7016ed88a04da40c131cff8136a5db5 \
--hash=sha256:98a133b40dc00cfda6609e1b0cb0ab0b77796fc2719aae886a12009514f73499 \
--hash=sha256:9913789a98ccf49213fbb1dabc597847a0ec33d3b0e151689498f4b38ba9be0f \
--hash=sha256:9930e20f0e9f65ad1d57d98290c2be3abd75812d058815605f44a56056fb9a66 \
--hash=sha256:9a73b5c6425a70ebd440579a677eb4f03b327b2f59090db34e6c937aeea5aabd \
--hash=sha256:9c747ae5c0fb4bd519f6abbfe4bd704b2f1c63fd4dd3dbb8d8864478974e1571 \
--hash=sha256:9d4448c874959ae012cda0f6d570ac0bd7f0fcf12007714eaebf86b86919b66f \
--hash=sha256:a058f4c6a591d5e5a47569f5648a26303ba19c76a960fef7e0beba2aa959e52e \
--hash=sha256:a0ef6f0ecc085c1f8fddb048f538c8bb89989e5d470eab45d4e9bd48ee73a40d \
--hash=sha256:a3cab91020e3ff7565e55a611ec3e3257c093ac950d55778a48bfc8c57562b6e \
--hash=sha256:ab1bfea271ce4bda09d975080d5465cf5a8b127e7c0ea61ea3f972417a7a2193 \
--hash=sha256:b35d4b5b688f759ffd075190a9850b04671bad14c5b37124eb43e99306ec16ea \
--hash=sha256:b37b76ea338c4a473c34a9b9e1e033a78aafb9040a8c0eea579fc5805d8e4b46 \
--hash=sha256:b8986d9768daddd5e87abf513ae168ea0afe690a444d4c82d5b1b14b0d045820 \
--hash=sha256:baa30c7b59bd1c5e105693108a6d7a98f3e7a1a59e23e15bc5897b91fd5849f5 \
--hash=sha256:bcdcd603a7737cd3f5a2ab10ce9b49844329deb81c2daafcd8131e54fc730205 \
--hash=sha256:bd186c041d9be86c4fa5e2490943ce5d7f05b472f45d7f49426f259f3dd20bc4 \
--hash=sha256:bebccd0652ac1c7e438ae1f451edefde63d14b3af6f6daa30c599919dcb92886 \
--hash=sha256:c3c18f13175a9c90f3abd8805720b0eb3e10eca1d5d4e0cf57722b2a62d62016 \
--hash=sha256:c499ea3a134130797c50e367687a6a46a12653c59af381bee92c41a5ab0bd55d \
--hash=sha256:c85aa2bc76a36d92a3197f406f86636664d5b323727dfec4fa2842a8a24a06ae \
--hash=sha256:c888f624d03e55e501bc438906505c79fb307d8da37a6dda18dd1ac2e6d5ea9c \
--hash=sha256:cf6cd68921f2bacc547ba1cf64ad0e76bc1727f3bab13bba2e5f5869aba038b1 \
--hash=sha256:d2b56a1fd2c5126c4a3362ec4470291cdd3c7daa22f583da67e75e30dc425ce6 \
--hash=sha256:d7be8021658b46b22500a77f1707901bd98fc210f185c229b81c74efd3c1baf2 \
--hash=sha256:db97d873f23b0c15b4ed911ece10e5cc0bb96cdc53666d5c3788bd0af81807f1 \
--hash=sha256:dd895cf63fac0a9f11b524fff810d9a6081dcf3c518b34172ac8684eb504dd43 \
--hash=sha256:ddf05ea471da2808d77ecfa425d8884124b4754f4d483afa7703b6655530cf5c \
--hash=sha256:e1b990fb15985571616f7f40a12d6fa062897b19fb5359b6dec3cd811d802c24 \
--hash=sha256:e4a210a960f3917da757b0581750b6e0a8db9acef68dafbc1b6e2ae39e847ba8 \
--hash=sha256:e5977810972c6f0b2e61252c4758fd5aee21abf663ff309881195a99d37daa94 \
--hash=sha256:e967e67058b78189d2b20a9586c7720a05ec4a580d6a98c796cd5cd2b7b11303 \
--hash=sha256:ecf62d4aad0baa8832ab60f96e7baedbe6558bd292597503d927e9c5bce745d8 \
--hash=sha256:f121218dc33fb318c34ddd889dc7362606ce1316af2bb63b73cc1df81523ca34 \
--hash=sha256:f69aeb08659a6c81d559bbe319075e3417434ae5b3a5e4a758d1c4055a03497a \
--hash=sha256:fb3c6e537cb4605c22895a8d4a10cdb26611ba2bbfc7f0b4c1d06bb9d8aad648
# via yake
jmespath==1.1.0 \
--hash=sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d \
--hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64
# via
# boto3
# botocore
markdown-it-py==4.2.0 \
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
# via rich
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
networkx==3.6.1 \
--hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \
--hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762
# via yake
numpy==2.5.1 \
--hash=sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2 \
--hash=sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d \
--hash=sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1 \
--hash=sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b \
--hash=sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd \
--hash=sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077 \
--hash=sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a \
--hash=sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e \
--hash=sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277 \
--hash=sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6 \
--hash=sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75 \
--hash=sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7 \
--hash=sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1 \
--hash=sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9 \
--hash=sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21 \
--hash=sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca \
--hash=sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0 \
--hash=sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb \
--hash=sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d \
--hash=sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75 \
--hash=sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74 \
--hash=sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf \
--hash=sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0 \
--hash=sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8 \
--hash=sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af \
--hash=sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a \
--hash=sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4 \
--hash=sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22 \
--hash=sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3 \
--hash=sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1 \
--hash=sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b \
--hash=sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1 \
--hash=sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373 \
--hash=sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95 \
--hash=sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6 \
--hash=sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09 \
--hash=sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9 \
--hash=sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438 \
--hash=sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2 \
--hash=sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7 \
--hash=sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace \
--hash=sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3 \
--hash=sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2 \
--hash=sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107
# via
# datasketch
# scipy
# yake
psycopg2-binary==2.9.12 \
--hash=sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f \
--hash=sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964 \
--hash=sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c \
--hash=sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2 \
--hash=sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115 \
--hash=sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c \
--hash=sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be \
--hash=sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6 \
--hash=sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c \
--hash=sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c \
--hash=sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c \
--hash=sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d \
--hash=sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019 \
--hash=sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7 \
--hash=sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3 \
--hash=sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777 \
--hash=sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd \
--hash=sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5 \
--hash=sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39 \
--hash=sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c \
--hash=sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf \
--hash=sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b \
--hash=sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433 \
--hash=sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d \
--hash=sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4 \
--hash=sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290 \
--hash=sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2 \
--hash=sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3 \
--hash=sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94 \
--hash=sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d \
--hash=sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b \
--hash=sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965 \
--hash=sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9 \
--hash=sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f \
--hash=sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354 \
--hash=sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433 \
--hash=sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9 \
--hash=sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463 \
--hash=sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5 \
--hash=sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be \
--hash=sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580 \
--hash=sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4 \
--hash=sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f \
--hash=sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1 \
--hash=sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915 \
--hash=sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033 \
--hash=sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03 \
--hash=sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe \
--hash=sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326 \
--hash=sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0 \
--hash=sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e \
--hash=sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86 \
--hash=sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5 \
--hash=sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e \
--hash=sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06 \
--hash=sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936 \
--hash=sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03 \
--hash=sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56 \
--hash=sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6 \
--hash=sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256 \
--hash=sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8 \
--hash=sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab \
--hash=sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980 \
--hash=sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10 \
--hash=sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a \
--hash=sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2 \
--hash=sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e
# via tht (harness/pyproject.toml)
pydantic==2.13.4 \
--hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \
--hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6
# via tht (harness/pyproject.toml)
pydantic-core==2.46.4 \
--hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \
--hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \
--hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \
--hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \
--hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \
--hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \
--hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \
--hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \
--hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \
--hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \
--hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \
--hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \
--hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \
--hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \
--hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \
--hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \
--hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \
--hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \
--hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \
--hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \
--hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \
--hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \
--hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \
--hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \
--hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \
--hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \
--hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \
--hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \
--hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \
--hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \
--hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \
--hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \
--hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \
--hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \
--hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \
--hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \
--hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \
--hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \
--hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \
--hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \
--hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \
--hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \
--hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \
--hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \
--hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \
--hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \
--hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \
--hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \
--hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \
--hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \
--hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \
--hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \
--hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \
--hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \
--hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \
--hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \
--hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \
--hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \
--hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \
--hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \
--hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \
--hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \
--hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \
--hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \
--hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \
--hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \
--hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \
--hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \
--hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \
--hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \
--hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \
--hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \
--hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \
--hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \
--hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \
--hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \
--hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \
--hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \
--hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \
--hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \
--hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \
--hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \
--hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \
--hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \
--hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \
--hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \
--hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \
--hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \
--hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \
--hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \
--hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \
--hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \
--hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \
--hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \
--hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \
--hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \
--hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \
--hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \
--hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \
--hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \
--hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \
--hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \
--hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \
--hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \
--hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \
--hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \
--hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \
--hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \
--hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \
--hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \
--hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \
--hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \
--hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \
--hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \
--hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \
--hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \
--hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \
--hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \
--hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \
--hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae
# via pydantic
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via rich
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via botocore
python-dotenv==1.2.2 \
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
# via tht (harness/pyproject.toml)
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via tht (harness/pyproject.toml)
regex==2026.7.10 \
--hash=sha256:0639b2488b775a0109f55a5a2172deebdedb4b6c5ab0d48c90b43cbf5de58d17 \
--hash=sha256:081acf191b4d614d573a56cab69f948b6864daa5e3cc69f209ee92e26e454c2f \
--hash=sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f \
--hash=sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49 \
--hash=sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135 \
--hash=sha256:13fba679fe035037e9d5286620f88bbfd105df4d5fcd975942edd282ab986775 \
--hash=sha256:14d27f6bd04beb01f6a25a1153d73e58c290fd45d92ba56af1bb44199fd1010d \
--hash=sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067 \
--hash=sha256:1f0d4ccf70b1d13711242de0ba78967db5c35d12ac408378c70e06295c3f6644 \
--hash=sha256:21150500b970b12202879dfd82e7fd809d8e853140fff84d08e57a90cf1e154e \
--hash=sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43 \
--hash=sha256:221f2771cb780186b94bbf125a151bbeb242fa1a971da6ad59d7b0370f19de9a \
--hash=sha256:234f8e0d65cf1df9becadae98648f74030ee85a8f12edcb5eb0f60a22a602197 \
--hash=sha256:28a0973eeffff4292f5a7ee498ab65d5e94ee8cc9cea364239251eb4a260a0f1 \
--hash=sha256:2b93eafd92c4128bab2f93500e8912cc9ecb3d3765f6685b902c6820d0909b6b \
--hash=sha256:2bc350e1c5fa250f30ab0c3e38e5cfdffcd82cb8af224df69955cab4e3003812 \
--hash=sha256:2c66a8a1969cfd506d1e203c0005fd0fc3fe6efc83c945606566b6f9611d4851 \
--hash=sha256:2f98ef73a13791a387d5c841416ad7f52040ae5caf10bcf46fa12bd2b3d63745 \
--hash=sha256:31fa17378b29519bfd0a1b8ba4e9c10cf0baf1cf4099b39b0689429e7dc2c795 \
--hash=sha256:3750c42d47712e362158a04d0fd80131f73a55e8c715b2885442a0ff6f9fc3fc \
--hash=sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c \
--hash=sha256:396ea70e4ea1f19571940add3bad9fd3eb6a19dc610d0d01f692bc1ba0c10cb4 \
--hash=sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca \
--hash=sha256:3d8ef9df02c8083c7b4b855e3cb87c8e0ebbcfea088d98c7a886aaefdf88d837 \
--hash=sha256:3e23458d8903e33e7d27196d7a311523dc4e2f4137a5f34e4dbd30c8d37ff33e \
--hash=sha256:3f03b92fb6ec739df042e45b06423fc717ecf0063e07ffe2897f7b2d5735e1e8 \
--hash=sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48 \
--hash=sha256:41a47c2b28d9421e2509a4583a22510dc31d83212fcf38e1508a7013140f71a8 \
--hash=sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042 \
--hash=sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d \
--hash=sha256:4574feca202f8c470bf678aed8b5d89df04aaf8dc677f3b83d92825051301c0f \
--hash=sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6 \
--hash=sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70 \
--hash=sha256:4db009b4fc533d79af3e841d6c8538730423f82ea8508e353a3713725de7901c \
--hash=sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f \
--hash=sha256:53bbbd6c610489700f7110db1d85f3623924c3f7c760f987eca033867360788a \
--hash=sha256:53f54993b462f3f91fea0f2076b46deb6619a5f45d70dbd1f543f789d8b900ef \
--hash=sha256:58a4571b2a093f6f6ee4fd281faa8ebf645abcf575f758173ea2605c7a1e1ecb \
--hash=sha256:5c363de7c0339d39341b6181839ed32509820b85ef506deafcf2e7e43baadab4 \
--hash=sha256:5e792367e5f9b4ffb8cad93f1beaa91837056b94da98aa5c65a0db0c1b474927 \
--hash=sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2 \
--hash=sha256:617e8f10472e34a8477931f978ff3a88d46ae2ba0e41927e580b933361f60948 \
--hash=sha256:64722a5031aeace7f6c8d5ea9a9b22d9368af0d6e8fa532585da8158549ea963 \
--hash=sha256:65ee5d1ac3cd541325f5ac92625b1c1505f4d171520dd931bda7952895c5321a \
--hash=sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be \
--hash=sha256:66d2c35587cd601c95965d5c0415058ba5cfd6ffbab7624ce198bd967102b341 \
--hash=sha256:6cbedeb5112f59dbd169385459b9943310bdd241c6966c19c5f6e2295055c93a \
--hash=sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba \
--hash=sha256:724ee9379568658ec06362cf24325c5315cc5a67f61dfe585bfeff58300a355b \
--hash=sha256:7252b48b0c60100095088fbeb281fca9a4fcf678a4e04b1c520c3f8613c952c4 \
--hash=sha256:732c19e5828eb287d01edb83b2eb87f283ba8e5fc3441c732709d3e8cbd14aaa \
--hash=sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3 \
--hash=sha256:74ae61d8573ecd51b5eeee7be2218e4c56e99c14fa8fcf97cf7519611d4be92e \
--hash=sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb \
--hash=sha256:799a369bdab91dcf0eb424ebd7aa9650897025ce22f729248d8f2c72002c4daa \
--hash=sha256:80151ca5bfc6c4524186b3e08b499e97319b2001fc265ed2d4fc12c0d5692cdf \
--hash=sha256:82ab8330e7e2e416c2d42fcec67f02c242393b8681014750d4b70b3f158e1f08 \
--hash=sha256:8331484450b3894298bef8abecce532171ff6ac60b71f999eed10f2c01941a8a \
--hash=sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8 \
--hash=sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c \
--hash=sha256:87794549a3f5c1c2bdfba2380c1bf87b931e375f4133d929da44f95e396bf5fe \
--hash=sha256:87b776cf2890e356e4ab104b9df846e169da3eb5b0f110975547091f4e51854e \
--hash=sha256:8e26a075fa9945b9e44a3d02cc83d776c3b76bb1ff4b133bbfa620d5650131da \
--hash=sha256:91b916d495db3e1b473c7c8e68733beec4dce8e487442db61764fff94f59740e \
--hash=sha256:948dfc62683a6947b9b486c4598d8f6e3ecc542478b6767b87d52be68aeb55c6 \
--hash=sha256:982d07727c809b42a3968785354f11c3728414e4e90af0754345b431b2c32561 \
--hash=sha256:9a094ed44a22f9da497453137c3118b531fd783866ab524b0b0fc146e7395e1d \
--hash=sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5 \
--hash=sha256:9d028d189d8f38d7ff292f22187c0df37f2317f554d2ed9a2908ada330af57c0 \
--hash=sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89 \
--hash=sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6 \
--hash=sha256:a2d6d30be35ddd70ce0f8ee259a4c25f24d6d689a45a5ac440f03e6bcc5a21d1 \
--hash=sha256:a68b637451d64ba30ed8ae125c973fa834cc2d37dfa7f154c2b479015d477ba8 \
--hash=sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361 \
--hash=sha256:aa34473fbcc108fea403074f3f45091461b18b2047d136f16ffaa4c65ad46a68 \
--hash=sha256:ab2fb1f7a2deb4ca3ddebbae6b93905d21480a3b4e11de28d79d9fb0d316fcf8 \
--hash=sha256:ab39d2c967aae3b48a412bff9cdbe7cd7559cd1e277599aceaeada7bc82b7200 \
--hash=sha256:b04583e8867136ae66353fa274f45121ab3ec3166dc45aaff3655a5db90d9f0e \
--hash=sha256:b1963ec5ba4d52788fb0eac6aca6eb8040e8e318c7e47ebbdfc09440c802919c \
--hash=sha256:b56416091bfd7a429f958f69aaf6823c517be9a49cb5bf1daa3767ce8bf8095e \
--hash=sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173 \
--hash=sha256:b96341cb29a3faa5db05aff29c77d141d827414f145330e5d8846892119351c1 \
--hash=sha256:bb52e10e453b5493afe1f7702a2973bc10f4dd8901c0f2ed869ffaa3f8319296 \
--hash=sha256:bb5aab464a0c5e03a97abad5bdf54517061ebbf72340d576e99ff661a42575cc \
--hash=sha256:be4223af640d0aa04c05db81d5d96ada3ead9c09187d892fd37f4f97829480be \
--hash=sha256:c2cbd385d82f63bb35edb60b09b08abad3619bd0a4a492ae59e55afaf98e1b9d \
--hash=sha256:c57b6ad3f7a1bdd101b2966f29dc161adf49727b1e8d3e1e89db2eda8a75c344 \
--hash=sha256:c622f4c638a725c39abcb2e680b1bd592663c83b672a4ed350a17f806d75618e \
--hash=sha256:cae27622c094558e519abf3242cf4272db961d12c5c9a9ffb7a1b44b2627d5c6 \
--hash=sha256:cfcec18f7da682c4e2d82112829ce906569cb8d69fa6c26f3a50dfbed5ceb682 \
--hash=sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be \
--hash=sha256:d0834c84ae8750ae1c4cede59b0afd4d2f775be958e11b18a3eea24ed9d0d9f1 \
--hash=sha256:d3c75d57a00109255e60bc9c623b6ececaf7905eaab845c79f036670ed4750a2 \
--hash=sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794 \
--hash=sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3 \
--hash=sha256:da6ef4cb8d457aab0482b50120136ae94238aaa421863eaa7d599759742c72d6 \
--hash=sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478 \
--hash=sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca \
--hash=sha256:e21e888a6b471b2bb1cdd4247e8d86632672232f29be583e7eafaa5f4634d34c \
--hash=sha256:e37aba1994d73b4944053ab65a15f313bd5c28c885dd7f0d494a11749d89db6e \
--hash=sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06 \
--hash=sha256:e6b6a11bf898cca3ce7bfaa17b646901107f3975677fbd5097f36e5eb5641983 \
--hash=sha256:eac1207936555aa691ce32df1432b478f2729d54e6d93a1f4db9215bcd8eb47d \
--hash=sha256:ebbf0d83ed5271991d666e54bb6c90ac2c55fb2ef3a88740c6af85dc85de2402 \
--hash=sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90 \
--hash=sha256:ecae626449d00db8c08f8f1fc00047a32d6d7eb5402b3976f5c3fda2b80a7a4f \
--hash=sha256:ed7c886a2fcbf14493ceaf9579394b33521730c161ebb8dad7db9c3e9fcab1a8 \
--hash=sha256:ee877b6d78f9dff1da94fef51ae8cf9cce0967e043fdcc864c40b85cf293c192 \
--hash=sha256:f0192e5f1cfc70e3cb35347135dd02e7497b3e7d83e378aa226d8b3e53a93f19 \
--hash=sha256:f3463a5f26be513a49e4d497debcf1b252a2db7b92c77d89621aa90b83d2dd38 \
--hash=sha256:f6222cafe00e072bb2b8f14142cd969637411fbc4dd3b1d73a90a3b817fa046f \
--hash=sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2 \
--hash=sha256:fadb07dbe36a541283ff454b1a268afd54b077d917043f2e1e5615372cb5f200 \
--hash=sha256:fe7ff456c22725c9d9017f7a2a7df2b51af6df77314176760b22e2d05278e181
# via segtok
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
# via tht (harness/pyproject.toml)
rich==15.0.0 \
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
# via
# tht (harness/pyproject.toml)
# typer
s3transfer==0.19.1 \
--hash=sha256:d3d6371dc3f1e5c5427b2b457bcf13bcf87bec334c95aed18642eae61f6926f3 \
--hash=sha256:d5fd7005ee39307455ad5f310b5ea67f4b1960d7fed5b3671ee50c249de675de
# via boto3
scipy==1.18.0 \
--hash=sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446 \
--hash=sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468 \
--hash=sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b \
--hash=sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553 \
--hash=sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0 \
--hash=sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7 \
--hash=sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2 \
--hash=sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b \
--hash=sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61 \
--hash=sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8 \
--hash=sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8 \
--hash=sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6 \
--hash=sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690 \
--hash=sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de \
--hash=sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578 \
--hash=sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab \
--hash=sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce \
--hash=sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f \
--hash=sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520 \
--hash=sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378 \
--hash=sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197 \
--hash=sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709 \
--hash=sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132 \
--hash=sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867 \
--hash=sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a \
--hash=sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677 \
--hash=sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4 \
--hash=sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0 \
--hash=sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58 \
--hash=sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8 \
--hash=sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76 \
--hash=sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9 \
--hash=sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f \
--hash=sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11 \
--hash=sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4 \
--hash=sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b \
--hash=sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b \
--hash=sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76 \
--hash=sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f \
--hash=sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d \
--hash=sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707
# via datasketch
segtok==1.5.11 \
--hash=sha256:8ab2dd44245bcbfec25b575dc4618473bbdf2af8c2649698cd5a370f42f3db23 \
--hash=sha256:910616b76198c3141b2772df530270d3b706e42ae69a5b30ef115c7bd5d1501a
# via yake
setuptools==80.9.0 \
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
# via -r docker/python-runtime/build-requirements.in
shellingham==1.5.4 \
--hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \
--hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de
# via typer
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
sqlalchemy==2.0.51 \
--hash=sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23 \
--hash=sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1 \
--hash=sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8 \
--hash=sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72 \
--hash=sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0 \
--hash=sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5 \
--hash=sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e \
--hash=sha256:111604e637da87031255ddc26c7d7bc22bc6af6f5d459ccff3af1b4660233a85 \
--hash=sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d \
--hash=sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2 \
--hash=sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba \
--hash=sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652 \
--hash=sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f \
--hash=sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9 \
--hash=sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84 \
--hash=sha256:247acaa29ccef6250dfd6a3eedf8f94ddf23564180a39fe362e32ae9dbdbde46 \
--hash=sha256:2a97eaad21c84b4ef8010b11eeba9fe6153eb0b3df3ff8b6abc309df1b978ef7 \
--hash=sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080 \
--hash=sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d \
--hash=sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d \
--hash=sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54 \
--hash=sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd \
--hash=sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195 \
--hash=sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc \
--hash=sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e \
--hash=sha256:59cab3686b1bc039dd9cded2f8d0c08a246e84e76bd4ab5b4f18c7cdae293825 \
--hash=sha256:6b588fd681ddf0c196b8df1ea49a8913514894b2b8f945a9511b4b48871f99c8 \
--hash=sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522 \
--hash=sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491 \
--hash=sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400 \
--hash=sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a \
--hash=sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07 \
--hash=sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7 \
--hash=sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a \
--hash=sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9 \
--hash=sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7 \
--hash=sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499 \
--hash=sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5 \
--hash=sha256:a42ad6afcbaaa777241e347aa2e29155993045a0d6b7db74da61053ffe875fe0 \
--hash=sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604 \
--hash=sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265 \
--hash=sha256:aa18ae738b5170e253ad0bb6c4b0f07585081e8a6e50893e4d911d47b39a0904 \
--hash=sha256:ad30ae663711786303fbcd46a47516302d201ee49a877cb3fac61f672895110a \
--hash=sha256:b21f0e7efc7a5c509e953784e9d1575ebb8b4318960e7e7d7a93bb803626cf64 \
--hash=sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d \
--hash=sha256:b7f08588854bbb724041d9ae9d980d40040c922382e1d9a2ecb390edc4fd5032 \
--hash=sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b \
--hash=sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5 \
--hash=sha256:bb1f5062f98b0b3290e72b707747fdd7e0f22d6956b236ba7ca7f5c9971d2da2 \
--hash=sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d \
--hash=sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389 \
--hash=sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080 \
--hash=sha256:c95ef01f53233a305a874a44a63fbfb1d81cd79b49de0f8529b3548cde437e37 \
--hash=sha256:ca216e8af5c05e326efc7e28716ac2381a7cf9791749f5ee1849dccdc99c9b00 \
--hash=sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86 \
--hash=sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260 \
--hash=sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de \
--hash=sha256:fa268106c8987639a17a18514cfe0cd9bf17420ab887e1e1bf486da8836135b1
# via tht (harness/pyproject.toml)
sqlglot==30.12.0 \
--hash=sha256:6b8369704662d4f654bc934cea4dd31c916c2a571b389210cb9e951a275e5fd9 \
--hash=sha256:86cccc610073c645c03e72b55b60ae0518aa3253a7fc3bd56551370d003c6554
# via tht (harness/pyproject.toml)
tabulate==0.10.0 \
--hash=sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d \
--hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3
# via yake
tqdm==4.68.4 \
--hash=sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520 \
--hash=sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2
# via tht (harness/pyproject.toml)
typer==0.26.8 \
--hash=sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c \
--hash=sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e
# via tht (harness/pyproject.toml)
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
# via
# pydantic
# pydantic-core
# sqlalchemy
# typing-inspection
typing-inspection==0.4.2 \
--hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \
--hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464
# via pydantic
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via
# botocore
# requests
yake==0.7.3 \
--hash=sha256:38f7f135ff8ed4bcdc05e16b533a9dc93299f1e694b0c308c3c086bab316c5fe \
--hash=sha256:8778fb2832e58d26d838d6d7ac967b4947521f1fe8cdf23dd872636161fc53ed
# via tht (harness/pyproject.toml)
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
set -eu
test "$(id -u)" != "0"
node_version="$(node --version)"
python_version="$(python --version 2>&1)"
case "$node_version" in
v22.19.*|v22.2[0-9].*|v2[3-9].*|v[3-9][0-9].*) ;;
*) echo "Node 22.19+ required, found $node_version" >&2; exit 1 ;;
esac
case "$python_version" in
"Python 3.1"[1-9].*|"Python 3."[2-9][0-9].*) ;;
*) echo "Python 3.11+ required, found $python_version" >&2; exit 1 ;;
esac
tht --help >/dev/null
pi --version >/dev/null
/app/docker/core-entrypoint.sh server &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true' EXIT INT TERM
attempt=0
until curl --fail --silent --show-error http://127.0.0.1:8787/health >/dev/null; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
echo "backend health check did not become ready" >&2
exit 1
fi
sleep 1
done
echo "$node_version"
echo "$python_version"
echo "core smoke: ok"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
corpus=/etc/thothii/backend-url-cases.json
jq -c '.[]' "$corpus" | while IFS= read -r case_json; do
value=$(printf '%s' "$case_json" | jq -r '.value')
valid=$(printf '%s' "$case_json" | jq -r '.valid')
if BACKEND_BASE_URL="$value" /usr/local/bin/frontend-entrypoint true \
>/dev/null 2>&1; then
actual=true
else
actual=false
fi
if [ "$actual" != "$valid" ]; then
echo "entrypoint policy mismatch for BACKEND_BASE_URL=$value: expected $valid" >&2
exit 1
fi
done
echo "frontend entrypoint canonical URL corpus: ok"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
assignment=$(sed \
-e 's/^window\.__THOTHII_CONFIG__ = //' \
-e 's/;$//' \
/usr/share/nginx/html/config.js)
printf '%s\n' "$assignment" \
| jq -e --arg expected "${BACKEND_BASE_URL-/api}" \
'type == "object" and keys == ["backendBaseUrl"] and .backendBaseUrl == $expected' \
>/dev/null
printf '%s\n' "frontend runtime config smoke: ok"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
set -eu
value=${1-}
policy_file=${BACKEND_URL_POLICY_FILE:-/etc/thothii/backend-url-policy.json}
if jq -e --arg value "$value" '.relativeBases | index($value) != null' \
"$policy_file" >/dev/null; then
exit 0
fi
if ! jq -e --arg value "$value" \
'.absolutePattern as $pattern | $value | test($pattern)' \
"$policy_file" >/dev/null; then
exit 2
fi
authority=${value#*://}
authority=${authority%%/*}
port=""
case "$authority" in
*]:*) port=${authority##*:} ;;
*]) ;;
*:*) port=${authority##*:} ;;
esac
if [ -n "$port" ]; then
max_port=$(jq -r '.maxPort' "$policy_file")
if [ "${#port}" -gt 5 ] || [ "$port" -gt "$max_port" ]; then exit 2; fi
fi
+23
View File
@@ -2,6 +2,29 @@
Pi (il coding agent che orchestra il workflow NL→SQL) può risolvere un `provider/model` in tre modi diversi. Non sono alternativi: coesistono, e la scelta di quale usare dipende da **quanto è standard l'endpoint** e da **quanto deve essere ampia la visibilità** del modello (tutti i progetti vs. un progetto solo).
## Credenziali nel backend container
In produzione configurare una sola sorgente generica, `THT_MODEL_API_KEY_FILE`, come secret file
assoluto e non il valore della chiave. `PiProcessManager` rilegge e valida il file per ogni processo,
normalizza il provider selezionato e passa al solo child Pi la variabile nativa appropriata
(`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `ZAI_API_KEY`, ecc.). Il percorso generico,
le chiavi di provider non selezionati e il vecchio `PI_PROVIDER_API_KEY` vengono rimossi dall'ambiente
del child. Provider locali come `ollama`, `lmstudio` e `aritmolab` continuano senza chiave; un provider
hosted non mappato o un secret mancante/non sicuro fallisce prima dello spawn con errore sanitizzato.
La sorgente generica supporta soltanto provider con una singola chiave: `ant-ling`, `anthropic`,
`cerebras`, `deepseek`, `fireworks`, `github-copilot`, `google` (anche tramite alias `gemini`),
`google-vertex` in modalità API key, `groq`, `huggingface`, `kimi-coding`, `minimax`, `minimax-cn`,
`mistral`, `moonshotai`, `moonshotai-cn`, `nvidia`, `openai`, `opencode`, `opencode-go`,
`openrouter`, `together`, `vercel-ai-gateway`, `xai`, i quattro provider `xiaomi*`, `zai` e
`zai-coding-cn`.
I provider composti `amazon-bedrock`, `azure-openai-responses`, `cloudflare-workers-ai` e
`cloudflare-ai-gateway` non sono rappresentabili da un solo file. La selezione fallisce prima
dello spawn (anche durante l'elenco modelli); tutte le credenziali ambientali AWS, Azure e
Cloudflare restano comunque rimosse. Servirà una futura configurazione dedicata per provider per
supportare questi bundle senza ambiguità.
## I tre livelli di provenienza di un modello
### 1. Built-in (compilato dentro Pi)
+4
View File
@@ -8,6 +8,10 @@ La documentazione è divisa in due aree:
Come funziona il sistema: architettura, specifiche di design delle singole funzionalità, piani di implementazione, report di test. Parte da qui: [Panoramica dell'architettura](architecture/overview.md).
Per installare l'applicazione in Docker nei quattro contesti operativi, partendo dal comando
predefinito `docker compose up --build -d` e dal bundle unico dei secret:
[Installazione Docker nei quattro contesti](installazione-docker-4-contesti.md).
## Considerazioni Generali
Note operative e di configurazione che non sono specifiche del dominio ThothII ma riguardano l'ambiente di sviluppo condiviso con altri progetti — ad esempio come Pi (il coding agent) risolve i modelli a livello built-in, utente e progetto. Parte da qui: [Configurazione dei modelli in Pi](general/pi-configuration.md).
+234
View File
@@ -0,0 +1,234 @@
# Installazione Docker nei quattro contesti operativi
ThothII viene distribuito con due immagini applicative:
- `thothii-core`: backend Fastify, harness `tht` e Pi;
- `thothii-frontend`: frontend React servito da nginx.
PostgreSQL/pgvector, DWH ed Evidence restano esterni nel profilo predefinito. Il profilo opzionale `local-vector` avvia PostgreSQL/pgvector nel progetto Compose.
## Installazione comune (il comando standard)
Servono Docker Engine/Compose v2 su Linux oppure Docker Desktop su macOS/Windows. Dalla directory in cui si vuole conservare il clone:
```sh
git clone <URL-REPOSITORY> ThothII
cd ThothII
cp .env.example .env
mkdir -p deploy/secrets deploy/workspaces
cp deploy/secrets/thothii.secrets.example deploy/secrets/thothii.secrets
chmod 600 deploy/secrets/thothii.secrets
```
Modificare **solo** questi file interni al clone:
| File | Cosa contiene |
|---|---|
| `.env` | endpoint, database, provider, `COMPOSE_FILE` e `COMPOSE_PROFILES`; mai password/token |
| `deploy/secrets/thothii.secrets` | un bundle `NOME=VALORE`, mode host `0600` o `0400` |
| `deploy/workspaces/<nome>.yaml` | adapter, endpoint non riservati, `roots` ed Evidence |
Il file `.env` viene caricato automaticamente da Docker Compose perché è nella radice del progetto. Il valore predefinito è `COMPOSE_FILE=compose.yaml`, con profili vuoti e `THT_SECRETS_FILE=deploy/secrets/thothii.secrets`. Perciò, dopo aver compilato `.env`, il bundle e almeno il workspace, l'avvio normale è sempre:
```sh
docker compose up --build -d
```
Non occorre usare `--env-file`, `-f` o `--profile` per questa installazione. Verificare lo stato con `docker compose ps` e aprire <http://127.0.0.1:8080>. `docker compose down` conserva il volume `thoth_data`; usare `down --volumes` solo per un ambiente effimero.
### Formato del bundle unico
`deploy/secrets/thothii.secrets` è un file di testo locale, non uno script shell. Sono ammessi commenti e righe vuote; ogni altra riga deve essere una sola assegnazione senza spazi:
```dotenv
THT_MODEL_API_KEY=...
THT_DWH_API_KEY=...
THT_VEC_API_KEY=...
THT_VEC_WRITE_API_KEY=...
THT_VECTOR_BOOTSTRAP_PASSWORD=...
THT_VECTOR_MIGRATOR_PASSWORD=...
THT_VECTOR_READER_PASSWORD=...
THT_VECTOR_WRITER_PASSWORD=...
```
Inserire solo le chiavi necessarie al profilo scelto. Il bundle viene montato in sola lettura nel container come `/run/secrets/thothii.secrets`; il parser rifiuta duplicati, chiavi sconosciute, valori vuoti, symlink e permessi host troppo aperti. Non inserire secret in `.env`, nei workspace, negli URL o nell'output di `docker compose config`.
Una catena CA PEM **non può essere inserita nel bundle**: contiene whitespace e viene rifiutata dal parser. Se un endpoint usa una CA privata, conservarla nel secret manager/host e aggiungere un override Compose revisionato che monti il file in `/run/secrets/ca-chain.pem` e imposti `THT_SSL_CA` (o il parametro dell'adapter). Il clone base non crea quel mount: questa è una limitazione intenzionale da considerare in fase di deployment.
### Overlay opzionali tramite `.env`
Gli overlay non cambiano il comando operativo. Impostare in `.env`:
```dotenv
# DWH/vector/embedding remoti (server applicativo o server con i DB):
COMPOSE_FILE=compose.yaml:deploy/compose.production.yaml
COMPOSE_PROFILES=
# pgvector locale (Mac, Windows o server autonomo):
COMPOSE_FILE=compose.yaml:deploy/compose.local-vector.yaml
COMPOSE_PROFILES=local-vector
```
Su Windows usare `;` come separatore di `COMPOSE_FILE`. Per il preprocessing locale aggiungere `deploy/compose.preprocess.yaml:deploy/compose.preprocess-local-vector.yaml` e impostare `COMPOSE_PROFILES=local-vector,preprocess`; poi usare `docker compose run --rm preprocess-evidence` oppure `docker compose run --rm preprocess-dwh`.
## Workspace, adapter e Evidence
Il workspace YAML seleziona il trasporto disponibile. Esempio DWH REST e vector DB HTTP:
```yaml
language: en
dwh:
type: thoth_rest
database: {database: warehouse, schema: datawarehouse}
endpoint: {base_url: https://dwh.example.test}
vectors:
type: thoth_vector_http
reader: {base_url: https://vectors.example.test}
writer: {base_url: https://vectors.example.test}
roots: {artifacts: artifacts, indexes: indexes, sessions: sessions}
evidence: {source_root: /data/source, evidence_dir: evidence}
embeddings: {base_url: https://embeddings.example.test, model: nomodel, dim: 768}
```
Esempio con accesso diretto a PostgreSQL e pgvector:
```yaml
language: en
dwh:
type: postgres_direct
connection: {host: dwh.internal, database: warehouse, schema: public,
user: thoth_reader, password_file: /run/secrets/dwh_password}
vectors:
type: pgvector_direct
reader: {host: vector.internal, database: thoth, schema: vectors,
user: thoth_vector_reader, password_file: /run/secrets/vector_reader_password}
writer: {host: vector.internal, database: thoth, schema: vectors,
user: thoth_vector_writer, password_file: /run/secrets/vector_writer_password}
roots: {artifacts: artifacts, indexes: indexes, sessions: sessions}
```
Questo esempio mostra il contratto dell'adapter: i file indicati da `password_file` devono
essere montati da un override Compose approvato. Il profilo base monta soltanto il bundle unico;
per un DWH diretto occorre quindi materializzare il file password dal secret manager e aggiungere
il bind mount/runtime adapter corrispondente. Non inserire la password nel workspace o nell'URL.
`roots` sono relativi e vengono risolti sotto `/data/workspaces/<workspace>` nel volume Docker; non inserire path host come `/Users/...` o `C:\\...`. Per Evidence usare una radice filesystem montata in sola lettura oppure l'adapter HTTP/S3 previsto dal workspace. Per HTTP/S3 definire allowlist, limiti di dimensione/paginazione e una politica egress; non mettere token nelle URI.
## 1. Server remoto insieme ai database e al vector DB
Usare quando il server Docker è nella stessa rete del DWH e del vector DB (containerizzati o meno). Il file `.env` può restare sul default, senza profili, impostando gli endpoint raggiungibili localmente:
```dotenv
COMPOSE_FILE=compose.yaml
COMPOSE_PROFILES=
THT_DB_NAME=warehouse
THT_DWH_REST_URL=https://dwh.internal.example
THT_VEC_REST_URL=https://vectors.internal.example
THT_OLLAMA_URL=https://embeddings.internal.example
AUTH_MODE=none
THOTH_PUBLIC_EXPOSURE=false
```
Riempire nel bundle le chiavi DWH/vector/model necessarie e avviare:
```sh
docker compose up --build -d
docker compose exec core /opt/venv/bin/tht doctor --json
```
Se si abilita l'overlay production, il proxy autenticato TLS deve essere l'unico listener pubblico
e deve iniettare `X-Authenticated-User`; non esporre direttamente la porta pubblicata da nginx.
Se il server deve essere raggiungibile da altri host, sostituire `COMPOSE_FILE` con
`compose.yaml:deploy/compose.production.yaml`, configurare il proxy autenticato e impostare
`AUTH_MODE=upstream`/`THOTH_PUBLIC_EXPOSURE=true` come descritto nella sezione di trust boundary.
## 2. Mac locale
Installare Docker Desktop e, se usato, Ollama sul Mac. Nel `.env` selezionare il profilo locale:
```dotenv
COMPOSE_FILE=compose.yaml:deploy/compose.local-vector.yaml
COMPOSE_PROFILES=local-vector
THT_DB_NAME=warehouse
THT_DWH_REST_URL=https://dwh.example.test
THT_OLLAMA_URL=http://host.docker.internal:11434
THT_DOCS_ROOT=/data/source/evidence
```
Nel bundle aggiungere quattro password generate localmente:
```dotenv
THT_VECTOR_BOOTSTRAP_PASSWORD=<valore casuale>
THT_VECTOR_MIGRATOR_PASSWORD=<valore casuale>
THT_VECTOR_READER_PASSWORD=<valore casuale>
THT_VECTOR_WRITER_PASSWORD=<valore casuale>
```
Poi eseguire il comando standard `docker compose up --build -d`. Il primo avvio esegue reconciliation dei ruoli e migrazione pgvector. Per preprocessing, impostare il preset indicato sopra e usare `docker compose run --rm preprocess-evidence`/`preprocess-dwh`.
## 3. PC Windows locale
Usare Docker Desktop con backend WSL2 e abilitare la condivisione della directory del clone. Modificare `.env` con il separatore Windows:
```dotenv
COMPOSE_FILE=compose.yaml;deploy/compose.local-vector.yaml
COMPOSE_PROFILES=local-vector
THT_DB_NAME=warehouse
THT_DWH_REST_URL=https://dwh.example.test
THT_OLLAMA_URL=http://host.docker.internal:11434
THT_DOCS_ROOT=/data/source/evidence
```
Creare `deploy/secrets/thothii.secrets` con un editor locale protetto (ACL leggibile solo dall'utente Docker) e le stesse quattro chiavi pgvector del profilo Mac. Non usare `ConvertFrom-SecureString`: il bundle deve contenere il valore in chiaro per il servizio, con accesso limitato al file. Da PowerShell, dalla radice del clone, eseguire:
```powershell
docker compose up --build -d
docker compose ps
```
Se un bind mount viene rifiutato, aggiungere la cartella del repository a Docker Desktop → Settings → Resources → File Sharing. Per Ollama eseguito in WSL2 usare l'indirizzo raggiungibile dalla rete Docker invece di assumere `localhost`.
## 4. Server applicativo distinto da DB ed Evidence
Usare il profilo production e consentire dal firewall solo le destinazioni necessarie:
```dotenv
COMPOSE_FILE=compose.yaml:deploy/compose.production.yaml
COMPOSE_PROFILES=
THT_DB_NAME=warehouse
THT_DWH_REST_URL=https://dwh.example.test
THT_VEC_REST_URL=https://vectors.example.test
THT_OLLAMA_URL=https://embeddings.example.test
```
Il DWH e il vector DB possono essere REST/HTTP oppure adapter diretti (`postgres_direct`, `pgvector_direct`) se il server ha connettività TCP. Le Evidence possono essere:
- filesystem NFS/SMB montato sul server e presentato come root read-only;
- endpoint HTTPS, con allowlist e limiti SSRF;
- bucket S3 con secret references e endpoint custom esplicitamente autorizzati.
Il preprocessing può girare sul server applicativo usando il volume `/data`; mantenere separati workspace, lock e artefatti dei job. Avviare con il comando standard e verificare `tht doctor`.
## Migrazione da installazioni con secret separati
Le variabili `THT_*_SECRET_FILE` e i file `dwh-api-key`, `vector-reader-api-key`, `vector-writer-api-key`, `model-api-key` e `vector_*_password` appartengono al layout precedente. Non vengono importati automaticamente dal bundle. Per migrare:
1. creare `deploy/secrets/thothii.secrets` mode `0600`;
2. copiare ogni valore nel nome chiave corrispondente (`THT_DWH_API_KEY`, `THT_VEC_API_KEY`, `THT_VEC_WRITE_API_KEY`, `THT_MODEL_API_KEY` o `THT_VECTOR_*_PASSWORD`), senza virgolette né newline;
3. rimuovere dal `.env` le variabili `_SECRET_FILE` e impostare `THT_SECRETS_FILE` al percorso del bundle (il default relativo è già corretto);
4. eseguire `docker compose config --quiet` e poi `docker compose up --build -d`;
5. solo dopo la verifica, cancellare i vecchi file separati.
Una CA PEM resta un'eccezione esterna come descritto sopra. Provider Pi con credenziali composte (Bedrock, Azure OpenAI Responses, Cloudflare Workers AI/Gateway) restano rifiutati finché non viene implementato un adapter dedicato.
## Controlli post-installazione
```sh
docker compose config --quiet
docker compose ps
docker compose exec core /opt/venv/bin/tht doctor --json
./scripts/docker-smoke.sh
```
Per il profilo locale usare anche `./scripts/local-vector-smoke.sh`; per il preprocessing `./scripts/preprocess-smoke.sh`. Non pubblicare `.env` o `deploy/secrets/thothii.secrets` nei log, nei backup Git o nei ticket.
@@ -26,7 +26,7 @@
- Test: `harness/tests/test_dwh_port_contract.py`
**Interfaces:**
- Produces: `DwhCapabilities`, `DwhAdapter`, `DwhHealth`, and `UnsupportedCapability`.
- Produces: `DwhCapabilities`, `DwhAdapter`, `DwhHealth`, `DistinctValues`, and `UnsupportedCapability`.
- Consumes: existing catalog models from `tht.db.introspect` and execution result types from `tht.db.execute`.
- [ ] **Step 1: Write the failing protocol-shape test**
@@ -54,16 +54,21 @@ class DwhCapabilities:
sampling: bool = True
distinct_values: bool = True
@dataclass(frozen=True)
class DistinctValues:
values: list[object]
truncated: bool
@runtime_checkable
class DwhAdapter(Protocol):
@property
def capabilities(self) -> DwhCapabilities: ...
def health(self) -> DwhHealth: ...
def introspect(self) -> DatabaseCatalog: ...
def run_query(self, sql: str, *, limit: int | None = None) -> QueryResult: ...
def introspect(self) -> PhysicalSchema: ...
def run_query(self, sql: str, *, limit: int) -> ExecResult: ...
def explain(self, sql: str) -> PlanSummary: ...
def sample_column(self, table: str, column: str, *, limit: int) -> list[object]: ...
def distinct_values(self, table: str, column: str) -> list[object]: ...
def distinct_values(self, table: str, column: str) -> DistinctValues: ...
```
- [ ] **Step 4: Run contract test and type-oriented import smoke test**
@@ -85,8 +90,15 @@ git commit -m "refactor(dwh): define adapter contract"
- Create: `harness/tht/adapters/dwh/postgres.py`
- Create: `harness/tht/adapters/dwh/thoth_rest.py`
- Test: `harness/tests/test_dwh_adapters.py`
- Test: `harness/tests/test_dwh_port_contract.py`
- Test: `harness/tests/l0/test_db_sampling.py`
- Modify: `harness/tht/ports/__init__.py`
- Modify: `harness/tht/ports/dwh.py`
- Modify: `harness/tht/execute/__init__.py`
- Modify: `harness/tht/db/execute.py`
- Modify: `harness/tht/db/sampling.py`
- Modify: `harness/tht/rest/execute.py`
- Modify: `docs/superpowers/plans/2026-07-11-adapter-foundations.md`
**Interfaces:**
- Consumes: `DwhAdapter` from Task 1; existing `DatabaseConfig`, `RestConfig`, catalog, sampling, execute, and explain functions.
@@ -97,8 +109,8 @@ git commit -m "refactor(dwh): define adapter contract"
```python
@pytest.mark.parametrize("factory", [postgres_factory, rest_factory])
def test_adapter_rejects_write_sql(factory):
with pytest.raises(ReadOnlyViolation):
factory().run_query("delete from fact_sales")
with pytest.raises(ExecutionError):
factory().run_query("delete from fact_sales", limit=10)
```
- [ ] **Step 2: Verify failure**
@@ -111,12 +123,18 @@ Expected: FAIL because the adapter classes are absent.
```python
class PostgresDwhAdapter:
capabilities = DwhCapabilities()
def __init__(self, config: DatabaseConfig): self._config = config
def run_query(self, sql: str, *, limit: int | None = None) -> QueryResult:
return run_query(self._config, sql, limit=limit)
def __init__(self, config: DatabaseConfig):
self._config = config
self._engine = make_engine(config)
def run_query(self, sql: str, *, limit: int) -> ExecResult:
return run_query(self._engine, sql, limit=limit)
```
Implement the analogous REST wrapper by delegating to `tht.rest.*`; translate transport-specific errors only at the adapter boundary.
Implement the analogous REST wrapper by delegating to `tht.rest.*`; translate transport-specific
errors only at the adapter boundary. Both wrappers delegate frequency-ranked, distinct sampling to
the paired implementations in `tht.db.sampling`. Query and sampling limits must be runtime-positive
integers (booleans and floats are rejected), and `distinct_values` reports any cap through
`DistinctValues.truncated`.
- [ ] **Step 4: Run adapter, read-only, sampling, and REST tests**
@@ -126,7 +144,11 @@ Expected: PASS; L0 may deselect when Docker is unavailable.
- [ ] **Step 5: Commit**
```bash
git add harness/tht/adapters harness/tht/db/execute.py harness/tht/rest/execute.py harness/tests/test_dwh_adapters.py
git add docs/superpowers/plans/2026-07-11-adapter-foundations.md \
harness/tht/ports harness/tht/adapters/dwh harness/tht/execute/__init__.py \
harness/tht/db/execute.py harness/tht/db/sampling.py harness/tht/rest/execute.py \
harness/tests/test_dwh_port_contract.py harness/tests/test_dwh_adapters.py \
harness/tests/l0/test_db_sampling.py
git commit -m "refactor(dwh): adapt direct and REST transports"
```
@@ -141,7 +163,8 @@ git commit -m "refactor(dwh): adapt direct and REST transports"
- Modify: `harness/tht/vectorstore/reader.py`
**Interfaces:**
- Produces: `VectorStore`, `VectorCapabilities`, `VectorHealth`, `VectorRecord`, `VectorHit`, `ThothHttpVectorStore`.
- Produces: `VectorStore`, `VectorCapabilities`, `VectorHealth`, `VectorRecord`,
`VectorWriteRecord`, `VectorHit`, `ThothHttpVectorStore`.
- Preserves: current `VectorRestClient`, `DirectSearcher`, and `RestSearcher` behavior behind wrappers.
- [ ] **Step 1: Write read/write capability and dual-credential tests**
@@ -171,9 +194,13 @@ class VectorStore(Protocol):
def search(self, collections: list[str], embedding: list[float], *, limit: int,
kinds: list[str] | None = None) -> list[VectorHit]: ...
def existing_hashes(self, collection: str, kinds: list[str]) -> dict[str, str]: ...
def upsert(self, collection: str, records: list[VectorRecord]) -> int: ...
def upsert(self, collection: str, records: list[VectorWriteRecord]) -> int: ...
```
`VectorWriteRecord` is the transport-neutral write envelope: it contains the canonical
`VectorRecord`, a precomputed embedding, and a content hash. Adapters must preserve
`VectorRecord.metadata` unchanged, including semantic keys named `embedding` or `content_hash`.
- [ ] **Step 4: Run vector regression tests**
Run: `cd harness && .venv/bin/pytest tests/test_vector_port_contract.py tests/test_vector_dual_key.py tests/test_search_similar_kinds.py tests/test_memory_save_one.py tests/test_solved_question.py -q`
@@ -262,6 +289,18 @@ git commit -m "feat(config): add typed resource schema"
- Produces: `build_dwh(cfg: Config) -> DwhAdapter` and `build_vector_store(cfg: Config, *, require_write: bool = False) -> VectorStore`.
- Consumes: resource configs from Task 4 and wrappers from Tasks 2-3.
Correction: `DwhAdapter.distinct_values(table, column, *, limit)` requires an explicit
positive limit, and direct DWH construction injects `cfg.execution.statement_timeout_ms`.
Targeted vector writes consume the factory-returned `VectorStore` and pass
`VectorWriteRecord` objects to `upsert`.
Transitional exception: `build_vector_loader` remains solely for bulk collection sync
(`vector init`/rebuild/index flows). It may still construct the legacy table-scoped writer
directly until `docs/superpowers/plans/2026-07-11-local-pgvector-profile.md` migrates the
local pgvector/vector schema and bulk-sync path. Interactive and targeted writes
(`memory save-one` and solved-question indexing) are not covered by this exception and must
continue through `build_vector_store(..., require_write=True)` and the public vector port.
- [ ] **Step 1: Write exact factory selection and missing-writer tests**
```python
@@ -236,6 +236,13 @@ git commit -m "build(docker): add runtime-configured frontend image"
### Task 5: Compose external profile and end-to-end smoke gate
> **Final-review security amendment (2026-07-12):** the frontend port binds to `127.0.0.1` by
> default. Public deployment uses an authenticated upstream proxy with `AUTH_MODE=upstream`;
> `THOTH_PUBLIC_EXPOSURE=true` plus `AUTH_MODE=none` is invalid. Local env files are development
> only; production uses read-only Compose secrets. Image gates pin exact tags and multi-platform
> digests and verify both linux/amd64 and linux/arm64 using the shared container verification
> script.
**Files:**
- Create: `compose.yaml`
- Create: `deploy/env.example`
@@ -0,0 +1,70 @@
# Local and Server Docker Deployment Implementation Plan
> **For Codex:** execute this plan in the current isolated worktree; keep runtime credentials out of Git.
**Goal:** Configure and verify a Docker Desktop deployment using GLM 5.2 and the existing PSD workspace, while retaining a portable server deployment contract.
**Architecture:** The base Compose file builds two applications and consumes only generic environment values and a Docker secret bundle. A tracked GLM Pi registry is mounted read-only in the core container. A Git-ignored local override supplies Mac-specific PSD workspace and CA mounts; server operators supply equivalent server runtime values separately.
**Tech Stack:** Docker Compose v2, Node 22, Python 3.12, Pi RPC, Fastify, nginx.
---
### Task 1: Add the non-secret GLM Pi registry
**Files:**
- Create: `deploy/pi/models.json`
- Modify: `docker/core.Dockerfile`
- Modify: `compose.yaml`
- Test: Compose configuration and Pi model discovery
1. Define the `zai/glm-5.2` OpenAI-compatible model registry without a credential.
2. Create the Pi user configuration directory in the core image and mount the registry read-only.
3. Verify that `get_available_models` returns `zai/glm-5.2` when the bundle supplies the model key.
### Task 2: Add generic PSD-compatible runtime templates
**Files:**
- Create: `deploy/workspaces/psd.yaml.example`
- Create: `deploy/compose.psd-local.yaml.example`
- Modify: `deploy/env.example`
- Modify: `README.md`
1. Define a relative `/data/workspaces/psd` workspace configuration with external REST DWH/vector adapters.
2. Document required non-secret environment values and the local/server boundary.
3. Keep host paths and credential values out of all tracked files.
### Task 3: Materialize local runtime configuration securely
**Files (ignored):**
- Create: `.env`
- Create: `deploy/secrets/thothii.secrets`
- Create: `deploy/compose.psd-local.yaml`
- Create: `deploy/workspaces/psd.yaml`
1. Transfer only required values from the existing local configuration without writing them to logs.
2. Set `PI_PROVIDER=zai`, `PI_MODEL=glm-5.2`, and the Docker Desktop host gateway for Ollama.
3. Bind-mount the PSD workspace and private CA read-only where appropriate; sessions remain writable.
4. Enforce restricted modes on the secret bundle.
### Task 4: Build and verify the Docker deployment
**Commands:**
- `docker compose config --quiet`
- `docker compose build`
- `docker compose up -d`
- health/API/model/session smoke checks
1. Validate rendered Compose configuration without exposing secrets.
2. Build the core and frontend images.
3. Verify secret mount, core and frontend health, and model listing.
4. Start a PSD session using GLM 5.2 and verify Pi emits a workflow event or gate.
5. Capture sanitized diagnostics and stop only disposable test resources; leave the validated local stack running unless it fails.
### Task 5: Record the deployment result
**Files:**
- Modify: `README.md` or deployment documentation
1. Record the exact local startup command and server-equivalent configuration steps.
2. State verified endpoints, model, and session-start result without secret values.
@@ -0,0 +1,156 @@
# Simple Docker Configuration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task with review checkpoints.
**Goal:** Make a fresh ThothII clone runnable with `docker compose up --build -d`, using one
`deploy/secrets/thothii.secrets` bundle while preserving a tested legacy fallback.
**Architecture:** A strict Python secret-bundle loader becomes the single in-process source of
secret values. Compose mounts the one bundle only where needed; the core converts values to
provider/database runtime interfaces without logging or placing them in argv. The root `.env`
is the default Compose interpolation file and selects the appropriate overlay through
`COMPOSE_FILE`/`COMPOSE_PROFILES`; legacy `THT_*_SECRET_FILE` installations remain supported.
**Tech Stack:** Docker Compose v2, YAML, Python 3.12/Pydantic, Fastify/TypeScript, shell smoke
tests, pytest, Vitest.
## Global Constraints
- The normal command must be exactly `docker compose up --build -d` from `ThothII/`.
- The canonical secret bundle is `deploy/secrets/thothii.secrets`, key/value syntax, mode `0600`,
ignored by Git and excluded from image build contexts.
- Secret values must never appear in Compose config output, logs, argv, settings, health, or
committed workspace files.
- Existing `THT_*_SECRET_FILE` variables remain a documented compatibility path until removed by
a later migration.
- External, local-vector, and preprocess overlays must remain independently renderable.
- Provider compound credentials remain fail-closed; only supported single-key providers are
restored from the bundle.
- Every task starts with a failing regression test and ends with focused tests, diff checks, and
a small commit.
---
### Task 1: Add the strict secret-bundle loader and compatibility adapter
**Files:**
- Create: `backend/src/config/secret-bundle.ts`
- Modify: `backend/src/config.ts`
- Modify: `backend/src/pi/provider-credentials.ts`
- Modify: `backend/src/pi/pi-process-manager.ts`
- Modify: `backend/src/pi/list-models.ts`
- Create: `backend/test/secret-bundle.test.ts`
- Modify: `backend/test/provider-credentials.test.ts`
- Modify: `backend/test/pi-process-manager.test.ts`
- Modify: `backend/test/list-models.test.ts`
**Interfaces:**
- `loadSecretBundle(file: string): ReadonlyMap<string, string>` validates `NAME=VALUE` lines,
duplicate/unknown/empty keys, `lstat`/`open(O_NOFOLLOW)`/`fstat` identity, owner and mode.
- `secretValue(config, key)` first reads `THT_SECRETS_FILE`, then falls back to the existing
`THT_*_SECRET_FILE` variable for compatibility.
- The existing provider environment builder consumes a value map, so session and model-listing
children share identical scrubbing and canonical-provider mapping.
- [ ] **Step 1: Write failing tests** for valid bundle parsing, comments/blank lines, duplicate
keys, unknown keys, missing file, mode/owner failure, inode replacement, and secret redaction.
- [ ] **Step 2: Run** `cd backend && npx vitest run test/secret-bundle.test.ts`; expected failure
because the loader does not exist.
- [ ] **Step 3: Implement** the loader with bounded line lengths, strict key allowlist, no shell
evaluation, sanitized errors, and legacy adapter lookup.
- [ ] **Step 4: Add tests** proving session spawn and model listing use the same bundle values and
do not inherit bundle path or unselected provider credentials.
- [ ] **Step 5: Run** `cd backend && npm run build && npx tsc --noEmit -p . && npx vitest run`;
expected all backend tests pass.
- [ ] **Step 6: Commit** `git commit -m "feat(config): load one validated secret bundle"`.
### Task 2: Make the root Compose command the default
**Files:**
- Create: `.env.example`
- Modify: `.gitignore`
- Modify: `compose.yaml`
- Modify: `deploy/compose.production.yaml`
- Modify: `deploy/compose.local.yaml`
- Modify: `deploy/env.example`
- Create: `deploy/secrets/thothii.secrets.example`
- Create: `scripts/test-default-compose.sh`
- Modify: `scripts/test-container-deployment.sh`
**Interfaces:**
- Root `.env` is Compose's automatic interpolation file; `.env.example` contains relative
`THT_SECRETS_FILE=deploy/secrets/thothii.secrets`, default `COMPOSE_FILE=compose.yaml`, and
the selected overlay/profile values.
- `compose.yaml` starts `core` and `frontend` without requiring a profile; overlays extend it.
- Core receives one `/run/secrets/thothii.secrets` mount and `THT_SECRETS_FILE` path.
- [ ] **Step 1: Write failing static tests** that run `docker compose config --quiet` from a
temporary clone with `.env` and assert the default services are `core` and `frontend`, one
bundle is declared, and no legacy secret file is required.
- [ ] **Step 2: Run** `./scripts/test-default-compose.sh`; expected failure because root defaults
still require profiles/separate secret files.
- [ ] **Step 3: Implement** `.env.example`, `.gitignore`, Compose defaults and one secret mount.
Preserve `deploy/compose.production.yaml` as an optional authenticated production override.
- [ ] **Step 4: Run** `docker compose --env-file .env.example config --quiet` and the existing
deployment/security scripts; expected no secret values in rendered YAML.
- [ ] **Step 5: Commit** `git commit -m "build(compose): make root startup the default"`.
### Task 3: Convert local-vector and preprocess services to the bundle
**Files:**
- Modify: `deploy/compose.local-vector.yaml`
- Modify: `deploy/compose.preprocess-local-vector.yaml`
- Modify: `deploy/compose.preprocess.yaml`
- Modify: `deploy/workspaces/local-vector.yaml`
- Modify: `deploy/workspaces/preprocess-evidence.yaml`
- Modify: `deploy/workspaces/preprocess-dwh.yaml`
- Modify: `scripts/local-vector-smoke.sh`
- Modify: `scripts/preprocess-smoke.sh`
- Modify: `scripts/test-preprocess-compose-config.sh`
- Modify: `scripts/test-vector-backup-restore-safety.sh`
**Interfaces:**
- Every local-vector/preprocess service reads the same mounted bundle path and selects only the
named value through the shared loader/helper.
- No service declares four file-backed Compose secrets after this task.
- [ ] **Step 1: Add failing tests** asserting one bundle mount, no `vector_*_password` secret
declarations, and valid local-vector workspace resolution.
- [ ] **Step 2: Run** focused Compose config and smoke tests; expected failure with current
separate-secret declarations.
- [ ] **Step 3: Implement** bundle mounts and helper invocations for bootstrap/migrator/reader/
writer operations, keeping passwords out of URLs and shell logs.
- [ ] **Step 4: Run** `./scripts/test-preprocess-compose-config.sh`, local-vector smoke and
preprocess smoke with a clean generated project; expected all pass.
- [ ] **Step 5: Commit** `git commit -m "feat(compose): use one secret bundle for local services"`.
### Task 4: Finish documentation and end-to-end default verification
**Files:**
- Modify: `README.md`
- Modify: `docs/installazione-docker-4-contesti.md`
- Modify: `docs/index.md`
- Modify: `deploy/secrets/README.md`
- Modify: `scripts/docker-smoke.sh`
- Modify: `scripts/test-default-compose.sh`
**Interfaces:**
- Installation docs show only `cp .env.example .env`, create/fill one bundle, then
`docker compose up --build -d`.
- Advanced overlays are shown as optional `.env` presets, not mandatory command-line flags.
- [ ] **Step 1: Add failing documentation/smoke assertions** for the exact command and default
files.
- [ ] **Step 2: Implement** concise context-specific instructions and migration notes for old
separate secret files.
- [ ] **Step 3: Run** all shell syntax/config gates, backend/frontend builds/tests, full harness,
default Docker smoke, local-vector smoke, preprocess smoke and `git diff --check`.
- [ ] **Step 4: Commit** `git commit -m "docs: document one-command Docker installation"`.
### Task 5: Whole-plan review and handoff
- [ ] Review `a6b195b..HEAD` against this plan and confirm no secret leakage, profile regression,
or legacy fallback bypass.
- [ ] Run the complete verification matrix and report exact counts, skipped L2 tests, and any
unavailable Docker/registry prerequisites.
- [ ] Keep the branch/worktree intact for the user's integration choice.
@@ -36,6 +36,7 @@ Non sono presenti Dockerfile o file Compose. Il processo di sviluppo assume Pi e
6. **Configurazione dichiarativa e validata.** I workspace contengono riferimenti logici e configurazioni non segrete; i segreti sono in environment variables o secret store.
7. **Capability esplicite.** Un adapter dichiara ciò che supporta. Le funzioni mancanti producono degradazione o blocco comprensibile, non emulazioni implicite.
8. **Read-only by construction sul DWH.** Credenziali, API e guard client-side mantengono la separazione dall'autorità di scrittura.
9. **Esposizione sicura per default.** La porta applicativa pubblicata è vincolata a loopback. Un'esposizione pubblica richiede un reverse proxy autenticante esterno e `AUTH_MODE=upstream`; la combinazione pubblico + `none` viene rifiutata all'avvio. OIDC interno non fa parte di questa fase.
## 4. Packaging e runtime
@@ -143,6 +144,11 @@ La configurazione si divide in:
- **workspace:** lingua, adapter, namespace, collezioni e policy di preprocessing;
- **segreti:** password, token, certificati e chiavi reader/writer.
Nel profilo locale i segreti possono provenire da un env-file non versionato. In produzione sono
file read-only sotto `/run/secrets`, leggibili dall'UID 10001. Il reverse proxy autenticante è un
confine fidato: rimuove header identità forniti dal client e inserisce
`X-Authenticated-User` soltanto dopo autenticazione.
Deve esistere un comando di diagnostica che produca sia output umano sia JSON pristino, rispettando il contratto CLI corrente.
## 7. Pipeline di preprocessing
@@ -0,0 +1,34 @@
# Local and Server Docker Deployment Design
## Goal
Run ThothII locally in Docker against the existing PSD services, while keeping the
same tracked Docker package deployable on a server hosting the DWH and pgvector.
## Decisions
- `compose.yaml` remains portable and contains no customer paths, credentials, or
private certificate material.
- The GLM registry is a tracked, non-secret Pi configuration. The provider key is
supplied only through the existing Docker secret bundle.
- A local-only Compose override binds the existing PSD workspace and CA material
from the developer machine. It is ignored by Git and exists solely to validate
Docker Desktop against the real workflow.
- A server profile consumes its own workspace mount, secret bundle, and service
endpoints. It never relies on macOS paths or a developer's `~/.pi` directory.
- The verification scope is a live session start through Pi using `zai/glm-5.2`;
it stops at the first human-review gate and does not finalize a datamart.
## Configuration Boundaries
Tracked files define images, Compose service contracts, templates, validation, and
documentation. Ignored runtime files hold the selected endpoint values, secret
bundle, certificate mount source, and local workspace path. The server receives
only the tracked package; its operator materializes equivalent runtime files with
server-specific values.
## Validation
The local run must validate Compose syntax, image builds, secret mount readability,
core/frontend health endpoints, model discovery, session creation, and receipt of a
Pi workflow event. Failure diagnostics must omit secret values.
@@ -0,0 +1,98 @@
# Design: configurazione Docker semplificata
## Obiettivo
Ridurre l'installazione a un file di configurazione `.env` interno al clone e a un solo file
contenente tutti i secret, mantenendo il comando operativo standard:
```sh
docker compose up --build -d
```
Il comportamento di default deve essere determinato dai file presenti nella directory radice
`ThothII/`, senza obbligare l'operatore a ricordare `-f`, `--env-file` o profili Compose.
## Struttura installativa
```text
ThothII/
├── .env # configurazione non segreta e default Compose
├── .env.example # template versionato
├── compose.yaml # file Compose principale, usabile senza -f
├── deploy/
│ ├── secrets/thothii.secrets # unico file secret, escluso da Git
│ └── workspaces/ # workspace YAML versionati
└── data/ # dati persistenti solo se bind mount esplicito
```
`.env` contiene host, endpoint, profilo scelto, `COMPOSE_FILE`, `COMPOSE_PROFILES` e il percorso
del bundle secret. Non contiene valori secret. Il file viene creato copiando `.env.example` e
rimane nella directory `ThothII/`.
Il bundle `deploy/secrets/thothii.secrets` usa righe `NOME=VALORE`, con nomi documentati e
validazione rigorosa. Non sono ammesse espansioni shell, comandi, URL con credenziali o righe
duplicate. Il file deve essere `0600` sull'host e viene montato read-only nei soli servizi che
ne hanno bisogno.
## Default Compose
`compose.yaml` diventa il file principale per il profilo applicativo esterno: `core` e
`frontend` non sono nascosti dietro un profilo obbligatorio. Il `.env` seleziona eventuali
overlay tramite la variabile Compose standard `COMPOSE_FILE` e il profilo tramite
`COMPOSE_PROFILES`.
Esempi:
- server con DWH/vector esterni: `COMPOSE_FILE=compose.yaml:deploy/compose.production.yaml`;
- Mac/Windows con pgvector locale: `COMPOSE_FILE=compose.yaml:deploy/compose.local-vector.yaml` e
`COMPOSE_PROFILES=local-vector`;
- preprocessing locale: aggiunta dell'overlay preprocess nel valore `COMPOSE_FILE`.
Quando `.env` è configurato, il comando non cambia tra i contesti:
```sh
docker compose up --build -d
```
I comandi con `-f` e `--env-file` restano documentati solo come override diagnostico, non come
percorso normale di installazione.
## Bundle secret e runtime
Il core riceve `THT_SECRETS_FILE=/run/secrets/thothii.secrets`. Un loader comune:
1. apre il bundle con `O_NOFOLLOW`, verifica owner, permessi e inode;
2. rifiuta chiavi sconosciute, duplicate, vuote o provider composti non supportati;
3. espone i singoli valori solo in memoria al componente che ne ha bisogno;
4. non stampa il bundle, non lo inserisce in `settings.json`, argv, health o log.
Per pgvector locale, il servizio di inizializzazione e le migrazioni usano lo stesso loader; non
si creano più file `bootstrap`, `reader`, `writer` e `migrator`. Le password non vengono passate
come argomenti URL. I workspace ricevono riferimenti logici al secret bundle, mai valori.
La compatibilità temporanea con le variabili `THT_*_SECRET_FILE` viene mantenuta come fallback
esplicito per installazioni già esistenti, ma il template e la documentazione nuovi usano solo
`THT_SECRETS_FILE`.
## Compatibilità e sicurezza
- `docker compose config --quiet` deve funzionare dalla radice senza opzioni aggiuntive;
- il default non deve avviare pgvector locale se il `.env` seleziona servizi esterni;
- i profili local-vector e preprocess devono aggiungere solo i servizi necessari;
- il bundle secret deve essere escluso da `.gitignore` e dai build context Docker;
- errori di secret mancanti o non validi devono terminare prima dell'avvio applicativo, con messaggi
sanitizzati;
- i test devono coprire sia il percorso standard `docker compose up --build -d` sia gli override
legacy con file secret separati.
## Verifica prevista
La verifica finale comprende:
1. rendering Compose del default e dei quattro preset `.env.example`;
2. test unitari del parser bundle e della compatibilità legacy;
3. build delle immagini core/frontend;
4. smoke health/SSE/persistenza;
5. smoke local-vector con un solo bundle e migrazioni;
6. smoke preprocess con il default selezionato dal `.env`;
7. controllo che nessun secret compaia in `docker compose config`, log, argv o immagini.
+1
View File
@@ -13,6 +13,7 @@
</head>
<body>
<div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
window.__THOTHII_CONFIG__ = {};
+26
View File
@@ -0,0 +1,26 @@
[
{ "value": "", "valid": true },
{ "value": "/", "valid": true },
{ "value": "/api", "valid": true },
{ "value": "/api/", "valid": true },
{ "value": "http://localhost:8787", "valid": true },
{ "value": "https://api.example.test/v1", "valid": true },
{ "value": "https://api.example.test/base/path/", "valid": true },
{ "value": "http://127.0.0.1:1/api", "valid": true },
{ "value": "http://[::1]:8787/api", "valid": true },
{ "value": "/backend", "valid": false },
{ "value": "api", "valid": false },
{ "value": "//evil.test", "valid": false },
{ "value": "http:///missing-authority", "valid": false },
{ "value": "https:///triple-slash", "valid": false },
{ "value": "http://", "valid": false },
{ "value": "http://example.test:abc", "valid": false },
{ "value": "http://example.test:65536", "valid": false },
{ "value": "http://example.test:999999999999999999999", "valid": false },
{ "value": "http://example.test:", "valid": false },
{ "value": "https://user:pass@example.test", "valid": false },
{ "value": "https://example.test/path with space", "valid": false },
{ "value": "ftp://example.test", "valid": false },
{ "value": "https://example.test/api?tenant=x", "valid": false },
{ "value": "https://example.test/api#fragment", "valid": false }
]
+8
View File
@@ -0,0 +1,8 @@
{
"relativeBases": ["", "/", "/api", "/api/"],
"absolutePattern": "^https?://(?:\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)(?::[0-9]+)?(?:/[^\\s?#]*)?/?$",
"maxPort": 65535,
"queryAllowed": false,
"fragmentAllowed": false,
"credentialsAllowed": false
}
+2 -2
View File
@@ -1,4 +1,4 @@
const BASE = import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8787";
import { backendBaseUrl as BASE, joinBackendPath } from "./runtime-config";
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
// Only declare a JSON content-type when we actually send a body. Body-less
@@ -10,7 +10,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
if (init?.body != null && !("content-type" in headers) && !("Content-Type" in headers)) {
headers["content-type"] = "application/json";
}
const res = await fetch(`${BASE}${path}`, { ...init, headers });
const res = await fetch(joinBackendPath(BASE, path), { ...init, headers });
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => "")}`);
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { backendBaseUrl, joinBackendPath, resolveBackendUrl } from "./runtime-config";
import cases from "./backend-url-cases.json";
describe("resolveBackendUrl", () => {
it("uses the runtime-injected backend URL", () => {
expect(resolveBackendUrl({ backendBaseUrl: "/api" })).toBe("/api");
});
it("falls back to the Vite backend URL", () => {
expect(resolveBackendUrl(undefined)).toBe(import.meta.env.VITE_BACKEND_URL ?? "");
});
it("preserves the client default when Vite has no configured backend", () => {
expect(backendBaseUrl).toBe(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8787");
});
it.each(["/backend", "api", "//evil.test", "ftp://example.test", "https://user:pass@example.test"])(
"rejects unsupported backend URL %j",
(backendBaseUrl) => {
expect(() => resolveBackendUrl({ backendBaseUrl })).toThrow(/BACKEND_BASE_URL/);
},
);
it.each(["", "/", "/api", "/api/", "http://localhost:8787", "https://api.example.test/v1"])(
"accepts supported backend URL %j",
(backendBaseUrl) => {
expect(resolveBackendUrl({ backendBaseUrl })).toBe(backendBaseUrl);
},
);
it.each(cases)("applies the canonical policy to $value", ({ value, valid }) => {
const resolve = () => resolveBackendUrl({ backendBaseUrl: value });
if (valid) expect(resolve()).toBe(value);
else expect(resolve).toThrow(/BACKEND_BASE_URL/);
});
});
describe("joinBackendPath", () => {
it.each([
["", "/sessions/s1", "/sessions/s1"],
["/", "/sessions/s1", "/sessions/s1"],
["/api", "/sessions/s1", "/api/sessions/s1"],
["/api/", "/sessions/s1", "/api/sessions/s1"],
["https://example.test/api", "/sessions/s1", "https://example.test/api/sessions/s1"],
["https://example.test/api/", "sessions/s1", "https://example.test/api/sessions/s1"],
])("joins base %j and path %j", (base, path, expected) => {
expect(joinBackendPath(base, path)).toBe(expected);
});
});
+41
View File
@@ -0,0 +1,41 @@
import policy from "./backend-url-policy.json";
export interface RuntimeConfig {
backendBaseUrl?: string;
}
declare global {
interface Window {
__THOTHII_CONFIG__?: RuntimeConfig;
}
}
export function resolveBackendUrl(config: RuntimeConfig | undefined): string {
const value = config?.backendBaseUrl ?? import.meta.env.VITE_BACKEND_URL ?? "";
if (policy.relativeBases.includes(value)) return value;
try {
if (!new RegExp(policy.absolutePattern).test(value)) throw new Error("syntax");
const authority = value.replace(/^https?:\/\//, "").split("/", 1)[0];
const suffix = authority.startsWith("[")
? authority.slice(authority.indexOf("]") + 1)
: authority.slice(authority.lastIndexOf(":"));
const port = suffix.startsWith(":") ? suffix.slice(1) : "";
if (port && (port.length > 5 || Number(port) > policy.maxPort)) throw new Error("port");
return value;
} catch {
// Fall through to the single actionable runtime error below.
}
throw new Error(
"Invalid BACKEND_BASE_URL: use empty/root, /api, or a valid http(s) base without credentials, query, or fragment",
);
}
export function joinBackendPath(base: string, path: string): string {
const normalizedBase = base === "/" ? "" : base.replace(/\/+$/, "");
const normalizedPath = path.replace(/^\/+/, "");
return `${normalizedBase}/${normalizedPath}`;
}
export const backendBaseUrl =
resolveBackendUrl(typeof window === "undefined" ? undefined : window.__THOTHII_CONFIG__) ||
"http://localhost:8787";
@@ -13,7 +13,7 @@ beforeEach(() => {
test("opens an EventSource and feeds NAMED events to the store", () => {
renderHook(() => useSessionStream("s1"));
const es = FakeEventSource.instances[0];
expect(es.url).toContain("/sessions/s1/events");
expect(es.url).toBe("http://localhost:8787/sessions/s1/events");
// Backend sends `event: ui_request` (named) — drive the addEventListener path
// that production relies on, not the unnamed onmessage fallback.
act(() =>
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { BASE } from "../api/client";
import { joinBackendPath } from "../api/runtime-config";
import { useSessionStore } from "../store/sessionStore";
import type { StreamEvent } from "../api/types";
@@ -10,7 +11,7 @@ export function useSessionStream(sessionId: string | null) {
useEffect(() => {
if (!sessionId) return;
const es = new EventSource(`${BASE}/sessions/${sessionId}/events`);
const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events`));
es.onopen = () => setConnected(true);
es.onerror = () => setConnected(false);
+4
View File
@@ -22,6 +22,7 @@ dependencies = [
tht = "tht.cli:app"
[project.optional-dependencies]
s3 = ["boto3>=1.34,<2"]
dev = [
"pytest>=8.0",
"testcontainers[postgres]>=4.0",
@@ -31,6 +32,9 @@ dev = [
[tool.setuptools.packages.find]
include = ["tht*"]
[tool.setuptools.package-data]
tht = ["migrations/vector/*.sql"]
[tool.ruff]
line-length = 100
+27 -6
View File
@@ -28,7 +28,9 @@ $$;
create or replace function public.search_similar(
table_name text,
query_embedding vector,
limit_count integer
limit_count integer,
kinds text[] default null,
metadata_filter jsonb default null
)
returns table(id bigint, similarity real, metadata jsonb)
language plpgsql
@@ -37,15 +39,33 @@ set search_path = public, vectors, extensions
as $$
begin
perform public._assert_vector_read_table(table_name);
if metadata_filter is not null and (
table_name <> 'evidence'
or not (metadata_filter ? 'vector_generation')
or not (metadata_filter ? 'document_ids')
or not (metadata_filter ? 'workspace_id')
or jsonb_object_length(metadata_filter) <> 3
or jsonb_typeof(metadata_filter->'document_ids') <> 'array'
) then
raise exception 'invalid Evidence metadata filter';
end if;
return query execute format(
'select t.id,
(1 - (t.embedding <=> $1))::real as similarity,
t.metadata
from vectors.%I t
where ($3 is null or t.kind = any ($3))
and ($4 is null or (
t.metadata->>''vector_generation'' = $4->>''vector_generation''
and t.metadata->>''workspace_id'' = $4->>''workspace_id''
and t.metadata->>''document_id'' in (
select jsonb_array_elements_text($4->''document_ids'')
)
))
order by t.embedding <=> $1
limit $2',
table_name
) using query_embedding, limit_count;
) using query_embedding, limit_count, kinds, metadata_filter;
end;
$$;
@@ -75,7 +95,7 @@ end;
$$;
revoke all on function public._assert_vector_read_table(text) from public;
revoke all on function public.search_similar(text, vector, integer) from public;
revoke all on function public.search_similar(text, vector, integer, text[], jsonb) from public;
revoke all on function public.list_tables() from public;
-- Revoca dai ruoli client generici, poi abilita solo il reader dedicato
@@ -83,15 +103,16 @@ revoke all on function public.list_tables() from public;
do $$
begin
if exists (select 1 from pg_roles where rolname = 'anon') then
revoke all on function public.search_similar(text, vector, integer) from anon;
revoke all on function public.search_similar(text, vector, integer, text[], jsonb) from anon;
revoke all on function public.list_tables() from anon;
end if;
if exists (select 1 from pg_roles where rolname = 'authenticated') then
revoke all on function public.search_similar(text, vector, integer) from authenticated;
revoke all on function public.search_similar(text, vector, integer, text[], jsonb) from authenticated;
revoke all on function public.list_tables() from authenticated;
end if;
if exists (select 1 from pg_roles where rolname = 'vector_reader') then
grant execute on function public.search_similar(text, vector, integer) to vector_reader;
grant execute on function public.search_similar(text, vector, integer, text[], jsonb)
to vector_reader;
grant execute on function public.list_tables() to vector_reader;
end if;
end $$;
@@ -40,6 +40,30 @@ begin
end;
$$;
drop function if exists public.list_evidence_generations(text, text);
create or replace function public.list_evidence_generations(
table_name text, kind text, workspace_id text
)
returns table(generation text)
language plpgsql
security definer
set search_path = public, vectors, extensions
as $$
begin
if table_name <> 'evidence' or kind <> 'evidence' or workspace_id !~ '^[a-z][a-z0-9_-]{0,63}$' then
raise exception 'only exact Evidence generations may be listed';
end if;
return query
select distinct e.metadata->>'vector_generation'
from vectors.evidence e
where e.kind = 'evidence'
and e.metadata->>'vector_generation' ~ '^gen:[0-9a-f]{32}$'
and e.metadata->>'workspace_id' = workspace_id
order by 1;
end;
$$;
create or replace function public.existing_vector_hashes(table_name text, kinds text[])
returns table(record_key text, content_hash text)
language plpgsql
@@ -101,9 +125,35 @@ begin
end;
$$;
drop function if exists public.delete_vector_generation(text, text, text);
create or replace function public.delete_vector_generation(
table_name text, kind text, generation text, workspace_id text
)
returns jsonb
language plpgsql
security definer
set search_path = public, vectors, extensions
as $$
declare affected integer;
begin
if table_name <> 'evidence' or kind <> 'evidence' or generation !~ '^gen:[0-9a-f]{32}$'
or workspace_id !~ '^[a-z][a-z0-9_-]{0,63}$' then
raise exception 'only an exact Evidence generation may be deleted';
end if;
delete from vectors.evidence e
where e.kind = 'evidence' and e.metadata->>'vector_generation' = generation
and e.metadata->>'workspace_id' = workspace_id;
get diagnostics affected = row_count;
return jsonb_build_object('deleted', affected);
end;
$$;
revoke all on function public._assert_vector_write_table(text, text[]) from public;
revoke all on function public.existing_vector_hashes(text, text[]) from public;
revoke all on function public.upsert_vector_records(text, jsonb) from public;
revoke all on function public.delete_vector_generation(text, text, text, text) from public;
revoke all on function public.list_evidence_generations(text, text, text) from public;
-- Su alcuni progetti Supabase le funzioni in `public` ricevono grant automatici: revoca
-- esplicitamente dai ruoli client generici, poi abilita solo il writer dedicato.
@@ -112,14 +162,20 @@ begin
if exists (select 1 from pg_roles where rolname = 'anon') then
revoke all on function public.existing_vector_hashes(text, text[]) from anon;
revoke all on function public.upsert_vector_records(text, jsonb) from anon;
revoke all on function public.delete_vector_generation(text, text, text, text) from anon;
revoke all on function public.list_evidence_generations(text, text, text) from anon;
end if;
if exists (select 1 from pg_roles where rolname = 'authenticated') then
revoke all on function public.existing_vector_hashes(text, text[]) from authenticated;
revoke all on function public.upsert_vector_records(text, jsonb) from authenticated;
revoke all on function public.delete_vector_generation(text, text, text, text) from authenticated;
revoke all on function public.list_evidence_generations(text, text, text) from authenticated;
end if;
if exists (select 1 from pg_roles where rolname = 'vector_writer') then
grant execute on function public.existing_vector_hashes(text, text[]) to vector_writer;
grant execute on function public.upsert_vector_records(text, jsonb) to vector_writer;
grant execute on function public.delete_vector_generation(text, text, text, text) to vector_writer;
grant execute on function public.list_evidence_generations(text, text, text) to vector_writer;
end if;
end $$;
+30 -1
View File
@@ -6,11 +6,27 @@ import pytest
from tht.config import LshConfig
from tht.db.introspect import introspect
from tht.db.sampling import is_text_type, unique_values_for_lsh
from tht.db.sampling import distinct_values, is_text_type, sample_column, unique_values_for_lsh
pytestmark = [pytest.mark.l0]
@pytest.mark.parametrize("invalid_limit", [True, 1.5, 0, -1])
def test_direct_sampling_rejects_non_positive_integer_limits(admin_engine, invalid_limit):
with pytest.raises(ValueError, match="positive integer"):
sample_column(
admin_engine, "dw", "fct_ricoveri", "reparto", limit=invalid_limit
)
with pytest.raises(ValueError, match="positive integer"):
distinct_values(
admin_engine,
"dw",
"fct_ricoveri",
"reparto",
max_values=invalid_limit,
)
def test_is_text_type():
assert is_text_type("text")
assert is_text_type("varchar(100)")
@@ -52,3 +68,16 @@ def test_unique_values_for_lsh_truncation_reported(admin_engine):
truncated_cols = {(t.table, t.column) for t in truncated}
# fct_ricoveri has several eligible text columns with distinct values
assert any(t[0] == "fct_ricoveri" for t in truncated_cols)
def test_adapter_sampling_is_distinct_and_frequency_ranked(admin_engine):
values = sample_column(admin_engine, "dw", "fct_ricoveri", "reparto", limit=2)
assert values == ["cardiologia", "pronto soccorso"]
def test_adapter_distinct_values_reports_truncation(admin_engine):
result = distinct_values(
admin_engine, "dw", "fct_ricoveri", "reparto", max_values=1
)
assert result.values == ["cardiologia"]
assert result.truncated is True

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