fix: render registry workspaces for harness

This commit is contained in:
2026-08-05 16:03:45 +02:00
parent ece9cfda50
commit bd798b1c96
21 changed files with 659 additions and 69 deletions
+119 -3
View File
@@ -4,9 +4,13 @@ import {
closeSync, constants as fsConstants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync,
openSync, readFileSync, readSync, realpathSync, statSync, unlinkSync, writeFileSync,
} from "node:fs";
import { dirname, isAbsolute, join, relative } from "node:path";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { parseAllDocuments } from "yaml";
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
import { secretValue, type SecretBundleConfig } from "../config/secret-bundle.js";
import { resolveRuntimeBindings } from "../workspaces/bindings.js";
import { renderRuntimeConfig, type RuntimeInstallationOverlay, type RuntimePaths } from "../workspaces/runtime-renderer.js";
import { parseWorkspaceYaml } from "../workspaces/schema.js";
export interface ThtConfig extends SecretBundleConfig {
thtBin: string;
@@ -14,6 +18,14 @@ export interface ThtConfig extends SecretBundleConfig {
configPath: string;
dataRoot?: string;
runtimeSnapshotRoot?: string;
secretRoots?: readonly string[];
}
export interface RuntimeConfigLease {
path: string;
workspaceId: string;
workspaceRevision: string;
release(): void;
}
export interface SessionRow {
@@ -87,18 +99,110 @@ export class ThtRunner {
return ["-c", this.cfg.configPath];
}
private assertWorkspaceSnapshot(path: string): void {
private assertWorkspaceSnapshot(path: string): { workspaceId: string; workspaceRevision: string } {
if (!this.cfg.runtimeSnapshotRoot) throw new Error("workspace snapshot root is not configured");
const snapshotsRoot = dirname(this.cfg.runtimeSnapshotRoot);
const pathRelative = relative(snapshotsRoot, path);
const match = /^([0-9a-f]{40})\/([a-z][a-z0-9-]{2,62})\.yaml$/.exec(pathRelative);
if (
pathRelative.startsWith("..") || isAbsolute(pathRelative)
|| !/^[0-9a-f]{40}\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(pathRelative)
|| !match
) throw new Error("config path is not a trusted runtime snapshot");
const entry = lstatSync(path);
if (!entry.isFile() || entry.isSymbolicLink()) {
throw new Error("config path is not a trusted runtime snapshot");
}
return { workspaceRevision: match[1], workspaceId: match[2] };
}
private readCanonicalWorkspaceSnapshot(path: string): {
workspace: ReturnType<typeof parseWorkspaceYaml>;
workspaceId: string;
workspaceRevision: string;
} {
const identity = this.assertWorkspaceSnapshot(path);
const fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
try {
const before = fstatSync(fd);
if (!before.isFile()) throw new Error("workspace snapshot is not a file");
const source = readFileSync(fd, "utf8");
const after = fstatSync(fd);
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) {
throw new Error("workspace snapshot changed while reading");
}
const workspace = parseWorkspaceYaml(source);
if (workspace.workspace.id !== identity.workspaceId) {
throw new Error("workspace snapshot identity does not match its path");
}
return { workspace, ...identity };
} finally {
closeSync(fd);
}
}
private runtimePaths(workspaceId: string): RuntimePaths {
if (!this.cfg.dataRoot || !isAbsolute(this.cfg.dataRoot)) {
throw new Error("registry workspace runtime requires an absolute data root");
}
// The portable stack persists one `sessions` store at <dataRoot>/sessions. Keep every
// workspace's mutable harness roots below that mounted boundary.
const root = join(this.cfg.dataRoot, "sessions", workspaceId);
return {
sessions: join(root, "sessions"),
artifacts: join(root, "artifacts"),
indexes: join(root, "indexes"),
};
}
private installationOverlay(): RuntimeInstallationOverlay {
const path = isAbsolute(this.cfg.configPath)
? this.cfg.configPath
: resolve(this.cfg.harnessDir, this.cfg.configPath);
if (!existsSync(path)) return {};
const documents = parseAllDocuments(readFileSync(path, "utf8"), { uniqueKeys: true });
if (documents.length !== 1) throw new Error("installation config must contain one YAML document");
const document = documents[0];
if (document.errors.length > 0 || document.warnings.length > 0) {
throw new Error("installation config contains invalid YAML");
}
const parsed = document.toJSON();
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("installation config must be a YAML mapping");
}
const source = parsed as Record<string, unknown>;
return {
...(source.session_storage === undefined ? {} : { session_storage: source.session_storage }),
...(source.profile === undefined ? {} : { profile: source.profile }),
};
}
/** Render one immutable canonical registry revision into a backend-owned harness config. */
acquireWorkspaceRuntime(workspaceConfigPath: string): RuntimeConfigLease {
const canonical = this.readCanonicalWorkspaceSnapshot(workspaceConfigPath);
const bindings = resolveRuntimeBindings(
canonical.workspace,
process.env,
this.cfg.secretRoots ?? [],
);
const config = renderRuntimeConfig(
canonical.workspace,
bindings,
this.runtimePaths(canonical.workspaceId),
canonical,
this.installationOverlay(),
);
const path = this.createRuntimeSnapshot(config);
let released = false;
return {
path,
workspaceId: canonical.workspaceId,
workspaceRevision: canonical.workspaceRevision,
release: () => {
if (released) return;
released = true;
this.cleanupRuntimeSnapshot(path);
},
};
}
private runtimeSnapshotDirectory(): string {
@@ -228,6 +332,18 @@ export class ThtRunner {
run(
args: string[], workspaceConfigPath?: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS,
): Promise<{ code: number; stdout: string; stderr: string }> {
if (
workspaceConfigPath && isAbsolute(workspaceConfigPath)
&& !this.runtimeSnapshots.has(workspaceConfigPath)
) {
let runtime: RuntimeConfigLease;
try {
runtime = this.acquireWorkspaceRuntime(workspaceConfigPath);
} catch (error) {
return Promise.reject(error);
}
return this.run(args, runtime.path, timeoutMs).finally(runtime.release);
}
return new Promise((resolve) => {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.THT_DATA_ROOT;