mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
fix(ui): scope canonical agents to their authorized company
Keep alias caches and redirects on the agent company. Defer remembered UUID paths until canonical resolution and verify organization switching with a delayed public alias response. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
50c6845e7d
commit
2d9e483cb9
4 files changed
+127
-17
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
fulfill,
|
||||
@@ -1304,6 +1305,85 @@ test.describe("Exact failed chat run retry", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("a cross-company UUID redirect keeps same-name agent caches isolated", async ({
|
||||
page,
|
||||
request,
|
||||
}, testInfo) => {
|
||||
const other = await seedCompanyAndAgent(request);
|
||||
const foreignTitle = "Foreign UUID agent cache sentinel";
|
||||
const scopedTitle = "Company-scoped Maya cache sentinel";
|
||||
const [foreignCompany, scopedCompany] = await Promise.all([
|
||||
json<{ id: string; name: string }>(await request.get(`/api/companies/${other.companyId}`), "read foreign company"),
|
||||
json<{ id: string; name: string }>(await request.get(`/api/companies/${seed.companyId}`), "read scoped company"),
|
||||
]);
|
||||
expect(foreignCompany.id).toBe(other.companyId);
|
||||
expect(scopedCompany.id).toBe(seed.companyId);
|
||||
expect(foreignCompany.name).not.toBe(scopedCompany.name);
|
||||
await json(await request.patch(`/api/agents/${other.agentId}`, {
|
||||
data: { title: foreignTitle },
|
||||
}), "set foreign agent title");
|
||||
await json(await request.patch(`/api/agents/${seed.agentId}`, {
|
||||
data: { title: scopedTitle },
|
||||
}), "set scoped agent title");
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
let observed!: () => void;
|
||||
const canonicalRead = new Promise<void>((resolve) => { observed = resolve; });
|
||||
page.once("close", release);
|
||||
await page.route("**/api/agents/maya?*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.searchParams.get("companyId") !== seed.companyId) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const response = await route.fetch();
|
||||
const agent = await response.json();
|
||||
expect(agent.id).toBe(seed.agentId);
|
||||
observed();
|
||||
await held;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
try {
|
||||
await page.goto(`/${seed.prefix}/agents/${other.agentId}/overview`);
|
||||
await expect(page).toHaveURL(new RegExp(`/${other.prefix}/agents/maya/overview$`));
|
||||
await expect(page.getByText(foreignTitle, { exact: true }).first()).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId")))
|
||||
.toBe(other.companyId);
|
||||
await page.getByRole("button", { name: `Open ${foreignCompany.name} organization switcher`, exact: true }).click();
|
||||
const menuItems = await page.getByRole("menuitem").allTextContents();
|
||||
const scopedOption = page.getByRole("menuitem").filter({ hasText: scopedCompany.name });
|
||||
const chosenOption = await scopedOption.innerText();
|
||||
const routeHistory: string[] = [];
|
||||
page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) routeHistory.push(frame.url()); });
|
||||
await scopedOption.click();
|
||||
await writeFile(testInfo.outputPath("cross-company-menu-context.json"), JSON.stringify({
|
||||
foreignCompany, scopedCompany, seed, other, menuItems, chosenOption, routeHistory,
|
||||
afterSwitchUrl: page.url(),
|
||||
afterSwitchSelectedCompany: await page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId")),
|
||||
}, null, 2));
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/dashboard$`));
|
||||
await page.getByRole("link", { name: "Agents", exact: true }).first().click();
|
||||
await page.getByRole("link", { name: "Maya", exact: true }).first().click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/agents/maya(?:/overview)?$`));
|
||||
await expect(page.getByText(foreignTitle, { exact: true })).toHaveCount(0);
|
||||
await Promise.race([
|
||||
canonicalRead,
|
||||
page.getByText("Paperclip hit an error", { exact: true }).waitFor({ timeout: 15_000 }).then(() => {
|
||||
throw new Error("Cross-company canonical navigation reached the app error boundary");
|
||||
}),
|
||||
]);
|
||||
release();
|
||||
await expect(page.getByText(scopedTitle, { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(foreignTitle, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Paperclip hit an error", { exact: true })).toHaveCount(0);
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId")))
|
||||
.toBe(seed.companyId);
|
||||
await page.screenshot({ path: testInfo.outputPath("cross-company-canonical-agent.png") });
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
for (const surface of ["agent run", "Inbox", "Legacy Inbox"] as const) {
|
||||
for (const outcome of ["queued", "deferred", "denied"] as const) {
|
||||
test(`${surface}: selected run ${outcome} preserves exact retry authority and truthful feedback`, async ({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRememberedPathOwnerCompanyId,
|
||||
isRememberableCompanyPath,
|
||||
sanitizeRememberedPathForCompany,
|
||||
} from "../lib/company-page-memory";
|
||||
|
||||
@@ -52,6 +53,25 @@ describe("getRememberedPathOwnerCompanyId", () => {
|
||||
});
|
||||
|
||||
describe("sanitizeRememberedPathForCompany", () => {
|
||||
it("does not remember an agent UUID before its company-scoped alias is resolved", () => {
|
||||
expect(isRememberableCompanyPath("/agents/209d54ec-4f22-4897-b65d-517a49593023/overview")).toBe(false);
|
||||
expect(isRememberableCompanyPath("/agents/maya/overview")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to dashboard for a legacy remembered agent UUID with unknown ownership", () => {
|
||||
expect(sanitizeRememberedPathForCompany({
|
||||
path: "/PAP/agents/209d54ec-4f22-4897-b65d-517a49593023/runs/run-1",
|
||||
companyPrefix: "PAP",
|
||||
})).toBe("/dashboard");
|
||||
});
|
||||
|
||||
it("keeps canonical agent run paths and their query when switching companies", () => {
|
||||
expect(sanitizeRememberedPathForCompany({
|
||||
path: "/agents/maya/runs/run-1?mode=raw",
|
||||
companyPrefix: "PAP",
|
||||
})).toBe("/agents/maya/runs/run-1?mode=raw");
|
||||
});
|
||||
|
||||
it("keeps remembered issue paths that belong to the target company", () => {
|
||||
expect(
|
||||
sanitizeRememberedPathForCompany({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isUuidLike } from "@paperclipai/shared";
|
||||
import {
|
||||
extractCompanyPrefixFromPath,
|
||||
normalizeCompanyPrefix,
|
||||
@@ -10,8 +11,12 @@ export function isRememberableCompanyPath(path: string): boolean {
|
||||
const pathname = path.split("?")[0] ?? "";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return true;
|
||||
const [root] = segments;
|
||||
const [root, entityId] = segments;
|
||||
if (GLOBAL_SEGMENTS.has(root!)) return false;
|
||||
// UUID agent links can resolve to another accessible company. Remember the
|
||||
// company-scoped alias after resolution, rather than replaying an unverified
|
||||
// UUID when the operator switches back to the company in the original URL.
|
||||
if (root === "agents" && isUuidLike(entityId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { applyCompanyPrefix } from "../lib/company-routes";
|
||||
import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab";
|
||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
@@ -766,7 +767,7 @@ export function AgentDetail() {
|
||||
tab?: string;
|
||||
runId?: string;
|
||||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { companies, selectedCompanyId, selectionSource, setSelectedCompanyId } = useCompany();
|
||||
const { closePanel } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -817,6 +818,7 @@ export function AgentDetail() {
|
||||
});
|
||||
const resolvedCompanyId = agent?.companyId ?? selectedCompanyId;
|
||||
const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef;
|
||||
const agentCompany = companies.find((company) => company.id === agent?.companyId);
|
||||
const handleLegacyTabChange = useCallback((next: string) => {
|
||||
if (!prepareAgentNavigation()) return;
|
||||
navigate(`/agents/${canonicalAgentRef || routeAgentRef}/${next}`);
|
||||
@@ -964,38 +966,41 @@ export function AgentDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent) return;
|
||||
if (!agentCompany) return;
|
||||
const companyMismatch = companyPrefix?.toUpperCase() !== agentCompany.issuePrefix.toUpperCase();
|
||||
if (routeAgentRef !== canonicalAgentRef) {
|
||||
// Reuse the authorized response across the alias redirect. An empty
|
||||
// alias query would unmount the selected run and discard its mutations.
|
||||
// UUID lookups can return another accessible company's agent. Its alias
|
||||
// belongs only to that company's cache, even if the URL used another scope.
|
||||
queryClient.setQueryData(
|
||||
[...queryKeys.agents.detail(canonicalAgentRef), lookupCompanyId ?? null],
|
||||
[...queryKeys.agents.detail(canonicalAgentRef), agent.companyId],
|
||||
agent,
|
||||
);
|
||||
if (lookupCompanyId !== agent.companyId) {
|
||||
queryClient.setQueryData(
|
||||
[...queryKeys.agents.detail(canonicalAgentRef), agent.companyId],
|
||||
agent,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (urlRunId) {
|
||||
if (routeAgentRef !== canonicalAgentRef) {
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
|
||||
if (routeAgentRef !== canonicalAgentRef || companyMismatch) {
|
||||
navigate(`/${agentCompany.issuePrefix}/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (legacyAuditSection) return;
|
||||
const canonicalTab = activeView === "run-detail" ? "overview" : activeView;
|
||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||
navigate(agentDetailHref(canonicalAgentRef, canonicalTab), { replace: true });
|
||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab || companyMismatch) {
|
||||
navigate(`/${agentCompany.issuePrefix}${agentDetailHref(canonicalAgentRef, canonicalTab)}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, lookupCompanyId, queryClient]);
|
||||
}, [agent, agentCompany, companyPrefix, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
|
||||
// Let an explicit organization switch finish its navigation first.
|
||||
if (selectionSource === "manual") return;
|
||||
// The route owns selection until canonical navigation reaches this company.
|
||||
// Competing with Layout's old-prefix selection can cause an update loop.
|
||||
if (routeCompanyId && routeCompanyId !== agent.companyId) return;
|
||||
setSelectedCompanyId(agent.companyId, { source: "route_sync" });
|
||||
}, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
}, [agent?.companyId, routeCompanyId, selectedCompanyId, selectionSource, setSelectedCompanyId]);
|
||||
|
||||
// Invoke / pause / resume / terminate / duplicate / reset live in the shared
|
||||
// AgentActionButtons component. The detail header keeps only "approve" here,
|
||||
@@ -1169,10 +1174,10 @@ export function AgentDetail() {
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!agent) return null;
|
||||
if (!urlRunId && legacyAuditSection) {
|
||||
return <Navigate to={agentScopedAuditHref(agent.id, legacyAuditSection)} replace />;
|
||||
return <Navigate to={applyCompanyPrefix(agentScopedAuditHref(agent.id, legacyAuditSection), agentCompany?.issuePrefix)} replace />;
|
||||
}
|
||||
if (!urlRunId && !urlTab) {
|
||||
return <Navigate to={agentDetailHref(canonicalAgentRef)} replace />;
|
||||
return <Navigate to={applyCompanyPrefix(agentDetailHref(canonicalAgentRef), agentCompany?.issuePrefix)} replace />;
|
||||
}
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
|
||||
|
||||
Reference in new issue
Block a user