Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,7 @@ export default function App() {
// Null while the server-derived role is unresolved. Privileged UI remains
// hidden until this has loaded; failures fall back to the ordinary user.
const [access, setAccess] = useState<StudioAccess | null>(null);
const grantedRuntimeScope = access?.capabilities.runtimeScope ?? "mine";
// Per-module feature gates (studio mode disables chat-centric modules).
// Defaults to all-enabled until /web/ui-config resolves.
const [features, setFeatures] = useState<UiFeatures>({
Expand Down Expand Up @@ -1312,7 +1313,7 @@ export default function App() {
let nextToken = "";
do {
const page = await getRuntimes({
scope: "mine",
scope: grantedRuntimeScope,
region: "all",
pageSize: 100,
nextToken,
Expand All @@ -1335,7 +1336,7 @@ export default function App() {
} finally {
setAgentLibraryLoading(false);
}
}, []);
}, [grantedRuntimeScope]);

// Placeholder: persisting/registering the created agent is a follow-up.
function onCreate(draft: AgentDraft) {
Expand Down Expand Up @@ -3506,6 +3507,8 @@ export default function App() {

{myAgents ? (
<MyAgents
canCreate={canCreateAgents}
runtimeScope={access.capabilities.runtimeScope}
onCreateAgent={openAgentCreateFromMyAgents}
onCreateCodexAgent={openSandboxLaunch}
onUseAgent={connectMyAgent}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,7 @@ body {
.agentsel-rt-row:hover { background: hsl(var(--foreground) / 0.05); }
.agentsel-rt-row .icon { width: 15px; height: 15px; color: hsl(var(--muted-foreground)); flex-shrink: 0; }
.agentsel-rt-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agentsel-badge {
.runtime-owner-badge {
flex-shrink: 0; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 999px;
background: hsl(211 100% 50% / 0.12); color: hsl(211 90% 42%);
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/ui/AgentSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ export function AgentSelector({
{bad ? "不支持" : runtimeStatusLabel(rt.status)}
</span>
{rt.isMine && (
<span className="agentsel-badge">我创建的</span>
<span className="runtime-owner-badge">我创建的</span>
)}
</div>
</div>
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/ui/MyAgents.css
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,15 @@
display: block;
}

.my-agent-card-title {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}

.my-agent-card h3 {
min-width: 0;
margin: 0;
overflow: hidden;
color: hsl(var(--foreground));
Expand Down
65 changes: 46 additions & 19 deletions frontend/src/ui/MyAgents.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { SVGProps } from "react";

import { getRuntimeAgentInfo, getRuntimes, type CloudRuntime } from "../adk/client";
import {
getRuntimeAgentInfo,
getRuntimes,
type CloudRuntime,
type RuntimeScope,
} from "../adk/client";
import "./MyAgents.css";

export interface MyAgentCardData {
Expand All @@ -10,6 +15,7 @@ export interface MyAgentCardData {
name: string;
description: string;
createdAt: string;
isMine?: boolean;
runtime?: {
runtimeId: string;
region: string;
Expand Down Expand Up @@ -117,6 +123,7 @@ function runtimeToAgent(runtime: CloudRuntime): MyAgentCardData {
name: runtime.name,
description: runtime.name,
createdAt: formatCreatedAt(runtime.createdAt ?? ""),
isMine: runtime.isMine,
runtime: {
runtimeId: runtime.runtimeId,
region: runtime.region,
Expand All @@ -127,11 +134,12 @@ function runtimeToAgent(runtime: CloudRuntime): MyAgentCardData {
}

async function loadRuntimeAgents(
runtimeScope: RuntimeScope,
region: RuntimeRegion,
nextToken: string,
onList: (agents: MyAgentCardData[]) => void,
): Promise<string> {
const requestKey = `${region}:${nextToken}`;
const requestKey = `${runtimeScope}:${region}:${nextToken}`;
const cached = runtimePageCache.get(requestKey);
if (cached && cached.expiresAt > Date.now()) {
onList(cached.page.runtimes.map(runtimeToAgent));
Expand All @@ -141,7 +149,7 @@ async function loadRuntimeAgents(
let request = runtimePageRequests.get(requestKey);
if (!request) {
request = getRuntimes({
scope: "mine",
scope: runtimeScope,
region,
pageSize: RUNTIME_PAGE_SIZE,
nextToken,
Expand All @@ -168,6 +176,7 @@ async function loadRuntimeAgents(
name: info.name || runtime.name,
description: info.description || runtime.name,
createdAt: formatCreatedAt(runtime.createdAt ?? ""),
isMine: runtime.isMine,
runtime: {
runtimeId: runtime.runtimeId,
region: runtime.region,
Expand All @@ -190,12 +199,14 @@ function AgentCard({
onViewDetails,
connecting,
connected,
showOwnership,
}: {
agent: MyAgentCardData;
onUse?: (agent: MyAgentCardData) => Promise<void>;
onViewDetails?: (agent: MyAgentCardData) => void;
connecting?: boolean;
connected?: boolean;
showOwnership?: boolean;
}) {
return (
<article className="my-agent-card">
Expand All @@ -207,7 +218,12 @@ function AgentCard({
aria-label={`查看 ${agent.name} 详情`}
>
<span className="my-agent-card-copy">
<h3>{agent.name}</h3>
<span className="my-agent-card-title">
<h3>{agent.name}</h3>
{showOwnership && agent.isMine && (
<span className="runtime-owner-badge">我创建的</span>
)}
</span>
<dl className="my-agent-meta">
<div className="my-agent-created-at">
<dt>创建时间</dt>
Expand All @@ -232,6 +248,8 @@ function AgentCard({
}

export interface MyAgentsProps {
canCreate: boolean;
runtimeScope: RuntimeScope;
onCreateAgent: (region: RuntimeRegion) => void;
onCreateCodexAgent: () => void;
onUseAgent: (agent: MyAgentCardData) => Promise<void>;
Expand All @@ -241,6 +259,8 @@ export interface MyAgentsProps {
}

export function MyAgents({
canCreate,
runtimeScope,
onCreateAgent,
onCreateCodexAgent,
onUseAgent,
Expand All @@ -265,7 +285,7 @@ export function MyAgents({
const requestId = ++runtimeRequestRef.current;
setLoadingRuntimes(true);
setRuntimeError("");
return loadRuntimeAgents(region, token, (agents) => {
return loadRuntimeAgents(runtimeScope, region, token, (agents) => {
if (runtimeRequestRef.current !== requestId) return;
setRuntimeAgents((current) => reset ? agents : [...current, ...agents]);
})
Expand All @@ -279,7 +299,7 @@ export function MyAgents({
.finally(() => {
if (runtimeRequestRef.current === requestId) setLoadingRuntimes(false);
});
}, [region]);
}, [region, runtimeScope]);

useEffect(() => {
setRuntimeAgents([]);
Expand Down Expand Up @@ -349,9 +369,9 @@ export function MyAgents({
const emptyMessage = activeType === "openclaw" || activeType === "hermes"
? "暂未开放"
: query.trim() ? "没有匹配的智能体" : `${activeLabel}暂无内容`;
const createAgent = activeType === "general"
const createAgent = canCreate && activeType === "general"
? () => onCreateAgent(region)
: activeType === "codex" ? onCreateCodexAgent : undefined;
: canCreate && activeType === "codex" ? onCreateCodexAgent : undefined;

return (
<div className="my-agents-page">
Expand Down Expand Up @@ -409,7 +429,11 @@ export function MyAgents({
)}
</div>
</div>
<p>在此处浏览您的所有智能体</p>
<p>
{runtimeScope === "all"
? "在此处浏览所有智能体"
: "在此处浏览您的所有智能体"}
</p>
</div>
<label className="my-agent-search">
<SearchIcon />
Expand Down Expand Up @@ -437,15 +461,17 @@ export function MyAgents({
</button>
))}
</nav>
<button
type="button"
className="my-agent-add"
disabled={!createAgent}
onClick={() => createAgent?.()}
>
<AddIcon />
{createLabel}
</button>
{canCreate && (
<button
type="button"
className="my-agent-add"
disabled={!createAgent}
onClick={() => createAgent?.()}
>
<AddIcon />
{createLabel}
</button>
)}
</div>

<section
Expand All @@ -465,7 +491,7 @@ export function MyAgents({
</div>
) : showEmpty ? (
<div className="my-agent-empty">
{!query.trim() && activeType === "general" ? (
{!query.trim() && activeType === "general" && canCreate ? (
<p>
暂无智能体,
<button
Expand Down Expand Up @@ -493,6 +519,7 @@ export function MyAgents({
onViewDetails={onViewAgentDetails}
connecting={agent.id === connectingAgentId}
connected={agent.runtime?.runtimeId === connectedRuntimeId}
showOwnership={runtimeScope === "all"}
/>
))}
</div>
Expand Down
21 changes: 18 additions & 3 deletions frontend/tests/myAgents.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ test("renders only account-backed agents and never ships placeholder cards", ()
});

test("offers the existing create action for the active agent type", () => {
assert.match(pageSource, /canCreate: boolean/);
assert.match(pageSource, /\{canCreate && \(/);
assert.match(pageSource, /activeType === "general"[\s\S]*?onCreateAgent/);
assert.match(pageSource, /activeType === "codex" \? onCreateCodexAgent : undefined/);
assert.match(pageSource, /className="my-agent-add"/);
Expand Down Expand Up @@ -102,9 +104,10 @@ test("creation time remains compact without data-plane metadata", () => {
assert.match(pageStyles, /\.my-agent-created-at dd\s*\{[\s\S]*?font-weight: 400/);
});

test("loads owned runtimes into the general agents section", () => {
test("loads the runtime scope granted to the current role", () => {
assert.match(pageSource, /getRuntimes/);
assert.match(pageSource, /scope: "mine"/);
assert.match(pageSource, /runtimeScope: RuntimeScope/);
assert.match(pageSource, /scope: runtimeScope/);
assert.match(pageSource, /const \[region, setRegion\] = useState<RuntimeRegion>\("cn-beijing"\)/);
assert.match(pageSource, /region,\s*pageSize: RUNTIME_PAGE_SIZE/);
assert.doesNotMatch(pageSource, /region: "all"/);
Expand All @@ -118,13 +121,25 @@ test("loads owned runtimes into the general agents section", () => {
assert.match(pageSource, /onList\(page\.runtimes\.map\(runtimeToAgent\)\)/);
assert.match(pageSource, /runtimeRequestRef\.current !== requestId/);
assert.match(pageSource, /const runtimePageRequests = new Map/);
assert.match(pageSource, /const requestKey = `\$\{region\}:\$\{nextToken\}`/);
assert.match(pageSource, /const requestKey = `\$\{runtimeScope\}:\$\{region\}:\$\{nextToken\}`/);
assert.match(pageSource, /runtimePageRequests\.get\(requestKey\)/);
assert.match(pageSource, /runtimePageRequests\.set\(requestKey, request\)/);
assert.match(pageSource, /const RUNTIME_PAGE_CACHE_TTL_MS = 30_000/);
assert.match(pageSource, /runtimePageCache\.get\(requestKey\)/);
assert.match(pageSource, /runtimePageCache\.set\(requestKey/);
assert.match(pageSource, /setRuntimeAgents\(\(current\) => reset \? agents : \[\.\.\.current, \.\.\.agents\]\)/);
assert.match(appSource, /<MyAgents[\s\S]*?runtimeScope=\{access\.capabilities\.runtimeScope\}/);
assert.match(appSource, /const grantedRuntimeScope = access\?\.capabilities\.runtimeScope \?\? "mine"/);
assert.match(appSource, /const refreshAgentLibrary[\s\S]*?scope: grantedRuntimeScope/);
});

test("marks runtimes created by the administrator", () => {
assert.match(pageSource, /isMine\?: boolean/);
assert.match(pageSource, /isMine: runtime\.isMine/);
assert.match(pageSource, /showOwnership=\{runtimeScope === "all"\}/);
assert.match(pageSource, /showOwnership && agent\.isMine/);
assert.match(pageSource, /className="runtime-owner-badge"[\s\S]*?>我创建的</);
assert.match(appSource, /canCreate=\{canCreateAgents\}/);
});

test("hides deleted Runtime cards and invalidates stale Runtime pages", () => {
Expand Down
9 changes: 9 additions & 0 deletions frontend/tests/studioAccess.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const appSource = read("App.tsx");
const clientSource = read("adk/client.ts");
const connectionsSource = read("adk/connections.ts");
const selectorSource = read("ui/AgentSelector.tsx");
const myAgentsSource = read("ui/MyAgents.tsx");
const sidebarSource = read("ui/Sidebar.tsx");
const stylesSource = read("styles.css");
const cliFrontendSource = readFileSync(
Expand Down Expand Up @@ -52,9 +53,17 @@ test("runtime selection obeys the server-granted scope", () => {
assert.match(selectorSource, /setMineOnly\(runtimeScope === "mine"\)/);
assert.match(selectorSource, /\{runtimeScope === "all" && \(/);
assert.match(selectorSource, /getRuntimes\(\{[\s\S]*?scope: "mine"/);
assert.match(myAgentsSource, /getRuntimes\(\{[\s\S]*?scope: runtimeScope/);
assert.match(appSource, /<MyAgents[\s\S]*?runtimeScope=\{access\.capabilities\.runtimeScope\}/);
assert.doesNotMatch(clientSource, /new URLSearchParams\(\{\s*author,/);
});

test("only administrators and developers receive Agent deployment controls", () => {
assert.match(appSource, /<MyAgents[\s\S]*?canCreate=\{canCreateAgents\}/);
assert.match(myAgentsSource, /\{canCreate && \([\s\S]*?className="my-agent-add"/);
assert.match(myAgentsSource, /!query\.trim\(\) && activeType === "general" && canCreate/);
});

test("runtime authorization failures are not reported as unsupported", () => {
assert.match(clientSource, /response\.clone\(\)\.json\(\)/);
assert.match(clientSource, /runtime_access_denied/);
Expand Down
15 changes: 13 additions & 2 deletions tests/cli/test_studio_rbac.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ def test_non_admin_runtime_list_uses_one_owner_filtered_request(

other = _runtime("runtime-other", "someone-else")
own = _runtime("runtime-own", "developer")
reader_own = _runtime("runtime-reader", "reader")
developer_tag_filters: list[tuple[str, list[str]]] = []

runtime_calls = 0
Expand All @@ -355,7 +356,9 @@ def list_runtimes(_self: Any, request: Any) -> SimpleNamespace:
for item in tag_filters:
developer_tag_filters.append((item.key, item.values))
if tag_filters:
return SimpleNamespace(agent_kit_runtimes=[own], next_token="")
owner = tag_filters[0].values[0]
owned = reader_own if owner == "reader" else own
return SimpleNamespace(agent_kit_runtimes=[owned], next_token="")
return SimpleNamespace(agent_kit_runtimes=[other, own], next_token="")

monkeypatch.setattr(AgentkitRuntimeClient, "list_runtimes", list_runtimes)
Expand All @@ -372,6 +375,10 @@ def list_runtimes(_self: Any, request: Any) -> SimpleNamespace:
headers={"X-VeADK-Local-User": "developer"},
)
developer_call_count = runtime_calls
reader = client.get(
"/web/runtimes?scope=all&page_size=10&region=cn-beijing",
headers={"X-VeADK-Local-User": "reader"},
)
admin = client.get(
"/web/runtimes?scope=all&page_size=10&region=cn-beijing",
headers={"X-VeADK-Local-User": "admin"},
Expand All @@ -384,7 +391,11 @@ def list_runtimes(_self: Any, request: Any) -> SimpleNamespace:
assert developer.json()["runtimes"][0]["canDelete"] is True
assert ("veadk:owner", ["developer"]) in developer_tag_filters
assert developer_call_count == 1
assert runtime_calls == 2
assert reader.status_code == 200
assert [item["runtimeId"] for item in reader.json()["runtimes"]] == [
"runtime-reader"
]
assert runtime_calls == 3
assert admin.status_code == 200
assert [item["runtimeId"] for item in admin.json()["runtimes"]] == [
"runtime-other",
Expand Down

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading