feat: preserve evidence in workspace artifacts

This commit is contained in:
2026-08-09 20:03:28 +02:00
parent 64b778ade9
commit 212c973e3b
6 changed files with 656 additions and 7 deletions
+372 -4
View File
@@ -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<typeof buildApp>;
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<CanonicalWorkspace["evidence"]["source"]> & { type: "filesystem" | "http" | "s3" },
changes: Partial<CanonicalWorkspace["evidence"]["policy"]> = {},
): 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<string> {
return (await runFile("git", args, { cwd })).stdout.trim();
}
async function createRealRouteFixture(
initialWorkspace: CanonicalWorkspace = filesystemEvidenceWorkspace,
): Promise<RealRouteFixture> {
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<Record<string, Buffer>> {
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<string, Buffer> = {};
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);
});