diff --git a/backend/src/app.ts b/backend/src/app.ts index b02d5126..df414f81 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -87,6 +87,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc sessionRoutes(app, { mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry, dwhPrecheck: config.dwhPrecheck, + legacyWorkspaceMode: config.legacyWorkspaceMode, }); sqlRoutes(app, { tht: tht as ThtRunner, getSettings }); metaRoutes(app, { harnessDir: config.harnessDir, listModels }); diff --git a/backend/src/config.ts b/backend/src/config.ts index 2d207ef1..4a663c25 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -23,6 +23,8 @@ export interface AppConfig { * pay the probe; the local dev launcher (run-stack.sh) opts in via THT_DWH_PRECHECK. */ dwhPrecheck: boolean; + /** Explicit compatibility mode for old loopback clients that send `workspace` in POST /sessions. */ + legacyWorkspaceMode: boolean; workspaceDiagnosticTimeoutMs: number; workspaceRegistry: WorkspaceRegistryConfig; } @@ -103,6 +105,13 @@ export function loadConfig(env: Record): AppConfig { if (sessionStorageMode === "local" && env.THOTH_PUBLIC_EXPOSURE === "true") { throw new Error("local session storage requires loopback-only deployment"); } + const legacyWorkspaceMode = env.THT_LEGACY_WORKSPACE_MODE; + if (legacyWorkspaceMode !== undefined && legacyWorkspaceMode !== "local") { + throw new Error("legacy workspace mode configuration is invalid"); + } + if (legacyWorkspaceMode === "local" && sessionStorageMode !== "local") { + throw new Error("legacy workspace mode requires local session storage"); + } const sessionStorage: AppConfig["sessionStorage"] = { mode: sessionStorageMode }; if (sessionStorageMode === "postgres") { const host = env.THT_SESSION_DB_HOST; @@ -203,6 +212,7 @@ export function loadConfig(env: Record): AppConfig { secretFiles, modelApiKeyFile, dwhPrecheck: env.THT_DWH_PRECHECK === "true" || env.THT_DWH_PRECHECK === "1", + legacyWorkspaceMode: legacyWorkspaceMode === "local", workspaceDiagnosticTimeoutMs: diagnosticTimeout(env.THT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS), workspaceRegistry, }; diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index d8cca1f2..a55973b1 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -32,6 +32,8 @@ export function sessionRoutes( workspaceRegistry: WorkspaceRegistry; /** Local-only guard: probe DWH reachability before creating a session (run-stack.sh). */ dwhPrecheck?: boolean; + /** Explicit loopback-only compatibility path for old clients that send `workspace`. */ + legacyWorkspaceMode?: boolean; }, ) { const lifecycleTails = new Map>(); @@ -267,9 +269,10 @@ export function sessionRoutes( try { s = await d.getSettings(principal); } catch { return storageFailure(reply); } const runner = runnerFor(principal); // `workspace` was the legacy request field before browser-local registry preferences. - // It opts a pre-registry caller into the existing installation-default config only; modern - // `workspaceId` and saved preferences must continue to resolve an immutable snapshot. - const legacyWorkspaceRequest = typeof b.workspace === "string" && b.workspace.length > 0; + // It is available only through the explicit loopback-only compatibility mode; every normal + // new session must resolve and pin an immutable registry revision. + const legacyWorkspaceRequest = d.legacyWorkspaceMode + && typeof b.workspace === "string" && b.workspace.length > 0; const requestedWorkspaceId = b.workspaceId ?? (legacyWorkspaceRequest ? undefined : s.workspace); if (!requestedWorkspaceId && !legacyWorkspaceRequest) { return reply.code(409).send({ diff --git a/backend/src/workspaces/migrate-legacy.ts b/backend/src/workspaces/migrate-legacy.ts index ef6f29ed..a0996a3e 100644 --- a/backend/src/workspaces/migrate-legacy.ts +++ b/backend/src/workspaces/migrate-legacy.ts @@ -175,19 +175,35 @@ export async function writeMigratedWorkspace(result: LegacyMigrationResult, repo return destination; } -function parseCliArguments(argv: readonly string[]): { input: string; output: string } { - if (argv.length !== 4 || argv[0] !== "--input" || argv[2] !== "--output") { - throw new Error("usage: migrate-legacy --input --output "); +function parseCliArguments(argv: readonly string[]): { input: string; output: string; id?: string } { + if (argv.length !== 4 && argv.length !== 6) { + throw new Error("usage: migrate-legacy --input --output [--id ]"); } - if (!isAbsolute(argv[1]) || !isAbsolute(argv[3])) { + const options = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index]; + const value = argv[index + 1]; + if ((flag !== "--input" && flag !== "--output" && flag !== "--id") || value === undefined || options.has(flag)) { + throw new Error("usage: migrate-legacy --input --output [--id ]"); + } + options.set(flag, value); + } + const input = options.get("--input"); + const output = options.get("--output"); + const id = options.get("--id"); + if (input === undefined || output === undefined) { + throw new Error("usage: migrate-legacy --input --output [--id ]"); + } + if (!isAbsolute(input) || !isAbsolute(output)) { throw new Error("migration input and output paths must be absolute"); } - return { input: argv[1], output: argv[3] }; + if (id !== undefined && !workspaceId.test(id)) throw new Error("legacy workspace ID is invalid"); + return { input, output, id }; } export async function main(argv = process.argv.slice(2)): Promise { - const { input, output } = parseCliArguments(argv); - const id = basename(input, ".yaml"); + const { input, output, id: explicitId } = parseCliArguments(argv); + const id = explicitId ?? basename(input, ".yaml"); const result = migrateLegacyWorkspace(await readFile(input, "utf8"), { id }); const destination = await writeMigratedWorkspace(result, output); process.stdout.write(`${destination}\n`); diff --git a/backend/test/config.test.ts b/backend/test/config.test.ts index 9b0dadec..e2a8dcaa 100644 --- a/backend/test/config.test.ts +++ b/backend/test/config.test.ts @@ -39,6 +39,24 @@ test("loadConfig keeps local development defaults", () => { expect(loadConfig({}).dataRoot).toBeUndefined(); }); +test("loadConfig enables the legacy workspace request only through explicit local mode", () => { + expect(loadConfig({ THT_LEGACY_WORKSPACE_MODE: "local" }).legacyWorkspaceMode).toBe(true); + + expect(() => loadConfig({ + THT_LEGACY_WORKSPACE_MODE: "local", + AUTH_MODE: "upstream", + THT_SESSION_STORAGE: "postgres", + THT_SESSION_DB_HOST: "db.internal", + THT_SESSION_DB_NAME: "thoth", + THT_SESSION_RUNTIME_USER: "thoth_sessions_app", + THT_SESSION_RUNTIME_PASSWORD_FILE: "/run/secrets/session_runtime_password", + THT_SESSION_DB_SSLMODE: "verify-full", + THT_SESSION_DB_SSLROOTCERT: "/run/secrets/session_ca.pem", + })).toThrow(/legacy workspace mode requires local session storage/); + expect(() => loadConfig({ THT_LEGACY_WORKSPACE_MODE: "true" })) + .toThrow(/legacy workspace mode configuration is invalid/); +}); + test("loadConfig rejects unauthenticated public exposure", () => { expect(() => loadConfig({ THOTH_PUBLIC_EXPOSURE: "true", diff --git a/backend/test/e2e-f1.test.ts b/backend/test/e2e-f1.test.ts index 80961419..cf3df606 100644 --- a/backend/test/e2e-f1.test.ts +++ b/backend/test/e2e-f1.test.ts @@ -29,7 +29,9 @@ async function readUntil( } test("loop F1: crea sessione → SSE riceve il widget → risponde → il modello riparte (follow-up)", async () => { - const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + const app = buildApp(loadConfig({ + THT_HARNESS_DIR: "../harness", THT_LEGACY_WORKSPACE_MODE: "local", + }), { thtRunner: { ollamaEnsure: async () => ({ ok: true }), searchPack: async () => {}, diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 068dbbe2..a69c3afd 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -173,6 +173,45 @@ test("new sessions are created through the authenticated principal, not a client expect(principal).toMatchObject({ issuer: "portal", subject: "alice" }); }); +test("new sessions reject the client legacy workspace field unless local legacy mode is explicit", async () => { + const sessionNew = vi.fn(async () => ({ id: "legacy" })); + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { sessionNew, searchPack: async () => {} } as any, + readiness: { ensure: async () => ({ ok: true }) } as any, + mgr: { get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }), configure: async () => {}, start: () => {} } as any, + getSettings: () => ({}) as any, + }); + + const response = await app.inject({ + method: "POST", url: "/sessions", payload: { question: "q", workspace: "legacy" }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ code: "workspace_revision_unavailable" }); + expect(sessionNew).not.toHaveBeenCalled(); +}); + +test("explicit local legacy mode permits the unpinned client workspace request", async () => { + const sessionNew = vi.fn(async () => ({ id: "legacy" })); + const app = buildApp(loadConfig({ + THT_HARNESS_DIR: "../harness", THT_LEGACY_WORKSPACE_MODE: "local", + }), { + thtRunner: { sessionNew, searchPack: async () => {} } as any, + readiness: { ensure: async () => ({ ok: true }) } as any, + mgr: { get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }), configure: async () => {}, start: () => {} } as any, + getSettings: () => ({}) as any, + }); + + const response = await app.inject({ + method: "POST", url: "/sessions", payload: { question: "q", workspace: "legacy" }, + }); + + expect(response.statusCode).toBe(200); + expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({ + workspaceConfigPath: undefined, workspaceId: undefined, workspaceRevision: undefined, + })); +}); + test("creates a session from the active immutable workspace revision", async () => { const sessionNew = vi.fn(async () => ({ id: "pinned" })); const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { diff --git a/backend/test/workspaces-migrate-legacy.test.ts b/backend/test/workspaces-migrate-legacy.test.ts index b79e8e6a..fe9c074f 100644 --- a/backend/test/workspaces-migrate-legacy.test.ts +++ b/backend/test/workspaces-migrate-legacy.test.ts @@ -1,9 +1,10 @@ -import { existsSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test } from "vitest"; import { + main, migrateLegacyWorkspace, writeMigratedWorkspace, } from "../src/workspaces/migrate-legacy.js"; @@ -48,13 +49,29 @@ test("writes versioned repository artifacts atomically without replacing a prior expect(existsSync(destination)).toBe(true); }); +test("CLI accepts an explicit valid ID when a legacy filename contains dots", async () => { + const root = await mkdtemp(join(tmpdir(), "thoth-workspace-migrate-")); + temporaryRoots.push(root); + const input = join(root, "psd.clinical.yaml"); + writeFileSync(input, readFixture("local.yaml")); + + await main(["--input", input, "--output", root, "--id", "psd-clinical"]); + + const destination = join(root, "workspaces", "psd-clinical.yaml"); + expect(parseWorkspaceYaml(readFileSync(destination, "utf8"))).toMatchObject({ + workspace: { id: "psd-clinical", schema_version: 1 }, + }); +}); + test("declares a durable isolated registry volume and only read-only Git credential mounts", () => { const compose = readFileSync(new URL("../../compose.yaml", import.meta.url), "utf8"); const development = readFileSync(new URL("../../docker-compose.dev.yml", import.meta.url), "utf8"); const dockerfile = readFileSync(new URL("../../docker/core.Dockerfile", import.meta.url), "utf8"); + const smoke = readFileSync(new URL("../../scripts/workspace-registry-smoke.sh", import.meta.url), "utf8"); for (const source of [compose, development]) { expect(source).toContain("THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry"); + expect(source).toContain("THT_WORKSPACE_GIT_REMOTE: ${THT_WORKSPACE_GIT_REMOTE:?set THT_WORKSPACE_GIT_REMOTE}"); expect(source).toContain("workspace-registry:/data/workspace-registry"); expect(source).toMatch(/workspace-registry-git-credentials:ro/); expect(source).toMatch(/workspace-registry-git-ca:ro/); @@ -62,4 +79,7 @@ test("declares a durable isolated registry volume and only read-only Git credent expect(source).toMatch(/workspace-registry-git-known-hosts:ro/); } expect(dockerfile).toMatch(/mkdir -p \/data\/workspace-registry && chown -R thoth:thoth \/data\/workspace-registry/); + expect(smoke).toContain('core_remote="/fixtures/offline.git"'); + expect(smoke).toContain('"degraded":true'); + expect(smoke).toContain('core_remote="/fixtures/remote.git"'); }); diff --git a/compose.yaml b/compose.yaml index 0f30225e..e9c600bb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -29,6 +29,7 @@ services: THT_CONFIG: /app/harness/workspaces/local.yaml # configPath di default per i route tht senza workspace esplicito THT_MODEL_API_KEY_FILE: /data/secrets/model_api_key # provider key per buildPiChildEnv (codex) THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry + THT_WORKSPACE_GIT_REMOTE: ${THT_WORKSPACE_GIT_REMOTE:?set THT_WORKSPACE_GIT_REMOTE} THT_WORKSPACE_GIT_BRANCH: ${THT_WORKSPACE_GIT_BRANCH:-main} THT_WORKSPACE_INSTALLATION_ID: ${THT_WORKSPACE_INSTALLATION_ID:-server} THT_WORKSPACE_GIT_AUTHOR_NAME: ${THT_WORKSPACE_GIT_AUTHOR_NAME:-Thoth Workspace Registry} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 65dce639..3bf0f38c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -24,6 +24,7 @@ services: THT_HOME: /data/local-home SETTINGS_FILE: /data/settings/settings.json THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry + THT_WORKSPACE_GIT_REMOTE: ${THT_WORKSPACE_GIT_REMOTE:?set THT_WORKSPACE_GIT_REMOTE} THT_WORKSPACE_GIT_BRANCH: ${THT_WORKSPACE_GIT_BRANCH:-main} THT_WORKSPACE_INSTALLATION_ID: ${THT_WORKSPACE_INSTALLATION_ID:-local} THT_WORKSPACE_GIT_AUTHOR_NAME: ${THT_WORKSPACE_GIT_AUTHOR_NAME:-Thoth Workspace Registry} diff --git a/scripts/workspace-registry-smoke.sh b/scripts/workspace-registry-smoke.sh index 606118b9..f086ae2f 100755 --- a/scripts/workspace-registry-smoke.sh +++ b/scripts/workspace-registry-smoke.sh @@ -10,6 +10,7 @@ project="thoth-workspace-registry-smoke-$$" remote="$tmp/remote.git" seed="$tmp/seed" branch="workspace-registry-smoke" +core_remote="/fixtures/remote.git" cleanup() { compose down --volumes --remove-orphans >/dev/null 2>&1 || true @@ -33,7 +34,7 @@ services: THT_BIN: /opt/venv/bin/tht SETTINGS_FILE: /tmp/settings.json THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry - THT_WORKSPACE_GIT_REMOTE: /fixtures/remote.git + THT_WORKSPACE_GIT_REMOTE: $core_remote THT_WORKSPACE_GIT_BRANCH: $branch THT_WORKSPACE_INSTALLATION_ID: smoke THT_WORKSPACE_SECRET_ROOTS: /run/secrets @@ -81,13 +82,16 @@ initial_status="$(compose exec -T core curl -fsS http://127.0.0.1:8787/workspace printf '%s' "$initial_status" | grep -Eq '"head":"[0-9a-f]{40}"' compose exec -T core test -f /data/workspace-registry/state/active.json -echo "== Recreate core and prove registry volume persistence ==" +echo "== Recreate offline and prove registry-volume fallback ==" +core_remote="/fixtures/offline.git" compose up -d --force-recreate wait_for_core recreated_status="$(compose exec -T core curl -fsS http://127.0.0.1:8787/workspace-registry/status)" initial_head="$(printf '%s' "$initial_status" | sed -n 's/.*"head":"\([0-9a-f]*\)".*/\1/p')" recreated_head="$(printf '%s' "$recreated_status" | sed -n 's/.*"head":"\([0-9a-f]*\)".*/\1/p')" test -n "$initial_head" && test "$initial_head" = "$recreated_head" +printf '%s' "$recreated_status" | grep -Fq '"degraded":true' +compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local' echo "== Pull a valid remote update ==" sed -i.bak 's/name: Local/name: Local Updated/' "$seed/workspaces/local.yaml" @@ -96,6 +100,9 @@ git -C "$seed" add workspaces/local.yaml git -C "$seed" -c user.name='Workspace Registry Smoke' -c user.email='workspace-registry-smoke@example.invalid' \ commit -m 'Update workspace registry smoke' >/dev/null git -C "$seed" push origin "HEAD:$branch" >/dev/null +core_remote="/fixtures/remote.git" +compose up -d --force-recreate +wait_for_core compose exec -T core curl -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull | grep -Eq '"head":"[0-9a-f]{40}"' compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local Updated'