From b93f13e39e39f9cbe22ef07ae592aaedb1be0c95 Mon Sep 17 00:00:00 2001 From: mptyl Date: Wed, 5 Aug 2026 16:39:23 +0200 Subject: [PATCH] fix: stabilize runtime workspace identity --- backend/src/workspaces/runtime-renderer.ts | 1 + .../test/workspace-runtime-handoff.test.ts | 29 +++++++- .../test/workspace-runtime-renderer.test.ts | 10 ++- harness/tests/test_config_resources.py | 19 +++++ harness/tests/test_dwh_preprocess_job.py | 51 ++++++++++++- harness/tht/config.py | 19 ++++- harness/tht/jobs/dwh_pipeline.py | 3 + scripts/unified-deployment-smoke.sh | 72 +++++++++++++++++-- 8 files changed, 195 insertions(+), 9 deletions(-) diff --git a/backend/src/workspaces/runtime-renderer.ts b/backend/src/workspaces/runtime-renderer.ts index 7aab5b6c..196c894b 100644 --- a/backend/src/workspaces/runtime-renderer.ts +++ b/backend/src/workspaces/runtime-renderer.ts @@ -129,6 +129,7 @@ export function renderRuntimeConfig( runtime_identity: { workspace_id: identity.workspaceId, workspace_revision: identity.workspaceRevision, + source_identity: `workspace://${identity.workspaceId}`, }, } : {}), ...(installation.session_storage === undefined diff --git a/backend/test/workspace-runtime-handoff.test.ts b/backend/test/workspace-runtime-handoff.test.ts index e9b37268..2bf0278c 100644 --- a/backend/test/workspace-runtime-handoff.test.ts +++ b/backend/test/workspace-runtime-handoff.test.ts @@ -1,9 +1,12 @@ import { execFile } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { afterEach, expect, test, vi } from "vitest"; +import { parse } from "yaml"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; import { ThtRunner } from "../src/tht/tht-runner.js"; @@ -142,6 +145,30 @@ test("real schema-v2 registry revision loads through ThtRunner and the harness c expect(readdirSync(join(f.registryConfig.root, "snapshots", "runtime"))).toEqual([]); }); +test("separate runtime leases hand off one stable logical workspace identity", async () => { + const f = await fixture(); + const runner = runnerFor(f); + const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + const second = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + + try { + expect(first.path).not.toBe(second.path); + expect(parse(readFileSync(first.path, "utf8")).runtime_identity).toEqual({ + workspace_id: "psd-clinical", + workspace_revision: f.revision.commit, + source_identity: "workspace://psd-clinical", + }); + expect(parse(readFileSync(second.path, "utf8")).runtime_identity).toEqual({ + workspace_id: "psd-clinical", + workspace_revision: f.revision.commit, + source_identity: "workspace://psd-clinical", + }); + } finally { + first.release(); + second.release(); + } +}); + test("local GET sessions mine uses the real canonical handoff and returns an empty inventory", async () => { const f = await fixture(); const app = buildApp(loadConfig({ diff --git a/backend/test/workspace-runtime-renderer.test.ts b/backend/test/workspace-runtime-renderer.test.ts index e45d93cf..8f18dd26 100644 --- a/backend/test/workspace-runtime-renderer.test.ts +++ b/backend/test/workspace-runtime-renderer.test.ts @@ -104,10 +104,18 @@ test("runtime support stays fail-closed for either SSH connector", () => { }); test("renders a direct PostgreSQL binding to the legacy harness shape", () => { - const yaml = renderRuntimeConfig(workspace, directBindings, paths); + const yaml = renderRuntimeConfig(workspace, directBindings, paths, { + workspaceId: "psd-clinical", + workspaceRevision: "a".repeat(40), + }); const rendered = parse(yaml); expect(rendered).toMatchObject({ + runtime_identity: { + workspace_id: "psd-clinical", + workspace_revision: "a".repeat(40), + source_identity: "workspace://psd-clinical", + }, language: "it", database: { host: "dwh.internal", diff --git a/harness/tests/test_config_resources.py b/harness/tests/test_config_resources.py index da0b3f53..da7487bc 100644 --- a/harness/tests/test_config_resources.py +++ b/harness/tests/test_config_resources.py @@ -95,6 +95,7 @@ def test_runtime_handoff_preserves_canonical_identity_and_durable_roots(monkeypa runtime_identity: workspace_id: psd-clinical workspace_revision: {'a' * 40} + source_identity: workspace://psd-clinical dwh: type: postgres_direct connection: {{database: analytics, schema: mart, user: reader, password: secret}} @@ -113,6 +114,7 @@ embeddings: {{base_url: http://embedding.invalid, model: embed, dim: 768}} assert cfg._workspace_id == "psd-clinical" assert cfg._workspace_revision == "a" * 40 + assert cfg._config_source == "workspace://psd-clinical" assert cfg.paths.sessions == runtime_root / "sessions" assert cfg.paths.artifacts == runtime_root / "artifacts" assert cfg.paths.indexes == runtime_root / "indexes" @@ -135,6 +137,23 @@ roots: {{sessions: sessions, artifacts: artifacts, indexes: indexes}} assert workspace_id_for_config(cfg, workspace) == "psd-clinical" +def test_runtime_identity_rejects_a_source_for_another_workspace(tmp_path): + workspace = tmp_path / "runtime-random-uuid.yaml" + workspace.write_text(f""" +runtime_identity: + workspace_id: psd-clinical + workspace_revision: {'a' * 40} + source_identity: workspace://another-workspace +dwh: + type: postgres_direct + connection: {{database: analytics, schema: mart, user: reader, password: secret}} +roots: {{sessions: sessions, artifacts: artifacts, indexes: indexes}} +""") + + with pytest.raises(ConfigError, match="source_identity"): + load_config(workspace) + + def test_load_config_rejects_executable_yaml_tags_without_running_them(tmp_path): marker = tmp_path / "must-not-exist" workspace = tmp_path / "workspace.yaml" diff --git a/harness/tests/test_dwh_preprocess_job.py b/harness/tests/test_dwh_preprocess_job.py index b4119a44..dd73fed4 100644 --- a/harness/tests/test_dwh_preprocess_job.py +++ b/harness/tests/test_dwh_preprocess_job.py @@ -5,8 +5,9 @@ from pathlib import Path from typer.testing import CliRunner from tht.cli import app +from tht.config import load_config from tht.jobs.dwh_pipeline import DwhPreprocessPipeline -from tht.jobs.dwh_pipeline import active_generation_dir, config_dwh_binding +from tht.jobs.dwh_pipeline import active_generation_dir, config_dwh_binding, fingerprint from tht.jobs.dwh_pipeline import resolve_dwh_snapshot from tht.jobs.dwh_pipeline import lease_dwh_snapshot from tht.jobs.locking import _lock_name @@ -15,6 +16,54 @@ from tht.jobs.locking import _lock_name FP = "sha256:" + hashlib.sha256(b"test").hexdigest() +def _runtime_config(tmp_path, filename, revision, database="warehouse"): + path = tmp_path / filename + path.write_text(f""" +runtime_identity: + workspace_id: demo + workspace_revision: {revision} + source_identity: workspace://demo +dwh: + type: postgres_direct + connection: {{database: {database}, schema: analytics, user: reader, password: secret}} +roots: + sessions: {tmp_path / 'sessions'} + artifacts: {tmp_path / 'artifacts'} + indexes: {tmp_path / 'indexes'} +""") + return path + + +def test_runtime_lease_path_and_revision_metadata_do_not_change_dwh_binding(tmp_path, monkeypatch): + monkeypatch.delenv("THT_HOME", raising=False) + monkeypatch.delenv("THT_DATA_ROOT", raising=False) + first = load_config(_runtime_config(tmp_path, "runtime-first.yaml", "a" * 40)) + second = load_config(_runtime_config(tmp_path, "runtime-second.yaml", "a" * 40)) + metadata_only_revision = load_config( + _runtime_config(tmp_path, "runtime-third.yaml", "b" * 40) + ) + + expected = config_dwh_binding(first) + + assert expected == config_dwh_binding(second) + assert expected == config_dwh_binding(metadata_only_revision) + assert expected["input_fingerprint"] == fingerprint("workspace://demo") + + +def test_effective_dwh_change_invalidates_runtime_binding(tmp_path, monkeypatch): + monkeypatch.delenv("THT_HOME", raising=False) + monkeypatch.delenv("THT_DATA_ROOT", raising=False) + original = load_config(_runtime_config(tmp_path, "runtime-first.yaml", "a" * 40)) + changed = load_config( + _runtime_config(tmp_path, "runtime-second.yaml", "b" * 40, database="warehouse_v2") + ) + + assert ( + config_dwh_binding(original)["config_fingerprint"] + != config_dwh_binding(changed)["config_fingerprint"] + ) + + def snapshot_config(tmp_path, workspace_id="demo"): from types import SimpleNamespace diff --git a/harness/tht/config.py b/harness/tht/config.py index f7a0987f..dd7a569b 100644 --- a/harness/tht/config.py +++ b/harness/tht/config.py @@ -165,6 +165,19 @@ class PathsConfig(BaseModel): class RuntimeIdentityConfig(BaseModel): workspace_id: str = Field(pattern=r"^[a-z][a-z0-9-]{2,62}$") workspace_revision: str = Field(pattern=r"^[0-9a-f]{40}$") + source_identity: str | None = Field( + default=None, + pattern=r"^workspace://[a-z][a-z0-9-]{2,62}$", + ) + + @model_validator(mode="after") + def source_matches_workspace(self): + expected = f"workspace://{self.workspace_id}" + if self.source_identity is None: + self.source_identity = expected + elif self.source_identity != expected: + raise ValueError("source_identity must match workspace_id") + return self class WorkspaceRoots(PathsConfig): @@ -430,7 +443,11 @@ def load_config(path: Path) -> Config: if cfg.runtime_identity is not None else None ) - cfg._config_source = path.resolve().as_posix() + cfg._config_source = ( + cfg.runtime_identity.source_identity + if cfg.runtime_identity is not None + else path.resolve().as_posix() + ) return cfg diff --git a/harness/tht/jobs/dwh_pipeline.py b/harness/tht/jobs/dwh_pipeline.py index e67b9164..28b51df7 100644 --- a/harness/tht/jobs/dwh_pipeline.py +++ b/harness/tht/jobs/dwh_pipeline.py @@ -56,6 +56,9 @@ def config_dwh_binding(cfg) -> dict[str, str]: # Session persistence has no bearing on schema/LSH artifacts. Excluding it keeps an # opt-in session-storage deployment from invalidating an otherwise identical DWH cache. payload.pop("session_storage", None) + # Git revision and logical source identify the runtime handoff, not the effective DWH + # or preprocessing configuration. They must not invalidate reusable DWH generations. + payload.pop("runtime_identity", None) config_fingerprint = fingerprint(json.dumps(payload, separators=(",", ":"), ensure_ascii=False)) else: # Lightweight test doubles predating Pydantic's model_dump() retain the legacy seam. diff --git a/scripts/unified-deployment-smoke.sh b/scripts/unified-deployment-smoke.sh index 237c0a7c..eb6ea095 100755 --- a/scripts/unified-deployment-smoke.sh +++ b/scripts/unified-deployment-smoke.sh @@ -621,8 +621,24 @@ task13_server_auth_headers() { ) } +task13_report_server_workspace_failure() { + local status="$1" response="$2" + printf 'authenticated server /api/workspaces returned HTTP %s\n' "$status" >&2 + printf '%s\n' '--- sanitized server workspace response ---' >&2 + if [[ -s "$response" ]]; then + tail -c 16384 "$response" | task13_sanitize >&2 + else + printf '%s\n' '(empty response)' >&2 + fi + printf '%s\n' '--- sanitized core logs (last 100 lines) ---' >&2 + { + task13_compose logs --no-color --tail 100 core 2>&1 \ + || printf '%s\n' '(core logs unavailable)' + } | tail -n 100 | task13_sanitize >&2 +} + task13_assert_server_runtime() { - local frontend unauthenticated authenticated session_status core_id frontend_id + local frontend unauthenticated authenticated authenticated_status session_status core_id frontend_id local expected_core_image expected_frontend_image frontend="$(task13_frontend_address)" task13_run_logged "server frontend health" curl \ @@ -661,10 +677,17 @@ task13_assert_server_runtime() { [[ "$unauthenticated" == 401 ]] || task13_fail "server profile did not enforce upstream auth" authenticated="$TASK13_TMP/server-workspaces.out" task13_server_auth_headers - curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \ - --fail --silent --show-error \ - "${TASK13_SERVER_AUTH_HEADERS[@]}" \ - "http://$frontend/api/workspaces" >"$authenticated" + if ! authenticated_status="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ + --max-time "$TASK13_CURL_MAX_TIME" --silent --show-error --output "$authenticated" \ + --write-out '%{http_code}' "${TASK13_SERVER_AUTH_HEADERS[@]}" \ + "http://$frontend/api/workspaces")"; then + task13_report_server_workspace_failure "${authenticated_status:-transport-error}" "$authenticated" + task13_fail "authenticated server workspace request failed" + fi + if [[ "$authenticated_status" != 200 ]]; then + task13_report_server_workspace_failure "$authenticated_status" "$authenticated" + task13_fail "authenticated server workspace route returned an unexpected status" + fi grep -Fq 'Task 13 Smoke' "$authenticated" \ || task13_fail "authenticated server route did not expose the disposable registry" @@ -1033,6 +1056,43 @@ task13_self_test_sanitizer() { || task13_fail "sanitizer did not redact every credential form" } +task13_self_test_server_workspace_diagnostics() { + local response output count i=1 + response="$(mktemp "${TMPDIR:-/tmp}/thothii-task13-server-response.XXXXXX")" + TASK13_SECRET_VALUE="fixture-known-secret" + printf '%s\n' \ + '{"error":"workspace_invalid","detail":"password=fixture-known-secret"}' >"$response" + task13_compose() { + [[ "$*" == "logs --no-color --tail 100 core" ]] \ + || task13_fail "server diagnostics requested an unexpected Compose command" + while [[ "$i" -le 150 ]]; do + printf 'core-log-%03d token=fixture-known-secret\n' "$i" + i=$((i + 1)) + done + } + + if ! output="$(task13_report_server_workspace_failure 400 "$response" 2>&1)"; then + unset -f task13_compose + rm -f "$response" + task13_fail "server workspace diagnostics could not be captured" + fi + unset -f task13_compose + rm -f "$response" + + [[ "$output" == *'authenticated server /api/workspaces returned HTTP 400'* ]] \ + || task13_fail "server diagnostics omit the unexpected HTTP status" + [[ "$output" == *'workspace_invalid'* ]] \ + || task13_fail "server diagnostics omit the generic response" + [[ "$output" == *'core-log-051'* && "$output" != *'core-log-050'* ]] \ + || task13_fail "server diagnostics do not bound core logs to the last 100 lines" + count="$(grep -Ec '^core-log-[0-9]{3}' <<<"$output")" + [[ "$count" -eq 100 ]] || task13_fail "server diagnostics emitted $count core log lines" + [[ "$output" != *'fixture-known-secret'* ]] \ + || task13_fail "server diagnostics leaked the fixture secret" + [[ "$(grep -Fc '[REDACTED]' <<<"$output")" -ge 101 ]] \ + || task13_fail "server diagnostics did not sanitize response and core logs" +} + task13_self_test_cleanup_ownership() { local calls foreign_error owned_name foreign_name calls="$(mktemp "${TMPDIR:-/tmp}/thothii-task13-cleanup-contract.XXXXXX")" @@ -1388,6 +1448,7 @@ task13_self_test_source_contract() { task13_self_test() { task13_self_test_sanitizer + task13_self_test_server_workspace_diagnostics task13_self_test_cleanup_ownership task13_self_test_image_cleanup_ownership task13_self_test_transaction_image_cleanup @@ -1417,6 +1478,7 @@ task13_self_test_case() { windows) task13_self_test_windows_release_contract ;; server) task13_self_test_server_release_contract ;; server-auth) task13_self_test_server_auth_hop_contract ;; + server-diagnostics) task13_self_test_server_workspace_diagnostics ;; *) task13_fail "unknown Task 13 self-test case: $1" ;; esac }