diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 4830c2c6..1338af53 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -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 | 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 { diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index e7841cd2..f395bd16 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -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 { 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); } diff --git a/backend/src/workspaces/runtime-config-lease.ts b/backend/src/workspaces/runtime-config-lease.ts index 5dd6fab4..d067a06c 100644 --- a/backend/src/workspaces/runtime-config-lease.ts +++ b/backend/src/workspaces/runtime-config-lease.ts @@ -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 { return this.acquire(snapshotPath); } + async acquireMaintenance(input: MaintenanceRuntimeInput): Promise { 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 { + 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): any { + private async helper(action: string, extra: Record): Promise { 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 { - 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> { + 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; + if (Object.values(record).some((v) => typeof v !== "string")) throw new Error("runtime config binding helper returned malformed output"); + return record as Record; } - private publishSecure(workspaceId: string, revision: string, content: string, manifestBase: Record): {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): Promise { + 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; + 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 { 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; + 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"); diff --git a/backend/test/workspace-runtime-config-lease.test.ts b/backend/test/workspace-runtime-config-lease.test.ts index b054a1b9..cfb0ef13 100644 --- a/backend/test/workspace-runtime-config-lease.test.ts +++ b/backend/test/workspace-runtime-config-lease.test.ts @@ -84,11 +84,11 @@ function fixture(extraEnv: Record = {}) { return { root, repo, snapshotPath, factory, factoryInput, canonicalDescriptor, snapshotManifest: join(snapshotsDir, "snapshot.json") }; } -test("session and maintenance share deterministic bytes and path", () => { +test("session and maintenance share deterministic bytes and path", async () => { const f = fixture(); try { - const session = f.factory.acquireSession(f.snapshotPath); - const maintenance = f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath }); + const session = await f.factory.acquireSession(f.snapshotPath); + const maintenance = await f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath }); expect(session.path).toBe(maintenance.path); expect(readFileSync(session.path, "utf8")).toBe(readFileSync(maintenance.path, "utf8")); expect(lstatSync(session.path).mode & 0o777).toBe(0o400); @@ -101,19 +101,19 @@ test("session and maintenance share deterministic bytes and path", () => { } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("same revision changed bytes are refused", () => { +test("same revision changed bytes are refused", async () => { const f = fixture(); try { - const first = f.factory.acquireSession(f.snapshotPath); + const first = await f.factory.acquireSession(f.snapshotPath); chmodSync(first.path, 0o600); writeFileSync(first.path, "changed", { mode: 0o600 }); chmodSync(first.path, 0o400); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/changed|mismatch|trusted/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/changed|mismatch|trusted/i); first.release(); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("snapshot descriptor must equal the Git canonical descriptor", () => { +test("snapshot descriptor must equal the Git canonical descriptor", async () => { const f = fixture(); try { const mutated = f.canonicalDescriptor.replace("database: analytics", "database: evil").replace("name: Lease", "name: Lease analytics"); @@ -126,29 +126,29 @@ test("snapshot descriptor must equal the Git canonical descriptor", () => { chmodSync(manifestPath, 0o600); writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 }); chmodSync(manifestPath, 0o400); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/Git descriptor|integrity|identity/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/Git descriptor|integrity|identity/i); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("same-byte replacement of the registry descriptor is refused", () => { +test("same-byte replacement of the registry descriptor is refused", async () => { const f = fixture(); try { - const first = f.factory.acquireSession(f.snapshotPath); + const first = await f.factory.acquireSession(f.snapshotPath); const replacement = `${f.snapshotPath}.replacement`; writeFileSync(replacement, readFileSync(f.snapshotPath), { mode: 0o400 }); chmodSync(f.snapshotPath, 0o600); rmSync(f.snapshotPath); writeFileSync(f.snapshotPath, readFileSync(replacement), { mode: 0o400 }); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/identity|changed|mismatch|trusted/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/identity|changed|mismatch|trusted/i); first.release(); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("manifest binds the complete canonical destination directory chain", () => { +test("manifest binds the complete canonical destination directory chain", async () => { const f = fixture(); try { - const lease = f.factory.acquireSession(f.snapshotPath); + const lease = await f.factory.acquireSession(f.snapshotPath); const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8")); expect(manifest.directory_identities.length).toBeGreaterThan(5); expect(manifest.directory_identities.map((entry: { path: string }) => entry.path)).toContain( @@ -160,7 +160,7 @@ test("manifest binds the complete canonical destination directory chain", () => } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("raw Git identity ignores replacement refs", () => { +test("raw Git identity ignores replacement refs", async () => { const f = fixture(); try { const evil = f.canonicalDescriptor.replace("database: analytics", "database: evil"); @@ -179,14 +179,14 @@ test("raw Git identity ignores replacement refs", () => { chmodSync(f.snapshotManifest, 0o600); writeFileSync(f.snapshotManifest, JSON.stringify(snapshot)); chmodSync(f.snapshotManifest, 0o400); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/Git descriptor|integrity|identity/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/Git descriptor|integrity|identity/i); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("replacement of canonical destination directories is refused", () => { +test("replacement of canonical destination directories is refused", async () => { const f = fixture(); try { - const lease = f.factory.acquireSession(f.snapshotPath); + const lease = await f.factory.acquireSession(f.snapshotPath); const original = join(f.root, "data", "sessions", workspace); const moved = `${original}.moved`; renameSync(original, moved); @@ -194,14 +194,14 @@ test("replacement of canonical destination directories is refused", () => { mkdirSync(join(original, "preprocessing", "runtime-config-manifests"), { recursive: true, mode: 0o700 }); renameSync(join(moved, "preprocessing", "runtime-config", `${lease.workspaceRevision}.yaml`), join(original, "preprocessing", "runtime-config", `${lease.workspaceRevision}.yaml`)); renameSync(join(moved, "preprocessing", "runtime-config-manifests", `${lease.workspaceRevision}.json`), join(original, "preprocessing", "runtime-config-manifests", `${lease.workspaceRevision}.json`)); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/changed|mismatch|same-revision|identity|trusted/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/changed|mismatch|same-revision|identity|trusted/i); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("rename faults fail closed and remove staging files", () => { +test("rename faults fail closed and remove staging files", async () => { const f = fixture({ THT_RUNTIME_CONFIG_RENAME_FAIL: "1" }); try { - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/rename|failed/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/rename|failed/i); const runtime = join(f.root, "data", "sessions", workspace, "preprocessing"); for (const dir of ["runtime-config", "runtime-config-manifests"]) { if (existsSync(join(runtime, dir))) expect(readdirSync(join(runtime, dir)).filter((name) => name.includes("staging")).length).toBe(0); @@ -210,11 +210,11 @@ test("rename faults fail closed and remove staging files", () => { }); -test("session and operator outputs retain normalized private-host policy and binding", () => { +test("session and operator outputs retain normalized private-host policy and binding", async () => { const f = fixture({ THT_HTTP_PRIVATE_HOST_ALLOWLIST: "internal.example,warehouse.example" }); try { - const session = f.factory.acquireSession(f.snapshotPath); - const maintenance = f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath }); + const session = await f.factory.acquireSession(f.snapshotPath); + const maintenance = await f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath }); const output = readFileSync(session.path, "utf8"); expect(output).toContain("http_private_host_allowlist"); expect(output).toContain("- internal.example"); @@ -228,29 +228,29 @@ test("session and operator outputs retain normalized private-host policy and bin }); -test("unexpected manifest fields are refused before handoff", () => { +test("unexpected manifest fields are refused before handoff", async () => { const f = fixture(); try { - const lease = f.factory.acquireSession(f.snapshotPath); + const lease = await f.factory.acquireSession(f.snapshotPath); const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8")); manifest.unexpected = true; chmodSync(lease.manifestPath, 0o600); writeFileSync(lease.manifestPath, JSON.stringify(manifest)); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/manifest|invalid|changed/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/manifest|invalid|changed/i); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("runtime config symlink replacement is refused", () => { +test("runtime config symlink replacement is refused", async () => { const f = fixture(); try { - const lease = f.factory.acquireSession(f.snapshotPath); + const lease = await f.factory.acquireSession(f.snapshotPath); const replacement = `${lease.path}.real`; writeFileSync(replacement, readFileSync(lease.path), { mode: 0o400 }); chmodSync(lease.path, 0o600); rmSync(lease.path); // A no-follow handoff must never consume this pathname. execFileSync("ln", ["-s", replacement, lease.path]); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|changed|configuration|symbolic/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|changed|configuration|symbolic/i); } finally { rmSync(f.root, { recursive: true, force: true }); } }); @@ -260,11 +260,11 @@ function realHarnessBinding(config: string): Record { const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python"); return JSON.parse(execFileSync(python, [helper], { cwd: join(process.cwd(), "..", "harness"), encoding: "utf8", - input: JSON.stringify({ action: "binding", config_hex: Buffer.from(config).toString("hex") }), + input: JSON.stringify({ protocol_version: 1, action: "binding", config_hex: Buffer.from(config).toString("hex") }), })); } -test("explicit installation overlay is canonical and has one real harness binding", () => { +test("explicit installation overlay is canonical and has one real harness binding", async () => { const f = fixture(); const overlay = { profile: "workstation", @@ -277,8 +277,8 @@ test("explicit installation overlay is canonical and has one real harness bindin const sessionFactory = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, installationOverlay: overlay }); const maintenanceFactory = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, installationOverlay: overlay }); try { - const session = sessionFactory.acquireSession(f.snapshotPath); - const maintenance = maintenanceFactory.acquireMaintenance({ workspaceConfigPath: f.snapshotPath }); + const session = await sessionFactory.acquireSession(f.snapshotPath); + const maintenance = await maintenanceFactory.acquireMaintenance({ workspaceConfigPath: f.snapshotPath }); const sessionYaml = readFileSync(session.path, "utf8"); const maintenanceYaml = readFileSync(maintenance.path, "utf8"); expect(session.path).toBe(maintenance.path); @@ -298,10 +298,10 @@ test("explicit installation overlay is canonical and has one real harness bindin test.each([ ["config-file", "config-file"], ["config-parent", "config-parent"], ["manifest-file", "manifest-file"], ["manifest-parent", "manifest-parent"], -] as const)("fsync fault at %s fails closed and retries to the same durable pair", (_label, stage) => { +] as const)("fsync fault at %s fails closed and retries to the same durable pair", async (_label, stage) => { const f = fixture({ THT_RUNTIME_CONFIG_FSYNC_FAIL: stage }); try { - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/fsync|failed/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/fsync|failed/i); const runtime = join(f.root, "data", "sessions", workspace, "preprocessing"); for (const dir of ["runtime-config", "runtime-config-manifests"]) { if (existsSync(join(runtime, dir))) { @@ -311,7 +311,7 @@ test.each([ const recovered = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, env: { ...f.factoryInput.env, THT_RUNTIME_CONFIG_FSYNC_FAIL: undefined, } }); - const lease = recovered.acquireSession(f.snapshotPath); + const lease = await recovered.acquireSession(f.snapshotPath); expect(existsSync(lease.path)).toBe(true); expect(existsSync(lease.manifestPath)).toBe(true); expect(JSON.parse(readFileSync(lease.manifestPath, "utf8")).config_sha256) @@ -324,10 +324,10 @@ for (const [label, mutate] of [ ["outside path", (f: ReturnType) => join(f.root, "outside.yaml")], ["wrong workspace id", (f: ReturnType) => f.snapshotPath.replace("abc.yaml", "abd.yaml")], ] as const) { - test(`rejects ${label} before publication`, () => { + test(`rejects ${label} before publication`, async () => { const f = fixture(); try { - expect(() => f.factory.acquireSession(mutate(f))).toThrow(/trusted|snapshot|identity|path|Git|unavailable|ENOENT|No such/i); + await expect(f.factory.acquireSession(mutate(f))).rejects.toThrow(/trusted|snapshot|identity|path|Git|unavailable|ENOENT|No such/i); } finally { rmSync(f.root, { recursive: true, force: true }); } }); } @@ -336,11 +336,11 @@ for (const [label, replace] of [ ["descriptor symlink", (path: string, root: string) => { const target = `${path}.target`; writeFileSync(target, readFileSync(path), { mode: 0o400 }); rmSync(path); execFileSync("ln", ["-s", target, path]); }], ["descriptor hardlink", (path: string, root: string) => { const target = `${path}.target`; execFileSync("ln", [path, target]); rmSync(path); execFileSync("ln", [target, path]); }], ] as const) { - test(`rejects ${label}`, () => { + test(`rejects ${label}`, async () => { const f = fixture(); try { replace(f.snapshotPath, f.root); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|integrity|identity|link/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|integrity|identity|link/i); } finally { rmSync(f.root, { recursive: true, force: true }); } }); } @@ -349,23 +349,23 @@ for (const [label, target] of [ ["config symlink", "config"], ["config hardlink", "config"], ["manifest symlink", "manifest"], ["manifest hardlink", "manifest"], ] as const) { - test(`rejects destination ${label} and recovers safely`, () => { + test(`rejects destination ${label} and recovers safely`, async () => { const f = fixture(); try { - const first = f.factory.acquireSession(f.snapshotPath); + const first = await f.factory.acquireSession(f.snapshotPath); const path = target === "config" ? first.path : first.manifestPath; const backup = `${path}.target`; writeFileSync(backup, readFileSync(path), { mode: target === "config" ? 0o400 : 0o600 }); rmSync(path); if (label.includes("symlink")) execFileSync("ln", ["-s", backup, path]); else execFileSync("ln", [backup, path]); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|configuration|manifest|link|changed/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|configuration|manifest|link|changed/i); rmSync(path); // A replaced inode can never be trusted again. Remove the paired durable // publication and let a fresh no-replace publication recover the layout. rmSync(first.path, { force: true }); rmSync(first.manifestPath, { force: true }); - const recovered = f.factory.acquireSession(f.snapshotPath); + const recovered = await f.factory.acquireSession(f.snapshotPath); expect(recovered.path).toBe(first.path); } finally { rmSync(f.root, { recursive: true, force: true }); } }); @@ -374,44 +374,44 @@ for (const [label, target] of [ for (const [label, target, mode] of [ ["config", "config", 0o600], ["manifest", "manifest", 0o400], ] as const) { - test(`refuses wrong ${label} mode then recovers after restoring mode`, () => { + test(`refuses wrong ${label} mode then recovers after restoring mode`, async () => { const f = fixture(); try { - const first = f.factory.acquireSession(f.snapshotPath); + const first = await f.factory.acquireSession(f.snapshotPath); const path = target === "config" ? first.path : first.manifestPath; chmodSync(path, mode); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|mode|configuration|manifest/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|mode|configuration|manifest/i); chmodSync(path, target === "config" ? 0o400 : 0o600); - expect(() => f.factory.acquireSession(f.snapshotPath)).not.toThrow(); + await expect(f.factory.acquireSession(f.snapshotPath)).resolves.toBeDefined(); } finally { rmSync(f.root, { recursive: true, force: true }); } }); } - test("release retains durable state while changed binding is refused by a new factory", () => { + test("release retains durable state while changed binding is refused by a new factory", async () => { const f = fixture(); try { - const first = f.factory.acquireSession(f.snapshotPath); + const first = await f.factory.acquireSession(f.snapshotPath); first.release(); const changed = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, env: { ...f.factoryInput.env, THT_WS_ABC_DWH_HOST: "other-dwh", } }); - expect(() => changed.acquireSession(f.snapshotPath)).toThrow(/changed|mismatch|configuration/i); + await expect(changed.acquireSession(f.snapshotPath)).rejects.toThrow(/changed|mismatch|configuration/i); expect(existsSync(first.path)).toBe(true); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("strict manifest rejects an extra field and recovery preserves exact bytes", () => { +test("strict manifest rejects an extra field and recovery preserves exact bytes", async () => { const f = fixture(); try { - const first = f.factory.acquireSession(f.snapshotPath); + const first = await f.factory.acquireSession(f.snapshotPath); const original = readFileSync(first.manifestPath, "utf8"); const manifest = JSON.parse(original); manifest.extra = "reject"; chmodSync(first.manifestPath, 0o600); writeFileSync(first.manifestPath, JSON.stringify(manifest), { mode: 0o600 }); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/manifest|invalid|changed/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/manifest|invalid|changed/i); writeFileSync(first.manifestPath, original, { mode: 0o600 }); - expect(() => f.factory.acquireSession(f.snapshotPath)).not.toThrow(); + await expect(f.factory.acquireSession(f.snapshotPath)).resolves.toBeDefined(); } finally { rmSync(f.root, { recursive: true, force: true }); } }); @@ -420,40 +420,40 @@ test.each([ ["duplicate", "alpha.example,alpha.example"], ["uppercase", "Alpha.example"], ["ip address", "127.0.0.1"], -] as const)("rejects %s private-host policy", (_label, allowlist) => { +] as const)("rejects %s private-host policy", async (_label, allowlist) => { expect(() => fixture({ THT_HTTP_PRIVATE_HOST_ALLOWLIST: allowlist })) .toThrow(/allowlist|hostname|duplicate|invalid/i); }); test.each([ ["runtime-config", "config"], ["runtime-config-manifests", "manifest"], -] as const)("rejects a replaced %s destination ancestor", (directory, _kind) => { +] as const)("rejects a replaced %s destination ancestor", async (directory, _kind) => { const f = fixture(); try { - const lease = f.factory.acquireSession(f.snapshotPath); + const lease = await f.factory.acquireSession(f.snapshotPath); const parent = join(f.root, "data", "sessions", workspace, "preprocessing", directory); const moved = `${parent}.moved`; renameSync(parent, moved); execFileSync("ln", ["-s", moved, parent]); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/trusted|symbolic|changed|directory/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|symbolic|changed|directory/i); rmSync(parent); renameSync(moved, parent); - expect(() => f.factory.acquireSession(f.snapshotPath)).not.toThrow(); + await expect(f.factory.acquireSession(f.snapshotPath)).resolves.toBeDefined(); lease.release(); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("release is idempotent and does not remove either durable publication", () => { +test("release is idempotent and does not remove either durable publication", async () => { const f = fixture(); try { - const lease = f.factory.acquireSession(f.snapshotPath); + const lease = await f.factory.acquireSession(f.snapshotPath); lease.release(); lease.release(); expect(existsSync(lease.path)).toBe(true); expect(existsSync(lease.manifestPath)).toBe(true); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("partial os.write calls are completed by the real Python publication helper", () => { +test("partial os.write calls are completed by the real Python publication helper", async () => { const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python"); const helper = join(process.cwd(), "..", "harness", "tht", "runtime_config_lease_io.py"); const code = `import os, sys; sys.path.insert(0, ${JSON.stringify(dirname(helper))}); import runtime_config_lease_io as m; real=os.write; os.write=lambda fd,b: real(fd,b[:3]); m.write_all(1, b'partial-write-ok\\n')`; @@ -461,20 +461,20 @@ test("partial os.write calls are completed by the real Python publication helper expect(output).toBe("partial-write-ok\n"); }); -test("a clean existing equal publication is reconciled by a new factory", () => { +test("a clean existing equal publication is reconciled by a new factory", async () => { const f = fixture(); try { - const first = f.factory.acquireSession(f.snapshotPath); - const second = new WorkspaceRuntimeConfigLeaseFactory(f.factoryInput).acquireMaintenance({ snapshotPath: f.snapshotPath }); + const first = await f.factory.acquireSession(f.snapshotPath); + const second = await new WorkspaceRuntimeConfigLeaseFactory(f.factoryInput).acquireMaintenance({ snapshotPath: f.snapshotPath }); expect(readFileSync(second.path)).toEqual(readFileSync(first.path)); expect(readFileSync(second.manifestPath)).toEqual(readFileSync(first.manifestPath)); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test.each([["config"], ["manifest"]] as const)("rename failure is scoped to the %s branch and leaves no staging", (kind) => { +test.each([["config"], ["manifest"]] as const)("rename failure is scoped to the %s branch and leaves no staging", async (kind) => { const f = fixture({ THT_RUNTIME_CONFIG_RENAME_FAIL: kind }); try { - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/rename|failed/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/rename|failed/i); const runtime = join(f.root, "data", "sessions", workspace, "preprocessing"); for (const dir of ["runtime-config", "runtime-config-manifests"]) { if (existsSync(join(runtime, dir))) expect(readdirSync(join(runtime, dir)).some((name) => name.includes("staging"))).toBe(false); @@ -482,7 +482,7 @@ test.each([["config"], ["manifest"]] as const)("rename failure is scoped to the } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("snapshot manifest rejects an undeclared extra immutable file", () => { +test("snapshot manifest rejects an undeclared extra immutable file", async () => { const f = fixture(); try { const snapshot = JSON.parse(readFileSync(f.snapshotManifest, "utf8")); @@ -490,15 +490,15 @@ test("snapshot manifest rejects an undeclared extra immutable file", () => { snapshot.files["smuggled.txt"] = createHash("sha256").update("smuggled").digest("hex"); chmodSync(f.snapshotManifest, 0o600); writeFileSync(f.snapshotManifest, JSON.stringify(snapshot), { mode: 0o400 }); - expect(() => f.factory.acquireSession(f.snapshotPath)).toThrow(/integrity|snapshot|trusted/i); + await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/integrity|snapshot|trusted/i); } finally { rmSync(f.root, { recursive: true, force: true }); } }); -test("independent OS publishers converge when equal and elect one winner when unequal", () => { +test("independent OS publishers converge when equal and elect one winner when unequal", async () => { const f = fixture(); try { - const seed = f.factory.acquireSession(f.snapshotPath); + const seed = await f.factory.acquireSession(f.snapshotPath); const full = JSON.parse(readFileSync(seed.manifestPath, "utf8")); const base = Object.fromEntries(["workspace_id", "workspace_revision", "descriptor_git_blob", "descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_dwh_binding"] @@ -509,7 +509,7 @@ test("independent OS publishers converge when equal and elect one winner when un // Leave the workspace-owned destination directories in place, but remove // both durable leaves: the following OS processes race on a clean layout. rmSync(seed.path); rmSync(seed.manifestPath); - const payload = JSON.stringify({ action: "publish", data_root: f.factoryInput.dataRoot, + const payload = JSON.stringify({ protocol_version: 1, action: "publish", data_root: f.factoryInput.dataRoot, workspace_id: workspace, workspace_revision: full.workspace_revision, config_hex: Buffer.from(configBytes).toString("hex"), manifest_base: base }); const code = `import json,multiprocessing,sys @@ -538,10 +538,10 @@ print(json.dumps([q.exitcode for q in p]))` }); -test.each(["leaf", "ancestor"] as const)("actual harness rejects canonical %s swap", (kind) => { +test.each(["leaf", "ancestor"] as const)("actual harness rejects canonical %s swap", async (kind) => { const f = fixture(); try { - const lease = f.factory.acquireSession(f.snapshotPath); + const lease = await f.factory.acquireSession(f.snapshotPath); const harnessBin = join(process.cwd(), "..", "harness", ".venv", "bin", "tht"); const harnessCwd = join(process.cwd(), "..", "harness"); const env = { ...process.env, THT_RUNTIME_CONFIG_MANIFEST_SHA256: lease.manifestSha256 }; @@ -562,10 +562,10 @@ test.each(["leaf", "ancestor"] as const)("actual harness rejects canonical %s sw }); -test("actual harness rejects a workspace chain swap between config and manifest traversal", () => { +test("actual harness rejects a workspace chain swap between config and manifest traversal", async () => { const f = fixture(); try { - const lease = f.factory.acquireSession(f.snapshotPath); + const lease = await f.factory.acquireSession(f.snapshotPath); const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python"); const helper = join(process.cwd(), "..", "harness", "tht"); const workspaceRoot = join(f.factoryInput.dataRoot, "sessions", workspace); diff --git a/backend/test/workspace-runtime-handoff.test.ts b/backend/test/workspace-runtime-handoff.test.ts index b407c651..24c74c4e 100644 --- a/backend/test/workspace-runtime-handoff.test.ts +++ b/backend/test/workspace-runtime-handoff.test.ts @@ -169,8 +169,8 @@ test("real schema-v3 registry revision loads through ThtRunner and the harness c test("separate runtime leases hand off byte-identical revision Evidence configs accepted by tht", async () => { const f = await fixture(); const runner = runnerFor(f); - const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); - const second = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + const first = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + const second = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath); const expectedRoot = join( f.registryConfig.root, "snapshots", @@ -226,7 +226,7 @@ test("separate runtime leases hand off byte-identical revision Evidence configs test("real Evidence-content-only commit changes runtime identity and root with identical descriptor YAML", async () => { const f = await fixture(); const runner = runnerFor(f); - const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + const first = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath); const descriptorBefore = readFileSync(f.revision.snapshotPath, "utf8"); writeFileSync( join(f.source, "workspace-content", "psd-clinical", "evidence", "guide.md"), @@ -237,7 +237,7 @@ test("real Evidence-content-only commit changes runtime identity and root with i await git(f.source, ["push", "origin", "main"]); await f.registry.pull(); const current = (await f.registry.list())[0]; - const second = runner.acquireWorkspaceRuntime(current.snapshotPath); + const second = await runner.acquireWorkspaceRuntime(current.snapshotPath); try { expect(current.commit).not.toBe(f.revision.commit); @@ -281,7 +281,7 @@ test("signed HTTP Evidence resolves its file binding and config check never capt max_cache_bytes: 67890 `)); const runner = runnerFor(f); - const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + const lease = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath); try { const yaml = readFileSync(lease.path, "utf8"); expect(parse(yaml).evidence.sources).toEqual([{ @@ -325,7 +325,7 @@ test("static S3 Evidence resolves only configured secret-root file paths", async retain_published_generations: 7 `)); const runner = runnerFor(f); - const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + const lease = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath); try { const yaml = readFileSync(lease.path, "utf8"); expect(parse(yaml).evidence.sources).toEqual([{ diff --git a/harness/tests/test_runtime_config_lease_io.py b/harness/tests/test_runtime_config_lease_io.py new file mode 100644 index 00000000..8170f277 --- /dev/null +++ b/harness/tests/test_runtime_config_lease_io.py @@ -0,0 +1,84 @@ +"""Focused unit coverage for the privileged runtime publication seam.""" + +import os + +import pytest + +from tht import runtime_config_lease_io as lease_io + + +def _manifest() -> dict: + return { + "version": 1, "workspace_id": "abc", "workspace_revision": "a" * 40, + "descriptor_git_blob": "b" * 40, "descriptor_sha256": "c" * 64, + "descriptor_dev": "1", "descriptor_ino": "2", "config_sha256": "d" * 64, + "config_dwh_binding": { + "workspace_id": "abc", "config_fingerprint": "e", "input_fingerprint": "f" + }, + "config_dev": "1", "config_ino": "3", "config_size": "4", + "config_mode": "400", "config_uid": str(os.getuid()), "config_nlink": "1", + "directory_identities": [{"path": "/", "dev": "1", "ino": "1", "mode": "755", "uid": "0"}], + } + + +def test_strict_manifest_rejects_unknown_or_missing_fields(): + value = _manifest() + assert lease_io.strict_manifest(value) is value + with pytest.raises(RuntimeError): + lease_io.strict_manifest({**value, "unexpected": True}) + missing = dict(value) + del missing["config_sha256"] + with pytest.raises(RuntimeError): + lease_io.strict_manifest(missing) + + +def test_private_alias_is_darwin_only(monkeypatch): + monkeypatch.setattr(lease_io.sys, "platform", "linux") + assert lease_io._canonical_root("/tmp/runtime") == "/tmp/runtime" + assert lease_io._canonical_root("/var/lib/runtime") == "/var/lib/runtime" + monkeypatch.setattr(lease_io.sys, "platform", "darwin") + assert lease_io._canonical_root("/tmp/runtime") == "/private/tmp/runtime" + assert lease_io._canonical_root("/var/lib/runtime") == "/private/var/lib/runtime" + + +def test_read_all_enforces_bound(tmp_path): + path = tmp_path / "large" + path.write_bytes(b"0123456789") + fd = os.open(path, os.O_RDONLY) + try: + with pytest.raises(RuntimeError, match="too large"): + lease_io.read_all(fd, limit=4) + with pytest.raises(RuntimeError, match="too large"): + lease_io.read_all(fd, limit=0) + finally: + os.close(fd) + + +@pytest.mark.parametrize("failure_stage", ["config-parent", "manifest-parent"]) +def test_retry_reasserts_parent_durability_before_success(tmp_path, monkeypatch, failure_stage): + events: list[str] = [] + failed = False + + def fsync(fd: int, stage: str) -> None: + nonlocal failed + events.append(stage) + if stage == failure_stage and not failed: + failed = True + raise RuntimeError("injected fsync failure") + + monkeypatch.setattr(lease_io, "publication_fsync", fsync) + inp = { + "data_root": str(tmp_path / "data"), "workspace_id": "abc", + "workspace_revision": "a" * 40, "config_hex": b"config".hex(), + "manifest_base": { + "workspace_id": "abc", "workspace_revision": "a" * 40, + "descriptor_git_blob": "b" * 40, "descriptor_sha256": "c" * 64, + "descriptor_dev": "1", "descriptor_ino": "2", + "config_dwh_binding": {"workspace_id": "abc", "config_fingerprint": "e", "input_fingerprint": "f"}, + }, + } + with pytest.raises(RuntimeError, match="injected"): + lease_io.publish(inp) + events.clear() + lease_io.publish(inp) + assert events.index("config-parent") < events.index("manifest-parent") diff --git a/harness/tht/config.py b/harness/tht/config.py index 0124b067..b5c50d62 100644 --- a/harness/tht/config.py +++ b/harness/tht/config.py @@ -638,10 +638,11 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]: def _canonical_runtime_path(path: Path) -> Path: value = str(path) - if value == "/tmp" or value.startswith("/tmp/"): - return Path("/private" + value) - if value == "/var" or value.startswith("/var/"): - return Path("/private" + value) + if sys.platform == "darwin": + if value == "/tmp" or value.startswith("/tmp/"): + return Path("/private" + value) + if value == "/var" or value.startswith("/var/"): + return Path("/private" + value) return path diff --git a/harness/tht/runtime_config_lease_io.py b/harness/tht/runtime_config_lease_io.py index 9fc9a2b5..f7885008 100644 --- a/harness/tht/runtime_config_lease_io.py +++ b/harness/tht/runtime_config_lease_io.py @@ -12,6 +12,7 @@ import stat import subprocess import sys import tempfile +import time from pathlib import Path @@ -20,12 +21,13 @@ def fail(msg: str) -> None: def _canonical_root(root: str) -> str: - # Darwin exposes /tmp and /var as symlink aliases. The trusted path boundary - # records the real OS-owned prefix so a manifest never contains a symlink. - if root == "/tmp" or root.startswith("/tmp/"): - return "/private" + root - if root == "/var" or root.startswith("/var/"): - return "/private" + root + # Darwin exposes /tmp and /var as symlink aliases. Linux does not: rewriting + # these paths there would redirect valid installations to a different root. + if sys.platform == "darwin": + if root == "/tmp" or root.startswith("/tmp/"): + return "/private" + root + if root == "/var" or root.startswith("/var/"): + return "/private" + root return root @@ -180,7 +182,7 @@ def walk(root: str, comps: list[str], create: bool = True) -> int: # macOS exposes temporary directories through the conventional /var and # /tmp symlinks. Resolve only these OS-owned aliases; workspace-owned # ancestors remain component checked and are never realpath-followed. - if root == "/var" or root == "/tmp" or root.startswith(("/var/", "/tmp/")): + if sys.platform == "darwin" and (root == "/var" or root == "/tmp" or root.startswith(("/var/", "/tmp/"))): root = "/private" + root parts = [part for part in Path(root).parts if part not in ("", "/")] if any(part in (".", "..") or "/" in part for part in parts + comps): @@ -249,7 +251,13 @@ def publication_fsync(fd: int, stage: str) -> None: def read_all(fd: int, limit: int = 16 * 1024 * 1024) -> bytes: + if limit < 0: + fail("runtime config file is too large") os.lseek(fd, 0, os.SEEK_SET) + if limit == 0: + if os.read(fd, 1): + fail("runtime config file is too large") + return b"" chunks: list[bytes] = [] total = 0 while True: @@ -258,8 +266,12 @@ def read_all(fd: int, limit: int = 16 * 1024 * 1024) -> bytes: return b"".join(chunks) chunks.append(chunk) total += len(chunk) - if total > limit: - fail("runtime config file is too large") + if total >= limit: + # The bounded read above cannot observe an additional byte when it + # lands exactly on the ceiling; probe once before accepting it. + if os.read(fd, 1): + fail("runtime config file is too large") + return b"".join(chunks) def strict_manifest(value: object) -> dict: @@ -341,7 +353,17 @@ def publish(inp: dict) -> dict: ]) # The retained preprocessing directory is the single cross-process lock seam. # No pathname lock file is created in the workspace layout. - fcntl.flock(prep, fcntl.LOCK_EX) + deadline = time.monotonic() + 2.0 + while True: + try: + fcntl.flock(prep, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + # Never let a wedged publisher block its caller indefinitely. The + # Node boundary turns this stable conflict into a bounded failure. + fail("runtime config publication is busy") + time.sleep(0.01) try: name = f"{rev}.yaml" mname = f"{rev}.json" @@ -397,6 +419,10 @@ def publish(inp: dict) -> dict: finally: os.close(fd) publication_fsync(cfgdir, "config-parent") + # A prior invocation may have reported success after publishing the + # entry but before its parent fsync. Re-establish that durability + # boundary before making the manifest durable. + publication_fsync(cfgdir, "config-parent") # Identity is deliberately recorded after final no-replace publication. got = current(cfgdir, name, 0o400) assert got @@ -462,9 +488,12 @@ def publish(inp: dict) -> dict: except FileNotFoundError: pass publication_fsync(mandir, "manifest-parent") + # As with the config directory, retries must repair a boundary that + # failed after the no-replace publication on an earlier invocation. + publication_fsync(mandir, "manifest-parent") return { - "path": f"{root}/sessions/{wid}/preprocessing/runtime-config/{name}", - "manifestPath": f"{root}/sessions/{wid}/preprocessing/runtime-config-manifests/{mname}", + "path": f"{canonical}/sessions/{wid}/preprocessing/runtime-config/{name}", + "manifestPath": f"{canonical}/sessions/{wid}/preprocessing/runtime-config-manifests/{mname}", "manifest": mb.decode(), "manifest_sha256": hashlib.sha256(mb).hexdigest(), "dev": s.st_dev, @@ -570,6 +599,20 @@ def verified_snapshot(inp: dict) -> dict: git_env.update({"GIT_NO_REPLACE_OBJECTS": "1", "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull}) try: + # A 40-hex object name is not necessarily a commit (trees and blobs are + # valid Git objects and also accept the :path syntax). Require + # the raw object itself to be a commit, with replacement/config controls + # disabled, before reading any descriptor bytes. + object_type = subprocess.check_output( + ["git", "--no-replace-objects", "-C", repo, "cat-file", "-t", rev], + stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env, + ).strip() + resolved_commit = subprocess.check_output( + ["git", "--no-replace-objects", "-C", repo, "rev-parse", f"{rev}^{{commit}}"], + stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env, + ).strip() + if object_type != "commit" or resolved_commit != rev: + fail("workspace Git revision is not an exact commit") blob = subprocess.check_output( ["git", "--no-replace-objects", "-C", repo, "rev-parse", f"{rev}:workspaces/{wid}.yaml"], stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env, @@ -600,6 +643,11 @@ def verified_snapshot(inp: dict) -> dict: def binding(inp: dict) -> dict: + # Runtime handoff variables are capabilities, never helper input. Remove + # inherited values before load_config can inspect its environment. + for key in tuple(os.environ): + if key.startswith(("THT_RUNTIME_CONFIG_", "THT_CONFIG_")): + os.environ.pop(key, None) try: raw = bytes.fromhex(inp["config_hex"]) except (TypeError, ValueError): @@ -626,15 +674,22 @@ def binding(inp: dict) -> dict: def main() -> None: try: inp = json.load(sys.stdin) + if not isinstance(inp, dict) or inp.get("protocol_version") != 1: + fail("unsupported runtime config protocol") action = inp.get("action") + request_keys = { + "publish": {"protocol_version", "action", "data_root", "workspace_id", "workspace_revision", "config_hex", "manifest_base"}, + "verified-snapshot": {"protocol_version", "action", "snapshots_root", "repository_root", "workspace_revision", "workspace_id"}, + "binding": {"protocol_version", "action", "config_hex"}, + } + if action not in request_keys or set(inp) != request_keys[action]: + fail("invalid runtime config request") if action == "publish": result = publish(inp) elif action == "verified-snapshot": result = verified_snapshot(inp) - elif action == "binding": - result = binding(inp) else: - fail("unsupported runtime config action") + result = binding(inp) print(json.dumps(result)) except Exception as e: # noqa: BLE001 print(json.dumps({"error": str(e)}))