fix: preserve deterministic runtime secret leases
This commit is contained in:
@@ -7,6 +7,7 @@ import { loadConfig } from "./config.js";
|
|||||||
import { ThtRunner } from "./tht/tht-runner.js";
|
import { ThtRunner } from "./tht/tht-runner.js";
|
||||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||||
import { publishDeterministicRuntimeConfigLease, renderActiveWorkspaceRuntime } from "./workspaces/runtime-config-lease.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 { WorkspacePreprocessingService, type WorkspaceOperationResult } from "./workspaces/preprocessing-service.js";
|
||||||
import type { SessionInventoryRow } from "./workspaces/preprocessing-state.js";
|
import type { SessionInventoryRow } from "./workspaces/preprocessing-state.js";
|
||||||
|
|
||||||
@@ -213,6 +214,11 @@ async function readSessionInventory(dataRoot: string, workspaceId: string): Prom
|
|||||||
function createProductionService(): WorkspacePreprocessingService {
|
function createProductionService(): WorkspacePreprocessingService {
|
||||||
const config = loadConfig(process.env);
|
const config = loadConfig(process.env);
|
||||||
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
||||||
|
const workspaceSecretStore = new WorkspaceSecretStore({
|
||||||
|
root: config.workspaceSecretStoreRoot,
|
||||||
|
runtimeRoot: config.workspaceSecretRuntimeRoot,
|
||||||
|
installationId: config.workspaceRegistry.installationId,
|
||||||
|
});
|
||||||
const runner = new ThtRunner({
|
const runner = new ThtRunner({
|
||||||
thtBin: config.thtBin,
|
thtBin: config.thtBin,
|
||||||
harnessDir: config.harnessDir,
|
harnessDir: config.harnessDir,
|
||||||
@@ -222,6 +228,7 @@ function createProductionService(): WorkspacePreprocessingService {
|
|||||||
secretRoots: config.workspaceRegistry.secretRoots,
|
secretRoots: config.workspaceRegistry.secretRoots,
|
||||||
secretsFile: config.secretsFile,
|
secretsFile: config.secretsFile,
|
||||||
secretFiles: config.secretFiles,
|
secretFiles: config.secretFiles,
|
||||||
|
workspaceSecretStore,
|
||||||
semanticRuntime: {
|
semanticRuntime: {
|
||||||
internalQdrantUrl: config.internalQdrantUrl,
|
internalQdrantUrl: config.internalQdrantUrl,
|
||||||
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||||
@@ -263,6 +270,7 @@ function createProductionService(): WorkspacePreprocessingService {
|
|||||||
internalEmbeddingModel: config.internalEmbeddingModel,
|
internalEmbeddingModel: config.internalEmbeddingModel,
|
||||||
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
||||||
},
|
},
|
||||||
|
workspaceSecretStore,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
workspace: active.workspace,
|
workspace: active.workspace,
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ export async function publishDeterministicRuntimeConfigLease(options: {
|
|||||||
dataRoot: string;
|
dataRoot: string;
|
||||||
secretRoots: readonly string[];
|
secretRoots: readonly string[];
|
||||||
semanticRuntime: SemanticRuntimeConfig;
|
semanticRuntime: SemanticRuntimeConfig;
|
||||||
|
workspaceSecretStore?: WorkspaceSecretStore;
|
||||||
}): Promise<DeterministicRuntimeConfigLease> {
|
}): Promise<DeterministicRuntimeConfigLease> {
|
||||||
const rendered = await renderActiveWorkspaceRuntime(options);
|
const rendered = await renderActiveWorkspaceRuntime(options);
|
||||||
const publishedConfig = applyCollectionLifecycle(rendered.renderedConfig, "require_existing");
|
const publishedConfig = applyCollectionLifecycle(rendered.renderedConfig, "require_existing");
|
||||||
@@ -542,7 +543,7 @@ export async function publishDeterministicRuntimeConfigLease(options: {
|
|||||||
effectiveConfigIdentity: effectiveConfigIdentityValue,
|
effectiveConfigIdentity: effectiveConfigIdentityValue,
|
||||||
configFingerprint: configFingerprintValue,
|
configFingerprint: configFingerprintValue,
|
||||||
inputFingerprint: inputFingerprintValue,
|
inputFingerprint: inputFingerprintValue,
|
||||||
release: () => undefined,
|
release: () => rendered.releaseSecrets(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +606,6 @@ export async function publishDeterministicRuntimeConfigLease(options: {
|
|||||||
effectiveConfigIdentity: effectiveConfigIdentityValue,
|
effectiveConfigIdentity: effectiveConfigIdentityValue,
|
||||||
configFingerprint: configFingerprintValue,
|
configFingerprint: configFingerprintValue,
|
||||||
inputFingerprint: inputFingerprintValue,
|
inputFingerprint: inputFingerprintValue,
|
||||||
release: () => undefined,
|
release: () => rendered.releaseSecrets(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import {
|
|||||||
fchmodSync,
|
fchmodSync,
|
||||||
fsyncSync,
|
fsyncSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
|
||||||
openSync,
|
openSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
renameSync,
|
renameSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
|
|
||||||
const STORE_ERROR = "Workspace secret store is unavailable.";
|
const STORE_ERROR = "Workspace secret store is unavailable.";
|
||||||
@@ -94,6 +93,7 @@ export class WorkspaceSecretStore {
|
|||||||
private readonly maxSecretBytes: number;
|
private readonly maxSecretBytes: number;
|
||||||
private readonly keyPath: string;
|
private readonly keyPath: string;
|
||||||
private readonly vaultPath: string;
|
private readonly vaultPath: string;
|
||||||
|
private readonly materializationReferences = new Map<string, number>();
|
||||||
|
|
||||||
constructor(options: WorkspaceSecretStoreOptions) {
|
constructor(options: WorkspaceSecretStoreOptions) {
|
||||||
if (!options.installationId.trim()) throw new Error("Installation identifier is required.");
|
if (!options.installationId.trim()) throw new Error("Installation identifier is required.");
|
||||||
@@ -184,11 +184,37 @@ export class WorkspaceSecretStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let directory: string | undefined;
|
let directory: string | undefined;
|
||||||
|
let retained = false;
|
||||||
try {
|
try {
|
||||||
const vault = this.readVault();
|
const vault = this.readVault();
|
||||||
const key = this.readKey();
|
const key = this.readKey();
|
||||||
directory = mkdtempSync(join(this.runtimeRoot, "lease-"));
|
const workspaceEntries = requirementIds
|
||||||
chmodSync(directory, 0o700);
|
.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>();
|
const files = new Map<string, string>();
|
||||||
for (const requirementId of [...new Set(requirementIds)]) {
|
for (const requirementId of [...new Set(requirementIds)]) {
|
||||||
const entry = vault.entries[entryKey(workspaceId, requirementId)];
|
const entry = vault.entries[entryKey(workspaceId, requirementId)];
|
||||||
@@ -207,18 +233,24 @@ export class WorkspaceSecretStore {
|
|||||||
decipher.update(Buffer.from(entry.ciphertext, "base64")),
|
decipher.update(Buffer.from(entry.ciphertext, "base64")),
|
||||||
decipher.final(),
|
decipher.final(),
|
||||||
]);
|
]);
|
||||||
const path = join(directory, randomBytes(16).toString("hex"));
|
const path = join(directory, createHash("sha256").update(requirementId).digest("hex"));
|
||||||
const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o400);
|
if (references === 0) {
|
||||||
try {
|
const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o400);
|
||||||
fchmodSync(fd, 0o400);
|
try {
|
||||||
writeFileSync(fd, plaintext);
|
fchmodSync(fd, 0o400);
|
||||||
fsyncSync(fd);
|
writeFileSync(fd, plaintext);
|
||||||
} finally {
|
fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
plaintext.fill(0);
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
plaintext.fill(0);
|
plaintext.fill(0);
|
||||||
closeSync(fd);
|
|
||||||
}
|
}
|
||||||
files.set(requirementId, path);
|
files.set(requirementId, path);
|
||||||
}
|
}
|
||||||
|
this.materializationReferences.set(directory, references + 1);
|
||||||
|
retained = true;
|
||||||
let released = false;
|
let released = false;
|
||||||
const leasedDirectory = directory;
|
const leasedDirectory = directory;
|
||||||
return {
|
return {
|
||||||
@@ -226,11 +258,19 @@ export class WorkspaceSecretStore {
|
|||||||
release: () => {
|
release: () => {
|
||||||
if (released) return;
|
if (released) return;
|
||||||
released = true;
|
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 {
|
} 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);
|
throw new Error(STORE_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkspaceSecretStore", () => {
|
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", () => {
|
test("persists ciphertext and exposes status without exposing plaintext", () => {
|
||||||
const { root, store } = fixture();
|
const { root, store } = fixture();
|
||||||
const secret = "correct horse battery staple";
|
const secret = "correct horse battery staple";
|
||||||
|
|||||||
Reference in New Issue
Block a user