mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
Compare commits
1 Commits
feat/apps-
...
feat/runti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
417b0d1820 |
@@ -6,14 +6,21 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BootstrapInvocationContext extracts global invocation options before
|
// BootstrapInvocationContext extracts global invocation options before
|
||||||
// the real command tree is built, so provider-backed config resolution sees
|
// the real command tree is built, so provider-backed config resolution sees
|
||||||
// the correct profile from the start.
|
// the correct profile from the start.
|
||||||
|
//
|
||||||
|
// Profile resolution: --profile flag > CliRuntimeAppID env > "" (defers to
|
||||||
|
// MultiAppConfig.CurrentApp). The env value flows through FindApp which
|
||||||
|
// matches by Name first, then by AppId — so callers can pass either form.
|
||||||
func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) {
|
func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) {
|
||||||
var globals GlobalOptions
|
var globals GlobalOptions
|
||||||
|
|
||||||
@@ -26,5 +33,9 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
|
|||||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||||
return cmdutil.InvocationContext{}, err
|
return cmdutil.InvocationContext{}, err
|
||||||
}
|
}
|
||||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
profile := globals.Profile
|
||||||
|
if profile == "" {
|
||||||
|
profile = strings.TrimSpace(os.Getenv(envvars.CliRuntimeAppID))
|
||||||
|
}
|
||||||
|
return cmdutil.InvocationContext{Profile: profile}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
|
)
|
||||||
|
|
||||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||||
@@ -70,3 +74,25 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
|||||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBootstrapInvocationContext_RuntimeAppIDEnv(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliRuntimeAppID, " env-app ")
|
||||||
|
inv, err := BootstrapInvocationContext([]string{"auth", "status"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||||
|
}
|
||||||
|
if inv.Profile != "env-app" {
|
||||||
|
t.Fatalf("profile = %q, want %q (env value, trimmed)", inv.Profile, "env-app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapInvocationContext_ProfileFlagBeatsRuntimeAppIDEnv(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliRuntimeAppID, "env-app")
|
||||||
|
inv, err := BootstrapInvocationContext([]string{"--profile", "flag-app", "auth", "status"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BootstrapInvocationContext() error = %v", err)
|
||||||
|
}
|
||||||
|
if inv.Profile != "flag-app" {
|
||||||
|
t.Fatalf("profile = %q, want %q (--profile wins over env)", inv.Profile, "flag-app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
@@ -55,6 +56,11 @@ type BindOptions struct {
|
|||||||
// configBindRun; downstream branches read this instead of rechecking
|
// configBindRun; downstream branches read this instead of rechecking
|
||||||
// IOStreams.IsTerminal. Do not set from outside — it is overwritten.
|
// IOStreams.IsTerminal. Do not set from outside — it is overwritten.
|
||||||
IsTUI bool
|
IsTUI bool
|
||||||
|
|
||||||
|
// All binds every account exposed by the agent source as a multi-app
|
||||||
|
// config. Hidden flag, currently openclaw source only, mutually
|
||||||
|
// exclusive with --app-id.
|
||||||
|
All bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdConfigBind creates the config bind subcommand.
|
// NewCmdConfigBind creates the config bind subcommand.
|
||||||
@@ -107,6 +113,8 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
|||||||
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
||||||
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||||
|
cmd.Flags().BoolVar(&opts.All, "all", false, "bind every account from the agent source as a multi-app config (currently openclaw source only)")
|
||||||
|
_ = cmd.Flags().MarkHidden("all")
|
||||||
cmdutil.SetRisk(cmd, "write")
|
cmdutil.SetRisk(cmd, "write")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -124,6 +132,10 @@ func configBindRun(opts *BindOptions) error {
|
|||||||
// opts.IsTUI instead of re-checking IOStreams.IsTerminal.
|
// opts.IsTUI instead of re-checking IOStreams.IsTerminal.
|
||||||
opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal
|
opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal
|
||||||
|
|
||||||
|
if opts.All {
|
||||||
|
return configBindAllRun(opts)
|
||||||
|
}
|
||||||
|
|
||||||
source, err := finalizeSource(opts)
|
source, err := finalizeSource(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -632,6 +644,15 @@ func validateBindFlags(opts *BindOptions) error {
|
|||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if opts.All {
|
||||||
|
if opts.AppID != "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--all is mutually exclusive with --app-id").WithParam("--all")
|
||||||
|
}
|
||||||
|
src := strings.TrimSpace(strings.ToLower(opts.Source))
|
||||||
|
if src != "" && src != "openclaw" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--all only supports --source openclaw").WithParam("--all")
|
||||||
|
}
|
||||||
|
}
|
||||||
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -677,3 +698,173 @@ func tuiSelectIdentity(opts *BindOptions) (string, error) {
|
|||||||
}
|
}
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configBindAllRun binds every account exposed by the agent source as a
|
||||||
|
// multi-app config. Flag-mode only; bot-only identity by default;
|
||||||
|
// silently overwrites prior bindings.
|
||||||
|
func configBindAllRun(opts *BindOptions) error {
|
||||||
|
// --all is flag-mode only: resolve the source through the same flag/env
|
||||||
|
// reconciliation as a single bind (including the explicit-vs-detected
|
||||||
|
// mismatch guard), but never prompt.
|
||||||
|
opts.IsTUI = false
|
||||||
|
source, err := finalizeSource(opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if source != "openclaw" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"--all only supports --source openclaw").WithParam("--all")
|
||||||
|
}
|
||||||
|
core.SetCurrentWorkspace(core.Workspace(source))
|
||||||
|
targetConfigPath := core.GetConfigPath()
|
||||||
|
previousConfigBytes, _ := vfs.ReadFile(targetConfigPath)
|
||||||
|
|
||||||
|
binder, err := newBinder(source, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
candidates, err := binder.ListCandidates()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return errs.NewConfigError(errs.SubtypeNotConfigured,
|
||||||
|
"no Feishu accounts found in openclaw.json").
|
||||||
|
WithHint("configure channels.feishu.accounts first")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Identity == "" {
|
||||||
|
opts.Identity = "bot-only"
|
||||||
|
}
|
||||||
|
if err := warnIdentityEscalation(opts, previousConfigBytes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
noticeUserDefaultRisk(opts)
|
||||||
|
prior := priorLang(previousConfigBytes)
|
||||||
|
|
||||||
|
oc, ok := binder.(*openclawBinder)
|
||||||
|
if !ok {
|
||||||
|
return errs.NewInternalError(errs.SubtypeSDKError, "internal: --all requires openclawBinder, got %T", binder)
|
||||||
|
}
|
||||||
|
apps, err := oc.BuildAll()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range apps {
|
||||||
|
applyPreferences(&apps[i], opts, prior)
|
||||||
|
}
|
||||||
|
sortAppsDefaultFirst(apps)
|
||||||
|
apps = dedupAndOrderApps(apps)
|
||||||
|
|
||||||
|
return commitMultiAppBinding(opts, apps, previousConfigBytes, source, targetConfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortAppsDefaultFirst orders the bound apps deterministically: the "default"
|
||||||
|
// alias first, then named accounts alphabetically. Account enumeration
|
||||||
|
// upstream follows Go map order, and CurrentApp is taken from the first
|
||||||
|
// entry — without a stable order the selected default app would vary
|
||||||
|
// between runs of the same bind.
|
||||||
|
func sortAppsDefaultFirst(apps []core.AppConfig) {
|
||||||
|
sort.SliceStable(apps, func(i, j int) bool {
|
||||||
|
di, dj := strings.EqualFold(apps[i].Name, "default"), strings.EqualFold(apps[j].Name, "default")
|
||||||
|
if di != dj {
|
||||||
|
return di
|
||||||
|
}
|
||||||
|
return apps[i].Name < apps[j].Name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupAndOrderApps collapses entries that share an AppId. Per OpenClaw
|
||||||
|
// semantics, "default" is an implicit fallback alias that inherits the
|
||||||
|
// top-level appId; when an explicit named account exposes the same appId,
|
||||||
|
// "default" is redundant and discarded. Insertion order is preserved.
|
||||||
|
func dedupAndOrderApps(apps []core.AppConfig) []core.AppConfig {
|
||||||
|
byID := make(map[string]int, len(apps))
|
||||||
|
out := make([]core.AppConfig, 0, len(apps))
|
||||||
|
for _, a := range apps {
|
||||||
|
if idx, seen := byID[a.AppId]; seen {
|
||||||
|
if strings.EqualFold(out[idx].Name, "default") && !strings.EqualFold(a.Name, "default") {
|
||||||
|
out[idx] = a
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byID[a.AppId] = len(out)
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitMultiAppBinding writes a MultiAppConfig containing every bound
|
||||||
|
// account. CurrentApp defaults to the first entry so existing single-app
|
||||||
|
// callers keep their behaviour; callers select another profile with
|
||||||
|
// --profile or LARKSUITE_CLI_RUNTIME_APP_ID.
|
||||||
|
func commitMultiAppBinding(opts *BindOptions, apps []core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: apps[0].ProfileName(),
|
||||||
|
Apps: apps,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||||
|
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(multi, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
|
||||||
|
}
|
||||||
|
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
||||||
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replaced := previousConfigBytes != nil
|
||||||
|
if replaced {
|
||||||
|
cleanupKeychainFromDataMulti(opts.Factory.Keychain, previousConfigBytes, apps)
|
||||||
|
}
|
||||||
|
|
||||||
|
appIDs := make([]string, len(apps))
|
||||||
|
for i := range apps {
|
||||||
|
appIDs[i] = apps[i].AppId
|
||||||
|
}
|
||||||
|
|
||||||
|
uiMsg := getBindMsg(opts.UILang)
|
||||||
|
display := sourceDisplayName(source)
|
||||||
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
|
||||||
|
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+
|
||||||
|
fmt.Sprintf("bound %d Feishu apps: %s", len(appIDs), strings.Join(appIDs, ", ")))
|
||||||
|
|
||||||
|
envelope := map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"workspace": source,
|
||||||
|
"app_ids": appIDs,
|
||||||
|
"config_path": configPath,
|
||||||
|
"replaced": replaced,
|
||||||
|
"identity": opts.Identity,
|
||||||
|
"all": true,
|
||||||
|
}
|
||||||
|
resultJSON, _ := json.Marshal(envelope)
|
||||||
|
fmt.Fprintln(opts.Factory.IOStreams.Out, string(resultJSON))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupKeychainFromDataMulti is cleanupKeychainFromData with a keep set,
|
||||||
|
// used when re-binding multiple apps in one pass.
|
||||||
|
func cleanupKeychainFromDataMulti(kc keychain.KeychainAccess, data []byte, keep []core.AppConfig) {
|
||||||
|
var multi core.MultiAppConfig
|
||||||
|
if err := json.Unmarshal(data, &multi); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keepIDs := make(map[string]struct{}, len(keep))
|
||||||
|
for i := range keep {
|
||||||
|
ref := keep[i].AppSecret.Ref
|
||||||
|
if ref != nil && ref.Source == "keychain" {
|
||||||
|
keepIDs[ref.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, app := range multi.Apps {
|
||||||
|
if app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" {
|
||||||
|
if _, ok := keepIDs[app.AppSecret.Ref.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
core.RemoveSecretStore(app.AppSecret, kc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1907,3 +1907,270 @@ func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
|
|||||||
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
|
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── --all flag tests (hidden multi-account bind) ──
|
||||||
|
|
||||||
|
func TestConfigBindCmd_AllFlagParsing(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
|
var gotOpts *BindOptions
|
||||||
|
cmd := NewCmdConfigBind(f, func(opts *BindOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--source", "openclaw", "--all"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !gotOpts.All {
|
||||||
|
t.Error("expected All=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --all is hidden but still parseable.
|
||||||
|
flag := cmd.Flags().Lookup("all")
|
||||||
|
if flag == nil {
|
||||||
|
t.Fatal("--all flag must be registered")
|
||||||
|
}
|
||||||
|
if !flag.Hidden {
|
||||||
|
t.Error("--all must be hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAllValidationParam asserts err is a typed validation error of
|
||||||
|
// subtype invalid_argument carrying the named param, locking the contract
|
||||||
|
// beyond the prose message.
|
||||||
|
func requireAllValidationParam(t *testing.T, err error, wantParam string) {
|
||||||
|
t.Helper()
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
|
||||||
|
}
|
||||||
|
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("expected validation/invalid_argument, got %s/%s", ve.Category, ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Param != wantParam {
|
||||||
|
t.Fatalf("param = %q, want %q", ve.Param, wantParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigBindRun_AllRejectsAppID(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := configBindRun(&BindOptions{
|
||||||
|
Factory: f,
|
||||||
|
Source: "openclaw",
|
||||||
|
AppID: "cli_x",
|
||||||
|
All: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "--all is mutually exclusive with --app-id") {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
requireAllValidationParam(t, err, "--all")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupAndOrderApps_NamedWinsOverDefault(t *testing.T) {
|
||||||
|
// OpenClaw semantics: "default" is an implicit fallback alias; when a
|
||||||
|
// named account exposes the same appId, default is dropped.
|
||||||
|
apps := []core.AppConfig{
|
||||||
|
{Name: "acct-a", AppId: "cli_main"},
|
||||||
|
{Name: "acct-b", AppId: "cli_product"},
|
||||||
|
{Name: "default", AppId: "cli_main"},
|
||||||
|
}
|
||||||
|
got := dedupAndOrderApps(apps)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len=%d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Name != "acct-a" || got[0].AppId != "cli_main" {
|
||||||
|
t.Errorf("apps[0]=%+v, want acct-a cli_main", got[0])
|
||||||
|
}
|
||||||
|
if got[1].Name != "acct-b" {
|
||||||
|
t.Errorf("apps[1]=%+v, want acct-b", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupAndOrderApps_DefaultFirstThenNamedOverwrites(t *testing.T) {
|
||||||
|
// default arrives before its named alias — named still wins.
|
||||||
|
apps := []core.AppConfig{
|
||||||
|
{Name: "default", AppId: "cli_main"},
|
||||||
|
{Name: "acct-a", AppId: "cli_main"},
|
||||||
|
}
|
||||||
|
got := dedupAndOrderApps(apps)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("len=%d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Name != "acct-a" {
|
||||||
|
t.Errorf("apps[0]=%+v, want acct-a", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupAndOrderApps_NoDuplicatesPreservesOrder(t *testing.T) {
|
||||||
|
apps := []core.AppConfig{
|
||||||
|
{Name: "acct-a", AppId: "cli_main"},
|
||||||
|
{Name: "acct-b", AppId: "cli_product"},
|
||||||
|
{Name: "acct-c", AppId: "cli_extra"},
|
||||||
|
}
|
||||||
|
got := dedupAndOrderApps(apps)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len=%d, want 3", len(got))
|
||||||
|
}
|
||||||
|
want := []string{"acct-a", "acct-b", "acct-c"}
|
||||||
|
for i := range got {
|
||||||
|
if got[i].Name != want[i] {
|
||||||
|
t.Errorf("apps[%d]=%q, want %q", i, got[i].Name, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupAndOrderApps_DefaultAlreadyFirst(t *testing.T) {
|
||||||
|
apps := []core.AppConfig{
|
||||||
|
{Name: "default", AppId: "cli_main"},
|
||||||
|
{Name: "acct-b", AppId: "cli_product"},
|
||||||
|
}
|
||||||
|
got := dedupAndOrderApps(apps)
|
||||||
|
if len(got) != 2 || got[0].Name != "default" {
|
||||||
|
t.Fatalf("got %+v, want default first", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigBindRun_AllRejectsNonOpenClawSource(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := configBindRun(&BindOptions{
|
||||||
|
Factory: f,
|
||||||
|
Source: "hermes",
|
||||||
|
All: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "--all only supports --source openclaw") {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
requireAllValidationParam(t, err, "--all")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigBindRun_AllRejectsSourceEnvMismatch(t *testing.T) {
|
||||||
|
saveWorkspace(t)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
clearAgentEnv(t)
|
||||||
|
t.Setenv("HERMES_HOME", t.TempDir()) // env says hermes, flag says openclaw
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", All: true})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected source/env mismatch error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "does not match detected Agent environment") {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
requireAllValidationParam(t, err, "--source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigBindRun_AllBindsEveryAccount(t *testing.T) {
|
||||||
|
saveWorkspace(t)
|
||||||
|
configDir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||||
|
clearAgentEnv(t)
|
||||||
|
|
||||||
|
openclawHome := t.TempDir()
|
||||||
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
||||||
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
||||||
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
// Top-level credentials surface as a "default" candidate aliasing the
|
||||||
|
// "work" account; dedup must collapse them and keep the named entry.
|
||||||
|
cfg := `{"channels":{"feishu":{"appId":"cli_main","appSecret":"sec_main","domain":"feishu","accounts":{"work":{"appId":"cli_main","appSecret":"sec_main"},"personal":{"appId":"cli_personal","appSecret":"sec_personal"}}}}}`
|
||||||
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(cfg), 0600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := configBindRun(&BindOptions{Factory: f, All: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(configDir, "openclaw", "config.json")
|
||||||
|
assertEnvelope(t, stdout.Bytes(), map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"workspace": "openclaw",
|
||||||
|
"app_ids": []any{"cli_main", "cli_personal"},
|
||||||
|
"config_path": configPath,
|
||||||
|
"replaced": false,
|
||||||
|
"identity": "bot-only",
|
||||||
|
"all": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read written config: %v", err)
|
||||||
|
}
|
||||||
|
var multi core.MultiAppConfig
|
||||||
|
if err := json.Unmarshal(data, &multi); err != nil {
|
||||||
|
t.Fatalf("written config is not a MultiAppConfig: %v", err)
|
||||||
|
}
|
||||||
|
if len(multi.Apps) != 2 {
|
||||||
|
t.Fatalf("apps = %d, want 2", len(multi.Apps))
|
||||||
|
}
|
||||||
|
// The account aliased by top-level "default" stays first and becomes
|
||||||
|
// the initial CurrentApp.
|
||||||
|
if multi.CurrentApp != "work" {
|
||||||
|
t.Errorf("currentApp = %q, want %q", multi.CurrentApp, "work")
|
||||||
|
}
|
||||||
|
if multi.Apps[0].Name != "work" || multi.Apps[1].Name != "personal" {
|
||||||
|
t.Errorf("app order = [%q, %q], want [work, personal]", multi.Apps[0].Name, multi.Apps[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigBindRun_AllEscalationRequiresForce(t *testing.T) {
|
||||||
|
saveWorkspace(t)
|
||||||
|
configDir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||||
|
clearAgentEnv(t)
|
||||||
|
|
||||||
|
openclawHome := t.TempDir()
|
||||||
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
||||||
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
||||||
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
cfg := `{"channels":{"feishu":{"appId":"cli_main","appSecret":"sec_main","domain":"feishu"}}}`
|
||||||
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(cfg), 0600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-existing binding locked to bot-only.
|
||||||
|
ocDir := filepath.Join(configDir, "openclaw")
|
||||||
|
if err := os.MkdirAll(ocDir, 0700); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
locked := `{"apps":[{"appId":"cli_old","strictMode":"bot"}]}`
|
||||||
|
if err := os.WriteFile(filepath.Join(ocDir, "config.json"), []byte(locked), 0600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
err := configBindRun(&BindOptions{Factory: f, All: true, Identity: "user-default"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected confirmation-required error without --force")
|
||||||
|
}
|
||||||
|
var ce *errs.ConfirmationRequiredError
|
||||||
|
if !errors.As(err, &ce) {
|
||||||
|
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T (%v)", err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same escalation with --force proceeds.
|
||||||
|
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := configBindRun(&BindOptions{Factory: f2, All: true, Identity: "user-default", Force: true}); err != nil {
|
||||||
|
t.Fatalf("expected --force to allow escalation, got %v", err)
|
||||||
|
}
|
||||||
|
envelope := map[string]any{}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||||
|
t.Fatalf("invalid JSON output: %v", err)
|
||||||
|
}
|
||||||
|
if envelope["replaced"] != true || envelope["identity"] != "user-default" {
|
||||||
|
t.Errorf("envelope = %v, want replaced=true identity=user-default", envelope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,40 +172,54 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
|||||||
if b.cfg == nil {
|
if b.cfg == nil {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected *binding.CandidateApp
|
|
||||||
for i := range b.rawApps {
|
for i := range b.rawApps {
|
||||||
if b.rawApps[i].AppID == appID {
|
if b.rawApps[i].AppID == appID {
|
||||||
selected = &b.rawApps[i]
|
return b.buildFromCandidate(&b.rawApps[i])
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if selected == nil {
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
|
||||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if selected.AppSecret.IsZero() {
|
// BuildAll builds every candidate without appID lookup so duplicate-appID
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
|
// entries (e.g. OpenClaw's "default" alias of a named account) keep their own
|
||||||
|
// Label. --all callers must use this; Build(appID) collapses duplicates.
|
||||||
|
func (b *openclawBinder) BuildAll() ([]core.AppConfig, error) {
|
||||||
|
if b.cfg == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: BuildAll called before ListCandidates")
|
||||||
|
}
|
||||||
|
out := make([]core.AppConfig, 0, len(b.rawApps))
|
||||||
|
for i := range b.rawApps {
|
||||||
|
cfg, err := b.buildFromCandidate(&b.rawApps[i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *cfg)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *openclawBinder) buildFromCandidate(c *binding.CandidateApp) (*core.AppConfig, error) {
|
||||||
|
if c.AppSecret.IsZero() {
|
||||||
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", c.AppID, b.path).
|
||||||
WithHint("configure channels.feishu.appSecret in openclaw.json")
|
WithHint("configure channels.feishu.appSecret in openclaw.json")
|
||||||
}
|
}
|
||||||
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
secret, err := binding.ResolveSecretInput(c.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", c.AppID, err).
|
||||||
WithHint("check appSecret configuration in %s", b.path).
|
WithHint("check appSecret configuration in %s", b.path).
|
||||||
WithCause(err)
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
stored, err := core.ForStorage(c.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||||
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||||
WithHint("use file: reference in config to bypass keychain").
|
WithHint("use file: reference in config to bypass keychain").
|
||||||
WithCause(err)
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &core.AppConfig{
|
return &core.AppConfig{
|
||||||
AppId: selected.AppID,
|
Name: c.Label,
|
||||||
|
AppId: c.AppID,
|
||||||
AppSecret: stored,
|
AppSecret: stored,
|
||||||
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
|
Brand: core.LarkBrand(normalizeBrand(c.Brand)),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
|||||||
return nil, core.NotConfiguredError()
|
return nil, core.NotConfiguredError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// p.profile already incorporates --profile / CliRuntimeAppID precedence
|
||||||
|
// (resolved in cmd/bootstrap.go before Factory construction).
|
||||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
package envvars
|
package envvars
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CliAppID = "LARKSUITE_CLI_APP_ID"
|
CliAppID = "LARKSUITE_CLI_APP_ID"
|
||||||
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
|
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
|
||||||
|
// CliRuntimeAppID pins a profile by appId when --profile is unset.
|
||||||
|
// Selector only; credentials still come from config.json / keychain.
|
||||||
|
CliRuntimeAppID = "LARKSUITE_CLI_RUNTIME_APP_ID"
|
||||||
CliBrand = "LARKSUITE_CLI_BRAND"
|
CliBrand = "LARKSUITE_CLI_BRAND"
|
||||||
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
||||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||||
|
|||||||
Reference in New Issue
Block a user