mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Give each AI Agent (OpenClaw, Hermes) its own lark-cli workspace so
its Feishu calls don't overwrite the developer's local config or
collide with other Agents.
lark-cli config bind [--source openclaw|hermes] [--app-id <id>]
[--identity bot-only|user-default] [--force]
Key capabilities:
- Source auto-detected from OPENCLAW_* / HERMES_* env signals; config
written to ~/.lark-cli/<agent>/, isolated per Agent.
- Two identity presets: 'bot-only' (flag-mode default) and
'user-default'. Flag mode rejects silent bot→user escalation
without --force; TUI prompts are exempt.
- Agent-friendly stdout JSON with 'identity' + 'message' for
next-step branching.
- 'config show' and 'doctor' expose the bound 'workspace'.
- OpenClaw SecretRef resolution: plain / ${VAR} / file:+JSON Pointer
/ exec:.
192 lines
4.8 KiB
Go
192 lines
4.8 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package keychain
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"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{}
|
|
|
|
authResponseLogNow = time.Now
|
|
authResponseLogArgs = func() []string { return os.Args }
|
|
)
|
|
|
|
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 {
|
|
return safeDir
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
authResponseLoggerOnce.Do(func() {
|
|
if authResponseLogger != nil {
|
|
return
|
|
}
|
|
|
|
dir := authLogDir()
|
|
now := authResponseLogNow()
|
|
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
|
return
|
|
}
|
|
|
|
logName := fmt.Sprintf("auth-%s.log", now.Format("2006-01-02"))
|
|
logPath := filepath.Join(dir, logName)
|
|
if f, err := vfs.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600); err == nil {
|
|
authResponseLogger = log.New(f, "", 0)
|
|
cleanupOldLogs(dir, now)
|
|
}
|
|
})
|
|
}
|
|
|
|
func FormatAuthCmdline(args []string) string {
|
|
if len(args) == 0 {
|
|
return ""
|
|
}
|
|
|
|
if len(args) <= 3 {
|
|
return strings.Join(args, " ")
|
|
}
|
|
|
|
return strings.Join(args[:3], " ") + " ..."
|
|
}
|
|
|
|
func LogAuthResponse(path string, status int, logID string) {
|
|
initAuthLogger()
|
|
if authResponseLogger == nil {
|
|
return
|
|
}
|
|
|
|
authResponseLogger.Printf(
|
|
"[lark-cli] auth-response: time=%s path=%s status=%d x-tt-logid=%s cmdline=%s",
|
|
authResponseLogNow().Format(time.RFC3339Nano),
|
|
path,
|
|
status,
|
|
logID,
|
|
FormatAuthCmdline(authResponseLogArgs()),
|
|
)
|
|
}
|
|
|
|
func LogAuthError(component, op string, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
|
|
initAuthLogger()
|
|
if authResponseLogger == nil {
|
|
return
|
|
}
|
|
|
|
authResponseLogger.Printf(
|
|
"[lark-cli] auth-error: time=%s component=%s op=%s error=%q cmdline=%s",
|
|
authResponseLogNow().Format(time.RFC3339Nano),
|
|
component,
|
|
op,
|
|
err.Error(),
|
|
FormatAuthCmdline(authResponseLogArgs()),
|
|
)
|
|
}
|
|
|
|
func SetAuthLogHooksForTest(logger *log.Logger, now func() time.Time, args func() []string) func() {
|
|
prevLogger := authResponseLogger
|
|
prevNow := authResponseLogNow
|
|
prevArgs := authResponseLogArgs
|
|
prevOnce := authResponseLoggerOnce
|
|
|
|
authResponseLogger = logger
|
|
authResponseLoggerOnce = &sync.Once{}
|
|
|
|
if now != nil {
|
|
authResponseLogNow = now
|
|
}
|
|
if args != nil {
|
|
authResponseLogArgs = args
|
|
}
|
|
|
|
return func() {
|
|
authResponseLogger = prevLogger
|
|
authResponseLogNow = prevNow
|
|
authResponseLogArgs = prevArgs
|
|
authResponseLoggerOnce = prevOnce
|
|
}
|
|
}
|
|
|
|
func cleanupOldLogs(dir string, now time.Time) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] background log cleanup panicked: %v\n", r)
|
|
}
|
|
}()
|
|
|
|
entries, err := vfs.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
cutoff := now.AddDate(0, 0, -7)
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "auth-") || !strings.HasSuffix(entry.Name(), ".log") {
|
|
continue
|
|
}
|
|
|
|
dateStr := strings.TrimPrefix(entry.Name(), "auth-")
|
|
dateStr = strings.TrimSuffix(dateStr, ".log")
|
|
|
|
logDate, err := time.Parse("2006-01-02", dateStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
logDate = time.Date(logDate.Year(), logDate.Month(), logDate.Day(), 0, 0, 0, 0, now.Location())
|
|
if logDate.Before(cutoff) {
|
|
_ = vfs.Remove(filepath.Join(dir, entry.Name()))
|
|
}
|
|
}
|
|
}
|