Files
larksuite-cli/internal/binding/secret_resolve.go
evandance ce80b3bc46 feat(config): add 'config bind' for per-Agent credential isolation (#515)
Give each AI Agent (OpenClaw, Hermes) its own lark-cli workspace so
its Feishu calls don't overwrite the developer's local config or
collide with other Agents.

    lark-cli config bind [--source openclaw|hermes] [--app-id <id>]
                         [--identity bot-only|user-default] [--force]

Key capabilities:

- Source auto-detected from OPENCLAW_* / HERMES_* env signals; config
  written to ~/.lark-cli/<agent>/, isolated per Agent.
- Two identity presets: 'bot-only' (flag-mode default) and
  'user-default'. Flag mode rejects silent bot→user escalation
  without --force; TUI prompts are exempt.
- Agent-friendly stdout JSON with 'identity' + 'message' for
  next-step branching.
- 'config show' and 'doctor' expose the bound 'workspace'.
- OpenClaw SecretRef resolution: plain / ${VAR} / file:+JSON Pointer
  / exec:.
2026-04-23 19:51:36 +08:00

105 lines
3.0 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"fmt"
"os"
)
// ResolveSecretInput resolves a SecretInput to a plain-text secret string.
// This is the main dispatcher that handles all SecretInput forms:
// - Plain string passthrough
// - "${VAR_NAME}" env template expansion
// - SecretRef object routing to env/file/exec sub-resolvers
//
// The getenv parameter allows injection for testing (typically os.Getenv).
// This function is only called during config bind (cold path).
func ResolveSecretInput(input SecretInput, cfg *SecretsConfig, getenv func(string) string) (string, error) {
if getenv == nil {
getenv = os.Getenv
}
if input.IsZero() {
return "", fmt.Errorf("appSecret is missing or empty")
}
// Plain string form (includes env templates)
if input.IsPlain() {
return resolvePlainOrTemplate(input.Plain, getenv)
}
// SecretRef object form
return resolveSecretRef(input.Ref, cfg, getenv)
}
// resolvePlainOrTemplate handles plain strings and "${VAR}" templates.
func resolvePlainOrTemplate(value string, getenv func(string) string) (string, error) {
if value == "" {
return "", fmt.Errorf("appSecret is empty string")
}
// Check for env template pattern: "${VAR_NAME}"
matches := EnvTemplateRe.FindStringSubmatch(value)
if matches != nil {
varName := matches[1]
envValue := getenv(varName)
if envValue == "" {
return "", fmt.Errorf("env variable %q referenced in openclaw.json is not set or empty", varName)
}
return envValue, nil
}
// Plain string: use as-is
return value, nil
}
// resolveSecretRef dispatches a SecretRef to the appropriate sub-resolver.
func resolveSecretRef(ref *SecretRef, cfg *SecretsConfig, getenv func(string) string) (string, error) {
// Lookup provider configuration
providerConfig, err := LookupProvider(ref, cfg)
if err != nil {
return "", err
}
// Resolve the effective provider name once so downstream resolvers
// (notably the exec JSON payload) see the config-defaulted value instead
// of the unset literal on ref.Provider.
providerName := ResolveDefaultProvider(ref, cfg)
switch ref.Source {
case "env":
return resolveEnvRef(ref, providerConfig, getenv)
case "file":
return resolveFileRef(ref, providerConfig)
case "exec":
return resolveExecRef(ref, providerName, providerConfig, getenv)
default:
return "", fmt.Errorf("unsupported secret source %q", ref.Source)
}
}
// resolveEnvRef handles {source:"env"} SecretRef.
func resolveEnvRef(ref *SecretRef, pc *ProviderConfig, getenv func(string) string) (string, error) {
// Check allowlist if configured
if len(pc.Allowlist) > 0 {
allowed := false
for _, name := range pc.Allowlist {
if name == ref.ID {
allowed = true
break
}
}
if !allowed {
return "", fmt.Errorf("environment variable %q is not allowlisted in provider", ref.ID)
}
}
value := getenv(ref.ID)
if value == "" {
return "", fmt.Errorf("environment variable %q is missing or empty", ref.ID)
}
return value, nil
}