diff --git a/backend/src/app.ts b/backend/src/app.ts index b40c915c..7da2410d 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -34,6 +34,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc thtBin: config.thtBin, harnessDir: config.harnessDir, configPath: "config/tht.yaml", + dataRoot: config.dataRoot, }); const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined); const hub = new SseHub(); diff --git a/backend/src/config.ts b/backend/src/config.ts index d16dd5f8..4f5ff0e2 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,13 +1,15 @@ export interface AppConfig { - port: number; harnessDir: string; thtBin: string; piBin: string; + host: string; port: number; harnessDir: string; thtBin: string; piBin: string; authMode: "none" | "mock" | "oidc"; defaults: { provider?: string; model?: string; thinking?: string }; maxPiProcesses: number; settingsFile: string; + dataRoot?: string; ollamaEnsureTimeoutMs: number; } export function loadConfig(env: Record): AppConfig { return { + host: env.HOST ?? "127.0.0.1", port: Number(env.PORT ?? 8787), harnessDir: env.THT_HARNESS_DIR ?? "../harness", thtBin: env.THT_BIN ?? "tht", @@ -16,6 +18,7 @@ export function loadConfig(env: Record): AppConfig { defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING }, maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4), settingsFile: env.SETTINGS_FILE ?? "data/settings.json", + dataRoot: env.THT_DATA_ROOT, ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000), }; } diff --git a/backend/src/pi/list-models.ts b/backend/src/pi/list-models.ts index a7600d0f..4c62a195 100644 --- a/backend/src/pi/list-models.ts +++ b/backend/src/pi/list-models.ts @@ -1,5 +1,4 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { join } from "node:path"; import type { AppConfig } from "../config.js"; import { RpcClient } from "../rpc/rpc-client.js"; @@ -11,7 +10,11 @@ export interface PiModel { } interface Opts { - spawnFn?: () => ChildProcessWithoutNullStreams; + spawnFn?: ( + command: string, + args: string[], + options: { cwd: string; env: NodeJS.ProcessEnv }, + ) => ChildProcessWithoutNullStreams; ttlMs?: number; nowMs?: () => number; } @@ -24,22 +27,16 @@ interface Opts { export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise { const ttlMs = opts.ttlMs ?? 60_000; const now = opts.nowMs ?? (() => Date.now()); - const spawnFn = - opts.spawnFn ?? - (() => { - const harnessVenvBin = join(cfg.harnessDir, ".venv", "bin"); - return nodeSpawn(cfg.piBin, ["--mode", "rpc"], { - cwd: cfg.harnessDir, - env: { ...process.env, PATH: `${harnessVenvBin}:${process.env.PATH ?? ""}` }, - }) as ChildProcessWithoutNullStreams; - }); + const spawnFn = opts.spawnFn ?? nodeSpawn; let cache: { at: number; models: PiModel[] } | null = null; return async function listModels(): Promise { if (cache && now() - cache.at < ttlMs) return cache.models; - const child = spawnFn(); + const env: NodeJS.ProcessEnv = { ...process.env }; + if (cfg.dataRoot !== undefined) env.THT_DATA_ROOT = cfg.dataRoot; + const child = spawnFn(cfg.piBin, ["--mode", "rpc"], { cwd: cfg.harnessDir, env }); child.stderr.resume(); const rpc = new RpcClient(child); try { diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts index e724ee83..a5c8106e 100644 --- a/backend/src/pi/pi-process-manager.ts +++ b/backend/src/pi/pi-process-manager.ts @@ -1,5 +1,4 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { join } from "node:path"; import type { AppConfig } from "../config.js"; import { RpcClient } from "../rpc/rpc-client.js"; import { SessionBridge } from "../bridge/session-bridge.js"; @@ -11,8 +10,12 @@ export interface SessionRuntime { child: ChildProcessWithoutNullStreams; } -/** Injected test double signature: produce a child process, no args needed. */ -type SpawnFn = () => ChildProcessWithoutNullStreams; +/** Injectable child-process boundary; callbacks may ignore arguments in simpler tests. */ +type SpawnFn = ( + command: string, + args: string[], + options: { cwd: string; env: NodeJS.ProcessEnv }, +) => ChildProcessWithoutNullStreams; export class PiProcessManager { private runtimes = new Map(); @@ -20,30 +23,29 @@ export class PiProcessManager { constructor(private cfg: AppConfig, opts?: { spawnFn?: SpawnFn }) { if (opts?.spawnFn) { - this.spawnFn = () => opts.spawnFn!(); + this.spawnFn = (sessionId: string, author: string) => this.spawnPi(opts.spawnFn!, sessionId, author); } else { - this.spawnFn = (sessionId: string, author: string) => { - const harnessVenvBin = join(cfg.harnessDir, ".venv", "bin"); - const env: NodeJS.ProcessEnv = { - ...process.env, - THT_SESSION: sessionId, - THT_AUTHOR: author, - PATH: `${harnessVenvBin}:${process.env.PATH ?? ""}`, - }; - // pi 0.73 (the @mariozechner rebrand) removed the `--approve` flag: rpc mode is - // headless and runs tools without an approval gate, so passing it makes pi exit - // with "Unknown option: --approve". Args are intentionally just `--mode rpc`. - const child = nodeSpawn(cfg.piBin, ["--mode", "rpc"], { - cwd: cfg.harnessDir, - env, - }); - // Drain stderr so the child's stderr buffer never blocks the process. - child.stderr.resume(); - return child; - }; + this.spawnFn = (sessionId: string, author: string) => this.spawnPi(nodeSpawn, sessionId, author); } } + private spawnPi(spawnFn: SpawnFn, sessionId: string, author: string): ChildProcessWithoutNullStreams { + const env: NodeJS.ProcessEnv = { + ...process.env, + THT_SESSION: sessionId, + THT_AUTHOR: author, + }; + 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. + const child = spawnFn(this.cfg.piBin, ["--mode", "rpc"], { + cwd: this.cfg.harnessDir, + env, + }); + // Drain stderr so the child's stderr buffer never blocks the process. + child.stderr.resume?.(); + return child; + } + count(): number { return this.runtimes.size; } get(id: string): SessionRuntime | undefined { return this.runtimes.get(id); } diff --git a/backend/src/server.ts b/backend/src/server.ts index ffc62349..6cef6b0d 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,5 +2,5 @@ import { buildApp } from "./app.js"; import { loadConfig } from "./config.js"; const config = loadConfig(process.env); const app = buildApp(config); -app.listen({ port: config.port, host: "127.0.0.1" }) +app.listen({ port: config.port, host: config.host }) .then((addr) => console.log(`backend listening on ${addr}`)); diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index c93409b6..0a8437b1 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -6,6 +6,7 @@ export interface ThtConfig { thtBin: string; harnessDir: string; configPath: string; + dataRoot?: string; } export interface SessionRow { @@ -61,8 +62,11 @@ export class ThtRunner { run(args: string[], workspace?: string): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { + const env: NodeJS.ProcessEnv = { ...process.env }; + if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot; const ch = spawn(this.cfg.thtBin, this.buildArgv(args, workspace), { cwd: this.cfg.harnessDir, + env, }); let stdout = ""; let stderr = ""; diff --git a/backend/test/config.test.ts b/backend/test/config.test.ts new file mode 100644 index 00000000..4c640897 --- /dev/null +++ b/backend/test/config.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "vitest"; +import { loadConfig } from "../src/config.js"; + +test("loadConfig accepts container listening and runtime paths", () => { + expect(loadConfig({ + HOST: "0.0.0.0", + PORT: "9000", + THT_HARNESS_DIR: "/app/harness", + THT_BIN: "/opt/venv/bin/tht", + PI_BIN: "/usr/local/bin/pi", + SETTINGS_FILE: "/data/settings/settings.json", + THT_DATA_ROOT: "/data", + })).toMatchObject({ + host: "0.0.0.0", + port: 9000, + harnessDir: "/app/harness", + thtBin: "/opt/venv/bin/tht", + piBin: "/usr/local/bin/pi", + settingsFile: "/data/settings/settings.json", + dataRoot: "/data", + }); +}); + +test("loadConfig keeps local development defaults", () => { + expect(loadConfig({})).toMatchObject({ + host: "127.0.0.1", + port: 8787, + harnessDir: "../harness", + thtBin: "tht", + piBin: "pi", + settingsFile: "data/settings.json", + }); + expect(loadConfig({}).dataRoot).toBeUndefined(); +}); diff --git a/backend/test/list-models.test.ts b/backend/test/list-models.test.ts index 6175cacd..51237ab8 100644 --- a/backend/test/list-models.test.ts +++ b/backend/test/list-models.test.ts @@ -51,3 +51,33 @@ test("createPiModelLister caches within ttl (spawns once for two calls)", async rmSync(path.dirname(script), { recursive: true, force: true }); } }); + +test("production model-list spawn preserves PATH and passes the portable data root", async () => { + const script = scriptWith([]); + const calls: any[][] = []; + const previousPath = process.env.PATH; + process.env.PATH = "/usr/local/bin:/usr/bin"; + try { + const lister = createPiModelLister(loadConfig({ + THT_HARNESS_DIR: "/app/harness", + PI_BIN: "/usr/local/bin/pi", + THT_DATA_ROOT: "/data", + }), { + spawnFn: (...args: any[]) => { + calls.push(args); + return spawn("node", [FAKE, script]) as any; + }, + }); + await lister(); + expect(calls[0][0]).toBe("/usr/local/bin/pi"); + expect(calls[0][1]).toEqual(["--mode", "rpc"]); + expect(calls[0][2]).toMatchObject({ + cwd: "/app/harness", + env: expect.objectContaining({ PATH: "/usr/local/bin:/usr/bin", THT_DATA_ROOT: "/data" }), + }); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + rmSync(path.dirname(script), { recursive: true, force: true }); + } +}); diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index 53e574e2..0ecb1f4a 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "vitest"; +import { test, expect, vi } from "vitest"; import { spawn } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -125,3 +125,38 @@ test("spawnFor default (new) mode sends /nuova-domanda", async () => { expect(child._writes.join("")).toContain("/nuova-domanda"); mgr.teardown("sid-10"); }); + +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"); + vi.stubEnv("NODE_EXTRA_CA_CERTS", "/certs/company-ca.pem"); + const calls: any[][] = []; + const child = recordingChild(); + child.stderr.resume = () => {}; + const spawnFn = (...args: any[]) => { calls.push(args); return child as any; }; + const cfg = loadConfig({ + THT_HARNESS_DIR: "/app/harness", + PI_BIN: "/usr/local/bin/pi", + THT_DATA_ROOT: "/data", + }); + const mgr = new PiProcessManager(cfg, { spawnFn }); + + try { + await mgr.spawnFor("portable-session", { author: "user@example.test" }); + const [bin, args, options] = calls[0]; + expect(bin).toBe("/usr/local/bin/pi"); + expect(args).toEqual(["--mode", "rpc"]); + expect(options.cwd).toBe("/app/harness"); + expect(options.env).toMatchObject({ + PATH: "/usr/local/bin:/usr/bin", + PI_PROVIDER_API_KEY: "provider-secret", + NODE_EXTRA_CA_CERTS: "/certs/company-ca.pem", + THT_DATA_ROOT: "/data", + THT_SESSION: "portable-session", + THT_AUTHOR: "user@example.test", + }); + } finally { + mgr.teardown("portable-session"); + vi.unstubAllEnvs(); + } +});