Merge remote-tracking branch 'origin/dev' into codex/issue-161-skill-self-evolution

# Conflicts:
#	cmd/skills_cmd.go
#	docs/project-changelog.md
#	internal/http/skills.go
#	internal/store/sqlitestore/schema.go
This commit is contained in:
Goon committed 2026-06-12 14:57:09 +07:00
commit 7692939d09
31 files changed
+2045 -45

No files matched your search

+4
View File
@@ -65,6 +65,10 @@ func gatewayHTTPPut(path string, body any) (map[string]any, error) {
return gatewayHTTPDo(http.MethodPut, path, body)
}
func gatewayHTTPPatch(path string, body any) (map[string]any, error) {
return gatewayHTTPDo(http.MethodPatch, path, body)
}
func gatewayHTTPDelete(path string) error {
_, err := gatewayHTTPDo(http.MethodDelete, path, nil)
return err
+4
View File
@@ -26,6 +26,10 @@ func skillsCmd() *cobra.Command {
cmd.AddCommand(skillsMetricsCmd())
cmd.AddCommand(skillsActivityCmd())
cmd.AddCommand(skillsSuggestionsCmd())
cmd.AddCommand(skillsDepsCmd())
cmd.AddCommand(skillsAccessCmd())
cmd.AddCommand(skillsGrantCmd())
cmd.AddCommand(skillsRevokeCmd())
return cmd
}
+166
View File
@@ -0,0 +1,166 @@
package cmd
import (
"fmt"
"net/http"
"net/url"
"github.com/spf13/cobra"
)
var (
skillsGatewayDo = gatewayHTTPDo
skillsGatewayDelete = gatewayHTTPDelete
skillsRequireGateway = requireRunningGatewayHTTP
)
func skillsDepsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "deps", Short: "Scan, check, and install skill dependencies"}
cmd.AddCommand(skillsDepsReadCmd("status", http.MethodGet, "Show dependency status"))
cmd.AddCommand(skillsDepsReadCmd("scan", http.MethodPost, "Scan dependency declarations"))
cmd.AddCommand(skillsDepsReadCmd("check", http.MethodPost, "Check dependency availability"))
cmd.AddCommand(skillsDepsInstallCmd())
return cmd
}
func skillsDepsReadCmd(name, method, short string) *cobra.Command {
var jsonOutput bool
cmd := &cobra.Command{
Use: name + " [skill-id-or-path]",
Short: short,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if pathExists(args[0]) {
return runLocalSkillDepsStatus(cmd.OutOrStdout(), args[0], jsonOutput)
}
skillsRequireGateway()
path := "/v1/skills/" + url.PathEscape(args[0]) + "/dependencies"
if method == http.MethodPost {
path += "/" + name
}
return runSkillsGateway(cmd.OutOrStdout(), method, path, nil, jsonOutput)
},
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
return cmd
}
func skillsDepsInstallCmd() *cobra.Command {
var jsonOutput bool
cmd := &cobra.Command{
Use: "install [skill-id]",
Short: "Install missing dependencies for a managed skill",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
skillsRequireGateway()
path := "/v1/skills/" + url.PathEscape(args[0]) + "/dependencies/install"
return runSkillsGateway(cmd.OutOrStdout(), http.MethodPost, path, nil, jsonOutput)
},
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
return cmd
}
func skillsAccessCmd() *cobra.Command {
cmd := &cobra.Command{Use: "access", Short: "Manage skill access mode and effective access"}
cmd.AddCommand(skillsAccessGetCmd())
cmd.AddCommand(skillsAccessSetCmd())
cmd.AddCommand(skillsAccessEffectiveCmd())
return cmd
}
func skillsAccessGetCmd() *cobra.Command {
var jsonOutput bool
cmd := &cobra.Command{
Use: "get [skill-id]",
Short: "Show skill access mode and grants",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
skillsRequireGateway()
path := "/v1/skills/" + url.PathEscape(args[0]) + "/access"
return runSkillsGateway(cmd.OutOrStdout(), http.MethodGet, path, nil, jsonOutput)
},
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
return cmd
}
func skillsAccessSetCmd() *cobra.Command {
var mode string
var jsonOutput bool
cmd := &cobra.Command{
Use: "set [skill-id]",
Short: "Set skill access mode",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if mode == "" {
return fmt.Errorf("--mode is required")
}
skillsRequireGateway()
path := "/v1/skills/" + url.PathEscape(args[0]) + "/access"
return runSkillsGateway(cmd.OutOrStdout(), http.MethodPatch, path, map[string]any{"mode": mode}, jsonOutput)
},
}
cmd.Flags().StringVar(&mode, "mode", "", "access mode: private, internal, public")
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
return cmd
}
func skillsAccessEffectiveCmd() *cobra.Command {
var agentID, userID string
var jsonOutput bool
cmd := &cobra.Command{
Use: "effective [skill-id]",
Short: "Inspect effective access for an agent and user",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if agentID == "" || userID == "" {
return fmt.Errorf("--agent and --user are required")
}
skillsRequireGateway()
values := url.Values{"agent_id": {agentID}, "user_id": {userID}}
path := "/v1/skills/access/effective"
if len(args) == 1 {
path = "/v1/skills/" + url.PathEscape(args[0]) + "/access/effective"
}
return runSkillsGateway(cmd.OutOrStdout(), http.MethodGet, path+"?"+values.Encode(), nil, jsonOutput)
},
}
cmd.Flags().StringVar(&agentID, "agent", "", "agent ID")
cmd.Flags().StringVar(&userID, "user", "", "user ID")
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
return cmd
}
func skillsGrantCmd() *cobra.Command {
cmd := &cobra.Command{Use: "grant", Short: "Grant skill access"}
cmd.AddCommand(skillsGrantAgentCmd())
cmd.AddCommand(skillsGrantUserCmd())
return cmd
}
func skillsGrantAgentCmd() *cobra.Command {
var canManage, jsonOutput bool
var pinnedVersion int
cmd := &cobra.Command{
Use: "agent [skill-id] [agent-id]",
Short: "Grant a skill to an agent",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
skillsRequireGateway()
body := map[string]any{"agent_id": args[1]}
if canManage {
body["can_manage"] = true
}
if pinnedVersion > 0 {
body["pinned_version"] = pinnedVersion
}
path := "/v1/skills/" + url.PathEscape(args[0]) + "/grants/agents"
return runSkillsGateway(cmd.OutOrStdout(), http.MethodPost, path, body, jsonOutput)
},
}
cmd.Flags().BoolVar(&canManage, "can-manage", false, "grant manage permission")
cmd.Flags().IntVar(&pinnedVersion, "pinned-version", 0, "pin a specific skill version")
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
return cmd
}
+159
View File
@@ -0,0 +1,159 @@
package cmd
import (
"bytes"
"io"
"net/http"
"os"
"reflect"
"testing"
)
func TestSkillsDepsInstallUsesPerSkillInstallEndpoint(t *testing.T) {
calls := captureSkillGatewayCalls(t)
cmd := skillsDepsCmd()
cmd.SetArgs([]string{"install", "skill-123", "--json"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if len(calls.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(calls.requests))
}
got := calls.requests[0]
if got.method != http.MethodPost || got.path != "/v1/skills/skill-123/dependencies/install" {
t.Fatalf("request = %+v", got)
}
}
func TestSkillsAccessSetUsesPatchModeBody(t *testing.T) {
calls := captureSkillGatewayCalls(t)
cmd := skillsAccessCmd()
cmd.SetArgs([]string{"set", "skill-123", "--mode", "public", "--json"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if len(calls.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(calls.requests))
}
got := calls.requests[0]
if got.method != http.MethodPatch || got.path != "/v1/skills/skill-123/access" {
t.Fatalf("request = %+v", got)
}
if !reflect.DeepEqual(got.body, map[string]any{"mode": "public"}) {
t.Fatalf("body = %#v", got.body)
}
}
func TestSkillsGrantAgentUsesPluralGrantEndpoint(t *testing.T) {
calls := captureSkillGatewayCalls(t)
cmd := skillsGrantCmd()
cmd.SetArgs([]string{"agent", "skill-123", "agent-456", "--can-manage", "--pinned-version", "7", "--json"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if len(calls.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(calls.requests))
}
got := calls.requests[0]
if got.method != http.MethodPost || got.path != "/v1/skills/skill-123/grants/agents" {
t.Fatalf("request = %+v", got)
}
want := map[string]any{"agent_id": "agent-456", "can_manage": true, "pinned_version": 7}
if !reflect.DeepEqual(got.body, want) {
t.Fatalf("body = %#v, want %#v", got.body, want)
}
}
func TestSkillsAccessEffectiveBuildsOptionalSkillURL(t *testing.T) {
calls := captureSkillGatewayCalls(t)
cmd := skillsAccessCmd()
cmd.SetArgs([]string{"effective", "skill-123", "--agent", "agent-456", "--user", "user-789", "--json"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if len(calls.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(calls.requests))
}
got := calls.requests[0]
wantPath := "/v1/skills/skill-123/access/effective?agent_id=agent-456&user_id=user-789"
if got.method != http.MethodGet || got.path != wantPath {
t.Fatalf("request = %+v, want path %s", got, wantPath)
}
}
type skillGatewayCall struct {
method string
path string
body any
}
type capturedSkillGatewayCalls struct {
requests []skillGatewayCall
}
func captureSkillGatewayCalls(t *testing.T) *capturedSkillGatewayCalls {
t.Helper()
calls := &capturedSkillGatewayCalls{}
prevDo := skillsGatewayDo
prevDelete := skillsGatewayDelete
prevRequire := skillsRequireGateway
prevStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdout = w
t.Cleanup(func() {
_ = w.Close()
_, _ = io.Copy(io.Discard, r)
_ = r.Close()
os.Stdout = prevStdout
skillsGatewayDo = prevDo
skillsGatewayDelete = prevDelete
skillsRequireGateway = prevRequire
})
skillsRequireGateway = func() {}
skillsGatewayDo = func(method, path string, body any) (map[string]any, error) {
calls.requests = append(calls.requests, skillGatewayCall{method: method, path: path, body: body})
return map[string]any{"ok": true}, nil
}
skillsGatewayDelete = func(path string) error {
calls.requests = append(calls.requests, skillGatewayCall{method: http.MethodDelete, path: path})
return nil
}
return calls
}
func TestRunLocalSkillDepsStatusPrintsJSON(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(dir+"/SKILL.md", []byte("---\nname: Demo\ndeps:\n - system:goclaw-missing-test-bin\n---\n"), 0o644); err != nil {
t.Fatalf("write SKILL.md: %v", err)
}
var out bytes.Buffer
if err := runLocalSkillDepsStatus(&out, dir, true); err != nil {
t.Fatalf("runLocalSkillDepsStatus: %v", err)
}
if !bytes.Contains(out.Bytes(), []byte(`"missing_count": 1`)) {
t.Fatalf("output = %s", out.String())
}
}
+134
View File
@@ -0,0 +1,134 @@
package cmd
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/nextlevelbuilder/goclaw/internal/skills"
)
func skillsGrantUserCmd() *cobra.Command {
var jsonOutput bool
cmd := &cobra.Command{
Use: "user [skill-id] [user-id]",
Short: "Grant a skill to a user",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
skillsRequireGateway()
path := "/v1/skills/" + url.PathEscape(args[0]) + "/grants/users"
return runSkillsGateway(cmd.OutOrStdout(), http.MethodPost, path, map[string]any{"user_id": args[1]}, jsonOutput)
},
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
return cmd
}
func skillsRevokeCmd() *cobra.Command {
cmd := &cobra.Command{Use: "revoke", Short: "Revoke skill access"}
cmd.AddCommand(skillsRevokeAgentCmd())
cmd.AddCommand(skillsRevokeUserCmd())
return cmd
}
func skillsRevokeAgentCmd() *cobra.Command {
return &cobra.Command{
Use: "agent [skill-id] [agent-id]",
Short: "Revoke a skill from an agent",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
skillsRequireGateway()
path := "/v1/skills/" + url.PathEscape(args[0]) + "/grants/agents/" + url.PathEscape(args[1])
return runSkillsGatewayDelete(cmd.OutOrStdout(), path)
},
}
}
func skillsRevokeUserCmd() *cobra.Command {
return &cobra.Command{
Use: "user [skill-id] [user-id]",
Short: "Revoke a skill from a user",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
skillsRequireGateway()
path := "/v1/skills/" + url.PathEscape(args[0]) + "/grants/users/" + url.PathEscape(args[1])
return runSkillsGatewayDelete(cmd.OutOrStdout(), path)
},
}
}
func runSkillsGateway(w io.Writer, method, path string, body any, jsonOutput bool) error {
resp, err := skillsGatewayDo(method, path, body)
if err != nil {
return err
}
if jsonOutput {
return writePrettyJSON(w, resp)
}
if ok, _ := resp["ok"].(bool); ok {
_, _ = fmt.Fprintln(w, "ok")
return nil
}
return writePrettyJSON(w, resp)
}
func runSkillsGatewayDelete(w io.Writer, path string) error {
if err := skillsGatewayDelete(path); err != nil {
return err
}
_, _ = fmt.Fprintln(w, "ok")
return nil
}
func runLocalSkillDepsStatus(w io.Writer, target string, jsonOutput bool) error {
dir := target
if filepath.Base(target) == "SKILL.md" {
dir = filepath.Dir(target)
}
manifest := skills.ScanSkillDeps(dir)
ok, missing := skills.CheckSkillDeps(manifest)
resp := map[string]any{
"skill": map[string]any{"path": dir},
"ok": ok,
"status": localDepsStatus(ok),
"manifest": manifest,
"missing": missing,
"missing_count": len(missing),
}
if jsonOutput {
return writePrettyJSON(w, resp)
}
if ok {
_, _ = fmt.Fprintln(w, "all deps satisfied")
return nil
}
_, _ = fmt.Fprintf(w, "missing dependencies: %s\n", skills.FormatMissing(missing))
return nil
}
func writePrettyJSON(w io.Writer, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(data))
return err
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func localDepsStatus(ok bool) string {
if ok {
return "ok"
}
return "missing"
}
@@ -345,10 +345,18 @@ All endpoints require authentication (`authMiddleware`). Mutation endpoints requ
| `PUT` | `/v1/skills/{id}` | Update metadata (owner/admin) |
| `DELETE` | `/v1/skills/{id}` | Delete/archive skill (owner/admin) |
| `POST` | `/v1/skills/{id}/toggle` | Enable/disable skill (owner/admin) |
| `GET` | `/v1/skills/{id}/dependencies` | Structured dependency status by source |
| `POST` | `/v1/skills/{id}/dependencies/scan` | Re-scan skill dependencies |
| `POST` | `/v1/skills/{id}/dependencies/check` | Check missing skill dependencies |
| `POST` | `/v1/skills/{id}/dependencies/install` | Install missing deps for one skill (master tenant) |
| `GET` | `/v1/skills/{id}/access` | Read visibility and grants |
| `PATCH` | `/v1/skills/{id}/access` | Set visibility/access mode |
| `GET` | `/v1/skills/{id}/access/effective` | Explain access for one skill/agent/user |
| `GET` | `/v1/skills/access/effective` | Explain effective access across skills |
| `POST` | `/v1/skills/{id}/grants/agent` | Grant skill to agent (owner/admin) |
| `DELETE` | `/v1/skills/{id}/grants/agent` | Revoke agent grant (owner/admin) |
| `DELETE` | `/v1/skills/{id}/grants/agent/{agentID}` | Revoke agent grant (owner/admin) |
| `POST` | `/v1/skills/{id}/grants/user` | Grant skill to user (owner/admin) |
| `DELETE` | `/v1/skills/{id}/grants/user` | Revoke user grant (owner/admin) |
| `DELETE` | `/v1/skills/{id}/grants/user/{userID}` | Revoke user grant (owner/admin) |
| `POST` | `/v1/skills/upload` | Upload custom skill ZIP |
| `POST` | `/v1/skills/rescan-deps` | Re-scan all enabled skills |
| `POST` | `/v1/skills/install-deps` | Install all missing deps |
+18
View File
@@ -51,6 +51,24 @@ Significant changes, features, and fixes in reverse chronological order.
- Added focused skill path validator coverage and deep-link coverage for the new
Web UI evolution tab.
### Skill lifecycle API and CLI (issue #159)
**Changes**
- Added per-skill dependency scan/check/install endpoints and CLI commands.
- Added access read/update, agent/user grant aliases, and effective-access
inspection for operator workflows.
- Dependency status now reports system, pip, npm, and GitHub release deps in
structured output.
- Skill user grants are now tenant-scoped so shared system skills can be
granted to the same user ID in different tenants without conflicts.
**Tests**
- Added HTTP, CLI, PostgreSQL, and SQLite coverage for dependency lifecycle,
access mode changes, grants, effective access, grant-driven visibility, and
tenant-scoped user grants.
### Mid-flight request preservation (issue #137)
**Fixes**
+17 -15
View File
@@ -108,12 +108,26 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/approve", h.adminMiddleware(h.handleApproveSkillSuggestion))
mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/reject", h.adminMiddleware(h.handleRejectSkillSuggestion))
mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/apply", h.adminMiddleware(h.handleApplySkillSuggestion))
mux.HandleFunc("GET /v1/skills/{id}/dependencies", h.adminMiddleware(h.handleSkillDependenciesStatus))
mux.HandleFunc("POST /v1/skills/{id}/dependencies/scan", h.adminMiddleware(h.handleSkillDependenciesStatus))
mux.HandleFunc("POST /v1/skills/{id}/dependencies/check", h.adminMiddleware(h.handleSkillDependenciesStatus))
mux.HandleFunc("POST /v1/skills/{id}/dependencies/install", h.adminMiddleware(h.handleSkillDependenciesInstall))
mux.HandleFunc("GET /v1/skills/{id}/access", h.adminMiddleware(h.handleGetSkillAccess))
mux.HandleFunc("PATCH /v1/skills/{id}/access", h.adminMiddleware(h.handlePatchSkillAccess))
mux.HandleFunc("GET /v1/skills/{id}/access/effective", h.adminMiddleware(h.handleGetSkillEffectiveAccess))
mux.HandleFunc("GET /v1/skills/access/effective", h.adminMiddleware(h.handleListEffectiveAccess))
// 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("GET /v1/skills/{id}/grants/agents", h.adminMiddleware(h.handleListAgentGrants))
mux.HandleFunc("POST /v1/skills/{id}/grants/agents", h.adminMiddleware(h.handleGrantAgent))
mux.HandleFunc("DELETE /v1/skills/{id}/grants/agents/{agentID}", h.adminMiddleware(h.handleRevokeAgent))
mux.HandleFunc("GET /v1/skills/{id}/grants/users", h.adminMiddleware(h.handleListUserGrants))
mux.HandleFunc("POST /v1/skills/{id}/grants/user", h.adminMiddleware(h.handleGrantUser))
mux.HandleFunc("DELETE /v1/skills/{id}/grants/user/{userID}", h.adminMiddleware(h.handleRevokeUser))
mux.HandleFunc("POST /v1/skills/{id}/grants/users", h.adminMiddleware(h.handleGrantUser))
mux.HandleFunc("DELETE /v1/skills/{id}/grants/users/{userID}", h.adminMiddleware(h.handleRevokeUser))
// System-level operations: admin + master tenant only.
// These execute shell commands (pip/npm install) and affect the entire server.
mux.HandleFunc("POST /v1/skills/rescan-deps", h.adminMiddleware(h.handleRescanDeps))
@@ -144,19 +158,7 @@ func (h *SkillsHandler) adminMiddleware(next http.HandlerFunc) http.HandlerFunc
// System skill management (install packages, rescan deps) is a server-wide operation
// that should only be accessible to the master tenant or cross-tenant admins.
func (h *SkillsHandler) requireMasterTenant(w http.ResponseWriter, r *http.Request) bool {
ctx := r.Context()
if store.IsOwnerRole(ctx) {
return true
}
tid := store.TenantIDFromContext(ctx)
if tid == store.MasterTenantID {
return true
}
locale := store.LocaleFromContext(ctx)
writeJSON(w, http.StatusForbidden, map[string]string{
"error": i18n.T(locale, i18n.MsgPermissionDenied, "system skill management"),
})
return false
return requireMasterScope(w, r)
}
func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) {
@@ -519,7 +521,7 @@ func stringSlicesEqual(a, b []string) bool {
// bundled dir if the managed copy's scripts/ directory is missing or empty.
// If a fallback scan succeeds, re-copies the bundled scripts to the managed dir.
func (h *SkillsHandler) scanWithFallback(sk store.SkillInfo) *skills.SkillManifest {
manifest := skills.ScanSkillDeps(sk.BaseDir)
manifest := scanSkillDeps(sk.BaseDir)
if manifest != nil && !manifest.IsEmpty() {
return manifest
}
@@ -536,7 +538,7 @@ func (h *SkillsHandler) scanWithFallback(sk store.SkillInfo) *skills.SkillManife
}
bundledSkillDir := filepath.Join(h.bundledDir, sk.Slug)
bundledManifest := skills.ScanSkillDeps(bundledSkillDir)
bundledManifest := scanSkillDeps(bundledSkillDir)
if bundledManifest == nil || bundledManifest.IsEmpty() {
return manifest
}
+152
View File
@@ -0,0 +1,152 @@
package http
import (
"net/http"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type skillAccessResponse struct {
Skill skillRef `json:"skill"`
Visibility string `json:"visibility"`
AgentGrants []store.SkillAgentGrantInfo `json:"agent_grants"`
UserGrants []store.SkillUserGrantInfo `json:"user_grants"`
}
type skillEffectiveAccessResponse struct {
Skill skillRef `json:"skill"`
Accessible bool `json:"accessible"`
Reason string `json:"reason"`
CanManage bool `json:"can_manage"`
PinnedVersion *int `json:"pinned_version,omitempty"`
}
func (h *SkillsHandler) handleGetSkillAccess(w http.ResponseWriter, r *http.Request) {
sk, ok := h.lifecycleSkill(w, r, false)
if !ok {
return
}
agentGrants, err := h.skills.ListAgentGrantsForSkill(r.Context(), mustParseUUID(sk.ID))
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
userGrants, err := h.skills.ListUserGrantsForSkill(r.Context(), mustParseUUID(sk.ID))
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, skillAccessResponse{
Skill: refForSkill(sk),
Visibility: sk.Visibility,
AgentGrants: agentGrants,
UserGrants: userGrants,
})
}
func (h *SkillsHandler) handlePatchSkillAccess(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
sk, ok := h.lifecycleSkill(w, r, false)
if !ok {
return
}
if sk.IsSystem && !h.requireMasterTenant(w, r) {
return
}
var req struct {
Mode string `json:"mode"`
Visibility string `json:"visibility"`
}
if !bindJSON(w, r, locale, &req) {
return
}
visibility := req.Visibility
if visibility == "" {
visibility = req.Mode
}
if err := skills.ValidateVisibility(visibility); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVisibility, visibility)})
return
}
visibility = skills.NormalizeVisibility(visibility)
id := mustParseUUID(sk.ID)
updateCtx := r.Context()
if sk.IsSystem {
updateCtx = store.WithCrossTenant(store.WithTenantID(updateCtx, store.MasterTenantID))
}
if err := h.skills.UpdateSkill(updateCtx, id, map[string]any{"visibility": visibility}); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
h.skills.BumpVersion()
h.emitCacheInvalidate(bus.CacheKindSkills, sk.ID, uuid.Nil)
emitAudit(h.msgBus, r, "skill.access_updated", "skill", sk.ID)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "visibility": visibility})
}
func (h *SkillsHandler) handleGetSkillEffectiveAccess(w http.ResponseWriter, r *http.Request) {
sk, ok := h.lifecycleSkill(w, r, false)
if !ok {
return
}
agentID, userID, ok := parseEffectiveAccessParams(w, r)
if !ok {
return
}
if !h.requireTenantUser(w, r, userID) {
return
}
idx, err := h.buildEffectiveAccessIndex(r, agentID, userID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
resp := effectiveAccessForSkill(r.Context(), sk, idx, userID)
writeJSON(w, http.StatusOK, resp)
}
func (h *SkillsHandler) handleListEffectiveAccess(w http.ResponseWriter, r *http.Request) {
agentID, userID, ok := parseEffectiveAccessParams(w, r)
if !ok {
return
}
if !h.requireTenantUser(w, r, userID) {
return
}
idx, err := h.buildEffectiveAccessIndex(r, agentID, userID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
skills := h.skills.ListAllSkills(r.Context())
out := make([]skillEffectiveAccessResponse, 0, len(skills))
for _, sk := range skills {
out = append(out, effectiveAccessForSkill(r.Context(), sk, idx, userID))
}
writeJSON(w, http.StatusOK, map[string]any{"skills": out})
}
func parseEffectiveAccessParams(w http.ResponseWriter, r *http.Request) (uuid.UUID, string, bool) {
locale := store.LocaleFromContext(r.Context())
agentID, err := uuid.Parse(r.URL.Query().Get("agent_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
return uuid.Nil, "", false
}
userID := r.URL.Query().Get("user_id")
if err := store.ValidateUserID(userID); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return uuid.Nil, "", false
}
return agentID, userID, true
}
func mustParseUUID(raw string) uuid.UUID {
id, _ := uuid.Parse(raw)
return id
}
+111
View File
@@ -0,0 +1,111 @@
package http
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type effectiveAccessIndex struct {
actorID string
accessibleBySlug map[string]bool
agentGrantByID map[string]store.SkillWithGrantStatus
agentGrantBySlug map[string]store.SkillWithGrantStatus
}
func (h *SkillsHandler) buildEffectiveAccessIndex(r interface{ Context() context.Context }, agentID uuid.UUID, userID string) (effectiveAccessIndex, error) {
ctx := r.Context()
accessStore, ok := h.skills.(store.SkillAccessStore)
if !ok {
return effectiveAccessIndex{}, fmt.Errorf("skill access store not available")
}
accessible, err := accessStore.ListAccessible(ctx, agentID, userID)
if err != nil {
return effectiveAccessIndex{}, err
}
grantStatus, err := h.skills.ListWithGrantStatus(ctx, agentID)
if err != nil {
return effectiveAccessIndex{}, err
}
idx := effectiveAccessIndex{
actorID: store.ActorIDFromContext(ctx),
accessibleBySlug: make(map[string]bool, len(accessible)),
agentGrantByID: make(map[string]store.SkillWithGrantStatus, len(grantStatus)),
agentGrantBySlug: make(map[string]store.SkillWithGrantStatus, len(grantStatus)),
}
if idx.actorID == "" {
idx.actorID = userID
}
for _, sk := range accessible {
idx.accessibleBySlug[sk.Slug] = true
}
for _, grant := range grantStatus {
idx.agentGrantByID[grant.ID.String()] = grant
idx.agentGrantBySlug[grant.Slug] = grant
}
return idx, nil
}
func effectiveAccessForSkill(ctx context.Context, sk store.SkillInfo, idx effectiveAccessIndex, userID string) skillEffectiveAccessResponse {
resp := skillEffectiveAccessResponse{Skill: refForSkill(sk), Reason: "none"}
if sk.Status != "active" || !sk.Enabled {
resp.Reason = "inactive"
return resp
}
if !idx.accessible(sk) {
return resp
}
if sk.IsSystem {
resp.Accessible = true
resp.Reason = "system"
return resp
}
if sk.Visibility == skills.VisibilityPublic {
resp.Accessible = true
resp.Reason = "public"
return resp
}
actorID := idx.actorID
if actorID == "" {
actorID = store.ActorIDFromContext(ctx)
}
if actorID == "" {
actorID = userID
}
if sk.Visibility == skills.VisibilityPrivate && (sk.OwnerID == userID || sk.OwnerID == actorID) {
resp.Accessible = true
resp.Reason = "owner"
return resp
}
if sk.Visibility != skills.VisibilityInternal {
return resp
}
if grant, ok := idx.agentGrant(sk); ok && grant.Granted {
resp.Accessible = true
resp.Reason = "agent_grant"
resp.CanManage = grant.CanManage
resp.PinnedVersion = grant.PinnedVer
return resp
}
resp.Accessible = true
resp.Reason = "user_grant"
return resp
}
func (idx effectiveAccessIndex) accessible(sk store.SkillInfo) bool {
return idx.accessibleBySlug[sk.Slug]
}
func (idx effectiveAccessIndex) agentGrant(sk store.SkillInfo) (store.SkillWithGrantStatus, bool) {
if sk.ID != "" {
if grant, ok := idx.agentGrantByID[sk.ID]; ok {
return grant, true
}
}
grant, ok := idx.agentGrantBySlug[sk.Slug]
return grant, ok
}
+53
View File
@@ -50,6 +50,25 @@ func (h *SkillsHandler) handleListAgentGrants(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusOK, map[string]any{"grants": grants})
}
func (h *SkillsHandler) handleListUserGrants(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.ListUserGrantsForSkill(r.Context(), skillID)
if err != nil {
slog.Error("failed to list skill user 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())
@@ -72,6 +91,7 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request)
var req struct {
AgentID string `json:"agent_id"`
Version int `json:"version"`
PinnedVersion int `json:"pinned_version"`
CanManage *bool `json:"can_manage"`
}
if !bindJSON(w, r, locale, &req) {
@@ -84,6 +104,9 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request)
return
}
if req.PinnedVersion > 0 {
req.Version = req.PinnedVersion
}
if req.Version <= 0 {
req.Version = 1
}
@@ -175,6 +198,9 @@ func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if !h.requireTenantUser(w, r, req.UserID) {
return
}
if err := h.skills.GrantToUser(r.Context(), skillID, req.UserID, userID); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -222,4 +248,31 @@ func (h *SkillsHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}
func (h *SkillsHandler) requireTenantUser(w http.ResponseWriter, r *http.Request, userID string) bool {
locale := store.LocaleFromContext(r.Context())
if h.tenantStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": i18n.T(locale, i18n.MsgNotImplemented, "tenant store")})
return false
}
tenantID := store.TenantIDFromContext(r.Context())
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
role, err := h.tenantStore.GetUserRole(r.Context(), tenantID, userID)
if err != nil {
slog.Error("skill_grants: tenant user membership check failed", "tenant_id", tenantID, "target_user_id", userID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tenant users")})
return false
}
if role == "" {
slog.Warn("security.skill_grant_user_not_tenant_member",
"tenant_id", tenantID,
"target_user_id", userID,
"user_id", store.UserIDFromContext(r.Context()))
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenant user")})
return false
}
return true
}
// --- Helpers ---
+144
View File
@@ -0,0 +1,144 @@
package http
import (
"context"
"net/http"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
var (
scanSkillDeps = skills.ScanSkillDeps
checkSkillDeps = skills.CheckSkillDeps
githubSkillDependencyInstalled = skillGitHubDependencyInstalled
)
type skillRef struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
Version int `json:"version"`
}
type skillDependencyItem struct {
Source string `json:"source"`
Name string `json:"name"`
Status string `json:"status"`
}
type skillDependencyStatusResponse struct {
Skill skillRef `json:"skill"`
Dependencies []skillDependencyItem `json:"dependencies"`
OK bool `json:"ok"`
Status string `json:"status"`
Missing []string `json:"missing,omitempty"`
MissingCount int `json:"missing_count"`
}
func (h *SkillsHandler) handleSkillDependenciesStatus(w http.ResponseWriter, r *http.Request) {
if !h.requireMasterTenant(w, r) {
return
}
sk, ok := h.lifecycleSkill(w, r, false)
if !ok {
return
}
writeJSON(w, http.StatusOK, h.buildSkillDependencyStatus(sk))
}
func (h *SkillsHandler) handleSkillDependenciesInstall(w http.ResponseWriter, r *http.Request) {
if !h.requireMasterTenant(w, r) {
return
}
sk, ok := h.lifecycleSkill(w, r, true)
if !ok {
return
}
manifest := h.scanWithFallback(sk)
if manifest == nil || manifest.IsEmpty() {
h.persistSkillDependencyState(r.Context(), sk, true, nil)
writeJSON(w, http.StatusOK, h.buildSkillDependencyStatus(sk))
return
}
_, missing := lifecycleCheckSkillDeps(manifest)
if len(missing) == 0 {
h.persistSkillDependencyState(r.Context(), sk, true, nil)
writeJSON(w, http.StatusOK, h.buildSkillDependencyStatus(sk))
return
}
result, err := installLifecycleDeps(r.Context(), manifest, missing)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
okAfter, missingAfter := lifecycleCheckSkillDeps(manifest)
h.persistSkillDependencyState(r.Context(), sk, okAfter, missingAfter)
resp := h.buildSkillDependencyStatus(sk)
resp.OK = okAfter
resp.Missing = missingAfter
resp.MissingCount = len(missingAfter)
resp.Status = dependencyStatus(okAfter)
writeJSON(w, http.StatusOK, map[string]any{"result": result, "dependencies": resp})
}
func (h *SkillsHandler) lifecycleSkill(w http.ResponseWriter, r *http.Request, crossTenant bool) (store.SkillInfo, bool) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
return store.SkillInfo{}, false
}
ctx := r.Context()
if crossTenant {
ctx = store.WithCrossTenant(store.WithTenantID(ctx, store.MasterTenantID))
}
sk, ok := h.skills.GetSkillByID(ctx, id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
return store.SkillInfo{}, false
}
return sk, true
}
func (h *SkillsHandler) buildSkillDependencyStatus(sk store.SkillInfo) skillDependencyStatusResponse {
manifest := h.scanWithFallback(sk)
if manifest == nil || manifest.IsEmpty() {
return skillDependencyStatusResponse{Skill: refForSkill(sk), OK: true, Status: "ok"}
}
ok, missing := lifecycleCheckSkillDeps(manifest)
return skillDependencyStatusResponse{
Skill: refForSkill(sk),
Dependencies: dependencyItems(manifest, missing),
OK: ok,
Status: dependencyStatus(ok),
Missing: missing,
MissingCount: len(missing),
}
}
func (h *SkillsHandler) persistSkillDependencyState(ctx context.Context, sk store.SkillInfo, ok bool, missing []string) {
id, err := uuid.Parse(sk.ID)
if err != nil {
return
}
updateCtx := skillTenantContext(ctx, sk)
_ = h.skills.StoreMissingDeps(updateCtx, id, missing)
wantStatus := "archived"
if ok {
wantStatus = "active"
}
if sk.Status != wantStatus {
_ = h.skills.UpdateSkill(updateCtx, id, map[string]any{"status": wantStatus})
h.emitCacheInvalidate(bus.CacheKindSkills, sk.ID, uuid.Nil)
}
}
func refForSkill(sk store.SkillInfo) skillRef {
return skillRef{ID: sk.ID, Slug: sk.Slug, Name: sk.Name, Status: sk.Status, Version: sk.Version}
}
+164
View File
@@ -0,0 +1,164 @@
package http
import (
"context"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/skills"
)
func lifecycleCheckSkillDeps(m *skills.SkillManifest) (bool, []string) {
ok, missing := checkSkillDeps(m)
missing = appendUniqueDeps(missing, missingGitHubSkillDeps(m)...)
return ok && len(missing) == 0, missing
}
func missingGitHubSkillDeps(m *skills.SkillManifest) []string {
if m == nil {
return nil
}
var missing []string
for _, raw := range m.Explicit {
if strings.HasPrefix(raw, "github:") && !githubSkillDependencyInstalled(raw) {
missing = append(missing, raw)
}
}
return missing
}
func skillGitHubDependencyInstalled(raw string) bool {
spec, err := skills.ParseGitHubSpec(raw)
if err != nil {
return false
}
installer := skills.DefaultGitHubInstaller()
if installer == nil {
return false
}
entries, err := installer.List()
if err != nil {
return false
}
wantRepo := spec.Owner + "/" + spec.Repo
for _, entry := range entries {
if strings.EqualFold(entry.Repo, wantRepo) && (spec.Tag == "" || entry.Tag == spec.Tag) {
return true
}
}
return false
}
func installLifecycleDeps(ctx context.Context, manifest *skills.SkillManifest, missing []string) (*skills.InstallResult, error) {
bulkMissing, githubMissing := splitGitHubMissingDeps(missing)
result := &skills.InstallResult{}
if len(bulkMissing) > 0 {
bulkResult, err := installManagedDeps(ctx, manifest, bulkMissing)
if err != nil {
return nil, err
}
mergeInstallResult(result, bulkResult)
}
for _, dep := range githubMissing {
ok, errMsg := installSingleDep(ctx, dep)
name := strings.TrimPrefix(dep, "github:")
if !ok {
result.Errors = append(result.Errors, "github "+name+": "+errMsg)
continue
}
result.GitHub = append(result.GitHub, name)
}
return result, nil
}
func splitGitHubMissingDeps(missing []string) ([]string, []string) {
var bulkMissing []string
var githubMissing []string
for _, dep := range missing {
if strings.HasPrefix(dep, "github:") {
githubMissing = append(githubMissing, dep)
continue
}
bulkMissing = append(bulkMissing, dep)
}
return bulkMissing, githubMissing
}
func mergeInstallResult(dst, src *skills.InstallResult) {
if dst == nil || src == nil {
return
}
dst.System = append(dst.System, src.System...)
dst.Pip = append(dst.Pip, src.Pip...)
dst.Npm = append(dst.Npm, src.Npm...)
dst.GitHub = append(dst.GitHub, src.GitHub...)
dst.Errors = append(dst.Errors, src.Errors...)
}
func dependencyItems(m *skills.SkillManifest, missing []string) []skillDependencyItem {
missingSet := dependencyMissingSet(missing)
var out []skillDependencyItem
add := func(source, name string) {
status := "installed"
if missingSet[dependencyKey(source, name)] {
status = "missing"
}
out = append(out, skillDependencyItem{Source: source, Name: name, Status: status})
}
for _, name := range m.Requires {
add("system", name)
}
for _, name := range m.RequiresPython {
add("pip", name)
}
for _, name := range m.RequiresNode {
add("npm", name)
}
for _, raw := range m.Explicit {
if after, ok := strings.CutPrefix(raw, "github:"); ok {
add("github", after)
}
}
return out
}
func dependencyMissingSet(missing []string) map[string]bool {
out := make(map[string]bool, len(missing))
for _, dep := range missing {
source, name := splitDependency(dep)
out[dependencyKey(source, name)] = true
}
return out
}
func splitDependency(dep string) (string, string) {
for _, prefix := range []string{"pip:", "npm:", "github:", "system:"} {
if after, ok := strings.CutPrefix(dep, prefix); ok {
return strings.TrimSuffix(prefix, ":"), after
}
}
return "system", dep
}
func dependencyKey(source, name string) string { return source + ":" + name }
func appendUniqueDeps(dst []string, src ...string) []string {
seen := make(map[string]bool, len(dst)+len(src))
for _, dep := range dst {
seen[dep] = true
}
for _, dep := range src {
if seen[dep] {
continue
}
dst = append(dst, dep)
seen[dep] = true
}
return dst
}
func dependencyStatus(ok bool) string {
if ok {
return "ok"
}
return "missing"
}
+437
View File
@@ -0,0 +1,437 @@
package http
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestHandleSkillDependenciesStatus_ReturnsStructuredMissingBySource(t *testing.T) {
handler, skillStore, ctx, root := newTestUploadHandler(t)
skillDir := filepath.Join(root, "skills-store", "dep-skill", "1")
skillID := skillStore.seedCustomSkill("dep-skill", skillDir, "archived", []string{"ffmpeg", "pip:requests"})
prevScan := scanSkillDeps
prevCheck := checkSkillDeps
prevGitHubInstalled := githubSkillDependencyInstalled
scanSkillDeps = func(string) *skills.SkillManifest {
return &skills.SkillManifest{
Requires: []string{"ffmpeg"},
RequiresPython: []string{"requests"},
RequiresNode: []string{"tsx"},
Explicit: []string{"github:cli/cli@v2.40.0"},
}
}
checkSkillDeps = func(*skills.SkillManifest) (bool, []string) {
return false, []string{"ffmpeg", "pip:requests"}
}
githubSkillDependencyInstalled = func(string) bool { return false }
t.Cleanup(func() {
scanSkillDeps = prevScan
checkSkillDeps = prevCheck
githubSkillDependencyInstalled = prevGitHubInstalled
})
req := httptest.NewRequest(http.MethodGet, "/v1/skills/"+skillID.String()+"/dependencies", http.NoBody).WithContext(ctx)
req.SetPathValue("id", skillID.String())
w := httptest.NewRecorder()
handler.handleSkillDependenciesStatus(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
MissingCount int `json:"missing_count"`
Dependencies []struct {
Source string `json:"source"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"dependencies"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.OK {
t.Fatal("ok = true, want false")
}
if resp.MissingCount != 3 {
t.Fatalf("missing_count = %d, want 3", resp.MissingCount)
}
got := map[string]string{}
for _, dep := range resp.Dependencies {
got[dep.Source+":"+dep.Name] = dep.Status
}
want := map[string]string{
"system:ffmpeg": "missing",
"pip:requests": "missing",
"npm:tsx": "installed",
"github:cli/cli@v2.40.0": "missing",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("dependencies = %#v, want %#v", got, want)
}
}
func TestSkillDependencyRoutesRequireAdmin(t *testing.T) {
handler, skillStore, _, root := newTestUploadHandler(t)
skillID := skillStore.seedCustomSkill("dep-skill", filepath.Join(root, "skills-store", "dep-skill", "1"), "active", nil)
tenantID := uuid.New()
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey("read-token"): {ID: uuid.New(), Scopes: []string{"operator.read"}},
crypto.HashAPIKey("tenant-admin-token"): {
ID: uuid.New(),
Scopes: []string{"operator.admin"},
TenantID: tenantID,
OwnerID: "tenant-admin",
},
})
prevScan := scanSkillDeps
scanCalled := false
scanSkillDeps = func(string) *skills.SkillManifest {
scanCalled = true
return &skills.SkillManifest{}
}
t.Cleanup(func() { scanSkillDeps = prevScan })
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/v1/skills/"+skillID.String()+"/dependencies", http.NoBody)
req.Header.Set("Authorization", "Bearer read-token")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
}
if scanCalled {
t.Fatal("dependency scan ran for non-admin caller")
}
scanCalled = false
req = httptest.NewRequest(http.MethodGet, "/v1/skills/"+skillID.String()+"/dependencies", http.NoBody)
req.Header.Set("Authorization", "Bearer tenant-admin-token")
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("tenant admin status = %d, want 403; body = %s", w.Code, w.Body.String())
}
if scanCalled {
t.Fatal("dependency scan ran for non-master tenant admin")
}
}
func TestHandleSkillDependenciesInstall_SplitsGitHubDepsToSingleInstaller(t *testing.T) {
handler, skillStore, ctx, root := newTestUploadHandler(t)
skillDir := filepath.Join(root, "skills-store", "dep-skill", "1")
skillID := skillStore.seedCustomSkill("dep-skill", skillDir, "archived", []string{"pip:requests", "github:cli/cli@v2.40.0"})
prevScan := scanSkillDeps
prevCheck := checkSkillDeps
prevInstallManaged := installManagedDeps
prevInstallSingle := installSingleDep
prevGitHubInstalled := githubSkillDependencyInstalled
githubInstalled := false
scanSkillDeps = func(string) *skills.SkillManifest {
return &skills.SkillManifest{
RequiresPython: []string{"requests"},
Explicit: []string{"github:cli/cli@v2.40.0"},
}
}
checkSkillDeps = func(*skills.SkillManifest) (bool, []string) {
if githubInstalled {
return true, nil
}
return false, []string{"pip:requests"}
}
githubSkillDependencyInstalled = func(raw string) bool {
if raw != "github:cli/cli@v2.40.0" {
t.Fatalf("github status checked for %q", raw)
}
return githubInstalled
}
installManagedDeps = func(_ context.Context, _ *skills.SkillManifest, missing []string) (*skills.InstallResult, error) {
if !reflect.DeepEqual(missing, []string{"pip:requests"}) {
t.Fatalf("managed missing = %#v, want pip only", missing)
}
return &skills.InstallResult{Pip: []string{"requests"}}, nil
}
installSingleDep = func(_ context.Context, dep string) (bool, string) {
if dep != "github:cli/cli@v2.40.0" {
t.Fatalf("single dep = %q, want github spec", dep)
}
githubInstalled = true
return true, ""
}
t.Cleanup(func() {
scanSkillDeps = prevScan
checkSkillDeps = prevCheck
installManagedDeps = prevInstallManaged
installSingleDep = prevInstallSingle
githubSkillDependencyInstalled = prevGitHubInstalled
})
req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+skillID.String()+"/dependencies/install", http.NoBody).WithContext(ctx)
req.SetPathValue("id", skillID.String())
w := httptest.NewRecorder()
handler.handleSkillDependenciesInstall(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var resp struct {
Result skills.InstallResult `json:"result"`
Deps struct {
OK bool `json:"ok"`
MissingCount int `json:"missing_count"`
} `json:"dependencies"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !reflect.DeepEqual(resp.Result.Pip, []string{"requests"}) || !reflect.DeepEqual(resp.Result.GitHub, []string{"cli/cli@v2.40.0"}) {
t.Fatalf("install result = %+v", resp.Result)
}
if !resp.Deps.OK || resp.Deps.MissingCount != 0 {
t.Fatalf("dependencies = %+v", resp.Deps)
}
}
func TestHandleSkillDependenciesInstall_RejectsNonMasterTenant(t *testing.T) {
handler, skillStore, _, root := newTestUploadHandler(t)
tenantID := uuid.New()
ctx := store.WithTenantID(context.Background(), tenantID)
skillDir := filepath.Join(root, "tenants", tenantID.String(), "skills-store", "dep-skill", "1")
skillID := skillStore.seedCustomSkillForTenant(tenantID, "dep-skill", skillDir, "archived", []string{"pip:requests"})
installCalled := false
prevInstall := installManagedDeps
installManagedDeps = func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
installCalled = true
return &skills.InstallResult{}, nil
}
t.Cleanup(func() { installManagedDeps = prevInstall })
req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+skillID.String()+"/dependencies/install", http.NoBody).WithContext(ctx)
req.SetPathValue("id", skillID.String())
w := httptest.NewRecorder()
handler.handleSkillDependenciesInstall(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
}
if installCalled {
t.Fatal("installManagedDeps was called for non-master tenant")
}
}
func TestSkillGrantUserRouteRejectsNonTenantMember(t *testing.T) {
handler, skillStore, _, root := newTestUploadHandler(t)
tenantID := uuid.New()
skillID := skillStore.seedCustomSkillForTenant(tenantID, "access-skill", filepath.Join(root, "tenants", tenantID.String(), "skills-store", "access-skill", "1"), "active", nil)
tenants := newMockTenantStore()
tenants.addTenant(tenantID, "tenant-a")
tenants.setUserRole(tenantID, "admin-user", store.TenantRoleAdmin)
handler.tenantStore = tenants
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey("admin-token"): {
ID: uuid.New(),
Scopes: []string{"operator.admin"},
TenantID: tenantID,
OwnerID: "admin-user",
},
})
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+skillID.String()+"/grants/users", strings.NewReader(`{"user_id":"outside-user"}`))
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
}
if len(skillStore.userGrantCalls) != 0 {
t.Fatalf("user grant calls = %+v, want none", skillStore.userGrantCalls)
}
}
func TestHandleSkillAccessGetListsAgentAndUserGrants(t *testing.T) {
handler, skillStore, ctx, root := newTestUploadHandler(t)
skillID := skillStore.seedCustomSkill("access-skill", filepath.Join(root, "skills-store", "access-skill", "1"), "active", nil)
agentID := uuid.New()
skillStore.agentGrants[skillID] = []store.SkillAgentGrantInfo{{
AgentID: agentID,
PinnedVersion: 3,
GrantedBy: "owner-user",
CanManage: true,
}}
skillStore.userGrants[skillID] = []store.SkillUserGrantInfo{{
UserID: "target-user",
GrantedBy: "owner-user",
}}
req := httptest.NewRequest(http.MethodGet, "/v1/skills/"+skillID.String()+"/access", http.NoBody).WithContext(ctx)
req.SetPathValue("id", skillID.String())
w := httptest.NewRecorder()
handler.handleGetSkillAccess(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var resp struct {
Visibility string `json:"visibility"`
AgentGrants []store.SkillAgentGrantInfo `json:"agent_grants"`
UserGrants []store.SkillUserGrantInfo `json:"user_grants"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.AgentGrants) != 1 || !resp.AgentGrants[0].CanManage || resp.AgentGrants[0].PinnedVersion != 3 {
t.Fatalf("agent grants = %+v", resp.AgentGrants)
}
if len(resp.UserGrants) != 1 || resp.UserGrants[0].UserID != "target-user" {
t.Fatalf("user grants = %+v", resp.UserGrants)
}
}
func TestHandleSkillEffectiveAccessReportsAgentGrant(t *testing.T) {
handler, skillStore, ctx, root := newTestUploadHandler(t)
skillID := skillStore.seedCustomSkill("effective-skill", filepath.Join(root, "skills-store", "effective-skill", "1"), "active", nil)
agentID := uuid.New()
tenants := newMockTenantStore()
tenants.addTenant(store.MasterTenantID, "master")
tenants.setUserRole(store.MasterTenantID, "target-user", store.TenantRoleMember)
handler.tenantStore = tenants
skillStore.skills[skillID] = store.SkillInfo{
ID: skillID.String(),
TenantID: store.MasterTenantID.String(),
Name: "Effective Skill",
Slug: "effective-skill",
Path: filepath.Join(root, "skills-store", "effective-skill", "1", "SKILL.md"),
BaseDir: filepath.Join(root, "skills-store", "effective-skill", "1"),
Visibility: "internal",
Status: "active",
Enabled: true,
}
skillStore.agentGrants[skillID] = []store.SkillAgentGrantInfo{{
AgentID: agentID,
PinnedVersion: 2,
GrantedBy: "owner-user",
CanManage: true,
}}
req := httptest.NewRequest(http.MethodGet, "/v1/skills/"+skillID.String()+"/access/effective?agent_id="+agentID.String()+"&user_id=target-user", http.NoBody).WithContext(ctx)
req.SetPathValue("id", skillID.String())
w := httptest.NewRecorder()
handler.handleGetSkillEffectiveAccess(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var resp struct {
Accessible bool `json:"accessible"`
Reason string `json:"reason"`
CanManage bool `json:"can_manage"`
PinnedVersion *int `json:"pinned_version"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.Accessible || resp.Reason != "agent_grant" || !resp.CanManage || resp.PinnedVersion == nil || *resp.PinnedVersion != 2 {
t.Fatalf("effective access = %+v", resp)
}
}
func TestEffectiveAccessTreatsAccessibleIndexAsSourceOfTruthForSystemSkills(t *testing.T) {
ctx := store.WithTenantID(context.Background(), uuid.New())
sk := store.SkillInfo{
ID: uuid.NewString(),
Name: "System Skill",
Slug: "system-skill",
Status: "active",
Enabled: true,
IsSystem: true,
}
resp := effectiveAccessForSkill(ctx, sk, effectiveAccessIndex{accessibleBySlug: map[string]bool{}}, "target-user")
if resp.Accessible || resp.Reason != "none" {
t.Fatalf("effective access = %+v, want inaccessible none", resp)
}
resp = effectiveAccessForSkill(ctx, sk, effectiveAccessIndex{accessibleBySlug: map[string]bool{"system-skill": true}}, "target-user")
if !resp.Accessible || resp.Reason != "system" {
t.Fatalf("effective access = %+v, want system access", resp)
}
}
func TestHandlePatchSkillAccessRejectsNonMasterSystemSkillUpdate(t *testing.T) {
handler, skillStore, _, root := newTestUploadHandler(t)
tenantID := uuid.New()
skillID := skillStore.seedSystemSkill("system-skill", filepath.Join(root, "bundled", "system-skill"))
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey("tenant-admin-token"): {
ID: uuid.New(),
Scopes: []string{"operator.admin"},
TenantID: tenantID,
OwnerID: "tenant-admin",
},
})
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/v1/skills/"+skillID.String()+"/access", strings.NewReader(`{"mode":"public"}`))
req.Header.Set("Authorization", "Bearer tenant-admin-token")
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
}
if updates := skillStore.lastUpdates[skillID]; updates != nil {
t.Fatalf("system skill updated by non-master tenant: %+v", updates)
}
}
func TestHandlePatchSkillAccessAcceptsModeAlias(t *testing.T) {
handler, skillStore, ctx, root := newTestUploadHandler(t)
skillID := skillStore.seedCustomSkill("access-skill", filepath.Join(root, "skills-store", "access-skill", "1"), "active", nil)
req := httptest.NewRequest(http.MethodPatch, "/v1/skills/"+skillID.String()+"/access", bytes.NewBufferString(`{"mode":"PUBLIC"}`)).WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
req.SetPathValue("id", skillID.String())
w := httptest.NewRecorder()
handler.handlePatchSkillAccess(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
info, _ := skillStore.GetSkillByID(ctx, skillID)
if info.Visibility != "public" {
t.Fatalf("visibility = %q, want public", info.Visibility)
}
}
+92 -6
View File
@@ -864,8 +864,11 @@ type skillManageStoreStub struct {
systemDirs map[string]string
hashBySlug map[string]string // slug -> SKILL.md content hash (most recent)
grantCalls []skillGrantCall
userGrantCalls []skillUserGrantCall
grantErrors map[uuid.UUID]error
lastUpdates map[uuid.UUID]map[string]any
agentGrants map[uuid.UUID][]store.SkillAgentGrantInfo
userGrants map[uuid.UUID][]store.SkillUserGrantInfo
}
type skillUploadSystemConfigStore struct {
@@ -904,6 +907,12 @@ type skillGrantCall struct {
CanManage bool
}
type skillUserGrantCall struct {
SkillID uuid.UUID
UserID string
GrantedBy string
}
func newSkillManageStoreStub(baseDir string) *skillManageStoreStub {
return &skillManageStoreStub{
baseDir: baseDir,
@@ -913,10 +922,12 @@ func newSkillManageStoreStub(baseDir string) *skillManageStoreStub {
hashBySlug: map[string]string{},
grantErrors: map[uuid.UUID]error{},
lastUpdates: map[uuid.UUID]map[string]any{},
agentGrants: map[uuid.UUID][]store.SkillAgentGrantInfo{},
userGrants: map[uuid.UUID][]store.SkillUserGrantInfo{},
}
}
func (s *skillManageStoreStub) seedSystemSkill(slug, dir string) {
func (s *skillManageStoreStub) seedSystemSkill(slug, dir string) uuid.UUID {
id := uuid.New()
s.skills[id] = store.SkillInfo{
ID: id.String(),
@@ -931,6 +942,7 @@ func (s *skillManageStoreStub) seedSystemSkill(slug, dir string) {
IsSystem: true,
}
s.systemDirs[slug] = dir
return id
}
func (s *skillManageStoreStub) seedCustomSkill(slug, dir, status string, missing []string) uuid.UUID {
@@ -1156,15 +1168,89 @@ func (s *skillManageStoreStub) GrantToAgent(_ context.Context, skillID uuid.UUID
func (s *skillManageStoreStub) RevokeFromAgent(context.Context, uuid.UUID, uuid.UUID) error {
return nil
}
func (s *skillManageStoreStub) GrantToUser(context.Context, uuid.UUID, string, string) error {
func (s *skillManageStoreStub) GrantToUser(_ context.Context, skillID uuid.UUID, userID, grantedBy string) error {
s.userGrantCalls = append(s.userGrantCalls, skillUserGrantCall{SkillID: skillID, UserID: userID, GrantedBy: grantedBy})
return nil
}
func (s *skillManageStoreStub) RevokeFromUser(context.Context, uuid.UUID, string) error { return nil }
func (s *skillManageStoreStub) ListWithGrantStatus(context.Context, uuid.UUID) ([]store.SkillWithGrantStatus, error) {
return nil, nil
func (s *skillManageStoreStub) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.SkillInfo, error) {
actorID := store.ActorIDFromContext(ctx)
if actorID == "" {
actorID = userID
}
func (s *skillManageStoreStub) ListAgentGrantsForSkill(context.Context, uuid.UUID) ([]store.SkillAgentGrantInfo, error) {
return nil, nil
out := make([]store.SkillInfo, 0, len(s.skills))
for id, skill := range s.skills {
if skill.Status != "active" || !skill.Enabled || !s.canAccessSkill(ctx, skill) {
continue
}
switch skill.Visibility {
case skills.VisibilityPublic:
out = append(out, skill)
case skills.VisibilityPrivate:
if skill.OwnerID == userID || skill.OwnerID == actorID {
out = append(out, skill)
}
case skills.VisibilityInternal:
if s.hasAgentGrant(id, agentID) || s.hasUserGrant(id, userID) || s.hasUserGrant(id, actorID) {
out = append(out, skill)
}
default:
if skill.IsSystem {
out = append(out, skill)
}
}
}
return out, nil
}
func (s *skillManageStoreStub) ListWithGrantStatus(ctx context.Context, agentID uuid.UUID) ([]store.SkillWithGrantStatus, error) {
out := make([]store.SkillWithGrantStatus, 0, len(s.skills))
for id, skill := range s.skills {
if skill.Status != "active" || !s.canAccessSkill(ctx, skill) {
continue
}
row := store.SkillWithGrantStatus{
ID: id,
Name: skill.Name,
Slug: skill.Slug,
Description: skill.Description,
Visibility: skill.Visibility,
Version: skill.Version,
IsSystem: skill.IsSystem,
}
for _, grant := range s.agentGrants[id] {
if grant.AgentID == agentID {
row.Granted = true
row.CanManage = grant.CanManage
pinned := grant.PinnedVersion
row.PinnedVer = &pinned
break
}
}
out = append(out, row)
}
return out, nil
}
func (s *skillManageStoreStub) hasAgentGrant(skillID, agentID uuid.UUID) bool {
for _, grant := range s.agentGrants[skillID] {
if grant.AgentID == agentID {
return true
}
}
return false
}
func (s *skillManageStoreStub) hasUserGrant(skillID uuid.UUID, userID string) bool {
for _, grant := range s.userGrants[skillID] {
if grant.UserID == userID {
return true
}
}
return false
}
func (s *skillManageStoreStub) ListAgentGrantsForSkill(_ context.Context, skillID uuid.UUID) ([]store.SkillAgentGrantInfo, error) {
return append([]store.SkillAgentGrantInfo(nil), s.agentGrants[skillID]...), nil
}
func (s *skillManageStoreStub) ListUserGrantsForSkill(_ context.Context, skillID uuid.UUID) ([]store.SkillUserGrantInfo, error) {
return append([]store.SkillUserGrantInfo(nil), s.userGrants[skillID]...), nil
}
func (s *skillManageStoreStub) AgentCanManageSkill(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
return false, nil
+1
View File
@@ -50,6 +50,7 @@ type InstallResult struct {
System []string `json:"system,omitempty"`
Pip []string `json:"pip,omitempty"`
Npm []string `json:"npm,omitempty"`
GitHub []string `json:"github,omitempty"`
Errors []string `json:"errors,omitempty"`
}
+1 -1
View File
@@ -24,7 +24,7 @@ type SkillManifest struct {
// IsEmpty returns true if the manifest has no dependencies.
func (m *SkillManifest) IsEmpty() bool {
return len(m.Requires) == 0 && len(m.RequiresPython) == 0 && len(m.RequiresNode) == 0
return len(m.Requires) == 0 && len(m.RequiresPython) == 0 && len(m.RequiresNode) == 0 && len(m.Explicit) == 0
}
// ScanSkillDeps auto-detects dependencies by statically analyzing the scripts/ directory,
+63 -4
View File
@@ -321,17 +321,37 @@ func (s *PGSkillStore) GrantToUser(ctx context.Context, skillID uuid.UUID, userI
if err := store.ValidateUserID(grantedBy); err != nil {
return err
}
tid := tenantIDForInsert(ctx)
if err := s.verifySkillInGrantScope(ctx, skillID, tid); err != nil {
return err
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at, tenant_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (skill_id, user_id) DO NOTHING`,
store.GenNewID(), skillID, userID, grantedBy, time.Now(), tenantIDForInsert(ctx),
ON CONFLICT (skill_id, user_id, tenant_id) DO NOTHING`,
store.GenNewID(), skillID, userID, grantedBy, time.Now(), tid,
)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`UPDATE skills
SET visibility = 'internal', updated_at = NOW()
WHERE id = $1 AND visibility = 'private' AND (is_system = true OR tenant_id = $2)`,
skillID, tid)
if err != nil {
slog.Warn("skill_grants: failed to auto-promote visibility for user grant", "skill_id", skillID, "error", err)
}
s.BumpVersion()
return nil
}
// RevokeFromUser revokes a skill grant from a user.
func (s *PGSkillStore) RevokeFromUser(ctx context.Context, skillID uuid.UUID, userID string) error {
tid := tenantIDForInsert(ctx)
if err := s.verifySkillInGrantScope(ctx, skillID, tid); err != nil {
return err
}
tClause, tArgs, _, err := scopeClause(ctx, 3)
if err != nil {
return err
@@ -339,8 +359,43 @@ func (s *PGSkillStore) RevokeFromUser(ctx context.Context, skillID uuid.UUID, us
_, err = s.db.ExecContext(ctx,
"DELETE FROM skill_user_grants WHERE skill_id = $1 AND user_id = $2"+tClause,
append([]any{skillID, userID}, tArgs...)...)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET visibility = 'private', updated_at = NOW()
WHERE id = $1 AND visibility = 'internal' AND (is_system = true OR tenant_id = $2)
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = $1)
AND NOT EXISTS (SELECT 1 FROM skill_user_grants WHERE skill_id = $1)`,
skillID, tid)
if err != nil {
slog.Warn("skill_grants: failed to auto-demote visibility for user grant", "skill_id", skillID, "error", err)
}
s.BumpVersion()
return nil
}
// ListUserGrantsForSkill returns all user grants for one skill.
func (s *PGSkillStore) ListUserGrantsForSkill(ctx context.Context, skillID uuid.UUID) ([]store.SkillUserGrantInfo, error) {
if err := s.verifySkillInGrantScope(ctx, skillID, tenantIDForInsert(ctx)); err != nil {
return nil, err
}
tClause, tArgs, _, err := scopeClauseAlias(ctx, 2, "sug")
if err != nil {
return nil, err
}
var result []store.SkillUserGrantInfo
err = pkgSqlxDB.SelectContext(ctx, &result,
`SELECT sug.user_id, sug.granted_by
FROM skill_user_grants sug
WHERE sug.skill_id = $1`+tClause+`
ORDER BY sug.created_at DESC`,
append([]any{skillID}, tArgs...)...)
if err != nil {
return nil, err
}
return result, nil
}
// ListAccessible returns skills accessible to a given agent+user combination.
// Access logic: public → all, private → owner only, internal → check grants.
@@ -364,9 +419,13 @@ func (s *PGSkillStore) ListAccessible(ctx context.Context, agentID uuid.UUID, us
return nil, err
}
tenantCond := ""
agentGrantTenantCond := ""
userGrantTenantCond := ""
if tc != "" {
// tc is " AND tenant_id = $4"; we need it as an OR condition inside the WHERE
tenantCond = fmt.Sprintf(" AND (s.is_system = true OR s.tenant_id = $%d)", 4)
agentGrantTenantCond = fmt.Sprintf(" AND sag.tenant_id = $%d", 4)
userGrantTenantCond = fmt.Sprintf(" AND sug.tenant_id = $%d", 4)
_ = tc // tcArgs carries the value
}
// LEFT JOIN skill_tenant_configs to exclude per-tenant disabled skills.
@@ -379,8 +438,8 @@ func (s *PGSkillStore) ListAccessible(ctx context.Context, agentID uuid.UUID, us
}
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT s.name, s.slug, s.description, s.version, s.file_path FROM skills s
LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = $1
LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND (sug.user_id = $2 OR sug.user_id = $3)`+stcJoin+`
LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = $1`+agentGrantTenantCond+`
LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND (sug.user_id = $2 OR sug.user_id = $3)`+userGrantTenantCond+stcJoin+`
WHERE s.status = 'active'`+tenantCond+stcFilter+` AND (
s.is_system = true
OR s.visibility = 'public'
+7
View File
@@ -114,6 +114,12 @@ type SkillAgentGrantInfo struct {
CanManage bool `json:"can_manage" db:"can_manage"`
}
// SkillUserGrantInfo is a user grant row for one skill.
type SkillUserGrantInfo struct {
UserID string `json:"user_id" db:"user_id"`
GrantedBy string `json:"granted_by" db:"granted_by"`
}
// 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.
@@ -146,6 +152,7 @@ type SkillManageStore interface {
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)
ListUserGrantsForSkill(ctx context.Context, skillID uuid.UUID) ([]SkillUserGrantInfo, 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)
+20 -3
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 47
const SchemaVersion = 48
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -831,8 +831,25 @@ CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials (
CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id);
CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id);
CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id);`,
// Version 46 → 47: skill self-evolution settings, metrics, suggestions, and immutable version records.
46: addSkillSelfEvolutionTables,
// Version 46 → 47: scope skill user-grant uniqueness by tenant.
46: `CREATE TABLE skill_user_grants_new (
id TEXT NOT NULL PRIMARY KEY,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
granted_by VARCHAR(255) NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(skill_id, user_id, tenant_id)
);
INSERT OR IGNORE INTO skill_user_grants_new (id, skill_id, user_id, granted_by, tenant_id, created_at)
SELECT id, skill_id, user_id, granted_by, tenant_id, created_at
FROM skill_user_grants;
DROP TABLE skill_user_grants;
ALTER TABLE skill_user_grants_new RENAME TO skill_user_grants;
CREATE INDEX IF NOT EXISTS idx_skill_user_grants_user ON skill_user_grants(user_id);
CREATE INDEX IF NOT EXISTS idx_skill_user_grants_tenant ON skill_user_grants(tenant_id);`,
// Version 47 → 48: skill self-evolution settings, metrics, suggestions, and immutable version records.
47: addSkillSelfEvolutionTables,
}
const addSkillSelfEvolutionTables = `
+1 -1
View File
@@ -428,7 +428,7 @@ CREATE TABLE IF NOT EXISTS skill_user_grants (
granted_by VARCHAR(255) NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(skill_id, user_id)
UNIQUE(skill_id, user_id, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_skill_user_grants_user ON skill_user_grants(user_id);
+6 -5
View File
@@ -123,7 +123,7 @@ func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo
var err error
if store.IsCrossTenant(ctx) {
rows, err = s.db.QueryContext(ctx,
`SELECT id, tenant_id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
FROM skills WHERE enabled = 1 AND status != 'deleted'
ORDER BY name`)
} else {
@@ -132,7 +132,7 @@ func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo
tid = store.MasterTenantID
}
rows, err = s.db.QueryContext(ctx,
`SELECT id, tenant_id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
FROM skills WHERE enabled = 1 AND status != 'deleted' AND (is_system = 1 OR tenant_id = ?)
ORDER BY name`, tid)
}
@@ -145,7 +145,7 @@ func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo
func (s *SQLiteSkillStore) ListAllSystemSkills(ctx context.Context) []store.SkillInfo {
rows, err := s.db.QueryContext(ctx,
`SELECT id, tenant_id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
FROM skills WHERE is_system = 1 AND enabled = 1 AND status != 'deleted'
ORDER BY name`)
if err != nil {
@@ -160,20 +160,21 @@ func (s *SQLiteSkillStore) scanSkillInfoList(rows *sql.Rows) []store.SkillInfo {
for rows.Next() {
var id uuid.UUID
var tenantID uuid.UUID
var name, slug, visibility, status string
var name, slug, visibility, ownerID, status string
var desc *string
var tagsJSON []byte
var version int
var isSystem, enabled bool
var depsRaw []byte
var filePath *string
if err := rows.Scan(&id, &tenantID, &name, &slug, &desc, &visibility, &tagsJSON, &version,
if err := rows.Scan(&id, &tenantID, &name, &slug, &desc, &visibility, &ownerID, &tagsJSON, &version,
&isSystem, &status, &enabled, &depsRaw, &filePath); err != nil {
continue
}
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
info.TenantID = tenantID.String()
info.Visibility = visibility
info.OwnerID = ownerID
scanJSONStringArray(tagsJSON, &info.Tags)
info.IsSystem = isSystem
info.Status = status
+80 -7
View File
@@ -370,18 +370,38 @@ func (s *SQLiteSkillStore) GrantToUser(ctx context.Context, skillID uuid.UUID, u
if err := store.ValidateUserID(grantedBy); err != nil {
return err
}
tid := tenantIDForInsert(ctx)
if err := s.verifySkillInGrantScope(ctx, skillID, tid); err != nil {
return err
}
id := store.GenNewID()
_, err := s.db.ExecContext(ctx,
`INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (skill_id, user_id) DO NOTHING`,
id, skillID, userID, grantedBy, time.Now().UTC(), tenantIDForInsert(ctx),
ON CONFLICT (skill_id, user_id, tenant_id) DO NOTHING`,
id, skillID, userID, grantedBy, time.Now().UTC(), tid,
)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`UPDATE skills
SET visibility = 'internal', updated_at = ?
WHERE id = ? AND visibility = 'private' AND (is_system = 1 OR tenant_id = ?)`,
time.Now().UTC(), skillID, tid)
if err != nil {
slog.Warn("skill_grants: failed to auto-promote visibility for user grant", "skill_id", skillID, "error", err)
}
s.BumpVersion()
return nil
}
// RevokeFromUser revokes a skill grant from a user.
func (s *SQLiteSkillStore) RevokeFromUser(ctx context.Context, skillID uuid.UUID, userID string) error {
tid := tenantIDForInsert(ctx)
if err := s.verifySkillInGrantScope(ctx, skillID, tid); err != nil {
return err
}
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
@@ -389,8 +409,53 @@ func (s *SQLiteSkillStore) RevokeFromUser(ctx context.Context, skillID uuid.UUID
_, err = s.db.ExecContext(ctx,
"DELETE FROM skill_user_grants WHERE skill_id = ? AND user_id = ?"+tClause,
append([]any{skillID, userID}, tArgs...)...)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET visibility = 'private', updated_at = ?
WHERE id = ? AND visibility = 'internal' AND (is_system = 1 OR tenant_id = ?)
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = ?)
AND NOT EXISTS (SELECT 1 FROM skill_user_grants WHERE skill_id = ?)`,
time.Now().UTC(), skillID, tid, skillID, skillID)
if err != nil {
slog.Warn("skill_grants: failed to auto-demote visibility for user grant", "skill_id", skillID, "error", err)
}
s.BumpVersion()
return nil
}
// ListUserGrantsForSkill returns all user grants for one skill.
func (s *SQLiteSkillStore) ListUserGrantsForSkill(ctx context.Context, skillID uuid.UUID) ([]store.SkillUserGrantInfo, error) {
if err := s.verifySkillInGrantScope(ctx, skillID, tenantIDForInsert(ctx)); err != nil {
return nil, err
}
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT sug.user_id, sug.granted_by
FROM skill_user_grants sug
WHERE sug.skill_id = ?`+strings.ReplaceAll(tClause, "tenant_id", "sug.tenant_id")+`
ORDER BY sug.created_at DESC`,
append([]any{skillID}, tArgs...)...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.SkillUserGrantInfo
for rows.Next() {
var g store.SkillUserGrantInfo
if err := rows.Scan(&g.UserID, &g.GrantedBy); err != nil {
slog.Warn("skill_grants: scan error in ListUserGrantsForSkill", "error", err)
continue
}
result = append(result, g)
}
return result, rows.Err()
}
// ListAccessible returns skills accessible to a given agent+user combination.
// See PGSkillStore.ListAccessible for the ACTOR-vs-SCOPE dual-match rationale.
@@ -404,27 +469,35 @@ func (s *SQLiteSkillStore) ListAccessible(ctx context.Context, agentID uuid.UUID
return nil, err
}
tenantCond := ""
agentGrantTenantCond := ""
userGrantTenantCond := ""
stcJoin := ""
stcFilter := ""
if len(tArgs) > 0 {
tenantCond = " AND (s.is_system = 1 OR s.tenant_id = ?)"
agentGrantTenantCond = " AND sag.tenant_id = ?"
userGrantTenantCond = " AND sug.tenant_id = ?"
stcJoin = " LEFT JOIN skill_tenant_configs stc ON s.id = stc.skill_id AND stc.tenant_id = ?"
stcFilter = " AND (stc.enabled IS NULL OR stc.enabled = 1)"
}
// Positional args: agentID, userID, actorID, [tenantID x2 if tenant-scoped], userID, actorID (private-owner clause)
queryArgs := []any{agentID, userID, actorID}
queryArgs := []any{agentID}
if len(tArgs) > 0 {
queryArgs = append(queryArgs, tArgs...) // tenant cond
queryArgs = append(queryArgs, tArgs...) // sag tenant
}
queryArgs = append(queryArgs, userID, actorID)
if len(tArgs) > 0 {
queryArgs = append(queryArgs, tArgs...) // sug tenant
queryArgs = append(queryArgs, tArgs...) // stc join
queryArgs = append(queryArgs, tArgs...) // tenant cond
}
// Remove tClause (aliased scope) — we handle it manually above.
_ = tClause
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT s.name, s.slug, s.description, s.version, s.file_path FROM skills s
LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = ?
LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND (sug.user_id = ? OR sug.user_id = ?)`+stcJoin+`
LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = ?`+agentGrantTenantCond+`
LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND (sug.user_id = ? OR sug.user_id = ?)`+userGrantTenantCond+stcJoin+`
WHERE s.status = 'active'`+tenantCond+stcFilter+` AND (
s.is_system = 1
OR s.visibility = 'public'
+107
View File
@@ -250,6 +250,89 @@ func TestSQLiteSkillStore_RevokeFromAgentKeepsInternalWhenUserGrantRemains(t *te
}
}
func TestSQLiteSkillStore_UserGrantListPromotesAndDemotesVisibility(t *testing.T) {
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
tenantID, _ := seedSQLiteTenantAgent(t, db)
ctx := store.WithTenantID(context.Background(), tenantID)
skillID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
Name: "User Shared Skill",
Slug: "user-shared-skill-" + tenantID.String()[:8],
OwnerID: "owner-user",
Visibility: "private",
FilePath: filepath.Join(t.TempDir(), "user-shared-skill", "1"),
})
if err != nil {
t.Fatalf("CreateSkillManaged error: %v", err)
}
if err := skillStore.GrantToUser(ctx, skillID, "granted-user", "owner-user"); err != nil {
t.Fatalf("GrantToUser error: %v", err)
}
grants, err := skillStore.ListUserGrantsForSkill(ctx, skillID)
if err != nil {
t.Fatalf("ListUserGrantsForSkill error: %v", err)
}
if len(grants) != 1 || grants[0].UserID != "granted-user" || grants[0].GrantedBy != "owner-user" {
t.Fatalf("user grants = %+v", grants)
}
got, ok := skillStore.GetSkillByID(ctx, skillID)
if !ok {
t.Fatal("GetSkillByID returned !ok")
}
if got.Visibility != "internal" {
t.Fatalf("visibility after user grant = %q, want internal", got.Visibility)
}
if err := skillStore.RevokeFromUser(ctx, skillID, "granted-user"); err != nil {
t.Fatalf("RevokeFromUser error: %v", err)
}
got, ok = skillStore.GetSkillByID(ctx, skillID)
if !ok {
t.Fatal("GetSkillByID returned !ok after revoke")
}
if got.Visibility != "private" {
t.Fatalf("visibility after last user grant revoke = %q, want private", got.Visibility)
}
}
func TestSQLiteSkillStore_UserGrantsAreTenantScopedForSystemSkill(t *testing.T) {
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
tenantA, _ := seedSQLiteTenantAgent(t, db)
tenantB, _ := seedSQLiteTenantAgent(t, db)
ctxA := store.WithTenantID(context.Background(), tenantA)
ctxB := store.WithTenantID(context.Background(), tenantB)
skillID := uuid.New()
slug := "system-user-grant-" + skillID.String()[:8]
if _, err := db.Exec(
`INSERT INTO skills (id, name, slug, owner_id, visibility, version, status, file_path, is_system, tenant_id)
VALUES (?, 'System User Grant Skill', ?, 'system', 'private', 1, 'active', ?, 1, ?)`,
skillID.String(), slug, filepath.Join(t.TempDir(), "system-user-grant", "1"), store.MasterTenantID.String(),
); err != nil {
t.Fatalf("insert system skill: %v", err)
}
if err := skillStore.GrantToUser(ctxA, skillID, "same-user", "tenant-a-admin"); err != nil {
t.Fatalf("GrantToUser tenant A error: %v", err)
}
if err := skillStore.GrantToUser(ctxB, skillID, "same-user", "tenant-b-admin"); err != nil {
t.Fatalf("GrantToUser tenant B error: %v", err)
}
grantsA, err := skillStore.ListUserGrantsForSkill(ctxA, skillID)
if err != nil {
t.Fatalf("ListUserGrantsForSkill tenant A error: %v", err)
}
grantsB, err := skillStore.ListUserGrantsForSkill(ctxB, skillID)
if err != nil {
t.Fatalf("ListUserGrantsForSkill tenant B error: %v", err)
}
if len(grantsA) != 1 || grantsA[0].GrantedBy != "tenant-a-admin" {
t.Fatalf("tenant A grants = %+v", grantsA)
}
if len(grantsB) != 1 || grantsB[0].GrantedBy != "tenant-b-admin" {
t.Fatalf("tenant B grants = %+v", grantsB)
}
}
func TestSQLiteSkillStore_ListWithGrantStatusIgnoresForeignTenantGrant(t *testing.T) {
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
tenantA, _ := seedSQLiteTenantAgent(t, db)
@@ -365,6 +448,30 @@ func TestSQLiteSkillStore_ListAccessibleHonorsAccessModes(t *testing.T) {
}
}
func TestSQLiteSkillStore_ListAllSkillsIncludesOwnerID(t *testing.T) {
ctx, skillStore := newTestSQLiteSkillStore(t)
skillID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
Name: "Owner Projection Skill",
Slug: "owner-projection-skill",
OwnerID: "owner-user",
Visibility: "private",
FilePath: filepath.Join(t.TempDir(), "owner-projection", "1"),
})
if err != nil {
t.Fatalf("CreateSkillManaged error: %v", err)
}
for _, skill := range skillStore.ListAllSkills(ctx) {
if skill.ID == skillID.String() {
if skill.OwnerID != "owner-user" {
t.Fatalf("OwnerID = %q, want owner-user", skill.OwnerID)
}
return
}
}
t.Fatalf("created skill %s not found", skillID)
}
func listAccessibleSlugs(t *testing.T, skillStore *SQLiteSkillStore, ctx context.Context, agentID uuid.UUID, userID string) map[string]bool {
t.Helper()
skills, err := skillStore.ListAccessible(ctx, agentID, userID)
@@ -594,6 +594,9 @@ func (s *skillManageFilesStore) ListWithGrantStatus(context.Context, uuid.UUID)
func (s *skillManageFilesStore) ListAgentGrantsForSkill(context.Context, uuid.UUID) ([]store.SkillAgentGrantInfo, error) {
return nil, nil
}
func (s *skillManageFilesStore) ListUserGrantsForSkill(context.Context, uuid.UUID) ([]store.SkillUserGrantInfo, error) {
return nil, nil
}
func (s *skillManageFilesStore) AgentCanManageSkill(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
return false, nil
}
+1 -1
View File
@@ -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 = 78
const RequiredSchemaVersion uint = 79
@@ -0,0 +1,6 @@
ALTER TABLE skill_user_grants
DROP CONSTRAINT IF EXISTS skill_user_grants_skill_id_user_id_tenant_id_key;
ALTER TABLE skill_user_grants
ADD CONSTRAINT skill_user_grants_skill_id_user_id_key
UNIQUE (skill_id, user_id);
@@ -0,0 +1,9 @@
ALTER TABLE skill_user_grants
DROP CONSTRAINT IF EXISTS skill_user_grants_skill_id_user_id_key;
ALTER TABLE skill_user_grants
DROP CONSTRAINT IF EXISTS skill_user_grants_skill_id_user_id_tenant_id_key;
ALTER TABLE skill_user_grants
ADD CONSTRAINT skill_user_grants_skill_id_user_id_tenant_id_key
UNIQUE (skill_id, user_id, tenant_id);
+75
View File
@@ -346,6 +346,81 @@ func TestStoreSkill_GrantToAgent(t *testing.T) {
}
}
func TestStoreSkill_UserGrantListPromotesAndDemotesVisibility(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
s := newSkillStore(t)
skillID := seedSkill(t, s, ctx, "user-grant-skill-"+tenantID.String()[:8], "User Grant Skill")
if err := s.GrantToUser(ctx, skillID, "granted-user", "test-owner"); err != nil {
t.Fatalf("GrantToUser: %v", err)
}
grants, err := s.ListUserGrantsForSkill(ctx, skillID)
if err != nil {
t.Fatalf("ListUserGrantsForSkill: %v", err)
}
if len(grants) != 1 || grants[0].UserID != "granted-user" || grants[0].GrantedBy != "test-owner" {
t.Fatalf("user grants = %+v", grants)
}
got, ok := s.GetSkillByID(ctx, skillID)
if !ok {
t.Fatal("GetSkillByID returned false")
}
if got.Visibility != "internal" {
t.Fatalf("visibility after user grant = %q, want internal", got.Visibility)
}
if err := s.RevokeFromUser(ctx, skillID, "granted-user"); err != nil {
t.Fatalf("RevokeFromUser: %v", err)
}
got, ok = s.GetSkillByID(ctx, skillID)
if !ok {
t.Fatal("GetSkillByID returned false after revoke")
}
if got.Visibility != "private" {
t.Fatalf("visibility after last user grant revoke = %q, want private", got.Visibility)
}
}
func TestStoreSkill_UserGrantsAreTenantScopedForSystemSkill(t *testing.T) {
db := testDB(t)
tenantA, _ := seedTenantAgent(t, db)
tenantB, _ := seedTenantAgent(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
s := newSkillStore(t)
skillID := uuid.New()
slug := "system-user-grant-" + skillID.String()[:8]
if _, err := db.Exec(
`INSERT INTO skills (id, name, slug, owner_id, visibility, version, status, file_path, is_system, tenant_id)
VALUES ($1, 'System User Grant Skill', $2, 'system', 'private', 1, 'active', $3, true, $4)`,
skillID, slug, "/tmp/skills/system-user-grant/1", store.MasterTenantID,
); err != nil {
t.Fatalf("insert system skill: %v", err)
}
if err := s.GrantToUser(ctxA, skillID, "same-user", "tenant-a-admin"); err != nil {
t.Fatalf("GrantToUser tenant A: %v", err)
}
if err := s.GrantToUser(ctxB, skillID, "same-user", "tenant-b-admin"); err != nil {
t.Fatalf("GrantToUser tenant B: %v", err)
}
grantsA, err := s.ListUserGrantsForSkill(ctxA, skillID)
if err != nil {
t.Fatalf("ListUserGrantsForSkill tenant A: %v", err)
}
grantsB, err := s.ListUserGrantsForSkill(ctxB, skillID)
if err != nil {
t.Fatalf("ListUserGrantsForSkill tenant B: %v", err)
}
if len(grantsA) != 1 || grantsA[0].GrantedBy != "tenant-a-admin" {
t.Fatalf("tenant A grants = %+v", grantsA)
}
if len(grantsB) != 1 || grantsB[0].GrantedBy != "tenant-b-admin" {
t.Fatalf("tenant B grants = %+v", grantsB)
}
}
func TestStoreSkill_GrantToAgentRejectsCrossTenantSkill(t *testing.T) {
db := testDB(t)
tenantA, agentA := seedTenantAgent(t, db)