Compare commits

..

5 Commits

Author SHA1 Message Date
evandance
a81e5d17c5 feat(config): bind OpenClaw keyless credentials 2026-07-22 19:46:33 +08:00
Yuxuan Zhao
8f6f8eb0fc test(e2e): declare request identities explicitly (#2004)
* test(e2e): declare request identities explicitly

* test(e2e): skip base workflow without bot credentials
2026-07-22 19:22:08 +08:00
SunPeiYang996
80323bb464 docs: update lark doc HTML size limit (#2001) 2026-07-22 18:22:23 +08:00
YH-1600
0a33bd7c57 docs: add topic move collector workflow (#1473) 2026-07-22 17:45:33 +08:00
Yuxuan Zhao
aafaed06a7 fix(e2e): inject shared credentials by identity (#1995) 2026-07-22 17:43:25 +08:00
96 changed files with 10643 additions and 699 deletions

View File

@@ -9,7 +9,11 @@ permissions:
contents: read
jobs:
# All platforms (incl. darwin keychain_signer) are CGO-free and cross-compiled
# on a single ubuntu runner in one goreleaser run (one checksums.txt). The
# darwin signer's runtime FFI is validated separately by the signer-test job.
goreleaser:
needs: signer-test-macos
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -34,6 +38,21 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Validate the macOS keychain signer on real hardware. The release binaries are
# cross-compiled on ubuntu (CGO-free purego FFI), so this is the only step that
# needs a Mac — and it gates the release rather than producing it.
signer-test-macos:
runs-on: macos-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- name: Keychain signer round-trip (CGO-free purego FFI)
run: LARK_KEYCHAIN_IT=1 CGO_ENABLED=0 go test -tags keychain_signer -run Keychain -v ./internal/keysigner/
publish-npm:
needs: goreleaser
runs-on: ubuntu-22.04

View File

@@ -5,25 +5,63 @@ before:
- python3 scripts/fetch_meta.py
builds:
- binary: lark-cli
# Linux & Windows: pure-Go TPM 2.0 signer is compiled in by default (no build
# tag), cross-compiled with CGO disabled — the binaries ship the platform key
# signer for private_key_jwt. windows/arm64 is the one exception: the sks
# Windows dependency stack (go-ole) has no arm64 support, so the signer file is
# arch-excluded there and that binary falls back to client_secret only.
- id: linux
binary: lark-cli
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
- riscv64
- id: windows
binary: lark-cli
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- windows
goarch:
- amd64
- arm64
# macOS: the keychain signer calls Security.framework via runtime FFI (purego),
# so it is CGO-free, compiled into every darwin build (no build tag), and
# cross-compiles from the same ubuntu runner as linux/windows.
- id: darwin
binary: lark-cli
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- darwin
goarch:
- amd64
- arm64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
formats: [zip]
files:
- README.md
- LICENSE

View File

@@ -40,6 +40,10 @@ type LoginOptions struct {
var pollDeviceToken = larkauth.PollDeviceToken
var resolveLoginClientAuth = func(ctx context.Context, cfg *core.CliConfig) (larkauth.ClientAuth, error) {
return larkauth.ClientAuthFromConfig(cfg).ResolveSigner(ctx)
}
// NewCmdAuthLogin creates the auth login subcommand.
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
opts := &LoginOptions{Factory: f}
@@ -265,7 +269,11 @@ func authLoginRun(opts *LoginOptions) error {
if err != nil {
return err
}
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
clientAuth, err := resolveLoginClientAuth(opts.Ctx, config)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
}
authResp, err := larkauth.RequestDeviceAuthorization(opts.Ctx, httpClient, clientAuth, config.Brand, finalScope, f.IOStreams.ErrOut)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
}
@@ -325,7 +333,7 @@ func authLoginRun(opts *LoginOptions) error {
// Step 3: Poll for token
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, clientAuth, config.Brand,
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if !result.OK {
@@ -398,6 +406,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
if err != nil {
return err
}
clientAuth, err := resolveLoginClientAuth(opts.Ctx, config)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %v", err).WithCause(err)
}
requestedScope, err := loadLoginRequestedScope(opts.DeviceCode)
if err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err)
@@ -415,7 +427,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, clientAuth, config.Brand,
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
if !result.OK {

View File

@@ -716,6 +716,14 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
originalResolve := resolveLoginClientAuth
resolveCalls := 0
resolveLoginClientAuth = func(_ context.Context, cfg *core.CliConfig) (larkauth.ClientAuth, error) {
resolveCalls++
return larkauth.ClientAuthFromConfig(cfg), nil
}
t.Cleanup(func() { resolveLoginClientAuth = originalResolve })
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
@@ -778,6 +786,9 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
if err != nil {
t.Fatalf("no-wait authLoginRun() error = %v", err)
}
if resolveCalls != 1 {
t.Fatalf("no-wait client auth preparations = %d, want 1", resolveCalls)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" {
t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err)
}
@@ -793,6 +804,9 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
if err != nil {
t.Fatalf("device-code authLoginRun() error = %v", err)
}
if resolveCalls != 2 {
t.Fatalf("split-flow client auth preparations = %d, want one per invocation", resolveCalls)
}
got := stderr.String()
for _, want := range []string{
"OK: 授权成功! 用户: tester (ou_user)",
@@ -847,7 +861,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
}
@@ -884,9 +898,17 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
originalResolve := resolveLoginClientAuth
resolveCalls := 0
resolveLoginClientAuth = func(_ context.Context, cfg *core.CliConfig) (larkauth.ClientAuth, error) {
resolveCalls++
return larkauth.ClientAuthFromConfig(cfg), nil
}
t.Cleanup(func() { resolveLoginClientAuth = originalResolve })
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
}
@@ -919,6 +941,9 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
if err == nil {
t.Fatal("expected error for aborted authorization")
}
if resolveCalls != 1 {
t.Fatalf("blocking-flow client auth preparations = %d, want 1", resolveCalls)
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
}

View File

@@ -4,12 +4,18 @@
package config
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/charmbracelet/huh"
"github.com/gofrs/flock"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -22,9 +28,14 @@ import (
"github.com/larksuite/cli/internal/vfs"
)
const bindCommitLockTimeout = 5 * time.Second
var bindCommitMu sync.Mutex
// BindOptions holds all inputs for config bind.
type BindOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Source string
AppID string
// Identity selects one of two presets — "bot-only" or "user-default" —
@@ -94,6 +105,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
# Interactive (terminal user) — TUI prompts for everything:
lark-cli config bind`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if runF != nil {
return runF(opts)
@@ -139,10 +151,11 @@ func configBindRun(opts *BindOptions) error {
return nil
}
appConfig, err := resolveAccount(opts, source)
result, err := resolveAccount(opts, source)
if err != nil {
return err
}
appConfig := result.AppConfig
opts.Brand = string(appConfig.Brand)
if err := resolveIdentity(opts); err != nil {
@@ -151,10 +164,20 @@ func configBindRun(opts *BindOptions) error {
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
return err
}
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
if err := validateBindResult(bindContext(opts), opts, result); err != nil {
return err
}
applyPreferences(appConfig, opts, priorLangForApp(existing.ConfigBytes, appConfig.AppId))
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
return commitBinding(opts, result, existing.ConfigBytes, source, targetConfigPath)
}
func bindContext(opts *BindOptions) context.Context {
if opts != nil && opts.Ctx != nil {
return opts.Ctx
}
return context.Background()
}
// existingBinding is the outcome of checking whether a workspace was already
@@ -239,9 +262,15 @@ func finalizeSource(opts *BindOptions) (string, error) {
// 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
oldConfigData, err := vfs.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return existingBinding{}, nil
}
return existingBinding{}, errs.NewConfigError(errs.SubtypeInvalidConfig,
"cannot read existing workspace config %s: %v", configPath, err).
WithHint("fix the file permissions or I/O error before binding").
WithCause(err)
}
if opts.IsTUI {
@@ -264,7 +293,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
// 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) {
func resolveAccount(opts *BindOptions, source string) (*BindResult, error) {
binder, err := newBinder(source, opts)
if err != nil {
return nil, err
@@ -278,7 +307,7 @@ func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
if err != nil {
return nil, err
}
return binder.Build(picked.AppID)
return binder.Build(bindContext(opts), *picked)
}
// resolveIdentity ensures opts.Identity is set before applyPreferences runs.
@@ -389,10 +418,21 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L
// wrong profile's preference into a re-bind when the workspace holds multiple
// named profiles and the active one disagrees with Apps[0].
func priorLang(previousConfigBytes []byte) i18n.Lang {
return priorLangForApp(previousConfigBytes, "")
}
func priorLangForApp(previousConfigBytes []byte, appID string) i18n.Lang {
var multi core.MultiAppConfig
if json.Unmarshal(previousConfigBytes, &multi) != nil {
return ""
}
if appID != "" {
for i := range multi.Apps {
if multi.Apps[i].AppId == appID {
return multi.Apps[i].Lang
}
}
}
if app := multi.CurrentAppConfig(""); app != nil {
return app.Lang
}
@@ -400,12 +440,16 @@ func priorLang(previousConfigBytes []byte) i18n.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}}
// deferred provider-manifest commit for keyless binds, and a JSON success
// envelope. The write and provider commit are serialized across CLI processes;
// if the provider commit fails, the workspace write is rolled back before any
// success output so an existing binding remains usable.
func commitBinding(opts *BindOptions, result *BindResult, previousConfigBytes []byte, source, configPath string) error {
appConfig := result.AppConfig
multi, err := mergeBoundApp(appConfig, previousConfigBytes, opts.langExplicit)
if err != nil {
return err
}
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
@@ -414,9 +458,38 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
}
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
releaseCommitLock, err := acquireBindCommitLock(opts)
if err != nil {
return err
}
commitLockHeld := true
defer func() {
if commitLockHeld {
releaseCommitLock()
}
}()
if err := ensureBindingSnapshotUnchanged(configPath, previousConfigBytes); err != nil {
return err
}
newConfigBytes := append(data, '\n')
if err := validate.AtomicWrite(configPath, newConfigBytes, 0600); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
}
if result.commitProviderManifest != nil {
if err := result.commitProviderManifest(); err != nil {
rollbackErr := rollbackBindingConfig(configPath, previousConfigBytes, newConfigBytes)
if rollbackErr != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to persist keyless signer provider: %v; failed to restore workspace config: %v", err, rollbackErr).
WithCause(err)
}
return errs.NewInternalError(errs.SubtypeStorage,
"failed to persist keyless signer provider (workspace config restored): %v", err).
WithCause(err)
}
}
releaseCommitLock()
commitLockHeld = false
replaced := previousConfigBytes != nil
// uiMsg renders human-facing TUI text (stderr success banner). Follows
@@ -425,10 +498,6 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
uiMsg := getBindMsg(opts.UILang)
display := sourceDisplayName(source)
if replaced {
cleanupKeychainFromData(opts.Factory.Keychain, previousConfigBytes, appConfig)
}
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
@@ -470,6 +539,133 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
return nil
}
func acquireBindCommitLock(opts *BindOptions) (func(), error) {
bindCommitMu.Lock()
lockDir := filepath.Join(core.GetBaseConfigDir(), "locks")
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
bindCommitMu.Unlock()
return nil, errs.NewInternalError(errs.SubtypeStorage,
"failed to create bind lock directory: %v", err).WithCause(err)
}
fileLock := flock.New(filepath.Join(lockDir, "config-bind.lock"))
ctx, cancel := context.WithTimeout(bindContext(opts), bindCommitLockTimeout)
locked, err := fileLock.TryLockContext(ctx, 50*time.Millisecond)
cancel()
if err != nil || !locked {
bindCommitMu.Unlock()
if err == nil {
err = context.DeadlineExceeded
}
return nil, errs.NewInternalError(errs.SubtypeStorage,
"failed to acquire config bind lock: %v", err).WithCause(err)
}
return func() {
_ = fileLock.Unlock()
bindCommitMu.Unlock()
}, nil
}
func ensureBindingSnapshotUnchanged(configPath string, previousConfigBytes []byte) error {
current, err := vfs.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) && previousConfigBytes == nil {
return nil
}
return errs.NewInternalError(errs.SubtypeStorage,
"failed to recheck workspace config %s before binding: %v", configPath, err).WithCause(err)
}
if previousConfigBytes != nil && bytes.Equal(current, previousConfigBytes) {
return nil
}
return errs.NewConfigError(errs.SubtypeInvalidConfig,
"workspace config %s changed while the bind was being validated", configPath).
WithHint("retry config bind using the latest workspace state")
}
func rollbackBindingConfig(configPath string, previousConfigBytes, writtenConfigBytes []byte) error {
current, err := vfs.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) && previousConfigBytes == nil {
return nil
}
//nolint:forbidigo // intermediate rollback diagnostic; commitBinding wraps it into a typed storage error
return fmt.Errorf("recheck workspace config before rollback: %w", err)
}
if !bytes.Equal(current, writtenConfigBytes) {
//nolint:forbidigo // intermediate rollback diagnostic; commitBinding wraps it into a typed storage error
return fmt.Errorf("workspace config changed after the bind write; refusing to overwrite it during rollback")
}
if previousConfigBytes != nil {
return validate.AtomicWrite(configPath, previousConfigBytes, 0600)
}
if err := vfs.Remove(configPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// mergeBoundApp upserts by unique appId and activates the target while
// preserving every non-target profile and root policy.
func mergeBoundApp(incoming *core.AppConfig, previousBytes []byte, langExplicit bool) (*core.MultiAppConfig, error) {
if incoming == nil || strings.TrimSpace(incoming.AppId) == "" {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "config bind produced an empty app")
}
if previousBytes == nil {
return &core.MultiAppConfig{Apps: []core.AppConfig{*incoming}, CurrentApp: incoming.ProfileName()}, nil
}
var multi core.MultiAppConfig
if err := json.Unmarshal(previousBytes, &multi); err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig,
"cannot update malformed workspace config: %v", err).WithCause(err)
}
match := -1
for i := range multi.Apps {
if multi.Apps[i].AppId != incoming.AppId {
continue
}
if match >= 0 {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig,
"appId %s appears in multiple CLI profiles", incoming.AppId).
WithHint("remove the duplicate profile before binding")
}
match = i
}
oldActive := ""
if active := multi.CurrentAppConfig(""); active != nil {
oldActive = active.ProfileName()
}
if match >= 0 {
old := multi.Apps[match]
incoming.Name = old.Name
incoming.Users = old.Users
if !langExplicit {
incoming.Lang = old.Lang
}
multi.Apps[match] = *incoming
} else {
for i := range multi.Apps {
if multi.Apps[i].Name != "" && multi.Apps[i].Name == incoming.AppId {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig,
"new appId %s conflicts with existing profile name", incoming.AppId).
WithHint("rename the existing profile before binding")
}
}
incoming.Name = ""
incoming.Users = []core.AppUser{}
multi.Apps = append(multi.Apps, *incoming)
match = len(multi.Apps) - 1
}
targetName := multi.Apps[match].ProfileName()
if oldActive != targetName {
multi.PreviousApp = oldActive
multi.CurrentApp = targetName
}
return &multi, 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

View File

@@ -84,6 +84,21 @@ func saveWorkspace(t *testing.T) {
t.Cleanup(func() { core.SetCurrentWorkspace(orig) })
}
func TestReconcileExistingBinding_ReadFailureIsNotTreatedAsMissing(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.Mkdir(configPath, 0700); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
_, err := reconcileExistingBinding(&BindOptions{Factory: f}, "openclaw", configPath)
if err == nil || !strings.Contains(err.Error(), "cannot read existing workspace config") {
t.Fatalf("reconcileExistingBinding error = %v", err)
}
if info, statErr := os.Stat(configPath); statErr != nil || !info.IsDir() {
t.Fatalf("unreadable existing config was changed: info=%v error=%v", info, statErr)
}
}
// ── Command flag parsing tests (aligned with config_test.go pattern) ──
func TestConfigBindCmd_FlagParsing(t *testing.T) {
@@ -1497,7 +1512,11 @@ func assertPresetApplied(t *testing.T, configPath string, wantStrict core.Strict
if len(multi.Apps) == 0 {
t.Fatalf("no apps in %s", configPath)
}
app := multi.Apps[0]
appPtr := multi.CurrentAppConfig("")
if appPtr == nil {
t.Fatalf("no current app in %s", configPath)
}
app := *appPtr
if app.StrictMode == nil || *app.StrictMode != wantStrict {
t.Errorf("StrictMode = %v, want %q", app.StrictMode, wantStrict)
}

View File

@@ -4,6 +4,7 @@
package config
import (
"context"
"fmt"
"os"
"path/filepath"
@@ -23,6 +24,19 @@ type Candidate struct {
Label string
}
// BindResult carries the selected app. External signer configuration is the
// logical provider on AppConfig.KeyRef; bind never persists executable paths.
type BindResult struct {
AppConfig *core.AppConfig
// commitProviderManifest is populated only after a keyless bind probe has
// authenticated successfully. commitBinding runs it after the workspace
// config write and rolls that write back if the global provider index cannot
// be committed, so a failed bind never changes the signer used by existing
// applications.
commitProviderManifest func() error
}
// 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.
@@ -34,9 +48,9 @@ type SourceBinder interface {
// 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)
// Build resolves credentials and returns the app plus any signer command
// needed by the workspace. Must be called after ListCandidates succeeds.
Build(ctx context.Context, candidate Candidate) (*BindResult, error)
}
// newBinder constructs the SourceBinder for the given source name.
@@ -93,11 +107,21 @@ func selectCandidate(
}
if appIDFlag != "" {
var matches []Candidate
for i := range candidates {
if candidates[i].AppID == appIDFlag {
return &candidates[i], nil
matches = append(matches, candidates[i])
}
}
if len(matches) == 1 {
return &matches[0], nil
}
if len(matches) > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--app-id %q matches multiple accounts in %s", appIDFlag, cfgBase).
WithHint("run 'lark-cli config bind' interactively to choose an account, or configure unique app IDs:\n %s", formatCandidates(matches)).
WithParam("--app-id")
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase).
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
WithParam("--app-id")
@@ -168,20 +192,48 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
return result, nil
}
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
func (b *openclawBinder) Build(_ context.Context, candidate Candidate) (*BindResult, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
var selected *binding.CandidateApp
for i := range b.rawApps {
if b.rawApps[i].AppID == appID {
if b.rawApps[i].AppID == candidate.AppID && b.rawApps[i].Label == candidate.Label {
selected = &b.rawApps[i]
break
}
}
if selected == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
return nil, errs.NewInternalError(errs.SubtypeSDKError,
"internal: account %q (appID %q) not in candidates", candidate.Label, candidate.AppID)
}
if selected.AuthMethod != "" && selected.AuthMethod != "app_secret" && selected.AuthMethod != binding.AuthMethodPrivateKeyJWT {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "unknown authMethod %q for app %s in %s", selected.AuthMethod, selected.AppID, b.path).
WithHint("supported values are app_secret and private_key_jwt")
}
// openclaw-lark deliberately gives appSecret precedence when both shapes
// are present. Reproduce that behavior so bind never changes the effective
// credential type merely because authMethod was left stale.
if selected.AppSecret.IsZero() && selected.AuthMethod == binding.AuthMethodPrivateKeyJWT {
if strings.TrimSpace(selected.KeyRef) == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig,
"private_key_jwt app %s in %s is missing keyRef", selected.AppID, b.path).
WithHint("re-run OpenClaw onboarding so the keyless account records its signer keyRef")
}
return &BindResult{
AppConfig: &core.AppConfig{
AppId: selected.AppID,
Brand: core.ParseBrand(selected.Brand),
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyRef: &core.SecretRef{
Source: core.SecretSourceTEE,
Provider: core.KeylessProviderLarkSuite,
ID: strings.TrimSpace(selected.KeyRef),
},
},
}, nil
}
if selected.AppSecret.IsZero() {
@@ -202,11 +254,11 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
WithCause(err)
}
return &core.AppConfig{
return &BindResult{AppConfig: &core.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: core.ParseBrand(selected.Brand),
}, nil
}}, nil
}
// ──────────────────────────────────────────────────────────────
@@ -238,7 +290,8 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
return []Candidate{{AppID: appID, Label: "default"}}, nil
}
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
func (b *hermesBinder) Build(_ context.Context, candidate Candidate) (*BindResult, error) {
appID := candidate.AppID
if b.envMap == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
@@ -258,11 +311,11 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
WithCause(err)
}
return &core.AppConfig{
return &BindResult{AppConfig: &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
}, nil
}}, nil
}
// ──────────────────────────────────────────────────────────────
@@ -295,7 +348,8 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
}
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
func (b *larkChannelBinder) Build(_ context.Context, candidate Candidate) (*BindResult, error) {
appID := candidate.AppID
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
@@ -323,11 +377,11 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
WithCause(err)
}
return &core.AppConfig{
return &BindResult{AppConfig: &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
}, nil
}}, nil
}
// ──────────────────────────────────────────────────────────────

View File

@@ -4,10 +4,12 @@
package config
import (
"context"
"path/filepath"
"reflect"
"testing"
"github.com/larksuite/cli/internal/binding"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
@@ -20,10 +22,10 @@ type fakeBinder struct {
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 }
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(context.Context, Candidate) (*BindResult, 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
@@ -107,6 +109,20 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
})
}
func TestSelectCandidate_AppIDFlag_RejectsDuplicateInheritedAppID(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_shared", Label: "work"},
{AppID: "cli_shared", Label: "personal"},
}
_, err := selectCandidate(b, candidates, "cli_shared", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "cli_shared" matches multiple accounts in openclaw.json`,
Hint: "run 'lark-cli config bind' interactively to choose an account, or configure unique app IDs:\n cli_shared (work)\n cli_shared (personal)",
})
}
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.
@@ -175,6 +191,27 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
assertCandidate(t, got, Candidate{AppID: "cli_b"})
}
func TestOpenClawBuildUsesSelectedLabelWhenAppIDIsShared(t *testing.T) {
b := &openclawBinder{
cfg: &binding.OpenClawRoot{},
rawApps: []binding.CandidateApp{
{Label: "work", AppID: "cli_shared", AuthMethod: binding.AuthMethodPrivateKeyJWT, KeyRef: "work-key"},
{Label: "personal", AppID: "cli_shared", AuthMethod: binding.AuthMethodPrivateKeyJWT, KeyRef: "personal-key"},
},
}
result, err := b.Build(context.Background(), Candidate{AppID: "cli_shared", Label: "personal"})
if err != nil {
t.Fatal(err)
}
if result.AppConfig.KeyRef == nil || result.AppConfig.KeyRef.ID != "personal-key" {
t.Fatalf("keyRef = %#v, want personal-key", result.AppConfig.KeyRef)
}
if result.AppConfig.KeyRef.Provider != core.KeylessProviderLarkSuite {
t.Fatalf("provider = %q, want %q", result.AppConfig.KeyRef.Provider, core.KeylessProviderLarkSuite)
}
}
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

View File

@@ -65,6 +65,39 @@ func TestConfigInitCmd_FlagParsing(t *testing.T) {
}
}
func TestConfigInitCmd_PrivateKeyJWTFlag(t *testing.T) {
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ConfigInitOptions
cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--new", "--private-key-jwt"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOpts.PrivateKeyJWT {
t.Error("PrivateKeyJWT = false, want true")
}
}
func TestConfigInitCmd_AuthMethodFlagRemoved(t *testing.T) {
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { return nil })
cmd.SetArgs([]string{"--new", "--auth-method", core.AuthMethodPrivateKeyJWT})
err := cmd.Execute()
if err == nil {
t.Fatal("expected unknown flag error")
}
if !strings.Contains(err.Error(), "unknown flag: --auth-method") {
t.Fatalf("error = %v, want unknown --auth-method flag", err)
}
}
func TestConfigShowCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -193,7 +226,7 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, "", "", nil); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
@@ -206,6 +239,68 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
}
}
func TestKeyRefFromResult_PrivateKeyJWT(t *testing.T) {
ref := keyRefFromResult(&configInitResult{
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "lark-cli-default",
})
if ref == nil {
t.Fatal("keyRefFromResult returned nil")
}
if ref.Source != "tee" || ref.ID != "lark-cli-default" {
t.Fatalf("key ref = %#v, want tee/lark-cli-default", ref)
}
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodPrivateKeyJWT}); ref != nil {
t.Fatalf("missing key label should not persist key ref, got %#v", ref)
}
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodClientSecret, KeyLabel: "ignored"}); ref != nil {
t.Fatalf("client_secret should not persist key ref, got %#v", ref)
}
if ref := keyRefFromResult(nil); ref != nil {
t.Fatalf("nil result should not persist key ref, got %#v", ref)
}
}
func TestPersistInitResult_PrivateKeyJWT(t *testing.T) {
for _, tc := range []struct {
name string
profile string
brand core.LarkBrand
}{
{name: "single app", brand: core.BrandFeishu},
{name: "named profile", profile: "prod", brand: core.BrandLark},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Lang: "en_us"}
result := &configInitResult{
Brand: tc.brand, AppID: "cli_pkjwt",
AuthMethod: core.AuthMethodPrivateKeyJWT, KeyLabel: "lark-cli-default",
}
if err := persistInitResult(opts, f, tc.profile, result); err != nil {
t.Fatal(err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
app := got.CurrentAppConfig(tc.profile)
if app == nil || app.AppId != "cli_pkjwt" || app.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Fatalf("saved app = %#v", app)
}
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
}
if !app.AppSecret.IsZero() {
t.Fatalf("private_key_jwt config must stay secretless, AppSecret value %#v", app.AppSecret)
}
})
}
}
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
// strictly validated the same way bind validates: wrong-case / typo / removed
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
@@ -388,7 +483,7 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
},
}
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en", "", nil)
if err == nil {
t.Fatal("expected conflict error")
}
@@ -427,6 +522,46 @@ func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
}
}
func TestSaveAsProfile_UpdatePersistsPrivateKeyJWT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "prod",
AppId: "cli_prod",
AppSecret: core.PlainSecret("old-secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
}},
}
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
if err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "prod", "cli_prod", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
t.Fatalf("saveAsProfile update private_key_jwt: %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := got.FindApp("prod")
if app == nil {
t.Fatalf("profile prod not saved: %#v", got.Apps)
}
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
}
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
}
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
t.Fatalf("private_key_jwt update must stay secretless, AppSecret value %#v", app.AppSecret)
}
if len(app.Users) != 1 || app.Users[0].UserOpenId != "ou_1" {
t.Fatalf("same-app update should preserve users, Users=%#v", app.Users)
}
}
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
multi := &core.MultiAppConfig{
CurrentApp: "prod",

View File

@@ -19,6 +19,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
)
@@ -31,6 +32,7 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
PrivateKeyJWT bool // --private-key-jwt: request private_key_jwt instead of the default client_secret
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
@@ -39,6 +41,8 @@ type ConfigInitOptions struct {
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
Restore bool // Restore re-registers the app already in config to recover a lost credential
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
// at config bind — which is what AI agents almost always want. Manual
@@ -81,17 +85,26 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
}
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
cmd.Flags().BoolVar(&opts.PrivateKeyJWT, "private-key-jwt", false, "create a new app with private_key_jwt (signed by a platform key, no app secret)")
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.Restore, "restore", false, "re-register the app already in config to recover a lost credential (keychain key / app secret); reuses the stored app ID and auth method")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write")
return cmd
}
func requestedInitAuthMethod(opts *ConfigInitOptions) string {
if opts.PrivateKeyJWT {
return core.AuthMethodPrivateKeyJWT
}
return core.AuthMethodClientSecret
}
// printLangPreferenceConfirmation echoes the set preference to stderr, only
// when --lang explicitly set a non-empty value.
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
@@ -132,7 +145,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.AppID != "" || o.AppSecretStdin
return o.New || o.Restore || o.AppID != "" || o.AppSecretStdin
}
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
@@ -151,22 +164,61 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
}
}
// removeStaleSecretForPKJWT clears a secret left in the keychain when the SAME
// appId is migrated from client_secret to private_key_jwt. cleanupOldConfig
// explicitly skips a matching appId, and saveAsProfile only cleans up on an
// appId change, so a same-appId migration would orphan the old secret. This
// fills that gap. RemoveSecretStore only deletes Source=="keychain" entries, so
// the new pkjwt tee key handle is never touched.
func removeStaleSecretForPKJWT(existing *core.MultiAppConfig, profileName, appID string, kc keychain.KeychainAccess) {
if existing == nil {
return
}
var prior *core.AppConfig
if profileName != "" {
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
prior = &existing.Apps[idx]
}
} else {
prior = existing.CurrentAppConfig("")
}
if prior != nil && prior.AppId == appID && !prior.AppSecret.IsZero() {
core.RemoveSecretStore(prior.AppSecret, kc)
}
}
// keyRefFromResult builds the TEE key reference to persist for a private_key_jwt
// registration result, or nil for client_secret.
func keyRefFromResult(r *configInitResult) *core.SecretRef {
if r != nil && r.AuthMethod == core.AuthMethodPrivateKeyJWT && r.KeyLabel != "" {
return &core.SecretRef{Source: "tee", ID: r.KeyLabel}
}
return nil
}
// saveAsOnlyApp overwrites config.json with a single-app config.
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
AuthMethod: authMethod, KeyRef: keyRef,
}},
}
return saveMultiAppConfigForInit(config)
}
func saveMultiAppConfigForInit(config *core.MultiAppConfig) error {
return core.SaveMultiAppConfig(config)
}
// saveInitConfig saves a new/updated app config, respecting --profile mode.
// With profileName: appends or updates the named profile (preserves other profiles).
// Without profileName: cleans up old config and saves as the only app.
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
// authMethod/keyRef carry the credential type: ("", nil) for client_secret,
// (private_key_jwt, &{tee,label}) for the secretless TEE flow.
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
if profileName != "" {
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang, authMethod, keyRef)
}
cleanupOldConfig(existing, f, appId)
var prior i18n.Lang
@@ -175,7 +227,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
prior = app.Lang
}
}
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)), authMethod, keyRef)
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
@@ -195,7 +247,7 @@ func wrapSaveConfigError(err error) error {
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
multi := existing
if multi == nil {
multi = &core.MultiAppConfig{}
@@ -214,6 +266,8 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
multi.Apps[idx].AuthMethod = authMethod
multi.Apps[idx].KeyRef = keyRef
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
@@ -222,15 +276,17 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
AuthMethod: authMethod,
KeyRef: keyRef,
})
}
return core.SaveMultiAppConfig(multi)
return saveMultiAppConfigForInit(multi)
}
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
@@ -302,12 +358,141 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
app.AppId = appID
app.Brand = brand
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
return core.SaveMultiAppConfig(existing)
return saveMultiAppConfigForInit(existing)
}
func persistInitResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error {
existing, _ := core.LoadMultiAppConfig()
switch {
case result.AuthMethod == core.AuthMethodPrivateKeyJWT:
if err := saveInitConfig(profileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
return wrapSaveConfigError(err)
}
removeStaleSecretForPKJWT(existing, profileName, result.AppID, f.Keychain)
return nil
case result.AppSecret != "":
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(profileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
return wrapSaveConfigError(err)
}
return nil
case result.Mode == "existing" && result.AppID != "":
return wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, profileName, result.AppID, result.Brand, opts.Lang))
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").WithParam("--app-id")
}
}
func probeInitResult(opts *ConfigInitOptions, f *cmdutil.Factory, result *configInitResult) error {
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
return runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel)
}
if result.AppSecret != "" {
return runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand)
}
return nil
}
// persistAndProbeResult saves a registration/restore result into profileName and
// runs the post-registration probe. profileName == "" replaces the single app
// (legacy); a named profile is updated in place. Shared by --new and --restore.
func persistAndProbeResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error {
if err := persistInitResult(opts, f, profileName, result); err != nil {
return err
}
printLangPreferenceConfirmation(opts)
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "authMethod": result.AuthMethod, "brand": result.Brand})
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
}
return probeInitResult(opts, f, result)
}
// runRestoreFlow re-registers the app already in config to recover a lost
// credential (deleted keychain key / lost app secret). It reads the existing
// app id + auth method + brand from config (no secret needed — that's the lost
// part) and re-runs the device-flow registration with the app id sent on begin,
// so the server re-registers that app instead of creating a new one. The
// re-issued credential is written back to the same profile.
func runRestoreFlow(opts *ConfigInitOptions, existing *core.MultiAppConfig, f *cmdutil.Factory, msg *initMsg) error {
if existing == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no config found").
WithHint("run: lark-cli config init")
}
app := existing.CurrentAppConfig(opts.ProfileName)
if app == nil || app.AppId == "" {
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no app id in config%s", profileSuffix(opts.ProfileName)).
WithHint("run: lark-cli config init")
}
if app.KeyRef != nil && strings.TrimSpace(app.KeyRef.Provider) != "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"config init --restore does not manage external signer provider %q", app.KeyRef.Provider).
WithHint("repair the OpenClaw provider with onboarding doctor --fix, then run config bind again")
}
restoreAppID := app.AppId
// Reuse the stored auth method authoritatively — never prompt. Empty on disk
// means client_secret (omitempty back-compat); pass it explicitly so restore
// preserves the existing credential type.
authMethod := app.AuthMethod
if authMethod == "" {
authMethod = core.AuthMethodClientSecret
}
result, err := runCreateAppFlow(opts.Ctx, f, app.Brand, authMethod, msg, restoreAppID)
if err != nil {
return err
}
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app restore returned no result")
}
// Safety: if the server did not honor app_id (e.g. not yet supported), it may
// have created a NEW app instead of restoring. Warn so the user is not silently
// switched to a different app id.
if result.AppID != restoreAppID {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] restore: server returned app %s, expected %s — it may have created a new app instead of restoring\n", result.AppID, restoreAppID)
}
// Write back to the profile we restored: an explicit --name, else the resolved
// app's own name. Empty name => legacy single-app replace.
saveProfile := opts.ProfileName
if saveProfile == "" {
saveProfile = app.Name
}
return persistAndProbeResult(opts, f, saveProfile, result)
}
// profileSuffix renders " (profile %q)" for error messages, or "" when unnamed.
func profileSuffix(profileName string) string {
if profileName == "" {
return ""
}
return fmt.Sprintf(" (profile %q)", profileName)
}
func configInitRun(opts *ConfigInitOptions) error {
f := opts.Factory
if opts.PrivateKeyJWT {
switch {
case opts.Restore:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--private-key-jwt cannot be combined with --restore; restore preserves the stored auth method").
WithParam("--private-key-jwt")
case opts.AppID != "":
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--private-key-jwt cannot be combined with --app-id; use --new to register a private_key_jwt app").
WithParam("--private-key-jwt")
case opts.AppSecretStdin:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--private-key-jwt cannot be combined with --app-secret-stdin; private_key_jwt does not use an app secret").
WithParam("--private-key-jwt")
}
}
// Read secret from stdin if --app-secret-stdin is set
if opts.AppSecretStdin {
scanner := bufio.NewScanner(f.IOStreams.In)
@@ -335,6 +520,26 @@ func configInitRun(opts *ConfigInitOptions) error {
}
}
// --restore recovers an existing app; it is incompatible with creating a new
// app (--new) or importing one non-interactively (--app-id / stdin secret).
if opts.Restore {
if opts.New {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --new").WithParam("--restore")
}
if opts.AppID != "" || opts.AppSecretStdin {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --app-id / --app-secret-stdin").WithParam("--restore")
}
}
// A user who explicitly asks for private_key_jwt needs immediate feedback
// before any interactive prompt. Otherwise unsupported machines enter the
// TUI and fail only after the user chooses a create flow.
if opts.PrivateKeyJWT && !opts.New && !opts.Restore {
if _, err := resolveRegisterAuthMethod(opts.Ctx, f, core.AuthMethodPrivateKeyJWT); err != nil {
return err
}
}
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
@@ -342,7 +547,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang, "", nil); err != nil {
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
@@ -368,34 +573,26 @@ func configInitRun(opts *ConfigInitOptions) error {
msg := getInitMsg(opts.UILang)
// Mode: Restore (--restore) — re-register the app already in config.
if opts.Restore {
return runRestoreFlow(opts, existing, f, msg)
}
// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), requestedInitAuthMethod(opts), msg, "")
if err != nil {
return err
}
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
}
existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil
return persistAndProbeResult(opts, f, opts.ProfileName, result)
}
// Mode 4: Interactive TUI (terminal)
if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal {
result, err := runInteractiveConfigInit(opts.Ctx, f, msg)
result, err := runInteractiveConfigInit(opts.Ctx, f, requestedInitAuthMethod(opts), msg)
if err != nil {
return err
}
@@ -404,35 +601,21 @@ func configInitRun(opts *ConfigInitOptions) error {
WithParam("--app-id")
}
existing, _ := core.LoadMultiAppConfig()
if result.AppSecret != "" {
// New secret provided (either from "create" or "existing" with input)
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil {
if err := persistInitResult(opts, f, opts.ProfileName, result); err != nil {
return err
}
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
if err := probeInitResult(opts, f, result); err != nil {
return err
}
} else {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
WithParam("--app-id")
}
if result.Mode == "existing" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
if result.AppSecret != "" {
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
if result.AuthMethod != core.AuthMethodPrivateKeyJWT {
return probeInitResult(opts, f, result)
}
return nil
}
@@ -517,7 +700,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang, "", nil); err != nil {
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))

View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"crypto"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
type authMethodTestSigner struct {
info keysigner.HardwareInfo
probeErr error
}
func (authMethodTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return nil, nil
}
func (authMethodTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return nil, nil
}
func (authMethodTestSigner) Sign(context.Context, keysigner.KeyRef, []byte) ([]byte, string, error) {
return nil, "", nil
}
func (s authMethodTestSigner) ProbeHardware(context.Context) (keysigner.HardwareInfo, error) {
return s.info, s.probeErr
}
// TestResolveRegisterAuthMethod covers the non-interactive gating paths. The
// darwin keychain signer is compiled into every build, so the test cannot rely
// on the binary lacking a signer — it forces a known no-signer state for the
// rejection cases, then registers a stub for the success case.
func TestResolveRegisterAuthMethod(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := &cmdutil.Factory{}
ctx := context.Background()
prevSigner := keysigner.Active()
t.Cleanup(func() { keysigner.Register(prevSigner) })
keysigner.Register(nil)
if m, err := resolveRegisterAuthMethod(ctx, f, core.AuthMethodClientSecret); err != nil || m != core.AuthMethodClientSecret {
t.Errorf("client_secret: got (%q, %v), want (client_secret, nil)", m, err)
}
if m, err := resolveRegisterAuthMethod(ctx, f, ""); err != nil || m != core.AuthMethodClientSecret {
t.Errorf("default: got (%q, %v), want (client_secret, nil)", m, err)
}
if _, err := resolveRegisterAuthMethod(ctx, f, "bogus"); err == nil {
t.Error("bogus auth-method: expected error")
}
if _, err := resolveRegisterAuthMethod(ctx, f, core.AuthMethodPrivateKeyJWT); err == nil {
t.Error("private_key_jwt without a signer: expected error")
}
keysigner.Register(authMethodTestSigner{info: keysigner.HardwareInfo{Backend: "tpm2", Available: true}})
if m, err := resolveRegisterAuthMethod(ctx, f, core.AuthMethodPrivateKeyJWT); err != nil || m != core.AuthMethodPrivateKeyJWT {
t.Errorf("private_key_jwt with signer: got (%q, %v), want (private_key_jwt, nil)", m, err)
}
f.IOStreams = &cmdutil.IOStreams{IsTerminal: true}
if m, err := resolveRegisterAuthMethod(ctx, f, ""); err != nil || m != core.AuthMethodClientSecret {
t.Errorf("default with terminal signer: got (%q, %v), want (client_secret, nil)", m, err)
}
}
func TestConfigInitRunRejectsPrivateKeyJWTIncompatibleModes(t *testing.T) {
tests := []struct {
name string
configure func(*ConfigInitOptions, *cmdutil.Factory)
wantTarget string
}{
{
name: "app id import",
configure: func(opts *ConfigInitOptions, _ *cmdutil.Factory) {
opts.AppID = "cli_test"
},
wantTarget: "--app-id",
},
{
name: "app secret stdin import",
configure: func(opts *ConfigInitOptions, f *cmdutil.Factory) {
opts.AppSecretStdin = true
f.IOStreams.In = strings.NewReader("secret\n")
},
wantTarget: "--app-secret-stdin",
},
{
name: "restore",
configure: func(opts *ConfigInitOptions, _ *cmdutil.Factory) {
opts.Restore = true
},
wantTarget: "--restore",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{
Factory: f,
Ctx: context.Background(),
PrivateKeyJWT: true,
}
tc.configure(opts, f)
err := configInitRun(opts)
if err == nil {
t.Fatal("expected incompatible mode error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %[1]v", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "--private-key-jwt" {
t.Fatalf("param = %q, want --private-key-jwt", validationErr.Param)
}
if !strings.Contains(problem.Message, tc.wantTarget) {
t.Fatalf("message = %q, want %s", problem.Message, tc.wantTarget)
}
})
}
}
func TestResolveRegisterAuthMethod_PrivateKeyJWTRejectsUnavailableHardware(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
prevSigner := keysigner.Active()
t.Cleanup(func() { keysigner.Register(prevSigner) })
keysigner.Register(authMethodTestSigner{info: keysigner.HardwareInfo{
Backend: "tpm2",
Reason: "open /dev/tpmrm0: permission denied",
}})
_, err := resolveRegisterAuthMethod(context.Background(), &cmdutil.Factory{}, core.AuthMethodPrivateKeyJWT)
if err == nil {
t.Fatal("private_key_jwt with unavailable signer hardware: expected error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %[1]v", err)
}
if problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient {
t.Fatalf("problem = %s/%s, want config/invalid_client", problem.Category, problem.Subtype)
}
wantMessage := "this machine does not support --private-key-jwt"
if problem.Message != wantMessage {
t.Fatalf("message = %q, want %q", problem.Message, wantMessage)
}
if strings.Contains(problem.Message, "sks") || strings.Contains(problem.Message, "/dev/tpm") || strings.Contains(problem.Message, "tpm") || strings.Contains(problem.Message, "TEE") || strings.Contains(problem.Message, "Keychain") {
t.Fatalf("message exposes backend detail: %q", problem.Message)
}
if !strings.Contains(problem.Hint, "omit --private-key-jwt") {
t.Fatalf("hint = %q, want guidance to omit --private-key-jwt", problem.Hint)
}
if strings.Contains(problem.Hint, "fix the local signer") {
t.Fatalf("hint exposes unnecessary signer recovery: %q", problem.Hint)
}
}
func TestResolveRegisterAuthMethod_PrivateKeyJWTRejectsProbeError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
probeErr := errors.New("probe exploded")
prevSigner := keysigner.Active()
t.Cleanup(func() { keysigner.Register(prevSigner) })
keysigner.Register(authMethodTestSigner{
info: keysigner.HardwareInfo{Backend: "keychain"},
probeErr: probeErr,
})
_, err := resolveRegisterAuthMethod(context.Background(), &cmdutil.Factory{}, core.AuthMethodPrivateKeyJWT)
if err == nil {
t.Fatal("private_key_jwt with probe error: expected error")
}
if !errors.Is(err, probeErr) {
t.Fatalf("error does not preserve probe cause: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %[1]v", err)
}
if problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient {
t.Fatalf("problem = %s/%s, want config/invalid_client", problem.Category, problem.Subtype)
}
wantMessage := "this machine does not support --private-key-jwt"
if problem.Message != wantMessage {
t.Fatalf("message = %q, want %q", problem.Message, wantMessage)
}
if strings.Contains(problem.Message, "probe") || strings.Contains(problem.Message, "keychain signer") {
t.Fatalf("message exposes probe detail: %q", problem.Message)
}
if !strings.Contains(problem.Hint, "omit --private-key-jwt") {
t.Fatalf("hint = %q, want guidance to omit --private-key-jwt", problem.Hint)
}
if strings.Contains(problem.Hint, "fix the local signer") {
t.Fatalf("hint exposes unnecessary signer recovery: %q", problem.Hint)
}
}
func TestConfigInitRun_PrivateKeyJWTRejectsBeforeInteractiveMode(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
prevSigner := keysigner.Active()
t.Cleanup(func() { keysigner.Register(prevSigner) })
keysigner.Register(authMethodTestSigner{info: keysigner.HardwareInfo{
Backend: "tpm2",
Reason: "not available",
}})
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.IsTerminal = true
opts := &ConfigInitOptions{
Factory: f,
Ctx: context.Background(),
PrivateKeyJWT: true,
Lang: "zh_cn",
UILang: "zh_cn",
}
err := configInitRun(opts)
if err == nil {
t.Fatal("config init --private-key-jwt on unsupported machine: expected error before interactive mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %[1]v", err)
}
if problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient {
t.Fatalf("problem = %s/%s, want config/invalid_client", problem.Category, problem.Subtype)
}
if problem.Message != "this machine does not support --private-key-jwt" {
t.Fatalf("message = %q", problem.Message)
}
}
func TestExistingAppRequiresSecret(t *testing.T) {
if !existingAppRequiresSecret(core.AuthMethodClientSecret) {
t.Error("client_secret existing app should require App Secret")
}
if existingAppRequiresSecret("") != true {
t.Error("default existing app should require App Secret")
}
if existingAppRequiresSecret(core.AuthMethodPrivateKeyJWT) {
t.Error("private_key_jwt existing app should not require App Secret")
}
}
// TestValidatePKJWTKeyBinding covers the guard that rejects a registration
// resolving to private_key_jwt with no signing key bound (e.g. an existing
// secret-based app was selected on the confirm page).
func TestValidatePKJWTKeyBinding(t *testing.T) {
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, ""); err == nil {
t.Error("pkjwt with empty keyLabel: expected error")
}
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, "agent-key"); err != nil {
t.Errorf("pkjwt with keyLabel: expected nil, got %v", err)
}
if err := validatePKJWTKeyBinding(core.AuthMethodClientSecret, ""); err != nil {
t.Errorf("client_secret: expected nil, got %v", err)
}
}
// TestResolveFinalAuthMethod locks the authoritative-method logic. The 2nd case
// is the real bug: we requested private_key_jwt but the server resolved to an
// existing client_secret app — we must persist client_secret, not pkjwt.
func TestResolveFinalAuthMethod(t *testing.T) {
if m := resolveFinalAuthMethod([]string{"client_secret", "private_key_jwt"}, core.AuthMethodClientSecret); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("prefers private_key_jwt: got %q", m)
}
if m := resolveFinalAuthMethod([]string{"client_secret"}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodClientSecret {
t.Errorf("server client_secret must override requested pkjwt: got %q", m)
}
if m := resolveFinalAuthMethod(nil, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("fallback to requested when server is silent: got %q", m)
}
// Explicit empty slice (not just nil) also falls back to requested — the same
// len()==0 back-compat allowance the init guard relies on to let private_key_jwt
// proceed against an older server (see internal/auth
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods).
if m := resolveFinalAuthMethod([]string{}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("empty []string should fall back to requested private_key_jwt: got %q", m)
}
if m := resolveFinalAuthMethod(nil, ""); m != core.AuthMethodClientSecret {
t.Errorf("default to client_secret: got %q", m)
}
}

View File

@@ -8,6 +8,9 @@ import (
"errors"
"fmt"
"net"
"slices"
"strings"
"time"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/build"
@@ -15,22 +18,26 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
)
// configInitResult holds the result of the interactive config init flow.
type configInitResult struct {
Mode string // "create" or "existing"
Brand core.LarkBrand
AppID string
AppSecret string
Mode string // "create" or "existing"
Brand core.LarkBrand
AppID string
AppSecret string
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // TEE key handle when AuthMethod == private_key_jwt
}
// runInteractiveConfigInit shows an interactive TUI for config init.
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMethodFlag string, msg *initMsg) (*configInitResult, error) {
// Phase 1: Choose mode
var mode string
form1 := huh.NewForm(
@@ -53,14 +60,18 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *init
}
if mode == "existing" {
return runExistingAppForm(f, msg)
return runExistingAppForm(ctx, f, authMethodFlag, msg)
}
return runCreateAppFlow(ctx, f, "", msg)
return runCreateAppFlow(ctx, f, "", authMethodFlag, msg, "")
}
func existingAppRequiresSecret(requestedAuthMethod string) bool {
return requestedAuthMethod != core.AuthMethodPrivateKeyJWT
}
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
func runExistingAppForm(ctx context.Context, f *cmdutil.Factory, requestedAuthMethod string, msg *initMsg) (*configInitResult, error) {
// Load existing config for defaults
existing, _ := core.LoadMultiAppConfig()
var firstApp *core.AppConfig
@@ -94,19 +105,31 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
brand = string(firstApp.Brand)
}
form := huh.NewForm(
huh.NewGroup(
appIDInput,
appSecretInput,
huh.NewSelect[string]().
Title(msg.Platform).
Options(
huh.NewOption(msg.Feishu, "feishu"),
huh.NewOption("Lark", "lark"),
).
Value(&brand),
),
).WithTheme(cmdutil.ThemeFeishu())
brandSelect := huh.NewSelect[string]().
Title(msg.Platform).
Options(
huh.NewOption(msg.Feishu, "feishu"),
huh.NewOption("Lark", "lark"),
).
Value(&brand)
var form *huh.Form
if existingAppRequiresSecret(requestedAuthMethod) {
form = huh.NewForm(
huh.NewGroup(
appIDInput,
appSecretInput,
brandSelect,
),
).WithTheme(cmdutil.ThemeFeishu())
} else {
form = huh.NewForm(
huh.NewGroup(
appIDInput,
brandSelect,
),
).WithTheme(cmdutil.ThemeFeishu())
}
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
@@ -119,6 +142,13 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
if appID == "" && firstApp != nil {
appID = firstApp.AppId
}
if !existingAppRequiresSecret(requestedAuthMethod) {
if appID == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty").
WithParam("--app-id")
}
return runCreateAppFlow(ctx, f, parseBrand(brand), core.AuthMethodPrivateKeyJWT, msg, appID)
}
if appSecret == "" && firstApp != nil && !firstApp.AppSecret.IsZero() {
// Keep existing secret - caller will handle
return &configInitResult{
@@ -148,9 +178,49 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
}, nil
}
// resolveRegisterAuthMethod decides the auth method for a new-app registration.
// An explicit private_key_jwt request wins; otherwise the default is
// client_secret with no extra prompt.
func resolveRegisterAuthMethod(ctx context.Context, _ *cmdutil.Factory, requested string) (string, error) {
const pkjwtUnsupportedMessage = "this machine does not support --private-key-jwt"
switch requested {
case core.AuthMethodPrivateKeyJWT:
info, ok, err := keysigner.ProbeActiveHardware(ctx)
if !ok {
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
pkjwtUnsupportedMessage).
WithHint("omit --private-key-jwt to register with an app secret")
}
if err != nil {
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
pkjwtUnsupportedMessage).
WithCause(err).
WithHint("omit --private-key-jwt to register with an app secret")
}
if !info.Available {
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
pkjwtUnsupportedMessage).
WithHint("omit --private-key-jwt to register with an app secret")
}
return core.AuthMethodPrivateKeyJWT, nil
case core.AuthMethodClientSecret:
return core.AuthMethodClientSecret, nil
case "":
return core.AuthMethodClientSecret, nil
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown auth method %q (use client_secret or private_key_jwt)", requested)
}
}
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
// If brandOverride is non-empty, skip the interactive brand selection.
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
// requestedAuthMethod is the requested auth method; empty means client_secret.
// restoreAppID, when non-empty, is sent on the registration begin request so the
// server re-registers that existing app (credential recovery) instead of creating
// a new one. Empty preserves the normal new-app flow.
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, requestedAuthMethod string, msg *initMsg, restoreAppID string) (*configInitResult, error) {
var larkBrand core.LarkBrand
if brandOverride != "" {
larkBrand = brandOverride
@@ -178,17 +248,57 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
larkBrand = parseBrand(brand)
}
// Step 1: Request app registration (begin)
authMethod, err := resolveRegisterAuthMethod(ctx, f, requestedAuthMethod)
if err != nil {
return nil, err
}
// Step 1: Request app registration (begin).
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
// For private_key_jwt: init to obtain a nonce, then sign a TEE attestation
// (carrying the public key in its jwk header) to send with begin.
beginOpts := larkauth.AppRegistrationBeginOptions{}
keyLabel := ""
if authMethod == core.AuthMethodPrivateKeyJWT {
initResp, initErr := larkauth.RequestAppRegistrationInit(ctx, httpClient)
if initErr != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration init failed: %v", initErr).WithCause(initErr)
}
// An empty SupportedAuthMethods is intentionally treated as "older server /
// unknown": len()==0 makes this guard false, so the requested
// private_key_jwt proceeds. This mirrors resolveFinalAuthMethod's
// back-compat fallback to the requested method. Only an explicit list that
// omits private_key_jwt rejects here.
if len(initResp.SupportedAuthMethods) > 0 && !slices.Contains(initResp.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT) {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
"server does not support private_key_jwt for this app type (supported: %s)", strings.Join(initResp.SupportedAuthMethods, ", ")).
WithHint("omit --private-key-jwt to register with an app secret instead")
}
keyLabel = keysigner.DefaultKeyLabel
signer := keysigner.Active() // non-nil, guaranteed by resolveRegisterAuthMethod
attestation, signErr := jwt.SignAttestation(ctx, signer, keysigner.KeyRef{Label: keyLabel}, initResp.Nonce, time.Now())
if signErr != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to sign registration attestation: %v", signErr).WithCause(signErr)
}
beginOpts = larkauth.AppRegistrationBeginOptions{
AuthMethod: core.AuthMethodPrivateKeyJWT,
AuthAttestation: attestation,
}
}
// Restore flow: re-register the existing app instead of creating a new one.
beginOpts.RestoreAppID = restoreAppID
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, beginOpts, f.IOStreams.ErrOut)
if err != nil {
return nil, classifyRegistrationBeginError(err)
}
// Step 2: Build and display verification URL + QR code
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version, restoreAppID)
// Branch on TTY: human-friendly copy in interactive terminals,
// preserve original copy for AI / non-interactive callers.
@@ -217,18 +327,42 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
return nil, classifyRegistrationError(err)
}
if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
// The final auth method is decided by the user/admin at confirmation and
// returned by poll — NOT necessarily what we requested. Selecting an existing
// client_secret app, for example, yields client_secret even though we sent
// private_key_jwt. Trust the result so we persist the truth.
finalMethod := resolveFinalAuthMethod(result.AuthMethods, authMethod)
if result.ClientID == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing app_id")
}
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_secret")
}
// Surface a downgrade: requested private_key_jwt but the app resolved to a
// secret-based method (e.g. an existing app was selected). The key was NOT
// bound, so we must store the secret method, not private_key_jwt.
if authMethod == core.AuthMethodPrivateKeyJWT && finalMethod != core.AuthMethodPrivateKeyJWT {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] note: requested private_key_jwt, but the app uses %q (e.g. an existing app was selected); storing %q.\n", finalMethod, finalMethod)
}
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
keyToStore := ""
if finalMethod == core.AuthMethodPrivateKeyJWT {
keyToStore = keyLabel
}
if err := validatePKJWTKeyBinding(finalMethod, keyToStore); err != nil {
return nil, err
}
return &configInitResult{
Mode: "create",
Brand: finalBrand,
AppID: result.ClientID,
AppSecret: result.ClientSecret,
Mode: "create",
Brand: finalBrand,
AppID: result.ClientID,
AppSecret: result.ClientSecret, // empty for private_key_jwt; real secret otherwise
AuthMethod: finalMethod,
KeyLabel: keyToStore,
}, nil
}
@@ -268,3 +402,41 @@ func classifyRegistrationError(err error) error {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration failed: %v", err).WithCause(err)
}
}
// validatePKJWTKeyBinding rejects a registration that resolved to
// private_key_jwt without a signing key bound to it. keyLabel is non-empty only
// when the local flow chose private_key_jwt and signed a TEE attestation; a
// resolved method of private_key_jwt with no key handle would save an unusable
// config (rejected later at config load, surfacing as "saved OK, fails on first
// use"), so it is caught here at registration time instead.
func validatePKJWTKeyBinding(finalMethod, keyLabel string) error {
if finalMethod == core.AuthMethodPrivateKeyJWT && keyLabel == "" {
return errs.NewConfigError(errs.SubtypeInvalidClient,
"registration resolved to private_key_jwt but no signing key was bound to this app (an existing secret-based app may have been selected)").
WithHint("re-register with: lark-cli config init --new --private-key-jwt")
}
return nil
}
// resolveFinalAuthMethod picks the authoritative method from the poll result,
// preferring private_key_jwt, then client_secret. It falls back to the requested
// method when the server returns nothing (older servers).
func resolveFinalAuthMethod(serverMethods []string, requested string) string {
if len(serverMethods) == 0 {
if requested == "" {
return core.AuthMethodClientSecret
}
return requested
}
for _, m := range serverMethods {
if m == core.AuthMethodPrivateKeyJWT {
return core.AuthMethodPrivateKeyJWT
}
}
for _, m := range serverMethods {
if m == core.AuthMethodClientSecret {
return core.AuthMethodClientSecret
}
}
return serverMethods[0]
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keysigner"
)
// probeTimeout is the total wall-clock budget for the credential probe step
@@ -90,3 +91,35 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
// runProbePKJWT does a best-effort key-binding validation after a private_key_jwt
// config is saved: it signs a client_assertion with the local platform key and
// mints a token. A typed error (a deterministic server rejection — e.g. the key
// is not bound to this app) is propagated so `config init` exits non-zero with
// the canonical envelope; untyped errors (transport / HTTP / parse / timeout)
// are swallowed (return nil). The mint itself is the probe — no second call.
func runProbePKJWT(parent context.Context, factory *cmdutil.Factory, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) error {
if factory == nil {
return nil
}
if signer == nil {
return nil
}
httpClient, err := factory.HttpClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
if _, err := credential.FetchTATWithAssertion(ctx, httpClient, brand, clientID, signer, keyLabel); err != nil {
// Typed = deterministic credential rejection → propagate. Untyped
// (transport / HTTP / parse / timeout) is ambiguous → stay silent.
if errs.IsTyped(err) {
return err
}
return nil
}
return nil
}

View File

@@ -6,6 +6,11 @@ package config
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
crand "crypto/rand"
"crypto/sha256"
"errors"
"io"
"net/http"
@@ -17,14 +22,17 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeRT routes requests to per-path handlers and records what it saw.
type fakeRT struct {
tatHandler func(req *http.Request) (*http.Response, error)
probeHandler func(req *http.Request) (*http.Response, error)
oauthHandler func(req *http.Request) (*http.Response, error)
tatCalls int
probeCalls int
oauthCalls int
probeReq *http.Request
probeBody string
}
@@ -48,10 +56,50 @@ func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
}
return f.probeHandler(req)
case strings.HasSuffix(req.URL.Path, "/authen/v2/oauth/token"):
f.oauthCalls++
if f.oauthHandler == nil {
return jsonResp(200, `{"access_token":"test-token"}`), nil
}
return f.oauthHandler(req)
}
return nil, errors.New("unexpected URL: " + req.URL.String())
}
// probeTestSigner is an in-memory real ECDSA P-256 signer used to sign the
// client_assertion in runProbePKJWT tests (authMethodTestSigner returns a nil
// key and cannot sign).
type probeTestSigner struct{ key *ecdsa.PrivateKey }
func newProbeTestSigner(t *testing.T) *probeTestSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
if err != nil {
t.Fatal(err)
}
return &probeTestSigner{key: k}
}
func (p *probeTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return p.key.Public(), nil
}
func (p *probeTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return p.key.Public(), nil
}
func (p *probeTestSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(crand.Reader, p.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func jsonResp(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
@@ -208,10 +256,12 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
assertSilent(t, err, errBuf)
}
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
func TestProbeInitResult_ClientSecret(t *testing.T) {
rt := &fakeRT{}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
opts := &ConfigInitOptions{Ctx: context.Background()}
result := &configInitResult{AppID: "cli_x", AppSecret: "test-secret", Brand: core.BrandFeishu}
err := probeInitResult(opts, f, result)
if rt.tatCalls != 1 || rt.probeCalls != 1 {
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
}
@@ -285,3 +335,47 @@ func TestRunProbe_TimeoutHonored(t *testing.T) {
// must stay silent and not block.
assertSilent(t, err, errBuf)
}
// runProbePKJWT: a deterministic server rejection (invalid_client) is propagated
// as a typed ConfigError so config init exits non-zero.
func TestRunProbePKJWT_DeterministicReject_Propagates(t *testing.T) {
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
return jsonResp(401, `{"error":"invalid_client","error_description":"unknown key"}`), nil
}}
f, errBuf := fakeFactory(t, rt)
err := runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key")
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected propagated typed error, got %T %v", err, err)
}
if errBuf.Len() != 0 {
t.Errorf("runProbePKJWT must not write stderr, got %q", errBuf.String())
}
}
// runProbePKJWT: ambiguous upstream noise (HTTP 503) is swallowed — silent, exit 0.
func TestRunProbePKJWT_Ambiguous_Silent(t *testing.T) {
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
return jsonResp(503, `unavailable`), nil
}}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf)
}
// probeInitResult dispatches private_key_jwt to the assertion-backed probe.
func TestProbeInitResult_PrivateKeyJWT(t *testing.T) {
rt := &fakeRT{} // default oauth handler returns 200 + access_token
f, errBuf := fakeFactory(t, rt)
previous := keysigner.Active()
keysigner.Register(newProbeTestSigner(t))
t.Cleanup(func() { keysigner.Register(previous) })
opts := &ConfigInitOptions{Ctx: context.Background()}
result := &configInitResult{AppID: "cli_x", AuthMethod: core.AuthMethodPrivateKeyJWT, KeyLabel: "agent-key", Brand: core.BrandFeishu}
assertSilent(t, probeInitResult(opts, f, result), errBuf)
}
// runProbePKJWT: a nil signer is a defensive no-op (should not be reached, must
// not panic).
func TestRunProbePKJWT_NilSigner_Silent(t *testing.T) {
f, errBuf := fakeFactory(t, &fakeRT{})
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", nil, "k"), errBuf)
}

View File

@@ -10,9 +10,25 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
// TestRunRestoreFlow_NothingToRestore covers the early guards that return before
// any network/registration call: no config at all, and a config whose resolved
// app has no app id (nothing to send on begin).
func TestRunRestoreFlow_NothingToRestore(t *testing.T) {
// No config on disk.
if err := runRestoreFlow(&ConfigInitOptions{}, nil, nil, nil); err == nil {
t.Fatal("expected error when there is no config to restore")
}
// Config present but the resolved app has no app id.
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: ""}}}
if err := runRestoreFlow(&ConfigInitOptions{}, existing, nil, nil); err == nil {
t.Fatal("expected error when the resolved app has no app id")
}
}
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
@@ -119,3 +135,58 @@ func assertValidationParam(t *testing.T, err error, wantParam string) {
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
}
}
// countingKeychain is an in-memory KeychainAccess that records whether Remove
// was invoked, so the stale-secret cleanup can be asserted without a real OS
// keychain.
type countingKeychain struct {
store map[string]string
removeCalled bool
}
func newCountingKeychain() *countingKeychain {
return &countingKeychain{store: map[string]string{}}
}
func (k *countingKeychain) Get(service, account string) (string, error) {
v, ok := k.store[service+"/"+account]
if !ok {
return "", keychain.ErrNotFound
}
return v, nil
}
func (k *countingKeychain) Set(service, account, value string) error {
k.store[service+"/"+account] = value
return nil
}
func (k *countingKeychain) Remove(service, account string) error {
k.removeCalled = true
delete(k.store, service+"/"+account)
return nil
}
func TestRemoveStaleSecretForPKJWT_SameAppID(t *testing.T) {
kc := newCountingKeychain()
ref, err := core.ForStorage("cli_same", core.PlainSecret("old-secret"), kc) // → Source:"keychain"
if err != nil {
t.Fatal(err)
}
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_same", AppSecret: ref}}}
removeStaleSecretForPKJWT(existing, "", "cli_same", kc)
if !kc.removeCalled {
t.Error("same appId with keychain secret: expected kc.Remove to be invoked")
}
}
func TestRemoveStaleSecretForPKJWT_DifferentAppID(t *testing.T) {
kc := newCountingKeychain()
ref, _ := core.ForStorage("cli_old", core.PlainSecret("old-secret"), kc)
kc.removeCalled = false // ForStorage does not call Remove, but reset to be safe
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_old", AppSecret: ref}}}
removeStaleSecretForPKJWT(existing, "", "cli_new", kc)
if kc.removeCalled {
t.Error("different appId: must NOT remove")
}
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"net/http"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keylessprovider"
"github.com/larksuite/cli/internal/keysigner"
)
const keylessBindProbeTimeout = 12 * time.Second
var fetchTATForBind = fetchTATForFreshBind
func fetchTATForFreshBind(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, provider, keyRef string) (string, func() error, error) {
helper, commitProviderManifest, err := keylessprovider.PrepareRefresh(ctx, provider)
if err != nil {
return "", nil, err
}
token, err := credential.FetchTATWithAssertionWithHelper(ctx, httpClient, brand, clientID, signer, helper, keyRef)
if err != nil {
return "", nil, err
}
return token, commitProviderManifest, nil
}
// validateBindResult proves that an OpenClaw keyless account can be used by
// the exact helper/keyRef/appID tuple that will be persisted. Minting a TAT is
// intentional: pubkey alone only proves that the helper runs (and some signer
// implementations create a missing key during pubkey); a successful token mint
// proves that this public key is already registered to the selected app, so no
// attach flow or second user authorization is needed.
func validateBindResult(parent context.Context, opts *BindOptions, result *BindResult) error {
if result == nil || result.AppConfig == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "config bind produced no app configuration")
}
app := result.AppConfig
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
return nil
}
if app.KeyRef == nil || app.KeyRef.ID == "" {
return errs.NewConfigError(errs.SubtypeInvalidConfig,
"private_key_jwt bind for app %s is missing keyRef", app.AppId)
}
if strings.TrimSpace(app.KeyRef.Provider) != core.KeylessProviderLarkSuite {
return errs.NewConfigError(errs.SubtypeInvalidClient,
"OpenClaw private_key_jwt bind for app %s did not select provider %s", app.AppId, core.KeylessProviderLarkSuite)
}
if opts == nil || opts.Factory == nil || opts.Factory.HttpClient == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "cannot validate keyless bind without an HTTP client")
}
httpClient, err := opts.Factory.HttpClient()
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
"cannot create HTTP client for keyless bind validation: %v", err).WithCause(err)
}
ctx, cancel := context.WithTimeout(parent, keylessBindProbeTimeout)
defer cancel()
_, commitProviderManifest, err := fetchTATForBind(
ctx, httpClient, app.Brand, app.AppId, keysigner.Active(), app.KeyRef.Provider, app.KeyRef.ID,
)
if err != nil {
if errs.IsTyped(err) {
return err
}
return errs.NewConfigError(errs.SubtypeInvalidClient,
"OpenClaw signer could not authenticate app %s: %v", app.AppId, err).
WithHint("repair or reinstall the OpenClaw Feishu plugin and its platform signer dependency, verify the keyless account, then retry config bind").
WithCause(err)
}
if commitProviderManifest == nil {
return errs.NewInternalError(errs.SubtypeStorage,
"OpenClaw signer validation did not produce a provider manifest commit")
}
result.commitProviderManifest = commitProviderManifest
return nil
}

View File

@@ -0,0 +1,438 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keysigner"
)
func TestConfigBindRun_OpenClawKeylessWritesProviderWithoutPath(t *testing.T) {
saveWorkspace(t)
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeOpenClawKeylessConfig(t, "cli_keyless", "openclaw-lark")
var gotProvider, gotKeyRef, gotClientID string
var providerCommits int
var providerCommitSawWorkspace bool
replaceBindProbe(t, func(_ context.Context, _ *http.Client, _ core.LarkBrand, clientID string, _ keysigner.Signer, provider, keyRef string) (string, func() error, error) {
gotClientID, gotProvider, gotKeyRef = clientID, provider, keyRef
return "tat-ok", func() error {
providerCommits++
data, err := os.ReadFile(core.GetConfigPath())
if err != nil {
return err
}
providerCommitSawWorkspace = strings.Contains(string(data), "cli_keyless")
return nil
}, nil
})
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"}); err != nil {
t.Fatalf("configBindRun: %v", err)
}
if gotClientID != "cli_keyless" || gotProvider != core.KeylessProviderLarkSuite || gotKeyRef != "openclaw-lark" {
t.Fatalf("probe route = client %q provider %q keyRef %q", gotClientID, gotProvider, gotKeyRef)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
app := multi.CurrentAppConfig("")
if app == nil || app.AuthMethod != core.AuthMethodPrivateKeyJWT || app.KeyRef == nil ||
app.KeyRef.Provider != core.KeylessProviderLarkSuite || app.KeyRef.ID != "openclaw-lark" || !app.AppSecret.IsZero() {
t.Fatalf("persisted app = %#v", app)
}
if stdout.Len() == 0 {
t.Fatal("bind did not emit success envelope")
}
if providerCommits != 1 {
t.Fatalf("provider manifest commits = %d, want 1", providerCommits)
}
if !providerCommitSawWorkspace {
t.Fatal("provider manifest committed before the workspace config became readable")
}
}
func TestConfigBindRun_OpenClawOptionalSignerClosedLoop(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test helper uses a POSIX shebang; Windows resolution is compile-checked separately")
}
saveWorkspace(t)
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
signerPath := installOpenClawOptionalSigner(t)
writeOpenClawKeylessConfig(t, "cli_keyless_optional", "openclaw-lark")
f, _, _, registry := cmdutil.TestFactory(t, nil)
registry.Register(&httpmock.Stub{
Method: http.MethodPost,
URL: auth.PathOAuthTokenV2,
Body: map[string]any{"code": 0, "access_token": "tat-from-optional-signer"},
BodyFilter: func(body []byte) bool {
form, err := url.ParseQuery(string(body))
return err == nil &&
form.Get("client_id") == "cli_keyless_optional" &&
form.Get("client_assertion_type") == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" &&
form.Get("client_assertion") == "optional.jwt" &&
!form.Has("client_secret")
},
})
if err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"}); err != nil {
t.Fatalf("configBindRun: %v", err)
}
data, err := os.ReadFile(core.GetConfigPath())
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(string(data), "\n") || !strings.Contains(string(data), "\n \"apps\": [") {
t.Fatalf("config is not formatted JSON with a trailing newline:\n%s", data)
}
if strings.Contains(string(data), signerPath) {
t.Fatalf("config persisted the discovered signer executable path:\n%s", data)
}
providerData, err := os.ReadFile(filepath.Join(core.GetBaseConfigDir(), "signing-providers.json"))
if err != nil {
t.Fatalf("read global signer manifest: %v", err)
}
if !strings.Contains(string(providerData), signerPath) {
t.Fatalf("global signer manifest did not record the verified executable")
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
app := multi.CurrentAppConfig("")
if app == nil || app.AppId != "cli_keyless_optional" || app.KeyRef == nil ||
app.KeyRef.Provider != core.KeylessProviderLarkSuite || app.KeyRef.ID != "openclaw-lark" || !app.AppSecret.IsZero() {
t.Fatalf("resolved config = %#v", app)
}
}
func TestConfigBindRun_OpenClawKeylessProbeFailureDoesNotWrite(t *testing.T) {
saveWorkspace(t)
clearAgentEnv(t)
base := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base)
writeOpenClawKeylessConfig(t, "cli_wrong_key", "openclaw-lark")
replaceBindProbe(t, func(context.Context, *http.Client, core.LarkBrand, string, keysigner.Signer, string, string) (string, func() error, error) {
return "", nil, errs.NewConfigError(errs.SubtypeInvalidClient, "public key is not bound")
})
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"}); err == nil {
t.Fatal("expected probe error")
}
if _, err := os.Stat(filepath.Join(base, "openclaw", "config.json")); !os.IsNotExist(err) {
t.Fatalf("config must not be written; stat error = %v", err)
}
}
func TestConfigBindRun_OpenClawKeylessMissingProviderCommitFailsClosed(t *testing.T) {
saveWorkspace(t)
clearAgentEnv(t)
base := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base)
writeOpenClawKeylessConfig(t, "cli_missing_commit", "openclaw-lark")
replaceBindProbe(t, func(context.Context, *http.Client, core.LarkBrand, string, keysigner.Signer, string, string) (string, func() error, error) {
return "tat-ok", nil, nil
})
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"})
if err == nil || !strings.Contains(err.Error(), "did not produce a provider manifest commit") {
t.Fatalf("configBindRun error = %v", err)
}
if _, statErr := os.Stat(filepath.Join(base, "openclaw", "config.json")); !os.IsNotExist(statErr) {
t.Fatalf("config must not be written; stat error = %v", statErr)
}
}
func TestCommitBinding_ProviderManifestFailureRestoresWorkspace(t *testing.T) {
saveWorkspace(t)
base := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base)
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
configPath := core.GetConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
t.Fatal(err)
}
previous := []byte("{\n \"current_app\": \"old\",\n \"apps\": [{\"name\": \"old\", \"app_id\": \"cli_old\", \"app_secret\": \"keep\"}]\n}\n")
if err := os.WriteFile(configPath, previous, 0600); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
commitCalls := 0
result := &BindResult{
AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu},
commitProviderManifest: func() error {
commitCalls++
return errors.New("manifest write failed")
},
}
err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, previous, "openclaw", configPath)
if err == nil || !strings.Contains(err.Error(), "workspace config restored") {
t.Fatalf("commitBinding error = %v", err)
}
if commitCalls != 1 {
t.Fatalf("provider manifest commits = %d, want 1", commitCalls)
}
got, readErr := os.ReadFile(configPath)
if readErr != nil {
t.Fatal(readErr)
}
if string(got) != string(previous) {
t.Fatalf("workspace was not restored:\n%s", got)
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("failed bind emitted success output: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}
func TestCommitBinding_ProviderManifestFailureRemovesNewWorkspace(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
configPath := core.GetConfigPath()
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
result := &BindResult{
AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu},
commitProviderManifest: func() error {
return errors.New("manifest write failed")
},
}
err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, nil, "openclaw", configPath)
if err == nil || !strings.Contains(err.Error(), "workspace config restored") {
t.Fatalf("commitBinding error = %v", err)
}
if _, statErr := os.Stat(configPath); !os.IsNotExist(statErr) {
t.Fatalf("new workspace config was not removed; stat error = %v", statErr)
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("failed bind emitted success output: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}
func TestCommitBinding_WorkspaceWriteFailureDoesNotCommitProvider(t *testing.T) {
saveWorkspace(t)
base := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base)
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
f, _, _, _ := cmdutil.TestFactory(t, nil)
providerCommitted := false
result := &BindResult{
AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu},
commitProviderManifest: func() error {
providerCommitted = true
return nil
},
}
configPath := filepath.Join(base, "missing-parent", "config.json")
if err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, nil, "openclaw", configPath); err == nil {
t.Fatal("expected workspace write failure")
}
if providerCommitted {
t.Fatal("provider manifest was committed before the workspace config write succeeded")
}
}
func TestCommitBinding_ConcurrentWorkspaceChangeFailsBeforeProviderCommit(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
configPath := core.GetConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
t.Fatal(err)
}
previous := []byte(`{"apps":[{"app_id":"cli_old","app_secret":"old"}]}`)
concurrent := []byte(`{"apps":[{"app_id":"cli_other","app_secret":"newer"}]}`)
if err := os.WriteFile(configPath, concurrent, 0600); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
providerCommitted := false
result := &BindResult{
AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu},
commitProviderManifest: func() error {
providerCommitted = true
return nil
},
}
err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, previous, "openclaw", configPath)
if err == nil || !strings.Contains(err.Error(), "changed while the bind was being validated") {
t.Fatalf("commitBinding error = %v", err)
}
if providerCommitted {
t.Fatal("provider manifest was committed after a concurrent workspace change")
}
got, readErr := os.ReadFile(configPath)
if readErr != nil || string(got) != string(concurrent) {
t.Fatalf("concurrent workspace was overwritten: %q, %v", got, readErr)
}
}
func TestCommitBinding_ProviderFailureDoesNotOverwriteConcurrentWriter(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
configPath := core.GetConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
t.Fatal(err)
}
previous := []byte(`{"apps":[{"app_id":"cli_old","app_secret":"old"}]}`)
concurrent := []byte(`{"apps":[{"app_id":"cli_other","app_secret":"newer"}]}`)
if err := os.WriteFile(configPath, previous, 0600); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
result := &BindResult{
AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu},
commitProviderManifest: func() error {
if err := os.WriteFile(configPath, concurrent, 0600); err != nil {
return err
}
return errors.New("manifest write failed")
},
}
err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, previous, "openclaw", configPath)
if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
t.Fatalf("commitBinding error = %v", err)
}
got, readErr := os.ReadFile(configPath)
if readErr != nil || string(got) != string(concurrent) {
t.Fatalf("concurrent workspace was overwritten: %q, %v", got, readErr)
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("failed bind emitted success output: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}
func TestMergeBoundApp_UpsertsAndActivatesWithoutClobberingSiblings(t *testing.T) {
lang := "en_us"
previous := &core.MultiAppConfig{
StrictMode: core.StrictModeUser,
CurrentApp: "other",
Apps: []core.AppConfig{
{Name: "bound", AppId: "cli_target", Brand: core.BrandLark, Lang: coreLang(lang), Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "alice"}}},
{Name: "other", AppId: "cli_other", AppSecret: core.PlainSecret("keep"), Brand: core.BrandFeishu, Users: []core.AppUser{}},
},
}
beforeSibling := previous.Apps[1]
data := mustJSON(t, previous)
incoming := &core.AppConfig{AppId: "cli_target", Brand: core.BrandFeishu, AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyRef: &core.SecretRef{Source: core.SecretSourceTEE, Provider: core.KeylessProviderLarkSuite, ID: "openclaw-lark"}}
got, err := mergeBoundApp(incoming, data, false)
if err != nil {
t.Fatal(err)
}
if len(got.Apps) != 2 || got.CurrentApp != "bound" || got.PreviousApp != "other" || got.StrictMode != previous.StrictMode {
t.Fatalf("merged root = %#v", got)
}
if !reflect.DeepEqual(got.Apps[1], beforeSibling) {
t.Fatalf("sibling changed: got %#v want %#v", got.Apps[1], beforeSibling)
}
if got.Apps[0].Name != "bound" || got.Apps[0].Lang != coreLang(lang) || !reflect.DeepEqual(got.Apps[0].Users, previous.Apps[0].Users) {
t.Fatalf("target-owned fields were lost: %#v", got.Apps[0])
}
}
func writeOpenClawKeylessConfig(t *testing.T, appID, keyRef string) {
t.Helper()
path := filepath.Join(t.TempDir(), "openclaw.json")
data := []byte(`{"channels":{"feishu":{"appId":"` + appID + `","authMethod":"private_key_jwt","keyRef":"` + keyRef + `","domain":"feishu"}}}`)
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
t.Setenv("OPENCLAW_CONFIG_PATH", path)
}
func replaceBindProbe(t *testing.T, fn func(context.Context, *http.Client, core.LarkBrand, string, keysigner.Signer, string, string) (string, func() error, error)) {
t.Helper()
previous := fetchTATForBind
fetchTATForBind = fn
t.Cleanup(func() { fetchTATForBind = previous })
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return data
}
func coreLang(value string) i18n.Lang { return i18n.Lang(value) }
func installOpenClawOptionalSigner(t *testing.T) string {
t.Helper()
// This closure specifically exercises the no-inspect compatibility path;
// keylessprovider tests separately cover authoritative managed-project
// discovery from `openclaw plugins inspect`.
t.Setenv("PATH", "")
type signerPackage struct {
name, npmOS, npmCPU, binary string
}
packages := map[string]signerPackage{
"darwin/arm64": {"@larksuite/lark-keyless-signer-darwin-arm64", "darwin", "arm64", "lark-keyless-signer"},
"darwin/amd64": {"@larksuite/lark-keyless-signer-darwin-x64", "darwin", "x64", "lark-keyless-signer"},
"linux/arm64": {"@larksuite/lark-keyless-signer-linux-arm64", "linux", "arm64", "lark-keyless-signer"},
"linux/amd64": {"@larksuite/lark-keyless-signer-linux-x64", "linux", "x64", "lark-keyless-signer"},
}
spec, ok := packages[runtime.GOOS+"/"+runtime.GOARCH]
if !ok {
t.Skipf("no optional signer package for %s/%s", runtime.GOOS, runtime.GOARCH)
return ""
}
stateDir := filepath.Join(t.TempDir(), "openclaw state")
packageDir := filepath.Join(
stateDir, "extensions", "openclaw-lark", "node_modules", "@larksuite", strings.TrimPrefix(spec.name, "@larksuite/"),
)
binDir := filepath.Join(packageDir, "bin")
if err := os.MkdirAll(binDir, 0700); err != nil {
t.Fatal(err)
}
packageJSON, err := json.MarshalIndent(map[string]any{
"name": spec.name, "version": "1.2.3", "os": []string{spec.npmOS}, "cpu": []string{spec.npmCPU},
}, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(packageDir, "package.json"), append(packageJSON, '\n'), 0600); err != nil {
t.Fatal(err)
}
script := "#!/bin/sh\n" +
"IFS= read -r request\n" +
"printf '%s\\n' '{\"ok\":true,\"client_assertion_type\":\"urn:ietf:params:oauth:client-assertion-type:jwt-bearer\",\"client_assertion\":\"optional.jwt\"}'\n"
signerPath := filepath.Join(binDir, spec.binary)
if err := os.WriteFile(signerPath, []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("OPENCLAW_STATE_DIR", stateDir)
t.Setenv("PATH", "")
return signerPath
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"sync"
@@ -19,6 +20,8 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/keylessprovider"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
@@ -135,6 +138,9 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
// ── 3b. private_key_jwt / TEE signer (local; runs even with --offline) ──
checks = append(checks, teeSignerCheck(opts.Ctx, cfg))
// ── 4 & 5. Endpoint reachability ──
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
@@ -148,6 +154,73 @@ func identityCheck(name string, id identitydiag.Identity) checkResult {
return warn(name, id.Message, id.Hint)
}
const teeUnavailableHint = "ensure the device secure hardware is accessible (Linux TPM: add your user to the 'tss' group or run with sufficient privileges)"
// teeSignerCheck reports the private_key_jwt signing backend (TEE/TPM) status.
// The probe is local hardware only (no network), so it runs even with --offline;
// in a build without a TEE signer it short-circuits without touching any
// hardware. It is a hard requirement for private_key_jwt apps and purely
// informational for client_secret apps.
func teeSignerCheck(ctx context.Context, cfg *core.CliConfig) checkResult {
usesPKJWT := cfg != nil && cfg.AuthMethod == core.AuthMethodPrivateKeyJWT
if usesPKJWT && cfg.KeyProvider != "" {
helper, err := keylessprovider.Resolve(ctx, cfg.KeyProvider)
if err != nil {
return fail("tee_signer", "external keyless signer is unavailable",
fmt.Sprintf("repair or reinstall the OpenClaw Feishu plugin and its platform signer dependency: %v", err))
}
keyLabel := ""
if cfg != nil {
keyLabel = cfg.KeyLabel
}
if err := helper.Probe(ctx, keyLabel); err != nil {
hint := fmt.Sprintf("fix the configured external keyless signer, or re-run config init to replace/remove it: %v", err)
if usesPKJWT {
return fail("tee_signer", "external keyless signer is unavailable", hint)
}
return warn("tee_signer", "external keyless signer is misconfigured", hint)
}
return pass("tee_signer", "external keyless signer available")
}
info, ok, err := keysigner.ProbeActiveHardware(ctx)
return teeCheckResult(info, ok, err, usesPKJWT)
}
// teeCheckResult maps a hardware probe to a doctor check. Split out from
// teeSignerCheck so the full matrix is unit-testable without a TPM.
func teeCheckResult(info keysigner.HardwareInfo, ok bool, probeErr error, usesPKJWT bool) checkResult {
const name = "tee_signer"
// No signer registered → private_key_jwt is unsupported on this build.
if !ok {
if usesPKJWT {
return fail(name,
"app uses private_key_jwt but this build has no TEE key signer",
"the platform key signer ships by default on macOS, Linux, and Windows/amd64; this platform (e.g. Windows/arm64) has none — use a supported platform or re-register without --private-key-jwt")
}
return skip(name, "no TEE signer in this build (only private_key_jwt is affected; client_secret is unaffected)")
}
backend := info.Backend
if backend == "" {
backend = "tee"
}
switch {
case probeErr != nil:
return warn(name, fmt.Sprintf("%s signer present but probe errored: %s", backend, probeErr), "")
case info.Available:
if info.VendorName != "" {
return pass(name, fmt.Sprintf("%s TEE available (%s)", backend, info.VendorName))
}
return pass(name, fmt.Sprintf("%s TEE available", backend))
case usesPKJWT:
return fail(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
default:
return warn(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
}
}
// networkChecks probes Open API and MCP endpoints concurrently.
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
if opts.Offline {
@@ -237,14 +310,90 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
}
}
result := map[string]interface{}{
"ok": allOK,
"workspace": core.CurrentWorkspace().Display(),
"checks": checks,
workspace := core.CurrentWorkspace().Display()
// A terminal on STDOUT gets a readable report; pipes, redirects, scripts and
// tests keep the stable JSON contract (NO_COLOR disables ANSI styling).
// OutIsTerminal checks stdout specifically — IOStreams.IsTerminal reflects
// stdin, which would wrongly send the human report into `doctor | jq`.
if f.IOStreams.OutIsTerminal {
renderDoctorHuman(f.IOStreams.Out, workspace, checks, allOK, os.Getenv("NO_COLOR") == "")
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": allOK,
"workspace": workspace,
"checks": checks,
})
}
output.PrintJson(f.IOStreams.Out, result)
if !allOK {
return output.ErrBare(1)
}
return nil
}
// renderDoctorHuman writes a readable health report: one aligned line per check
// with a colored status tag, an indented hint when present, and a summary line.
func renderDoctorHuman(w io.Writer, workspace string, checks []checkResult, allOK, color bool) {
const (
green = "\033[32m"
yellow = "\033[33m"
red = "\033[31m"
gray = "\033[90m"
bold = "\033[1m"
reset = "\033[0m"
)
colorOf := map[string]string{"pass": green, "warn": yellow, "fail": red, "skip": gray}
tagOf := map[string]string{"pass": "PASS", "warn": "WARN", "fail": "FAIL", "skip": "SKIP"}
paint := func(code, s string) string {
if !color || code == "" {
return s
}
return code + s + reset
}
nameW := 0
for _, c := range checks {
if len(c.Name) > nameW {
nameW = len(c.Name)
}
}
fmt.Fprintf(w, "\n%s (workspace: %s)\n\n", paint(bold, "lark-cli doctor"), workspace)
var passN, warnN, failN, skipN int
for _, c := range checks {
tag := tagOf[c.Status]
if tag == "" {
tag = "????"
}
fmt.Fprintf(w, " %s %-*s %s\n", paint(colorOf[c.Status], "["+tag+"]"), nameW, c.Name, c.Message)
if c.Hint != "" {
fmt.Fprintf(w, " %-*s %s\n", nameW, "", paint(gray, "↳ "+c.Hint))
}
switch c.Status {
case "pass":
passN++
case "warn":
warnN++
case "fail":
failN++
case "skip":
skipN++
}
}
headline := paint(green, "healthy")
if !allOK {
headline = paint(red, "problems found")
}
fmt.Fprintf(w, "\n %s — %d passed", headline, passN)
if warnN > 0 {
fmt.Fprintf(w, ", %d warning(s)", warnN)
}
if failN > 0 {
fmt.Fprintf(w, ", %d failed", failN)
}
if skipN > 0 {
fmt.Fprintf(w, ", %d skipped", skipN)
}
fmt.Fprintln(w)
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
@@ -17,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keysigner"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -144,6 +146,107 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
assertCheck(t, got.Checks, "identity_ready", "pass")
}
func TestTeeCheckResult(t *testing.T) {
avail := keysigner.HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
unavail := keysigner.HardwareInfo{Backend: "tpm2", Reason: "open /dev/tpmrm0: permission denied"}
cases := []struct {
name string
info keysigner.HardwareInfo
ok bool
probeErr error
pkjwt bool
want string
}{
{"no signer + private_key_jwt → fail", keysigner.HardwareInfo{}, false, nil, true, "fail"},
{"no signer + client_secret → skip", keysigner.HardwareInfo{}, false, nil, false, "skip"},
{"available + private_key_jwt → pass", avail, true, nil, true, "pass"},
{"available + client_secret → pass", avail, true, nil, false, "pass"},
{"unavailable + private_key_jwt → fail", unavail, true, nil, true, "fail"},
{"unavailable + client_secret → warn", unavail, true, nil, false, "warn"},
{"probe error → warn", keysigner.HardwareInfo{Backend: "tpm2"}, true, errors.New("boom"), true, "warn"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := teeCheckResult(tc.info, tc.ok, tc.probeErr, tc.pkjwt)
if got.Name != "tee_signer" {
t.Errorf("name = %q, want tee_signer", got.Name)
}
if got.Status != tc.want {
t.Errorf("status = %q, want %q (msg=%q)", got.Status, tc.want, got.Message)
}
})
}
}
// TestDoctorRun_TeeSignerWired proves the tee_signer check is part of doctorRun.
// It asserts the build-independent invariant (a client_secret app must never
// FAIL on TEE) so the test passes whether or not a signer is compiled in.
func TestDoctorRun_TeeSignerWired(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{{
Name: "default", AppId: "test-app",
AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
var c *checkResult
for i := range got.Checks {
if got.Checks[i].Name == "tee_signer" {
c = &got.Checks[i]
}
}
if c == nil {
t.Fatalf("tee_signer check not present in doctor output: %#v", got.Checks)
}
if c.Status == "fail" {
t.Errorf("tee_signer = fail for a client_secret app; want skip/warn/pass (msg=%q)", c.Message)
}
}
func TestRenderDoctorHuman(t *testing.T) {
var buf bytes.Buffer
checks := []checkResult{
pass("cli_version", "1.0.50"),
warn("tee_signer", "tpm2 signer present but TEE unavailable", "add your user to the 'tss' group"),
fail("identity_ready", "no usable identity", "run: lark-cli auth status --verify"),
skip("endpoint_open", "skipped (--offline)"),
}
renderDoctorHuman(&buf, "local", checks, false, false)
out := buf.String()
for _, want := range []string{
"lark-cli doctor", "workspace: local",
"[PASS]", "cli_version", "1.0.50",
"[WARN]", "tee_signer", "↳ add your user to the 'tss' group",
"[FAIL]", "identity_ready", "↳ run: lark-cli auth status --verify",
"[SKIP]", "endpoint_open",
"problems found", "1 passed", "1 warning(s)", "1 failed", "1 skipped",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\n---\n%s", want, out)
}
}
if strings.Contains(out, "\033[") {
t.Errorf("color=false but ANSI escapes present:\n%s", out)
}
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
if got := findCheck(t, checks, name); got.Status != status {

16
go.mod
View File

@@ -7,6 +7,8 @@ require (
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c
github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
@@ -27,7 +29,10 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require github.com/ebitengine/purego v0.10.1
require (
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
@@ -42,12 +47,21 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-ole/go-ole v1.2.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/certificate-transparency-go v1.1.8 // indirect
github.com/google/certtostore v1.0.6 // indirect
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect
github.com/google/go-attestation v0.5.1 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/go-tspi v0.3.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -57,10 +71,12 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.31.0 // indirect
)

37
go.sum
View File

@@ -2,6 +2,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -50,14 +52,42 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c h1:KqlxcP2nuOcMjudCvK0qME2K/aFBDH+xcvYv7HYQaYc=
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c/go.mod h1:QGzNH9ujQ2ZUr/CjDGZGWeDAVStrWNjHeEcjJL96Nuk=
github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4 h1:z9oNXvtDZv73Rg8UjFhu+wMtDvGkhLm1NMTwZQ68gOM=
github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4/go.mod h1:FEWpPBUpkMwxqAbprURvgWgdwjeGkge5QFDaZBsfRHQ=
github.com/go-ole/go-ole v1.2.5 h1:t4MGB5xEDZvXI+0rMjjsfBsD7yAgp/s9ZDkL1JndXwY=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg=
github.com/google/certificate-transparency-go v1.1.8 h1:LGYKkgZF7satzgTak9R4yzfJXEeYVAjV6/EAEJOf1to=
github.com/google/certificate-transparency-go v1.1.8/go.mod h1:bV/o8r0TBKRf1X//iiiSgWrvII4d7/8OiA+3vG26gI8=
github.com/google/certtostore v1.0.6 h1:LlCIgyTvDxTlcncMPTSYZGo6lCsiHzO6Dy7ff6ltk/0=
github.com/google/certtostore v1.0.6/go.mod h1:2N0ZPLkGvQWhYvXaiBGq02r71fnSLfq78VKIWQHr1wo=
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae h1:Iy1Ad7L9qPtNAFJad+Ch2kwDXrcwu7QUBR0bfChjnEM=
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae/go.mod h1:DoDv8G58DuLNZF0KysYn0bA/6ZWhmRW3fZE2VnGEH0w=
github.com/google/go-attestation v0.5.1 h1:jqtOrLk5MNdliTKjPbIPrAaRKJaKW+0LIU2n/brJYms=
github.com/google/go-attestation v0.5.1/go.mod h1:KqGatdUhg5kPFkokyzSBDxwSCFyRgIgtRkMp6c3lOBQ=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/go-tpm-tools v0.4.2 h1:iyaCPKt2N5Rd0yz0G8ANa022SgCNZkMpp+db6QELtvI=
github.com/google/go-tpm-tools v0.4.2/go.mod h1:fGUDZu4tw3V4hUVuFHmiYgRd0c58/IXivn9v3Ea/ck4=
github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus=
github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@@ -70,6 +100,8 @@ github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd h1:E3y4CkzAXArgOQAw9gzW0Exe7XQqF4MYH3rCYprAj+Q=
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd/go.mod h1:ayRB9iNq3dqzUb9oW2JkoVQkDBkJ88NJb66OH13CKSk=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -97,6 +129,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -137,6 +171,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -154,6 +190,7 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -65,6 +65,7 @@ type AppRegistrationResponse struct {
VerificationUriComplete string
ExpiresIn int
Interval int
RequestedAuthMethod string
}
// AppRegistrationResult is the result of a successful app registration poll.
@@ -72,6 +73,11 @@ type AppRegistrationResult struct {
ClientID string
ClientSecret string
UserInfo *AppRegUserInfo
// AuthMethods is the authoritative auth method(s) the app must use, as
// returned by the registration service after user/admin confirmation. It may
// differ from what the client requested, for example when selecting an
// existing client_secret app. Empty is accepted for compatible older servers.
AuthMethods []string
}
// AppRegUserInfo contains user info returned from app registration.
@@ -85,10 +91,81 @@ func appRegistrationEndpoint(brand core.LarkBrand) string {
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
}
// AppRegistrationInit is the response from the app registration init endpoint.
type AppRegistrationInit struct {
Nonce string
SupportedAuthMethods []string // e.g. ["client_secret", "private_key_jwt"]
}
// AppRegistrationBeginOptions parametrizes the registration begin request.
// A zero value selects the legacy client_secret flow, preserving prior behavior.
type AppRegistrationBeginOptions struct {
AuthMethod string // "" => client_secret; core.AuthMethodPrivateKeyJWT
AuthAttestation string // private_key_jwt: the TEE-signed attestation JWT
RestoreAppID string // when set, asks the server to re-register this existing app
}
// RequestAppRegistrationInit performs the init step of the registration flow,
// returning a server nonce (to be embedded in a TEE-signed attestation JWT) and
// the auth methods the server supports for this archetype.
func RequestAppRegistrationInit(ctx context.Context, httpClient *http.Client) (*AppRegistrationInit, error) {
// Registration always begins against the Feishu accounts host (mirrors begin).
endpoint := appRegistrationEndpoint(registrationBootstrapBrand)
ctx, cancel := context.WithTimeout(ctx, beginRequestTimeout)
defer cancel()
form := url.Values{}
form.Set("action", "init")
form.Set("archetype", "PersonalAgent")
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("app registration init failed: read body: %w", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("app registration init failed: HTTP %d response not JSON", resp.StatusCode)
}
if _, hasError := data["error"]; resp.StatusCode >= 400 || hasError {
msg := getStr(data, "error_description")
if msg == "" {
msg = getStr(data, "error")
}
if msg == "" {
msg = "Unknown error"
}
return nil, fmt.Errorf("app registration init failed: %s", msg)
}
out := &AppRegistrationInit{
Nonce: getStr(data, "nonce"),
SupportedAuthMethods: parseAuthMethods(data["supported_auth_methods"]),
}
if out.Nonce == "" {
return nil, fmt.Errorf("app registration init failed: server returned no nonce")
}
return out, nil
}
// RequestAppRegistration initiates the device flow. The registration protocol
// always bootstraps on Feishu; brand selects the user-facing verification host.
// The request is bounded by ctx and a begin timeout.
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, opts AppRegistrationBeginOptions, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -99,11 +176,25 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
ep := core.ResolveEndpoints(brand)
endpoint := appRegistrationEndpoint(registrationBootstrapBrand)
authMethod := opts.AuthMethod
if authMethod == "" {
authMethod = core.AuthMethodClientSecret
}
form := url.Values{}
form.Set("action", "begin")
form.Set("archetype", "PersonalAgent")
form.Set("auth_method", "client_secret")
form.Set("auth_method", authMethod)
form.Set("request_user_info", "open_id tenant_brand")
if opts.AuthAttestation != "" {
form.Set("auth_attestation", opts.AuthAttestation)
}
// Restore flow: the registration service accepts the existing OAuth client
// identifier under client_id. The launcher URL still uses app_id; these are
// separate contracts and must not be changed together.
if opts.RestoreAppID != "" {
form.Set("client_id", opts.RestoreAppID)
}
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
@@ -156,7 +247,24 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
userCode := getStr(data, "user_code")
verificationUri := getStr(data, "verification_uri")
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
// Prefer the server-provided complete URL (currently /page/launcher); fall
// back to building it from verification_uri, then to /page/launcher. The old
// hard-coded /page/cli is stale — the server now returns /page/launcher.
verificationUriComplete := getStr(data, "verification_uri_complete")
if verificationUriComplete == "" {
base := verificationUri
if base == "" {
base = ep.Open + "/page/launcher"
}
// The server may return verification_uri with its own query (e.g.
// app_id when registering against an existing app), so join with
// the same ?/& logic as BuildVerificationURL.
sep := "?"
if strings.Contains(base, "?") {
sep = "&"
}
verificationUriComplete = base + sep + "user_code=" + url.QueryEscape(userCode)
}
return &AppRegistrationResponse{
DeviceCode: deviceCode,
@@ -165,18 +273,91 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
VerificationUriComplete: verificationUriComplete,
ExpiresIn: expiresIn,
Interval: interval,
RequestedAuthMethod: authMethod,
}, nil
}
// parseAuthMethods normalizes the poll response `auth_method` field, which the
// server returns as a JSON array of strings (e.g. ["private_key_jwt"]) — or, on
// some variants, a single space-separated string.
func parseAuthMethods(v interface{}) []string {
switch t := v.(type) {
case []interface{}:
out := make([]string, 0, len(t))
for _, m := range t {
if s, ok := m.(string); ok && s != "" {
out = append(out, s)
}
}
return out
case string:
return strings.Fields(t)
default:
return nil
}
}
func containsAuthMethod(methods []string, target string) bool {
for _, method := range methods {
if method == target {
return true
}
}
return false
}
func registrationResultComplete(result *AppRegistrationResult, requestedAuthMethod string) bool {
if result.ClientID == "" {
return false
}
if result.ClientSecret != "" {
return true
}
if len(result.AuthMethods) > 0 {
return containsAuthMethod(result.AuthMethods, core.AuthMethodPrivateKeyJWT)
}
// Older servers may omit auth_method. In that case only a begin request
// explicitly made as private_key_jwt may complete without a client secret.
return requestedAuthMethod == core.AuthMethodPrivateKeyJWT
}
// BuildVerificationURL appends CLI tracking parameters to the verification URL.
func BuildVerificationURL(baseURL, cliVersion string) string {
// When targetAppID is non-empty, it is also included so the launcher can lock
// authorization to that existing app.
func BuildVerificationURL(baseURL, cliVersion string, targetAppID ...string) string {
u, err := url.Parse(baseURL)
if err != nil {
return appendVerificationURLFallback(baseURL, cliVersion, targetAppID...)
}
q := u.Query()
if q.Get("lpv") == "" {
q.Set("lpv", cliVersion)
}
if q.Get("ocv") == "" {
q.Set("ocv", cliVersion)
}
if q.Get("from") == "" {
q.Set("from", "cli")
}
if len(targetAppID) > 0 && targetAppID[0] != "" && q.Get("app_id") == "" {
q.Set("app_id", targetAppID[0])
}
u.RawQuery = q.Encode()
return u.String()
}
func appendVerificationURLFallback(baseURL, cliVersion string, targetAppID ...string) string {
sep := "&"
if !strings.Contains(baseURL, "?") {
sep = "?"
}
return baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) +
out := baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) +
"&ocv=" + url.QueryEscape(cliVersion) +
"&from=cli"
if len(targetAppID) > 0 && targetAppID[0] != "" && !strings.Contains(baseURL, "app_id=") {
out += "&app_id=" + url.QueryEscape(targetAppID[0])
}
return out
}
// pollOnce performs one ctx-bound poll request and decodes the payload.
@@ -273,6 +454,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp
result := &AppRegistrationResult{
ClientID: getStr(data, "client_id"),
ClientSecret: getStr(data, "client_secret"),
AuthMethods: parseAuthMethods(data["auth_method"]),
}
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
result.UserInfo = &AppRegUserInfo{
@@ -281,7 +463,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp
}
}
if result.ClientID != "" && result.ClientSecret != "" {
if registrationResultComplete(result, resp.RequestedAuthMethod) {
// The issuing domain is authoritative; a contradictory final
// tenant report is a protocol violation, not a brand override.
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" &&

View File

@@ -8,6 +8,8 @@ import (
"errors"
"io"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"time"
@@ -30,10 +32,14 @@ func jsonResponse(body string) *http.Response {
func Test_BuildVerificationURL(t *testing.T) {
t.Run("URL不含问号则添加?分隔符", func(t *testing.T) {
result := BuildVerificationURL("https://example.com/verify", "1.0.0")
got, err := url.Parse(result)
if err != nil {
t.Fatal(err)
}
convey.Convey("should add ? separator", t, func() {
convey.So(result, convey.ShouldContainSubstring, "?lpv=1.0.0")
convey.So(result, convey.ShouldContainSubstring, "&ocv=1.0.0")
convey.So(result, convey.ShouldContainSubstring, "&from=cli")
convey.So(got.Query().Get("lpv"), convey.ShouldEqual, "1.0.0")
convey.So(got.Query().Get("ocv"), convey.ShouldEqual, "1.0.0")
convey.So(got.Query().Get("from"), convey.ShouldEqual, "cli")
convey.So(result, convey.ShouldStartWith, "https://example.com/verify?")
})
})
@@ -47,6 +53,237 @@ func Test_BuildVerificationURL(t *testing.T) {
convey.So(result, convey.ShouldNotContainSubstring, "?lpv=")
})
})
t.Run("指定已有应用时添加app_id", func(t *testing.T) {
result := BuildVerificationURL("https://example.com/verify?user_code=abc", "2.0.0", "cli_existing")
got, err := url.Parse(result)
if err != nil {
t.Fatal(err)
}
convey.Convey("should include target app_id", t, func() {
convey.So(got.Query().Get("app_id"), convey.ShouldEqual, "cli_existing")
convey.So(got.Query().Get("client_id"), convey.ShouldEqual, "")
convey.So(got.Query().Get("lpv"), convey.ShouldEqual, "2.0.0")
})
})
t.Run("服务端已返回app_id时不覆盖", func(t *testing.T) {
result := BuildVerificationURL("https://example.com/verify?app_id=cli_server&user_code=abc", "2.0.0", "cli_existing")
got, err := url.Parse(result)
if err != nil {
t.Fatal(err)
}
convey.Convey("should keep server app_id", t, func() {
convey.So(got.Query().Get("app_id"), convey.ShouldEqual, "cli_server")
})
})
}
// captureClient returns an http.Client that records the last request's form body
// and replies with the given JSON payload.
func captureClient(gotBody *url.Values, respJSON string) *http.Client {
return &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
v, _ := url.ParseQuery(string(b))
*gotBody = v
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(respJSON)),
}, nil
}),
}
}
func TestRequestAppRegistrationInit_ParsesNonceAndMethods(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"nonce":"n-123","supported_auth_methods":["client_secret","private_key_jwt"]}`)
out, err := RequestAppRegistrationInit(context.Background(), hc)
if err != nil {
t.Fatal(err)
}
if out.Nonce != "n-123" {
t.Errorf("nonce = %q, want n-123", out.Nonce)
}
if len(out.SupportedAuthMethods) != 2 || out.SupportedAuthMethods[1] != "private_key_jwt" {
t.Errorf("methods = %v", out.SupportedAuthMethods)
}
if body.Get("action") != "init" {
t.Errorf("action = %q, want init", body.Get("action"))
}
}
func TestRequestAppRegistrationInit_ErrorOnMissingNonce(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"supported_auth_methods":["client_secret"]}`)
if _, err := RequestAppRegistrationInit(context.Background(), hc); err == nil {
t.Fatal("expected error when server returns no nonce")
}
}
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods covers the older-server
// back-compat path: an empty supported_auth_methods array parses to an empty
// slice, so the init guard in cmd/config/init_interactive.go
// (`len(SupportedAuthMethods) > 0 && !slices.Contains(...)`) stays false and does
// NOT reject the requested private_key_jwt. This aligns with
// resolveFinalAuthMethod(nil/[], private_key_jwt) == private_key_jwt
// (see cmd/config TestResolveFinalAuthMethod).
func TestRequestAppRegistrationInit_EmptySupportedAuthMethods(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"nonce":"n-1","supported_auth_methods":[]}`)
out, err := RequestAppRegistrationInit(context.Background(), hc)
if err != nil {
t.Fatal(err)
}
if out.Nonce != "n-1" {
t.Errorf("nonce = %q, want n-1", out.Nonce)
}
if len(out.SupportedAuthMethods) != 0 {
t.Errorf("SupportedAuthMethods = %v, want empty", out.SupportedAuthMethods)
}
// Reproduce the init guard expression on the real parsed result: an empty
// slice must NOT reject private_key_jwt.
rejected := len(out.SupportedAuthMethods) > 0 &&
!slices.Contains(out.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT)
if rejected {
t.Error("empty SupportedAuthMethods must allow private_key_jwt (older-server back-compat)")
}
}
const beginRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
func TestRequestAppRegistration_BeginDefaultsToClientSecret(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil); err != nil {
t.Fatal(err)
}
if body.Get("action") != "begin" {
t.Errorf("action = %q", body.Get("action"))
}
if body.Get("auth_method") != "client_secret" {
t.Errorf("auth_method = %q, want client_secret (default)", body.Get("auth_method"))
}
if body.Has("auth_attestation") {
t.Errorf("auth_attestation should be absent for client_secret, got %q", body.Get("auth_attestation"))
}
// Normal (non-restore) begin must NOT carry client_id.
if body.Has("client_id") {
t.Errorf("client_id should be absent when RestoreAppID is empty, got %q", body.Get("client_id"))
}
}
// TestRequestAppRegistration_BeginRestoreAppID verifies the restore flow sends the
// existing app id on begin so the server re-registers that app.
func TestRequestAppRegistration_BeginRestoreAppID(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
opts := AppRegistrationBeginOptions{RestoreAppID: "cli_restore_me"}
if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, opts, nil); err != nil {
t.Fatal(err)
}
if body.Get("action") != "begin" {
t.Errorf("action = %q, want begin", body.Get("action"))
}
if body.Get("client_id") != "cli_restore_me" {
t.Errorf("client_id = %q, want cli_restore_me", body.Get("client_id"))
}
if body.Has("app_id") {
t.Errorf("begin form app_id must be absent, got %q", body.Get("app_id"))
}
}
func TestRequestAppRegistration_VerificationURICompleteFallback(t *testing.T) {
cases := []struct {
name string
resp string
want string
}{
{
name: "bare verification_uri",
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`,
want: "https://example/verify?user_code=uc",
},
{
name: "verification_uri with existing query",
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify?app_id=cli_x","expires_in":300,"interval":5}`,
want: "https://example/verify?app_id=cli_x&user_code=uc",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var body url.Values
hc := captureClient(&body, tc.resp)
got, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil)
if err != nil {
t.Fatal(err)
}
if got.VerificationUriComplete != tc.want {
t.Errorf("VerificationUriComplete = %q, want %q", got.VerificationUriComplete, tc.want)
}
})
}
}
func TestParseAuthMethods(t *testing.T) {
if got := parseAuthMethods([]interface{}{"private_key_jwt", "client_secret"}); len(got) != 2 || got[0] != "private_key_jwt" {
t.Errorf("array form = %v", got)
}
if got := parseAuthMethods("client_secret private_key_jwt"); len(got) != 2 || got[1] != "private_key_jwt" {
t.Errorf("string form = %v", got)
}
if got := parseAuthMethods(nil); got != nil {
t.Errorf("nil form = %v, want nil", got)
}
}
func TestRequestAppRegistration_BeginPrivateKeyJWT(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
opts := AppRegistrationBeginOptions{
AuthMethod: core.AuthMethodPrivateKeyJWT,
AuthAttestation: "header.claims.sig",
}
if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, opts, nil); err != nil {
t.Fatal(err)
}
if body.Get("auth_method") != "private_key_jwt" {
t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method"))
}
if body.Get("auth_attestation") != "header.claims.sig" {
t.Errorf("auth_attestation = %q", body.Get("auth_attestation"))
}
}
func TestRequestAppRegistration_BeginPrivateKeyJWTExistingAppID(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
opts := AppRegistrationBeginOptions{
AuthMethod: core.AuthMethodPrivateKeyJWT,
AuthAttestation: "header.claims.sig",
RestoreAppID: "cli_existing",
}
if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, opts, nil); err != nil {
t.Fatal(err)
}
if body.Get("auth_method") != "private_key_jwt" {
t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method"))
}
if body.Get("auth_attestation") != "header.claims.sig" {
t.Errorf("auth_attestation = %q", body.Get("auth_attestation"))
}
if body.Get("client_id") != "cli_existing" {
t.Errorf("client_id = %q, want cli_existing", body.Get("client_id"))
}
}
func TestAppRegistrationEndpoint(t *testing.T) {
@@ -80,11 +317,11 @@ func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBran
}
return jsonResponse(`{"device_code":"d","user_code":"TEST-CODE","expire_in":60,"interval":5}`), nil
})}
resp, err := RequestAppRegistration(context.Background(), client, c.brand, io.Discard)
resp, err := RequestAppRegistration(context.Background(), client, c.brand, AppRegistrationBeginOptions{}, io.Discard)
if err != nil {
t.Fatalf("RequestAppRegistration(%q) error = %v", c.brand, err)
}
if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/cli?") {
if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/launcher?") {
t.Errorf("verification URL = %q, want host %q", resp.VerificationUriComplete, c.verificationHost)
}
})
@@ -115,11 +352,11 @@ func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) {
t.Errorf("unexpected host polled: %s", r.URL.Host)
return jsonResponse(`{}`), nil
})}
resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard)
resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, AppRegistrationBeginOptions{}, io.Discard)
if err != nil {
t.Fatalf("RequestAppRegistration error = %v", err)
}
if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/cli?user_code=TEST-CODE"; got != want {
if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/launcher?user_code=TEST-CODE"; got != want {
t.Errorf("verification URL = %q, want %q", got, want)
}
@@ -219,6 +456,51 @@ func TestRegisterAppWithDiscovery_PollsUntilCredentials(t *testing.T) {
}
}
func TestRegisterAppWithDiscovery_KeylessCompletesWithoutSecret(t *testing.T) {
tests := []struct {
name string
response string
requestedAuthMethod string
}{
{
name: "server explicitly returns private_key_jwt",
response: `{"client_id":"cli_keyless","auth_method":["private_key_jwt"]}`,
},
{
name: "older server omits auth_method for a keyless begin",
response: `{"client_id":"cli_keyless"}`,
requestedAuthMethod: core.AuthMethodPrivateKeyJWT,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
polls := 0
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
polls++
return jsonResponse(tt.response), nil
})}
resp := &AppRegistrationResponse{
DeviceCode: "device",
Interval: 0,
ExpiresIn: 5,
RequestedAuthMethod: tt.requestedAuthMethod,
}
result, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if polls != 1 {
t.Errorf("polls = %d, want 1", polls)
}
if result.ClientID != "cli_keyless" || result.ClientSecret != "" {
t.Errorf("result = (%q, %q), want (cli_keyless, empty secret)", result.ClientID, result.ClientSecret)
}
})
}
}
// Neither the first poll nor the cross-brand switch waits out the interval
// (a 5s interval would blow the elapsed bound).
func TestRegisterAppWithDiscovery_ImmediateFirstPollAndSwitch(t *testing.T) {
@@ -286,7 +568,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
resp, err := RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard)
serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard)
if err != nil {
t.Fatalf("begin error = %v", err)
}
@@ -295,7 +577,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
resp, err = RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard)
serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard)
if err != nil {
t.Fatalf("legacy begin error = %v", err)
}
@@ -304,7 +586,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
resp, err = RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard)
serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard)
if err != nil {
t.Fatalf("defaults begin error = %v", err)
}
@@ -313,7 +595,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
if _, err := RequestAppRegistration(context.Background(),
serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil {
serve(`{"interval":5}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard); err == nil {
t.Error("missing device_code: expected error, got nil")
}
}
@@ -394,7 +676,7 @@ func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) {
Header: make(http.Header),
}, nil
})}
_, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard)
_, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard)
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want a context.Canceled cause", err)
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"fmt"
"net/url"
"time"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keylesshelper"
"github.com/larksuite/cli/internal/keylessprovider"
"github.com/larksuite/cli/internal/keysigner"
)
// ClientAuth describes how to authenticate the OAuth client at the token
// endpoint: with a client_secret (default) or a TEE-signed client_assertion
// (private_key_jwt).
type ClientAuth struct {
AppID string
AppSecret string
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
Signer keysigner.Signer
KeyLabel string
KeyProvider string
// externalSigner is a verified provider snapshot prepared once for a
// multi-request operation (for example a device-flow poll loop). The helper
// still re-verifies its binary and mints a fresh assertion on every call.
externalSigner clientAssertionSigner
}
type clientAssertionSigner interface {
SignClientAssertion(context.Context, string, string, string) (string, string, error)
}
var resolveExternalAssertionSigner = func(ctx context.Context, provider string) (clientAssertionSigner, error) {
return keylessprovider.Resolve(ctx, provider)
}
// ClientAuthFromConfig builds a ClientAuth from resolved config, picking up the
// active key signer for private_key_jwt apps.
func ClientAuthFromConfig(cfg *core.CliConfig) ClientAuth {
if cfg == nil {
return ClientAuth{}
}
return ClientAuth{
AppID: cfg.AppID,
AppSecret: cfg.AppSecret,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
KeyProvider: cfg.KeyProvider,
Signer: keysigner.Active(),
}
}
func (c ClientAuth) isPrivateKeyJWT() bool { return c.AuthMethod == core.AuthMethodPrivateKeyJWT }
// ResolveSigner prepares the external private_key_jwt signer for reuse within
// one operation and returns the prepared copy. Built-in signers and
// client_secret authentication need no provider discovery. Keeping the
// resolved helper on ClientAuth separates expensive provider discovery from
// assertion minting: callers may reuse the returned value, while every call to
// applyClientAssertion still asks the signer for a fresh assertion.
func (c ClientAuth) ResolveSigner(ctx context.Context) (ClientAuth, error) {
if !c.isPrivateKeyJWT() || c.KeyProvider == "" || c.externalSigner != nil {
return c, nil
}
helper, err := resolveExternalAssertionSigner(ctx, c.KeyProvider)
if err != nil {
return c, err
}
if helper == nil {
return c, fmt.Errorf("private_key_jwt provider %q resolved without a signer", c.KeyProvider)
}
c.externalSigner = helper
return c, nil
}
// SignClientAssertion signs with a resolved external helper when present,
// otherwise with the platform signer.
func SignClientAssertion(ctx context.Context, signer keysigner.Signer, helper *keylesshelper.Command, keyLabel, clientID, audience string) (string, string, error) {
if helper != nil {
return helper.SignClientAssertion(ctx, keyLabel, clientID, audience)
}
assertion, err := jwt.SignClientAssertion(ctx, signer, keysigner.KeyRef{Label: keyLabel}, clientID, audience, time.Now())
return jwt.ClientAssertionType, assertion, err
}
// applyClientAssertion adds client_assertion(+type) to a token-endpoint form for
// private_key_jwt and returns true. For client_secret it returns false, leaving
// the caller to apply its own secret-based authentication. audience is the token
// endpoint URL (the assertion's aud claim).
func (c ClientAuth) applyClientAssertion(ctx context.Context, form url.Values, audience string) (bool, error) {
if !c.isPrivateKeyJWT() {
return false, nil
}
var err error
if c.KeyProvider != "" {
c, err = c.ResolveSigner(ctx)
if err != nil {
return false, err
}
}
helper := c.externalSigner
if helper == nil && c.Signer == nil {
return false, fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build")
}
var assertionType, assertion string
if helper != nil {
assertionType, assertion, err = helper.SignClientAssertion(ctx, c.KeyLabel, c.AppID, audience)
} else {
assertionType, assertion, err = SignClientAssertion(ctx, c.Signer, nil, c.KeyLabel, c.AppID, audience)
}
if err != nil {
return false, err
}
form.Set("client_assertion_type", assertionType)
form.Set("client_assertion", assertion)
return true, nil
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"fmt"
"net/url"
"testing"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeAuthSigner is a real in-memory ECDSA P-256 signer for client-auth tests.
type fakeAuthSigner struct{ key *ecdsa.PrivateKey }
type fakeExternalAssertionSigner struct {
keyRef, clientID, audience string
calls int
}
func (f *fakeExternalAssertionSigner) SignClientAssertion(_ context.Context, keyRef, clientID, audience string) (string, string, error) {
f.keyRef, f.clientID, f.audience = keyRef, clientID, audience
f.calls++
return jwt.ClientAssertionType, fmt.Sprintf("external.jwt.%d", f.calls), nil
}
func newFakeAuthSigner(t *testing.T) *fakeAuthSigner {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeAuthSigner{key: k}
}
func (f *fakeAuthSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeAuthSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeAuthSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestClientAuth_applyClientAssertion_ClientSecret(t *testing.T) {
ca := ClientAuth{AppID: "cli_a", AppSecret: "test-secret"} // AuthMethod "" => client_secret
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "https://aud/token")
if err != nil {
t.Fatal(err)
}
if used {
t.Error("client_secret must not produce a client_assertion")
}
if form.Has("client_assertion") || form.Has("client_assertion_type") {
t.Errorf("form should be untouched, got %v", form)
}
}
func TestClientAuth_applyClientAssertion_PrivateKeyJWT(t *testing.T) {
ca := ClientAuth{
AppID: "cli_a",
AuthMethod: core.AuthMethodPrivateKeyJWT,
Signer: newFakeAuthSigner(t),
KeyLabel: "k",
}
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token")
if err != nil {
t.Fatal(err)
}
if !used {
t.Fatal("expected client_assertion to be applied")
}
if form.Get("client_assertion_type") != jwt.ClientAssertionType {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Get("client_assertion") == "" {
t.Error("client_assertion is empty")
}
if form.Has("client_secret") {
t.Error("client_secret must NOT be present for private_key_jwt")
}
}
func TestClientAuth_applyClientAssertion_NilSigner(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT} // Signer nil
if _, err := ca.applyClientAssertion(context.Background(), url.Values{}, "aud"); err == nil {
t.Fatal("expected error when private_key_jwt has no signer")
}
}
func TestClientAuth_applyClientAssertion_UnknownProviderFailsClosed(t *testing.T) {
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT, Signer: newFakeAuthSigner(t), KeyLabel: "k", KeyProvider: "evil.provider"}
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "aud")
if err == nil || used || form.Has("client_assertion") {
t.Fatalf("unknown provider must fail closed: used=%v form=%v err=%v", used, form, err)
}
}
func TestClientAuth_applyClientAssertion_NilExternalProviderDoesNotFallback(t *testing.T) {
previous := resolveExternalAssertionSigner
resolveExternalAssertionSigner = func(context.Context, string) (clientAssertionSigner, error) {
return nil, nil
}
t.Cleanup(func() { resolveExternalAssertionSigner = previous })
ca := ClientAuth{
AppID: "cli_a", AppSecret: "must-not-send", AuthMethod: core.AuthMethodPrivateKeyJWT,
Signer: newFakeAuthSigner(t), KeyLabel: "k", KeyProvider: core.KeylessProviderLarkSuite,
}
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "aud")
if err == nil || used || form.Has("client_assertion") || form.Has("client_secret") {
t.Fatalf("nil external provider must fail closed: used=%v form=%v err=%v", used, form, err)
}
}
func TestClientAuth_applyClientAssertion_ExplicitProviderDoesNotUseBuiltinOrSecret(t *testing.T) {
fake := &fakeExternalAssertionSigner{}
previous := resolveExternalAssertionSigner
resolveExternalAssertionSigner = func(_ context.Context, provider string) (clientAssertionSigner, error) {
if provider != core.KeylessProviderLarkSuite {
t.Fatalf("provider = %q", provider)
}
return fake, nil
}
t.Cleanup(func() { resolveExternalAssertionSigner = previous })
ca := ClientAuth{
AppID: "cli_external", AppSecret: "must-not-send", AuthMethod: core.AuthMethodPrivateKeyJWT,
Signer: newFakeAuthSigner(t), KeyLabel: "openclaw-lark", KeyProvider: core.KeylessProviderLarkSuite,
}
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "open.feishu.cn")
if err != nil || !used {
t.Fatalf("applyClientAssertion = used %v err %v", used, err)
}
if form.Get("client_assertion") != "external.jwt.1" || form.Has("client_secret") ||
fake.keyRef != "openclaw-lark" || fake.clientID != "cli_external" || fake.audience != "open.feishu.cn" {
t.Fatalf("form=%v signer=(%q,%q,%q)", form, fake.keyRef, fake.clientID, fake.audience)
}
}
func TestClientAuth_ResolveSignerPreparedCopyReusesResolutionAndRemintsAssertions(t *testing.T) {
fake := &fakeExternalAssertionSigner{}
resolveCalls := 0
previous := resolveExternalAssertionSigner
resolveExternalAssertionSigner = func(_ context.Context, provider string) (clientAssertionSigner, error) {
resolveCalls++
if provider != core.KeylessProviderLarkSuite {
t.Fatalf("provider = %q", provider)
}
return fake, nil
}
t.Cleanup(func() { resolveExternalAssertionSigner = previous })
original := ClientAuth{
AppID: "cli_external", AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "openclaw-lark", KeyProvider: core.KeylessProviderLarkSuite,
}
prepared, err := original.ResolveSigner(context.Background())
if err != nil {
t.Fatal(err)
}
if original.externalSigner != nil {
t.Fatal("ResolveSigner must return a prepared copy without mutating the original")
}
forms := []url.Values{{}, {}}
for _, form := range forms {
used, err := prepared.applyClientAssertion(context.Background(), form, "open.feishu.cn")
if err != nil || !used {
t.Fatalf("applyClientAssertion = used %v err %v", used, err)
}
}
if resolveCalls != 1 {
t.Fatalf("provider resolution calls = %d, want 1", resolveCalls)
}
if fake.calls != 2 {
t.Fatalf("assertion signing calls = %d, want 2", fake.calls)
}
first := forms[0].Get("client_assertion")
second := forms[1].Get("client_assertion")
if first == "" || second == "" || first == second {
t.Fatalf("assertions = (%q, %q), want two fresh values", first, second)
}
for _, form := range forms {
if form.Has("client_secret") {
t.Fatalf("private_key_jwt form leaked client_secret: %v", form)
}
}
}
func TestClientAuthFromConfig(t *testing.T) {
ca := ClientAuthFromConfig(&core.CliConfig{
AppID: "cli_x",
AppSecret: "test-secret",
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "label-1",
})
if ca.AppID != "cli_x" || ca.AppSecret != "test-secret" || ca.AuthMethod != core.AuthMethodPrivateKeyJWT || ca.KeyLabel != "label-1" {
t.Errorf("ClientAuth = %+v", ca)
}
}

View File

@@ -62,7 +62,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
}
// RequestDeviceAuthorization requests a device authorization code.
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -77,18 +77,26 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string
}
}
basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret))
form := url.Values{}
form.Set("client_id", appId)
form.Set("client_id", ca.AppID)
form.Set("scope", scope)
req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
// private_key_jwt authenticates the client with a signed assertion in the
// body; client_secret uses HTTP Basic.
usedAssertion, err := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Basic "+basicAuth)
if !usedAssertion {
basicAuth := base64.StdEncoding.EncodeToString([]byte(ca.AppID + ":" + ca.AppSecret))
req.Header.Set("Authorization", "Basic "+basicAuth)
}
resp, err := httpClient.Do(req)
if err != nil {
@@ -139,7 +147,7 @@ func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string
}
// PollDeviceToken polls the token endpoint until authorization completes or times out.
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
if errOut == nil {
errOut = io.Discard
}
@@ -171,10 +179,16 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
form.Set("device_code", deviceCode)
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
form.Set("client_id", ca.AppID)
usedAssertion, caErr := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
if caErr != nil {
return &DeviceFlowResult{OK: false, Error: "invalid_client", Message: caErr.Error()}
}
if !usedAssertion {
form.Set("client_secret", ca.AppSecret)
}
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {
continue
}

View File

@@ -7,8 +7,10 @@ import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync/atomic"
"testing"
@@ -83,7 +85,7 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
})
t.Cleanup(restore)
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
_, err := RequestDeviceAuthorization(context.Background(), httpmock.NewClient(reg), ClientAuth{AppID: "cli_a", AppSecret: "test-secret"}, core.BrandFeishu, "", nil)
if err != nil {
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
}
@@ -106,6 +108,66 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
}
}
// captureRT records the last request + body and returns a canned device-auth response.
func captureDeviceAuthClient(gotReq **http.Request, gotBody *string, respJSON string) *http.Client {
return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
*gotReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
*gotBody = string(b)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(respJSON)),
}, nil
})}
}
const deviceAuthRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
func TestRequestDeviceAuthorization_PrivateKeyJWT_UsesAssertionNotBasic(t *testing.T) {
var req *http.Request
var body string
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT, Signer: newFakeAuthSigner(t), KeyLabel: "k"}
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "im:message:send", nil); err != nil {
t.Fatal(err)
}
if req.Header.Get("Authorization") != "" {
t.Errorf("private_key_jwt must NOT send Basic auth, got %q", req.Header.Get("Authorization"))
}
form, _ := url.ParseQuery(body)
if form.Get("client_assertion") == "" {
t.Error("missing client_assertion")
}
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Has("client_secret") {
t.Error("client_secret must not be present for private_key_jwt")
}
}
func TestRequestDeviceAuthorization_ClientSecret_UsesBasic(t *testing.T) {
var req *http.Request
var body string
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
ca := ClientAuth{AppID: "cli_a", AppSecret: "test-secret"} // client_secret
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "", nil); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(req.Header.Get("Authorization"), "Basic ") {
t.Errorf("client_secret should use Basic auth, got %q", req.Header.Get("Authorization"))
}
form, _ := url.ParseQuery(body)
if form.Has("client_assertion") {
t.Error("client_secret must not send a client_assertion")
}
}
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
got := keychain.FormatAuthCmdline([]string{
@@ -205,7 +267,7 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
t.Cleanup(cancel)
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
result := PollDeviceToken(ctx, client, ClientAuth{AppID: "cli_a", AppSecret: "test-secret"}, core.BrandFeishu, "device-code", 0, 10, nil)
if result == nil {
t.Fatal("PollDeviceToken() returned nil result")
}

152
internal/auth/jwt/jwt.go Normal file
View File

@@ -0,0 +1,152 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package jwt builds compact JWS tokens signed by a keysigner.Signer.
//
// It deliberately depends only on the standard library plus the existing
// google/uuid dependency — no third-party JWT library is introduced, keeping
// go.mod free of new dependencies. The actual signing (and, for ECDSA, the
// ASN.1->r||s conversion) is delegated to the Signer implementation.
package jwt
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/keysigner"
)
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// buildSignedJWT builds a compact JWS:
//
// base64url(header).base64url(claims).base64url(signature)
//
// alg is written into the header (it is part of the signed input) and verified
// against the alg the signer reports, guarding against a header/key mismatch.
// typ defaults to "JWT" because the client-assertion endpoint requires that
// protected-header value, even though some protocol examples show only alg.
func buildSignedJWT(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, alg string, header, claims map[string]any) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
if header == nil {
header = map[string]any{}
}
header["alg"] = alg
if _, ok := header["typ"]; !ok {
header["typ"] = "JWT"
}
hb, err := json.Marshal(header)
if err != nil {
return "", fmt.Errorf("jwt: marshal header: %w", err)
}
cb, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("jwt: marshal claims: %w", err)
}
signingInput := b64(hb) + "." + b64(cb)
sig, gotAlg, err := signer.Sign(ctx, ref, []byte(signingInput))
if err != nil {
return "", fmt.Errorf("jwt: sign: %w", err)
}
if gotAlg != alg {
return "", fmt.Errorf("jwt: signer alg %q does not match header alg %q", gotAlg, alg)
}
return signingInput + "." + b64(sig), nil
}
// newJTI returns a random unique token identifier.
func newJTI() string { return uuid.NewString() }
// attestationTTL bounds the attestation JWT's lifetime. The init nonce (60s,
// single-use) is the real anti-replay constraint; this is a modest margin for
// clock skew on top of the immediate init→sign→begin round-trip.
const attestationTTL = 2 * time.Minute
// attestationClaims builds the registration attestation claim set per the App
// Registration JWT spec: jti, iat, exp (all required) and the init-issued nonce.
func attestationClaims(nonce string, now time.Time) map[string]any {
return map[string]any{
"jti": newJTI(),
"iat": now.Unix(),
"exp": now.Add(attestationTTL).Unix(),
"nonce": nonce,
}
}
// clientAssertionClaims builds an RFC 7523 client_assertion claim set used to
// mint tokens in place of client_secret. aud is the brand's token endpoint URL.
func clientAssertionClaims(clientID, aud string, now time.Time, ttl time.Duration) map[string]any {
return map[string]any{
"iss": clientID,
"sub": clientID,
"aud": aud,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
"jti": newJTI(),
}
}
// ClientAssertionType is the RFC 7523 client_assertion_type value used for JWT
// bearer client authentication at the token endpoint.
const ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
// defaultAssertionTTL bounds a client_assertion's lifetime.
const defaultAssertionTTL = 5 * time.Minute
// SignAttestation signs the registration attestation JWT. The public key is
// embedded in the JWS "jwk" header so the registration backend can bind it to
// the app during action=begin; the claims carry the server nonce as a
// proof-of-possession challenge.
func SignAttestation(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, nonce string, now time.Time) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
pub, err := signer.EnsureKey(ctx, ref)
if err != nil {
return "", fmt.Errorf("jwt: ensure key: %w", err)
}
alg, err := keysigner.AlgForKey(pub)
if err != nil {
return "", err
}
jwk, err := keysigner.PublicKeyJWK(pub)
if err != nil {
return "", err
}
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{"jwk": jwk}, attestationClaims(nonce, now))
}
// SignClientAssertion mints a short-lived RFC 7523 client_assertion: it reads the
// registered key (it must already exist — bound at registration; a missing key is
// an error, not a reason to create a new unbound one), derives the JWS alg from
// the public key, and signs an assertion whose audience is the brand's Open API
// host. The server, holding the public key bound at registration, verifies it in
// place of client_secret. The assertion header carries only alg (no jwk/kid);
// the server locates the key via iss/sub = client_id.
//
// This is the model-independent glue: the assertion JWT is identical whether the
// server augments an existing grant (device_code/refresh_token) with client
// authentication or uses a dedicated jwt-bearer grant — only where the caller
// attaches it differs.
func SignClientAssertion(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, clientID, audience string, now time.Time) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
pub, err := signer.PublicKey(ctx, ref)
if err != nil {
return "", fmt.Errorf("jwt: public key: %w", err)
}
alg, err := keysigner.AlgForKey(pub)
if err != nil {
return "", err
}
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{}, clientAssertionClaims(clientID, audience, now, defaultAssertionTTL))
}

View File

@@ -0,0 +1,254 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package jwt
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeSigner is a real in-memory ECDSA P-256 signer, so tests exercise the full
// JWS path and the produced token is actually cryptographically verifiable.
type fakeSigner struct{ key *ecdsa.PrivateKey }
func newFakeSigner(t *testing.T) *fakeSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeSigner{key: k}
}
func (f *fakeSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
// JOSE ES256: fixed-width big-endian r||s (32 bytes each for P-256).
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestBuildSignedJWT_VerifiableES256(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
tok, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{Label: "x"}, keysigner.AlgES256,
map[string]any{}, clientAssertionClaims("cli_app", "https://accounts.example/token", now, 5*time.Minute))
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 JWS parts, got %d", len(parts))
}
hb, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("header not base64url: %v", err)
}
var hdr map[string]any
if err := json.Unmarshal(hb, &hdr); err != nil {
t.Fatal(err)
}
if hdr["alg"] != "ES256" || hdr["typ"] != "JWT" {
t.Errorf("header = %v, want alg=ES256 typ=JWT", hdr)
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" || claims["aud"] != "https://accounts.example/token" {
t.Errorf("claims = %v", claims)
}
// Cryptographically verify the signature against the signing input.
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Fatalf("sig not base64url: %v", err)
}
if len(sig) != 64 {
t.Fatalf("ES256 sig len = %d, want 64", len(sig))
}
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("signature did not verify")
}
}
func TestBuildSignedJWT_NilSigner(t *testing.T) {
if _, err := buildSignedJWT(context.Background(), nil, keysigner.KeyRef{}, "ES256", nil, nil); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestBuildSignedJWT_AlgMismatch(t *testing.T) {
f := newFakeSigner(t) // always reports ES256
if _, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{}, keysigner.AlgRS256, nil, nil); err == nil {
t.Fatal("expected error when header alg != signer alg")
}
}
func TestBuildSignedJWT_MarshalErrors(t *testing.T) {
f := newFakeSigner(t)
ctx := context.Background()
_, err := buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
map[string]any{"bad": func() {}}, nil)
if err == nil || !strings.Contains(err.Error(), "jwt: marshal header") {
t.Fatalf("header marshal error = %v, want prefix %q", err, "jwt: marshal header")
}
_, err = buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
nil, map[string]any{"bad": make(chan int)})
if err == nil || !strings.Contains(err.Error(), "jwt: marshal claims") {
t.Fatalf("claims marshal error = %v, want prefix %q", err, "jwt: marshal claims")
}
}
func TestSignClientAssertion(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
const aud = "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token"
tok, err := SignClientAssertion(context.Background(), f, keysigner.KeyRef{Label: "k"}, "cli_app", aud, now)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 parts, got %d", len(parts))
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "cli_app" || claims["aud"] != aud {
t.Errorf("claims = %v", claims)
}
// Signature must verify against the key's public half.
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("client_assertion signature did not verify")
}
}
func TestSignClientAssertion_NilSigner(t *testing.T) {
if _, err := SignClientAssertion(context.Background(), nil, keysigner.KeyRef{}, "cli_app", "aud", time.Unix(0, 0)); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestSignAttestation(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
tok, err := SignAttestation(context.Background(), f, keysigner.KeyRef{Label: "k"}, "nonce-abc", now)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 parts, got %d", len(parts))
}
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
var hdr map[string]any
if err := json.Unmarshal(hb, &hdr); err != nil {
t.Fatal(err)
}
jwk, ok := hdr["jwk"].(map[string]any)
if !ok {
t.Fatalf("attestation header missing jwk: %v", hdr)
}
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" || jwk["use"] != "sig" {
t.Errorf("jwk = %v", jwk)
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["nonce"] != "nonce-abc" {
t.Errorf("nonce = %v", claims["nonce"])
}
// jti, iat, exp are all required by the attestation spec.
iat, iatOK := claims["iat"].(float64)
exp, expOK := claims["exp"].(float64)
if !iatOK || !expOK || exp <= iat {
t.Errorf("claims iat/exp invalid: iat=%v exp=%v", claims["iat"], claims["exp"])
}
if jti, _ := claims["jti"].(string); jti == "" {
t.Error("claims jti empty")
}
// Signature verifies against the embedded key.
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("attestation signature did not verify")
}
}
func TestSignAttestation_NilSigner(t *testing.T) {
if _, err := SignAttestation(context.Background(), nil, keysigner.KeyRef{}, "n", time.Unix(0, 0)); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestClaimFactories(t *testing.T) {
now := time.Unix(1700000000, 0)
a := attestationClaims("nonce-xyz", now)
if a["nonce"] != "nonce-xyz" || a["iat"] != now.Unix() {
t.Errorf("attestation claims = %v", a)
}
if a["exp"] != now.Add(attestationTTL).Unix() {
t.Errorf("attestation exp = %v, want %v", a["exp"], now.Add(attestationTTL).Unix())
}
if jti, _ := a["jti"].(string); jti == "" {
t.Error("attestation jti empty")
}
c := clientAssertionClaims("cli_app", "aud", now, time.Minute)
if c["exp"].(int64) != now.Add(time.Minute).Unix() {
t.Errorf("client_assertion exp = %v", c["exp"])
}
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/vfs"
)
@@ -33,11 +34,15 @@ func sanitizeID(id string) string {
// UATCallOptions contains options for UAT API calls.
type UATCallOptions struct {
UserOpenId string
AppId string
AppSecret string
Domain core.LarkBrand
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
UserOpenId string
AppId string
AppSecret string
Domain core.LarkBrand
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // TEE key handle for private_key_jwt
KeyProvider string // empty == built-in signer; explicit external route otherwise
Signer keysigner.Signer // active signer for private_key_jwt
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
}
// UATStatus represents the status of a user access token.
@@ -57,11 +62,15 @@ func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions {
errOut = os.Stderr
}
return UATCallOptions{
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: cfg.Brand,
ErrOut: errOut,
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: cfg.Brand,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
KeyProvider: cfg.KeyProvider,
Signer: keysigner.Active(),
ErrOut: errOut,
}
}
@@ -187,13 +196,31 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
}
endpoints := ResolveOAuthEndpoints(opts.Domain)
clientAuth := ClientAuth{
AppID: opts.AppId,
AppSecret: opts.AppSecret,
AuthMethod: opts.AuthMethod,
Signer: opts.Signer,
KeyLabel: opts.KeyLabel,
KeyProvider: opts.KeyProvider,
}
clientAuth, err := clientAuth.ResolveSigner(context.Background())
if err != nil {
return nil, err
}
callEndpoint := func() (map[string]interface{}, error) {
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", stored.RefreshToken)
form.Set("client_id", opts.AppId)
form.Set("client_secret", opts.AppSecret)
usedAssertion, caErr := clientAuth.applyClientAssertion(context.Background(), form, core.OpenAPIAudience(opts.Domain))
if caErr != nil {
return nil, caErr
}
if !usedAssertion {
form.Set("client_secret", opts.AppSecret)
}
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {

View File

@@ -38,3 +38,27 @@ func TestNewUATCallOptions(t *testing.T) {
t.Error("ErrOut not set correctly")
}
}
// TestNewUATCallOptions_PrivateKeyJWT verifies the auth-method fields propagate
// so the refresh path can mint a client_assertion instead of sending a secret.
func TestNewUATCallOptions_PrivateKeyJWT(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_pk",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "agent-key",
KeyProvider: core.KeylessProviderLarkSuite,
}
opts := NewUATCallOptions(cfg, &bytes.Buffer{})
if opts.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Errorf("AuthMethod = %q, want private_key_jwt", opts.AuthMethod)
}
if opts.KeyLabel != "agent-key" {
t.Errorf("KeyLabel = %q, want agent-key", opts.KeyLabel)
}
if opts.KeyProvider != core.KeylessProviderLarkSuite {
t.Errorf("KeyProvider = %q, want %q", opts.KeyProvider, core.KeylessProviderLarkSuite)
}
}

View File

@@ -0,0 +1,122 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
)
type uatRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn uatRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
type retryExternalAssertionSigner struct {
calls int
}
func (s *retryExternalAssertionSigner) SignClientAssertion(_ context.Context, _, _, _ string) (string, string, error) {
s.calls++
return jwt.ClientAssertionType, fmt.Sprintf("refresh.jwt.%d", s.calls), nil
}
func TestDoRefreshToken_PrivateKeyJWTRetryResolvesOnceAndRemintsAssertion(t *testing.T) {
signer := &retryExternalAssertionSigner{}
resolveCalls := 0
previous := resolveExternalAssertionSigner
resolveExternalAssertionSigner = func(_ context.Context, provider string) (clientAssertionSigner, error) {
resolveCalls++
if provider != core.KeylessProviderLarkSuite {
t.Fatalf("provider = %q", provider)
}
return signer, nil
}
t.Cleanup(func() { resolveExternalAssertionSigner = previous })
var forms []url.Values
httpClient := &http.Client{Transport: uatRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
form, err := url.ParseQuery(string(body))
if err != nil {
return nil, err
}
forms = append(forms, form)
responseBody := `{"code":20050,"error":"server_error","error_description":"retry"}`
if len(forms) == 2 {
// A success-shaped response without a token lets the test exercise the
// retry without writing platform keychain state.
responseBody = `{"code":0}`
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(responseBody)),
Request: req,
}, nil
})}
now := time.Now().UnixMilli()
stored := &StoredUAToken{
UserOpenId: "ou_test",
AppId: "cli_external",
RefreshToken: "refresh-token",
RefreshExpiresAt: now + int64(time.Hour/time.Millisecond),
Scope: "offline_access",
GrantedAt: now,
}
opts := UATCallOptions{
UserOpenId: stored.UserOpenId,
AppId: stored.AppId,
Domain: core.BrandFeishu,
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "openclaw-lark",
KeyProvider: core.KeylessProviderLarkSuite,
ErrOut: io.Discard,
}
updated, err := doRefreshToken(httpClient, opts, stored)
if err == nil || !strings.Contains(err.Error(), "no access_token") {
t.Fatalf("doRefreshToken error = %v, want missing access_token after retry", err)
}
if updated != nil {
t.Fatalf("updated token = %#v, want nil", updated)
}
if resolveCalls != 1 {
t.Fatalf("provider resolution calls = %d, want 1", resolveCalls)
}
if signer.calls != 2 {
t.Fatalf("assertion signing calls = %d, want 2", signer.calls)
}
if len(forms) != 2 {
t.Fatalf("token endpoint requests = %d, want 2", len(forms))
}
first := forms[0].Get("client_assertion")
second := forms[1].Get("client_assertion")
if first == "" || second == "" || first == second {
t.Fatalf("assertions = (%q, %q), want two fresh values", first, second)
}
for _, form := range forms {
if form.Get("grant_type") != "refresh_token" {
t.Fatalf("grant_type = %q, want refresh_token", form.Get("grant_type"))
}
if form.Has("client_secret") {
t.Fatalf("private_key_jwt form leaked client_secret: %v", form)
}
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import "testing"
func TestListCandidateApps_KeylessSingleAccount(t *testing.T) {
apps := ListCandidateApps(&FeishuChannel{
AppID: "cli_keyless",
Brand: "feishu",
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: "openclaw-lark",
})
if len(apps) != 1 {
t.Fatalf("count = %d, want 1", len(apps))
}
if !apps[0].IsKeyless() {
t.Fatalf("candidate = %#v, want keyless", apps[0])
}
if apps[0].KeyRef != "openclaw-lark" {
t.Fatalf("KeyRef = %q, want openclaw-lark", apps[0].KeyRef)
}
}
func TestListCandidateApps_KeylessMultiAccountInheritance(t *testing.T) {
apps := ListCandidateApps(&FeishuChannel{
AppID: "cli_top",
Brand: "lark",
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: "openclaw-lark",
Accounts: map[string]*FeishuAccount{
"work": {},
},
})
if len(apps) != 2 {
t.Fatalf("count = %d, want 2 (implicit default + work)", len(apps))
}
app := candidateByLabel(t, apps, "work")
if app.Label != "work" || app.AppID != "cli_top" || app.Brand != "lark" {
t.Fatalf("candidate identity = %#v", app)
}
if app.AuthMethod != AuthMethodPrivateKeyJWT || app.KeyRef != "openclaw-lark" || !app.IsKeyless() {
t.Fatalf("candidate keyless fields = %#v", app)
}
}
func TestListCandidateApps_KeylessAccountOverride(t *testing.T) {
apps := ListCandidateApps(&FeishuChannel{
AppID: "cli_top",
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: "top-key",
Accounts: map[string]*FeishuAccount{
"work": {AppID: "cli_work", KeyRef: "work-key"},
},
})
if len(apps) != 2 {
t.Fatalf("count = %d, want 2 (implicit default + work)", len(apps))
}
if got := candidateByLabel(t, apps, "work"); got.AppID != "cli_work" || got.KeyRef != "work-key" || !got.IsKeyless() {
t.Fatalf("candidate = %#v", got)
}
}
func candidateByLabel(t *testing.T, apps []CandidateApp, label string) CandidateApp {
t.Helper()
for _, app := range apps {
if app.Label == label {
return app
}
}
t.Fatalf("candidate %q not found in %#v", label, apps)
return CandidateApp{}
}
func TestCandidateApp_SecretTakesPrecedenceOverKeyless(t *testing.T) {
app := CandidateApp{
AppID: "cli_both",
AppSecret: SecretInput{Plain: "secret"},
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: "openclaw-lark",
}
if app.IsKeyless() {
t.Fatal("an appSecret-backed OpenClaw account must not be treated as keyless")
}
}
func TestCandidateApp_KeylessRequiresKeyRef(t *testing.T) {
app := CandidateApp{AppID: "cli_missing_key", AuthMethod: AuthMethodPrivateKeyJWT}
if app.IsKeyless() {
t.Fatal("private_key_jwt without keyRef must not be treated as usable keyless")
}
}
func TestListCandidateApps_KeylessImplicitDefault(t *testing.T) {
apps := ListCandidateApps(&FeishuChannel{
AppID: "cli_default",
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: "openclaw-lark",
Accounts: map[string]*FeishuAccount{
"work": {AppID: "cli_work", AuthMethod: AuthMethodPrivateKeyJWT, KeyRef: "work-key"},
},
})
if len(apps) != 2 {
t.Fatalf("count = %d, want 2", len(apps))
}
for _, app := range apps {
if !app.IsKeyless() {
t.Fatalf("candidate = %#v, want keyless", app)
}
}
}

View File

@@ -31,20 +31,24 @@ type ChannelsRoot struct {
// `Brand` stays aligned with our internal terminology, but the JSON
// tag matches OpenClaw's on-disk format.
type FeishuChannel struct {
Enabled *bool `json:"enabled,omitempty"` // nil = default enabled
AppID string `json:"appId,omitempty"`
AppSecret SecretInput `json:"appSecret,omitempty"`
Brand string `json:"domain,omitempty"`
Accounts map[string]*FeishuAccount `json:"accounts,omitempty"`
Enabled *bool `json:"enabled,omitempty"` // nil = default enabled
AppID string `json:"appId,omitempty"`
AppSecret SecretInput `json:"appSecret,omitempty"`
Brand string `json:"domain,omitempty"`
Accounts map[string]*FeishuAccount `json:"accounts,omitempty"`
AuthMethod string `json:"authMethod,omitempty"`
KeyRef string `json:"keyRef,omitempty"`
}
// FeishuAccount is a single account entry within Accounts.
// Like FeishuChannel, `Brand` maps to OpenClaw's `domain` key.
type FeishuAccount struct {
Enabled *bool `json:"enabled,omitempty"` // nil = default enabled
AppID string `json:"appId,omitempty"`
AppSecret SecretInput `json:"appSecret,omitempty"`
Brand string `json:"domain,omitempty"`
Enabled *bool `json:"enabled,omitempty"` // nil = default enabled
AppID string `json:"appId,omitempty"`
AppSecret SecretInput `json:"appSecret,omitempty"`
Brand string `json:"domain,omitempty"`
AuthMethod string `json:"authMethod,omitempty"`
KeyRef string `json:"keyRef,omitempty"`
}
// isEnabled returns true if the enabled field is nil (default) or explicitly true.
@@ -228,10 +232,25 @@ func LookupProvider(ref *SecretRef, cfg *SecretsConfig) (*ProviderConfig, error)
// CandidateApp represents a bindable app from OpenClaw's feishu channel config.
type CandidateApp struct {
Label string
AppID string
AppSecret SecretInput
Brand string
Label string
AppID string
AppSecret SecretInput
Brand string
AuthMethod string
KeyRef string
}
const AuthMethodPrivateKeyJWT = "private_key_jwt"
// IsKeyless mirrors openclaw-lark's resolved-account precedence: an app
// secret wins when both credential shapes are present. Only a secretless
// private_key_jwt account with a keyRef is eligible for helper reuse.
func (c CandidateApp) IsKeyless() bool {
return c.AppSecret.IsZero() && c.AuthMethod == AuthMethodPrivateKeyJWT && strings.TrimSpace(c.KeyRef) != ""
}
func bindableCredential(secret SecretInput, authMethod, keyRef string) bool {
return !secret.IsZero() || (authMethod == AuthMethodPrivateKeyJWT && strings.TrimSpace(keyRef) != "")
}
// ListCandidateApps enumerates all bindable (enabled) apps from a FeishuChannel.
@@ -243,7 +262,7 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp {
if len(ch.Accounts) > 0 {
apps := make([]CandidateApp, 0, len(ch.Accounts)+1)
// When accounts exist AND top-level has its own appId+appSecret,
// When accounts exist AND top-level has its own bindable credential,
// 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.
@@ -254,12 +273,15 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp {
break
}
}
if !hasDefault && ch.AppID != "" && !ch.AppSecret.IsZero() && isEnabled(ch.Enabled) {
if !hasDefault && ch.AppID != "" && isEnabled(ch.Enabled) &&
bindableCredential(ch.AppSecret, ch.AuthMethod, ch.KeyRef) {
apps = append(apps, CandidateApp{
Label: "default",
AppID: ch.AppID,
AppSecret: ch.AppSecret,
Brand: ch.Brand,
Label: "default",
AppID: ch.AppID,
AppSecret: ch.AppSecret,
Brand: ch.Brand,
AuthMethod: ch.AuthMethod,
KeyRef: ch.KeyRef,
})
}
@@ -282,11 +304,21 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp {
if brand == "" {
brand = ch.Brand
}
authMethod := acct.AuthMethod
if authMethod == "" {
authMethod = ch.AuthMethod
}
keyRef := acct.KeyRef
if keyRef == "" {
keyRef = ch.KeyRef
}
apps = append(apps, CandidateApp{
Label: label,
AppID: appID,
AppSecret: appSecret,
Brand: brand,
Label: label,
AppID: appID,
AppSecret: appSecret,
Brand: brand,
AuthMethod: authMethod,
KeyRef: keyRef,
})
}
return apps
@@ -295,10 +327,12 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp {
// 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,
Label: "",
AppID: ch.AppID,
AppSecret: ch.AppSecret,
Brand: ch.Brand,
AuthMethod: ch.AuthMethod,
KeyRef: ch.KeyRef,
}}
}

View File

@@ -36,6 +36,14 @@ type AppUser struct {
UserName string `json:"userName"`
}
// Auth methods for app credentials. An empty AppConfig.AuthMethod means the
// default, client_secret.
const (
AuthMethodClientSecret = "client_secret" // app_id + app_secret
authMethodPKJWTValue = "private_key_jwt" // TEE-signed client_assertion; no app secret
AuthMethodPrivateKeyJWT = authMethodPKJWTValue
)
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
type AppConfig struct {
Name string `json:"name,omitempty"`
@@ -46,6 +54,15 @@ type AppConfig struct {
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
StrictMode *StrictMode `json:"strictMode,omitempty"`
Users []AppUser `json:"users"`
// AuthMethod selects how tokens are minted. Empty == AuthMethodClientSecret
// (back-compat). AuthMethodPrivateKeyJWT uses a TEE-held key (see KeyRef) to
// sign client_assertion JWTs instead of sending an app secret.
AuthMethod string `json:"authMethod,omitempty"`
// KeyRef references the non-exportable signing key for private_key_jwt.
// Source is "tee" and ID is the backend key label; the actual key never
// leaves the secure backend, so this is a handle, not secret material.
KeyRef *SecretRef `json:"keyRef,omitempty"`
}
// ProfileName returns the display name for this app config.
@@ -161,7 +178,10 @@ type CliConfig struct {
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
AuthMethod string // "" == client_secret; AuthMethodPrivateKeyJWT
KeyLabel string // resolved TEE key handle for private_key_jwt
KeyProvider string // empty == built-in signer; otherwise an explicit external signer route
}
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
@@ -247,31 +267,67 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames()))
}
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
WithHint("%s", err.Error()).
WithCause(err)
// Validate the auth method first so a malformed profile fails here rather
// than silently degrading to client_secret (unknown method) or failing later
// at token-signing. Empty stays empty — downstream treats it as client_secret
// (back-compat).
switch app.AuthMethod {
case "", AuthMethodClientSecret, AuthMethodPrivateKeyJWT:
default:
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "unknown authMethod %q", app.AuthMethod).
WithHint("supported: %s, %s (empty defaults to %s)", AuthMethodClientSecret, AuthMethodPrivateKeyJWT, AuthMethodClientSecret)
}
secret, err := ResolveSecretInput(app.AppSecret, kc)
if err != nil {
if errs.IsTyped(err) {
return nil, err
// private_key_jwt carries no secret: validate the key handle and skip secret
// resolution entirely, so a stale/broken AppSecret ref never produces a
// confusing secret-resolution error for an otherwise-valid pkjwt profile.
var secret string
if app.AuthMethod == AuthMethodPrivateKeyJWT {
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "private_key_jwt requires a key handle (keyRef) but none is configured").
WithHint("re-run: lark-cli config init --new --private-key-jwt")
}
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(err) {
subtype = errs.SubtypeInvalidConfig
provider := strings.TrimSpace(app.KeyRef.Provider)
switch provider {
case "", KeylessProviderLarkSuite:
default:
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig,
"unknown keyless signer provider %q", app.KeyRef.Provider).
WithHint("supported external provider: %s; omit provider to use the built-in signer", KeylessProviderLarkSuite)
}
} else {
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
WithHint("%s", err.Error()).
WithCause(err)
}
var resolveErr error
secret, resolveErr = ResolveSecretInput(app.AppSecret, kc)
if resolveErr != nil {
if errs.IsTyped(resolveErr) {
return nil, resolveErr
}
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(resolveErr) {
subtype = errs.SubtypeInvalidConfig
}
return nil, errs.NewConfigError(subtype, "%s", resolveErr.Error()).WithCause(resolveErr)
}
return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err)
}
cfg := &CliConfig{
ProfileName: app.ProfileName(),
AppID: app.AppId,
AppSecret: secret,
Brand: ParseBrand(string(app.Brand)),
Lang: app.Lang,
AuthMethod: app.AuthMethod,
DefaultAs: app.DefaultAs,
}
if app.KeyRef != nil {
cfg.KeyLabel = app.KeyRef.ID
cfg.KeyProvider = strings.TrimSpace(app.KeyRef.Provider)
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId
cfg.UserName = app.Users[0].UserName

View File

@@ -86,6 +86,35 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
}
}
func TestResolveConfigFromMulti_KeyProviderRouting(t *testing.T) {
base := AppConfig{
AppId: "cli_pk", Brand: BrandFeishu, AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: &SecretRef{Source: SecretSourceTEE, ID: "key-1"}, Users: []AppUser{},
}
for _, provider := range []string{"", KeylessProviderLarkSuite} {
app := base
ref := *base.KeyRef
ref.Provider = provider
app.KeyRef = &ref
cfg, err := ResolveConfigFromMulti(&MultiAppConfig{Apps: []AppConfig{app}}, stubKeychain{}, "")
if err != nil {
t.Fatalf("provider %q: %v", provider, err)
}
if cfg.KeyProvider != provider {
t.Fatalf("KeyProvider = %q, want %q", cfg.KeyProvider, provider)
}
}
app := base
ref := *base.KeyRef
ref.Provider = "unknown.provider"
app.KeyRef = &ref
if _, err := ResolveConfigFromMulti(&MultiAppConfig{Apps: []AppConfig{app}}, stubKeychain{}, ""); err == nil {
t.Fatal("unknown provider must fail closed")
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
@@ -133,6 +162,108 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
}
}
// TestResolveConfigFromMulti_RejectsUnknownAuthMethod ensures an unsupported
// authMethod fails at resolution rather than silently degrading to client_secret.
func TestResolveConfigFromMulti_RejectsUnknownAuthMethod(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
AuthMethod: "bogus_method",
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for unknown authMethod")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
}
// TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef ensures private_key_jwt
// without a key handle fails at resolution rather than later at token-signing.
func TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: SecretInput{}, // private_key_jwt carries no app secret
Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT,
// KeyRef intentionally nil
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for private_key_jwt without keyRef")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
// Control: same config WITH a keyRef resolves cleanly and sets KeyLabel.
raw.Apps[0].KeyRef = &SecretRef{Source: "tee", ID: "larksuite-cli-agent"}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error with keyRef present: %v", err)
}
if cfg.KeyLabel != "larksuite-cli-agent" {
t.Errorf("KeyLabel = %q, want larksuite-cli-agent", cfg.KeyLabel)
}
}
// TestResolveConfigFromMulti_PKJWTSkipsSecretResolution ensures a private_key_jwt
// profile that carries a stale/broken AppSecret ref still resolves cleanly: the
// auth method is judged before any secret handling, so the stale ref is ignored
// instead of producing a confusing secret-resolution failure.
func TestResolveConfigFromMulti_PKJWTSkipsSecretResolution(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{{
AppId: "cli_pk",
// Stale keychain ref whose ID does not match appId — would trip
// ValidateSecretKeyMatch / ResolveSecretInput if it were reached.
AppSecret: SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_OTHER"}},
Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: &SecretRef{Source: "tee", ID: "agent-key"},
Users: []AppUser{},
}},
}
cfg, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
if err != nil {
t.Fatalf("pkjwt with stale secret ref must skip secret resolution, got %v", err)
}
if cfg.AuthMethod != AuthMethodPrivateKeyJWT || cfg.KeyLabel != "agent-key" {
t.Errorf("got authMethod=%q keyLabel=%q", cfg.AuthMethod, cfg.KeyLabel)
}
}
// TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef ensures the stricter keyRef
// check (Source=="tee" && ID!="") rejects malformed handles.
func TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef(t *testing.T) {
for i, ref := range []*SecretRef{
{Source: "keychain", ID: "x"}, // wrong source
{Source: "tee", ID: ""}, // empty id
} {
raw := &MultiAppConfig{Apps: []AppConfig{{
AppId: "cli_pk", Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT, KeyRef: ref, Users: []AppUser{},
}}}
if _, err := ResolveConfigFromMulti(raw, stubKeychain{}, ""); err == nil {
t.Errorf("case %d: expected ConfigError for bad keyRef", i)
}
}
}
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{

View File

@@ -19,6 +19,12 @@ type SecretRef struct {
ID string `json:"id"` // env var name / file path / command / keychain key
}
// KeylessProviderLarkSuite is the only external private_key_jwt signer route.
// An absent or empty provider always means the CLI's built-in signer.
const KeylessProviderLarkSuite = "larksuite.keyless"
const SecretSourceTEE = "tee"
// ---------------------------------------------------------------------------
// SecretInput — union type: plain string or SecretRef
// ---------------------------------------------------------------------------

View File

@@ -63,3 +63,10 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
func ResolveOpenBaseURL(brand LarkBrand) string {
return ResolveEndpoints(brand).Open
}
// OpenAPIAudience returns the client_assertion `aud` value for the brand: the
// bare Open API host per the App Authentication JWT spec — "open.feishu.cn" or
// "open.larksuite.com" — not the full token endpoint URL.
func OpenAPIAudience(brand LarkBrand) string {
return strings.TrimPrefix(ResolveOpenBaseURL(brand), "https://")
}

View File

@@ -91,3 +91,12 @@ func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
}
}
func TestOpenAPIAudience(t *testing.T) {
if got := OpenAPIAudience(BrandFeishu); got != "open.feishu.cn" {
t.Errorf("OpenAPIAudience(feishu) = %q, want open.feishu.cn", got)
}
if got := OpenAPIAudience(BrandLark); got != "open.larksuite.com" {
t.Errorf("OpenAPIAudience(lark) = %q, want open.larksuite.com", got)
}
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/keychain"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/keysigner"
)
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
@@ -175,6 +176,18 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
if err != nil {
return nil, err
}
// private_key_jwt apps have no app secret: mint via the jwt-bearer grant
// using a TEE-signed client_assertion instead.
if acct.AuthMethod == core.AuthMethodPrivateKeyJWT {
signer := keysigner.Active()
token, err := FetchTATWithAssertionForProvider(ctx, httpClient, acct.Brand, acct.AppID, signer, acct.KeyProvider, acct.KeyLabel)
if err != nil {
return nil, err
}
return &TokenResult{Token: token}, nil
}
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
if err != nil {
return nil, err

View File

@@ -12,7 +12,12 @@ import (
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keylesshelper"
"github.com/larksuite/cli/internal/keylessprovider"
"github.com/larksuite/cli/internal/keysigner"
)
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
@@ -100,3 +105,120 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
}
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
}
// FetchTATWithAssertion mints a tenant access token for a private_key_jwt app via
// the RFC 7523 jwt-bearer grant: it signs a short-lived client_assertion with the
// TEE-held key and posts it to the unified OAuth token endpoint, replacing the
// app_secret entirely.
//
// The unified v2 token endpoint returns the minted token as access_token
// (tenant_access_token is accepted as a fallback).
func FetchTATWithAssertion(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) (string, error) {
return FetchTATWithAssertionForProvider(ctx, httpClient, brand, clientID, signer, "", keyLabel)
}
// FetchTATWithAssertionForProvider routes one app authentication by its
// persisted keyRef.provider. Empty is the stable built-in signer route;
// larksuite.keyless is resolved afresh for this operation; all other values
// fail closed in keylessprovider.Resolve.
func FetchTATWithAssertionForProvider(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, provider, keyLabel string) (string, error) {
var helper *keylesshelper.Command
var err error
if strings.TrimSpace(provider) != "" {
helper, err = keylessprovider.Resolve(ctx, provider)
if err != nil {
return "", err
}
}
return FetchTATWithAssertionWithHelper(ctx, httpClient, brand, clientID, signer, helper, keyLabel)
}
// FetchTATWithAssertionWithHelper is the single-resolution variant used when
// the caller must make a preflight decision from the same helper snapshot.
func FetchTATWithAssertionWithHelper(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, helper *keylesshelper.Command, keyLabel string) (string, error) {
if signer == nil && helper == nil {
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
"profile uses private_key_jwt but no TEE key signer is available on this build").
WithHint("install a build with the platform key-signer extension, configure an external keyless signer, or reconfigure the app to use an app secret")
}
ep := core.ResolveEndpoints(brand)
endpoint := ep.Open + auth.PathOAuthTokenV2
assertionType, assertion, err := auth.SignClientAssertion(ctx, signer, helper, keyLabel, clientID, core.OpenAPIAudience(brand))
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("client_id", clientID)
form.Set("client_assertion_type", assertionType)
form.Set("client_assertion", assertion)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("read token response: %w", err)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
AccessToken string `json:"access_token"`
TenantAccessToken string `json:"tenant_access_token"`
}
_ = json.Unmarshal(body, &result) // best-effort; error body may not be JSON
token := result.AccessToken
if token == "" {
token = result.TenantAccessToken
}
if resp.StatusCode == http.StatusOK && token != "" && result.Error == "" && result.Code == 0 {
return token, nil
}
// Surface the server's reason, preferring the OAuth `error` code (e.g.
// unauthorized_client) which is more diagnostic than the description alone.
detail := result.ErrorDescription
if detail == "" {
detail = result.Msg
}
if detail == "" {
detail = strings.TrimSpace(string(body))
}
if result.Error != "" {
return "", classifyAssertionError(result.Error, resp.StatusCode, detail)
}
return "", fmt.Errorf("token endpoint HTTP %d (code=%d): %s", resp.StatusCode, result.Code, detail)
}
// classifyAssertionError maps the OAuth token endpoint's `error` field to a
// typed or untyped error. Only deterministic client-credential rejections get a
// typed errs.ConfigError (so runProbePKJWT can tell "this key is not bound to
// this app" apart from upstream noise); every other error (e.g.
// temporarily_unavailable) stays untyped and is swallowed by the probe. detail
// carries only the server's error_description / msg / body text — it never
// echoes the client_assertion or private key (the assertion lives only in the
// request form).
func classifyAssertionError(oauthError string, httpStatus int, detail string) error {
switch oauthError {
case "invalid_client", "unauthorized_client", "invalid_grant":
return errs.NewConfigError(errs.SubtypeInvalidClient,
"token endpoint rejected the key (%s): %s", oauthError, detail)
default:
return fmt.Errorf("token endpoint HTTP %d (%s): %s", httpStatus, oauthError, detail)
}
}

View File

@@ -5,15 +5,22 @@ package credential
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// stubRoundTripper lets us assert request shape and return canned responses.
@@ -307,3 +314,141 @@ func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
req2.Header = req.Header
return http.DefaultTransport.RoundTrip(req2)
}
// fakeTATSigner is a real in-memory ECDSA P-256 signer for assertion tests.
type fakeTATSigner struct{ key *ecdsa.PrivateKey }
func newFakeTATSigner(t *testing.T) *fakeTATSigner {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeTATSigner{key: k}
}
func (f *fakeTATSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeTATSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeTATSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestFetchTATWithAssertion_Success(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"access_token":"test-token","token_type":"Bearer","expires_in":7200}`}
hc := &http.Client{Transport: rt}
token, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "agent-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "test-token" {
t.Errorf("token = %q, want test-token", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
form, err := url.ParseQuery(rt.gotBody)
if err != nil {
t.Fatal(err)
}
if form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" {
t.Errorf("grant_type = %q", form.Get("grant_type"))
}
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Get("client_assertion") == "" {
t.Error("client_assertion is empty")
}
if form.Has("client_secret") {
t.Error("client_secret must NOT be sent for private_key_jwt")
}
if form.Get("client_id") != "cli_app" {
t.Errorf("client_id = %q", form.Get("client_id"))
}
}
func TestFetchTATWithAssertion_NilSigner(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hc := &http.Client{Transport: &stubRoundTripper{respCode: 200, respBody: `{}`}}
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", nil, "k"); err == nil {
t.Fatal("expected error when signer is nil")
}
}
func TestFetchTATWithAssertion_ServerError(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"error":"invalid_client","error_description":"unknown key"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k"); err == nil {
t.Fatal("expected error for invalid_client response")
}
}
func TestFetchTATWithAssertion_LimitsErrorBody(t *testing.T) {
rt := &stubRoundTripper{respCode: 502, respBody: strings.Repeat("x", 2<<20)}
hc := &http.Client{Transport: rt}
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
if err == nil {
t.Fatal("expected error")
}
if len(err.Error()) > (1<<20)+512 {
t.Fatalf("error length = %d, want bounded", len(err.Error()))
}
}
// Deterministic OAuth client rejections must be typed (ConfigError /
// SubtypeInvalidClient) so runProbePKJWT can tell "the key is not bound to this
// app" apart from transport noise.
func TestFetchTATWithAssertion_DeterministicReject_Typed(t *testing.T) {
for _, oauthErr := range []string{"invalid_client", "unauthorized_client", "invalid_grant"} {
rt := &stubRoundTripper{respCode: 401, respBody: `{"error":"` + oauthErr + `","error_description":"bad key"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
if err == nil {
t.Fatalf("%s: expected error", oauthErr)
}
if !errs.IsTyped(err) {
t.Errorf("%s: must be typed, got %T", oauthErr, err)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) || cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("%s: want ConfigError/InvalidClient, got %T %v", oauthErr, err, err)
}
}
}
// Unrecognized OAuth errors and non-payload noise stay UNTYPED so the probe
// treats them as upstream noise and stays silent.
func TestFetchTATWithAssertion_AmbiguousError_Untyped(t *testing.T) {
cases := []string{
`{"error":"temporarily_unavailable","error_description":"retry"}`,
`{"code":99999,"msg":"weird"}`,
`not json`,
}
for _, body := range cases {
rt := &stubRoundTripper{respCode: 503, respBody: body}
hc := &http.Client{Transport: rt}
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
if err == nil {
t.Fatalf("body %q: expected error", body)
}
if errs.IsTyped(err) {
t.Errorf("body %q: must be UNTYPED, got typed %T", body, err)
}
}
}

View File

@@ -26,6 +26,9 @@ type Account struct {
UserName string
Lang i18n.Lang
SupportedIdentities uint8
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // resolved TEE key handle for private_key_jwt
KeyProvider string // empty == built-in signer; explicit external provider otherwise
}
const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__"
@@ -69,6 +72,9 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
UserName: cfg.UserName,
Lang: cfg.Lang,
SupportedIdentities: cfg.SupportedIdentities,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
KeyProvider: cfg.KeyProvider,
}
}
@@ -88,6 +94,9 @@ func (a *Account) ToCliConfig() *core.CliConfig {
UserName: a.UserName,
Lang: a.Lang,
SupportedIdentities: a.SupportedIdentities,
AuthMethod: a.AuthMethod,
KeyLabel: a.KeyLabel,
KeyProvider: a.KeyProvider,
}
}

View File

@@ -56,13 +56,16 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
UserName: "alice",
Lang: i18n.LangJaJP,
SupportedIdentities: 3,
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "openclaw-lark",
KeyProvider: core.KeylessProviderLarkSuite,
}
acct := AccountFromCliConfig(cfg)
if acct == nil {
t.Fatal("AccountFromCliConfig() = nil")
}
if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName {
if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName || acct.KeyProvider != cfg.KeyProvider {
t.Fatalf("AccountFromCliConfig() = %#v, want copied fields from %#v", acct, cfg)
}
if acct.Lang != cfg.Lang {
@@ -73,7 +76,7 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
if roundtrip == nil {
t.Fatal("ToCliConfig() = nil")
}
if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName {
if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName || roundtrip.KeyProvider != cfg.KeyProvider {
t.Fatalf("ToCliConfig() = %#v, want copied fields from %#v", roundtrip, cfg)
}
if roundtrip.Lang != cfg.Lang {

View File

@@ -202,7 +202,9 @@ func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, v
Hint: "check strict mode or the active credential provider",
}
}
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
// private_key_jwt apps have no app secret — the bot/tenant token is minted via
// a TEE-signed client_assertion — so absence of a secret is NOT "unconfigured".
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) && cfg.AuthMethod != core.AuthMethodPrivateKeyJWT {
return Identity{
Status: StatusNotConfigured,
Message: "Bot identity: not configured (missing app secret or bot token)",

View File

@@ -0,0 +1,287 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package keylesshelper invokes a signer generation that has already been
// resolved and verified by internal/keylessprovider.
package keylesshelper
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/vfs"
)
const (
helperOutputLimit = 1 << 20
helperStderrLimit = 64 << 10
helperExecutionTimeout = 10 * time.Second
)
type request struct {
Op string `json:"op"`
KeyRef string `json:"keyRef,omitempty"`
Nonce string `json:"nonce,omitempty"`
ClientID string `json:"clientId,omitempty"`
Audience string `json:"aud,omitempty"`
}
type response struct {
OK bool `json:"ok"`
Error *protocolError `json:"error,omitempty"`
Attestation string `json:"attestation,omitempty"`
ClientAssertionType string `json:"client_assertion_type,omitempty"`
ClientAssertion string `json:"client_assertion,omitempty"`
}
type protocolError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// Command is one verified provider executable. It is intentionally impossible
// to construct from an app-config path, argv, or environment variable.
type Command struct {
argv []string
providerCWD string
providerHome string
providerSHA string
}
// NewProviderCommand builds the fixed empty-argv command used by a verified
// provider executable. Provider execution never accepts argv from app config or
// the environment.
func NewProviderCommand(binaryPath, providerRoot, signerHome, expectedSHA256 string) (*Command, error) {
if strings.TrimSpace(binaryPath) == "" || strings.TrimSpace(providerRoot) == "" {
return nil, fmt.Errorf("provider binary path and root must be non-empty")
}
if len(expectedSHA256) != 64 {
return nil, fmt.Errorf("provider binary digest must be a SHA-256 hex string")
}
return &Command{argv: []string{binaryPath}, providerCWD: providerRoot, providerHome: signerHome, providerSHA: expectedSHA256}, nil
}
// Probe asks this resolved helper for its public key.
func (c *Command) Probe(ctx context.Context, keyRef string) error {
resp, err := c.execute(ctx, request{
Op: "pubkey",
KeyRef: defaultKeyRef(keyRef),
})
if err != nil {
return err
}
return validateResponse(resp)
}
// SignAttestation asks this resolved helper to mint a registration attestation JWT.
func (c *Command) SignAttestation(ctx context.Context, keyRef, nonce string) (string, error) {
resp, err := c.execute(ctx, request{
Op: "sign-attestation",
KeyRef: defaultKeyRef(keyRef),
Nonce: nonce,
})
if err != nil {
return "", err
}
if err := validateResponse(resp); err != nil {
return "", err
}
if resp.Attestation == "" {
return "", fmt.Errorf("keyless helper returned empty attestation")
}
return resp.Attestation, nil
}
// SignClientAssertion asks this resolved helper to mint a token-endpoint client_assertion.
func (c *Command) SignClientAssertion(ctx context.Context, keyRef, clientID, audience string) (string, string, error) {
resp, err := c.execute(ctx, request{
Op: "sign-assertion",
KeyRef: defaultKeyRef(keyRef),
ClientID: clientID,
Audience: audience,
})
if err != nil {
return "", "", err
}
if err := validateResponse(resp); err != nil {
return "", "", err
}
if resp.ClientAssertionType == "" {
return "", "", fmt.Errorf("keyless helper returned empty client_assertion_type")
}
if resp.ClientAssertion == "" {
return "", "", fmt.Errorf("keyless helper returned empty client_assertion")
}
return resp.ClientAssertionType, resp.ClientAssertion, nil
}
func (c *Command) execute(ctx context.Context, req request) (response, error) {
if err := verifyProviderBinary(c.argv[0], c.providerSHA); err != nil {
return response{}, err
}
return runCommandConfigured(ctx, c.argv, req, c.providerCWD, providerEnvironment(c.providerHome))
}
func verifyProviderBinary(path, expectedSHA string) error {
info, err := vfs.Lstat(path)
if err != nil {
return fmt.Errorf("recheck provider signer: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() <= 0 || info.Size() > 512<<20 {
return fmt.Errorf("provider signer changed before execution")
}
f, err := vfs.Open(path)
if err != nil {
return fmt.Errorf("reopen provider signer: %w", err)
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, io.LimitReader(f, 512<<20+1))
if err != nil || n != info.Size() {
return fmt.Errorf("rehash provider signer: file changed while reading")
}
if hex.EncodeToString(h.Sum(nil)) != expectedSHA {
return fmt.Errorf("provider signer digest changed before execution")
}
return nil
}
func validateResponse(resp response) error {
if resp.Error != nil {
return fmt.Errorf("keyless helper %s: %s", resp.Error.Type, resp.Error.Message)
}
if !resp.OK {
return fmt.Errorf("keyless helper returned ok=false")
}
return nil
}
func defaultKeyRef(keyRef string) string {
if keyRef != "" {
return keyRef
}
return keysigner.DefaultKeyLabel
}
func runCommand(ctx context.Context, argv []string, req request) (response, error) {
return runCommandConfigured(ctx, argv, req, "", nil)
}
func runCommandConfigured(ctx context.Context, argv []string, req request, cwd string, env []string) (response, error) {
body, err := json.Marshal(req)
if err != nil {
return response{}, fmt.Errorf("marshal keyless helper request: %w", err)
}
body = append(body, '\n')
helperCtx, cancel := withExecutionTimeout(ctx)
defer cancel()
// CommandContext's default cancellation kills the helper process. This is
// important for unattended agent calls: a signer blocked on platform UI must
// not hold the caller indefinitely.
cmd := exec.CommandContext(helperCtx, argv[0], argv[1:]...)
if cwd != "" {
cmd.Dir = cwd
cmd.Env = env
}
cmd.Stdin = bytes.NewReader(body)
stdout := &limitedBuffer{limit: helperOutputLimit}
stderr := &limitedBuffer{limit: helperStderrLimit}
cmd.Stdout = stdout
cmd.Stderr = stderr
runErr := cmd.Run()
if err := helperCtx.Err(); err != nil {
// Never parse or include helper output on cancellation. A partially written
// response may contain a client assertion or other credential material.
if errors.Is(err, context.DeadlineExceeded) {
return response{}, fmt.Errorf("keyless helper timed out: %w", context.DeadlineExceeded)
}
return response{}, fmt.Errorf("keyless helper canceled: %w", err)
}
var resp response
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
if runErr != nil {
return response{}, helperRunError(runErr, stderr.String())
}
return response{}, fmt.Errorf("keyless helper produced invalid JSON: %w", err)
}
if runErr != nil && resp.Error == nil {
return response{}, helperRunError(runErr, stderr.String())
}
return resp, nil
}
func providerEnvironment(homeOverride string) []string {
// Signer implementations use OS facilities and must not inherit language
// runtime/proxy/library injection variables. HOME/TMPDIR/SystemRoot are the
// minimal cross-platform values currently needed by supported backends.
keep := map[string]bool{"HOME": true, "TMPDIR": true, "TEMP": true, "TMP": true, "SystemRoot": true, "WINDIR": true}
var env []string
for _, entry := range os.Environ() {
name := entry
if idx := strings.IndexByte(entry, '='); idx >= 0 {
name = entry[:idx]
}
if keep[name] && !(name == "HOME" && homeOverride != "") {
env = append(env, entry)
}
}
if homeOverride != "" {
env = append(env, "HOME="+homeOverride)
}
return env
}
func withExecutionTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, helperExecutionTimeout)
}
func helperRunError(runErr error, stderr string) error {
if errors.Is(runErr, os.ErrNotExist) {
return fmt.Errorf("keyless helper executable no longer exists; repair or reinstall the OpenClaw Feishu plugin: %w", runErr)
}
if strings.TrimSpace(stderr) != "" {
return fmt.Errorf("keyless helper failed: %w (stderr omitted)", runErr)
}
return fmt.Errorf("keyless helper failed: %w", runErr)
}
type limitedBuffer struct {
buf bytes.Buffer
limit int
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
if b.limit <= 0 {
return len(p), nil
}
remaining := b.limit - b.buf.Len()
if remaining > 0 {
if len(p) < remaining {
remaining = len(p)
}
_, _ = b.buf.Write(p[:remaining])
}
return len(p), nil
}
func (b *limitedBuffer) Bytes() []byte {
return b.buf.Bytes()
}
func (b *limitedBuffer) String() string {
return b.buf.String()
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keylesshelper
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const helperProcessMode = "GO_WANT_KEYLESS_PROVIDER_HELPER"
func TestRunCommandProtocol(t *testing.T) {
t.Setenv(helperProcessMode, "reply")
resp, err := runCommand(context.Background(), []string{os.Args[0], "-test.run=^TestHelperProcess$"}, request{
Op: "sign-assertion", KeyRef: "key-1", ClientID: "cli_a", Audience: "aud",
})
if err != nil {
t.Fatal(err)
}
if !resp.OK || resp.ClientAssertion != "helper.jwt" {
t.Fatalf("response = %#v", resp)
}
}
func TestRunCommandTimeoutDoesNotLeakOutput(t *testing.T) {
t.Setenv(helperProcessMode, "hang")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := runCommand(ctx, []string{os.Args[0], "-test.run=^TestHelperProcess$"}, request{Op: "pubkey", KeyRef: "key-1"})
if !errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "secret.jwt") {
t.Fatalf("error = %v", err)
}
}
func TestProviderCommandRechecksDigestBeforeSpawn(t *testing.T) {
root := t.TempDir()
binary := filepath.Join(root, "signer")
if err := os.WriteFile(binary, []byte("first"), 0700); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte("first"))
command, err := NewProviderCommand(binary, root, "", hex.EncodeToString(digest[:]))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(binary, []byte("second"), 0700); err != nil {
t.Fatal(err)
}
if err := command.Probe(context.Background(), "key-1"); err == nil || !strings.Contains(err.Error(), "digest changed") {
t.Fatalf("Probe error = %v", err)
}
}
func TestProviderEnvironmentOverridesOnlyPinnedHome(t *testing.T) {
t.Setenv("HOME", "/original")
t.Setenv("HTTPS_PROXY", "http://untrusted.invalid")
env := providerEnvironment("/isolated")
joined := strings.Join(env, "\n")
if !strings.Contains(joined, "HOME=/isolated") || strings.Contains(joined, "HOME=/original") || strings.Contains(joined, "HTTPS_PROXY") {
t.Fatalf("provider environment = %q", joined)
}
}
func TestHelperProcess(t *testing.T) {
switch os.Getenv(helperProcessMode) {
case "reply":
var req request
if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
os.Exit(2)
}
_ = json.NewEncoder(os.Stdout).Encode(response{
OK: true, ClientAssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", ClientAssertion: "helper.jwt",
})
os.Exit(0)
case "hang":
_, _ = io.WriteString(os.Stdout, `{"ok":true,"client_assertion":"secret.jwt"`)
for {
time.Sleep(time.Hour)
}
}
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build darwin || linux
package keylessprovider
import (
"context"
"os/exec"
)
func newOpenClawInspectCommand(ctx context.Context, executable string, env []string) (*exec.Cmd, error) {
cmd := exec.CommandContext(ctx, executable, "plugins", "inspect", pluginID, "--json")
cmd.Env = env
return cmd, nil
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package keylessprovider
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"syscall"
"golang.org/x/sys/windows"
)
const inspectLauncherEnv = "LARK_CLI_OPENCLAW_INSPECT_LAUNCHER"
// newOpenClawInspectCommand runs native launchers directly and npm's .cmd/.bat
// launchers through the OS-owned command interpreter. The launcher path travels
// in a dedicated environment variable and is expanded once inside a quoted
// command, while delayed expansion is disabled. This avoids interpolating a
// user-controlled path into cmd.exe syntax.
func newOpenClawInspectCommand(ctx context.Context, executable string, env []string) (*exec.Cmd, error) {
ext := strings.ToLower(filepath.Ext(executable))
if ext == ".exe" || ext == ".com" {
cmd := exec.CommandContext(ctx, executable, "plugins", "inspect", pluginID, "--json")
cmd.Env = env
return cmd, nil
}
if ext != ".cmd" && ext != ".bat" {
return nil, fmt.Errorf("unsupported Windows OpenClaw launcher extension %q", ext)
}
systemDir, err := windows.GetSystemDirectory()
if err != nil {
return nil, fmt.Errorf("resolve Windows system directory: %w", err)
}
commandInterpreter := filepath.Join(systemDir, "cmd.exe")
cmd := exec.CommandContext(ctx, commandInterpreter)
cmd.Args = nil
cmd.Env = append(append([]string(nil), env...), inspectLauncherEnv+"="+executable)
cmd.SysProcAttr = &syscall.SysProcAttr{
CmdLine: `/d /q /s /v:off /c ""%` + inspectLauncherEnv + `%" plugins inspect ` + pluginID + ` --json"`,
}
return cmd, nil
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package keylessprovider
import (
"context"
"path/filepath"
"strings"
"testing"
)
func TestNewOpenClawInspectCommand_WindowsBatchUsesSystemCmdWithoutPathInterpolation(t *testing.T) {
launcher := `C:\Users\A B&(team)%literal%\openclaw.cmd`
cmd, err := newOpenClawInspectCommand(context.Background(), launcher, []string{"PATH=C:\\Windows\\System32"})
if err != nil {
t.Fatal(err)
}
if !strings.EqualFold(filepath.Base(cmd.Path), "cmd.exe") {
t.Fatalf("command path = %q, want System32 cmd.exe", cmd.Path)
}
if cmd.SysProcAttr == nil || !strings.Contains(cmd.SysProcAttr.CmdLine, "/d /q /s /v:off /c") {
t.Fatalf("cmd line = %#v", cmd.SysProcAttr)
}
if strings.Contains(cmd.SysProcAttr.CmdLine, launcher) {
t.Fatalf("launcher path was interpolated into cmd syntax: %q", cmd.SysProcAttr.CmdLine)
}
if !strings.Contains(strings.Join(cmd.Env, "\n"), inspectLauncherEnv+"="+launcher) {
t.Fatalf("launcher environment missing: %q", cmd.Env)
}
}
func TestNewOpenClawInspectCommand_WindowsNativeExecutableIsDirect(t *testing.T) {
launcher := `C:\Program Files\OpenClaw\openclaw.exe`
cmd, err := newOpenClawInspectCommand(context.Background(), launcher, []string{"TEMP=C:\\Temp"})
if err != nil {
t.Fatal(err)
}
if cmd.Path != launcher || cmd.SysProcAttr != nil {
t.Fatalf("native command = path %q sys %#v", cmd.Path, cmd.SysProcAttr)
}
wantArgs := []string{launcher, "plugins", "inspect", pluginID, "--json"}
if strings.Join(cmd.Args, "\x00") != strings.Join(wantArgs, "\x00") {
t.Fatalf("args = %#v, want %#v", cmd.Args, wantArgs)
}
}
func TestNewOpenClawInspectCommand_WindowsRejectsUnknownLauncher(t *testing.T) {
if _, err := newOpenClawInspectCommand(context.Background(), `C:\\OpenClaw\\openclaw.ps1`, nil); err == nil {
t.Fatal("PowerShell launcher unexpectedly accepted")
}
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keylessprovider
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const (
providerManifestFormatVersion = 1
providerManifestFileName = "signing-providers.json"
)
type providerManifest struct {
Version int `json:"version"`
Providers map[string]providerManifestEntry `json:"providers"`
}
type providerManifestEntry struct {
PluginID string `json:"pluginId"`
PluginPackage string `json:"pluginPackage"`
SignerPackage string `json:"signerPackage"`
StateDir string `json:"stateDir"`
PluginDir string `json:"pluginDir"`
PackageDir string `json:"packageDir"`
BinaryPath string `json:"binaryPath"`
PackageVersion string `json:"packageVersion"`
OS string `json:"os"`
Arch string `json:"arch"`
SHA256 string `json:"sha256"`
PackageSize int64 `json:"packageSize"`
PackageModTimeNS int64 `json:"packageModTimeNs"`
BinarySize int64 `json:"binarySize"`
BinaryModTimeNS int64 `json:"binaryModTimeNs"`
}
var providerManifestMu sync.Mutex
func providerManifestPath() string {
return filepath.Join(core.GetBaseConfigDir(), providerManifestFileName)
}
// resolveFromProviderManifest uses the global manifest only as a location
// index. resolvePackage revalidates the full path, ownership, permissions,
// package metadata, and binary digest before a cached location can be used.
func resolveFromProviderManifest(stateDir, goos, goarch string) (resolvedProvider, bool) {
manifest, err := readProviderManifest()
if err != nil || manifest.Version != providerManifestFormatVersion {
return resolvedProvider{}, false
}
entry, ok := manifest.Providers[ProviderID]
if !ok || entry.PluginID != pluginID || entry.PluginPackage != pluginPackageName ||
entry.StateDir != stateDir || entry.OS != goos || entry.Arch != goarch {
return resolvedProvider{}, false
}
spec, err := signerPackageFor(goos, goarch)
if err != nil {
return resolvedProvider{}, false
}
if !allowedPackageLocation(entry.PluginDir, entry.PackageDir, spec, goos) {
return resolvedProvider{}, false
}
resolved, err := resolvePackage(stateDir, entry.PluginDir, entry.PackageDir, spec, goos)
if err != nil {
return resolvedProvider{}, false
}
if entry.SignerPackage != spec.name ||
!samePath(resolved.binaryPath, entry.BinaryPath, goos) ||
resolved.packageVersion != entry.PackageVersion ||
resolved.digest != entry.SHA256 ||
resolved.packageSize != entry.PackageSize ||
resolved.packageModTimeNS != entry.PackageModTimeNS ||
resolved.binarySize != entry.BinarySize ||
resolved.binaryModTimeNS != entry.BinaryModTimeNS {
return resolvedProvider{}, false
}
return resolved, true
}
func readProviderManifest() (providerManifest, error) {
path := providerManifestPath()
if err := validateProviderObject(path, false); err != nil {
return providerManifest{}, err
}
data, err := readMetadata(path)
if err != nil {
return providerManifest{}, err
}
var manifest providerManifest
if err := decodeJSONObject(data, &manifest); err != nil {
return providerManifest{}, err
}
if manifest.Providers == nil {
manifest.Providers = make(map[string]providerManifestEntry)
}
return manifest, nil
}
func saveProviderManifest(resolved resolvedProvider, goos, goarch string) error {
providerManifestMu.Lock()
defer providerManifestMu.Unlock()
spec, err := signerPackageFor(goos, goarch)
if err != nil {
return err
}
baseDir := core.GetBaseConfigDir()
if err := vfs.MkdirAll(baseDir, 0700); err != nil {
return err
}
if err := validateProviderObject(baseDir, true); err != nil {
return err
}
manifest, err := readProviderManifest()
if err != nil || manifest.Version != providerManifestFormatVersion {
manifest = providerManifest{
Version: providerManifestFormatVersion,
Providers: make(map[string]providerManifestEntry),
}
}
if manifest.Providers == nil {
manifest.Providers = make(map[string]providerManifestEntry)
}
manifest.Providers[ProviderID] = providerManifestEntry{
PluginID: pluginID,
PluginPackage: pluginPackageName,
SignerPackage: spec.name,
StateDir: resolved.stateDir,
PluginDir: resolved.pluginDir,
PackageDir: resolved.packageDir,
BinaryPath: resolved.binaryPath,
PackageVersion: resolved.packageVersion,
OS: goos,
Arch: goarch,
SHA256: resolved.digest,
PackageSize: resolved.packageSize,
PackageModTimeNS: resolved.packageModTimeNS,
BinarySize: resolved.binarySize,
BinaryModTimeNS: resolved.binaryModTimeNS,
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return validate.AtomicWrite(providerManifestPath(), append(data, '\n'), os.FileMode(0600))
}

View File

@@ -0,0 +1,493 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keylessprovider
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"testing"
)
func TestResolve_ManifestMissPersistsAndHitSkipsInspect(t *testing.T) {
configDir := useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
var calls atomic.Int32
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
calls.Add(1)
return data, nil
})
if command, err := Resolve(context.Background(), ProviderID); err != nil || command == nil {
t.Fatalf("first Resolve = %v, %v", command, err)
}
if calls.Load() != 1 {
t.Fatalf("inspect calls after miss = %d, want 1", calls.Load())
}
path := filepath.Join(configDir, providerManifestFileName)
manifestData, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var manifest providerManifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
t.Fatalf("manifest is not valid JSON: %v", err)
}
entry := manifest.Providers[ProviderID]
if manifest.Version != providerManifestFormatVersion || entry.PackageDir != fx.packageDir ||
entry.BinaryPath != fx.binary || entry.SHA256 != fx.binaryDigest || entry.PackageVersion != "1.2.3" {
t.Fatalf("persisted manifest = %#v", manifest)
}
if runtime.GOOS != "windows" {
if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("manifest permissions = %v, %v", info, err)
}
}
if command, err := Resolve(context.Background(), ProviderID); err != nil || command == nil {
t.Fatalf("cached Resolve = %v, %v", command, err)
}
if calls.Load() != 1 {
t.Fatalf("manifest hit restarted inspect; calls = %d", calls.Load())
}
}
func TestResolve_ManifestMutationRefreshes(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
var calls atomic.Int32
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
calls.Add(1)
return data, nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
changed := []byte("TEST optional-package signer") // same length, different digest
if info, err := os.Stat(fx.binary); err != nil || info.Size() != int64(len(changed)) {
t.Fatalf("fixture length precondition failed: %v, %v", info, err)
}
if err := os.WriteFile(fx.binary, changed, 0700); err != nil {
t.Fatal(err)
}
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Fatalf("inspect calls after binary mutation = %d, want 2", calls.Load())
}
manifest, err := readProviderManifest()
if err != nil {
t.Fatal(err)
}
entry := manifest.Providers[ProviderID]
if entry.SHA256 == fx.binaryDigest {
t.Fatal("manifest retained the stale signer digest")
}
}
func TestRefresh_BypassesValidManifest(t *testing.T) {
useIsolatedProviderManifest(t)
oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, oldFx.stateDir)
var current = oldFx
var calls atomic.Int32
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
calls.Add(1)
return marshalInspectDocument(t, inspectPluginDocument(current, pluginID, current.packageDir)), nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
current = newFx
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("normal Resolve should retain valid binding; calls = %d", calls.Load())
}
if _, err := Refresh(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Fatalf("Refresh did not inspect; calls = %d", calls.Load())
}
manifest, err := readProviderManifest()
if err != nil {
t.Fatal(err)
}
if got := manifest.Providers[ProviderID].PackageDir; got != newFx.packageDir {
t.Fatalf("refreshed packageDir = %q, want %q", got, newFx.packageDir)
}
}
func TestPrepareRefresh_AbandonedCommitPreservesManifestBytes(t *testing.T) {
configDir := useIsolatedProviderManifest(t)
oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, oldFx.stateDir)
current := oldFx
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
return marshalInspectDocument(t, inspectPluginDocument(current, pluginID, current.packageDir)), nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(configDir, providerManifestFileName)
before, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
current = newFx
command, commit, err := PrepareRefresh(context.Background(), ProviderID)
if err != nil || command == nil || commit == nil {
t.Fatalf("PrepareRefresh = %v, commit %v, %v", command, commit != nil, err)
}
afterPrepare, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(afterPrepare, before) {
t.Fatal("PrepareRefresh changed the manifest before its commit callback ran")
}
const committers = 8
errs := make(chan error, committers)
var wg sync.WaitGroup
for i := 0; i < committers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
errs <- commit()
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
manifest, err := readProviderManifest()
if err != nil {
t.Fatal(err)
}
if got := manifest.Providers[ProviderID].PackageDir; got != newFx.packageDir {
t.Fatalf("committed packageDir = %q, want %q", got, newFx.packageDir)
}
}
func TestPrepareRefresh_CommitReturnsFirstErrorWithoutRetry(t *testing.T) {
configDir := useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
return marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir)), nil
})
_, commit, err := PrepareRefresh(context.Background(), ProviderID)
if err != nil || commit == nil {
t.Fatalf("PrepareRefresh commit %v, error %v", commit != nil, err)
}
if err := os.RemoveAll(configDir); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configDir, []byte("block manifest directory"), 0600); err != nil {
t.Fatal(err)
}
firstErr := commit()
if firstErr == nil {
t.Fatal("first commit unexpectedly succeeded")
}
if err := os.Remove(configDir); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(configDir, 0700); err != nil {
t.Fatal(err)
}
secondErr := commit()
if secondErr != firstErr {
t.Fatalf("second commit error = %v; want cached first error %v", secondErr, firstErr)
}
if _, err := os.Stat(filepath.Join(configDir, providerManifestFileName)); !os.IsNotExist(err) {
t.Fatalf("second commit retried manifest write: %v", err)
}
}
func TestPrepareRefresh_CommitRejectsSameStampBinaryMutation(t *testing.T) {
configDir := useIsolatedProviderManifest(t)
oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, oldFx.stateDir)
current := oldFx
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
return marshalInspectDocument(t, inspectPluginDocument(current, pluginID, current.packageDir)), nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(configDir, providerManifestFileName)
before, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
current = newFx
_, commit, err := PrepareRefresh(context.Background(), ProviderID)
if err != nil || commit == nil {
t.Fatalf("PrepareRefresh commit %v, error %v", commit != nil, err)
}
info, err := os.Stat(newFx.binary)
if err != nil {
t.Fatal(err)
}
changed := []byte("TEST optional-package signer")
if info.Size() != int64(len(changed)) {
t.Fatalf("fixture size = %d, mutated size = %d", info.Size(), len(changed))
}
if err := os.WriteFile(newFx.binary, changed, 0700); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(newFx.binary, info.ModTime(), info.ModTime()); err != nil {
t.Fatal(err)
}
mutatedInfo, err := os.Stat(newFx.binary)
if err != nil {
t.Fatal(err)
}
if mutatedInfo.Size() != info.Size() || !mutatedInfo.ModTime().Equal(info.ModTime()) {
t.Fatalf("mutation did not preserve size/mtime: before %d/%v, after %d/%v",
info.Size(), info.ModTime(), mutatedInfo.Size(), mutatedInfo.ModTime())
}
if err := commit(); err == nil {
t.Fatal("commit accepted a signer binary changed after PrepareRefresh")
}
after, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(after, before) {
t.Fatal("failed commit changed the previous manifest")
}
}
func TestRefresh_BypassesLiveInspectResultCache(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test fixture uses a POSIX shell executable")
}
useIsolatedProviderManifest(t)
oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, oldFx.stateDir)
t.Setenv(openClawConfigEnv, "")
inspectOutput := filepath.Join(oldFx.stateDir, "inspect-output.json")
writeInspectOutput := func(fx optionalPackageFixture) {
t.Helper()
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
if err := os.WriteFile(inspectOutput, data, 0600); err != nil {
t.Fatal(err)
}
}
writeInspectOutput(oldFx)
binDir := t.TempDir()
openClawPath := filepath.Join(binDir, "openclaw")
const script = "#!/bin/sh\n/bin/cat \"$OPENCLAW_STATE_DIR/inspect-output.json\"\n"
if err := os.WriteFile(openClawPath, []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir)
previousCached, previousFresh := runOpenClawInspect, runOpenClawInspectFresh
runOpenClawInspect, runOpenClawInspectFresh = executeOpenClawInspect, executeOpenClawInspectFresh
t.Cleanup(func() {
runOpenClawInspect, runOpenClawInspectFresh = previousCached, previousFresh
})
// This normal resolution populates the real process cache with oldFx. The
// cache key is unchanged when only inspect-output.json is replaced.
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
writeInspectOutput(newFx)
cachedData, err := executeOpenClawInspect(context.Background(), oldFx.stateDir)
if err != nil {
t.Fatal(err)
}
cachedPlugin, err := decodeInspectedPlugin(cachedData)
if err != nil {
t.Fatal(err)
}
if cachedPlugin.RootDir != oldFx.pluginDir {
t.Fatalf("cache precondition failed: rootDir = %q, want old %q", cachedPlugin.RootDir, oldFx.pluginDir)
}
if _, err := Refresh(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
manifest, err := readProviderManifest()
if err != nil {
t.Fatal(err)
}
if got := manifest.Providers[ProviderID].PackageDir; got != newFx.packageDir {
t.Fatalf("Refresh reused cached packageDir %q; want fresh %q", got, newFx.packageDir)
}
}
func TestResolve_CorruptManifestSelfHeals(t *testing.T) {
configDir := useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
if err := os.WriteFile(filepath.Join(configDir, providerManifestFileName), []byte(`{"version":`), 0600); err != nil {
t.Fatal(err)
}
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
var calls atomic.Int32
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
calls.Add(1)
return data, nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("inspect calls = %d, want 1", calls.Load())
}
if _, err := readProviderManifest(); err != nil {
t.Fatalf("manifest was not repaired: %v", err)
}
}
func TestResolve_UnsafeManifestIsIgnoredAndReplaced(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX mode and symlink test")
}
configDir := useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
var calls atomic.Int32
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
calls.Add(1)
return data, nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
path := filepath.Join(configDir, providerManifestFileName)
if err := os.Chmod(path, 0666); err != nil {
t.Fatal(err)
}
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Fatalf("unsafe mode did not trigger discovery; calls = %d", calls.Load())
}
if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("repaired manifest permissions = %v, %v", info, err)
}
target := filepath.Join(t.TempDir(), "must-not-overwrite.json")
const targetContents = "outside"
if err := os.WriteFile(target, []byte(targetContents), 0600); err != nil {
t.Fatal(err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, path); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 3 {
t.Fatalf("manifest symlink did not trigger discovery; calls = %d", calls.Load())
}
if info, err := os.Lstat(path); err != nil || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("manifest symlink was not safely replaced: %v, %v", info, err)
}
if contents, err := os.ReadFile(target); err != nil || string(contents) != targetContents {
t.Fatalf("symlink target changed: %q, %v", contents, err)
}
}
func TestResolve_InvalidManifestDoesNotFallBackToStaleCommand(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
stubInspectFunction(t, func(context.Context, string) ([]byte, error) { return data, nil })
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if err := os.Remove(fx.binary); err != nil {
t.Fatal(err)
}
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
return nil, errors.New("inspection unavailable")
})
if _, err := Resolve(context.Background(), ProviderID); err == nil {
t.Fatal("Resolve reused an invalid manifest entry")
}
}
func marshalInspectDocument(t *testing.T, document any) []byte {
t.Helper()
data, err := json.Marshal(document)
if err != nil {
t.Fatal(err)
}
return data
}
func stubInspectFunction(t *testing.T, fn func(context.Context, string) ([]byte, error)) {
t.Helper()
previousCached, previousFresh := runOpenClawInspect, runOpenClawInspectFresh
runOpenClawInspect, runOpenClawInspectFresh = fn, fn
t.Cleanup(func() {
runOpenClawInspect, runOpenClawInspectFresh = previousCached, previousFresh
})
}
func newManagedPackageFixtureInState(t *testing.T, stateDir, goos, goarch string, hoisted bool) optionalPackageFixture {
t.Helper()
spec, err := signerPackageFor(goos, goarch)
if err != nil {
t.Skipf("host platform has no optional package: %v", err)
}
projectDir := filepath.Join(stateDir, "npm", "projects", "larksuite-openclaw-lark-next-generation")
nodeModules := filepath.Join(projectDir, "node_modules")
pluginDir := filepath.Join(nodeModules, signerPackageScope, pluginPackageName[len(signerPackageScope)+1:])
packageNodeModules := filepath.Join(pluginDir, "node_modules")
if hoisted {
packageNodeModules = nodeModules
}
return writeOptionalPackageFixture(t, optionalPackageFixture{
stateDir: stateDir, projectDir: projectDir, pluginDir: pluginDir,
packageDir: signerPackageUnder(packageNodeModules, spec), spec: spec,
})
}

View File

@@ -0,0 +1,858 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package keylessprovider resolves the fixed keyless signer optional dependency
// installed with the OpenClaw Feishu plugin. Application config contains only
// the logical provider ID and keyRef; executable paths and argv are never read
// from application config, environment variables, or PATH.
package keylessprovider
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keylesshelper"
"github.com/larksuite/cli/internal/vfs"
"golang.org/x/sync/singleflight"
)
const (
ProviderID = core.KeylessProviderLarkSuite
openClawStateDirEnv = "OPENCLAW_STATE_DIR"
openClawHomeEnv = "OPENCLAW_HOME"
openClawConfigEnv = "OPENCLAW_CONFIG_PATH"
openClawDirName = ".openclaw"
pluginID = "openclaw-lark"
pluginPackageName = "@larksuite/openclaw-lark"
signerPackageScope = "@larksuite"
signerBinaryBase = "lark-keyless-signer"
metadataMaxBytes = 64 << 10
binaryMaxBytes = 512 << 20
inspectStdoutLimit = 4 << 20
inspectStderrLimit = 64 << 10
inspectCommandLimit = 30 * time.Second
inspectCacheTTL = 3 * time.Second
)
var semver = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
type platformPackage struct {
name string
npmOS string
npmCPU string
binaryName string
}
type packageManifest struct {
Name string `json:"name"`
Version string `json:"version"`
OS []string `json:"os"`
CPU []string `json:"cpu"`
}
type resolvedProvider struct {
stateDir string
pluginDir string
packageDir string
binaryPath string
packageVersion string
packageSize int64
packageModTimeNS int64
binarySize int64
binaryModTimeNS int64
digest string
}
type inspectDependency struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Optional bool `json:"optional"`
ResolvedPath string `json:"resolvedPath"`
}
type inspectedPlugin struct {
ID string `json:"id"`
Name string `json:"name"`
PackageName string `json:"packageName"`
RootDir string `json:"rootDir"`
Status string `json:"status"`
DependencyStatus struct {
OptionalDependencies []inspectDependency `json:"optionalDependencies"`
} `json:"dependencyStatus"`
}
type inspectUnavailableError struct{ cause error }
func (e *inspectUnavailableError) Error() string { return e.cause.Error() }
func (e *inspectUnavailableError) Unwrap() error { return e.cause }
var (
runOpenClawInspect = executeOpenClawInspect
runOpenClawInspectFresh = executeOpenClawInspectFresh
)
type inspectCacheEntry struct {
data []byte
expiresAt time.Time
}
// inspectResultCache caches only OpenClaw's discovery document. Callers still
// validate the complete directory tree, package metadata, binary mode, and
// binary digest after every lookup, and keylesshelper re-hashes before exec.
// The short TTL removes duplicate OpenClaw startups within one CLI process
// without persisting an executable path across processes or plugin upgrades.
type inspectResultCache struct {
mu sync.Mutex
entries map[string]inspectCacheEntry
flights singleflight.Group
}
var openClawInspectCache inspectResultCache
// Resolve returns a freshly verified command for one provider operation. A
// global manifest avoids restarting OpenClaw when the previously bound package
// is unchanged; the package tree and binary digest are still revalidated.
func Resolve(ctx context.Context, provider string) (*keylesshelper.Command, error) {
command, resolved, err := resolve(ctx, provider, false)
if err != nil {
return nil, err
}
if resolved != nil {
// The manifest is a rebuildable performance index, not an
// authentication source. Runtime resolution remains available if its
// best-effort cache write fails.
_ = saveProviderManifest(*resolved, runtime.GOOS, runtime.GOARCH)
}
return command, nil
}
// PrepareRefresh bypasses both the global manifest and the short-lived
// in-process OpenClaw inspection cache. It returns a freshly verified command
// and a deferred manifest commit. Callers that validate the signer before
// changing binding state must invoke commit only after that validation
// succeeds; abandoning the callback leaves the existing manifest unchanged.
func PrepareRefresh(ctx context.Context, provider string) (*keylesshelper.Command, func() error, error) {
command, resolved, err := resolve(ctx, provider, true)
if err != nil {
return nil, nil, err
}
if resolved == nil {
return command, func() error { return nil }, nil
}
candidate := *resolved
var once sync.Once
var commitErr error
commit := func() error {
once.Do(func() {
commitErr = revalidatePreparedProvider(candidate, runtime.GOOS, runtime.GOARCH)
if commitErr != nil {
return
}
commitErr = saveProviderManifest(candidate, runtime.GOOS, runtime.GOARCH)
})
return commitErr
}
return command, commit, nil
}
// revalidatePreparedProvider closes the gap between signer validation and a
// deferred manifest commit. It repeats the complete package validation and
// requires every captured property to remain identical before the candidate is
// allowed to replace the current manifest entry.
func revalidatePreparedProvider(candidate resolvedProvider, goos, goarch string) error {
spec, err := signerPackageFor(goos, goarch)
if err != nil {
return err
}
verified, err := resolvePackage(candidate.stateDir, candidate.pluginDir, candidate.packageDir, spec, goos)
if err != nil {
return fmt.Errorf("revalidate prepared keyless signer: %w", err)
}
if verified != candidate {
return fmt.Errorf("prepared keyless signer changed before manifest commit")
}
return nil
}
// Refresh bypasses the global manifest and both discovers and records the
// signer reported by the current OpenClaw installation. Transactional bind
// flows should use PrepareRefresh so a failed signer validation cannot replace
// a previously working manifest entry.
func Refresh(ctx context.Context, provider string) (*keylesshelper.Command, error) {
command, commit, err := PrepareRefresh(ctx, provider)
if err != nil {
return nil, err
}
if commit != nil {
if err := commit(); err != nil {
return nil, fmt.Errorf("persist refreshed keyless signer manifest: %w", err)
}
}
return command, nil
}
// resolve returns a non-nil resolvedProvider only for a newly discovered
// package. A manifest hit has already been persisted and therefore returns a
// nil resolvedProvider.
func resolve(ctx context.Context, provider string, forceRefresh bool) (*keylesshelper.Command, *resolvedProvider, error) {
if ctx == nil {
ctx = context.Background()
}
provider = strings.TrimSpace(provider)
if provider == "" {
return nil, nil, nil
}
if provider != ProviderID {
return nil, nil, fmt.Errorf("unknown keyless signer provider %q", provider)
}
stateDir, err := openClawStateDir()
if err != nil {
return nil, nil, fmt.Errorf("resolve OpenClaw state directory: %w", err)
}
if !forceRefresh {
resolved, manifestHit := resolveFromProviderManifest(stateDir, runtime.GOOS, runtime.GOARCH)
if manifestHit {
command, err := keylesshelper.NewProviderCommand(resolved.binaryPath, resolved.packageDir, "", resolved.digest)
return command, nil, err
}
}
inspectResolver := resolveFromInspect
if forceRefresh {
inspectResolver = resolveFromInspectFresh
}
resolved, inspectErr := inspectResolver(ctx, stateDir, runtime.GOOS, runtime.GOARCH)
if inspectErr != nil {
if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("%s provider inspection canceled: %w", ProviderID, err)
}
var unavailable *inspectUnavailableError
if !errors.As(inspectErr, &unavailable) {
return nil, nil, fmt.Errorf("%s provider is unavailable: %w", ProviderID, inspectErr)
}
// Older/local OpenClaw installations may not expose `plugins inspect`.
// The only fallback is the fixed extension-local package path; managed
// npm project directories are never scanned or guessed.
resolved, err = resolveFromStateDir(stateDir, runtime.GOOS, runtime.GOARCH)
if err != nil {
return nil, nil, fmt.Errorf("%s provider is unavailable: OpenClaw inspect failed (%w); fixed extension fallback failed: %w", ProviderID, inspectErr, err)
}
}
// An empty home override deliberately preserves the recipient user's normal
// HOME. The helper still supplies a minimal environment and never inherits
// PATH, proxy, language-runtime, or loader injection variables.
command, err := keylesshelper.NewProviderCommand(resolved.binaryPath, resolved.packageDir, "", resolved.digest)
if err != nil {
return nil, nil, err
}
return command, &resolved, nil
}
func resolveFromStateDir(stateDir, goos, goarch string) (resolvedProvider, error) {
spec, err := signerPackageFor(goos, goarch)
if err != nil {
return resolvedProvider{}, err
}
stateDir, err = cleanAbsolutePath(stateDir)
if err != nil {
return resolvedProvider{}, fmt.Errorf("invalid OpenClaw state directory: %w", err)
}
pluginDir := filepath.Join(stateDir, "extensions", pluginID)
packageDir := signerPackageUnder(filepath.Join(pluginDir, "node_modules"), spec)
return resolvePackage(stateDir, pluginDir, packageDir, spec, goos)
}
func resolveFromInspect(ctx context.Context, stateDir, goos, goarch string) (resolvedProvider, error) {
return resolveFromInspectUsing(ctx, stateDir, goos, goarch, runOpenClawInspect)
}
func resolveFromInspectFresh(ctx context.Context, stateDir, goos, goarch string) (resolvedProvider, error) {
return resolveFromInspectUsing(ctx, stateDir, goos, goarch, runOpenClawInspectFresh)
}
func resolveFromInspectUsing(
ctx context.Context,
stateDir, goos, goarch string,
inspect func(context.Context, string) ([]byte, error),
) (resolvedProvider, error) {
spec, err := signerPackageFor(goos, goarch)
if err != nil {
return resolvedProvider{}, err
}
stateDir, err = cleanAbsolutePath(stateDir)
if err != nil {
return resolvedProvider{}, fmt.Errorf("invalid OpenClaw state directory: %w", err)
}
data, err := inspect(ctx, stateDir)
if err != nil {
return resolvedProvider{}, err
}
plugin, err := decodeInspectedPlugin(data)
if err != nil {
return resolvedProvider{}, fmt.Errorf("parse OpenClaw plugin inspection: %w", err)
}
if plugin.ID != pluginID {
return resolvedProvider{}, fmt.Errorf("OpenClaw inspected unexpected plugin %q", plugin.ID)
}
if plugin.PackageName != "" && plugin.PackageName != pluginPackageName {
return resolvedProvider{}, fmt.Errorf("OpenClaw plugin package %q does not match %q", plugin.PackageName, pluginPackageName)
}
if plugin.Name != "" && plugin.Name != pluginPackageName && plugin.Name != "Feishu" {
return resolvedProvider{}, fmt.Errorf("OpenClaw plugin name %q is not recognized", plugin.Name)
}
if plugin.Status != "" && plugin.Status != "loaded" {
return resolvedProvider{}, fmt.Errorf("OpenClaw plugin is not loaded (status %q)", plugin.Status)
}
pluginDir, err := cleanAbsolutePath(plugin.RootDir)
if err != nil {
return resolvedProvider{}, fmt.Errorf("invalid inspected plugin root: %w", err)
}
if err := ensureWithin(stateDir, pluginDir); err != nil {
return resolvedProvider{}, fmt.Errorf("inspected plugin root is outside OpenClaw state: %w", err)
}
var dependency *inspectDependency
for i := range plugin.DependencyStatus.OptionalDependencies {
candidate := &plugin.DependencyStatus.OptionalDependencies[i]
if candidate.Name != spec.name {
continue
}
if dependency != nil {
return resolvedProvider{}, fmt.Errorf("OpenClaw inspection contains duplicate signer dependency %q", spec.name)
}
dependency = candidate
}
if dependency == nil || !dependency.Optional || !dependency.Installed || strings.TrimSpace(dependency.ResolvedPath) == "" {
return resolvedProvider{}, fmt.Errorf("OpenClaw plugin optional signer dependency %q is not installed", spec.name)
}
packageDir, err := cleanAbsolutePath(dependency.ResolvedPath)
if err != nil {
return resolvedProvider{}, fmt.Errorf("invalid inspected signer package path: %w", err)
}
if err := ensureWithin(stateDir, packageDir); err != nil {
return resolvedProvider{}, fmt.Errorf("inspected signer package is outside OpenClaw state: %w", err)
}
if !allowedPackageLocation(pluginDir, packageDir, spec, goos) {
return resolvedProvider{}, fmt.Errorf("inspected signer package is neither plugin-local nor managed-project-hoisted")
}
return resolvePackage(stateDir, pluginDir, packageDir, spec, goos)
}
func resolvePackage(stateDir, pluginDir, packageDir string, spec platformPackage, goos string) (resolvedProvider, error) {
packageJSON := filepath.Join(packageDir, "package.json")
binDir := filepath.Join(packageDir, "bin")
binaryPath := filepath.Join(binDir, spec.binaryName)
for _, dir := range []string{pluginDir, packageDir, binDir} {
if err := validateDirectoryTree(stateDir, dir); err != nil {
return resolvedProvider{}, err
}
}
for _, file := range []string{packageJSON, binaryPath} {
if err := ensureWithin(stateDir, file); err != nil {
return resolvedProvider{}, err
}
if err := validateProviderObject(file, false); err != nil {
return resolvedProvider{}, err
}
}
data, err := readMetadata(packageJSON)
if err != nil {
return resolvedProvider{}, fmt.Errorf("read signer package metadata: %w", err)
}
var manifest packageManifest
if err := decodeJSONObject(data, &manifest); err != nil {
return resolvedProvider{}, fmt.Errorf("parse signer package metadata: %w", err)
}
if manifest.Name != spec.name {
return resolvedProvider{}, fmt.Errorf("signer package name %q does not match %q", manifest.Name, spec.name)
}
if !semver.MatchString(manifest.Version) {
return resolvedProvider{}, fmt.Errorf("signer package version %q is not valid semver", manifest.Version)
}
if len(manifest.OS) != 1 || manifest.OS[0] != spec.npmOS {
return resolvedProvider{}, fmt.Errorf("signer package os metadata does not match %s", spec.npmOS)
}
if len(manifest.CPU) != 1 || manifest.CPU[0] != spec.npmCPU {
return resolvedProvider{}, fmt.Errorf("signer package cpu metadata does not match %s", spec.npmCPU)
}
packageInfo, err := vfs.Lstat(packageJSON)
if err != nil {
return resolvedProvider{}, fmt.Errorf("stat signer package metadata: %w", err)
}
info, err := vfs.Lstat(binaryPath)
if err != nil {
return resolvedProvider{}, fmt.Errorf("stat signer binary: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return resolvedProvider{}, fmt.Errorf("signer binary is not a regular file")
}
if goos != "windows" && info.Mode().Perm()&0o111 == 0 {
return resolvedProvider{}, fmt.Errorf("signer binary is not executable")
}
digest, err := hashRegularFile(binaryPath, binaryMaxBytes)
if err != nil {
return resolvedProvider{}, fmt.Errorf("hash signer binary: %w", err)
}
return resolvedProvider{
stateDir: stateDir,
pluginDir: pluginDir,
packageDir: packageDir,
binaryPath: binaryPath,
packageVersion: manifest.Version,
packageSize: packageInfo.Size(),
packageModTimeNS: packageInfo.ModTime().UnixNano(),
binarySize: info.Size(),
binaryModTimeNS: info.ModTime().UnixNano(),
digest: digest,
}, nil
}
func signerPackageFor(goos, goarch string) (platformPackage, error) {
key := goos + "/" + goarch
packages := map[string]platformPackage{
"darwin/arm64": {"@larksuite/lark-keyless-signer-darwin-arm64", "darwin", "arm64", signerBinaryBase},
"darwin/amd64": {"@larksuite/lark-keyless-signer-darwin-x64", "darwin", "x64", signerBinaryBase},
"linux/arm64": {"@larksuite/lark-keyless-signer-linux-arm64", "linux", "arm64", signerBinaryBase},
"linux/amd64": {"@larksuite/lark-keyless-signer-linux-x64", "linux", "x64", signerBinaryBase},
"windows/amd64": {"@larksuite/lark-keyless-signer-win32-x64", "win32", "x64", signerBinaryBase + ".exe"},
}
if spec, ok := packages[key]; ok {
return spec, nil
}
return platformPackage{}, fmt.Errorf("OpenClaw keyless signer optional package is unsupported on %s/%s", goos, goarch)
}
func signerPackageUnder(nodeModules string, spec platformPackage) string {
return filepath.Join(nodeModules, signerPackageScope, strings.TrimPrefix(spec.name, signerPackageScope+"/"))
}
func allowedPackageLocation(pluginDir, packageDir string, spec platformPackage, goos string) bool {
pluginLocal := signerPackageUnder(filepath.Join(pluginDir, "node_modules"), spec)
if samePath(pluginLocal, packageDir, goos) {
return true
}
// npm-pack managed projects may hoist the optional dependency beside the
// plugin package:
// <project>/node_modules/@larksuite/openclaw-lark
// <project>/node_modules/@larksuite/lark-keyless-signer-...
pluginNodeModules := filepath.Dir(filepath.Dir(pluginDir))
expectedPlugin := filepath.Join(pluginNodeModules, signerPackageScope, strings.TrimPrefix(pluginPackageName, signerPackageScope+"/"))
if filepath.Base(pluginNodeModules) != "node_modules" || !samePath(expectedPlugin, pluginDir, goos) {
return false
}
return samePath(signerPackageUnder(pluginNodeModules, spec), packageDir, goos)
}
func samePath(left, right, goos string) bool {
left, right = filepath.Clean(left), filepath.Clean(right)
if goos == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func decodeInspectedPlugin(data []byte) (inspectedPlugin, error) {
if len(data) == 0 || len(data) > inspectStdoutLimit {
return inspectedPlugin{}, fmt.Errorf("inspection output size is invalid")
}
var envelope struct {
Plugin json.RawMessage `json:"plugin"`
}
if err := decodeJSONObject(data, &envelope); err != nil {
return inspectedPlugin{}, err
}
var plugin inspectedPlugin
if len(envelope.Plugin) != 0 && string(envelope.Plugin) != "null" {
if err := decodeJSONObject(envelope.Plugin, &plugin); err != nil {
return inspectedPlugin{}, err
}
} else if err := decodeJSONObject(data, &plugin); err != nil {
return inspectedPlugin{}, err
}
return plugin, nil
}
func executeOpenClawInspect(ctx context.Context, stateDir string) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
}
executable, cacheKey, err := prepareOpenClawInspect(stateDir)
if err != nil {
return nil, err
}
return openClawInspectCache.load(ctx, cacheKey, func(loadCtx context.Context) ([]byte, error) {
return executeOpenClawInspectCommand(loadCtx, stateDir, executable)
})
}
// executeOpenClawInspectFresh deliberately skips inspectResultCache, including
// its in-flight singleflight results. Bind/repair discovery must observe the
// current plugin generation even if normal resolution inspected the same
// OpenClaw installation during the preceding few seconds.
func executeOpenClawInspectFresh(ctx context.Context, stateDir string) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
}
executable, _, err := prepareOpenClawInspect(stateDir)
if err != nil {
return nil, err
}
return executeOpenClawInspectCommand(ctx, stateDir, executable)
}
func prepareOpenClawInspect(stateDir string) (string, string, error) {
executable, err := exec.LookPath("openclaw")
if err != nil {
return "", "", &inspectUnavailableError{cause: fmt.Errorf("openclaw executable not found: %w", err)}
}
if !filepath.IsAbs(executable) {
cwd, cwdErr := vfs.Getwd()
if cwdErr != nil {
return "", "", fmt.Errorf("resolve current directory for openclaw executable: %w", cwdErr)
}
executable = filepath.Join(cwd, executable)
}
executable, err = vfs.EvalSymlinks(executable)
if err != nil {
return "", "", fmt.Errorf("resolve openclaw executable symlinks: %w", err)
}
executable, err = cleanAbsolutePath(executable)
if err != nil {
return "", "", fmt.Errorf("validate openclaw executable path: %w", err)
}
if err := validateInspectExecutable(executable); err != nil {
return "", "", fmt.Errorf("validate openclaw executable: %w", err)
}
info, err := vfs.Lstat(executable)
if err != nil || runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
return "", "", fmt.Errorf("openclaw executable is not executable")
}
cacheKey := strings.Join([]string{
stateDir,
executable,
fmt.Sprintf("%d", info.Size()),
fmt.Sprintf("%d", info.ModTime().UnixNano()),
strings.TrimSpace(os.Getenv(openClawConfigEnv)),
}, "\x00")
return executable, cacheKey, nil
}
func (c *inspectResultCache) load(ctx context.Context, key string, loader func(context.Context) ([]byte, error)) ([]byte, error) {
now := time.Now()
c.mu.Lock()
if entry, ok := c.entries[key]; ok && now.Before(entry.expiresAt) {
data := append([]byte(nil), entry.data...)
c.mu.Unlock()
return data, nil
}
c.mu.Unlock()
resultCh := c.flights.DoChan(key, func() (any, error) {
now := time.Now()
c.mu.Lock()
if entry, ok := c.entries[key]; ok && now.Before(entry.expiresAt) {
data := append([]byte(nil), entry.data...)
c.mu.Unlock()
return data, nil
}
c.mu.Unlock()
data, err := loader(ctx)
if err != nil {
return nil, err
}
data = append([]byte(nil), data...)
c.mu.Lock()
if c.entries == nil {
c.entries = make(map[string]inspectCacheEntry)
}
c.entries[key] = inspectCacheEntry{data: data, expiresAt: time.Now().Add(inspectCacheTTL)}
c.mu.Unlock()
return append([]byte(nil), data...), nil
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case result := <-resultCh:
if result.Err != nil {
return nil, result.Err
}
data, ok := result.Val.([]byte)
if !ok {
return nil, fmt.Errorf("OpenClaw inspect cache returned an invalid result")
}
return append([]byte(nil), data...), nil
}
}
func executeOpenClawInspectCommand(ctx context.Context, stateDir, executable string) ([]byte, error) {
inspectCtx, cancel := context.WithTimeout(ctx, inspectCommandLimit)
defer cancel()
cmd, err := newOpenClawInspectCommand(inspectCtx, executable, openClawInspectEnvironment(stateDir))
if err != nil {
return nil, &inspectUnavailableError{cause: fmt.Errorf("prepare openclaw plugin inspection: %w", err)}
}
stdout := &cappedBuffer{limit: inspectStdoutLimit}
stderr := &cappedBuffer{limit: inspectStderrLimit}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
if inspectCtx.Err() != nil {
return nil, &inspectUnavailableError{cause: fmt.Errorf("openclaw plugin inspection stopped: %w", inspectCtx.Err())}
}
return nil, &inspectUnavailableError{cause: fmt.Errorf("openclaw plugin inspection failed: %w (stderr omitted)", err)}
}
if stdout.exceeded {
return nil, fmt.Errorf("openclaw plugin inspection stdout exceeds %d bytes", inspectStdoutLimit)
}
if stderr.exceeded {
return nil, fmt.Errorf("openclaw plugin inspection stderr exceeds %d bytes", inspectStderrLimit)
}
return append([]byte(nil), stdout.Bytes()...), nil
}
func openClawInspectEnvironment(stateDir string) []string {
keep := map[string]bool{
"HOME": true, "PATH": true, "PATHEXT": true, "TMPDIR": true, "TEMP": true, "TMP": true,
"LANG": true, "LC_ALL": true, "USERPROFILE": true, "SYSTEMROOT": true, "WINDIR": true,
"COMSPEC": true, "APPDATA": true, "LOCALAPPDATA": true,
"OPENCLAW_HOME": true, openClawConfigEnv: true,
}
env := make([]string, 0, len(keep)+1)
for _, entry := range os.Environ() {
name := entry
if idx := strings.IndexByte(entry, '='); idx >= 0 {
name = entry[:idx]
}
if keep[strings.ToUpper(name)] {
env = append(env, entry)
}
}
return append(env, openClawStateDirEnv+"="+stateDir)
}
type cappedBuffer struct {
bytes.Buffer
limit int
exceeded bool
}
func (b *cappedBuffer) Write(data []byte) (int, error) {
originalLen := len(data)
remaining := b.limit - b.Len()
if remaining < len(data) {
b.exceeded = true
if remaining < 0 {
remaining = 0
}
data = data[:remaining]
}
_, _ = b.Buffer.Write(data)
return originalLen, nil
}
func validateDirectoryTree(root, target string) error {
root, err := cleanAbsolutePath(root)
if err != nil {
return err
}
target, err = cleanAbsolutePath(target)
if err != nil {
return err
}
if err := ensureWithin(root, target); err != nil {
return err
}
if err := validateProviderObject(root, true); err != nil {
return err
}
rel, err := filepath.Rel(root, target)
if err != nil {
return err
}
current := root
if rel == "." {
return nil
}
for _, part := range strings.Split(rel, string(filepath.Separator)) {
if part == "" || part == "." || part == ".." {
return fmt.Errorf("provider directory tree contains invalid component %q", part)
}
current = filepath.Join(current, part)
if err := validateProviderObject(current, true); err != nil {
return err
}
}
return nil
}
func openClawStateDir() (string, error) {
if stateDir := strings.TrimSpace(os.Getenv(openClawStateDirEnv)); stateDir != "" {
return resolveOpenClawPath(stateDir)
}
if configPath := strings.TrimSpace(os.Getenv(openClawConfigEnv)); configPath != "" {
resolved, err := resolveOpenClawPath(configPath)
if err != nil {
return "", fmt.Errorf("invalid %s: %w", openClawConfigEnv, err)
}
return cleanAbsolutePath(filepath.Dir(resolved))
}
home, err := openClawEffectiveHome()
if err != nil {
return "", err
}
return cleanAbsolutePath(filepath.Join(home, openClawDirName))
}
func resolveOpenClawPath(path string) (string, error) {
home, err := openClawEffectiveHome()
if err != nil {
return "", err
}
return expandWithHomeAndClean(path, home)
}
func openClawEffectiveHome() (string, error) {
osHome, err := vfs.UserHomeDir()
if err != nil || strings.TrimSpace(osHome) == "" {
return "", fmt.Errorf("resolve user home: %w", err)
}
osHome, err = cleanAbsolutePath(filepath.Clean(osHome))
if err != nil {
return "", fmt.Errorf("invalid user home: %w", err)
}
if configured := strings.TrimSpace(os.Getenv(openClawHomeEnv)); configured != "" {
home, err := expandWithHomeAndClean(configured, osHome)
if err != nil {
return "", fmt.Errorf("invalid %s: %w", openClawHomeEnv, err)
}
return home, nil
}
return osHome, nil
}
func expandWithHomeAndClean(path, home string) (string, error) {
if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
if path == "~" {
path = home
} else {
path = filepath.Join(home, path[2:])
}
} else if strings.HasPrefix(path, "~") {
return "", fmt.Errorf("named-user home expansion is not supported")
}
return cleanAbsolutePath(path)
}
func cleanAbsolutePath(path string) (string, error) {
if strings.IndexByte(path, 0) >= 0 {
return "", fmt.Errorf("path contains NUL")
}
if !filepath.IsAbs(path) {
return "", fmt.Errorf("path must be absolute")
}
clean := filepath.Clean(path)
if clean != path {
return "", fmt.Errorf("path must be clean (got %q)", path)
}
return clean, nil
}
func ensureWithin(root, path string) error {
rel, err := filepath.Rel(root, path)
if err != nil {
return fmt.Errorf("check signer path boundary: %w", err)
}
if rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("signer path escapes OpenClaw state directory")
}
return nil
}
func decodeJSONObject(data []byte, out any) error {
trimmed := strings.TrimSpace(string(data))
if !strings.HasPrefix(trimmed, "{") || !strings.HasSuffix(trimmed, "}") {
return fmt.Errorf("expected JSON object")
}
decoder := json.NewDecoder(strings.NewReader(trimmed))
if err := decoder.Decode(out); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return fmt.Errorf("multiple JSON values are not allowed")
}
return err
}
return nil
}
func readMetadata(path string) ([]byte, error) {
info, err := vfs.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() < 2 || info.Size() > metadataMaxBytes {
return nil, fmt.Errorf("metadata size/type is invalid")
}
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err
}
if len(data) > metadataMaxBytes {
return nil, fmt.Errorf("metadata exceeds %d bytes", metadataMaxBytes)
}
return data, nil
}
func hashRegularFile(path string, maxSize int64) (string, error) {
info, err := vfs.Lstat(path)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() <= 0 || info.Size() > maxSize {
return "", fmt.Errorf("file size/type is invalid")
}
f, err := vfs.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, io.LimitReader(f, maxSize+1))
if err != nil {
return "", err
}
if n != info.Size() || n > maxSize {
return "", fmt.Errorf("file changed while hashing")
}
return hex.EncodeToString(h.Sum(nil)), nil
}

View File

@@ -0,0 +1,537 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keylessprovider
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
)
type optionalPackageFixture struct {
stateDir, projectDir, pluginDir string
packageDir, packageJSON, binDir, binary string
spec platformPackage
binaryDigest string
}
func TestSignerPackageFor(t *testing.T) {
tests := []struct {
goos, goarch, name, npmOS, npmCPU, binary string
}{
{"darwin", "arm64", "@larksuite/lark-keyless-signer-darwin-arm64", "darwin", "arm64", signerBinaryBase},
{"darwin", "amd64", "@larksuite/lark-keyless-signer-darwin-x64", "darwin", "x64", signerBinaryBase},
{"linux", "arm64", "@larksuite/lark-keyless-signer-linux-arm64", "linux", "arm64", signerBinaryBase},
{"linux", "amd64", "@larksuite/lark-keyless-signer-linux-x64", "linux", "x64", signerBinaryBase},
{"windows", "amd64", "@larksuite/lark-keyless-signer-win32-x64", "win32", "x64", signerBinaryBase + ".exe"},
}
for _, test := range tests {
t.Run(test.goos+"_"+test.goarch, func(t *testing.T) {
got, err := signerPackageFor(test.goos, test.goarch)
if err != nil {
t.Fatal(err)
}
if got.name != test.name || got.npmOS != test.npmOS || got.npmCPU != test.npmCPU || got.binaryName != test.binary {
t.Fatalf("spec = %#v", got)
}
})
}
for _, unsupported := range [][2]string{{"windows", "arm64"}, {"linux", "riscv64"}, {"freebsd", "amd64"}} {
if _, err := signerPackageFor(unsupported[0], unsupported[1]); err == nil {
t.Fatalf("%s/%s unexpectedly supported", unsupported[0], unsupported[1])
}
}
}
func TestResolveFromStateDir_OptionalPackage(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
got, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH)
if err != nil {
t.Fatal(err)
}
if got.binaryPath != fx.binary || got.packageDir != fx.packageDir || got.digest != fx.binaryDigest {
t.Fatalf("resolved = %#v, fixture = %#v", got, fx)
}
}
func TestResolve_UsesOpenClawStateDirAndFixedExtensionFallback(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
t.Setenv(openClawStateDirEnv, fx.stateDir)
t.Setenv(openClawHomeEnv, filepath.Join(t.TempDir(), "must-not-win"))
t.Setenv("PATH", "") // force the fixed extensions fallback
command, err := Resolve(context.Background(), ProviderID)
if err != nil {
t.Fatal(err)
}
if command == nil {
t.Fatal("Resolve returned nil command")
}
}
func TestResolveFromInspect_ManagedProjectPluginLocal(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, false)
got, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, fx.packageDir))
if err != nil {
t.Fatal(err)
}
assertResolvedFixture(t, got, fx)
}
func TestResolveFromInspect_ManagedProjectHoisted(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
got, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, fx.packageDir))
if err != nil {
t.Fatal(err)
}
assertResolvedFixture(t, got, fx)
}
func TestResolveFromInspect_RejectsWrongPluginAndEscapingPaths(t *testing.T) {
t.Run("wrong plugin", func(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
_, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, "evil-plugin", fx.packageDir))
if err == nil || !strings.Contains(err.Error(), "unexpected plugin") {
t.Fatalf("error = %v", err)
}
})
t.Run("package outside state", func(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
outside := filepath.Join(t.TempDir(), "node_modules", signerPackageScope, strings.TrimPrefix(fx.spec.name, signerPackageScope+"/"))
_, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, outside))
if err == nil || !strings.Contains(err.Error(), "outside OpenClaw state") {
t.Fatalf("error = %v", err)
}
})
t.Run("unrelated package inside state", func(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
unrelated := signerPackageUnder(filepath.Join(fx.stateDir, "other", "node_modules"), fx.spec)
_, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, unrelated))
if err == nil || !strings.Contains(err.Error(), "neither plugin-local nor managed-project-hoisted") {
t.Fatalf("error = %v", err)
}
})
t.Run("plugin outside state", func(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
document := inspectPluginDocument(fx, pluginID, fx.packageDir)
plugin := document["plugin"].(map[string]any)
plugin["rootDir"] = filepath.Join(t.TempDir(), "openclaw-lark")
_, err := resolveFromInspectDocument(t, fx, document)
if err == nil || !strings.Contains(err.Error(), "outside OpenClaw state") {
t.Fatalf("error = %v", err)
}
})
}
func TestResolve_InspectUnavailableUsesOnlyFixedExtensionFallback(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
t.Setenv(openClawStateDirEnv, fx.stateDir)
stubOpenClawInspect(t, nil, &inspectUnavailableError{cause: errors.New("not installed")})
command, err := Resolve(context.Background(), ProviderID)
if err != nil || command == nil {
t.Fatalf("Resolve = %v, %v", command, err)
}
}
func TestResolve_InspectUnavailableDoesNotScanManagedProjects(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
stubOpenClawInspect(t, nil, &inspectUnavailableError{cause: errors.New("not installed")})
if _, err := Resolve(context.Background(), ProviderID); err == nil || !strings.Contains(err.Error(), "fixed extension fallback failed") {
t.Fatalf("error = %v", err)
}
}
func TestResolve_InvalidInspectDoesNotFallBack(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
t.Setenv(openClawStateDirEnv, fx.stateDir)
stubOpenClawInspect(t, []byte(`{"plugin":`), nil)
if _, err := Resolve(context.Background(), ProviderID); err == nil || !strings.Contains(err.Error(), "parse OpenClaw") {
t.Fatalf("error = %v", err)
}
}
func TestResolve_InspectCancellationHonorsCallerContext(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
t.Setenv(openClawStateDirEnv, fx.stateDir)
previous := runOpenClawInspect
runOpenClawInspect = func(ctx context.Context, _ string) ([]byte, error) {
<-ctx.Done()
return nil, &inspectUnavailableError{cause: ctx.Err()}
}
t.Cleanup(func() { runOpenClawInspect = previous })
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Resolve(ctx, ProviderID); !errors.Is(err, context.Canceled) {
t.Fatalf("Resolve error = %v, want context.Canceled", err)
}
}
func TestInspectResultCache_ShortLivedAndSingleflight(t *testing.T) {
var cache inspectResultCache
var calls atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
loader := func(context.Context) ([]byte, error) {
if calls.Add(1) == 1 {
close(started)
}
<-release
return []byte(`{"plugin":{"id":"openclaw-lark"}}`), nil
}
const callers = 8
results := make(chan []byte, callers)
errs := make(chan error, callers)
var wg sync.WaitGroup
for i := 0; i < callers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
data, err := cache.load(context.Background(), "same-install", loader)
results <- data
errs <- err
}()
}
<-started
close(release)
wg.Wait()
close(results)
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
for data := range results {
if string(data) != `{"plugin":{"id":"openclaw-lark"}}` {
t.Fatalf("cached data = %q", data)
}
if len(data) > 0 {
data[0] = 'x'
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("loader calls = %d, want 1", got)
}
data, err := cache.load(context.Background(), "same-install", loader)
if err != nil || string(data) != `{"plugin":{"id":"openclaw-lark"}}` || calls.Load() != 1 {
t.Fatalf("cache hit = %q, %v, calls %d", data, err, calls.Load())
}
}
func TestInspectResultCache_WaiterHonorsContext(t *testing.T) {
var cache inspectResultCache
started := make(chan struct{})
release := make(chan struct{})
loader := func(context.Context) ([]byte, error) {
close(started)
<-release
return []byte(`{}`), nil
}
go func() {
_, _ = cache.load(context.Background(), "busy", loader)
}()
<-started
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := cache.load(ctx, "busy", loader); !errors.Is(err, context.Canceled) {
t.Fatalf("cache waiter error = %v, want context.Canceled", err)
}
close(release)
}
func TestInspectOutputAndEnvironmentAreBounded(t *testing.T) {
buffer := &cappedBuffer{limit: 4}
if n, err := buffer.Write([]byte("123456")); err != nil || n != 6 || !buffer.exceeded || buffer.String() != "1234" {
t.Fatalf("bounded write = n %d exceeded %v data %q err %v", n, buffer.exceeded, buffer.String(), err)
}
t.Setenv("HOME", "/safe-home")
t.Setenv("NODE_OPTIONS", "--require=/must-not-load")
t.Setenv(openClawStateDirEnv, "/must-not-win")
env := strings.Join(openClawInspectEnvironment("/selected-state"), "\n")
if !strings.Contains(env, "HOME=/safe-home") || !strings.Contains(env, openClawStateDirEnv+"=/selected-state") ||
strings.Contains(env, "NODE_OPTIONS") || strings.Contains(env, openClawStateDirEnv+"=/must-not-win") {
t.Fatalf("inspect environment = %q", env)
}
}
func TestResolve_UnknownProviderFailsClosed(t *testing.T) {
useIsolatedProviderManifest(t)
if _, err := Resolve(context.Background(), "evil.provider"); err == nil || !strings.Contains(err.Error(), "unknown") {
t.Fatalf("error = %v", err)
}
}
func useIsolatedProviderManifest(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
return dir
}
func TestOpenClawStateDirPriorityAndExpansion(t *testing.T) {
home := t.TempDir()
state := filepath.Join(t.TempDir(), "custom state")
t.Setenv("HOME", home)
t.Setenv(openClawConfigEnv, "")
t.Setenv(openClawHomeEnv, filepath.Join(t.TempDir(), "must-not-win"))
t.Setenv(openClawStateDirEnv, state)
if got, err := openClawStateDir(); err != nil || got != state {
t.Fatalf("state dir = %q, %v", got, err)
}
t.Setenv(openClawStateDirEnv, "")
openClawHome := filepath.Join(t.TempDir(), "openclaw home")
t.Setenv(openClawHomeEnv, openClawHome)
if got, err := openClawStateDir(); err != nil || got != filepath.Join(openClawHome, openClawDirName) {
t.Fatalf("OPENCLAW_HOME state dir = %q, %v", got, err)
}
t.Setenv(openClawHomeEnv, "")
t.Setenv(openClawStateDirEnv, "~/custom-openclaw")
if got, err := openClawStateDir(); err != nil || got != filepath.Join(home, "custom-openclaw") {
t.Fatalf("expanded state dir = %q, %v", got, err)
}
}
func TestOpenClawStateDir_ConfigPathPrecedesHome(t *testing.T) {
osHome := t.TempDir()
openClawHome := filepath.Join(t.TempDir(), "openclaw-home")
t.Setenv("HOME", osHome)
t.Setenv(openClawStateDirEnv, "")
t.Setenv(openClawHomeEnv, openClawHome)
t.Setenv(openClawConfigEnv, "~/profiles/team/openclaw.json")
want := filepath.Join(openClawHome, "profiles", "team")
if got, err := openClawStateDir(); err != nil || got != want {
t.Fatalf("state dir = %q, %v; want %q", got, err, want)
}
explicitState := filepath.Join(t.TempDir(), "state-wins")
t.Setenv(openClawStateDirEnv, explicitState)
if got, err := openClawStateDir(); err != nil || got != explicitState {
t.Fatalf("explicit state dir = %q, %v; want %q", got, err, explicitState)
}
}
func TestOpenClawStateDirRejectsRelativeOrUncleanPath(t *testing.T) {
unclean := filepath.Join(t.TempDir(), "child") + string(filepath.Separator) + ".." + string(filepath.Separator) + "state"
for _, path := range []string{"relative/state", unclean} {
t.Run(strings.ReplaceAll(path, string(filepath.Separator), "_"), func(t *testing.T) {
t.Setenv(openClawStateDirEnv, path)
if _, err := openClawStateDir(); err == nil {
t.Fatalf("path %q was accepted", path)
}
})
}
}
func TestResolveFromStateDir_ValidatesPackageMetadata(t *testing.T) {
tests := []struct {
name string
mutate func(*packageManifest)
want string
}{
{"name", func(m *packageManifest) { m.Name = "@evil/signer" }, "name"},
{"version", func(m *packageManifest) { m.Version = "latest" }, "version"},
{"os", func(m *packageManifest) { m.OS = []string{"other"} }, "os metadata"},
{"cpu", func(m *packageManifest) { m.CPU = []string{"other"} }, "cpu metadata"},
{"multiple os", func(m *packageManifest) { m.OS = append(m.OS, "other") }, "os metadata"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
manifest := validManifest(fx.spec)
test.mutate(&manifest)
writeJSON(t, fx.packageJSON, manifest, 0600)
if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v", err)
}
})
}
}
func TestResolveFromStateDir_RejectsParentAndBinarySymlinks(t *testing.T) {
t.Run("parent", func(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
realBin := fx.binDir + "-real"
if err := os.Rename(fx.binDir, realBin); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realBin, fx.binDir); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Fatalf("error = %v", err)
}
})
t.Run("binary", func(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
realBinary := fx.binary + "-real"
if err := os.Rename(fx.binary, realBinary); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realBinary, fx.binary); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Fatalf("error = %v", err)
}
})
}
func TestResolveFromStateDir_RejectsInsecureModeAndNonExecutable(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX mode test")
}
t.Run("writable parent", func(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
if err := os.Chmod(fx.packageDir, 0777); err != nil {
t.Fatal(err)
}
if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "writable") {
t.Fatalf("error = %v", err)
}
})
t.Run("non executable", func(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
if err := os.Chmod(fx.binary, 0600); err != nil {
t.Fatal(err)
}
if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "executable") {
t.Fatalf("error = %v", err)
}
})
}
func TestResolveFromStateDir_MissingPackageFailsClosed(t *testing.T) {
stateDir := t.TempDir()
if _, err := resolveFromStateDir(stateDir, runtime.GOOS, runtime.GOARCH); err == nil {
t.Fatal("missing optional package was accepted")
}
}
func newOptionalPackageFixture(t *testing.T, goos, goarch string) optionalPackageFixture {
t.Helper()
spec, err := signerPackageFor(goos, goarch)
if err != nil {
t.Skipf("host platform has no optional package: %v", err)
}
stateDir := filepath.Join(t.TempDir(), "openclaw state")
pluginDir := filepath.Join(stateDir, "extensions", pluginID)
packageDir := signerPackageUnder(filepath.Join(pluginDir, "node_modules"), spec)
return writeOptionalPackageFixture(t, optionalPackageFixture{
stateDir: stateDir, pluginDir: pluginDir, packageDir: packageDir, spec: spec,
})
}
func newManagedPackageFixture(t *testing.T, goos, goarch string, hoisted bool) optionalPackageFixture {
t.Helper()
spec, err := signerPackageFor(goos, goarch)
if err != nil {
t.Skipf("host platform has no optional package: %v", err)
}
stateDir := filepath.Join(t.TempDir(), "custom OpenClaw state")
projectDir := filepath.Join(stateDir, "npm", "projects", "larksuite-openclaw-lark-test-generation")
nodeModules := filepath.Join(projectDir, "node_modules")
pluginDir := filepath.Join(nodeModules, signerPackageScope, strings.TrimPrefix(pluginPackageName, signerPackageScope+"/"))
packageNodeModules := filepath.Join(pluginDir, "node_modules")
if hoisted {
packageNodeModules = nodeModules
}
packageDir := signerPackageUnder(packageNodeModules, spec)
return writeOptionalPackageFixture(t, optionalPackageFixture{
stateDir: stateDir, projectDir: projectDir, pluginDir: pluginDir, packageDir: packageDir, spec: spec,
})
}
func writeOptionalPackageFixture(t *testing.T, fx optionalPackageFixture) optionalPackageFixture {
t.Helper()
if err := os.MkdirAll(fx.pluginDir, 0700); err != nil {
t.Fatal(err)
}
packageDir := fx.packageDir
binDir := filepath.Join(packageDir, "bin")
if err := os.MkdirAll(binDir, 0700); err != nil {
t.Fatal(err)
}
packageJSON := filepath.Join(packageDir, "package.json")
writeJSON(t, packageJSON, validManifest(fx.spec), 0600)
binary := filepath.Join(binDir, fx.spec.binaryName)
binaryBytes := []byte("test optional-package signer")
if err := os.WriteFile(binary, binaryBytes, 0700); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(binaryBytes)
fx.packageJSON, fx.binDir, fx.binary = packageJSON, binDir, binary
fx.binaryDigest = hex.EncodeToString(digest[:])
return fx
}
func inspectPluginDocument(fx optionalPackageFixture, inspectedID, resolvedPath string) map[string]any {
return map[string]any{"plugin": map[string]any{
"id": inspectedID, "name": pluginPackageName, "packageName": pluginPackageName,
"rootDir": fx.pluginDir, "status": "loaded",
"dependencyStatus": map[string]any{"optionalDependencies": []map[string]any{{
"name": fx.spec.name, "installed": true, "optional": true, "resolvedPath": resolvedPath,
}}},
}}
}
func resolveFromInspectDocument(t *testing.T, fx optionalPackageFixture, document any) (resolvedProvider, error) {
t.Helper()
data, err := json.Marshal(document)
if err != nil {
t.Fatal(err)
}
stubOpenClawInspect(t, data, nil)
return resolveFromInspect(context.Background(), fx.stateDir, runtime.GOOS, runtime.GOARCH)
}
func stubOpenClawInspect(t *testing.T, data []byte, err error) {
t.Helper()
previous := runOpenClawInspect
runOpenClawInspect = func(context.Context, string) ([]byte, error) { return data, err }
t.Cleanup(func() { runOpenClawInspect = previous })
}
func assertResolvedFixture(t *testing.T, got resolvedProvider, fx optionalPackageFixture) {
t.Helper()
if got.binaryPath != fx.binary || got.packageDir != fx.packageDir || got.digest != fx.binaryDigest {
t.Fatalf("resolved = %#v, fixture = %#v", got, fx)
}
}
func validManifest(spec platformPackage) packageManifest {
return packageManifest{Name: spec.name, Version: "1.2.3", OS: []string{spec.npmOS}, CPU: []string{spec.npmCPU}}
}
func writeJSON(t *testing.T, path string, value any, mode os.FileMode) {
t.Helper()
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatal(err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, mode); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build darwin || linux
package keylessprovider
import (
"fmt"
"os"
"syscall"
"github.com/larksuite/cli/internal/vfs"
)
func validateProviderObject(path string, wantDir bool) error {
return validateOwnedObject(path, wantDir)
}
func validateInspectExecutable(path string) error {
info, err := vfs.Lstat(path)
if err != nil {
return fmt.Errorf("inspect OpenClaw executable: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("OpenClaw executable must be a regular non-symlink file")
}
if info.Mode().Perm()&0o022 != 0 {
return fmt.Errorf("OpenClaw executable is group/world writable")
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || int(stat.Uid) != os.Getuid() && stat.Uid != 0 {
return fmt.Errorf("OpenClaw executable is not owned by the current user or root")
}
if stat.Nlink != 1 {
return fmt.Errorf("OpenClaw executable must have exactly one hard link")
}
return nil
}
func validateOwnedObject(path string, wantDir bool) error {
info, err := vfs.Lstat(path)
if err != nil {
return fmt.Errorf("inspect provider object %s: %w", path, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("provider object must not be a symlink: %s", path)
}
if wantDir != info.IsDir() || (!wantDir && !info.Mode().IsRegular()) {
return fmt.Errorf("provider object has unexpected type: %s", path)
}
if info.Mode().Perm()&0o022 != 0 {
return fmt.Errorf("provider object is group/world writable: %s (mode %o)", path, info.Mode().Perm())
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || int(stat.Uid) != os.Getuid() {
return fmt.Errorf("provider object is not owned by the current user: %s", path)
}
if !wantDir && stat.Nlink != 1 {
return fmt.Errorf("provider file must have exactly one hard link: %s", path)
}
return nil
}

View File

@@ -0,0 +1,149 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package keylessprovider
import (
"fmt"
"os"
"unsafe"
"github.com/larksuite/cli/internal/vfs"
"golang.org/x/sys/windows"
)
func validateProviderObject(path string, wantDir bool) error {
info, err := vfs.Lstat(path)
if err != nil {
return fmt.Errorf("inspect provider object %s: %w", path, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("provider object must not be a symlink: %s", path)
}
if wantDir != info.IsDir() || (!wantDir && !info.Mode().IsRegular()) {
return fmt.Errorf("provider object has unexpected type: %s", path)
}
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("encode provider object path: %w", err)
}
attrs, err := windows.GetFileAttributes(path16)
if err != nil {
return fmt.Errorf("inspect provider object attributes: %w", err)
}
if attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("provider object must not be a reparse point: %s", path)
}
sd, err := windows.GetNamedSecurityInfo(
path,
windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
)
if err != nil {
return fmt.Errorf("inspect provider object security descriptor: %w", err)
}
owner, _, err := sd.Owner()
if err != nil || owner == nil {
return fmt.Errorf("inspect provider object owner: %w", err)
}
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
return fmt.Errorf("inspect current Windows user: %w", err)
}
if !owner.Equals(user.User.Sid) {
return fmt.Errorf("provider object is not owned by the current user: %s", path)
}
if err := validateWindowsDACL(sd, user.User.Sid); err != nil {
return fmt.Errorf("provider object has unsafe permissions: %s: %w", path, err)
}
return nil
}
func validateInspectExecutable(path string) error {
info, err := vfs.Lstat(path)
if err != nil {
return fmt.Errorf("inspect OpenClaw executable: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("OpenClaw executable must be a regular non-symlink file")
}
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
attrs, err := windows.GetFileAttributes(path16)
if err != nil || attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("OpenClaw executable must not be a reparse point")
}
sd, err := windows.GetNamedSecurityInfo(
path,
windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
)
if err != nil {
return err
}
owner, _, err := sd.Owner()
if err != nil || owner == nil {
return fmt.Errorf("inspect OpenClaw executable owner: %w", err)
}
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
return err
}
if !owner.Equals(user.User.Sid) &&
!owner.IsWellKnown(windows.WinLocalSystemSid) &&
!owner.IsWellKnown(windows.WinBuiltinAdministratorsSid) {
return fmt.Errorf("OpenClaw executable has an untrusted owner")
}
if err := validateWindowsDACL(sd, user.User.Sid); err != nil {
return fmt.Errorf("OpenClaw executable has unsafe permissions: %w", err)
}
return nil
}
func validateWindowsDACL(sd *windows.SECURITY_DESCRIPTOR, currentUser *windows.SID) error {
dacl, _, err := sd.DACL()
if err != nil || dacl == nil {
return fmt.Errorf("missing or unreadable DACL")
}
const (
fileDeleteChild windows.ACCESS_MASK = 0x00000040
writeMask = windows.GENERIC_ALL | windows.GENERIC_WRITE |
windows.WRITE_DAC | windows.WRITE_OWNER | windows.DELETE |
windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA |
windows.FILE_WRITE_EA | windows.FILE_WRITE_ATTRIBUTES | fileDeleteChild
)
for i := uint16(0); i < dacl.AceCount; i++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, uint32(i), &ace); err != nil {
return fmt.Errorf("read ACL entry %d: %w", i, err)
}
if ace == nil || ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE {
continue
}
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
return fmt.Errorf("unsupported allow ACL entry type %d", ace.Header.AceType)
}
if ace.Mask&writeMask == 0 {
continue
}
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
if sid == nil || !sid.IsValid() {
return fmt.Errorf("ACL entry %d has invalid SID", i)
}
if sid.Equals(currentUser) ||
sid.IsWellKnown(windows.WinLocalSystemSid) ||
sid.IsWellKnown(windows.WinBuiltinAdministratorsSid) ||
sid.IsWellKnown(windows.WinCreatorOwnerSid) ||
sid.IsWellKnown(windows.WinCreatorOwnerRightsSid) {
continue
}
return fmt.Errorf("write access is granted to SID %s", sid.String())
}
return nil
}

View File

@@ -0,0 +1,210 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package keysigner defines the pluggable signing abstraction used by the
// private_key_jwt registration and authentication flow.
//
// Platform implementations hold non-exportable private keys in TPM 2.0 on
// supported Linux/Windows targets and in Keychain on macOS. Build constraints
// select the implementation for each target, and each backend registers itself
// through Register from init().
package keysigner
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"errors"
"fmt"
"math/big"
"strings"
)
// KeyRef identifies a non-exportable signing key held by a backend
// (TEE/TPM/Keychain). It is a stable handle (label), never the key material.
type KeyRef struct {
// Label is the backend key label/tag (e.g. "larksuite-cli-agent").
Label string
}
// Signer signs JWS signing inputs with a non-exportable key.
type Signer interface {
// EnsureKey returns the public key for ref, creating the key if absent.
EnsureKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error)
// PublicKey returns the public key for ref without creating it.
PublicKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error)
// Sign signs signingInput and returns a JOSE-format signature plus the JWS
// alg ("ES256"/"RS256"). Implementations apply the alg's hash and, for
// ECDSA, MUST return the fixed-width r||s form required by RFC 7518 §3.4
// (not ASN.1 DER), because the backend (TPM/Keychain) typically yields DER.
Sign(ctx context.Context, ref KeyRef, signingInput []byte) (sig []byte, alg string, err error)
}
// Supported JWS algorithms.
const (
AlgES256 = "ES256"
AlgRS256 = "RS256"
)
// DefaultKeyLabel is the backend key label lark-cli uses for its device signing
// key. One non-exportable key is created on first private_key_jwt registration
// and reused across subsequent app registrations on the same device.
const DefaultKeyLabel = "larksuite-cli-agent"
// HardwareInfo describes the secure hardware backing a Signer, as reported by a
// HardwareProber. It is advisory/diagnostic: it tells a user whether
// private_key_jwt can use a real TEE on this device.
type HardwareInfo struct {
Backend string // backing technology, e.g. "tpm2" or "keychain"
Available bool // the hardware is present and usable for signing
VendorName string // hardware vendor/manufacturer, when known
VendorInfo string // additional vendor detail, when known
Reason string // when Available is false, a human-readable cause
}
// HardwareProber is an optional capability a Signer may implement to report on
// the secure hardware backing it (TPM/TEE vendor and availability) WITHOUT
// creating or using a key. Probing never mutates key state.
type HardwareProber interface {
ProbeHardware(ctx context.Context) (HardwareInfo, error)
}
// ProbeActiveHardware probes the active signer's secure hardware. ok is false
// when there is no active signer or it does not implement HardwareProber — in
// which case private_key_jwt is unsupported on this build. When ok is true, info
// reports availability and, if unavailable, info.Reason explains why.
func ProbeActiveHardware(ctx context.Context) (info HardwareInfo, ok bool, err error) {
return probeHardware(ctx, Active())
}
// probeHardware is the registry-independent core of ProbeActiveHardware, so it
// can be unit-tested without touching the global signer.
func probeHardware(ctx context.Context, s Signer) (HardwareInfo, bool, error) {
p, ok := s.(HardwareProber)
if !ok {
return HardwareInfo{}, false, nil
}
info, err := p.ProbeHardware(ctx)
return info, true, err
}
// cleanProbeError renders err's message with redundant re-wraps collapsed. Some
// backends (e.g. facebookincubator/sks) wrap an error twice with the SAME "%w"
// prefix, yielding "P: P: cause"; this peels each outer layer whose only
// contribution is to repeat the prefix already present in the wrapped error,
// leaving a single "P: cause". A layer that adds genuinely new context is kept.
func cleanProbeError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
for {
inner := errors.Unwrap(err)
if inner == nil {
break
}
innerMsg := inner.Error()
prefix, ok := strings.CutSuffix(msg, innerMsg)
if !ok || prefix == "" || !strings.HasPrefix(innerMsg, prefix) {
break
}
msg, err = innerMsg, inner
}
return msg
}
// AlgForKey returns the JWS alg for a public key: EC P-256 -> ES256, RSA -> RS256.
// The signer backend chooses the key type (the macOS keychain signer uses an
// RSA-2048 key, hence RS256).
func AlgForKey(pub crypto.PublicKey) (string, error) {
switch k := pub.(type) {
case *ecdsa.PublicKey:
if k.Curve == elliptic.P256() {
return AlgES256, nil
}
return "", fmt.Errorf("keysigner: unsupported EC curve %q (only P-256/ES256)", k.Curve.Params().Name)
case *rsa.PublicKey:
return AlgRS256, nil
default:
return "", fmt.Errorf("keysigner: unsupported public key type %T", pub)
}
}
// ecdsaDERToJOSE converts an ASN.1 DER-encoded ECDSA signature — the form most
// TEE/TPM backends emit (e.g. facebookincubator/sks marshals the TPM's r,s with
// asn1.Marshal) — into the fixed-width r||s form JWS requires for ES256
// (RFC 7518 §3.4). byteLen is the curve coordinate size (32 for P-256), so the
// result is exactly 2*byteLen bytes with r and s each left-zero-padded.
//
// This is intentionally part of the pure-stdlib core (not a platform signer) so
// it can be unit-tested with a software key on any machine, including TPM-less CI.
func ecdsaDERToJOSE(der []byte, byteLen int) ([]byte, error) {
var sig struct{ R, S *big.Int }
rest, err := asn1.Unmarshal(der, &sig)
if err != nil {
return nil, fmt.Errorf("keysigner: parse ECDSA DER signature: %w", err)
}
if len(rest) != 0 {
return nil, fmt.Errorf("keysigner: %d trailing byte(s) after ECDSA DER signature", len(rest))
}
if sig.R == nil || sig.S == nil || sig.R.Sign() <= 0 || sig.S.Sign() <= 0 {
return nil, fmt.Errorf("keysigner: ECDSA signature has non-positive r/s")
}
// Guard before FillBytes, which panics if the scalar does not fit in byteLen.
if sig.R.BitLen() > byteLen*8 || sig.S.BitLen() > byteLen*8 {
return nil, fmt.Errorf("keysigner: ECDSA r/s exceeds %d-byte coordinate", byteLen)
}
out := make([]byte, 2*byteLen)
sig.R.FillBytes(out[:byteLen])
sig.S.FillBytes(out[byteLen:])
return out, nil
}
// EncodePublicKey marshals pub to PKIX DER and base64-encodes it (std encoding),
// matching the public-key form the registration backend binds to the app.
func EncodePublicKey(pub crypto.PublicKey) (string, error) {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", fmt.Errorf("keysigner: encode public key: %w", err)
}
return base64.StdEncoding.EncodeToString(der), nil
}
// PublicKeyJWK returns the RFC 7517 JSON Web Key for pub, used to embed the
// public key in the attestation JWT's "jwk" header so the registration backend
// can bind it to the app. EC keys use base64url fixed-width coordinates
// (RFC 7518 §6.2.1); RSA keys use base64url-encoded modulus and exponent.
func PublicKeyJWK(pub crypto.PublicKey) (map[string]any, error) {
switch k := pub.(type) {
case *ecdsa.PublicKey:
if k.Curve != elliptic.P256() {
return nil, fmt.Errorf("keysigner: JWK supports EC P-256 only, got %q", k.Curve.Params().Name)
}
const coordLen = 32 // P-256 field element size
x := make([]byte, coordLen)
y := make([]byte, coordLen)
k.X.FillBytes(x)
k.Y.FillBytes(y)
return map[string]any{
"use": "sig",
"kty": "EC",
"crv": "P-256",
"x": base64.RawURLEncoding.EncodeToString(x),
"y": base64.RawURLEncoding.EncodeToString(y),
}, nil
case *rsa.PublicKey:
return map[string]any{
"use": "sig",
"kty": "RSA",
"n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()),
}, nil
default:
return nil, fmt.Errorf("keysigner: unsupported public key type %T for JWK", pub)
}
}

View File

@@ -0,0 +1,240 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"math/big"
"reflect"
"testing"
)
func TestAlgForKey(t *testing.T) {
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if alg, err := AlgForKey(ec.Public()); err != nil || alg != AlgES256 {
t.Errorf("P-256: alg=%q err=%v, want ES256/nil", alg, err)
}
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
if alg, err := AlgForKey(rsaKey.Public()); err != nil || alg != AlgRS256 {
t.Errorf("RSA: alg=%q err=%v, want RS256/nil", alg, err)
}
ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if _, err := AlgForKey(ec384.Public()); err == nil {
t.Error("P-384: expected unsupported-curve error")
}
if _, err := AlgForKey("not a key"); err == nil {
t.Error("string: expected unsupported-type error")
}
}
func TestEncodePublicKeyRoundTrip(t *testing.T) {
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
enc, err := EncodePublicKey(ec.Public())
if err != nil {
t.Fatal(err)
}
der, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
t.Fatalf("not valid base64: %v", err)
}
pub, err := x509.ParsePKIXPublicKey(der)
if err != nil {
t.Fatalf("not valid PKIX: %v", err)
}
if !reflect.DeepEqual(pub, ec.Public()) {
t.Error("public key did not round-trip")
}
}
func TestPublicKeyJWK_EC(t *testing.T) {
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
jwk, err := PublicKeyJWK(ec.Public())
if err != nil {
t.Fatal(err)
}
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" {
t.Errorf("jwk = %v, want kty=EC crv=P-256", jwk)
}
if jwk["use"] != "sig" {
t.Errorf("jwk use = %v, want sig", jwk["use"])
}
x, _ := jwk["x"].(string)
xb, err := base64.RawURLEncoding.DecodeString(x)
if err != nil || len(xb) != 32 {
t.Errorf("x = %q (decoded %d bytes), want 32-byte base64url", x, len(xb))
}
if _, ok := jwk["y"].(string); !ok {
t.Error("jwk missing y")
}
}
func TestPublicKeyJWK_RSA(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
jwk, err := PublicKeyJWK(rsaKey.Public())
if err != nil {
t.Fatal(err)
}
if jwk["kty"] != "RSA" || jwk["n"] == "" || jwk["e"] == "" {
t.Errorf("jwk = %v, want kty=RSA with n,e", jwk)
}
if jwk["use"] != "sig" {
t.Errorf("jwk use = %v, want sig", jwk["use"])
}
}
func TestPublicKeyJWK_UnsupportedCurve(t *testing.T) {
ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if _, err := PublicKeyJWK(ec384.Public()); err == nil {
t.Error("P-384: expected error")
}
}
func TestECDSADERToJOSE(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
// Iterate so we hit signatures whose r or s has its high bit set (ASN.1 pads
// those with a leading 0x00) and whose scalars are short (need left-zero
// padding) — verifying fixed-width conversion in both directions.
for i := 0; i < 64; i++ {
digest := sha256.Sum256([]byte{byte(i), byte(i >> 8), 'j', 'w', 't'})
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
t.Fatal(err)
}
jose, err := ecdsaDERToJOSE(der, 32)
if err != nil {
t.Fatalf("iter %d: %v", i, err)
}
if len(jose) != 64 {
t.Fatalf("iter %d: len(jose)=%d, want 64 (fixed-width r||s)", i, len(jose))
}
r := new(big.Int).SetBytes(jose[:32])
s := new(big.Int).SetBytes(jose[32:])
if !ecdsa.Verify(&key.PublicKey, digest[:], r, s) {
t.Fatalf("iter %d: converted r||s did not verify against the public key", i)
}
}
}
func TestECDSADERToJOSE_Errors(t *testing.T) {
if _, err := ecdsaDERToJOSE([]byte{0x01, 0x02, 0x03}, 32); err == nil {
t.Error("garbage DER: expected error")
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte("trailing"))
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
t.Fatal(err)
}
if _, err := ecdsaDERToJOSE(append(der, 0x00), 32); err == nil {
t.Error("DER with trailing byte: expected error")
}
}
type stubSigner struct{}
func (stubSigner) EnsureKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil }
func (stubSigner) PublicKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil }
func (stubSigner) Sign(context.Context, KeyRef, []byte) ([]byte, string, error) { return nil, "", nil }
func TestCleanProbeError(t *testing.T) {
cause := errors.New("open /dev/tpmrm0: permission denied")
const p = "sks: error fetching Secure Hardware Vendor Data: "
// sks double-wraps with the same %w prefix → collapse to a single prefix.
doubled := fmt.Errorf(p+"%w", fmt.Errorf(p+"%w", cause))
if got, want := cleanProbeError(doubled), p+cause.Error(); got != want {
t.Errorf("doubled: got %q, want %q", got, want)
}
// Triple wrap collapses too.
if got, want := cleanProbeError(fmt.Errorf(p+"%w", doubled)), p+cause.Error(); got != want {
t.Errorf("tripled: got %q, want %q", got, want)
}
// A layer adding genuinely new context is preserved.
if got, want := cleanProbeError(fmt.Errorf("load: %w", cause)), "load: "+cause.Error(); got != want {
t.Errorf("distinct prefix: got %q, want %q", got, want)
}
// nil and unwrapped-leaf cases.
if got := cleanProbeError(nil); got != "" {
t.Errorf("nil: got %q, want empty", got)
}
if got := cleanProbeError(cause); got != cause.Error() {
t.Errorf("leaf: got %q, want %q", got, cause.Error())
}
}
type proberSigner struct {
stubSigner
info HardwareInfo
}
func (p proberSigner) ProbeHardware(context.Context) (HardwareInfo, error) { return p.info, nil }
func TestProbeHardware(t *testing.T) {
// nil signer and a signer that does not implement HardwareProber both yield ok=false.
if _, ok, _ := probeHardware(context.Background(), nil); ok {
t.Error("nil signer: ok should be false")
}
if _, ok, _ := probeHardware(context.Background(), stubSigner{}); ok {
t.Error("non-prober signer: ok should be false")
}
want := HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
info, ok, err := probeHardware(context.Background(), proberSigner{info: want})
if err != nil || !ok {
t.Fatalf("prober: ok=%v err=%v, want true/nil", ok, err)
}
if info != want {
t.Errorf("info = %+v, want %+v", info, want)
}
}
func TestRegistry(t *testing.T) {
if Active() != nil {
t.Skip("a signer is already registered in this build")
}
Register(stubSigner{})
if _, ok := Active().(stubSigner); !ok {
t.Error("Active did not return the registered signer")
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import "sync"
var (
mu sync.RWMutex
active Signer
)
// Register sets the active Signer. It is typically called from the init() of a
// build-tagged or extension package that provides the platform TEE/Keychain
// implementation. The last registration wins (one backend per platform).
func Register(s Signer) {
mu.Lock()
defer mu.Unlock()
active = s
}
// Active returns the registered Signer, or nil if none is available — in which
// case private_key_jwt is unsupported on this build and only client_secret auth
// can be used.
func Active() Signer {
mu.RLock()
defer mu.RUnlock()
return active
}

View File

@@ -0,0 +1,760 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// macOS non-exportable Keychain signer (compiled into every darwin build).
//
// It does NOT use the Secure Enclave / hardware TEE (which would require
// code-signing entitlements that are unfriendly to open source). Instead it
// generates an RSA-2048 key directly inside a dedicated app keychain. The
// private key is permanent, sensitive, and non-extractable; it is never present
// in Go memory or a temporary file. Its access list trusts only the creating
// application by default. Signing is RSASSA-PKCS1v15-SHA256 (RS256).
//
// Security and CoreFoundation are called through runtime FFI
// (github.com/ebitengine/purego). Key generation and signing stay inside the OS
// APIs while the binary remains CGO-free and can be cross-compiled for darwin.
//
// Build with: go build (cgo-free; compiled into every darwin build, no tag)
package keysigner
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"unsafe"
"github.com/ebitengine/purego"
"github.com/larksuite/cli/internal/vfs"
)
// ---- Security / CoreFoundation runtime bindings (purego, no cgo) ----
const (
cfFrameworkPath = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
secFrameworkPath = "/System/Library/Frameworks/Security.framework/Security"
// kCFStringEncodingUTF8 (CFStringBuiltInEncodings).
cfStringEncodingUTF8 = 0x08000100
// OSStatus values.
errSecSuccess = 0
// Legacy Security.framework key-generation values from cssmtype.h. The
// legacy API is used because it can target a dedicated file keychain and set
// both a non-extractable attribute and an application-only SecAccess ACL.
cssmAlgIDRSA = 42
cssmKeyUseSign = 0x00000004
cssmKeyUseVerify = 0x00000008
cssmKeyAttrPermanent = 0x00000001
cssmKeyAttrSensitive = 0x00000008
cssmKeyAttrExtractable = 0x00000020
publicKeyAttributes = cssmKeyAttrPermanent | cssmKeyAttrExtractable
privateKeyAttributes = cssmKeyAttrPermanent | cssmKeyAttrSensitive
)
var (
ffiOnce sync.Once
ffiErr error
cfDataCreate func(alloc uintptr, bytes *byte, length int) uintptr
cfDataGetLength func(d uintptr) int
cfDataGetBytePtr func(d uintptr) unsafe.Pointer
cfStringCreate func(alloc uintptr, cstr *byte, encoding uint32) uintptr
cfArrayCreate func(alloc uintptr, values *uintptr, numValues int, cb uintptr) uintptr
cfDictCreateMutable func(alloc uintptr, capacity int, keyCB, valCB uintptr) uintptr
cfDictSetValue func(dict, key, val uintptr)
cfRelease func(ref uintptr)
cfErrorGetCode func(e uintptr) int
dlsymDataPointer func(handle uintptr, name string) *uintptr
secKeychainCreate func(path *byte, passwordLength uint32, password unsafe.Pointer, promptUser uint8, initialAccess uintptr, out *uintptr) int32
secKeychainOpen func(path *byte, out *uintptr) int32
secKeychainUnlock func(keychain uintptr, passwordLength uint32, password unsafe.Pointer, usePassword uint8) int32
secAccessCreate func(descriptor, trustedList uintptr, out *uintptr) int32
secKeyCreatePair func(keychain uintptr, algorithm, keySize uint32, contextHandle uint64, publicKeyUsage, publicKeyAttr, privateKeyUsage, privateKeyAttr uint32, initialAccess uintptr, publicKey, privateKey *uintptr) int32
secKeyCopyExternal func(key uintptr, errOut *uintptr) uintptr
secKeychainItemDelete func(item uintptr) int32
secItemCopyMatching func(query uintptr, result *uintptr) int32
secItemUpdate func(query, attrs uintptr) int32
secKeyCreateSignature func(key, algo, data uintptr, errOut *uintptr) uintptr
// CFTypeRef data-symbol constants (deref to obtain the held ref value).
kSecClass uintptr
kSecClassKey uintptr
kSecAttrKeyClass uintptr
kSecAttrKeyClassPrivate uintptr
kSecAttrKeyType uintptr
kSecAttrKeyTypeRSA uintptr
kSecAttrApplicationLabel uintptr
kSecReturnRef uintptr
kSecMatchSearchList uintptr
kSecAttrLabel uintptr
kCFBooleanTrue uintptr
algRSAPKCS1SHA256 uintptr
// Struct-symbol constants (passed BY ADDRESS, not dereferenced).
cbTypeArray uintptr
cbDictKey uintptr
cbDictValue uintptr
)
// loadFFI resolves the framework functions and constants once. Any failure
// (framework missing, symbol absent) is returned to every caller so signing
// fails cleanly rather than crashing.
func loadFFI() error {
ffiOnce.Do(func() {
// RegisterLibFunc panics when a symbol is unavailable. Convert that into
// the same stable availability error as dlopen/dlsym failures so doctor
// and auth commands never crash on a future macOS without a legacy symbol.
defer func() {
if recovered := recover(); recovered != nil {
ffiErr = fmt.Errorf("keysigner: load Security framework bindings: %v", recovered)
}
}()
cf, err := purego.Dlopen(cfFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
ffiErr = fmt.Errorf("keysigner: dlopen CoreFoundation: %w", err)
return
}
sec, err := purego.Dlopen(secFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
ffiErr = fmt.Errorf("keysigner: dlopen Security: %w", err)
return
}
purego.RegisterLibFunc(&cfDataCreate, cf, "CFDataCreate")
purego.RegisterLibFunc(&cfDataGetLength, cf, "CFDataGetLength")
purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr")
purego.RegisterLibFunc(&cfStringCreate, cf, "CFStringCreateWithCString")
purego.RegisterLibFunc(&cfArrayCreate, cf, "CFArrayCreate")
purego.RegisterLibFunc(&cfDictCreateMutable, cf, "CFDictionaryCreateMutable")
purego.RegisterLibFunc(&cfDictSetValue, cf, "CFDictionarySetValue")
purego.RegisterLibFunc(&cfRelease, cf, "CFRelease")
purego.RegisterLibFunc(&cfErrorGetCode, cf, "CFErrorGetCode")
// purego.Dlsym exposes symbol addresses as uintptr. Bind dlsym with a
// pointer return for data symbols so reading exported CFTypeRef variables
// never performs a uintptr-to-pointer conversion in Go.
purego.RegisterLibFunc(&dlsymDataPointer, purego.RTLD_DEFAULT, "dlsym")
purego.RegisterLibFunc(&secKeychainCreate, sec, "SecKeychainCreate")
purego.RegisterLibFunc(&secKeychainOpen, sec, "SecKeychainOpen")
purego.RegisterLibFunc(&secKeychainUnlock, sec, "SecKeychainUnlock")
purego.RegisterLibFunc(&secAccessCreate, sec, "SecAccessCreate")
purego.RegisterLibFunc(&secKeyCreatePair, sec, "SecKeyCreatePair")
purego.RegisterLibFunc(&secKeyCopyExternal, sec, "SecKeyCopyExternalRepresentation")
purego.RegisterLibFunc(&secKeychainItemDelete, sec, "SecKeychainItemDelete")
purego.RegisterLibFunc(&secItemCopyMatching, sec, "SecItemCopyMatching")
purego.RegisterLibFunc(&secItemUpdate, sec, "SecItemUpdate")
purego.RegisterLibFunc(&secKeyCreateSignature, sec, "SecKeyCreateSignature")
// CFStringRef/CFBooleanRef constants: Dlsym gives the address of the
// exported variable; deref once to read the ref it holds.
derefs := []struct {
dst *uintptr
handle uintptr
name string
}{
{&kSecClass, sec, "kSecClass"},
{&kSecClassKey, sec, "kSecClassKey"},
{&kSecAttrKeyClass, sec, "kSecAttrKeyClass"},
{&kSecAttrKeyClassPrivate, sec, "kSecAttrKeyClassPrivate"},
{&kSecAttrKeyType, sec, "kSecAttrKeyType"},
{&kSecAttrKeyTypeRSA, sec, "kSecAttrKeyTypeRSA"},
{&kSecAttrApplicationLabel, sec, "kSecAttrApplicationLabel"},
{&kSecReturnRef, sec, "kSecReturnRef"},
{&kSecMatchSearchList, sec, "kSecMatchSearchList"},
{&kSecAttrLabel, sec, "kSecAttrLabel"},
{&kCFBooleanTrue, cf, "kCFBooleanTrue"},
{&algRSAPKCS1SHA256, sec, "kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA256"},
}
for _, d := range derefs {
sym := dlsymDataPointer(d.handle, d.name)
if sym == nil {
ffiErr = fmt.Errorf("keysigner: dlsym %s returned zero address", d.name)
return
}
*d.dst = *sym
if *d.dst == 0 {
ffiErr = fmt.Errorf("keysigner: data symbol %s contains a zero reference", d.name)
return
}
}
// Callback structs are passed by address (no deref).
addrs := []struct {
dst *uintptr
handle uintptr
name string
}{
{&cbTypeArray, cf, "kCFTypeArrayCallBacks"},
{&cbDictKey, cf, "kCFTypeDictionaryKeyCallBacks"},
{&cbDictValue, cf, "kCFTypeDictionaryValueCallBacks"},
}
for _, a := range addrs {
sym, e := purego.Dlsym(a.handle, a.name)
if e != nil {
ffiErr = fmt.Errorf("keysigner: dlsym %s: %w", a.name, e)
return
}
if sym == 0 {
ffiErr = fmt.Errorf("keysigner: dlsym %s returned zero address", a.name)
return
}
*a.dst = sym
}
})
return ffiErr
}
// cstr returns a pointer to a NUL-terminated copy of s. The backing array stays
// alive while the returned pointer is reachable.
func cstr(s string) *byte {
b := append([]byte(s), 0)
return &b[0]
}
// cfBytes wraps Go bytes in a CFData (CFDataCreate copies the bytes). Caller
// releases the returned CFDataRef.
func cfBytes(b []byte) uintptr {
var p *byte
if len(b) > 0 {
p = &b[0]
}
d := cfDataCreate(0, p, len(b))
runtime.KeepAlive(b)
return d
}
// keychainSearchArray opens the dedicated keychain file and wraps it in a
// CFArray for kSecMatchSearchList. Caller releases the returned array.
//
// NOTE: SecKeychainOpen / the file-based keychain are deprecated by Apple in
// favor of the data-protection keychain. They still function on current macOS;
// migrating off them is tracked separately and is independent of the cgo→purego
// change (the original cgo version used the same APIs).
func keychainSearchArray(keychainPath string) (uintptr, error) {
var kc uintptr
if st := secKeychainOpen(cstr(keychainPath), &kc); st != errSecSuccess {
return 0, keychainError("open keychain", int(st))
}
vals := [1]uintptr{kc}
arr := cfArrayCreate(0, &vals[0], 1, cbTypeArray)
cfRelease(kc) // the array retains it
if arr == 0 {
return 0, fmt.Errorf("keysigner: CFArrayCreate(search list) failed")
}
return arr, nil
}
// findPrivateKey locates the non-extractable private key by its application
// label within the dedicated keychain. Caller releases the returned SecKeyRef.
func findPrivateKey(appLabel []byte, keychainPath string) (uintptr, error) {
search, err := keychainSearchArray(keychainPath)
if err != nil {
return 0, err
}
defer cfRelease(search)
labelData := cfBytes(appLabel)
defer cfRelease(labelData)
q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
if q == 0 {
return 0, fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed")
}
defer cfRelease(q)
cfDictSetValue(q, kSecClass, kSecClassKey)
cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate)
cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA)
cfDictSetValue(q, kSecAttrApplicationLabel, labelData)
cfDictSetValue(q, kSecReturnRef, kCFBooleanTrue)
cfDictSetValue(q, kSecMatchSearchList, search)
var keyRef uintptr
if st := secItemCopyMatching(q, &keyRef); st != errSecSuccess {
return 0, keychainError("find private key", int(st))
}
return keyRef, nil
}
// These seams keep lifecycle tests hermetic. Production calls Security.framework
// directly, so the generated keychain password never appears in process argv.
var (
createKeychainFile = createKeychainFileFFI
unlockKeychainFile = unlockKeychainFileFFI
)
// keychainSigner implements Signer using a macOS non-exportable Keychain key.
type keychainSigner struct{}
func init() { Register(keychainSigner{}) }
// ProbeHardware reports the macOS Keychain backend backing this signer. The
// keychain signer is compiled into every darwin build and needs no special
// hardware, so it reports available whenever its framework bindings load.
// It performs no key access, so it never prompts. Implementing HardwareProber
// is what lets `doctor` report the signer as present rather than treating the
// (prober-less) signer as "no platform signer in this build".
func (keychainSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) {
info := HardwareInfo{Backend: "keychain", VendorName: "macOS Keychain"}
// A missing framework or symbol is a status (Available=false via Reason),
// not a probe error. Loading symbols does not touch the keychain or prompt.
if err := loadFFI(); err != nil {
info.Reason = err.Error()
return info, nil //nolint:nilerr // absence is reported via Reason, not as an error
}
info.Available = true
return info, nil
}
func (keychainSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
if md, err := readKeyMetadata(ref.Label); err == nil {
return decodePublicKey(md.PublicKey)
} else if !os.IsNotExist(err) {
return nil, err
}
return createKeychainKey(ref.Label)
}
func (keychainSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
md, err := readKeyMetadata(ref.Label)
if err != nil {
return nil, err
}
return decodePublicKey(md.PublicKey)
}
func (keychainSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) {
if err := loadFFI(); err != nil {
return nil, "", err
}
md, err := readKeyMetadata(ref.Label)
if err != nil {
return nil, "", err
}
appLabel, err := hex.DecodeString(md.AppLabel)
if err != nil {
return nil, "", fmt.Errorf("keysigner: decode app label: %w", err)
}
if len(appLabel) == 0 {
// Guard the &appLabel[0] pointer below against corrupted metadata.
return nil, "", fmt.Errorf("keysigner: key metadata for %q has empty app label", ref.Label)
}
keychain, err := ensureKeychain()
if err != nil {
return nil, "", err
}
keyRef, err := findPrivateKey(appLabel, keychain)
if err != nil {
return nil, "", err
}
defer cfRelease(keyRef)
digest := sha256.Sum256(signingInput)
digestData := cfBytes(digest[:])
defer cfRelease(digestData)
var errRef uintptr
sigRef := secKeyCreateSignature(keyRef, algRSAPKCS1SHA256, digestData, &errRef)
if sigRef == 0 {
code := 0
if errRef != 0 {
code = cfErrorGetCode(errRef)
cfRelease(errRef)
}
return nil, "", fmt.Errorf("keysigner: SecKeyCreateSignature failed (CFError %d)", code)
}
defer cfRelease(sigRef)
n := cfDataGetLength(sigRef)
bp := cfDataGetBytePtr(sigRef)
out := make([]byte, n)
copy(out, unsafe.Slice((*byte)(bp), n))
// RS256: the SecKey PKCS1v15-SHA256 signature is the JOSE signature as-is.
return out, AlgRS256, nil
}
// keyMetadata records the public key + the keychain application-label used to
// locate the non-extractable private key.
type keyMetadata struct {
PublicKey string `json:"public_key"` // PKIX DER, std base64 (see EncodePublicKey)
AppLabel string `json:"app_label"` // hex(sha1(PKCS1 public key))
}
func createKeychainKey(label string) (crypto.PublicKey, error) {
metadataPath, err := keyMetadataPath(label)
if err != nil {
return nil, err
}
if err := loadFFI(); err != nil {
return nil, err
}
keychain, err := ensureKeychain()
if err != nil {
return nil, err
}
var keychainRef uintptr
if st := secKeychainOpen(cstr(keychain), &keychainRef); st != errSecSuccess {
return nil, keychainError("open keychain for key generation", int(st))
}
if keychainRef == 0 {
return nil, fmt.Errorf("keysigner: open keychain for key generation returned an empty reference")
}
defer cfRelease(keychainRef)
descriptor := cfStringCreate(0, cstr(label), cfStringEncodingUTF8)
if descriptor == 0 {
return nil, fmt.Errorf("keysigner: create key access descriptor failed")
}
defer cfRelease(descriptor)
// A nil trusted list means only this application is trusted without a
// confirmation dialog. This is intentionally stricter than security(1) -A.
var access uintptr
if st := secAccessCreate(descriptor, 0, &access); st != errSecSuccess {
return nil, keychainError("create key access policy", int(st))
}
if access == 0 {
return nil, fmt.Errorf("keysigner: create key access policy returned an empty reference")
}
defer cfRelease(access)
var publicKeyRef, privateKeyRef uintptr
status := secKeyCreatePair(
keychainRef,
cssmAlgIDRSA,
2048,
0,
cssmKeyUseVerify,
publicKeyAttributes,
cssmKeyUseSign,
privateKeyAttributes,
access,
&publicKeyRef,
&privateKeyRef,
)
deleteAndRelease := func(keyRef uintptr) {
if keyRef != 0 {
_ = secKeychainItemDelete(keyRef)
cfRelease(keyRef)
}
}
if status != errSecSuccess {
deleteAndRelease(privateKeyRef)
deleteAndRelease(publicKeyRef)
return nil, keychainError("generate non-extractable RSA key", int(status))
}
if publicKeyRef == 0 || privateKeyRef == 0 {
deleteAndRelease(privateKeyRef)
deleteAndRelease(publicKeyRef)
return nil, fmt.Errorf("keysigner: key generation returned an empty key reference")
}
defer cfRelease(publicKeyRef)
defer cfRelease(privateKeyRef)
committed := false
defer func() {
if !committed {
_ = secKeychainItemDelete(privateKeyRef)
_ = secKeychainItemDelete(publicKeyRef)
}
}()
var exportErr uintptr
publicDERRef := secKeyCopyExternal(publicKeyRef, &exportErr)
if publicDERRef == 0 {
code := 0
if exportErr != 0 {
code = cfErrorGetCode(exportErr)
cfRelease(exportErr)
}
return nil, fmt.Errorf("keysigner: export public key failed (CFError %d)", code)
}
defer cfRelease(publicDERRef)
publicDERLength := cfDataGetLength(publicDERRef)
publicDERPointer := cfDataGetBytePtr(publicDERRef)
if publicDERLength <= 0 || publicDERPointer == nil {
return nil, fmt.Errorf("keysigner: exported public key is empty")
}
publicDER := make([]byte, publicDERLength)
copy(publicDER, unsafe.Slice((*byte)(publicDERPointer), publicDERLength))
publicKey, err := x509.ParsePKCS1PublicKey(publicDER)
if err != nil {
return nil, fmt.Errorf("keysigner: parse generated RSA public key: %w", err)
}
appLabel := sha1.Sum(publicDER)
if err := setKeychainKeyLabel(appLabel[:], keychain, label); err != nil {
return nil, err
}
encodedPub, err := EncodePublicKey(publicKey)
if err != nil {
return nil, err
}
if err := writeKeyMetadata(metadataPath, keyMetadata{PublicKey: encodedPub, AppLabel: hex.EncodeToString(appLabel[:])}); err != nil {
return nil, err
}
committed = true
return publicKey, nil
}
func setKeychainKeyLabel(appLabel []byte, keychain, label string) error {
if err := loadFFI(); err != nil {
return err
}
search, err := keychainSearchArray(keychain)
if err != nil {
return err
}
defer cfRelease(search)
labelData := cfBytes(appLabel)
defer cfRelease(labelData)
q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
if q == 0 {
return fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed")
}
defer cfRelease(q)
cfDictSetValue(q, kSecClass, kSecClassKey)
cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate)
cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA)
cfDictSetValue(q, kSecAttrApplicationLabel, labelData)
cfDictSetValue(q, kSecMatchSearchList, search)
cfLabel := cfStringCreate(0, cstr(label), cfStringEncodingUTF8)
if cfLabel == 0 {
return fmt.Errorf("keysigner: CFStringCreateWithCString failed")
}
defer cfRelease(cfLabel)
attrs := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
if attrs == 0 {
return fmt.Errorf("keysigner: CFDictionaryCreateMutable(attrs) failed")
}
defer cfRelease(attrs)
cfDictSetValue(attrs, kSecAttrLabel, cfLabel)
if st := secItemUpdate(q, attrs); st != errSecSuccess {
return keychainError("set keychain key label", int(st))
}
return nil
}
func decodePublicKey(encoded string) (crypto.PublicKey, error) {
der, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("keysigner: decode public key: %w", err)
}
return x509.ParsePKIXPublicKey(der)
}
func readKeyMetadata(label string) (*keyMetadata, error) {
path, err := keyMetadataPath(label)
if err != nil {
return nil, err
}
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err // preserves os.ErrNotExist for EnsureKey
}
var md keyMetadata
if err := json.Unmarshal(data, &md); err != nil {
return nil, fmt.Errorf("keysigner: parse key metadata: %w", err)
}
return &md, nil
}
func writeKeyMetadata(path string, md keyMetadata) error {
if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
data, err := json.MarshalIndent(md, "", " ")
if err != nil {
return err
}
return vfs.WriteFile(path, data, 0600)
}
func ensureKeychain() (string, error) {
keychainPath, err := keychainFilePath()
if err != nil {
return "", err
}
password, err := keychainPassword()
if err != nil {
return "", err
}
defer clear(password)
if _, err := vfs.Stat(keychainPath); err != nil {
if !os.IsNotExist(err) {
return "", fmt.Errorf("keysigner: stat keychain: %w", err)
}
if err := vfs.MkdirAll(filepath.Dir(keychainPath), 0700); err != nil {
return "", err
}
if err := createKeychainFile(keychainPath, password); err != nil {
return "", err
}
}
// A file keychain can be locked after logout, reboot, an idle interval, or an
// explicit `security lock-keychain`. SecKeychainOpen may still succeed while
// it is locked; the failure then appears later at signing time (commonly as
// SecKeyCreateSignature CFError -128). Always unlock the dedicated keychain
// before returning it, including when the file already existed.
if err := unlockKeychainFile(keychainPath, password); err != nil {
return "", err
}
return keychainPath, nil
}
func createKeychainFileFFI(path string, password []byte) error {
if err := loadFFI(); err != nil {
return err
}
pathBytes := append([]byte(path), 0)
var keychain uintptr
status := secKeychainCreate(
&pathBytes[0],
uint32(len(password)),
byteSlicePointer(password),
0, // promptUser=false: unattended creation must never display UI.
0, // initialAccess is ignored; Apple documents passing NULL.
&keychain,
)
runtime.KeepAlive(pathBytes)
runtime.KeepAlive(password)
if status != errSecSuccess {
return keychainError("create keychain", int(status))
}
if keychain == 0 {
return fmt.Errorf("keysigner: create keychain returned an empty reference")
}
defer cfRelease(keychain)
// Keep the system's default lock policy. ensureKeychain explicitly unlocks
// this dedicated keychain with its generated password before every use, so
// changing settings here would be unnecessary and could trigger system UI.
return nil
}
func unlockKeychainFileFFI(path string, password []byte) error {
if err := loadFFI(); err != nil {
return err
}
pathBytes := append([]byte(path), 0)
var keychain uintptr
status := secKeychainOpen(&pathBytes[0], &keychain)
runtime.KeepAlive(pathBytes)
if status != errSecSuccess {
return keychainError("open keychain for unlock", int(status))
}
if keychain == 0 {
return fmt.Errorf("keysigner: open keychain for unlock returned an empty reference")
}
defer cfRelease(keychain)
status = secKeychainUnlock(keychain, uint32(len(password)), byteSlicePointer(password), 1)
runtime.KeepAlive(password)
if status != errSecSuccess {
return keychainError("unlock keychain", int(status))
}
return nil
}
func byteSlicePointer(data []byte) unsafe.Pointer {
if len(data) == 0 {
return nil
}
return unsafe.Pointer(&data[0])
}
func keysignerDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("keysigner: resolve config dir: %w", err)
}
return filepath.Join(configDir, "lark-cli", "keysigner"), nil
}
func keychainFilePath() (string, error) {
dir, err := keysignerDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "lark-cli.keychain"), nil
}
func keychainPassword() ([]byte, error) {
dir, err := keysignerDir()
if err != nil {
return nil, err
}
path := filepath.Join(dir, "keychain.pass")
if data, err := vfs.ReadFile(path); err == nil {
defer clear(data)
if pw := bytes.TrimSpace(data); len(pw) != 0 {
return append([]byte(nil), pw...), nil
}
return nil, fmt.Errorf("keysigner: empty keychain password")
} else if !os.IsNotExist(err) {
return nil, err
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return nil, err
}
defer clear(buf)
pw := make([]byte, hex.EncodedLen(len(buf)))
hex.Encode(pw, buf)
if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil {
clear(pw)
return nil, err
}
stored := append(append([]byte(nil), pw...), '\n')
if err := vfs.WriteFile(path, stored, 0600); err != nil {
clear(stored)
clear(pw)
return nil, err
}
clear(stored)
return pw, nil
}
func keyMetadataPath(label string) (string, error) {
dir, err := keysignerDir()
if err != nil {
return "", err
}
id := sha256.Sum256([]byte(label))
return filepath.Join(dir, "keys", hex.EncodeToString(id[:])+".json"), nil
}
func keychainError(operation string, status int) error {
switch status {
case -25299:
return fmt.Errorf("keysigner: %s: key already exists", operation)
case -25300:
return fmt.Errorf("keysigner: %s: key not found", operation)
case -2:
return fmt.Errorf("keysigner: %s: allocation failed", operation)
default:
return fmt.Errorf("keysigner: %s: Security framework status %d", operation, status)
}
}

View File

@@ -0,0 +1,282 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// TestKeychainSignerRegistered confirms every Darwin build self-registers the
// signer (init → Register), so keysigner.Active() is non-nil. No keychain access.
func TestKeychainSignerRegistered(t *testing.T) {
if _, ok := Active().(keychainSigner); !ok {
t.Fatalf("Active() = %T, want keychainSigner (Darwin build must self-register)", Active())
}
}
func TestKeychainFFIBindings(t *testing.T) {
if err := loadFFI(); err != nil {
t.Fatalf("loadFFI: %v", err)
}
if secKeychainCreate == nil || secKeychainOpen == nil || secKeychainUnlock == nil ||
secAccessCreate == nil || secKeyCreatePair == nil || secKeyCopyExternal == nil || secKeychainItemDelete == nil {
t.Fatal("one or more keychain functions were not registered")
}
}
func TestPrivateKeyGenerationAttributesAreNonExtractable(t *testing.T) {
if privateKeyAttributes&cssmKeyAttrPermanent == 0 {
t.Fatal("private key must be stored permanently in the dedicated keychain")
}
if privateKeyAttributes&cssmKeyAttrSensitive == 0 {
t.Fatal("private key must be marked sensitive")
}
if privateKeyAttributes&cssmKeyAttrExtractable != 0 {
t.Fatal("private key must not be extractable")
}
if publicKeyAttributes&cssmKeyAttrExtractable == 0 {
t.Fatal("public key must remain exportable")
}
}
// TestEnsureKeychainUnlocksExistingKeychain covers the recovery path for a
// dedicated file keychain that was created in an earlier process and has since
// become locked. No real Security.framework call is executed.
func TestEnsureKeychainUnlocksExistingKeychain(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dir := filepath.Join(home, "Library", "Application Support", "lark-cli", "keysigner")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
keychainPath := filepath.Join(dir, "lark-cli.keychain")
if err := os.WriteFile(keychainPath, []byte("not-a-real-keychain"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "keychain.pass"), []byte("test-password\n"), 0600); err != nil {
t.Fatal(err)
}
previousCreate := createKeychainFile
previousUnlock := unlockKeychainFile
createKeychainFile = func(string, []byte) error {
t.Fatal("createKeychainFile called for an existing keychain")
return nil
}
type unlockCall struct {
path string
password []byte
}
var calls []unlockCall
var borrowedPassword []byte
unlockKeychainFile = func(path string, password []byte) error {
borrowedPassword = password
calls = append(calls, unlockCall{path: path, password: append([]byte(nil), password...)})
return nil
}
t.Cleanup(func() {
createKeychainFile = previousCreate
unlockKeychainFile = previousUnlock
})
got, err := ensureKeychain()
if err != nil {
t.Fatalf("ensureKeychain: %v", err)
}
if got != keychainPath {
t.Fatalf("ensureKeychain path = %q, want %q", got, keychainPath)
}
want := []unlockCall{{path: keychainPath, password: []byte("test-password")}}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("unlock calls = %#v, want %#v", calls, want)
}
for i, value := range borrowedPassword {
if value != 0 {
t.Fatalf("borrowed password byte %d was not cleared after use", i)
}
}
}
func TestEnsureKeychainNewKeychainStillUnlocksAfterCreation(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
type lifecycleCall struct {
op string
path string
password []byte
}
var calls []lifecycleCall
previousCreate := createKeychainFile
previousUnlock := unlockKeychainFile
createKeychainFile = func(path string, password []byte) error {
calls = append(calls, lifecycleCall{op: "create", path: path, password: append([]byte(nil), password...)})
return nil
}
unlockKeychainFile = func(path string, password []byte) error {
calls = append(calls, lifecycleCall{op: "unlock", path: path, password: append([]byte(nil), password...)})
return nil
}
t.Cleanup(func() {
createKeychainFile = previousCreate
unlockKeychainFile = previousUnlock
})
keychainPath, err := ensureKeychain()
if err != nil {
t.Fatalf("ensureKeychain: %v", err)
}
if len(calls) != 2 || calls[0].op != "create" || calls[1].op != "unlock" {
t.Fatalf("lifecycle calls = %#v, want create then unlock", calls)
}
for _, call := range calls {
if call.path != keychainPath || len(call.password) != 64 {
t.Fatalf("lifecycle call = %#v, want path %q and generated 64-byte password", call, keychainPath)
}
}
if !reflect.DeepEqual(calls[0].password, calls[1].password) {
t.Fatal("create and unlock did not receive the same generated password")
}
}
func TestEnsureKeychainExistingUnlockErrorIsReturned(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dir := filepath.Join(home, "Library", "Application Support", "lark-cli", "keysigner")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "lark-cli.keychain"), []byte("not-a-real-keychain"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "keychain.pass"), []byte("test-password\n"), 0600); err != nil {
t.Fatal(err)
}
previousCreate := createKeychainFile
previousUnlock := unlockKeychainFile
createKeychainFile = func(string, []byte) error {
t.Fatal("createKeychainFile called for an existing keychain")
return nil
}
unlockKeychainFile = func(string, []byte) error {
return errors.New("keysigner: unlock keychain: Security framework status -25293")
}
t.Cleanup(func() {
createKeychainFile = previousCreate
unlockKeychainFile = previousUnlock
})
_, err := ensureKeychain()
if err == nil {
t.Fatal("ensureKeychain returned nil error")
}
if got := err.Error(); !strings.Contains(got, "unlock keychain") || !strings.Contains(got, "-25293") {
t.Fatalf("ensureKeychain error = %q", got)
}
}
func TestEnsureKeychainCreateErrorStopsBeforeUnlock(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
previousCreate := createKeychainFile
previousUnlock := unlockKeychainFile
createKeychainFile = func(string, []byte) error {
return errors.New("keysigner: create keychain: Security framework status -50")
}
unlockKeychainFile = func(string, []byte) error {
t.Fatal("unlockKeychainFile called after creation failed")
return nil
}
t.Cleanup(func() {
createKeychainFile = previousCreate
unlockKeychainFile = previousUnlock
})
_, err := ensureKeychain()
if err == nil || !strings.Contains(err.Error(), "create keychain") || !strings.Contains(err.Error(), "-50") {
t.Fatalf("ensureKeychain error = %v", err)
}
}
// TestKeychainSignerRoundTrip creates a real non-extractable RSA key, signs, and
// verifies RS256 against the returned public key. Gated by LARK_KEYCHAIN_IT
// because it mutates the dedicated lark-cli keychain store. The signer is now
// cgo-free (purego runtime FFI), so it runs with CGO_ENABLED=0. Run with:
//
// LARK_KEYCHAIN_IT=1 go test -run RoundTrip ./internal/keysigner/
func TestKeychainSignerRoundTrip(t *testing.T) {
if os.Getenv("LARK_KEYCHAIN_IT") == "" {
t.Skip("set LARK_KEYCHAIN_IT=1 to run the macOS Keychain integration test")
}
t.Setenv("HOME", t.TempDir())
s := keychainSigner{}
ref := KeyRef{Label: "lark-cli-keychain-it"}
pub, err := s.EnsureKey(context.Background(), ref)
if err != nil {
t.Fatalf("EnsureKey: %v", err)
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
t.Fatalf("public key = %T, want *rsa.PublicKey", pub)
}
if alg, err := AlgForKey(pub); err != nil || alg != AlgRS256 {
t.Fatalf("AlgForKey = %q, %v; want RS256", alg, err)
}
input := []byte("header.payload")
sig, alg, err := s.Sign(context.Background(), ref, input)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if alg != AlgRS256 {
t.Errorf("Sign alg = %q, want RS256", alg)
}
h := sha256.Sum256(input)
if err := rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, h[:], sig); err != nil {
t.Errorf("RS256 signature did not verify: %v", err)
}
md, err := readKeyMetadata(ref.Label)
if err != nil {
t.Fatalf("read metadata: %v", err)
}
appLabel, err := hex.DecodeString(md.AppLabel)
if err != nil {
t.Fatalf("decode app label: %v", err)
}
keychain, err := ensureKeychain()
if err != nil {
t.Fatalf("ensure keychain for export check: %v", err)
}
privateKeyRef, err := findPrivateKey(appLabel, keychain)
if err != nil {
t.Fatalf("find private key for export check: %v", err)
}
defer cfRelease(privateKeyRef)
var exportErr uintptr
if exported := secKeyCopyExternal(privateKeyRef, &exportErr); exported != 0 {
cfRelease(exported)
t.Fatal("private key was exportable")
}
if exportErr != 0 {
cfRelease(exportErr)
}
}

View File

@@ -0,0 +1,135 @@
//go:build linux || (windows && amd64)
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// TPM 2.0 signer (compiled into every linux and windows/amd64 build, no build
// tag required), backed by github.com/facebookincubator/sks.
//
// sks holds a non-exportable ECDSA P-256 key in the platform TPM and signs
// SHA-256 digests. On Linux it talks to /dev/tpmrm0; on Windows it uses the
// Microsoft Platform Crypto Provider (CNG). Both backends return an ASN.1 DER
// ECDSA signature, which we convert to the fixed-width r||s form JWS requires for
// ES256 (see ecdsaDERToJOSE). One key is created on the first private_key_jwt
// registration (DefaultKeyLabel) and reused for subsequent app registrations and
// every client_assertion on the same device.
//
// Excluded from windows/arm64: the sks Windows dependency stack (go-ole) has no
// arm64 VARIANT and fails to compile, so windows/arm64 falls back to
// client_secret only (keysigner.Active() is nil). On darwin the keychain signer
// is used instead. CGO is never required.
package keysigner
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/sha256"
"fmt"
"io"
"github.com/facebookincubator/flog"
"github.com/facebookincubator/sks"
)
// p256ByteLen is the P-256 coordinate width. sks regular keys are always ECDSA
// P-256, so ES256 signatures are 2*p256ByteLen bytes of r||s.
const p256ByteLen = 32
// keyTag is the sks key tag. Both the Linux and Windows sks backends address
// keys by label and ignore the tag, but the macOS backend uses it, so we set a
// stable namespaced value for forward compatibility.
const keyTag = "com.larksuite.cli"
// sksSigner implements Signer (and HardwareProber) using a non-exportable
// TPM 2.0 ECDSA key via sks.
type sksSigner struct{}
func init() {
Register(sksSigner{})
// This sks version logs verbose TPM-operation chatter to stderr via flog (a
// glog fork it owns exclusively) — e.g. "Loaded TPM device", "Found handle
// for key" on every sign. The CLI does not use flog, so silence it
// process-wide here; real failures are returned as errors, never relied upon
// from these logs. (Newer sks switched to slog, but that lands only on its
// go-1.24 line, which we avoid to keep the module on go 1.23.)
flog.SetOutput(io.Discard)
}
// EnsureKey returns the public key for ref, creating the TPM key if absent.
// sks.NewKey is find-or-create: it returns the existing key when one is present.
func (sksSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
key, err := sks.NewKey(ref.Label, keyTag, false, true, nil)
if err != nil {
return nil, fmt.Errorf("keysigner: ensure TPM key %q: %w", ref.Label, err)
}
defer key.Close()
return ecdsaPublic(ref.Label, key.Public())
}
// PublicKey returns the public key for ref without creating it. FromLabelTag does
// not touch the TPM until Public() loads the sealed key; a missing key yields a
// nil public key, which we surface as an error — at runtime the key MUST already
// exist (it was bound to the app at registration), so we never silently mint a
// new, unbound one here.
func (sksSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
pub := sks.FromLabelTag(ref.Label).Public()
if pub == nil {
return nil, fmt.Errorf("keysigner: TPM key %q not found", ref.Label)
}
return ecdsaPublic(ref.Label, pub)
}
// Sign signs signingInput with the TPM key and returns a JOSE-format ES256
// signature (fixed-width r||s) plus its alg.
func (sksSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) {
key, err := sks.NewKey(ref.Label, keyTag, false, true, nil)
if err != nil {
return nil, "", fmt.Errorf("keysigner: load TPM key %q: %w", ref.Label, err)
}
defer key.Close()
// ES256 signs the SHA-256 digest of the JWS signing input.
digest := sha256.Sum256(signingInput)
der, err := key.Sign(nil, digest[:], crypto.SHA256)
if err != nil {
return nil, "", fmt.Errorf("keysigner: TPM sign with key %q: %w", ref.Label, err)
}
// Both sks backends emit ASN.1 DER; JWS ES256 requires fixed-width r||s
// (RFC 7518 §3.4).
rs, err := ecdsaDERToJOSE(der, p256ByteLen)
if err != nil {
return nil, "", err
}
return rs, AlgES256, nil
}
// ProbeHardware reports on the TPM backing this signer without touching any key.
// A failure to reach the TPM (no device, permission denied, not TPM 2.0) is
// reported as Available=false with Reason set, NOT as a Go error — the probe
// still succeeded in determining that the TEE is currently unusable.
func (sksSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) {
info := HardwareInfo{Backend: "tpm2"}
data, err := sks.GetSecureHardwareVendorData()
if err != nil {
info.Reason = cleanProbeError(err)
return info, nil
}
info.VendorName = data.VendorName
info.VendorInfo = data.VendorInfo
info.Available = data.IsTPM20CompliantDevice
if !info.Available {
info.Reason = "secure hardware is not a TPM 2.0 compliant device"
}
return info, nil
}
// ecdsaPublic asserts that an sks public key is an ECDSA key (it always is for
// regular sks keys) so the caller gets the concrete type AlgForKey/PublicKeyJWK expect.
func ecdsaPublic(label string, pub crypto.PublicKey) (*ecdsa.PublicKey, error) {
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("keysigner: TPM key %q public is %T, want *ecdsa.PublicKey", label, pub)
}
return ecPub, nil
}

View File

@@ -0,0 +1,122 @@
//go:build linux || (windows && amd64)
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/sha256"
"io"
"math/big"
"strings"
"testing"
"github.com/facebookincubator/flog"
"github.com/facebookincubator/sks"
)
// TestFlogSilenced verifies the mechanism init() relies on to keep sks's flog
// TPM chatter off the CLI's stderr: SetOutput redirects flog, and io.Discard
// drops it. Cleanup restores io.Discard so init()'s silencing holds for the
// rest of the package's tests.
func TestFlogSilenced(t *testing.T) {
var buf bytes.Buffer
flog.SetOutput(&buf)
t.Cleanup(func() { flog.SetOutput(io.Discard) })
flog.Info("captured-line")
if !strings.Contains(buf.String(), "captured-line") {
t.Fatalf("flog.SetOutput(buffer) did not capture output: %q", buf.String())
}
flog.SetOutput(io.Discard)
buf.Reset()
flog.Info("should-be-discarded")
if buf.Len() != 0 {
t.Errorf("flog output not discarded: %q", buf.String())
}
}
// requireTEE skips the test unless the TPM is present and usable. On a Linux
// machine with a TPM but a restrictive device owner (`/dev/tpmrm0` is `tss:tss`
// by default), grant access with `sudo usermod -aG tss $USER` then re-login, or
// run the test under sudo.
func requireTEE(t *testing.T) {
t.Helper()
info, err := sksSigner{}.ProbeHardware(context.Background())
if err != nil || !info.Available {
reason := info.Reason
if err != nil {
reason = err.Error()
}
t.Skipf("TEE not available (%s)", reason)
}
}
// TestSKSSignerRoundTrip exercises the full registration→assertion contract
// against the real TPM: create the key, read it back without creating, derive
// the JWS alg + JWK, sign, and verify the fixed-width r||s output.
func TestSKSSignerRoundTrip(t *testing.T) {
requireTEE(t)
var s sksSigner
ctx := context.Background()
ref := KeyRef{Label: "larksuite-cli-test"}
// Best-effort cleanup so the test key does not linger in the TPM-sealed store.
t.Cleanup(func() {
if k, err := sks.NewKey(ref.Label, keyTag, false, true, nil); err == nil {
_ = k.Remove()
_ = k.Close()
}
})
pub, err := s.EnsureKey(ctx, ref)
if err != nil {
t.Fatalf("EnsureKey: %v", err)
}
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
t.Fatalf("EnsureKey returned %T, want *ecdsa.PublicKey", pub)
}
// PublicKey (no-create) must return the same key bound at EnsureKey.
pub2, err := s.PublicKey(ctx, ref)
if err != nil {
t.Fatalf("PublicKey: %v", err)
}
if !ecPub.Equal(pub2) {
t.Fatal("PublicKey returned a different key than EnsureKey")
}
// The JWT layer derives alg + JWK from the public key; both must work.
if alg, err := AlgForKey(pub); err != nil || alg != AlgES256 {
t.Fatalf("AlgForKey = %q, %v; want ES256", alg, err)
}
if _, err := PublicKeyJWK(pub); err != nil {
t.Fatalf("PublicKeyJWK: %v", err)
}
// Sign a representative JWS signing input and verify the converted r||s.
input := []byte("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJjbGkifQ")
sig, alg, err := s.Sign(ctx, ref, input)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if alg != AlgES256 {
t.Fatalf("Sign alg = %q, want ES256", alg)
}
if len(sig) != 2*p256ByteLen {
t.Fatalf("len(sig) = %d, want %d (fixed-width r||s)", len(sig), 2*p256ByteLen)
}
digest := sha256.Sum256(input)
r := new(big.Int).SetBytes(sig[:p256ByteLen])
ss := new(big.Int).SetBytes(sig[p256ByteLen:])
if !ecdsa.Verify(ecPub, digest[:], r, ss) {
t.Fatal("TPM signature did not verify against the public key")
}
}

49
release_config_test.go Normal file
View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"testing"
"github.com/larksuite/cli/internal/vfs"
"gopkg.in/yaml.v3"
)
func TestGoReleaserPlatformMatrix(t *testing.T) {
data, err := vfs.ReadFile(".goreleaser.yml")
if err != nil {
t.Fatalf("read .goreleaser.yml: %v", err)
}
var config struct {
Builds []struct {
ID string `yaml:"id"`
GOArch []string `yaml:"goarch"`
} `yaml:"builds"`
}
if err := yaml.Unmarshal(data, &config); err != nil {
t.Fatalf("parse .goreleaser.yml: %v", err)
}
builds := make(map[string][]string, len(config.Builds))
for _, build := range config.Builds {
builds[build.ID] = build.GOArch
}
if !contains(builds["linux"], "riscv64") {
t.Errorf("linux release matrix must include riscv64; got %v", builds["linux"])
}
if contains(builds["darwin"], "riscv64") {
t.Errorf("darwin release matrix must not include unsupported riscv64; got %v", builds["darwin"])
}
}
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -24,6 +24,10 @@ build_target() {
ext=".exe"
fi
# The platform key signers are compiled in by build constraint, no tags:
# darwin keychain (//go:build darwin) and linux/windows-amd64 TPM
# (//go:build linux || (windows && amd64)). windows/arm64 arch-excludes the TPM
# signer (go-ole has no arm64) and falls back to client_secret only.
local output="$OUT_DIR/bin/lark-cli-${goos}-${goarch}${ext}"
echo "Building ${goos}/${goarch} -> ${output}"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" ./main.go

View File

@@ -1,68 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// Tests pinning bot-identity support for `minutes +detail` (minute metadata,
// artifacts, and transcript all flow under a tenant access token).
package minutes
import (
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestMinutesDetailSupportsUserAndBotIdentity(t *testing.T) {
want := []string{"user", "bot"}
if !reflect.DeepEqual(MinutesDetail.AuthTypes, want) {
t.Fatalf("MinutesDetail.AuthTypes = %v, want %v", MinutesDetail.AuthTypes, want)
}
}
func TestDetail_DryRun_BotIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error under --as bot: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/") {
t.Errorf("dry-run should show minutes API path, got: %s", stdout.String())
}
}
func TestDetail_DryRun_BotIdentity_Transcript(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--transcript", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error under --as bot: %v", err)
}
if !strings.Contains(stdout.String(), "artifacts") {
t.Errorf("dry-run should show artifacts API path when --transcript is set, got: %s", stdout.String())
}
}
func TestMinutesApplyPermissionSupportsUserAndBotIdentity(t *testing.T) {
want := []string{"user", "bot"}
if !reflect.DeepEqual(MinutesApplyPermission.AuthTypes, want) {
t.Fatalf("MinutesApplyPermission.AuthTypes = %v, want %v", MinutesApplyPermission.AuthTypes, want)
}
}
func TestApplyPermission_DryRun_BotIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, MinutesApplyPermission, []string{
"+apply-permission", "--minute-token", "obcnexampleminute", "--perm", "view", "--dry-run", "--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error under --as bot: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/obcnexampleminute/permissions/apply") {
t.Errorf("dry-run should show apply-permission API path, got: %s", out)
}
if !strings.Contains(out, `"perm": "view"`) && !strings.Contains(out, `"perm":"view"`) {
t.Errorf("dry-run should show perm body, got: %s", out)
}
}

View File

@@ -21,7 +21,7 @@ var MinutesApplyPermission = common.Shortcut{
Description: "Apply for view or edit permission on a minute",
Risk: "write",
Scopes: []string{"minutes:permission:apply"},
AuthTypes: []string{"user", "bot"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "minute-token", Desc: "minute token", Required: true},
{Name: "perm", Desc: "permission to apply for", Required: true, Enum: []string{"view", "edit"}},

View File

@@ -285,7 +285,7 @@ var MinutesDetail = common.Shortcut{
Description: "Query minute details with selective artifact flags (summary, todo, chapter, transcript, keyword)",
Risk: "read",
Scopes: []string{"minutes:minutes.basic:read", "minutes:minutes.artifacts:read"},
AuthTypes: []string{"user", "bot"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch", Required: true},

View File

@@ -1,145 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// Tests pinning bot-identity support for the vc read shortcuts
// (+detail / +notes / +recording).
package vc
import (
"context"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
)
// ---------------------------------------------------------------------------
// AuthTypes contracts
// ---------------------------------------------------------------------------
func TestVCReadShortcutsSupportUserAndBotIdentity(t *testing.T) {
want := []string{"user", "bot"}
cases := map[string][]string{
"+detail": VCDetail.AuthTypes,
"+notes": VCNotes.AuthTypes,
"+recording": VCRecording.AuthTypes,
}
for cmd, got := range cases {
if !reflect.DeepEqual(got, want) {
t.Errorf("%s AuthTypes = %v, want %v", cmd, got, want)
}
}
}
// ---------------------------------------------------------------------------
// Bot dry-run: the meeting/recording paths flow under bot identity
// ---------------------------------------------------------------------------
func TestDetail_DryRun_BotIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error under --as bot: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/vc/v1/meetings/{meeting_id}") {
t.Errorf("dry-run should show meeting.get API, got: %s", out)
}
if !strings.Contains(out, "recording") {
t.Errorf("dry-run should show recording API, got: %s", out)
}
}
func TestRecording_DryRun_BotIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error under --as bot: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "recording") {
t.Errorf("dry-run should show recording API, got: %s", out)
}
}
func TestNotes_DryRun_BotIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error under --as bot: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/vc/v1/notes/{note_id}") {
t.Errorf("dry-run should show note.get API, got: %s", out)
}
}
// ---------------------------------------------------------------------------
// calendar-event-ids also flows under bot: a bot has a primary calendar, so the
// primary-calendar -> meeting_id -> recording/notes chain is expected to work.
// ---------------------------------------------------------------------------
func TestRecording_DryRun_BotIdentity_CalendarEventIDs(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCRecording, []string{"+recording", "--calendar-event-ids", "evt001", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error under --as bot: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "mget_instance_relation_info") {
t.Errorf("dry-run should show the primary-calendar resolution step, got: %s", out)
}
if !strings.Contains(out, "recording") {
t.Errorf("dry-run should show recording API, got: %s", out)
}
}
func TestNotes_DryRun_BotIdentity_CalendarEventIDs(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt001", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error under --as bot: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "mget_instance_relation_info") {
t.Errorf("dry-run should show the primary-calendar resolution step, got: %s", out)
}
}
// ---------------------------------------------------------------------------
// Identity-aware preflight: bot resolves TAT (empty local scopes in this stub),
// so an under-scoped UAT must not make --as bot fail. Reverting to
// auth.GetStoredToken(user) would break this.
// ---------------------------------------------------------------------------
func TestRecording_BotIdentityAwareScopePreflight(t *testing.T) {
cfg := defaultConfig()
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(nil, nil, &recordingIdentityTokenResolver{
uatScopes: "calendar:calendar:read", // deliberately missing vc:record:readonly
tatScopes: "", // bot/tenant: no local scope metadata
}, nil)
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("bot preflight must resolve tenant token, not the under-scoped user token; got error: %v", err)
}
}
// recordingIdentityTokenResolver returns different scopes for UAT vs TAT so
// bot identity-aware preflight can be pinned separately from user preflight.
type recordingIdentityTokenResolver struct {
uatScopes string
tatScopes string
}
func (r *recordingIdentityTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
scopes := r.uatScopes
if req.Type == credential.TokenTypeTAT {
scopes = r.tatScopes
}
return &credential.TokenResult{Token: "test-token", Scopes: scopes}, nil
}

View File

@@ -164,7 +164,7 @@ var VCDetail = common.Shortcut{
Description: "Get meeting details including note_id and minute_token by meeting IDs",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read", "vc:record:readonly"},
AuthTypes: []string{"user", "bot"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch", Required: true},

View File

@@ -536,7 +536,7 @@ var VCNotes = common.Shortcut{
Description: "Query meeting notes (via meeting-ids, minute-tokens, or calendar-event-ids)",
Risk: "read",
Scopes: []string{"vc:note:read"}, // minimum scope; additional per-flag scopes checked in Validate
AuthTypes: []string{"user", "bot"},
AuthTypes: []string{"user"},
Hidden: true, // hidden from --help; prefer vc +detail, minutes +detail, or note +detail
HasFormat: true,
Flags: []common.Flag{

View File

@@ -20,7 +20,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -92,13 +91,13 @@ var VCRecording = common.Shortcut{
Description: "Query minute_token from meeting-ids or calendar-event-ids",
Risk: "read",
Scopes: []string{"vc:record:readonly"},
AuthTypes: []string{"user", "bot"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
{Name: "calendar-event-ids", Desc: "calendar event instance IDs, comma-separated for batch"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.ExactlyOneTyped(runtime, "meeting-ids", "calendar-event-ids"); err != nil {
return err
}
@@ -117,14 +116,18 @@ var VCRecording = common.Shortcut{
case runtime.Str("calendar-event-ids") != "":
required = scopesRecordingCalendarEventIDs
}
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err == nil && result != nil && result.Scopes != "" {
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
appID := runtime.Config.AppID
userOpenID := runtime.UserOpenId()
if appID != "" && userOpenID != "" {
stored := auth.GetStoredToken(appID, userOpenID)
if stored != nil {
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
}
}
}
return nil

View File

@@ -10,12 +10,14 @@ import (
"fmt"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
keyring "github.com/zalando/go-keyring"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -139,15 +141,27 @@ func TestRecording_BatchLimit_CalendarEventIDs(t *testing.T) {
}
func TestRecording_Validate_MissingScope(t *testing.T) {
cfg := defaultConfig()
f, _, _, _ := cmdutil.TestFactory(t, cfg)
// TestFactory's default token resolver returns empty Scopes, which skips
// identity-aware preflight. Inject a resolver that returns an under-scoped
// user token so the MissingScopes path is exercised.
f.Credential = credential.NewCredentialProvider(nil, nil, &recordingScopedTokenResolver{
scopes: "calendar:calendar:read",
}, nil)
keyring.MockInit() // use in-memory keyring to avoid macOS keychain popups
t.Setenv("HOME", t.TempDir())
cfg := defaultConfig()
// Store a token that intentionally lacks the vc:record:readonly scope.
token := &auth.StoredUAToken{
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AccessToken: "test-user-access-token",
RefreshToken: "test-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
Scope: "calendar:calendar:read",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
}
if err := auth.SetStoredToken(token); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
t.Cleanup(func() { _ = auth.RemoveStoredToken(cfg.AppID, cfg.UserOpenId) })
f, _, _, _ := cmdutil.TestFactory(t, cfg)
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected missing_scope error, got nil")
@@ -175,16 +189,6 @@ func TestRecording_Validate_MissingScope(t *testing.T) {
}
}
// recordingScopedTokenResolver returns a token with caller-controlled scopes
// so tests can deterministically exercise the identity-aware scope preflight.
type recordingScopedTokenResolver struct {
scopes string
}
func (r *recordingScopedTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token", Scopes: r.scopes}, nil
}
// ---------------------------------------------------------------------------
// DryRun tests
// ---------------------------------------------------------------------------

View File

@@ -204,7 +204,7 @@ func (ab *authBridge) handleLogin(w http.ResponseWriter, _ *http.Request, body [
len(strings.Fields(scope)), req.Domains, clientID)
authResp, err := larkauth.RequestDeviceAuthorization(
ab.httpCl, ab.appID, ab.appSecret, ab.brand, scope, io.Discard,
context.Background(), ab.httpCl, larkauth.ClientAuth{AppID: ab.appID, AppSecret: ab.appSecret}, ab.brand, scope, io.Discard,
)
if err != nil {
jsonError(w, http.StatusBadGateway, "device authorization failed: "+err.Error())
@@ -255,7 +255,7 @@ func (ab *authBridge) handlePoll(w http.ResponseWriter, r *http.Request, body []
}()
result := larkauth.PollDeviceToken(
ctx, ab.httpCl, ab.appID, ab.appSecret, ab.brand,
ctx, ab.httpCl, larkauth.ClientAuth{AppID: ab.appID, AppSecret: ab.appSecret}, ab.brand,
req.DeviceCode, 5, 600, io.Discard,
)

View File

@@ -41,7 +41,7 @@
### 内容限制
- HTML 总长度上限为 900000 字符。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
- HTML 总长度上限为 500KB。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
## OKR block

View File

@@ -26,7 +26,10 @@ metadata:
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要为指定飞书文档**设置 / 修改密级标签secure label**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search``drive +move` 开始。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案创建目录、移动资源、申请权限都必须单独确认。
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;支持妙搭 apps 的 `/page/<token>` URL具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
- 妙搭 apps 评论场景:除新增全文/局部评论不支持外,评论列表、批量查询、解决/恢复、回复创建/读取/更新/删除、reaction 添加/删除等评论管理能力已支持;使用原生命令时文档类型传 `apps``file_type=apps`),裸 token 调 shortcut 时传 `--type apps`

View File

@@ -0,0 +1,273 @@
# 主题资料收集工作流:执行
由状态 `CONFIRM_EXECUTION``EXECUTE``VERIFY``RESTORE` 加载。
本文档负责最终写操作确认、目标创建、资源移动、验证、恢复行为、`RollbackSnapshotItem` 和执行日志。不得修改搜索、召回、分类规则或计划 schema。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理写操作确认、高风险操作、身份、认证和权限。
2. 按 [`lark-drive-create-folder.md`](lark-drive-create-folder.md) 创建 Drive 文件夹。
3. 按 [`lark-drive-move.md`](lark-drive-move.md) 执行 Drive 移动。
4. 按 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md) 创建 Wiki 节点。
5. 按 [`../../lark-wiki/references/lark-wiki-move.md`](../../lark-wiki/references/lark-wiki-move.md) 执行 Wiki 移动和 Drive 文档移动到 Wiki。
6. 按 [`../../lark-wiki/references/lark-wiki-move-to-drive.md`](../../lark-wiki/references/lark-wiki-move-to-drive.md) 将 Wiki 节点移出到 Drive 文件夹。
7. 按 [`lark-drive-delete.md`](lark-drive-delete.md) 删除本次 workflow 新建的 Drive 文件夹。
8. 按 [`../../lark-wiki/references/lark-wiki-node-delete.md`](../../lark-wiki/references/lark-wiki-node-delete.md) 删除本次 workflow 新建的 Wiki 节点。
9. 需要轮询异步任务时,按 [`lark-drive-task-result.md`](lark-drive-task-result.md) 执行。
10. `MovePlanItem` schema 由 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) 定义,本文件只消费已确认计划。
## 状态:`CONFIRM_EXECUTION`
进入条件:移动计划已准备,且用户要求执行。
必须:
1. 执行前展示所有写操作类别。
2. 将目标创建和资源移动分开展示。
3. 展示默认纳入的高相关资源。
4. 如有用户选择的中相关资源,也要展示。
5. 展示跳过分组和原因。
6. 明确展示跨容器移动。
7. 展示无移动权限和移动权限未知的资源数量。
8. 请求用户明确确认。
9. 确认前校验每个 `move_resource` 项都包含完整 `command_family``command_args`、权限快照和 `rollback_input`;缺失时必须返回 `PLAN_MOVE` 重新生成计划,不得在执行阶段补猜。
10. 只有 `move_permission_state=movable``target_write_state=confirmed` 的计划项可以列入“将移动”。
11. 对每个 `rollback_supported=false` 的计划项逐项展示标题、当前位置、目标位置、不可恢复原因和影响,不得只展示数量。
### 确认 UI
```text
请确认是否执行以下写操作:
本次搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
将创建:
- 目标名称|父级位置|目标类型
将移动:
- 标题|类型|当前位置|目标位置|原因
不会移动:
- 中相关未选择N 项
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
风险提示:
- 不可自动恢复N 项
- 标题|当前位置|目标位置|不可恢复原因|影响:移动成功后 workflow 无法自动搬回原位置,需要手动处理
- 如果搜索范围是所有当前身份可见资源,移动权限未知项不会移动。
确认后才会创建目标和移动资源。
如果不存在不可自动恢复项,请回复“确认执行”开始写操作。
如果存在不可自动恢复项,请回复“确认执行,包括不可自动恢复项”;普通“确认执行”不满足本次风险确认。
也可以回复“调整计划”返回选择资源,或回复“取消”结束流程。
```
如果用户修改选择或相关性分组,废弃当前 `move_plan_items` 并返回 `PLAN_MOVE` 重新生成计划;不得在 `CONFIRM_EXECUTION` 直接局部改写计划。
## 状态:`EXECUTE`
进入条件:用户明确确认写操作;存在 `rollback_supported=false` 的计划项时,用户已明确确认包括不可自动恢复项。
必须:
1. 只执行已确认 `MovePlanItem.command_family``command_args`;不得回查 `ResourceItem` 补齐或改写命令参数。
2. 当存在 `action_type=create_target``MovePlanItem` 时,先创建目标。
3. 目标创建后记录返回 token只允许把 `created_by_plan:<create_target plan_id>` 引用解析为该 token并把解析后的实际参数写入 `execution_journal`。不得重新搜索或猜测目标。
4. 目标 token 引用解析成功后再移动依赖该目标的资源;解析失败时停止依赖该创建目标的移动并记录 blocker不得替换为其他目标。
5. 执行任何写操作前,基于每个已确认计划项的 `rollback_input` 生成 `rollback_snapshot``rollback_supported=false` 且已有明确 `rollback_blocker` 的快照视为完整风险快照,不阻塞其他项。
6. 执行任何写操作前,初始化 `execution_journal`
7. 每次写操作尝试后记录 `execution_journal`
8. 单项失败后可继续执行相互独立的移动;目标创建失败时必须停止。
9. 不得移动 `permission_denied``no_move_permission``move_permission_unknown``unverifiable``low``unsupported_move_target` 项。
10. 不得移动 `move_permission_state!=movable``target_write_state!=confirmed` 的资源。
11. 如果移动命令返回权限错误,记录失败原因,不自动申请权限,不自动重试同一移动。
12. 如果 `rollback_supported=true``rollback_input` 缺少恢复所需字段,将该计划项标记为 `failed` / `plan_snapshot_incomplete` 并跳过;不得在未重新确认风险的情况下把它静默降级为不可恢复项,也不得阻塞其他独立项。
### 移动方式选择
| 来源 -> 目标 | 移动方式 |
|------------------|-------------|
| Drive resource -> Drive folder | `drive +move` |
| Drive document-like resource -> Wiki target | `wiki +move` 的 docs-to-wiki 模式;默认不可自动恢复 |
| Wiki node -> Wiki target | `wiki +move --node-token` |
| Wiki node -> Drive folder | `wiki +move-to-drive` |
### 执行顺序
1. 如有 `create_target` 项,先执行。
2. 按确认计划顺序执行 `move_resource` 项。
3. 如果命令返回 task ID执行异步任务轮询。
4. 输出写操作执行摘要。
### 进度 UI
批量较大时,按计数汇报进度:
```text
执行进度:已完成 <done_count>/<total_count>,成功 <success_count>,失败 <failed_count>。
当前操作:<title>
继续执行中,不需要你操作;如遇到需要确认的失败会单独提示。
```
## 状态:`VERIFY`
进入条件:执行完成。
必须:
1. 如果创建了目标,验证目标存在。
2. 能力支持时,验证已移动资源在目标位置可见。
3. 对比实际位置和 `move_plan_items`
4. 为每一项标记验证状态。
5. 只有当已有移动成功且存在严重不一致或失败时,才提供恢复选项。
6. 输出验证结果时,必须说明用户下一步可以结束流程、查看失败项,或在可恢复时选择恢复。
7. 如果出现 `async_pending`,先使用 `drive +task_result` 轮询确认;超过轮询限制后再报告 pending blocker。
### 验证结果
| 状态值 | 说明 |
|--------|------|
| `verified` | 资源已在目标位置可见。 |
| `not_found` | 目标位置未找到资源。 |
| `permission_unknown` | 当前身份无法确认结果。 |
| `async_pending` | 异步任务尚未完成,需要继续轮询。 |
| `failed` | 移动命令失败或结果不符合计划。 |
## 状态:`RESTORE`
进入条件:失败、不一致或用户明确要求恢复。
必须:
1. 只基于 `rollback_snapshot``execution_journal` 生成恢复计划。
2. 展示可恢复项和不可恢复项。
3. 执行恢复写操作前请求明确确认;确认内容必须包含反向移动和删除本次 workflow 新建目标。
4. 只恢复本次 workflow 移动过的资源。
5. 只恢复 `rollback_supported=true``rollback_eligible=true` 的移动项。
6. Drive / Wiki 跨容器移动、原父级 token 缺失等 `rollback_supported=false` 的项不得反向移动,也不得删除迁入后的文档。
7. 本次 workflow 成功创建的目标文件夹或 Wiki 节点必须纳入清理计划。
8. 删除 workflow 新建的 Wiki 目标节点时,必须使用 `wiki +node-delete --include-children=false --yes`,让已迁入的直接子文档保留到该节点父级层级。
9. 删除 workflow 新建的 Drive 文件夹前,必须先恢复或移出其中由本次 workflow 放入的资源;如果无法确认文件夹已安全可删,报告清理阻塞,不得用删除文件夹来删除用户资源。
### 恢复顺序
1. 先恢复 `rollback_supported=true``rollback_eligible=true` 的移动项。
2. 对全部 `rollback_supported=false` 的项,只记录“保留在当前目标位置,不回迁、不删除”和对应 blocker。
3. 再清理 `created_by_workflow=true` 的目标容器。
4. Wiki 新建目标清理使用 `--include-children=false`Drive 新建目标清理只在不会删除用户资源时执行。
### 恢复 UI
```text
可以尝试恢复本次已移动的资源:
可恢复:
- 标题|当前位置|原位置
不可自动恢复:
- 标题|当前位置|原位置|原因|影响:需要手动恢复
将清理本次新建目标:
- 名称|类型|清理方式
将保留在当前目标位置的跨容器迁入文档:
- 标题|当前位置|保留结果
是否执行恢复?
```
## RollbackSnapshotItem
```json
{
"snapshot_id": "稳定快照行 ID",
"plan_id": "对应 MovePlanItem.plan_id",
"resource_id": "对应 MovePlanItem.resource_id",
"source_kind": "drive|wiki",
"title": "资源标题",
"resource_type": "Drive 恢复命令需要的资源类型",
"original_token": "原始 Drive token",
"original_node_token": "原始 Wiki node token",
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"original_parent_token": "原始父级 token",
"original_space_id": "原始 Wiki space_id",
"original_path": "执行前路径",
"planned_target_parent_token": "计划目标父级 token",
"rollback_supported": "是否支持自动恢复",
"rollback_blocker": "不可自动恢复原因"
}
```
| 字段 | 说明 |
|-------|------|
| `snapshot_id` | 稳定快照行 ID。 |
| `plan_id` | 对应 `MovePlanItem.plan_id`,用于连接计划、快照和执行日志。 |
| `resource_id` | 对应稳定资源 ID用于审计计划来源。 |
| `resource_type` | `drive +move` 恢复时必须传入的 `--type`;非 Drive 恢复也保留原始资源类型。 |
| `original_token` / `original_node_token` | 执行前源资源身份。 |
| `original_parent_kind` / `original_parent_token` | 执行前父级位置。 |
| `rollback_supported` | 是否支持自动恢复。 |
| `rollback_blocker` | 不可自动恢复原因。 |
## 执行日志
每次写操作尝试都必须追加一条内部日志:
```json
{
"journal_id": "稳定日志行 ID",
"plan_id": "对应 MovePlanItem 的 plan_id",
"time": "ISO-8601",
"action_type": "create_target|move_resource|restore_resource|cleanup_target",
"operation": "create_folder|create_node|move_drive|move_wiki_node|move_wiki_to_drive|restore_drive|restore_wiki_node|delete_folder|delete_wiki_node",
"command_family": "drive +move|wiki +move|wiki +move-to-drive|drive +create-folder|wiki +node-create|drive +delete|wiki +node-delete",
"resolved_command_args": {"<arg>": "实际发送的参数"},
"title": "资源或目标名称",
"resource_type": "资源类型",
"input_token": "命令输入 token",
"input_node_token": "命令输入 Wiki node token",
"input_parent_token": "已知源父级 token",
"target_parent_token": "目标父级 token",
"returned_token": "命令返回 token",
"returned_node_token": "命令返回 Wiki node token",
"returned_parent_token": "返回父级 token",
"task_id": "异步任务 ID",
"next_command": "异步继续命令",
"created_by_workflow": "是否由本次 workflow 创建",
"rollback_eligible": "是否可进入自动恢复计划",
"status": "success|failed|pending",
"error": "失败原因"
}
```
字段说明:
| 字段 | 说明 |
|------|------|
| `journal_id` | 稳定日志行 ID。 |
| `plan_id` | 对应 `MovePlanItem`,用于把日志项匹配回原计划。 |
| `operation` | 细分操作类型,用于区分创建、移动和恢复。 |
| `resolved_command_args` | 从确认计划解析出的实际发送参数;用于审计 `created_by_plan:<plan_id>` 的唯一运行时替换。 |
| `resource_type` | 实际移动 / 恢复使用的资源类型。 |
| `input_token` / `input_node_token` | 命令实际输入的资源 token。 |
| `input_parent_token` | 执行前已知源父级 token。 |
| `target_parent_token` | 命令输入的目标父级 token。 |
| `returned_token` / `returned_node_token` | 命令返回的资源 token恢复时作为当前源。 |
| `returned_parent_token` | 命令返回的当前父级 token。 |
| `task_id` / `next_command` | 异步任务跟踪信息。 |
| `created_by_workflow` | 是否由本次 workflow 创建,用于后续清理判断。 |
| `rollback_eligible` | 是否可进入自动恢复计划。 |
| `status` | 写操作状态,异步未完成时为 `pending`。 |
除非用户要求查看技术调试细节,否则不要展示完整原始命令输出。

View File

@@ -0,0 +1,202 @@
# 主题资料收集工作流:召回
由状态 `SEARCH_RECALL``RECALL_ENHANCE` 加载。
本文档负责基础搜索召回、覆盖增强、query 证据、去重和 `CandidateItem`。不得解析目标移动 token、读取完整文档内容、判断相关性或执行写操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 按 [`lark-drive-search.md`](lark-drive-search.md) 处理 `drive +search` 语法、过滤条件、单批最多 5 页和身份语义;本 workflow 的全量续批规则见下文。
## 搜索原则
1. 默认使用 `drive +search --mine` 召回当前用户 owner / 负责的 Workspace 资源。
2. 除非用户本来就要求限定范围,否则不要要求用户指定文件夹或 Wiki 范围。
3. `SEARCH_RECALL``RECALL_ENHANCE` 必须保持为独立状态。
4. `SEARCH_RECALL` 使用用户原始关键词、`owner_scope` 和显式限制。
5. `RECALL_ENHANCE` 可以基于基础召回证据增加扩展 query且必须继承同一个 `owner_scope`
6. 每个候选项必须保留 query 证据,方便后续解释来源。
7. 单页或单个最多 5 页的 query 批次不代表完整覆盖;`has_more=true` 时必须保存 `next_page_token` 并自动开始下一批,直到 `has_more=false` 或出现阻塞。
8. 召回和增强召回可能耗时较长,执行超过 60 秒时必须输出进度提示,之后约每 60 秒提示一次。
9. 只有用户在 `CONFIRM_CONTEXT` 明确确认 `owner_scope=all_visible` 时,才允许移除 `--mine`
### 分页优先级与完成语义
1. 用户确认进入 `topic_move_collector` 即表示同意为本次收集任务执行完整召回;无需再要求用户额外说“全部 / 全量 / 继续翻”。本规则覆盖 `lark-drive-search.md` 的默认首屏交互规则。
2. 仍遵守 `lark-drive-search.md` 的单轮最多 5 页限制。每读取最多 5 页形成一个批次;批次结束且 `has_more=true` 时,保存 checkpoint并使用原 query、原过滤条件和返回的 `next_page_token` 自动开始下一批。
3. 自动续批不改变 workflow 状态,也不触发用户确认。执行超过约 60 秒时只输出进度。
4. 一个 query 只有在 `has_more=false` 时才是 `complete`。单批结束、达到 5 页或已有部分候选都不代表完成。
5. 当前状态的全部 query 都为 `complete` 后,才能进入下一状态。认证、权限、无效分页 token、连续重试失败或工具预算不足属于 blocker必须保留 checkpoint、报告部分召回并停在当前状态不得把部分结果当成完整召回继续分类。
### QueryRecallState
每个基础 / 增强 query 必须维护:
```json
{
"query_id": "稳定 query ID",
"query": "完整 query",
"recall_stage": "search_recall|recall_enhance",
"page_count": 0,
"batch_count": 0,
"next_page_token": "下一批起点",
"has_more": true,
"status": "pending|running|complete|blocked",
"blocker": "阻塞原因"
}
```
## 状态:`SEARCH_RECALL`
进入条件:用户已确认 `CONFIRM_CONTEXT`
必须:
1. 基于已确认的 `topic` 构造基础 query。
2. 应用默认 `owner_scope=mine``constraints` 中的显式限制。
3. 不隐式添加 `--folder-tokens``--space-ids`
4.`owner_scope=mine` 时,所有基础 query 必须带 `--mine`
5.`owner_scope=all_visible` 时,不带 `--mine`,并记录扩展召回风险。
6. 除非命令限制要求更低值,否则使用 `--page-size 20`
7. 每个基础 query 按每批最多 5 页执行;批次结束仍有更多结果时自动续批,并合并所有页面。
8. 记录基础统计query、搜索范围、页数、批次数、收集数量、重复数量、阻塞项。
9. 只有全部基础 query 的 `status=complete``has_more=false` 时,才进入 `RECALL_ENHANCE`;出现阻塞时保持在 `SEARCH_RECALL`
### 召回进度 UI
`SEARCH_RECALL``RECALL_ENHANCE` 持续超过约 60 秒时,输出当前进度:
```text
搜索进度:当前阶段 <SEARCH_RECALL|RECALL_ENHANCE>,已执行 <query_count> 个 query已读取 <page_count> 页,收集候选 <raw_count> 项,去重后 <unique_count> 项。继续搜索,不会创建或移动资源。
```
如果正在执行具体 query可补充
```text
当前 query<query>
```
### 基础 Query 规则
| 用户输入 | 基础 Query |
|------------|----------------|
| 单个关键词 | 直接作为 `--query`。 |
| 多个关键词组成一个短语 | 优先按用户输入的短语执行。 |
| 明确精确短语 | 保留引号。 |
| 明确排除词 | 保留负向词。 |
| 没有真实关键词,只有过滤条件 | 使用 `--query ""` 搭配过滤条件。 |
`SEARCH_RECALL` 中不得添加同义词、仅标题搜索、仅评论搜索或 OR 扩展。
### 基础召回输出
```text
基础召回完成:
- 使用 query
- 搜索范围:
- 应用限制:
- 收集候选:
- 去重后候选:
- 阻塞项:
下一步:继续执行覆盖增强,不需要你操作;不会创建或移动资源。
```
## 状态:`RECALL_ENHANCE`
进入条件:基础召回完成。
必须:
1. 基于已确认主题和基础召回证据生成增强 query。
2. 确保增强 query 可解释且不引入明显污染。
3. 每个增强 query 都必须继承 `owner_scope``owner_scope=mine` 时必须带 `--mine`
4. 每个 query 都必须按每批最多 5 页处理分页,并自动续批直到 `has_more=false`
5. 有稳定去重键时,按稳定去重键合并候选项。
6. 为每个候选项保留 `source_queries` 和命中证据。
7. 当 query 不再产生新候选,或出现工具预算 / API 阻塞时,停止增强。
### 召回阶段退出门禁
`RECALL_ENHANCE` 完成后,必须:
1. 确认全部基础和增强 query 的 `status=complete``has_more=false`,再固化完整 `candidate_items`,包含去重结果、`source_queries``match_channels``snippets``dedupe_status`
2.`current_state` 设置为 `RESOURCE_RESOLVE`
3. 加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md)。
4. 把完整 `candidate_items` 交给 `RESOURCE_RESOLVE`
5. 不得直接进入 `RELEVANCE_CLASSIFY``PLAN_MOVE` 或展示相关性结果。
6. 不得用搜索标题、摘要或 query 命中直接生成高 / 中 / 低相关分组。
### 增强策略
| 策略 | 说明 |
|----------|------|
| 精确短语 | 对明确短语使用 `"..."` 提高精确命中。 |
| `intitle:` | 对项目名、客户名、制度名、报表名等标题特征强的主题执行标题召回。 |
| `--only-title` | 当标题命中更可信时使用。 |
| `--only-comment` | 当主题可能只出现在评论讨论中时使用。 |
| 类型拆分 | 对 `docx``sheet``bitable``slides``file` 等分类型搜索,减少服务端排序偏差。 |
| 同义词 / 别名 | 使用业务上明确的同义词、简称、英文名、中文名。 |
| OR 扩展 | 对同一实体的别名做 OR 扩展。 |
| 负向词 | 对明显噪声使用 `-term`,但不能排除可能相关的主题词。 |
### Query 证据
每个候选项都要记录:
| 字段 | 说明 |
|-------|------|
| `source_queries` | 命中过该资源的 query 列表。 |
| `match_channels` | 命中位置,如 title、body、comment、metadata。 |
| `snippets` | 搜索返回的摘要或片段。 |
| `query_rank` | 资源在各 query 中的相对位置。 |
| `recall_stage` | `search_recall``recall_enhance`。 |
## 去重规则
必须:
1. 搜索响应提供 canonical token 时,优先使用 canonical token。
2. 对 Wiki 结果,不得只按 object token 去重;同一对象可能出现在多个 Wiki 节点中。
3. token 缺失时,使用 URL 作为 fallback。
4. 合并重复项时保留所有 query 证据。
5. 如果无法确定去重是否稳定,保留该项并设置 `dedupe_status=uncertain`
## CandidateItem
```json
{
"title": "资源标题",
"url": "资源链接",
"raw_type": "搜索返回类型",
"source_queries": ["query"],
"match_channels": ["title|body|comment|metadata"],
"snippets": ["命中片段"],
"page_rank": 1,
"dedupe_key": "候选去重键",
"dedupe_status": "stable|fallback|uncertain",
"recall_stage": "search_recall|recall_enhance"
}
```
| 字段 | 说明 |
|-------|------|
| `title` | 搜索结果标题。 |
| `url` | 资源访问链接。 |
| `raw_type` | 搜索返回的原始类型。 |
| `source_queries` | 命中过该资源的搜索 query。 |
| `match_channels` | 命中位置。 |
| `snippets` | 摘要或命中片段。 |
| `page_rank` | 当前 query 下的排序位置。 |
| `dedupe_key` | 候选去重键。 |
| `dedupe_status` | 去重可信度。 |
| `recall_stage` | 资源首次进入候选集的召回阶段。 |
## 阻塞项
缺少认证 / scope、`drive +search` 返回权限或策略阻塞、分页 token 无效、分页重试后仍无法继续,或工具预算不足以完成全部页面时,必须把对应 `QueryRecallState.status` 设置为 `blocked`,保留累计候选、页数和 `next_page_token`,停止并报告。阻塞解除后从 checkpoint 续跑;在全部 query 完成前不得进入资源解析或分类阶段。

View File

@@ -0,0 +1,231 @@
# 主题资料收集工作流:资源解析与内容验证
由状态 `RESOURCE_RESOLVE``CONTENT_VERIFY` 加载。
本文档负责资源解析、结构化父级、移动资格、内容验证和 `ResourceItem`。不得判断相关性、生成移动计划、创建目标、移动资源或执行恢复操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 按 [`lark-drive-inspect.md`](lark-drive-inspect.md) 处理 URL / token 解析。
3. 使用 `drive metas batch_query` 补齐 Drive 资源 owner、标题和 URL。
4. 必要时使用 `drive permission.members auth` 读取权限信号;该接口不提供 `full_access` / 移动权限的直接判定,不能把 `manage_public` 等同为可移动。
5. 按 [`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 处理 Wiki 节点解析。
6. 按 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md) 读取文档内容。
7. 需要验证 Sheet 内容时,按 [`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md) 执行。
## 进入解析与验证阶段前校验
进入本文档后,如果 `resource_items` 还不存在,当前状态必须是 `RESOURCE_RESOLVE`
禁止从 `candidate_items` 直接进入 `CONTENT_VERIFY``RELEVANCE_CLASSIFY`,也禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使候选项已有标题、URL、摘要或 token也必须依次执行 `RESOURCE_RESOLVE``CONTENT_VERIFY`;两个状态不得合并。
## 状态:`RESOURCE_RESOLVE`
进入条件:候选列表已准备。
必须:
1. 为每个 `CandidateItem` 生成稳定 `resource_id`,并转换为标准化 `ResourceItem`
2. 解析 canonical token、资源类型、URL、结构化当前父级、Wiki 节点身份和读取权限状态。
3. 对 Wiki 资源同时保留 `wiki_node_token``wiki_obj_token`
4.`move_method` 补齐 `owner_id``is_owner``source_move_state``source_parent_write_state``target_write_state``move_permission_state``move_permission_basis`
5. 基于 `target_location` 检测不支持的移动方向。
6. 未解析成功的资源仍保留在审核分组中,不得静默丢弃。
7. 即使搜索结果已经包含标题、URL 或 token也必须经过本状态生成 `ResourceItem`;不得从召回结果直接进入相关性分级。
8. 只有确认 `move_permission_state=movable``target_write_state=confirmed` 的资源,才能进入后续默认移动链路。
9. 解析耗时超过约 60 秒时,必须输出进度提示,之后约每 60 秒提示一次。
### 解析规则
| 候选类型 | agent 必须执行 |
|----------------|---------------|
| Drive URL / token | token 或类型不确定时,使用 `drive +inspect`。 |
| Wiki URL / token | 使用 `drive +inspect``wiki +node-get`;保留节点身份和对象身份。 |
| 文件夹候选 | 标记为容器;不要当作普通文档做内容验证。 |
| 快捷方式候选 | 能解析源资源时解析源资源;同时保留快捷方式身份。 |
| 无读取权限 | 保留可见元数据,并设置 `permission_state=denied`。 |
| 无移动权限或移动权限未知 | 保留可见元数据和召回证据,并设置对应 `move_permission_state`。 |
| 无法解析当前父级 | 设置 `current_parent_kind=unknown`,保留已知路径,后续计划项设置 `rollback_supported=false` 和明确 blocker不得编造父级 token。 |
### 资源解析进度 UI
`RESOURCE_RESOLVE` 持续超过约 60 秒时,输出当前进度:
```text
资源解析进度:已解析 <resolved_count>/<total_count> 项,已确认可移动 <movable_count> 项,无移动权限 <denied_count> 项,移动权限未知 <unknown_count> 项,解析失败 <failed_count> 项。
当前资源:<title>
继续解析中,不会创建或移动资源。
```
如果正在处理权限或 owner 元数据,可补充:
```text
当前步骤:解析 owner / 当前父级 / 移动资格。
```
`RESOURCE_RESOLVE` 完成后,输出摘要:
```text
资源解析完成:
- 候选总数N 项
- 可进入内容验证N 项
- 无移动权限N 项
- 移动权限未知N 项
- 解析失败或无读取权限N 项
下一步会对可移动资源做内容验证;不会创建或移动资源。
```
### 资源解析出口门禁
`RESOURCE_RESOLVE` 完成后必须:
1.`content_verify_completed` 重置为 `false`
2. 将下一状态设置为 `CONTENT_VERIFY`,不得设置为 `RELEVANCE_CLASSIFY``PLAN_MOVE`
3. 不得在本状态生成 `relevance``relevance_groups` 或移动计划。
4. 即使可读取正文的资源数量为 0也必须进入 `CONTENT_VERIFY`,为每项记录跳过验证原因并输出验证摘要。
### 移动资格判定
`owner` 只能作为部分权限证据,不得单独把资源判为 `movable``RESOURCE_RESOLVE` 必须先按 `move_method` 记录以下独立状态:
| 字段 | 说明 |
|------|------|
| `source_move_state` | 当前身份是否确认可以对源资源执行对应移动Drive owner 只可作为 Drive 源资源可管理的证据Wiki 底层资源 owner 不能证明 Wiki 节点可移动。 |
| `source_parent_write_state` | 当前身份是否确认可编辑源位置;仅 `drive_move` 必须确认,其他移动方式为 `not_required`。 |
| `target_write_state` | 当前身份是否确认可写目标位置;待创建目标以父级位置的创建 / 写入权限为准。 |
#### 按移动方式的权限矩阵
| `move_method` | `source_move_state=confirmed` 的证据 | `source_parent_write_state` | `target_write_state` |
|---------------|--------------------------------------|-----------------------------|----------------------|
| `drive_move` | 当前用户是可靠解析出的 Drive 资源 owner或有明确资源可管理证据 | 必须为 `confirmed` | 必须为 `confirmed` |
| `wiki_move_docs_to_wiki` | 有明确的 Drive 文档直接迁入权限;仅 owner 元数据不足以证明可直接迁入 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
| `wiki_move_node` | 有明确的 Wiki 节点 / 源空间移动权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
| `wiki_move_to_drive` | 有明确的 Wiki 节点移出权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Drive 文件夹可写 |
#### 聚合顺序
1. 目标方向或资源类型不支持时,设置 `move_permission_state=denied``move_permission_basis=["unsupported_direction"]`
2. 任一必需状态为 `denied` 时,设置 `move_permission_state=denied`,并在 `move_permission_basis` 记录 `source_denied``source_parent_denied``target_denied`
3. 任一必需状态为 `unknown` 时,设置 `move_permission_state=unknown`,并记录对应的 `source_unknown``source_parent_unknown``target_unknown`
4. 只有权限矩阵中的全部必需状态都为 `confirmed` 时,才能设置 `move_permission_state=movable``move_permission_basis=["permission_matrix_confirmed"]`
注意:
1. `drive permission.members auth` 不提供 `full_access``move` action不能用 `view``edit``share``manage_public` 结果推断源位置或目标位置可写。
2. `target_write_state=unknown|denied` 的资源不得进入高 / 中相关可执行分组或移动计划。
3. `move_permission_state=unknown` 的资源默认不进入内容验证、相关性高 / 中分组或移动计划。
4.`owner_scope=mine` 但解析出的 owner 不是当前用户时,将该资源视为异常候选,设置 `source_move_state=unknown``move_permission_state=unknown`,不得加入移动计划。
## 状态:`CONTENT_VERIFY`
进入条件:资源列表已准备。
必须:
1. 本状态不可跳过,也不得与 `RESOURCE_RESOLVE``RELEVANCE_CLASSIFY` 合并;没有可读取正文的资源时仍须执行。
2. 只在资源解析后读取支持的内容。
3. 按数量、大小和类型能力限制读取范围。
4. 结合搜索证据和内容证据;除非标题精确且足够强,否则不要仅凭标题判为高相关。
5. 将不可读取资源标记为 `unverifiable``permission_denied`
6. 不得自动申请权限。
7. 为每个资源写入验证状态:已读取内容证据、仅可使用搜索证据、无权限、无移动权限、移动权限未知、无法验证或不支持内容验证。
8.`move_permission_state=denied|unknown` 的资源,不再读取正文内容,写入跳过验证原因并保留召回证据;写入跳过原因属于执行本状态,不等于跳过本状态。
9. 所有资源都有验证状态或跳过原因后,将 `content_verify_completed` 设置为 `true` 并输出验证摘要。
10. `content_verify_completed=true` 前不得进入 `RELEVANCE_CLASSIFY`
### 验证方式
| 资源类型 | 验证方式 |
|---------------|---------------------|
| `docx` / `doc` | 允许时使用 `docs +fetch --api-version v2`。 |
| `sheet` | 使用 `sheets +find` 查关键词证据,或用 `sheets +read` 读取有界范围。 |
| `bitable` | 只有必要且已加载 Base 能力时验证。 |
| `slides` | 除非具备幻灯片读取能力,否则使用元数据 / 预览 / 标题证据。 |
| `file` | 仅在支持时使用标题、元数据、预览或导出文本。 |
| `wiki` 节点 | 按 `obj_type` 验证底层对象;节点本身不是内容 token。 |
| `folder` | 除非用户明确要移动容器,否则通常不作为主题证据移动。 |
### 内容验证完成 UI
完成 `CONTENT_VERIFY` 后必须输出:
```text
内容验证完成:
- 已读取内容证据N 项
- 仅复用搜索证据N 项
- 因无权限或移动资格跳过N 项
- 无法验证或不支持验证N 项
下一步会基于以上证据进行相关性分组;不会创建或移动资源。
```
如果没有任何资源可以读取正文,仍须输出该摘要,并明确说明所有资源采用的搜索证据或跳过原因。
### 内容验证出口门禁
`CONTENT_VERIFY` 完成后必须:
1. 确认 `content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过原因。
2. 将下一状态设置为 `RELEVANCE_CLASSIFY`
3. 加载 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md)。
4. 不得直接进入 `PLAN_MOVE`
## ResourceItem
```json
{
"resource_id": "稳定资源 ID",
"title": "资源标题",
"resource_type": "doc|docx|sheet|bitable|file|folder|wiki|slides|shortcut",
"url": "资源链接",
"canonical_token": "标准资源 token",
"wiki_node_token": "Wiki 节点 token",
"wiki_obj_token": "Wiki 底层对象 token",
"wiki_obj_type": "Wiki 底层对象类型",
"space_id": "知识空间 ID",
"current_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"current_parent_token": "当前父级 token",
"current_parent_space_id": "当前父级 Wiki space_id",
"current_path": "用于展示的当前位置",
"owner_id": "资源 owner open_id",
"is_owner": "true|false|unknown",
"permission_state": "readable|denied|unknown",
"source_move_state": "confirmed|unknown|denied",
"source_parent_write_state": "confirmed|unknown|denied|not_required",
"move_permission_state": "movable|denied|unknown",
"move_permission_basis": ["权限矩阵证据或阻塞原因"],
"target_write_state": "confirmed|unknown|denied",
"item_resolve_status": "resolved|partial|failed",
"content_verify_state": "verified|search_evidence_only|skipped_by_move_permission|permission_denied|unverifiable|unsupported",
"content_evidence": ["证据"],
"relevance": "high|medium|low|permission_denied|no_move_permission|move_permission_unknown|unverifiable|unsupported_move_target"
}
```
| 字段 | 说明 |
|-------|------|
| `canonical_token` | 内容读取、Drive 对象操作或底层对象操作使用的标准 tokenWiki 节点移动不得使用该字段。 |
| `resource_id` | 资源解析时生成的稳定 ID用于连接 `ResourceItem``MovePlanItem`。 |
| `wiki_node_token` | Wiki 节点身份,用于 Wiki 节点移动。 |
| `wiki_obj_token` | Wiki 节点背后的真实文档 token。 |
| `current_parent_kind` / `current_parent_token` / `current_parent_space_id` | 结构化执行前父级,用于 `already_at_target` 判断和恢复;未知值不得猜测。 |
| `current_path` | 仅用于用户展示的当前位置,不得代替父级 token。 |
| `owner_id` | 资源 ownerDrive 资源优先来自 `drive metas batch_query`Wiki 节点优先来自 `wiki +node-get`。 |
| `is_owner` | 当前用户是否为资源 owner。 |
| `permission_state` | 当前身份下的读取权限状态。 |
| `source_move_state` | 当前身份是否确认能对源资源执行所选 `move_method`;必须按权限矩阵判断。 |
| `source_parent_write_state` | Drive 内移动所需的源位置编辑状态;非 `drive_move``not_required`。 |
| `move_permission_state` | 权限矩阵聚合结果;只有 `movable` 且目标写入状态为 `confirmed` 才可进入默认移动链路。 |
| `move_permission_basis` | 移动资格判断依据,用于解释为什么纳入或排除。 |
| `target_write_state` | 目标位置是否确认可写。 |
| `item_resolve_status` | 资源项解析状态;不要和 `TargetLocation.target_resolve_status` 混用。 |
| `content_verify_state` | 内容验证状态或跳过验证原因。 |
| `content_evidence` | 支撑相关性判断的命中证据。 |
| `relevance` | 相关性和可执行性分组。 |

View File

@@ -0,0 +1,248 @@
# 主题资料收集工作流:审核与计划
由状态 `RELEVANCE_CLASSIFY``PLAN_MOVE` 加载。
本文档负责相关性分级、审核 UI、移动计划生成和 `MovePlanItem`。不得重新执行资源解析或内容验证,也不得创建目标、移动资源或执行恢复操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 输入契约
进入本文档前必须已有:
1. `resource_items`,且每个 `ResourceItem` 已包含稳定 `resource_id`、资源类型、移动所需 token、结构化当前父级、权限状态、内容验证状态和证据。
2. `content_verify_completed=true`
3. 每个资源都有内容证据、搜索证据复用说明或明确跳过原因。
`ResourceItem` schema 和字段生成规则由 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 负责。只要上述输入契约完整,本状态不得为重复读取 schema 而重新加载或执行前一阶段文档。
如果输入字段缺失、资源需要重新解析或用户要求重新读取证据,废弃受影响的相关性和计划结果,返回 `RESOURCE_RESOLVE``CONTENT_VERIFY`,并加载资源解析与内容验证文档;不得在本状态补猜。
## 状态:`RELEVANCE_CLASSIFY`
进入条件:`CONTENT_VERIFY` 已完成,`content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过验证原因。
禁止条件:
1. 只有 `candidate_items`,没有 `resource_items`
2. 资源未经过 `RESOURCE_RESOLVE`
3. 资源没有 `RESOURCE_RESOLVE` 写入的移动资格状态。
4. 资源没有 `CONTENT_VERIFY` 写入的验证状态或跳过验证原因。
5. 上一完成状态是 `RESOURCE_RESOLVE`,或 `content_verify_completed` 不为 `true`
必须将每个资源归入且只归入一个分组:
| 分组 | 说明 | 默认移动 |
|-------|------|--------------|
| `high` | 可移动资源,且主题或内容直接命中,有明确标题 / 正文 / 表格 / 评论证据。 | 是 |
| `medium` | 可移动资源,可能相关,但证据不足或只命中弱相关片段。 | 否,需用户选择 |
| `low` | 可移动资源,弱相关或噪声,保留展示但不建议移动。 | 否 |
| `permission_denied` | 当前身份无权读取或解析,不能验证内容。 | 否 |
| `no_move_permission` | 已确认当前身份不具备移动资格。 | 否 |
| `move_permission_unknown` | 无法确认当前身份是否具备移动资格。 | 否 |
| `unverifiable` | 类型或工具限制导致无法验证内容。 | 否 |
| `unsupported_move_target` | 目标方向或资源类型不支持移动。 | 否 |
`high``medium``low` 只能包含 `move_permission_state=movable``target_write_state=confirmed` 的资源。
判为高相关至少需要一个强证据:
1. 标题或内容中出现精确主题短语。
2. 多个主题词在相关上下文中同时出现。
3. Sheet / 表格单元格明确匹配用户主题。
4. 用户明确提供的文档名或项目别名命中。
中相关示例:
1. 标题包含一个主题词,但内容无法确认。
2. 搜索摘要看起来相关,但无法完整读取。
3. 别名命中合理但证据不够强。
## 审核 UI
必须展示每个分组中的资源名称。
默认展示规则:
1. 展开 `high``medium`
2. 折叠 `low``permission_denied``no_move_permission``move_permission_unknown``unverifiable``unsupported_move_target`,但展示数量并允许展开。
3. 每个可见资源展示标题、类型、当前位置、证据和默认动作。
4. 除非用户要求技术细节,否则不展示原始 token。
示例:
```text
筛选结果:
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
高相关(默认移动):
- 标题|类型|证据|当前位置
中相关(需你勾选后才移动):
- 标题|类型|证据|当前位置
未默认移动:
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
你可以选择:
1. 确认按默认规则生成移动计划。
2. 勾选要加入计划的中相关资源。
3. 要求把某些资源移到其他分组或从计划中移除。
4. 展开低相关 / 无权限 / 无移动权限 / 移动权限未知 / 无法验证 / 不支持移动分组查看名称。
```
### 用户调整规则
如果用户不同意相关性结果,必须基于用户要求更新 `relevance_groups`,再重新展示分组结果并重新生成后续移动计划。
典型调整包括:
1.`high` 中移除某个资源。
2.`medium` 中某个资源提升为 `high`
3. 将某个资源标为 `low` 或不移动。
4. 要求重新读取证据或重新判断一批资源。
5. 要求重新确认某些资源的移动权限。
用户调整后:
1. 旧的 `move_plan_items` 立即失效。
2. 必须先输出“调整后相关性结果”,展示被调整项、各分组数量和高 / 中相关资源名称。
3. 不得只回复“已调整”,也不得直接跳到 `CONFIRM_EXECUTION`
4. 必须基于新的 `relevance_groups` 重新执行 `PLAN_MOVE`
5. 不得把 `no_move_permission``move_permission_unknown` 资源直接提升到 `high` / `medium`;必须先回到 `RESOURCE_RESOLVE`,加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 取得可移动证据。
### 调整后结果 UI
```text
已按你的要求调整相关性结果:
- <标题><原分组> -> <新分组>
调整后分组:
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
高相关默认移动N 项
- 标题|类型|证据|当前位置
中相关需你勾选后才移动N 项
- 标题|类型|证据|当前位置
未默认移动:
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
接下来会基于这个调整后的结果重新生成移动计划;你也可以继续调整。
```
## 状态:`PLAN_MOVE`
进入条件:相关性分组已准备。
必须:
1.`target_location.create_required=true` 时,纳入目标创建计划。
2. 生成移动计划前,比较规范化的当前父级与目标父级;已在目标位置的资源生成 `skip_resource`,设置 `skip_reason=already_at_target`,不得生成移动命令。
3. 默认纳入全部 `high``move_permission_state=movable``target_write_state=confirmed` 的资源。
4. 只有用户明确选择时,才纳入 `medium``move_permission_state=movable``target_write_state=confirmed` 的资源。
5. 默认排除 `low``permission_denied``no_move_permission``move_permission_unknown``unverifiable``unsupported_move_target`
6. 为每个跳过项生成 `skip_reason`
7. 为每个计划项生成稳定 `plan_id`,并使用 `resource_id` 连接对应资源;不得按标题或临时 token 猜测关联。
8.`command_family` 保存完整、不可变的 `command_args`;不得把 Wiki 底层对象 token 当作 Wiki 节点移动 token。
9. 为每个 `move_resource` 项复制执行前恢复所需的完整 `rollback_input`,使确认计划不依赖运行时回查 `ResourceItem`
10. 当前父级无法结构化解析或属于 Drive / Wiki 跨容器移动时,设置 `rollback_supported=false` 和明确 `rollback_blocker`;该单项仍可进入确认,但必须逐项展示不可恢复风险,不得阻塞其他独立项。
11. 停止并等待用户选择或执行意图。
12. 不得为 `move_permission_state!=movable``target_write_state!=confirmed` 的资源生成 `move_resource` 计划项。
### 已在目标位置判定
1. `drive_move` 比较 `current_parent_kind` 和目标 Drive 父级,并比较规范化后的 `current_parent_token` / root 标识。
2. `wiki_move_node` 比较 `current_parent_space_id``current_parent_kind``current_parent_token`Wiki 空间根节点使用明确的 root 标识,不得用空字符串和未知状态混淆。
3. 只有父级类型、space ID适用时和 token 都已解析且相等时,才能设置 `skip_reason=already_at_target`;父级未知时不得猜测为相等。
### 移动 token 选择
| `command_family` | `command_args` 必须包含 |
|------------------|---------------------------|
| `drive +move` | `file_token``type``folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空且目标类型为 root。 |
| `wiki +move`node | `node_token`,以及 `target_space_id``target_parent_token`;可选 `source_space_id`。不得使用 `wiki_obj_token` 代替 `node_token`。 |
| `wiki +move`docs-to-wiki | `obj_type``obj_token``target_space_id`、可选 `target_parent_token`,并显式保存 `apply=false`。 |
| `wiki +move-to-drive` | `node_token``folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空。 |
| `drive +create-folder` | `name`、父级 `folder_token`;创建在 Drive root 时显式记录父级为空。 |
| `wiki +node-create` | `space_id``title``obj_type`、可选 `parent_node_token`。 |
| `none` | 不执行命令,保留 `skip_reason`。 |
目标由本次 workflow 创建时,对应目标参数保存 `created_by_plan:<create_target plan_id>` 引用。`EXECUTE` 只允许把该引用替换为对应创建计划返回的 token不得重新搜索或猜测目标。
### 计划 UI
```text
移动计划已生成:
- 默认将移动高相关N 项
- 你已选择中相关N 项
- 其中不可自动恢复N 项
- 已在目标位置N 项
- 不会移动N 项
- 无移动权限N 项
- 移动权限未知N 项
你可以回复“确认执行”,也可以继续调整分组、增减中相关资源,或取消本次移动。
```
## MovePlanItem
```json
{
"plan_id": "稳定计划项 ID",
"resource_id": "对应 ResourceItem.resource_idcreate_target 为空",
"action_type": "create_target|move_resource|skip_resource|unsupported",
"title": "资源或目标名称",
"resource_type": "源资源类型",
"move_method": "drive_move|wiki_move_node|wiki_move_docs_to_wiki|wiki_move_to_drive|none",
"command_family": "具体 shortcut 命令或 none",
"command_args": {
"<arg>": "按 command_family 参数表保存的完整、类型明确的参数"
},
"source_path": "用户确认时展示的源位置",
"target_path": "用户确认时展示的目标位置",
"move_permission_state": "movable|denied|unknown|not_required",
"target_write_state": "confirmed|unknown|denied",
"reason": "纳入或跳过原因",
"skip_reason": "already_at_target 或其他跳过原因",
"rollback_input": {
"source_kind": "drive|wiki",
"original_token": "原始 Drive / obj token",
"original_node_token": "原始 Wiki node token",
"resource_type": "恢复命令需要的资源类型",
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"original_parent_token": "原始父级 token",
"original_space_id": "原始 Wiki space_id",
"original_path": "执行前路径"
},
"rollback_supported": "是否支持自动恢复",
"rollback_blocker": "不可自动恢复原因",
"execution_status": "pending|success|failed|skipped"
}
```
| 字段 | 说明 |
|-------|------|
| `plan_id` | 稳定计划项 ID用于连接计划、快照和执行日志。 |
| `resource_id` | 稳定资源 ID用于连接确认计划和解析结果`create_target` 为空。执行阶段不得依赖该关联回查可变参数。 |
| `action_type` | 计划动作类型。 |
| `move_method` | 实际使用的移动方式。 |
| `command_family` / `command_args` | 用户确认的完整写命令及参数快照;确认后保持不可变。目标待创建时只允许使用 `created_by_plan:<plan_id>` 引用。 |
| `move_permission_state` / `target_write_state` | 用户确认时的权限门禁快照;`move_resource` 必须分别为 `movable` / `confirmed``create_target` 的移动权限为 `not_required`,但父级写入权限仍必须为 `confirmed`。 |
| `rollback_input` | 从 `ResourceItem` 复制出的完整恢复输入;仅 `move_resource` 必填,生成确认计划后不得再回查或猜测。 |
| `rollback_supported` | 是否支持自动恢复。 |
| `rollback_blocker` | 不可自动恢复原因;跨容器移动使用 `cross_container_permission_model_not_losslessly_restorable`,原父级 token 缺失使用 `original_parent_token_unavailable`。 |
| `execution_status` | 执行状态。 |

View File

@@ -0,0 +1,174 @@
# 主题资料收集工作流:输入与目标确认
由状态 `PARSE_INPUT``RESOLVE_TARGET``CONFIRM_CONTEXT` 加载。
本文档负责用户输入解析、目标位置解析、搜索前确认和 `TargetLocation`。不得执行搜索召回、资源分类、目标创建或资源移动。
本文档只服务 `topic_move_collector`。进入本文档后必须确认 `workflow_id=topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 解析 Drive 目标时,遵循 [`lark-drive-inspect.md`](lark-drive-inspect.md)、[`lark-drive-create-folder.md`](lark-drive-create-folder.md) 和 [`lark-drive-search.md`](lark-drive-search.md)。
3. 解析 Wiki 目标时,遵循 [`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)、[`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 和 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md)。
## 状态:`PARSE_INPUT`
进入条件workflow 被触发。
必须:
1. 提取 `topic``target``identity``owner_scope``constraints`
2.`topic``target` 视为必填字段。
3. 除非用户明确要求 bot / app 视角,否则 `identity` 默认使用用户身份。
4. 默认 `allow_cross_container_move=true`,但必须在 `CONFIRM_CONTEXT` 展示。
5. 默认 `owner_scope=mine`,表示只搜索当前用户 owner / 负责的资源。
6. 只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时才设置 `owner_scope=all_visible`
7. 除非用户明确提供限制,否则 `constraints` 保持为空。
8. 如果缺少 `topic``target`,只提出最小澄清问题。
### 输入字段
| 字段 | 说明 |
|-------|------|
| `topic` | 用户要查找的主题、关键词、内容线索、同义词、缩写、排除词。 |
| `target` | 归档目标,可以是已有 Drive 文件夹、已有 Wiki 节点、待创建 Drive 文件夹或待创建 Wiki 节点。 |
| `identity` | 执行身份,默认 `--as user`。 |
| `owner_scope` | 搜索 owner 范围,默认 `mine``all_visible` 仅在用户明确要求扩展到所有可见资源时使用。 |
| `constraints` | 用户显式给出的类型、时间、创建人、评论、标题、范围等限制。 |
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须确认。 |
### 澄清模板
```text
我还需要补齐两个信息后才能开始:
1. 要查找的主题 / 关键词 / 内容线索是什么?
2. 找到后要移动到哪个 Drive 文件夹或 Wiki 节点?如果需要新建目标,也请说明父级位置和新名称。
```
## 状态:`RESOLVE_TARGET`
进入条件:`topic``target` 已获得。
必须:
1. 将已有目标解析为具体 token。
2. 如果目标需要创建,只解析父级位置和新目标名称。
3. 在本状态中不得创建文件夹或 Wiki 节点。
4. 分别保留 Drive 文件夹 token、Wiki 节点 token、Wiki 对象 token、space ID 和 parent token。
5. 如果目标 URL / token 存在,但当前身份无法读取或解析目标位置,设置 `target_resolve_status=permission_denied`,保持在 `RESOLVE_TARGET` 并等待用户更换目标或结束;不得进入搜索。
6. 如果已知移动方向不支持,尽早标记。
### 目标解析
| 条件 | agent 必须执行 | 设置 `target_type` |
|-----------|---------------|-------------------|
| 已有 Drive 文件夹 URL 或 token | 有 URL 时用 `drive +inspect` 解析;保留 `folder_token` | `drive_folder` |
| 已有 Wiki 节点 URL 或 token | 用 `wiki +node-get``drive +inspect` 解析;保留 `wiki_node_token``space_id` | `wiki_node` |
| 在已知父级下新建 Drive 文件夹 | 解析父文件夹;保存新文件夹名称;不创建 | `new_drive_folder` |
| 在已知父级下新建 Wiki 节点 | 解析知识空间和可选父节点;保存新节点标题;不创建 | `new_wiki_node` |
| 以 Wiki 空间根节点作为目标 | 解析 `space_id`parent token 可以为空 | `wiki_space` |
| 目标名称有歧义 | 仅在必要时搜索或列出候选;展示候选并等待用户选择 | `unknown` |
### 目标解析状态
| 条件 | `target_resolve_status` |
|------|--------------------------|
| 目标已解析,或待创建目标的父级位置已解析 | `resolved` |
| 目标名称有歧义、候选不唯一,或 `target_type=unknown` 需要用户选择 | `ambiguous` |
| 已知目标方向或目标类型不支持本 workflow | `unsupported` |
| 目标 URL / token 存在,但当前身份无权读取、解析或确认目标位置 | `permission_denied` |
### 目标解析出口门禁
| `target_resolve_status` | 下一状态 | agent 必须执行 |
|-------------------------|----------|----------------|
| `resolved` | `CONFIRM_CONTEXT` | 展示已解析目标并进入搜索前确认。 |
| `ambiguous` | 保持 `RESOLVE_TARGET` | 展示候选并等待用户选择;不得进入 `CONFIRM_CONTEXT`。 |
| `unsupported` | 保持 `RESOLVE_TARGET` | 展示不支持原因,等待用户更换目标或结束;不得搜索。 |
| `permission_denied` | 保持 `RESOLVE_TARGET` | 展示权限 blocker等待用户更换目标或结束不得搜索。 |
用户提供新目标后,重新执行 `RESOLVE_TARGET`。只有新的解析结果为 `resolved`,才能进入 `CONFIRM_CONTEXT`;用户选择结束时进入 `DONE`
### 跨容器规则
| 来源 -> 目标 | 默认规则 |
|------------------|---------|
| Drive 资源 -> Drive 文件夹 | 支持,使用 `drive +move`。 |
| Drive 文档类资源 -> Wiki 节点 / 空间 | 资源类型支持时,使用 `wiki +move`。 |
| Wiki 节点 -> Wiki 节点 / 空间 | 支持,使用 `wiki +move --node-token`。 |
| Wiki 节点 -> Drive 文件夹 | `wiki +move-to-drive`。 |
## 状态:`CONFIRM_CONTEXT`
进入条件:`target_resolve_status=resolved`
必须:
1. 展示主题、目标、身份、搜索 owner 范围、限制和目标解析字段。
2. 说明下一步只进行搜索 / 读取。
3. 说明是否计划创建目标,但尚未执行。
4. 展示是否允许跨容器移动。
5. 在进入 `SEARCH_RECALL` 前停止并等待用户确认。
6. 如果 `owner_scope=all_visible`,明确提示候选数量可能较多,且可能包含无法移动的资源。
### 确认 UI
```text
我先确认本次收集任务。
查找主题:
目标位置:
目标解析:
执行身份:
搜索范围:
可选限制:
跨容器移动:
下一步操作:只进行搜索和读取验证,不创建目标,不移动资源。
请确认是否按以上信息开始搜索?
```
默认搜索范围文案:
```text
搜索范围:当前用户 owner / 负责的资源
```
扩展搜索范围文案:
```text
搜索范围:所有当前身份可见资源
风险提示:候选数量可能较多,且部分资源可能无法移动;后续仍会经过资源解析和内容验证。
```
如果用户修改任一字段,更新 `topic``target_location``owner_scope``constraints`,然后只重新执行受影响的 setup 状态,再次展示确认信息。
## TargetLocation
```json
{
"target_type": "drive_folder|wiki_node|wiki_space|new_drive_folder|new_wiki_node|unknown",
"target_token": "已有目标的 folder_token 或 wiki_node_token",
"parent_token": "待创建目标的父级 folder_token 或 wiki_node_token",
"space_id": "知识库空间 ID",
"target_name": "待创建目标名称",
"create_required": false,
"allow_cross_container_move": true,
"target_resolve_status": "resolved|ambiguous|unsupported|permission_denied"
}
```
| 字段 | 说明 |
|-------|------|
| `target_type` | 目标位置类型,用于决定后续创建和移动命令。 |
| `target_token` | 已有目标的可执行 token。 |
| `parent_token` | 待创建目标的父级位置 token。 |
| `space_id` | Wiki 目标所属知识空间 ID。 |
| `target_name` | 待创建目标的名称。 |
| `create_required` | 是否需要在 `EXECUTE` 阶段创建目标。 |
| `allow_cross_container_move` | 是否允许 Drive / Wiki 之间移动。 |
| `target_resolve_status` | 目标位置解析状态;不要和 `ResourceItem.item_resolve_status` 混用。 |

View File

@@ -0,0 +1,202 @@
# 主题资料收集工作流
Workflow id: `topic_move_collector`
Risk / Structure: `R2-R3` / `S3`
本文档实现已注册的主题资料收集 workflow。执行前必须先阅读 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。
本文档负责定义本 workflow 的全局约束、状态机和渐进加载关系。具体阶段规则放在配套文档中,只有进入对应状态时才加载。
配套文档只是本 workflow 的引用文件,不是独立 skill。不要把用户请求直接路由到某个配套文档。
## 必读上下文
执行本 workflow 前,必须先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),用于处理身份、认证、权限和写操作确认规则。
按阶段渐进加载其他 skill / 引用文档:
- 目标是 Wiki 或个人文档库:[`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)
- 需要读取文档内容:[`../../lark-doc/SKILL.md`](../../lark-doc/SKILL.md) 和 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md)
- 需要验证 Sheet 内容:[`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md)
- 需要 Drive 搜索:[`lark-drive-search.md`](lark-drive-search.md)
- 需要资源解析:[`lark-drive-inspect.md`](lark-drive-inspect.md)
## 适用范围
本 workflow 用于根据用户给出的主题、关键词或内容线索,在云空间 / 云盘 / Wiki / 电子表格等 Workspace 资源中查找相关资料,并在用户确认后统一移动到指定 Drive 文件夹或 Wiki 节点下。
适用触发语包括:
- "帮我找到和某主题相关的文档并放到这个文件夹"
- "把所有关于某项目的资料收集到知识库节点下"
- "找出包含某内容的资料,确认后移动到新建目录"
- "按这个关键词搜索我负责的资料,把相关资料归档"
默认搜索范围是当前用户 owner / 负责的 Workspace 资源,即 `owner_scope=mine`。只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时才使用 `owner_scope=all_visible` 进入扩展召回模式。
不要求用户先限定文件夹或知识库范围。只有用户明确指定范围时,才使用 `--folder-tokens``--space-ids` 或其他显式限制。
## 非目标
默认不生成:
- 长篇研究报告
- 内容总结文档
- Sheet 清单或统计看板
- 自动权限治理报告
默认禁止执行:
- 未确认前创建文件夹或 Wiki 节点
- 未确认前移动资源
- 删除资源、重命名资源或修改公开权限
- 自动批量申请权限
- 把无权限或无法验证的资源加入移动计划
- 把移动权限未知或不具备移动资格的资源加入移动计划
如果用户明确要求把结果写入 Sheet / Doc切到对应专项能力本 workflow 的默认产物是移动后的资源归档结果。
## Agent 执行约束
触发本 workflow 后agent 必须:
1. 按“执行状态机”的顺序执行。
2. 维护“运行时状态”中的字段。
3. 执行某个状态前,先读取本文档 `## 渐进加载关系` 表格中该状态对应的文档。
4. 用户可见说明、字段说明和 UI 文案使用中文。
5. 状态名、字段名、枚举值、命令名保留英文稳定标识。
6.`CONFIRM_CONTEXT``CONFIRM_EXECUTION` 作为强用户确认门:前者确认主题、目标位置、身份、搜索范围、可选限制和目标解析结果后才能搜索;后者确认创建目标和移动资源后才能写入。
7. 进入 `EXECUTE` 前,不得创建目标文件夹 / 节点,也不得移动资源。
8. 必须展示每个相关性分组中的资源名称;低置信分组可以折叠,但必须可查看。
9. 默认只移动 `high` 相关资源;`medium` 资源必须由用户显式选择。
10. 即使用户可见列表分页展示,也必须维护完整内部状态。
11. `RESOURCE_RESOLVE``CONTENT_VERIFY` 是两个独立的强制阶段,不得合并;不得用搜索结果、标题或摘要直接替代 `CONTENT_VERIFY`,也不得从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`
12. 触发后锁定 `workflow_id=topic_move_collector`;执行期间不得自动切换到其他 workflow。
13. 如果认为需要切换 workflow必须停止并向用户说明原因等待用户确认。
14. `RESOURCE_RESOLVE` 是移动资格门禁;只有确认 `move_permission_state=movable``target_write_state=confirmed` 的资源才能进入默认移动链路。
## 用户展示 UI 规则
所有用户可见 UI 都必须包含:
1. 已经完成的关键结果。
2. 下一步会做什么,以及是否会产生写操作。
3. 如果 `wait_for_user=true`,明确告诉用户可以选择的动作。
4. 如果无需用户操作,明确说明将继续执行,避免用户误以为流程停住。
典型动作包括:确认继续、修改主题 / 目标 / 限制、展开更多结果、调整相关性分组、选择中相关资源、确认执行、取消执行。
## 职责边界
| 文件 | 负责 | 不负责 |
|------|------|--------------|
| `lark-drive-workflow-topic-move-collector.md` | 触发规则、全局约束、状态机、渐进加载关系、命令族白名单 | 具体阶段规则、UI 模板、执行细节 |
| `lark-drive-workflow-topic-move-collector-setup.md` | `PARSE_INPUT``RESOLVE_TARGET``CONFIRM_CONTEXT``TargetLocation` | 搜索执行、相关性分类、写操作 |
| `lark-drive-workflow-topic-move-collector-recall.md` | `SEARCH_RECALL``RECALL_ENHANCE`、搜索 query 策略、去重、`CandidateItem` | 资源 token 解析、内容验证、写操作 |
| `lark-drive-workflow-topic-move-collector-resolve-verify.md` | `RESOURCE_RESOLVE``CONTENT_VERIFY`、权限矩阵、`ResourceItem` | 相关性分类、移动计划、写操作 |
| `lark-drive-workflow-topic-move-collector-review-plan.md` | `RELEVANCE_CLASSIFY``PLAN_MOVE``MovePlanItem`、展示分组 | 资源解析、内容验证、写操作执行、恢复 |
| `lark-drive-workflow-topic-move-collector-execute.md` | `CONFIRM_EXECUTION``EXECUTE``VERIFY``RESTORE``RollbackSnapshotItem`、执行日志 | 搜索、分类和计划 schema |
## 运行时状态
本 workflow 扩展共享 Artifact Contract。agent 在一次 workflow 运行中必须维护以下专项内部字段:
| 字段 | 说明 |
|-------|------|
| `current_state` | 当前状态机节点。 |
| `topic` | 用户确认后的主题、关键词、同义词和排除词。 |
| `target_location` | 目标位置解析结果,见 setup 文件的 `TargetLocation`。 |
| `identity` | 执行身份;默认优先 `--as user`。 |
| `owner_scope` | 搜索 owner 范围;默认 `mine`,仅搜索当前用户 owner / 负责的资源;用户明确要求扩展时才为 `all_visible`。 |
| `constraints` | 用户显式确认的类型、时间、创建人、范围等限制。 |
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须展示给用户确认。 |
| `recall_query_states` | 每个基础 / 增强 query 的分页状态、累计页数、`next_page_token``has_more`、完成或阻塞状态。 |
| `candidate_items` | 搜索召回结果,包含 query 证据和去重信息。 |
| `resource_items` | 解析后的标准资源列表。 |
| `content_verify_completed` | 内容验证阶段完成标记;`resource_items` 新建或变化时重置为 `false`,只有全部资源都有验证状态或跳过原因后才设为 `true`。 |
| `relevance_groups` | 高相关、中相关、低相关、无权限、无移动权限、移动权限未知、无法验证、不可移动分组。 |
| `move_plan_items` | 经用户选择后生成的完整移动计划,包含稳定资源关联、不可变命令参数、权限快照和恢复输入。 |
| `execution_journal` | 写操作日志,用于验证和恢复。 |
| `rollback_snapshot` | 写操作前位置快照,仅用于失败恢复或用户要求恢复。 |
| `display_page_state` | 用户可见列表的分页、筛选和展开状态。 |
## 执行状态机
| 状态 | Protocol Step | 进入条件 | agent 必须执行 | 用户可见输出 | `wait_for_user` | 下一状态 |
|-------|---------------|-----------------|---------------|--------------------|---------------|------------|
| `PARSE_INPUT` | `route` / `scope` | workflow 被触发 | 加载 setup 文档;解析主题、目标、身份和限制 | 澄清问题或解析摘要 | 必填字段缺失时为 `true` | `RESOLVE_TARGET` |
| `RESOLVE_TARGET` | `scope` | 主题和目标已获得 | 解析已有目标,或解析待创建目标;按解析状态分流 | 目标解析结果或 blocker | 非 `resolved` 时为 `true` | `resolved` 时进入 `CONFIRM_CONTEXT`;否则保持本状态 |
| `CONFIRM_CONTEXT` | `scope` | `target_resolve_status=resolved` | 展示主题、目标、身份、限制和跨容器设置 | 搜索前确认 UI | `true` | `SEARCH_RECALL` |
| `SEARCH_RECALL` | `read` | 用户确认上下文 | 用原始关键词、默认 owner 范围和显式限制执行基础召回;按每批最多 5 页自动续批 | 搜索进度 / 基础统计 | 阻塞时为 `true` | 所有基础 query 完成后进入 `RECALL_ENHANCE` |
| `RECALL_ENHANCE` | `read` | 所有基础 query 已完成 | 执行覆盖增强 query按每批最多 5 页自动续批并合并结果 | 增强召回摘要 | 阻塞时为 `true` | 所有增强 query 完成后进入 `RESOURCE_RESOLVE` |
| `RESOURCE_RESOLVE` | `read` | 候选列表已准备 | 解析 token、类型、父级位置、owner 和移动资格 | 解析进度 / 阻塞摘要 | 阻塞时为 `true` | `CONTENT_VERIFY` |
| `CONTENT_VERIFY` | `read` | 资源列表已准备 | 对支持的资源做有界内容读取,并为其余资源写入跳过原因 | 验证进度 / 验证摘要 | 阻塞时为 `true` | `RELEVANCE_CLASSIFY` |
| `RELEVANCE_CLASSIFY` | `assess` | 证据已准备 | 按相关性和可执行性分组 | 分组结果列表 | `false` | `PLAN_MOVE` |
| `PLAN_MOVE` | `assess` / `plan` | 分组完成 | 基于默认规则和用户可选项生成移动计划 | 草案计划和选择项 | `true` | `CONFIRM_EXECUTION` |
| `CONFIRM_EXECUTION` | `confirm` | 用户要求执行 | 展示创建、移动、跳过项和风险 | 写操作确认 UI | `true` | `EXECUTE``PLAN_MOVE``DONE` |
| `EXECUTE` | `execute` | 用户明确确认写操作 | 需要时先创建目标,再移动确认资源 | 执行进度 | 阻塞时为 `true` | `VERIFY``RESTORE` |
| `VERIFY` | `verify` | 执行完成 | 验证目标位置下的移动结果 | 验证结果 | 提供恢复选项时为 `true` | `DONE``RESTORE` |
| `RESTORE` | `recovery confirm` / `recovery execute` | 用户要求恢复 | 仅基于快照和日志恢复 | 恢复确认 / 结果 | 写操作前为 `true` | `VERIFY``DONE` |
| `DONE` | `done` | 无后续操作 | 停止 | 最终回复 | `false` | 结束 |
### 状态跳转硬约束
1. `RESOLVE_TARGET` 只有在 `target_resolve_status=resolved` 时才能进入 `CONFIRM_CONTEXT``ambiguous``unsupported``permission_denied` 必须保持在 `RESOLVE_TARGET` 并等待用户选择、更换目标或结束。
2. `SEARCH_RECALL` 只有在全部基础 query 的 `has_more=false` 时才能进入 `RECALL_ENHANCE`;单批达到 5 页但仍有更多结果时必须自动续批,不得提前跳转。
3. `RECALL_ENHANCE` 只有在全部增强 query 的 `has_more=false` 时才能进入 `RESOURCE_RESOLVE`;不得直接进入 `RELEVANCE_CLASSIFY``PLAN_MOVE`
4. `RESOURCE_RESOLVE` 必须为每个 `CandidateItem` 生成对应的 `ResourceItem`,或生成明确的解析失败 / 权限受限状态。
5. `RESOURCE_RESOLVE` 必须为每个 `ResourceItem` 写入 `move_permission_state``move_permission_basis`;完成后将 `content_verify_completed=false`,下一状态只能是 `CONTENT_VERIFY`
6. 禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使没有任何资源可以读取正文,也必须进入 `CONTENT_VERIFY`,为每项写入验证状态或跳过原因并输出验证摘要。
7. `CONTENT_VERIFY` 必须为每个 `ResourceItem` 写入内容证据、搜索证据复用说明,或不可验证原因;移动权限未知或无移动权限的资源可以只写入跳过验证原因。
8. 只有当 `resource_items` 已准备、每项都有验证状态或跳过原因,且 `content_verify_completed=true` 时,才能进入 `RELEVANCE_CLASSIFY`
9. 用户调整相关性分组后,必须回到 `RELEVANCE_CLASSIFY` 输出调整后的分组结果,再进入 `PLAN_MOVE` 重新生成计划。
### Workflow 切换门禁
只有以下情况允许考虑切换 workflow
1. 用户明确说不再做主题资料收集,改为整理整个目录结构或生成盘点方案。
2. 当前 workflow 明确无法覆盖用户的新目标。
3. 用户要求的是目录结构治理,而不是查找主题相关资料并移动。
即使满足以上条件,也不得自动切换;必须先向用户说明原因并等待确认。
## 渐进加载关系
| 状态 | 必读文档 |
|-------|---------------|
| `PARSE_INPUT` / `RESOLVE_TARGET` / `CONFIRM_CONTEXT` | [`lark-drive-workflow-topic-move-collector-setup.md`](lark-drive-workflow-topic-move-collector-setup.md) |
| `SEARCH_RECALL` / `RECALL_ENHANCE` | [`lark-drive-workflow-topic-move-collector-recall.md`](lark-drive-workflow-topic-move-collector-recall.md) |
| `RESOURCE_RESOLVE` / `CONTENT_VERIFY` | [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) |
| `RELEVANCE_CLASSIFY` / `PLAN_MOVE` | [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) |
| `CONFIRM_EXECUTION` / `EXECUTE` / `VERIFY` / `RESTORE` | [`lark-drive-workflow-topic-move-collector-execute.md`](lark-drive-workflow-topic-move-collector-execute.md) |
## 命令映射
| 状态 | 允许的命令族 | 用途 |
|-------|--------------------------|---------|
| `RESOLVE_TARGET` | `drive +inspect``wiki +node-get``wiki +space-list`、仅用于查找文件夹候选的 `drive +search` | 解析目标位置 |
| `SEARCH_RECALL` / `RECALL_ENHANCE` | `drive +search` | 搜索召回和覆盖增强 |
| `RESOURCE_RESOLVE` | `drive +inspect``wiki +node-get``drive metas batch_query`、必要时 `drive permission.members auth` | 解析标准 token、owner、权限信号和移动资格 |
| `CONTENT_VERIFY` | `docs +fetch``sheets +read``sheets +find`、必要时 `drive +preview` | 验证内容证据 |
| `EXECUTE` | `drive +create-folder``wiki +node-create``drive +move``wiki +move``wiki +move-to-drive``drive +task_result` | 执行已确认写操作 |
| `VERIFY` | `drive files list``wiki +node-list``wiki +node-get``drive +inspect``drive +task_result` | 验证执行结果 |
| `RESTORE` | `drive +move``wiki +move``drive +delete``wiki +node-delete``drive +task_result` | 恢复已确认资源并清理本次新建目标 |
## 引用文档
- [输入与目标确认](lark-drive-workflow-topic-move-collector-setup.md)
- [召回](lark-drive-workflow-topic-move-collector-recall.md)
- [资源解析与内容验证](lark-drive-workflow-topic-move-collector-resolve-verify.md)
- [审核与计划](lark-drive-workflow-topic-move-collector-review-plan.md)
- [执行](lark-drive-workflow-topic-move-collector-execute.md)
- [lark-drive-search](lark-drive-search.md)
- [lark-drive-inspect](lark-drive-inspect.md)
- [lark-drive-move](lark-drive-move.md)
- [lark-drive-create-folder](lark-drive-create-folder.md)
- [lark-drive-delete](lark-drive-delete.md)
- [lark-wiki-move](../../lark-wiki/references/lark-wiki-move.md)
- [lark-wiki-move-to-drive](../../lark-wiki/references/lark-wiki-move-to-drive.md)
- [lark-wiki-node-create](../../lark-wiki/references/lark-wiki-node-create.md)
- [lark-wiki-node-delete](../../lark-wiki/references/lark-wiki-node-delete.md)

View File

@@ -97,7 +97,7 @@ Structure Level
2. Entry file 超过约 300 行时,优先拆 `commands``outputs``artifacts` reference。
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template不默认新增独立 workflow。
5. 已有样板:`permission_governance``R2/S2``knowledge_organize``R2-R3/S3`
5. 已有样板:`permission_governance``R2/S2``knowledge_organize``topic_move_collector``R2-R3/S3`
## 加载与拆分边界
@@ -108,10 +108,11 @@ Structure Level
## Workflow Registry
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|----------|--------|------|-----------|------------|---------|
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|----------|--------|------|-----------|------------|-----------------------------------------------------------------|
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
| `topic_move_collector` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-topic-move-collector.md`](lark-drive-workflow-topic-move-collector.md) | 按主题、关键词或内容线索跨容器搜索资料,验证相关性和移动资格,并在用户确认后归档到 Drive 文件夹或 Wiki 节点 |
## Workflow Loading

View File

@@ -20,7 +20,7 @@ metadata:
## 身份
所有 minutes 命令默认使用 `--as user``+detail``+download` 也支持 `--as bot`bot 只能访问 bot 有权限的妙记)。
所有 minutes 命令默认使用 `--as user`
## Shortcuts

View File

@@ -20,7 +20,7 @@ metadata:
## 身份
所有 vc 命令默认使用 `--as user``meeting get``+detail``+recording``+notes` 也支持 `--as bot`bot 只能访问 bot 有权限的会议、录制和纪要)。`+search` 仅支持 user
所有 vc 命令默认使用 `--as user``+search``meeting get` 也支持 `--as bot`
```bash
# BAD — 查昨天的会议用 calendar会漏掉即时会议

View File

@@ -40,9 +40,9 @@ lark-cli vc +recording --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
每次只能指定一种输入方式。同时传入会报错。
### 2. 身份支持
### 2. 仅支持 user 身份
`--meeting-ids``--calendar-event-ids` 两种模式都支持 `--as user``--as bot`。user token 只能查自己有权限的录制bot 使用 tenant_access_token只能查 bot 有权限的录制。
该命令仅支持 `user` 身份,使用前需完成 `lark-cli auth login`。user token 只能查自己有权限的录制。
### 3. 批量上限

View File

@@ -24,6 +24,7 @@ metadata:
## 快速决策
- 用户要**按特定主题 / 关键词 / 内容线索查找资料并收集到知识库节点或新建知识库节点下**,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](../lark-drive/references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 使用 Drive 全量搜索召回,再按 Wiki 目标解析、确认和移动;不要只用 Wiki 节点列表做局部遍历。
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move``drive +move`。这是会改变节点归属和权限继承的写操作,执行前确认源节点与目标位置。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`

View File

@@ -15,6 +15,7 @@ import (
)
func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)

View File

@@ -88,12 +88,6 @@ func SkipWithoutTenantAccessToken(t *testing.T) {
if token == "" || appID == "" {
t.Skip("skipped: tenant test credentials not set")
}
// Scope standard env credentials to tests that explicitly require a live
// tenant token. Keeping TEST_* variables in the gotestsum parent prevents
// config and dry-run CLI subprocesses from activating the env provider.
t.Setenv("LARKSUITE_CLI_APP_ID", appID)
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", token)
}
// DryRunGet reads a field from the dry-run payload inside the standard success envelope.
@@ -245,14 +239,36 @@ func buildCommandEnv(req Request) []string {
for k, v := range req.Env {
overrides[k] = v
}
// Keep user-token injection scoped to user-only test commands so bot
// commands retain the process-level bot credentials.
if req.DefaultAs == "user" {
if appID := os.Getenv("TEST_BOT1_APP_ID"); appID != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
// Shared TEST_* credentials are fallbacks for explicitly identified live
// commands. Existing standard env (including dry-run fixtures) and
// per-request overrides always take precedence.
switch req.DefaultAs {
case "bot":
if !hasCredentialEnv(req.Env,
"LARKSUITE_CLI_APP_ID",
"LARKSUITE_CLI_APP_SECRET",
"LARKSUITE_CLI_TENANT_ACCESS_TOKEN",
) {
appID := os.Getenv("TEST_BOT1_APP_ID")
token := os.Getenv("TEST_TENANT_ACCESS_TOKEN")
if appID != "" && token != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
overrides["LARKSUITE_CLI_TENANT_ACCESS_TOKEN"] = token
}
}
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
case "user":
if !hasCredentialEnv(req.Env,
"LARKSUITE_CLI_APP_ID",
"LARKSUITE_CLI_APP_SECRET",
"LARKSUITE_CLI_USER_ACCESS_TOKEN",
) {
appID := os.Getenv("TEST_BOT1_APP_ID")
token := os.Getenv("TEST_USER_ACCESS_TOKEN")
if appID != "" && token != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
}
}
}
for k, v := range overrides {
@@ -272,6 +288,18 @@ func buildCommandEnv(req Request) []string {
return env
}
func hasCredentialEnv(requestEnv map[string]string, keys ...string) bool {
for _, key := range keys {
if _, ok := requestEnv[key]; ok {
return true
}
if os.Getenv(key) != "" {
return true
}
}
return false
}
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
if opts.Attempts <= 0 {

View File

@@ -190,7 +190,7 @@ func TestSkipWithoutTenantAccessToken(t *testing.T) {
assert.True(t, ran)
})
t.Run("scopes shared tenant credentials to the requiring test", func(t *testing.T) {
t.Run("accepts shared tenant credentials without mutating standard env", func(t *testing.T) {
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
t.Setenv("LARKSUITE_CLI_APP_ID", "")
@@ -198,8 +198,8 @@ func TestSkipWithoutTenantAccessToken(t *testing.T) {
ok := t.Run("inner", func(t *testing.T) {
SkipWithoutTenantAccessToken(t)
assert.Equal(t, "shared-test-app", os.Getenv("LARKSUITE_CLI_APP_ID"))
assert.Equal(t, "shared-test-token", os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
assert.Empty(t, os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
})
require.True(t, ok)
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
@@ -274,25 +274,65 @@ func TestRunCmd(t *testing.T) {
assert.Equal(t, "hello from stdin\n", result.Stdout)
})
t.Run("injects user token env only for user commands", func(t *testing.T) {
t.Run("injects shared credentials by requested identity", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
t.Setenv("TEST_BOT1_APP_ID", "cli_app_test")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "tat_test")
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat_test")
env := buildCommandEnv(Request{DefaultAs: "user"})
env := buildCommandEnv(Request{DefaultAs: "bot"})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.Contains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
env = buildCommandEnv(Request{DefaultAs: "user"})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.Contains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
env = buildCommandEnv(Request{DefaultAs: "bot"})
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
env = buildCommandEnv(Request{})
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
})
t.Run("preserves standard dry-run bot credentials", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "dry-run-app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "dry-run-secret")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
env := buildCommandEnv(Request{DefaultAs: "bot"})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=dry-run-app")
assert.Contains(t, env, "LARKSUITE_CLI_APP_SECRET=dry-run-secret")
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=shared-test-app")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=shared-test-token")
})
t.Run("request env overrides shared bot credentials", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
env := buildCommandEnv(Request{
DefaultAs: "bot",
Env: map[string]string{
"LARKSUITE_CLI_APP_ID": "request-app",
"LARKSUITE_CLI_TENANT_ACCESS_TOKEN": "",
},
})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=request-app")
assert.Contains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=")
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=shared-test-app")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=shared-test-token")
})
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")

View File

@@ -44,6 +44,7 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
"--doc", docToken,
"--doc-format", "markdown",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -37,29 +37,6 @@ func TestMinutesApplyPermission_DryRun(t *testing.T) {
assert.True(t, strings.Contains(output, `"perm": "view"`) || strings.Contains(output, `"perm":"view"`), "dry-run should contain perm body, got: %s", output)
}
func TestMinutesApplyPermission_DryRun_BotIdentity(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"minutes", "+apply-permission",
"--minute-token", "obcnexampleminute",
"--perm", "view",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "POST"), "dry-run should contain POST method, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/permissions/apply"), "dry-run should contain API path, got: %s", output)
assert.True(t, strings.Contains(output, `"perm": "view"`) || strings.Contains(output, `"perm":"view"`), "dry-run should contain perm body, got: %s", output)
}
func TestMinutesApplyPermission_InvalidPerm(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

View File

@@ -1,50 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
// TestMinutesDetailDryRun_BotIdentity pins that `minutes +detail` accepts
// --as bot for both the metadata (GetMinuteArtifacts) and transcript
// (GetMinuteTranscript) paths, which accept a tenant access token.
func TestMinutesDetailDryRun_BotIdentity(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"minutes", "+detail",
"--minute-tokens", "obcn1234567890",
"--transcript",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Args, "--as")
require.Contains(t, result.Args, "bot")
out := result.Stdout
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/minutes/v1/minutes/{minute_token}", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/minutes/v1/minutes/{minute_token}/artifacts", clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"minutes", "+detail", "--help"},
})
require.NoError(t, err)
helpResult.AssertExitCode(t, 0)
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
}

View File

@@ -366,6 +366,7 @@ func createTestObjectives(t *testing.T, ctx context.Context, cycleID string, suf
"--cycle-id", cycleID,
"--input", string(inputJSON),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create test objectives")
result.AssertExitCode(t, 0)
@@ -411,6 +412,7 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
"--key-result-id", krID,
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete KR %s", krID), result, err)
select {
@@ -426,6 +428,7 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
"--objective-id", obj.ObjectiveID,
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete objective %s", obj.ObjectiveID), result, err)
if i > 0 {
@@ -447,6 +450,7 @@ func createLiveObjective(t *testing.T, ctx context.Context, cycleID string, suff
"--cycle-id", cycleID,
"--content", fmt.Sprintf(`{"text":"E2E Single Objective %s","mention":["ou_test"]}`, suffix),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create live objective")
result.AssertExitCode(t, 0)
@@ -466,6 +470,7 @@ func createLiveKeyResult(t *testing.T, ctx context.Context, objectiveID string,
"--objective-id", objectiveID,
"--content", fmt.Sprintf(`{"text":"E2E Single KR %s","mention":["ou_test"]}`, suffix),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create live key result")
result.AssertExitCode(t, 0)
@@ -499,6 +504,7 @@ func TestOKR_BatchCreateLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -544,6 +550,7 @@ func TestOKR_CreateLive_Objective(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -581,6 +588,7 @@ func TestOKR_CreateLive_KeyResultUnderExistingObjective(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -640,6 +648,7 @@ func TestOKR_ReorderLive(t *testing.T) {
"--level", "objective",
"--ops", string(opsJSON),
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -650,6 +659,7 @@ func TestOKR_ReorderLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -702,6 +712,7 @@ func TestOKR_WeightLive(t *testing.T) {
"--level", "objective",
"--weights", string(weightsJSON),
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -712,6 +723,7 @@ func TestOKR_WeightLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -1,50 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
// TestVCDetailDryRun_BotIdentity pins that `vc +detail` accepts --as bot and
// previews the meeting.get + recording API round-trip (GetMeetingByID /
// GetRecordingByMeetingID both accept a tenant access token).
func TestVCDetailDryRun_BotIdentity(t *testing.T) {
setVCDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"vc", "+detail",
"--meeting-ids", "7628568141510692381",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Args, "--as")
require.Contains(t, result.Args, "bot")
out := result.Stdout
require.Equal(t, int64(2), clie2e.DryRunGet(out, "api.#").Int(), "stdout:\n%s", out)
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/vc/v1/meetings/{meeting_id}", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/vc/v1/meetings/{meeting_id}/recording", clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "7628568141510692381", clie2e.DryRunGet(out, "meeting_ids.0").String(), "stdout:\n%s", out)
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"vc", "+detail", "--help"},
})
require.NoError(t, err)
helpResult.AssertExitCode(t, 0)
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
// TestVCRecordingDryRun_BotIdentity pins that `vc +recording --meeting-ids`
// accepts --as bot (GetRecordingByMeetingID accepts a tenant access token).
func TestVCRecordingDryRun_BotIdentity(t *testing.T) {
setVCDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"vc", "+recording",
"--meeting-ids", "7628568141510692381",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/vc/v1/meetings/{meeting_id}/recording", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"vc", "+recording", "--help"},
})
require.NoError(t, err)
helpResult.AssertExitCode(t, 0)
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
}
// TestVCRecordingDryRun_BotIdentity_CalendarEventIDs pins that the
// calendar-event-ids path also flows under --as bot: a bot has a primary
// calendar, so the primary -> mget_instance_relation_info -> recording chain
// is previewed without a validation error.
func TestVCRecordingDryRun_BotIdentity_CalendarEventIDs(t *testing.T) {
setVCDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"vc", "+recording",
"--calendar-event-ids", "evt_001",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Contains(t, out, "mget_instance_relation_info", "stdout:\n%s", out)
require.Contains(t, out, "recording", "stdout:\n%s", out)
}