diff --git a/backend/src/workspace-maintenance.ts b/backend/src/workspace-maintenance.ts index fdf26285..0ec8bbce 100644 --- a/backend/src/workspace-maintenance.ts +++ b/backend/src/workspace-maintenance.ts @@ -7,6 +7,7 @@ import { loadConfig } from "./config.js"; import { ThtRunner } from "./tht/tht-runner.js"; import { WorkspaceRegistry } from "./workspaces/registry.js"; import { publishDeterministicRuntimeConfigLease, renderActiveWorkspaceRuntime } from "./workspaces/runtime-config-lease.js"; +import { WorkspaceSecretStore } from "./workspaces/secret-store.js"; import { WorkspacePreprocessingService, type WorkspaceOperationResult } from "./workspaces/preprocessing-service.js"; import type { SessionInventoryRow } from "./workspaces/preprocessing-state.js"; @@ -213,6 +214,11 @@ async function readSessionInventory(dataRoot: string, workspaceId: string): Prom function createProductionService(): WorkspacePreprocessingService { const config = loadConfig(process.env); const registry = new WorkspaceRegistry(config.workspaceRegistry); + const workspaceSecretStore = new WorkspaceSecretStore({ + root: config.workspaceSecretStoreRoot, + runtimeRoot: config.workspaceSecretRuntimeRoot, + installationId: config.workspaceRegistry.installationId, + }); const runner = new ThtRunner({ thtBin: config.thtBin, harnessDir: config.harnessDir, @@ -222,6 +228,7 @@ function createProductionService(): WorkspacePreprocessingService { secretRoots: config.workspaceRegistry.secretRoots, secretsFile: config.secretsFile, secretFiles: config.secretFiles, + workspaceSecretStore, semanticRuntime: { internalQdrantUrl: config.internalQdrantUrl, internalEmbeddingUrl: config.internalEmbeddingUrl, @@ -263,6 +270,7 @@ function createProductionService(): WorkspacePreprocessingService { internalEmbeddingModel: config.internalEmbeddingModel, internalEmbeddingDimensions: config.internalEmbeddingDimensions, }, + workspaceSecretStore, }); return { workspace: active.workspace, diff --git a/backend/src/workspaces/runtime-config-lease.ts b/backend/src/workspaces/runtime-config-lease.ts index f7edcd0a..912fb872 100644 --- a/backend/src/workspaces/runtime-config-lease.ts +++ b/backend/src/workspaces/runtime-config-lease.ts @@ -458,6 +458,7 @@ export async function publishDeterministicRuntimeConfigLease(options: { dataRoot: string; secretRoots: readonly string[]; semanticRuntime: SemanticRuntimeConfig; + workspaceSecretStore?: WorkspaceSecretStore; }): Promise { const rendered = await renderActiveWorkspaceRuntime(options); const publishedConfig = applyCollectionLifecycle(rendered.renderedConfig, "require_existing"); @@ -542,7 +543,7 @@ export async function publishDeterministicRuntimeConfigLease(options: { effectiveConfigIdentity: effectiveConfigIdentityValue, configFingerprint: configFingerprintValue, inputFingerprint: inputFingerprintValue, - release: () => undefined, + release: () => rendered.releaseSecrets(), }; } @@ -605,6 +606,6 @@ export async function publishDeterministicRuntimeConfigLease(options: { effectiveConfigIdentity: effectiveConfigIdentityValue, configFingerprint: configFingerprintValue, inputFingerprint: inputFingerprintValue, - release: () => undefined, + release: () => rendered.releaseSecrets(), }; } diff --git a/backend/src/workspaces/secret-store.ts b/backend/src/workspaces/secret-store.ts index a33a21e8..3431ea07 100644 --- a/backend/src/workspaces/secret-store.ts +++ b/backend/src/workspaces/secret-store.ts @@ -5,14 +5,13 @@ import { fchmodSync, fsyncSync, mkdirSync, - mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs"; -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; import { basename, join } from "node:path"; const STORE_ERROR = "Workspace secret store is unavailable."; @@ -94,6 +93,7 @@ export class WorkspaceSecretStore { private readonly maxSecretBytes: number; private readonly keyPath: string; private readonly vaultPath: string; + private readonly materializationReferences = new Map(); constructor(options: WorkspaceSecretStoreOptions) { if (!options.installationId.trim()) throw new Error("Installation identifier is required."); @@ -184,11 +184,37 @@ export class WorkspaceSecretStore { } let directory: string | undefined; + let retained = false; try { const vault = this.readVault(); const key = this.readKey(); - directory = mkdtempSync(join(this.runtimeRoot, "lease-")); - chmodSync(directory, 0o700); + const workspaceEntries = requirementIds + .map((requirementId) => vault.entries[entryKey(workspaceId, requirementId)]) + .filter((entry): entry is EncryptedEntry => entry !== undefined) + .sort((left, right) => left.requirementId.localeCompare(right.requirementId)); + const materializationId = createHash("sha256") + .update(this.installationId) + .update("\0") + .update(workspaceId) + .update("\0") + .update(JSON.stringify(workspaceEntries)) + .digest("hex") + .slice(0, 24); + directory = join( + this.runtimeRoot, + `workspace-${createHash("sha256").update(workspaceId).digest("hex").slice(0, 16)}-${materializationId}`, + ); + const references = this.materializationReferences.get(directory) ?? 0; + if (references === 0) { + try { + mkdirSync(directory, { recursive: false, mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + rmSync(directory, { recursive: true, force: true }); + mkdirSync(directory, { recursive: false, mode: 0o700 }); + } + chmodSync(directory, 0o700); + } const files = new Map(); for (const requirementId of [...new Set(requirementIds)]) { const entry = vault.entries[entryKey(workspaceId, requirementId)]; @@ -207,18 +233,24 @@ export class WorkspaceSecretStore { decipher.update(Buffer.from(entry.ciphertext, "base64")), decipher.final(), ]); - const path = join(directory, randomBytes(16).toString("hex")); - const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o400); - try { - fchmodSync(fd, 0o400); - writeFileSync(fd, plaintext); - fsyncSync(fd); - } finally { + const path = join(directory, createHash("sha256").update(requirementId).digest("hex")); + if (references === 0) { + const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o400); + try { + fchmodSync(fd, 0o400); + writeFileSync(fd, plaintext); + fsyncSync(fd); + } finally { + plaintext.fill(0); + closeSync(fd); + } + } else { plaintext.fill(0); - closeSync(fd); } files.set(requirementId, path); } + this.materializationReferences.set(directory, references + 1); + retained = true; let released = false; const leasedDirectory = directory; return { @@ -226,11 +258,19 @@ export class WorkspaceSecretStore { release: () => { if (released) return; released = true; - rmSync(leasedDirectory, { recursive: true, force: true }); + const remaining = (this.materializationReferences.get(leasedDirectory) ?? 1) - 1; + if (remaining > 0) { + this.materializationReferences.set(leasedDirectory, remaining); + } else { + this.materializationReferences.delete(leasedDirectory); + rmSync(leasedDirectory, { recursive: true, force: true }); + } }, }; } catch { - if (directory !== undefined) rmSync(directory, { recursive: true, force: true }); + if (directory !== undefined && !retained && !this.materializationReferences.has(directory)) { + rmSync(directory, { recursive: true, force: true }); + } throw new Error(STORE_ERROR); } } diff --git a/backend/test/workspace-secret-store.test.ts b/backend/test/workspace-secret-store.test.ts index 46d96e70..bd9e82c0 100644 --- a/backend/test/workspace-secret-store.test.ts +++ b/backend/test/workspace-secret-store.test.ts @@ -34,6 +34,23 @@ afterEach(() => { }); describe("WorkspaceSecretStore", () => { + test("reuses stable materialized paths and removes them after the last lease", () => { + const { store } = fixture(); + store.put("north-star", "dwh.password", "correct horse battery staple"); + + const first = store.materialize("north-star", ["dwh.password"]); + const second = store.materialize("north-star", ["dwh.password"]); + const firstPath = first.files.get("dwh.password")!; + const secondPath = second.files.get("dwh.password")!; + + expect(secondPath).toBe(firstPath); + expect(readFileSync(firstPath, "utf8")).toBe("correct horse battery staple"); + first.release(); + expect(readFileSync(secondPath, "utf8")).toBe("correct horse battery staple"); + second.release(); + expect(existsSync(secondPath)).toBe(false); + }); + test("persists ciphertext and exposes status without exposing plaintext", () => { const { root, store } = fixture(); const secret = "correct horse battery staple";