chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// Identity represents the caller identity for API requests.
|
||||
type Identity string
|
||||
|
||||
const (
|
||||
AsUser Identity = "user"
|
||||
AsBot Identity = "bot"
|
||||
AsAuto Identity = "auto"
|
||||
)
|
||||
|
||||
// IsBot returns true if the identity is bot.
|
||||
func (id Identity) IsBot() bool { return id == AsBot }
|
||||
|
||||
// AppUser is a logged-in user record stored in config.
|
||||
type AppUser struct {
|
||||
UserOpenId string `json:"userOpenId"`
|
||||
UserName string `json:"userName"`
|
||||
}
|
||||
|
||||
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
|
||||
type AppConfig struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
AppId string `json:"appId"`
|
||||
AppSecret SecretInput `json:"appSecret"`
|
||||
Brand LarkBrand `json:"brand"`
|
||||
Lang i18n.Lang `json:"lang,omitempty"`
|
||||
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
|
||||
StrictMode *StrictMode `json:"strictMode,omitempty"`
|
||||
Users []AppUser `json:"users"`
|
||||
}
|
||||
|
||||
// ProfileName returns the display name for this app config.
|
||||
// If Name is set, returns Name; otherwise falls back to AppId.
|
||||
func (a *AppConfig) ProfileName() string {
|
||||
if a.Name != "" {
|
||||
return a.Name
|
||||
}
|
||||
return a.AppId
|
||||
}
|
||||
|
||||
// MultiAppConfig is the multi-app config file format.
|
||||
type MultiAppConfig struct {
|
||||
StrictMode StrictMode `json:"strictMode,omitempty"`
|
||||
CurrentApp string `json:"currentApp,omitempty"`
|
||||
PreviousApp string `json:"previousApp,omitempty"`
|
||||
Apps []AppConfig `json:"apps"`
|
||||
}
|
||||
|
||||
// CurrentAppConfig returns the currently active app config.
|
||||
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
|
||||
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
|
||||
if profileOverride != "" {
|
||||
if app := m.FindApp(profileOverride); app != nil {
|
||||
return app
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if m.CurrentApp != "" {
|
||||
if app := m.FindApp(m.CurrentApp); app != nil {
|
||||
return app
|
||||
}
|
||||
return nil // explicit currentApp not found; don't silently fallback
|
||||
}
|
||||
if len(m.Apps) > 0 {
|
||||
return &m.Apps[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindApp looks up an app by name, then by appId. Returns nil if not found.
|
||||
// Name match takes priority: if profile A has Name "X" and profile B has AppId "X",
|
||||
// FindApp("X") returns profile A.
|
||||
func (m *MultiAppConfig) FindApp(name string) *AppConfig {
|
||||
// First pass: match by Name
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].Name != "" && m.Apps[i].Name == name {
|
||||
return &m.Apps[i]
|
||||
}
|
||||
}
|
||||
// Second pass: match by AppId
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].AppId == name {
|
||||
return &m.Apps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindAppIndex looks up an app index by name, then by appId. Returns -1 if not found.
|
||||
func (m *MultiAppConfig) FindAppIndex(name string) int {
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].Name != "" && m.Apps[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for i := range m.Apps {
|
||||
if m.Apps[i].AppId == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ProfileNames returns all profile names (Name if set, otherwise AppId).
|
||||
func (m *MultiAppConfig) ProfileNames() []string {
|
||||
names := make([]string, len(m.Apps))
|
||||
for i := range m.Apps {
|
||||
names[i] = m.Apps[i].ProfileName()
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ValidateProfileName checks that a profile name is valid.
|
||||
// Rejects empty names, whitespace, control characters, and shell-problematic characters,
|
||||
// but allows Unicode letters (e.g. Chinese, Japanese) for localized profile names.
|
||||
func ValidateProfileName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("profile name cannot be empty")
|
||||
}
|
||||
if utf8.RuneCountInString(name) > 64 {
|
||||
return fmt.Errorf("profile name %q is too long (max 64 characters)", name)
|
||||
}
|
||||
for _, r := range name {
|
||||
if r <= 0x1F || r == 0x7F { // control characters
|
||||
return fmt.Errorf("invalid profile name %q: contains control characters", name)
|
||||
}
|
||||
switch r {
|
||||
case ' ', '\t', '/', '\\', '"', '\'', '`', '$', '#', '!', '&', '|', ';', '(', ')', '{', '}', '[', ']', '<', '>', '?', '*', '~':
|
||||
return fmt.Errorf("invalid profile name %q: contains invalid character %q", name, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CliConfig is the resolved single-app config used by downstream code.
|
||||
type CliConfig struct {
|
||||
ProfileName string
|
||||
AppID string
|
||||
AppSecret string
|
||||
Brand LarkBrand
|
||||
DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file)
|
||||
UserOpenId string
|
||||
UserName string
|
||||
Lang i18n.Lang
|
||||
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
|
||||
}
|
||||
|
||||
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
|
||||
// Must match extension/credential.SupportsBot.
|
||||
const identityBotBit uint8 = 1 << 1
|
||||
|
||||
// CanBot reports whether the current credential context supports bot identity.
|
||||
// Returns true when SupportedIdentities is unset (0, unknown) or includes the bot bit.
|
||||
func (c *CliConfig) CanBot() bool {
|
||||
return c.SupportedIdentities == 0 || c.SupportedIdentities&identityBotBit != 0
|
||||
}
|
||||
|
||||
// GetConfigDir returns the config directory path for the current workspace.
|
||||
// When workspace is local (default), this returns the same path as before
|
||||
// (LARKSUITE_CLI_CONFIG_DIR or ~/.lark-cli) — fully backward-compatible.
|
||||
// When workspace is openclaw/hermes, returns base/openclaw or base/hermes.
|
||||
func GetConfigDir() string {
|
||||
return GetRuntimeDir()
|
||||
}
|
||||
|
||||
// GetConfigPath returns the config file path for the current workspace.
|
||||
func GetConfigPath() string {
|
||||
return filepath.Join(GetConfigDir(), "config.json")
|
||||
}
|
||||
|
||||
// ErrMalformedConfig marks a config-load failure caused by malformed file
|
||||
// content (unparseable JSON, structurally empty) rather than a missing or
|
||||
// unreadable file. Callers classify with errors.Is rather than sniffing the
|
||||
// message text.
|
||||
var ErrMalformedConfig = errors.New("malformed config")
|
||||
|
||||
// LoadMultiAppConfig loads multi-app config from disk.
|
||||
func LoadMultiAppConfig() (*MultiAppConfig, error) {
|
||||
data, err := vfs.ReadFile(GetConfigPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var multi MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return nil, fmt.Errorf("invalid config format: %w: %w", ErrMalformedConfig, err)
|
||||
}
|
||||
if len(multi.Apps) == 0 {
|
||||
return nil, fmt.Errorf("invalid config format: no apps: %w", ErrMalformedConfig)
|
||||
}
|
||||
return &multi, nil
|
||||
}
|
||||
|
||||
// SaveMultiAppConfig saves config to disk.
|
||||
func SaveMultiAppConfig(config *MultiAppConfig) error {
|
||||
dir := GetConfigDir()
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600)
|
||||
}
|
||||
|
||||
// RequireConfig loads the single-app config using the default profile resolution.
|
||||
func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
return RequireConfigForProfile(kc, "")
|
||||
}
|
||||
|
||||
// RequireConfigForProfile loads the single-app config for a specific profile.
|
||||
// Resolution priority: profileOverride > config.CurrentApp > Apps[0].
|
||||
func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
raw, err := LoadMultiAppConfig()
|
||||
if err != nil || raw == nil || len(raw.Apps) == 0 {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
return ResolveConfigFromMulti(raw, kc, profileOverride)
|
||||
}
|
||||
|
||||
// ResolveConfigFromMulti resolves a single-app config from an already-loaded MultiAppConfig.
|
||||
// This avoids re-reading the config file when the caller has already loaded it.
|
||||
func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
app := raw.CurrentAppConfig(profileOverride)
|
||||
if app == nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found", profileOverride).
|
||||
WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames()))
|
||||
}
|
||||
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
||||
WithHint("%s", err.Error()).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
if errs.IsTyped(err) {
|
||||
return nil, err
|
||||
}
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(err) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
cfg := &CliConfig{
|
||||
ProfileName: app.ProfileName(),
|
||||
AppID: app.AppId,
|
||||
AppSecret: secret,
|
||||
Brand: ParseBrand(string(app.Brand)),
|
||||
Lang: app.Lang,
|
||||
DefaultAs: app.DefaultAs,
|
||||
}
|
||||
if len(app.Users) > 0 {
|
||||
cfg.UserOpenId = app.Users[0].UserOpenId
|
||||
cfg.UserName = app.Users[0].UserName
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RequireAuth loads config and ensures a user is logged in.
|
||||
func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
return RequireAuthForProfile(kc, "")
|
||||
}
|
||||
|
||||
// RequireAuthForProfile loads config for a profile and ensures a user is logged in.
|
||||
func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
cfg, err := RequireConfigForProfile(kc, profileOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.UserOpenId == "" {
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").
|
||||
WithHint("run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// formatProfileNames joins profile names for display.
|
||||
func formatProfileNames(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMultiAppConfig_StrictMode_JSON(t *testing.T) {
|
||||
// StrictMode="" should be omitted (omitempty)
|
||||
m := &MultiAppConfig{
|
||||
Apps: []AppConfig{{AppId: "a", AppSecret: PlainSecret("s"), Brand: BrandFeishu, Users: []AppUser{}}},
|
||||
}
|
||||
data, _ := json.Marshal(m)
|
||||
if string(data) != `{"apps":[{"appId":"a","appSecret":"s","brand":"feishu","users":[]}]}` {
|
||||
t.Errorf("StrictMode empty should be omitted, got: %s", data)
|
||||
}
|
||||
|
||||
// StrictMode="bot" should be present
|
||||
m.StrictMode = StrictModeBot
|
||||
data, _ = json.Marshal(m)
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
if parsed["strictMode"] != "bot" {
|
||||
t.Errorf("StrictMode=bot should be present, got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfig_StrictMode_JSON(t *testing.T) {
|
||||
// StrictMode nil should be omitted
|
||||
app := &AppConfig{AppId: "a", AppSecret: PlainSecret("s"), Brand: BrandFeishu, Users: []AppUser{}}
|
||||
data, _ := json.Marshal(app)
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
if _, ok := parsed["strictMode"]; ok {
|
||||
t.Errorf("nil StrictMode should be omitted, got: %s", data)
|
||||
}
|
||||
|
||||
// StrictMode = pointer to "user"
|
||||
v := StrictModeUser
|
||||
app.StrictMode = &v
|
||||
data, _ = json.Marshal(app)
|
||||
json.Unmarshal(data, &parsed)
|
||||
if parsed["strictMode"] != "user" {
|
||||
t.Errorf("StrictMode=user should be present, got: %s", data)
|
||||
}
|
||||
|
||||
// StrictMode = pointer to "off" (explicit off — should be present, not omitted)
|
||||
voff := StrictModeOff
|
||||
app.StrictMode = &voff
|
||||
data, _ = json.Marshal(app)
|
||||
json.Unmarshal(data, &parsed)
|
||||
if val, ok := parsed["strictMode"]; !ok || val != "off" {
|
||||
t.Errorf("StrictMode=off (explicit) should be present, got: %s", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
// stubKeychain is a minimal KeychainAccess that always returns ErrNotFound.
|
||||
type stubKeychain struct{}
|
||||
|
||||
func (stubKeychain) Get(service, account string) (string, error) {
|
||||
return "", keychain.ErrNotFound
|
||||
}
|
||||
func (stubKeychain) Set(service, account, value string) error { return nil }
|
||||
func (stubKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
func TestAppConfig_LangSerialization(t *testing.T) {
|
||||
app := AppConfig{
|
||||
AppId: "cli_test", AppSecret: PlainSecret("secret"),
|
||||
Brand: BrandFeishu, Lang: "en", Users: []AppUser{},
|
||||
}
|
||||
data, err := json.Marshal(app)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var got AppConfig
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got.Lang != "en" {
|
||||
t.Errorf("Lang = %q, want %q", got.Lang, "en")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfig_LangOmitEmpty(t *testing.T) {
|
||||
app := AppConfig{
|
||||
AppId: "cli_test", AppSecret: PlainSecret("secret"),
|
||||
Brand: BrandFeishu, Users: []AppUser{},
|
||||
}
|
||||
data, err := json.Marshal(app)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
// Lang should be omitted when empty
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
t.Fatalf("unmarshal raw: %v", err)
|
||||
}
|
||||
if _, exists := raw["lang"]; exists {
|
||||
t.Error("expected lang to be omitted when empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||
config := &MultiAppConfig{
|
||||
Apps: []AppConfig{{
|
||||
AppId: "cli_test", AppSecret: PlainSecret("s"),
|
||||
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
|
||||
}},
|
||||
}
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var got MultiAppConfig
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(got.Apps) != 1 {
|
||||
t.Fatalf("expected 1 app, got %d", len(got.Apps))
|
||||
}
|
||||
if got.Apps[0].Lang != "zh" {
|
||||
t.Errorf("Lang = %q, want %q", got.Apps[0].Lang, "zh")
|
||||
}
|
||||
if got.Apps[0].Brand != BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
AppId: "cli_new_app",
|
||||
AppSecret: SecretInput{Ref: &SecretRef{
|
||||
Source: "keychain",
|
||||
ID: "appsecret:cli_old_app",
|
||||
}},
|
||||
Brand: BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for mismatched appId and appSecret keychain key")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Hint == "" {
|
||||
t.Error("expected non-empty hint in ConfigError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
AppId: "cli_abc",
|
||||
AppSecret: PlainSecret("my-secret"),
|
||||
Brand: BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.AppID != "cli_abc" {
|
||||
t.Errorf("AppID = %q, want %q", cfg.AppID, "cli_abc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
AppId: "cli_abc",
|
||||
AppSecret: PlainSecret("my-secret"),
|
||||
Brand: BrandFeishu,
|
||||
Lang: "en",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Lang != "en" {
|
||||
t.Errorf("Lang = %q, want %q", cfg.Lang, "en")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) {
|
||||
// Keychain ref matches appId, so validation passes.
|
||||
// The subsequent ResolveSecretInput will fail (no real keychain),
|
||||
// but that proves the mismatch check itself passed.
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
AppId: "cli_abc",
|
||||
AppSecret: SecretInput{Ref: &SecretRef{
|
||||
Source: "keychain",
|
||||
ID: "appsecret:cli_abc",
|
||||
}},
|
||||
Brand: BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
|
||||
if err == nil {
|
||||
// stubKeychain returns ErrNotFound, so we expect a keychain error,
|
||||
// but NOT a mismatch error — that's the point of this test.
|
||||
t.Fatal("expected error (keychain entry not found), got nil")
|
||||
}
|
||||
// The error should come from keychain resolution, NOT from our mismatch check.
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
if cfgErr.Message == "appId and appSecret keychain key are out of sync" {
|
||||
t.Fatal("error came from mismatch check, but keys should match")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_PROFILE", "missing")
|
||||
|
||||
raw := &MultiAppConfig{
|
||||
CurrentApp: "active",
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
Name: "active",
|
||||
AppId: "cli_active",
|
||||
AppSecret: PlainSecret("secret"),
|
||||
Brand: BrandFeishu,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfigFromMulti() error = %v", err)
|
||||
}
|
||||
if cfg.ProfileName != "active" {
|
||||
t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCliConfig_CanBot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
supportedIdentities uint8
|
||||
want bool
|
||||
}{
|
||||
{"unset (0) defaults to true", 0, true},
|
||||
{"user only", 1, false},
|
||||
{"bot only", 2, true},
|
||||
{"both", 3, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &CliConfig{SupportedIdentities: tt.supportedIdentities}
|
||||
if got := cfg.CanBot(); got != tt.want {
|
||||
t.Errorf("CanBot() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime configs must never carry raw brand casing: the config ingress
|
||||
// normalizes it, so downstream equality checks see canonical values.
|
||||
func TestResolveConfigFromMulti_NormalizesBrand(t *testing.T) {
|
||||
multi := &MultiAppConfig{Apps: []AppConfig{{
|
||||
AppId: "cli_x",
|
||||
AppSecret: PlainSecret("test-secret"),
|
||||
Brand: LarkBrand(" LARK "),
|
||||
}}}
|
||||
cfg, err := ResolveConfigFromMulti(multi, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfigFromMulti error = %v", err)
|
||||
}
|
||||
if cfg.Brand != BrandLark {
|
||||
t.Errorf("Brand = %q, want %q (normalized at ingress)", cfg.Brand, BrandLark)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// isMalformedConfigError reports whether a config load failure indicates a
|
||||
// malformed file (unparseable / structurally empty) rather than an absent or
|
||||
// inaccessible one. Malformed files map to the invalid_config subtype so the
|
||||
// user is told to fix the file instead of re-running init. Detection is by
|
||||
// ErrMalformedConfig sentinel, not message text.
|
||||
func isMalformedConfigError(err error) bool {
|
||||
return errors.Is(err, ErrMalformedConfig)
|
||||
}
|
||||
|
||||
// LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet
|
||||
// configured vs. couldn't read" disambiguation that every config-required
|
||||
// command should use:
|
||||
//
|
||||
// - file missing → workspace-aware NotConfiguredError (init / bind hint)
|
||||
// - parse error / permission error → real load failure with the original
|
||||
// cause preserved, so the user can actually fix the broken file
|
||||
//
|
||||
// Without this, every call site that did `if err != nil { return
|
||||
// NotConfiguredError() }` silently coerced corrupt-config into "run init",
|
||||
// which sent users in circles when their config.json was just malformed.
|
||||
func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
multi, err := LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
// Surface the real cause (parse error, permission denied, etc.)
|
||||
// so the user can fix the broken file. A malformed file is
|
||||
// invalid_config; anything else (permission denied, etc.) is
|
||||
// not_configured. Both stay on the typed structured-envelope path
|
||||
// at the root command's error sink.
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(err) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
return multi, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// localInitHint is the canonical "you're in a regular terminal, run
|
||||
// init" guidance — shared by NotConfiguredError and NoActiveProfileError
|
||||
// so the same session can't show two different recommended commands.
|
||||
localInitHint = "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."
|
||||
|
||||
// agentBindHint is the canonical "you're in an Agent workspace, see
|
||||
// the binding workflow" guidance. Always points at --help (never a
|
||||
// ready-to-run bind command) so the AI reads the confirmation
|
||||
// discipline (identity preset, user opt-in) before acting.
|
||||
agentBindHint = "read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`"
|
||||
)
|
||||
|
||||
// NotConfiguredError returns the canonical "not configured" error, with a
|
||||
// hint that depends on the active workspace:
|
||||
//
|
||||
// - WorkspaceLocal → suggest `config init --new` (creates a new app).
|
||||
// - WorkspaceOpenClaw / WorkspaceHermes → point at `config bind --help`
|
||||
// rather than a ready-to-run command, because binding is policy-laden:
|
||||
// the user must pick an identity preset (bot-only vs user-default),
|
||||
// and re-binding may overwrite an existing one. The help text walks
|
||||
// the AI through the confirmation flow.
|
||||
//
|
||||
// All "config not loaded yet" call sites should use this helper rather than
|
||||
// hand-rolling a hint, so AI agents always get a workspace-correct next step.
|
||||
func NotConfiguredError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured").
|
||||
WithHint("%s", localInitHint)
|
||||
}
|
||||
// Agent workspace: the workspace name appears only in the message, never
|
||||
// in the wire subtype, which stays not_configured.
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured,
|
||||
"%s context detected but lark-cli is not bound to it", ws.Display()).
|
||||
WithHint("%s", agentBindHint)
|
||||
}
|
||||
|
||||
// reconfigureHint returns the workspace-aware "fix it from scratch" hint
|
||||
// used by error paths that aren't full ConfigErrors (e.g. plain fmt.Errorf
|
||||
// strings from keychain / secret validation). Local → `config init`;
|
||||
// Agent → `config bind --help` so the AI reads the binding workflow and
|
||||
// confirms identity preset with the user before running the actual command.
|
||||
func reconfigureHint() string {
|
||||
if CurrentWorkspace().IsLocal() {
|
||||
return "please run `lark-cli config init` to reconfigure"
|
||||
}
|
||||
return agentBindHint
|
||||
}
|
||||
|
||||
// NoActiveProfileError mirrors NotConfiguredError for the related
|
||||
// "config exists but the requested profile cannot be resolved" case. In agent
|
||||
// workspaces a missing profile typically means the binding was wiped while
|
||||
// the workspace marker remained — re-binding is the correct fix, not init.
|
||||
func NoActiveProfileError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
|
||||
WithHint("%s", localInitHint)
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured,
|
||||
"no active profile in %s workspace", ws.Display()).
|
||||
WithHint("%s", agentBindHint)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// saveAndRestoreWorkspace ensures package-level currentWorkspace is reset
|
||||
// between subtests so cross-test pollution can't make assertions pass by
|
||||
// accident.
|
||||
func saveAndRestoreWorkspace(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := CurrentWorkspace()
|
||||
t.Cleanup(func() { SetCurrentWorkspace(prev) })
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig || cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("category/subtype = %q/%q, want config/not_configured", cfgErr.Category, cfgErr.Subtype)
|
||||
}
|
||||
if cfgErr.Message != "not configured" {
|
||||
t.Errorf("message = %q, want %q", cfgErr.Message, "not configured")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config init --new") {
|
||||
t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config bind") {
|
||||
t.Errorf("local hint must not mention config bind; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_OpenClaw(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
// The wire subtype stays not_configured; the workspace name only appears
|
||||
// in the message, never in the typed taxonomy.
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw") {
|
||||
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
|
||||
}
|
||||
// Hint must point at --help (read first, confirm with user, then bind),
|
||||
// NOT a directly-executable bind command — binding is policy-laden
|
||||
// (identity preset, may overwrite existing binding).
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") {
|
||||
t.Errorf("agent hint must NOT mention config init (would cause AI to create a new app); got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_Hermes(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceHermes)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "hermes") {
|
||||
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveProfileError_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
err := NoActiveProfileError()
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != "no active profile" {
|
||||
t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
|
||||
err := NoActiveProfileError()
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureHint_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
got := reconfigureHint()
|
||||
if !strings.Contains(got, "config init") {
|
||||
t.Errorf("local reconfigure hint must mention config init; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureHint_Agent(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceHermes)
|
||||
|
||||
got := reconfigureHint()
|
||||
if !strings.Contains(got, "config bind --help") {
|
||||
t.Errorf("agent reconfigure hint must point to `config bind --help`; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
_, err := LoadOrNotConfigured()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
if cfgErr.Message != "not configured" {
|
||||
t.Errorf("message = %q, want \"not configured\"", cfgErr.Message)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config init --new") {
|
||||
t.Errorf("missing-file in local must hint `config init --new`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrNotConfigured_CorruptFile_PreservesCause is the regression guard
|
||||
// for the previous "every load error → not configured" coercion: a malformed
|
||||
// config.json must surface its real failure cause so the user can fix it,
|
||||
// not get sent in circles by an init/bind hint that wouldn't help here.
|
||||
func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Write garbage that will fail JSON parsing.
|
||||
if err := os.WriteFile(dir+"/config.json", []byte("{not valid json"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := LoadOrNotConfigured()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupt config")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
// A malformed file maps to invalid_config, not not_configured.
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidConfig {
|
||||
t.Errorf("subtype = %q, want invalid_config", cfgErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "failed to load config") {
|
||||
t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message)
|
||||
}
|
||||
// And it must NOT pretend the user just hasn't initialised yet.
|
||||
if cfgErr.Message == "not configured" {
|
||||
t.Errorf("corrupt-file must not be coerced to 'not configured'")
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") || strings.Contains(cfgErr.Hint, "config bind") {
|
||||
t.Errorf("corrupt-file hint must not redirect to init/bind; got %q", cfgErr.Hint)
|
||||
}
|
||||
// The underlying parse failure stays reachable through the unwrap chain.
|
||||
if cfgErr.Cause == nil {
|
||||
t.Error("Cause must wrap the underlying load error for errors.Is/Unwrap")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
// Risk levels — the three-tier convention used across the CLI. They live here,
|
||||
// at the leaf, so the envelope renderer (internal/schema) and the command
|
||||
// toolkit (internal/cmdutil) share one vocabulary without a renderer depending
|
||||
// on command utilities. Framework confirmation gating acts only on
|
||||
// RiskHighRiskWrite.
|
||||
const (
|
||||
RiskRead = "read"
|
||||
RiskWrite = "write"
|
||||
RiskHighRiskWrite = "high-risk-write"
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SecretRef — external secret reference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SecretRef references a secret stored externally.
|
||||
type SecretRef struct {
|
||||
Source string `json:"source"` // "file" | "keychain"
|
||||
Provider string `json:"provider,omitempty"` // optional, reserved
|
||||
ID string `json:"id"` // env var name / file path / command / keychain key
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SecretInput — union type: plain string or SecretRef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SecretInput represents a secret value: either a plain string or a SecretRef object.
|
||||
type SecretInput struct {
|
||||
Plain string // non-empty for plain string values
|
||||
Ref *SecretRef // non-nil for SecretRef values
|
||||
}
|
||||
|
||||
// PlainSecret creates a SecretInput from a plain string.
|
||||
func PlainSecret(s string) SecretInput {
|
||||
return SecretInput{Plain: s}
|
||||
}
|
||||
|
||||
// IsZero returns true if the SecretInput has no value.
|
||||
func (s SecretInput) IsZero() bool {
|
||||
return s.Plain == "" && s.Ref == nil
|
||||
}
|
||||
|
||||
// IsSecretRef returns true if this is a SecretRef object (env/file/keychain).
|
||||
func (s SecretInput) IsSecretRef() bool {
|
||||
return s.Ref != nil
|
||||
}
|
||||
|
||||
// IsPlain returns true if this is a plain text string (not a SecretRef).
|
||||
func (s SecretInput) IsPlain() bool {
|
||||
return s.Ref == nil
|
||||
}
|
||||
|
||||
// MarshalJSON serializes SecretInput: plain string → JSON string, SecretRef → JSON object.
|
||||
func (s SecretInput) MarshalJSON() ([]byte, error) {
|
||||
if s.Ref != nil {
|
||||
return json.Marshal(s.Ref)
|
||||
}
|
||||
return json.Marshal(s.Plain)
|
||||
}
|
||||
|
||||
// UnmarshalJSON deserializes SecretInput from either a JSON string or a SecretRef object.
|
||||
func (s *SecretInput) UnmarshalJSON(data []byte) error {
|
||||
// Try string first
|
||||
var plain string
|
||||
if err := json.Unmarshal(data, &plain); err == nil {
|
||||
s.Plain = plain
|
||||
s.Ref = nil
|
||||
return nil
|
||||
}
|
||||
// Try SecretRef object
|
||||
var ref SecretRef
|
||||
if err := json.Unmarshal(data, &ref); err == nil && isValidSource(ref.Source) && ref.ID != "" {
|
||||
s.Ref = &ref
|
||||
s.Plain = ""
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("appSecret must be a string or {source, id} object")
|
||||
}
|
||||
|
||||
// ValidSecretSources is the set of recognized SecretRef sources.
|
||||
var ValidSecretSources = map[string]bool{
|
||||
"file": true, "keychain": true,
|
||||
}
|
||||
|
||||
func isValidSource(source string) bool {
|
||||
return ValidSecretSources[source]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const secretKeyPrefix = "appsecret:"
|
||||
|
||||
func secretAccountKey(appId string) string {
|
||||
return secretKeyPrefix + appId
|
||||
}
|
||||
|
||||
// ResolveSecretInput resolves a SecretInput to a plain string.
|
||||
// SecretRef objects are resolved by source (file / keychain).
|
||||
func ResolveSecretInput(s SecretInput, kc keychain.KeychainAccess) (string, error) {
|
||||
if s.Ref == nil {
|
||||
return s.Plain, nil
|
||||
}
|
||||
switch s.Ref.Source {
|
||||
case "file":
|
||||
data, err := vfs.ReadFile(s.Ref.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read secret file %s: %w", s.Ref.ID, err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
case "keychain":
|
||||
return kc.Get(keychain.LarkCliService, s.Ref.ID)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown secret source: %s", s.Ref.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// ForStorage determines how to store a secret in config.json.
|
||||
// - SecretRef → preserved as-is
|
||||
// - Plain text → stored in keychain, returns keychain SecretRef
|
||||
// Returns error if keychain is unavailable (no silent plaintext fallback).
|
||||
func ForStorage(appId string, input SecretInput, kc keychain.KeychainAccess) (SecretInput, error) {
|
||||
if !input.IsPlain() {
|
||||
return input, nil // SecretRef → keep as-is
|
||||
}
|
||||
key := secretAccountKey(appId)
|
||||
if err := kc.Set(keychain.LarkCliService, key, input.Plain); err != nil {
|
||||
return SecretInput{}, fmt.Errorf("keychain unavailable: %w\nhint: use file: reference in config to bypass keychain", err)
|
||||
}
|
||||
return SecretInput{Ref: &SecretRef{Source: "keychain", ID: key}}, nil
|
||||
}
|
||||
|
||||
// ValidateSecretKeyMatch checks that the appSecret keychain key references the
|
||||
// expected appId. This prevents silent mismatches when config.json is edited by
|
||||
// hand (e.g. appId changed but appSecret.id still points to the old app).
|
||||
// Only applicable when appSecret is a keychain SecretRef; other forms are skipped.
|
||||
func ValidateSecretKeyMatch(appId string, secret SecretInput) error {
|
||||
if secret.Ref == nil || secret.Ref.Source != "keychain" {
|
||||
return nil
|
||||
}
|
||||
expected := secretAccountKey(appId)
|
||||
if secret.Ref.ID != expected {
|
||||
return fmt.Errorf(
|
||||
"appSecret keychain key %q does not match appId %q (expected %q); %s",
|
||||
secret.Ref.ID, appId, expected, reconfigureHint(),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSecretStore cleans up keychain entries when an app is removed.
|
||||
// Errors are intentionally ignored — cleanup is best-effort.
|
||||
func RemoveSecretStore(input SecretInput, kc keychain.KeychainAccess) {
|
||||
if input.IsSecretRef() && input.Ref.Source == "keychain" {
|
||||
_ = kc.Remove(keychain.LarkCliService, input.Ref.ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateSecretKeyMatch_KeychainMatches(t *testing.T) {
|
||||
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_abc123"}}
|
||||
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSecretKeyMatch_KeychainMismatch(t *testing.T) {
|
||||
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_old_app"}}
|
||||
err := ValidateSecretKeyMatch("cli_new_app", secret)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for mismatched appId and keychain key")
|
||||
}
|
||||
// Verify the error message contains useful context
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"cli_old_app", "cli_new_app", "appsecret:cli_new_app", "config init"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("error message missing %q: %s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSecretKeyMatch_PlainSecret_Skipped(t *testing.T) {
|
||||
secret := PlainSecret("some-secret")
|
||||
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
|
||||
t.Errorf("plain secret should be skipped, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSecretKeyMatch_FileRef_Skipped(t *testing.T) {
|
||||
secret := SecretInput{Ref: &SecretRef{Source: "file", ID: "/tmp/secret.txt"}}
|
||||
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
|
||||
t.Errorf("file ref should be skipped, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSecretKeyMatch_ZeroValue_Skipped(t *testing.T) {
|
||||
if err := ValidateSecretKeyMatch("cli_abc123", SecretInput{}); err != nil {
|
||||
t.Errorf("zero SecretInput should be skipped, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSecretKeyMatch_EmptyAppId_Mismatch(t *testing.T) {
|
||||
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_abc123"}}
|
||||
err := ValidateSecretKeyMatch("", secret)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when appId is empty but keychain key references a real app")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
// StrictMode represents the identity restriction policy.
|
||||
type StrictMode string
|
||||
|
||||
const (
|
||||
StrictModeOff StrictMode = "off"
|
||||
StrictModeBot StrictMode = "bot"
|
||||
StrictModeUser StrictMode = "user"
|
||||
)
|
||||
|
||||
// IsActive returns true if strict mode restricts identity.
|
||||
func (m StrictMode) IsActive() bool {
|
||||
return m == StrictModeBot || m == StrictModeUser
|
||||
}
|
||||
|
||||
// AllowsIdentity reports whether the given identity is permitted under this mode.
|
||||
func (m StrictMode) AllowsIdentity(id Identity) bool {
|
||||
switch m {
|
||||
case StrictModeBot:
|
||||
return id.IsBot()
|
||||
case StrictModeUser:
|
||||
return id == AsUser
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ForcedIdentity returns the identity forced by this mode, or "" if not active.
|
||||
func (m StrictMode) ForcedIdentity() Identity {
|
||||
switch m {
|
||||
case StrictModeBot:
|
||||
return AsBot
|
||||
case StrictModeUser:
|
||||
return AsUser
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStrictMode_IsActive(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode StrictMode
|
||||
active bool
|
||||
}{
|
||||
{StrictModeOff, false},
|
||||
{"", false},
|
||||
{StrictModeBot, true},
|
||||
{StrictModeUser, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.mode.IsActive(); got != tt.active {
|
||||
t.Errorf("StrictMode(%q).IsActive() = %v, want %v", tt.mode, got, tt.active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_AllowsIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode StrictMode
|
||||
id Identity
|
||||
ok bool
|
||||
}{
|
||||
{StrictModeOff, AsUser, true},
|
||||
{StrictModeOff, AsBot, true},
|
||||
{StrictModeBot, AsBot, true},
|
||||
{StrictModeBot, AsUser, false},
|
||||
{StrictModeUser, AsUser, true},
|
||||
{StrictModeUser, AsBot, false},
|
||||
{"", AsUser, true},
|
||||
{"", AsBot, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.mode.AllowsIdentity(tt.id); got != tt.ok {
|
||||
t.Errorf("StrictMode(%q).AllowsIdentity(%q) = %v, want %v", tt.mode, tt.id, got, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_ForcedIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode StrictMode
|
||||
want Identity
|
||||
}{
|
||||
{StrictModeOff, ""},
|
||||
{StrictModeBot, AsBot},
|
||||
{StrictModeUser, AsUser},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.mode.ForcedIdentity(); got != tt.want {
|
||||
t.Errorf("StrictMode(%q).ForcedIdentity() = %q, want %q", tt.mode, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
// ParseBrand and ResolveEndpoints map unrecognized values to BrandFeishu.
|
||||
type LarkBrand string
|
||||
|
||||
const (
|
||||
BrandFeishu LarkBrand = "feishu"
|
||||
BrandLark LarkBrand = "lark"
|
||||
)
|
||||
|
||||
// ParseBrand normalizes a brand string (case-insensitive, whitespace-tolerant);
|
||||
// anything other than "lark" normalizes to BrandFeishu.
|
||||
func ParseBrand(value string) LarkBrand {
|
||||
if strings.ToLower(strings.TrimSpace(value)) == "lark" {
|
||||
return BrandLark
|
||||
}
|
||||
return BrandFeishu
|
||||
}
|
||||
|
||||
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
|
||||
// domain. It serves every grant type (client_credentials for TAT,
|
||||
// authorization_code / device_code / refresh_token for UAT) and replaces the
|
||||
// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal).
|
||||
const OAuthTokenV3Path = "/oauth/v3/token"
|
||||
|
||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||
type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
Accounts string // e.g. "https://accounts.feishu.cn"
|
||||
MCP string // e.g. "https://mcp.feishu.cn"
|
||||
AppLink string // e.g. "https://applink.feishu.cn"
|
||||
}
|
||||
|
||||
// ResolveEndpoints resolves endpoint URLs for the brand, normalizing its
|
||||
// input so stored values with unusual casing still resolve correctly.
|
||||
func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
switch ParseBrand(string(brand)) {
|
||||
case BrandLark:
|
||||
return Endpoints{
|
||||
Open: "https://open.larksuite.com",
|
||||
Accounts: "https://accounts.larksuite.com",
|
||||
MCP: "https://mcp.larksuite.com",
|
||||
AppLink: "https://applink.larksuite.com",
|
||||
}
|
||||
default:
|
||||
return Endpoints{
|
||||
Open: "https://open.feishu.cn",
|
||||
Accounts: "https://accounts.feishu.cn",
|
||||
MCP: "https://mcp.feishu.cn",
|
||||
AppLink: "https://applink.feishu.cn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveOpenBaseURL returns the Open API base URL for the given brand.
|
||||
func ResolveOpenBaseURL(brand LarkBrand) string {
|
||||
return ResolveEndpoints(brand).Open
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveEndpoints(BrandFeishu)
|
||||
if ep.Open != "https://open.feishu.cn" {
|
||||
t.Errorf("Open = %q, want feishu.cn", ep.Open)
|
||||
}
|
||||
if ep.Accounts != "https://accounts.feishu.cn" {
|
||||
t.Errorf("Accounts = %q, want feishu.cn", ep.Accounts)
|
||||
}
|
||||
if ep.MCP != "https://mcp.feishu.cn" {
|
||||
t.Errorf("MCP = %q, want feishu.cn", ep.MCP)
|
||||
}
|
||||
if ep.AppLink != "https://applink.feishu.cn" {
|
||||
t.Errorf("AppLink = %q, want feishu.cn", ep.AppLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoints_Lark(t *testing.T) {
|
||||
ep := ResolveEndpoints(BrandLark)
|
||||
if ep.Open != "https://open.larksuite.com" {
|
||||
t.Errorf("Open = %q, want larksuite.com", ep.Open)
|
||||
}
|
||||
if ep.Accounts != "https://accounts.larksuite.com" {
|
||||
t.Errorf("Accounts = %q, want larksuite.com", ep.Accounts)
|
||||
}
|
||||
if ep.MCP != "https://mcp.larksuite.com" {
|
||||
t.Errorf("MCP = %q, want larksuite.com", ep.MCP)
|
||||
}
|
||||
if ep.AppLink != "https://applink.larksuite.com" {
|
||||
t.Errorf("AppLink = %q, want larksuite.com", ep.AppLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
|
||||
ep := ResolveEndpoints("")
|
||||
if ep.Open != "https://open.feishu.cn" {
|
||||
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
|
||||
}
|
||||
// The unified OAuth v3 Token Endpoint mints TAT on the accounts domain;
|
||||
// pin the default-brand host so a stray non-production domain revert is caught.
|
||||
if ep.Accounts != "https://accounts.feishu.cn" {
|
||||
t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenBaseURL(t *testing.T) {
|
||||
if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" {
|
||||
t.Errorf("ResolveOpenBaseURL(feishu) = %q", got)
|
||||
}
|
||||
if got := ResolveOpenBaseURL(BrandLark); got != "https://open.larksuite.com" {
|
||||
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want LarkBrand
|
||||
}{
|
||||
{"", BrandFeishu},
|
||||
{"feishu", BrandFeishu},
|
||||
{"lark", BrandLark},
|
||||
{"LARK", BrandLark},
|
||||
{" lark ", BrandLark},
|
||||
{"Lark", BrandLark},
|
||||
{"xyz", BrandFeishu},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ParseBrand(c.in); got != c.want {
|
||||
t.Errorf("ParseBrand(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveEndpoints_NormalizesBrand locks the boundary invariant: the
|
||||
// resolver normalizes its brand input, so historical config values with
|
||||
// unusual casing or whitespace still resolve to their intended endpoints.
|
||||
func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
|
||||
for _, raw := range []string{"LARK", " lark ", "Lark"} {
|
||||
if got := ResolveEndpoints(LarkBrand(raw)).Open; got != "https://open.larksuite.com" {
|
||||
t.Errorf("ResolveEndpoints(%q).Open = %q, want the lark endpoint", raw, got)
|
||||
}
|
||||
}
|
||||
if got := ResolveEndpoints(LarkBrand("unexpected")).Open; got != "https://open.feishu.cn" {
|
||||
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// Workspace identifies a config isolation context.
|
||||
// Each non-local workspace maps to a subdirectory under the base config dir.
|
||||
type Workspace string
|
||||
|
||||
const (
|
||||
// WorkspaceLocal is the default workspace. GetConfigDir returns the base
|
||||
// config dir without any subdirectory — identical to pre-workspace behavior.
|
||||
WorkspaceLocal Workspace = ""
|
||||
|
||||
// WorkspaceOpenClaw activates when any OpenClaw-specific env signal is
|
||||
// present (see DetectWorkspaceFromEnv for the full list).
|
||||
WorkspaceOpenClaw Workspace = "openclaw"
|
||||
|
||||
// WorkspaceHermes activates when any Hermes-specific env signal is
|
||||
// present (see DetectWorkspaceFromEnv for the full list).
|
||||
WorkspaceHermes Workspace = "hermes"
|
||||
|
||||
// WorkspaceLarkChannel activates when LARK_CHANNEL == "1" is set by
|
||||
// lark-channel-bridge in subprocesses it spawns (e.g. claude). See
|
||||
// DetectWorkspaceFromEnv for the detection rule.
|
||||
WorkspaceLarkChannel Workspace = "lark-channel"
|
||||
)
|
||||
|
||||
// currentWorkspace holds the workspace for the current process invocation.
|
||||
// Set once during Factory initialization; config bind's RunE may re-set it
|
||||
// to the workspace being bound. Uses atomic.Value for goroutine safety
|
||||
// (background registry refresh reads GetRuntimeDir concurrently with the
|
||||
// Factory init that writes workspace).
|
||||
var currentWorkspace atomic.Value // stores Workspace; zero value → Load returns nil → treated as Local
|
||||
|
||||
// SetCurrentWorkspace sets the active workspace for this process.
|
||||
func SetCurrentWorkspace(ws Workspace) {
|
||||
currentWorkspace.Store(ws)
|
||||
}
|
||||
|
||||
// CurrentWorkspace returns the active workspace.
|
||||
// Returns WorkspaceLocal if not yet set (safe default, backward-compatible).
|
||||
func CurrentWorkspace() Workspace {
|
||||
v := currentWorkspace.Load()
|
||||
if v == nil {
|
||||
return WorkspaceLocal
|
||||
}
|
||||
return v.(Workspace)
|
||||
}
|
||||
|
||||
// Display returns the user-visible workspace label.
|
||||
// Used in config show, doctor, and error messages.
|
||||
func (w Workspace) Display() string {
|
||||
if w == WorkspaceLocal || w == "" {
|
||||
return "local"
|
||||
}
|
||||
return string(w)
|
||||
}
|
||||
|
||||
// IsLocal returns true if this is the default local workspace.
|
||||
func (w Workspace) IsLocal() bool {
|
||||
return w == WorkspaceLocal || w == ""
|
||||
}
|
||||
|
||||
// DetectWorkspaceFromEnv determines the workspace from process environment.
|
||||
//
|
||||
// Detection is signal-based, not credential-based: we look for environment
|
||||
// variables that the host Agent itself sets when launching a subprocess.
|
||||
// Generic FEISHU_APP_ID / FEISHU_APP_SECRET are intentionally NOT used —
|
||||
// any third-party Feishu script can set those, so they would cause
|
||||
// false-positive routing into a Hermes workspace.
|
||||
//
|
||||
// Priority:
|
||||
// 1. Any OpenClaw signal → WorkspaceOpenClaw
|
||||
// - OPENCLAW_CLI == "1": subprocess marker (added 2026-03-09 via
|
||||
// OpenClaw PR #41411). Most precise, but absent on older builds.
|
||||
// - OPENCLAW_HOME / OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH non-empty:
|
||||
// user-facing paths introduced with the 2026-01-30 rename. Detected
|
||||
// so that OpenClaw builds predating the subprocess marker — or
|
||||
// invocation paths that do not propagate the marker — still route
|
||||
// correctly.
|
||||
// 2. Any Hermes signal → WorkspaceHermes. All of the checked variables are
|
||||
// set by Hermes itself (hermes_cli/main.py, gateway/run.py). No
|
||||
// unrelated tool uses the HERMES_* namespace.
|
||||
// - HERMES_HOME: exported by the CLI at startup
|
||||
// - HERMES_QUIET == "1": exported by the gateway
|
||||
// - HERMES_EXEC_ASK == "1": exported by the gateway (paired w/ QUIET)
|
||||
// - HERMES_GATEWAY_TOKEN: injected into every gateway subprocess
|
||||
// - HERMES_SESSION_KEY: session identifier scoped to the current chat
|
||||
// 3. LARK_CHANNEL == "1" → WorkspaceLarkChannel. Set by lark-channel-bridge
|
||||
// when spawning subprocesses (e.g. claude). Single boolean marker —
|
||||
// mirrors the OPENCLAW_CLI / HERMES_QUIET style.
|
||||
// 4. Otherwise → WorkspaceLocal
|
||||
func DetectWorkspaceFromEnv(getenv func(string) string) Workspace {
|
||||
if getenv("OPENCLAW_CLI") == "1" ||
|
||||
getenv("OPENCLAW_HOME") != "" ||
|
||||
getenv("OPENCLAW_STATE_DIR") != "" ||
|
||||
getenv("OPENCLAW_CONFIG_PATH") != "" ||
|
||||
getenv("OPENCLAW_SERVICE_MARKER") != "" ||
|
||||
getenv("OPENCLAW_SERVICE_VERSION") != "" ||
|
||||
getenv("OPENCLAW_GATEWAY_PORT") != "" ||
|
||||
getenv("OPENCLAW_SHELL") != "" {
|
||||
return WorkspaceOpenClaw
|
||||
}
|
||||
if getenv("HERMES_HOME") != "" ||
|
||||
getenv("HERMES_QUIET") == "1" ||
|
||||
getenv("HERMES_EXEC_ASK") == "1" ||
|
||||
getenv("HERMES_GATEWAY_TOKEN") != "" ||
|
||||
getenv("HERMES_SESSION_KEY") != "" {
|
||||
return WorkspaceHermes
|
||||
}
|
||||
if getenv("LARK_CHANNEL") == "1" {
|
||||
return WorkspaceLarkChannel
|
||||
}
|
||||
return WorkspaceLocal
|
||||
}
|
||||
|
||||
// GetBaseConfigDir returns the root config directory, ignoring workspace.
|
||||
// Priority: LARKSUITE_CLI_CONFIG_DIR env → ~/.lark-cli.
|
||||
// If the home directory cannot be determined and no override is set, a
|
||||
// warning is written to stderr and the path falls back to a relative
|
||||
// ".lark-cli" — callers will then see an explicit I/O error at first use
|
||||
// instead of a silent misconfiguration.
|
||||
func GetBaseConfigDir() string {
|
||||
if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
// Fall back to a relative ".lark-cli" so the first I/O operation
|
||||
// surfaces a clear "no such file or directory" error. We cannot
|
||||
// emit a stderr warning here — this package has no IOStreams in
|
||||
// scope, and direct writes to os.Stderr violate the IOStreams
|
||||
// injection boundary (enforced by lint). Users who hit this path
|
||||
// should set LARKSUITE_CLI_CONFIG_DIR explicitly.
|
||||
home = ""
|
||||
}
|
||||
return filepath.Join(home, ".lark-cli")
|
||||
}
|
||||
|
||||
// GetRuntimeDir returns the workspace-aware config directory.
|
||||
// - WorkspaceLocal → GetBaseConfigDir() (unchanged, backward-compatible)
|
||||
// - WorkspaceOpenClaw → GetBaseConfigDir()/openclaw
|
||||
// - WorkspaceHermes → GetBaseConfigDir()/hermes
|
||||
// - WorkspaceLarkChannel → GetBaseConfigDir()/lark-channel
|
||||
func GetRuntimeDir() string {
|
||||
base := GetBaseConfigDir()
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return base
|
||||
}
|
||||
return filepath.Join(base, string(ws))
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectWorkspaceFromEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
expect Workspace
|
||||
}{
|
||||
{
|
||||
name: "no agent env → local",
|
||||
env: map[string]string{},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_CLI=1 → openclaw",
|
||||
env: map[string]string{"OPENCLAW_CLI": "1"},
|
||||
expect: WorkspaceOpenClaw,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_CLI=true → local (strict ==1 check)",
|
||||
env: map[string]string{"OPENCLAW_CLI": "true"},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_CLI=yes → local",
|
||||
env: map[string]string{"OPENCLAW_CLI": "yes"},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_CLI=0 → local",
|
||||
env: map[string]string{"OPENCLAW_CLI": "0"},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_CLI empty → local",
|
||||
env: map[string]string{"OPENCLAW_CLI": ""},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_CLI=1 with trailing space → local (strict)",
|
||||
env: map[string]string{"OPENCLAW_CLI": "1 "},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "generic FEISHU_APP_ID + SECRET → local (not a Hermes signal)",
|
||||
env: map[string]string{"FEISHU_APP_ID": "cli_abc", "FEISHU_APP_SECRET": "xxx"},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "HERMES_HOME set → hermes",
|
||||
env: map[string]string{"HERMES_HOME": "/Users/me/.hermes"},
|
||||
expect: WorkspaceHermes,
|
||||
},
|
||||
{
|
||||
name: "HERMES_QUIET=1 → hermes (set by gateway)",
|
||||
env: map[string]string{"HERMES_QUIET": "1"},
|
||||
expect: WorkspaceHermes,
|
||||
},
|
||||
{
|
||||
name: "HERMES_EXEC_ASK=1 → hermes",
|
||||
env: map[string]string{"HERMES_EXEC_ASK": "1"},
|
||||
expect: WorkspaceHermes,
|
||||
},
|
||||
{
|
||||
name: "HERMES_GATEWAY_TOKEN set → hermes",
|
||||
env: map[string]string{"HERMES_GATEWAY_TOKEN": "69ce6b...6065"},
|
||||
expect: WorkspaceHermes,
|
||||
},
|
||||
{
|
||||
name: "HERMES_SESSION_KEY set → hermes",
|
||||
env: map[string]string{"HERMES_SESSION_KEY": "agent:main:feishu:dm:oc_xxx"},
|
||||
expect: WorkspaceHermes,
|
||||
},
|
||||
{
|
||||
name: "HERMES_QUIET=0 alone → local (strict ==1 check)",
|
||||
env: map[string]string{"HERMES_QUIET": "0"},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_CLI=1 + HERMES_HOME both set → openclaw wins (priority)",
|
||||
env: map[string]string{"OPENCLAW_CLI": "1", "HERMES_HOME": "/Users/me/.hermes"},
|
||||
expect: WorkspaceOpenClaw,
|
||||
},
|
||||
{
|
||||
name: "FEISHU_APP_ID + HERMES_HOME → hermes (HERMES_ signals suffice)",
|
||||
env: map[string]string{"FEISHU_APP_ID": "cli_abc", "FEISHU_APP_SECRET": "xxx", "HERMES_HOME": "/Users/me/.hermes"},
|
||||
expect: WorkspaceHermes,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_HOME set → openclaw (older OpenClaw builds without subprocess marker)",
|
||||
env: map[string]string{"OPENCLAW_HOME": "/Users/me/.openclaw"},
|
||||
expect: WorkspaceOpenClaw,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_STATE_DIR set → openclaw",
|
||||
env: map[string]string{"OPENCLAW_STATE_DIR": "/srv/openclaw/state"},
|
||||
expect: WorkspaceOpenClaw,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_CONFIG_PATH set → openclaw",
|
||||
env: map[string]string{"OPENCLAW_CONFIG_PATH": "/etc/openclaw/openclaw.json"},
|
||||
expect: WorkspaceOpenClaw,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_HOME + FEISHU both set → openclaw wins (priority)",
|
||||
env: map[string]string{"OPENCLAW_HOME": "/Users/me/.openclaw", "FEISHU_APP_ID": "cli_abc", "FEISHU_APP_SECRET": "xxx"},
|
||||
expect: WorkspaceOpenClaw,
|
||||
},
|
||||
{
|
||||
name: "LARKSUITE_CLI_APP_ID does not affect workspace",
|
||||
env: map[string]string{"LARKSUITE_CLI_APP_ID": "cli_local", "LARKSUITE_CLI_APP_SECRET": "local_secret"},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "LARK_CHANNEL=1 → lark-channel",
|
||||
env: map[string]string{"LARK_CHANNEL": "1"},
|
||||
expect: WorkspaceLarkChannel,
|
||||
},
|
||||
{
|
||||
name: "LARK_CHANNEL=true → local (strict ==1 check)",
|
||||
env: map[string]string{"LARK_CHANNEL": "true"},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "LARK_CHANNEL=0 → local",
|
||||
env: map[string]string{"LARK_CHANNEL": "0"},
|
||||
expect: WorkspaceLocal,
|
||||
},
|
||||
{
|
||||
name: "OPENCLAW_CLI=1 + LARK_CHANNEL=1 → openclaw wins (priority)",
|
||||
env: map[string]string{"OPENCLAW_CLI": "1", "LARK_CHANNEL": "1"},
|
||||
expect: WorkspaceOpenClaw,
|
||||
},
|
||||
{
|
||||
name: "HERMES_HOME + LARK_CHANNEL=1 → hermes wins (priority over lark-channel)",
|
||||
env: map[string]string{"HERMES_HOME": "/Users/me/.hermes", "LARK_CHANNEL": "1"},
|
||||
expect: WorkspaceHermes,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
getenv := func(key string) string { return tt.env[key] }
|
||||
got := DetectWorkspaceFromEnv(getenv)
|
||||
if got != tt.expect {
|
||||
t.Errorf("DetectWorkspaceFromEnv() = %q, want %q", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay(t *testing.T) {
|
||||
tests := []struct {
|
||||
ws Workspace
|
||||
expect string
|
||||
}{
|
||||
{WorkspaceLocal, "local"},
|
||||
{Workspace(""), "local"},
|
||||
{WorkspaceOpenClaw, "openclaw"},
|
||||
{WorkspaceHermes, "hermes"},
|
||||
{WorkspaceLarkChannel, "lark-channel"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.ws.Display(); got != tt.expect {
|
||||
t.Errorf("Workspace(%q).Display() = %q, want %q", tt.ws, got, tt.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceIsLocal(t *testing.T) {
|
||||
if !WorkspaceLocal.IsLocal() {
|
||||
t.Error("WorkspaceLocal.IsLocal() should be true")
|
||||
}
|
||||
if !Workspace("").IsLocal() {
|
||||
t.Error(`Workspace("").IsLocal() should be true`)
|
||||
}
|
||||
if WorkspaceOpenClaw.IsLocal() {
|
||||
t.Error("WorkspaceOpenClaw.IsLocal() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCurrentWorkspace(t *testing.T) {
|
||||
orig := CurrentWorkspace()
|
||||
defer SetCurrentWorkspace(orig)
|
||||
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
if got := CurrentWorkspace(); got != WorkspaceOpenClaw {
|
||||
t.Errorf("CurrentWorkspace() = %q, want %q", got, WorkspaceOpenClaw)
|
||||
}
|
||||
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
if got := CurrentWorkspace(); got != WorkspaceLocal {
|
||||
t.Errorf("CurrentWorkspace() = %q, want %q", got, WorkspaceLocal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRuntimeDir(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
|
||||
orig := CurrentWorkspace()
|
||||
defer SetCurrentWorkspace(orig)
|
||||
|
||||
// Local → base dir (same as pre-workspace behavior)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
if got := GetRuntimeDir(); got != tmp {
|
||||
t.Errorf("local: GetRuntimeDir() = %q, want %q", got, tmp)
|
||||
}
|
||||
if got := GetConfigDir(); got != tmp {
|
||||
t.Errorf("local: GetConfigDir() = %q, want %q", got, tmp)
|
||||
}
|
||||
|
||||
// OpenClaw → base/openclaw
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
want := filepath.Join(tmp, "openclaw")
|
||||
if got := GetRuntimeDir(); got != want {
|
||||
t.Errorf("openclaw: GetRuntimeDir() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Hermes → base/hermes
|
||||
SetCurrentWorkspace(WorkspaceHermes)
|
||||
want = filepath.Join(tmp, "hermes")
|
||||
if got := GetRuntimeDir(); got != want {
|
||||
t.Errorf("hermes: GetRuntimeDir() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// LarkChannel → base/lark-channel
|
||||
SetCurrentWorkspace(WorkspaceLarkChannel)
|
||||
want = filepath.Join(tmp, "lark-channel")
|
||||
if got := GetRuntimeDir(); got != want {
|
||||
t.Errorf("lark-channel: GetRuntimeDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigPath(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
|
||||
orig := CurrentWorkspace()
|
||||
defer SetCurrentWorkspace(orig)
|
||||
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
want := filepath.Join(tmp, "config.json")
|
||||
if got := GetConfigPath(); got != want {
|
||||
t.Errorf("local: GetConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
want = filepath.Join(tmp, "openclaw", "config.json")
|
||||
if got := GetConfigPath(); got != want {
|
||||
t.Errorf("openclaw: GetConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user