fix: preserve deterministic runtime secret leases

This commit is contained in:
2026-08-14 17:47:17 +02:00
parent fd3b62ce6f
commit a94df262f4
4 changed files with 82 additions and 16 deletions
+8
View File
@@ -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,
@@ -458,6 +458,7 @@ export async function publishDeterministicRuntimeConfigLease(options: {
dataRoot: string;
secretRoots: readonly string[];
semanticRuntime: SemanticRuntimeConfig;
workspaceSecretStore?: WorkspaceSecretStore;
}): Promise<DeterministicRuntimeConfigLease> {
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(),
};
}
+54 -14
View File
@@ -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<string, number>();
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<string, string>();
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);
}
}
@@ -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";