From ce22ad76327ee135249be95223a38dc049b93424 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 8 Jun 2026 22:12:17 +0000 Subject: [PATCH] fix: auto-resolve workspace links across projects --- server/src/__tests__/file-resources.test.ts | 110 +++++++++++ .../src/services/workspace-file-resources.ts | 174 ++++++++++++++++-- ui/src/lib/workspace-file-parser.test.ts | 4 + ui/src/lib/workspace-file-parser.ts | 1 + 4 files changed, 274 insertions(+), 15 deletions(-) diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index 072eccb1052..19482011de4 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -243,6 +243,35 @@ describeEmbeddedPostgres("workspace file resources", () => { expect(resolved.capabilities.preview).toBe(true); }); + it("auto-discovers unhinted same-company project files when issue workspaces miss", async () => { + const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + const targetPath = "docs/reference/skills.md"; + await fs.mkdir(path.join(targetProjectRoot, path.dirname(targetPath)), { recursive: true }); + await fs.writeFile(path.join(targetProjectRoot, targetPath), "# Skills reference\n", "utf8"); + + const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, { + path: targetPath, + workspace: "auto", + }); + + expect(resolved).toMatchObject({ + workspaceKind: "project_workspace", + workspaceId: graph.targetProjectWorkspaceId, + projectId: graph.targetProjectId, + projectName: "Target project", + displayPath: `Target project / ${targetPath}`, + previewKind: "text", + }); + + const content = await workspaceFileResourceService(db).readContent(graph.issueId, { + path: targetPath, + workspace: "auto", + }); + expect(content.resource.workspaceId).toBe(graph.targetProjectWorkspaceId); + expect(content.content.data).toContain("# Skills reference"); + }); + it("resolves explicit same-company cross-project workspace files and logs target details", async () => { const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); @@ -386,6 +415,87 @@ describeEmbeddedPostgres("workspace file resources", () => { expect(JSON.stringify(listed.body)).not.toContain("outside.txt"); }); + it("auto-discovers unhinted same-company project folders when issue workspaces miss", async () => { + const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/"; + await fs.mkdir(path.join(targetProjectRoot, folderPath), { recursive: true }); + await fs.writeFile(path.join(targetProjectRoot, folderPath, "README.md"), "# Bundled skills\n", "utf8"); + await fs.writeFile(path.join(targetProjectRoot, folderPath, "suggestions.md"), "# Suggestions\n", "utf8"); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + const resolved = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/resolve`) + .query({ path: folderPath }); + expect(resolved.status).toBe(200); + expect(resolved.body).toMatchObject({ + kind: "directory", + workspaceId: graph.targetProjectWorkspaceId, + projectId: graph.targetProjectId, + projectName: "Target project", + displayPath: `Target project / ${folderPath}`, + }); + + const listed = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ path: folderPath }); + expect(listed.status).toBe(200); + expect(listed.body.state).toBe("available"); + expect(listed.body.workspace).toMatchObject({ + workspaceId: graph.targetProjectWorkspaceId, + projectId: graph.targetProjectId, + projectName: "Target project", + }); + expect(new Set(listed.body.items.map((item: { relativePath: string }) => item.relativePath))).toEqual(new Set([ + `${folderPath}README.md`, + `${folderPath}suggestions.md`, + ])); + expect(JSON.stringify(listed.body)).not.toContain(root); + }); + + it("rejects ambiguous unhinted same-company project file matches", async () => { + const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + const duplicateProjectId = crypto.randomUUID(); + const duplicateWorkspaceId = crypto.randomUUID(); + const duplicateRoot = path.join(path.dirname(targetProjectRoot), "duplicate-target"); + const targetPath = "docs/reference/skills.md"; + await fs.mkdir(path.join(targetProjectRoot, path.dirname(targetPath)), { recursive: true }); + await fs.writeFile(path.join(targetProjectRoot, targetPath), "# Target\n", "utf8"); + await fs.mkdir(path.join(duplicateRoot, path.dirname(targetPath)), { recursive: true }); + await fs.writeFile(path.join(duplicateRoot, targetPath), "# Duplicate\n", "utf8"); + await db.insert(projects).values({ + id: duplicateProjectId, + companyId: graph.companyId, + name: "Duplicate target", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: duplicateWorkspaceId, + companyId: graph.companyId, + projectId: duplicateProjectId, + name: "Duplicate workspace", + sourceType: "local_path", + cwd: duplicateRoot, + isPrimary: true, + }); + + await expect(workspaceFileResourceService(db).resolve(graph.issueId, { + path: targetPath, + workspace: "auto", + })).rejects.toMatchObject({ + status: 409, + details: { code: "ambiguous_workspace_path" }, + }); + }); + it("denies explicit cross-company project workspaces", async () => { const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index d5c27c2f74e..e46c1f0cb6b 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -126,6 +126,11 @@ type LocalResolvedDirectory = { rootReal: string; }; +type AutoDiscovered = + | { state: "none" } + | { state: "one"; value: T } + | { state: "ambiguous"; count: number }; + type WorkspaceFileListQueryInput = { workspace?: WorkspaceFileSelector | null; projectId?: string | null; @@ -340,6 +345,14 @@ function candidateFromProjectWorkspace( }; } +function isHttpStatus(error: unknown, status: number) { + return ( + error instanceof Error && + "status" in error && + (error as { status?: number }).status === status + ); +} + function directoryResource(input: { candidate: WorkspaceCandidate; relativePath: string; @@ -834,6 +847,63 @@ export function workspaceFileResourceService(db: Db) { return candidates; } + async function sameCompanyProjectWorkspaceCandidates( + issue: IssueRow, + excludedWorkspaceIds: Set, + ): Promise { + const rows = await db.select({ + workspace: projectWorkspaces, + project: { + id: projects.id, + name: projects.name, + }, + }) + .from(projectWorkspaces) + .innerJoin(projects, eq(projectWorkspaces.projectId, projects.id)) + .where(and( + eq(projectWorkspaces.companyId, issue.companyId), + eq(projects.companyId, issue.companyId), + )) + .orderBy(desc(projectWorkspaces.isPrimary), desc(projectWorkspaces.updatedAt)); + + const candidates: WorkspaceCandidate[] = []; + const seen = new Set(excludedWorkspaceIds); + for (const row of rows) { + if (seen.has(row.workspace.id)) continue; + seen.add(row.workspace.id); + candidates.push(candidateFromProjectWorkspace(row.workspace, row.project)); + } + return candidates; + } + + async function discoverUniqueProjectWorkspaceMatch( + issue: IssueRow, + excludedWorkspaceIds: Set, + check: (candidate: WorkspaceCandidate) => Promise, + ): Promise> { + const candidates = await sameCompanyProjectWorkspaceCandidates(issue, excludedWorkspaceIds); + const matches: T[] = []; + for (const candidate of candidates) { + if (candidate.remote) continue; + try { + matches.push(await check(candidate)); + } catch (error) { + if (isHttpStatus(error, 404)) continue; + throw error; + } + if (matches.length > 1) return { state: "ambiguous", count: matches.length }; + } + if (matches.length === 1) return { state: "one", value: matches[0]! }; + return { state: "none" }; + } + + function throwAmbiguousWorkspacePath(count: number): never { + throw new HttpError(409, "Workspace path matched multiple project workspaces", { + code: "ambiguous_workspace_path", + matchCount: count, + }); + } + async function resolve(issueId: string, input: { path: string; workspace?: WorkspaceFileSelector | null; @@ -861,13 +931,7 @@ export function workspaceFileResourceService(db: Db) { ? (await statLocalDirectory(candidate, normalized)).resource : (await statLocalCandidate(candidate, normalized)).resource; } catch (error) { - if ( - !explicitTarget && - error instanceof Error && - "status" in error && - (error as { status?: number }).status === 404 && - selector === "auto" - ) { + if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) { lastNotFound = error; continue; } @@ -875,6 +939,18 @@ export function workspaceFileResourceService(db: Db) { } } + if (lastNotFound && !explicitTarget && selector === "auto") { + const discovered = await discoverUniqueProjectWorkspaceMatch( + issue, + new Set(candidates.map((candidate) => candidate.workspaceId)), + async (candidate) => isDirectoryRequest + ? (await statLocalDirectory(candidate, normalized)).resource + : (await statLocalCandidate(candidate, normalized)).resource, + ); + if (discovered.state === "one") return discovered.value; + if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count); + } + if (lastNotFound) throw lastNotFound; throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" }); } @@ -901,6 +977,7 @@ export function workspaceFileResourceService(db: Db) { } let firstUnavailable: { candidate: WorkspaceCandidate; reason: string } | null = null; + let lastNotFound: unknown = null; for (const candidate of candidates) { if (candidate.remote) { firstUnavailable ??= { candidate, reason: "remote_workspace" }; @@ -922,7 +999,13 @@ export function workspaceFileResourceService(db: Db) { startRelativePath = normalizedPath.relativePath; } } catch (error) { - if (normalizedPath) throw error; + if (normalizedPath) { + if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) { + lastNotFound = error; + continue; + } + throw error; + } firstUnavailable ??= { candidate, reason: "workspace_unavailable" }; if (explicitTarget || selector !== "auto") { return unavailableFileList({ selector, mode, path: null, q, limit, candidate, reason: "workspace_unavailable" }); @@ -975,6 +1058,45 @@ export function workspaceFileResourceService(db: Db) { }); } + if (lastNotFound && normalizedPath && !explicitTarget && selector === "auto") { + const discovered = await discoverUniqueProjectWorkspaceMatch( + issue, + new Set(candidates.map((candidate) => candidate.workspaceId)), + async (candidate) => { + const resolvedDirectory = await statLocalDirectory(candidate, normalizedPath); + return { + candidate, + rootReal: resolvedDirectory.rootReal, + startReal: resolvedDirectory.realPath, + startRelativePath: normalizedPath.relativePath, + }; + }, + ); + if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count); + if (discovered.state === "one") { + const listed = await enumerateWorkspaceFiles({ + candidate: discovered.value.candidate, + rootReal: discovered.value.rootReal, + startReal: discovered.value.startReal, + startRelativePath: discovered.value.startRelativePath, + mode, + normalizedQuery, + limit, + }); + return availableFileList({ + selector, + mode, + path: normalizedPath.relativePath, + q, + limit, + candidate: discovered.value.candidate, + items: listed.items, + scannedCount: listed.scannedCount, + truncated: listed.truncated, + }); + } + } + return unavailableFileList({ selector, mode, @@ -1013,13 +1135,7 @@ export function workspaceFileResourceService(db: Db) { try { resolved = await statLocalCandidate(candidate, normalized); } catch (error) { - if ( - !explicitTarget && - error instanceof Error && - "status" in error && - (error as { status?: number }).status === 404 && - selector === "auto" - ) { + if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) { lastNotFound = error; continue; } @@ -1044,6 +1160,34 @@ export function workspaceFileResourceService(db: Db) { }; } + if (lastNotFound && !explicitTarget && selector === "auto") { + const discovered = await discoverUniqueProjectWorkspaceMatch( + issue, + new Set(candidates.map((candidate) => candidate.workspaceId)), + async (candidate) => statLocalCandidate(candidate, normalized), + ); + if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count); + if (discovered.state === "one") { + const resolved = discovered.value; + if (!resolved.resource.capabilities.preview) { + throw unprocessable("Workspace file cannot be previewed", { code: resolved.resource.denialReason ?? "unsupported_content" }); + } + const cap = previewCapForKind(resolved.resource.previewKind); + const data = await readStableFile(resolved.realPath, cap); + if (resolved.resource.previewKind === "text" && !looksLikeText(data.subarray(0, Math.min(data.length, TEXT_SNIFF_BYTES)))) { + throw unprocessable("Workspace file is not a text file", { code: "binary_content" }); + } + + return { + resource: resolved.resource, + content: { + encoding: resolved.resource.previewKind === "text" ? "utf8" : "base64", + data: resolved.resource.previewKind === "text" ? data.toString("utf8") : data.toString("base64"), + }, + }; + } + } + if (lastNotFound) throw lastNotFound; throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" }); } diff --git a/ui/src/lib/workspace-file-parser.test.ts b/ui/src/lib/workspace-file-parser.test.ts index 75ee4dcc2cb..cea36e39f54 100644 --- a/ui/src/lib/workspace-file-parser.test.ts +++ b/ui/src/lib/workspace-file-parser.test.ts @@ -80,6 +80,10 @@ describe("parseWorkspaceFileRef", () => { }); }); + it("rejects one-segment directory refs because they are too ambiguous to route", () => { + expect(parseWorkspaceFileRef("sources/")).toBeNull(); + }); + it("rejects unsafe directory refs", () => { expect(parseWorkspaceFileRef("../secrets/")).toBeNull(); expect(parseWorkspaceFileRef("foo/../secrets/")).toBeNull(); diff --git a/ui/src/lib/workspace-file-parser.ts b/ui/src/lib/workspace-file-parser.ts index c8ea83e1451..3472794dfa2 100644 --- a/ui/src/lib/workspace-file-parser.ts +++ b/ui/src/lib/workspace-file-parser.ts @@ -66,6 +66,7 @@ export function parseWorkspaceFileRef(input: string): ParsedWorkspaceFileRef | n if (directoryMatch) { const [, rawPath] = directoryMatch; if (!rawPath || !looksLikeWorkspacePath(rawPath, { allowTrailingSlash: true })) return null; + if (!rawPath.slice(0, -1).includes("/")) return null; return { path: rawPath, resourceKind: "directory",