fix(docker): run real questions through trusted Pi gate

This commit is contained in:
2026-07-12 19:20:10 +02:00
parent c446d40e1f
commit 7628eaa579
16 changed files with 266 additions and 14 deletions
+2
View File
@@ -17,8 +17,10 @@ PI_THINKING=
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
+2
View File
@@ -33,6 +33,8 @@ 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
+4 -1
View File
@@ -56,7 +56,10 @@ 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;
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);
}
+10 -2
View File
@@ -44,6 +44,14 @@ export class PiProcessManager {
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.
@@ -62,7 +70,7 @@ export class PiProcessManager {
async spawnFor(
sessionId: string,
o: { provider?: string; model?: string; thinking?: string; author?: string; mode?: "new" | "resume" },
o: { provider?: string; model?: string; thinking?: string; author?: string; question?: string; mode?: "new" | "resume" },
): Promise<SessionRuntime> {
// Idempotent per session id: tear down any existing runtime for this id
// first (before the cap check) so a resume/respawn neither leaks the old
@@ -110,7 +118,7 @@ export class PiProcessManager {
const message = o.mode === "resume"
? `/riprendi-sessione ${sessionId}`
: `/nuova-domanda "kickoff"`;
: `/nuova-domanda ${JSON.stringify(o.question ?? "")}`;
rpc.send({ type: "prompt", message });
return rt;
}
+5 -4
View File
@@ -29,12 +29,13 @@ export function sessionRoutes(
model: s.model,
thinking: s.thinking,
author: getUser(req).id,
question: b.question,
});
rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e));
return { id };
});
app.get("/sessions", async () => d.tht.sessionList());
app.get("/sessions/:id", async (req) => d.tht.sessionShow((req.params as any).id));
app.get("/sessions", async () => d.tht.sessionList(d.getSettings().workspace));
app.get("/sessions/:id", async (req) => d.tht.sessionShow((req.params as any).id, d.getSettings().workspace));
app.post("/sessions/:id/response", async (req, reply) => {
const id = (req.params as any).id;
const rt = d.mgr.get(id);
@@ -50,7 +51,7 @@ export function sessionRoutes(
});
app.post("/sessions/:id/resume", async (req, reply) => {
const id = (req.params as any).id;
const manifest = (await d.tht.sessionShow(id)) as { status?: string; archived?: boolean } | null;
const manifest = (await d.tht.sessionShow(id, d.getSettings().workspace)) as { status?: string; archived?: boolean } | null;
if (manifest?.status === "finalized" || manifest?.archived) {
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
}
@@ -103,7 +104,7 @@ export function sessionRoutes(
app.delete("/sessions/:id", async (req, reply) => {
const id = (req.params as any).id;
d.mgr.teardown(id); // drop any live runtime before deleting on disk
await d.tht.deleteSession(id);
await d.tht.deleteSession(id, d.getSettings().workspace);
return reply.code(204).send();
});
app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id));
+8 -5
View File
@@ -109,12 +109,12 @@ export class ThtRunner {
return this.json<{ id: string }>(a, o.workspace);
}
sessionList() {
return this.json<SessionRow[]>(["session", "list", "--json"]);
sessionList(workspace?: string) {
return this.json<SessionRow[]>(["session", "list", "--json"], workspace);
}
sessionShow(id: string) {
return this.json<unknown>(["session", "show", id, "--json"]);
sessionShow(id: string, workspace?: string) {
return this.json<unknown>(["session", "show", id, "--json"], workspace);
}
sqlPreview(id: string, p: { limit?: number; offset?: number }) {
@@ -141,7 +141,10 @@ export class ThtRunner {
setGroup(id: string, group: string) { return this.ok(["session", "set-group", id, "--group", group]); }
archive(id: string) { return this.ok(["session", "archive", id]); }
unarchive(id: string) { return this.ok(["session", "unarchive", id]); }
deleteSession(id: string) { return this.ok(["session", "delete", id]); }
async deleteSession(id: string, workspace?: string) {
const { code, stderr } = await this.run(["session", "delete", id], workspace);
if (code !== 0) throw new Error(`tht session delete exit ${code}: ${stderr.trim()}`);
}
documents(id: string) { return this.json<SessionDocument[]>(["session", "documents", id, "--json"]); }
async ollamaEnsure(workspace: string, timeoutSec: number): Promise<OllamaEnsureResult> {
+11
View File
@@ -127,6 +127,17 @@ test("spawnFor default (new) mode sends /nuova-domanda", async () => {
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");
+4
View File
@@ -20,7 +20,9 @@ services:
THT_DB_NAME: "${THT_DB_NAME:-}"
THT_DWH_REST_URL: "${THT_DWH_REST_URL:-}"
THT_VEC_REST_URL: "${THT_VEC_REST_URL:-}"
THT_VEC_WRITE_REST_URL: "${THT_VEC_WRITE_REST_URL:-}"
THT_OLLAMA_URL: "${THT_OLLAMA_URL:-}"
THT_PROFILE: "${THT_PROFILE:-server}"
THT_DOCS_ROOT: "${THT_DOCS_ROOT:-/data/workspaces/example/evidence-source}"
THT_SECRETS_FILE: /run/secrets/thothii.secrets
THT_DATA_ROOT: /data
@@ -31,6 +33,8 @@ services:
volumes:
- thoth_data:/data
- ./deploy/workspaces:/app/harness/workspaces:ro
- ./deploy/pi/models.json:/home/thoth/.pi/agent/models.json:ro
- ./deploy/pi/settings.json:/home/thoth/.pi/agent/settings.json:ro
healthcheck:
test: [CMD, curl, --fail, --silent, http://127.0.0.1:8787/health]
interval: 5s
+11
View File
@@ -0,0 +1,11 @@
services:
core:
environment:
THT_DOCS_ROOT: /data/workspaces/psd
extra_hosts:
- host.docker.internal:host-gateway
volumes:
- ./deploy/workspaces/psd.yaml:/app/harness/config/tht.yaml:ro
- type: bind
source: ${THT_PSD_WORKSPACE_HOST_PATH:?set THT_PSD_WORKSPACE_HOST_PATH}
target: /data/workspaces/psd
+18
View File
@@ -0,0 +1,18 @@
{
"providers": {
"zai": {
"baseUrl": "https://api.z.ai/api/coding/paas/v4",
"api": "openai-completions",
"apiKey": "$ZAI_API_KEY",
"models": [
{
"id": "glm-5.2",
"name": "GLM-5.2",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 131072
}
]
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"defaultProjectTrust": "always"
}
+43
View File
@@ -0,0 +1,43 @@
language: it
dwh:
type: thoth_rest
database:
database: ${THT_DB_NAME}
schema: datawarehouse
endpoint:
base_url: ${THT_DWH_REST_URL}
api_key: ${THT_DWH_API_KEY}
ssl_ca: ${THT_SSL_CA}
vectors:
type: thoth_vector_http
reader:
base_url: ${THT_VEC_REST_URL}
api_key: ${THT_VEC_API_KEY}
ssl_ca: ${THT_SSL_CA}
writer:
base_url: ${THT_VEC_WRITE_REST_URL}
api_key: ${THT_VEC_WRITE_API_KEY}
ssl_ca: ${THT_SSL_CA}
roots:
artifacts: /data/workspaces/psd/runtime-v2/artifacts
indexes: /data/workspaces/psd/runtime-v2/indexes
sessions: /data/workspaces/psd/sessions
evidence:
source_root: ${THT_DOCS_ROOT}
evidence_dir: evidence
embeddings:
base_url: ${THT_OLLAMA_URL}
model: nomic-embed-text-v2-moe
dim: 768
batch_size: 32
execution:
allow: [cte_test, explain, preview, aggregate, export]
max_preview_rows: 10
max_export_rows: 100000
statement_timeout_ms: 30000
+4 -2
View File
@@ -24,8 +24,8 @@ RUN apt-get update \
&& apt-get install --yes --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 10001 thoth \
&& mkdir -p /app/backend /app/docker/smoke /data/settings \
&& chown -R thoth:thoth /data
&& mkdir -p /app/backend /app/docker/smoke /app/harness/config /data/settings /home/thoth/.pi/agent \
&& chown -R thoth:thoth /app/harness /data /home/thoth/.pi
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
COPY --from=node-runtime /opt/pi-runtime /opt/pi-runtime
@@ -36,11 +36,13 @@ RUN ln -s /opt/pi-runtime/node_modules/.bin/pi /usr/local/bin/pi \
WORKDIR /app
COPY harness/ /app/harness/
COPY --from=gate-deps /src/harness/node_modules /app/harness/node_modules
RUN chown -R thoth:thoth /app/harness
COPY docker/python-runtime/requirements.lock /app/docker/python-runtime/requirements.lock
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir --require-hashes \
--requirement /app/docker/python-runtime/requirements.lock \
&& /opt/venv/bin/pip install --no-cache-dir --no-deps --no-build-isolation '/app/harness[s3]' \
&& cp /app/harness/workflow.yaml /opt/venv/lib/python3.12/site-packages/workflow.yaml \
&& /opt/venv/bin/tht vector migrate --help >/dev/null
COPY --from=backend-build /src/backend/dist /app/backend/dist
@@ -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,68 @@
#!/bin/sh
set -eu
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
source_env=${1:-"$root/../../harness/.env"}
workspace=${2:-"$root/../../../tht-workspace-psd"}
auth_file=${3:-"$HOME/.pi/agent/auth.json"}
value() {
awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); sub(/[[:space:]].*$/, ""); print; exit }' "$source_env"
}
required() {
result=$(value "$1")
[ -n "$result" ] || { echo "missing $1 in local source configuration" >&2; exit 2; }
printf '%s' "$result"
}
test -f "$source_env"
test -d "$workspace"
test -f "$auth_file"
ca=$(value THT_SSL_CA)
[ -z "$ca" ] || test -f "$ca"
model_key=$(jq -er '.zai.key' "$auth_file")
test -n "$model_key"
umask 077
mkdir -p "$root/deploy/secrets" "$root/deploy/workspaces"
cp "$root/deploy/compose.psd-local.yaml.example" "$root/deploy/compose.psd-local.yaml"
cp "$root/deploy/workspaces/psd.yaml.example" "$root/deploy/workspaces/psd.yaml"
cat >"$root/.env" <<EOF
COMPOSE_FILE=compose.yaml:deploy/compose.psd-local.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=zai
PI_MODEL=glm-5.2
PI_THINKING=medium
THT_PROFILE=workstation
THT_DB_NAME=$(required THT_DB_NAME)
THT_DWH_REST_URL=$(required THT_DWH_REST_URL)
THT_VEC_REST_URL=$(required THT_VEC_REST_URL)
THT_VEC_WRITE_REST_URL=$(required THT_VEC_WRITE_REST_URL)
THT_OLLAMA_URL=http://host.docker.internal:11434
THT_DOCS_ROOT=/data/workspaces/psd
THT_PSD_WORKSPACE_HOST_PATH=$workspace
EOF
cat >"$root/deploy/secrets/thothii.secrets" <<EOF
THT_MODEL_API_KEY=$model_key
THT_DWH_API_KEY=$(required THT_DWH_API_KEY)
THT_VEC_API_KEY=$(required THT_VEC_API_KEY)
THT_VEC_WRITE_API_KEY=$(required THT_VEC_WRITE_API_KEY)
EOF
if [ -n "$ca" ]; then
cat >>"$root/deploy/compose.psd-local.yaml" <<EOF
- type: bind
source: $ca
target: /run/secrets/ca-chain.pem
read_only: true
EOF
printf '%s\n' 'THT_CA=/run/secrets/ca-chain.pem' >>"$root/deploy/secrets/thothii.secrets"
else
printf '%s\n' 'THT_CA=/etc/ssl/certs/ca-certificates.crt' >>"$root/deploy/secrets/thothii.secrets"
fi
chmod 600 "$root/.env" "$root/deploy/secrets/thothii.secrets"
echo "Local PSD Docker configuration materialized without printing secret values."
+3
View File
@@ -25,6 +25,9 @@ grep -q 'host_ip: 127.0.0.1' "$tmp/base.yaml"
grep -q 'AUTH_MODE: none' "$tmp/base.yaml"
grep -q 'THOTH_PUBLIC_EXPOSURE: "false"' "$tmp/base.yaml"
grep -q 'THT_SECRETS_FILE: /run/secrets/thothii.secrets' "$tmp/base.yaml"
grep -q 'target: /home/thoth/.pi/agent/models.json' "$tmp/base.yaml"
grep -q 'source: .*/deploy/pi/models.json' "$tmp/base.yaml"
grep -q 'target: /home/thoth/.pi/agent/settings.json' "$tmp/base.yaml"
if grep -q 'THT_[A-Z0-9_]*_SECRET_FILE:' "$tmp/base.yaml"; then
echo "base Compose must not require legacy secret-file variables" >&2
exit 1