import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { closeSync, constants as fsConstants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; import { isAbsolute, join } from "node:path"; import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js"; import { secretValue, type SecretBundleConfig } from "../config/secret-bundle.js"; export interface ThtConfig extends SecretBundleConfig { thtBin: string; harnessDir: string; configPath: string; dataRoot?: string; runtimeSnapshotRoot?: string; } export interface SessionRow { id: string; status: string; question: string; summary: string | null; created_at: string; updated_at: string | null; author: string | null; } export interface SessionDocument { phase: string; key: string; title: string; format: string; content: string; } export interface OllamaEnsureResult { ok: boolean; stage?: string; error?: string; server?: string; model?: string; model_name?: string; } interface RuntimeSnapshot { path: string; dev: number; ino: number; size: number; mode: number; digest: string; } export class ThtRunner { private readonly runtimeSnapshots = new Map(); constructor(private cfg: ThtConfig, private principal?: PrincipalContext) {} /** Bind one trusted request principal to every child spawned by this runner. */ withPrincipal(principal: PrincipalContext): ThtRunner { return new ThtRunner(this.cfg, principal); } /** * Resolve the `-c ` args. A named workspace MUST exist: silently falling * back to the default config would point every operation at the wrong workspace * (wrong DB, wrong sessions dir) — fail loud instead. */ private configArg(workspaceConfigPath?: string): string[] { if (workspaceConfigPath) { if (isAbsolute(workspaceConfigPath)) { this.assertTrustedRuntimeSnapshot(workspaceConfigPath); return ["-c", workspaceConfigPath]; } if (workspaceConfigPath.includes("/")) { throw new Error("workspace snapshot config path must be absolute"); } const workspace = workspaceConfigPath; if (!existsSync(join(this.cfg.harnessDir, "workspaces", `${workspace}.yaml`))) { throw new Error(`workspace non trovato: workspaces/${workspace}.yaml (harness: ${this.cfg.harnessDir})`); } return ["-c", `workspaces/${workspace}.yaml`]; } return ["-c", this.cfg.configPath]; } private runtimeSnapshotDirectory(): string { if (!this.cfg.runtimeSnapshotRoot) throw new Error("runtime snapshot root is not configured"); if (!isAbsolute(this.cfg.runtimeSnapshotRoot)) throw new Error("runtime snapshot root must be absolute"); mkdirSync(this.cfg.runtimeSnapshotRoot, { recursive: true, mode: 0o700 }); const directory = lstatSync(this.cfg.runtimeSnapshotRoot); if (!directory.isDirectory() || directory.isSymbolicLink() || (directory.mode & 0o077) !== 0) { throw new Error("runtime snapshot root is not trusted"); } return realpathSync(this.cfg.runtimeSnapshotRoot); } private static isRestrictiveMode(mode: number): boolean { const permissions = mode & 0o777; return (permissions & 0o400) !== 0 && (permissions & ~0o600) === 0; } private assertTrustedRuntimeSnapshot(path: string): RuntimeSnapshot { const snapshot = this.runtimeSnapshots.get(path); if (!snapshot) throw new Error("config path is not a trusted runtime snapshot"); try { const entry = lstatSync(path); const stat = statSync(path); if ( !entry.isFile() || entry.isSymbolicLink() || stat.dev !== snapshot.dev || stat.ino !== snapshot.ino || stat.size !== snapshot.size || (stat.mode & 0o777) !== snapshot.mode || !ThtRunner.isRestrictiveMode(stat.mode) || createHash("sha256").update(readFileSync(path)).digest("hex") !== snapshot.digest ) throw new Error("changed runtime snapshot"); return snapshot; } catch { throw new Error("config path is not a trusted runtime snapshot"); } } /** Create an opaque, backend-owned temporary config that is safe to hand to `tht`. */ createRuntimeSnapshot(config: string): string { const directory = this.runtimeSnapshotDirectory(); const path = join(directory, `runtime-${randomUUID()}.yaml`); const fd = openSync( path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600, ); try { writeFileSync(fd, config, "utf8"); fsyncSync(fd); fchmodSync(fd, 0o400); const stat = fstatSync(fd); this.runtimeSnapshots.set(path, { path, dev: stat.dev, ino: stat.ino, size: stat.size, mode: stat.mode & 0o777, digest: createHash("sha256").update(config, "utf8").digest("hex"), }); return path; } catch (error) { try { unlinkSync(path); } catch { /* creation did not produce a removable file */ } throw error; } finally { closeSync(fd); } } cleanupRuntimeSnapshot(path: string): void { const snapshot = this.runtimeSnapshots.get(path); if (!snapshot) return; this.runtimeSnapshots.delete(path); try { unlinkSync(snapshot.path); } catch { /* a changed path is never removed recursively */ } } private openTrustedRuntimeSnapshot(path: string): number { const snapshot = this.assertTrustedRuntimeSnapshot(path); const fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); try { const stat = fstatSync(fd); if ( !stat.isFile() || stat.dev !== snapshot.dev || stat.ino !== snapshot.ino || stat.size !== snapshot.size || (stat.mode & 0o777) !== snapshot.mode || !ThtRunner.isRestrictiveMode(stat.mode) ) throw new Error("changed runtime snapshot"); const contents = Buffer.alloc(snapshot.size); let offset = 0; while (offset < contents.length) { const bytes = readSync(fd, contents, offset, contents.length - offset, offset); if (bytes === 0) throw new Error("truncated runtime snapshot"); offset += bytes; } if (createHash("sha256").update(contents).digest("hex") !== snapshot.digest) { throw new Error("changed runtime snapshot"); } return fd; } catch { closeSync(fd); throw new Error("config path is not a trusted runtime snapshot"); } } async runWithRuntimeSnapshot( args: string[], config: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS, ): Promise<{ code: number; stdout: string; stderr: string }> { const snapshot = this.createRuntimeSnapshot(config); try { return await this.run(args, snapshot, timeoutMs); } finally { this.cleanupRuntimeSnapshot(snapshot); } } /** * Build the full argv for a `tht` invocation. `--config`/`-c` is a PER-COMMAND * option in the `tht` CLI (there is NO global `-c`), so it MUST be appended * AFTER the subcommand + its flags, never prepended. */ buildArgv(args: string[], workspace?: string): string[] { return [...args, ...this.configArg(workspace)]; } // Every route awaits these children; without a ceiling, one hung DWH/vector call // (dropped VPN mid-connect) wedges its HTTP request forever. Session/file commands // get the default; DWH-touching commands pass a wider explicit budget. static readonly DEFAULT_TIMEOUT_MS = 60_000; static readonly DWH_TIMEOUT_MS = 120_000; run( args: string[], workspaceConfigPath?: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS, ): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { const env: NodeJS.ProcessEnv = { ...process.env }; delete env.THT_DATA_ROOT; clearPrincipalEnvironment(env); if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot; if (this.principal) Object.assign(env, principalEnvironment(this.principal)); for (const name of [ "THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY", ] as const) { const value = secretValue(this.cfg, name); if (value !== undefined) env[name] = value; } const ca = secretValue(this.cfg, "THT_SSL_CA") ?? secretValue(this.cfg, "THT_CA"); if (ca !== undefined) { env.THT_CA = ca; env.THT_SSL_CA = ca; } let snapshotFd: number | undefined; let ch; try { snapshotFd = workspaceConfigPath && isAbsolute(workspaceConfigPath) ? this.openTrustedRuntimeSnapshot(workspaceConfigPath) : undefined; ch = spawn( this.cfg.thtBin, snapshotFd === undefined ? this.buildArgv(args, workspaceConfigPath) : [...args, "-c", "/dev/fd/3"], { cwd: this.cfg.harnessDir, env, ...(snapshotFd === undefined ? {} : { stdio: ["ignore", "pipe", "pipe", snapshotFd] }), }, ); } finally { if (snapshotFd !== undefined) closeSync(snapshotFd); } let stdout = ""; let stderr = ""; let settled = false; let timer: ReturnType | undefined; const finish = (result: { code: number; stdout: string; stderr: string }) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); resolve(result); }; if (timeoutMs !== undefined) { timer = setTimeout(() => { try { ch.kill("SIGKILL"); } catch { /* already gone */ } finish({ code: 124, stdout, stderr: stderr || `timed out after ${timeoutMs}ms` }); }, timeoutMs); } ch.stdout?.on("data", (d: Buffer) => (stdout += d)); ch.stderr?.on("data", (d: Buffer) => (stderr += d)); ch.on("error", (error) => finish({ code: 1, stdout, stderr: stderr || error.message })); ch.on("close", (code) => finish({ code: code ?? 0, stdout, stderr })); }); } private async json(args: string[], workspace?: string, timeoutMs?: number): Promise { const { code, stdout, stderr } = await this.run(args, workspace, timeoutMs); if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`); return JSON.parse(stdout) as T; } private async ok(args: string[], workspace?: string): Promise { const { code, stderr } = await this.run(args, workspace); if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`); } async sessionNew(o: { question: string; provider?: string; model?: string; thinking?: string; name?: string; workspace?: string; }) { const a = ["session", "new", o.question]; for (const [f, v] of [ ["--provider", o.provider], ["--model", o.model], ["--thinking", o.thinking], ["--name", o.name], ] as const) { if (v) a.push(f, v); } a.push("--json"); return this.json<{ id: string }>(a, o.workspace); } /** Build and persist the deterministic F1 retrieval pack for a new session. */ async searchPack(question: string, sessionId: string, workspace?: string): Promise { const args = ["search", "pack", question, "--session", sessionId]; const { code, stderr } = await this.run(args, workspace, ThtRunner.DWH_TIMEOUT_MS); if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`); } /** * Probe DWH reachability via `tht db ping` (a REST health check). Never throws — * returns ok=false with the failure detail so the caller can refuse a new session * cleanly. Timed out to keep POST /sessions responsive when the host hangs. */ async dbPing(workspace?: string): Promise<{ ok: boolean; detail: string }> { const { code, stdout, stderr } = await this.run(["db", "ping"], workspace, 5_000); if (code === 0) return { ok: true, detail: stdout.trim() }; return { ok: false, detail: (stderr || stdout).trim() }; } sessionList(workspace?: string) { return this.json(["session", "list", "--json"], workspace); } sessionShow(id: string, workspace?: string) { return this.json(["session", "show", id, "--json"], workspace); } sqlPreview(id: string, p: { limit?: number; offset?: number }, workspace?: string) { // No positional FILE: the harness resolves sql_final.sql from the session // via _session_sql_file(cfg, session_id), which respects the workspace path. const a = ["sql", "preview", "--session", id, "--json"]; if (p.limit != null) a.push("--limit", String(p.limit)); if (p.offset != null) a.push("--offset", String(p.offset)); return this.json<{ columns: string[]; rows: unknown[][]; execution_ms: number; truncated: boolean; }>(a, workspace, ThtRunner.DWH_TIMEOUT_MS); } async sqlExport(id: string, workspace?: string) { const { code, stdout, stderr } = await this.run( ["sql", "export", "--session", id], workspace, ThtRunner.DWH_TIMEOUT_MS, ); if (code !== 0) throw new Error(`tht sql export exit ${code}: ${stderr.trim()}`); return { path: stdout.trim() }; } closeSession(id: string, workspace?: string) { return this.ok(["session", "close", id], workspace); } failSession(id: string, workspace?: string) { return this.ok(["session", "fail", id], workspace); } reopenSession(id: string, workspace?: string) { return this.ok(["session", "reopen", id], workspace); } setName(id: string, name: string, workspace?: string) { return this.ok(["session", "set-name", id, "--name", name], workspace); } setGroup(id: string, group: string, workspace?: string) { return this.ok(["session", "set-group", id, "--group", group], workspace); } archive(id: string, workspace?: string) { return this.ok(["session", "archive", id], workspace); } unarchive(id: string, workspace?: string) { return this.ok(["session", "unarchive", id], workspace); } 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, workspace?: string) { return this.json(["session", "documents", id, "--json"], workspace); } preferencesGet(workspace?: string) { return this.json>(["session", "preferences", "get", "--json"], workspace); } async preferencesSet(preferences: Record, workspace?: string): Promise { await this.ok(["session", "preferences", "set", "--json", JSON.stringify(preferences)], workspace); } async ollamaEnsure(workspace: string, timeoutSec: number): Promise { // Process budget wider than the CLI's own --timeout so the CLI reports its // failure itself; SIGKILL is only the backstop for a wedged child. const { code, stdout, stderr } = await this.run( ["ollama", "ensure", "--json", "--timeout", String(timeoutSec)], workspace, timeoutSec * 1000 + 30_000, ); let parsed: Partial | null = null; try { parsed = JSON.parse(stdout.trim()); } catch { /* not JSON */ } // Exit 0 with unparseable output is NOT a verified readiness: --json promises // pristine JSON, so treat the violation as a failed check, never as ok. if (code === 0 && parsed !== null) return { ok: true, ...parsed }; if (code === 0) { return { ok: false, error: `tht ollama ensure: output non-JSON: ${stdout.trim().slice(0, 200)}` }; } return { ok: false, stage: parsed?.stage, error: parsed?.error ?? (stderr.trim() || `tht ollama ensure exit ${code}`), }; } }