diff --git a/backend/src/routes/workspaces.ts b/backend/src/routes/workspaces.ts index 3ce75baa..f08e2559 100644 --- a/backend/src/routes/workspaces.ts +++ b/backend/src/routes/workspaces.ts @@ -56,6 +56,8 @@ const bundleManifest = z.object({ }).strict(), }).strict(); +// Public P1 bundles contain only the descriptor and derived docs. Evidence file bytes remain +// revision-owned Git content for the later P6 materialization boundary. const BUNDLE_FILES = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"] as const; type BundleFile = (typeof BUNDLE_FILES)[number]; @@ -298,6 +300,7 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps) displayName: workspace.workspace.name, description: workspace.workspace.description, language: workspace.workspace.language, + workspace, revision, }; })); diff --git a/backend/src/workspaces/contracts.ts b/backend/src/workspaces/contracts.ts index 9722a2bc..8b25998c 100644 --- a/backend/src/workspaces/contracts.ts +++ b/backend/src/workspaces/contracts.ts @@ -177,6 +177,72 @@ function localizedIntroduction(workspace: WorkspaceDescriptor): string { : `Installation setup for ${workspace.workspace.name}. Configure only the bindings supported by this installation.`; } +function evidenceDocumentation( + workspace: WorkspaceDescriptor, + variables: readonly InstallationVariable[], +): string[] { + if (!("evidence" in workspace) || workspace.evidence === undefined) return []; + const { source, policy } = workspace.evidence; + const common = [ + "## Evidence source", + "", + `- Type: \`${source.type}\``, + ]; + let details: string[]; + if (source.type === "filesystem") { + details = [ + `- URI: \`${source.uri}\``, + `- Patterns: ${source.patterns.map((pattern) => `\`${pattern}\``).join(", ")}`, + `- Maximum source bytes: \`${source.max_bytes}\``, + "- Ownership: the descriptor and its Evidence tree are owned by the same Git revision.", + "- Materialization: P6 materializes that revision-pinned tree and verifies real containment, including symlink safety.", + "- Export boundary: the browser/API ZIP does not include Evidence file bytes.", + ]; + } else if (source.type === "http") { + details = [ + "- URIs:", + ...source.uris.map((uri) => ` - \`${uri}\``), + `- Authentication: \`${source.authentication}\`. ${source.authentication === "none" + ? "No credential file is required." + : "Provide the signed URL file through the installation file variable listed below."}`, + `- Connect timeout (ms): \`${source.connect_timeout_ms}\``, + `- Read timeout (ms): \`${source.read_timeout_ms}\``, + `- Maximum source bytes: \`${source.max_bytes}\``, + `- Maximum redirects: \`${source.max_redirects}\``, + `- Private hosts allowed: \`${source.allow_private_hosts}\``, + `- Maximum cache bytes: \`${source.max_cache_bytes}\``, + ]; + } else { + details = [ + `- URI: \`${source.uri}\``, + ...(source.endpoint_url === undefined ? [] : [`- Endpoint URL: \`${source.endpoint_url}\``]), + ...(source.region === undefined ? [] : [`- Region: \`${source.region}\``]), + `- Credentials: \`${source.credentials}\`. ${source.credentials === "ambient" + ? "Use ambient credentials; no Evidence credential file is required." + : "Provide credentials through the installation file variables listed below."}`, + `- Trusted endpoint: \`${source.trusted_endpoint}\``, + `- Private endpoint allowed: \`${source.allow_private_endpoint}\``, + `- Insecure endpoint allowed: \`${source.allow_insecure_endpoint}\``, + `- Maximum source bytes: \`${source.max_bytes}\``, + `- Maximum objects: \`${source.max_objects}\``, + `- Maximum pages: \`${source.max_pages}\``, + `- Page size: \`${source.page_size}\``, + ]; + } + const evidenceVariables = variables.filter(({ role }) => role === "EVIDENCE"); + return [ + ...common, + ...details, + `- Maximum chunk characters: \`${policy.max_chunk_chars}\``, + `- Retained published generations: \`${policy.retain_published_generations}\``, + ...(evidenceVariables.length === 0 ? [] : [ + "- Required installation file variables:", + ...evidenceVariables.map(({ name }) => ` - \`${name}\``), + ]), + "", + ]; +} + export function renderWorkspaceDocs(workspace: WorkspaceDescriptor): { envExample: string; markdown: string } { const descriptor = validateWorkspaceDescriptor(workspace); const contract = buildInstallationContract(descriptor); @@ -213,6 +279,7 @@ export function renderWorkspaceDocs(workspace: WorkspaceDescriptor): { envExampl )), "", ]), + ...evidenceDocumentation(descriptor, contract.variables), ].join("\n"); return { envExample, markdown }; diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index 913785b7..3c18c011 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -790,6 +790,8 @@ export class WorkspaceRegistry { } private expectedSnapshotFiles(state: ActiveState): string[] { + // P1 snapshots only descriptors and derived public docs. P6 owns revision-pinned + // workspace-content materialization and its recursive containment checks. return state.revisions.flatMap((revision) => revision.state === "operational" ? [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`] : [`${revision.id}.yaml`]); diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index 083ae35d..41938a9b 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -1,14 +1,23 @@ +import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { once } from "node:events"; import { Buffer } from "node:buffer"; -import { expect, test, vi } from "vitest"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test, vi } from "vitest"; +import yauzl from "yauzl"; import yazl from "yazl"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js"; import { WorkspaceRegistryError } from "../src/workspaces/git-repository.js"; -import type { WorkspaceRegistry, WorkspaceRevision } from "../src/workspaces/registry.js"; -import { renderWorkspaceDocs, serializeWorkspaceYaml, type CanonicalWorkspace, type WorkspaceV2 } from "../src/workspaces/schema.js"; +import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js"; +import { + parseWorkspaceYaml, renderWorkspaceDocs, serializeWorkspaceYaml, validateCanonicalWorkspace, + type CanonicalWorkspace, type WorkspaceV2, +} from "../src/workspaces/schema.js"; const workspace: CanonicalWorkspace = { workspace: { @@ -131,7 +140,7 @@ function appFor(registry: RegistryFake, diagnose = vi.fn(async () => ({ activata } as any); } -function sha256(value: string): string { +function sha256(value: string | Buffer): string { return createHash("sha256").update(value).digest("hex"); } @@ -419,3 +428,362 @@ test("imports an exact generated bundle only as a browser draft", async () => { expect(res.json()).toMatchObject({ draft: { workspace } }); expect(registry.publish).not.toHaveBeenCalled(); }); + + +const runFile = promisify(execFile); +const realRouteRoots: string[] = []; + +interface RealRouteFixture { + root: string; + remote: string; + author: string; + registryRoot: string; + initialCommit: string; + app: ReturnType; + registry: WorkspaceRegistry; +} + +const EVIDENCE_FILE_BYTES = "PUBLIC-EVIDENCE-FILE-BYTES-NOT-FOR-ZIP\n"; +const SECRET_CANARY = "CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"; + +function withEvidence( + source: Partial & { type: "filesystem" | "http" | "s3" }, + changes: Partial = {}, +): CanonicalWorkspace { + return validateCanonicalWorkspace({ + ...workspace, + evidence: { source, policy: changes }, + }); +} + +const filesystemEvidenceWorkspace = withEvidence({ + type: "filesystem", uri: "workspace-content/psd-clinical/evidence", +}); +const httpEvidenceWorkspace = withEvidence({ + type: "http", + uris: ["https://evidence.example.test/guide.md"], + authentication: "signed_urls_file", +}); + +async function realGit(cwd: string, args: string[]): Promise { + return (await runFile("git", args, { cwd })).stdout.trim(); +} + +async function createRealRouteFixture( + initialWorkspace: CanonicalWorkspace = filesystemEvidenceWorkspace, +): Promise { + const root = mkdtempSync(join(tmpdir(), "thoth-real-workspace-route-")); + realRouteRoots.push(root); + const remote = join(root, "remote.git"); + const author = join(root, "author"); + const registryRoot = join(root, "registry"); + await realGit(root, ["init", "--bare", "--initial-branch=main", remote]); + mkdirSync(author); + await realGit(author, ["init", "--initial-branch=main"]); + await realGit(author, ["config", "user.name", "Workspace Route Test"]); + await realGit(author, ["config", "user.email", "workspace-route@example.invalid"]); + mkdirSync(join(author, "workspaces")); + writeFileSync(join(author, "workspaces", "psd-clinical.yaml"), serializeWorkspaceYaml(initialWorkspace)); + if (initialWorkspace.evidence?.source.type === "filesystem") { + mkdirSync(join(author, "workspace-content", "psd-clinical", "evidence"), { recursive: true }); + writeFileSync( + join(author, "workspace-content", "psd-clinical", "evidence", "guide.md"), + EVIDENCE_FILE_BYTES, + ); + } + await realGit(author, ["add", "."]); + await realGit(author, ["commit", "-m", "Initial Evidence workspace"]); + await realGit(author, ["remote", "add", "origin", remote]); + await realGit(author, ["push", "origin", "main"]); + const initialCommit = await realGit(author, ["rev-parse", "HEAD"]); + const config = loadConfig({ + THT_HARNESS_DIR: "/missing-harness", + THT_WORKSPACE_REGISTRY_ROOT: registryRoot, + THT_WORKSPACE_GIT_REMOTE: remote, + THT_WORKSPACE_GIT_AUTHOR_NAME: "Workspace Route Publisher", + THT_WORKSPACE_GIT_AUTHOR_EMAIL: "workspace-route-publisher@example.invalid", + }); + const registry = new WorkspaceRegistry(config.workspaceRegistry); + const app = buildApp(config, { + thtRunner: {} as any, + workspaceRegistry: registry, + workspaceDiagnoser: vi.fn(async () => ({ activatable: true, diagnostics: [] })), + }); + return { root, remote, author, registryRoot, initialCommit, app, registry }; +} + +async function extractZip(source: Buffer): Promise> { + return await new Promise((resolve, reject) => { + yauzl.fromBuffer(source, { lazyEntries: true, strictFileNames: true }, (error, archive) => { + if (error || !archive) return reject(error ?? new Error("archive unavailable")); + const files: Record = {}; + archive.on("error", reject); + archive.on("entry", (entry) => { + if (entry.fileName.startsWith("/") || entry.fileName.includes("..") || entry.fileName.includes("\\")) { + archive.close(); + reject(new Error("unsafe exported path")); + return; + } + archive.openReadStream(entry, (streamError, stream) => { + if (streamError || !stream) return reject(streamError ?? new Error("entry unavailable")); + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("error", reject); + stream.on("end", () => { + files[entry.fileName] = Buffer.concat(chunks); + archive.readEntry(); + }); + }); + }); + archive.on("end", () => resolve(files)); + archive.readEntry(); + }); + }); +} + +afterEach(() => { + realRouteRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +test.each([ + { + source: { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }, + expectedVariables: [], + }, + { + source: { + type: "http", uris: ["https://evidence.example.test/guide.md"], + authentication: "signed_urls_file", + }, + expectedVariables: ["THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE"], + }, + { + source: { type: "s3", uri: "s3://clinical-evidence/published/", credentials: "static_files" }, + expectedVariables: [ + "THT_WS_PSD_CLINICAL_EVIDENCE_ACCESS_KEY_FILE", + "THT_WS_PSD_CLINICAL_EVIDENCE_SECRET_KEY_FILE", + "THT_WS_PSD_CLINICAL_EVIDENCE_SESSION_TOKEN_FILE", + ], + }, +])("real validate route canonicalizes $source.type Evidence and returns only its file contract", async ({ + source, expectedVariables, +}) => { + const fixture = await createRealRouteFixture(); + const response = await fixture.app.inject({ + method: "POST", url: "/workspaces/validate", + payload: { workspace: { ...workspace, evidence: { source } } }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.workspace.evidence.policy).toEqual({ + max_chunk_chars: 4_000, retain_published_generations: 3, + }); + expect(body.workspace.evidence.source.max_bytes).toBe(10 * 1024 * 1024); + expect(body.contract.variables.filter(({ role }: { role: string }) => role === "EVIDENCE") + .map(({ name }: { name: string }) => name)).toEqual(expectedVariables); +}); + +test("real publish create/update, pull, list, and read preserve a complete Evidence descriptor", async () => { + const fixture = await createRealRouteFixture(httpEvidenceWorkspace); + const status = await fixture.app.inject({ method: "GET", url: "/workspace-registry/status" }); + const created = validateCanonicalWorkspace({ + ...httpEvidenceWorkspace, + workspace: { ...httpEvidenceWorkspace.workspace, id: "research-clinical", name: "Research Clinical" }, + semantic_index: { + ...httpEvidenceWorkspace.semantic_index, + vector_store: { ...httpEvidenceWorkspace.semantic_index.vector_store, collection: "research-clinical" }, + }, + }); + const create = await fixture.app.inject({ + method: "POST", url: "/workspaces/publish", + payload: { action: "create", workspace: created, baseCommit: status.json().head }, + }); + const createdRevision = create.json().revision as WorkspaceRevision; + const updated = validateCanonicalWorkspace({ + ...created, + evidence: { + ...created.evidence, + policy: { max_chunk_chars: 8_192, retain_published_generations: 7 }, + }, + }); + + const update = await fixture.app.inject({ + method: "POST", url: "/workspaces/publish", + payload: { + action: "update", workspace: updated, + baseCommit: createdRevision.commit, baseBlob: createdRevision.blob, + }, + }); + const pull = await fixture.app.inject({ method: "POST", url: "/workspace-registry/pull" }); + const list = await fixture.app.inject({ method: "GET", url: "/workspaces" }); + const read = await fixture.app.inject({ method: "GET", url: "/workspaces/research-clinical" }); + + expect(status.statusCode).toBe(200); + expect(create.statusCode).toBe(200); + expect(update.statusCode).toBe(200); + expect(pull.statusCode).toBe(200); + expect(list.statusCode).toBe(200); + expect(list.json().find(({ id }: { id: string }) => id === "research-clinical").workspace).toEqual(updated); + expect(read.statusCode).toBe(200); + expect(read.json().workspace).toEqual(updated); +}); + +test("real route reports a safe field for an Evidence-only concurrent edit", async () => { + const fixture = await createRealRouteFixture(httpEvidenceWorkspace); + await fixture.registry.bootstrap(); + const base = await fixture.registry.read("psd-clinical"); + const remote = withEvidence( + { ...httpEvidenceWorkspace.evidence!.source }, + { max_chunk_chars: 9_000, retain_published_generations: 3 }, + ); + writeFileSync(join(fixture.author, "workspaces", "psd-clinical.yaml"), serializeWorkspaceYaml(remote)); + await realGit(fixture.author, ["add", "workspaces/psd-clinical.yaml"]); + await realGit(fixture.author, ["commit", "-m", "Change Evidence policy only"]); + await realGit(fixture.author, ["push", "origin", "main"]); + const local = withEvidence( + { ...httpEvidenceWorkspace.evidence!.source }, + { max_chunk_chars: 4_000, retain_published_generations: 8 }, + ); + + const response = await fixture.app.inject({ + method: "POST", url: "/workspaces/publish", + payload: { + action: "update", workspace: local, + baseCommit: base.revision.commit, baseBlob: base.revision.blob, + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + code: "workspace_conflict", fields: ["evidence.policy.max_chunk_chars"], + }); + expect(response.body).not.toContain(SECRET_CANARY); +}); + +test.each([ + ["absolute", "/tmp/CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"], + ["traversal", "workspace-content/psd-clinical/../CANARY-EVIDENCE-ROUTE-SECRET-DO-NOT-LEAK"], + ["cross-workspace", "workspace-content/research/evidence"], +])("real publish rejects %s filesystem Evidence paths without changing HEAD", async (_label, uri) => { + const fixture = await createRealRouteFixture(); + await fixture.registry.bootstrap(); + const base = await fixture.registry.read("psd-clinical"); + const invalid = structuredClone(filesystemEvidenceWorkspace) as any; + invalid.evidence.source.uri = uri; + + const response = await fixture.app.inject({ + method: "POST", url: "/workspaces/publish", + payload: { action: "update", workspace: invalid, baseCommit: base.revision.commit, baseBlob: base.revision.blob }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); + expect(response.body).not.toContain(SECRET_CANARY); + expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"])) + .toBe(fixture.initialCommit); +}); + +test.each([ + { + label: "credential-bearing HTTP URI", + source: { type: "http", uris: [`https://user:${SECRET_CANARY}@evidence.example.test/guide.md`] }, + }, + { + label: "unsupported HTTP protocol", + source: { type: "http", uris: [`ftp://evidence.example.test/${SECRET_CANARY}`] }, + }, + { + label: "inline S3 credential field", + source: { type: "s3", uri: "s3://clinical-evidence/published/", access_key: SECRET_CANARY }, + }, +])("real validate rejects $label without echoing it", async ({ source }) => { + const fixture = await createRealRouteFixture(); + const response = await fixture.app.inject({ + method: "POST", url: "/workspaces/validate", + payload: { workspace: { ...workspace, evidence: { source } } }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); + expect(response.body).not.toContain(SECRET_CANARY); + expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"])) + .toBe(fixture.initialCommit); +}); + +test("real publish and pull fail safely when the contextual Evidence Git tree is missing", async () => { + const fixture = await createRealRouteFixture(); + await fixture.registry.bootstrap(); + const current = await fixture.registry.read("psd-clinical"); + const missing = validateCanonicalWorkspace({ + ...workspace, + workspace: { ...workspace.workspace, id: "missing-evidence", name: "Missing Evidence" }, + semantic_index: { + ...workspace.semantic_index, + vector_store: { ...workspace.semantic_index.vector_store, collection: "missing-evidence" }, + }, + evidence: { source: { type: "filesystem", uri: "workspace-content/missing-evidence/evidence" } }, + }); + const publish = await fixture.app.inject({ + method: "POST", url: "/workspaces/publish", + payload: { action: "create", workspace: missing, baseCommit: current.revision.commit }, + }); + expect(publish.statusCode).toBe(400); + expect(publish.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); + expect(await realGit(fixture.author, ["--git-dir", fixture.remote, "rev-parse", "HEAD"])) + .toBe(fixture.initialCommit); + + rmSync(join(fixture.author, "workspace-content", "psd-clinical", "evidence"), { recursive: true }); + await realGit(fixture.author, ["add", "-A"]); + await realGit(fixture.author, ["commit", "-m", "Remove Evidence tree"]); + await realGit(fixture.author, ["push", "origin", "main"]); + const pull = await fixture.app.inject({ method: "POST", url: "/workspace-registry/pull" }); + expect(pull.statusCode).toBe(400); + expect(pull.json()).toEqual({ code: "workspace_invalid", message: "Workspace request or bundle is invalid." }); + expect(pull.body).not.toContain(SECRET_CANARY); + await expect(fixture.registry.read("psd-clinical")).resolves.toMatchObject({ + revision: { commit: fixture.initialCommit }, + }); +}); + +test("real export and import preserve stable public Evidence artifacts without Evidence or secret bytes", async () => { + const fixture = await createRealRouteFixture(); + const secretDirectory = join(fixture.root, "fixture-secrets"); + mkdirSync(secretDirectory); + writeFileSync(join(secretDirectory, "credential"), SECRET_CANARY); + await fixture.registry.bootstrap(); + + const firstResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" }); + const secondResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" }); + expect(firstResponse.statusCode).toBe(200); + expect(secondResponse.statusCode).toBe(200); + const first = await extractZip(firstResponse.rawPayload); + const second = await extractZip(secondResponse.rawPayload); + const names = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"]; + expect(Object.keys(first).sort()).toEqual([...names].sort()); + expect(Object.keys(second).sort()).toEqual([...names].sort()); + for (const name of names) expect(second[name]).toEqual(first[name]); + + const descriptor = parseWorkspaceYaml(first["workspace.yaml"].toString("utf8")); + const docs = renderWorkspaceDocs(descriptor); + const manifest = JSON.parse(first["manifest.json"].toString("utf8")); + expect(descriptor).toEqual(filesystemEvidenceWorkspace); + expect(first["contract.env.example"].toString("utf8")).toBe(docs.envExample); + expect(first["README.md"].toString("utf8")).toBe(docs.markdown); + expect(manifest.files).toEqual({ + "workspace.yaml": sha256(first["workspace.yaml"]), + "contract.env.example": sha256(first["contract.env.example"]), + "README.md": sha256(first["README.md"]), + }); + const publicBytes = Buffer.concat(Object.values(first)).toString("utf8"); + expect(publicBytes).not.toContain(EVIDENCE_FILE_BYTES.trim()); + expect(publicBytes).not.toContain(SECRET_CANARY); + + const imported = await importBundle(fixture.app, firstResponse.rawPayload); + expect(imported.statusCode).toBe(200); + expect(imported.json().draft.workspace).toEqual(filesystemEvidenceWorkspace); + expect(imported.json().draft.contract.variables.some(({ role }: { role: string }) => role === "EVIDENCE")) + .toBe(false); + expect(imported.body).not.toContain(EVIDENCE_FILE_BYTES.trim()); + expect(imported.body).not.toContain(SECRET_CANARY); +}); diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 95e7913c..f0f24136 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { - chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, + chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -9,7 +9,9 @@ import { promisify } from "node:util"; import { afterEach, expect, test } from "vitest"; import { WorkspaceRepositoryLock } from "../src/workspaces/git-repository.js"; import { WorkspaceRegistry } from "../src/workspaces/registry.js"; -import { parseWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js"; +import { + parseWorkspaceYaml, renderWorkspaceDocs, serializeWorkspaceYaml, type CanonicalWorkspace, +} from "../src/workspaces/schema.js"; import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; const validYaml = `workspace: @@ -988,3 +990,96 @@ test("rejects a corrupt fallback snapshot instead of returning degraded active s await expect(registry.pull()).rejects.toMatchObject({ code: "workspace_invalid" }); }); + + +test("snapshots canonical Evidence artifacts at the active commit without copying Evidence bytes", async () => { + const remote = await fixture(withFilesystemEvidence(validYaml)); + const registryRoot = join(remote.root, "registry"); + const registry = new WorkspaceRegistry(config(registryRoot, remote.remote)); + + const status = await registry.bootstrap(); + const active = await registry.read("psd-clinical"); + const snapshotDirectory = join(registryRoot, "snapshots", status.head!); + const descriptor = active.workspace as CanonicalWorkspace; + const docs = renderWorkspaceDocs(descriptor); + const expectedFiles: Record = { + "psd-clinical.yaml": serializeWorkspaceYaml(descriptor), + "psd-clinical.env.example": docs.envExample, + "psd-clinical.md": docs.markdown, + }; + const manifest = JSON.parse(readFileSync(join(snapshotDirectory, "snapshot.json"), "utf8")); + + expect(status.head).toBe(remote.initialCommit); + const committedDescriptor = parseWorkspaceYaml(await gitOutput(remote.source, [ + "show", `${remote.initialCommit}:workspaces/psd-clinical.yaml`, + ])) as CanonicalWorkspace; + expect(expectedFiles["psd-clinical.yaml"]).toBe(serializeWorkspaceYaml(committedDescriptor)); + expect(readdirSync(snapshotDirectory).sort()).toEqual([ + "psd-clinical.env.example", "psd-clinical.md", "psd-clinical.yaml", "snapshot.json", + ]); + expect(manifest.head).toBe(remote.initialCommit); + expect(manifest.revisions[0]).toMatchObject({ + id: "psd-clinical", commit: remote.initialCommit, blob: active.revision.blob, + }); + expect(Object.keys(manifest.files).sort()).toEqual(Object.keys(expectedFiles).sort()); + for (const [name, contents] of Object.entries(expectedFiles)) { + expect(readFileSync(join(snapshotDirectory, name), "utf8")).toBe(contents); + expect(manifest.files[name]).toBe(createHash("sha256").update(contents).digest("hex")); + } + expect(JSON.stringify(manifest)).not.toContain("workspace-content/"); + expect(readdirSync(snapshotDirectory).some((name) => name === "workspace-content")).toBe(false); + expect(readFileSync(join(remote.source, "workspace-content/psd-clinical/evidence/guide.md"), "utf8")) + .toBe("guide v1\n"); +}); + +test("never copies an installation secret canary into Git, generated artifacts, metadata, or errors", async () => { + const remote = await fixture(validYaml.concat(`evidence: + source: + type: http + uris: [https://evidence.example.test/guide.md] + authentication: signed_urls_file +`)); + const canary = "CANARY-EVIDENCE-SECRET-ONLY-IN-FIXTURE"; + const secretDirectory = join(remote.root, "fixture-secrets"); + mkdirSync(secretDirectory); + const secretFile = join(secretDirectory, "signed-urls"); + writeFileSync(secretFile, canary); + const registryRoot = join(remote.root, "registry"); + const registry = new WorkspaceRegistry(config(registryRoot, remote.remote, { + secretRoots: [secretDirectory], + })); + const previous = process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE; + process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE = secretFile; + try { + const status = await registry.bootstrap(); + const snapshotDirectory = join(registryRoot, "snapshots", status.head!); + let gitBlobText = ""; + try { + gitBlobText = (await runFile( + "git", ["grep", "-I", "-h", "-e", canary, "HEAD", "--", "."], { cwd: remote.source }, + )).stdout; + } catch (error) { + if (!error || typeof error !== "object" || !("code" in error) || error.code !== 1) throw error; + gitBlobText = "stdout" in error ? String(error.stdout ?? "") : ""; + } + expect(gitBlobText).toBe(""); + for (const name of readdirSync(snapshotDirectory)) { + expect(readFileSync(join(snapshotDirectory, name), "utf8")).not.toContain(canary); + } + let thrown: unknown; + try { + await registry.publish({ + action: "create", + workspace: filesystemWorkspace("missing-secret-canary-tree"), + baseCommit: status.head!, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ code: "workspace_invalid" }); + expect(String(thrown)).not.toContain(canary); + } finally { + if (previous === undefined) delete process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE; + else process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE = previous; + } +}); diff --git a/backend/test/workspaces-contracts.test.ts b/backend/test/workspaces-contracts.test.ts index 0657804b..2ac47f83 100644 --- a/backend/test/workspaces-contracts.test.ts +++ b/backend/test/workspaces-contracts.test.ts @@ -1,5 +1,7 @@ import { expect, test } from "vitest"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { parse } from "yaml"; import { buildInstallationContract, renderWorkspaceDocs } from "../src/workspaces/contracts.js"; @@ -385,3 +387,115 @@ semantic_index: llm_policy: { allowed: [zai/glm-5.2] } `; } + + +const evidenceSources = [ + { + label: "filesystem", + source: { type: "filesystem", uri: "workspace-content/psd-clinical/evidence" }, + variables: [], + }, + { + label: "HTTP signed URL file", + source: { + type: "http", + uris: ["https://evidence.example.test/guide.md", "http://public.example.test/policy.pdf"], + authentication: "signed_urls_file", + }, + variables: ["THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE"], + }, + { + label: "S3 static files", + source: { + type: "s3", uri: "s3://clinical-evidence/published/", credentials: "static_files", + }, + variables: [ + "THT_WS_PSD_CLINICAL_EVIDENCE_ACCESS_KEY_FILE", + "THT_WS_PSD_CLINICAL_EVIDENCE_SECRET_KEY_FILE", + "THT_WS_PSD_CLINICAL_EVIDENCE_SESSION_TOKEN_FILE", + ], + }, +] as const; + +test.each(evidenceSources)("renders deterministic public Evidence docs for $label", ({ source, variables }) => { + const descriptor = parseWorkspaceYaml( + `${renderWorkspaceWithoutEvidence()}evidence:\n source: ${JSON.stringify(source)}\n`, + ); + const firstContract = buildInstallationContract(descriptor); + const secondContract = buildInstallationContract(descriptor); + const firstDocs = renderWorkspaceDocs(descriptor); + const secondDocs = renderWorkspaceDocs(descriptor); + + expect(secondContract).toEqual(firstContract); + expect(secondDocs).toEqual(firstDocs); + expect(firstContract.variables.filter(({ role }) => role === "EVIDENCE").map(({ name }) => name)) + .toEqual(variables); + expect(firstDocs.markdown).toContain("## Evidence source"); + expect(firstDocs.markdown).toContain(`- Type: \`${source.type}\``); + for (const uri of "uris" in source ? source.uris : [source.uri]) { + expect(firstDocs.markdown).toContain(`\`${uri}\``); + } + expect(firstDocs.markdown).toContain("- Maximum source bytes: `10485760`"); + expect(firstDocs.markdown).toContain("- Maximum chunk characters: `4000`"); + expect(firstDocs.markdown).toContain("- Retained published generations: `3`"); + for (const variable of variables) { + expect(firstDocs.markdown).toContain(`\`${variable}\``); + expect(firstDocs.envExample).toContain(`${variable}=`); + } +}); + +test("documents same-revision filesystem ownership without claiming P1 materialization", () => { + const descriptor = parseWorkspaceYaml( + `${renderWorkspaceWithoutEvidence()}evidence:\n source: { type: filesystem, uri: workspace-content/psd-clinical/evidence }\n`, + ); + const docs = renderWorkspaceDocs(descriptor).markdown; + + expect(docs).toContain("`workspace-content/psd-clinical/evidence`"); + expect(docs).toMatch(/same Git revision/i); + expect(docs).toMatch(/P6.*materializ/i); + expect(docs).toMatch(/containment.*symlink/i); + expect(docs).toMatch(/does not include Evidence file bytes/i); +}); + +test.each([ + { + source: { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "none" }, + expected: "No credential file is required", + }, + { + source: { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "signed_urls_file" }, + expected: "signed URL file", + }, + { + source: { type: "s3", uri: "s3://clinical-evidence/published/", credentials: "ambient" }, + expected: "ambient credentials", + }, + { + source: { type: "s3", uri: "s3://clinical-evidence/published/", credentials: "static_files" }, + expected: "installation file variables", + }, +])("documents $source.type credential mode without reading credential contents", ({ source, expected }) => { + const root = mkdtempSync(join(tmpdir(), "thoth-evidence-doc-secret-")); + const secrets = join(root, "secrets"); + const canary = "CANARY-EVIDENCE-CREDENTIAL-DO-NOT-LEAK"; + mkdirSync(secrets); + const binding = join(secrets, "credential"); + writeFileSync(binding, canary); + const previous = process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE; + process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE = binding; + try { + const descriptor = parseWorkspaceYaml( + `${renderWorkspaceWithoutEvidence()}evidence:\n source: ${JSON.stringify(source)}\n`, + ); + const generated = JSON.stringify({ + contract: buildInstallationContract(descriptor), + docs: renderWorkspaceDocs(descriptor), + }); + expect(generated).toContain(expected); + expect(generated).not.toContain(canary); + } finally { + if (previous === undefined) delete process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE; + else process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE = previous; + rmSync(root, { recursive: true, force: true }); + } +});