Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
f3af104bfe feat(notifications): wire web browser desktop notifications
The Settings → System Notifications toggle persisted but never fired
on the web app — `inbox:new` only invoked `desktopAPI.showNotification`,
which is undefined outside Electron. Web users saw the toggle, enabled
it, and got nothing (GH #2339).

- Extract a shared `showSystemNotification` helper in `core/notifications`
  that bridges the Electron preload IPC AND the browser Notifications API.
  On web, the click handler focuses the tab and navigates to the inbox
  with the issue selector.
- Settings tab now requests browser permission when the toggle flips on,
  shows actionable hints when permission is denied / not yet granted /
  unsupported, and offers a "Send test notification" button so users can
  verify their setup without waiting for an inbox event.
- Refresh permission state on visibilitychange so the UI reflects changes
  the user makes in browser site settings.
- Tests cover desktop bridge, granted/denied/missing permission, and
  click-routing.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 14:38:27 +08:00
27 changed files with 565 additions and 1134 deletions

View File

@@ -47,8 +47,10 @@ export function LandingHeader({
<Link
href="/changelog"
className={cn(
headerButtonClassName("ghost", variant),
"hidden sm:inline-flex",
"hidden text-[13px] font-medium transition-colors sm:inline-flex",
variant === "dark"
? "text-white/72 hover:text-white"
: "text-[#0a0d12]/64 hover:text-[#0a0d12]",
)}
>
{t.header.changelog}

View File

@@ -0,0 +1,7 @@
export {
showSystemNotification,
detectWebNotificationSupport,
requestWebNotificationPermission,
isDesktopApp,
} from "./system-notification";
export type { WebNotificationSupport, SystemNotificationPayload } from "./system-notification";

View File

@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
detectWebNotificationSupport,
isDesktopApp,
showSystemNotification,
} from "./system-notification";
interface NotificationMock {
title: string;
options?: NotificationOptions;
listeners: Map<string, EventListener>;
close: () => void;
}
const notificationInstances: NotificationMock[] = [];
class FakeNotification {
static permission: NotificationPermission = "default";
static requestPermission = vi.fn();
title: string;
options?: NotificationOptions;
listeners = new Map<string, EventListener>();
close = vi.fn();
constructor(title: string, options?: NotificationOptions) {
this.title = title;
this.options = options;
notificationInstances.push(this as unknown as NotificationMock);
}
addEventListener(type: string, listener: EventListener) {
this.listeners.set(type, listener);
}
}
const originalNotification = (globalThis as { Notification?: unknown }).Notification;
const originalWindow = (globalThis as { window?: unknown }).window;
beforeEach(() => {
notificationInstances.length = 0;
FakeNotification.permission = "default";
const win: Record<string, unknown> = {
focus: vi.fn(),
location: { assign: vi.fn() },
};
(globalThis as { window?: unknown }).window = win;
(globalThis as { Notification?: unknown }).Notification = FakeNotification;
});
afterEach(() => {
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = originalWindow;
}
if (originalNotification === undefined) {
delete (globalThis as { Notification?: unknown }).Notification;
} else {
(globalThis as { Notification?: unknown }).Notification = originalNotification;
}
});
describe("detectWebNotificationSupport", () => {
it("reports api_unavailable when Notification is missing", () => {
delete (globalThis as { Notification?: unknown }).Notification;
expect(detectWebNotificationSupport()).toBe("api_unavailable");
});
it("reports permission_default when permission has not been asked", () => {
FakeNotification.permission = "default";
expect(detectWebNotificationSupport()).toBe("permission_default");
});
it("reports permission_denied when permission is denied", () => {
FakeNotification.permission = "denied";
expect(detectWebNotificationSupport()).toBe("permission_denied");
});
it("reports supported when permission is granted", () => {
FakeNotification.permission = "granted";
expect(detectWebNotificationSupport()).toBe("supported");
});
});
describe("showSystemNotification", () => {
it("uses desktopAPI when available", () => {
const showNotification = vi.fn();
(globalThis as { window?: { desktopAPI?: unknown } }).window = {
desktopAPI: { showNotification },
};
const result = showSystemNotification({
slug: "acme",
itemId: "item-1",
issueKey: "issue-1",
title: "Hello",
body: "World",
inboxPath: "/acme/inbox?issue=issue-1",
});
expect(result).toBe("delivered_desktop");
expect(showNotification).toHaveBeenCalledWith({
slug: "acme",
itemId: "item-1",
issueKey: "issue-1",
title: "Hello",
body: "World",
});
});
it("creates a web Notification when permission is granted", () => {
FakeNotification.permission = "granted";
const result = showSystemNotification({
slug: "acme",
itemId: "item-1",
issueKey: "issue-1",
title: "Hello",
body: "World",
inboxPath: "/acme/inbox?issue=issue-1",
});
expect(result).toBe("supported");
expect(notificationInstances).toHaveLength(1);
expect(notificationInstances[0]?.title).toBe("Hello");
expect(notificationInstances[0]?.options).toMatchObject({
body: "World",
tag: "item-1",
});
});
it("skips when permission is denied", () => {
FakeNotification.permission = "denied";
const result = showSystemNotification({
slug: "acme",
itemId: "item-1",
issueKey: "issue-1",
title: "Hello",
body: "World",
inboxPath: "/acme/inbox?issue=issue-1",
});
expect(result).toBe("permission_denied");
expect(notificationInstances).toHaveLength(0);
});
it("navigates to inbox path on click", () => {
FakeNotification.permission = "granted";
const assign = vi.fn();
(globalThis as { window?: unknown }).window = {
focus: vi.fn(),
location: { assign },
};
showSystemNotification({
slug: "acme",
itemId: "item-1",
issueKey: "issue-1",
title: "Hello",
body: "World",
inboxPath: "/acme/inbox?issue=issue-1",
});
const click = notificationInstances[0]?.listeners.get("click");
expect(click).toBeTypeOf("function");
click?.(new Event("click"));
expect(assign).toHaveBeenCalledWith("/acme/inbox?issue=issue-1");
});
});
describe("isDesktopApp", () => {
it("is false when desktopAPI is missing", () => {
expect(isDesktopApp()).toBe(false);
});
it("is true when desktopAPI is injected", () => {
(globalThis as { window?: unknown }).window = {
desktopAPI: { showNotification: vi.fn() },
};
expect(isDesktopApp()).toBe(true);
});
});

View File

@@ -0,0 +1,132 @@
import { createLogger } from "../logger";
const logger = createLogger("system-notification");
interface DesktopNotificationPayload {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
}
interface DesktopAPI {
showNotification?: (payload: DesktopNotificationPayload) => void;
}
function getDesktopAPI(): DesktopAPI | undefined {
if (typeof window === "undefined") return undefined;
return (window as unknown as { desktopAPI?: DesktopAPI }).desktopAPI;
}
export type WebNotificationSupport =
| "supported"
| "permission_default"
| "permission_denied"
| "api_unavailable"
| "no_window";
export function detectWebNotificationSupport(): WebNotificationSupport {
if (typeof window === "undefined") return "no_window";
if (typeof Notification === "undefined") return "api_unavailable";
switch (Notification.permission) {
case "granted":
return "supported";
case "denied":
return "permission_denied";
default:
return "permission_default";
}
}
export interface SystemNotificationPayload extends DesktopNotificationPayload {
/** Path to navigate to when the user clicks the banner (web fallback only). */
inboxPath: string;
}
/**
* Fire a native OS notification for an inbox item, abstracting over the
* Electron preload bridge (`window.desktopAPI`) and the browser
* Notifications API. Returns a status string useful for diagnostics; the
* caller is expected to have already gated on focus + the user's
* `system_notifications` preference.
*
* On the desktop app the click handler routing is wired in the main process
* (see apps/desktop/src/main/index.ts). On web we wire it here: the Notification
* click event focuses the tab and navigates to the inbox path with the issue
* selector pre-populated, mirroring the desktop UX as closely as the browser
* sandbox allows.
*/
export function showSystemNotification(payload: SystemNotificationPayload): WebNotificationSupport | "delivered_desktop" {
const desktopAPI = getDesktopAPI();
if (desktopAPI?.showNotification) {
desktopAPI.showNotification({
slug: payload.slug,
itemId: payload.itemId,
issueKey: payload.issueKey,
title: payload.title,
body: payload.body,
});
return "delivered_desktop";
}
const support = detectWebNotificationSupport();
if (support !== "supported") {
logger.debug("skip web notification", { support, title: payload.title });
return support;
}
try {
const notification = new Notification(payload.title, {
body: payload.body,
tag: payload.itemId,
});
notification.addEventListener("click", () => {
try {
window.focus();
} catch {
// Some browsers reject window.focus() outside a user gesture; ignore.
}
window.location.assign(payload.inboxPath);
notification.close();
});
return "supported";
} catch (err) {
logger.warn("web notification failed", err);
return "api_unavailable";
}
}
/**
* Prompt the browser for notification permission. Must be invoked from a
* user gesture (click, keypress) or the request is silently denied in many
* browsers. Returns the resulting permission state, or "unsupported" if the
* Notifications API is missing entirely.
*/
export async function requestWebNotificationPermission(): Promise<
"granted" | "denied" | "default" | "unsupported"
> {
if (typeof window === "undefined" || typeof Notification === "undefined") {
return "unsupported";
}
if (Notification.permission === "granted" || Notification.permission === "denied") {
return Notification.permission;
}
try {
const result = await Notification.requestPermission();
return result;
} catch (err) {
logger.warn("requestPermission failed", err);
return "denied";
}
}
/**
* True when this build is the Electron desktop app — the preload script
* injects `window.desktopAPI`. Used by the settings UI to hide the
* browser-permission affordance, since the main process owns notifications
* on desktop.
*/
export function isDesktopApp(): boolean {
return Boolean(getDesktopAPI());
}

View File

@@ -39,6 +39,7 @@
"./notification-preferences": "./notification-preferences/index.ts",
"./notification-preferences/queries": "./notification-preferences/queries.ts",
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",
"./notifications": "./notifications/index.ts",
"./chat": "./chat/index.ts",
"./chat/queries": "./chat/queries.ts",
"./chat/mutations": "./chat/mutations.ts",

View File

@@ -29,10 +29,11 @@ import {
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import { notificationPreferenceOptions } from "../notification-preferences/queries";
import { showSystemNotification } from "../notifications";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import { chatKeys } from "../chat/queries";
import { useChatStore } from "../chat";
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
import { paths, resolvePostAuthDestination, useHasOnboarded } from "../paths";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
@@ -305,28 +306,19 @@ export function useRealtimeSync(
// workspace B's inbox and 404.
const slug = getCurrentSlug();
if (!slug) return;
const desktopAPI = (
window as unknown as {
desktopAPI?: {
showNotification?: (payload: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
}) => void;
};
}
).desktopAPI;
// `issueKey` matches the inbox page's URL selector (issue id when the
// item is attached to an issue, otherwise the inbox item id). `itemId`
// is the inbox row's own id, needed to fire markInboxRead on click.
desktopAPI?.showNotification?.({
// showSystemNotification handles both the Electron preload bridge and
// the browser Notifications API (web), so this single call works
// across all platforms — see notifications/system-notification.ts.
showSystemNotification({
slug,
itemId: item.id,
issueKey: item.issue_id ?? item.id,
title: item.title,
body: item.body ?? "",
inboxPath: `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(item.issue_id ?? item.id)}`,
});
});

View File

@@ -4,7 +4,6 @@ import { useState } from "react";
import {
AlertCircle,
ArrowLeft,
Lock,
MoreHorizontal,
Trash2,
} from "lucide-react";
@@ -15,7 +14,7 @@ import {
type AgentPresenceDetail,
useWorkspacePresenceMap,
} from "@multica/core/agents";
import { api, ApiError } from "@multica/core/api";
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
@@ -79,19 +78,6 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
const presence: AgentPresenceDetail | null =
agent ? presenceMap.get(agent.id) ?? null : null;
// Fallback fetch: when the agent is missing from the workspace list, hit
// GET /api/agents/{id} directly to disambiguate "doesn't exist" (404) from
// "you can't see this private agent" (403). Only fires after the list has
// settled, so the common path makes zero extra requests.
const { error: detailError } = useQuery({
queryKey: ["agent-detail-probe", wsId, agentId],
queryFn: () => api.getAgent(agentId),
enabled: !agentsLoading && !agent && !!agentId,
retry: false,
});
const isForbidden =
detailError instanceof ApiError && detailError.status === 403;
// Permission hook MUST be called unconditionally — its `agent | null`
// signature handles the not-found / loading case internally so the early
// returns below don't violate the rules of hooks. Backend gates archive
@@ -136,31 +122,6 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
return <DetailLoadingSkeleton />;
}
// --- No permission (private agent the caller is not in allowed_principals for) ---
if (!agent && isForbidden) {
return (
<div className="flex flex-1 min-h-0 flex-col">
<BackHeader paths={paths.agents()} title={t(($) => $.detail.back_to_agents)} />
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<Lock className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{t(($) => $.detail.no_access_title)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t(($) => $.detail.no_access_hint)}
</p>
</div>
<Button
type="button"
size="sm"
onClick={() => navigation.push(paths.agents())}
>
{t(($) => $.detail.back_to_agents_full)}
</Button>
</div>
</div>
);
}
// --- Not found / error ---
if (!agent) {
return (

View File

@@ -375,30 +375,6 @@ function renderIssueDetail(issueId = "issue-1") {
);
}
function renderIssueDetailWithHighlight(
highlightCommentId: string,
issueId = "issue-1",
options: { seedTimeline?: boolean } = {},
) {
const queryClient = createTestQueryClient();
if (options.seedTimeline) {
// Pre-populate the timeline cache so the first render sees timeline.length>0.
// This reproduces the inbox-click race: timeline data is available before
// the issue itself has finished loading, so the effect that scrolls to
// the comment fires once with `loading=true` (skeleton still rendered,
// no comment DOM) and must re-fire when `loading` flips to false.
queryClient.setQueryData(["issues", "timeline", issueId], mockTimeline);
}
const result = render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<IssueDetail issueId={issueId} highlightCommentId={highlightCommentId} />
</QueryClientProvider>
</I18nProvider>,
);
return { ...result, queryClient };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -534,67 +510,6 @@ describe("IssueDetail (shared)", () => {
expect(screen.getByText("I can help with this")).toBeInTheDocument();
});
describe("highlightCommentId scroll-to-comment", () => {
let scrollIntoViewSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
scrollIntoViewSpy = vi.fn();
Element.prototype.scrollIntoView =
scrollIntoViewSpy as unknown as Element["scrollIntoView"];
});
it("scrolls to the highlighted comment after both issue and timeline finish loading", async () => {
renderIssueDetailWithHighlight("comment-2");
// Wait until the comment DOM is rendered.
await waitFor(() => {
expect(document.getElementById("comment-comment-2")).not.toBeNull();
});
// requestAnimationFrame defers the actual scrollIntoView call.
await waitFor(() => {
expect(scrollIntoViewSpy).toHaveBeenCalled();
});
const callContext = scrollIntoViewSpy.mock.contexts[0] as HTMLElement;
expect(callContext.id).toBe("comment-comment-2");
});
it("still scrolls when the timeline is ready before the issue (regression for inbox click)", async () => {
// Reproduces the inbox-click race: timeline data is already in the cache
// (resolved first), but the issue is still pending — so the first render
// sees timeline.length=2 alongside loading=true (skeleton still showing,
// no comment DOM). The scroll effect fires once, fails to find the
// element, and must re-fire when `loading` flips to false. Without
// `loading` in the dep list, that second fire never happens and the
// user lands at the top of the issue.
let resolveIssue: (value: Issue) => void = () => {};
const issuePromise = new Promise<Issue>((resolve) => {
resolveIssue = resolve;
});
mockApiObj.getIssue.mockReturnValue(issuePromise);
renderIssueDetailWithHighlight("comment-2", "issue-1", { seedTimeline: true });
// The skeleton is still showing (issue pending), so even though
// timeline.length>0 the comment DOM is not mounted and no scroll
// can happen yet.
expect(document.getElementById("comment-comment-2")).toBeNull();
expect(scrollIntoViewSpy).not.toHaveBeenCalled();
// Now the issue resolves — comment elements mount, the effect re-runs
// because `loading` is part of its deps, and the scroll fires.
resolveIssue(mockIssue);
await waitFor(() => {
expect(document.getElementById("comment-comment-2")).not.toBeNull();
});
await waitFor(() => {
expect(scrollIntoViewSpy).toHaveBeenCalled();
});
});
});
it("sends empty description when editor is cleared", async () => {
renderIssueDetail();

View File

@@ -581,15 +581,9 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const loading = issueLoading;
// Scroll to highlighted comment once both the issue and its timeline are
// available (fire only once per highlightCommentId). `loading` must be in
// the dep list: when timeline.length flips to >0 while the issue itself is
// still loading, the component is still rendering the skeleton, so
// getElementById finds nothing — without re-running on the loading→false
// transition, the scroll silently never happens and the user lands at the
// top of the issue.
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
useEffect(() => {
if (!highlightCommentId || timeline.length === 0 || loading) return;
if (!highlightCommentId || timeline.length === 0) return;
if (didHighlightRef.current === highlightCommentId) return;
const el = document.getElementById(`comment-${highlightCommentId}`);
if (el) {
@@ -597,10 +591,11 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "instant", block: "center" });
setHighlightedId(highlightCommentId);
setTimeout(() => setHighlightedId(null), 2000);
const timer = setTimeout(() => setHighlightedId(null), 2000);
return () => clearTimeout(timer);
});
}
}, [highlightCommentId, timeline.length, loading]);
}, [highlightCommentId, timeline.length]);
const descEditorRef = useRef<ContentEditorRef>(null);
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({

View File

@@ -107,8 +107,6 @@
"back_to_agents_full": "Back to agents",
"not_found_title": "Agent not found",
"not_found_default": "This agent may have been archived or deleted.",
"no_access_title": "You don't have access to this agent",
"no_access_hint": "Only the agent owner or a workspace admin can view this private agent.",
"try_again": "Try again",
"archived_banner": "This agent is archived. It cannot be assigned or mentioned.",
"restore": "Restore",

View File

@@ -69,7 +69,15 @@
"title": "System Notifications",
"description": "Control native OS notification banners shown when Multica is in the background.",
"label": "Show system notifications",
"hint": "Show a banner from your operating system for new inbox items when the app isn't focused."
"hint": "Show a banner from your operating system for new inbox items when the app isn't focused.",
"permission_default_hint": "Browser notifications are not yet authorized for this site. Toggling on again will prompt your browser for permission.",
"permission_denied_hint": "Browser notifications are blocked. Open the site permissions for this page in your browser settings, allow Notifications, then reload.",
"api_unavailable_hint": "This browser does not support Web Notifications. Try a recent version of Chrome, Edge, Firefox, or Safari, or use the Multica desktop app.",
"permission_denied_toast": "Browser blocked notification permission. Update your browser site settings to allow it.",
"unsupported_toast": "This browser does not support notifications.",
"test_button": "Send test notification",
"test_title": "Multica notifications are working",
"test_body": "You'll see banners like this when new inbox items arrive while Multica isn't focused."
}
},
"tokens": {

View File

@@ -103,8 +103,6 @@
"back_to_agents_full": "返回智能体列表",
"not_found_title": "未找到该智能体",
"not_found_default": "该智能体可能已被归档或删除。",
"no_access_title": "你没有访问该智能体的权限",
"no_access_hint": "只有该私密智能体的拥有者或工作区管理员可以查看。",
"try_again": "重试",
"archived_banner": "该智能体已归档,无法被分配或提及。",
"restore": "恢复",

View File

@@ -69,7 +69,15 @@
"title": "系统通知",
"description": "控制 Multica 在后台时是否显示操作系统的原生通知横幅。",
"label": "显示系统通知",
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。"
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。",
"permission_default_hint": "浏览器尚未授权当前站点发送通知。再次打开开关时,浏览器会向你请求权限。",
"permission_denied_hint": "浏览器已拒绝通知权限。请在浏览器的站点权限设置中允许「通知」并刷新页面。",
"api_unavailable_hint": "当前浏览器不支持 Web 通知。请使用较新版本的 Chrome、Edge、Firefox、Safari或改用 Multica 桌面端。",
"permission_denied_toast": "浏览器拒绝了通知权限。请在站点权限里手动允许。",
"unsupported_toast": "当前浏览器不支持通知。",
"test_button": "发送测试通知",
"test_title": "Multica 通知已生效",
"test_body": "Multica 不在前台时收到新的收件箱条目,会以这种横幅形式提醒你。"
}
},
"tokens": {

View File

@@ -1,12 +1,21 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { notificationPreferenceOptions } from "@multica/core/notification-preferences/queries";
import { useUpdateNotificationPreferences } from "@multica/core/notification-preferences/mutations";
import type { NotificationGroupKey, NotificationPreferences } from "@multica/core/types";
import {
detectWebNotificationSupport,
isDesktopApp,
requestWebNotificationPermission,
showSystemNotification,
type WebNotificationSupport,
} from "@multica/core/notifications";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Switch } from "@multica/ui/components/ui/switch";
import { Button } from "@multica/ui/components/ui/button";
import { toast } from "sonner";
import { useT } from "../../i18n";
@@ -45,6 +54,51 @@ export function NotificationsTab() {
const systemEnabled = preferences.system_notifications !== "muted";
// Browser permission state — desktop app handles notifications natively
// through the main process, so this UI only shows for the web app.
const desktop = isDesktopApp();
const [support, setSupport] = useState<WebNotificationSupport>(() =>
desktop ? "supported" : detectWebNotificationSupport(),
);
// Re-check permission on mount and when the page is re-shown (the user may
// change browser-level permission in another tab, or grant via the URL bar).
useEffect(() => {
if (desktop) return;
const refresh = () => setSupport(detectWebNotificationSupport());
refresh();
document.addEventListener("visibilitychange", refresh);
return () => document.removeEventListener("visibilitychange", refresh);
}, [desktop]);
const handleSystemToggle = async (enabled: boolean) => {
if (enabled && !desktop) {
// Permission requests must originate from a user gesture; doing it
// here (synchronously inside the click handler) keeps that contract.
const result = await requestWebNotificationPermission();
setSupport(detectWebNotificationSupport());
if (result === "denied") {
toast.error(t(($) => $.notifications.system.permission_denied_toast));
} else if (result === "unsupported") {
toast.error(t(($) => $.notifications.system.unsupported_toast));
}
}
handleToggle("system_notifications", enabled);
};
const handleTest = () => {
showSystemNotification({
slug: "",
itemId: "test",
issueKey: "test",
title: t(($) => $.notifications.system.test_title),
body: t(($) => $.notifications.system.test_body),
inboxPath: "/",
});
};
const showPermissionHint = !desktop && systemEnabled && support !== "supported";
return (
<div className="space-y-8">
<section className="space-y-4">
@@ -90,7 +144,7 @@ export function NotificationsTab() {
</div>
<Card>
<CardContent>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5 pr-4">
<p className="text-sm font-medium">{t(($) => $.notifications.system.label)}</p>
@@ -100,9 +154,31 @@ export function NotificationsTab() {
</div>
<Switch
checked={systemEnabled}
onCheckedChange={(checked) => handleToggle("system_notifications", checked)}
onCheckedChange={handleSystemToggle}
/>
</div>
{showPermissionHint && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
{support === "permission_denied" && (
<p>{t(($) => $.notifications.system.permission_denied_hint)}</p>
)}
{support === "permission_default" && (
<p>{t(($) => $.notifications.system.permission_default_hint)}</p>
)}
{support === "api_unavailable" && (
<p>{t(($) => $.notifications.system.api_unavailable_hint)}</p>
)}
</div>
)}
{systemEnabled && support === "supported" && (
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={handleTest}>
{t(($) => $.notifications.system.test_button)}
</Button>
</div>
)}
</CardContent>
</Card>
</section>

View File

@@ -10,12 +10,8 @@ import (
"testing"
)
// authRequestWithAgent makes an authenticated request with X-Agent-ID +
// X-Task-ID headers, causing the server to resolve the actor as an agent
// instead of a member. resolveActor requires both headers to grant agent
// identity (defense against header forgery — see #2359 PR review), so we
// seed a queued task for the agent on demand and pass its UUID as
// X-Task-ID. The task is best-effort cleaned up via test teardown elsewhere.
// authRequestWithAgent makes an authenticated request with X-Agent-ID header,
// causing the server to resolve the actor as an agent instead of a member.
func authRequestWithAgent(t *testing.T, method, path string, body any, agentID string) *http.Response {
t.Helper()
var bodyReader io.Reader
@@ -31,7 +27,6 @@ func authRequestWithAgent(t *testing.T, method, path string, body any, agentID s
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("X-Workspace-ID", testWorkspaceID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", ensureAgentTask(t, agentID))
r, err := http.DefaultClient.Do(req)
if err != nil {
@@ -40,37 +35,6 @@ func authRequestWithAgent(t *testing.T, method, path string, body any, agentID s
return r
}
// ensureAgentTask returns a queued task UUID belonging to the given agent,
// inserting one if none exists. Used by authRequestWithAgent so callers
// can keep treating "set X-Agent-ID" as the single knob for posing as an
// agent — resolveActor's pair-required policy is satisfied transparently.
func ensureAgentTask(t *testing.T, agentID string) string {
t.Helper()
ctx := context.Background()
var taskID string
if err := testPool.QueryRow(ctx,
`SELECT id::text FROM agent_task_queue WHERE agent_id = $1 LIMIT 1`,
agentID,
).Scan(&taskID); err == nil && taskID != "" {
return taskID
}
var runtimeID string
if err := testPool.QueryRow(ctx,
`SELECT runtime_id::text FROM agent WHERE id = $1`,
agentID,
).Scan(&runtimeID); err != nil {
t.Fatalf("ensureAgentTask: load runtime_id for agent %s: %v", agentID, err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
VALUES ($1, $2, 'queued', 0)
RETURNING id::text
`, agentID, runtimeID).Scan(&taskID); err != nil {
t.Fatalf("ensureAgentTask: insert task for agent %s: %v", agentID, err)
}
return taskID
}
// countPendingTasks returns the number of queued/dispatched tasks for an issue.
func countPendingTasks(t *testing.T, issueID string) int {
t.Helper()

View File

@@ -289,17 +289,9 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
})
}
// Resolve the request actor once. Agents bypass the private-agent gate
// to preserve A2A collaboration; members must be in allowed_principals
// (agent owner or workspace owner/admin) to see private agents.
actorType, actorID := h.resolveActor(r, userID, workspaceID)
// All agents (including private) are visible to workspace members.
visible := make([]AgentResponse, 0, len(agents))
for _, a := range agents {
if a.Visibility == "private" && actorType == "member" {
if !memberAllowedForPrivateAgent(a, actorID, member.Role) {
continue
}
}
resp := agentToResponse(a)
if skills, ok := skillMap[resp.ID]; ok {
resp.Skills = skills
@@ -321,16 +313,6 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
// Private-agent gate: members must be in allowed_principals to view
// (and therefore navigate to) a private agent. The 403 lets the front-end
// render an explicit "no access" placeholder instead of a 404 — see
// agent-detail-page.tsx.
workspaceID := uuidToString(agent.WorkspaceID)
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
writeError(w, http.StatusForbidden, "you do not have access to this agent")
return
}
resp := agentToResponse(agent)
// Use the summary query (no `content` column) — the embedded
// AgentSkillSummary only needs id/name/description, and reading large
@@ -832,14 +814,6 @@ func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
// Run history is part of the private-agent gate ("查看历史会话"). Same
// 403 semantics as GetAgent.
workspaceID := uuidToString(agent.WorkspaceID)
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
writeError(w, http.StatusForbidden, "you do not have access to this agent")
return
}
tasks, err := h.Queries.ListAgentTasks(r.Context(), agent.ID)
if err != nil {
@@ -876,8 +850,7 @@ type AgentRunCount struct {
// activity to keep the Agents list cheap regardless of agent count.
func (h *Handler) GetWorkspaceAgentRunCounts(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return
}
@@ -887,23 +860,12 @@ func (h *Handler) GetWorkspaceAgentRunCounts(w http.ResponseWriter, r *http.Requ
return
}
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return
}
resp := make([]AgentRunCount, 0, len(rows))
for _, row := range rows {
agentID := uuidToString(row.AgentID)
if _, ok := allowed[agentID]; !ok {
continue
}
resp = append(resp, AgentRunCount{
AgentID: agentID,
resp := make([]AgentRunCount, len(rows))
for i, row := range rows {
resp[i] = AgentRunCount{
AgentID: uuidToString(row.AgentID),
RunCount: row.RunCount,
})
}
}
writeJSON(w, http.StatusOK, resp)
@@ -917,8 +879,7 @@ func (h *Handler) GetWorkspaceAgentRunCounts(w http.ResponseWriter, r *http.Requ
// empty buckets to keep the response small.
func (h *Handler) GetWorkspaceAgentActivity30d(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return
}
@@ -928,25 +889,14 @@ func (h *Handler) GetWorkspaceAgentActivity30d(w http.ResponseWriter, r *http.Re
return
}
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return
}
resp := make([]AgentActivityBucket, 0, len(rows))
for _, row := range rows {
agentID := uuidToString(row.AgentID)
if _, ok := allowed[agentID]; !ok {
continue
}
resp = append(resp, AgentActivityBucket{
AgentID: agentID,
resp := make([]AgentActivityBucket, len(rows))
for i, row := range rows {
resp[i] = AgentActivityBucket{
AgentID: uuidToString(row.AgentID),
BucketAt: timestampToString(row.Bucket),
TaskCount: row.TaskCount,
FailedCount: row.FailedCount,
})
}
}
writeJSON(w, http.StatusOK, resp)
@@ -963,8 +913,7 @@ func (h *Handler) GetWorkspaceAgentActivity30d(w http.ResponseWriter, r *http.Re
// snapshot.
func (h *Handler) ListWorkspaceAgentTaskSnapshot(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return
}
@@ -974,19 +923,9 @@ func (h *Handler) ListWorkspaceAgentTaskSnapshot(w http.ResponseWriter, r *http.
return
}
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return
}
resp := make([]AgentTaskResponse, 0, len(tasks))
for _, t := range tasks {
if _, ok := allowed[uuidToString(t.AgentID)]; !ok {
continue
}
resp = append(resp, taskToResponse(t))
resp := make([]AgentTaskResponse, len(tasks))
for i, t := range tasks {
resp[i] = taskToResponse(t)
}
writeJSON(w, http.StatusOK, resp)

View File

@@ -1,75 +0,0 @@
package handler
import (
"context"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// canAccessPrivateAgent gates the four protected surfaces for private
// agents: chat / @-mention dispatch, viewing the agent's history, editing
// configuration, and deletion.
//
// Public agents are unrestricted — the predicate returns true unconditionally.
//
// Agent-to-agent traffic is always allowed (actorType == "agent"); this is
// what preserves A2A collaboration even with private agents. The trust
// boundary is at member↔agent, not agent↔agent.
//
// For members, the implicit allowed_principals set is computed inline as:
// {agent.owner_id} workspace owner/admin members. Manual configuration of
// allowed_principals is not exposed in v1; future work can extend this set
// without changing call sites.
func (h *Handler) canAccessPrivateAgent(ctx context.Context, agent db.Agent, actorType, actorID, workspaceID string) bool {
if agent.Visibility != "private" {
return true
}
if actorType == "agent" {
return true
}
if uuidToString(agent.OwnerID) == actorID {
return true
}
member, err := h.getWorkspaceMember(ctx, actorID, workspaceID)
if err != nil {
return false
}
return roleAllowed(member.Role, "owner", "admin")
}
// memberAllowedForPrivateAgent is the pure predicate used by both
// canAccessPrivateAgent and the ListAgents filter loop. Caller must have
// already confirmed agent.Visibility == "private".
func memberAllowedForPrivateAgent(agent db.Agent, userID, role string) bool {
if roleAllowed(role, "owner", "admin") {
return true
}
return uuidToString(agent.OwnerID) == userID
}
// accessibleAgentIDs returns the set of agent IDs in the workspace the actor
// is allowed to see, for use by workspace-wide aggregation endpoints
// (run counts, activity histograms, task snapshots) that need to filter out
// private agents the member can't access. Returns nil and false on error.
func (h *Handler) accessibleAgentIDs(ctx context.Context, workspaceID, actorType, actorID, role string) (map[string]struct{}, bool) {
wsUUID, err := util.ParseUUID(workspaceID)
if err != nil {
return nil, false
}
agents, err := h.Queries.ListAllAgents(ctx, wsUUID)
if err != nil {
return nil, false
}
allowed := make(map[string]struct{}, len(agents))
for _, a := range agents {
if a.Visibility == "private" && actorType == "member" {
if !memberAllowedForPrivateAgent(a, actorID, role) {
continue
}
}
allowed[uuidToString(a.ID)] = struct{}{}
}
return allowed, true
}

View File

@@ -1,503 +0,0 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestMemberAllowedForPrivateAgent_Pure exercises the pure predicate that
// drives the private-agent gate. The gate must allow:
// - workspace owner / admin (regardless of agent ownership)
// - the agent owner (regardless of role)
//
// And deny everyone else. This test runs without a database.
func TestMemberAllowedForPrivateAgent_Pure(t *testing.T) {
ownerUserID := "11111111-1111-1111-1111-111111111111"
otherUserID := "22222222-2222-2222-2222-222222222222"
agent := db.Agent{
OwnerID: util.MustParseUUID(ownerUserID),
}
cases := []struct {
name string
userID string
role string
want bool
}{
{"workspace owner, not agent owner", otherUserID, "owner", true},
{"workspace admin, not agent owner", otherUserID, "admin", true},
{"agent owner with member role", ownerUserID, "member", true},
{"agent owner with admin role", ownerUserID, "admin", true},
{"plain member, not agent owner", otherUserID, "member", false},
{"plain member with no role string", otherUserID, "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := memberAllowedForPrivateAgent(agent, tc.userID, tc.role)
if got != tc.want {
t.Fatalf("memberAllowedForPrivateAgent(userID=%s, role=%s) = %v; want %v",
tc.userID, tc.role, got, tc.want)
}
})
}
}
// privateAgentTestFixture sets up a private agent owned by a freshly created
// user, plus a second non-admin member in the workspace. Returns the agent
// id, the owner's user id, and the unrelated member's user id. The caller's
// own testUserID stays workspace owner so it can act as the privileged
// admin path.
func privateAgentTestFixture(t *testing.T) (agentID, ownerID, memberID string) {
t.Helper()
ctx := context.Background()
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ('Private Agent Owner', 'private-agent-owner@multica.test')
RETURNING id
`).Scan(&ownerID); err != nil {
t.Fatalf("create owner user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM "user" WHERE email = 'private-agent-owner@multica.test'`)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'member')
`, testWorkspaceID, ownerID); err != nil {
t.Fatalf("add owner as member: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ('Plain Member', 'plain-member@multica.test')
RETURNING id
`).Scan(&memberID); err != nil {
t.Fatalf("create plain member user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM "user" WHERE email = 'plain-member@multica.test'`)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'member')
`, testWorkspaceID, memberID); err != nil {
t.Fatalf("add plain member: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id,
instructions, custom_env, custom_args
)
VALUES ($1, 'private-access-test-agent', '', 'cloud', '{}'::jsonb,
$2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
RETURNING id
`, testWorkspaceID, handlerTestRuntimeID(t), ownerID).Scan(&agentID); err != nil {
t.Fatalf("create private agent: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM agent WHERE id = $1`, agentID)
})
return agentID, ownerID, memberID
}
func newRequestAs(userID, method, path string, body any) *http.Request {
req := newRequest(method, path, body)
req.Header.Set("X-User-ID", userID)
return req
}
// TestGetAgent_PrivateAgentForbidsPlainMember verifies the private-agent
// visibility gate at the read-detail endpoint: a workspace member who is
// neither the agent owner nor a workspace owner/admin gets 403, while the
// agent owner and workspace owner both succeed. Mirrors the four-entry-point
// gate (chat, history, edit, delete) on its read surface.
func TestGetAgent_PrivateAgentForbidsPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, ownerID, memberID := privateAgentTestFixture(t)
// Workspace owner (testUserID): allowed via role.
w := httptest.NewRecorder()
testHandler.GetAgent(w, withURLParam(newRequest("GET", "/api/agents/"+agentID, nil), "id", agentID))
if w.Code != http.StatusOK {
t.Fatalf("GetAgent as workspace owner: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Agent owner (plain member who happens to own the agent): allowed.
w = httptest.NewRecorder()
testHandler.GetAgent(w, withURLParam(newRequestAs(ownerID, "GET", "/api/agents/"+agentID, nil), "id", agentID))
if w.Code != http.StatusOK {
t.Fatalf("GetAgent as agent owner: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Plain member (not in allowed_principals): denied with 403.
w = httptest.NewRecorder()
testHandler.GetAgent(w, withURLParam(newRequestAs(memberID, "GET", "/api/agents/"+agentID, nil), "id", agentID))
if w.Code != http.StatusForbidden {
t.Fatalf("GetAgent as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestListAgents_FiltersPrivateForPlainMember verifies that the workspace
// agents listing hides private agents from members who lack access. This is
// what makes the @-mention autocomplete picker (which feeds off this list)
// drop unreachable private agents without any client-side logic.
func TestListAgents_FiltersPrivateForPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, _, memberID := privateAgentTestFixture(t)
// Workspace owner sees the agent.
w := httptest.NewRecorder()
testHandler.ListAgents(w, newRequest("GET", "/api/agents", nil))
if w.Code != http.StatusOK {
t.Fatalf("ListAgents as owner: expected 200, got %d: %s", w.Code, w.Body.String())
}
if !listContainsAgent(t, w.Body.Bytes(), agentID) {
t.Fatalf("ListAgents as owner did not include private agent %s", agentID)
}
// Plain member does NOT see the agent.
w = httptest.NewRecorder()
testHandler.ListAgents(w, newRequestAs(memberID, "GET", "/api/agents", nil))
if w.Code != http.StatusOK {
t.Fatalf("ListAgents as plain member: expected 200, got %d: %s", w.Code, w.Body.String())
}
if listContainsAgent(t, w.Body.Bytes(), agentID) {
t.Fatalf("ListAgents as plain member leaked private agent %s", agentID)
}
}
func listContainsAgent(t *testing.T, body []byte, agentID string) bool {
t.Helper()
var resp []AgentResponse
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("decode ListAgents response: %v", err)
}
for _, a := range resp {
if a.ID == agentID {
return true
}
}
return false
}
// TestListAgentTasks_PrivateAgentForbidsPlainMember verifies that the agent
// task history endpoint (the "查看历史会话" surface) is also gated.
func TestListAgentTasks_PrivateAgentForbidsPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, ownerID, memberID := privateAgentTestFixture(t)
w := httptest.NewRecorder()
testHandler.ListAgentTasks(w, withURLParam(newRequestAs(ownerID, "GET", "/api/agents/"+agentID+"/tasks", nil), "id", agentID))
if w.Code != http.StatusOK {
t.Fatalf("ListAgentTasks as owner: expected 200, got %d: %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
testHandler.ListAgentTasks(w, withURLParam(newRequestAs(memberID, "GET", "/api/agents/"+agentID+"/tasks", nil), "id", agentID))
if w.Code != http.StatusForbidden {
t.Fatalf("ListAgentTasks as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateIssue_AssignToPrivateAgentForbidsPlainMember verifies that the
// issue-assignment surface is gated by the same predicate. Without this gate
// a plain workspace member could side-step chat/@-mention by assigning a
// private agent to an issue and letting normal task dispatch run it.
func TestCreateIssue_AssignToPrivateAgentForbidsPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, ownerID, memberID := privateAgentTestFixture(t)
body := func(actorID string) map[string]any {
return map[string]any{
"title": "assign-to-private-agent test " + actorID,
"status": "todo",
"priority": "medium",
"assignee_type": "agent",
"assignee_id": agentID,
}
}
// Workspace owner (testUserID): allowed.
w := httptest.NewRecorder()
testHandler.CreateIssue(w, newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, body(testUserID)))
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue as workspace owner: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Agent owner (plain member who happens to own the agent): allowed.
w = httptest.NewRecorder()
testHandler.CreateIssue(w, newRequestAs(ownerID, "POST", "/api/issues?workspace_id="+testWorkspaceID, body(ownerID)))
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue as agent owner: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Plain member: denied with 403 — closes the back door where issue
// assignment would otherwise hand the agent a task without going
// through chat / @-mention.
w = httptest.NewRecorder()
testHandler.CreateIssue(w, newRequestAs(memberID, "POST", "/api/issues?workspace_id="+testWorkspaceID, body(memberID)))
if w.Code != http.StatusForbidden {
t.Fatalf("CreateIssue as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateChatSession_PrivateAgentForbidsPlainMember verifies that members
// who can't access the private agent cannot start a chat session against it.
// The chat handler reads workspace context from middleware, so we set it
// explicitly via middleware.SetMemberContext before invoking the handler
// (the test harness doesn't run the real middleware chain).
func TestCreateChatSession_PrivateAgentForbidsPlainMember(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, _, memberID := privateAgentTestFixture(t)
// Load the plain member's row so we can build a realistic context.
memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{
UserID: util.MustParseUUID(memberID),
WorkspaceID: util.MustParseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("load plain member row: %v", err)
}
body := map[string]any{
"agent_id": agentID,
"title": "should be denied",
}
w := httptest.NewRecorder()
req := newRequestAs(memberID, "POST", "/api/chat/sessions", body)
req = req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow))
testHandler.CreateChatSession(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("CreateChatSession as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestGetAgent_RejectsForgedAgentIDHeader is the regression test for the
// #2359 review finding "X-Agent-ID can be forged by a plain member to bypass
// the private gate". A workspace member sets X-Agent-ID to any visible
// agent's UUID without supplying a valid X-Task-ID — resolveActor must now
// fall back to the member identity, so the private-agent gate stays effective.
func TestGetAgent_RejectsForgedAgentIDHeader(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, _, memberID := privateAgentTestFixture(t)
w := httptest.NewRecorder()
req := newRequestAs(memberID, "GET", "/api/agents/"+agentID, nil)
// Forge X-Agent-ID without X-Task-ID. Pre-fix this would have made
// resolveActor return ("agent", agentID) and canAccessPrivateAgent
// would have unconditionally allowed the read.
req.Header.Set("X-Agent-ID", agentID)
req = withURLParam(req, "id", agentID)
testHandler.GetAgent(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("GetAgent with forged X-Agent-ID: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestListChatMessages_PrivateAgentForbidsAfterAccessRevoked is the regression
// test for the #2359 review finding "chat history read path doesn't re-gate".
// A member who created a chat session is later denied access to the agent
// (here simulated by the member never being on the allowlist for a private
// agent owned by someone else; the equivalent of an after-the-fact ownership
// transfer). The session row still names them as creator, but the read
// endpoints must refuse to surface the transcript.
func TestListChatMessages_PrivateAgentForbidsAfterAccessRevoked(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, memberID := privateAgentTestFixture(t)
// Insert a chat session row directly with the plain member as creator,
// bypassing CreateChatSession's own gate. This represents a session
// that existed before the member lost access (or before the gate
// landed).
var sessionID string
if err := testPool.QueryRow(ctx, `
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
VALUES ($1, $2, $3, 'pre-revocation session', 'active')
RETURNING id
`, testWorkspaceID, agentID, memberID).Scan(&sessionID); err != nil {
t.Fatalf("seed chat session: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM chat_session WHERE id = $1`, sessionID)
})
memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: util.MustParseUUID(memberID),
WorkspaceID: util.MustParseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("load plain member row: %v", err)
}
w := httptest.NewRecorder()
req := newRequestAs(memberID, "GET", "/api/chat/sessions/"+sessionID+"/messages", nil)
req = req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow))
req = withURLParam(req, "sessionId", sessionID)
testHandler.ListChatMessages(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("ListChatMessages on stale session: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestMentionAgent_RejectsCrossWorkspaceAgentUUID is the regression test for
// the #2359 review finding "@mention path doesn't constrain the mentioned
// agent to the current workspace". A plain member in workspace A who happens
// to be owner of workspace B should NOT be able to @mention a private agent
// in workspace B from a comment on a workspace-A issue and have it pass the
// gate (the gate was being applied against the wrong workspace's roles).
func TestMentionAgent_RejectsCrossWorkspaceAgentUUID(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Create a separate workspace + agent runtime + private agent.
var foreignWorkspaceID, foreignUserID, foreignRuntimeID, foreignAgentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ('Foreign Owner', 'cross-ws-foreign@multica.test')
RETURNING id
`).Scan(&foreignUserID); err != nil {
t.Fatalf("create foreign user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM "user" WHERE email = 'cross-ws-foreign@multica.test'`)
})
if err := testPool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description, issue_prefix)
VALUES ('Cross-WS Foreign', 'cross-ws-foreign', '', 'XWF')
RETURNING id
`).Scan(&foreignWorkspaceID); err != nil {
t.Fatalf("create foreign workspace: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM workspace WHERE slug = 'cross-ws-foreign'`)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
`, foreignWorkspaceID, foreignUserID); err != nil {
t.Fatalf("add foreign member: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at)
VALUES ($1, NULL, 'Foreign Runtime', 'cloud', 'foreign_test', 'online', 'Foreign', '{}'::jsonb, now())
RETURNING id
`, foreignWorkspaceID).Scan(&foreignRuntimeID); err != nil {
t.Fatalf("create foreign runtime: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO agent (workspace_id, name, description, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id, instructions, custom_env, custom_args)
VALUES ($1, 'foreign-private-agent', '', 'cloud', '{}'::jsonb, $2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
RETURNING id
`, foreignWorkspaceID, foreignRuntimeID, foreignUserID).Scan(&foreignAgentID); err != nil {
t.Fatalf("create foreign agent: %v", err)
}
// Create an issue in OUR workspace and a comment that @mentions the
// foreign agent's UUID. testUserID is owner of our workspace; pre-fix
// the gate would have applied our-workspace-owner status to the foreign
// agent and enqueued a task.
var issueID, commentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number)
VALUES ($1, 'cross-ws mention test', 'todo', 'medium', 'member', $2,
COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1)
RETURNING id
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
t.Fatalf("create test issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
// Multica's mention format is markdown-linked: [@Name](mention://agent/<uuid>).
mention := "[@Foreign](mention://agent/" + foreignAgentID + ")"
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content)
VALUES ($1, $2, 'member', $3, $4)
RETURNING id
`, testWorkspaceID, issueID, testUserID, mention).Scan(&commentID); err != nil {
t.Fatalf("create test comment: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM comment WHERE id = $1`, commentID)
})
issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID))
if err != nil {
t.Fatalf("load test issue: %v", err)
}
comment, err := testHandler.Queries.GetComment(ctx, util.MustParseUUID(commentID))
if err != nil {
t.Fatalf("load test comment: %v", err)
}
// Count tasks for the foreign agent before. Calling the dispatcher
// directly bypasses HTTP-layer concerns and exercises only the
// workspace-scoping check.
var beforeCount int
if err := testPool.QueryRow(ctx,
`SELECT COUNT(*) FROM agent_task_queue WHERE agent_id = $1`,
foreignAgentID,
).Scan(&beforeCount); err != nil {
t.Fatalf("count tasks before: %v", err)
}
testHandler.enqueueMentionedAgentTasks(ctx, issue, comment, nil, "member", testUserID)
var afterCount int
if err := testPool.QueryRow(ctx,
`SELECT COUNT(*) FROM agent_task_queue WHERE agent_id = $1`,
foreignAgentID,
).Scan(&afterCount); err != nil {
t.Fatalf("count tasks after: %v", err)
}
if afterCount != beforeCount {
t.Fatalf("foreign agent task count changed: before=%d after=%d — cross-workspace mention was not rejected",
beforeCount, afterCount)
}
}

View File

@@ -60,14 +60,6 @@ func (h *Handler) CreateChatSession(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "agent is archived")
return
}
// Private-agent gate: members must be in allowed_principals to start
// a chat with a private agent. Agent-to-agent chat sessions bypass
// the gate so A2A collaboration still works.
actorType, actorID := h.resolveActor(r, userID, workspaceID)
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
writeError(w, http.StatusForbidden, "you do not have access to this agent")
return
}
session, err := h.Queries.CreateChatSession(r.Context(), db.CreateChatSessionParams{
WorkspaceID: workspaceUUID,
@@ -90,23 +82,6 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
}
workspaceID := ctxWorkspaceID(r.Context())
// Compute the accessible-agents set once and use it to drop sessions
// whose target agent the caller no longer has access to — without this,
// a member whose role was downgraded would still see the session list
// (and transcripts via ListChatMessages) for any private agent they
// previously had access to. Falls back to the user's role from the
// workspace member context.
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return
}
status := r.URL.Query().Get("status")
// Two call sites → two row types with identical shape. Collect into a
@@ -121,12 +96,9 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
return
}
resp = make([]ChatSessionResponse, 0, len(rows))
for _, s := range rows {
if _, ok := allowed[uuidToString(s.AgentID)]; !ok {
continue
}
resp = append(resp, ChatSessionResponse{
resp = make([]ChatSessionResponse, len(rows))
for i, s := range rows {
resp[i] = ChatSessionResponse{
ID: uuidToString(s.ID),
WorkspaceID: uuidToString(s.WorkspaceID),
AgentID: uuidToString(s.AgentID),
@@ -136,7 +108,7 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
HasUnread: s.HasUnread,
CreatedAt: timestampToString(s.CreatedAt),
UpdatedAt: timestampToString(s.UpdatedAt),
})
}
}
} else {
rows, err := h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
@@ -147,12 +119,9 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
return
}
resp = make([]ChatSessionResponse, 0, len(rows))
for _, s := range rows {
if _, ok := allowed[uuidToString(s.AgentID)]; !ok {
continue
}
resp = append(resp, ChatSessionResponse{
resp = make([]ChatSessionResponse, len(rows))
for i, s := range rows {
resp[i] = ChatSessionResponse{
ID: uuidToString(s.ID),
WorkspaceID: uuidToString(s.WorkspaceID),
AgentID: uuidToString(s.AgentID),
@@ -162,7 +131,7 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
HasUnread: s.HasUnread,
CreatedAt: timestampToString(s.CreatedAt),
UpdatedAt: timestampToString(s.UpdatedAt),
})
}
}
}
writeJSON(w, http.StatusOK, resp)
@@ -192,29 +161,6 @@ func (h *Handler) loadChatSessionForUser(w http.ResponseWriter, r *http.Request,
return session, true
}
// gateChatSessionForUser combines the session ownership check with the
// private-agent access gate so a member who has lost access to the target
// agent (role downgrade, ownership transfer, agent flipped to private)
// cannot continue reading the chat transcript even though they remain the
// session creator. Returns ok=false after writing the error response.
func (h *Handler) gateChatSessionForUser(w http.ResponseWriter, r *http.Request, userID, workspaceID, sessionID string) (db.ChatSession, bool) {
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return db.ChatSession{}, false
}
agent, err := h.Queries.GetAgent(r.Context(), session.AgentID)
if err != nil {
writeError(w, http.StatusNotFound, "agent not found")
return db.ChatSession{}, false
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
writeError(w, http.StatusForbidden, "you do not have access to this agent")
return db.ChatSession{}, false
}
return session, true
}
func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -223,7 +169,7 @@ func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}
@@ -337,12 +283,8 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) {
return
}
// Load chat session and re-check the private-agent gate on every send.
// The session's creator passed the gate at create time, but their
// workspace role (or the agent's owner) may have changed since — keep
// stale sessions from being a back-door into a private agent the user
// can no longer reach. Agent senders bypass to preserve A2A collaboration.
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
// Load chat session.
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}
@@ -414,7 +356,7 @@ func (h *Handler) ListChatMessages(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}
@@ -455,7 +397,7 @@ func (h *Handler) MarkChatSessionRead(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}
@@ -486,8 +428,7 @@ type PendingChatTaskItem struct {
// ListPendingChatTasks returns every in-flight chat task owned by the current
// user in this workspace. Drives the FAB's "running" indicator when the chat
// window is closed (no per-session query is subscribed). Tasks belonging to
// private agents the caller has lost access to are dropped from the response.
// window is closed (no per-session query is subscribed).
func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -495,17 +436,6 @@ func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
}
workspaceID := ctxWorkspaceID(r.Context())
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
actorType, actorID := h.resolveActor(r, userID, workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
return
}
rows, err := h.Queries.ListPendingChatTasksByCreator(r.Context(), db.ListPendingChatTasksByCreatorParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
@@ -515,37 +445,13 @@ func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
return
}
// Map session → agent so we can filter without an N+1. The user's own
// session list is small, so one extra query is cheaper than per-row
// lookups.
sessions, err := h.Queries.ListAllChatSessionsByCreator(r.Context(), db.ListAllChatSessionsByCreatorParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve chat session agents")
return
}
sessionAgent := make(map[string]string, len(sessions))
for _, s := range sessions {
sessionAgent[uuidToString(s.ID)] = uuidToString(s.AgentID)
}
items := make([]PendingChatTaskItem, 0, len(rows))
for _, row := range rows {
sessionID := uuidToString(row.ChatSessionID)
agentID, hasAgent := sessionAgent[sessionID]
if !hasAgent {
continue
}
if _, ok := allowed[agentID]; !ok {
continue
}
items = append(items, PendingChatTaskItem{
items := make([]PendingChatTaskItem, len(rows))
for i, row := range rows {
items[i] = PendingChatTaskItem{
TaskID: uuidToString(row.TaskID),
Status: row.Status,
ChatSessionID: sessionID,
})
ChatSessionID: uuidToString(row.ChatSessionID),
}
}
writeJSON(w, http.StatusOK, PendingChatTasksResponse{Tasks: items})
}
@@ -561,7 +467,7 @@ func (h *Handler) GetPendingChatTask(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
if !ok {
return
}

View File

@@ -426,23 +426,20 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
continue
}
agentUUID := parseUUID(m.ID)
// Load the agent scoped to the current issue's workspace. Using the
// bare GetAgent here would let a mention resolve to an agent in a
// different workspace, and the visibility check below would then be
// applied against the wrong workspace's roles (a workspace owner in
// THIS workspace would pass the gate for a private agent that lives
// in someone else's workspace).
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: agentUUID,
WorkspaceID: issue.WorkspaceID,
})
// Load the agent to check visibility, archive status, and trigger config.
agent, err := h.Queries.GetAgent(ctx, agentUUID)
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
continue
}
// Private-agent gate (member→private requires allowed_principals;
// agent→agent always passes).
if !h.canAccessPrivateAgent(ctx, agent, authorType, authorID, wsID) {
continue
// Private agents can only be mentioned by the agent owner or workspace admin/owner.
if agent.Visibility == "private" && authorType == "member" {
isOwner := uuidToString(agent.OwnerID) == authorID
if !isOwner {
member, err := h.getWorkspaceMember(ctx, authorID, wsID)
if err != nil || !roleAllowed(member.Role, "owner", "admin") {
continue
}
}
}
// Dedup: skip if this agent already has a pending task for this issue.
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{

View File

@@ -235,29 +235,15 @@ func requestUserID(r *http.Request) string {
}
// resolveActor determines whether the request is from an agent or a human member.
// To claim "agent" identity the request MUST carry both X-Agent-ID and a valid
// X-Task-ID, and the task must belong to the claimed agent. Otherwise we fall
// back to "member" using the user ID from the session.
//
// X-Agent-ID alone is not trusted: any workspace member can guess or observe
// an agent's UUID, and a member-supplied X-Agent-ID would otherwise let that
// member impersonate the agent and bypass the private-agent gate (#2359
// review). The daemon always pairs the two headers — X-Agent-ID names the
// agent claiming the request, X-Task-ID names the in-flight task that
// authorizes it — so requiring both has no effect on legitimate agent
// callers but closes the impersonation path.
//
// If X-Agent-ID and X-Task-ID headers are both set, validates that the task
// belongs to the claimed agent (defense-in-depth against manual header spoofing).
// If only X-Agent-ID is set, validates that the agent belongs to the workspace.
// Returns ("agent", agentID) on success, ("member", userID) otherwise.
func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (actorType, actorID string) {
agentID := r.Header.Get("X-Agent-ID")
if agentID == "" {
return "member", userID
}
taskID := r.Header.Get("X-Task-ID")
if taskID == "" {
slog.Debug("resolveActor: X-Agent-ID present but X-Task-ID missing, refusing to trust agent identity", "agent_id", agentID)
return "member", userID
}
agentUUID, err := util.ParseUUID(agentID)
if err != nil {
@@ -271,15 +257,18 @@ func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (act
return "member", userID
}
taskUUID, err := util.ParseUUID(taskID)
if err != nil {
slog.Debug("resolveActor: X-Task-ID is not a valid UUID, falling back to member", "task_id", taskID)
return "member", userID
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil || uuidToString(task.AgentID) != agentID {
slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID)
return "member", userID
// When X-Task-ID is provided, cross-check that the task belongs to this agent.
if taskID := r.Header.Get("X-Task-ID"); taskID != "" {
taskUUID, err := util.ParseUUID(taskID)
if err != nil {
slog.Debug("resolveActor: X-Task-ID is not a valid UUID, falling back to member", "task_id", taskID)
return "member", userID
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil || uuidToString(task.AgentID) != agentID {
slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID)
return "member", userID
}
}
return "agent", agentID

View File

@@ -198,27 +198,6 @@ func createHandlerTestAgent(t *testing.T, name string, mcpConfig []byte) string
return agentID
}
// createHandlerTestTaskForAgent seeds a queued agent_task_queue row for the
// given agent and returns the task UUID. Used by tests that need to set
// X-Task-ID alongside X-Agent-ID — resolveActor now requires the pair to be
// present and consistent before granting "agent" actor identity.
func createHandlerTestTaskForAgent(t *testing.T, agentID string) string {
t.Helper()
var taskID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
VALUES ($1, $2, 'queued', 0)
RETURNING id
`, agentID, handlerTestRuntimeID(t)).Scan(&taskID); err != nil {
t.Fatalf("failed to create handler test task: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
})
return taskID
}
func fetchAgentMcpConfig(t *testing.T, agentID string) []byte {
t.Helper()
@@ -1838,18 +1817,14 @@ func TestResolveActor(t *testing.T) {
wantActorType: "member",
},
{
// X-Agent-ID without X-Task-ID is not trusted — otherwise a
// workspace member who guesses an agent's UUID could impersonate
// it and bypass the private-agent gate. See resolveActor for the
// rationale.
name: "agent ID without task ID returns member",
name: "valid agent ID returns agent",
agentIDHeader: agentID,
wantActorType: "member",
wantActorType: "agent",
wantIsAgent: true,
},
{
name: "non-existent agent ID with task returns member",
name: "non-existent agent ID returns member",
agentIDHeader: "00000000-0000-0000-0000-000000000099",
taskIDHeader: taskID,
wantActorType: "member",
},
{
@@ -2113,13 +2088,10 @@ func TestAgentReplyDoesNotInheritParentMentions(t *testing.T) {
// 3. Agent A posts a reply in the same thread with NO mentions.
// With the fix, this must NOT inherit the parent mention of Agent B.
// resolveActor requires X-Task-ID paired with X-Agent-ID to trust the
// agent identity, so we seed a task that belongs to agent A.
agentATask := createHandlerTestTaskForAgent(t, agentA)
w = postComment(issueID, map[string]any{
"content": "No reply needed — just an acknowledgment.",
"parent_id": parentComment.ID,
}, map[string]string{"X-Agent-ID": agentA, "X-Task-ID": agentATask})
}, map[string]string{"X-Agent-ID": agentA})
if w.Code != http.StatusCreated {
t.Fatalf("agent A reply: expected 201, got %d: %s", w.Code, w.Body.String())
}
@@ -2192,16 +2164,12 @@ func TestMemberReplyToAgentRootDoesNotInheritParentMentions(t *testing.T) {
// 1. Agent J posts a PR-completion comment that @mentions Reviewer for review.
// This is a deliberate handoff and must enqueue a task for Reviewer.
// X-Task-ID is required alongside X-Agent-ID for resolveActor to grant
// the "agent" actor identity (defense against header forgery).
jAgentTask := createHandlerTestTaskForAgent(t, jAgent)
w = httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": fmt.Sprintf("PR ready. [@Reviewer](mention://agent/%s) please review this.", reviewerAgent),
})
r = withURLParam(r, "id", issueID)
r.Header.Set("X-Agent-ID", jAgent)
r.Header.Set("X-Task-ID", jAgentTask)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("J PR completion: expected 201, got %d: %s", w.Code, w.Body.String())
@@ -2287,10 +2255,7 @@ func TestAgentExplicitMentionStillTriggers(t *testing.T) {
// Agent A posts a top-level comment that explicitly @mentions Agent B —
// a deliberate handoff. This must enqueue a task for Agent B, and must
// not enqueue a self-trigger for Agent A. resolveActor requires
// X-Task-ID to grant "agent" identity; without it the self-trigger
// suppression (authorType=="agent") would not fire.
agentATask := createHandlerTestTaskForAgent(t, agentA)
// not enqueue a self-trigger for Agent A.
explicitMention := fmt.Sprintf("[@Agent B](mention://agent/%s) please take it from here", agentB)
w = httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
@@ -2298,7 +2263,6 @@ func TestAgentExplicitMentionStillTriggers(t *testing.T) {
})
r = withURLParam(r, "id", issueID)
r.Header.Set("X-Agent-ID", agentA)
r.Header.Set("X-Task-ID", agentATask)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("agent A handoff: expected 201, got %d: %s", w.Code, w.Body.String())

View File

@@ -1584,11 +1584,9 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
// validateAssigneePair verifies the (assignee_type, assignee_id) pair refers
// to an existing entity in the workspace. For agent assignees it also rejects
// archived agents and runs the private-agent gate via canAccessPrivateAgent
// — assigning an issue is a task-producing surface, so it must use the same
// predicate as chat / @-mention / history. Agent callers (X-Agent-ID) bypass
// the gate so A2A flows can still hand work off to private agents.
// to an existing entity in the workspace. For agent assignees it also enforces
// visibility (private agents are only assignable by their owner or by
// workspace admins/owners) and rejects archived agents.
//
// Returns (statusCode, errorMessage). statusCode == 0 means the pair is valid;
// callers should treat any non-zero status as a rejection and surface it back
@@ -1626,9 +1624,14 @@ func (h *Handler) validateAssigneePair(ctx context.Context, r *http.Request, wor
if agent.ArchivedAt.Valid {
return http.StatusBadRequest, "cannot assign to archived agent"
}
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
if !h.canAccessPrivateAgent(ctx, agent, actorType, actorID, workspaceID) {
return http.StatusForbidden, "cannot assign to private agent"
if agent.Visibility == "private" {
userID := requestUserID(r)
if uuidToString(agent.OwnerID) != userID {
member, err := h.getWorkspaceMember(ctx, userID, workspaceID)
if err != nil || !roleAllowed(member.Role, "owner", "admin") {
return http.StatusForbidden, "cannot assign to private agent"
}
}
}
return 0, ""
default:

View File

@@ -236,14 +236,10 @@ func TestSubscriberAPI(t *testing.T) {
// Subscribe with X-Agent-ID set — no body, so the handler must default
// to subscribing the agent itself (not the member behind X-User-ID).
// resolveActor requires X-Task-ID alongside X-Agent-ID to grant the
// "agent" identity (defense against header forgery), so seed a task.
agentTask := createHandlerTestTaskForAgent(t, agentID)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
req = withURLParam(req, "id", issueID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", agentTask)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SubscribeToIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
@@ -274,13 +270,10 @@ func TestSubscriberAPI(t *testing.T) {
}
// Unsubscribe with X-Agent-ID set — same default-to-caller expectation.
// Re-use the same task as the subscribe call; resolveActor only
// validates that the task belongs to the agent, not which task.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
req = withURLParam(req, "id", issueID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", agentTask)
testHandler.UnsubscribeFromIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UnsubscribeFromIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"sync"
@@ -82,15 +81,13 @@ func checkOrigin(r *http.Request) bool {
if origin == "" {
return true
}
// Same-origin: native clients (mobile, CLI) have no real page host, so
// their WebSocket library fills Origin with the connection target —
// which equals the server's own Host. They authenticate via bearer
// token, not auto-attached cookies, so CSRF (the attack the explicit
// allowlist below defends against) does not apply. This matches the
// gorilla/websocket default CheckOrigin behavior; the allowlist exists
// in addition to support cross-origin browser clients (web/desktop).
if u, err := url.Parse(origin); err == nil && strings.EqualFold(u.Host, r.Host) {
return true
// Native mobile clients authenticate with an explicit first-frame token.
// Origin is a browser CSRF control, so only skip it for mobile requests
// that are not carrying the browser session cookie.
if r.URL.Query().Get("client_platform") == "mobile" {
if _, err := r.Cookie(auth.AuthCookieName); err == http.ErrNoCookie {
return true
}
}
origins := allowedWSOrigins.Load().([]string)
for _, allowed := range origins {

View File

@@ -81,6 +81,46 @@ func connectWS(t *testing.T, server *httptest.Server) *websocket.Conn {
return conn
}
func TestCheckOrigin_AllowsMobileClientWithoutCookie(t *testing.T) {
prevOrigins := allowedWSOrigins.Load().([]string)
SetAllowedOrigins([]string{"https://app.example.com"})
t.Cleanup(func() { SetAllowedOrigins(prevOrigins) })
req := httptest.NewRequest(http.MethodGet, "/ws?client_platform=mobile", nil)
req.Header.Set("Origin", "https://not-allowed.example.com")
if !checkOrigin(req) {
t.Fatal("expected mobile request without browser auth cookie to bypass Origin whitelist")
}
}
func TestCheckOrigin_RejectsDisallowedOriginWithoutMobileClient(t *testing.T) {
prevOrigins := allowedWSOrigins.Load().([]string)
SetAllowedOrigins([]string{"https://app.example.com"})
t.Cleanup(func() { SetAllowedOrigins(prevOrigins) })
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
req.Header.Set("Origin", "https://not-allowed.example.com")
if checkOrigin(req) {
t.Fatal("expected disallowed Origin without mobile client platform to be rejected")
}
}
func TestCheckOrigin_RejectsMobileClientWithBrowserCookie(t *testing.T) {
prevOrigins := allowedWSOrigins.Load().([]string)
SetAllowedOrigins([]string{"https://app.example.com"})
t.Cleanup(func() { SetAllowedOrigins(prevOrigins) })
req := httptest.NewRequest(http.MethodGet, "/ws?client_platform=mobile", nil)
req.Header.Set("Origin", "https://not-allowed.example.com")
req.AddCookie(&http.Cookie{Name: auth.AuthCookieName, Value: "browser-session"})
if checkOrigin(req) {
t.Fatal("expected disallowed mobile Origin with browser auth cookie to be rejected")
}
}
// totalClients counts all currently registered clients.
func totalClients(hub *Hub) int {
hub.mu.RLock()
@@ -311,41 +351,3 @@ func (l *lockedWriter) Write(p []byte) (int, error) {
defer l.mu.Unlock()
return l.w.Write(p)
}
func TestCheckOrigin(t *testing.T) {
prev := allowedWSOrigins.Load().([]string)
SetAllowedOrigins([]string{
"http://localhost:3000",
"https://multica.ai",
})
t.Cleanup(func() { SetAllowedOrigins(prev) })
cases := []struct {
name string
host string
origin string
want bool
}{
{"empty origin allowed", "api.multica.ai", "", true},
{"same-origin allowed (native client default)", "localhost:8080", "http://localhost:8080", true},
{"same-origin allowed (https)", "api.multica.ai", "https://api.multica.ai", true},
{"same-origin allowed (case-insensitive host, RFC 7230)", "API.Multica.AI", "https://api.multica.ai", true},
{"whitelisted origin allowed (web cross-origin)", "localhost:8080", "http://localhost:3000", true},
{"whitelisted origin allowed (prod web)", "api.multica.ai", "https://multica.ai", true},
{"unknown origin rejected (CSWSH defense)", "api.multica.ai", "https://evil.com", false},
{"different port rejected", "localhost:8080", "http://localhost:9999", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/ws", nil)
r.Host = tc.host
if tc.origin != "" {
r.Header.Set("Origin", tc.origin)
}
if got := checkOrigin(r); got != tc.want {
t.Fatalf("checkOrigin(host=%q, origin=%q) = %v, want %v", tc.host, tc.origin, got, tc.want)
}
})
}
}

View File

@@ -383,27 +383,6 @@ func (s *AutopilotService) shouldSkipDispatch(ctx context.Context, ap db.Autopil
if rt.Status != "online" {
return "agent runtime is " + rt.Status + " at dispatch time", true
}
// Private-agent gate at the autopilot layer. Caller identity = the
// autopilot's creator: if the creator no longer has access to the
// (now-private) target agent, the dispatch is recorded as `skipped`.
// Agent-created autopilots bypass the gate to preserve A2A
// collaboration. Errors loading the workspace member fail closed —
// without an authoritative role the gate cannot grant access.
if agent.Visibility == "private" && ap.CreatedByType == "member" {
creatorID := util.UUIDToString(ap.CreatedByID)
if util.UUIDToString(agent.OwnerID) != creatorID {
member, err := s.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: ap.CreatedByID,
WorkspaceID: ap.WorkspaceID,
})
if err != nil {
return "autopilot creator no longer in workspace", true
}
if member.Role != "owner" && member.Role != "admin" {
return "autopilot creator lacks access to private assignee agent", true
}
}
}
return "", false
}