From bd798b1c964613ceceb802bdf69a69306d3351f9 Mon Sep 17 00:00:00 2001 From: mptyl Date: Wed, 5 Aug 2026 16:03:45 +0200 Subject: [PATCH] fix: render registry workspaces for harness --- PROJECT_STATE.md | 18 +- README.md | 16 +- backend/src/app.ts | 3 +- backend/src/pi/pi-process-manager.ts | 38 +++- backend/src/routes/sessions.ts | 24 ++- backend/src/routes/sql.ts | 51 ++++-- backend/src/tht/tht-runner.ts | 122 ++++++++++++- backend/src/workspaces/runtime-renderer.ts | 22 +++ backend/src/workspaces/schema.ts | 5 +- backend/test/auth.test.ts | 31 +++- backend/test/pi-process-manager.test.ts | 21 +++ backend/test/routes-sessions.test.ts | 16 +- backend/test/routes-sql-meta.test.ts | 37 ++++ .../test/workspace-runtime-handoff.test.ts | 167 ++++++++++++++++++ backend/test/workspaces-schema.test.ts | 7 + harness/tests/test_config_resources.py | 60 +++++++ harness/tht/cli/search_cmd.py | 6 +- harness/tht/config.py | 26 ++- scripts/task13-runtime-fixture-check.ts | 30 ++-- scripts/test-task13-runtime-fixtures.sh | 5 +- scripts/unified-deployment-smoke.sh | 23 ++- 21 files changed, 659 insertions(+), 69 deletions(-) create mode 100644 backend/test/workspace-runtime-handoff.test.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index fd5cb107..ba0c1feb 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # ThothII — Project State -> Starting-point snapshot for new sessions. Last updated: 2026-08-05 (portable deployment decoupled). +> Starting-point snapshot for new sessions. Last updated: 2026-08-05 (Task 13 fix round 3/5). > Point a fresh session here ("read PROJECT_STATE.md") before substantial work. ## Unified deployment release gate — Task 13 (2026-08-05) @@ -39,11 +39,17 @@ writable parent bind while protected/tracked sources remain separate read-only mounts. Clean empty-root render/setup and wrong-service/value/mount mutations are green. The corrected server one-shot built and started both healthy services from an empty Pi-state root, then stopped at an - incorrectly addressed authenticated frontend hop; the trusted-hop fixture correction is - deterministic-only. The corrected rollback one-shot passed runtime/Pi/registry/persistence and - stopped-candidate preflight, then stopped at active-session inventory before mutation. Exact - cleanup passed for both. Full server behavior, compensation/all-sentinel preservation, and - native Windows PowerShell/Docker execution remain explicit release gates. + incorrectly addressed authenticated frontend hop. Fix round 3 adds the exact fourth private + non-admin claim and proves its nginx/backend transformation in a focused auth test. It also + centralizes schema-v2 registry descriptor resolution and secret-safe runtime rendering in + `ThtRunner`, preserving canonical revision identity and durable session roots for inventory, + create/resume/show, SQL, and Pi calls. The fresh update-only one-shot now passes mutation, + automatic `rolled_back` compensation, exact prior-image restoration, unchanged registry head + and mount identities, all four persistence sentinels, post-rollback doctor/workspace checks, + and exact labeled-resource cleanup. The one authorized server invocation was blocked at its + first Docker readiness call by the execution sandbox's socket permission before any Compose + resource could be created, so authenticated workspace/fail-closed session behavior remains an + explicit release gate. Native Windows PowerShell/Docker execution also remains pending. ## Portable deployment decoupling — LIVE 2026-08-05 diff --git a/README.md b/README.md index fcad9967..77009767 100644 --- a/README.md +++ b/README.md @@ -141,12 +141,16 @@ Each public smoke has its own 30-minute process-group supervisor with TERM/KILL an independent 32-minute outer timeout and does not retry a failed command. Current release status (2026-08-05): clean-root render/setup and the production runtime-binding -resolver contracts are green. The single corrected server-profile run proved image build, -clean-root startup, and core/frontend health, then stopped at a fixture-authenticated frontend -request; its trusted-hop headers are corrected deterministically but were not rerun. The single -corrected rollback run reached runtime/Pi/registry/persistence checks and the stopped-candidate -preflight, then stopped at active-session inventory before mutation. Full server behavior and -bad-Pi compensation with unchanged state therefore remain release gates. Native Windows Docker +resolver contracts are green. The server fixture supplies all four private trusted claims, +including exact non-admin value `0`, and a focused test proves nginx normalization produces the +accepted non-admin backend principal. Canonical schema-v2 registry descriptors now pass through +one backend-owned, secret-safe runtime handoff for inventory and session execution; canonical +identity and durable session/artifact/index roots are retained. The fresh update-only smoke passed +bad-candidate mutation, automatic `rolled_back` compensation, exact prior-image restoration, +unchanged registry/mount identity, all four sentinels, post-rollback doctor/workspace checks, and +exact cleanup. The one authorized server-smoke invocation was denied access to the Docker socket +by its execution sandbox before startup, so the complete authenticated workspace and fail-closed +session assertions still require a fresh authorized release run. Native Windows Docker Desktop/WSL2 remains a separate manual/self-hosted gate. The deterministic native Windows contract is: diff --git a/backend/src/app.ts b/backend/src/app.ts index cb357f48..e02f92d0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -55,6 +55,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc configPath: process.env.THT_CONFIG ?? "config/tht.yaml", dataRoot: config.dataRoot, runtimeSnapshotRoot: join(config.workspaceRegistry.root, "snapshots", "runtime"), + secretRoots: config.workspaceRegistry.secretRoots, secretsFile: config.secretsFile, secretFiles: config.secretFiles, }); @@ -141,7 +142,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc app.get("/internal/maintenance/status", async (req, reply) => { return maintenanceBarrier.status(); }); - sqlRoutes(app, { tht: tht as ThtRunner, getSettings }); + sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry }); metaRoutes(app, { harnessDir: config.harnessDir, listModels }); workspaceRoutes(app, { registry: workspaceRegistry, config: config.workspaceRegistry, diagnose: workspaceDiagnoser }); settingsRoutes(app, { cfg: config, listModels, getSettings }); diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts index e4b2486b..139a350d 100644 --- a/backend/src/pi/pi-process-manager.ts +++ b/backend/src/pi/pi-process-manager.ts @@ -2,7 +2,7 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:ch import type { AppConfig } from "../config.js"; import { RpcClient } from "../rpc/rpc-client.js"; import { SessionBridge } from "../bridge/session-bridge.js"; -import type { ThtRunner } from "../tht/tht-runner.js"; +import type { RuntimeConfigLease, ThtRunner } from "../tht/tht-runner.js"; import { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js"; import { loadPiAuthProviders } from "./auth-providers.js"; import { secretValue } from "../config/secret-bundle.js"; @@ -14,6 +14,7 @@ export interface SessionRuntime { bridge: SessionBridge; child: ChildProcessWithoutNullStreams; ownerKey?: string; + releaseRuntimeConfig?: () => void; } export interface RuntimeOptions { @@ -24,6 +25,7 @@ export interface RuntimeOptions { question?: string; mode?: "new" | "resume"; principal?: PrincipalContext; + runtimeConfig?: RuntimeConfigLease; } /** Injectable child-process boundary; callbacks may ignore arguments in simpler tests. */ @@ -37,6 +39,7 @@ export class PiProcessManager { private runtimes = new Map(); private spawnFn: ( sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext, + runtimeConfigPath?: string, ) => ChildProcessWithoutNullStreams; private loadAuthProviders: (agentDir: string) => ReadonlySet; @@ -47,16 +50,17 @@ export class PiProcessManager { this.loadAuthProviders = opts?.authProviders ?? ((agentDir) => loadPiAuthProviders({ agentDir })); if (opts?.spawnFn) { - this.spawnFn = (sessionId, author, provider, principal) => - this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal); + this.spawnFn = (sessionId, author, provider, principal, runtimeConfigPath) => + this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal, runtimeConfigPath); } else { - this.spawnFn = (sessionId, author, provider, principal) => - this.spawnPi(nodeSpawn, sessionId, author, provider, principal); + this.spawnFn = (sessionId, author, provider, principal, runtimeConfigPath) => + this.spawnPi(nodeSpawn, sessionId, author, provider, principal, runtimeConfigPath); } } private spawnPi( - spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext, + spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined, + principal?: PrincipalContext, runtimeConfigPath?: string, ): ChildProcessWithoutNullStreams { // This is the final shared boundary for createFor(), spawnFor(), and resume(). Validate // before auth-provider inspection, then make Pi consume the exact copied bytes rather than @@ -94,6 +98,7 @@ export class PiProcessManager { } delete env.THT_DATA_ROOT; if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot; + if (runtimeConfigPath !== undefined) env.THT_CONFIG = runtimeConfigPath; // pi 0.73 removed `--approve`: rpc mode is headless and its argv is intentionally minimal. child = spawnFn(this.cfg.piBin, ["--mode", "rpc"], { cwd: this.cfg.harnessDir, @@ -132,15 +137,31 @@ export class PiProcessManager { // SIGTERM to the in-flight Pi process and lose its pending gate. const existing = this.runtimes.get(sessionId); if (existing) { + o.runtimeConfig?.release(); throw new Error(`session runtime already active: ${sessionId}`); } if (o.principal) this.teardownForPrincipal(o.principal); if (this.runtimes.size >= this.cfg.maxPiProcesses) { + o.runtimeConfig?.release(); throw new Error("max Pi processes reached"); } const author = o.author ?? "dev@local"; const provider = canonicalPiProvider(o.provider ?? this.cfg.defaults.provider); - const child = this.spawnFn(sessionId, author, provider, o.principal); + let child: ChildProcessWithoutNullStreams; + try { + child = this.spawnFn(sessionId, author, provider, o.principal, o.runtimeConfig?.path); + } catch (error) { + o.runtimeConfig?.release(); + throw error; + } + let runtimeConfigReleased = false; + const releaseRuntimeConfig = () => { + if (runtimeConfigReleased) return; + runtimeConfigReleased = true; + o.runtimeConfig?.release(); + }; + child.once("exit", releaseRuntimeConfig); + child.once("close", releaseRuntimeConfig); let rt: SessionRuntime | undefined; try { const rpc = new RpcClient(child); @@ -150,6 +171,7 @@ export class PiProcessManager { bridge, child, ownerKey: o.principal ? `${o.principal.issuer}\0${o.principal.subject}` : undefined, + ...(o.runtimeConfig ? { releaseRuntimeConfig } : {}), }; rt = runtime; bridge.beginTurn(); @@ -184,6 +206,7 @@ export class PiProcessManager { return runtime; } catch (error) { if (rt && this.runtimes.get(sessionId) === rt) this.runtimes.delete(sessionId); + releaseRuntimeConfig(); try { child.kill(); } catch { /* preserve the initialization error */ } throw error; } @@ -251,6 +274,7 @@ export class PiProcessManager { // Delete before signalling the child so its asynchronous exit cannot be mistaken for a // crash, and so a replacement installed by a later lifecycle operation is never targeted. this.runtimes.delete(id); + expected.releaseRuntimeConfig?.(); expected.child.kill(); return true; } diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 029733ee..785473e2 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -77,6 +77,12 @@ export function sessionRoutes( return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner; }; + const optionsWithRuntimeConfig = (runner: any, workspaceConfigPath: string | undefined, options: any) => ( + workspaceConfigPath && typeof runner.acquireWorkspaceRuntime === "function" + ? { ...options, runtimeConfig: runner.acquireWorkspaceRuntime(workspaceConfigPath) } + : options + ); + const maintenanceReply = (reply: any) => reply.code(503).send({ code: "maintenance", error: "Session admission is temporarily paused for maintenance. Try again shortly.", @@ -413,9 +419,11 @@ export function sessionRoutes( principal, question: b.question, }; + let runtimeOptions = options; let rt: ReturnType | undefined; try { - rt = d.mgr.createFor(id, options); + runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options); + rt = d.mgr.createFor(id, runtimeOptions); bindRuntime(id, rt, runner, workspaceConfigPath); } catch (error) { if (rt) d.mgr.teardownIfCurrent(id, rt); @@ -430,9 +438,9 @@ export function sessionRoutes( } info(id, "Session created"); bootstrap( - id, rt, runner, workspaceConfigPath, d.mgr.configure(rt, options), + id, rt, runner, workspaceConfigPath, d.mgr.configure(rt, runtimeOptions), runner.searchPack(b.question, id, workspaceConfigPath), - () => d.mgr.start(id, rt, options), + () => d.mgr.start(id, rt, runtimeOptions), ); return { id }; } finally { @@ -560,6 +568,7 @@ export function sessionRoutes( principal, mode: "resume" as const, }; + let runtimeOptions = options; // Reopening is validation, not the transport commit point. Keep the old hub intact if // persistence cannot be reopened. @@ -589,7 +598,8 @@ export function sessionRoutes( if (boundRuntimes.get(id) === current) boundRuntimes.delete(id); d.mgr.teardownIfCurrent(id, current); } - rt = d.mgr.createFor(id, options); + runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options); + rt = d.mgr.createFor(id, runtimeOptions); bindRuntime(id, rt, runner, workspaceConfigPath); } catch { // A created-but-unbound runtime is not usable. The old hub remains attached because @@ -605,7 +615,11 @@ export function sessionRoutes( // immediately before the first event produced by the new Resume. d.hub.clear(id); info(id, "Resuming session"); - bootstrap(id, rt, runner, workspaceConfigPath, d.mgr.configure(rt, options), null, () => d.mgr.start(id, rt, options)); + bootstrap( + id, rt, runner, workspaceConfigPath, + d.mgr.configure(rt, runtimeOptions), null, + () => d.mgr.start(id, rt, runtimeOptions), + ); return reply.code(200).send({ id, alreadyActive: false }); }); }); diff --git a/backend/src/routes/sql.ts b/backend/src/routes/sql.ts index f47304cb..67b529f1 100644 --- a/backend/src/routes/sql.ts +++ b/backend/src/routes/sql.ts @@ -3,22 +3,49 @@ import type { ThtRunner } from "../tht/tht-runner.js"; import { getPrincipal } from "../auth/auth.js"; import type { PrincipalContext } from "../auth/principal.js"; import type { Settings } from "../settings/settings-store.js"; +import type { WorkspaceRegistry } from "../workspaces/registry.js"; export function sqlRoutes(app: FastifyInstance, deps: { tht: ThtRunner; getSettings: (principal: PrincipalContext) => Promise; + workspaceRegistry: WorkspaceRegistry; }): void { const runnerFor = (principal: PrincipalContext): any => { const runner = deps.tht as any; return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner; }; - const authorize = async (principal: PrincipalContext, id: string, workspace?: string) => { - try { - const runner = runnerFor(principal); - if (typeof runner.sessionShow !== "function") return {}; - return await runner.sessionShow(id, workspace); + const isNotFound = (error: unknown) => /not found|non trovata|inesistente|404/i.test( + error instanceof Error ? error.message : String(error), + ); + const locate = async (principal: PrincipalContext, id: string, legacyWorkspace?: string) => { + const runner = runnerFor(principal); + if (typeof runner.sessionShow !== "function") return { manifest: {}, workspace: legacyWorkspace }; + const registry = deps.workspaceRegistry as Partial; + const revisions = typeof registry.listRetainedSnapshots === "function" + ? await registry.listRetainedSnapshots.call(deps.workspaceRegistry) + : await deps.workspaceRegistry.list(); + for (const revision of revisions) { + if (revision.state !== "operational") continue; + try { + const manifest = await runner.sessionShow(id, revision.snapshotPath); + if (!manifest) continue; + const saved = manifest as { workspace_id?: string; workspace_revision?: string }; + if (saved.workspace_id && saved.workspace_revision) { + const pinned = await deps.workspaceRegistry.readPinned(saved.workspace_id, saved.workspace_revision); + return { + manifest, + workspace: pinned.workspaceConfigPath ?? (pinned as any).revision?.snapshotPath, + }; + } + return { manifest, workspace: revision.snapshotPath }; + } catch (error) { + if (!isNotFound(error)) throw error; + } } - catch (error) { - if (/not found|non trovata|inesistente|404/i.test(error instanceof Error ? error.message : String(error))) return undefined; + try { + const manifest = await runner.sessionShow(id, legacyWorkspace); + return manifest ? { manifest, workspace: legacyWorkspace } : undefined; + } catch (error) { + if (isNotFound(error)) return undefined; throw error; } }; @@ -30,8 +57,9 @@ export function sqlRoutes(app: FastifyInstance, deps: { try { principal = getPrincipal(req); const settings = await deps.getSettings(principal); - workspace = settings.workspace; - if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" }); + const located = await locate(principal, id, settings.workspace); + if (!located) return reply.code(404).send({ error: "session not found" }); + workspace = located.workspace; } catch { return reply.code(503).send({ error: "session storage is unavailable" }); } @@ -49,8 +77,9 @@ export function sqlRoutes(app: FastifyInstance, deps: { try { principal = getPrincipal(req); const settings = await deps.getSettings(principal); - workspace = settings.workspace; - if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" }); + const located = await locate(principal, id, settings.workspace); + if (!located) return reply.code(404).send({ error: "session not found" }); + workspace = located.workspace; } catch { return reply.code(503).send({ error: "session storage is unavailable" }); } diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index 8a6644f3..dc6e0db9 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -4,9 +4,13 @@ import { closeSync, constants as fsConstants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; -import { dirname, isAbsolute, join, relative } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { parseAllDocuments } from "yaml"; import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js"; import { secretValue, type SecretBundleConfig } from "../config/secret-bundle.js"; +import { resolveRuntimeBindings } from "../workspaces/bindings.js"; +import { renderRuntimeConfig, type RuntimeInstallationOverlay, type RuntimePaths } from "../workspaces/runtime-renderer.js"; +import { parseWorkspaceYaml } from "../workspaces/schema.js"; export interface ThtConfig extends SecretBundleConfig { thtBin: string; @@ -14,6 +18,14 @@ export interface ThtConfig extends SecretBundleConfig { configPath: string; dataRoot?: string; runtimeSnapshotRoot?: string; + secretRoots?: readonly string[]; +} + +export interface RuntimeConfigLease { + path: string; + workspaceId: string; + workspaceRevision: string; + release(): void; } export interface SessionRow { @@ -87,18 +99,110 @@ export class ThtRunner { return ["-c", this.cfg.configPath]; } - private assertWorkspaceSnapshot(path: string): void { + private assertWorkspaceSnapshot(path: string): { workspaceId: string; workspaceRevision: string } { if (!this.cfg.runtimeSnapshotRoot) throw new Error("workspace snapshot root is not configured"); const snapshotsRoot = dirname(this.cfg.runtimeSnapshotRoot); const pathRelative = relative(snapshotsRoot, path); + const match = /^([0-9a-f]{40})\/([a-z][a-z0-9-]{2,62})\.yaml$/.exec(pathRelative); if ( pathRelative.startsWith("..") || isAbsolute(pathRelative) - || !/^[0-9a-f]{40}\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(pathRelative) + || !match ) throw new Error("config path is not a trusted runtime snapshot"); const entry = lstatSync(path); if (!entry.isFile() || entry.isSymbolicLink()) { throw new Error("config path is not a trusted runtime snapshot"); } + return { workspaceRevision: match[1], workspaceId: match[2] }; + } + + private readCanonicalWorkspaceSnapshot(path: string): { + workspace: ReturnType; + workspaceId: string; + workspaceRevision: string; + } { + const identity = this.assertWorkspaceSnapshot(path); + const fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const before = fstatSync(fd); + if (!before.isFile()) throw new Error("workspace snapshot is not a file"); + const source = readFileSync(fd, "utf8"); + const after = fstatSync(fd); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) { + throw new Error("workspace snapshot changed while reading"); + } + const workspace = parseWorkspaceYaml(source); + if (workspace.workspace.id !== identity.workspaceId) { + throw new Error("workspace snapshot identity does not match its path"); + } + return { workspace, ...identity }; + } finally { + closeSync(fd); + } + } + + private runtimePaths(workspaceId: string): RuntimePaths { + if (!this.cfg.dataRoot || !isAbsolute(this.cfg.dataRoot)) { + throw new Error("registry workspace runtime requires an absolute data root"); + } + // The portable stack persists one `sessions` store at /sessions. Keep every + // workspace's mutable harness roots below that mounted boundary. + const root = join(this.cfg.dataRoot, "sessions", workspaceId); + return { + sessions: join(root, "sessions"), + artifacts: join(root, "artifacts"), + indexes: join(root, "indexes"), + }; + } + + private installationOverlay(): RuntimeInstallationOverlay { + const path = isAbsolute(this.cfg.configPath) + ? this.cfg.configPath + : resolve(this.cfg.harnessDir, this.cfg.configPath); + if (!existsSync(path)) return {}; + const documents = parseAllDocuments(readFileSync(path, "utf8"), { uniqueKeys: true }); + if (documents.length !== 1) throw new Error("installation config must contain one YAML document"); + const document = documents[0]; + if (document.errors.length > 0 || document.warnings.length > 0) { + throw new Error("installation config contains invalid YAML"); + } + const parsed = document.toJSON(); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("installation config must be a YAML mapping"); + } + const source = parsed as Record; + return { + ...(source.session_storage === undefined ? {} : { session_storage: source.session_storage }), + ...(source.profile === undefined ? {} : { profile: source.profile }), + }; + } + + /** Render one immutable canonical registry revision into a backend-owned harness config. */ + acquireWorkspaceRuntime(workspaceConfigPath: string): RuntimeConfigLease { + const canonical = this.readCanonicalWorkspaceSnapshot(workspaceConfigPath); + const bindings = resolveRuntimeBindings( + canonical.workspace, + process.env, + this.cfg.secretRoots ?? [], + ); + const config = renderRuntimeConfig( + canonical.workspace, + bindings, + this.runtimePaths(canonical.workspaceId), + canonical, + this.installationOverlay(), + ); + const path = this.createRuntimeSnapshot(config); + let released = false; + return { + path, + workspaceId: canonical.workspaceId, + workspaceRevision: canonical.workspaceRevision, + release: () => { + if (released) return; + released = true; + this.cleanupRuntimeSnapshot(path); + }, + }; } private runtimeSnapshotDirectory(): string { @@ -228,6 +332,18 @@ export class ThtRunner { run( args: string[], workspaceConfigPath?: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS, ): Promise<{ code: number; stdout: string; stderr: string }> { + if ( + workspaceConfigPath && isAbsolute(workspaceConfigPath) + && !this.runtimeSnapshots.has(workspaceConfigPath) + ) { + let runtime: RuntimeConfigLease; + try { + runtime = this.acquireWorkspaceRuntime(workspaceConfigPath); + } catch (error) { + return Promise.reject(error); + } + return this.run(args, runtime.path, timeoutMs).finally(runtime.release); + } return new Promise((resolve) => { const env: NodeJS.ProcessEnv = { ...process.env }; delete env.THT_DATA_ROOT; diff --git a/backend/src/workspaces/runtime-renderer.ts b/backend/src/workspaces/runtime-renderer.ts index d922361a..7aab5b6c 100644 --- a/backend/src/workspaces/runtime-renderer.ts +++ b/backend/src/workspaces/runtime-renderer.ts @@ -10,6 +10,16 @@ export interface RuntimePaths { indexes: string; } +export interface RuntimeIdentity { + workspaceId: string; + workspaceRevision: string; +} + +export interface RuntimeInstallationOverlay { + session_storage?: unknown; + profile?: unknown; +} + function seconds(timeoutMs: number | undefined): number | undefined { return timeoutMs === undefined ? undefined : Math.max(1, Math.ceil(timeoutMs / 1_000)); } @@ -73,6 +83,8 @@ export function renderRuntimeConfig( workspace: WorkspaceDescriptor, bindings: RuntimeBindings, paths: RuntimePaths, + identity?: RuntimeIdentity, + installation: RuntimeInstallationOverlay = {}, ): string { const canonical = validateCanonicalWorkspace(workspace); if ([...bindings.dwh.missing, ...bindings.vector.missing, ...bindings.embedding.missing].length > 0) { @@ -113,10 +125,20 @@ export function renderRuntimeConfig( if (embeddingTimeout !== undefined) embedding.timeout = embeddingTimeout; const rendered: Record = { + ...(identity ? { + runtime_identity: { + workspace_id: identity.workspaceId, + workspace_revision: identity.workspaceRevision, + }, + } : {}), + ...(installation.session_storage === undefined + ? {} : { session_storage: installation.session_storage }), + ...(installation.profile === undefined ? {} : { profile: installation.profile }), language: canonical.workspace.language, database, vector_db: vectorDb, embeddings: embedding, + roots: paths, paths, }; if (dwhDirect) { diff --git a/backend/src/workspaces/schema.ts b/backend/src/workspaces/schema.ts index e0754fc7..8a79cf6d 100644 --- a/backend/src/workspaces/schema.ts +++ b/backend/src/workspaces/schema.ts @@ -266,8 +266,9 @@ export function parseWorkspaceYaml(source: string): WorkspaceDescriptor { const documents = parseAllDocuments(source, { uniqueKeys: true }); if (documents.length !== 1) throw new Error("Workspace YAML must contain exactly one document"); const document = documents[0]; - if (document.errors.length > 0) { - throw new Error(`Invalid workspace YAML: ${document.errors.map((error) => error.message).join("; ")}`); + if (document.errors.length > 0 || document.warnings.length > 0) { + throw new Error(`Invalid workspace YAML: ${[...document.errors, ...document.warnings] + .map((error) => error.message).join("; ")}`); } return validateWorkspaceDescriptor(document.toJSON()); } diff --git a/backend/test/auth.test.ts b/backend/test/auth.test.ts index 8285eb47..24eec111 100644 --- a/backend/test/auth.test.ts +++ b/backend/test/auth.test.ts @@ -1,10 +1,37 @@ import { test, expect } from "vitest"; import Fastify from "fastify"; import { authPreHandler, getPrincipal } from "../src/auth/auth.js"; -import { chmodSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { expandLocalHome, localPrincipal } from "../src/auth/principal.js"; +import { expandLocalHome, localPrincipal, upstreamPrincipal } from "../src/auth/principal.js"; + +test("server smoke trusted claims transform through nginx to a non-admin principal", () => { + const smoke = readFileSync("../scripts/unified-deployment-smoke.sh", "utf8"); + const nginx = readFileSync("../docker/nginx.conf.template", "utf8"); + const helper = smoke.match(/task13_server_auth_headers\(\) \{([\s\S]*?)\n\}/)?.[1] ?? ""; + const trusted = Object.fromEntries( + [...helper.matchAll(/-H '([^:']+): ([^']+)'/g)].map((match) => [match[1].toLowerCase(), match[2]]), + ); + const normalized: Record = {}; + for (const [header, suffix] of [ + ["x-thoth-principal-issuer", "principal_issuer"], + ["x-thoth-principal-subject", "principal_subject"], + ["x-thoth-principal-display-name", "principal_display_name"], + ["x-thoth-is-admin", "is_admin"], + ]) { + expect(nginx).toContain(`$http_x_thoth_trusted_${suffix}`); + const value = trusted[`x-thoth-trusted-${header.slice("x-thoth-".length)}`]; + if (value !== undefined) normalized[header] = value; + } + + expect(upstreamPrincipal(normalized)).toEqual({ + issuer: "task13-proxy", + subject: "task13-user", + displayName: "Task 13 User", + isAdmin: false, + }); +}); test("local mode resolves a stable local principal", async () => { const app = Fastify(); diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index 51d1ce96..3238ecb1 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -168,6 +168,27 @@ function recordingChild() { return ch; } +test("Pi receives the leased workspace runtime config and releases it on direct teardown", () => { + const child = recordingChild(); + let spawnEnv: NodeJS.ProcessEnv | undefined; + const release = vi.fn(); + const mgr = new PiProcessManager(loadConfig({}), { + spawnFn: (_command, _args, options) => { + spawnEnv = options.env; + return child as any; + }, + }); + + mgr.createFor("canonical-runtime", { + runtimeConfig: { path: "/trusted/runtime-uuid.yaml", release }, + } as any); + expect(spawnEnv?.THT_CONFIG).toBe("/trusted/runtime-uuid.yaml"); + + // The child deliberately emits neither exit nor close. Ownership cleanup must not depend on it. + mgr.teardown("canonical-runtime"); + expect(release).toHaveBeenCalledOnce(); +}); + test.each([ ["new", "auth.json", '{"deepseek":{"key":"!runtime-auth-command runtime-secret /private/runtime-auth"}}\n'], ["new", "models.json", '{"providers":{"local-qwen":{"headers":["!runtime-model-command runtime-secret /private/runtime-model"]}}}\n'], diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 4d05cae0..a1461109 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -601,6 +601,8 @@ test("session lifecycle locates a B session when installation default is A", asy provider: "zai", model: "glm-5.2", thinking: "low", }; const calls: string[] = []; + const runtimeSources: string[] = []; + const runtimeOptions: string[] = []; let active: any; const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { @@ -622,11 +624,21 @@ test("session lifecycle locates a B session when installation default is A", asy calls.push(`reopen:${workspace}`); expect(id).toBe("session-b"); }, + acquireWorkspaceRuntime: (workspace: string) => { + runtimeSources.push(workspace); + return { + path: `/runtime/${runtimeSources.length}.yaml`, + workspaceId: "b-workspace", + workspaceRevision: "c".repeat(40), + release: vi.fn(), + }; + }, } as any, readiness: { ensure: async () => ({ ok: true }) } as any, mgr: { get: () => active, - createFor: () => { + createFor: (_id: string, options: any) => { + runtimeOptions.push(options.runtimeConfig?.path ?? "missing"); active = { bridge: { onClientEvent: () => {}, respond: () => true, turnState: () => "idle" } }; return active; }, @@ -673,6 +685,8 @@ test("session lifecycle locates a B session when installation default is A", asy expect(calls).toContain(`list:${bPath}`); expect(calls).toContain(`show:${bPath}`); expect(calls).toContain(`reopen:${bPinnedPath}`); + expect(runtimeSources).toEqual([bPath, bPinnedPath]); + expect(runtimeOptions).toEqual(["/runtime/1.yaml", "/runtime/2.yaml"]); }); test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => { diff --git a/backend/test/routes-sql-meta.test.ts b/backend/test/routes-sql-meta.test.ts index f81370f1..a0f6b1c0 100644 --- a/backend/test/routes-sql-meta.test.ts +++ b/backend/test/routes-sql-meta.test.ts @@ -85,6 +85,43 @@ test("POST /sessions/:id/sql/preview returns 500 when thtRunner throws", async ( expect(res.json()).toMatchObject({ error: /boom/ }); }); +test("registry-backed SQL preview resolves and uses the session's pinned runtime revision", async () => { + const activePath = `/registry/snapshots/${"a".repeat(40)}/psd-clinical.yaml`; + const pinnedPath = `/registry/snapshots/${"b".repeat(40)}/psd-clinical.yaml`; + const calls: string[] = []; + const runner = { + sessionShow: async (_id: string, workspace: string) => { + calls.push(`show:${workspace}`); + if (workspace === activePath) return { + id: "s1", workspace_id: "psd-clinical", workspace_revision: "b".repeat(40), + }; + throw new Error("session not found"); + }, + sqlPreview: async (_id: string, _page: unknown, workspace: string) => { + calls.push(`preview:${workspace}`); + return { columns: [], rows: [], execution_ms: 0, truncated: false }; + }, + }; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { ...runner, withPrincipal: () => runner } as any, + getSettings: () => ({ workspace: "legacy-default" }) as any, + workspaceRegistry: { + list: async () => [{ + id: "psd-clinical", commit: "a".repeat(40), blob: "c".repeat(40), + snapshotPath: activePath, state: "operational", + }], + readPinned: async () => ({ workspace: {}, workspaceConfigPath: pinnedPath }), + } as any, + }); + + const response = await app.inject({ + method: "POST", url: "/sessions/s1/sql/preview", payload: { limit: 10 }, + }); + + expect(response.statusCode).toBe(200); + expect(calls).toEqual([`show:${activePath}`, `preview:${pinnedPath}`]); +}); + // Workspace registry route coverage lives in routes-workspaces.test.ts. `/workspaces` no longer // reads legacy harness files: the Git registry is the single shared source of truth. diff --git a/backend/test/workspace-runtime-handoff.test.ts b/backend/test/workspace-runtime-handoff.test.ts new file mode 100644 index 00000000..e9b37268 --- /dev/null +++ b/backend/test/workspace-runtime-handoff.test.ts @@ -0,0 +1,167 @@ +import { execFile } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test, vi } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { ThtRunner } from "../src/tht/tht-runner.js"; +import { WorkspaceRegistry } from "../src/workspaces/registry.js"; +import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; + +const runFile = promisify(execFile); +const harnessDir = resolve("../harness"); +const thtBin = join(harnessDir, ".venv", "bin", "tht"); +const roots: string[] = []; + +const canonicalWorkspace = `workspace: + schema_version: 2 + id: psd-clinical + name: Runtime handoff + language: en +dwh: + engine: postgres + database: analytics + schema: mart + supported_transports: [postgres_direct] +semantic_index: + vector_store: + engine: pgvector + database: analytics + schema: vectors + collection: documents + dimensions: 768 + distance: cosine + supported_transports: [pgvector_direct] + embedding: + provider: ollama_compatible + model: embed + dimensions: 768 +llm_policy: + allowed: [zai/glm-5.2] +`; + +afterEach(() => { + vi.unstubAllEnvs(); + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +async function git(cwd: string, args: string[]): Promise { + return (await runFile("git", args, { cwd })).stdout.trim(); +} + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), "tht-runtime-handoff-")); + roots.push(root); + const remote = join(root, "remote.git"); + const source = join(root, "source"); + const registryRoot = join(root, "registry"); + const secretRoot = join(root, "secrets"); + const dataRoot = join(root, "data"); + await git(root, ["init", "--bare", "--initial-branch=main", remote]); + mkdirSync(source); + await git(source, ["init", "--initial-branch=main"]); + await git(source, ["config", "user.name", "Runtime Handoff Test"]); + await git(source, ["config", "user.email", "runtime-handoff@example.invalid"]); + mkdirSync(join(source, "workspaces")); + writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), canonicalWorkspace); + await git(source, ["add", "workspaces/psd-clinical.yaml"]); + await git(source, ["commit", "-m", "Canonical workspace"]); + await git(source, ["remote", "add", "origin", remote]); + await git(source, ["push", "origin", "main"]); + mkdirSync(secretRoot); + for (const name of ["dwh-password", "vector-password"]) { + const path = join(secretRoot, name); + writeFileSync(path, `${name}-value`, { mode: 0o600 }); + chmodSync(path, 0o600); + } + mkdirSync(dataRoot); + const registryConfig: WorkspaceRegistryConfig = { + root: registryRoot, + remoteUrl: remote, + branch: "main", + gitAuthorName: "Runtime Handoff Test", + gitAuthorEmail: "runtime-handoff@example.invalid", + installationId: "test", + secretRoots: [secretRoot], + maxImportBytes: 1024 * 1024, + maxImportEntries: 16, + }; + const registry = new WorkspaceRegistry(registryConfig); + await registry.bootstrap(); + const revision = (await registry.list())[0]; + const environment = { + THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "postgres_direct", + THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.invalid", + THT_WS_PSD_CLINICAL_DWH_PORT: "5432", + THT_WS_PSD_CLINICAL_DWH_USER: "reader", + THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE: join(secretRoot, "dwh-password"), + THT_WS_PSD_CLINICAL_VECTOR_TRANSPORT: "pgvector_direct", + THT_WS_PSD_CLINICAL_VECTOR_HOST: "vector.invalid", + THT_WS_PSD_CLINICAL_VECTOR_PORT: "5432", + THT_WS_PSD_CLINICAL_VECTOR_USER: "vector-reader", + THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_FILE: join(secretRoot, "vector-password"), + THT_WS_PSD_CLINICAL_EMBEDDING_BASE_URL: "http://embedding.invalid", + }; + for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value); + vi.stubEnv("THT_HOME", join(root, "home")); + return { root, dataRoot, registry, registryConfig, revision }; +} + +function runnerFor(f: Awaited>): ThtRunner { + return new ThtRunner({ + thtBin, + harnessDir, + configPath: "config/tht.yaml", + dataRoot: f.dataRoot, + runtimeSnapshotRoot: join(f.registryConfig.root, "snapshots", "runtime"), + secretRoots: f.registryConfig.secretRoots, + } as any); +} + +test("real schema-v2 registry revision loads through ThtRunner and the harness contract", async () => { + const f = await fixture(); + const runner = runnerFor(f); + + expect(await runner.sessionList(f.revision.snapshotPath)).toEqual([]); + const created = await runner.sessionNew({ + question: "runtime handoff", + workspaceConfigPath: f.revision.snapshotPath, + workspaceId: f.revision.id, + workspaceRevision: f.revision.commit, + }); + expect(await runner.sessionShow(created.id, f.revision.snapshotPath)).toMatchObject({ + id: created.id, + workspace_id: "psd-clinical", + workspace_revision: f.revision.commit, + }); + expect(existsSync(join( + f.dataRoot, "sessions", "psd-clinical", "sessions", created.id, "session_manifest.yaml", + ))).toBe(true); + expect(readdirSync(join(f.registryConfig.root, "snapshots", "runtime"))).toEqual([]); +}); + +test("local GET sessions mine uses the real canonical handoff and returns an empty inventory", async () => { + const f = await fixture(); + const app = buildApp(loadConfig({ + AUTH_MODE: "none", + THT_HARNESS_DIR: harnessDir, + THT_BIN: thtBin, + THT_DATA_ROOT: f.dataRoot, + THT_WORKSPACE_REGISTRY_ROOT: f.registryConfig.root, + THT_WORKSPACE_GIT_REMOTE: f.registryConfig.remoteUrl, + THT_WORKSPACE_SECRET_ROOTS: f.registryConfig.secretRoots.join(","), + }), { + thtRunner: runnerFor(f), + workspaceRegistry: f.registry, + mgr: { get: () => undefined } as any, + }); + try { + const response = await app.inject({ method: "GET", url: "/sessions?scope=mine" }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual([]); + } finally { + await app.close(); + } +}); diff --git a/backend/test/workspaces-schema.test.ts b/backend/test/workspaces-schema.test.ts index 5aa28ac8..3b744a60 100644 --- a/backend/test/workspaces-schema.test.ts +++ b/backend/test/workspaces-schema.test.ts @@ -61,6 +61,13 @@ test("rejects unknown keys and invalid immutable IDs", () => { .toThrow(/id/i); }); +test("rejects executable or otherwise custom YAML tags in canonical descriptors", () => { + expect(() => parseWorkspaceYaml(validYaml.replace( + "name: Policlinico San Donato", + "name: !command echo-never-execute", + ))).toThrow(/tag|yaml/i); +}); + test("accepts optional connection ports and timeouts but rejects unsafe values", () => { expect(parseWorkspaceYaml(validYaml).dwh.port).toBe(5432); expect(() => parseWorkspaceYaml(validYaml.replace("port: 5432", "port: 0"))) diff --git a/harness/tests/test_config_resources.py b/harness/tests/test_config_resources.py index ab13f271..da0b3f53 100644 --- a/harness/tests/test_config_resources.py +++ b/harness/tests/test_config_resources.py @@ -6,6 +6,7 @@ from tht.config import ( PostgresDwhConfig, ThothRestDwhConfig, ThothVectorHttpConfig, + workspace_id_for_config, load_config, ) from tht.adapters.evidence import FilesystemEvidenceSource, HttpManifestEvidenceSource @@ -86,6 +87,65 @@ roots: assert cfg.roots.sessions.as_posix() == "build/sessions" +def test_runtime_handoff_preserves_canonical_identity_and_durable_roots(monkeypatch, tmp_path): + data_root = tmp_path / "data" + runtime_root = data_root / "sessions" / "psd-clinical" + workspace = tmp_path / "runtime-random-uuid.yaml" + workspace.write_text(f""" +runtime_identity: + workspace_id: psd-clinical + workspace_revision: {'a' * 40} +dwh: + type: postgres_direct + connection: {{database: analytics, schema: mart, user: reader, password: secret}} +vectors: + type: pgvector_direct + connection: {{database: analytics, schema: vectors, user: vector, password: secret}} +roots: + sessions: {runtime_root / 'sessions'} + artifacts: {runtime_root / 'artifacts'} + indexes: {runtime_root / 'indexes'} +embeddings: {{base_url: http://embedding.invalid, model: embed, dim: 768}} +""") + monkeypatch.setenv("THT_DATA_ROOT", str(data_root)) + + cfg = load_config(workspace) + + assert cfg._workspace_id == "psd-clinical" + assert cfg._workspace_revision == "a" * 40 + assert cfg.paths.sessions == runtime_root / "sessions" + assert cfg.paths.artifacts == runtime_root / "artifacts" + assert cfg.paths.indexes == runtime_root / "indexes" + + +def test_runtime_identity_is_authoritative_over_runtime_filename(tmp_path): + workspace = tmp_path / "runtime-random-uuid.yaml" + workspace.write_text(f""" +runtime_identity: + workspace_id: psd-clinical + workspace_revision: {'a' * 40} +dwh: + type: postgres_direct + connection: {{database: analytics, schema: mart, user: reader, password: secret}} +roots: {{sessions: sessions, artifacts: artifacts, indexes: indexes}} +""") + + cfg = load_config(workspace) + + assert workspace_id_for_config(cfg, workspace) == "psd-clinical" + + +def test_load_config_rejects_executable_yaml_tags_without_running_them(tmp_path): + marker = tmp_path / "must-not-exist" + workspace = tmp_path / "workspace.yaml" + workspace.write_text(f"dwh: !command touch {marker}\n") + + with pytest.raises(ConfigError, match="YAML|configurazione"): + load_config(workspace) + + assert not marker.exists() + + def test_loads_direct_discriminated_resources(tmp_path): workspace = tmp_path / "workspace.yaml" workspace.write_text( diff --git a/harness/tht/cli/search_cmd.py b/harness/tht/cli/search_cmd.py index b9bef1d5..7f397a55 100644 --- a/harness/tht/cli/search_cmd.py +++ b/harness/tht/cli/search_cmd.py @@ -5,7 +5,7 @@ import typer from tht.cli.config_cmd import CONFIG_OPT from tht.cli.schema_cmd import _load_config_or_exit -from tht.config import workspace_id_from_path +from tht.config import workspace_id_for_config KIND_MAP = { "evidence": ["evidence"], @@ -60,7 +60,7 @@ def search_cmd( cfg = _load_config_or_exit(config) from tht.search.evidence import validate_corpus_workspace - workspace_id = workspace_id_from_path(config) + workspace_id = workspace_id_for_config(cfg, config) validate_corpus_workspace(cfg, workspace_id) dwh_snapshot = _leased_dwh_snapshot(cfg, ctx) require_vector_cfg(cfg) @@ -264,7 +264,7 @@ def pack_cmd( cfg = _load_config_or_exit(config) from tht.search.evidence import validate_corpus_workspace - workspace_id = workspace_id_from_path(config) + workspace_id = workspace_id_for_config(cfg, config) validate_corpus_workspace(cfg, workspace_id) dwh_snapshot = _leased_dwh_snapshot(cfg, ctx) require_vector_cfg(cfg) diff --git a/harness/tht/config.py b/harness/tht/config.py index a4743613..f7a0987f 100644 --- a/harness/tht/config.py +++ b/harness/tht/config.py @@ -162,6 +162,11 @@ class PathsConfig(BaseModel): sessions: Path = Path("sessions") +class RuntimeIdentityConfig(BaseModel): + workspace_id: str = Field(pattern=r"^[a-z][a-z0-9-]{2,62}$") + workspace_revision: str = Field(pattern=r"^[0-9a-f]{40}$") + + class WorkspaceRoots(PathsConfig): pass @@ -305,7 +310,9 @@ class ExecutionConfig(BaseModel): class Config(BaseModel): _workspace_id: str = PrivateAttr(default="default") + _workspace_revision: str | None = PrivateAttr(default=None) _config_source: str = PrivateAttr(default="direct") + runtime_identity: RuntimeIdentityConfig | None = None dwh: DwhResourceConfig vectors: VectorResourceConfig | None = None session_storage: SessionStorageConfig | None = None @@ -354,10 +361,20 @@ def workspace_id_from_path(path: Path) -> str: return path.resolve().stem.lower().replace(".", "-").replace("_", "-") +def workspace_id_for_config(config: Config, path: Path) -> str: + """Return the canonical runtime identity, falling back for legacy configs.""" + if config.runtime_identity is not None: + return config.runtime_identity.workspace_id + return workspace_id_from_path(path) + + def load_config(path: Path) -> Config: if not path.exists(): raise ConfigError(f"File di configurazione non trovato: {path}") - raw = yaml.safe_load(path.read_text()) + try: + raw = yaml.safe_load(path.read_text()) + except yaml.YAMLError as exc: + raise ConfigError(f"Configurazione YAML non valida: {path}") from exc if not isinstance(raw, dict): raise ConfigError(f"Configurazione non valida (atteso un mapping YAML): {path}") expanded = _resolve_secret_files(_expand_env(raw)) @@ -407,7 +424,12 @@ def load_config(path: Path) -> Config: FutureWarning, stacklevel=2, ) - cfg._workspace_id = workspace_id_from_path(path) + cfg._workspace_id = workspace_id_for_config(cfg, path) + cfg._workspace_revision = ( + cfg.runtime_identity.workspace_revision + if cfg.runtime_identity is not None + else None + ) cfg._config_source = path.resolve().as_posix() return cfg diff --git a/scripts/task13-runtime-fixture-check.ts b/scripts/task13-runtime-fixture-check.ts index fd16f367..6162e1ee 100644 --- a/scripts/task13-runtime-fixture-check.ts +++ b/scripts/task13-runtime-fixture-check.ts @@ -23,12 +23,12 @@ const expected = { THT_WS_TASK13_SMOKE_DWH_HOST: "dwh.task13.invalid", THT_WS_TASK13_SMOKE_DWH_PORT: "5432", THT_WS_TASK13_SMOKE_DWH_USER: "task13_reader", - THT_WS_TASK13_SMOKE_DWH_PASSWORD_FILE: "/run/secrets/thothii.secrets", + THT_WS_TASK13_SMOKE_DWH_PASSWORD_FILE: "/run/secrets/task13-runtime-password", THT_WS_TASK13_SMOKE_VECTOR_TRANSPORT: "pgvector_direct", THT_WS_TASK13_SMOKE_VECTOR_HOST: "vector.task13.invalid", THT_WS_TASK13_SMOKE_VECTOR_PORT: "5432", THT_WS_TASK13_SMOKE_VECTOR_USER: "task13_vector_reader", - THT_WS_TASK13_SMOKE_VECTOR_PASSWORD_FILE: "/run/secrets/thothii.secrets", + THT_WS_TASK13_SMOKE_VECTOR_PASSWORD_FILE: "/run/secrets/task13-runtime-password", THT_WS_TASK13_SMOKE_EMBEDDING_BASE_URL: profile === "local" ? `http://${config.name}-llm:9000` : "https://embedding.task13.invalid", @@ -55,6 +55,14 @@ if (coreBundle.length !== 1) throw new Error("core lacks exactly one runtime sec if ((frontend.secrets || []).length !== 0) throw new Error("frontend received a runtime secret"); const mounts = core.volumes || []; +const runtimePasswordMounts = mounts.filter( + (mount: any) => mount.target === "/run/secrets/task13-runtime-password", +); +if (runtimePasswordMounts.length !== 1 || runtimePasswordMounts[0].type !== "bind" + || !runtimePasswordMounts[0].read_only || !statSync(runtimePasswordMounts[0].source).isFile()) { + throw new Error("runtime fixture lacks one readable, read-only password-file bind"); +} +accessSync(runtimePasswordMounts[0].source, constants.R_OK); for (const target of [ "/home/thoth/.pi/agent/auth.json", "/home/thoth/.pi/agent/models.json", @@ -73,9 +81,9 @@ for (const target of [ } const resolverEnvironment = { ...core.environment }; -resolverEnvironment.THT_WS_TASK13_SMOKE_DWH_PASSWORD_FILE = bundleSource; -resolverEnvironment.THT_WS_TASK13_SMOKE_VECTOR_PASSWORD_FILE = bundleSource; -const bindings = resolveRuntimeBindings(workspace, resolverEnvironment, [dirname(bundleSource)]); +resolverEnvironment.THT_WS_TASK13_SMOKE_DWH_PASSWORD_FILE = runtimePasswordMounts[0].source; +resolverEnvironment.THT_WS_TASK13_SMOKE_VECTOR_PASSWORD_FILE = runtimePasswordMounts[0].source; +const bindings = resolveRuntimeBindings(workspace, resolverEnvironment, [dirname(runtimePasswordMounts[0].source)]); for (const [role, binding] of Object.entries(bindings)) { if ((binding as any).missing.length !== 0) { throw new Error(`workspace resolver reports missing ${role} bindings: ${(binding as any).missing.join(",")}`); @@ -88,18 +96,20 @@ const runtime = parse(renderRuntimeConfig(workspace, bindings, { })); if (runtime.database.host !== expected.THT_WS_TASK13_SMOKE_DWH_HOST || runtime.database.user !== expected.THT_WS_TASK13_SMOKE_DWH_USER - || runtime.database.password_file !== bundleSource) { + || runtime.database.password_file !== runtimePasswordMounts[0].source) { throw new Error("workspace resolver produced the wrong DWH runtime"); } if (runtime.vector_db.host !== expected.THT_WS_TASK13_SMOKE_VECTOR_HOST || runtime.vector_db.user !== expected.THT_WS_TASK13_SMOKE_VECTOR_USER - || runtime.vector_db.password_file !== bundleSource) { + || runtime.vector_db.password_file !== runtimePasswordMounts[0].source) { throw new Error("workspace resolver produced the wrong vector runtime"); } if (runtime.embeddings.base_url !== expected.THT_WS_TASK13_SMOKE_EMBEDDING_BASE_URL) { throw new Error("workspace resolver produced the wrong embedding runtime"); } const secret = readFileSync(bundleSource, "utf8").trim(); -if (JSON.stringify(config).includes(secret) || JSON.stringify(runtime).includes(secret)) { - throw new Error("fixture render or resolver output leaked secret content"); -} +const runtimePassword = readFileSync(runtimePasswordMounts[0].source, "utf8"); +if (JSON.stringify(config).includes(secret)) throw new Error("fixture render leaked application bundle content"); +if (JSON.stringify(runtime).includes(secret)) throw new Error("runtime render leaked application bundle content"); +if (JSON.stringify(config).includes(runtimePassword)) throw new Error("fixture render leaked runtime password content"); +if (JSON.stringify(runtime).includes(runtimePassword)) throw new Error("runtime render leaked runtime password content"); diff --git a/scripts/test-task13-runtime-fixtures.sh b/scripts/test-task13-runtime-fixtures.sh index 0f44b1ca..54f844aa 100755 --- a/scripts/test-task13-runtime-fixtures.sh +++ b/scripts/test-task13-runtime-fixtures.sh @@ -30,6 +30,7 @@ TASK13_LOG="$fixture/task13.log" TASK13_INSTALLATION="$fixture/thothii-installation.yaml" TASK13_PI_AUTH="$fixture/pi-auth.json" TASK13_SECRETS="$fixture/thothii.secrets" +TASK13_SESSION_RUNTIME_PASSWORD="$fixture/runtime-password" TASK13_PI_MODELS="$fixture/models.json" TASK13_PI_SETTINGS="$fixture/settings.json" TASK13_LLM_SERVER="$fixture/fake-llm.mjs" @@ -79,8 +80,8 @@ else TASK13_SESSION_RUNTIME_PASSWORD="$fixture/session-runtime-password" TASK13_SESSION_MIGRATOR_PASSWORD_FILE="$fixture/session-migrator-password" TASK13_SESSION_CA="$fixture/session-ca.pem" - TASK13_SESSION_PASSWORD="task13-runtime-$profile" - TASK13_SESSION_MIGRATOR_PASSWORD="task13-migrator-$profile" + TASK13_SESSION_PASSWORD="fixture-private-token-$profile" + TASK13_SESSION_MIGRATOR_PASSWORD="fixture-migrator-token-$profile" task13_write_server_fixture_files compose_files=( -f "$root/compose.yaml" diff --git a/scripts/unified-deployment-smoke.sh b/scripts/unified-deployment-smoke.sh index 8b17d8e4..237c0a7c 100755 --- a/scripts/unified-deployment-smoke.sh +++ b/scripts/unified-deployment-smoke.sh @@ -186,8 +186,9 @@ task13_write_environment() { task13_write_fixture_files() { printf '{}\n' >"$TASK13_PI_AUTH" printf 'THT_MODEL_API_KEY=%s\n' "$TASK13_SECRET_VALUE" >"$TASK13_SECRETS" + printf '%s' "task13-runtime-password-$TASK13_RUN_ID" >"$TASK13_SESSION_RUNTIME_PASSWORD" chmod 0644 "$TASK13_PI_AUTH" - chmod 0600 "$TASK13_SECRETS" + chmod 0600 "$TASK13_SECRETS" "$TASK13_SESSION_RUNTIME_PASSWORD" cat >"$TASK13_PI_MODELS" <"$TASK13_PI_AUTH" printf 'THT_MODEL_API_KEY=%s\n' "$TASK13_SECRET_VALUE" >"$TASK13_SECRETS" - printf '%s\n' "$TASK13_SESSION_PASSWORD" >"$TASK13_SESSION_RUNTIME_PASSWORD" - printf '%s\n' "$TASK13_SESSION_MIGRATOR_PASSWORD" >"$TASK13_SESSION_MIGRATOR_PASSWORD_FILE" + printf '%s' "$TASK13_SESSION_PASSWORD" >"$TASK13_SESSION_RUNTIME_PASSWORD" + printf '%s' "$TASK13_SESSION_MIGRATOR_PASSWORD" >"$TASK13_SESSION_MIGRATOR_PASSWORD_FILE" cat >"$TASK13_SESSION_CA" <<'EOF' -----BEGIN CERTIFICATE----- VEFTSzEzLURJU1BPU0FCTEUtU0VTU0lPTi1DQQ== @@ -396,17 +398,18 @@ services: THT_WS_TASK13_SMOKE_DWH_HOST: dwh.task13.invalid THT_WS_TASK13_SMOKE_DWH_PORT: "5432" THT_WS_TASK13_SMOKE_DWH_USER: task13_reader - THT_WS_TASK13_SMOKE_DWH_PASSWORD_FILE: /run/secrets/thothii.secrets + THT_WS_TASK13_SMOKE_DWH_PASSWORD_FILE: /run/secrets/task13-runtime-password THT_WS_TASK13_SMOKE_VECTOR_TRANSPORT: pgvector_direct THT_WS_TASK13_SMOKE_VECTOR_HOST: vector.task13.invalid THT_WS_TASK13_SMOKE_VECTOR_PORT: "5432" THT_WS_TASK13_SMOKE_VECTOR_USER: task13_vector_reader - THT_WS_TASK13_SMOKE_VECTOR_PASSWORD_FILE: /run/secrets/thothii.secrets + THT_WS_TASK13_SMOKE_VECTOR_PASSWORD_FILE: /run/secrets/task13-runtime-password THT_WS_TASK13_SMOKE_EMBEDDING_BASE_URL: https://embedding.task13.invalid labels: io.thothii.task13.run: "$TASK13_RUN_ID" volumes: - $remote_path:/fixtures/remote.git:ro + - $TASK13_SESSION_RUNTIME_PASSWORD:/run/secrets/task13-runtime-password:ro frontend: image: $TASK13_FRONTEND_IMAGE build: @@ -614,6 +617,7 @@ task13_server_auth_headers() { -H 'x-thoth-trusted-principal-issuer: task13-proxy' -H 'x-thoth-trusted-principal-subject: task13-user' -H 'x-thoth-trusted-principal-display-name: Task 13 User' + -H 'x-thoth-trusted-is-admin: 0' ) } @@ -1325,10 +1329,13 @@ task13_self_test_server_auth_hop_contract() { for header in \ x-thoth-trusted-principal-issuer \ x-thoth-trusted-principal-subject \ - x-thoth-trusted-principal-display-name; do + x-thoth-trusted-principal-display-name \ + x-thoth-trusted-is-admin; do [[ "$joined" == *"$header:"* ]] \ || task13_fail "server smoke omits trusted frontend hop header: $header" done + [[ "$joined" == *'x-thoth-trusted-is-admin: 0'* ]] \ + || task13_fail "server smoke admin claim is not the exact non-admin value" [[ "$joined" != *'x-thoth-principal-issuer:'* ]] \ || task13_fail "server smoke sends public identity headers to the frontend hop" }