From dee82177d39f219a6ce0c42e70fd7586fcea950b Mon Sep 17 00:00:00 2001
From: Chrys <53332481+ChrAlpha@users.noreply.github.com>
Date: Fri, 17 Apr 2026 16:05:00 +0800
Subject: [PATCH] feat: add bot-level skill paths configuration (#383)
---
apps/web/src/i18n/locales/en.json | 35 +-
apps/web/src/i18n/locales/zh.json | 35 +-
.../src/pages/bots/components/bot-skills.vue | 335 +++++++++++++++++-
internal/handlers/skills.go | 32 +-
internal/handlers/skills_test.go | 55 ++-
internal/skills/skills.go | 82 ++++-
internal/skills/skills_test.go | 62 +++-
internal/workspace/image_preference.go | 140 +++++++-
internal/workspace/image_preference_test.go | 71 ++++
internal/workspace/manager.go | 13 +-
internal/workspace/versioning.go | 4 +-
11 files changed, 780 insertions(+), 84 deletions(-)
diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json
index b5b211b4..131e92d8 100644
--- a/apps/web/src/i18n/locales/en.json
+++ b/apps/web/src/i18n/locales/en.json
@@ -1120,28 +1120,49 @@
},
"skills": {
"title": "Skills",
+ "discoveryTitle": "Skill Paths",
+ "discoveryDescription": "Manage where this bot stores managed skills and which external paths are scanned.",
+ "managedPathLabel": "Managed Path",
+ "managedPathDescription": "Memoh-managed skills are stored here.",
+ "managedPathHint": "\"Copy to Managed Path\" copies an external skill here. Managed copies take precedence.",
+ "discoveryPathsLabel": "External Skill Paths",
+ "discoveryPathsDescription": "Memoh scans these absolute paths for compatible external skills. One path per line.",
+ "discoveryPathPlaceholder": "/root/.agents/skills",
+ "discoveryAddPath": "Add Path",
+ "discoveryEmpty": "No external skill paths. Only managed and legacy skills will be scanned.",
+ "discoveryDefaultHint": "Default external paths: {paths}",
+ "discoveryReset": "Reset",
+ "discoverySummaryDefault": "Default",
+ "discoverySummaryCustom": "{count} paths",
+ "discoverySummaryUnsaved": "Unsaved changes",
+ "discoveryPathRequired": "Path is required",
+ "discoveryPathAbsolute": "Path must be absolute",
+ "discoveryPathReserved": "This path is reserved for Memoh-managed or legacy skills",
+ "discoveryPathDuplicate": "Duplicate path",
+ "discoverySaveSuccess": "Skill paths saved",
+ "discoverySaveFailed": "Failed to save skill paths",
"addSkill": "New Skill",
"emptyTitle": "No Skills",
"emptyDescription": "Click above to create a new skill",
"managedBadge": "Managed",
- "discoveredBadge": "Discovered",
+ "discoveredBadge": "External",
"effectiveBadge": "Effective",
"shadowedBadge": "Shadowed",
"disabledBadge": "Disabled",
"legacyBadge": "Legacy",
- "compatBadge": "Compatible",
+ "compatBadge": "External",
"description": "Description",
"descriptionPlaceholder": "Enter skill description",
"content": "Content",
"contentPlaceholder": "Enter skill content/prompt",
"deleteConfirm": "Are you sure you want to delete this skill?",
- "overrideTitle": "Edit to create a managed override",
- "adoptAction": "Adopt into Memoh-managed skills",
- "adoptBlocked": "A higher-priority skill already exists",
+ "overrideTitle": "Edit to create a managed copy",
+ "adoptAction": "Copy to Managed Path",
+ "adoptBlocked": "A managed copy already exists",
"disableAction": "Disable this skill source",
"enableAction": "Enable this skill source",
- "adoptSuccess": "Skill adopted",
- "adoptFailed": "Failed to adopt skill",
+ "adoptSuccess": "Skill copied to managed path",
+ "adoptFailed": "Failed to copy skill to managed path",
"disableSuccess": "Skill disabled",
"disableFailed": "Failed to disable skill",
"enableSuccess": "Skill enabled",
diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json
index 3840439b..055bbd2e 100644
--- a/apps/web/src/i18n/locales/zh.json
+++ b/apps/web/src/i18n/locales/zh.json
@@ -1116,28 +1116,49 @@
},
"skills": {
"title": "技能",
+ "discoveryTitle": "技能路径",
+ "discoveryDescription": "管理这个 Bot 的托管技能保存位置,以及会扫描哪些外部技能路径。",
+ "managedPathLabel": "托管路径",
+ "managedPathDescription": "Memoh 托管技能会保存在这里。",
+ "managedPathHint": "“复制到托管路径”会把外部技能复制到这里。托管副本会优先生效。",
+ "discoveryPathsLabel": "外部技能路径",
+ "discoveryPathsDescription": "Memoh 会扫描这些绝对路径中的兼容外部技能。每行填写一个路径。",
+ "discoveryPathPlaceholder": "/root/.agents/skills",
+ "discoveryAddPath": "添加路径",
+ "discoveryEmpty": "当前没有外部技能路径,仅会扫描托管技能和旧版技能。",
+ "discoveryDefaultHint": "默认外部路径:{paths}",
+ "discoveryReset": "重置",
+ "discoverySummaryDefault": "默认",
+ "discoverySummaryCustom": "{count} 个路径",
+ "discoverySummaryUnsaved": "有未保存修改",
+ "discoveryPathRequired": "路径不能为空",
+ "discoveryPathAbsolute": "路径必须是绝对路径",
+ "discoveryPathReserved": "这个路径已被 Memoh 托管技能或旧版技能占用",
+ "discoveryPathDuplicate": "路径重复",
+ "discoverySaveSuccess": "技能路径已保存",
+ "discoverySaveFailed": "保存技能路径失败",
"addSkill": "新建技能",
"emptyTitle": "暂无技能",
"emptyDescription": "点击上方按钮创建新技能",
"managedBadge": "托管",
- "discoveredBadge": "发现",
+ "discoveredBadge": "外部",
"effectiveBadge": "生效中",
"shadowedBadge": "被覆盖",
"disabledBadge": "已禁用",
"legacyBadge": "旧版",
- "compatBadge": "兼容",
+ "compatBadge": "外部",
"description": "描述",
"descriptionPlaceholder": "输入技能描述",
"content": "内容",
"contentPlaceholder": "输入技能内容/提示词",
"deleteConfirm": "确定要删除这个技能吗?",
- "overrideTitle": "编辑后将创建托管覆盖版本",
- "adoptAction": "纳入 Memoh 托管",
- "adoptBlocked": "已有更高优先级的技能副本",
+ "overrideTitle": "编辑后将创建托管副本",
+ "adoptAction": "复制到托管路径",
+ "adoptBlocked": "已有托管副本",
"disableAction": "禁用这个技能来源",
"enableAction": "启用这个技能来源",
- "adoptSuccess": "技能已纳入托管",
- "adoptFailed": "纳入托管失败",
+ "adoptSuccess": "技能已复制到托管路径",
+ "adoptFailed": "复制到托管路径失败",
"disableSuccess": "技能已禁用",
"disableFailed": "禁用技能失败",
"enableSuccess": "技能已启用",
diff --git a/apps/web/src/pages/bots/components/bot-skills.vue b/apps/web/src/pages/bots/components/bot-skills.vue
index 60ee6ed1..421d9203 100644
--- a/apps/web/src/pages/bots/components/bot-skills.vue
+++ b/apps/web/src/pages/bots/components/bot-skills.vue
@@ -7,15 +7,31 @@
{{ $t('bots.skills.title') }}
-
+
+
+
+
@@ -230,26 +246,109 @@
+
+
diff --git a/internal/handlers/skills.go b/internal/handlers/skills.go
index 2a378ad7..e0ab3c28 100644
--- a/internal/handlers/skills.go
+++ b/internal/handlers/skills.go
@@ -10,6 +10,7 @@ import (
"github.com/labstack/echo/v4"
skillset "github.com/memohai/memoh/internal/skills"
+ "github.com/memohai/memoh/internal/workspace"
)
type SkillItem struct {
@@ -190,8 +191,12 @@ func (h *ContainerdHandler) ApplySkillAction(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("container not reachable: %v", err))
}
+ roots, err := h.skillDiscoveryRoots(ctx, botID)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
- if err := skillset.ApplyAction(ctx, client, skillset.ActionRequest{
+ if err := skillset.ApplyAction(ctx, client, roots, skillset.ActionRequest{
Action: req.Action,
TargetPath: req.TargetPath,
}); err != nil {
@@ -207,7 +212,11 @@ func (h *ContainerdHandler) LoadSkills(ctx context.Context, botID string) ([]Ski
if err != nil {
return nil, err
}
- items, err := skillset.LoadEffective(ctx, client)
+ roots, err := h.skillDiscoveryRoots(ctx, botID)
+ if err != nil {
+ return nil, err
+ }
+ items, err := skillset.LoadEffective(ctx, client, roots)
if err != nil {
return nil, err
}
@@ -219,13 +228,30 @@ func (h *ContainerdHandler) listSkillsFromContainer(ctx context.Context, botID s
if err != nil {
return nil, err
}
- items, err := skillset.List(ctx, client)
+ roots, err := h.skillDiscoveryRoots(ctx, botID)
+ if err != nil {
+ return nil, err
+ }
+ items, err := skillset.List(ctx, client, roots)
if err != nil {
return nil, err
}
return skillItemsFromEntries(items), nil
}
+func (h *ContainerdHandler) skillDiscoveryRoots(ctx context.Context, botID string) ([]string, error) {
+ if h.botService != nil {
+ bot, err := h.botService.Get(ctx, botID)
+ if err == nil {
+ return workspace.SkillDiscoveryRootsFromMetadata(bot.Metadata), nil
+ }
+ }
+ if h.manager == nil {
+ return nil, nil
+ }
+ return h.manager.ResolveWorkspaceSkillDiscoveryRoots(ctx, botID)
+}
+
func skillItemsFromEntries(entries []skillset.Entry) []SkillItem {
items := make([]SkillItem, len(entries))
for i, entry := range entries {
diff --git a/internal/handlers/skills_test.go b/internal/handlers/skills_test.go
index 2c939c44..61f6180b 100644
--- a/internal/handlers/skills_test.go
+++ b/internal/handlers/skills_test.go
@@ -253,7 +253,11 @@ func TestLoadSkillsUsesEffectiveSetAndPromptReflectsOverrideFallback(t *testing.
if err != nil {
t.Fatalf("get bridge client: %v", err)
}
- if err := skillset.ApplyAction(context.Background(), client, skillset.ActionRequest{
+ roots, err := env.handler.skillDiscoveryRoots(context.Background(), env.botID)
+ if err != nil {
+ t.Fatalf("resolve skill discovery roots: %v", err)
+ }
+ if err := skillset.ApplyAction(context.Background(), client, roots, skillset.ActionRequest{
Action: skillset.ActionDisable,
TargetPath: managedPath,
}); err != nil {
@@ -280,6 +284,27 @@ func TestLoadSkillsUsesEffectiveSetAndPromptReflectsOverrideFallback(t *testing.
}
}
+func TestListSkillsAPIUsesConfiguredDiscoveryRoots(t *testing.T) {
+ env := newSkillsTestEnvWithMetadata(t, map[string]any{
+ "workspace": map[string]any{
+ "skill_discovery_roots": []string{"/root/.openclaw/skills"},
+ },
+ })
+ env.writeSkillFile(t, path.Join("/root/.openclaw/skills", "alpha", "SKILL.md"), managedSkillRaw("alpha", "OpenClaw Alpha"))
+ env.writeSkillFile(t, path.Join("/data/.agents/skills", "beta", "SKILL.md"), managedSkillRaw("beta", "Ignored Beta"))
+
+ skills := env.listSkills(t)
+ if len(skills) != 1 {
+ t.Fatalf("expected 1 configured-discovery skill, got %d", len(skills))
+ }
+ if got := skills[0].SourceRoot; got != "/root/.openclaw/skills" {
+ t.Fatalf("source_root = %q, want %q", got, "/root/.openclaw/skills")
+ }
+ if got := skills[0].Name; got != "alpha" {
+ t.Fatalf("skill name = %q, want %q", got, "alpha")
+ }
+}
+
type skillsTestEnv struct {
handler *ContainerdHandler
dataRoot string
@@ -288,6 +313,10 @@ type skillsTestEnv struct {
}
func newSkillsTestEnv(t *testing.T) *skillsTestEnv {
+ return newSkillsTestEnvWithMetadata(t, nil)
+}
+
+func newSkillsTestEnvWithMetadata(t *testing.T, metadata map[string]any) *skillsTestEnv {
t.Helper()
dataRoot, err := newSkillsTestDataRoot()
@@ -300,7 +329,18 @@ func newSkillsTestEnv(t *testing.T) *skillsTestEnv {
startSkillsTestBridgeServer(t, dataRoot, botID)
cfg := config.WorkspaceConfig{DataRoot: dataRoot}
- db := &skillsTestDB{userID: userID, botID: botID}
+ var metadataJSON []byte
+ if metadata != nil {
+ var err error
+ metadataJSON, err = json.Marshal(metadata)
+ if err != nil {
+ t.Fatalf("marshal bot metadata: %v", err)
+ }
+ } else {
+ metadataJSON = []byte(`{}`)
+ }
+ cfg.DataRoot = dataRoot
+ db := &skillsTestDB{userID: userID, botID: botID, metadataJSON: metadataJSON}
manager := workspace.NewManager(slog.Default(), nil, cfg, "", nil)
handler := NewContainerdHandler(
slog.Default(),
@@ -395,8 +435,9 @@ func newSkillsTestDataRoot() (string, error) {
}
type skillsTestDB struct {
- userID string
- botID string
+ userID string
+ botID string
+ metadataJSON []byte
}
func (*skillsTestDB) Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) {
@@ -412,7 +453,7 @@ func (d *skillsTestDB) QueryRow(_ context.Context, sql string, _ ...interface{})
case strings.Contains(sql, "FROM users WHERE id = $1"):
return makeUserRow(mustParseUUID(d.userID), "user")
case strings.Contains(sql, "FROM bots"):
- return makeBotRow(mustParseUUID(d.botID), mustParseUUID(d.userID))
+ return makeBotRow(mustParseUUID(d.botID), mustParseUUID(d.userID), d.metadataJSON)
default:
return &skillsTestRow{scanFunc: func(_ ...any) error { return pgx.ErrNoRows }}
}
@@ -451,7 +492,7 @@ func makeUserRow(userID pgtype.UUID, role string) *skillsTestRow {
}
}
-func makeBotRow(botID, ownerUserID pgtype.UUID) *skillsTestRow {
+func makeBotRow(botID, ownerUserID pgtype.UUID, metadataJSON []byte) *skillsTestRow {
return &skillsTestRow{
scanFunc: func(dest ...any) error {
if len(dest) < 23 {
@@ -477,7 +518,7 @@ func makeBotRow(botID, ownerUserID pgtype.UUID) *skillsTestRow {
*dest[17].(*int32) = 100000
*dest[18].(*int32) = 80
*dest[19].(*pgtype.UUID) = pgtype.UUID{}
- *dest[20].(*[]byte) = []byte(`{}`)
+ *dest[20].(*[]byte) = metadataJSON
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
return nil
diff --git a/internal/skills/skills.go b/internal/skills/skills.go
index 8a10e471..93535b87 100644
--- a/internal/skills/skills.go
+++ b/internal/skills/skills.go
@@ -19,10 +19,11 @@ import (
)
const (
- ManagedDirPath = config.DefaultDataMount + "/skills"
- LegacyDirPath = config.DefaultDataMount + "/.skills"
- IndexDirPath = config.DefaultDataMount + "/.memoh/skills"
- IndexFilePath = IndexDirPath + "/index.json"
+ ManagedDirPath = config.DefaultDataMount + "/skills"
+ LegacyDirPath = config.DefaultDataMount + "/.skills"
+ IndexDirPath = config.DefaultDataMount + "/.memoh/skills"
+ IndexFilePath = IndexDirPath + "/index.json"
+ SkillDiscoveryRootsEnvVar = "MEMOH_SKILL_DISCOVERY_ROOTS"
SourceKindManaged = "managed"
SourceKindLegacy = "legacy"
@@ -115,34 +116,39 @@ func ManagedSkillDirForName(name string) (string, error) {
return dirPath, nil
}
-func ContainerEnv() []string {
- return []string{
+func ContainerEnv(rawCompatRoots []string) []string {
+ compatRoots := compatDiscoveryRoots(rawCompatRoots)
+ env := []string{
"HOME=" + config.DefaultDataMount,
"XDG_CONFIG_HOME=" + path.Join(config.DefaultDataMount, ".config"),
"XDG_DATA_HOME=" + path.Join(config.DefaultDataMount, ".local", "share"),
"XDG_CACHE_HOME=" + path.Join(config.DefaultDataMount, ".cache"),
}
+ env = append(env, SkillDiscoveryRootsEnvVar+"="+strings.Join(compatRoots, ":"))
+ return env
}
-func DiscoveryRoots() []Root {
- return []Root{
+func DiscoveryRoots(rawCompatRoots []string) []Root {
+ roots := []Root{
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
- {Path: path.Join(config.DefaultDataMount, ".agents", "skills"), Kind: SourceKindCompat, Managed: false},
- {Path: path.Join("/root", ".agents", "skills"), Kind: SourceKindCompat, Managed: false},
}
+ for _, compatRoot := range compatDiscoveryRoots(rawCompatRoots) {
+ roots = append(roots, Root{Path: compatRoot, Kind: SourceKindCompat, Managed: false})
+ }
+ return roots
}
-func List(ctx context.Context, client fileClient) ([]Entry, error) {
+func List(ctx context.Context, client fileClient, rawCompatRoots []string) ([]Entry, error) {
idx := readIndex(ctx, client)
- items := scan(ctx, client, DiscoveryRoots())
+ items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
resolved := resolve(items, idx.Overrides)
writeIndex(ctx, client, idx.withItems(resolved))
return resolved, nil
}
-func LoadEffective(ctx context.Context, client fileClient) ([]Entry, error) {
- items, err := List(ctx, client)
+func LoadEffective(ctx context.Context, client fileClient, rawCompatRoots []string) ([]Entry, error) {
+ items, err := List(ctx, client, rawCompatRoots)
if err != nil {
return nil, err
}
@@ -155,7 +161,7 @@ func LoadEffective(ctx context.Context, client fileClient) ([]Entry, error) {
return out, nil
}
-func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) error {
+func ApplyAction(ctx context.Context, client fileClient, rawCompatRoots []string, req ActionRequest) error {
targetPath := strings.TrimSpace(req.TargetPath)
if targetPath == "" {
return bridge.ErrBadRequest
@@ -164,7 +170,7 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
switch strings.TrimSpace(req.Action) {
case ActionDisable:
idx := readIndex(ctx, client)
- items := scan(ctx, client, DiscoveryRoots())
+ items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
if !containsSourcePath(items, targetPath) {
return bridge.ErrNotFound
}
@@ -176,7 +182,7 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
return nil
case ActionEnable:
idx := readIndex(ctx, client)
- items := scan(ctx, client, DiscoveryRoots())
+ items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
if !containsSourcePath(items, targetPath) {
return bridge.ErrNotFound
}
@@ -184,7 +190,7 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
writeIndex(ctx, client, idx.withItems(resolve(items, idx.Overrides)))
return nil
case ActionAdopt:
- items := scan(ctx, client, DiscoveryRoots())
+ items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
target, ok := findBySourcePath(items, targetPath)
if !ok {
return bridge.ErrNotFound
@@ -208,13 +214,51 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
return err
}
idx := readIndex(ctx, client)
- writeIndex(ctx, client, idx.withItems(resolve(scan(ctx, client, DiscoveryRoots()), idx.Overrides)))
+ writeIndex(ctx, client, idx.withItems(resolve(scan(ctx, client, DiscoveryRoots(rawCompatRoots)), idx.Overrides)))
return nil
default:
return bridge.ErrBadRequest
}
}
+func compatDiscoveryRoots(rawRoots []string) []string {
+ if rawRoots == nil {
+ rawRoots = defaultCompatDiscoveryRoots()
+ }
+ return normalizeCompatDiscoveryRoots(rawRoots)
+}
+
+func defaultCompatDiscoveryRoots() []string {
+ return []string{
+ path.Join(config.DefaultDataMount, ".agents", "skills"),
+ path.Join("/root", ".agents", "skills"),
+ }
+}
+
+func normalizeCompatDiscoveryRoots(paths []string) []string {
+ out := make([]string, 0, len(paths))
+ seen := make(map[string]struct{}, len(paths))
+ for _, p := range paths {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ p = path.Clean(p)
+ if !strings.HasPrefix(p, "/") {
+ continue
+ }
+ if p == ManagedDirPath || p == LegacyDirPath {
+ continue
+ }
+ if _, ok := seen[p]; ok {
+ continue
+ }
+ seen[p] = struct{}{}
+ out = append(out, p)
+ }
+ return out
+}
+
func ParseFile(raw string, fallbackName string) Parsed {
trimmed := strings.TrimSpace(raw)
result := Parsed{
diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go
index bd32bd59..48fbb136 100644
--- a/internal/skills/skills_test.go
+++ b/internal/skills/skills_test.go
@@ -66,7 +66,7 @@ func TestListReadsFullRawContentAndWritesIndex(t *testing.T) {
client.listings[ManagedDirPath] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
client.files[pathJoin(ManagedDirPath, "alpha", "SKILL.md")] = "---\nname: alpha\ndescription: Alpha\n---\n\n" + strings.Repeat("A", 7000)
- items, err := List(context.Background(), client)
+ items, err := List(context.Background(), client, nil)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
@@ -87,7 +87,7 @@ func TestApplyActionAdoptAndDisable(t *testing.T) {
client.listings["/data/.agents/skills"] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
client.files[externalPath] = "---\nname: alpha\ndescription: Alpha\n---\n\n# Alpha"
- if err := ApplyAction(context.Background(), client, ActionRequest{
+ if err := ApplyAction(context.Background(), client, nil, ActionRequest{
Action: ActionAdopt,
TargetPath: externalPath,
}); err != nil {
@@ -97,7 +97,7 @@ func TestApplyActionAdoptAndDisable(t *testing.T) {
t.Fatalf("expected managed copy after adopt")
}
- if err := ApplyAction(context.Background(), client, ActionRequest{
+ if err := ApplyAction(context.Background(), client, nil, ActionRequest{
Action: ActionDisable,
TargetPath: externalPath,
}); err != nil {
@@ -115,7 +115,7 @@ func TestApplyActionAdoptRejectsInvalidManagedName(t *testing.T) {
client.listings["/data/.agents/skills"] = []*pb.FileEntry{{Path: "escape", IsDir: true}}
client.files[externalPath] = "---\nname: ..\ndescription: Escape\n---\n\n# Escape"
- err := ApplyAction(context.Background(), client, ActionRequest{
+ err := ApplyAction(context.Background(), client, nil, ActionRequest{
Action: ActionAdopt,
TargetPath: externalPath,
})
@@ -165,8 +165,8 @@ func TestManagedSkillDirForNameRejectsEscapingNames(t *testing.T) {
}
}
-func TestDiscoveryRootsMatchCurrentPolicy(t *testing.T) {
- roots := DiscoveryRoots()
+func TestDiscoveryRootsMatchDefaultPolicy(t *testing.T) {
+ roots := DiscoveryRoots(nil)
want := []Root{
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
@@ -178,15 +178,46 @@ func TestDiscoveryRootsMatchCurrentPolicy(t *testing.T) {
}
}
+func TestDiscoveryRootsUseConfiguredCompatRoots(t *testing.T) {
+ roots := DiscoveryRoots([]string{
+ " /custom/skills ",
+ "/data/skills",
+ "/custom/skills",
+ "relative/skills",
+ "/root/.openclaw/skills",
+ })
+ want := []Root{
+ {Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
+ {Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
+ {Path: "/custom/skills", Kind: SourceKindCompat, Managed: false},
+ {Path: "/root/.openclaw/skills", Kind: SourceKindCompat, Managed: false},
+ }
+ if !slices.Equal(roots, want) {
+ t.Fatalf("DiscoveryRoots(custom) = %+v, want %+v", roots, want)
+ }
+}
+
+func TestDiscoveryRootsAllowExplicitEmptyCompatRoots(t *testing.T) {
+ roots := DiscoveryRoots([]string{})
+ want := []Root{
+ {Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
+ {Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
+ }
+ if !slices.Equal(roots, want) {
+ t.Fatalf("DiscoveryRoots(empty) = %+v, want %+v", roots, want)
+ }
+}
+
func TestListScansConfiguredDiscoveryRootsInOrder(t *testing.T) {
client := newFakeClient()
- for _, root := range DiscoveryRoots() {
+ rawCompatRoots := []string(nil)
+ for _, root := range DiscoveryRoots(rawCompatRoots) {
client.listings[root.Path] = nil
}
client.listings[ManagedDirPath] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
client.files[pathJoin(ManagedDirPath, "alpha", "SKILL.md")] = "---\nname: alpha\ndescription: Alpha\n---\n\n# Alpha"
- items, err := List(context.Background(), client)
+ items, err := List(context.Background(), client, rawCompatRoots)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
@@ -194,8 +225,8 @@ func TestListScansConfiguredDiscoveryRootsInOrder(t *testing.T) {
t.Fatalf("List() items = %+v, want managed alpha only", items)
}
- wantCalls := make([]string, 0, len(DiscoveryRoots()))
- for _, root := range DiscoveryRoots() {
+ wantCalls := make([]string, 0, len(DiscoveryRoots(rawCompatRoots)))
+ for _, root := range DiscoveryRoots(rawCompatRoots) {
wantCalls = append(wantCalls, root.Path)
}
if !slices.Equal(client.listCalls, wantCalls) {
@@ -204,12 +235,13 @@ func TestListScansConfiguredDiscoveryRootsInOrder(t *testing.T) {
}
func TestContainerEnvUsesDataHomeAndXDGDirs(t *testing.T) {
- env := ContainerEnv()
+ env := ContainerEnv(nil)
for _, want := range []string{
"HOME=/data",
"XDG_CONFIG_HOME=/data/.config",
"XDG_DATA_HOME=/data/.local/share",
"XDG_CACHE_HOME=/data/.cache",
+ "MEMOH_SKILL_DISCOVERY_ROOTS=/data/.agents/skills:/root/.agents/skills",
} {
if !slices.Contains(env, want) {
t.Fatalf("env %+v does not contain %q", env, want)
@@ -217,6 +249,14 @@ func TestContainerEnvUsesDataHomeAndXDGDirs(t *testing.T) {
}
}
+func TestContainerEnvUsesConfiguredSkillDiscoveryRoots(t *testing.T) {
+ env := ContainerEnv([]string{"/custom/skills", "/root/.openclaw/skills"})
+ want := SkillDiscoveryRootsEnvVar + "=/custom/skills:/root/.openclaw/skills"
+ if !slices.Contains(env, want) {
+ t.Fatalf("env %+v does not contain %q", env, want)
+ }
+}
+
type fakeClient struct {
listings map[string][]*pb.FileEntry
files map[string]string
diff --git a/internal/workspace/image_preference.go b/internal/workspace/image_preference.go
index 2a2c4a5f..5ffd6f12 100644
--- a/internal/workspace/image_preference.go
+++ b/internal/workspace/image_preference.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
+ "path"
"strings"
"github.com/jackc/pgx/v5"
@@ -14,10 +15,11 @@ import (
)
const (
- workspaceMetadataKey = "workspace"
- workspaceImageMetadataKey = "image"
- workspaceGPUMetadataKey = "gpu"
- workspaceGPUDevicesKey = "devices"
+ workspaceMetadataKey = "workspace"
+ workspaceImageMetadataKey = "image"
+ workspaceGPUMetadataKey = "gpu"
+ workspaceGPUDevicesKey = "devices"
+ workspaceSkillDiscoveryRootsMetadataKey = "skill_discovery_roots"
)
type WorkspaceGPUConfig struct {
@@ -115,6 +117,30 @@ func workspaceGPUFromMetadata(metadata map[string]any) (WorkspaceGPUConfig, bool
return WorkspaceGPUConfig{Devices: normalizeWorkspaceGPUDevices(devices)}, true
}
+func workspaceSkillDiscoveryRootsFromMetadata(metadata map[string]any) ([]string, bool) {
+ section := workspaceSection(metadata)
+ raw, ok := section[workspaceSkillDiscoveryRootsMetadataKey]
+ if !ok {
+ return nil, false
+ }
+
+ var roots []string
+ switch typed := raw.(type) {
+ case []string:
+ roots = append(roots, typed...)
+ case []any:
+ for _, item := range typed {
+ if root, ok := item.(string); ok {
+ roots = append(roots, root)
+ }
+ }
+ default:
+ return []string{}, true
+ }
+
+ return normalizeWorkspaceSkillDiscoveryRoots(roots), true
+}
+
func withWorkspaceImagePreference(metadata map[string]any, image string) map[string]any {
next := cloneAnyMap(metadata)
section := workspaceSection(next)
@@ -145,6 +171,18 @@ func withWorkspaceGPUPreference(metadata map[string]any, gpu WorkspaceGPUConfig)
return next
}
+func withWorkspaceSkillDiscoveryRoots(metadata map[string]any, roots []string) map[string]any {
+ next := cloneAnyMap(metadata)
+ section := workspaceSection(next)
+ normalized := normalizeWorkspaceSkillDiscoveryRoots(roots)
+ if normalized == nil {
+ normalized = []string{}
+ }
+ section[workspaceSkillDiscoveryRootsMetadataKey] = normalized
+ next[workspaceMetadataKey] = section
+ return next
+}
+
func withoutWorkspaceGPUPreference(metadata map[string]any) map[string]any {
next := cloneAnyMap(metadata)
section := workspaceSection(next)
@@ -157,8 +195,20 @@ func withoutWorkspaceGPUPreference(metadata map[string]any) map[string]any {
return next
}
+func withoutWorkspaceSkillDiscoveryRoots(metadata map[string]any) map[string]any {
+ next := cloneAnyMap(metadata)
+ section := workspaceSection(next)
+ delete(section, workspaceSkillDiscoveryRootsMetadataKey)
+ if len(section) == 0 {
+ delete(next, workspaceMetadataKey)
+ return next
+ }
+ next[workspaceMetadataKey] = section
+ return next
+}
+
func (m *Manager) botWorkspaceImagePreference(ctx context.Context, botID string) (string, error) {
- if m.queries == nil {
+ if m.db == nil || m.queries == nil {
return "", nil
}
botUUID, err := db.ParseUUID(botID)
@@ -180,7 +230,7 @@ func (m *Manager) botWorkspaceImagePreference(ctx context.Context, botID string)
}
func (m *Manager) updateBotWorkspaceImagePreference(ctx context.Context, botID, image string, clearPreference bool) error {
- if m.queries == nil {
+ if m.db == nil || m.queries == nil {
return nil
}
botUUID, err := db.ParseUUID(botID)
@@ -224,7 +274,7 @@ func (m *Manager) ClearWorkspaceImagePreference(ctx context.Context, botID strin
}
func (m *Manager) botWorkspaceGPUPreference(ctx context.Context, botID string) (WorkspaceGPUConfig, bool, error) {
- if m.queries == nil {
+ if m.db == nil || m.queries == nil {
return WorkspaceGPUConfig{}, false, nil
}
botUUID, err := db.ParseUUID(botID)
@@ -246,8 +296,31 @@ func (m *Manager) botWorkspaceGPUPreference(ctx context.Context, botID string) (
return gpu, ok, nil
}
+func (m *Manager) botWorkspaceSkillDiscoveryRootsPreference(ctx context.Context, botID string) ([]string, bool, error) {
+ if m.db == nil || m.queries == nil {
+ return nil, false, nil
+ }
+ botUUID, err := db.ParseUUID(botID)
+ if err != nil {
+ return nil, false, err
+ }
+ row, err := m.queries.GetBotByID(ctx, botUUID)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, false, nil
+ }
+ return nil, false, err
+ }
+ metadata, err := decodeBotMetadata(row.Metadata)
+ if err != nil {
+ return nil, false, err
+ }
+ roots, ok := workspaceSkillDiscoveryRootsFromMetadata(metadata)
+ return roots, ok, nil
+}
+
func (m *Manager) updateBotWorkspaceGPUPreference(ctx context.Context, botID string, gpu WorkspaceGPUConfig, clearPreference bool) error {
- if m.queries == nil {
+ if m.db == nil || m.queries == nil {
return nil
}
botUUID, err := db.ParseUUID(botID)
@@ -299,8 +372,20 @@ func (m *Manager) ResolveWorkspaceGPU(ctx context.Context, botID string) (Worksp
return m.resolveWorkspaceGPU(ctx, botID)
}
+func (m *Manager) ResolveWorkspaceSkillDiscoveryRoots(ctx context.Context, botID string) ([]string, error) {
+ return m.resolveWorkspaceSkillDiscoveryRoots(ctx, botID)
+}
+
+func SkillDiscoveryRootsFromMetadata(metadata map[string]any) []string {
+ roots, ok := workspaceSkillDiscoveryRootsFromMetadata(metadata)
+ if !ok {
+ return nil
+ }
+ return roots
+}
+
func (m *Manager) resolveWorkspaceImage(ctx context.Context, botID string) (string, error) {
- if m.queries != nil {
+ if m.db != nil && m.queries != nil {
pgBotID, err := db.ParseUUID(botID)
if err == nil {
row, dbErr := m.queries.GetContainerByBotID(ctx, pgBotID)
@@ -336,3 +421,40 @@ func (m *Manager) resolveWorkspaceGPU(ctx context.Context, botID string) (Worksp
return WorkspaceGPUConfig{}, nil
}
+
+func (m *Manager) resolveWorkspaceSkillDiscoveryRoots(ctx context.Context, botID string) ([]string, error) {
+ roots, hasPreference, err := m.botWorkspaceSkillDiscoveryRootsPreference(ctx, botID)
+ if err != nil {
+ return nil, err
+ }
+ if !hasPreference {
+ return nil, nil
+ }
+ return roots, nil
+}
+
+func normalizeWorkspaceSkillDiscoveryRoots(roots []string) []string {
+ if len(roots) == 0 {
+ return nil
+ }
+
+ managedDir := path.Join(config.DefaultDataMount, "skills")
+ legacyDir := path.Join(config.DefaultDataMount, ".skills")
+ seen := make(map[string]struct{}, len(roots))
+ normalized := make([]string, 0, len(roots))
+ for _, raw := range roots {
+ root := path.Clean(strings.TrimSpace(raw))
+ if root == "" || !strings.HasPrefix(root, "/") {
+ continue
+ }
+ if root == managedDir || root == legacyDir {
+ continue
+ }
+ if _, ok := seen[root]; ok {
+ continue
+ }
+ seen[root] = struct{}{}
+ normalized = append(normalized, root)
+ }
+ return normalized
+}
diff --git a/internal/workspace/image_preference_test.go b/internal/workspace/image_preference_test.go
index 6b89e401..157a09f2 100644
--- a/internal/workspace/image_preference_test.go
+++ b/internal/workspace/image_preference_test.go
@@ -119,3 +119,74 @@ func TestWithoutWorkspaceGPUPreferenceRemovesOnlyGPUKey(t *testing.T) {
t.Fatalf("expected unrelated workspace metadata to remain, got %#v", workspace)
}
}
+
+func TestWorkspaceSkillDiscoveryRootsMetadataRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ metadata := map[string]any{
+ workspaceMetadataKey: map[string]any{
+ "keep": "value",
+ },
+ }
+
+ updated := withWorkspaceSkillDiscoveryRoots(metadata, []string{
+ " /custom/skills ",
+ "/root/.openclaw/skills",
+ "/custom/skills",
+ "/custom/./skills",
+ "/data/skills",
+ "relative/path",
+ })
+
+ roots, ok := workspaceSkillDiscoveryRootsFromMetadata(updated)
+ if !ok {
+ t.Fatal("expected skill discovery roots preference to be present")
+ }
+ if got, want := roots, []string{"/custom/skills", "/root/.openclaw/skills"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
+ t.Fatalf("expected normalized skill discovery roots %v, got %v", want, got)
+ }
+ workspace, ok := updated[workspaceMetadataKey].(map[string]any)
+ if !ok {
+ t.Fatal("expected workspace metadata section")
+ }
+ if workspace["keep"] != "value" {
+ t.Fatalf("expected existing workspace metadata to be preserved, got %#v", workspace)
+ }
+}
+
+func TestWorkspaceSkillDiscoveryRootsExplicitDisableRemainsPresent(t *testing.T) {
+ t.Parallel()
+
+ metadata := withWorkspaceSkillDiscoveryRoots(map[string]any{}, []string{})
+
+ roots, ok := workspaceSkillDiscoveryRootsFromMetadata(metadata)
+ if !ok {
+ t.Fatal("expected skill discovery roots key to remain present")
+ }
+ if len(roots) != 0 {
+ t.Fatalf("expected explicit disable with no roots, got %#v", roots)
+ }
+}
+
+func TestWithoutWorkspaceSkillDiscoveryRootsRemovesOnlyThatKey(t *testing.T) {
+ t.Parallel()
+
+ metadata := map[string]any{
+ workspaceMetadataKey: map[string]any{
+ workspaceSkillDiscoveryRootsMetadataKey: []any{"/data/.agents/skills"},
+ "keep": true,
+ },
+ }
+
+ updated := withoutWorkspaceSkillDiscoveryRoots(metadata)
+ if _, ok := workspaceSkillDiscoveryRootsFromMetadata(updated); ok {
+ t.Fatal("expected skill discovery roots preference to be cleared")
+ }
+ workspace, ok := updated[workspaceMetadataKey].(map[string]any)
+ if !ok {
+ t.Fatal("expected workspace metadata section to remain")
+ }
+ if workspace["keep"] != true {
+ t.Fatalf("expected unrelated workspace metadata to remain, got %#v", workspace)
+ }
+}
diff --git a/internal/workspace/manager.go b/internal/workspace/manager.go
index 8271e5dc..55fe960d 100644
--- a/internal/workspace/manager.go
+++ b/internal/workspace/manager.go
@@ -209,7 +209,7 @@ func workspaceCDIDevicesFromLabels(labels map[string]string) []string {
return normalizeWorkspaceGPUDevices(strings.Split(value, ","))
}
-func (m *Manager) buildWorkspaceContainerSpec(botID string, gpu WorkspaceGPUConfig) (ctr.ContainerSpec, error) {
+func (m *Manager) buildWorkspaceContainerSpec(ctx context.Context, botID string, gpu WorkspaceGPUConfig) (ctr.ContainerSpec, error) {
resolvPath, err := ctr.ResolveConfSource(m.dataRoot())
if err != nil {
return ctr.ContainerSpec{}, err
@@ -244,10 +244,15 @@ func (m *Manager) buildWorkspaceContainerSpec(botID string, gpu WorkspaceGPUConf
tzMounts, tzEnv := ctr.TimezoneSpec()
mounts = append(mounts, tzMounts...)
- env := make([]string, 0, len(tzEnv)+1+len(skillset.ContainerEnv()))
+ skillRoots, err := m.ResolveWorkspaceSkillDiscoveryRoots(ctx, botID)
+ if err != nil {
+ return ctr.ContainerSpec{}, err
+ }
+ skillEnv := skillset.ContainerEnv(skillRoots)
+ env := make([]string, 0, len(tzEnv)+1+len(skillEnv))
env = append(env, tzEnv...)
env = append(env, "BRIDGE_SOCKET_PATH=/run/memoh/bridge.sock")
- env = append(env, skillset.ContainerEnv()...)
+ env = append(env, skillEnv...)
return ctr.ContainerSpec{
Cmd: []string{"/opt/memoh/bridge"},
@@ -261,7 +266,7 @@ func (m *Manager) ensureBotWithImage(ctx context.Context, botID, image string, g
if err := validateBotID(botID); err != nil {
return err
}
- spec, err := m.buildWorkspaceContainerSpec(botID, gpu)
+ spec, err := m.buildWorkspaceContainerSpec(ctx, botID, gpu)
if err != nil {
return err
}
diff --git a/internal/workspace/versioning.go b/internal/workspace/versioning.go
index 728ede12..7cee51fa 100644
--- a/internal/workspace/versioning.go
+++ b/internal/workspace/versioning.go
@@ -233,7 +233,7 @@ func (m *Manager) ListBotSnapshotData(ctx context.Context, botID string) (*BotSn
}
managedMeta := make(map[string]ManagedSnapshotMeta)
- if m.queries != nil {
+ if m.db != nil && m.queries != nil {
rows, err := m.queries.ListSnapshotsWithVersionByContainerID(ctx, containerID)
if err != nil {
return nil, err
@@ -410,7 +410,7 @@ func (m *Manager) buildVersionSpec(ctx context.Context, botID string, cdiDevices
}
cdiDevices = gpu.Devices
}
- return m.buildWorkspaceContainerSpec(botID, WorkspaceGPUConfig{Devices: cdiDevices})
+ return m.buildWorkspaceContainerSpec(ctx, botID, WorkspaceGPUConfig{Devices: cdiDevices})
}
func (m *Manager) safeStopTask(ctx context.Context, containerID string) error {