mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
fix(skills): add agent manage grants
Add explicit per-agent manage grants for skills so granted agents can patch/delete skills when ownership identity drifts. Expose skill owner and manage-grant controls in the web skills UI, and add PostgreSQL/SQLite migrations plus coverage for preserve/revoke behavior.
This commit is contained in:
1 parent
ba3b535286
commit
0d6c5bbb7c
24 files changed
+595
-38
No files matched your search
@@ -6,6 +6,24 @@ Significant changes, features, and fixes in reverse chronological order.
|
||||
|
||||
## 2026-05-17
|
||||
|
||||
### Skills: agent manage grants
|
||||
|
||||
**Fixes**
|
||||
|
||||
- Added explicit per-agent skill manage grants so agents can edit/delete skills they were authorized to maintain even when `owner_id` no longer matches their current actor identity.
|
||||
- Auto-granted manage permission to the creating/publishing agent for new managed skills.
|
||||
|
||||
**UI**
|
||||
|
||||
- Show custom skill owner IDs in the Skills table.
|
||||
- Added Skills page controls to grant agent skill access and edit/delete permission.
|
||||
|
||||
**Tests**
|
||||
|
||||
- Added PG/SQLite grant coverage and verified Go builds plus Web UI build.
|
||||
|
||||
---
|
||||
|
||||
### Agents: provider switch save fix
|
||||
|
||||
**Fixes**
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
// skillOwnerGetter is an optional interface for stores that can return a skill's owner ID.
|
||||
type skillOwnerGetter interface {
|
||||
GetSkillOwnerID(id uuid.UUID) (string, bool)
|
||||
GetSkillOwnerID(ctx context.Context, id uuid.UUID) (string, bool)
|
||||
}
|
||||
|
||||
// SkillsMethods handles skills.list, skills.get, skills.update.
|
||||
@@ -56,6 +56,9 @@ func (m *SkillsMethods) handleList(ctx context.Context, client *gateway.Client,
|
||||
"is_system": s.IsSystem,
|
||||
"enabled": s.Enabled,
|
||||
}
|
||||
if s.OwnerID != "" {
|
||||
entry["owner_id"] = s.OwnerID
|
||||
}
|
||||
if s.ID != "" {
|
||||
entry["id"] = s.ID
|
||||
}
|
||||
@@ -146,6 +149,9 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r
|
||||
if info.Visibility != "" {
|
||||
resp["visibility"] = info.Visibility
|
||||
}
|
||||
if info.OwnerID != "" {
|
||||
resp["owner_id"] = info.OwnerID
|
||||
}
|
||||
if len(info.Tags) > 0 {
|
||||
resp["tags"] = info.Tags
|
||||
}
|
||||
@@ -219,7 +225,7 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
|
||||
return
|
||||
}
|
||||
if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() {
|
||||
if ownerID, found := ownerGetter.GetSkillOwnerID(ctx, skillID); found && ownerID != client.UserID() {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ type SkillsHandler struct {
|
||||
msgBus *bus.MessageBus
|
||||
tenantCfgStore store.SkillTenantConfigStore
|
||||
tenantStore store.TenantStore
|
||||
db *sql.DB // for export/import direct queries
|
||||
db *sql.DB // for export/import direct queries
|
||||
uploadLocks sync.Map // per-slug mutex; bounded by validated slug set, entries are tiny (*sync.Mutex)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("PUT /v1/skills/{id}", h.adminMiddleware(h.handleUpdate))
|
||||
mux.HandleFunc("DELETE /v1/skills/{id}", h.adminMiddleware(h.handleDelete))
|
||||
// Skill grants (admin+)
|
||||
mux.HandleFunc("GET /v1/skills/{id}/grants/agent", h.adminMiddleware(h.handleListAgentGrants))
|
||||
mux.HandleFunc("POST /v1/skills/{id}/grants/agent", h.adminMiddleware(h.handleGrantAgent))
|
||||
mux.HandleFunc("DELETE /v1/skills/{id}/grants/agent/{agentID}", h.adminMiddleware(h.handleRevokeAgent))
|
||||
mux.HandleFunc("POST /v1/skills/{id}/grants/user", h.adminMiddleware(h.handleGrantUser))
|
||||
|
||||
@@ -33,6 +33,25 @@ func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Req
|
||||
writeJSON(w, http.StatusOK, map[string]any{"skills": skills})
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) handleListAgentGrants(w http.ResponseWriter, r *http.Request) {
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
idStr := r.PathValue("id")
|
||||
skillID, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
|
||||
return
|
||||
}
|
||||
|
||||
grants, err := h.skills.ListAgentGrantsForSkill(r.Context(), skillID)
|
||||
if err != nil {
|
||||
slog.Error("failed to list skill agent grants", "skill_id", skillID, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "skill grants")})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"grants": grants})
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) {
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
userID := store.UserIDFromContext(r.Context())
|
||||
@@ -53,8 +72,9 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Version int `json:"version"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Version int `json:"version"`
|
||||
CanManage *bool `json:"can_manage"`
|
||||
}
|
||||
if !bindJSON(w, r, locale, &req) {
|
||||
return
|
||||
@@ -70,8 +90,14 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request)
|
||||
req.Version = 1
|
||||
}
|
||||
|
||||
if err := h.skills.GrantToAgent(r.Context(), skillID, agentID, req.Version, userID); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
var grantErr error
|
||||
if req.CanManage == nil {
|
||||
grantErr = h.skills.GrantToAgent(r.Context(), skillID, agentID, req.Version, userID)
|
||||
} else {
|
||||
grantErr = h.skills.GrantToAgent(r.Context(), skillID, agentID, req.Version, userID, *req.CanManage)
|
||||
}
|
||||
if grantErr != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": grantErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -528,7 +528,7 @@ func (s *skillManageStoreStub) StoreMissingDeps(_ context.Context, id uuid.UUID,
|
||||
s.skills[id] = skill
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageStoreStub) GrantToAgent(context.Context, uuid.UUID, uuid.UUID, int, string) error {
|
||||
func (s *skillManageStoreStub) GrantToAgent(context.Context, uuid.UUID, uuid.UUID, int, string, ...bool) error {
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageStoreStub) RevokeFromAgent(context.Context, uuid.UUID, uuid.UUID) error {
|
||||
@@ -541,6 +541,12 @@ func (s *skillManageStoreStub) RevokeFromUser(context.Context, uuid.UUID, string
|
||||
func (s *skillManageStoreStub) ListWithGrantStatus(context.Context, uuid.UUID) ([]store.SkillWithGrantStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *skillManageStoreStub) ListAgentGrantsForSkill(context.Context, uuid.UUID) ([]store.SkillAgentGrantInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *skillManageStoreStub) AgentCanManageSkill(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (s *skillManageStoreStub) GetSkillFilePath(context.Context, uuid.UUID) (string, string, int, bool, bool) {
|
||||
return "", "", 0, false, false
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -15,12 +16,11 @@ import (
|
||||
// Auto-promotes visibility from 'private' to 'internal' so the skill
|
||||
// becomes accessible via ListAccessible for granted agents.
|
||||
// Validates the agent belongs to the requesting tenant (prevents cross-tenant grant injection).
|
||||
func (s *PGSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.UUID, version int, grantedBy string) error {
|
||||
func (s *PGSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.UUID, version int, grantedBy string, canManage ...bool) error {
|
||||
if err := store.ValidateUserID(grantedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
tid := tenantIDForInsert(ctx)
|
||||
|
||||
// Verify agent belongs to the requesting tenant.
|
||||
var agentTenantID uuid.UUID
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
@@ -32,12 +32,28 @@ func (s *PGSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.U
|
||||
return fmt.Errorf("agent not found")
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (skill_id, agent_id) DO UPDATE SET pinned_version = EXCLUDED.pinned_version`,
|
||||
store.GenNewID(), skillID, agentID, version, grantedBy, time.Now(), tid,
|
||||
)
|
||||
now := time.Now()
|
||||
var err error
|
||||
if len(canManage) > 0 {
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, can_manage, created_at, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (skill_id, agent_id) DO UPDATE SET
|
||||
pinned_version = EXCLUDED.pinned_version,
|
||||
granted_by = EXCLUDED.granted_by,
|
||||
can_manage = EXCLUDED.can_manage`,
|
||||
store.GenNewID(), skillID, agentID, version, grantedBy, canManage[0], now, tid,
|
||||
)
|
||||
} else {
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (skill_id, agent_id) DO UPDATE SET
|
||||
pinned_version = EXCLUDED.pinned_version,
|
||||
granted_by = EXCLUDED.granted_by`,
|
||||
store.GenNewID(), skillID, agentID, version, grantedBy, now, tid,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -101,6 +117,41 @@ func (s *PGSkillStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) (
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListAgentGrantsForSkill returns all agent grants for one skill.
|
||||
func (s *PGSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID uuid.UUID) ([]store.SkillAgentGrantInfo, error) {
|
||||
tClause, tArgs, _, err := scopeClause(ctx, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []store.SkillAgentGrantInfo
|
||||
err = pkgSqlxDB.SelectContext(ctx, &result,
|
||||
"SELECT agent_id, pinned_version, granted_by, can_manage FROM skill_agent_grants WHERE skill_id = $1"+tClause+" ORDER BY created_at DESC",
|
||||
append([]any{skillID}, tArgs...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AgentCanManageSkill reports whether an agent has explicit edit/delete rights for a skill.
|
||||
func (s *PGSkillStore) AgentCanManageSkill(ctx context.Context, skillID, agentID uuid.UUID) (bool, error) {
|
||||
tClause, tArgs, _, err := scopeClause(ctx, 3)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var canManage bool
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
"SELECT can_manage FROM skill_agent_grants WHERE skill_id = $1 AND agent_id = $2"+tClause,
|
||||
append([]any{skillID, agentID}, tArgs...)...).Scan(&canManage)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return canManage, nil
|
||||
}
|
||||
|
||||
// GrantToUser grants a skill to a user (for internal visibility skills).
|
||||
func (s *PGSkillStore) GrantToUser(ctx context.Context, skillID uuid.UUID, userID, grantedBy string) error {
|
||||
if err := store.ValidateUserID(userID); err != nil {
|
||||
@@ -216,6 +267,7 @@ func (s *PGSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUI
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT s.id, s.name, s.slug, COALESCE(s.description, ''), s.visibility, s.version,
|
||||
(sag.id IS NOT NULL) AS granted,
|
||||
COALESCE(sag.can_manage, false) AS can_manage,
|
||||
sag.pinned_version,
|
||||
s.is_system
|
||||
FROM skills s
|
||||
@@ -230,7 +282,7 @@ func (s *PGSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUI
|
||||
var result []store.SkillWithGrantStatus
|
||||
for rows.Next() {
|
||||
var r store.SkillWithGrantStatus
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Slug, &r.Description, &r.Visibility, &r.Version, &r.Granted, &r.PinnedVer, &r.IsSystem); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Slug, &r.Description, &r.Visibility, &r.Version, &r.Granted, &r.CanManage, &r.PinnedVer, &r.IsSystem); err != nil {
|
||||
slog.Warn("skill_grants: scan error in ListWithGrantStatus", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -89,10 +89,19 @@ type SkillWithGrantStatus struct {
|
||||
Visibility string `json:"visibility" db:"visibility"`
|
||||
Version int `json:"version" db:"version"`
|
||||
Granted bool `json:"granted" db:"granted"`
|
||||
CanManage bool `json:"can_manage" db:"can_manage"`
|
||||
PinnedVer *int `json:"pinned_version,omitempty" db:"pinned_version"`
|
||||
IsSystem bool `json:"is_system" db:"is_system"`
|
||||
}
|
||||
|
||||
// SkillAgentGrantInfo is a grant row for one skill across agents.
|
||||
type SkillAgentGrantInfo struct {
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
PinnedVersion int `json:"pinned_version" db:"pinned_version"`
|
||||
GrantedBy string `json:"granted_by" db:"granted_by"`
|
||||
CanManage bool `json:"can_manage" db:"can_manage"`
|
||||
}
|
||||
|
||||
// SkillManageStore extends SkillStore with CRUD, ownership, and grant operations
|
||||
// needed by HTTP upload handlers and agent tools (skill_manage, publish_skill).
|
||||
// Implemented by both PGSkillStore and SQLiteSkillStore.
|
||||
@@ -119,11 +128,13 @@ type SkillManageStore interface {
|
||||
ListSystemSkillDirs(ctx context.Context) map[string]string
|
||||
StoreMissingDeps(ctx context.Context, id uuid.UUID, missing []string) error
|
||||
// Grants
|
||||
GrantToAgent(ctx context.Context, skillID, agentID uuid.UUID, version int, grantedBy string) error
|
||||
GrantToAgent(ctx context.Context, skillID, agentID uuid.UUID, version int, grantedBy string, canManage ...bool) error
|
||||
RevokeFromAgent(ctx context.Context, skillID, agentID uuid.UUID) error
|
||||
GrantToUser(ctx context.Context, skillID uuid.UUID, userID, grantedBy string) error
|
||||
RevokeFromUser(ctx context.Context, skillID uuid.UUID, userID string) error
|
||||
ListWithGrantStatus(ctx context.Context, agentID uuid.UUID) ([]SkillWithGrantStatus, error)
|
||||
ListAgentGrantsForSkill(ctx context.Context, skillID uuid.UUID) ([]SkillAgentGrantInfo, error)
|
||||
AgentCanManageSkill(ctx context.Context, skillID, agentID uuid.UUID) (bool, error)
|
||||
// Files
|
||||
GetSkillFilePath(ctx context.Context, id uuid.UUID) (filePath string, slug string, version int, isSystem bool, ok bool)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ var schemaSQL string
|
||||
|
||||
// SchemaVersion is the current SQLite schema version.
|
||||
// Bump this when adding new migration steps below.
|
||||
const SchemaVersion = 34
|
||||
const SchemaVersion = 35
|
||||
|
||||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||||
@@ -596,6 +596,9 @@ CREATE INDEX IF NOT EXISTS idx_ws_activity_retention ON workstation_activity(c
|
||||
// Version 33 → 34: per-agent ordered provider/model fallback config.
|
||||
33: `ALTER TABLE agents ADD COLUMN model_fallback TEXT NOT NULL DEFAULT '{}';`,
|
||||
|
||||
// Version 34 → 35: agent skill grants can optionally allow skill management.
|
||||
34: `ALTER TABLE skill_agent_grants ADD COLUMN can_manage INTEGER NOT NULL DEFAULT 0;`,
|
||||
|
||||
// Version 23 → 24: vault_documents scope/ownership consistency triggers.
|
||||
// Mirrors PG migration 000055 CHECK constraint; SQLite cannot add CHECK via
|
||||
// ALTER TABLE so we use BEFORE INSERT + BEFORE UPDATE triggers instead.
|
||||
@@ -975,6 +978,8 @@ func idempotentColumnMigration(version int) (string, string, bool) {
|
||||
return "webhooks", "encrypted_secret", true
|
||||
case 33:
|
||||
return "agents", "model_fallback", true
|
||||
case 34:
|
||||
return "skill_agent_grants", "can_manage", true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
@@ -408,6 +408,7 @@ CREATE TABLE IF NOT EXISTS skill_agent_grants (
|
||||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
pinned_version INT NOT NULL,
|
||||
granted_by VARCHAR(255) NOT NULL,
|
||||
can_manage INTEGER NOT NULL DEFAULT 0,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE(skill_id, agent_id)
|
||||
|
||||
@@ -4,6 +4,7 @@ package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
@@ -20,19 +21,35 @@ type SkillGrantInfo struct {
|
||||
}
|
||||
|
||||
// GrantToAgent grants a skill to an agent with version pinning.
|
||||
func (s *SQLiteSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.UUID, version int, grantedBy string) error {
|
||||
func (s *SQLiteSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.UUID, version int, grantedBy string, canManage ...bool) error {
|
||||
if err := store.ValidateUserID(grantedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Upsert grant.
|
||||
id := store.GenNewID()
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (skill_id, agent_id) DO UPDATE SET pinned_version = excluded.pinned_version`,
|
||||
id, skillID, agentID, version, grantedBy, time.Now().UTC(), tenantIDForInsert(ctx),
|
||||
)
|
||||
now := time.Now().UTC()
|
||||
tid := tenantIDForInsert(ctx)
|
||||
var err error
|
||||
if len(canManage) > 0 {
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, can_manage, created_at, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (skill_id, agent_id) DO UPDATE SET
|
||||
pinned_version = excluded.pinned_version,
|
||||
granted_by = excluded.granted_by,
|
||||
can_manage = excluded.can_manage`,
|
||||
id, skillID, agentID, version, grantedBy, canManage[0], now, tid,
|
||||
)
|
||||
} else {
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (skill_id, agent_id) DO UPDATE SET
|
||||
pinned_version = excluded.pinned_version,
|
||||
granted_by = excluded.granted_by`,
|
||||
id, skillID, agentID, version, grantedBy, now, tid,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -102,6 +119,51 @@ func (s *SQLiteSkillStore) ListAgentGrants(ctx context.Context, agentID uuid.UUI
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ListAgentGrantsForSkill returns all agent grants for one skill.
|
||||
func (s *SQLiteSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID uuid.UUID) ([]store.SkillAgentGrantInfo, error) {
|
||||
tClause, tArgs, err := scopeClause(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
"SELECT agent_id, pinned_version, granted_by, can_manage FROM skill_agent_grants WHERE skill_id = ?"+tClause+" ORDER BY created_at DESC",
|
||||
append([]any{skillID}, tArgs...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.SkillAgentGrantInfo
|
||||
for rows.Next() {
|
||||
var g store.SkillAgentGrantInfo
|
||||
if err := rows.Scan(&g.AgentID, &g.PinnedVersion, &g.GrantedBy, &g.CanManage); err != nil {
|
||||
slog.Warn("skill_grants: scan error in ListAgentGrantsForSkill", "error", err)
|
||||
continue
|
||||
}
|
||||
result = append(result, g)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// AgentCanManageSkill reports whether an agent has explicit edit/delete rights for a skill.
|
||||
func (s *SQLiteSkillStore) AgentCanManageSkill(ctx context.Context, skillID, agentID uuid.UUID) (bool, error) {
|
||||
tClause, tArgs, err := scopeClause(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var canManage bool
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
"SELECT can_manage FROM skill_agent_grants WHERE skill_id = ? AND agent_id = ?"+tClause,
|
||||
append([]any{skillID, agentID}, tArgs...)...).Scan(&canManage)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return canManage, nil
|
||||
}
|
||||
|
||||
// GrantToUser grants a skill to a user.
|
||||
func (s *SQLiteSkillStore) GrantToUser(ctx context.Context, skillID uuid.UUID, userID, grantedBy string) error {
|
||||
if err := store.ValidateUserID(userID); err != nil {
|
||||
@@ -214,6 +276,7 @@ func (s *SQLiteSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT s.id, s.name, s.slug, COALESCE(s.description, ''), s.visibility, s.version,
|
||||
(sag.id IS NOT NULL) AS granted,
|
||||
COALESCE(sag.can_manage, 0) AS can_manage,
|
||||
sag.pinned_version,
|
||||
s.is_system
|
||||
FROM skills s
|
||||
@@ -229,7 +292,7 @@ func (s *SQLiteSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid
|
||||
for rows.Next() {
|
||||
var r store.SkillWithGrantStatus
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Slug, &r.Description, &r.Visibility,
|
||||
&r.Version, &r.Granted, &r.PinnedVer, &r.IsSystem); err != nil {
|
||||
&r.Version, &r.Granted, &r.CanManage, &r.PinnedVer, &r.IsSystem); err != nil {
|
||||
slog.Warn("skill_grants: scan error in ListWithGrantStatus", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ func (t *PublishSkillTool) Execute(ctx context.Context, args map[string]any) *Re
|
||||
// Auto-grant to calling agent (granted-by = owner, same as CreateSkillManaged)
|
||||
agentID := store.AgentIDFromContext(ctx)
|
||||
if agentID != uuid.Nil {
|
||||
if err := t.skills.GrantToAgent(ctx, id, agentID, version, ownerID); err != nil {
|
||||
if err := t.skills.GrantToAgent(ctx, id, agentID, version, ownerID, true); err != nil {
|
||||
slog.Warn("publish_skill: auto-grant failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,29 @@ func isOwnerOfSkill(ctx context.Context, skills store.SkillManageStore, slug str
|
||||
return ownerID == actorID || ownerID == userID || ownerID == senderID
|
||||
}
|
||||
|
||||
func canManageSkill(ctx context.Context, skills store.SkillManageStore, info *store.SkillInfo) bool {
|
||||
if isOwnerOfSkill(ctx, skills, info.Slug) {
|
||||
return true
|
||||
}
|
||||
if info.ID == "" {
|
||||
return false
|
||||
}
|
||||
skillID, err := uuid.Parse(info.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
agentID := store.AgentIDFromContext(ctx)
|
||||
if agentID == uuid.Nil {
|
||||
return false
|
||||
}
|
||||
ok, err := skills.AgentCanManageSkill(ctx, skillID, agentID)
|
||||
if err != nil {
|
||||
slog.Warn("skill_manage: manage grant check failed", "skill", info.Slug, "agent_id", agentID, "error", err)
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// tenantSkillsDir returns the skills-store directory scoped to the calling agent's tenant.
|
||||
func (t *SkillManageTool) tenantSkillsDir(ctx context.Context) string {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
@@ -211,7 +234,7 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any
|
||||
granted := false
|
||||
agentID := store.AgentIDFromContext(ctx)
|
||||
if agentID != uuid.Nil {
|
||||
if err := t.skills.GrantToAgent(ctx, id, agentID, version, ownerID); err != nil {
|
||||
if err := t.skills.GrantToAgent(ctx, id, agentID, version, ownerID, true); err != nil {
|
||||
slog.Warn("skill_manage: auto-grant failed", "error", err)
|
||||
} else {
|
||||
granted = true
|
||||
@@ -278,7 +301,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
|
||||
// where DM owners got the raw channel sender)
|
||||
// A DM user merged to "viettx" with Telegram ID "386246614" matches all
|
||||
// three of their skills regardless of when they were created.
|
||||
if !isOwnerOfSkill(ctx, t.skills, slug) {
|
||||
if !canManageSkill(ctx, t.skills, info) {
|
||||
return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug))
|
||||
}
|
||||
|
||||
@@ -392,7 +415,7 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any
|
||||
|
||||
// Ownership check: only the skill owner can delete.
|
||||
// Same three-identity match as the patch flow above (#915).
|
||||
if !isOwnerOfSkill(ctx, t.skills, slug) {
|
||||
if !canManageSkill(ctx, t.skills, info) {
|
||||
return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 65
|
||||
const RequiredSchemaVersion uint = 66
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE skill_agent_grants
|
||||
DROP COLUMN IF EXISTS can_manage;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE skill_agent_grants
|
||||
ADD COLUMN IF NOT EXISTS can_manage BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -271,6 +271,9 @@ func TestStoreSkill_GrantToAgent(t *testing.T) {
|
||||
if !g.Granted {
|
||||
t.Error("expected Granted=true for granted skill")
|
||||
}
|
||||
if g.CanManage {
|
||||
t.Error("expected CanManage=false by default")
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@@ -295,6 +298,37 @@ func TestStoreSkill_GrantToAgent(t *testing.T) {
|
||||
t.Error("granted skill not found in ListAccessible")
|
||||
}
|
||||
|
||||
if err := s.GrantToAgent(ctx, skillID, agentID, 1, "test-owner", true); err != nil {
|
||||
t.Fatalf("GrantToAgent can_manage: %v", err)
|
||||
}
|
||||
canManage, err := s.AgentCanManageSkill(ctx, skillID, agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("AgentCanManageSkill: %v", err)
|
||||
}
|
||||
if !canManage {
|
||||
t.Error("expected AgentCanManageSkill=true after manage grant")
|
||||
}
|
||||
if err := s.GrantToAgent(ctx, skillID, agentID, 1, "test-owner"); err != nil {
|
||||
t.Fatalf("GrantToAgent preserve can_manage: %v", err)
|
||||
}
|
||||
canManage, err = s.AgentCanManageSkill(ctx, skillID, agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("AgentCanManageSkill after preserve grant: %v", err)
|
||||
}
|
||||
if !canManage {
|
||||
t.Error("expected omitted can_manage grant update to preserve existing manage permission")
|
||||
}
|
||||
if err := s.GrantToAgent(ctx, skillID, agentID, 1, "test-owner", false); err != nil {
|
||||
t.Fatalf("GrantToAgent can_manage false: %v", err)
|
||||
}
|
||||
canManage, err = s.AgentCanManageSkill(ctx, skillID, agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("AgentCanManageSkill after false grant: %v", err)
|
||||
}
|
||||
if canManage {
|
||||
t.Error("expected explicit can_manage=false to revoke manage permission")
|
||||
}
|
||||
|
||||
// Revoke
|
||||
if err := s.RevokeFromAgent(ctx, skillID, agentID); err != nil {
|
||||
t.Fatalf("RevokeFromAgent: %v", err)
|
||||
|
||||
@@ -11,11 +11,30 @@
|
||||
"description": "Description",
|
||||
"source": "Source",
|
||||
"author": "Author",
|
||||
"owner": "Owner",
|
||||
"visibility": "Visibility",
|
||||
"status": "Status",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"noDescription": "No description",
|
||||
"owner": "Owner",
|
||||
"unknownOwner": "Unknown",
|
||||
"grants": {
|
||||
"title": "Agent access for {{name}}",
|
||||
"manage": "Manage agent access",
|
||||
"current": "Current agent grants",
|
||||
"none": "No agent grants yet.",
|
||||
"add": "Add grant",
|
||||
"update": "Update grant",
|
||||
"grant": "Grant",
|
||||
"save": "Save",
|
||||
"selectAgent": "Select agent",
|
||||
"allowManage": "Allow this agent to edit or delete the skill",
|
||||
"canManage": "Can edit",
|
||||
"loadFailed": "Failed to load grants",
|
||||
"saveFailed": "Failed to save grant",
|
||||
"revokeFailed": "Failed to revoke grant"
|
||||
},
|
||||
"visibility": {
|
||||
"clickToCycle": "Click to change visibility"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"author": "Tác giả",
|
||||
"visibility": "Hiển thị",
|
||||
"status": "Trạng thái",
|
||||
"actions": "Thao tác"
|
||||
"actions": "Thao tác",
|
||||
"owner": "Chủ sở hữu"
|
||||
},
|
||||
"noDescription": "Không có mô tả",
|
||||
"visibility": {
|
||||
@@ -125,5 +126,23 @@
|
||||
"rescanUpdated": "Đã cập nhật {{count}} skill",
|
||||
"rescanNoChanges": "Tất cả skill đã cập nhật",
|
||||
"rescanFailed": "Không thể quét lại dependencies"
|
||||
},
|
||||
"owner": "Chủ sở hữu",
|
||||
"unknownOwner": "Không rõ",
|
||||
"grants": {
|
||||
"title": "Quyền agent cho {{name}}",
|
||||
"manage": "Quản lý quyền agent",
|
||||
"current": "Grant agent hiện tại",
|
||||
"none": "Chưa có grant agent.",
|
||||
"add": "Thêm grant",
|
||||
"update": "Cập nhật grant",
|
||||
"grant": "Grant",
|
||||
"save": "Lưu",
|
||||
"selectAgent": "Chọn agent",
|
||||
"allowManage": "Cho phép agent này sửa hoặc xóa skill",
|
||||
"canManage": "Được sửa",
|
||||
"loadFailed": "Không thể tải grant",
|
||||
"saveFailed": "Không thể lưu grant",
|
||||
"revokeFailed": "Không thể thu hồi grant"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@
|
||||
"author": "作者",
|
||||
"visibility": "可见性",
|
||||
"status": "状态",
|
||||
"actions": "操作"
|
||||
"actions": "操作",
|
||||
"owner": "所有者"
|
||||
},
|
||||
"noDescription": "暂无描述",
|
||||
"visibility": {
|
||||
@@ -125,5 +126,23 @@
|
||||
"rescanUpdated": "已更新 {{count}} 个技能",
|
||||
"rescanNoChanges": "所有技能已是最新",
|
||||
"rescanFailed": "重新扫描依赖失败"
|
||||
},
|
||||
"owner": "所有者",
|
||||
"unknownOwner": "未知",
|
||||
"grants": {
|
||||
"title": "{{name}} 的 Agent 权限",
|
||||
"manage": "管理 Agent 权限",
|
||||
"current": "当前 Agent 授权",
|
||||
"none": "暂无 Agent 授权。",
|
||||
"add": "添加授权",
|
||||
"update": "更新授权",
|
||||
"grant": "授权",
|
||||
"save": "保存",
|
||||
"selectAgent": "选择 Agent",
|
||||
"allowManage": "允许此 Agent 编辑或删除 Skill",
|
||||
"canManage": "可编辑",
|
||||
"loadFailed": "无法加载授权",
|
||||
"saveFailed": "无法保存授权",
|
||||
"revokeFailed": "无法撤销授权"
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import i18next from "i18next";
|
||||
import { userFriendlyError } from "@/lib/error-utils";
|
||||
import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill";
|
||||
import type { SkillInfo, SkillFile, SkillVersions, SkillAgentGrant } from "@/types/skill";
|
||||
|
||||
export type { SkillInfo, SkillFile, SkillVersions };
|
||||
|
||||
@@ -105,6 +105,34 @@ export function useSkills() {
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const listAgentGrants = useCallback(
|
||||
async (id: string) => {
|
||||
const res = await http.get<{ grants: SkillAgentGrant[] }>(`/v1/skills/${id}/grants/agent`);
|
||||
return res.grants ?? [];
|
||||
},
|
||||
[http],
|
||||
);
|
||||
|
||||
const grantSkillToAgent = useCallback(
|
||||
async (id: string, agentId: string, version: number, canManage: boolean) => {
|
||||
await http.post<{ ok: string }>(`/v1/skills/${id}/grants/agent`, {
|
||||
agent_id: agentId,
|
||||
version,
|
||||
can_manage: canManage,
|
||||
});
|
||||
await invalidate();
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const revokeSkillFromAgent = useCallback(
|
||||
async (id: string, agentId: string) => {
|
||||
await http.delete<{ ok: string }>(`/v1/skills/${id}/grants/agent/${agentId}`);
|
||||
await invalidate();
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const getSkillVersions = useCallback(
|
||||
async (id: string) => {
|
||||
return http.get<SkillVersions>(`/v1/skills/${id}/versions`);
|
||||
@@ -220,6 +248,7 @@ export function useSkills() {
|
||||
return {
|
||||
skills, loading, refresh: invalidate, getSkill,
|
||||
uploadSkill, updateSkill, deleteSkill,
|
||||
listAgentGrants, grantSkillToAgent, revokeSkillFromAgent,
|
||||
getSkillVersions, getSkillFiles, getSkillFileContent, rescanDeps, installDeps, installSingleDep, toggleSkill,
|
||||
setTenantConfig, deleteTenantConfig,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import type { SkillAgentGrant, SkillInfo } from "@/types/skill";
|
||||
|
||||
interface SkillAgentGrantsDialogProps {
|
||||
skill: SkillInfo;
|
||||
onClose: () => void;
|
||||
onLoad: (skillId: string) => Promise<SkillAgentGrant[]>;
|
||||
onGrant: (skillId: string, agentId: string, version: number, canManage: boolean) => Promise<void>;
|
||||
onRevoke: (skillId: string, agentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function SkillAgentGrantsDialog({
|
||||
skill,
|
||||
onClose,
|
||||
onLoad,
|
||||
onGrant,
|
||||
onRevoke,
|
||||
}: SkillAgentGrantsDialogProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
const { agents } = useAgents();
|
||||
const [grants, setGrants] = useState<SkillAgentGrant[]>([]);
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [canManage, setCanManage] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!skill.id) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
onLoad(skill.id)
|
||||
.then(setGrants)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : t("grants.loadFailed")))
|
||||
.finally(() => setLoading(false));
|
||||
}, [skill.id, onLoad, t]);
|
||||
|
||||
const agentNames = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const agent of agents) map.set(agent.id, agent.display_name || agent.agent_key);
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
const selectedGrant = grants.find((grant) => grant.agent_id === agentId);
|
||||
|
||||
useEffect(() => {
|
||||
setCanManage(selectedGrant?.can_manage ?? false);
|
||||
}, [selectedGrant]);
|
||||
|
||||
const handleGrant = async () => {
|
||||
if (!skill.id || !agentId) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await onGrant(skill.id, agentId, skill.version ?? 1, canManage);
|
||||
setGrants((current) => {
|
||||
const next: SkillAgentGrant = {
|
||||
agent_id: agentId,
|
||||
pinned_version: skill.version ?? 1,
|
||||
granted_by: "",
|
||||
can_manage: canManage,
|
||||
};
|
||||
if (current.some((grant) => grant.agent_id === agentId)) {
|
||||
return current.map((grant) => (grant.agent_id === agentId ? next : grant));
|
||||
}
|
||||
return [...current, next];
|
||||
});
|
||||
setAgentId("");
|
||||
setCanManage(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("grants.saveFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (grant: SkillAgentGrant) => {
|
||||
if (!skill.id) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await onRevoke(skill.id, grant.agent_id);
|
||||
setGrants((current) => current.filter((item) => item.agent_id !== grant.agent_id));
|
||||
if (agentId === grant.agent_id) setAgentId("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("grants.revokeFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] flex flex-col sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("grants.title", { name: skill.name })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 overflow-y-auto min-h-0 pr-1">
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
<span className="text-muted-foreground">{t("owner")}:</span>{" "}
|
||||
<span className="font-mono">{skill.owner_id || t("unknownOwner")}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("grants.current")}</Label>
|
||||
{grants.length === 0 ? (
|
||||
<p className="rounded-md border px-3 py-4 text-sm text-muted-foreground">{t("grants.none")}</p>
|
||||
) : (
|
||||
<div className="divide-y rounded-md border">
|
||||
{grants.map((grant) => (
|
||||
<div key={grant.agent_id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{agentNames.get(grant.agent_id) || grant.agent_id}</p>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<Badge variant="secondary" className="text-2xs">v{grant.pinned_version}</Badge>
|
||||
{grant.can_manage && (
|
||||
<Badge variant="outline" className="text-2xs border-emerald-500 text-emerald-600">
|
||||
{t("grants.canManage")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={loading} onClick={() => handleRevoke(grant)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<Label>{selectedGrant ? t("grants.update") : t("grants.add")}</Label>
|
||||
<Select value={agentId} onValueChange={setAgentId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("grants.selectAgent")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
{agent.display_name || agent.agent_key}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex items-center justify-between gap-3 rounded-md border px-3 py-2">
|
||||
<span className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-600" />
|
||||
{t("grants.allowManage")}
|
||||
</span>
|
||||
<Switch checked={canManage} onCheckedChange={setCanManage} />
|
||||
</label>
|
||||
<Button size="sm" onClick={handleGrant} disabled={loading || !agentId} className="gap-1">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{selectedGrant ? t("grants.save") : t("grants.grant")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Zap, Pencil, Trash2 } from "lucide-react";
|
||||
import { Zap, Pencil, Trash2, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -20,6 +20,7 @@ interface SkillTableRowProps {
|
||||
toggling: string | null;
|
||||
onView: (name: string) => void;
|
||||
onEdit: (skill: SkillInfo) => void;
|
||||
onManageGrants: (skill: SkillInfo) => void;
|
||||
onDelete: (skill: SkillInfo) => void;
|
||||
onToggle: (skill: SkillInfo, enabled: boolean) => void;
|
||||
onCycleVisibility: (skill: SkillInfo) => void;
|
||||
@@ -30,7 +31,7 @@ interface SkillTableRowProps {
|
||||
/** Single row in the skills table with inline status, visibility, and action controls. */
|
||||
export function SkillTableRow({
|
||||
skill, tab, hasTenantScope, toggling,
|
||||
onView, onEdit, onDelete, onToggle, onCycleVisibility,
|
||||
onView, onEdit, onManageGrants, onDelete, onToggle, onCycleVisibility,
|
||||
onSetTenantConfig, onDeleteTenantConfig,
|
||||
}: SkillTableRowProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
@@ -64,6 +65,13 @@ export function SkillTableRow({
|
||||
{tab === "custom" && (
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{skill.author || "—"}</td>
|
||||
)}
|
||||
{tab === "custom" && (
|
||||
<td className="px-4 py-3">
|
||||
<span className="block max-w-[12rem] truncate font-mono text-xs text-muted-foreground">
|
||||
{skill.owner_id || t("unknownOwner")}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge
|
||||
@@ -132,6 +140,11 @@ export function SkillTableRow({
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(skill)} className="gap-1">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!skill.is_system && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onManageGrants(skill)} className="gap-1" title={t("grants.manage")}>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{!skill.is_system && (
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useSkills, type SkillInfo } from "./hooks/use-skills";
|
||||
import { SkillDetailDialog } from "./skill-detail-dialog";
|
||||
import { SkillEditDialog } from "./skill-edit-dialog";
|
||||
import { SkillAgentGrantsDialog } from "./skill-agent-grants-dialog";
|
||||
|
||||
const SkillUploadDialog = lazy(() =>
|
||||
import("./skill-upload-dialog").then((m) => ({ default: m.SkillUploadDialog }))
|
||||
@@ -32,6 +33,7 @@ export function SkillsPage() {
|
||||
const { t } = useTranslation("skills");
|
||||
const {
|
||||
skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill,
|
||||
listAgentGrants, grantSkillToAgent, revokeSkillFromAgent,
|
||||
getSkillVersions, getSkillFiles, getSkillFileContent, rescanDeps, installSingleDep, toggleSkill,
|
||||
setTenantConfig, deleteTenantConfig,
|
||||
} = useSkills();
|
||||
@@ -45,6 +47,7 @@ export function SkillsPage() {
|
||||
const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<SkillInfo | null>(null);
|
||||
const [grantsTarget, setGrantsTarget] = useState<SkillInfo | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SkillInfo | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
@@ -165,6 +168,7 @@ export function SkillsPage() {
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.description")}</th>
|
||||
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.author")}</th>}
|
||||
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.owner")}</th>}
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
|
||||
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.visibility")}</th>}
|
||||
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
|
||||
@@ -180,6 +184,7 @@ export function SkillsPage() {
|
||||
toggling={toggling}
|
||||
onView={handleViewSkill}
|
||||
onEdit={setEditTarget}
|
||||
onManageGrants={setGrantsTarget}
|
||||
onDelete={setDeleteTarget}
|
||||
onToggle={handleToggle}
|
||||
onCycleVisibility={handleCycleVisibility}
|
||||
@@ -219,6 +224,16 @@ export function SkillsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{grantsTarget && (
|
||||
<SkillAgentGrantsDialog
|
||||
skill={grantsTarget}
|
||||
onClose={() => setGrantsTarget(null)}
|
||||
onLoad={listAgentGrants}
|
||||
onGrant={grantSkillToAgent}
|
||||
onRevoke={revokeSkillFromAgent}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<SkillUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onUpload={(f) => uploadSkill(f)} />
|
||||
</Suspense>
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface SkillInfo {
|
||||
enabled?: boolean;
|
||||
tenant_enabled?: boolean | null;
|
||||
author?: string;
|
||||
owner_id?: string;
|
||||
missing_deps?: string[];
|
||||
}
|
||||
|
||||
@@ -35,6 +36,14 @@ export interface SkillWithGrant {
|
||||
visibility: string;
|
||||
version: number;
|
||||
granted: boolean;
|
||||
can_manage?: boolean;
|
||||
pinned_version?: number;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export interface SkillAgentGrant {
|
||||
agent_id: string;
|
||||
pinned_version: number;
|
||||
granted_by: string;
|
||||
can_manage: boolean;
|
||||
}
|
||||
Reference in new issue
Block a user