fix: harden runtime config lease boundary

This commit is contained in:
2026-08-11 12:43:14 +02:00
parent cf88e2df86
commit ec92f7f994
8 changed files with 324 additions and 149 deletions
+4 -4
View File
@@ -77,9 +77,9 @@ export function sessionRoutes(
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
};
const optionsWithRuntimeConfig = (runner: any, workspaceConfigPath: string | undefined, options: any) => (
const optionsWithRuntimeConfig = async (runner: any, workspaceConfigPath: string | undefined, options: any) => (
workspaceConfigPath && typeof runner.acquireWorkspaceRuntime === "function"
? { ...options, runtimeConfig: runner.acquireWorkspaceRuntime(workspaceConfigPath) }
? { ...options, runtimeConfig: await runner.acquireWorkspaceRuntime(workspaceConfigPath) }
: options
);
@@ -431,7 +431,7 @@ export function sessionRoutes(
let runtimeOptions = options;
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
try {
runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
runtimeOptions = await optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
rt = d.mgr.createFor(id, runtimeOptions);
bindRuntime(id, rt, runner, workspaceConfigPath);
} catch (error) {
@@ -616,7 +616,7 @@ export function sessionRoutes(
if (boundRuntimes.get(id) === current) boundRuntimes.delete(id);
d.mgr.teardownIfCurrent(id, current);
}
runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
runtimeOptions = await optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
rt = d.mgr.createFor(id, runtimeOptions);
bindRuntime(id, rt, runner, workspaceConfigPath);
} catch {
+3 -3
View File
@@ -141,7 +141,7 @@ export class ThtRunner {
}
/** Render the pinned registry revision through the shared deterministic lease. */
acquireWorkspaceRuntime(workspaceConfigPath: string): RuntimeConfigLease {
async acquireWorkspaceRuntime(workspaceConfigPath: string): Promise<RuntimeConfigLease> {
return this.runtimeConfigLeases().acquireSession(workspaceConfigPath);
}
@@ -283,7 +283,7 @@ export class ThtRunner {
static readonly DEFAULT_TIMEOUT_MS = 60_000;
static readonly DWH_TIMEOUT_MS = 120_000;
run(
async run(
args: string[], workspaceConfigPath?: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS,
): Promise<{ code: number; stdout: string; stderr: string }> {
if (
@@ -293,7 +293,7 @@ export class ThtRunner {
) {
let runtime: RuntimeConfigLease;
try {
runtime = this.acquireWorkspaceRuntime(workspaceConfigPath);
runtime = await this.acquireWorkspaceRuntime(workspaceConfigPath);
} catch (error) {
return Promise.reject(error);
}
+75 -40
View File
@@ -1,11 +1,11 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { isIP } from "node:net";
import { domainToASCII } from "node:url";
import {
existsSync, lstatSync, readFileSync,
} from "node:fs";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { dirname, isAbsolute, join, relative, resolve, normalize } from "node:path";
import { parseAllDocuments } from "yaml";
import { resolveRuntimeBindings } from "./bindings.js";
import {
@@ -53,6 +53,14 @@ interface SnapshotIdentity {
descriptorDev: string;
descriptorIno: string;
}
interface PublishedResponse {
path: string;
manifestPath: string;
manifest: string;
manifest_sha256: string;
dev: number;
ino: number;
}
interface PublishedIdentity {
path: string;
manifestPath: string;
@@ -142,28 +150,25 @@ export class WorkspaceRuntimeConfigLeaseFactory {
} as RuntimeInstallationOverlay;
}
acquireSession(snapshotPath: string): RuntimeConfigLease { return this.acquire(snapshotPath); }
acquireMaintenance(input: MaintenanceRuntimeInput): RuntimeConfigLease {
async acquireSession(snapshotPath: string): Promise<RuntimeConfigLease> { return this.acquire(snapshotPath); }
async acquireMaintenance(input: MaintenanceRuntimeInput): Promise<RuntimeConfigLease> {
const snapshotPath = input.snapshotPath ?? input.workspaceConfigPath;
if (!snapshotPath) throw new Error("maintenance runtime snapshot is required");
return this.acquire(snapshotPath);
}
private acquire(snapshotPath: string): RuntimeConfigLease {
const snapshot = this.readSnapshot(snapshotPath);
private async acquire(snapshotPath: string): Promise<RuntimeConfigLease> {
const snapshot = await this.readSnapshot(snapshotPath);
const paths = this.runtimePaths(snapshot.workspaceId);
const rendered = renderRuntimeConfig(snapshot.workspace, resolveRuntimeBindings(snapshot.workspace, this.env, this.secretRoots), paths, snapshot, this.installation, this.input.semanticRuntime ?? DEFAULT_SEMANTIC_RUNTIME);
const renderedDigest = digest(rendered);
const base = {
workspace_id: snapshot.workspaceId, workspace_revision: snapshot.workspaceRevision,
descriptor_git_blob: snapshot.descriptorBlob!,
descriptor_sha256: snapshot.digest,
descriptor_dev: snapshot.descriptorDev,
descriptor_ino: snapshot.descriptorIno,
config_sha256: renderedDigest,
config_dwh_binding: this.computeBinding(rendered),
descriptor_git_blob: snapshot.descriptorBlob!, descriptor_sha256: snapshot.digest,
descriptor_dev: snapshot.descriptorDev, descriptor_ino: snapshot.descriptorIno,
config_sha256: renderedDigest, config_dwh_binding: await this.computeBinding(rendered),
};
const result = this.publishSecure(snapshot.workspaceId, snapshot.workspaceRevision, rendered, base);
const result = await this.publishSecure(snapshot.workspaceId, snapshot.workspaceRevision, rendered, base);
const identity: PublishedIdentity = {
path: result.path, manifestPath: result.manifestPath, workspaceId: snapshot.workspaceId,
workspaceRevision: snapshot.workspaceRevision, digest: renderedDigest, content: rendered,
@@ -172,41 +177,68 @@ export class WorkspaceRuntimeConfigLeaseFactory {
return this.lease(identity);
}
private helper(action: string, extra: Record<string, unknown>): any {
private async helper(action: string, extra: Record<string, unknown>): Promise<unknown> {
const python = join(this.input.harnessDir, ".venv", "bin", "python");
const modulePath = existsSync(join(this.input.harnessDir, "tht", "runtime_config_lease_io.py"))
? join(this.input.harnessDir, "tht", "runtime_config_lease_io.py")
: join(process.cwd(), "../harness/tht/runtime_config_lease_io.py");
// Fixtures may provide a temporary harness directory; still execute the real
// project helper environment, never a fabricated TypeScript binding.
const projectPython = join(dirname(dirname(modulePath)), ".venv", "bin", "python");
const executable = existsSync(python) ? python
: existsSync(projectPython) ? projectPython : (process.env.PYTHON ?? "python3");
const executable = existsSync(python) ? python : existsSync(projectPython) ? projectPython : (process.env.PYTHON ?? "python3");
const helperArgs = existsSync(modulePath) ? [modulePath] : ["-m", "tht.runtime_config_lease_io"];
const result = spawnSync(executable, helperArgs, { cwd: this.input.harnessDir,
input: JSON.stringify({ action, ...extra }), encoding: "utf8",
env: { ...this.env, PYTHONPATH: [this.input.harnessDir, dirname(dirname(modulePath)), this.env.PYTHONPATH].filter(Boolean).join(":"), }, });
if (result.status !== 0) {
let detail = result.stderr?.trim() || result.stdout?.trim() || `runtime config ${action} failed`;
try { detail = JSON.parse(result.stdout).error ?? detail; } catch { /* preserve helper detail */ }
throw new Error(detail);
const env = { ...this.env };
// Never pass ambient capability variables to binding/snapshot/publication.
for (const key of Object.keys(env)) {
if ((key.startsWith("THT_RUNTIME_CONFIG_") && !["THT_RUNTIME_CONFIG_FSYNC_FAIL", "THT_RUNTIME_CONFIG_RENAME_FAIL"].includes(key)) || key.startsWith("THT_CONFIG_")) delete env[key];
}
try { return JSON.parse(result.stdout); } catch { throw new Error(`runtime config ${action} returned invalid JSON`); }
env.PYTHONPATH = [this.input.harnessDir, dirname(dirname(modulePath)), env.PYTHONPATH].filter(Boolean).join(":");
const payload = JSON.stringify({ protocol_version: 1, action, ...extra });
const timeoutMs = 10_000;
return await new Promise((resolveResult, reject) => {
const child = spawn(executable, helperArgs, { cwd: this.input.harnessDir, env, detached: true, stdio: ["pipe", "pipe", "pipe"] });
let stdout = ""; let stderr = ""; let settled = false;
const finish = (error?: Error, value?: unknown) => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolveResult(value); };
const kill = () => { try { process.kill(-child.pid!, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* gone */ } } };
const timer = setTimeout(() => { kill(); finish(new Error(`runtime config ${action} timed out`)); }, timeoutMs);
const append = (target: "stdout" | "stderr", data: Buffer) => {
const next = target === "stdout" ? stdout + data.toString() : stderr + data.toString();
if (next.length > 16 * 1024 * 1024) { kill(); finish(new Error(`runtime config ${action} output exceeded limit`)); return; }
if (target === "stdout") stdout = next; else stderr = next;
};
child.stdout.on("data", (d: Buffer) => append("stdout", d)); child.stderr.on("data", (d: Buffer) => append("stderr", d));
child.on("error", (error) => finish(error));
child.on("close", (code) => {
if (code !== 0) { let detail = stderr.trim() || stdout.trim() || `runtime config ${action} failed`; try { detail = (JSON.parse(stdout) as {error?: string}).error ?? detail; } catch { /* preserve detail */ } finish(new Error(detail)); return; }
try { finish(undefined, JSON.parse(stdout)); } catch { finish(new Error(`runtime config ${action} returned invalid JSON`)); }
});
child.stdin.end(payload);
});
}
private computeBinding(content: string): Record<string, string> {
try {
const value = this.helper("binding", { config_hex: Buffer.from(content).toString("hex") });
if (value && typeof value.workspace_id === "string" && typeof value.config_fingerprint === "string" && typeof value.input_fingerprint === "string") return value;
throw new Error("runtime config binding helper returned malformed output");
} catch (error) {
throw error instanceof Error ? error : new Error("runtime config binding failed");
}
private async computeBinding(content: string): Promise<Record<string, string>> {
const value = await this.helper("binding", { config_hex: Buffer.from(content).toString("hex") });
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== "config_fingerprint,input_fingerprint,workspace_id") throw new Error("runtime config binding helper returned malformed output");
const record = value as Record<string, unknown>;
if (Object.values(record).some((v) => typeof v !== "string")) throw new Error("runtime config binding helper returned malformed output");
return record as Record<string, string>;
}
private publishSecure(workspaceId: string, revision: string, content: string, manifestBase: Record<string, unknown>): {path:string; manifestPath:string; manifest:string; manifest_sha256:string} {
return this.helper("publish", { data_root: this.input.dataRoot, workspace_id: workspaceId,
private async publishSecure(workspaceId: string, revision: string, content: string, manifestBase: Record<string, unknown>): Promise<PublishedResponse> {
const value = await this.helper("publish", { data_root: this.input.dataRoot, workspace_id: workspaceId,
workspace_revision: revision, config_hex: Buffer.from(content).toString("hex"), manifest_base: manifestBase });
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("runtime config publish helper returned malformed output");
const result = value as Record<string, unknown>;
if (Object.keys(result).sort().join(",") !== "dev,ino,manifest,manifestPath,manifest_sha256,path") throw new Error("runtime config publish helper returned malformed output");
let expectedRoot = resolve(this.input.dataRoot);
if (process.platform === "darwin" && (expectedRoot === "/var" || expectedRoot.startsWith("/var/") || expectedRoot === "/tmp" || expectedRoot.startsWith("/tmp/"))) expectedRoot = `/private${expectedRoot}`;
const expectedPath = join(expectedRoot, "sessions", workspaceId, "preprocessing", "runtime-config", `${revision}.yaml`);
const expectedManifestPath = join(expectedRoot, "sessions", workspaceId, "preprocessing", "runtime-config-manifests", `${revision}.json`);
if (result.path !== expectedPath || result.manifestPath !== expectedManifestPath
|| typeof result.path !== "string" || !isAbsolute(result.path) || normalize(result.path) !== result.path
|| typeof result.manifestPath !== "string" || !isAbsolute(result.manifestPath) || normalize(result.manifestPath) !== result.manifestPath
|| result.workspace_id !== undefined || typeof result.manifest !== "string"
|| typeof result.manifest_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(result.manifest_sha256)
|| typeof result.dev !== "number" || !Number.isSafeInteger(result.dev) || typeof result.ino !== "number" || !Number.isSafeInteger(result.ino)) throw new Error("runtime config publish helper returned malformed output");
return result as unknown as PublishedResponse;
}
private lease(identity: PublishedIdentity): RuntimeConfigLease {
@@ -225,7 +257,7 @@ export class WorkspaceRuntimeConfigLeaseFactory {
if (!e.isDirectory() || e.isSymbolicLink() || e.nlink < 1 || (e.mode & 0o077) !== 0 || e.uid !== process.getuid?.()) throw new Error(`${label} is not trusted`);
}
private readSnapshot(path: string): SnapshotIdentity {
private async readSnapshot(path: string): Promise<SnapshotIdentity> {
if (!isAbsolute(path)) throw new Error("workspace snapshot path must be absolute");
const root = resolve(this.input.runtimeSnapshotRoot);
const rel = relative(root, path);
@@ -235,11 +267,14 @@ export class WorkspaceRuntimeConfigLeaseFactory {
// production snapshot.json and descriptor component-by-component, and MUST prove
// the Git commit/blob identity; a pathname-shaped file is never sufficient.
const repositoryRoot = join(dirname(root), "repo");
const verified = this.helper("verified-snapshot", {
const verified = await this.helper("verified-snapshot", {
snapshots_root: root, repository_root: repositoryRoot,
workspace_revision: match[1], workspace_id: match[2],
});
if (!verified || typeof verified.source !== "string" || typeof verified.git_source !== "string"
}) as Record<string, unknown>;
const verifiedKeys = ["descriptor_dev", "descriptor_git_blob", "descriptor_ino", "git_source", "sha256", "snapshot_path", "source", "workspace_id", "workspace_revision"];
if (Object.keys(verified).sort().join(",") !== verifiedKeys.join(",")
|| verified.workspace_id !== match[2] || verified.workspace_revision !== match[1]
|| typeof verified.source !== "string" || typeof verified.git_source !== "string"
|| verified.sha256 !== digest(verified.source) || verified.snapshot_path !== path
|| !/^\d+$/.test(String(verified.descriptor_dev)) || !/^\d+$/.test(String(verified.descriptor_ino))) {
throw new Error("workspace snapshot integrity check failed");