fix: use Pi user auth and handle startup failures
This commit is contained in:
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
Executable
+34
@@ -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."
|
||||
Reference in New Issue
Block a user