mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* feat(runtime): visibility (public/private) gate on CreateAgent / UpdateAgent Closes the hole where a plain workspace member could pick another member's runtime in the Create Agent dialog and bind an agent to it — the backend wasn't checking runtime ownership, so the agent ran on someone else's hardware / tokens. Reported on GH #1804. Schema - Migration 083 adds agent_runtime.visibility ('private' default, 'public') with a CHECK constraint. Existing rows default to private — same ownership semantics as before, no behavior change for legacy data. Backend - canUseRuntimeForAgent predicate: allow when caller is workspace owner/admin, the runtime owner, or the runtime is public. - CreateAgent and UpdateAgent both gate on it: UpdateAgent matters because a plain member could otherwise create on their own runtime, then re-bind to a private one. - PATCH /api/runtimes/:id accepts { visibility } — owner/admin only, validated against the same private/public allow-list. Frontend - Create-agent dialog renders other-owned private runtimes disabled with a Lock badge + tooltip explaining who to ask. - Inspector runtime-picker disables the same set so re-binding fails the same way at the UI layer. - Runtime detail diagnostics gains a Visibility editor (owner/admin) or read-only chip (everyone else). - Runtime list shows a private/public chip next to the name. Tests - Go: canUseRuntimeForAgent truth table; CreateAgent / UpdateAgent end-to-end gate tests (admin / runtime owner / plain member); PATCH visibility owner / admin / member / invalid-value coverage. - Vitest: create-agent dialog disabled state on private/public runtimes, default-runtime selection skips locked rows; runtime detail visibility editor → mutation, read-only fallback. Migrating runtimes: existing rows default to private to preserve the "owner only" status quo. Owners switch to public via the detail page diagnostics card. Co-authored-by: multica-agent <github@multica.ai> * fix(runtime): apply timezone+visibility atomically; don't seed locked template runtime Two issues surfaced in review of MUL-2062: 1. PATCH /api/runtimes/:id ran the timezone branch first, which: - returned early on a tz no-op, silently dropping a concurrent `visibility` patch in the same body; - committed the timezone mutation (+ usage rollup rebuild) before validating visibility, so an invalid visibility left the row half-updated. Validate every field first, then run the mutations in order. The no-op short-circuit now only triggers when nothing else is requested. 2. The Create Agent dialog in duplicate mode unconditionally seeded `template.runtime_id` as the selected runtime, even when that runtime is now private and owned by someone else — the user saw a selected row they couldn't submit (Create → backend 403). Fall back to the first usable runtime when the template's runtime is locked, and gate the Create button on `selectedRuntimeLocked` as defense in depth. Tests: - Go: TestUpdateAgentRuntime_CombinedPatchAppliesBoth (tz no-op + visibility flip), TestUpdateAgentRuntime_InvalidVisibilityDoesNotMutateTimezone (atomic-fail invariant). - Vitest: duplicate template pointing at a locked runtime now seeds the first usable one; Create button stays disabled when no usable alternative exists. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
269 lines
9.1 KiB
TypeScript
269 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { Cloud, Lock, Monitor } from "lucide-react";
|
|
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
|
|
import { ActorAvatar } from "../../../common/actor-avatar";
|
|
import {
|
|
PickerItem,
|
|
PropertyPicker,
|
|
} from "../../../issues/components/pickers";
|
|
import { ProviderLogo } from "../../../runtimes/components/provider-logo";
|
|
import { CHIP_CLASS } from "./chip";
|
|
import { useT } from "../../../i18n";
|
|
|
|
type Filter = "mine" | "all";
|
|
|
|
/**
|
|
* Inline runtime picker for the agent inspector. Mirrors the runtime selector
|
|
* the previous Settings tab embedded — same Mine/All filter, same provider
|
|
* logos, same online dot — but renders inside the inspector's PropRow so
|
|
* users don't have to leave the page to switch runtime.
|
|
*/
|
|
export function RuntimePicker({
|
|
value,
|
|
runtimes,
|
|
members,
|
|
currentUserId,
|
|
canEdit = true,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
runtimes: AgentRuntime[];
|
|
members: MemberWithUser[];
|
|
currentUserId: string | null;
|
|
/** When false, render a static read-only display and skip the popover. */
|
|
canEdit?: boolean;
|
|
onChange: (runtimeId: string) => Promise<void> | void;
|
|
}) {
|
|
const { t } = useT("agents");
|
|
const [open, setOpen] = useState(false);
|
|
const [filter, setFilter] = useState<Filter>("mine");
|
|
|
|
const selected = runtimes.find((r) => r.id === value) ?? null;
|
|
const Icon = selected?.runtime_mode === "cloud" ? Cloud : Monitor;
|
|
|
|
// Compute filtered list unconditionally — the early `!canEdit` return
|
|
// below would otherwise re-order this hook across renders.
|
|
const isDisabled = (r: AgentRuntime): boolean => {
|
|
if (!currentUserId) return false;
|
|
if (r.owner_id === currentUserId) return false;
|
|
return r.visibility !== "public";
|
|
};
|
|
const filtered = useMemo(() => {
|
|
const list =
|
|
filter === "mine" && currentUserId
|
|
? runtimes.filter((r) => r.owner_id === currentUserId)
|
|
: runtimes;
|
|
return [...list].sort((a, b) => {
|
|
const aMine = a.owner_id === currentUserId;
|
|
const bMine = b.owner_id === currentUserId;
|
|
if (aMine && !bMine) return -1;
|
|
if (!aMine && bMine) return 1;
|
|
const aDisabled = isDisabled(a);
|
|
const bDisabled = isDisabled(b);
|
|
if (!aDisabled && bDisabled) return -1;
|
|
if (aDisabled && !bDisabled) return 1;
|
|
return 0;
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [runtimes, filter, currentUserId]);
|
|
|
|
if (!canEdit) {
|
|
const isOnline = selected?.status === "online";
|
|
return (
|
|
<span className="inline-flex min-w-0 items-center gap-1.5 px-1.5 py-0.5 text-xs text-muted-foreground">
|
|
<Icon className="h-3 w-3 shrink-0" />
|
|
<span className="min-w-0 truncate font-mono">
|
|
{selected?.name ?? t(($) => $.pickers.runtime_none)}
|
|
</span>
|
|
{selected && (
|
|
<span
|
|
className={`ml-auto h-1.5 w-1.5 shrink-0 rounded-full ${
|
|
isOnline ? "bg-success" : "bg-muted-foreground/40"
|
|
}`}
|
|
/>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
// The chip shows only the runtime name. `runtime.name` already comes back
|
|
// from the back-end pre-formatted as e.g. "Claude (host.local)", so we
|
|
// deliberately do NOT append `device_info` to the tooltip — that string
|
|
// also leads with the host and would just repeat what's already in name,
|
|
// producing the "Claude (host) (host · 2.1.121 (Claude Code))" mess.
|
|
const triggerLabel = selected?.name ?? t(($) => $.pickers.runtime_none);
|
|
const isOnline = selected?.status === "online";
|
|
const triggerTitle = selected
|
|
? t(($) => $.pickers.runtime_tooltip, {
|
|
name: selected.name,
|
|
status: isOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline),
|
|
})
|
|
: t(($) => $.pickers.runtime_tooltip_none);
|
|
|
|
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
|
|
|
|
const getOwner = (id: string | null) =>
|
|
id ? members.find((m) => m.user_id === id) ?? null : null;
|
|
|
|
const select = async (id: string) => {
|
|
setOpen(false);
|
|
if (id !== value) await onChange(id);
|
|
};
|
|
|
|
return (
|
|
<PropertyPicker
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
width="w-auto min-w-[18rem] max-w-md"
|
|
align="start"
|
|
tooltip={triggerTitle}
|
|
triggerRender={
|
|
<button
|
|
type="button"
|
|
className={CHIP_CLASS}
|
|
aria-label={triggerTitle}
|
|
/>
|
|
}
|
|
trigger={
|
|
<>
|
|
<Icon className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 truncate font-mono">{triggerLabel}</span>
|
|
{selected && (
|
|
<span
|
|
className={`ml-auto h-1.5 w-1.5 shrink-0 rounded-full ${
|
|
isOnline ? "bg-success" : "bg-muted-foreground/40"
|
|
}`}
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
header={
|
|
hasOtherRuntimes ? (
|
|
<div className="p-2">
|
|
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
|
|
<FilterButton
|
|
active={filter === "mine"}
|
|
onClick={() => setFilter("mine")}
|
|
>
|
|
{t(($) => $.scope.mine)}
|
|
</FilterButton>
|
|
<FilterButton
|
|
active={filter === "all"}
|
|
onClick={() => setFilter("all")}
|
|
>
|
|
{t(($) => $.scope.all)}
|
|
</FilterButton>
|
|
</div>
|
|
</div>
|
|
) : undefined
|
|
}
|
|
>
|
|
{filtered.length === 0 ? (
|
|
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
|
{t(($) => $.pickers.runtime_empty)}
|
|
</p>
|
|
) : (
|
|
filtered.map((rt) => {
|
|
const owner = getOwner(rt.owner_id);
|
|
const rtOnline = rt.status === "online";
|
|
const locked = isDisabled(rt);
|
|
const tooltip = [
|
|
rt.name,
|
|
owner ? t(($) => $.pickers.runtime_owned_by, { name: owner.name }) : null,
|
|
rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline),
|
|
locked ? t(($) => $.create_dialog.runtime_private_locked_tooltip) : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
return (
|
|
<PickerItem
|
|
key={rt.id}
|
|
selected={rt.id === value}
|
|
disabled={locked}
|
|
onClick={() => {
|
|
if (locked) return;
|
|
void select(rt.id);
|
|
}}
|
|
tooltip={tooltip}
|
|
>
|
|
<ProviderLogo
|
|
provider={rt.provider}
|
|
className="h-4 w-4 shrink-0"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="truncate text-sm font-medium">
|
|
{rt.name}
|
|
</span>
|
|
{rt.runtime_mode === "cloud" && (
|
|
<span className="shrink-0 rounded bg-info/10 px-1 text-[10px] font-medium text-info">
|
|
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
|
</span>
|
|
)}
|
|
{locked && (
|
|
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
|
<Lock className="h-2.5 w-2.5" />
|
|
{t(($) => $.create_dialog.runtime_private_badge)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
{owner && (
|
|
<span className="flex min-w-0 items-center gap-1">
|
|
<ActorAvatar
|
|
actorType="member"
|
|
actorId={owner.user_id}
|
|
size={12}
|
|
/>
|
|
<span className="truncate">{owner.name}</span>
|
|
</span>
|
|
)}
|
|
{owner && rt.device_info && (
|
|
<span className="text-muted-foreground/40">·</span>
|
|
)}
|
|
{rt.device_info && (
|
|
<span className="truncate font-mono text-[10px]">
|
|
{rt.device_info}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span
|
|
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
|
rtOnline ? "bg-success" : "bg-muted-foreground/40"
|
|
}`}
|
|
aria-label={rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline)}
|
|
/>
|
|
</PickerItem>
|
|
);
|
|
})
|
|
)}
|
|
</PropertyPicker>
|
|
);
|
|
}
|
|
|
|
function FilterButton({
|
|
active,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`flex-1 rounded px-2 py-0.5 text-xs font-medium transition-colors ${
|
|
active
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|