mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
5 Commits
feat/suppo
...
feat/keyle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a81e5d17c5 | ||
|
|
8f6f8eb0fc | ||
|
|
80323bb464 | ||
|
|
0a33bd7c57 | ||
|
|
aafaed06a7 |
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()))
|
||||
|
||||
306
cmd/config/init_auth_method_test.go
Normal file
306
cmd/config/init_auth_method_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
86
cmd/config/keyless_bind.go
Normal file
86
cmd/config/keyless_bind.go
Normal 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
|
||||
}
|
||||
438
cmd/config/keyless_bind_test.go
Normal file
438
cmd/config/keyless_bind_test.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
16
go.mod
@@ -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
37
go.sum
@@ -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=
|
||||
|
||||
@@ -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 != "" &&
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
124
internal/auth/client_auth.go
Normal file
124
internal/auth/client_auth.go
Normal 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
|
||||
}
|
||||
227
internal/auth/client_auth_test.go
Normal file
227
internal/auth/client_auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
152
internal/auth/jwt/jwt.go
Normal 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))
|
||||
}
|
||||
254
internal/auth/jwt/jwt_test.go
Normal file
254
internal/auth/jwt/jwt_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
122
internal/auth/uat_client_test.go
Normal file
122
internal/auth/uat_client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
112
internal/binding/keyless_types_test.go
Normal file
112
internal/binding/keyless_types_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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://")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
287
internal/keylesshelper/helper.go
Normal file
287
internal/keylesshelper/helper.go
Normal 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()
|
||||
}
|
||||
91
internal/keylesshelper/helper_test.go
Normal file
91
internal/keylesshelper/helper_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
internal/keylessprovider/inspect_command_unix.go
Normal file
17
internal/keylessprovider/inspect_command_unix.go
Normal 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
|
||||
}
|
||||
49
internal/keylessprovider/inspect_command_windows.go
Normal file
49
internal/keylessprovider/inspect_command_windows.go
Normal 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
|
||||
}
|
||||
54
internal/keylessprovider/inspect_command_windows_test.go
Normal file
54
internal/keylessprovider/inspect_command_windows_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
155
internal/keylessprovider/manifest.go
Normal file
155
internal/keylessprovider/manifest.go
Normal 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))
|
||||
}
|
||||
493
internal/keylessprovider/manifest_test.go
Normal file
493
internal/keylessprovider/manifest_test.go
Normal 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,
|
||||
})
|
||||
}
|
||||
858
internal/keylessprovider/provider.go
Normal file
858
internal/keylessprovider/provider.go
Normal 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
|
||||
}
|
||||
537
internal/keylessprovider/provider_test.go
Normal file
537
internal/keylessprovider/provider_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
63
internal/keylessprovider/security_unix.go
Normal file
63
internal/keylessprovider/security_unix.go
Normal 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
|
||||
}
|
||||
149
internal/keylessprovider/security_windows.go
Normal file
149
internal/keylessprovider/security_windows.go
Normal 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
|
||||
}
|
||||
210
internal/keysigner/keysigner.go
Normal file
210
internal/keysigner/keysigner.go
Normal 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)
|
||||
}
|
||||
}
|
||||
240
internal/keysigner/keysigner_test.go
Normal file
240
internal/keysigner/keysigner_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
29
internal/keysigner/registry.go
Normal file
29
internal/keysigner/registry.go
Normal 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
|
||||
}
|
||||
760
internal/keysigner/signer_keychain_darwin.go
Normal file
760
internal/keysigner/signer_keychain_darwin.go
Normal 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)
|
||||
}
|
||||
}
|
||||
282
internal/keysigner/signer_keychain_darwin_test.go
Normal file
282
internal/keysigner/signer_keychain_darwin_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
135
internal/keysigner/signer_sks.go
Normal file
135
internal/keysigner/signer_sks.go
Normal 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
|
||||
}
|
||||
122
internal/keysigner/signer_sks_test.go
Normal file
122
internal/keysigner/signer_sks_test.go
Normal 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
49
release_config_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
### 内容限制
|
||||
|
||||
- HTML 总长度上限为 900000 字符。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
|
||||
- HTML 总长度上限为 500KB。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
|
||||
|
||||
## OKR block
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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`。 |
|
||||
|
||||
除非用户要求查看技术调试细节,否则不要展示完整原始命令输出。
|
||||
@@ -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 完成前不得进入资源解析或分类阶段。
|
||||
@@ -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 对象操作或底层对象操作使用的标准 token;Wiki 节点移动不得使用该字段。 |
|
||||
| `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` | 资源 owner;Drive 资源优先来自 `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` | 相关性和可执行性分组。 |
|
||||
@@ -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_id;create_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` | 执行状态。 |
|
||||
@@ -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` 混用。 |
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user