From 871c2f51312601f3be96ff055f3fdf0131eb32a0 Mon Sep 17 00:00:00 2001 From: "mazhe.nerd" Date: Fri, 8 May 2026 20:43:19 +0800 Subject: [PATCH] feat(config): add lark-channel as a bind source --- cmd/config/bind.go | 19 ++- cmd/config/bind_messages.go | 27 ++-- cmd/config/bind_test.go | 215 ++++++++++++++++++++++++-- cmd/config/binder.go | 75 +++++++++ cmd/config/config_test.go | 3 + cmd/config/init_guard_test.go | 4 +- internal/binding/lark_channel.go | 51 ++++++ internal/binding/lark_channel_test.go | 121 +++++++++++++++ internal/core/workspace.go | 14 +- internal/core/workspace_test.go | 33 ++++ tests/cli_e2e/config/bind_test.go | 139 ++++++++++++++++- 11 files changed, 660 insertions(+), 41 deletions(-) create mode 100644 internal/binding/lark_channel.go create mode 100644 internal/binding/lark_channel_test.go diff --git a/cmd/config/bind.go b/cmd/config/bind.go index 2494971f67..2068d14392 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -60,9 +60,9 @@ func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra. cmd := &cobra.Command{ Use: "bind", Short: "Bind Agent config to a workspace (source / app-id / force)", - Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace. + Long: `Bind an AI Agent's (OpenClaw / Hermes / Lark Channel) Feishu credentials to a lark-cli workspace. ---source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME); pass it only to override. +--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME / LARK_CHANNEL); pass it only to override. For AI agents — DO NOT bind without user confirmation. Binding may overwrite an existing one and locks in an identity policy. Ask the user: @@ -85,6 +85,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`, Example: ` # AI flow: confirm intent + identity with user FIRST, then run: lark-cli config bind --source openclaw --app-id --identity bot-only lark-cli config bind --source hermes --identity user-default + lark-cli config bind --source lark-channel # Interactive (terminal user) — TUI prompts for everything: lark-cli config bind`, @@ -97,7 +98,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`, }, } - cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes); auto-detected from env signals when omitted") + cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes|lark-channel); auto-detected from env signals when omitted") cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)") 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)") @@ -175,8 +176,8 @@ type existingBinding struct { // fall back to a TUI prompt (TUI mode) or an error (flag mode). func finalizeSource(opts *BindOptions) (string, error) { explicit := strings.TrimSpace(strings.ToLower(opts.Source)) - if explicit != "" && explicit != "openclaw" && explicit != "hermes" { - return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes", explicit) + if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" { + return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit) } var detected string @@ -185,6 +186,8 @@ func finalizeSource(opts *BindOptions) (string, error) { detected = "openclaw" case core.WorkspaceHermes: detected = "hermes" + case core.WorkspaceLarkChannel: + detected = "lark-channel" } // Explicit and env detection must agree when both are present. Reject @@ -221,7 +224,7 @@ func finalizeSource(opts *BindOptions) (string, error) { } return "", output.ErrWithHint(output.ExitValidation, "bind", "cannot determine Agent source: no --source flag and no Agent environment detected", - "pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat") + "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context") } // reconcileExistingBinding reads any existing config at configPath and decides @@ -467,6 +470,8 @@ func tuiSelectSource(opts *BindOptions) (string, error) { source = "openclaw" case core.WorkspaceHermes: source = "hermes" + case core.WorkspaceLarkChannel: + source = "lark-channel" default: source = "openclaw" // default first option } @@ -474,6 +479,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) { // Resolve actual paths for display openclawPath := resolveOpenClawConfigPath() hermesEnvPath := resolveHermesEnvPath() + larkChannelPath := resolveLarkChannelConfigPath() form := huh.NewForm( huh.NewGroup( @@ -483,6 +489,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) { Options( huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"), huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"), + huh.NewOption(fmt.Sprintf(msg.SourceLarkChannel, larkChannelPath), "lark-channel"), ). Value(&source), ), diff --git a/cmd/config/bind_messages.go b/cmd/config/bind_messages.go index 6703326ff7..375f38923c 100644 --- a/cmd/config/bind_messages.go +++ b/cmd/config/bind_messages.go @@ -12,10 +12,11 @@ package config type bindMsg struct { // Source selection. // SelectSourceDesc format: brand. - SelectSource string - SelectSourceDesc string - SourceOpenClaw string // format: resolved config path. - SourceHermes string // format: resolved dotenv path. + SelectSource string + SelectSourceDesc string + SourceOpenClaw string // format: resolved config path. + SourceHermes string // format: resolved dotenv path. + SourceLarkChannel string // format: resolved config path. // Account selection (OpenClaw multi-account). // Format: source display name ("OpenClaw" | "Hermes"), brand. @@ -86,10 +87,11 @@ type bindMsg struct { } var bindMsgZh = &bindMsg{ - SelectSource: "你想在哪个 Agent 中使用 lark-cli?", - SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息,并配置到 lark-cli 中", - SourceOpenClaw: "OpenClaw — 配置文件: %s", - SourceHermes: "Hermes — 配置文件: %s", + SelectSource: "你想在哪个 Agent 中使用 lark-cli?", + SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息,并配置到 lark-cli 中", + SourceOpenClaw: "OpenClaw — 配置文件: %s", + SourceHermes: "Hermes — 配置文件: %s", + SourceLarkChannel: "Lark Channel — 配置文件: %s", SelectAccount: "检测到 %s 中已配置多个%s应用,请选择一个", @@ -117,10 +119,11 @@ var bindMsgZh = &bindMsg{ } var bindMsgEn = &bindMsg{ - SelectSource: "Which Agent are you running?", - SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.", - SourceOpenClaw: "OpenClaw — config: %s", - SourceHermes: "Hermes — config: %s", + SelectSource: "Which Agent are you running?", + SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.", + SourceOpenClaw: "OpenClaw — config: %s", + SourceHermes: "Hermes — config: %s", + SourceLarkChannel: "Lark Channel — config: %s", // Args order (source, brand) matches the Chinese template; %[N]s lets the // English reading order differ while the caller passes args in one order. diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index 800f8241f2..bace505d7a 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -123,7 +123,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) { err := configBindRun(&BindOptions{Factory: f, Source: "invalid"}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ Type: "validation", - Message: `invalid --source "invalid"; valid values: openclaw, hermes`, + Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`, }) } @@ -141,21 +141,29 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) { 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", + Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context", }) } -// 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. +// clearAgentEnv removes every env var that DetectWorkspaceFromEnv treats as +// an Agent signal, so tests exercising the "no signals" path stay isolated +// from whatever the host shell exported. Prefix-based instead of an explicit +// list — when DetectWorkspaceFromEnv gains a new OPENCLAW_* / HERMES_* signal, +// this helper does not need to be updated and tests do not silently misroute. +// t.Setenv restores the original values 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, "") + for _, kv := range os.Environ() { + idx := strings.IndexByte(kv, '=') + if idx < 0 { + continue + } + k := kv[:idx] + if strings.HasPrefix(k, "OPENCLAW_") || + strings.HasPrefix(k, "HERMES_") || + k == "LARK_CHANNEL" { + t.Setenv(k, "") + } } } @@ -339,6 +347,191 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) { }) } +// writeLarkChannelFixture writes a ~/.lark-channel/config.json under fakeHome +// and returns the config path. resolveLarkChannelConfigPath reads HOME via +// os.UserHomeDir, so callers must `t.Setenv("HOME", fakeHome)`. +func writeLarkChannelFixture(t *testing.T, fakeHome, body string) string { + t.Helper() + dir := filepath.Join(fakeHome, ".lark-channel") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatalf("write: %v", err) + } + return path +} + +// Happy-path: --source lark-channel reads ~/.lark-channel/config.json, +// writes the workspace config, emits a JSON envelope with workspace: +// "lark-channel" and brand from accounts.app.tenant. +func TestConfigBindRun_LarkChannel_Success(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + clearAgentEnv(t) + + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_lc_main","secret":"lc_secret","tenant":"feishu"}}}`) + + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); 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"] != "lark-channel" { + t.Errorf("workspace = %v, want %q", envelope["workspace"], "lark-channel") + } + if envelope["app_id"] != "cli_lc_main" { + t.Errorf("app_id = %v, want %q", envelope["app_id"], "cli_lc_main") + } + + // Brand is not in the stdout envelope — read it back from the persisted + // workspace config to verify accounts.app.tenant flowed through to the + // stored AppConfig.Brand field. + core.SetCurrentWorkspace(core.WorkspaceLarkChannel) + multi, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("load workspace config: %v", err) + } + if len(multi.Apps) != 1 { + t.Fatalf("expected 1 app, got %d", len(multi.Apps)) + } + if got := string(multi.Apps[0].Brand); got != "feishu" { + t.Errorf("Brand = %q, want %q", got, "feishu") + } +} + +// tenant: "lark" should land as Brand("lark"), not normalized to "feishu". +func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + clearAgentEnv(t) + + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_lc_lark","secret":"s","tenant":"lark"}}}`) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil { + t.Fatalf("expected success, got error: %v", err) + } + core.SetCurrentWorkspace(core.WorkspaceLarkChannel) + multi, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("load workspace config: %v", err) + } + if got := string(multi.Apps[0].Brand); got != "lark" { + t.Errorf("Brand = %q, want %q (tenant: lark must flow through to AppConfig.Brand)", got, "lark") + } +} + +// LARK_CHANNEL=1 alone (no --source) auto-detects to the lark-channel +// workspace, mirroring the OpenClaw/Hermes auto-detect flow. +func TestConfigBindRun_AutoDetect_LarkChannelFromEnv(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + clearAgentEnv(t) + t.Setenv("LARK_CHANNEL", "1") + + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_auto_lc","secret":"s","tenant":"feishu"}}}`) + + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f}); 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"] != "lark-channel" { + t.Errorf("workspace = %v, want %q (auto-detection should pick lark-channel from LARK_CHANNEL=1)", envelope["workspace"], "lark-channel") + } +} + +// --source lark-channel while the env signals OpenClaw must fail loud, same +// rule as OpenClaw/Hermes mismatch (running in the wrong Agent context). +func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + clearAgentEnv(t) + t.Setenv("OPENCLAW_HOME", t.TempDir()) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) + assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + Type: "bind", + Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`, + Hint: "remove --source to auto-detect, or run this command in the correct Agent context", + }) +} + +// Missing config.json → typed error with a hint pointing at bridge setup. +func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + clearAgentEnv(t) + + fakeHome := t.TempDir() // empty — no .lark-channel/config.json + t.Setenv("HOME", fakeHome) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) + configPath := filepath.Join(fakeHome, ".lark-channel", "config.json") + assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + Type: "lark-channel", + Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory", + Hint: "verify lark-channel-bridge is installed and configured", + }) +} + +// Empty accounts.app.id → typed error pointing at bridge setup. Distinct +// from "missing file" so users know whether to install or to re-run setup. +func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + clearAgentEnv(t) + + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + configPath := writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"","secret":"","tenant":"feishu"}}}`) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) + assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + Type: "lark-channel", + Message: "accounts.app.id missing in " + configPath, + Hint: "run lark-channel-bridge's setup to populate the app credential", + }) +} + +// app.id present but app.secret missing → typed error at the Build step. +func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + clearAgentEnv(t) + + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + configPath := writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_no_secret","secret":"","tenant":"feishu"}}}`) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) + assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + Type: "lark-channel", + Message: "accounts.app.secret is empty in " + configPath, + Hint: "run lark-channel-bridge's setup to populate the app credential", + }) +} + func TestConfigShowRun_WorkspaceField(t *testing.T) { saveWorkspace(t) configDir := t.TempDir() diff --git a/cmd/config/binder.go b/cmd/config/binder.go index ab068894a7..ee17808403 100644 --- a/cmd/config/binder.go +++ b/cmd/config/binder.go @@ -46,6 +46,8 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) { return &openclawBinder{opts: opts, path: resolveOpenClawConfigPath()}, nil case "hermes": return &hermesBinder{opts: opts, path: resolveHermesEnvPath()}, nil + case "lark-channel": + return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil default: return nil, output.ErrValidation("unsupported source: %s", source) } @@ -270,6 +272,65 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) { }, nil } +// ────────────────────────────────────────────────────────────── +// larkChannelBinder +// ────────────────────────────────────────────────────────────── + +type larkChannelBinder struct { + opts *BindOptions + path string + + // Cached between ListCandidates and Build so we don't re-read the file. + cfg *binding.LarkChannelRoot +} + +func (b *larkChannelBinder) Name() string { return "lark-channel" } +func (b *larkChannelBinder) ConfigPath() string { return b.path } + +func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) { + cfg, err := binding.ReadLarkChannelConfig(b.path) + if err != nil { + return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", + fmt.Sprintf("cannot read %s: %v", b.path, err), + "verify lark-channel-bridge is installed and configured") + } + if cfg.Accounts.App.ID == "" { + return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", + fmt.Sprintf("accounts.app.id missing in %s", b.path), + "run lark-channel-bridge's setup to populate the app credential") + } + b.cfg = cfg + return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil +} + +func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { + if b.cfg == nil { + return nil, output.Errorf(output.ExitInternal, "lark-channel", + "internal: Build called before ListCandidates") + } + if b.cfg.Accounts.App.ID != appID { + return nil, output.Errorf(output.ExitInternal, "lark-channel", + "internal: appID %q does not match config", appID) + } + if b.cfg.Accounts.App.Secret == "" { + return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", + fmt.Sprintf("accounts.app.secret is empty in %s", b.path), + "run lark-channel-bridge's setup to populate the app credential") + } + + stored, err := core.ForStorage(appID, core.PlainSecret(b.cfg.Accounts.App.Secret), b.opts.Factory.Keychain) + if err != nil { + return nil, output.Errorf(output.ExitInternal, "lark-channel", + "keychain unavailable: %v", err) + } + + return &core.AppConfig{ + AppId: appID, + AppSecret: stored, + Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)), + }, nil +} + // ────────────────────────────────────────────────────────────── // Source-specific helpers (path / dotenv / brand) — kept private to this package. // Moved here from bind.go so bind.go can focus on orchestration. @@ -283,6 +344,8 @@ func sourceDisplayName(source string) string { return "OpenClaw" case "hermes": return "Hermes" + case "lark-channel": + return "Lark Channel" default: return source } @@ -316,6 +379,18 @@ func resolveHermesEnvPath() string { return filepath.Join(hermesHome, ".env") } +// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's +// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes +// ~/.lark-channel/config.json with no env override — multi-instance is not +// a supported scenario today. +func resolveLarkChannelConfigPath() string { + home, err := vfs.UserHomeDir() + if err != nil || home == "" { + fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) + } + return filepath.Join(home, ".lark-channel", "config.json") +} + // resolveOpenClawConfigPath resolves openclaw.json path using the same priority // chain as OpenClaw's src/config/paths.ts: // 1. OPENCLAW_CONFIG_PATH env → exact file path diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index da734eb2c4..827790b93a 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -38,6 +38,7 @@ func (r *recordingConfigKeychain) Remove(service, account string) error { } func TestConfigInitCmd_FlagParsing(t *testing.T) { + clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts f, _, _, _ := cmdutil.TestFactory(t, nil) f.IOStreams.In = strings.NewReader("secret123\n") @@ -136,6 +137,7 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) { } func TestConfigInitCmd_LangFlag(t *testing.T) { + clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts f, _, _, _ := cmdutil.TestFactory(t, nil) var gotOpts *ConfigInitOptions @@ -157,6 +159,7 @@ func TestConfigInitCmd_LangFlag(t *testing.T) { } func TestConfigInitCmd_LangDefault(t *testing.T) { + clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts f, _, _, _ := cmdutil.TestFactory(t, nil) var gotOpts *ConfigInitOptions diff --git a/cmd/config/init_guard_test.go b/cmd/config/init_guard_test.go index 6e2ba18855..ee82e3672c 100644 --- a/cmd/config/init_guard_test.go +++ b/cmd/config/init_guard_test.go @@ -12,9 +12,7 @@ import ( ) func TestGuardAgentWorkspace_LocalAllows(t *testing.T) { - t.Setenv("OPENCLAW_HOME", "") - t.Setenv("OPENCLAW_CLI", "") - t.Setenv("HERMES_HOME", "") + clearAgentEnv(t) if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil { t.Errorf("local workspace should allow init, got: %v", err) diff --git a/internal/binding/lark_channel.go b/internal/binding/lark_channel.go new file mode 100644 index 0000000000..511f19dd76 --- /dev/null +++ b/internal/binding/lark_channel.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package binding + +import ( + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/vfs" +) + +// LarkChannelRoot captures ~/.lark-channel/config.json. +// Schema mirrors lark-channel-bridge/src/config/schema.ts:AppConfig. +// Unknown fields are ignored — forward-compatible with future bridge versions. +type LarkChannelRoot struct { + Accounts LarkChannelAccounts `json:"accounts"` +} + +// LarkChannelAccounts is the namespace for credential entries. +// Currently only `app` is defined; left as a struct (not a flat field) so +// future entries (oauth, alternate apps) can be added without re-shaping the +// top-level on disk. +type LarkChannelAccounts struct { + App LarkChannelApp `json:"app"` +} + +// LarkChannelApp is the bot app credential entry. +// Bridge stores the secret as plain text — secret-resolve indirection +// (${VAR} / file: / exec:) is intentionally not supported here, matching +// the bridge's on-disk format. +type LarkChannelApp struct { + ID string `json:"id"` + Secret string `json:"secret"` + Tenant string `json:"tenant"` // "feishu" | "lark" +} + +// ReadLarkChannelConfig reads and parses ~/.lark-channel/config.json. +func ReadLarkChannelConfig(path string) (*LarkChannelRoot, error) { + data, err := vfs.ReadFile(path) + if err != nil { + return nil, err // caller formats user-facing message with path context + } + + var root LarkChannelRoot + if err := json.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("invalid JSON in %s: %w", path, err) + } + + return &root, nil +} diff --git a/internal/binding/lark_channel_test.go b/internal/binding/lark_channel_test.go new file mode 100644 index 0000000000..2883144b54 --- /dev/null +++ b/internal/binding/lark_channel_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package binding + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadLarkChannelConfig_Valid(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.json") + data := `{"accounts":{"app":{"id":"cli_abc123","secret":"plain_secret","tenant":"feishu"}}}` + if err := os.WriteFile(p, []byte(data), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + root, err := ReadLarkChannelConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := root.Accounts.App.ID; got != "cli_abc123" { + t.Errorf("ID = %q, want %q", got, "cli_abc123") + } + if got := root.Accounts.App.Secret; got != "plain_secret" { + t.Errorf("Secret = %q, want %q", got, "plain_secret") + } + if got := root.Accounts.App.Tenant; got != "feishu" { + t.Errorf("Tenant = %q, want %q", got, "feishu") + } +} + +func TestReadLarkChannelConfig_LarkTenant(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.json") + data := `{"accounts":{"app":{"id":"cli_xyz","secret":"s","tenant":"lark"}}}` + if err := os.WriteFile(p, []byte(data), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + root, err := ReadLarkChannelConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := root.Accounts.App.Tenant; got != "lark" { + t.Errorf("Tenant = %q, want %q", got, "lark") + } +} + +func TestReadLarkChannelConfig_MissingFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "does-not-exist.json") + + _, err := ReadLarkChannelConfig(p) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + if !os.IsNotExist(err) { + t.Errorf("expected os.IsNotExist, got %v", err) + } +} + +func TestReadLarkChannelConfig_MalformedJSON(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.json") + if err := os.WriteFile(p, []byte("{not valid json"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + _, err := ReadLarkChannelConfig(p) + if err == nil { + t.Fatal("expected error for malformed JSON, got nil") + } +} + +func TestReadLarkChannelConfig_PartialFields(t *testing.T) { + // schema isComplete check belongs at the binder layer; the reader should + // happily parse a partial config — emptiness is detected downstream. + dir := t.TempDir() + p := filepath.Join(dir, "config.json") + data := `{"accounts":{"app":{}}}` + if err := os.WriteFile(p, []byte(data), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + root, err := ReadLarkChannelConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if root.Accounts.App.ID != "" { + t.Errorf("expected empty ID, got %q", root.Accounts.App.ID) + } + if root.Accounts.App.Secret != "" { + t.Errorf("expected empty Secret, got %q", root.Accounts.App.Secret) + } +} + +func TestReadLarkChannelConfig_UnknownFieldsIgnored(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.json") + data := `{ + "accounts": { + "app": {"id": "cli_a", "secret": "s", "tenant": "feishu"}, + "oauth": {"clientId": "ignored"} + }, + "preferences": {"theme": "dark"} + }` + if err := os.WriteFile(p, []byte(data), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + root, err := ReadLarkChannelConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := root.Accounts.App.ID; got != "cli_a" { + t.Errorf("ID = %q, want %q", got, "cli_a") + } +} diff --git a/internal/core/workspace.go b/internal/core/workspace.go index 8a3b8e5298..181a6bce1f 100644 --- a/internal/core/workspace.go +++ b/internal/core/workspace.go @@ -27,6 +27,11 @@ const ( // 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. @@ -90,7 +95,10 @@ func (w Workspace) IsLocal() bool { // - 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. Otherwise → WorkspaceLocal +// 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") != "" || @@ -109,6 +117,9 @@ func DetectWorkspaceFromEnv(getenv func(string) string) Workspace { getenv("HERMES_SESSION_KEY") != "" { return WorkspaceHermes } + if getenv("LARK_CHANNEL") == "1" { + return WorkspaceLarkChannel + } return WorkspaceLocal } @@ -139,6 +150,7 @@ func GetBaseConfigDir() string { // - WorkspaceLocal → GetBaseConfigDir() (unchanged, backward-compatible) // - WorkspaceOpenClaw → GetBaseConfigDir()/openclaw // - WorkspaceHermes → GetBaseConfigDir()/hermes +// - WorkspaceLarkChannel → GetBaseConfigDir()/lark-channel func GetRuntimeDir() string { base := GetBaseConfigDir() ws := CurrentWorkspace() diff --git a/internal/core/workspace_test.go b/internal/core/workspace_test.go index 53223bf953..846fbb750d 100644 --- a/internal/core/workspace_test.go +++ b/internal/core/workspace_test.go @@ -119,6 +119,31 @@ func TestDetectWorkspaceFromEnv(t *testing.T) { 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 { @@ -141,6 +166,7 @@ func TestWorkspaceDisplay(t *testing.T) { {Workspace(""), "local"}, {WorkspaceOpenClaw, "openclaw"}, {WorkspaceHermes, "hermes"}, + {WorkspaceLarkChannel, "lark-channel"}, } for _, tt := range tests { if got := tt.ws.Display(); got != tt.expect { @@ -205,6 +231,13 @@ func TestGetRuntimeDir(t *testing.T) { 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) { diff --git a/tests/cli_e2e/config/bind_test.go b/tests/cli_e2e/config/bind_test.go index 088750800f..e8b64aecad 100644 --- a/tests/cli_e2e/config/bind_test.go +++ b/tests/cli_e2e/config/bind_test.go @@ -7,6 +7,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" @@ -16,6 +17,26 @@ import ( "github.com/tidwall/gjson" ) +// clearAgentEnv removes every env var that DetectWorkspaceFromEnv treats as +// an Agent signal (OPENCLAW_* / HERMES_* / LARK_CHANNEL). Prefix-based so the +// helper stays correct when DetectWorkspaceFromEnv adds new signals; tests +// no longer drift silently. Mirrors cmd/config/bind_test.go's helper. +func clearAgentEnv(t *testing.T) { + t.Helper() + for _, kv := range os.Environ() { + idx := strings.IndexByte(kv, '=') + if idx < 0 { + continue + } + k := kv[:idx] + if strings.HasPrefix(k, "OPENCLAW_") || + strings.HasPrefix(k, "HERMES_") || + k == "LARK_CHANNEL" { + t.Setenv(k, "") + } + } +} + // setupTempConfig creates a temp config dir and sets LARKSUITE_CLI_CONFIG_DIR. func setupTempConfig(t *testing.T) string { t.Helper() @@ -44,6 +65,16 @@ func writeOpenClawConfig(t *testing.T, openclawHome, appID, appSecret, brand str require.NoError(t, os.WriteFile(filepath.Join(dir, "openclaw.json"), []byte(content), 0600)) } +// writeLarkChannelConfig creates a fake ~/.lark-channel/config.json under +// fakeHome (caller is responsible for setting HOME=fakeHome via t.Setenv). +func writeLarkChannelConfig(t *testing.T, fakeHome, appID, appSecret, tenant string) { + t.Helper() + dir := filepath.Join(fakeHome, ".lark-channel") + require.NoError(t, os.MkdirAll(dir, 0700)) + content := `{"accounts":{"app":{"id":"` + appID + `","secret":"` + appSecret + `","tenant":"` + tenant + `"}}}` + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0600)) +} + // assertStderrError verifies the structured error JSON envelope in stderr. // Checks error.type and error.message exactly. hint is checked if non-empty. func assertStderrError(t *testing.T, result *clie2e.Result, wantExitCode int, wantType, wantMessage, wantHint string) { @@ -74,7 +105,7 @@ func TestBind_InvalidSource(t *testing.T) { }) require.NoError(t, err) assertStderrError(t, result, 2, "validation", - `invalid --source "invalid"; valid values: openclaw, hermes`, "") + `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`, "") } func TestBind_MissingSource_NonTTY(t *testing.T) { @@ -83,12 +114,7 @@ func TestBind_MissingSource_NonTTY(t *testing.T) { // finalizeSource hits the "cannot determine Agent source" branch instead // of silently auto-detecting whichever Agent the CI runner happens to // inherit env from. - 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, "") - } + clearAgentEnv(t) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -100,7 +126,7 @@ func TestBind_MissingSource_NonTTY(t *testing.T) { require.NoError(t, err) assertStderrError(t, result, 2, "bind", "cannot determine Agent source: no --source flag and no Agent environment detected", - "pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat") + "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context") } func TestBind_Hermes_Success(t *testing.T) { @@ -227,6 +253,10 @@ func TestBind_ConfigShow_WorkspaceField(t *testing.T) { defer cancel() configDir := setupTempConfig(t) + // Test asserts workspace == "local"; clear Agent signals so an inherited + // LARK_CHANNEL=1 / OPENCLAW_* / HERMES_* doesn't reroute to a workspace + // where the local config we just wrote is invisible. + clearAgentEnv(t) require.NoError(t, os.WriteFile( filepath.Join(configDir, "config.json"), []byte(`{"apps":[{"appId":"cli_local","appSecret":"secret","brand":"feishu"}]}`), @@ -316,3 +346,96 @@ func TestBind_OpenClaw_Success(t *testing.T) { "non-zero exit should be from openclaw bind path\nstderr:\n%s", result.Stderr) } } + +// TestBind_LarkChannel_Success exercises the full end-to-end happy path: +// fake bridge config under HOME → bind reads it → workspace config written +// to LARKSUITE_CLI_CONFIG_DIR/lark-channel/config.json with brand from tenant. +func TestBind_LarkChannel_Success(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + configDir := setupTempConfig(t) + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + writeLarkChannelConfig(t, fakeHome, "cli_lc_e2e", "lc_secret", "lark") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind", "--source", "lark-channel"}, + }) + require.NoError(t, err) + + if result.ExitCode == 0 { + stdout := result.Stdout + assert.True(t, gjson.Get(stdout, "ok").Bool(), "stdout:\n%s", stdout) + assert.Equal(t, "lark-channel", gjson.Get(stdout, "workspace").String(), "stdout:\n%s", stdout) + assert.Equal(t, "cli_lc_e2e", gjson.Get(stdout, "app_id").String(), "stdout:\n%s", stdout) + + expectedConfigPath := filepath.Join(configDir, "lark-channel", "config.json") + assert.Equal(t, expectedConfigPath, gjson.Get(stdout, "config_path").String(), "stdout:\n%s", stdout) + + data, readErr := os.ReadFile(expectedConfigPath) + require.NoError(t, readErr) + assert.Equal(t, "cli_lc_e2e", gjson.GetBytes(data, "apps.0.appId").String()) + assert.Equal(t, "lark", gjson.GetBytes(data, "apps.0.brand").String()) + } else { + // Keychain failure acceptable in CI; verify the error came from the + // lark-channel binder (i.e. routing was correct) rather than another path. + errType := gjson.Get(result.Stderr, "error.type").String() + assert.Equal(t, "lark-channel", errType, + "non-zero exit should be from lark-channel bind path\nstderr:\n%s", result.Stderr) + } +} + +// TestBind_LarkChannel_MissingFile verifies the routed error path when the +// bridge has not been configured: hint must point at bridge setup, not at +// `config init` (which would silently create a parallel local app and waste +// the user's existing bridge credentials). +func TestBind_LarkChannel_MissingFile(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + setupTempConfig(t) + fakeHome := t.TempDir() // empty — no .lark-channel/config.json + t.Setenv("HOME", fakeHome) + + configPath := filepath.Join(fakeHome, ".lark-channel", "config.json") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind", "--source", "lark-channel"}, + }) + require.NoError(t, err) + assertStderrError(t, result, 2, "lark-channel", + "cannot read "+configPath+": open "+configPath+": no such file or directory", + "verify lark-channel-bridge is installed and configured") +} + +// TestBind_LarkChannel_AutoDetect verifies LARK_CHANNEL=1 alone routes the +// no-flag bind into the lark-channel workspace (matches the bridge's actual +// runtime — it sets the env, not --source). +func TestBind_LarkChannel_AutoDetect(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + setupTempConfig(t) + // Clear other agent env so OpenClaw/Hermes signals from the host shell + // don't preempt the lark-channel detection. + clearAgentEnv(t) + t.Setenv("LARK_CHANNEL", "1") + + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + writeLarkChannelConfig(t, fakeHome, "cli_lc_auto", "auto_secret", "feishu") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind"}, // no --source + }) + require.NoError(t, err) + + if result.ExitCode == 0 { + assert.Equal(t, "lark-channel", gjson.Get(result.Stdout, "workspace").String(), + "stdout:\n%s", result.Stdout) + } else { + errType := gjson.Get(result.Stderr, "error.type").String() + assert.Equal(t, "lark-channel", errType, + "non-zero exit should be from lark-channel bind path\nstderr:\n%s", result.Stderr) + } +}