diff --git a/backend/src/workspaces/git-repository.ts b/backend/src/workspaces/git-repository.ts index c146523f..d2de0057 100644 --- a/backend/src/workspaces/git-repository.ts +++ b/backend/src/workspaces/git-repository.ts @@ -130,34 +130,52 @@ export class GitWorkspaceRepository { } async workspacePaths(): Promise { - const output = await this.git(["ls-tree", "-r", "--name-only", "HEAD", "--", "workspaces"]); - const paths = output.trim() === "" ? [] : output.trim().split("\n"); - for (const path of paths) { - if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) { + const output = await this.git(["ls-tree", "-d", "--name-only", "HEAD"]); + const directories = output.trim() === "" ? [] : output.trim().split("\n"); + const paths: string[] = []; + for (const id of directories) { + if (id === "workspace-docs") continue; + if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid path"); } + const path = `${id}/workspace.yaml`; + const type = (await this.git(["cat-file", "-t", `HEAD:${path}`], {}, + "Workspace descriptor is invalid")).trim(); + if (type !== "blob") { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor is invalid"); + } + paths.push(path); } - return paths; + return paths.sort(); } - async readWorkspace(path: string): Promise { - if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) { - throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); - } - return await this.git(["show", `HEAD:${path}`]); + async readCatalog(revision = "HEAD"): Promise { + return await this.git(["show", `${revision}:thoth-workspaces.yaml`], {}, "Workspace catalog is invalid"); } - async blob(path: string): Promise { - if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) { + async catalogBlob(revision = "HEAD"): Promise { + return (await this.git(["rev-parse", `${revision}:thoth-workspaces.yaml`], {}, + "Workspace catalog is invalid")).trim(); + } + + async readWorkspace(path: string, revision = "HEAD"): Promise { + if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); } - return (await this.git(["rev-parse", `HEAD:${path}`])).trim(); + return await this.git(["show", `${revision}:${path}`]); + } + + async blob(path: string, revision = "HEAD"): Promise { + if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid"); + } + return (await this.git(["rev-parse", `${revision}:${path}`])).trim(); } /** Assert that a canonical Evidence root is a Git tree at an exact commit. */ async assertTreeAtRevision(revision: string, repoRelativePath: string): Promise { if (!/^[0-9a-f]{40}$/.test(revision) - || !/^workspace-content\/[a-z][a-z0-9-]{2,62}\/evidence$/.test(repoRelativePath)) { + || !/^[a-z][a-z0-9-]{2,62}\/evidence$/.test(repoRelativePath)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence revision is invalid"); } const type = (await this.git( @@ -170,7 +188,7 @@ export class GitWorkspaceRepository { } } - /** Write only a validated registry artifact below the checked-out repository. */ + /** Write only a validated API-owned artifact below the checked-out repository. */ async writeRegistryFile(path: string, source: string): Promise { this.assertRegistryArtifactPath(path); const target = join(this.repoPath, path); @@ -178,6 +196,21 @@ export class GitWorkspaceRepository { await writeFile(target, source, { encoding: "utf8", mode: 0o600 }); } + /** Create a descriptor only when no filesystem entry exists at its exact path. */ + async createRegistryFile(path: string, source: string): Promise { + this.assertRegistryArtifactPath(path); + if (!/^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path)) { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace descriptor path is invalid"); + } + const target = join(this.repoPath, path); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + try { + await writeFile(target, source, { encoding: "utf8", mode: 0o600, flag: "wx" }); + } catch { + throw new WorkspaceRegistryError("workspace_curator_owned", "Workspace descriptor is curator-owned"); + } + } + async removeRegistryFile(path: string): Promise { this.assertRegistryArtifactPath(path); await rm(join(this.repoPath, path), { force: true }); @@ -214,7 +247,7 @@ export class GitWorkspaceRepository { } private isRegistryArtifactPath(path: string): boolean { - return /^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path) + return /^(?!workspace-docs\/)[a-z][a-z0-9-]{2,62}\/workspace\.yaml$/.test(path) || /^workspace-docs\/[a-z][a-z0-9-]{2,62}\/(?:contract\.env\.example|README\.md)$/.test(path); } @@ -258,7 +291,7 @@ export class GitWorkspaceRepository { private async restoreFailedPublication(): Promise { try { await this.git(["reset", "--hard", `refs/remotes/origin/${this.config.branch}`]); - await this.git(["clean", "-fd", "--", "workspaces", "workspace-docs"]); + await this.git(["clean", "-fd", "--", "workspace-docs"]); } catch { // Keep the original sanitized publish failure. A future refresh will surface any recovery // problem without leaking the Git failure details through the API. diff --git a/backend/src/workspaces/schema.ts b/backend/src/workspaces/schema.ts index 2e1886d1..4d9369a9 100644 --- a/backend/src/workspaces/schema.ts +++ b/backend/src/workspaces/schema.ts @@ -341,7 +341,7 @@ function workspaceInvariants(workspace: any, context: z.RefinementCtx): void { unique(workspace.llm_policy.allowed, context, ["llm_policy", "allowed"]); if (workspace.evidence?.source.type === "filesystem") { - const expected = `workspace-content/${workspace.workspace.id}/evidence`; + const expected = `${workspace.workspace.id}/evidence`; if (workspace.evidence.source.uri !== expected) { context.addIssue({ code: "custom", diff --git a/backend/src/workspaces/types.ts b/backend/src/workspaces/types.ts index 3017d6c7..e10909bb 100644 --- a/backend/src/workspaces/types.ts +++ b/backend/src/workspaces/types.ts @@ -12,7 +12,7 @@ export interface WorkspaceRegistryConfig { export type WorkspaceErrorCode = | "workspace_invalid" | "binding_missing" | "workspace_not_activatable" - | "workspace_stale" | "workspace_conflict" | "git_unavailable" + | "workspace_stale" | "workspace_conflict" | "workspace_curator_owned" | "git_unavailable" | "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected" | "connector_unavailable" | "semantic_index_incompatible"; diff --git a/backend/test/workspaces-git-repository.test.ts b/backend/test/workspaces-git-repository.test.ts index d2046735..e4776dd4 100644 --- a/backend/test/workspaces-git-repository.test.ts +++ b/backend/test/workspaces-git-repository.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -55,20 +55,22 @@ async function temporaryRemote(): Promise<{ root: string; remote: string; source await git(source, ["init", "--initial-branch=main"]); await git(source, ["config", "user.name", "Workspace Registry Test"]); await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); - mkdirSync(join(source, "workspaces")); - writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), validYaml); - writeFileSync(join(source, "workspaces", "research.yaml"), validYaml + writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: psd-clinical, name: Policlinico San Donato}, {id: research, name: Policlinico San Donato}]\n"); + mkdirSync(join(source, "psd-clinical"), { recursive: true }); + mkdirSync(join(source, "research"), { recursive: true }); + writeFileSync(join(source, "psd-clinical", "workspace.yaml"), validYaml); + writeFileSync(join(source, "research", "workspace.yaml"), validYaml .replace("id: psd-clinical", "id: research")); - mkdirSync(join(source, "workspace-content", "research", "evidence"), { recursive: true }); - mkdirSync(join(source, "workspace-content", "other", "evidence"), { recursive: true }); - mkdirSync(join(source, "workspace-content", "blob"), { recursive: true }); - mkdirSync(join(source, "workspace-content", "link"), { recursive: true }); - writeFileSync(join(source, "workspace-content", "research", "evidence", "guide.md"), "guide v1\n"); - writeFileSync(join(source, "workspace-content", "other", "evidence", "other.md"), "other\n"); - writeFileSync(join(source, "workspace-content", "blob", "evidence"), "not a tree\n"); - symlinkSync("../research/evidence", join(source, "workspace-content", "link", "evidence")); - symlinkSync("guide.md", join(source, "workspace-content", "research", "evidence", "nested-link")); - await git(source, ["add", "workspaces", "workspace-content"]); + mkdirSync(join(source, "research", "evidence"), { recursive: true }); + mkdirSync(join(source, "other", "evidence"), { recursive: true }); + mkdirSync(join(source, "blob"), { recursive: true }); + mkdirSync(join(source, "link"), { recursive: true }); + writeFileSync(join(source, "research", "evidence", "guide.md"), "guide v1\n"); + writeFileSync(join(source, "other", "evidence", "other.md"), "other\n"); + writeFileSync(join(source, "blob", "evidence"), "not a tree\n"); + symlinkSync("../research/evidence", join(source, "link", "evidence")); + symlinkSync("guide.md", join(source, "research", "evidence", "nested-link")); + await git(source, ["add", "-A"]); await git(source, ["commit", "-m", "Initial workspace"]); await git(source, ["remote", "add", "origin", remote]); await git(source, ["push", "origin", "main"]); @@ -112,19 +114,19 @@ test("accepts only a tree at the declared Evidence root for the requested revisi await expect(repository.assertTreeAtRevision( fixture.initialCommit, - "workspace-content/research/evidence", + "research/evidence", )).resolves.toBeUndefined(); await expect(repository.assertTreeAtRevision( fixture.initialCommit, - "workspace-content/missing/evidence", + "missing/evidence", )).rejects.toMatchObject({ code: "workspace_invalid" }); await expect(repository.assertTreeAtRevision( fixture.initialCommit, - "workspace-content/blob/evidence", + "blob/evidence", )).rejects.toMatchObject({ code: "workspace_invalid" }); await expect(repository.assertTreeAtRevision( fixture.initialCommit, - "workspace-content/link/evidence", + "link/evidence", )).rejects.toMatchObject({ code: "workspace_invalid" }); }); @@ -136,7 +138,7 @@ test("redacts Git failures while checking an Evidence tree", async () => { const error = await repository.assertTreeAtRevision( fixture.initialCommit, - "workspace-content/research/evidence", + "research/evidence", ).catch((failure: unknown) => failure); expect(error).toMatchObject({ code: "git_unavailable", message: "Workspace Git operation failed" }); expect((error as Error).message).not.toContain(fixture.root); @@ -150,7 +152,7 @@ test("classifies repository corruption as unavailable rather than invalid Eviden await expect(repository.assertTreeAtRevision( fixture.initialCommit, - "workspace-content/research/evidence", + "research/evidence", )).rejects.toMatchObject({ code: "git_unavailable", message: "Workspace Git operation failed" }); }); @@ -158,28 +160,28 @@ test("binds Evidence tree validation to old and new content-only commits", async const fixture = await temporaryRemote(); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); await repository.bootstrap(); - writeFileSync(join(fixture.source, "workspace-content", "research", "evidence", "guide.md"), "guide v2\n"); - await git(fixture.source, ["add", "workspace-content/research/evidence/guide.md"]); + writeFileSync(join(fixture.source, "research", "evidence", "guide.md"), "guide v2\n"); + await git(fixture.source, ["add", "research/evidence/guide.md"]); await git(fixture.source, ["commit", "-m", "Update Evidence content"]); await git(fixture.source, ["push", "origin", "main"]); const { stdout } = await runFile("git", ["rev-parse", "HEAD"], { cwd: fixture.source }); const newCommit = stdout.trim(); await repository.pull(); - const oldTree = (await runFile("git", ["rev-parse", `${fixture.initialCommit}:workspace-content/research/evidence`], { + const oldTree = (await runFile("git", ["rev-parse", `${fixture.initialCommit}:research/evidence`], { cwd: fixture.source, })).stdout.trim(); - const newTree = (await runFile("git", ["rev-parse", `${newCommit}:workspace-content/research/evidence`], { + const newTree = (await runFile("git", ["rev-parse", `${newCommit}:research/evidence`], { cwd: fixture.source, })).stdout.trim(); expect(newTree).not.toBe(oldTree); await expect(repository.assertTreeAtRevision( fixture.initialCommit, - "workspace-content/research/evidence", + "research/evidence", )).resolves.toBeUndefined(); await expect(repository.assertTreeAtRevision( newCommit, - "workspace-content/research/evidence", + "research/evidence", )).resolves.toBeUndefined(); }); @@ -191,7 +193,7 @@ test("defers nested Evidence symlink containment to P6", async () => { // Task 2 validates only the declared root object. Recursive containment remains a P6 boundary. await expect(repository.assertTreeAtRevision( fixture.initialCommit, - "workspace-content/research/evidence", + "research/evidence", )).resolves.toBeUndefined(); }); @@ -203,11 +205,11 @@ test("rejects malformed revisions and shell-like paths without executing them", await expect(repository.assertTreeAtRevision( "HEAD", - "workspace-content/research/evidence", + "research/evidence", )).rejects.toMatchObject({ code: "workspace_invalid" }); await expect(repository.assertTreeAtRevision( fixture.initialCommit, - `workspace-content/research/evidence;touch ${marker}`, + `research/evidence;touch ${marker}`, )).rejects.toMatchObject({ code: "workspace_invalid" }); expect(existsSync(marker)).toBe(false); }); @@ -298,3 +300,19 @@ test("parallel contenders recover a stale lock file without overlapping critical expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); expect(maximum).toBe(1); }); + + +test("creates a descriptor only when the exact curator path is absent", async () => { + const fixture = await temporaryRemote(); + const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); + await repository.bootstrap(); + const descriptorPath = "new-workspace/workspace.yaml"; + const descriptor = "curator descriptor\n"; + await expect(repository.createRegistryFile(descriptorPath, descriptor)).resolves.toBeUndefined(); + expect(readFileSync(join(fixture.root, "registry", "repo", descriptorPath), "utf8")).toBe(descriptor); + await expect(repository.createRegistryFile(descriptorPath, "overwrite\n")) + .rejects.toMatchObject({ code: "workspace_curator_owned" }); + expect(readFileSync(join(fixture.root, "registry", "repo", descriptorPath), "utf8")).toBe(descriptor); + await expect(repository.createRegistryFile("workspace-docs/workspace.yaml", descriptor)) + .rejects.toMatchObject({ code: "workspace_invalid" }); +}); diff --git a/deploy/workspaces/example.yaml b/deploy/workspaces/example.yaml index 9c6e1978..c625015f 100644 --- a/deploy/workspaces/example.yaml +++ b/deploy/workspaces/example.yaml @@ -27,7 +27,7 @@ semantic_index: evidence: source: type: filesystem - uri: workspace-content/example/evidence + uri: example/evidence patterns: - "**/*.md" max_bytes: 10485760 diff --git a/deploy/workspaces/psd.yaml.example b/deploy/workspaces/psd.yaml.example index d80862ae..39eea0d0 100644 --- a/deploy/workspaces/psd.yaml.example +++ b/deploy/workspaces/psd.yaml.example @@ -27,7 +27,7 @@ semantic_index: evidence: source: type: filesystem - uri: workspace-content/example-workspace/evidence + uri: example-workspace/evidence patterns: - "**/*.md" max_bytes: 10485760