mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Give each AI Agent (OpenClaw, Hermes) its own lark-cli workspace so
its Feishu calls don't overwrite the developer's local config or
collide with other Agents.
lark-cli config bind [--source openclaw|hermes] [--app-id <id>]
[--identity bot-only|user-default] [--force]
Key capabilities:
- Source auto-detected from OPENCLAW_* / HERMES_* env signals; config
written to ~/.lark-cli/<agent>/, isolated per Agent.
- Two identity presets: 'bot-only' (flag-mode default) and
'user-default'. Flag mode rejects silent bot→user escalation
without --force; TUI prompts are exempt.
- Agent-friendly stdout JSON with 'identity' + 'message' for
next-step branching.
- 'config show' and 'doctor' expose the bound 'workspace'.
- OpenClaw SecretRef resolution: plain / ${VAR} / file:+JSON Pointer
/ exec:.
1401 lines
48 KiB
Go
1401 lines
48 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// assertExitError checks the full structured error in one assertion.
|
|
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
|
}
|
|
if exitErr.Code != wantCode {
|
|
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
|
|
}
|
|
if exitErr.Detail == nil {
|
|
t.Fatal("expected non-nil error detail")
|
|
}
|
|
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
|
|
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
|
|
}
|
|
}
|
|
|
|
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
|
// present, no extras, values equal via reflect.DeepEqual. Future-proofs the
|
|
// JSON wire contract: new fields added by future work force test updates.
|
|
func assertEnvelope(t *testing.T, stdout []byte, want map[string]any) {
|
|
t.Helper()
|
|
var got map[string]any
|
|
if err := json.Unmarshal(stdout, &got); err != nil {
|
|
t.Fatalf("invalid JSON envelope: %v\nstdout: %s", err, stdout)
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("envelope mismatch:\n got: %#v\n want: %#v", got, want)
|
|
}
|
|
}
|
|
|
|
// saveWorkspace saves the current workspace and returns a cleanup func to restore it.
|
|
// Must be called at the start of any test that may trigger configBindRun (which sets workspace).
|
|
func saveWorkspace(t *testing.T) {
|
|
t.Helper()
|
|
orig := core.CurrentWorkspace()
|
|
t.Cleanup(func() { core.SetCurrentWorkspace(orig) })
|
|
}
|
|
|
|
// ── Command flag parsing tests (aligned with config_test.go pattern) ──
|
|
|
|
func TestConfigBindCmd_FlagParsing(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", "--app-id", "cli_test", "--identity", "bot-only", "--lang", "en"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.Source != "openclaw" {
|
|
t.Errorf("Source = %q, want %q", gotOpts.Source, "openclaw")
|
|
}
|
|
if gotOpts.AppID != "cli_test" {
|
|
t.Errorf("AppID = %q, want %q", gotOpts.AppID, "cli_test")
|
|
}
|
|
if gotOpts.Identity != "bot-only" {
|
|
t.Errorf("Identity = %q, want %q", gotOpts.Identity, "bot-only")
|
|
}
|
|
if gotOpts.Lang != "en" {
|
|
t.Errorf("Lang = %q, want %q", gotOpts.Lang, "en")
|
|
}
|
|
if !gotOpts.langExplicit {
|
|
t.Error("expected langExplicit=true when --lang is passed")
|
|
}
|
|
}
|
|
|
|
func TestConfigBindCmd_LangDefault(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", "hermes"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.Lang != "zh" {
|
|
t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
|
|
}
|
|
if gotOpts.langExplicit {
|
|
t.Error("expected langExplicit=false when --lang not passed")
|
|
}
|
|
}
|
|
|
|
// ── Run function tests (aligned with TestConfigShowRun pattern) ──
|
|
|
|
func TestConfigBindRun_InvalidSource(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "validation",
|
|
Message: `invalid --source "invalid"; valid values: openclaw, hermes`,
|
|
})
|
|
}
|
|
|
|
func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
// Ensure no Agent env signals leak in from the host shell and silently
|
|
// trigger auto-detection; this test exercises the "no signals at all"
|
|
// path, where flag mode must error out with an actionable hint.
|
|
clearAgentEnv(t)
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
// TestFactory has IsTerminal=false by default
|
|
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "bind",
|
|
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
|
|
Hint: "pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat",
|
|
})
|
|
}
|
|
|
|
// clearAgentEnv removes all env vars that DetectWorkspaceFromEnv checks, so
|
|
// tests exercising the "no signals" path are not affected by whatever the
|
|
// host shell happens to have exported. t.Setenv restores them after the
|
|
// test returns.
|
|
func clearAgentEnv(t *testing.T) {
|
|
t.Helper()
|
|
for _, k := range []string{
|
|
"OPENCLAW_CLI", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH",
|
|
"HERMES_HOME", "HERMES_QUIET", "HERMES_EXEC_ASK", "HERMES_GATEWAY_TOKEN", "HERMES_SESSION_KEY",
|
|
} {
|
|
t.Setenv(k, "")
|
|
}
|
|
}
|
|
|
|
// --source openclaw specified while the env clearly identifies Hermes is
|
|
// almost always a user mistake (wrong Agent context); we fail loud.
|
|
func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
clearAgentEnv(t)
|
|
t.Setenv("HERMES_HOME", t.TempDir()) // Hermes env signal
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "bind",
|
|
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
|
|
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
|
})
|
|
}
|
|
|
|
// Reverse direction: --source hermes while OpenClaw env is active.
|
|
func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
clearAgentEnv(t)
|
|
t.Setenv("OPENCLAW_HOME", t.TempDir()) // OpenClaw env signal
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "bind",
|
|
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
|
|
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
|
})
|
|
}
|
|
|
|
// With --source omitted and Hermes env present, auto-detect picks hermes.
|
|
// We only assert the source routing worked (config.json was written to the
|
|
// hermes workspace path); the bind command's own happy path is covered by
|
|
// other tests.
|
|
func TestConfigBindRun_AutoDetect_HermesFromEnv(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
clearAgentEnv(t)
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
// Note: Source is empty — auto-detection should pick hermes.
|
|
err := configBindRun(&BindOptions{Factory: f})
|
|
if err != nil {
|
|
t.Fatalf("expected success, got error: %v", err)
|
|
}
|
|
|
|
envelope := map[string]any{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("invalid JSON output: %v", err)
|
|
}
|
|
if envelope["workspace"] != "hermes" {
|
|
t.Errorf("workspace = %v, want %q (auto-detection should pick hermes from HERMES_HOME)", envelope["workspace"], "hermes")
|
|
}
|
|
}
|
|
|
|
// With --source omitted and OpenClaw env present, auto-detect picks openclaw.
|
|
func TestConfigBindRun_AutoDetect_OpenClawFromEnv(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)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
|
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
openclawCfg := `{"channels":{"feishu":{"appId":"cli_auto_oc","appSecret":"auto_oc_secret","domain":"feishu"}}}`
|
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(openclawCfg), 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
// Note: Source is empty — auto-detection should pick openclaw.
|
|
err := configBindRun(&BindOptions{Factory: f})
|
|
if err != nil {
|
|
t.Fatalf("expected success, got error: %v", err)
|
|
}
|
|
|
|
envelope := map[string]any{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("invalid JSON output: %v", err)
|
|
}
|
|
if envelope["workspace"] != "openclaw" {
|
|
t.Errorf("workspace = %v, want %q (auto-detection should pick openclaw from OPENCLAW_HOME)", envelope["workspace"], "openclaw")
|
|
}
|
|
}
|
|
|
|
func TestConfigBindRun_FlagModeOverwrite(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
// Pre-create hermes workspace config to simulate an existing binding.
|
|
hermesDir := filepath.Join(configDir, "hermes")
|
|
if err := os.MkdirAll(hermesDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(hermesDir, "config.json"), []byte(`{"apps":[{"appId":"old_app"}]}`), 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
|
if err != nil {
|
|
t.Fatalf("expected flag-mode overwrite to succeed, got error: %v", err)
|
|
}
|
|
|
|
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
|
assertEnvelope(t, stdout.Bytes(), map[string]any{
|
|
"ok": true,
|
|
"workspace": "hermes",
|
|
"app_id": "cli_new_app",
|
|
"config_path": filepath.Join(configDir, "hermes", "config.json"),
|
|
"replaced": true,
|
|
"identity": "bot-only",
|
|
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_new_app", "Hermes", brandDisplay("feishu", "")),
|
|
})
|
|
// stderr carries only the bind-success header + one-time-sync notice;
|
|
// the "replaced existing binding" suffix is intentionally dropped now
|
|
// that `replaced:true` in the stdout envelope carries the same signal.
|
|
if want := fmt.Sprintf(msg.BindSuccessHeader, "Hermes"); !strings.Contains(stderr.String(), want) {
|
|
t.Errorf("stderr missing bind-success header %q; got:\n%s", want, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
hermesHome := filepath.Join(t.TempDir(), "nonexistent")
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
|
envPath := filepath.Join(hermesHome, ".env")
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "hermes",
|
|
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
|
Hint: "verify Hermes is installed and configured at " + envPath,
|
|
})
|
|
}
|
|
|
|
func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
openclawHome := filepath.Join(t.TempDir(), "nonexistent")
|
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
|
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "openclaw",
|
|
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
|
Hint: "verify OpenClaw is installed and configured",
|
|
})
|
|
}
|
|
|
|
func TestConfigShowRun_WorkspaceField(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
|
|
|
multi := &core.MultiAppConfig{
|
|
Apps: []core.AppConfig{{
|
|
AppId: "cli_local_test",
|
|
AppSecret: core.PlainSecret("secret"),
|
|
Brand: core.BrandFeishu,
|
|
}},
|
|
}
|
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configShowRun(&ConfigShowOptions{Factory: f})
|
|
if err != nil {
|
|
t.Fatalf("configShowRun error: %v", err)
|
|
}
|
|
// If we get here without error, show succeeded.
|
|
// Workspace field in JSON output is verified by e2e tests (real binary output).
|
|
}
|
|
|
|
func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configShowRun(&ConfigShowOptions{Factory: f})
|
|
if err == nil {
|
|
t.Fatal("expected error for unbound workspace")
|
|
}
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("error type = %T, want *output.ExitError", err)
|
|
}
|
|
// Should suggest config bind, not config init
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "openclaw",
|
|
Message: "openclaw context detected but lark-cli not bound to openclaw workspace",
|
|
Hint: "run: lark-cli config bind --source openclaw",
|
|
})
|
|
}
|
|
|
|
// ── Helper function tests (dotenv, brand, path resolution) ──
|
|
|
|
func TestReadDotenv(t *testing.T) {
|
|
dir := t.TempDir()
|
|
envPath := filepath.Join(dir, ".env")
|
|
|
|
content := "# Hermes config\nFEISHU_APP_ID=cli_abc123\nFEISHU_APP_SECRET=supersecret\nFEISHU_DOMAIN=lark\n\nFEISHU_CONNECTION_MODE=websocket\n"
|
|
if err := os.WriteFile(envPath, []byte(content), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
got, err := readDotenv(envPath)
|
|
if err != nil {
|
|
t.Fatalf("readDotenv() error: %v", err)
|
|
}
|
|
|
|
checks := map[string]string{
|
|
"FEISHU_APP_ID": "cli_abc123",
|
|
"FEISHU_APP_SECRET": "supersecret",
|
|
"FEISHU_DOMAIN": "lark",
|
|
"FEISHU_CONNECTION_MODE": "websocket",
|
|
}
|
|
for key, want := range checks {
|
|
if got[key] != want {
|
|
t.Errorf("key %q = %q, want %q", key, got[key], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadDotenv_FileNotFound(t *testing.T) {
|
|
_, err := readDotenv("/nonexistent/path/.env")
|
|
if err == nil {
|
|
t.Error("expected error for missing file")
|
|
}
|
|
}
|
|
|
|
func TestReadDotenv_ValueWithEquals(t *testing.T) {
|
|
dir := t.TempDir()
|
|
envPath := filepath.Join(dir, ".env")
|
|
content := `DATABASE_URL=postgres://user:pass@host:5432/db?sslmode=require`
|
|
if err := os.WriteFile(envPath, []byte(content), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
got, err := readDotenv(envPath)
|
|
if err != nil {
|
|
t.Fatalf("readDotenv() error: %v", err)
|
|
}
|
|
want := "postgres://user:pass@host:5432/db?sslmode=require"
|
|
if got["DATABASE_URL"] != want {
|
|
t.Errorf("DATABASE_URL = %q, want %q", got["DATABASE_URL"], want)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeBrand(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"", "feishu"},
|
|
{"feishu", "feishu"},
|
|
{"lark", "lark"},
|
|
{"LARK", "lark"},
|
|
{" lark ", "lark"},
|
|
{"Lark", "lark"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := normalizeBrand(tt.input); got != tt.want {
|
|
t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveOpenClawConfigPath_Overrides(t *testing.T) {
|
|
t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) {
|
|
custom := filepath.Join(t.TempDir(), "custom.json")
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", custom)
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
t.Setenv("OPENCLAW_HOME", "")
|
|
if got := resolveOpenClawConfigPath(); got != custom {
|
|
t.Errorf("got %q, want %q", got, custom)
|
|
}
|
|
})
|
|
|
|
t.Run("OPENCLAW_STATE_DIR", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", dir)
|
|
t.Setenv("OPENCLAW_HOME", "")
|
|
want := filepath.Join(dir, "openclaw.json")
|
|
if got := resolveOpenClawConfigPath(); got != want {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("OPENCLAW_HOME", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
t.Setenv("OPENCLAW_HOME", dir)
|
|
want := filepath.Join(dir, ".openclaw", "openclaw.json")
|
|
if got := resolveOpenClawConfigPath(); got != want {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestResolveHermesEnvPath_Override(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
t.Setenv("HERMES_HOME", tmp)
|
|
want := filepath.Join(tmp, ".env")
|
|
if got := resolveHermesEnvPath(); got != want {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// ── Success path tests (Hermes bind flow) ──
|
|
|
|
func TestConfigBindRun_HermesSuccess(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(envContent), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Lang: "en"})
|
|
if err != nil {
|
|
t.Fatalf("expected success, got error: %v", err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
|
t.Fatalf("invalid JSON output: %v", err)
|
|
}
|
|
if result["ok"] != true {
|
|
t.Errorf("ok = %v, want true", result["ok"])
|
|
}
|
|
if result["workspace"] != "hermes" {
|
|
t.Errorf("workspace = %v, want %q", result["workspace"], "hermes")
|
|
}
|
|
if result["app_id"] != "cli_hermes_abc" {
|
|
t.Errorf("app_id = %v, want %q", result["app_id"], "cli_hermes_abc")
|
|
}
|
|
|
|
targetPath := filepath.Join(configDir, "hermes", "config.json")
|
|
data, err := os.ReadFile(targetPath)
|
|
if err != nil {
|
|
t.Fatalf("read config.json: %v", err)
|
|
}
|
|
var multi core.MultiAppConfig
|
|
if err := json.Unmarshal(data, &multi); err != nil {
|
|
t.Fatalf("unmarshal config.json: %v", err)
|
|
}
|
|
if len(multi.Apps) != 1 {
|
|
t.Fatalf("apps count = %d, want 1", len(multi.Apps))
|
|
}
|
|
if multi.Apps[0].AppId != "cli_hermes_abc" {
|
|
t.Errorf("appId = %q, want %q", multi.Apps[0].AppId, "cli_hermes_abc")
|
|
}
|
|
if multi.Apps[0].Brand != core.BrandLark {
|
|
t.Errorf("brand = %q, want %q", multi.Apps[0].Brand, core.BrandLark)
|
|
}
|
|
}
|
|
|
|
func TestConfigBindRun_OpenClawSuccess_SingleAccount(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
openclawHome := t.TempDir()
|
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
|
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
openclawCfg := `{"channels":{"feishu":{"appId":"cli_oc_123","appSecret":"oc_secret_456","domain":"feishu"}}}`
|
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(openclawCfg), 0600); err != nil {
|
|
t.Fatalf("write openclaw.json: %v", err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Lang: "zh"})
|
|
if err != nil {
|
|
t.Fatalf("expected success, got error: %v", err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
|
t.Fatalf("invalid JSON output: %v", err)
|
|
}
|
|
if result["ok"] != true {
|
|
t.Errorf("ok = %v, want true", result["ok"])
|
|
}
|
|
if result["workspace"] != "openclaw" {
|
|
t.Errorf("workspace = %v, want %q", result["workspace"], "openclaw")
|
|
}
|
|
if result["app_id"] != "cli_oc_123" {
|
|
t.Errorf("app_id = %v, want %q", result["app_id"], "cli_oc_123")
|
|
}
|
|
}
|
|
|
|
func TestConfigBindRun_OpenClawMultiAccount_WithAppID(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
openclawHome := t.TempDir()
|
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
|
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
openclawCfg := `{
|
|
"channels":{"feishu":{
|
|
"accounts":{
|
|
"work":{"appId":"cli_work_111","appSecret":"secret_work","domain":"feishu"},
|
|
"personal":{"appId":"cli_personal_222","appSecret":"secret_personal","domain":"lark"}
|
|
}
|
|
}}
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(openclawCfg), 0600); err != nil {
|
|
t.Fatalf("write openclaw.json: %v", err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "cli_personal_222"})
|
|
if err != nil {
|
|
t.Fatalf("expected success, got error: %v", err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
|
t.Fatalf("invalid JSON output: %v", err)
|
|
}
|
|
if result["app_id"] != "cli_personal_222" {
|
|
t.Errorf("app_id = %v, want %q", result["app_id"], "cli_personal_222")
|
|
}
|
|
}
|
|
|
|
func TestConfigBindRun_OpenClawMultiAccount_MissingAppID(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
openclawHome := t.TempDir()
|
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
|
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
openclawCfg := `{
|
|
"channels":{"feishu":{
|
|
"accounts":{
|
|
"work":{"appId":"cli_work_111","appSecret":"secret_work"},
|
|
"personal":{"appId":"cli_personal_222","appSecret":"secret_personal"}
|
|
}
|
|
}}
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(openclawCfg), 0600); err != nil {
|
|
t.Fatalf("write openclaw.json: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
|
if err == nil {
|
|
t.Fatal("expected error for multi-account without --app-id, got nil")
|
|
}
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("error type = %T, want *output.ExitError", err)
|
|
}
|
|
if exitErr.Code != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
|
}
|
|
}
|
|
|
|
// TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode asserts the end-to-end
|
|
// contract: passing --source on a real terminal is flag-mode. With multiple
|
|
// candidates and no --app-id, the command must error with the candidate list
|
|
// instead of opening an interactive prompt just because stdin is a TTY.
|
|
func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
openclawHome := t.TempDir()
|
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
|
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
openclawCfg := `{
|
|
"channels":{"feishu":{
|
|
"accounts":{
|
|
"work":{"appId":"cli_work_111","appSecret":"secret_work"},
|
|
"personal":{"appId":"cli_personal_222","appSecret":"secret_personal"}
|
|
}
|
|
}}
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(openclawCfg), 0600); err != nil {
|
|
t.Fatalf("write openclaw.json: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
// Simulate a real terminal. Because --source is explicit, opts.IsTUI is
|
|
// still false, so selectCandidate must refuse the multi-candidate case
|
|
// with a validation error rather than opening the huh prompt.
|
|
f.IOStreams.IsTerminal = true
|
|
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
|
|
|
// The hint's candidate list comes from openclaw.ListCandidateApps, which
|
|
// iterates a map — ordering is non-deterministic. DeepEqual inline against
|
|
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
|
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
|
base := output.ErrDetail{
|
|
Type: "openclaw",
|
|
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
|
}
|
|
wantWorkFirst := base
|
|
wantWorkFirst.Hint = "available app IDs:\n cli_work_111 (work)\n cli_personal_222 (personal)"
|
|
wantPersonalFirst := base
|
|
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
|
|
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("error type = %T, want *output.ExitError; err = %v", err, err)
|
|
}
|
|
if exitErr.Code != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
|
}
|
|
if exitErr.Detail == nil {
|
|
t.Fatal("expected non-nil error detail")
|
|
}
|
|
if !reflect.DeepEqual(*exitErr.Detail, wantWorkFirst) &&
|
|
!reflect.DeepEqual(*exitErr.Detail, wantPersonalFirst) {
|
|
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
|
|
*exitErr.Detail, wantWorkFirst, wantPersonalFirst)
|
|
}
|
|
}
|
|
|
|
func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
openclawHome := t.TempDir()
|
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
|
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
openclawCfg := `{"channels":{"feishu":{"appId":"cli_only_one","appSecret":"secret_only"}}}`
|
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(openclawCfg), 0600); err != nil {
|
|
t.Fatalf("write openclaw.json: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "openclaw",
|
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
|
Hint: "available app IDs:\n cli_only_one",
|
|
})
|
|
}
|
|
|
|
func TestConfigBindRun_InvalidIdentity(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "validation",
|
|
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
|
|
})
|
|
}
|
|
|
|
// TestConfigBindRun_Identity_BotOnly_Applied verifies the bot-only preset:
|
|
// full envelope contract on stdout, plus the disk-side StrictMode/DefaultAs
|
|
// expansion that the preset is responsible for.
|
|
func TestConfigBindRun_Identity_BotOnly_Applied(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{
|
|
Factory: f,
|
|
Source: "hermes",
|
|
Identity: "bot-only",
|
|
Lang: "en",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected success, got error: %v", err)
|
|
}
|
|
|
|
msg := getBindMsg("en")
|
|
assertEnvelope(t, stdout.Bytes(), map[string]any{
|
|
"ok": true,
|
|
"workspace": "hermes",
|
|
"app_id": "cli_abc",
|
|
"config_path": filepath.Join(configDir, "hermes", "config.json"),
|
|
"replaced": false,
|
|
"identity": "bot-only",
|
|
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "en")),
|
|
})
|
|
assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"),
|
|
core.StrictModeBot, core.AsBot)
|
|
}
|
|
|
|
// TestConfigBindRun_FlagModeDefaultsToBotOnly verifies the flag-mode default
|
|
// (no --identity → bot-only) both on-wire and on-disk. Flag mode defaults to
|
|
// the safer preset — bot acts under its own identity, no impersonation risk.
|
|
// Covers the bot-only preset expansion end-to-end.
|
|
func TestConfigBindRun_FlagModeDefaultsToBotOnly(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
|
if err != nil {
|
|
t.Fatalf("expected success, got error: %v", err)
|
|
}
|
|
|
|
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
|
assertEnvelope(t, stdout.Bytes(), map[string]any{
|
|
"ok": true,
|
|
"workspace": "hermes",
|
|
"app_id": "cli_abc",
|
|
"config_path": filepath.Join(configDir, "hermes", "config.json"),
|
|
"replaced": false,
|
|
"identity": "bot-only",
|
|
"message": fmt.Sprintf(msg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", "")),
|
|
})
|
|
assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"),
|
|
core.StrictModeBot, core.AsBot)
|
|
}
|
|
|
|
// TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce verifies the
|
|
// risk-warning gate: when a workspace is already bound to bot-only and a
|
|
// flag-mode caller tries to rebind with --identity user-default, the CLI
|
|
// refuses and returns structured guidance telling the Agent to surface the
|
|
// risk to the user and re-run with --force after getting confirmation.
|
|
func TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
hermesDir := filepath.Join(configDir, "hermes")
|
|
if err := os.MkdirAll(hermesDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
existing := []byte(`{"apps":[{"appId":"cli_old","strictMode":"bot","defaultAs":"bot"}]}`)
|
|
if err := os.WriteFile(filepath.Join(hermesDir, "config.json"), existing, 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"),
|
|
[]byte("FEISHU_APP_ID=cli_new\nFEISHU_APP_SECRET=new\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{
|
|
Factory: f,
|
|
Source: "hermes",
|
|
Identity: "user-default",
|
|
})
|
|
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "bind",
|
|
Message: msg.IdentityEscalationMessage,
|
|
Hint: msg.IdentityEscalationHint,
|
|
})
|
|
|
|
// Config on disk must remain untouched — the gate runs before
|
|
// commitBinding writes anything.
|
|
after, readErr := os.ReadFile(filepath.Join(hermesDir, "config.json"))
|
|
if readErr != nil {
|
|
t.Fatalf("read post-reject config: %v", readErr)
|
|
}
|
|
if string(after) != string(existing) {
|
|
t.Errorf("config was modified despite rejection; got:\n%s", after)
|
|
}
|
|
}
|
|
|
|
// TestConfigBindRun_IdentityEscalationWithForceAllowed verifies the --force
|
|
// override: the same bot-only → user-default transition that the previous
|
|
// test rejects succeeds when the caller explicitly opts in.
|
|
func TestConfigBindRun_IdentityEscalationWithForceAllowed(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
hermesDir := filepath.Join(configDir, "hermes")
|
|
if err := os.MkdirAll(hermesDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(hermesDir, "config.json"),
|
|
[]byte(`{"apps":[{"appId":"cli_old","strictMode":"bot","defaultAs":"bot"}]}`), 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"),
|
|
[]byte("FEISHU_APP_ID=cli_new\nFEISHU_APP_SECRET=new\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{
|
|
Factory: f,
|
|
Source: "hermes",
|
|
Identity: "user-default",
|
|
Force: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected --force to allow the escalation, got: %v", err)
|
|
}
|
|
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
|
|
core.StrictModeOff, core.AsUser)
|
|
}
|
|
|
|
// TestConfigBindRun_AllowsRebindSameBotOnly verifies re-binding the same
|
|
// bot-only identity is NOT blocked — only bot→user escalation is gated.
|
|
func TestConfigBindRun_AllowsRebindSameBotOnly(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
hermesDir := filepath.Join(configDir, "hermes")
|
|
if err := os.MkdirAll(hermesDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(hermesDir, "config.json"),
|
|
[]byte(`{"apps":[{"appId":"cli_old","strictMode":"bot","defaultAs":"bot"}]}`), 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"),
|
|
[]byte("FEISHU_APP_ID=cli_new\nFEISHU_APP_SECRET=new\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{
|
|
Factory: f,
|
|
Source: "hermes",
|
|
Identity: "bot-only",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected rebind to same bot-only identity to succeed, got: %v", err)
|
|
}
|
|
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
|
|
core.StrictModeBot, core.AsBot)
|
|
}
|
|
|
|
// TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig verifies that if the
|
|
// existing binding is already user-default, another user-default bind passes
|
|
// through (no lock to fire, only bot→user is escalation).
|
|
func TestConfigBindRun_AllowsUserDefaultOnUserDefaultConfig(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
hermesDir := filepath.Join(configDir, "hermes")
|
|
if err := os.MkdirAll(hermesDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(hermesDir, "config.json"),
|
|
[]byte(`{"apps":[{"appId":"cli_old","strictMode":"off","defaultAs":"user"}]}`), 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"),
|
|
[]byte("FEISHU_APP_ID=cli_new\nFEISHU_APP_SECRET=new\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{
|
|
Factory: f,
|
|
Source: "hermes",
|
|
Identity: "user-default",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected user-default→user-default rebind to succeed, got: %v", err)
|
|
}
|
|
assertPresetApplied(t, filepath.Join(hermesDir, "config.json"),
|
|
core.StrictModeOff, core.AsUser)
|
|
}
|
|
|
|
// assertPresetApplied verifies the on-disk config.json applied the identity
|
|
// preset's StrictMode + DefaultAs expansion.
|
|
func assertPresetApplied(t *testing.T, configPath string, wantStrict core.StrictMode, wantDefault core.Identity) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", configPath, err)
|
|
}
|
|
var multi core.MultiAppConfig
|
|
if err := json.Unmarshal(data, &multi); err != nil {
|
|
t.Fatalf("unmarshal %s: %v", configPath, err)
|
|
}
|
|
if len(multi.Apps) == 0 {
|
|
t.Fatalf("no apps in %s", configPath)
|
|
}
|
|
app := multi.Apps[0]
|
|
if app.StrictMode == nil || *app.StrictMode != wantStrict {
|
|
t.Errorf("StrictMode = %v, want %q", app.StrictMode, wantStrict)
|
|
}
|
|
if app.DefaultAs != wantDefault {
|
|
t.Errorf("DefaultAs = %q, want %q", app.DefaultAs, wantDefault)
|
|
}
|
|
}
|
|
|
|
func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_SECRET=secret_only\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
|
envPath := filepath.Join(hermesHome, ".env")
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "hermes",
|
|
Message: "FEISHU_APP_ID not found in " + envPath,
|
|
Hint: "run 'hermes setup' to configure Feishu credentials",
|
|
})
|
|
}
|
|
|
|
func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
hermesHome := t.TempDir()
|
|
t.Setenv("HERMES_HOME", hermesHome)
|
|
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\n"), 0600); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
|
envPath := filepath.Join(hermesHome, ".env")
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "hermes",
|
|
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
|
Hint: "run 'hermes setup' to configure Feishu credentials",
|
|
})
|
|
}
|
|
|
|
func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
openclawHome := t.TempDir()
|
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
|
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(`{"channels":{}}`), 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "openclaw",
|
|
Message: "openclaw.json missing channels.feishu section",
|
|
Hint: "configure Feishu in OpenClaw first",
|
|
})
|
|
}
|
|
|
|
func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
openclawHome := t.TempDir()
|
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
|
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
openclawCfg := `{"channels":{"feishu":{"appId":"cli_no_secret","appSecret":""}}}`
|
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(openclawCfg), 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "openclaw",
|
|
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
|
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
|
})
|
|
}
|
|
|
|
func TestConfigBindRun_OpenClawEnvTemplate(t *testing.T) {
|
|
saveWorkspace(t)
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
openclawHome := t.TempDir()
|
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
t.Setenv("MY_OC_SECRET", "resolved_env_secret")
|
|
|
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
openclawCfg := `{"channels":{"feishu":{"appId":"cli_env_test","appSecret":"${MY_OC_SECRET}","domain":"lark"}}}`
|
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(openclawCfg), 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
|
if err != nil {
|
|
t.Fatalf("expected success, got error: %v", err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
|
t.Fatalf("invalid JSON output: %v", err)
|
|
}
|
|
if result["app_id"] != "cli_env_test" {
|
|
t.Errorf("app_id = %v, want %q", result["app_id"], "cli_env_test")
|
|
}
|
|
}
|
|
|
|
func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
|
|
saveWorkspace(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
openclawHome := t.TempDir()
|
|
t.Setenv("OPENCLAW_HOME", openclawHome)
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
|
|
openclawDir := filepath.Join(openclawHome, ".openclaw")
|
|
if err := os.MkdirAll(openclawDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
openclawCfg := `{"channels":{"feishu":{"accounts":{"work":{"appId":"cli_disabled","appSecret":"secret","enabled":false}}}}}`
|
|
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(openclawCfg), 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "openclaw",
|
|
Message: "no Feishu app configured in openclaw.json",
|
|
Hint: "configure channels.feishu.appId in openclaw.json",
|
|
})
|
|
}
|
|
|
|
// ── getBindMsg tests ──
|
|
|
|
func TestGetBindMsg_Zh(t *testing.T) {
|
|
msg := getBindMsg("zh")
|
|
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
|
|
t.Errorf("zh SelectSource = %q, want %q", msg.SelectSource, want)
|
|
}
|
|
if want := "你希望 AI 如何与你协作?"; msg.SelectIdentity != want {
|
|
t.Errorf("zh SelectIdentity = %q, want %q", msg.SelectIdentity, want)
|
|
}
|
|
if want := "以机器人身份"; msg.IdentityBotOnly != want {
|
|
t.Errorf("zh IdentityBotOnly = %q, want %q", msg.IdentityBotOnly, want)
|
|
}
|
|
}
|
|
|
|
func TestGetBindMsg_En(t *testing.T) {
|
|
msg := getBindMsg("en")
|
|
if want := "Which Agent are you running?"; msg.SelectSource != want {
|
|
t.Errorf("en SelectSource = %q, want %q", msg.SelectSource, want)
|
|
}
|
|
if want := "As bot"; msg.IdentityBotOnly != want {
|
|
t.Errorf("en IdentityBotOnly = %q, want %q", msg.IdentityBotOnly, want)
|
|
}
|
|
}
|
|
|
|
func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) {
|
|
msg := getBindMsg("fr")
|
|
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
|
|
t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want)
|
|
}
|
|
}
|
|
|
|
// ── Resolve path edge case tests ──
|
|
|
|
func TestResolveOpenClawConfigPath_LegacyFallback(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
t.Setenv("OPENCLAW_HOME", home)
|
|
|
|
legacyDir := filepath.Join(home, ".clawdbot")
|
|
if err := os.MkdirAll(legacyDir, 0700); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
legacyFile := filepath.Join(legacyDir, "clawdbot.json")
|
|
if err := os.WriteFile(legacyFile, []byte(`{}`), 0600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
got := resolveOpenClawConfigPath()
|
|
if got != legacyFile {
|
|
t.Errorf("got %q, want legacy fallback %q", got, legacyFile)
|
|
}
|
|
}
|
|
|
|
func TestResolveOpenClawConfigPath_DefaultPath(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("OPENCLAW_CONFIG_PATH", "")
|
|
t.Setenv("OPENCLAW_STATE_DIR", "")
|
|
t.Setenv("OPENCLAW_HOME", home)
|
|
|
|
want := filepath.Join(home, ".openclaw", "openclaw.json")
|
|
got := resolveOpenClawConfigPath()
|
|
if got != want {
|
|
t.Errorf("got %q, want default %q", got, want)
|
|
}
|
|
}
|
|
|
|
// ── cleanupKeychainFromData ──
|
|
|
|
func TestCleanupKeychainFromData_InvalidJSON(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
// Should not panic on invalid JSON
|
|
cleanupKeychainFromData(f.Keychain, []byte("not json"), nil)
|
|
}
|
|
|
|
func TestCleanupKeychainFromData_ValidConfig(t *testing.T) {
|
|
configData := []byte(`{"apps":[{"appId":"test_app","appSecret":{"ref":{"source":"keychain","id":"test_key"}}}]}`)
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
// Should not panic even when there is no new-app to keep.
|
|
cleanupKeychainFromData(f.Keychain, configData, nil)
|
|
}
|
|
|
|
// statefulKeychain is a local in-memory KeychainAccess used only by the
|
|
// cleanup tests below. The package-wide noopKeychain in internal/cmdutil is
|
|
// intentionally untouched (it is pre-existing stable code) — this local mock
|
|
// gives the cleanup tests real Set/Get roundtrip semantics without changing
|
|
// any existing test infrastructure.
|
|
type statefulKeychain struct{ items map[string]string }
|
|
|
|
func newStatefulKeychain() *statefulKeychain {
|
|
return &statefulKeychain{items: map[string]string{}}
|
|
}
|
|
func (k *statefulKeychain) key(service, account string) string {
|
|
return service + "\x00" + account
|
|
}
|
|
func (k *statefulKeychain) Get(service, account string) (string, error) {
|
|
return k.items[k.key(service, account)], nil
|
|
}
|
|
func (k *statefulKeychain) Set(service, account, value string) error {
|
|
k.items[k.key(service, account)] = value
|
|
return nil
|
|
}
|
|
func (k *statefulKeychain) Remove(service, account string) error {
|
|
delete(k.items, k.key(service, account))
|
|
return nil
|
|
}
|
|
|
|
// Rebinding the same appId MUST NOT delete the secret that ForStorage just
|
|
// wrote. This regression was observed in real use: the old config's secret
|
|
// key is identical to the new one (both derive from appId), and the
|
|
// indiscriminate cleanup clobbered it.
|
|
func TestCleanupKeychainFromData_KeepsSecretSharedWithNewApp(t *testing.T) {
|
|
kc := newStatefulKeychain()
|
|
|
|
const sharedID = "appsecret:cli_shared"
|
|
if err := kc.Set("lark-cli", sharedID, "top-secret"); err != nil {
|
|
t.Fatalf("seed keychain: %v", err)
|
|
}
|
|
|
|
oldConfig := []byte(`{"apps":[{"appId":"cli_shared","appSecret":{"source":"keychain","id":"` + sharedID + `"}}]}`)
|
|
newApp := &core.AppConfig{
|
|
AppId: "cli_shared",
|
|
AppSecret: core.SecretInput{
|
|
Ref: &core.SecretRef{Source: "keychain", ID: sharedID},
|
|
},
|
|
}
|
|
|
|
cleanupKeychainFromData(kc, oldConfig, newApp)
|
|
|
|
got, err := kc.Get("lark-cli", sharedID)
|
|
if err != nil {
|
|
t.Fatalf("keychain read after cleanup: %v", err)
|
|
}
|
|
if got != "top-secret" {
|
|
t.Fatalf("shared secret was deleted; got %q, want %q", got, "top-secret")
|
|
}
|
|
}
|
|
|
|
// When the new app uses a different keychain ID, the old app's secret still
|
|
// gets removed (that's the point of cleanup — reclaim stale entries).
|
|
func TestCleanupKeychainFromData_RemovesStaleSecretWhenAppIDChanges(t *testing.T) {
|
|
kc := newStatefulKeychain()
|
|
|
|
const oldID = "appsecret:cli_old"
|
|
const newID = "appsecret:cli_new"
|
|
if err := kc.Set("lark-cli", oldID, "old-secret"); err != nil {
|
|
t.Fatalf("seed keychain: %v", err)
|
|
}
|
|
|
|
oldConfig := []byte(`{"apps":[{"appId":"cli_old","appSecret":{"source":"keychain","id":"` + oldID + `"}}]}`)
|
|
newApp := &core.AppConfig{
|
|
AppId: "cli_new",
|
|
AppSecret: core.SecretInput{
|
|
Ref: &core.SecretRef{Source: "keychain", ID: newID},
|
|
},
|
|
}
|
|
|
|
cleanupKeychainFromData(kc, oldConfig, newApp)
|
|
|
|
got, _ := kc.Get("lark-cli", oldID)
|
|
if got != "" {
|
|
t.Fatalf("stale secret should have been removed; still got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestHasStrictBotLock locks down the predicate's contract across every
|
|
// branch that warnIdentityEscalation depends on. Corrupt JSON is
|
|
// intentionally treated as "no lock" — commitBinding will overwrite the
|
|
// bad bytes anyway, matching the rest of the bind flow's lenient handling.
|
|
func TestHasStrictBotLock(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want bool
|
|
}{
|
|
{"bot lock present", `{"apps":[{"appId":"a","strictMode":"bot"}]}`, true},
|
|
{"no strictMode field", `{"apps":[{"appId":"a"}]}`, false},
|
|
{"explicit off", `{"apps":[{"appId":"a","strictMode":"off"}]}`, false},
|
|
{"multi-app, one locked", `{"apps":[{"appId":"a"},{"appId":"b","strictMode":"bot"}]}`, true},
|
|
{"empty apps array", `{"apps":[]}`, false},
|
|
{"corrupt JSON → no lock", `{not-json`, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := hasStrictBotLock([]byte(c.in)); got != c.want {
|
|
t.Errorf("hasStrictBotLock(%q) = %v, want %v", c.in, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|