Merge pull request #211 from multica-ai/agent/lambda/5e70a174

fix(agent): fix agent visibility defaults and permission model
This commit is contained in:
Jiayuan Zhang 2026-03-31 15:50:07 +08:00 committed by GitHub
commit 5a7b34cab5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 188 additions and 26 deletions

View file

@ -27,6 +27,7 @@ import {
ChevronDown,
Globe,
Lock,
Settings,
} from "lucide-react";
import type {
Agent,
@ -1154,11 +1155,143 @@ function TasksTab({ agent }: { agent: Agent }) {
);
}
// ---------------------------------------------------------------------------
// Settings Tab
// ---------------------------------------------------------------------------
function SettingsTab({
agent,
runtimes,
onSave,
}: {
agent: Agent;
runtimes: RuntimeDevice[];
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [name, setName] = useState(agent.name);
const [description, setDescription] = useState(agent.description ?? "");
const [visibility, setVisibility] = useState<AgentVisibility>(agent.visibility);
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
const [saving, setSaving] = useState(false);
const dirty =
name !== agent.name ||
description !== (agent.description ?? "") ||
visibility !== agent.visibility ||
maxTasks !== agent.max_concurrent_tasks;
const handleSave = async () => {
if (!name.trim()) {
toast.error("Name is required");
return;
}
setSaving(true);
try {
await onSave({ name: name.trim(), description, visibility, max_concurrent_tasks: maxTasks });
toast.success("Settings saved");
} catch {
toast.error("Failed to save settings");
} finally {
setSaving(false);
}
};
const runtimeDevice = runtimes.find((r) => r.id === agent.runtime_id);
return (
<div className="max-w-lg space-y-6">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this agent do?"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Visibility</Label>
<div className="mt-1.5 flex gap-2">
<button
type="button"
onClick={() => setVisibility("workspace")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "workspace"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Workspace</div>
<div className="text-xs text-muted-foreground">All members can assign</div>
</div>
</button>
<button
type="button"
onClick={() => setVisibility("private")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "private"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Private</div>
<div className="text-xs text-muted-foreground">Only you can assign</div>
</div>
</button>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Max Concurrent Tasks</Label>
<Input
type="number"
min={1}
max={50}
value={maxTasks}
onChange={(e) => setMaxTasks(Number(e.target.value))}
className="mt-1 w-24"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Runtime</Label>
<div className="mt-1 flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm text-muted-foreground">
{agent.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4" />
) : (
<Monitor className="h-4 w-4" />
)}
{runtimeDevice?.name ?? (agent.runtime_mode === "cloud" ? "Cloud" : "Local")}
</div>
</div>
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Save className="h-3.5 w-3.5 mr-1.5" />}
Save Changes
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Agent Detail
// ---------------------------------------------------------------------------
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks";
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks" | "settings";
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
{ id: "instructions", label: "Instructions", icon: FileText },
@ -1166,6 +1299,7 @@ const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
{ id: "tools", label: "Tools", icon: Wrench },
{ id: "triggers", label: "Triggers", icon: Timer },
{ id: "tasks", label: "Tasks", icon: ListTodo },
{ id: "settings", label: "Settings", icon: Settings },
];
function AgentDetail({
@ -1270,6 +1404,13 @@ function AgentDetail({
/>
)}
{activeTab === "tasks" && <TasksTab agent={agent} />}
{activeTab === "settings" && (
<SettingsTab
agent={agent}
runtimes={runtimes}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}
</div>
{/* Delete Confirmation */}

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { X, Trash2, Bot, UserMinus } from "lucide-react";
import { X, Trash2, Bot, Lock, UserMinus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@ -19,8 +19,9 @@ import {
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import type { UpdateIssueRequest } from "@/shared/types";
import type { Agent, UpdateIssueRequest } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues/store";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
@ -206,6 +207,13 @@ export function BatchActionToolbar() {
);
}
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
if (agent.visibility !== "private") return true;
if (agent.owner_id === userId) return true;
if (memberRole === "owner" || memberRole === "admin") return true;
return false;
}
function BatchAssigneePicker({
open,
onOpenChange,
@ -218,10 +226,14 @@ function BatchAssigneePicker({
loading: boolean;
}) {
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorInitials } = useActorName();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
const query = filter.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
@ -297,22 +309,30 @@ function BatchAssigneePicker({
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agents
</div>
{filteredAgents.map((a) => (
<button
key={a.id}
type="button"
onClick={() => {
onUpdate({ assignee_type: "agent", assignee_id: a.id });
onOpenChange(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
<span>{a.name}</span>
</button>
))}
{filteredAgents.map((a) => {
const allowed = canAssignAgent(a, user?.id, memberRole);
return (
<button
key={a.id}
type="button"
disabled={!allowed}
onClick={() => {
if (!allowed) return;
onUpdate({ assignee_type: "agent", assignee_id: a.id });
onOpenChange(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${allowed ? "hover:bg-accent" : "opacity-50 cursor-not-allowed"}`}
>
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
<Bot className="size-2.5" />
</div>
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
{a.visibility === "private" && (
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
)}
</button>
);
})}
</div>
)}
</div>

View file

@ -328,24 +328,23 @@ type UpdateAgentRequest struct {
}
// canManageAgent checks whether the current user can update or delete an agent.
// Workspace-visible agents require owner/admin role. Private agents additionally
// require the user to be the agent's owner (or a workspace owner/admin).
// Workspace-visible agents can be managed by any workspace member.
// Private agents can only be managed by their owner or workspace owner/admin.
func (h *Handler) canManageAgent(w http.ResponseWriter, r *http.Request, agent db.Agent) bool {
wsID := uuidToString(agent.WorkspaceID)
member, ok := h.requireWorkspaceRole(w, r, wsID, "agent not found", "owner", "admin", "member")
if !ok {
return false
}
if agent.Visibility != "private" {
return true
}
isAdmin := roleAllowed(member.Role, "owner", "admin")
isAgentOwner := uuidToString(agent.OwnerID) == requestUserID(r)
if agent.Visibility == "private" && !isAdmin && !isAgentOwner {
if !isAdmin && !isAgentOwner {
writeError(w, http.StatusForbidden, "only the agent owner can manage this private agent")
return false
}
if agent.Visibility != "private" && !isAdmin && !isAgentOwner {
writeError(w, http.StatusForbidden, "insufficient permissions")
return false
}
return true
}

View file

@ -0,0 +1 @@
ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'workspace';

View file

@ -0,0 +1 @@
ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'private';