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:
DottaandPaperclip committed 2026-10-09 05:19:26 -05:00
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 ({
+20
View File
@@ -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({
+6 -1
View File
@@ -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;
}
+21 -16
View File
@@ -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";