diff --git a/CHANGELOG.md b/CHANGELOG.md index a848f4e..685ac7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to `discrawl` will be documented in this file. - Refreshed Go module dependencies and CI tool/action pins, including staticcheck, gofumpt, gosec, govulncheck, gitleaks, setup-node, and GoReleaser. - Hardened report README writes and Discord Desktop cache reads with root-scoped filesystem access to satisfy the latest gosec checks. +### Fixes + +- OpenClaw Discord token loading now accepts SecretRef objects backed by file or env providers in addition to plaintext token strings. (#49) Thanks @TeodoroRodrigo. + ## 0.6.0 - 2026-04-24 ### Changes diff --git a/internal/config/config.go b/internal/config/config.go index 74f9e35..bd5be16 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -94,19 +94,106 @@ type openClawConfig struct { Channels struct { Discord openClawDiscord `json:"discord"` } `json:"channels"` + Secrets openClawSecrets `json:"secrets"` } type openClawDiscord struct { - Token string `json:"token"` + Token openClawSecretValue `json:"token"` Accounts map[string]openClawDiscordAcct `json:"accounts"` Guilds map[string]json.RawMessage `json:"guilds"` } type openClawDiscordAcct struct { - Token string `json:"token"` + Token openClawSecretValue `json:"token"` Guilds map[string]json.RawMessage `json:"guilds"` } +type openClawSecretValue struct { + Plain string + Ref *openClawSecretRef +} + +type openClawSecretRef struct { + Source string `json:"source"` + Provider string `json:"provider"` + ID string `json:"id"` +} + +type openClawSecrets struct { + Providers map[string]openClawSecretProvider + Aliases map[string]openClawSecretProvider + Defaults openClawSecretDefaults +} + +type openClawSecretProvider struct { + Source string `json:"source"` + Path string `json:"path"` + Mode string `json:"mode"` + Allowlist []string `json:"allowlist"` +} + +type openClawSecretDefaults struct { + Env string `json:"env"` + File string `json:"file"` + Exec string `json:"exec"` +} + +func (v *openClawSecretValue) UnmarshalJSON(data []byte) error { + var plain string + if err := json.Unmarshal(data, &plain); err == nil { + v.Plain = plain + v.Ref = nil + return nil + } + var ref openClawSecretRef + if err := json.Unmarshal(data, &ref); err != nil { + return err + } + v.Plain = "" + v.Ref = &ref + return nil +} + +func (s *openClawSecrets) UnmarshalJSON(data []byte) error { + var aux struct { + Providers map[string]openClawSecretProvider `json:"providers"` + Defaults openClawSecretDefaults `json:"defaults"` + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + aliases := map[string]openClawSecretProvider{} + for name, value := range raw { + if name == "providers" || name == "defaults" { + continue + } + var provider openClawSecretProvider + if err := json.Unmarshal(value, &provider); err == nil && (provider.Source != "" || provider.Path != "") { + aliases[name] = provider + } + } + s.Providers = aux.Providers + s.Aliases = aliases + s.Defaults = aux.Defaults + return nil +} + +func (s openClawSecrets) provider(name string) (openClawSecretProvider, bool) { + name = strings.TrimSpace(name) + if name == "" { + name = "default" + } + if provider, ok := s.Providers[name]; ok { + return provider, true + } + provider, ok := s.Aliases[name] + return provider, ok +} + func Default() Config { home, _ := os.UserHomeDir() base := filepath.Join(home, ".discrawl") @@ -445,14 +532,20 @@ func loadOpenClawDiscordFile(path, account string) (OpenClawDiscord, error) { return OpenClawDiscord{}, fmt.Errorf("parse openclaw config: %w", err) } discord := payload.Channels.Discord - token := expandOpenClawToken(discord.Token) + token, err := resolveOpenClawSecretValue(discord.Token, payload.Secrets, expanded) + if err != nil { + return OpenClawDiscord{}, fmt.Errorf("resolve openclaw discord token: %w", err) + } guildIDs := mapKeys(discord.Guilds) if token == "" { acct := discord.Accounts[normalizeAccount(account)] - if acct.Token == "" && account != normalizeAccount(account) { + if acct.Token.empty() && account != normalizeAccount(account) { acct = discord.Accounts[account] } - token = expandOpenClawToken(acct.Token) + token, err = resolveOpenClawSecretValue(acct.Token, payload.Secrets, expanded) + if err != nil { + return OpenClawDiscord{}, fmt.Errorf("resolve openclaw discord account token: %w", err) + } if len(guildIDs) == 0 { guildIDs = mapKeys(acct.Guilds) } @@ -485,8 +578,146 @@ func NormalizeBotToken(raw string) string { return strings.TrimSpace(raw) } -func expandOpenClawToken(raw string) string { - return NormalizeBotToken(os.ExpandEnv(raw)) +func (v openClawSecretValue) empty() bool { + return strings.TrimSpace(v.Plain) == "" && v.Ref == nil +} + +func resolveOpenClawSecretValue(value openClawSecretValue, secrets openClawSecrets, configPath string) (string, error) { + if value.Ref == nil { + return NormalizeBotToken(os.ExpandEnv(value.Plain)), nil + } + ref := *value.Ref + source := strings.ToLower(strings.TrimSpace(ref.Source)) + switch source { + case "env": + return resolveOpenClawEnvSecret(ref, secrets) + case "file": + return resolveOpenClawFileSecret(ref, secrets, configPath) + case "": + return "", errors.New("secret ref missing source") + default: + return "", fmt.Errorf("unsupported secret ref source %q", ref.Source) + } +} + +func resolveOpenClawEnvSecret(ref openClawSecretRef, secrets openClawSecrets) (string, error) { + id := strings.TrimSpace(ref.ID) + if id == "" { + return "", errors.New("env secret ref missing id") + } + providerName := strings.TrimSpace(ref.Provider) + if providerName == "" { + providerName = defaultOpenClawSecretProvider(secrets.Defaults.Env, "default") + } + if provider, ok := secrets.provider(providerName); ok { + source := strings.ToLower(strings.TrimSpace(provider.Source)) + if source != "" && source != "env" { + return "", fmt.Errorf("secret provider %q has source %q, want env", providerName, provider.Source) + } + if len(provider.Allowlist) > 0 && !stringInSlice(id, provider.Allowlist) { + return "", fmt.Errorf("environment variable %q is not allowlisted in secret provider %q", id, providerName) + } + } else if providerName != defaultOpenClawSecretProvider(secrets.Defaults.Env, "default") { + return "", fmt.Errorf("secret provider %q not found", providerName) + } + value, ok := os.LookupEnv(id) + if !ok || strings.TrimSpace(value) == "" { + return "", fmt.Errorf("environment variable %q is missing or empty", id) + } + return NormalizeBotToken(value), nil +} + +func resolveOpenClawFileSecret(ref openClawSecretRef, secrets openClawSecrets, configPath string) (string, error) { + providerName := strings.TrimSpace(ref.Provider) + if providerName == "" { + providerName = defaultOpenClawSecretProvider(secrets.Defaults.File, "filemain") + } + provider, ok := secrets.provider(providerName) + if !ok { + return "", fmt.Errorf("secret provider %q not found", providerName) + } + source := strings.ToLower(strings.TrimSpace(provider.Source)) + if source != "" && source != "file" { + return "", fmt.Errorf("secret provider %q has source %q, want file", providerName, provider.Source) + } + path := strings.TrimSpace(provider.Path) + if path == "" { + return "", fmt.Errorf("secret provider %q missing path", providerName) + } + expanded, err := expandOpenClawSecretPath(path, configPath) + if err != nil { + return "", err + } + data, err := os.ReadFile(expanded) + if err != nil { + return "", err + } + mode := strings.ToLower(strings.TrimSpace(provider.Mode)) + if mode == "" { + mode = "json" + } + switch mode { + case "json": + token, err := readJSONPointerString(data, ref.ID) + if err != nil { + return "", fmt.Errorf("read secret provider %q id %q: %w", providerName, ref.ID, err) + } + return NormalizeBotToken(os.ExpandEnv(token)), nil + case "singlevalue": + if strings.TrimSpace(ref.ID) != "value" { + return "", fmt.Errorf("secret provider %q singleValue mode requires id %q", providerName, "value") + } + return NormalizeBotToken(os.ExpandEnv(string(data))), nil + default: + return "", fmt.Errorf("unsupported secret provider %q mode %q", providerName, provider.Mode) + } +} + +func expandOpenClawSecretPath(path, configPath string) (string, error) { + path = os.ExpandEnv(strings.TrimSpace(path)) + if path == "" { + return "", errors.New("empty secret provider path") + } + if strings.HasPrefix(path, "~") || filepath.IsAbs(path) { + return ExpandPath(path) + } + return filepath.Clean(filepath.Join(filepath.Dir(configPath), path)), nil +} + +func readJSONPointerString(data []byte, pointer string) (string, error) { + pointer = strings.TrimSpace(pointer) + if pointer == "" || pointer[0] != '/' { + return "", errors.New("id must be an absolute JSON pointer") + } + var current any + if err := json.Unmarshal(data, ¤t); err != nil { + return "", fmt.Errorf("parse secret file: %w", err) + } + for _, rawSegment := range strings.Split(pointer[1:], "/") { + segment := strings.ReplaceAll(strings.ReplaceAll(rawSegment, "~1", "/"), "~0", "~") + object, ok := current.(map[string]any) + if !ok { + return "", fmt.Errorf("segment %q is not an object", segment) + } + next, ok := object[segment] + if !ok { + return "", fmt.Errorf("segment %q not found", segment) + } + current = next + } + secret, ok := current.(string) + if !ok { + return "", errors.New("secret value is not a string") + } + return secret, nil +} + +func defaultOpenClawSecretProvider(configured, fallback string) string { + configured = strings.TrimSpace(configured) + if configured != "" { + return configured + } + return fallback } func normalizeAccount(account string) string { @@ -525,3 +756,12 @@ func uniqueStrings(in []string) []string { } return out } + +func stringInSlice(needle string, haystack []string) bool { + for _, item := range haystack { + if item == needle { + return true + } + } + return false +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index df0ae0a..35075e9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -75,6 +75,195 @@ func TestResolveDiscordTokenPrefersOpenClaw(t *testing.T) { require.Equal(t, "openclaw", token.Source) } +func TestResolveDiscordTokenFromOpenClawFileSecretRef(t *testing.T) { + dir := t.TempDir() + openClawPath := filepath.Join(dir, "openclaw.json") + secretsPath := filepath.Join(dir, "credentials", "secrets.json") + require.NoError(t, os.MkdirAll(filepath.Dir(secretsPath), 0o755)) + require.NoError(t, os.WriteFile(secretsPath, []byte(`{ + "channels": { + "discord": { + "token": "Bot file-secret-token" + } + } + }`), 0o600)) + require.NoError(t, os.WriteFile(openClawPath, []byte(`{ + "secrets": { + "providers": { + "filemain": { + "source": "file", + "path": "credentials/secrets.json", + "mode": "json" + } + } + }, + "channels": { + "discord": { + "token": { + "source": "file", + "provider": "filemain", + "id": "/channels/discord/token" + }, + "guilds": { "g1": {} } + } + } + }`), 0o600)) + t.Setenv(DefaultTokenEnv, "env-token") + + cfg := Default() + cfg.Discord.OpenClawConfig = openClawPath + + token, err := ResolveDiscordToken(cfg) + require.NoError(t, err) + require.Equal(t, "file-secret-token", token.Token) + require.Equal(t, "openclaw", token.Source) + + info, err := LoadOpenClawDiscord(openClawPath, "default") + require.NoError(t, err) + require.Equal(t, []string{"g1"}, info.GuildIDs) +} + +func TestResolveDiscordAccountTokenFromOpenClawDirectFileSecretRef(t *testing.T) { + dir := t.TempDir() + openClawPath := filepath.Join(dir, "openclaw.json") + secretsPath := filepath.Join(dir, "secrets.json") + require.NoError(t, os.WriteFile(secretsPath, []byte(`{ + "accounts": { + "atlas": { + "discordToken": "account-file-token" + } + } + }`), 0o600)) + require.NoError(t, os.WriteFile(openClawPath, []byte(`{ + "secrets": { + "filemain": { + "source": "file", + "path": "secrets.json", + "mode": "json" + } + }, + "channels": { + "discord": { + "accounts": { + "atlas": { + "token": { + "source": "file", + "provider": "filemain", + "id": "/accounts/atlas/discordToken" + }, + "guilds": { "g9": {} } + } + } + } + } + }`), 0o600)) + + info, err := LoadOpenClawDiscord(openClawPath, "atlas") + require.NoError(t, err) + require.Equal(t, "account-file-token", info.Token) + require.Equal(t, []string{"g9"}, info.GuildIDs) +} + +func TestResolveDiscordTokenFromOpenClawEnvSecretRef(t *testing.T) { + dir := t.TempDir() + openClawPath := filepath.Join(dir, "openclaw.json") + require.NoError(t, os.WriteFile(openClawPath, []byte(`{ + "channels": { + "discord": { + "token": { + "source": "env", + "provider": "default", + "id": "DISCRAWL_TEST_OPENCLAW_TOKEN" + } + } + } + }`), 0o600)) + t.Setenv("DISCRAWL_TEST_OPENCLAW_TOKEN", "Bot env-ref-token") + + info, err := LoadOpenClawDiscord(openClawPath, "default") + require.NoError(t, err) + require.Equal(t, "env-ref-token", info.Token) +} + +func TestResolveDiscordTokenFromOpenClawEnvSecretRefRequiresEnvValue(t *testing.T) { + dir := t.TempDir() + openClawPath := filepath.Join(dir, "openclaw.json") + require.NoError(t, os.WriteFile(openClawPath, []byte(`{ + "channels": { + "discord": { + "token": { + "source": "env", + "provider": "default", + "id": "DISCRAWL_TEST_MISSING_OPENCLAW_TOKEN" + } + } + } + }`), 0o600)) + t.Setenv(DefaultTokenEnv, "fallback-token") + + cfg := Default() + cfg.Discord.OpenClawConfig = openClawPath + + _, err := ResolveDiscordToken(cfg) + require.ErrorContains(t, err, `environment variable "DISCRAWL_TEST_MISSING_OPENCLAW_TOKEN" is missing or empty`) +} + +func TestResolveDiscordTokenFromOpenClawEnvSecretRefHonorsAllowlist(t *testing.T) { + dir := t.TempDir() + openClawPath := filepath.Join(dir, "openclaw.json") + require.NoError(t, os.WriteFile(openClawPath, []byte(`{ + "secrets": { + "providers": { + "default": { + "source": "env", + "allowlist": ["OTHER_TOKEN"] + } + } + }, + "channels": { + "discord": { + "token": { + "source": "env", + "provider": "default", + "id": "DISCRAWL_TEST_OPENCLAW_TOKEN" + } + } + } + }`), 0o600)) + t.Setenv("DISCRAWL_TEST_OPENCLAW_TOKEN", "env-ref-token") + + _, err := LoadOpenClawDiscord(openClawPath, "default") + require.ErrorContains(t, err, `environment variable "DISCRAWL_TEST_OPENCLAW_TOKEN" is not allowlisted`) +} + +func TestResolveDiscordTokenFromOpenClawEnvSecretRefRejectsProviderSourceMismatch(t *testing.T) { + dir := t.TempDir() + openClawPath := filepath.Join(dir, "openclaw.json") + require.NoError(t, os.WriteFile(openClawPath, []byte(`{ + "secrets": { + "providers": { + "default": { + "source": "file", + "path": "secrets.json" + } + } + }, + "channels": { + "discord": { + "token": { + "source": "env", + "provider": "default", + "id": "DISCRAWL_TEST_OPENCLAW_TOKEN" + } + } + } + }`), 0o600)) + t.Setenv("DISCRAWL_TEST_OPENCLAW_TOKEN", "env-ref-token") + + _, err := LoadOpenClawDiscord(openClawPath, "default") + require.ErrorContains(t, err, `secret provider "default" has source "file", want env`) +} + func TestResolveDiscordTokenFallsBackToEnv(t *testing.T) { cfg := Default() cfg.Discord.TokenSource = "env"