fix: use Pi user auth and handle startup failures

This commit is contained in:
2026-07-21 14:01:47 +02:00
parent 2ff63d371f
commit 801f847ec4
12 changed files with 311 additions and 6 deletions
+1
View File
@@ -12,6 +12,7 @@ MAX_PI_PROCESSES=4
PI_PROVIDER=
PI_MODEL=
PI_THINKING=
PI_AUTH_FILE=${HOME}/.pi/agent/auth.json
# Set these for the selected DWH/vector/embedding adapters.
THT_DB_NAME=
+23 -1
View File
@@ -1,8 +1,30 @@
# ThothII — Project State
> Starting-point snapshot for new sessions. Last updated: 2026-07-15 (central live log and compact CTE density live).
> Starting-point snapshot for new sessions. Last updated: 2026-07-21 (local Pi user-auth wiring and startup error handling live).
> Point a fresh session here ("read PROJECT_STATE.md") before substantial work.
## Local Pi user auth + startup failure handling — LIVE 2026-07-21
- The PSD Docker profile now bind-mounts the configurable host `PI_AUTH_FILE` read-only at
`/home/thoth/.pi/agent/auth.json`; on this Mac it resolves to the real user profile
`/Users/mp/.pi/agent/auth.json`. The container keeps its correct Linux identity
`HOME=/home/thoth` while Pi sees the user's independent `deepseek` and `zai` credentials.
- `deploy/pi/settings.json` is the non-secret model policy and exposes, in order,
`zai/glm-5.2`, `deepseek/deepseek-v4-flash`, `deepseek/deepseek-v4-pro`, and
`aritmolab/qwen3.6-35b-a3b`. The core image is aligned to Pi 0.80.3.
- New-session creation now validates the saved provider/model against Pi before persistence;
unavailable selections return sanitized `503 model_unavailable` without creating a manifest.
A synchronous runtime-construction failure after persistence marks that session `failed` and
returns the fixed startup-recovery message instead of leaving an ambiguous `open` session.
- Verification: backend 235/235, TypeScript clean, dedicated Compose auth/model contract green
with a demonstrated RED→GREEN cycle. Rebuilt core image
`sha256:a8b4dd9f016c2335e4da897073bc6d5bdf1e8ce9b60dcfcca171b6677f563228`
is healthy; live `/models` returned all four models; a real `deepseek-v4-pro` smoke reached its
first reviewer gate, deleted only its own session, and restored the exact prior settings.
- Deleted the three explicitly approved incomplete DeepSeek attempts:
`a390c8b8-0a91-4a37-967b-ce7ff9be9797`, `a2f974b2-4c48-4967-b4b6-afdbc2b2d541`, and
`f66e1959-3c71-4b10-8aa1-606992046b7e` (API delete 204, subsequent lookup 404 for each).
## User-owned sessions cutover — prepared, manual gate pending (2026-07-16)
- **Target contract:** the public server runs `AUTH_MODE=upstream` with Task 4 portal identity
+2 -1
View File
@@ -95,7 +95,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
app.get("/health/dwh", async () => tht.dbPing());
app.get("/me", async (req) => getPrincipal(req));
sessionRoutes(app, {
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, dwhPrecheck: config.dwhPrecheck,
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels,
dwhPrecheck: config.dwhPrecheck,
});
sqlRoutes(app, { tht: tht as ThtRunner, getSettings });
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
+39 -2
View File
@@ -6,6 +6,7 @@ import type { Settings } from "../settings/settings-store.js";
import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
import type { ReadinessManager } from "../runtime/readiness-manager.js";
import type { ListModelsFn } from "./meta.js";
const BOOTSTRAP_FAILURE_MESSAGE =
"Session startup failed. Check configuration and connectivity, then Resume the session.";
@@ -15,6 +16,8 @@ const RESUME_FAILURE_MESSAGE =
"Session could not be resumed. Check configuration and connectivity, then try again.";
const DWH_UNREACHABLE_MESSAGE =
"Cannot start a session: the database is unreachable. Check the VPN connection and try again.";
const MODEL_UNAVAILABLE_MESSAGE =
"Selected model is unavailable. Check Pi authentication and model settings, then try again.";
export function sessionRoutes(
app: FastifyInstance,
@@ -22,6 +25,7 @@ export function sessionRoutes(
mgr: PiProcessManager; tht: ThtRunner; hub: SseHub;
getSettings: (principal: PrincipalContext) => Promise<Settings>;
readiness: ReadinessManager;
listModels: ListModelsFn;
/** Local-only guard: probe DWH reachability before creating a session (run-stack.sh). */
dwhPrecheck?: boolean;
},
@@ -189,6 +193,26 @@ export function sessionRoutes(
return reply.code(503).send({ error: DWH_UNREACHABLE_MESSAGE, code: "dwh_unreachable" });
}
}
if (s.provider && s.model) {
let available: Awaited<ReturnType<ListModelsFn>>;
try {
available = await d.listModels();
} catch {
return reply.code(503).send({
error: MODEL_UNAVAILABLE_MESSAGE,
code: "model_unavailable",
});
}
const selectedAvailable = available.some(
(candidate) => candidate.provider === s.provider && candidate.id === s.model,
);
if (!selectedAvailable) {
return reply.code(503).send({
error: MODEL_UNAVAILABLE_MESSAGE,
code: "model_unavailable",
});
}
}
// Settings (global) supply workspace/provider/model/thinking. The new-question
// form sends only the question text. `workspace` selects the tht `-c <config>`.
let id: string;
@@ -206,8 +230,21 @@ export function sessionRoutes(
principal,
question: b.question,
};
const rt = d.mgr.createFor(id, options);
bindRuntime(id, rt, runner, s.workspace);
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
try {
rt = d.mgr.createFor(id, options);
bindRuntime(id, rt, runner, s.workspace);
} catch (error) {
if (rt) d.mgr.teardownIfCurrent(id, rt);
console.error(
`[pi:${id}] runtime construction failed:`,
error instanceof Error ? error.message : "unknown error",
);
await runner.failSession(id, s.workspace).catch((persistenceError: unknown) => {
console.error(`[session:${id}] failSession persistence failed:`, persistenceError);
});
return reply.code(503).send({ error: BOOTSTRAP_FAILURE_MESSAGE });
}
info(id, "Session created");
bootstrap(
id, rt, runner, s.workspace, d.mgr.configure(rt, options),
+69
View File
@@ -164,6 +164,9 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
sessionList: async () => [{ id: "s1" }],
} as any,
getSettings: () => ({ workspace: "w", provider: "zai", model: "glm-5.2", thinking: "high" }),
listModels: async () => [
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
],
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
@@ -259,6 +262,9 @@ test("POST /sessions configura Pi con il thinking globale selezionato", async ()
sessionNew: async () => ({ id: "s-thinking" }),
} as any,
getSettings: () => ({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }) as any,
listModels: async () => [
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
],
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
@@ -1539,6 +1545,69 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
expect(ensureWs).toBe("psd");
});
test("POST /sessions rejects an unavailable saved model before persisting a session", async () => {
let created = 0;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionNew: async () => { created += 1; return { id: "must-not-exist" }; },
} as any,
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({
workspace: "psd",
provider: "deepseek",
model: "deepseek-v4-pro",
thinking: "medium",
}) as any,
listModels: async () => [
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
],
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toEqual({
error: "Selected model is unavailable. Check Pi authentication and model settings, then try again.",
code: "model_unavailable",
});
expect(created).toBe(0);
});
test("POST /sessions marks a persisted session failed when runtime construction throws", async () => {
let failed = 0;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
mgr: {
createFor: () => { throw new Error("provider bootstrap unavailable"); },
} as any,
thtRunner: {
sessionNew: async () => ({ id: "s-runtime-failure" }),
failSession: async (id: string, workspace: string) => {
expect(id).toBe("s-runtime-failure");
expect(workspace).toBe("psd");
failed += 1;
},
} as any,
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({
workspace: "psd",
provider: "deepseek",
model: "deepseek-v4-pro",
thinking: "medium",
}) as any,
listModels: async () => [
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
],
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toEqual({
error: "Session startup failed. Check configuration and connectivity, then Resume the session.",
});
expect(failed).toBe(1);
});
test("POST /sessions/:id/resume returns 409 for a read-only session without calling ollamaEnsure", async () => {
let ensureCalled = false;
const app = mutApp({
+1
View File
@@ -33,6 +33,7 @@ services:
volumes:
- /home/chirone/thothii-data:/data
- /home/chirone/thothii-data/pi-config:/home/thoth/.pi
- ${PI_AUTH_FILE:?set PI_AUTH_FILE}:/home/thoth/.pi/agent/auth.json:ro
- /home/chirone/chirone/etl/docs/evidence:/data/evidence:ro
restart: unless-stopped
networks:
+7 -1
View File
@@ -1,3 +1,9 @@
{
"defaultProjectTrust": "always"
"defaultProjectTrust": "always",
"enabledModels": [
"zai/glm-5.2",
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-pro",
"aritmolab/qwen3.6-35b-a3b"
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.7
# thothii-core: Fastify (Node 22) + harness Python 3.12 (tht CLI) + runtime Pi.
# Singolo container, entrypoint logico "server" (default).
ARG PI_VERSION=0.80.2
ARG PI_VERSION=0.80.3
# ---- Stage 1: backend TypeScript -> dist ----
FROM node:22-bookworm AS backend-build
@@ -0,0 +1,87 @@
# Pi User Authentication and Startup Error Handling Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make Dockerized Pi consume the configured user auth store, expose four approved models, fail session startup cleanly, and remove three incomplete attempts.
**Architecture:** Compose mounts only the host Pi `auth.json` into the container while repository-owned Pi settings define the model policy. The backend validates the persisted model before creating a session and converts post-persistence runtime-construction errors into a failed session plus a sanitized 503.
**Tech Stack:** Docker Compose, Pi 0.80.3, Fastify/TypeScript, Vitest, shell deployment-contract tests.
## Global Constraints
- Never copy or log provider keys.
- Mount the auth file read-only.
- Enabled model order is ZAI, DeepSeek Flash, DeepSeek Pro, local Qwen.
- UI error strings remain English.
- Preserve the question in the frontend retry composer.
- Delete only the three explicitly approved incomplete session IDs.
---
### Task 1: Container Pi auth and model policy
**Files:**
- Modify: `compose.yaml`
- Modify: `.env.example`
- Modify: `deploy/pi/settings.json`
- Modify: `docker/core.Dockerfile`
- Test: `scripts/test-default-compose.sh`
- Test: `scripts/test-container-deployment.sh`
**Interfaces:**
- Consumes: `PI_AUTH_FILE`, an absolute readable host path.
- Produces: `/home/thoth/.pi/agent/auth.json` read-only and a four-entry `enabledModels` policy.
- [ ] Add failing deployment-contract assertions for the auth mount, four enabled models, and Pi 0.80.3.
- [ ] Run the focused shell tests and confirm the expected failures.
- [ ] Add the configurable auth mount, model policy, environment documentation, and Pi version alignment.
- [ ] Re-run the focused shell tests and confirm they pass.
### Task 2: Pre-persistence model availability guard
**Files:**
- Modify: `backend/src/app.ts`
- Modify: `backend/src/routes/sessions.ts`
- Test: `backend/test/routes-sessions.test.ts`
**Interfaces:**
- Consumes: the existing `ListModelsFn` used by model/settings routes.
- Produces: a sanitized 503 before `sessionNew` when saved settings are unavailable.
- [ ] Add a failing route test proving an unavailable model returns 503 and never calls `sessionNew`.
- [ ] Run the single Vitest test and confirm the expected failure.
- [ ] Inject the model-list dependency into session routes and implement the minimal guard.
- [ ] Re-run the focused test and confirm it passes.
### Task 3: Post-persistence runtime-construction failure
**Files:**
- Modify: `backend/src/routes/sessions.ts`
- Test: `backend/test/routes-sessions.test.ts`
**Interfaces:**
- Consumes: `ThtRunner.failSession(id, workspace)` and `PiProcessManager.createFor`.
- Produces: failed persisted state plus sanitized 503 when runtime construction throws.
- [ ] Add a failing route test for a throwing `createFor` after `sessionNew`.
- [ ] Run the single test and confirm it fails because the route currently returns 500 and leaves the session open.
- [ ] Catch runtime construction/binding failures, persist `failed`, and return the startup error.
- [ ] Re-run the focused test and confirm it passes.
### Task 4: Cleanup and full verification
**Files:**
- Modify: external PSD session store only through `tht session delete`.
**Interfaces:**
- Consumes: the three approved session IDs.
- Produces: no corresponding directories or API rows.
- [ ] Run backend Vitest and TypeScript typecheck.
- [ ] Run deployment-contract tests and render Compose with the real auth path.
- [ ] Rebuild/recreate the core container and verify health.
- [ ] Verify `/models` returns all four enabled models in order.
- [ ] Start a uniquely named DeepSeek smoke and observe the first reviewer gate; clean up only the smoke.
- [ ] Delete the three approved incomplete sessions and verify they are absent.
- [ ] Run `git diff --check` and inspect the final diff for secret leakage.
@@ -0,0 +1,46 @@
# Pi User Authentication and Startup Error Handling
## Goal
Make the Dockerized ThothII runtime use the same provider authentication that Pi stores for
the host user, expose only the explicitly enabled model set (including DeepSeek V4 Pro), and
avoid leaving apparently healthy `open` sessions when model startup cannot begin.
## Runtime configuration
- Keep the container user `thoth` and `HOME=/home/thoth`; those are valid container-local
identities and must not be changed to a macOS path.
- Bind-mount a configurable host Pi auth file, `PI_AUTH_FILE`, to
`/home/thoth/.pi/agent/auth.json` read-only. The default local value is
`${HOME}/.pi/agent/auth.json`; deployments may override it with another absolute path.
- Keep `deploy/pi/settings.json` as the non-secret runtime policy. It must enable, in order:
`zai/glm-5.2`, `deepseek/deepseek-v4-flash`, `deepseek/deepseek-v4-pro`, and
`aritmolab/qwen3.6-35b-a3b`.
- Keep `deploy/pi/models.json` for custom provider definitions only. Provider credentials stay
exclusively in Pi's user auth file and are never copied into the repository.
- Run Pi 0.80.3 in the core image, matching the audited backend provider contract.
## Session-start behavior
The settings write endpoint remains the main model-validation boundary. Session creation adds a
defense-in-depth availability check before persistence: if the saved provider/model is absent
from Pi's current enabled and authenticated model list, return a sanitized 503 and do not call
`tht session new`.
If runtime construction fails after persistence despite that check (for example a race or local
process limit), catch the error, mark the just-created session failed, and return the same
sanitized startup error. Never expose provider credentials or raw Pi errors to the browser.
Asynchronous bootstrap failures continue to emit `session_failed` and persist failed state.
## Cleanup and verification
Delete only these incomplete sessions:
- `a390c8b8-0a91-4a37-967b-ce7ff9be9797`
- `a2f974b2-4c48-4967-b4b6-afdbc2b2d541`
- `f66e1959-3c71-4b10-8aa1-606992046b7e`
Verify configuration contracts, backend tests and typecheck, rendered Compose mounts, `/models`
containing all four configured models, and a live DeepSeek startup reaching its first reviewer
gate. The auth file must remain read-only and no secret value may appear in rendered Compose,
logs, tests, or source control.
@@ -39,6 +39,7 @@ MAX_PI_PROCESSES=4
PI_PROVIDER=zai
PI_MODEL=glm-5.2
PI_THINKING=medium
PI_AUTH_FILE=$auth_file
THT_PROFILE=workstation
THT_DB_NAME=$(required THT_DB_NAME)
THT_DWH_REST_URL=$(required THT_DWH_REST_URL)
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
auth_file="$tmp/auth.json"
printf '%s\n' '{}' >"$auth_file"
chmod 0600 "$auth_file"
rendered=$(PI_AUTH_FILE="$auth_file" docker compose config)
printf '%s\n' "$rendered" | grep -q "source: $auth_file"
printf '%s\n' "$rendered" | grep -q 'target: /home/thoth/.pi/agent/auth.json'
printf '%s\n' "$rendered" | grep -A4 'target: /home/thoth/.pi/agent/auth.json' \
| grep -q 'read_only: true'
python3 - <<'PY'
import json
from pathlib import Path
settings = json.loads(Path("deploy/pi/settings.json").read_text())
assert settings["enabledModels"] == [
"zai/glm-5.2",
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-pro",
"aritmolab/qwen3.6-35b-a3b",
]
PY
grep -q '^ARG PI_VERSION=0.80.3$' docker/core.Dockerfile
grep -q '^PI_AUTH_FILE=$auth_file$' scripts/bootstrap-local-psd-docker-config.sh
echo "Pi user-auth Compose contract passed."