From 87cefd120c9fe9415d15ba4b63984f5abd20755f Mon Sep 17 00:00:00 2001 From: mptyl Date: Fri, 14 Aug 2026 17:30:35 +0200 Subject: [PATCH] feat: configure workspace runtime secrets through API --- backend/src/app.ts | 32 ++++- backend/src/config.ts | 12 ++ backend/src/routes/workspaces.ts | 116 ++++++++++++++++-- backend/src/tht/tht-runner.ts | 12 +- .../src/workspaces/runtime-config-lease.ts | 63 +++++++--- backend/test/routes-workspaces.test.ts | 100 ++++++++++++++- .../test/workspace-runtime-handoff.test.ts | 31 +++++ 7 files changed, 325 insertions(+), 41 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index fdc8ac35..a983d492 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -21,8 +21,10 @@ import { WorkspaceRegistry } from "./workspaces/registry.js"; import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js"; import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js"; import { piManagementRoutes } from "./routes/pi-management.js"; -import { resolveRuntimeBindings, supportsSessionRuntime } from "./workspaces/bindings.js"; +import { supportsSessionRuntime } from "./workspaces/bindings.js"; +import { resolveRuntimeBindingsWithWorkspaceSecrets } from "./workspaces/secret-requirements.js"; import type { WorkspaceDescriptor } from "./workspaces/schema.js"; +import { WorkspaceSecretStore } from "./workspaces/secret-store.js"; export interface BuildAppDeps { thtRunner?: ThtRunner; @@ -34,6 +36,7 @@ export interface BuildAppDeps { hub?: SseHub; workspaceRegistry?: WorkspaceRegistry; workspaceDiagnoser?: WorkspaceDiagnoser; + workspaceSecretStore?: WorkspaceSecretStore; workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean; maintenanceBarrier?: MaintenanceBarrier; piManagement?: PiManagementService; @@ -41,6 +44,11 @@ export interface BuildAppDeps { export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance { const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true }); + const workspaceSecretStore = deps?.workspaceSecretStore ?? new WorkspaceSecretStore({ + root: config.workspaceSecretStoreRoot, + runtimeRoot: config.workspaceSecretRuntimeRoot, + installationId: config.workspaceRegistry.installationId, + }); // Allow any origin in dev/e2e; tighten in production via config if needed. app.register(cors, { @@ -58,6 +66,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc secretRoots: config.workspaceRegistry.secretRoots, secretsFile: config.secretsFile, secretFiles: config.secretFiles, + workspaceSecretStore, semanticRuntime: { internalQdrantUrl: config.internalQdrantUrl, internalEmbeddingUrl: config.internalEmbeddingUrl, @@ -75,13 +84,19 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc internalEmbeddingModel: config.internalEmbeddingModel, internalEmbeddingDimensions: config.internalEmbeddingDimensions, }); - const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => ( - supportsSessionRuntime(resolveRuntimeBindings( + const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => { + const lease = resolveRuntimeBindingsWithWorkspaceSecrets( workspace, process.env, config.workspaceRegistry.secretRoots, - )) - )); + workspaceSecretStore, + ); + try { + return supportsSessionRuntime(lease.bindings); + } finally { + lease.release(); + } + }); const readiness = deps?.readiness ?? new ReadinessManager( tht as ThtRunner, Math.round(config.ollamaEnsureTimeoutMs / 1000), @@ -180,7 +195,12 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc }); sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry }); metaRoutes(app, { harnessDir: config.harnessDir, listModels }); - workspaceRoutes(app, { registry: workspaceRegistry, config: config.workspaceRegistry, diagnose: workspaceDiagnoser }); + workspaceRoutes(app, { + registry: workspaceRegistry, + config: config.workspaceRegistry, + diagnose: workspaceDiagnoser, + secretStore: workspaceSecretStore, + }); settingsRoutes(app, { cfg: config, listModels, getSettings }); piManagementRoutes(app, { config, service: piManagement }); diff --git a/backend/src/config.ts b/backend/src/config.ts index 5866d11f..8eaea18c 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -30,6 +30,8 @@ export interface AppConfig { legacyWorkspaceMode: boolean; workspaceDiagnosticTimeoutMs: number; workspaceRegistry: WorkspaceRegistryConfig; + workspaceSecretStoreRoot: string; + workspaceSecretRuntimeRoot: string; internalQdrantUrl: string; internalEmbeddingUrl: string; internalEmbeddingModel: string; @@ -248,6 +250,14 @@ export function loadConfig(env: Record): AppConfig { maxEvidencePathBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_PATH_BYTES, 4096), maxEvidenceManifestBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_MANIFEST_BYTES, 1024 * 1024), }; + const workspaceSecretStoreRoot = absoluteRegistryPath( + env.THT_WORKSPACE_SECRET_STORE_ROOT ?? path.join(path.dirname(registryRoot), "workspace-secrets"), + "secret store root", + ); + const workspaceSecretRuntimeRoot = absoluteRegistryPath( + env.THT_WORKSPACE_SECRET_RUNTIME_ROOT ?? "/tmp/thothii-workspace-secrets", + "secret runtime root", + ); const settingsFile = env.SETTINGS_FILE ?? "data/settings.json"; const internalQdrantUrl = internalServiceUrl( env.THT_INTERNAL_QDRANT_URL, @@ -284,6 +294,8 @@ export function loadConfig(env: Record): AppConfig { legacyWorkspaceMode: legacyWorkspaceMode === "local", workspaceDiagnosticTimeoutMs: diagnosticTimeout(env.THT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS), workspaceRegistry, + workspaceSecretStoreRoot, + workspaceSecretRuntimeRoot, internalQdrantUrl, internalEmbeddingUrl, internalEmbeddingModel: env.THT_INTERNAL_EMBEDDING_MODEL ?? "qwen3-embedding:0.6b", diff --git a/backend/src/routes/workspaces.ts b/backend/src/routes/workspaces.ts index 424922c5..721f214c 100644 --- a/backend/src/routes/workspaces.ts +++ b/backend/src/routes/workspaces.ts @@ -3,8 +3,12 @@ import { z } from "zod"; import type { WorkspaceRegistryConfig } from "../workspaces/types.js"; import { WorkspaceRegistryError } from "../workspaces/git-repository.js"; import type { WorkspaceRegistry } from "../workspaces/registry.js"; -import { resolveRuntimeBindings } from "../workspaces/bindings.js"; import { buildInstallationContract } from "../workspaces/contracts.js"; +import { + discoverWorkspaceSecretRequirements, + resolveRuntimeBindingsWithWorkspaceSecrets, +} from "../workspaces/secret-requirements.js"; +import type { WorkspaceSecretStore } from "../workspaces/secret-store.js"; import { validateOperationalWorkspace, validateWorkspaceDescriptor, @@ -24,10 +28,17 @@ interface WorkspaceRoutesDeps { registry: WorkspaceRegistry; config: WorkspaceRegistryConfig; diagnose: WorkspaceDiagnoser; + secretStore: WorkspaceSecretStore; } const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/); const workspacePayload = z.object({ workspace: z.unknown() }).strict(); +const secretRequirementId = z.string().regex(/^[a-z0-9][a-z0-9._-]{1,127}$/); +const secretValuesPayload = z.object({ + values: z.record(secretRequirementId, z.string()).refine( + (values) => Object.keys(values).length > 0 && Object.keys(values).length <= 16, + ), +}).strict(); const SAFE_MESSAGES = { workspace_invalid: "Workspace request is invalid.", @@ -57,6 +68,29 @@ function errorReply(reply: FastifyReply, error: unknown) { } export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps): void { + const runtimeConfiguration = async (id: string) => { + const { workspace, revision } = await deps.registry.read(id); + const operational = validateOperationalWorkspace(workspace); + const requirements = discoverWorkspaceSecretRequirements(operational, process.env) + .map((requirement) => ({ + id: requirement.id, + connector: requirement.connector, + label: requirement.label, + description: requirement.description, + input: requirement.input, + required: requirement.required, + configured: deps.secretStore.has(id, requirement.id), + })); + return { + workspaceId: id, + revision, + configurationState: requirements.some(({ required, configured }) => required && !configured) + ? "configuration_required" as const + : "ready" as const, + requirements, + }; + }; + app.get("/workspace-registry/status", async (_request, reply) => { try { return await deps.registry.bootstrap(); @@ -76,15 +110,18 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps) app.get("/workspaces", async (_request, reply) => { try { const records = await deps.registry.listCatalog(); - return records.map((record) => ({ - id: record.id, - // Retain the metadata endpoint's selector field while adding catalog metadata. - name: record.id, - file: `${record.id}/workspace.yaml`, - displayName: record.name, - description: record.description, - configurationState: record.configurationState, - ...(record.revision ? { revision: record.revision } : {}), + return await Promise.all(records.map(async (record) => { + const configuration = await runtimeConfiguration(record.id); + return { + id: record.id, + // Retain the metadata endpoint's selector field while adding catalog metadata. + name: record.id, + file: `${record.id}/workspace.yaml`, + displayName: record.name, + description: record.description, + configurationState: configuration.configurationState, + ...(record.revision ? { revision: record.revision } : {}), + }; })); } catch (error) { return errorReply(reply, error); @@ -110,6 +147,52 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps) } }); + app.get("/workspaces/:id/runtime-configuration", async (request, reply) => { + try { + const { id } = z.object({ id: workspaceId }).parse(request.params); + return await runtimeConfiguration(id); + } catch (error) { + return errorReply(reply, error); + } + }); + + app.put("/workspaces/:id/secrets", async (request, reply) => { + try { + const { id } = z.object({ id: workspaceId }).parse(request.params); + const { values } = secretValuesPayload.parse(request.body); + const { workspace } = await deps.registry.read(id); + const declared = new Set( + discoverWorkspaceSecretRequirements(validateOperationalWorkspace(workspace), process.env) + .map(({ id: requirementId }) => requirementId), + ); + if (Object.keys(values).some((requirementId) => !declared.has(requirementId))) { + throw new Error("undeclared workspace secret"); + } + deps.secretStore.putMany(id, values); + return await runtimeConfiguration(id); + } catch (error) { + return errorReply(reply, error); + } + }); + + app.delete("/workspaces/:id/secrets/:requirementId", async (request, reply) => { + try { + const { id, requirementId } = z.object({ + id: workspaceId, + requirementId: secretRequirementId, + }).parse(request.params); + const { workspace } = await deps.registry.read(id); + const declared = discoverWorkspaceSecretRequirements( + validateOperationalWorkspace(workspace), process.env, + ).some(({ id: candidate }) => candidate === requirementId); + if (!declared) throw new Error("undeclared workspace secret"); + deps.secretStore.forget(id, requirementId); + return await runtimeConfiguration(id); + } catch (error) { + return errorReply(reply, error); + } + }); + app.post("/workspaces/:id/test", async (request, reply) => { try { const { id } = z.object({ id: workspaceId }).parse(request.params); @@ -122,8 +205,17 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps) "workspace_not_activatable", "Workspace requires explicit migration", ); } - const bindings = resolveRuntimeBindings(operational, process.env, deps.config.secretRoots); - return await deps.diagnose(operational, bindings, { writeProbe: false }); + const lease = resolveRuntimeBindingsWithWorkspaceSecrets( + operational, + process.env, + deps.config.secretRoots, + deps.secretStore, + ); + try { + return await deps.diagnose(operational, lease.bindings, { writeProbe: false }); + } finally { + lease.release(); + } } catch (error) { return errorReply(reply, error); } diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index 466613d0..8b681a9f 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -21,6 +21,7 @@ import { type WorkspaceDescriptor, } from "../workspaces/schema.js"; import { reconcileCollection } from "../workspaces/qdrant-collection.js"; +import type { WorkspaceSecretStore } from "../workspaces/secret-store.js"; export interface ThtConfig extends SecretBundleConfig { thtBin: string; @@ -33,6 +34,7 @@ export interface ThtConfig extends SecretBundleConfig { qdrantRequest?: typeof fetch; /** "self_heal" for session admission (create missing collections/indexes), default "require_existing". */ qdrantCollectionMode?: "self_heal" | "require_existing"; + workspaceSecretStore?: WorkspaceSecretStore; } export interface RuntimeConfigLease { @@ -221,8 +223,15 @@ export class ThtRunner { })(), secretRoots: this.cfg.secretRoots ?? [], semanticRuntime: this.cfg.semanticRuntime ?? DEFAULT_SEMANTIC_RUNTIME, + workspaceSecretStore: this.cfg.workspaceSecretStore, }); - const path = this.createRuntimeSnapshot(rendered.renderedConfig); + let path: string; + try { + path = this.createRuntimeSnapshot(rendered.renderedConfig); + } catch (error) { + rendered.releaseSecrets(); + throw error; + } let released = false; return { path, @@ -232,6 +241,7 @@ export class ThtRunner { if (released) return; released = true; this.cleanupRuntimeSnapshot(path); + rendered.releaseSecrets(); }, }; } diff --git a/backend/src/workspaces/runtime-config-lease.ts b/backend/src/workspaces/runtime-config-lease.ts index e8cc4a77..f7edcd0a 100644 --- a/backend/src/workspaces/runtime-config-lease.ts +++ b/backend/src/workspaces/runtime-config-lease.ts @@ -27,6 +27,8 @@ import { type CanonicalEffectiveConfig, } from "./effective-config.js"; import { resolveRuntimeBindings, type RuntimeBindings } from "./bindings.js"; +import { resolveRuntimeBindingsWithWorkspaceSecrets } from "./secret-requirements.js"; +import type { WorkspaceSecretStore } from "./secret-store.js"; import { GitWorkspaceRepository } from "./git-repository.js"; import { WorkspaceRegistry } from "./registry.js"; import { @@ -58,6 +60,7 @@ export interface RenderedWorkspaceRuntime { bindingDigest: string; renderedConfig: string; semanticQdrantUrl: string; + releaseSecrets(): void; } export interface ActiveRenderedWorkspaceRuntime extends RenderedWorkspaceRuntime { @@ -298,33 +301,49 @@ function renderWorkspaceRuntimeFromWorkspace(options: { dataRoot: string; secretRoots: readonly string[]; semanticRuntime: SemanticRuntimeConfig; + workspaceSecretStore?: WorkspaceSecretStore; }): RenderedWorkspaceRuntime { - const bindings = resolveRuntimeBindings(options.workspace, process.env, options.secretRoots); + const secretLease = options.workspaceSecretStore === undefined + ? undefined + : resolveRuntimeBindingsWithWorkspaceSecrets( + options.workspace, + process.env, + options.secretRoots, + options.workspaceSecretStore, + ); + const bindings = secretLease?.bindings + ?? resolveRuntimeBindings(options.workspace, process.env, options.secretRoots); const overlay = installationOverlay(options.harnessDir, options.configPath); const context: RuntimeRenderContext = { workspaceId: options.workspaceId, workspaceRevision: options.workspaceRevision, revisionContentRoot: options.revisionContentRoot, }; - return { - workspace: options.workspace, - workspaceId: options.workspaceId, - workspaceRevision: options.workspaceRevision, - revisionContentRoot: options.revisionContentRoot, - runtimePaths: runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision), - installationOverlay: overlay, - bindings, - bindingDigest: stableBindingDigest(bindings), - semanticQdrantUrl: options.semanticRuntime.internalQdrantUrl, - renderedConfig: renderRuntimeConfig( - options.workspace, + try { + return { + workspace: options.workspace, + workspaceId: options.workspaceId, + workspaceRevision: options.workspaceRevision, + revisionContentRoot: options.revisionContentRoot, + runtimePaths: runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision), + installationOverlay: overlay, bindings, - runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision), - context, - overlay, - options.semanticRuntime, - ), - }; + bindingDigest: stableBindingDigest(bindings), + semanticQdrantUrl: options.semanticRuntime.internalQdrantUrl, + releaseSecrets: () => secretLease?.release(), + renderedConfig: renderRuntimeConfig( + options.workspace, + bindings, + runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision), + context, + overlay, + options.semanticRuntime, + ), + }; + } catch (error) { + secretLease?.release(); + throw error; + } } export function renderWorkspaceRuntimeFromSnapshotPath(options: { @@ -334,6 +353,7 @@ export function renderWorkspaceRuntimeFromSnapshotPath(options: { dataRoot: string; secretRoots: readonly string[]; semanticRuntime: SemanticRuntimeConfig; + workspaceSecretStore?: WorkspaceSecretStore; }): RenderedWorkspaceRuntime { const snapshot = readSnapshotWorkspace(options.snapshotPath); return renderWorkspaceRuntimeFromWorkspace({ @@ -346,6 +366,7 @@ export function renderWorkspaceRuntimeFromSnapshotPath(options: { dataRoot: options.dataRoot, secretRoots: options.secretRoots, semanticRuntime: options.semanticRuntime, + workspaceSecretStore: options.workspaceSecretStore, }); } @@ -358,6 +379,7 @@ export async function renderActiveWorkspaceRuntime(options: { dataRoot: string; secretRoots: readonly string[]; semanticRuntime: SemanticRuntimeConfig; + workspaceSecretStore?: WorkspaceSecretStore; }): Promise { // The persisted active state may reference host-side snapshot paths (written by another // process or installation). Read the active state directly and resolve the immutable snapshot @@ -387,6 +409,7 @@ export async function renderActiveWorkspaceRuntime(options: { dataRoot: options.dataRoot, secretRoots: options.secretRoots, semanticRuntime: options.semanticRuntime, + workspaceSecretStore: options.workspaceSecretStore, }); return { ...rendered, @@ -584,4 +607,4 @@ export async function publishDeterministicRuntimeConfigLease(options: { inputFingerprint: inputFingerprintValue, release: () => undefined, }; -} \ No newline at end of file +} diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index 048890ef..9e7b824a 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -9,6 +9,7 @@ import { loadConfig } from "../src/config.js"; import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js"; import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js"; import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js"; +import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js"; const workspace: CanonicalWorkspace = { workspace: { @@ -72,6 +73,7 @@ function registryFake(overrides: Partial = {}): RegistryFake { function appFor( registry: RegistryFake, diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })), + secretStore = testSecretStore(), ) { return buildApp(loadConfig({ THT_HARNESS_DIR: "/missing-harness", @@ -80,9 +82,19 @@ function appFor( thtRunner: {} as any, workspaceRegistry: registry as WorkspaceRegistry, workspaceDiagnoser: diagnose, + workspaceSecretStore: secretStore, } as any); } +const secretStoreRoots: string[] = []; + +function testSecretStore(): WorkspaceSecretStore { + const root = mkdtempSync(join(tmpdir(), "thoth-route-secret-store-")); + const runtimeRoot = mkdtempSync(join(tmpdir(), "thoth-route-secret-runtime-")); + secretStoreRoots.push(root, runtimeRoot); + return new WorkspaceSecretStore({ root, runtimeRoot, installationId: "route-test" }); +} + test("returns a redacted registry status and pulls without Git credential details", async () => { const registry = registryFake({ bootstrap: vi.fn(async () => ({ @@ -126,7 +138,7 @@ test("lists workspace summaries and reads a validated immutable workspace", asyn expect(list.json()).toEqual([expect.objectContaining({ id: "psd-clinical", displayName: "Policlinico San Donato", - configurationState: "ready", + configurationState: "configuration_required", revision, })]); expect(read.statusCode).toBe(200); @@ -174,6 +186,89 @@ test("runs diagnostics for a schema v3 workspace", async () => { }, { writeProbe: false }); }); +test("reports runtime secret requirements without returning stored values", async () => { + const secretStore = testSecretStore(); + const app = appFor(registryFake(), undefined, secretStore); + + const missing = await app.inject({ + method: "GET", url: "/workspaces/psd-clinical/runtime-configuration", + }); + expect(missing.statusCode).toBe(200); + expect(missing.json()).toMatchObject({ + workspaceId: "psd-clinical", + revision, + configurationState: "configuration_required", + requirements: [{ + id: "dwh.password", + connector: "dwh", + label: "Data warehouse password", + required: true, + configured: false, + }], + }); + + const secret = "never-return-this-password"; + const save = await app.inject({ + method: "PUT", + url: "/workspaces/psd-clinical/secrets", + payload: { values: { "dwh.password": secret } }, + }); + expect(save.statusCode).toBe(200); + expect(save.body).not.toContain(secret); + expect(save.json()).toMatchObject({ + configurationState: "ready", + requirements: [{ id: "dwh.password", configured: true }], + }); + + const configured = await app.inject({ + method: "GET", url: "/workspaces/psd-clinical/runtime-configuration", + }); + expect(configured.body).not.toContain(secret); + expect(configured.json()).toMatchObject({ configurationState: "ready" }); +}); + +test("rejects undeclared secret identifiers and supports forgetting a configured secret", async () => { + const secretStore = testSecretStore(); + const app = appFor(registryFake(), undefined, secretStore); + + const unknown = await app.inject({ + method: "PUT", + url: "/workspaces/psd-clinical/secrets", + payload: { values: { "evidence.secret_key": "not-applicable" } }, + }); + expect(unknown.statusCode).toBe(400); + expect(secretStore.configured("psd-clinical")).toEqual([]); + + secretStore.put("psd-clinical", "dwh.password", "temporary-password"); + const forget = await app.inject({ + method: "DELETE", + url: "/workspaces/psd-clinical/secrets/dwh.password", + }); + expect(forget.statusCode).toBe(200); + expect(forget.json()).toMatchObject({ configurationState: "configuration_required" }); + expect(secretStore.has("psd-clinical", "dwh.password")).toBe(false); +}); + +test("materializes stored secrets only for the diagnostic lease", async () => { + const secretStore = testSecretStore(); + secretStore.put("psd-clinical", "dwh.password", "diagnostic-password"); + let materializedPath = ""; + const diagnose = vi.fn(async (_workspace, bindings) => { + materializedPath = bindings.dwh.values.THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE; + expect(readFileSync(materializedPath, "utf8")).toBe("diagnostic-password"); + return { activatable: true, diagnostics: [] }; + }); + const app = appFor(registryFake(), diagnose, secretStore); + + const response = await app.inject({ + method: "POST", url: "/workspaces/psd-clinical/test", payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect(materializedPath).not.toBe(""); + expect(existsSync(materializedPath)).toBe(false); +}); + test("reports a missing Evidence credential without changing the registry revision", async () => { const evidenceWorkspace: CanonicalWorkspace = { ...workspace, @@ -282,6 +377,7 @@ async function createRealRouteFixture() { afterEach(() => { realRouteRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); + secretStoreRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); }); test("a failed candidate pull keeps the last valid active workspace", async () => { diff --git a/backend/test/workspace-runtime-handoff.test.ts b/backend/test/workspace-runtime-handoff.test.ts index dd9490ad..5f29c7ab 100644 --- a/backend/test/workspace-runtime-handoff.test.ts +++ b/backend/test/workspace-runtime-handoff.test.ts @@ -12,6 +12,7 @@ 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 { WorkspaceSecretStore } from "../src/workspaces/secret-store.js"; import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; const runFile = promisify(execFile); @@ -166,6 +167,36 @@ test("real schema-v3 registry revision loads through ThtRunner and the harness c expect(readdirSync(join(f.registryConfig.root, "snapshots", "runtime"))).toEqual([]); }); +test("ThtRunner uses a vault secret only for the lifetime of its runtime lease", async () => { + const f = await fixture(); + vi.stubEnv("THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE", ""); + const vaultRoot = join(f.root, "workspace-secrets"); + const runtimeRoot = join(f.root, "workspace-secret-runtime"); + const secretStore = new WorkspaceSecretStore({ + root: vaultRoot, + runtimeRoot, + installationId: "test", + }); + secretStore.put("psd-clinical", "dwh.password", "vault-runtime-password"); + const runner = new ThtRunner({ + thtBin, + harnessDir, + configPath: "config/tht.yaml", + dataRoot: f.dataRoot, + runtimeSnapshotRoot: join(f.registryConfig.root, "snapshots", "runtime"), + secretRoots: f.registryConfig.secretRoots, + workspaceSecretStore: secretStore, + } as any); + + const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + const rendered = parse(readFileSync(lease.path, "utf8")) as { + database: { password_file: string }; + }; + expect(readFileSync(rendered.database.password_file, "utf8")).toBe("vault-runtime-password"); + lease.release(); + expect(existsSync(rendered.database.password_file)).toBe(false); +}); + test("separate runtime leases hand off byte-identical revision Evidence configs accepted by tht", async () => { const f = await fixture(); const runner = runnerFor(f);