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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent/app/service/agents_overview.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func countOpenclawConfiguredChannels(conf map[string]interface{}) int {

func loadOpenclawOverviewSkillStats(containerName string) (int, error) {
output, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s openclaw gateway call skills.status --json 2>&1",
"docker exec %s openclaw skills list --json 2>&1",
30*time.Second,
containerName,
)
Expand Down
81 changes: 51 additions & 30 deletions agent/app/service/agents_skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ type openclawSkillListItem struct {
Source string `json:"source"`
Bundled bool `json:"bundled"`
Disabled bool `json:"disabled"`
SkillKey string `json:"skillKey"`
}

type openclawSkillInfo struct {
SkillKey string `json:"skillKey"`
}

type skillhubSearchPayload struct {
Expand All @@ -32,6 +35,7 @@ type skillhubSearchPayload struct {
}

var clawhubSearchLinePattern = regexp.MustCompile(`^(\S+)\s+(.+?)\s+\(([\d.]+)\)$`)
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)

func (a AgentService) ListSkills(req dto.AgentIDReq) ([]dto.AgentSkillItem, error) {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
Expand All @@ -41,7 +45,11 @@ func (a AgentService) ListSkills(req dto.AgentIDReq) ([]dto.AgentSkillItem, erro
if err := ensureContainerRunning(install.ContainerName); err != nil {
return nil, err
}
output, err := loadOpenclawSkillsStatus(install.ContainerName)
output, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s openclaw skills list --json 2>&1",
30*time.Second,
install.ContainerName,
)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -86,7 +94,7 @@ func (a AgentService) UpdateSkill(req dto.AgentSkillUpdateReq) error {
if err != nil {
return err
}
skillKey, err := getOpenclawSkillKeyFromStatus(install.ContainerName, req.Name)
skillKey, err := getOpenclawSkillKey(install.ContainerName, req.Name)
if err != nil {
return err
}
Expand Down Expand Up @@ -119,12 +127,15 @@ func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
}

func parseOpenclawSkillsList(output string) ([]dto.AgentSkillItem, error) {
trimmed := strings.TrimSpace(output)
if trimmed == "" {
payloadBytes, err := extractEmbeddedJSON(output)
if err != nil {
return nil, err
}
if len(payloadBytes) == 0 {
return nil, nil
}
var payload openclawSkillsList
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return nil, err
}
items := make([]dto.AgentSkillItem, 0, len(payload.Skills))
Expand All @@ -140,14 +151,6 @@ func parseOpenclawSkillsList(output string) ([]dto.AgentSkillItem, error) {
return items, nil
}

func loadOpenclawSkillsStatus(containerName string) (string, error) {
return cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s openclaw gateway call skills.status --json 2>&1",
30*time.Second,
containerName,
)
}

func loadOpenclawSkillSearchOutput(containerName, source, keyword string) (string, error) {
switch source {
case "skillhub":
Expand Down Expand Up @@ -229,32 +232,50 @@ func buildOpenclawSkillInstallCommand(source, slug string) string {
}
}

func getOpenclawSkillKeyFromStatus(containerName, name string) (string, error) {
output, err := loadOpenclawSkillsStatus(containerName)
func getOpenclawSkillKey(containerName, name string) (string, error) {
output, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s openclaw skills info %q --json 2>&1",
30*time.Second,
containerName,
name,
Comment on lines +237 to +240

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid shell expansion when passing skill names

getOpenclawSkillKey now builds a bash -c command that interpolates req.Name via %q, but %q only produces a double-quoted Go string literal and does not neutralize shell substitutions like $() or backticks. A crafted skill name (sent directly to the API) can therefore execute arbitrary host-side shell commands before docker exec runs. The previous implementation did not pass user-controlled skill names into the shell, so this is a new command-injection path.

Useful? React with 👍 / 👎.

)
if err != nil {
return "", err
}
return parseOpenclawSkillKeyFromStatus(name, output)
return parseOpenclawSkillKey(name, output)
}

func parseOpenclawSkillKeyFromStatus(name, output string) (string, error) {
trimmed := strings.TrimSpace(output)
if trimmed == "" {
return "", fmt.Errorf("skill %s does not have a skillKey", name)
func parseOpenclawSkillKey(name, output string) (string, error) {
payloadBytes, err := extractEmbeddedJSON(output)
if err != nil {
return "", err
}
var payload openclawSkillsList
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
var payload openclawSkillInfo
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return "", err
}
for _, item := range payload.Skills {
if item.Name == name {
if item.SkillKey == "" {
return "", fmt.Errorf("skill %s does not have a skillKey", name)
}
return item.SkillKey, nil
if payload.SkillKey == "" {
return "", fmt.Errorf("skill %s does not have a skillKey", name)
}
return payload.SkillKey, nil
}

func extractEmbeddedJSON(output string) ([]byte, error) {
trimmed := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(output, ""))
if trimmed == "" {
return nil, nil
}
for i := 0; i < len(trimmed); i++ {
if trimmed[i] != '{' && trimmed[i] != '[' {
continue
}
decoder := json.NewDecoder(strings.NewReader(trimmed[i:]))
var raw json.RawMessage
if err := decoder.Decode(&raw); err == nil {
return raw, nil
}
}
return "", fmt.Errorf("skill %s not found", name)
return nil, fmt.Errorf("json payload not found")
}

func setOpenclawSkillEnabled(conf map[string]interface{}, skillKey string, enabled bool) {
Expand Down
Loading