From 325aca228fc2520a3ef8067128fcc588eb3f0997 Mon Sep 17 00:00:00 2001 From: "zhaoyukun.yk" Date: Sat, 18 Apr 2026 16:32:26 +0800 Subject: [PATCH] feat(config): add 'config bind' for per-Agent credential isolation Give each AI Agent (OpenClaw, Hermes, ...) its own lark-cli workspace so it can run Feishu commands without interfering with the local install. lark-cli config bind --source openclaw|hermes [--app-id ] [--identity only_bot|default_user] Key capabilities: - Workspace auto-routing via OPENCLAW_CLI / FEISHU_APP_* env signals - Two identity presets (`only_bot`, `default_user`); flag mode defaults to `default_user` and silently replaces an existing binding - Agent-friendly JSON envelope on stdout with `identity` + `message` so the Agent can branch and relay the next step to the user - OpenClaw SecretRef resolution: plain, `${VAR}`, file + JSON Pointer, exec + JSON protocol; all paths audited - Atomic config write, keychain cleanup deferred until after success - `workspace` field added to `config show` and `doctor` output --- CHANGELOG.md | 6 + cmd/config/bind.go | 506 +++++++ cmd/config/bind_messages.go | 107 ++ cmd/config/bind_test.go | 1210 +++++++++++++++++ cmd/config/binder.go | 414 ++++++ cmd/config/binder_test.go | 175 +++ cmd/config/config.go | 1 + cmd/config/show.go | 18 +- cmd/doctor/doctor.go | 5 +- internal/cmdutil/factory_default.go | 11 + internal/core/config.go | 19 +- internal/core/workspace.go | 145 ++ internal/core/workspace_test.go | 228 ++++ internal/keychain/auth_log.go | 39 +- internal/openclaw/audit.go | 157 +++ internal/openclaw/audit_test.go | 363 +++++ internal/openclaw/audit_unix.go | 31 + internal/openclaw/audit_windows.go | 11 + internal/openclaw/json_pointer.go | 55 + internal/openclaw/json_pointer_test.go | 111 ++ internal/openclaw/reader.go | 26 + internal/openclaw/reader_test.go | 182 +++ internal/openclaw/secret_resolve.go | 104 ++ internal/openclaw/secret_resolve_exec.go | 241 ++++ internal/openclaw/secret_resolve_exec_test.go | 437 ++++++ internal/openclaw/secret_resolve_file.go | 95 ++ internal/openclaw/secret_resolve_file_test.go | 232 ++++ internal/openclaw/secret_resolve_test.go | 153 +++ internal/openclaw/types.go | 300 ++++ internal/openclaw/types_test.go | 419 ++++++ tests/cli_e2e/config/bind_test.go | 315 +++++ 31 files changed, 6090 insertions(+), 26 deletions(-) create mode 100644 cmd/config/bind.go create mode 100644 cmd/config/bind_messages.go create mode 100644 cmd/config/bind_test.go create mode 100644 cmd/config/binder.go create mode 100644 cmd/config/binder_test.go create mode 100644 internal/core/workspace.go create mode 100644 internal/core/workspace_test.go create mode 100644 internal/openclaw/audit.go create mode 100644 internal/openclaw/audit_test.go create mode 100644 internal/openclaw/audit_unix.go create mode 100644 internal/openclaw/audit_windows.go create mode 100644 internal/openclaw/json_pointer.go create mode 100644 internal/openclaw/json_pointer_test.go create mode 100644 internal/openclaw/reader.go create mode 100644 internal/openclaw/reader_test.go create mode 100644 internal/openclaw/secret_resolve.go create mode 100644 internal/openclaw/secret_resolve_exec.go create mode 100644 internal/openclaw/secret_resolve_exec_test.go create mode 100644 internal/openclaw/secret_resolve_file.go create mode 100644 internal/openclaw/secret_resolve_file_test.go create mode 100644 internal/openclaw/secret_resolve_test.go create mode 100644 internal/openclaw/types.go create mode 100644 internal/openclaw/types_test.go create mode 100644 tests/cli_e2e/config/bind_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5bf3f15..cfed92b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## Unreleased + +### Features + +- Add `config bind` command to sync AI Agent (OpenClaw / Hermes) credentials into isolated lark-cli workspaces; supports TUI + flag dual mode, OS keychain storage, OpenClaw SecretRef resolution, and per-workspace runtime artifact isolation + ## [v1.0.16] - 2026-04-21 ### Features diff --git a/cmd/config/bind.go b/cmd/config/bind.go new file mode 100644 index 000000000..75d076500 --- /dev/null +++ b/cmd/config/bind.go @@ -0,0 +1,506 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +// BindOptions holds all inputs for config bind. +type BindOptions struct { + Factory *cmdutil.Factory + Source string + AppID string + // Identity selects one of two presets — "bot-only" or "user-default" — + // that expand to underlying StrictMode + DefaultAs in applyPreferences. + // Empty means "decide later": TUI prompts, flag mode defaults to bot-only + // (the safer choice — bot acts under its own identity, no impersonation + // risk; users can still opt into "user-default" via --identity). + Identity string + Lang string + langExplicit bool // true when --lang was explicitly passed + + // IsTUI is the resolved interactive-mode flag: true only when Source is + // empty and stdin is a terminal. Computed once at the top of + // configBindRun; downstream branches read this instead of rechecking + // IOStreams.IsTerminal. Do not set from outside — it is overwritten. + IsTUI bool +} + +// NewCmdConfigBind creates the config bind subcommand. +func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command { + opts := &BindOptions{Factory: f} + + 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. + +For AI agents: pass --source and --app-id to bind non-interactively. +Credentials are synced once; subsequent calls in the Agent's process +context automatically use the bound workspace.`, + Example: ` lark-cli config bind --source openclaw --app-id + lark-cli config bind --source hermes`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.langExplicit = cmd.Flags().Changed("lang") + if runF != nil { + return runF(opts) + } + return configBindRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes); 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().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)") + + return cmd +} + +// configBindRun is the top-level orchestrator. Each step delegates to a named +// helper whose signature declares its contract; the body reads as the shape of +// the bind flow itself, not its mechanics. +func configBindRun(opts *BindOptions) error { + if err := validateBindFlags(opts); err != nil { + return err + } + + // Decide TUI-vs-flag mode exactly once; every downstream branch reads + // opts.IsTUI instead of re-checking IOStreams.IsTerminal. + opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal + + source, err := finalizeSource(opts) + if err != nil { + return err + } + core.SetCurrentWorkspace(core.Workspace(source)) + targetConfigPath := core.GetConfigPath() + + existing, err := reconcileExistingBinding(opts, source, targetConfigPath) + if err != nil { + return err + } + if existing.Cancelled { + return nil + } + + appConfig, err := resolveAccount(opts, source) + if err != nil { + return err + } + + if err := resolveIdentity(opts); err != nil { + return err + } + applyPreferences(appConfig, opts) + + return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath) +} + +// existingBinding is the outcome of checking whether a workspace was already +// bound. ConfigBytes is non-nil iff a previous binding existed (and the caller +// should pass it to commitBinding for stale-keychain cleanup after the new +// config is durably written). Cancelled is true iff the user declined to +// replace it in the TUI prompt; the caller should exit cleanly. +type existingBinding struct { + ConfigBytes []byte + Cancelled bool +} + +// finalizeSource returns the validated bind source, reconciling three inputs: +// - opts.Source: the value of --source (may be empty) +// - env signals: OPENCLAW_* / HERMES_* detected via DetectWorkspaceFromEnv +// - TUI mode: can prompt the user if neither flag nor env yields a source +// +// Resolution (in order): +// 1. If --source is a non-empty invalid value → fail with ErrValidation. +// 2. If both --source and an env signal are present and disagree → fail +// loud; the user almost certainly ran the command in the wrong context. +// 3. TUI mode only: prompt for language first (so later prompts respect it). +// 4. --source wins if set. Otherwise use the env-detected source. Otherwise +// 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) + } + + var detected string + switch core.DetectWorkspaceFromEnv(os.Getenv) { + case core.WorkspaceOpenClaw: + detected = "openclaw" + case core.WorkspaceHermes: + detected = "hermes" + } + + // Explicit and env detection must agree when both are present. Reject + // before any interactive prompts — running inside Hermes with + // --source openclaw (or vice versa) is almost always a mistake. + if explicit != "" && detected != "" && explicit != detected { + return "", output.ErrWithHint(output.ExitValidation, "bind", + fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected), + "remove --source to auto-detect, or run this command in the correct Agent context") + } + + // TUI: prompt for language before any downstream prompts. The source + // selection itself may still be skipped entirely if --source or the + // env already pinned it. + if opts.IsTUI && !opts.langExplicit { + lang, err := promptLangSelection("") + if err != nil { + if err == huh.ErrUserAborted { + return "", output.ErrBare(1) + } + return "", err + } + opts.Lang = lang + } + + if explicit != "" { + return explicit, nil + } + if detected != "" { + return detected, nil + } + if opts.IsTUI { + return tuiSelectSource(opts) + } + 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") +} + +// reconcileExistingBinding reads any existing config at configPath and decides +// how to proceed. In TUI mode the user is prompted to keep or replace. In flag +// mode the existing binding is silently overwritten — commitBinding will emit a +// notice on success so the caller still sees that a rebind happened. +// See existingBinding for the returned fields. +func reconcileExistingBinding(opts *BindOptions, source, configPath string) (existingBinding, error) { + oldConfigData, _ := vfs.ReadFile(configPath) + if oldConfigData == nil { + return existingBinding{}, nil + } + + if opts.IsTUI { + action, err := tuiConflictPrompt(opts, source, configPath) + if err != nil { + return existingBinding{}, err + } + if action == "cancel" { + msg := getBindMsg(opts.Lang) + fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled) + return existingBinding{Cancelled: true}, nil + } + return existingBinding{ConfigBytes: oldConfigData}, nil + } + + return existingBinding{ConfigBytes: oldConfigData}, nil +} + +// resolveAccount runs the source-agnostic bind flow: construct the binder, +// enumerate candidates, pick one via the shared decision layer, and build a +// ready-to-persist AppConfig. Adding a new bind source only requires +// implementing SourceBinder — none of the logic below needs to change. +func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) { + binder, err := newBinder(source, opts) + if err != nil { + return nil, err + } + candidates, err := binder.ListCandidates() + if err != nil { + return nil, err + } + picked, err := selectCandidate(binder, candidates, opts.AppID, opts.IsTUI, + func(cs []Candidate) (*Candidate, error) { return tuiSelectApp(opts, source, cs) }) + if err != nil { + return nil, err + } + return binder.Build(picked.AppID) +} + +// resolveIdentity ensures opts.Identity is set before applyPreferences runs. +// TUI mode prompts when empty; flag mode defaults to "bot-only" — the safer +// preset (bot acts under its own identity, no impersonation). Users who +// want the broader capability set can pass --identity user-default. +func resolveIdentity(opts *BindOptions) error { + if opts.Identity != "" { + return nil + } + if opts.IsTUI { + id, err := tuiSelectIdentity(opts) + if err != nil { + return err + } + opts.Identity = id + return nil + } + opts.Identity = "bot-only" + return nil +} + +// applyPreferences expands the chosen identity preset into the underlying +// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the +// profile's intent survives later changes to global strict-mode settings. +func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) { + switch opts.Identity { + case "bot-only": + sm := core.StrictModeBot + appConfig.StrictMode = &sm + appConfig.DefaultAs = core.AsBot + case "user-default": + sm := core.StrictModeOff + appConfig.StrictMode = &sm + appConfig.DefaultAs = core.AsUser + } + if opts.Lang != "" { + appConfig.Lang = opts.Lang + } +} + +// commitBinding finalizes the bind: atomic write of the new workspace config, +// best-effort cleanup of stale keychain entries from the previous binding (if +// any), and a JSON success envelope. Cleanup runs only after the new config +// is durably written — if anything fails earlier, the old workspace stays +// usable. +func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error { + multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}} + + if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil { + return output.Errorf(output.ExitInternal, "bind", + "failed to create workspace directory: %v", err) + } + data, err := json.MarshalIndent(multi, "", " ") + if err != nil { + return output.Errorf(output.ExitInternal, "bind", + "failed to marshal config: %v", err) + } + if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil { + return output.Errorf(output.ExitInternal, "bind", + "failed to write config %s: %v", configPath, err) + } + + replaced := previousConfigBytes != nil + msg := getBindMsg(opts.Lang) + display := sourceDisplayName(source) + + if replaced { + cleanupKeychainFromData(opts.Factory.Keychain, previousConfigBytes, appConfig) + fmt.Fprintln(opts.Factory.IOStreams.ErrOut, + fmt.Sprintf(msg.RebindReplaced, display)) + } + + envelope := map[string]interface{}{ + "ok": true, + "workspace": source, + "app_id": appConfig.AppId, + "config_path": configPath, + "replaced": replaced, + "identity": opts.Identity, + } + switch opts.Identity { + case "bot-only": + envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display) + case "user-default": + envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display) + } + + resultJSON, _ := json.Marshal(envelope) + fmt.Fprintln(opts.Factory.IOStreams.Out, string(resultJSON)) + return nil +} + +// cleanupKeychainFromData removes keychain entries referenced by a previous +// config snapshot, skipping any entry whose keychain ID is still in use by +// the new app config. This prevents rebinding the same appId from deleting +// the secret that ForStorage just wrote (old and new secret share the same +// keychain key, derived from appId). Best-effort: errors are silently +// ignored (same contract as config init's cleanup). +func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) { + var multi core.MultiAppConfig + if err := json.Unmarshal(data, &multi); err != nil { + return + } + keepID := "" + if keep != nil && keep.AppSecret.Ref != nil && keep.AppSecret.Ref.Source == "keychain" { + keepID = keep.AppSecret.Ref.ID + } + for _, app := range multi.Apps { + if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID { + continue + } + core.RemoveSecretStore(app.AppSecret, kc) + } +} + +// ────────────────────────────────────────────────────────────── +// TUI helpers (huh forms, matching config init interactive style) +// ────────────────────────────────────────────────────────────── + +// tuiSelectSource prompts user to choose bind source. +func tuiSelectSource(opts *BindOptions) (string, error) { + msg := getBindMsg(opts.Lang) + var source string + + // Pre-select based on detected env signals + detected := core.DetectWorkspaceFromEnv(os.Getenv) + switch detected { + case core.WorkspaceOpenClaw: + source = "openclaw" + case core.WorkspaceHermes: + source = "hermes" + default: + source = "openclaw" // default first option + } + + // Resolve actual paths for display + openclawPath := resolveOpenClawConfigPath() + hermesEnvPath := resolveHermesEnvPath() + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(msg.SelectSource). + Description(msg.SelectSourceDesc). + Options( + huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"), + huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"), + ). + Value(&source), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form.Run(); err != nil { + if err == huh.ErrUserAborted { + return "", output.ErrBare(1) + } + return "", err + } + return source, nil +} + +// tuiSelectApp prompts the user to choose from multiple account candidates. +// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode. +func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) { + msg := getBindMsg(opts.Lang) + options := make([]huh.Option[int], 0, len(candidates)) + for i, c := range candidates { + label := c.AppID + if c.Label != "" { + label = fmt.Sprintf("%s (%s)", c.Label, c.AppID) + } + options = append(options, huh.NewOption(label, i)) + } + + var selected int + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[int](). + Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source))). + Options(options...). + Value(&selected), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + return &candidates[selected], nil +} + +// tuiConflictPrompt shows existing binding and asks user to Force or Cancel. +func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) { + msg := getBindMsg(opts.Lang) + + // Build existing binding summary + existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath) + if data, err := vfs.ReadFile(configPath); err == nil { + var multi core.MultiAppConfig + if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 { + app := multi.Apps[0] + existingSummary = fmt.Sprintf(msg.ConflictDesc, + source, app.AppId, app.Brand, configPath) + } + } + + var action string + form := huh.NewForm( + huh.NewGroup( + huh.NewNote(). + Title(msg.ConflictTitle). + Description(existingSummary), + huh.NewSelect[string](). + Options( + huh.NewOption(msg.ConflictForce, "force"), + huh.NewOption(msg.ConflictCancel, "cancel"), + ). + Value(&action), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form.Run(); err != nil { + if err == huh.ErrUserAborted { + return "cancel", nil + } + return "", err + } + return action, nil +} + +// validateBindFlags validates enum flags early, before any side effects. +func validateBindFlags(opts *BindOptions) error { + if opts.Identity != "" { + switch opts.Identity { + case "bot-only", "user-default": + default: + return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity) + } + } + return nil +} + +// tuiSelectIdentity prompts user to pick one of two identity presets. +// bot-only is listed first so Enter on the default highlight maps to the +// flag-mode default for consistency across the two modes, and also because +// bot-only is the safer preset (no impersonation risk). +func tuiSelectIdentity(opts *BindOptions) (string, error) { + msg := getBindMsg(opts.Lang) + var value string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(msg.SelectIdentity). + Description(msg.SelectIdentityDesc). + Options( + huh.NewOption(msg.IdentityBotOnly, "bot-only"), + huh.NewOption(msg.IdentityUserDefault, "user-default"), + ). + Value(&value), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form.Run(); err != nil { + if err == huh.ErrUserAborted { + return "", output.ErrBare(1) + } + return "", err + } + return value, nil +} diff --git a/cmd/config/bind_messages.go b/cmd/config/bind_messages.go new file mode 100644 index 000000000..7dade4be7 --- /dev/null +++ b/cmd/config/bind_messages.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +// bindMsg holds all TUI text for config bind, supporting zh/en via --lang. +type bindMsg struct { + // Source selection + SelectSource string + SelectSourceDesc string + SourceOpenClaw string // format string: %s = resolved config path + SourceHermes string // format string: %s = resolved dotenv path + + // Account selection (OpenClaw multi-account). + // Format: %s = source display name ("OpenClaw" | "Hermes"). + SelectAccount string + + // Conflict prompt + ConflictTitle string + ConflictDesc string // format: workspace, appId, brand, configPath + ConflictForce string + ConflictCancel string + ConflictCancelled string + + // Rebind notice (printed to stderr after successful overwrite). + // Format: %s = source display name ("OpenClaw" | "Hermes"). + RebindReplaced string + + // Post-bind agent-friendly message emitted in the stdout JSON envelope's + // "message" field. Written as imperative instructions to the agent reading + // the JSON — not as description for a human reader. + // MessageBotOnly format: app_id, source display name. + // MessageUserDefault format: app_id, source display name, source display name + // (second ref anchors the "run in this chat" directive). + MessageBotOnly string + MessageUserDefault string + + // Identity preset (collapses strict-mode + default-as into one choice). + SelectIdentity string + SelectIdentityDesc string + IdentityBotOnly string + IdentityUserDefault string + + // Success / error + BindSuccess string +} + +var bindMsgZh = &bindMsg{ + SelectSource: "你想在哪个 Agent 中使用 lark-cli?", + SelectSourceDesc: "从你选择的 Agent 中获取飞书应用信息,并配置到 lark-cli 中", + SourceOpenClaw: "OpenClaw — 配置文件: %s", + SourceHermes: "Hermes — 配置文件: %s", + + SelectAccount: "检测到 %s 中已配置多个飞书应用,请选择一个", + + ConflictTitle: "检测到已有配置", + ConflictDesc: "%q 已配置 lark-cli:\n App ID: %s\n 品牌: %s\n 配置文件: %s", + ConflictForce: "修改配置", + ConflictCancel: "保留当前配置", + ConflictCancelled: "已保留当前配置", + + RebindReplaced: "已覆盖 %s workspace 原有绑定", + + MessageBotOnly: "已绑定应用 %s 到 %s,可立即以应用(bot)身份调用飞书 API,现在可以继续执行用户的请求。", + MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`,将输出的授权链接展示给用户并引导其点击完成授权。", + + SelectIdentity: "你希望 AI 如何与你协作?", + SelectIdentityDesc: "", + IdentityBotOnly: "以机器人身份 — AI 将在飞书中以机器人的身份执行所有操作,适合作为团队助手,用于多人协作场景,如群聊问答、团队通知、公共文档维护。", + IdentityUserDefault: "以你的身份 — AI 将在飞书中以你的名义执行所有操作,如读写文档、搜索消息、修改日程等,建议仅限个人使用。(⚠️ 注意:请勿将此机器人分享给其他人使用,避免泄露你的飞书数据)", + + BindSuccess: "绑定成功", +} + +var bindMsgEn = &bindMsg{ + SelectSource: "Which Agent are you using?", + SelectSourceDesc: "lark-cli will read and bind your Feishu app credentials from the selected Agent's config.", + SourceOpenClaw: "OpenClaw — config: %s", + SourceHermes: "Hermes — config: %s", + + SelectAccount: "Multiple Feishu apps configured in %s — select one to continue.", + + ConflictTitle: "This workspace is already configured", + ConflictDesc: "Workspace %q already bound:\n App ID: %s\n Brand: %s\n Config: %s", + ConflictForce: "Overwrite — use new credentials instead", + ConflictCancel: "Cancel — keep existing config", + ConflictCancelled: "Canceled. No changes were made.", + + RebindReplaced: "replaced existing %s workspace binding", + + MessageBotOnly: "Bound app %s to %s. The app (bot) identity is ready — you can now continue with the user's request.", + MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`, then show the returned authorization URL to the user and guide them to click it.", + + SelectIdentity: "Which identity should be the default?", + SelectIdentityDesc: "The identity lark-cli uses when none is specified.", + IdentityBotOnly: "As bot — Works under its own identity. Best for group chats, team notifications, and shared documents.", + IdentityUserDefault: "As you — Works under your identity, managing messages, docs, calendar, and more on your behalf. Requires authorization. (⚠️ Important: Everything the AI does will appear as you. Keep this bot private.)", + + BindSuccess: "Bind successful", +} + +func getBindMsg(lang string) *bindMsg { + if lang == "en" { + return bindMsgEn + } + return bindMsgZh +} diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go new file mode 100644 index 000000000..db4a389b3 --- /dev/null +++ b/cmd/config/bind_test.go @@ -0,0 +1,1210 @@ +// 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","brand":"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"), + }) + if want := fmt.Sprintf(msg.RebindReplaced, "Hermes"); !strings.Contains(stderr.String(), want) { + t.Errorf("stderr missing rebind notice %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","brand":"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","brand":"feishu"}, + "personal":{"appId":"cli_personal_222","appSecret":"secret_personal","brand":"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 ", + } + 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"), + }) + 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"), + }) + assertPresetApplied(t, filepath.Join(configDir, "hermes", "config.json"), + core.StrictModeBot, core.AsBot) +} + +// 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}","brand":"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 := "以机器人身份 — AI 将在飞书中以机器人的身份执行所有操作,适合作为团队助手,用于多人协作场景,如群聊问答、团队通知、公共文档维护。"; 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 using?"; msg.SelectSource != want { + t.Errorf("en SelectSource = %q, want %q", msg.SelectSource, want) + } + if want := "As bot — Works under its own identity. Best for group chats, team notifications, and shared documents."; 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) + } +} diff --git a/cmd/config/binder.go b/cmd/config/binder.go new file mode 100644 index 000000000..6227c6f05 --- /dev/null +++ b/cmd/config/binder.go @@ -0,0 +1,414 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/openclaw" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/vfs" +) + +// Candidate is the source-agnostic view of a bindable account. +// It carries only the identity fields needed by selectCandidate / TUI; +// secrets remain inside the SourceBinder implementation. +type Candidate struct { + AppID string + Label string +} + +// SourceBinder abstracts a bind source (openclaw / hermes / future sources). +// Implementations only list candidates and build an AppConfig for a chosen +// candidate — they stay out of mode (TUI vs flag) and orchestration concerns. +type SourceBinder interface { + // Name returns the source identifier (used in error envelopes). + Name() string + // ConfigPath returns the resolved path to the source's config file. + ConfigPath() string + // ListCandidates enumerates bindable accounts from the source config. + // An empty slice is valid (selectCandidate will turn it into a typed error). + ListCandidates() ([]Candidate, error) + // Build resolves secrets, persists to keychain, and returns a ready AppConfig + // for the chosen candidate AppID. Must be called after ListCandidates succeeds. + Build(appID string) (*core.AppConfig, error) +} + +// newBinder constructs the SourceBinder for the given source name. +func newBinder(source string, opts *BindOptions) (SourceBinder, error) { + switch source { + case "openclaw": + return &openclawBinder{opts: opts, path: resolveOpenClawConfigPath()}, nil + case "hermes": + return &hermesBinder{opts: opts, path: resolveHermesEnvPath()}, nil + default: + return nil, output.ErrValidation("unsupported source: %s", source) + } +} + +// selectCandidate is the single source of truth for account-selection logic. +// Every bind source funnels through this function, so the "how many +// candidates × was --app-id given × is this TUI" policy is defined once. +// +// Decision matrix: +// +// candidates=0 → error "no app configured" +// appID set, match → selected +// appID set, no match → error + candidate list +// candidates=1, appID="" → auto-select +// candidates≥2, appID="", isTUI=true → tuiPrompt +// candidates≥2, appID="", isTUI=false → error + candidate list +// +// The last branch is the one that matters for flag-mode callers: an explicit +// --source must never silently drop into an interactive prompt just because +// stdin happens to be a terminal. +func selectCandidate( + binder SourceBinder, + candidates []Candidate, + appIDFlag string, + isTUI bool, + tuiPrompt func([]Candidate) (*Candidate, error), +) (*Candidate, error) { + src := binder.Name() + cfgBase := filepath.Base(binder.ConfigPath()) + + if len(candidates) == 0 { + // Reader succeeded but yielded nothing — e.g. every openclaw account + // is disabled. Missing-file / missing-field cases return typed errors + // from ListCandidates itself and never reach here. + switch src { + case "openclaw": + return nil, output.ErrWithHint(output.ExitValidation, src, + "no Feishu app configured in openclaw.json", + "configure channels.feishu.appId in openclaw.json") + default: + return nil, output.ErrValidation("%s: no app configured", src) + } + } + + if appIDFlag != "" { + for i := range candidates { + if candidates[i].AppID == appIDFlag { + return &candidates[i], nil + } + } + return nil, output.ErrWithHint(output.ExitValidation, src, + fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase), + fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates))) + } + + if len(candidates) == 1 { + return &candidates[0], nil + } + + if isTUI { + return tuiPrompt(candidates) + } + + return nil, output.ErrWithHint(output.ExitValidation, src, + fmt.Sprintf("multiple accounts in %s; pass --app-id ", cfgBase), + fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates))) +} + +// formatCandidates renders candidates as "AppID (Label)" lines for error hints. +func formatCandidates(candidates []Candidate) string { + ids := make([]string, 0, len(candidates)) + for _, c := range candidates { + label := c.AppID + if c.Label != "" { + label = fmt.Sprintf("%s (%s)", c.AppID, c.Label) + } + ids = append(ids, label) + } + return strings.Join(ids, "\n ") +} + +// ────────────────────────────────────────────────────────────── +// openclawBinder +// ────────────────────────────────────────────────────────────── + +type openclawBinder struct { + opts *BindOptions + path string + + // Cached between ListCandidates and Build so we don't re-read / re-parse. + cfg *openclaw.OpenClawRoot + rawApps []openclaw.CandidateApp +} + +func (b *openclawBinder) Name() string { return "openclaw" } +func (b *openclawBinder) ConfigPath() string { return b.path } + +func (b *openclawBinder) ListCandidates() ([]Candidate, error) { + cfg, err := openclaw.ReadOpenClawConfig(b.path) + if err != nil { + return nil, output.ErrWithHint(output.ExitValidation, "openclaw", + fmt.Sprintf("cannot read %s: %v", b.path, err), + "verify OpenClaw is installed and configured") + } + if cfg.Channels.Feishu == nil { + return nil, output.ErrWithHint(output.ExitValidation, "openclaw", + "openclaw.json missing channels.feishu section", + "configure Feishu in OpenClaw first") + } + + raw := openclaw.ListCandidateApps(cfg.Channels.Feishu) + b.cfg = cfg + b.rawApps = raw + + result := make([]Candidate, 0, len(raw)) + for _, c := range raw { + result = append(result, Candidate{AppID: c.AppID, Label: c.Label}) + } + return result, nil +} + +func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { + if b.cfg == nil { + return nil, output.Errorf(output.ExitInternal, "openclaw", + "internal: Build called before ListCandidates") + } + + var selected *openclaw.CandidateApp + for i := range b.rawApps { + if b.rawApps[i].AppID == appID { + selected = &b.rawApps[i] + break + } + } + if selected == nil { + return nil, output.Errorf(output.ExitInternal, "openclaw", + "internal: appID %q not in candidates", appID) + } + + if selected.AppSecret.IsZero() { + return nil, output.ErrWithHint(output.ExitValidation, "openclaw", + fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path), + "configure channels.feishu.appSecret in openclaw.json") + } + secret, err := openclaw.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv) + if err != nil { + return nil, output.ErrWithHint(output.ExitValidation, "openclaw", + fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err), + fmt.Sprintf("check appSecret configuration in %s", b.path)) + } + + stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain) + if err != nil { + return nil, output.Errorf(output.ExitInternal, "openclaw", + "keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err) + } + + return &core.AppConfig{ + AppId: selected.AppID, + AppSecret: stored, + Brand: core.LarkBrand(normalizeBrand(selected.Brand)), + }, nil +} + +// ────────────────────────────────────────────────────────────── +// hermesBinder +// ────────────────────────────────────────────────────────────── + +type hermesBinder struct { + opts *BindOptions + path string + envMap map[string]string // cached between ListCandidates and Build +} + +func (b *hermesBinder) Name() string { return "hermes" } +func (b *hermesBinder) ConfigPath() string { return b.path } + +func (b *hermesBinder) ListCandidates() ([]Candidate, error) { + envMap, err := readDotenv(b.path) + if err != nil { + return nil, output.ErrWithHint(output.ExitValidation, "hermes", + fmt.Sprintf("failed to read Hermes config: %v", err), + fmt.Sprintf("verify Hermes is installed and configured at %s", b.path)) + } + appID := envMap["FEISHU_APP_ID"] + if appID == "" { + return nil, output.ErrWithHint(output.ExitValidation, "hermes", + fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path), + "run 'hermes setup' to configure Feishu credentials") + } + b.envMap = envMap + return []Candidate{{AppID: appID, Label: "default"}}, nil +} + +func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) { + if b.envMap == nil { + return nil, output.Errorf(output.ExitInternal, "hermes", + "internal: Build called before ListCandidates") + } + if b.envMap["FEISHU_APP_ID"] != appID { + return nil, output.Errorf(output.ExitInternal, "hermes", + "internal: appID %q does not match env", appID) + } + appSecret := b.envMap["FEISHU_APP_SECRET"] + if appSecret == "" { + return nil, output.ErrWithHint(output.ExitValidation, "hermes", + fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path), + "run 'hermes setup' to configure Feishu credentials") + } + + stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain) + if err != nil { + return nil, output.Errorf(output.ExitInternal, "hermes", + "keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err) + } + + return &core.AppConfig{ + AppId: appID, + AppSecret: stored, + Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])), + }, nil +} + +// ────────────────────────────────────────────────────────────── +// Source-specific helpers (path / dotenv / brand) — kept private to this package. +// Moved here from bind.go so bind.go can focus on orchestration. +// ────────────────────────────────────────────────────────────── + +// sourceDisplayName returns the user-facing label for a source identifier, +// matching the casing used in bind_messages.go (OpenClaw / Hermes). +func sourceDisplayName(source string) string { + switch source { + case "openclaw": + return "OpenClaw" + case "hermes": + return "Hermes" + default: + return source + } +} + +// normalizeBrand applies .strip().lower() and defaults to "feishu". +// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior. +func normalizeBrand(raw string) string { + s := strings.TrimSpace(strings.ToLower(raw)) + if s == "" { + return "feishu" + } + return s +} + +// resolveHermesEnvPath returns the path to Hermes's .env file. +// Respects HERMES_HOME override; defaults to ~/.hermes/.env. +// +// Note: HERMES_HOME is typically unset when users run bind from a regular +// terminal. When AI agents execute bind within a Hermes subprocess, HERMES_HOME +// may be set and should be respected. +func resolveHermesEnvPath() string { + hermesHome := os.Getenv("HERMES_HOME") + if hermesHome == "" { + home, err := vfs.UserHomeDir() + if err != nil || home == "" { + fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) + } + hermesHome = filepath.Join(home, ".hermes") + } + return filepath.Join(hermesHome, ".env") +} + +// 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 +// 2. OPENCLAW_STATE_DIR env → /openclaw.json +// 3. OPENCLAW_HOME env → /.openclaw/openclaw.json +// 4. ~/.openclaw/openclaw.json (default) +// 5. Legacy: ~/.clawdbot/clawdbot.json, ~/.openclaw/clawdbot.json +func resolveOpenClawConfigPath() string { + if p := os.Getenv("OPENCLAW_CONFIG_PATH"); p != "" { + return expandHome(p) + } + + if stateDir := os.Getenv("OPENCLAW_STATE_DIR"); stateDir != "" { + dir := expandHome(stateDir) + return findConfigInDir(dir) + } + + home := os.Getenv("OPENCLAW_HOME") + if home == "" { + h, err := vfs.UserHomeDir() + if err != nil || h == "" { + fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) + } + home = h + } else { + home = expandHome(home) + } + + newDir := filepath.Join(home, ".openclaw") + if configFile := findConfigInDir(newDir); fileExists(configFile) { + return configFile + } + + legacyDir := filepath.Join(home, ".clawdbot") + if configFile := findConfigInDir(legacyDir); fileExists(configFile) { + return configFile + } + + return filepath.Join(newDir, "openclaw.json") +} + +func findConfigInDir(dir string) string { + primary := filepath.Join(dir, "openclaw.json") + if fileExists(primary) { + return primary + } + legacy := filepath.Join(dir, "clawdbot.json") + if fileExists(legacy) { + return legacy + } + return primary +} + +func fileExists(path string) bool { + _, err := vfs.Stat(path) + return err == nil +} + +func expandHome(path string) string { + if strings.HasPrefix(path, "~/") || path == "~" { + home, err := vfs.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(home, path[1:]) + } + return path +} + +// readDotenv reads a KEY=VALUE .env file. Comments (#) and blank lines skipped. +// Matches Hermes's load_env() in hermes_cli/config.py. +func readDotenv(path string) (map[string]string, error) { + data, err := vfs.ReadFile(path) + if err != nil { + return nil, err + } + + result := make(map[string]string) + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + if key != "" { + result[key] = value + } + } + return result, nil +} diff --git a/cmd/config/binder_test.go b/cmd/config/binder_test.go new file mode 100644 index 000000000..febc994d3 --- /dev/null +++ b/cmd/config/binder_test.go @@ -0,0 +1,175 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "reflect" + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// fakeBinder is a test double for SourceBinder. selectCandidate only touches +// Name and ConfigPath (for error messages); ListCandidates/Build are not called +// from selectCandidate, so we can leave them as no-ops. +type fakeBinder struct { + name string + path string +} + +func (b *fakeBinder) Name() string { return b.name } +func (b *fakeBinder) ConfigPath() string { return b.path } +func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil } +func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil } + +// tuiUnreachable is a tuiPrompt that fails the test if called. It's the +// guardrail that proves the non-TUI decision paths really do stay out of the +// interactive prompt — otherwise a green test could still hide a silent TUI. +func tuiUnreachable(t *testing.T) func([]Candidate) (*Candidate, error) { + t.Helper() + return func([]Candidate) (*Candidate, error) { + t.Fatal("tuiPrompt must not be called in flag mode") + return nil, nil + } +} + +// assertCandidate compares the full Candidate struct via DeepEqual so that +// any future field added to Candidate is covered automatically. +func assertCandidate(t *testing.T, got *Candidate, want Candidate) { + t.Helper() + if got == nil { + t.Fatal("expected non-nil Candidate") + } + if !reflect.DeepEqual(*got, want) { + t.Errorf("candidate mismatch:\n got: %+v\n want: %+v", *got, want) + } +} + +func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) { + b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} + _, err := selectCandidate(b, nil, "", false, tuiUnreachable(t)) + 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", + }) +} + +func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) { + // Locks in the generic fallback so that any future source added to + // newBinder gets a well-formed validation error on "zero candidates" + // even before it has a bespoke error message. + b := &fakeBinder{name: "hermes", path: "/tmp/.env"} + _, err := selectCandidate(b, nil, "", false, tuiUnreachable(t)) + assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + Type: "validation", + Message: "hermes: no app configured", + }) +} + +func TestSelectCandidate_SingleCandidate_NoFlag_AutoSelect(t *testing.T) { + b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} + candidates := []Candidate{{AppID: "cli_only", Label: "default"}} + got, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertCandidate(t, got, Candidate{AppID: "cli_only", Label: "default"}) +} + +func TestSelectCandidate_AppIDFlag_ExactMatch(t *testing.T) { + b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} + candidates := []Candidate{ + {AppID: "cli_work", Label: "work"}, + {AppID: "cli_home", Label: "home"}, + } + got, err := selectCandidate(b, candidates, "cli_home", false, tuiUnreachable(t)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"}) +} + +func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) { + b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} + candidates := []Candidate{ + {AppID: "cli_work", Label: "work"}, + {AppID: "cli_home", Label: "home"}, + } + _, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t)) + assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + Type: "openclaw", + Message: `--app-id "nonexistent" not found in openclaw.json`, + Hint: "available app IDs:\n cli_work (work)\n cli_home (home)", + }) +} + +func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) { + // Flag-mode with multiple candidates and no --app-id must produce a + // validation error and the candidate list, never an interactive prompt. + // isTUI is the single gate; a real terminal alone must not trigger TUI. + b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} + candidates := []Candidate{ + {AppID: "cli_work", Label: "work"}, + {AppID: "cli_home", Label: "home"}, + } + _, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t)) + assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + Type: "openclaw", + Message: "multiple accounts in openclaw.json; pass --app-id ", + Hint: "available app IDs:\n cli_work (work)\n cli_home (home)", + }) +} + +func TestSelectCandidate_MultiCandidate_NoFlag_TUI(t *testing.T) { + b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} + candidates := []Candidate{ + {AppID: "cli_work", Label: "work"}, + {AppID: "cli_home", Label: "home"}, + } + var gotCandidates []Candidate + got, err := selectCandidate(b, candidates, "", true, func(cs []Candidate) (*Candidate, error) { + gotCandidates = cs + return &cs[1], nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Whole-slice DeepEqual so additions to Candidate propagate to this check. + if !reflect.DeepEqual(gotCandidates, candidates) { + t.Errorf("tuiPrompt received %+v, want %+v", gotCandidates, candidates) + } + assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"}) +} + +func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) { + // Even with only one candidate, a wrong --app-id must error rather than + // silently auto-selecting. An explicit mismatch is always a user mistake, + // not a reason to override their intent. + b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} + candidates := []Candidate{{AppID: "cli_only"}} + _, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t)) + 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", + }) +} + +func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) { + // An explicit --app-id short-circuits the prompt even in TUI mode: a + // flag the user typed should never be second-guessed by an interactive + // prompt asking the same question. + b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} + candidates := []Candidate{ + {AppID: "cli_a"}, + {AppID: "cli_b"}, + } + got, err := selectCandidate(b, candidates, "cli_b", true, tuiUnreachable(t)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertCandidate(t, got, Candidate{AppID: "cli_b"}) +} diff --git a/cmd/config/config.go b/cmd/config/config.go index 275309609..cf40e3920 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -18,6 +18,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { cmdutil.DisableAuthCheck(cmd) cmd.AddCommand(NewCmdConfigInit(f, nil)) + cmd.AddCommand(NewCmdConfigBind(f, nil)) cmd.AddCommand(NewCmdConfigRemove(f, nil)) cmd.AddCommand(NewCmdConfigShow(f, nil)) cmd.AddCommand(NewCmdConfigDefaultAs(f)) diff --git a/cmd/config/show.go b/cmd/config/show.go index 6e7abb9fe..7e3a19b7f 100644 --- a/cmd/config/show.go +++ b/cmd/config/show.go @@ -44,7 +44,7 @@ func configShowRun(opts *ConfigShowOptions) error { config, err := core.LoadMultiAppConfig() if err != nil { if errors.Is(err, os.ErrNotExist) { - return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init") + return notConfiguredError() } return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err) } @@ -64,6 +64,7 @@ func configShowRun(opts *ConfigShowOptions) error { users = strings.Join(userStrs, ", ") } output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "workspace": core.CurrentWorkspace().Display(), "profile": app.ProfileName(), "appId": app.AppId, "appSecret": "****", @@ -74,3 +75,18 @@ func configShowRun(opts *ConfigShowOptions) error { fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath()) return nil } + +// notConfiguredError returns the "not configured" error with a hint that +// points the user to the right next step: config init for the default local +// workspace, config bind for an Agent workspace that has not been bound yet. +func notConfiguredError() error { + ws := core.CurrentWorkspace() + if ws.IsLocal() { + return output.ErrWithHint(output.ExitValidation, "config", + "not configured", + "run: lark-cli config init") + } + return output.ErrWithHint(output.ExitValidation, ws.Display(), + fmt.Sprintf("%s context detected but lark-cli not bound to %s workspace", ws.Display(), ws.Display()), + fmt.Sprintf("run: lark-cli config bind --source %s", ws.Display())) +} diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 7df188b4b..4b53aceb9 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -253,8 +253,9 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error { } result := map[string]interface{}{ - "ok": allOK, - "checks": checks, + "ok": allOK, + "workspace": core.CurrentWorkspace().Display(), + "checks": checks, } output.PrintJson(f.IOStreams.Out, result) if !allOK { diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index c1dc10817..323fd7272 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "os" "sync" "time" @@ -40,6 +41,16 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory { IOStreams: streams, } + // Workspace detection: determines which config subtree to use. + // Must run before any config or credential load, since those paths are + // workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged. + ws := core.DetectWorkspaceFromEnv(os.Getenv) + core.SetCurrentWorkspace(ws) + + // Inject workspace-aware dir into keychain's log system. + // This breaks the core↔keychain import cycle by using a function variable. + keychain.RuntimeDirFunc = core.GetRuntimeDir + // Phase 0: FileIO provider (no dependency) f.FileIOProvider = fileio.GetProvider() diff --git a/internal/core/config.go b/internal/core/config.go index 8570d5f38..ca27a3b39 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "os" "path/filepath" "strings" "unicode/utf8" @@ -173,21 +172,15 @@ func (c *CliConfig) CanBot() bool { return c.SupportedIdentities == 0 || c.SupportedIdentities&identityBotBit != 0 } -// GetConfigDir returns the config directory path. -// If the home directory cannot be determined, it falls back to a relative path -// and prints a warning to stderr. +// GetConfigDir returns the config directory path for the current workspace. +// When workspace is local (default), this returns the same path as before +// (LARKSUITE_CLI_CONFIG_DIR or ~/.lark-cli) — fully backward-compatible. +// When workspace is openclaw/hermes, returns base/openclaw or base/hermes. func GetConfigDir() string { - if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" { - return dir - } - 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-cli") + return GetRuntimeDir() } -// GetConfigPath returns the config file path. +// GetConfigPath returns the config file path for the current workspace. func GetConfigPath() string { return filepath.Join(GetConfigDir(), "config.json") } diff --git a/internal/core/workspace.go b/internal/core/workspace.go new file mode 100644 index 000000000..eb6a7da73 --- /dev/null +++ b/internal/core/workspace.go @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "os" + "path/filepath" + "sync/atomic" + + "github.com/larksuite/cli/internal/vfs" +) + +// Workspace identifies a config isolation context. +// Each non-local workspace maps to a subdirectory under the base config dir. +type Workspace string + +const ( + // WorkspaceLocal is the default workspace. GetConfigDir returns the base + // config dir without any subdirectory — identical to pre-workspace behavior. + WorkspaceLocal Workspace = "" + + // WorkspaceOpenClaw activates when any OpenClaw-specific env signal is + // present (see DetectWorkspaceFromEnv for the full list). + WorkspaceOpenClaw Workspace = "openclaw" + + // WorkspaceHermes activates when any Hermes-specific env signal is + // present (see DetectWorkspaceFromEnv for the full list). + WorkspaceHermes Workspace = "hermes" +) + +// currentWorkspace holds the workspace for the current process invocation. +// Set once during Factory initialization; config bind's RunE may re-set it +// to the workspace being bound. Uses atomic.Value for goroutine safety +// (background registry refresh reads GetRuntimeDir concurrently with the +// Factory init that writes workspace). +var currentWorkspace atomic.Value // stores Workspace; zero value → Load returns nil → treated as Local + +// SetCurrentWorkspace sets the active workspace for this process. +func SetCurrentWorkspace(ws Workspace) { + currentWorkspace.Store(ws) +} + +// CurrentWorkspace returns the active workspace. +// Returns WorkspaceLocal if not yet set (safe default, backward-compatible). +func CurrentWorkspace() Workspace { + v := currentWorkspace.Load() + if v == nil { + return WorkspaceLocal + } + return v.(Workspace) +} + +// Display returns the user-visible workspace label. +// Used in config show, doctor, and error messages. +func (w Workspace) Display() string { + if w == WorkspaceLocal || w == "" { + return "local" + } + return string(w) +} + +// IsLocal returns true if this is the default local workspace. +func (w Workspace) IsLocal() bool { + return w == WorkspaceLocal || w == "" +} + +// DetectWorkspaceFromEnv determines the workspace from process environment. +// +// Detection is signal-based, not credential-based: we look for environment +// variables that the host Agent itself sets when launching a subprocess. +// Generic FEISHU_APP_ID / FEISHU_APP_SECRET are intentionally NOT used — +// any third-party Feishu script can set those, so they would cause +// false-positive routing into a Hermes workspace. +// +// Priority: +// 1. Any OpenClaw signal → WorkspaceOpenClaw +// - OPENCLAW_CLI == "1": subprocess marker (added 2026-03-09 via +// OpenClaw PR #41411). Most precise, but absent on older builds. +// - OPENCLAW_HOME / OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH non-empty: +// user-facing paths introduced with the 2026-01-30 rename. Detected +// so that OpenClaw builds predating the subprocess marker — or +// invocation paths that do not propagate the marker — still route +// correctly. +// 2. Any Hermes signal → WorkspaceHermes. All of the checked variables are +// set by Hermes itself (hermes_cli/main.py, gateway/run.py). No +// unrelated tool uses the HERMES_* namespace. +// - HERMES_HOME: exported by the CLI at startup +// - HERMES_QUIET == "1": exported by the gateway +// - HERMES_EXEC_ASK == "1": exported by the gateway (paired w/ QUIET) +// - HERMES_GATEWAY_TOKEN: injected into every gateway subprocess +// - HERMES_SESSION_KEY: session identifier scoped to the current chat +// 3. Otherwise → WorkspaceLocal +func DetectWorkspaceFromEnv(getenv func(string) string) Workspace { + if getenv("OPENCLAW_CLI") == "1" || + getenv("OPENCLAW_HOME") != "" || + getenv("OPENCLAW_STATE_DIR") != "" || + getenv("OPENCLAW_CONFIG_PATH") != "" { + return WorkspaceOpenClaw + } + if getenv("HERMES_HOME") != "" || + getenv("HERMES_QUIET") == "1" || + getenv("HERMES_EXEC_ASK") == "1" || + getenv("HERMES_GATEWAY_TOKEN") != "" || + getenv("HERMES_SESSION_KEY") != "" { + return WorkspaceHermes + } + return WorkspaceLocal +} + +// GetBaseConfigDir returns the root config directory, ignoring workspace. +// Priority: LARKSUITE_CLI_CONFIG_DIR env → ~/.lark-cli. +// If the home directory cannot be determined and no override is set, a +// warning is written to stderr and the path falls back to a relative +// ".lark-cli" — callers will then see an explicit I/O error at first use +// instead of a silent misconfiguration. +func GetBaseConfigDir() string { + if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" { + return dir + } + home, err := vfs.UserHomeDir() + if err != nil || home == "" { + // Fall back to a relative ".lark-cli" so the first I/O operation + // surfaces a clear "no such file or directory" error. We cannot + // emit a stderr warning here — this package has no IOStreams in + // scope, and direct writes to os.Stderr violate the IOStreams + // injection boundary (enforced by lint). Users who hit this path + // should set LARKSUITE_CLI_CONFIG_DIR explicitly. + home = "" + } + return filepath.Join(home, ".lark-cli") +} + +// GetRuntimeDir returns the workspace-aware config directory. +// - WorkspaceLocal → GetBaseConfigDir() (unchanged, backward-compatible) +// - WorkspaceOpenClaw → GetBaseConfigDir()/openclaw +// - WorkspaceHermes → GetBaseConfigDir()/hermes +func GetRuntimeDir() string { + base := GetBaseConfigDir() + ws := CurrentWorkspace() + if ws.IsLocal() { + return base + } + return filepath.Join(base, string(ws)) +} diff --git a/internal/core/workspace_test.go b/internal/core/workspace_test.go new file mode 100644 index 000000000..53223bf95 --- /dev/null +++ b/internal/core/workspace_test.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "path/filepath" + "testing" +) + +func TestDetectWorkspaceFromEnv(t *testing.T) { + tests := []struct { + name string + env map[string]string + expect Workspace + }{ + { + name: "no agent env → local", + env: map[string]string{}, + expect: WorkspaceLocal, + }, + { + name: "OPENCLAW_CLI=1 → openclaw", + env: map[string]string{"OPENCLAW_CLI": "1"}, + expect: WorkspaceOpenClaw, + }, + { + name: "OPENCLAW_CLI=true → local (strict ==1 check)", + env: map[string]string{"OPENCLAW_CLI": "true"}, + expect: WorkspaceLocal, + }, + { + name: "OPENCLAW_CLI=yes → local", + env: map[string]string{"OPENCLAW_CLI": "yes"}, + expect: WorkspaceLocal, + }, + { + name: "OPENCLAW_CLI=0 → local", + env: map[string]string{"OPENCLAW_CLI": "0"}, + expect: WorkspaceLocal, + }, + { + name: "OPENCLAW_CLI empty → local", + env: map[string]string{"OPENCLAW_CLI": ""}, + expect: WorkspaceLocal, + }, + { + name: "OPENCLAW_CLI=1 with trailing space → local (strict)", + env: map[string]string{"OPENCLAW_CLI": "1 "}, + expect: WorkspaceLocal, + }, + { + name: "generic FEISHU_APP_ID + SECRET → local (not a Hermes signal)", + env: map[string]string{"FEISHU_APP_ID": "cli_abc", "FEISHU_APP_SECRET": "xxx"}, + expect: WorkspaceLocal, + }, + { + name: "HERMES_HOME set → hermes", + env: map[string]string{"HERMES_HOME": "/Users/me/.hermes"}, + expect: WorkspaceHermes, + }, + { + name: "HERMES_QUIET=1 → hermes (set by gateway)", + env: map[string]string{"HERMES_QUIET": "1"}, + expect: WorkspaceHermes, + }, + { + name: "HERMES_EXEC_ASK=1 → hermes", + env: map[string]string{"HERMES_EXEC_ASK": "1"}, + expect: WorkspaceHermes, + }, + { + name: "HERMES_GATEWAY_TOKEN set → hermes", + env: map[string]string{"HERMES_GATEWAY_TOKEN": "69ce6b...6065"}, + expect: WorkspaceHermes, + }, + { + name: "HERMES_SESSION_KEY set → hermes", + env: map[string]string{"HERMES_SESSION_KEY": "agent:main:feishu:dm:oc_xxx"}, + expect: WorkspaceHermes, + }, + { + name: "HERMES_QUIET=0 alone → local (strict ==1 check)", + env: map[string]string{"HERMES_QUIET": "0"}, + expect: WorkspaceLocal, + }, + { + name: "OPENCLAW_CLI=1 + HERMES_HOME both set → openclaw wins (priority)", + env: map[string]string{"OPENCLAW_CLI": "1", "HERMES_HOME": "/Users/me/.hermes"}, + expect: WorkspaceOpenClaw, + }, + { + name: "FEISHU_APP_ID + HERMES_HOME → hermes (HERMES_ signals suffice)", + env: map[string]string{"FEISHU_APP_ID": "cli_abc", "FEISHU_APP_SECRET": "xxx", "HERMES_HOME": "/Users/me/.hermes"}, + expect: WorkspaceHermes, + }, + { + name: "OPENCLAW_HOME set → openclaw (older OpenClaw builds without subprocess marker)", + env: map[string]string{"OPENCLAW_HOME": "/Users/me/.openclaw"}, + expect: WorkspaceOpenClaw, + }, + { + name: "OPENCLAW_STATE_DIR set → openclaw", + env: map[string]string{"OPENCLAW_STATE_DIR": "/srv/openclaw/state"}, + expect: WorkspaceOpenClaw, + }, + { + name: "OPENCLAW_CONFIG_PATH set → openclaw", + env: map[string]string{"OPENCLAW_CONFIG_PATH": "/etc/openclaw/openclaw.json"}, + expect: WorkspaceOpenClaw, + }, + { + name: "OPENCLAW_HOME + FEISHU both set → openclaw wins (priority)", + env: map[string]string{"OPENCLAW_HOME": "/Users/me/.openclaw", "FEISHU_APP_ID": "cli_abc", "FEISHU_APP_SECRET": "xxx"}, + expect: WorkspaceOpenClaw, + }, + { + name: "LARKSUITE_CLI_APP_ID does not affect workspace", + env: map[string]string{"LARKSUITE_CLI_APP_ID": "cli_local", "LARKSUITE_CLI_APP_SECRET": "local_secret"}, + expect: WorkspaceLocal, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getenv := func(key string) string { return tt.env[key] } + got := DetectWorkspaceFromEnv(getenv) + if got != tt.expect { + t.Errorf("DetectWorkspaceFromEnv() = %q, want %q", got, tt.expect) + } + }) + } +} + +func TestWorkspaceDisplay(t *testing.T) { + tests := []struct { + ws Workspace + expect string + }{ + {WorkspaceLocal, "local"}, + {Workspace(""), "local"}, + {WorkspaceOpenClaw, "openclaw"}, + {WorkspaceHermes, "hermes"}, + } + for _, tt := range tests { + if got := tt.ws.Display(); got != tt.expect { + t.Errorf("Workspace(%q).Display() = %q, want %q", tt.ws, got, tt.expect) + } + } +} + +func TestWorkspaceIsLocal(t *testing.T) { + if !WorkspaceLocal.IsLocal() { + t.Error("WorkspaceLocal.IsLocal() should be true") + } + if !Workspace("").IsLocal() { + t.Error(`Workspace("").IsLocal() should be true`) + } + if WorkspaceOpenClaw.IsLocal() { + t.Error("WorkspaceOpenClaw.IsLocal() should be false") + } +} + +func TestSetCurrentWorkspace(t *testing.T) { + orig := CurrentWorkspace() + defer SetCurrentWorkspace(orig) + + SetCurrentWorkspace(WorkspaceOpenClaw) + if got := CurrentWorkspace(); got != WorkspaceOpenClaw { + t.Errorf("CurrentWorkspace() = %q, want %q", got, WorkspaceOpenClaw) + } + + SetCurrentWorkspace(WorkspaceLocal) + if got := CurrentWorkspace(); got != WorkspaceLocal { + t.Errorf("CurrentWorkspace() = %q, want %q", got, WorkspaceLocal) + } +} + +func TestGetRuntimeDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + + orig := CurrentWorkspace() + defer SetCurrentWorkspace(orig) + + // Local → base dir (same as pre-workspace behavior) + SetCurrentWorkspace(WorkspaceLocal) + if got := GetRuntimeDir(); got != tmp { + t.Errorf("local: GetRuntimeDir() = %q, want %q", got, tmp) + } + if got := GetConfigDir(); got != tmp { + t.Errorf("local: GetConfigDir() = %q, want %q", got, tmp) + } + + // OpenClaw → base/openclaw + SetCurrentWorkspace(WorkspaceOpenClaw) + want := filepath.Join(tmp, "openclaw") + if got := GetRuntimeDir(); got != want { + t.Errorf("openclaw: GetRuntimeDir() = %q, want %q", got, want) + } + + // Hermes → base/hermes + SetCurrentWorkspace(WorkspaceHermes) + want = filepath.Join(tmp, "hermes") + if got := GetRuntimeDir(); got != want { + t.Errorf("hermes: GetRuntimeDir() = %q, want %q", got, want) + } +} + +func TestGetConfigPath(t *testing.T) { + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + + orig := CurrentWorkspace() + defer SetCurrentWorkspace(orig) + + SetCurrentWorkspace(WorkspaceLocal) + want := filepath.Join(tmp, "config.json") + if got := GetConfigPath(); got != want { + t.Errorf("local: GetConfigPath() = %q, want %q", got, want) + } + + SetCurrentWorkspace(WorkspaceOpenClaw) + want = filepath.Join(tmp, "openclaw", "config.json") + if got := GetConfigPath(); got != want { + t.Errorf("openclaw: GetConfigPath() = %q, want %q", got, want) + } +} diff --git a/internal/keychain/auth_log.go b/internal/keychain/auth_log.go index c74791267..7b175942c 100644 --- a/internal/keychain/auth_log.go +++ b/internal/keychain/auth_log.go @@ -16,6 +16,29 @@ import ( "github.com/larksuite/cli/internal/vfs" ) +// RuntimeDirFunc returns the workspace-aware config directory. +// Default: falls back to LARKSUITE_CLI_CONFIG_DIR or ~/.lark-cli (pre-workspace behavior). +// Injected by cmdutil.NewDefault → core.GetRuntimeDir after workspace detection. +// This avoids an import cycle (core → keychain → core). +var RuntimeDirFunc = defaultRuntimeDir + +func defaultRuntimeDir() string { + if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" { + return dir + } + home, err := vfs.UserHomeDir() + if err != nil || home == "" { + // Silent fallback to a relative ".lark-cli": this package has no + // IOStreams in scope, so we cannot surface a warning here without + // violating the IOStreams injection boundary (enforced by lint). + // Users who hit this path should set LARKSUITE_CLI_CONFIG_DIR + // explicitly; the relative path will otherwise surface as an + // explicit I/O error at first use. + home = "" + } + return filepath.Join(home, ".lark-cli") +} + var ( authResponseLogger *log.Logger authResponseLoggerOnce = &sync.Once{} @@ -25,6 +48,8 @@ var ( ) func authLogDir() string { + // LARKSUITE_CLI_LOG_DIR is the highest-priority override. + // When set, it bypasses workspace subtree routing entirely. if dir := os.Getenv("LARKSUITE_CLI_LOG_DIR"); dir != "" { safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_LOG_DIR") if err == nil { @@ -32,16 +57,10 @@ func authLogDir() string { } } - if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" { - return filepath.Join(dir, "logs") - } - - 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-cli", "logs") + // Fall back to the workspace-aware runtime dir. RuntimeDirFunc is injected + // by factory after workspace detection; before injection it defaults to + // the pre-workspace behavior so older call paths remain correct. + return filepath.Join(RuntimeDirFunc(), "logs") } func initAuthLogger() { diff --git a/internal/openclaw/audit.go b/internal/openclaw/audit.go new file mode 100644 index 000000000..119d28505 --- /dev/null +++ b/internal/openclaw/audit.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/vfs" +) + +// AuditParams holds parameters for AssertSecurePath. +type AuditParams struct { + TargetPath string + Label string // e.g. "secrets.providers.vault.command" + TrustedDirs []string + AllowInsecurePath bool + AllowReadableByOthers bool + AllowSymlinkPath bool +} + +// AssertSecurePath verifies that a file/command path is safe for use with +// OpenClaw SecretRef resolution. On success it returns the effective path +// (the symlink target, if the input was a symlink and allowed). +// +// The check is a short, ordered pipeline — each step below is both a read of +// the contract and a pointer to the helper that enforces it. +func AssertSecurePath(params AuditParams) (string, error) { + target := params.TargetPath + label := params.Label + + if err := requireAbsolutePath(target, label); err != nil { + return "", err + } + + linfo, err := lstatNonDir(target, label) + if err != nil { + return "", err + } + + effectivePath, err := resolveSymlinkIfAllowed(target, linfo, params) + if err != nil { + return "", err + } + + if err := requireInTrustedDirs(effectivePath, params.TrustedDirs, label); err != nil { + return "", err + } + + if params.AllowInsecurePath { + return effectivePath, nil + } + + if err := auditFilePermissions(effectivePath, params.AllowReadableByOthers, label); err != nil { + return "", err + } + if err := checkOwnerUID(effectivePath, label); err != nil { + return "", err + } + return effectivePath, nil +} + +// requireAbsolutePath rejects relative paths; relative paths would depend on +// the process cwd and defeat the point of a static audit. +func requireAbsolutePath(target, label string) error { + if !filepath.IsAbs(target) { + return fmt.Errorf("%s: path must be absolute, got %q", label, target) + } + return nil +} + +// lstatNonDir stats the path without following symlinks, rejecting +// directories. Returns the stat info for downstream steps to reuse. +func lstatNonDir(target, label string) (fs.FileInfo, error) { + info, err := vfs.Lstat(target) + if err != nil { + return nil, fmt.Errorf("%s: cannot stat %q: %w", label, target, err) + } + if info.IsDir() { + return nil, fmt.Errorf("%s: path %q is a directory, not a file", label, target) + } + return info, nil +} + +// resolveSymlinkIfAllowed resolves a symlink to its target when +// params.AllowSymlinkPath is true, or rejects it otherwise. When the input +// is not a symlink, target is returned unchanged. A symlink that points to +// another symlink is rejected so callers only deal with a single hop. +func resolveSymlinkIfAllowed(target string, linfo fs.FileInfo, params AuditParams) (string, error) { + if linfo.Mode()&os.ModeSymlink == 0 { + return target, nil + } + if !params.AllowSymlinkPath { + return "", fmt.Errorf("%s: path %q is a symlink (not allowed)", params.Label, target) + } + resolved, err := vfs.EvalSymlinks(target) + if err != nil { + return "", fmt.Errorf("%s: cannot resolve symlink %q: %w", params.Label, target, err) + } + rinfo, err := vfs.Lstat(resolved) + if err != nil { + return "", fmt.Errorf("%s: cannot stat resolved path %q: %w", params.Label, resolved, err) + } + if rinfo.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%s: resolved path %q is still a symlink", params.Label, resolved) + } + return resolved, nil +} + +// requireInTrustedDirs enforces that effectivePath lives under one of the +// caller-declared trusted directories, if any were declared. An empty +// trustedDirs list disables the check. +func requireInTrustedDirs(effectivePath string, trustedDirs []string, label string) error { + if len(trustedDirs) == 0 { + return nil + } + cleaned := filepath.Clean(effectivePath) + for _, dir := range trustedDirs { + cleanDir := filepath.Clean(dir) + if cleaned == cleanDir || strings.HasPrefix(cleaned, cleanDir+"/") { + return nil + } + } + return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath) +} + +// auditFilePermissions rejects world/group-writable modes (always) and +// world/group-readable modes (unless allowReadableByOthers is true, which +// exec commands typically need for their usual 755 mode). +func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error { + info, err := vfs.Stat(effectivePath) + if err != nil { + return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err) + } + mode := info.Mode().Perm() + + if mode&0o002 != 0 { + return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode) + } + if mode&0o020 != 0 { + return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode) + } + if allowReadableByOthers { + return nil + } + if mode&0o004 != 0 { + return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode) + } + if mode&0o040 != 0 { + return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode) + } + return nil +} diff --git a/internal/openclaw/audit_test.go b/internal/openclaw/audit_test.go new file mode 100644 index 000000000..ff1ab6aec --- /dev/null +++ b/internal/openclaw/audit_test.go @@ -0,0 +1,363 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestAssertSecurePath_NonAbsolutePath(t *testing.T) { + _, err := AssertSecurePath(AuditParams{ + TargetPath: "relative/path.txt", + Label: "test", + AllowInsecurePath: true, + }) + if err == nil { + t.Fatal("expected error for non-absolute path, got nil") + } + want := fmt.Sprintf("test: path must be absolute, got %q", "relative/path.txt") + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestAssertSecurePath_FileDoesNotExist(t *testing.T) { + nonexistent := filepath.Join(t.TempDir(), "nonexistent.txt") + _, err := AssertSecurePath(AuditParams{ + TargetPath: nonexistent, + Label: "test", + AllowInsecurePath: true, + }) + if err == nil { + t.Fatal("expected error for non-existent file, got nil") + } + wantPrefix := fmt.Sprintf("test: cannot stat %q: ", nonexistent) + if !strings.HasPrefix(err.Error(), wantPrefix) { + t.Errorf("error = %q, want prefix %q", err.Error(), wantPrefix) + } +} + +func TestAssertSecurePath_ValidAbsolutePath(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "valid.txt") + if err := os.WriteFile(p, []byte("data"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + got, err := AssertSecurePath(AuditParams{ + TargetPath: p, + Label: "test", + AllowInsecurePath: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != p { + t.Errorf("got %q, want %q", got, p) + } +} + +func TestAssertSecurePath_WorldWritable_Rejected(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission tests not applicable on Windows") + } + + dir := t.TempDir() + p := filepath.Join(dir, "insecure.txt") + if err := os.WriteFile(p, []byte("data"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + if err := os.Chmod(p, 0o666); err != nil { + t.Fatalf("chmod: %v", err) + } + + _, err := AssertSecurePath(AuditParams{ + TargetPath: p, + Label: "test", + AllowInsecurePath: false, + AllowReadableByOthers: true, // only test writable check + }) + if err == nil { + t.Fatal("expected error for world-writable file, got nil") + } + want := fmt.Sprintf("test: path %q is world-writable (mode 0666)", p) + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestAssertSecurePath_AllowInsecurePath_Bypasses(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission tests not applicable on Windows") + } + + dir := t.TempDir() + p := filepath.Join(dir, "insecure.txt") + if err := os.WriteFile(p, []byte("data"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + if err := os.Chmod(p, 0o666); err != nil { + t.Fatalf("chmod: %v", err) + } + + got, err := AssertSecurePath(AuditParams{ + TargetPath: p, + Label: "test", + AllowInsecurePath: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != p { + t.Errorf("got %q, want %q", got, p) + } +} + +func TestAssertSecurePath_DirectoryRejected(t *testing.T) { + dir := t.TempDir() + _, err := AssertSecurePath(AuditParams{ + TargetPath: dir, + Label: "test", + AllowInsecurePath: true, + }) + if err == nil { + t.Fatal("expected error for directory path, got nil") + } + want := fmt.Sprintf("test: path %q is a directory, not a file", dir) + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestAssertSecurePath_GroupWritable_Rejected(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission tests not applicable on Windows") + } + dir := t.TempDir() + p := filepath.Join(dir, "groupw.txt") + if err := os.WriteFile(p, []byte("data"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.Chmod(p, 0o620); err != nil { + t.Fatalf("chmod: %v", err) + } + _, err := AssertSecurePath(AuditParams{ + TargetPath: p, + Label: "test", + AllowInsecurePath: false, + AllowReadableByOthers: true, + }) + if err == nil { + t.Fatal("expected error for group-writable file, got nil") + } + want := fmt.Sprintf("test: path %q is group-writable (mode 0620)", p) + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestAssertSecurePath_WorldReadable_Rejected(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission tests not applicable on Windows") + } + dir := t.TempDir() + p := filepath.Join(dir, "worldr.txt") + if err := os.WriteFile(p, []byte("data"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.Chmod(p, 0o604); err != nil { + t.Fatalf("chmod: %v", err) + } + _, err := AssertSecurePath(AuditParams{ + TargetPath: p, + Label: "test", + AllowInsecurePath: false, + AllowReadableByOthers: false, + }) + if err == nil { + t.Fatal("expected error for world-readable file, got nil") + } + want := fmt.Sprintf("test: path %q is world-readable (mode 0604)", p) + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestAssertSecurePath_AllowReadableByOthers_Passes(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission tests not applicable on Windows") + } + dir := t.TempDir() + p := filepath.Join(dir, "readable.txt") + if err := os.WriteFile(p, []byte("data"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.Chmod(p, 0o644); err != nil { + t.Fatalf("chmod: %v", err) + } + got, err := AssertSecurePath(AuditParams{ + TargetPath: p, + Label: "test", + AllowInsecurePath: false, + AllowReadableByOthers: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != p { + t.Errorf("got %q, want %q", got, p) + } +} + +func TestAssertSecurePath_OwnerUID_Valid(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("owner UID tests not applicable on Windows") + } + dir := t.TempDir() + p := filepath.Join(dir, "owned.txt") + if err := os.WriteFile(p, []byte("data"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + got, err := AssertSecurePath(AuditParams{ + TargetPath: p, + Label: "test", + AllowInsecurePath: false, + AllowReadableByOthers: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != p { + t.Errorf("got %q, want %q", got, p) + } +} + +func TestAssertSecurePath_Symlink_Rejected(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink tests not applicable on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "real.txt") + if err := os.WriteFile(target, []byte("data"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + link := filepath.Join(dir, "link.txt") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + _, err := AssertSecurePath(AuditParams{ + TargetPath: link, + Label: "test", + AllowSymlinkPath: false, + AllowInsecurePath: true, + }) + if err == nil { + t.Fatal("expected error for symlink with AllowSymlinkPath=false, got nil") + } + want := fmt.Sprintf("test: path %q is a symlink (not allowed)", link) + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestAssertSecurePath_Symlink_Allowed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink tests not applicable on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "real.txt") + if err := os.WriteFile(target, []byte("data"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + link := filepath.Join(dir, "link.txt") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + got, err := AssertSecurePath(AuditParams{ + TargetPath: link, + Label: "test", + AllowSymlinkPath: true, + AllowInsecurePath: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // On macOS /var → /private/var, so compare resolved paths + wantResolved, err := filepath.EvalSymlinks(target) + if err != nil { + t.Fatalf("EvalSymlinks(target): %v", err) + } + if got != wantResolved { + t.Errorf("got %q, want resolved %q", got, wantResolved) + } +} + +func TestAssertSecurePath_TrustedDirs_ExactMatch(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "file.txt") + if err := os.WriteFile(p, []byte("data"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + got, err := AssertSecurePath(AuditParams{ + TargetPath: p, + Label: "test", + TrustedDirs: []string{p}, + AllowInsecurePath: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != p { + t.Errorf("got %q, want %q", got, p) + } +} + +func TestAssertSecurePath_TrustedDirs(t *testing.T) { + trustedDir := t.TempDir() + untrustedDir := t.TempDir() + + trustedFile := filepath.Join(trustedDir, "secret.txt") + if err := os.WriteFile(trustedFile, []byte("data"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + untrustedFile := filepath.Join(untrustedDir, "secret.txt") + if err := os.WriteFile(untrustedFile, []byte("data"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + // File outside trusted dir should fail + _, err := AssertSecurePath(AuditParams{ + TargetPath: untrustedFile, + Label: "test", + TrustedDirs: []string{trustedDir}, + AllowInsecurePath: true, + }) + if err == nil { + t.Fatal("expected error for file outside trusted dir, got nil") + } + want := fmt.Sprintf("test: path %q is not inside any trusted directory", untrustedFile) + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } + + // File inside trusted dir should pass + got, err := AssertSecurePath(AuditParams{ + TargetPath: trustedFile, + Label: "test", + TrustedDirs: []string{trustedDir}, + AllowInsecurePath: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != trustedFile { + t.Errorf("got %q, want %q", got, trustedFile) + } +} diff --git a/internal/openclaw/audit_unix.go b/internal/openclaw/audit_unix.go new file mode 100644 index 000000000..506427c54 --- /dev/null +++ b/internal/openclaw/audit_unix.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package openclaw + +import ( + "fmt" + "os" + "syscall" + + "github.com/larksuite/cli/internal/vfs" +) + +// checkOwnerUID verifies the file is owned by the current user. +func checkOwnerUID(path, label string) error { + stat, err := vfs.Stat(path) + if err != nil { + return fmt.Errorf("%s: cannot stat %q: %w", label, path, err) + } + sysStat, ok := stat.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("%s: cannot retrieve file owner for %q", label, path) + } + if sysStat.Uid != uint32(os.Getuid()) { + return fmt.Errorf("%s: path %q is owned by uid %d, expected %d", + label, path, sysStat.Uid, os.Getuid()) + } + return nil +} diff --git a/internal/openclaw/audit_windows.go b/internal/openclaw/audit_windows.go new file mode 100644 index 000000000..e214d42d1 --- /dev/null +++ b/internal/openclaw/audit_windows.go @@ -0,0 +1,11 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build windows + +package openclaw + +// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply. +func checkOwnerUID(path, label string) error { + return nil +} diff --git a/internal/openclaw/json_pointer.go b/internal/openclaw/json_pointer.go new file mode 100644 index 000000000..37ad1675b --- /dev/null +++ b/internal/openclaw/json_pointer.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "fmt" + "strings" +) + +// ReadJSONPointer navigates a parsed JSON value (typically the result of +// json.Unmarshal into interface{}) using an RFC 6901 JSON Pointer string. +// +// Supported pointer format: "/key/subkey/subsubkey". +// An empty pointer ("") returns data as-is. +// RFC 6901 escape sequences: ~1 → /, ~0 → ~. +// +// Limitation: only object (map) traversal is supported. Array index segments +// (e.g., "/channels/0/appId") are not implemented because OpenClaw's +// SecretRef file provider uses object-only paths in practice. +func ReadJSONPointer(data interface{}, pointer string) (interface{}, error) { + if pointer == "" { + return data, nil + } + + if !strings.HasPrefix(pointer, "/") { + return nil, fmt.Errorf("json pointer must start with '/' or be empty, got %q", pointer) + } + + // Split after the leading "/" and decode each segment. + segments := strings.Split(pointer[1:], "/") + current := data + + for i, raw := range segments { + // RFC 6901 unescaping: ~1 → /, ~0 → ~ (order matters). + key := strings.ReplaceAll(raw, "~1", "/") + key = strings.ReplaceAll(key, "~0", "~") + + m, ok := current.(map[string]interface{}) + if !ok { + traversed := "/" + strings.Join(segments[:i], "/") + return nil, fmt.Errorf("json pointer %q: value at %q is %T, not an object", + pointer, traversed, current) + } + + val, exists := m[key] + if !exists { + return nil, fmt.Errorf("json pointer %q: key %q not found", pointer, key) + } + + current = val + } + + return current, nil +} diff --git a/internal/openclaw/json_pointer_test.go b/internal/openclaw/json_pointer_test.go new file mode 100644 index 000000000..e96dc9200 --- /dev/null +++ b/internal/openclaw/json_pointer_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "testing" +) + +func TestReadJSONPointer_EmptyPointer(t *testing.T) { + data := map[string]interface{}{"key": "value"} + got, err := ReadJSONPointer(data, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + m, ok := got.(map[string]interface{}) + if !ok { + t.Fatalf("expected map, got %T", got) + } + if m["key"] != "value" { + t.Errorf("got %v, want map with key=value", m) + } +} + +func TestReadJSONPointer_OneLevel(t *testing.T) { + data := map[string]interface{}{"key": "hello"} + got, err := ReadJSONPointer(data, "/key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "hello" { + t.Errorf("got %v, want %q", got, "hello") + } +} + +func TestReadJSONPointer_TwoLevels(t *testing.T) { + data := map[string]interface{}{ + "key": map[string]interface{}{ + "subkey": "deep_value", + }, + } + got, err := ReadJSONPointer(data, "/key/subkey") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "deep_value" { + t.Errorf("got %v, want %q", got, "deep_value") + } +} + +func TestReadJSONPointer_MissingKey(t *testing.T) { + data := map[string]interface{}{"key": "value"} + _, err := ReadJSONPointer(data, "/nonexistent") + if err == nil { + t.Fatal("expected error for missing key, got nil") + } + want := `json pointer "/nonexistent": key "nonexistent" not found` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestReadJSONPointer_NonMapIntermediate(t *testing.T) { + data := map[string]interface{}{"key": "scalar_string"} + _, err := ReadJSONPointer(data, "/key/subkey") + if err == nil { + t.Fatal("expected error for non-map intermediate, got nil") + } + want := `json pointer "/key/subkey": value at "/key" is string, not an object` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestReadJSONPointer_RFC6901_Escaping(t *testing.T) { + // ~1 decodes to / and ~0 decodes to ~ + data := map[string]interface{}{ + "a/b": "slash_value", + "c~d": "tilde_value", + } + + // ~1 -> / + got, err := ReadJSONPointer(data, "/a~1b") + if err != nil { + t.Fatalf("unexpected error for ~1 escape: %v", err) + } + if got != "slash_value" { + t.Errorf("got %v, want %q", got, "slash_value") + } + + // ~0 -> ~ + got, err = ReadJSONPointer(data, "/c~0d") + if err != nil { + t.Fatalf("unexpected error for ~0 escape: %v", err) + } + if got != "tilde_value" { + t.Errorf("got %v, want %q", got, "tilde_value") + } +} + +func TestReadJSONPointer_InvalidFormat(t *testing.T) { + data := map[string]interface{}{"key": "val"} + _, err := ReadJSONPointer(data, "no-leading-slash") + if err == nil { + t.Fatal("expected error for pointer without leading /") + } + want := `json pointer must start with '/' or be empty, got "no-leading-slash"` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} diff --git a/internal/openclaw/reader.go b/internal/openclaw/reader.go new file mode 100644 index 000000000..305ed8212 --- /dev/null +++ b/internal/openclaw/reader.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/vfs" +) + +// ReadOpenClawConfig reads and parses an openclaw.json file at the given path. +func ReadOpenClawConfig(path string) (*OpenClawRoot, error) { + data, err := vfs.ReadFile(path) + if err != nil { + return nil, err // caller (bind.go) formats user-facing message with path context + } + + var root OpenClawRoot + 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/openclaw/reader_test.go b/internal/openclaw/reader_test.go new file mode 100644 index 000000000..ee001aeae --- /dev/null +++ b/internal/openclaw/reader_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadOpenClawConfig_ValidSingleAccount(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "openclaw.json") + data := `{"channels":{"feishu":{"appId":"cli_abc","appSecret":"plain_secret","brand":"feishu"}}}` + if err := os.WriteFile(p, []byte(data), 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + + root, err := ReadOpenClawConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if root.Channels.Feishu == nil { + t.Fatal("expected Channels.Feishu to be non-nil") + } + if got := root.Channels.Feishu.AppID; got != "cli_abc" { + t.Errorf("AppID = %q, want %q", got, "cli_abc") + } + if got := root.Channels.Feishu.AppSecret.Plain; got != "plain_secret" { + t.Errorf("AppSecret.Plain = %q, want %q", got, "plain_secret") + } + if root.Channels.Feishu.AppSecret.Ref != nil { + t.Error("AppSecret.Ref should be nil for a plain string") + } + if got := root.Channels.Feishu.Brand; got != "feishu" { + t.Errorf("Brand = %q, want %q", got, "feishu") + } +} + +func TestReadOpenClawConfig_ValidMultiAccount(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "openclaw.json") + data := `{ + "channels": { + "feishu": { + "brand": "feishu", + "accounts": { + "work": {"appId": "cli_work", "appSecret": "secret_work", "brand": "feishu"}, + "personal": {"appId": "cli_personal", "appSecret": "secret_personal", "brand": "lark"} + } + } + } + }` + if err := os.WriteFile(p, []byte(data), 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + + root, err := ReadOpenClawConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if root.Channels.Feishu == nil { + t.Fatal("expected Channels.Feishu to be non-nil") + } + + apps := ListCandidateApps(root.Channels.Feishu) + if len(apps) != 2 { + t.Fatalf("ListCandidateApps returned %d apps, want 2", len(apps)) + } + + byLabel := make(map[string]CandidateApp, len(apps)) + for _, a := range apps { + byLabel[a.Label] = a + } + + work, ok := byLabel["work"] + if !ok { + t.Fatal("missing account label 'work'") + } + if work.AppID != "cli_work" { + t.Errorf("work.AppID = %q, want %q", work.AppID, "cli_work") + } + + personal, ok := byLabel["personal"] + if !ok { + t.Fatal("missing account label 'personal'") + } + if personal.AppID != "cli_personal" { + t.Errorf("personal.AppID = %q, want %q", personal.AppID, "cli_personal") + } +} + +func TestReadOpenClawConfig_MissingFeishu(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "openclaw.json") + data := `{"channels":{}}` + if err := os.WriteFile(p, []byte(data), 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + + root, err := ReadOpenClawConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if root.Channels.Feishu != nil { + t.Error("expected Channels.Feishu to be nil when not present in JSON") + } +} + +func TestReadOpenClawConfig_InvalidJSON(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "openclaw.json") + if err := os.WriteFile(p, []byte(`{not valid json`), 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + + _, err := ReadOpenClawConfig(p) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestReadOpenClawConfig_FileNotFound(t *testing.T) { + _, err := ReadOpenClawConfig(filepath.Join(t.TempDir(), "nonexistent.json")) + if err == nil { + t.Fatal("expected error for non-existent file, got nil") + } +} + +func TestReadOpenClawConfig_EnvTemplate(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "openclaw.json") + data := `{"channels":{"feishu":{"appId":"cli_env","appSecret":"${FEISHU_APP_SECRET}","brand":"feishu"}}}` + if err := os.WriteFile(p, []byte(data), 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + + root, err := ReadOpenClawConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + secret := root.Channels.Feishu.AppSecret + if secret.Plain != "${FEISHU_APP_SECRET}" { + t.Errorf("SecretInput.Plain = %q, want %q", secret.Plain, "${FEISHU_APP_SECRET}") + } + if secret.Ref != nil { + t.Error("SecretInput.Ref should be nil for env template string") + } +} + +func TestReadOpenClawConfig_SecretRefObject(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "openclaw.json") + data := `{"channels":{"feishu":{"appId":"cli_ref","appSecret":{"source":"file","provider":"fp","id":"/path"},"brand":"feishu"}}}` + if err := os.WriteFile(p, []byte(data), 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + + root, err := ReadOpenClawConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + secret := root.Channels.Feishu.AppSecret + if secret.Plain != "" { + t.Errorf("SecretInput.Plain = %q, want empty for object form", secret.Plain) + } + if secret.Ref == nil { + t.Fatal("SecretInput.Ref should be non-nil for object form") + } + if secret.Ref.Source != "file" { + t.Errorf("Ref.Source = %q, want %q", secret.Ref.Source, "file") + } + if secret.Ref.Provider != "fp" { + t.Errorf("Ref.Provider = %q, want %q", secret.Ref.Provider, "fp") + } + if secret.Ref.ID != "/path" { + t.Errorf("Ref.ID = %q, want %q", secret.Ref.ID, "/path") + } +} diff --git a/internal/openclaw/secret_resolve.go b/internal/openclaw/secret_resolve.go new file mode 100644 index 000000000..531f0ad00 --- /dev/null +++ b/internal/openclaw/secret_resolve.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "fmt" + "os" +) + +// ResolveSecretInput resolves a SecretInput to a plain-text secret string. +// This is the main dispatcher that handles all SecretInput forms: +// - Plain string passthrough +// - "${VAR_NAME}" env template expansion +// - SecretRef object routing to env/file/exec sub-resolvers +// +// The getenv parameter allows injection for testing (typically os.Getenv). +// This function is only called during config bind (cold path). +func ResolveSecretInput(input SecretInput, cfg *SecretsConfig, getenv func(string) string) (string, error) { + if getenv == nil { + getenv = os.Getenv + } + + if input.IsZero() { + return "", fmt.Errorf("appSecret is missing or empty") + } + + // Plain string form (includes env templates) + if input.IsPlain() { + return resolvePlainOrTemplate(input.Plain, getenv) + } + + // SecretRef object form + return resolveSecretRef(input.Ref, cfg, getenv) +} + +// resolvePlainOrTemplate handles plain strings and "${VAR}" templates. +func resolvePlainOrTemplate(value string, getenv func(string) string) (string, error) { + if value == "" { + return "", fmt.Errorf("appSecret is empty string") + } + + // Check for env template pattern: "${VAR_NAME}" + matches := EnvTemplateRe.FindStringSubmatch(value) + if matches != nil { + varName := matches[1] + envValue := getenv(varName) + if envValue == "" { + return "", fmt.Errorf("env variable %q referenced in openclaw.json is not set or empty", varName) + } + return envValue, nil + } + + // Plain string: use as-is + return value, nil +} + +// resolveSecretRef dispatches a SecretRef to the appropriate sub-resolver. +func resolveSecretRef(ref *SecretRef, cfg *SecretsConfig, getenv func(string) string) (string, error) { + // Lookup provider configuration + providerConfig, err := LookupProvider(ref, cfg) + if err != nil { + return "", err + } + + // Resolve the effective provider name once so downstream resolvers + // (notably the exec JSON payload) see the config-defaulted value instead + // of the unset literal on ref.Provider. + providerName := ResolveDefaultProvider(ref, cfg) + + switch ref.Source { + case "env": + return resolveEnvRef(ref, providerConfig, getenv) + case "file": + return resolveFileRef(ref, providerConfig) + case "exec": + return resolveExecRef(ref, providerName, providerConfig, getenv) + default: + return "", fmt.Errorf("unsupported secret source %q", ref.Source) + } +} + +// resolveEnvRef handles {source:"env"} SecretRef. +func resolveEnvRef(ref *SecretRef, pc *ProviderConfig, getenv func(string) string) (string, error) { + // Check allowlist if configured + if len(pc.Allowlist) > 0 { + allowed := false + for _, name := range pc.Allowlist { + if name == ref.ID { + allowed = true + break + } + } + if !allowed { + return "", fmt.Errorf("environment variable %q is not allowlisted in provider", ref.ID) + } + } + + value := getenv(ref.ID) + if value == "" { + return "", fmt.Errorf("environment variable %q is missing or empty", ref.ID) + } + return value, nil +} diff --git a/internal/openclaw/secret_resolve_exec.go b/internal/openclaw/secret_resolve_exec.go new file mode 100644 index 000000000..1e1e6b2e5 --- /dev/null +++ b/internal/openclaw/secret_resolve_exec.go @@ -0,0 +1,241 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "time" +) + +// execRequest is the JSON payload sent to exec provider's stdin. +type execRequest struct { + ProtocolVersion int `json:"protocolVersion"` + Provider string `json:"provider"` + IDs []string `json:"ids"` +} + +// execResponse is the JSON payload expected from exec provider's stdout. +type execResponse struct { + ProtocolVersion int `json:"protocolVersion"` + Values map[string]interface{} `json:"values"` + Errors map[string]execRefError `json:"errors,omitempty"` +} + +// execRefError is an optional per-id error in exec provider response. +type execRefError struct { + Message string `json:"message"` +} + +// execRun bundles everything runExecCommand needs to spawn the child process. +// It is populated once by prepareExecRun and consumed exactly once by +// runExecCommand; keeping the two stages pure data + pure side effect makes +// each independently testable. +type execRun struct { + Path string // absolute, already-audited path to the command + Args []string // command arguments (from pc.Args) + Env []string // minimal child env (passEnv + explicit env only) + Request []byte // JSON payload to feed on the child's stdin + Timeout time.Duration // spawn deadline + MaxOut int // hard cap on stdout size, enforced post-Run +} + +// resolveExecRef handles {source:"exec"} SecretRef resolution. It audits the +// command path, runs the child under a timeout with a hard stdout cap, and +// extracts the secret from the JSON response. providerName is the caller- +// resolved effective alias (honours secrets.defaults.exec from openclaw.json). +func resolveExecRef(ref *SecretRef, providerName string, pc *ProviderConfig, getenv func(string) string) (string, error) { + prep, err := prepareExecRun(ref, providerName, pc, getenv) + if err != nil { + return "", err + } + stdout, err := runExecCommand(prep) + if err != nil { + return "", err + } + return extractExecSecret(stdout, ref.ID, effectiveJSONOnly(pc)) +} + +// prepareExecRun audits the command path, marshals the JSON request, +// assembles the minimal child env, and resolves timeout / output limits. +// Never spawns a process — the returned execRun is pure data. +func prepareExecRun(ref *SecretRef, providerName string, pc *ProviderConfig, getenv func(string) string) (*execRun, error) { + if pc.Command == "" { + return nil, fmt.Errorf("exec provider command is empty") + } + + securePath, err := AssertSecurePath(AuditParams{ + TargetPath: pc.Command, + Label: "exec provider command", + TrustedDirs: pc.TrustedDirs, + AllowInsecurePath: pc.AllowInsecurePath, + AllowReadableByOthers: true, // exec commands are typically 755 + AllowSymlinkPath: pc.AllowSymlinkCommand, + }) + if err != nil { + return nil, fmt.Errorf("exec provider security audit failed: %w", err) + } + + reqJSON, err := marshalExecRequest(ref, providerName) + if err != nil { + return nil, err + } + + timeoutMs, maxOut := effectiveExecLimits(pc) + return &execRun{ + Path: securePath, + Args: pc.Args, + Env: buildExecEnv(pc, getenv), + Request: reqJSON, + Timeout: time.Duration(timeoutMs) * time.Millisecond, + MaxOut: maxOut, + }, nil +} + +// marshalExecRequest encodes the JSON protocol request sent to the child. +// providerName is supplied by resolveSecretRef after consulting +// secrets.defaults.exec; an empty value falls back to DefaultProviderAlias +// so the function can still be reasoned about in isolation. +func marshalExecRequest(ref *SecretRef, providerName string) ([]byte, error) { + if providerName == "" { + providerName = DefaultProviderAlias + } + data, err := json.Marshal(execRequest{ + ProtocolVersion: 1, + Provider: providerName, + IDs: []string{ref.ID}, + }) + if err != nil { + return nil, fmt.Errorf("exec provider: failed to marshal request: %w", err) + } + return data, nil +} + +// buildExecEnv assembles the child's environment: only variables listed in +// pc.PassEnv (and non-empty in the parent) plus pc.Env entries. The child +// never inherits the full parent env — always set cmd.Env explicitly. +func buildExecEnv(pc *ProviderConfig, getenv func(string) string) []string { + env := make([]string, 0, len(pc.PassEnv)+len(pc.Env)) + for _, key := range pc.PassEnv { + if val := getenv(key); val != "" { + env = append(env, key+"="+val) + } + } + for key, val := range pc.Env { + env = append(env, key+"="+val) + } + return env +} + +// effectiveExecLimits returns (timeoutMs, maxOutputBytes), falling back to +// package defaults for any non-positive value. The exec provider uses its +// own NoOutputTimeoutMs field (pc.TimeoutMs is the file-provider field and +// should not be consulted here); the value is applied as the overall +// deadline for the child process. +func effectiveExecLimits(pc *ProviderConfig) (timeoutMs, maxOutputBytes int) { + timeoutMs = pc.NoOutputTimeoutMs + if timeoutMs <= 0 { + timeoutMs = DefaultExecTimeoutMs + } + maxOutputBytes = pc.MaxOutputBytes + if maxOutputBytes <= 0 { + maxOutputBytes = DefaultExecMaxOutputBytes + } + return timeoutMs, maxOutputBytes +} + +// effectiveJSONOnly returns pc.JSONOnly or its documented default (true). +func effectiveJSONOnly(pc *ProviderConfig) bool { + if pc.JSONOnly != nil { + return *pc.JSONOnly + } + return true +} + +// runExecCommand spawns the child per prep, feeds prep.Request on stdin, and +// returns trimmed stdout on success. Failure modes: +// - timeout → typed error with the configured limit +// - non-zero exit → wrapped *exec.ExitError +// - stdout exceeds prep.MaxOut → typed error (size enforced post-Run) +// - empty trimmed stdout → typed error +func runExecCommand(prep *execRun) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), prep.Timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, prep.Path, prep.Args...) + cmd.Dir = filepath.Dir(prep.Path) + cmd.Env = prep.Env // always set — leaving nil would inherit the parent env + cmd.Stdin = bytes.NewReader(prep.Request) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("exec provider timed out after %dms", int(prep.Timeout/time.Millisecond)) + } + return nil, fmt.Errorf("exec provider exited with error: %w", err) + } + + if stdout.Len() > prep.MaxOut { + return nil, fmt.Errorf("exec provider output exceeded maxOutputBytes (%d)", prep.MaxOut) + } + + trimmed := bytes.TrimSpace(stdout.Bytes()) + if len(trimmed) == 0 { + return nil, fmt.Errorf("exec provider returned empty stdout") + } + return trimmed, nil +} + +// extractExecSecret parses stdout as a JSON execResponse and returns the +// string value at refID. When jsonOnly is false and the response is not valid +// JSON (or the value is not a string), it falls back to the raw stdout or the +// JSON encoding of the value respectively — mirroring OpenClaw's resolve.ts. +func extractExecSecret(stdout []byte, refID string, jsonOnly bool) (string, error) { + var resp execResponse + if err := json.Unmarshal(stdout, &resp); err != nil { + if !jsonOnly { + return string(stdout), nil + } + return "", fmt.Errorf("exec provider returned invalid JSON: %w", err) + } + + if resp.ProtocolVersion != 1 { + return "", fmt.Errorf("exec provider protocolVersion must be 1, got %d", resp.ProtocolVersion) + } + + if refErr, ok := resp.Errors[refID]; ok { + msg := refErr.Message + if msg == "" { + msg = "unknown error" + } + return "", fmt.Errorf("exec provider failed for id %q: %s", refID, msg) + } + + if resp.Values == nil { + return "", fmt.Errorf("exec provider response missing 'values'") + } + value, ok := resp.Values[refID] + if !ok { + return "", fmt.Errorf("exec provider response missing id %q", refID) + } + + if str, ok := value.(string); ok { + return str, nil + } + if !jsonOnly { + data, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("exec provider value for id %q is not JSON-serializable: %w", refID, err) + } + return string(data), nil + } + return "", fmt.Errorf("exec provider value for id %q is not a string", refID) +} diff --git a/internal/openclaw/secret_resolve_exec_test.go b/internal/openclaw/secret_resolve_exec_test.go new file mode 100644 index 000000000..3be4398eb --- /dev/null +++ b/internal/openclaw/secret_resolve_exec_test.go @@ -0,0 +1,437 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" +) + +// writeExecHelper writes a small shell script that mimics an exec provider. +// The script reads stdin (the JSON request) and writes a JSON response to stdout. +func writeExecHelper(t *testing.T, dir, body string) string { + t.Helper() + p := filepath.Join(dir, "helper.sh") + script := "#!/bin/sh\n" + body + if err := os.WriteFile(p, []byte(script), 0o700); err != nil { + t.Fatalf("write helper script: %v", err) + } + return p +} + +func TestResolveExecRef_EmptyCommand(t *testing.T) { + ref := &SecretRef{Source: "exec", ID: "MY_KEY"} + pc := &ProviderConfig{Source: "exec", Command: ""} + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for empty command, got nil") + } + want := "exec provider command is empty" + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveExecRef_CommandNotFound(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("path audit not applicable on Windows") + } + + ref := &SecretRef{Source: "exec", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: "/nonexistent/command", + AllowInsecurePath: true, + } + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for nonexistent command, got nil") + } +} + +func TestResolveExecRef_JSONResponse(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + // Script reads stdin (ignores), writes valid JSON response + helper := writeExecHelper(t, dir, `cat > /dev/null +printf '{"protocolVersion":1,"values":{"MY_KEY":"exec_secret_123"}}' +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + } + + got, err := resolveExecRef(ref, "", pc, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "exec_secret_123" { + t.Errorf("got %q, want %q", got, "exec_secret_123") + } +} + +func TestResolveExecRef_PerRefError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `cat > /dev/null +printf '{"protocolVersion":1,"values":{},"errors":{"MY_KEY":{"message":"secret not found"}}}' +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + } + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for per-ref error, got nil") + } + want := `exec provider failed for id "MY_KEY": secret not found` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveExecRef_WrongProtocolVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `cat > /dev/null +printf '{"protocolVersion":99,"values":{"MY_KEY":"v"}}' +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + } + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for wrong protocol version, got nil") + } + want := "exec provider protocolVersion must be 1, got 99" + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveExecRef_MissingValues(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `cat > /dev/null +printf '{"protocolVersion":1}' +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + } + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for missing values, got nil") + } + want := "exec provider response missing 'values'" + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveExecRef_MissingID(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `cat > /dev/null +printf '{"protocolVersion":1,"values":{"OTHER":"val"}}' +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + } + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for missing ID, got nil") + } + want := `exec provider response missing id "MY_KEY"` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveExecRef_EmptyStdout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `cat > /dev/null +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + } + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for empty stdout, got nil") + } + want := "exec provider returned empty stdout" + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveExecRef_InvalidJSON_JSONOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `cat > /dev/null +echo "not json" +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + // JSONOnly defaults to true (nil) + } + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestResolveExecRef_NonJSON_RawString(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `cat > /dev/null +echo "raw_secret_value" +`) + + jsonOnly := false + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + JSONOnly: &jsonOnly, + } + + got, err := resolveExecRef(ref, "", pc, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "raw_secret_value" { + t.Errorf("got %q, want %q", got, "raw_secret_value") + } +} + +func TestResolveExecRef_NonStringValue_JSONOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `cat > /dev/null +printf '{"protocolVersion":1,"values":{"MY_KEY":42}}' +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + } + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for non-string value with jsonOnly=true, got nil") + } + want := `exec provider value for id "MY_KEY" is not a string` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveExecRef_NonStringValue_NoJSONOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `cat > /dev/null +printf '{"protocolVersion":1,"values":{"MY_KEY":42}}' +`) + + jsonOnly := false + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + JSONOnly: &jsonOnly, + } + + got, err := resolveExecRef(ref, "", pc, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "42" { + t.Errorf("got %q, want %q", got, "42") + } +} + +func TestResolveExecRef_CommandExitError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `exit 1 +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + } + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for command exit error, got nil") + } +} + +func TestResolveExecRef_PassEnv(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + // Script uses TEST_SECRET env to produce value + helper := writeExecHelper(t, dir, `cat > /dev/null +printf '{"protocolVersion":1,"values":{"MY_KEY":"%s"}}' "$TEST_SECRET" +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + PassEnv: []string{"TEST_SECRET"}, + } + + getenv := func(key string) string { + if key == "TEST_SECRET" { + return "passed_env_value" + } + return "" + } + + got, err := resolveExecRef(ref, "", pc, getenv) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "passed_env_value" { + t.Errorf("got %q, want %q", got, "passed_env_value") + } +} + +func TestResolveExecRef_ExplicitEnv(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + helper := writeExecHelper(t, dir, `cat > /dev/null +printf '{"protocolVersion":1,"values":{"MY_KEY":"%s"}}' "$CUSTOM_VAR" +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + Env: map[string]string{"CUSTOM_VAR": "explicit_value"}, + } + + got, err := resolveExecRef(ref, "", pc, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "explicit_value" { + t.Errorf("got %q, want %q", got, "explicit_value") + } +} + +func TestResolveExecRef_OutputExceedsMax(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell scripts not applicable on Windows") + } + + dir := t.TempDir() + // Script outputs more than maxOutputBytes + helper := writeExecHelper(t, dir, `cat > /dev/null +python3 -c "print('x' * 200)" +`) + + ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"} + pc := &ProviderConfig{ + Source: "exec", + Command: helper, + AllowInsecurePath: true, + MaxOutputBytes: 10, + } + + _, err := resolveExecRef(ref, "", pc, nil) + if err == nil { + t.Fatal("expected error for output exceeding maxOutputBytes, got nil") + } + want := fmt.Sprintf("exec provider output exceeded maxOutputBytes (%d)", 10) + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} diff --git a/internal/openclaw/secret_resolve_file.go b/internal/openclaw/secret_resolve_file.go new file mode 100644 index 000000000..d1a1390af --- /dev/null +++ b/internal/openclaw/secret_resolve_file.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/vfs" +) + +// SingleValueFileRefID is the required ref.ID for singleValue file mode +// (aligned with OpenClaw ref-contract.ts SINGLE_VALUE_FILE_REF_ID). +const SingleValueFileRefID = "$SINGLE_VALUE" + +// resolveFileRef handles {source:"file"} SecretRef resolution. +// Reads the file via assertSecurePath audit, then extracts the secret value +// based on the provider's mode (singleValue or json with JSON Pointer). +func resolveFileRef(ref *SecretRef, pc *ProviderConfig) (string, error) { + if pc.Path == "" { + return "", fmt.Errorf("file provider path is empty") + } + + // Security audit on file path + securePath, err := AssertSecurePath(AuditParams{ + TargetPath: pc.Path, + Label: "secrets.providers file path", + TrustedDirs: pc.TrustedDirs, + AllowInsecurePath: pc.AllowInsecurePath, + AllowReadableByOthers: false, // file provider: strict by default + AllowSymlinkPath: false, + }) + if err != nil { + return "", fmt.Errorf("file provider security audit failed: %w", err) + } + + // Read file content + maxBytes := pc.MaxBytes + if maxBytes <= 0 { + maxBytes = DefaultFileMaxBytes + } + + // Note: vfs.ReadFile loads the entire file. maxBytes is enforced post-read + // because vfs does not expose a size-limited reader. For secret files this + // is acceptable (default limit 1 MiB; secrets are typically < 1 KB). + data, err := vfs.ReadFile(securePath) + if err != nil { + return "", fmt.Errorf("failed to read secret file %s: %w", securePath, err) + } + + if len(data) > maxBytes { + return "", fmt.Errorf("file provider exceeded maxBytes (%d)", maxBytes) + } + + content := string(data) + mode := pc.Mode + if mode == "" { + mode = "json" // default mode per OpenClaw + } + + switch mode { + case "singleValue": + // OpenClaw requires ref.id == SINGLE_VALUE_FILE_REF_ID for singleValue mode + if ref.ID != SingleValueFileRefID { + return "", fmt.Errorf("singleValue file provider expects ref id %q, got %q", + SingleValueFileRefID, ref.ID) + } + // Entire file content is the secret; trim trailing newline + return strings.TrimRight(content, "\r\n"), nil + + case "json": + // Parse as JSON, then navigate via JSON Pointer (ref.ID) + var parsed interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + return "", fmt.Errorf("file provider JSON parse error: %w", err) + } + + value, err := ReadJSONPointer(parsed, ref.ID) + if err != nil { + return "", fmt.Errorf("file provider JSON Pointer %q: %w", ref.ID, err) + } + + // Value must be a string + strValue, ok := value.(string) + if !ok { + return "", fmt.Errorf("file provider JSON Pointer %q resolved to non-string value", ref.ID) + } + return strValue, nil + + default: + return "", fmt.Errorf("unsupported file provider mode %q", mode) + } +} diff --git a/internal/openclaw/secret_resolve_file_test.go b/internal/openclaw/secret_resolve_file_test.go new file mode 100644 index 000000000..ccde7939d --- /dev/null +++ b/internal/openclaw/secret_resolve_file_test.go @@ -0,0 +1,232 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveFileRef_SingleValue(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "secret.txt") + if err := os.WriteFile(p, []byte("my_secret\n"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + ref := &SecretRef{Source: "file", ID: SingleValueFileRefID} + pc := &ProviderConfig{ + Source: "file", + Path: p, + Mode: "singleValue", + AllowInsecurePath: true, + } + + got, err := resolveFileRef(ref, pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "my_secret" { + t.Errorf("got %q, want %q", got, "my_secret") + } +} + +func TestResolveFileRef_SingleValue_WrongRefID(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "secret.txt") + if err := os.WriteFile(p, []byte("my_secret\n"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + ref := &SecretRef{Source: "file", ID: "WRONG_ID"} + pc := &ProviderConfig{ + Source: "file", + Path: p, + Mode: "singleValue", + AllowInsecurePath: true, + } + + _, err := resolveFileRef(ref, pc) + if err == nil { + t.Fatal("expected error for wrong ref ID, got nil") + } + want := `singleValue file provider expects ref id "$SINGLE_VALUE", got "WRONG_ID"` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveFileRef_JSONMode(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "secrets.json") + content := `{"providers":{"feishu":{"key":"secret123"}}}` + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + ref := &SecretRef{Source: "file", ID: "/providers/feishu/key"} + pc := &ProviderConfig{ + Source: "file", + Path: p, + Mode: "json", + AllowInsecurePath: true, + } + + got, err := resolveFileRef(ref, pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "secret123" { + t.Errorf("got %q, want %q", got, "secret123") + } +} + +func TestResolveFileRef_JSONMode_MissingPointer(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "secrets.json") + content := `{"providers":{"feishu":{"key":"secret123"}}}` + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + ref := &SecretRef{Source: "file", ID: "/providers/nonexistent/key"} + pc := &ProviderConfig{ + Source: "file", + Path: p, + Mode: "json", + AllowInsecurePath: true, + } + + _, err := resolveFileRef(ref, pc) + if err == nil { + t.Fatal("expected error for missing JSON pointer, got nil") + } + want := `file provider JSON Pointer "/providers/nonexistent/key": json pointer "/providers/nonexistent/key": key "nonexistent" not found` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveFileRef_FileNotFound(t *testing.T) { + nonexistent := filepath.Join(t.TempDir(), "no_such_file.txt") + ref := &SecretRef{Source: "file", ID: SingleValueFileRefID} + pc := &ProviderConfig{ + Source: "file", + Path: nonexistent, + Mode: "singleValue", + AllowInsecurePath: true, + } + + _, err := resolveFileRef(ref, pc) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestResolveFileRef_EmptyProviderPath(t *testing.T) { + ref := &SecretRef{Source: "file", ID: SingleValueFileRefID} + pc := &ProviderConfig{Source: "file", Path: "", Mode: "singleValue", AllowInsecurePath: true} + _, err := resolveFileRef(ref, pc) + if err == nil { + t.Fatal("expected error for empty provider path, got nil") + } + want := "file provider path is empty" + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveFileRef_JSONMode_NonStringValue(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "secrets.json") + if err := os.WriteFile(p, []byte(`{"count":42}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + ref := &SecretRef{Source: "file", ID: "/count"} + pc := &ProviderConfig{Source: "file", Path: p, Mode: "json", AllowInsecurePath: true} + _, err := resolveFileRef(ref, pc) + if err == nil { + t.Fatal("expected error for non-string JSON value, got nil") + } + want := `file provider JSON Pointer "/count" resolved to non-string value` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveFileRef_UnsupportedMode(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "secret.txt") + if err := os.WriteFile(p, []byte("data"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + ref := &SecretRef{Source: "file", ID: SingleValueFileRefID} + pc := &ProviderConfig{Source: "file", Path: p, Mode: "yaml", AllowInsecurePath: true} + _, err := resolveFileRef(ref, pc) + if err == nil { + t.Fatal("expected error for unsupported mode, got nil") + } + want := `unsupported file provider mode "yaml"` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveFileRef_DefaultMode_IsJSON(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "secrets.json") + if err := os.WriteFile(p, []byte(`{"key":"value123"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + ref := &SecretRef{Source: "file", ID: "/key"} + pc := &ProviderConfig{Source: "file", Path: p, Mode: "", AllowInsecurePath: true} + got, err := resolveFileRef(ref, pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "value123" { + t.Errorf("got %q, want %q", got, "value123") + } +} + +func TestResolveFileRef_JSONMode_InvalidJSON(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "bad.json") + if err := os.WriteFile(p, []byte("not json"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + ref := &SecretRef{Source: "file", ID: "/key"} + pc := &ProviderConfig{Source: "file", Path: p, Mode: "json", AllowInsecurePath: true} + _, err := resolveFileRef(ref, pc) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestResolveFileRef_ExceedsMaxBytes(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "big.txt") + if err := os.WriteFile(p, []byte("this content is longer than 5 bytes"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + ref := &SecretRef{Source: "file", ID: SingleValueFileRefID} + pc := &ProviderConfig{ + Source: "file", + Path: p, + Mode: "singleValue", + MaxBytes: 5, + AllowInsecurePath: true, + } + + _, err := resolveFileRef(ref, pc) + if err == nil { + t.Fatal("expected error for file exceeding maxBytes, got nil") + } + want := "file provider exceeded maxBytes (5)" + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} diff --git a/internal/openclaw/secret_resolve_test.go b/internal/openclaw/secret_resolve_test.go new file mode 100644 index 000000000..b7489567e --- /dev/null +++ b/internal/openclaw/secret_resolve_test.go @@ -0,0 +1,153 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "testing" +) + +func makeGetenv(m map[string]string) func(string) string { + return func(key string) string { return m[key] } +} + +func TestResolve_PlainString(t *testing.T) { + got, err := ResolveSecretInput(SecretInput{Plain: "my_secret"}, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "my_secret" { + t.Errorf("got %q, want %q", got, "my_secret") + } +} + +func TestResolve_EmptyInput(t *testing.T) { + _, err := ResolveSecretInput(SecretInput{}, nil, nil) + if err == nil { + t.Fatal("expected error for empty input, got nil") + } + want := "appSecret is missing or empty" + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolve_EnvTemplate_Found(t *testing.T) { + getenv := makeGetenv(map[string]string{"MY_VAR": "resolved_value"}) + got, err := ResolveSecretInput(SecretInput{Plain: "${MY_VAR}"}, nil, getenv) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "resolved_value" { + t.Errorf("got %q, want %q", got, "resolved_value") + } +} + +func TestResolve_EnvTemplate_NotFound(t *testing.T) { + getenv := makeGetenv(map[string]string{}) + _, err := ResolveSecretInput(SecretInput{Plain: "${MY_VAR}"}, nil, getenv) + if err == nil { + t.Fatal("expected error for unset env variable, got nil") + } + want := `env variable "MY_VAR" referenced in openclaw.json is not set or empty` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolve_EnvTemplate_InvalidFormat(t *testing.T) { + getenv := makeGetenv(map[string]string{}) + got, err := ResolveSecretInput(SecretInput{Plain: "${lowercase}"}, nil, getenv) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "${lowercase}" { + t.Errorf("got %q, want %q (treated as plain string)", got, "${lowercase}") + } +} + +func TestResolve_EnvRef(t *testing.T) { + getenv := makeGetenv(map[string]string{"MY_KEY": "env_val"}) + input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}} + got, err := ResolveSecretInput(input, nil, getenv) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "env_val" { + t.Errorf("got %q, want %q", got, "env_val") + } +} + +func TestResolve_EnvRef_NotFound(t *testing.T) { + getenv := makeGetenv(map[string]string{}) + input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}} + _, err := ResolveSecretInput(input, nil, getenv) + if err == nil { + t.Fatal("expected error for missing env variable, got nil") + } +} + +func TestResolve_EnvRef_Allowlisted(t *testing.T) { + getenv := makeGetenv(map[string]string{"MY_KEY": "allowed_val"}) + cfg := &SecretsConfig{ + Providers: map[string]*ProviderConfig{ + "default": {Source: "env", Allowlist: []string{"MY_KEY"}}, + }, + } + input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}} + got, err := ResolveSecretInput(input, cfg, getenv) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "allowed_val" { + t.Errorf("got %q, want %q", got, "allowed_val") + } +} + +func TestResolve_EnvRef_NotAllowlisted(t *testing.T) { + getenv := makeGetenv(map[string]string{"MY_KEY": "some_val"}) + cfg := &SecretsConfig{ + Providers: map[string]*ProviderConfig{ + "default": {Source: "env", Allowlist: []string{"OTHER"}}, + }, + } + input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}} + _, err := ResolveSecretInput(input, cfg, getenv) + if err == nil { + t.Fatal("expected error for non-allowlisted key, got nil") + } + want := `environment variable "MY_KEY" is not allowlisted in provider` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolve_UnknownSource(t *testing.T) { + getenv := makeGetenv(map[string]string{}) + cfg := &SecretsConfig{ + Providers: map[string]*ProviderConfig{ + "default": {Source: "unknown"}, + }, + } + input := SecretInput{Ref: &SecretRef{Source: "unknown", Provider: "default", ID: "some_id"}} + _, err := ResolveSecretInput(input, cfg, getenv) + if err == nil { + t.Fatal("expected error for unknown source, got nil") + } +} + +func TestResolve_ProviderNotConfigured(t *testing.T) { + getenv := makeGetenv(map[string]string{}) + cfg := &SecretsConfig{ + Providers: map[string]*ProviderConfig{}, + } + input := SecretInput{Ref: &SecretRef{Source: "file", Provider: "nonexistent", ID: "/some/path"}} + _, err := ResolveSecretInput(input, cfg, getenv) + if err == nil { + t.Fatal("expected error for non-configured provider, got nil") + } + want := `secret provider "nonexistent" is not configured (ref: file:nonexistent:/some/path)` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} diff --git a/internal/openclaw/types.go b/internal/openclaw/types.go new file mode 100644 index 000000000..2a352ae1e --- /dev/null +++ b/internal/openclaw/types.go @@ -0,0 +1,300 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +// OpenClawRoot captures the minimal subset of openclaw.json needed by config bind. +// Unknown fields are silently ignored (forward-compatible with future OpenClaw versions). +type OpenClawRoot struct { + Channels ChannelsRoot `json:"channels"` + Secrets *SecretsConfig `json:"secrets,omitempty"` +} + +// ChannelsRoot holds channel configurations. +type ChannelsRoot struct { + Feishu *FeishuChannel `json:"feishu,omitempty"` +} + +// FeishuChannel represents the channels.feishu subtree. +// Single-account: AppID + AppSecret + Brand at top level. +// Multi-account: Accounts map (keyed by label like "work", "personal"). +type FeishuChannel struct { + Enabled *bool `json:"enabled,omitempty"` // nil = default enabled + AppID string `json:"appId,omitempty"` + AppSecret SecretInput `json:"appSecret,omitempty"` + Brand string `json:"brand,omitempty"` + Accounts map[string]*FeishuAccount `json:"accounts,omitempty"` +} + +// FeishuAccount is a single account entry within Accounts. +type FeishuAccount struct { + Enabled *bool `json:"enabled,omitempty"` // nil = default enabled + AppID string `json:"appId,omitempty"` + AppSecret SecretInput `json:"appSecret,omitempty"` + Brand string `json:"brand,omitempty"` +} + +// isEnabled returns true if the enabled field is nil (default) or explicitly true. +func isEnabled(enabled *bool) bool { + return enabled == nil || *enabled +} + +// SecretInput is a union type: either a plain string or a SecretRef object. +// Implements custom JSON unmarshaling to handle both forms. +type SecretInput struct { + Plain string // non-empty when value is a plain string (including "${VAR}" templates) + Ref *SecretRef // non-nil when value is a SecretRef object +} + +// IsZero returns true if no value was provided. +func (s SecretInput) IsZero() bool { + return s.Plain == "" && s.Ref == nil +} + +// IsPlain returns true if this is a plain string (not a SecretRef object). +func (s SecretInput) IsPlain() bool { + return s.Ref == nil +} + +// SecretRef references a secret stored externally via OpenClaw's provider system. +type SecretRef struct { + Source string `json:"source"` // "env" | "file" | "exec" + Provider string `json:"provider,omitempty"` // provider alias; defaults to config.secrets.defaults. or "default" + ID string `json:"id"` // lookup key (env var name / JSON pointer / exec ref id) +} + +// validSources lists accepted SecretRef source values. +var validSources = map[string]bool{ + "env": true, + "file": true, + "exec": true, +} + +// EnvTemplateRe matches OpenClaw env template strings like "${FEISHU_APP_SECRET}". +// Only uppercase letters, digits, and underscores; 1-128 chars; must start with uppercase. +var EnvTemplateRe = regexp.MustCompile(`^\$\{([A-Z][A-Z0-9_]{0,127})\}$`) + +// UnmarshalJSON handles both string and object forms of SecretInput. +func (s *SecretInput) UnmarshalJSON(data []byte) error { + // Try string first + var str string + if err := json.Unmarshal(data, &str); err == nil { + s.Plain = str + s.Ref = nil + return nil + } + + // Try SecretRef object + var ref SecretRef + if err := json.Unmarshal(data, &ref); err == nil { + if !validSources[ref.Source] { + return fmt.Errorf("SecretRef.source must be env|file|exec, got %q", ref.Source) + } + if ref.ID == "" { + return fmt.Errorf("SecretRef.id must be non-empty") + } + s.Ref = &ref + s.Plain = "" + return nil + } + + return fmt.Errorf("appSecret must be a string or {source, provider?, id} object") +} + +// MarshalJSON serializes SecretInput back to JSON. +func (s SecretInput) MarshalJSON() ([]byte, error) { + if s.Ref != nil { + return json.Marshal(s.Ref) + } + return json.Marshal(s.Plain) +} + +// SecretsConfig captures the secrets.providers registry from openclaw.json. +type SecretsConfig struct { + Providers map[string]*ProviderConfig `json:"providers,omitempty"` + Defaults *ProviderDefaults `json:"defaults,omitempty"` +} + +// ProviderDefaults holds default provider aliases for each source type. +type ProviderDefaults struct { + Env string `json:"env,omitempty"` + File string `json:"file,omitempty"` + Exec string `json:"exec,omitempty"` +} + +// DefaultProviderAlias is the fallback provider name when none is specified. +const DefaultProviderAlias = "default" + +// ProviderConfig holds configuration for a secret provider. +// Fields are source-specific; unused fields for other sources are ignored. +type ProviderConfig struct { + Source string `json:"source"` // "env" | "file" | "exec" + + // env source fields + Allowlist []string `json:"allowlist,omitempty"` + + // file source fields + Path string `json:"path,omitempty"` + Mode string `json:"mode,omitempty"` // "singleValue" | "json"; default "json" + TimeoutMs int `json:"timeoutMs,omitempty"` + MaxBytes int `json:"maxBytes,omitempty"` + + // exec source fields + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + NoOutputTimeoutMs int `json:"noOutputTimeoutMs,omitempty"` + MaxOutputBytes int `json:"maxOutputBytes,omitempty"` + JSONOnly *bool `json:"jsonOnly,omitempty"` // nil = default true + Env map[string]string `json:"env,omitempty"` + PassEnv []string `json:"passEnv,omitempty"` + TrustedDirs []string `json:"trustedDirs,omitempty"` + AllowInsecurePath bool `json:"allowInsecurePath,omitempty"` + AllowSymlinkCommand bool `json:"allowSymlinkCommand,omitempty"` +} + +// Default values for provider config fields (aligned with OpenClaw resolve.ts). +const ( + DefaultFileTimeoutMs = 5000 + DefaultFileMaxBytes = 1024 * 1024 // 1 MiB + DefaultExecTimeoutMs = 5000 + DefaultExecMaxOutputBytes = 1024 * 1024 // 1 MiB +) + +// ResolveDefaultProvider returns the effective provider alias for a SecretRef. +// If ref.Provider is set, returns it; otherwise falls back to config defaults or "default". +func ResolveDefaultProvider(ref *SecretRef, cfg *SecretsConfig) string { + if ref.Provider != "" { + return ref.Provider + } + if cfg != nil && cfg.Defaults != nil { + switch ref.Source { + case "env": + if cfg.Defaults.Env != "" { + return cfg.Defaults.Env + } + case "file": + if cfg.Defaults.File != "" { + return cfg.Defaults.File + } + case "exec": + if cfg.Defaults.Exec != "" { + return cfg.Defaults.Exec + } + } + } + return DefaultProviderAlias +} + +// LookupProvider resolves a provider config from the registry. +// Returns the provider config or an error if not found. +// Special case: env source with "default" provider returns a synthetic empty env provider. +func LookupProvider(ref *SecretRef, cfg *SecretsConfig) (*ProviderConfig, error) { + providerName := ResolveDefaultProvider(ref, cfg) + + if cfg != nil && cfg.Providers != nil { + if pc, ok := cfg.Providers[providerName]; ok { + if pc == nil { + return nil, fmt.Errorf("secret provider %q is configured as null", providerName) + } + if pc.Source != ref.Source { + return nil, fmt.Errorf("secret provider %q has source %q but ref requests %q", + providerName, pc.Source, ref.Source) + } + return pc, nil + } + } + + // Special case: default env provider (implicit, per OpenClaw resolve.ts) + if ref.Source == "env" && providerName == DefaultProviderAlias { + return &ProviderConfig{Source: "env"}, nil + } + + return nil, fmt.Errorf("secret provider %q is not configured (ref: %s:%s:%s)", + providerName, ref.Source, providerName, ref.ID) +} + +// CandidateApp represents a bindable app from OpenClaw's feishu channel config. +type CandidateApp struct { + Label string + AppID string + AppSecret SecretInput + Brand string +} + +// ListCandidateApps enumerates all bindable (enabled) apps from a FeishuChannel. +// Disabled accounts (enabled: false) are filtered out. +func ListCandidateApps(ch *FeishuChannel) []CandidateApp { + if ch == nil { + return nil + } + if len(ch.Accounts) > 0 { + apps := make([]CandidateApp, 0, len(ch.Accounts)+1) + + // When accounts exist AND top-level has its own appId+appSecret, + // include the top-level as a "default" candidate — aligned with + // openclaw-lark getLarkAccountIds() which adds DEFAULT_ACCOUNT_ID + // when top-level credentials are present and no explicit "default" exists. + hasDefault := false + for label := range ch.Accounts { + if strings.EqualFold(strings.TrimSpace(label), "default") { + hasDefault = true + break + } + } + if !hasDefault && ch.AppID != "" && !ch.AppSecret.IsZero() && isEnabled(ch.Enabled) { + apps = append(apps, CandidateApp{ + Label: "default", + AppID: ch.AppID, + AppSecret: ch.AppSecret, + Brand: ch.Brand, + }) + } + + for label, acct := range ch.Accounts { + if acct == nil || !isEnabled(acct.Enabled) { + continue // skip disabled accounts + } + appID := acct.AppID + if appID == "" { + appID = ch.AppID // inherit from top-level + } + if appID == "" { + continue // skip entries with no effective AppID + } + appSecret := acct.AppSecret + if appSecret.IsZero() { + appSecret = ch.AppSecret // inherit from top-level + } + brand := acct.Brand + if brand == "" { + brand = ch.Brand + } + apps = append(apps, CandidateApp{ + Label: label, + AppID: appID, + AppSecret: appSecret, + Brand: brand, + }) + } + return apps + } + + // Single account at top level — check if channel itself is enabled + if ch.AppID != "" && isEnabled(ch.Enabled) { + return []CandidateApp{{ + Label: "", + AppID: ch.AppID, + AppSecret: ch.AppSecret, + Brand: ch.Brand, + }} + } + + return nil +} diff --git a/internal/openclaw/types_test.go b/internal/openclaw/types_test.go new file mode 100644 index 000000000..76b04b784 --- /dev/null +++ b/internal/openclaw/types_test.go @@ -0,0 +1,419 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package openclaw + +import ( + "encoding/json" + "testing" +) + +func TestSecretInput_MarshalJSON_PlainString(t *testing.T) { + input := SecretInput{Plain: "my_secret"} + data, err := input.MarshalJSON() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `"my_secret"` + if string(data) != want { + t.Errorf("got %s, want %s", data, want) + } +} + +func TestSecretInput_MarshalJSON_SecretRef(t *testing.T) { + input := SecretInput{Ref: &SecretRef{Source: "env", ID: "MY_VAR"}} + data, err := input.MarshalJSON() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var ref SecretRef + if err := json.Unmarshal(data, &ref); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ref.Source != "env" { + t.Errorf("source = %q, want %q", ref.Source, "env") + } + if ref.ID != "MY_VAR" { + t.Errorf("id = %q, want %q", ref.ID, "MY_VAR") + } +} + +func TestSecretInput_UnmarshalJSON_InvalidSource(t *testing.T) { + data := []byte(`{"source":"invalid","id":"key"}`) + var input SecretInput + err := json.Unmarshal(data, &input) + if err == nil { + t.Fatal("expected error for invalid source, got nil") + } +} + +func TestSecretInput_UnmarshalJSON_EmptyID(t *testing.T) { + data := []byte(`{"source":"env","id":""}`) + var input SecretInput + err := json.Unmarshal(data, &input) + if err == nil { + t.Fatal("expected error for empty id, got nil") + } +} + +func TestSecretInput_UnmarshalJSON_InvalidType(t *testing.T) { + data := []byte(`42`) + var input SecretInput + err := json.Unmarshal(data, &input) + if err == nil { + t.Fatal("expected error for numeric input, got nil") + } + want := "appSecret must be a string or {source, provider?, id} object" + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestResolveDefaultProvider_ExplicitProvider(t *testing.T) { + ref := &SecretRef{Source: "env", Provider: "my-custom", ID: "KEY"} + got := ResolveDefaultProvider(ref, nil) + if got != "my-custom" { + t.Errorf("got %q, want %q", got, "my-custom") + } +} + +func TestResolveDefaultProvider_FromDefaults(t *testing.T) { + tests := []struct { + name string + source string + defaults *ProviderDefaults + want string + }{ + { + name: "env default", + source: "env", + defaults: &ProviderDefaults{Env: "my-env-prov"}, + want: "my-env-prov", + }, + { + name: "file default", + source: "file", + defaults: &ProviderDefaults{File: "my-file-prov"}, + want: "my-file-prov", + }, + { + name: "exec default", + source: "exec", + defaults: &ProviderDefaults{Exec: "my-exec-prov"}, + want: "my-exec-prov", + }, + { + name: "no defaults configured", + source: "env", + defaults: &ProviderDefaults{}, + want: DefaultProviderAlias, + }, + { + name: "nil defaults", + source: "env", + defaults: nil, + want: DefaultProviderAlias, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref := &SecretRef{Source: tt.source, ID: "KEY"} + cfg := &SecretsConfig{Defaults: tt.defaults} + got := ResolveDefaultProvider(ref, cfg) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveDefaultProvider_NilConfig(t *testing.T) { + ref := &SecretRef{Source: "env", ID: "KEY"} + got := ResolveDefaultProvider(ref, nil) + if got != DefaultProviderAlias { + t.Errorf("got %q, want %q", got, DefaultProviderAlias) + } +} + +func TestLookupProvider_SourceMismatch(t *testing.T) { + cfg := &SecretsConfig{ + Providers: map[string]*ProviderConfig{ + "default": {Source: "file"}, + }, + } + ref := &SecretRef{Source: "env", ID: "KEY"} + _, err := LookupProvider(ref, cfg) + if err == nil { + t.Fatal("expected error for source mismatch, got nil") + } + want := `secret provider "default" has source "file" but ref requests "env"` + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestLookupProvider_ImplicitDefaultEnv(t *testing.T) { + // Default env provider is implicitly available even without explicit config + ref := &SecretRef{Source: "env", ID: "KEY"} + pc, err := LookupProvider(ref, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pc.Source != "env" { + t.Errorf("source = %q, want %q", pc.Source, "env") + } +} + +func TestListCandidateApps_NilChannel(t *testing.T) { + got := ListCandidateApps(nil) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestListCandidateApps_SingleAccount(t *testing.T) { + ch := &FeishuChannel{ + AppID: "cli_single", + AppSecret: SecretInput{Plain: "secret"}, + Brand: "feishu", + } + got := ListCandidateApps(ch) + if len(got) != 1 { + t.Fatalf("count = %d, want 1", len(got)) + } + if got[0].AppID != "cli_single" { + t.Errorf("appId = %q, want %q", got[0].AppID, "cli_single") + } + if got[0].Label != "" { + t.Errorf("label = %q, want empty", got[0].Label) + } + if got[0].Brand != "feishu" { + t.Errorf("brand = %q, want %q", got[0].Brand, "feishu") + } +} + +func TestListCandidateApps_SingleAccount_Disabled(t *testing.T) { + disabled := false + ch := &FeishuChannel{ + Enabled: &disabled, + AppID: "cli_disabled", + AppSecret: SecretInput{Plain: "secret"}, + } + got := ListCandidateApps(ch) + if len(got) != 0 { + t.Errorf("expected 0 apps for disabled channel, got %d", len(got)) + } +} + +func TestListCandidateApps_MultiAccount_InheritTopLevel(t *testing.T) { + ch := &FeishuChannel{ + AppID: "cli_top_level", + Brand: "lark", + Accounts: map[string]*FeishuAccount{ + "work": { + // No AppID → inherits from top-level + AppSecret: SecretInput{Plain: "secret"}, + // No Brand → inherits from top-level + }, + }, + } + got := ListCandidateApps(ch) + if len(got) != 1 { + t.Fatalf("count = %d, want 1", len(got)) + } + if got[0].AppID != "cli_top_level" { + t.Errorf("inherited appId = %q, want %q", got[0].AppID, "cli_top_level") + } + if got[0].Brand != "lark" { + t.Errorf("inherited brand = %q, want %q", got[0].Brand, "lark") + } + if got[0].Label != "work" { + t.Errorf("label = %q, want %q", got[0].Label, "work") + } +} + +func TestListCandidateApps_MultiAccount_InheritAppSecret(t *testing.T) { + // Reproduces the "default": {} edge case from real openclaw.json configs + // where an empty account object should inherit appSecret from the top-level channel. + ch := &FeishuChannel{ + AppID: "cli_fake_top_level", + AppSecret: SecretInput{Plain: "fake_top_level_secret"}, + Brand: "feishu", + Accounts: map[string]*FeishuAccount{ + "default": {}, // empty — should inherit everything from top-level + "other": { + Enabled: boolPtr(true), + AppID: "cli_fake_other", + AppSecret: SecretInput{Plain: "fake_other_secret"}, + }, + }, + } + got := ListCandidateApps(ch) + if len(got) != 2 { + t.Fatalf("count = %d, want 2", len(got)) + } + // Find the "default" account + var def *CandidateApp + for i := range got { + if got[i].Label == "default" { + def = &got[i] + } + } + if def == nil { + t.Fatal("default account not found in candidates") + } + if def.AppID != "cli_fake_top_level" { + t.Errorf("default appId = %q, want inherited top-level", def.AppID) + } + if def.AppSecret.IsZero() { + t.Error("default appSecret should inherit from top-level, got zero") + } + if def.AppSecret.Plain != "fake_top_level_secret" { + t.Errorf("default appSecret = %q, want inherited top-level", def.AppSecret.Plain) + } + if def.Brand != "feishu" { + t.Errorf("default brand = %q, want inherited top-level", def.Brand) + } +} + +func TestListCandidateApps_ImplicitDefault_WhenTopLevelHasCredentials(t *testing.T) { + // When accounts exist but none is named "default", and top-level has + // its own appId+appSecret, the top-level should be included as a + // synthetic "default" candidate (aligned with openclaw-lark plugin). + ch := &FeishuChannel{ + AppID: "cli_top", + AppSecret: SecretInput{Plain: "top_secret"}, + Brand: "feishu", + Accounts: map[string]*FeishuAccount{ + "ethan": { + AppID: "cli_ethan", + AppSecret: SecretInput{Plain: "ethan_secret"}, + Brand: "lark", + }, + }, + } + got := ListCandidateApps(ch) + if len(got) != 2 { + t.Fatalf("count = %d, want 2 (default + ethan)", len(got)) + } + var def, ethan *CandidateApp + for i := range got { + switch got[i].Label { + case "default": + def = &got[i] + case "ethan": + ethan = &got[i] + } + } + if def == nil { + t.Fatal("implicit default candidate not found") + } + if def.AppID != "cli_top" { + t.Errorf("default appId = %q, want %q", def.AppID, "cli_top") + } + if ethan == nil { + t.Fatal("ethan candidate not found") + } + if ethan.AppID != "cli_ethan" { + t.Errorf("ethan appId = %q, want %q", ethan.AppID, "cli_ethan") + } +} + +func TestListCandidateApps_NoImplicitDefault_WhenExplicitDefaultExists(t *testing.T) { + // When accounts already contain a "default" entry, don't duplicate it. + ch := &FeishuChannel{ + AppID: "cli_top", + AppSecret: SecretInput{Plain: "top_secret"}, + Accounts: map[string]*FeishuAccount{ + "default": {}, // inherits top-level + "other": {AppID: "cli_other", AppSecret: SecretInput{Plain: "s"}}, + }, + } + got := ListCandidateApps(ch) + defaultCount := 0 + for _, c := range got { + if c.Label == "default" { + defaultCount++ + } + } + if defaultCount != 1 { + t.Errorf("expected exactly 1 default candidate, got %d", defaultCount) + } +} + +func TestListCandidateApps_NoImplicitDefault_WhenTopLevelMissingSecret(t *testing.T) { + // Top-level has appId but no appSecret → no implicit default. + ch := &FeishuChannel{ + AppID: "cli_top", + // no appSecret + Accounts: map[string]*FeishuAccount{ + "ethan": {AppID: "cli_ethan", AppSecret: SecretInput{Plain: "s"}}, + }, + } + got := ListCandidateApps(ch) + if len(got) != 1 { + t.Fatalf("count = %d, want 1 (only ethan)", len(got)) + } + if got[0].Label != "ethan" { + t.Errorf("label = %q, want %q", got[0].Label, "ethan") + } +} + +func boolPtr(v bool) *bool { return &v } + +func TestListCandidateApps_MultiAccount_DisabledFiltered(t *testing.T) { + disabled := false + ch := &FeishuChannel{ + Accounts: map[string]*FeishuAccount{ + "active": { + AppID: "cli_active", + AppSecret: SecretInput{Plain: "secret"}, + }, + "disabled": { + Enabled: &disabled, + AppID: "cli_disabled", + AppSecret: SecretInput{Plain: "secret"}, + }, + "nil_acct": nil, + }, + } + got := ListCandidateApps(ch) + if len(got) != 1 { + t.Fatalf("count = %d, want 1 (disabled and nil filtered out)", len(got)) + } + if got[0].AppID != "cli_active" { + t.Errorf("appId = %q, want %q", got[0].AppID, "cli_active") + } +} + +func TestListCandidateApps_EmptyAppID(t *testing.T) { + ch := &FeishuChannel{ + AppID: "", + // No accounts, no appId → no candidates + } + got := ListCandidateApps(ch) + if len(got) != 0 { + t.Errorf("expected 0 apps for empty appId, got %d", len(got)) + } +} + +func TestIsEnabled_Nil(t *testing.T) { + if !isEnabled(nil) { + t.Error("nil should default to enabled") + } +} + +func TestIsEnabled_True(t *testing.T) { + v := true + if !isEnabled(&v) { + t.Error("explicit true should be enabled") + } +} + +func TestIsEnabled_False(t *testing.T) { + v := false + if isEnabled(&v) { + t.Error("explicit false should be disabled") + } +} diff --git a/tests/cli_e2e/config/bind_test.go b/tests/cli_e2e/config/bind_test.go new file mode 100644 index 000000000..1249a2eb1 --- /dev/null +++ b/tests/cli_e2e/config/bind_test.go @@ -0,0 +1,315 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// setupTempConfig creates a temp config dir and sets LARKSUITE_CLI_CONFIG_DIR. +func setupTempConfig(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + return dir +} + +// writeHermesEnv creates a fake ~/.hermes/.env with feishu credentials. +func writeHermesEnv(t *testing.T, hermesHome, appID, appSecret, domain string) { + t.Helper() + require.NoError(t, os.MkdirAll(hermesHome, 0700)) + content := "FEISHU_APP_ID=" + appID + "\nFEISHU_APP_SECRET=" + appSecret + "\n" + if domain != "" { + content += "FEISHU_DOMAIN=" + domain + "\n" + } + require.NoError(t, os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(content), 0600)) +} + +// writeOpenClawConfig creates a fake openclaw.json with a single feishu account. +func writeOpenClawConfig(t *testing.T, openclawHome, appID, appSecret, brand string) { + t.Helper() + dir := filepath.Join(openclawHome, ".openclaw") + require.NoError(t, os.MkdirAll(dir, 0700)) + content := `{"channels":{"feishu":{"appId":"` + appID + `","appSecret":"` + appSecret + `","brand":"` + brand + `"}}}` + require.NoError(t, os.WriteFile(filepath.Join(dir, "openclaw.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) { + t.Helper() + assert.Equal(t, wantExitCode, result.ExitCode, "exit code mismatch\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + + errJSON := gjson.Get(result.Stderr, "error") + require.True(t, errJSON.Exists(), "stderr missing 'error' JSON envelope\nstderr:\n%s", result.Stderr) + + assert.Equal(t, wantType, errJSON.Get("type").String(), + "error.type mismatch\nstderr:\n%s", result.Stderr) + assert.Equal(t, wantMessage, errJSON.Get("message").String(), + "error.message mismatch\nstderr:\n%s", result.Stderr) + if wantHint != "" { + assert.Equal(t, wantHint, errJSON.Get("hint").String(), + "error.hint mismatch\nstderr:\n%s", result.Stderr) + } +} + +func TestBind_InvalidSource(t *testing.T) { + setupTempConfig(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind", "--source", "invalid"}, + }) + require.NoError(t, err) + assertStderrError(t, result, 2, "validation", + `invalid --source "invalid"; valid values: openclaw, hermes`, "") +} + +func TestBind_MissingSource_NonTTY(t *testing.T) { + setupTempConfig(t) + // Clear Agent env so DetectWorkspaceFromEnv returns WorkspaceLocal and + // 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, "") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind"}, + Stdin: []byte{}, // force non-TTY via explicit empty stdin + }) + 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") +} + +func TestBind_Hermes_Success(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + configDir := setupTempConfig(t) + hermesHome := filepath.Join(t.TempDir(), ".hermes-test") + t.Setenv("HERMES_HOME", hermesHome) + writeHermesEnv(t, hermesHome, "cli_e2e_test", "e2e_secret", "lark") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind", "--source", "hermes"}, + }) + require.NoError(t, err) + + if result.ExitCode == 0 { + // Success path: verify stdout JSON envelope exactly + stdout := result.Stdout + assert.True(t, gjson.Get(stdout, "ok").Bool(), "stdout:\n%s", stdout) + assert.Equal(t, "hermes", gjson.Get(stdout, "workspace").String(), "stdout:\n%s", stdout) + assert.Equal(t, "cli_e2e_test", gjson.Get(stdout, "app_id").String(), "stdout:\n%s", stdout) + + expectedConfigPath := filepath.Join(configDir, "hermes", "config.json") + assert.Equal(t, expectedConfigPath, gjson.Get(stdout, "config_path").String(), "stdout:\n%s", stdout) + + // Verify config file content exactly + data, readErr := os.ReadFile(expectedConfigPath) + require.NoError(t, readErr) + assert.Equal(t, "cli_e2e_test", gjson.GetBytes(data, "apps.0.appId").String()) + assert.Equal(t, "lark", gjson.GetBytes(data, "apps.0.brand").String()) + } else { + // Keychain failure is acceptable in CI — verify error type is keychain-related. + // Note: exact message depends on OS keychain error (platform-dependent), so we + // check the structured type field instead of message text. + errType := gjson.Get(result.Stderr, "error.type").String() + assert.Equal(t, "hermes", errType, + "non-zero exit should be from hermes bind path\nstderr:\n%s", result.Stderr) + } +} + +func TestBind_Hermes_MissingEnvFile(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + setupTempConfig(t) + hermesHome := filepath.Join(t.TempDir(), "nonexistent") + t.Setenv("HERMES_HOME", hermesHome) + + envPath := filepath.Join(hermesHome, ".env") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind", "--source", "hermes"}, + }) + require.NoError(t, err) + assertStderrError(t, result, 2, "hermes", + "failed to read Hermes config: open "+envPath+": no such file or directory", + "verify Hermes is installed and configured at "+envPath) +} + +func TestBind_Hermes_MissingAppID(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + setupTempConfig(t) + hermesHome := filepath.Join(t.TempDir(), ".hermes-test") + t.Setenv("HERMES_HOME", hermesHome) + require.NoError(t, os.MkdirAll(hermesHome, 0700)) + require.NoError(t, os.WriteFile( + filepath.Join(hermesHome, ".env"), + []byte("FEISHU_APP_SECRET=secret_only\n"), + 0600, + )) + + envPath := filepath.Join(hermesHome, ".env") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind", "--source", "hermes"}, + }) + require.NoError(t, err) + assertStderrError(t, result, 2, "hermes", + "FEISHU_APP_ID not found in "+envPath, + "run 'hermes setup' to configure Feishu credentials") +} + +func TestBind_FlagMode_Overwrite(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + configDir := setupTempConfig(t) + hermesHome := filepath.Join(t.TempDir(), ".hermes-test") + t.Setenv("HERMES_HOME", hermesHome) + writeHermesEnv(t, hermesHome, "cli_e2e_new", "e2e_new_secret", "feishu") + + // Pre-create config to simulate existing binding + hermesDir := filepath.Join(configDir, "hermes") + require.NoError(t, os.MkdirAll(hermesDir, 0700)) + configPath := filepath.Join(hermesDir, "config.json") + require.NoError(t, os.WriteFile(configPath, []byte(`{"apps":[{"appId":"old_app"}]}`), 0600)) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind", "--source", "hermes"}, + }) + require.NoError(t, err) + + if result.ExitCode == 0 { + // Flag mode silently overwrites and flags replaced=true in stdout. + assert.True(t, gjson.Get(result.Stdout, "ok").Bool(), "stdout:\n%s", result.Stdout) + assert.True(t, gjson.Get(result.Stdout, "replaced").Bool(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "cli_e2e_new", gjson.Get(result.Stdout, "app_id").String(), "stdout:\n%s", result.Stdout) + assert.Contains(t, result.Stderr, "已覆盖 Hermes workspace 原有绑定", + "stderr should carry the rebind notice\nstderr:\n%s", result.Stderr) + } else { + // Keychain failure acceptable in CI + errType := gjson.Get(result.Stderr, "error.type").String() + assert.Equal(t, "hermes", errType, + "non-zero exit should be from hermes bind path\nstderr:\n%s", result.Stderr) + } +} + +func TestBind_ConfigShow_WorkspaceField(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + configDir := setupTempConfig(t) + require.NoError(t, os.WriteFile( + filepath.Join(configDir, "config.json"), + []byte(`{"apps":[{"appId":"cli_local","appSecret":"secret","brand":"feishu"}]}`), + 0600, + )) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "show"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, "local", gjson.Get(result.Stdout, "workspace").String()) + assert.Equal(t, "cli_local", gjson.Get(result.Stdout, "appId").String()) +} + +func TestBind_ConfigShow_UnboundWorkspace(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + setupTempConfig(t) + t.Setenv("OPENCLAW_CLI", "1") // force openclaw workspace + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "show"}, + }) + require.NoError(t, err) + assertStderrError(t, result, 2, "openclaw", + "openclaw context detected but lark-cli not bound to openclaw workspace", + "run: lark-cli config bind --source openclaw") +} + +func TestBind_OpenClaw_MissingFile(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + setupTempConfig(t) + openclawHome := filepath.Join(t.TempDir(), "nonexistent") + t.Setenv("OPENCLAW_HOME", openclawHome) + t.Setenv("OPENCLAW_CONFIG_PATH", "") + t.Setenv("OPENCLAW_STATE_DIR", "") + + configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind", "--source", "openclaw"}, + }) + require.NoError(t, err) + assertStderrError(t, result, 2, "openclaw", + "cannot read "+configPath+": open "+configPath+": no such file or directory", + "verify OpenClaw is installed and configured") +} + +func TestBind_OpenClaw_Success(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + configDir := setupTempConfig(t) + openclawHome := t.TempDir() + t.Setenv("OPENCLAW_HOME", openclawHome) + t.Setenv("OPENCLAW_CONFIG_PATH", "") + t.Setenv("OPENCLAW_STATE_DIR", "") + writeOpenClawConfig(t, openclawHome, "cli_oc_test", "oc_secret", "feishu") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"config", "bind", "--source", "openclaw"}, + }) + require.NoError(t, err) + + if result.ExitCode == 0 { + // Success path: verify stdout JSON envelope exactly + stdout := result.Stdout + assert.True(t, gjson.Get(stdout, "ok").Bool(), "stdout:\n%s", stdout) + assert.Equal(t, "openclaw", gjson.Get(stdout, "workspace").String(), "stdout:\n%s", stdout) + assert.Equal(t, "cli_oc_test", gjson.Get(stdout, "app_id").String(), "stdout:\n%s", stdout) + + expectedConfigPath := filepath.Join(configDir, "openclaw", "config.json") + assert.Equal(t, expectedConfigPath, gjson.Get(stdout, "config_path").String(), "stdout:\n%s", stdout) + + // Verify config file content exactly + data, readErr := os.ReadFile(expectedConfigPath) + require.NoError(t, readErr) + assert.Equal(t, "cli_oc_test", gjson.GetBytes(data, "apps.0.appId").String()) + assert.Equal(t, "feishu", gjson.GetBytes(data, "apps.0.brand").String()) + } else { + // Keychain failure acceptable in CI + errType := gjson.Get(result.Stderr, "error.type").String() + assert.Equal(t, "openclaw", errType, + "non-zero exit should be from openclaw bind path\nstderr:\n%s", result.Stderr) + } +}