diff --git a/.env.example b/.env.example index 444490bb..3dbeb9b9 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 3f795eb5..a0352e19 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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 diff --git a/backend/src/app.ts b/backend/src/app.ts index fff44715..190da83a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 }); diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index fd660456..17b782be 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -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; 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>; + 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 `. 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 | 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), diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 1d286313..91307844 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -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({ diff --git a/compose.yaml b/compose.yaml index 92b9c700..fdbc1e42 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/deploy/pi/settings.json b/deploy/pi/settings.json index bc2fe370..31a18ce0 100644 --- a/deploy/pi/settings.json +++ b/deploy/pi/settings.json @@ -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" + ] } diff --git a/docker/core.Dockerfile b/docker/core.Dockerfile index a1287633..7eb8be45 100644 --- a/docker/core.Dockerfile +++ b/docker/core.Dockerfile @@ -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 diff --git a/docs/superpowers/plans/2026-07-21-pi-user-auth-and-startup-errors.md b/docs/superpowers/plans/2026-07-21-pi-user-auth-and-startup-errors.md new file mode 100644 index 00000000..0aae78c3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-pi-user-auth-and-startup-errors.md @@ -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. diff --git a/docs/superpowers/specs/2026-07-21-pi-user-auth-and-startup-errors-design.md b/docs/superpowers/specs/2026-07-21-pi-user-auth-and-startup-errors-design.md new file mode 100644 index 00000000..b7d1581b --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-pi-user-auth-and-startup-errors-design.md @@ -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. diff --git a/scripts/bootstrap-local-psd-docker-config.sh b/scripts/bootstrap-local-psd-docker-config.sh index dd5b9f0e..81c54e04 100644 --- a/scripts/bootstrap-local-psd-docker-config.sh +++ b/scripts/bootstrap-local-psd-docker-config.sh @@ -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) diff --git a/scripts/test-pi-user-auth-compose.sh b/scripts/test-pi-user-auth-compose.sh new file mode 100755 index 00000000..a183730f --- /dev/null +++ b/scripts/test-pi-user-auth-compose.sh @@ -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."