fix(docker): run real questions through trusted Pi gate
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user