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:
@@ -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
|
||||||
@@ -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
@@ -33,6 +33,14 @@ deploy/thothii.env
|
|||||||
ca-chain.pem
|
ca-chain.pem
|
||||||
config/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) ===
|
# === Runtime data (sessions contain PII; indexes are derived) ===
|
||||||
harness/sessions/
|
harness/sessions/
|
||||||
harness/indexes/
|
harness/indexes/
|
||||||
@@ -55,3 +63,6 @@ htmlcov/
|
|||||||
|
|
||||||
# === MkDocs build output ===
|
# === MkDocs build output ===
|
||||||
site/
|
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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -34,6 +34,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
thtBin: config.thtBin,
|
thtBin: config.thtBin,
|
||||||
harnessDir: config.harnessDir,
|
harnessDir: config.harnessDir,
|
||||||
configPath: process.env.THT_CONFIG ?? "config/tht.yaml",
|
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 mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
|
||||||
const hub = new SseHub();
|
const hub = new SseHub();
|
||||||
@@ -41,7 +42,12 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
const listModels = deps?.listModels ?? createPiModelLister(config);
|
const listModels = deps?.listModels ?? createPiModelLister(config);
|
||||||
const getSettings = deps?.getSettings ?? (() => effectiveSettings(config, loadSettings(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" }));
|
app.get("/health", async () => ({ status: "ok" }));
|
||||||
sessionRoutes(app, {
|
sessionRoutes(app, {
|
||||||
mgr, tht: tht as ThtRunner, hub, getSettings,
|
mgr, tht: tht as ThtRunner, hub, getSettings,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
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) => {
|
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
if (mode === "none") {
|
if (mode === "none") {
|
||||||
(req as any).user = { id: "dev@local" };
|
(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",
|
id: (req.headers["x-mock-user"] as string) ?? "mock",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
reply.code(501);
|
const id = req.headers["x-authenticated-user"];
|
||||||
throw new Error("OIDC non configurato (MVP: usa none/mock)");
|
if (typeof id !== "string" || id.trim() === "") {
|
||||||
|
return reply.code(401).send({ error: "authenticated upstream identity required" });
|
||||||
|
}
|
||||||
|
(req as any).user = { id };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-2
@@ -1,22 +1,60 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
|
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 };
|
defaults: { provider?: string; model?: string; thinking?: string };
|
||||||
maxPiProcesses: number;
|
maxPiProcesses: number;
|
||||||
settingsFile: string;
|
settingsFile: string;
|
||||||
|
dataRoot?: string;
|
||||||
ollamaEnsureTimeoutMs: number;
|
ollamaEnsureTimeoutMs: number;
|
||||||
|
secretsFile?: string;
|
||||||
|
secretFiles: Readonly<Record<string, string | undefined>>;
|
||||||
|
modelApiKeyFile?: string;
|
||||||
}
|
}
|
||||||
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
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 {
|
return {
|
||||||
host: env.HOST ?? "127.0.0.1",
|
host: env.HOST ?? "127.0.0.1",
|
||||||
port: Number(env.PORT ?? 8787),
|
port: Number(env.PORT ?? 8787),
|
||||||
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
|
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
|
||||||
thtBin: env.THT_BIN ?? "tht",
|
thtBin: env.THT_BIN ?? "tht",
|
||||||
piBin: env.PI_BIN ?? "pi",
|
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 },
|
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
|
||||||
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
|
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
|
||||||
settingsFile: env.SETTINGS_FILE ?? "data/settings.json",
|
settingsFile: env.SETTINGS_FILE ?? "data/settings.json",
|
||||||
|
dataRoot: env.THT_DATA_ROOT,
|
||||||
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
|
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
|
||||||
|
secretsFile,
|
||||||
|
secretFiles,
|
||||||
|
modelApiKeyFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { join } from "node:path";
|
|
||||||
import type { AppConfig } from "../config.js";
|
import type { AppConfig } from "../config.js";
|
||||||
import { RpcClient } from "../rpc/rpc-client.js";
|
import { RpcClient } from "../rpc/rpc-client.js";
|
||||||
|
import { buildPiChildEnv } from "./provider-credentials.js";
|
||||||
|
import { secretValue } from "../config/secret-bundle.js";
|
||||||
|
|
||||||
export interface PiModel {
|
export interface PiModel {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -11,7 +12,11 @@ export interface PiModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Opts {
|
interface Opts {
|
||||||
spawnFn?: () => ChildProcessWithoutNullStreams;
|
spawnFn?: (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||||
|
) => ChildProcessWithoutNullStreams;
|
||||||
ttlMs?: number;
|
ttlMs?: number;
|
||||||
nowMs?: () => number;
|
nowMs?: () => number;
|
||||||
}
|
}
|
||||||
@@ -24,22 +29,21 @@ interface Opts {
|
|||||||
export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise<PiModel[]> {
|
export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise<PiModel[]> {
|
||||||
const ttlMs = opts.ttlMs ?? 60_000;
|
const ttlMs = opts.ttlMs ?? 60_000;
|
||||||
const now = opts.nowMs ?? (() => Date.now());
|
const now = opts.nowMs ?? (() => Date.now());
|
||||||
const spawnFn =
|
const spawnFn = opts.spawnFn ?? nodeSpawn;
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
let cache: { at: number; models: PiModel[] } | null = null;
|
let cache: { at: number; models: PiModel[] } | null = null;
|
||||||
|
|
||||||
return async function listModels(): Promise<PiModel[]> {
|
return async function listModels(): Promise<PiModel[]> {
|
||||||
if (cache && now() - cache.at < ttlMs) return cache.models;
|
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();
|
child.stderr.resume();
|
||||||
const rpc = new RpcClient(child);
|
const rpc = new RpcClient(child);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { join } from "node:path";
|
|
||||||
import type { AppConfig } from "../config.js";
|
import type { AppConfig } from "../config.js";
|
||||||
import { RpcClient } from "../rpc/rpc-client.js";
|
import { RpcClient } from "../rpc/rpc-client.js";
|
||||||
import { SessionBridge } from "../bridge/session-bridge.js";
|
import { SessionBridge } from "../bridge/session-bridge.js";
|
||||||
import type { ThtRunner } from "../tht/tht-runner.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 {
|
export interface SessionRuntime {
|
||||||
rpc: RpcClient;
|
rpc: RpcClient;
|
||||||
@@ -11,46 +12,65 @@ export interface SessionRuntime {
|
|||||||
child: ChildProcessWithoutNullStreams;
|
child: ChildProcessWithoutNullStreams;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Injected test double signature: produce a child process, no args needed. */
|
/** Injectable child-process boundary; callbacks may ignore arguments in simpler tests. */
|
||||||
type SpawnFn = () => ChildProcessWithoutNullStreams;
|
type SpawnFn = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||||
|
) => ChildProcessWithoutNullStreams;
|
||||||
|
|
||||||
export class PiProcessManager {
|
export class PiProcessManager {
|
||||||
private runtimes = new Map<string, SessionRuntime>();
|
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 }) {
|
constructor(private cfg: AppConfig, opts?: { spawnFn?: SpawnFn }) {
|
||||||
if (opts?.spawnFn) {
|
if (opts?.spawnFn) {
|
||||||
this.spawnFn = () => opts.spawnFn!();
|
this.spawnFn = (sessionId, author, provider) =>
|
||||||
|
this.spawnPi(opts.spawnFn!, sessionId, author, provider);
|
||||||
} else {
|
} else {
|
||||||
this.spawnFn = (sessionId: string, author: string) => {
|
this.spawnFn = (sessionId, author, provider) =>
|
||||||
const harnessVenvBin = join(cfg.harnessDir, ".venv", "bin");
|
this.spawnPi(nodeSpawn, sessionId, author, provider);
|
||||||
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,
|
|
||||||
env,
|
|
||||||
});
|
|
||||||
// Drain stderr so the child's stderr buffer never blocks the process.
|
|
||||||
child.stderr.resume();
|
|
||||||
return child;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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?.();
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
count(): number { return this.runtimes.size; }
|
count(): number { return this.runtimes.size; }
|
||||||
|
|
||||||
get(id: string): SessionRuntime | undefined { return this.runtimes.get(id); }
|
get(id: string): SessionRuntime | undefined { return this.runtimes.get(id); }
|
||||||
|
|
||||||
async spawnFor(
|
async spawnFor(
|
||||||
sessionId: string,
|
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> {
|
): Promise<SessionRuntime> {
|
||||||
// Idempotent per session id: tear down any existing runtime for this id
|
// 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
|
// 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");
|
throw new Error("max Pi processes reached");
|
||||||
}
|
}
|
||||||
const author = o.author ?? "dev@local";
|
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 rpc = new RpcClient(child);
|
||||||
const bridge = new SessionBridge(rpc);
|
const bridge = new SessionBridge(rpc);
|
||||||
const rt: SessionRuntime = { rpc, bridge, child };
|
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 model = o.model ?? this.cfg.defaults.model;
|
||||||
const thinking = o.thinking ?? this.cfg.defaults.thinking;
|
const thinking = o.thinking ?? this.cfg.defaults.thinking;
|
||||||
|
|
||||||
@@ -98,7 +118,7 @@ export class PiProcessManager {
|
|||||||
|
|
||||||
const message = o.mode === "resume"
|
const message = o.mode === "resume"
|
||||||
? `/riprendi-sessione ${sessionId}`
|
? `/riprendi-sessione ${sessionId}`
|
||||||
: `/nuova-domanda "kickoff"`;
|
: `/nuova-domanda ${JSON.stringify(o.question ?? "")}`;
|
||||||
rpc.send({ type: "prompt", message });
|
rpc.send({ type: "prompt", message });
|
||||||
return rt;
|
return rt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -29,12 +29,13 @@ export function sessionRoutes(
|
|||||||
model: s.model,
|
model: s.model,
|
||||||
thinking: s.thinking,
|
thinking: s.thinking,
|
||||||
author: getUser(req).id,
|
author: getUser(req).id,
|
||||||
|
question: b.question,
|
||||||
});
|
});
|
||||||
rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e));
|
rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e));
|
||||||
return { id };
|
return { id };
|
||||||
});
|
});
|
||||||
app.get("/sessions", async () => d.tht.sessionList());
|
app.get("/sessions", async () => d.tht.sessionList(d.getSettings().workspace));
|
||||||
app.get("/sessions/:id", async (req) => d.tht.sessionShow((req.params as any).id));
|
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) => {
|
app.post("/sessions/:id/response", async (req, reply) => {
|
||||||
const id = (req.params as any).id;
|
const id = (req.params as any).id;
|
||||||
const rt = d.mgr.get(id);
|
const rt = d.mgr.get(id);
|
||||||
@@ -50,7 +51,7 @@ export function sessionRoutes(
|
|||||||
});
|
});
|
||||||
app.post("/sessions/:id/resume", async (req, reply) => {
|
app.post("/sessions/:id/resume", async (req, reply) => {
|
||||||
const id = (req.params as any).id;
|
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) {
|
if (manifest?.status === "finalized" || manifest?.archived) {
|
||||||
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
|
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-Origin": origin,
|
||||||
"Access-Control-Allow-Credentials": "true",
|
"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 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);
|
const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null);
|
||||||
req.raw.on("close", off);
|
req.raw.on("close", off);
|
||||||
@@ -100,7 +104,7 @@ export function sessionRoutes(
|
|||||||
app.delete("/sessions/:id", async (req, reply) => {
|
app.delete("/sessions/:id", async (req, reply) => {
|
||||||
const id = (req.params as any).id;
|
const id = (req.params as any).id;
|
||||||
d.mgr.teardown(id); // drop any live runtime before deleting on disk
|
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();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id));
|
app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface ThtConfig {
|
|||||||
thtBin: string;
|
thtBin: string;
|
||||||
harnessDir: string;
|
harnessDir: string;
|
||||||
configPath: string;
|
configPath: string;
|
||||||
|
dataRoot?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionRow {
|
export interface SessionRow {
|
||||||
@@ -61,8 +62,12 @@ export class ThtRunner {
|
|||||||
|
|
||||||
run(args: string[], workspace?: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
run(args: string[], workspace?: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||||
return new Promise((resolve) => {
|
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), {
|
const ch = spawn(this.cfg.thtBin, this.buildArgv(args, workspace), {
|
||||||
cwd: this.cfg.harnessDir,
|
cwd: this.cfg.harnessDir,
|
||||||
|
env,
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
@@ -104,12 +109,12 @@ export class ThtRunner {
|
|||||||
return this.json<{ id: string }>(a, o.workspace);
|
return this.json<{ id: string }>(a, o.workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionList() {
|
sessionList(workspace?: string) {
|
||||||
return this.json<SessionRow[]>(["session", "list", "--json"]);
|
return this.json<SessionRow[]>(["session", "list", "--json"], workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionShow(id: string) {
|
sessionShow(id: string, workspace?: string) {
|
||||||
return this.json<unknown>(["session", "show", id, "--json"]);
|
return this.json<unknown>(["session", "show", id, "--json"], workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlPreview(id: string, p: { limit?: number; offset?: number }) {
|
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]); }
|
setGroup(id: string, group: string) { return this.ok(["session", "set-group", id, "--group", group]); }
|
||||||
archive(id: string) { return this.ok(["session", "archive", id]); }
|
archive(id: string) { return this.ok(["session", "archive", id]); }
|
||||||
unarchive(id: string) { return this.ok(["session", "unarchive", 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"]); }
|
documents(id: string) { return this.json<SessionDocument[]>(["session", "documents", id, "--json"]); }
|
||||||
|
|
||||||
async ollamaEnsure(workspace: string, timeoutSec: number): Promise<OllamaEnsureResult> {
|
async ollamaEnsure(workspace: string, timeoutSec: number): Promise<OllamaEnsureResult> {
|
||||||
|
|||||||
@@ -22,3 +22,17 @@ test("mode mock legge l'header", async () => {
|
|||||||
});
|
});
|
||||||
expect(res.json()).toEqual({ id: "alice" });
|
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
@@ -1,20 +1,57 @@
|
|||||||
import { test, expect } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
test("loadConfig espone host con default dev-safe 127.0.0.1", () => {
|
test("loadConfig accepts container listening and runtime paths", () => {
|
||||||
expect(loadConfig({})).toMatchObject({ host: "127.0.0.1" });
|
expect(loadConfig({
|
||||||
});
|
HOST: "0.0.0.0",
|
||||||
|
PORT: "9000",
|
||||||
test("loadConfig rispetta HOST esplicito (necessario in container)", () => {
|
THT_HARNESS_DIR: "/app/harness",
|
||||||
expect(loadConfig({ HOST: "0.0.0.0" })).toMatchObject({ host: "0.0.0.0" });
|
THT_BIN: "/opt/venv/bin/tht",
|
||||||
});
|
PI_BIN: "/usr/local/bin/pi",
|
||||||
|
SETTINGS_FILE: "/data/settings/settings.json",
|
||||||
test("loadConfig merge altri campi con l'host", () => {
|
THT_DATA_ROOT: "/data",
|
||||||
expect(
|
})).toMatchObject({
|
||||||
loadConfig({ HOST: "0.0.0.0", THT_BIN: "/opt/venv/bin/tht", PORT: "9000" })
|
|
||||||
).toMatchObject({
|
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
thtBin: "/opt/venv/bin/tht",
|
|
||||||
port: 9000,
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,9 +2,44 @@ import { test, expect } from "vitest";
|
|||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
import { loadConfig } from "../src/config.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 app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
|
||||||
const res = await app.inject({ method: "GET", url: "/health" });
|
const res = await app.inject({ method: "GET", url: "/health" });
|
||||||
expect(res.statusCode).toBe(200);
|
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" });
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "vitest";
|
import { test, expect } from "vitest";
|
||||||
import { spawn } from "node:child_process";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import path 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 });
|
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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { test, expect } from "vitest";
|
import { test, expect, vi } from "vitest";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import { chmodSync, writeFileSync } from "node:fs";
|
||||||
import { PiProcessManager } from "../src/pi/pi-process-manager.js";
|
import { PiProcessManager } from "../src/pi/pi-process-manager.js";
|
||||||
import { loadConfig } from "../src/config.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");
|
expect(child._writes.join("")).toContain("/nuova-domanda");
|
||||||
mgr.teardown("sid-10");
|
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`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect } from "vitest";
|
import { test, expect } from "vitest";
|
||||||
import { spawn as nodeSpawn } from "node:child_process";
|
import { spawn as nodeSpawn } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { chmodSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
import { loadConfig } from "../src/config.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 () => {
|
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 sessionNewArg: any;
|
||||||
let spawnArg: 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: {
|
thtRunner: {
|
||||||
ollamaEnsure: async () => ({ ok: true }),
|
ollamaEnsure: async () => ({ ok: true }),
|
||||||
sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; },
|
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");
|
expect(sessionNewArg.question).toBe("q");
|
||||||
const list = await app.inject({ method: "GET", url: "/sessions" });
|
const list = await app.inject({ method: "GET", url: "/sessions" });
|
||||||
expect(list.json()).toEqual([{ id: "s1" }]);
|
expect(list.json()).toEqual([{ id: "s1" }]);
|
||||||
|
unlinkSync(modelKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
|
test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -24,6 +24,54 @@ test("sessionNew parses id from JSON", async () => {
|
|||||||
expect(await r.sessionNew({ question: "q" })).toEqual({ id: "2026-06-27-100000-x" });
|
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 () => {
|
test("run with exit != 0 propagates error with stderr", async () => {
|
||||||
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
||||||
r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: boom" });
|
r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: boom" });
|
||||||
|
|||||||
@@ -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}"}
|
||||||
@@ -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}
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"defaultProjectTrust": "always"
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
Executable
+54
@@ -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
|
||||||
Executable
+93
@@ -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())
|
||||||
Executable
+95
@@ -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"
|
||||||
|
}
|
||||||
Executable
+9
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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;'
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1833
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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)
|
||||||
Executable
+36
@@ -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"
|
||||||
Executable
+21
@@ -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"
|
||||||
@@ -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"
|
||||||
Executable
+30
@@ -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
|
||||||
@@ -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).
|
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
|
## I tre livelli di provenienza di un modello
|
||||||
|
|
||||||
### 1. Built-in (compilato dentro Pi)
|
### 1. Built-in (compilato dentro Pi)
|
||||||
|
|||||||
@@ -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).
|
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
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -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`
|
- Test: `harness/tests/test_dwh_port_contract.py`
|
||||||
|
|
||||||
**Interfaces:**
|
**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`.
|
- Consumes: existing catalog models from `tht.db.introspect` and execution result types from `tht.db.execute`.
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing protocol-shape test**
|
- [ ] **Step 1: Write the failing protocol-shape test**
|
||||||
@@ -54,16 +54,21 @@ class DwhCapabilities:
|
|||||||
sampling: bool = True
|
sampling: bool = True
|
||||||
distinct_values: bool = True
|
distinct_values: bool = True
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DistinctValues:
|
||||||
|
values: list[object]
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class DwhAdapter(Protocol):
|
class DwhAdapter(Protocol):
|
||||||
@property
|
@property
|
||||||
def capabilities(self) -> DwhCapabilities: ...
|
def capabilities(self) -> DwhCapabilities: ...
|
||||||
def health(self) -> DwhHealth: ...
|
def health(self) -> DwhHealth: ...
|
||||||
def introspect(self) -> DatabaseCatalog: ...
|
def introspect(self) -> PhysicalSchema: ...
|
||||||
def run_query(self, sql: str, *, limit: int | None = None) -> QueryResult: ...
|
def run_query(self, sql: str, *, limit: int) -> ExecResult: ...
|
||||||
def explain(self, sql: str) -> PlanSummary: ...
|
def explain(self, sql: str) -> PlanSummary: ...
|
||||||
def sample_column(self, table: str, column: str, *, limit: int) -> list[object]: ...
|
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**
|
- [ ] **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/postgres.py`
|
||||||
- Create: `harness/tht/adapters/dwh/thoth_rest.py`
|
- Create: `harness/tht/adapters/dwh/thoth_rest.py`
|
||||||
- Test: `harness/tests/test_dwh_adapters.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/execute.py`
|
||||||
|
- Modify: `harness/tht/db/sampling.py`
|
||||||
- Modify: `harness/tht/rest/execute.py`
|
- Modify: `harness/tht/rest/execute.py`
|
||||||
|
- Modify: `docs/superpowers/plans/2026-07-11-adapter-foundations.md`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `DwhAdapter` from Task 1; existing `DatabaseConfig`, `RestConfig`, catalog, sampling, execute, and explain functions.
|
- 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
|
```python
|
||||||
@pytest.mark.parametrize("factory", [postgres_factory, rest_factory])
|
@pytest.mark.parametrize("factory", [postgres_factory, rest_factory])
|
||||||
def test_adapter_rejects_write_sql(factory):
|
def test_adapter_rejects_write_sql(factory):
|
||||||
with pytest.raises(ReadOnlyViolation):
|
with pytest.raises(ExecutionError):
|
||||||
factory().run_query("delete from fact_sales")
|
factory().run_query("delete from fact_sales", limit=10)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Verify failure**
|
- [ ] **Step 2: Verify failure**
|
||||||
@@ -111,12 +123,18 @@ Expected: FAIL because the adapter classes are absent.
|
|||||||
```python
|
```python
|
||||||
class PostgresDwhAdapter:
|
class PostgresDwhAdapter:
|
||||||
capabilities = DwhCapabilities()
|
capabilities = DwhCapabilities()
|
||||||
def __init__(self, config: DatabaseConfig): self._config = config
|
def __init__(self, config: DatabaseConfig):
|
||||||
def run_query(self, sql: str, *, limit: int | None = None) -> QueryResult:
|
self._config = config
|
||||||
return run_query(self._config, sql, limit=limit)
|
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**
|
- [ ] **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**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```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"
|
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`
|
- Modify: `harness/tht/vectorstore/reader.py`
|
||||||
|
|
||||||
**Interfaces:**
|
**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.
|
- Preserves: current `VectorRestClient`, `DirectSearcher`, and `RestSearcher` behavior behind wrappers.
|
||||||
|
|
||||||
- [ ] **Step 1: Write read/write capability and dual-credential tests**
|
- [ ] **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,
|
def search(self, collections: list[str], embedding: list[float], *, limit: int,
|
||||||
kinds: list[str] | None = None) -> list[VectorHit]: ...
|
kinds: list[str] | None = None) -> list[VectorHit]: ...
|
||||||
def existing_hashes(self, collection: str, kinds: list[str]) -> dict[str, str]: ...
|
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**
|
- [ ] **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`
|
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`.
|
- 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.
|
- 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**
|
- [ ] **Step 1: Write exact factory selection and missing-writer tests**
|
||||||
|
|
||||||
```python
|
```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
|
### 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:**
|
**Files:**
|
||||||
- Create: `compose.yaml`
|
- Create: `compose.yaml`
|
||||||
- Create: `deploy/env.example`
|
- 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.
|
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.
|
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.
|
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
|
## 4. Packaging e runtime
|
||||||
|
|
||||||
@@ -143,6 +144,11 @@ La configurazione si divide in:
|
|||||||
- **workspace:** lingua, adapter, namespace, collezioni e policy di preprocessing;
|
- **workspace:** lingua, adapter, namespace, collezioni e policy di preprocessing;
|
||||||
- **segreti:** password, token, certificati e chiavi reader/writer.
|
- **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.
|
Deve esistere un comando di diagnostica che produca sia output umano sia JSON pristino, rispettando il contratto CLI corrente.
|
||||||
|
|
||||||
## 7. Pipeline di preprocessing
|
## 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.
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<script src="/config.js"></script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
window.__THOTHII_CONFIG__ = {};
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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> {
|
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
|
// 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)) {
|
if (init?.body != null && !("content-type" in headers) && !("Content-Type" in headers)) {
|
||||||
headers["content-type"] = "application/json";
|
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(() => "")}`);
|
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => "")}`);
|
||||||
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
|
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
test("opens an EventSource and feeds NAMED events to the store", () => {
|
||||||
renderHook(() => useSessionStream("s1"));
|
renderHook(() => useSessionStream("s1"));
|
||||||
const es = FakeEventSource.instances[0];
|
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
|
// Backend sends `event: ui_request` (named) — drive the addEventListener path
|
||||||
// that production relies on, not the unnamed onmessage fallback.
|
// that production relies on, not the unnamed onmessage fallback.
|
||||||
act(() =>
|
act(() =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { BASE } from "../api/client";
|
import { BASE } from "../api/client";
|
||||||
|
import { joinBackendPath } from "../api/runtime-config";
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
import type { StreamEvent } from "../api/types";
|
import type { StreamEvent } from "../api/types";
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ export function useSessionStream(sessionId: string | null) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
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.onopen = () => setConnected(true);
|
||||||
es.onerror = () => setConnected(false);
|
es.onerror = () => setConnected(false);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ dependencies = [
|
|||||||
tht = "tht.cli:app"
|
tht = "tht.cli:app"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
s3 = ["boto3>=1.34,<2"]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"testcontainers[postgres]>=4.0",
|
"testcontainers[postgres]>=4.0",
|
||||||
@@ -31,6 +32,9 @@ dev = [
|
|||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["tht*"]
|
include = ["tht*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
tht = ["migrations/vector/*.sql"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ $$;
|
|||||||
create or replace function public.search_similar(
|
create or replace function public.search_similar(
|
||||||
table_name text,
|
table_name text,
|
||||||
query_embedding vector,
|
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)
|
returns table(id bigint, similarity real, metadata jsonb)
|
||||||
language plpgsql
|
language plpgsql
|
||||||
@@ -37,15 +39,33 @@ set search_path = public, vectors, extensions
|
|||||||
as $$
|
as $$
|
||||||
begin
|
begin
|
||||||
perform public._assert_vector_read_table(table_name);
|
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(
|
return query execute format(
|
||||||
'select t.id,
|
'select t.id,
|
||||||
(1 - (t.embedding <=> $1))::real as similarity,
|
(1 - (t.embedding <=> $1))::real as similarity,
|
||||||
t.metadata
|
t.metadata
|
||||||
from vectors.%I t
|
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
|
order by t.embedding <=> $1
|
||||||
limit $2',
|
limit $2',
|
||||||
table_name
|
table_name
|
||||||
) using query_embedding, limit_count;
|
) using query_embedding, limit_count, kinds, metadata_filter;
|
||||||
end;
|
end;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
@@ -75,7 +95,7 @@ end;
|
|||||||
$$;
|
$$;
|
||||||
|
|
||||||
revoke all on function public._assert_vector_read_table(text) from public;
|
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;
|
revoke all on function public.list_tables() from public;
|
||||||
|
|
||||||
-- Revoca dai ruoli client generici, poi abilita solo il reader dedicato
|
-- 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 $$
|
do $$
|
||||||
begin
|
begin
|
||||||
if exists (select 1 from pg_roles where rolname = 'anon') then
|
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;
|
revoke all on function public.list_tables() from anon;
|
||||||
end if;
|
end if;
|
||||||
if exists (select 1 from pg_roles where rolname = 'authenticated') then
|
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;
|
revoke all on function public.list_tables() from authenticated;
|
||||||
end if;
|
end if;
|
||||||
if exists (select 1 from pg_roles where rolname = 'vector_reader') then
|
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;
|
grant execute on function public.list_tables() to vector_reader;
|
||||||
end if;
|
end if;
|
||||||
end $$;
|
end $$;
|
||||||
|
|||||||
@@ -40,6 +40,30 @@ begin
|
|||||||
end;
|
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[])
|
create or replace function public.existing_vector_hashes(table_name text, kinds text[])
|
||||||
returns table(record_key text, content_hash text)
|
returns table(record_key text, content_hash text)
|
||||||
language plpgsql
|
language plpgsql
|
||||||
@@ -101,9 +125,35 @@ begin
|
|||||||
end;
|
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._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.existing_vector_hashes(text, text[]) from public;
|
||||||
revoke all on function public.upsert_vector_records(text, jsonb) 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
|
-- Su alcuni progetti Supabase le funzioni in `public` ricevono grant automatici: revoca
|
||||||
-- esplicitamente dai ruoli client generici, poi abilita solo il writer dedicato.
|
-- 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
|
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.existing_vector_hashes(text, text[]) from anon;
|
||||||
revoke all on function public.upsert_vector_records(text, jsonb) 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;
|
end if;
|
||||||
if exists (select 1 from pg_roles where rolname = 'authenticated') then
|
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.existing_vector_hashes(text, text[]) from authenticated;
|
||||||
revoke all on function public.upsert_vector_records(text, jsonb) 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;
|
end if;
|
||||||
if exists (select 1 from pg_roles where rolname = 'vector_writer') then
|
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.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.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 if;
|
||||||
end $$;
|
end $$;
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,27 @@ import pytest
|
|||||||
|
|
||||||
from tht.config import LshConfig
|
from tht.config import LshConfig
|
||||||
from tht.db.introspect import introspect
|
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]
|
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():
|
def test_is_text_type():
|
||||||
assert is_text_type("text")
|
assert is_text_type("text")
|
||||||
assert is_text_type("varchar(100)")
|
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}
|
truncated_cols = {(t.table, t.column) for t in truncated}
|
||||||
# fct_ricoveri has several eligible text columns with distinct values
|
# fct_ricoveri has several eligible text columns with distinct values
|
||||||
assert any(t[0] == "fct_ricoveri" for t in truncated_cols)
|
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
Reference in New Issue
Block a user