chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// AuditParams holds parameters for AssertSecurePath.
|
||||
type AuditParams struct {
|
||||
TargetPath string
|
||||
Label string // e.g. "secrets.providers.vault.command"
|
||||
TrustedDirs []string
|
||||
AllowInsecurePath bool
|
||||
AllowReadableByOthers bool
|
||||
AllowSymlinkPath bool
|
||||
}
|
||||
|
||||
// AssertSecurePath verifies that a file/command path is safe for use with
|
||||
// OpenClaw SecretRef resolution. On success it returns the effective path
|
||||
// (the symlink target, if the input was a symlink and allowed).
|
||||
//
|
||||
// The check is a short, ordered pipeline — each step below is both a read of
|
||||
// the contract and a pointer to the helper that enforces it.
|
||||
func AssertSecurePath(params AuditParams) (string, error) {
|
||||
target := params.TargetPath
|
||||
label := params.Label
|
||||
|
||||
if err := requireAbsolutePath(target, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
linfo, err := lstatNonDir(target, label)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
effectivePath, err := resolveSymlinkIfAllowed(target, linfo, params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := requireInTrustedDirs(effectivePath, params.TrustedDirs, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if params.AllowInsecurePath {
|
||||
return effectivePath, nil
|
||||
}
|
||||
|
||||
if err := auditFilePermissions(effectivePath, params.AllowReadableByOthers, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkOwnerUID(effectivePath, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return effectivePath, nil
|
||||
}
|
||||
|
||||
// requireAbsolutePath rejects relative paths; relative paths would depend on
|
||||
// the process cwd and defeat the point of a static audit. Shell-style
|
||||
// shortcuts like `~` are home-relative, not cwd-relative — they are an
|
||||
// orthogonal concern and the audit is intentionally Go-stdlib strict here.
|
||||
// Callers that accept user-authored config (e.g. resolveFileRef) must
|
||||
// pre-resolve any such shortcuts before passing the path in.
|
||||
func requireAbsolutePath(target, label string) error {
|
||||
if !filepath.IsAbs(target) {
|
||||
return fmt.Errorf("%s: path must be absolute, got %q", label, target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lstatNonDir stats the path without following symlinks, rejecting
|
||||
// directories. Returns the stat info for downstream steps to reuse.
|
||||
func lstatNonDir(target, label string) (fs.FileInfo, error) {
|
||||
info, err := vfs.Lstat(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: cannot stat %q: %w", label, target, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("%s: path %q is a directory, not a file", label, target)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// resolveSymlinkIfAllowed resolves a symlink to its target when
|
||||
// params.AllowSymlinkPath is true, or rejects it otherwise. When the input
|
||||
// is not a symlink, target is returned unchanged. A symlink that points to
|
||||
// another symlink is rejected so callers only deal with a single hop.
|
||||
func resolveSymlinkIfAllowed(target string, linfo fs.FileInfo, params AuditParams) (string, error) {
|
||||
if linfo.Mode()&os.ModeSymlink == 0 {
|
||||
return target, nil
|
||||
}
|
||||
if !params.AllowSymlinkPath {
|
||||
return "", fmt.Errorf("%s: path %q is a symlink (not allowed)", params.Label, target)
|
||||
}
|
||||
resolved, err := vfs.EvalSymlinks(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: cannot resolve symlink %q: %w", params.Label, target, err)
|
||||
}
|
||||
rinfo, err := vfs.Lstat(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: cannot stat resolved path %q: %w", params.Label, resolved, err)
|
||||
}
|
||||
if rinfo.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("%s: resolved path %q is still a symlink", params.Label, resolved)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// requireInTrustedDirs enforces that effectivePath lives under one of the
|
||||
// caller-declared trusted directories, if any were declared. An empty
|
||||
// trustedDirs list disables the check.
|
||||
func requireInTrustedDirs(effectivePath string, trustedDirs []string, label string) error {
|
||||
if len(trustedDirs) == 0 {
|
||||
return nil
|
||||
}
|
||||
cleaned := filepath.Clean(effectivePath)
|
||||
for _, dir := range trustedDirs {
|
||||
cleanDir := filepath.Clean(dir)
|
||||
if cleaned == cleanDir || strings.HasPrefix(cleaned, cleanDir+"/") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssertSecurePath_NonAbsolutePath(t *testing.T) {
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: "relative/path.txt",
|
||||
Label: "test",
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-absolute path, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path must be absolute, got %q", "relative/path.txt")
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_FileDoesNotExist(t *testing.T) {
|
||||
nonexistent := filepath.Join(t.TempDir(), "nonexistent.txt")
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: nonexistent,
|
||||
Label: "test",
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent file, got nil")
|
||||
}
|
||||
wantPrefix := fmt.Sprintf("test: cannot stat %q: ", nonexistent)
|
||||
if !strings.HasPrefix(err.Error(), wantPrefix) {
|
||||
t.Errorf("error = %q, want prefix %q", err.Error(), wantPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_ValidAbsolutePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "valid.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_WorldWritable_Rejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "insecure.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
if err := os.Chmod(p, 0o666); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true, // only test writable check
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for world-writable file, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is world-writable (mode 0666)", p)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_AllowInsecurePath_Bypasses(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "insecure.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
if err := os.Chmod(p, 0o666); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_DirectoryRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: dir,
|
||||
Label: "test",
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for directory path, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is a directory, not a file", dir)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_GroupWritable_Rejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "groupw.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Chmod(p, 0o620); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for group-writable file, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is group-writable (mode 0620)", p)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_WorldReadable_Rejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "worldr.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Chmod(p, 0o604); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: false,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for world-readable file, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is world-readable (mode 0604)", p)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_AllowReadableByOthers_Passes(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "readable.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Chmod(p, 0o644); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_OwnerUID_Valid(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("owner UID tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "owned.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_Symlink_Rejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "real.txt")
|
||||
if err := os.WriteFile(target, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "link.txt")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: link,
|
||||
Label: "test",
|
||||
AllowSymlinkPath: false,
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for symlink with AllowSymlinkPath=false, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is a symlink (not allowed)", link)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_Symlink_Allowed(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "real.txt")
|
||||
if err := os.WriteFile(target, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "link.txt")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: link,
|
||||
Label: "test",
|
||||
AllowSymlinkPath: true,
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// On macOS /var → /private/var, so compare resolved paths
|
||||
wantResolved, err := filepath.EvalSymlinks(target)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks(target): %v", err)
|
||||
}
|
||||
if got != wantResolved {
|
||||
t.Errorf("got %q, want resolved %q", got, wantResolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_TrustedDirs_ExactMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "file.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
TrustedDirs: []string{p},
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_TrustedDirs(t *testing.T) {
|
||||
trustedDir := t.TempDir()
|
||||
untrustedDir := t.TempDir()
|
||||
|
||||
trustedFile := filepath.Join(trustedDir, "secret.txt")
|
||||
if err := os.WriteFile(trustedFile, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
untrustedFile := filepath.Join(untrustedDir, "secret.txt")
|
||||
if err := os.WriteFile(untrustedFile, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
// File outside trusted dir should fail
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: untrustedFile,
|
||||
Label: "test",
|
||||
TrustedDirs: []string{trustedDir},
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file outside trusted dir, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is not inside any trusted directory", untrustedFile)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
|
||||
// File inside trusted dir should pass
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: trustedFile,
|
||||
Label: "test",
|
||||
TrustedDirs: []string{trustedDir},
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != trustedFile {
|
||||
t.Errorf("got %q, want %q", got, trustedFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// checkOwnerUID verifies the file is owned by the current user.
|
||||
func checkOwnerUID(path, label string) error {
|
||||
stat, err := vfs.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, path, err)
|
||||
}
|
||||
sysStat, ok := stat.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: cannot retrieve file owner for %q", label, path)
|
||||
}
|
||||
if sysStat.Uid != uint32(os.Getuid()) {
|
||||
return fmt.Errorf("%s: path %q is owned by uid %d, expected %d",
|
||||
label, path, sysStat.Uid, os.Getuid())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
|
||||
func checkOwnerUID(path, label string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
|
||||
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
if _, err := vfs.Stat(effectivePath); err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets-getter.cmd")
|
||||
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp command: %v", err)
|
||||
}
|
||||
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "exec provider command",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReadJSONPointer navigates a parsed JSON value (typically the result of
|
||||
// json.Unmarshal into interface{}) using an RFC 6901 JSON Pointer string.
|
||||
//
|
||||
// Supported pointer format: "/key/subkey/subsubkey".
|
||||
// An empty pointer ("") returns data as-is.
|
||||
// RFC 6901 escape sequences: ~1 → /, ~0 → ~.
|
||||
//
|
||||
// Limitation: only object (map) traversal is supported. Array index segments
|
||||
// (e.g., "/channels/0/appId") are not implemented because OpenClaw's
|
||||
// SecretRef file provider uses object-only paths in practice.
|
||||
func ReadJSONPointer(data interface{}, pointer string) (interface{}, error) {
|
||||
if pointer == "" {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(pointer, "/") {
|
||||
return nil, fmt.Errorf("json pointer must start with '/' or be empty, got %q", pointer)
|
||||
}
|
||||
|
||||
// Split after the leading "/" and decode each segment.
|
||||
segments := strings.Split(pointer[1:], "/")
|
||||
current := data
|
||||
|
||||
for i, raw := range segments {
|
||||
// RFC 6901 unescaping: ~1 → /, ~0 → ~ (order matters).
|
||||
key, err := decodeJSONPointerSegment(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("json pointer %q: segment %q: %w", pointer, raw, err)
|
||||
}
|
||||
|
||||
m, ok := current.(map[string]interface{})
|
||||
if !ok {
|
||||
traversed := "/" + strings.Join(segments[:i], "/")
|
||||
return nil, fmt.Errorf("json pointer %q: value at %q is %T, not an object",
|
||||
pointer, traversed, current)
|
||||
}
|
||||
|
||||
val, exists := m[key]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("json pointer %q: key %q not found", pointer, key)
|
||||
}
|
||||
|
||||
current = val
|
||||
}
|
||||
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func decodeJSONPointerSegment(raw string) (string, error) {
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] != '~' {
|
||||
out.WriteByte(raw[i])
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(raw) {
|
||||
return "", fmt.Errorf("invalid escape: ~ must be followed by 0 or 1")
|
||||
}
|
||||
switch raw[i+1] {
|
||||
case '0':
|
||||
out.WriteByte('~')
|
||||
case '1':
|
||||
out.WriteByte('/')
|
||||
default:
|
||||
return "", fmt.Errorf("invalid escape: ~%c must be ~0 or ~1", raw[i+1])
|
||||
}
|
||||
i++
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadJSONPointer_EmptyPointer(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "value"}
|
||||
got, err := ReadJSONPointer(data, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
if m["key"] != "value" {
|
||||
t.Errorf("got %v, want map with key=value", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_OneLevel(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "hello"}
|
||||
got, err := ReadJSONPointer(data, "/key")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "hello" {
|
||||
t.Errorf("got %v, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_TwoLevels(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"key": map[string]interface{}{
|
||||
"subkey": "deep_value",
|
||||
},
|
||||
}
|
||||
got, err := ReadJSONPointer(data, "/key/subkey")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "deep_value" {
|
||||
t.Errorf("got %v, want %q", got, "deep_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_MissingKey(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "value"}
|
||||
_, err := ReadJSONPointer(data, "/nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing key, got nil")
|
||||
}
|
||||
want := `json pointer "/nonexistent": key "nonexistent" not found`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_NonMapIntermediate(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "scalar_string"}
|
||||
_, err := ReadJSONPointer(data, "/key/subkey")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-map intermediate, got nil")
|
||||
}
|
||||
want := `json pointer "/key/subkey": value at "/key" is string, not an object`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_RFC6901_Escaping(t *testing.T) {
|
||||
// ~1 decodes to / and ~0 decodes to ~
|
||||
data := map[string]interface{}{
|
||||
"a/b": "slash_value",
|
||||
"c~d": "tilde_value",
|
||||
}
|
||||
|
||||
// ~1 -> /
|
||||
got, err := ReadJSONPointer(data, "/a~1b")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for ~1 escape: %v", err)
|
||||
}
|
||||
if got != "slash_value" {
|
||||
t.Errorf("got %v, want %q", got, "slash_value")
|
||||
}
|
||||
|
||||
// ~0 -> ~
|
||||
got, err = ReadJSONPointer(data, "/c~0d")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for ~0 escape: %v", err)
|
||||
}
|
||||
if got != "tilde_value" {
|
||||
t.Errorf("got %v, want %q", got, "tilde_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_InvalidEscape(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"a~2b": "literal",
|
||||
"a~": "literal",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
pointer string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unsupported escape code",
|
||||
pointer: "/a~2b",
|
||||
want: `json pointer "/a~2b": segment "a~2b": invalid escape: ~2 must be ~0 or ~1`,
|
||||
},
|
||||
{
|
||||
name: "dangling tilde",
|
||||
pointer: "/a~",
|
||||
want: `json pointer "/a~": segment "a~": invalid escape: ~ must be followed by 0 or 1`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ReadJSONPointer(data, tt.pointer)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid escape, got nil")
|
||||
}
|
||||
if err.Error() != tt.want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_InvalidFormat(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "val"}
|
||||
_, err := ReadJSONPointer(data, "no-leading-slash")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for pointer without leading /")
|
||||
}
|
||||
want := `json pointer must start with '/' or be empty, got "no-leading-slash"`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// LarkChannelRoot captures ~/.lark-channel/config.json.
|
||||
// Schema mirrors lark-channel-bridge/src/config/schema.ts:AppConfig.
|
||||
// Unknown fields are ignored — forward-compatible with future bridge versions.
|
||||
type LarkChannelRoot struct {
|
||||
Accounts LarkChannelAccounts `json:"accounts"`
|
||||
// Secrets is an optional registry of secret providers — same shape as
|
||||
// openclaw's `secrets` block. Lets bridge declare `exec` provider scripts
|
||||
// (for AES-encrypted secret backends), `env` allowlists, or `file`
|
||||
// indirection rules. Resolved by binding.ResolveSecretInput.
|
||||
Secrets *SecretsConfig `json:"secrets,omitempty"`
|
||||
}
|
||||
|
||||
// LarkChannelAccounts is the namespace for credential entries.
|
||||
// Currently only `app` is defined; left as a struct (not a flat field) so
|
||||
// future entries (oauth, alternate apps) can be added without re-shaping the
|
||||
// top-level on disk.
|
||||
type LarkChannelAccounts struct {
|
||||
App LarkChannelApp `json:"app"`
|
||||
}
|
||||
|
||||
// LarkChannelApp is the bot app credential entry.
|
||||
//
|
||||
// `Secret` accepts the full SecretInput protocol (string / "${VAR}" template /
|
||||
// SecretRef object with source env|file|exec) so users can keep secrets out
|
||||
// of config.json — either by referencing an env var the bridge inherits, a
|
||||
// chmod-0400 file outside the bridge dir, or an exec script that decrypts a
|
||||
// local AES-encrypted secret store. Aligns lark-channel with the same secret
|
||||
// protocol openclaw already uses.
|
||||
type LarkChannelApp struct {
|
||||
ID string `json:"id"`
|
||||
Secret SecretInput `json:"secret"`
|
||||
Tenant string `json:"tenant"` // "feishu" | "lark"
|
||||
}
|
||||
|
||||
// ReadLarkChannelConfig reads and parses ~/.lark-channel/config.json.
|
||||
func ReadLarkChannelConfig(path string) (*LarkChannelRoot, error) {
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err // caller formats user-facing message with path context
|
||||
}
|
||||
|
||||
var root LarkChannelRoot
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON in %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &root, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadLarkChannelConfig_Valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
data := `{"accounts":{"app":{"id":"cli_abc123","secret":"plain_secret","tenant":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadLarkChannelConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := root.Accounts.App.ID; got != "cli_abc123" {
|
||||
t.Errorf("ID = %q, want %q", got, "cli_abc123")
|
||||
}
|
||||
if got := root.Accounts.App.Secret.Plain; got != "plain_secret" {
|
||||
t.Errorf("Secret.Plain = %q, want %q", got, "plain_secret")
|
||||
}
|
||||
if root.Accounts.App.Secret.Ref != nil {
|
||||
t.Errorf("expected Plain form, got SecretRef = %+v", root.Accounts.App.Secret.Ref)
|
||||
}
|
||||
if got := root.Accounts.App.Tenant; got != "feishu" {
|
||||
t.Errorf("Tenant = %q, want %q", got, "feishu")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_LarkTenant(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
data := `{"accounts":{"app":{"id":"cli_xyz","secret":"s","tenant":"lark"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadLarkChannelConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := root.Accounts.App.Tenant; got != "lark" {
|
||||
t.Errorf("Tenant = %q, want %q", got, "lark")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_MissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "does-not-exist.json")
|
||||
|
||||
_, err := ReadLarkChannelConfig(p)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file, got nil")
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("expected os.IsNotExist, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_MalformedJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(p, []byte("{not valid json"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
_, err := ReadLarkChannelConfig(p)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_PartialFields(t *testing.T) {
|
||||
// schema isComplete check belongs at the binder layer; the reader should
|
||||
// happily parse a partial config — emptiness is detected downstream.
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
data := `{"accounts":{"app":{}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadLarkChannelConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root.Accounts.App.ID != "" {
|
||||
t.Errorf("expected empty ID, got %q", root.Accounts.App.ID)
|
||||
}
|
||||
if !root.Accounts.App.Secret.IsZero() {
|
||||
t.Errorf("expected zero Secret, got %+v", root.Accounts.App.Secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_SecretEnvTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
data := `{"accounts":{"app":{"id":"cli_a","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
root, err := ReadLarkChannelConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := root.Accounts.App.Secret.Plain; got != "${LARK_APP_SECRET}" {
|
||||
t.Errorf("Secret.Plain = %q, want template string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_SecretRefExec(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
data := `{
|
||||
"accounts": {
|
||||
"app": {
|
||||
"id": "cli_a",
|
||||
"secret": {"source": "exec", "provider": "decrypt", "id": "app-cli_a"},
|
||||
"tenant": "feishu"
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"providers": {
|
||||
"decrypt": {"source": "exec", "command": "/usr/local/bin/lark-channel-bridge", "args": ["secrets", "get"]}
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
root, err := ReadLarkChannelConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root.Accounts.App.Secret.Ref == nil {
|
||||
t.Fatal("expected SecretRef, got Plain")
|
||||
}
|
||||
if got := root.Accounts.App.Secret.Ref.Source; got != "exec" {
|
||||
t.Errorf("Secret.Ref.Source = %q, want %q", got, "exec")
|
||||
}
|
||||
if got := root.Accounts.App.Secret.Ref.ID; got != "app-cli_a" {
|
||||
t.Errorf("Secret.Ref.ID = %q, want %q", got, "app-cli_a")
|
||||
}
|
||||
if root.Secrets == nil || root.Secrets.Providers["decrypt"] == nil {
|
||||
t.Errorf("expected secrets.providers[decrypt] to be parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_SecretRefInvalidSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
data := `{"accounts":{"app":{"id":"cli_a","secret":{"source":"bogus","id":"x"},"tenant":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
if _, err := ReadLarkChannelConfig(p); err == nil {
|
||||
t.Fatal("expected error for invalid secret source, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_UnknownFieldsIgnored(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
data := `{
|
||||
"accounts": {
|
||||
"app": {"id": "cli_a", "secret": "s", "tenant": "feishu"},
|
||||
"oauth": {"clientId": "ignored"}
|
||||
},
|
||||
"preferences": {"theme": "dark"}
|
||||
}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadLarkChannelConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := root.Accounts.App.ID; got != "cli_a" {
|
||||
t.Errorf("ID = %q, want %q", got, "cli_a")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ReadOpenClawConfig reads and parses an openclaw.json file at the given path.
|
||||
func ReadOpenClawConfig(path string) (*OpenClawRoot, error) {
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err // caller (bind.go) formats user-facing message with path context
|
||||
}
|
||||
|
||||
var root OpenClawRoot
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON in %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &root, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadOpenClawConfig_ValidSingleAccount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
data := `{"channels":{"feishu":{"appId":"cli_abc","appSecret":"plain_secret","domain":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadOpenClawConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root.Channels.Feishu == nil {
|
||||
t.Fatal("expected Channels.Feishu to be non-nil")
|
||||
}
|
||||
if got := root.Channels.Feishu.AppID; got != "cli_abc" {
|
||||
t.Errorf("AppID = %q, want %q", got, "cli_abc")
|
||||
}
|
||||
if got := root.Channels.Feishu.AppSecret.Plain; got != "plain_secret" {
|
||||
t.Errorf("AppSecret.Plain = %q, want %q", got, "plain_secret")
|
||||
}
|
||||
if root.Channels.Feishu.AppSecret.Ref != nil {
|
||||
t.Error("AppSecret.Ref should be nil for a plain string")
|
||||
}
|
||||
if got := root.Channels.Feishu.Brand; got != "feishu" {
|
||||
t.Errorf("Brand = %q, want %q", got, "feishu")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_ValidMultiAccount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
data := `{
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"domain": "feishu",
|
||||
"accounts": {
|
||||
"work": {"appId": "cli_work", "appSecret": "secret_work", "domain": "feishu"},
|
||||
"personal": {"appId": "cli_personal", "appSecret": "secret_personal", "domain": "lark"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadOpenClawConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root.Channels.Feishu == nil {
|
||||
t.Fatal("expected Channels.Feishu to be non-nil")
|
||||
}
|
||||
|
||||
apps := ListCandidateApps(root.Channels.Feishu)
|
||||
if len(apps) != 2 {
|
||||
t.Fatalf("ListCandidateApps returned %d apps, want 2", len(apps))
|
||||
}
|
||||
|
||||
byLabel := make(map[string]CandidateApp, len(apps))
|
||||
for _, a := range apps {
|
||||
byLabel[a.Label] = a
|
||||
}
|
||||
|
||||
work, ok := byLabel["work"]
|
||||
if !ok {
|
||||
t.Fatal("missing account label 'work'")
|
||||
}
|
||||
if work.AppID != "cli_work" {
|
||||
t.Errorf("work.AppID = %q, want %q", work.AppID, "cli_work")
|
||||
}
|
||||
|
||||
personal, ok := byLabel["personal"]
|
||||
if !ok {
|
||||
t.Fatal("missing account label 'personal'")
|
||||
}
|
||||
if personal.AppID != "cli_personal" {
|
||||
t.Errorf("personal.AppID = %q, want %q", personal.AppID, "cli_personal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_MissingFeishu(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
data := `{"channels":{}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadOpenClawConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root.Channels.Feishu != nil {
|
||||
t.Error("expected Channels.Feishu to be nil when not present in JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_InvalidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
if err := os.WriteFile(p, []byte(`{not valid json`), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
_, err := ReadOpenClawConfig(p)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_FileNotFound(t *testing.T) {
|
||||
_, err := ReadOpenClawConfig(filepath.Join(t.TempDir(), "nonexistent.json"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_EnvTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
data := `{"channels":{"feishu":{"appId":"cli_env","appSecret":"${FEISHU_APP_SECRET}","domain":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadOpenClawConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
secret := root.Channels.Feishu.AppSecret
|
||||
if secret.Plain != "${FEISHU_APP_SECRET}" {
|
||||
t.Errorf("SecretInput.Plain = %q, want %q", secret.Plain, "${FEISHU_APP_SECRET}")
|
||||
}
|
||||
if secret.Ref != nil {
|
||||
t.Error("SecretInput.Ref should be nil for env template string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_SecretRefObject(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
data := `{"channels":{"feishu":{"appId":"cli_ref","appSecret":{"source":"file","provider":"fp","id":"/path"},"domain":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadOpenClawConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
secret := root.Channels.Feishu.AppSecret
|
||||
if secret.Plain != "" {
|
||||
t.Errorf("SecretInput.Plain = %q, want empty for object form", secret.Plain)
|
||||
}
|
||||
if secret.Ref == nil {
|
||||
t.Fatal("SecretInput.Ref should be non-nil for object form")
|
||||
}
|
||||
if secret.Ref.Source != "file" {
|
||||
t.Errorf("Ref.Source = %q, want %q", secret.Ref.Source, "file")
|
||||
}
|
||||
if secret.Ref.Provider != "fp" {
|
||||
t.Errorf("Ref.Provider = %q, want %q", secret.Ref.Provider, "fp")
|
||||
}
|
||||
if secret.Ref.ID != "/path" {
|
||||
t.Errorf("Ref.ID = %q, want %q", secret.Ref.ID, "/path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ResolveSecretInput resolves a SecretInput to a plain-text secret string.
|
||||
// This is the main dispatcher that handles all SecretInput forms:
|
||||
// - Plain string passthrough
|
||||
// - "${VAR_NAME}" env template expansion
|
||||
// - SecretRef object routing to env/file/exec sub-resolvers
|
||||
//
|
||||
// The getenv parameter allows injection for testing (typically os.Getenv).
|
||||
// This function is only called during config bind (cold path).
|
||||
func ResolveSecretInput(input SecretInput, cfg *SecretsConfig, getenv func(string) string) (string, error) {
|
||||
if getenv == nil {
|
||||
getenv = os.Getenv
|
||||
}
|
||||
|
||||
if input.IsZero() {
|
||||
return "", fmt.Errorf("appSecret is missing or empty")
|
||||
}
|
||||
|
||||
// Plain string form (includes env templates)
|
||||
if input.IsPlain() {
|
||||
return resolvePlainOrTemplate(input.Plain, getenv)
|
||||
}
|
||||
|
||||
// SecretRef object form
|
||||
return resolveSecretRef(input.Ref, cfg, getenv)
|
||||
}
|
||||
|
||||
// resolvePlainOrTemplate handles plain strings and "${VAR}" templates.
|
||||
func resolvePlainOrTemplate(value string, getenv func(string) string) (string, error) {
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("appSecret is empty string")
|
||||
}
|
||||
|
||||
// Check for env template pattern: "${VAR_NAME}"
|
||||
matches := EnvTemplateRe.FindStringSubmatch(value)
|
||||
if matches != nil {
|
||||
varName := matches[1]
|
||||
envValue := getenv(varName)
|
||||
if envValue == "" {
|
||||
return "", fmt.Errorf("env variable %q referenced in openclaw.json is not set or empty", varName)
|
||||
}
|
||||
return envValue, nil
|
||||
}
|
||||
|
||||
// Plain string: use as-is
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// resolveSecretRef dispatches a SecretRef to the appropriate sub-resolver.
|
||||
func resolveSecretRef(ref *SecretRef, cfg *SecretsConfig, getenv func(string) string) (string, error) {
|
||||
// Lookup provider configuration
|
||||
providerConfig, err := LookupProvider(ref, cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Resolve the effective provider name once so downstream resolvers
|
||||
// (notably the exec JSON payload) see the config-defaulted value instead
|
||||
// of the unset literal on ref.Provider.
|
||||
providerName := ResolveDefaultProvider(ref, cfg)
|
||||
|
||||
switch ref.Source {
|
||||
case "env":
|
||||
return resolveEnvRef(ref, providerConfig, getenv)
|
||||
case "file":
|
||||
return resolveFileRef(ref, providerConfig)
|
||||
case "exec":
|
||||
return resolveExecRef(ref, providerName, providerConfig, getenv)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported secret source %q", ref.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEnvRef handles {source:"env"} SecretRef.
|
||||
func resolveEnvRef(ref *SecretRef, pc *ProviderConfig, getenv func(string) string) (string, error) {
|
||||
// Check allowlist if configured
|
||||
if len(pc.Allowlist) > 0 {
|
||||
allowed := false
|
||||
for _, name := range pc.Allowlist {
|
||||
if name == ref.ID {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return "", fmt.Errorf("environment variable %q is not allowlisted in provider", ref.ID)
|
||||
}
|
||||
}
|
||||
|
||||
value := getenv(ref.ID)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("environment variable %q is missing or empty", ref.ID)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// execRequest is the JSON payload sent to exec provider's stdin.
|
||||
type execRequest struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Provider string `json:"provider"`
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// execResponse is the JSON payload expected from exec provider's stdout.
|
||||
type execResponse struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
Errors map[string]execRefError `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// execRefError is an optional per-id error in exec provider response.
|
||||
type execRefError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// execRun bundles everything runExecCommand needs to spawn the child process.
|
||||
// It is populated once by prepareExecRun and consumed exactly once by
|
||||
// runExecCommand; keeping the two stages pure data + pure side effect makes
|
||||
// each independently testable.
|
||||
type execRun struct {
|
||||
Path string // absolute, already-audited path to the command
|
||||
Args []string // command arguments (from pc.Args)
|
||||
Env []string // minimal child env (passEnv + explicit env only)
|
||||
Request []byte // JSON payload to feed on the child's stdin
|
||||
Timeout time.Duration // spawn deadline
|
||||
MaxOut int // hard cap on stdout size, enforced post-Run
|
||||
}
|
||||
|
||||
// resolveExecRef handles {source:"exec"} SecretRef resolution. It audits the
|
||||
// command path, runs the child under a timeout with a hard stdout cap, and
|
||||
// extracts the secret from the JSON response. providerName is the caller-
|
||||
// resolved effective alias (honours secrets.defaults.exec from openclaw.json).
|
||||
func resolveExecRef(ref *SecretRef, providerName string, pc *ProviderConfig, getenv func(string) string) (string, error) {
|
||||
prep, err := prepareExecRun(ref, providerName, pc, getenv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stdout, err := runExecCommand(prep)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractExecSecret(stdout, ref.ID, effectiveJSONOnly(pc))
|
||||
}
|
||||
|
||||
// prepareExecRun audits the command path, marshals the JSON request,
|
||||
// assembles the minimal child env, and resolves timeout / output limits.
|
||||
// Never spawns a process — the returned execRun is pure data.
|
||||
func prepareExecRun(ref *SecretRef, providerName string, pc *ProviderConfig, getenv func(string) string) (*execRun, error) {
|
||||
if pc.Command == "" {
|
||||
return nil, fmt.Errorf("exec provider command is empty")
|
||||
}
|
||||
|
||||
securePath, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: pc.Command,
|
||||
Label: "exec provider command",
|
||||
TrustedDirs: pc.TrustedDirs,
|
||||
AllowInsecurePath: pc.AllowInsecurePath,
|
||||
AllowReadableByOthers: true, // exec commands are typically 755
|
||||
AllowSymlinkPath: pc.AllowSymlinkCommand,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec provider security audit failed: %w", err)
|
||||
}
|
||||
|
||||
reqJSON, err := marshalExecRequest(ref, providerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeoutMs, maxOut := effectiveExecLimits(pc)
|
||||
return &execRun{
|
||||
Path: securePath,
|
||||
Args: pc.Args,
|
||||
Env: buildExecEnv(pc, getenv),
|
||||
Request: reqJSON,
|
||||
Timeout: time.Duration(timeoutMs) * time.Millisecond,
|
||||
MaxOut: maxOut,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// marshalExecRequest encodes the JSON protocol request sent to the child.
|
||||
// providerName is supplied by resolveSecretRef after consulting
|
||||
// secrets.defaults.exec; an empty value falls back to DefaultProviderAlias
|
||||
// so the function can still be reasoned about in isolation.
|
||||
func marshalExecRequest(ref *SecretRef, providerName string) ([]byte, error) {
|
||||
if providerName == "" {
|
||||
providerName = DefaultProviderAlias
|
||||
}
|
||||
data, err := json.Marshal(execRequest{
|
||||
ProtocolVersion: 1,
|
||||
Provider: providerName,
|
||||
IDs: []string{ref.ID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec provider: failed to marshal request: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// buildExecEnv assembles the child's environment: only variables listed in
|
||||
// pc.PassEnv (and non-empty in the parent) plus pc.Env entries. The child
|
||||
// never inherits the full parent env — always set cmd.Env explicitly.
|
||||
func buildExecEnv(pc *ProviderConfig, getenv func(string) string) []string {
|
||||
env := make([]string, 0, len(pc.PassEnv)+len(pc.Env))
|
||||
for _, key := range pc.PassEnv {
|
||||
if val := getenv(key); val != "" {
|
||||
env = append(env, key+"="+val)
|
||||
}
|
||||
}
|
||||
for key, val := range pc.Env {
|
||||
env = append(env, key+"="+val)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// effectiveExecLimits returns (timeoutMs, maxOutputBytes), falling back to
|
||||
// package defaults for any non-positive value. The exec provider uses its
|
||||
// own NoOutputTimeoutMs field (pc.TimeoutMs is the file-provider field and
|
||||
// should not be consulted here); the value is applied as the overall
|
||||
// deadline for the child process.
|
||||
func effectiveExecLimits(pc *ProviderConfig) (timeoutMs, maxOutputBytes int) {
|
||||
timeoutMs = pc.NoOutputTimeoutMs
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = DefaultExecTimeoutMs
|
||||
}
|
||||
maxOutputBytes = pc.MaxOutputBytes
|
||||
if maxOutputBytes <= 0 {
|
||||
maxOutputBytes = DefaultExecMaxOutputBytes
|
||||
}
|
||||
return timeoutMs, maxOutputBytes
|
||||
}
|
||||
|
||||
// effectiveJSONOnly returns pc.JSONOnly or its documented default (true).
|
||||
func effectiveJSONOnly(pc *ProviderConfig) bool {
|
||||
if pc.JSONOnly != nil {
|
||||
return *pc.JSONOnly
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// runExecCommand spawns the child per prep, feeds prep.Request on stdin, and
|
||||
// returns trimmed stdout on success. Failure modes:
|
||||
// - timeout → typed error with the configured limit
|
||||
// - non-zero exit → wrapped *exec.ExitError
|
||||
// - stdout exceeds prep.MaxOut → typed error (size enforced post-Run)
|
||||
// - empty trimmed stdout → typed error
|
||||
func runExecCommand(prep *execRun) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), prep.Timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, prep.Path, prep.Args...)
|
||||
cmd.Dir = filepath.Dir(prep.Path)
|
||||
cmd.Env = prep.Env // always set — leaving nil would inherit the parent env
|
||||
cmd.Stdin = bytes.NewReader(prep.Request)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("exec provider timed out after %dms", int(prep.Timeout/time.Millisecond))
|
||||
}
|
||||
return nil, fmt.Errorf("exec provider exited with error: %w", err)
|
||||
}
|
||||
|
||||
if stdout.Len() > prep.MaxOut {
|
||||
return nil, fmt.Errorf("exec provider output exceeded maxOutputBytes (%d)", prep.MaxOut)
|
||||
}
|
||||
|
||||
trimmed := bytes.TrimSpace(stdout.Bytes())
|
||||
if len(trimmed) == 0 {
|
||||
return nil, fmt.Errorf("exec provider returned empty stdout")
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// extractExecSecret parses stdout as a JSON execResponse and returns the
|
||||
// string value at refID. When jsonOnly is false and the response is not valid
|
||||
// JSON (or the value is not a string), it falls back to the raw stdout or the
|
||||
// JSON encoding of the value respectively — mirroring OpenClaw's resolve.ts.
|
||||
func extractExecSecret(stdout []byte, refID string, jsonOnly bool) (string, error) {
|
||||
var resp execResponse
|
||||
if err := json.Unmarshal(stdout, &resp); err != nil {
|
||||
if !jsonOnly {
|
||||
return string(stdout), nil
|
||||
}
|
||||
return "", fmt.Errorf("exec provider returned invalid JSON: %w", err)
|
||||
}
|
||||
|
||||
if resp.ProtocolVersion != 1 {
|
||||
return "", fmt.Errorf("exec provider protocolVersion must be 1, got %d", resp.ProtocolVersion)
|
||||
}
|
||||
|
||||
if refErr, ok := resp.Errors[refID]; ok {
|
||||
msg := refErr.Message
|
||||
if msg == "" {
|
||||
msg = "unknown error"
|
||||
}
|
||||
return "", fmt.Errorf("exec provider failed for id %q: %s", refID, msg)
|
||||
}
|
||||
|
||||
if resp.Values == nil {
|
||||
return "", fmt.Errorf("exec provider response missing 'values'")
|
||||
}
|
||||
value, ok := resp.Values[refID]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("exec provider response missing id %q", refID)
|
||||
}
|
||||
|
||||
if str, ok := value.(string); ok {
|
||||
return str, nil
|
||||
}
|
||||
if !jsonOnly {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("exec provider value for id %q is not JSON-serializable: %w", refID, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", fmt.Errorf("exec provider value for id %q is not a string", refID)
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeExecHelper writes a small shell script that mimics an exec provider.
|
||||
// The script reads stdin (the JSON request) and writes a JSON response to stdout.
|
||||
func writeExecHelper(t *testing.T, dir, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, "helper.sh")
|
||||
script := "#!/bin/sh\n" + body
|
||||
if err := os.WriteFile(p, []byte(script), 0o700); err != nil {
|
||||
t.Fatalf("write helper script: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestResolveExecRef_EmptyCommand(t *testing.T) {
|
||||
ref := &SecretRef{Source: "exec", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{Source: "exec", Command: ""}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty command, got nil")
|
||||
}
|
||||
want := "exec provider command is empty"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_CommandNotFound(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("path audit not applicable on Windows")
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "exec", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: "/nonexistent/command",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent command, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_JSONResponse(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
// Script reads stdin (ignores), writes valid JSON response
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"MY_KEY":"exec_secret_123"}}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
got, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "exec_secret_123" {
|
||||
t.Errorf("got %q, want %q", got, "exec_secret_123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_PerRefError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{},"errors":{"MY_KEY":{"message":"secret not found"}}}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for per-ref error, got nil")
|
||||
}
|
||||
want := `exec provider failed for id "MY_KEY": secret not found`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_WrongProtocolVersion(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":99,"values":{"MY_KEY":"v"}}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong protocol version, got nil")
|
||||
}
|
||||
want := "exec provider protocolVersion must be 1, got 99"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_MissingValues(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing values, got nil")
|
||||
}
|
||||
want := "exec provider response missing 'values'"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_MissingID(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"OTHER":"val"}}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing ID, got nil")
|
||||
}
|
||||
want := `exec provider response missing id "MY_KEY"`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_EmptyStdout(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty stdout, got nil")
|
||||
}
|
||||
want := "exec provider returned empty stdout"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_InvalidJSON_JSONOnly(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
echo "not json"
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
// JSONOnly defaults to true (nil)
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_NonJSON_RawString(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
echo "raw_secret_value"
|
||||
`)
|
||||
|
||||
jsonOnly := false
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
JSONOnly: &jsonOnly,
|
||||
}
|
||||
|
||||
got, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "raw_secret_value" {
|
||||
t.Errorf("got %q, want %q", got, "raw_secret_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_NonStringValue_JSONOnly(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"MY_KEY":42}}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-string value with jsonOnly=true, got nil")
|
||||
}
|
||||
want := `exec provider value for id "MY_KEY" is not a string`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_NonStringValue_NoJSONOnly(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"MY_KEY":42}}'
|
||||
`)
|
||||
|
||||
jsonOnly := false
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
JSONOnly: &jsonOnly,
|
||||
}
|
||||
|
||||
got, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "42" {
|
||||
t.Errorf("got %q, want %q", got, "42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_CommandExitError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `exit 1
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for command exit error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_PassEnv(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
// Script uses TEST_SECRET env to produce value
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"MY_KEY":"%s"}}' "$TEST_SECRET"
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
PassEnv: []string{"TEST_SECRET"},
|
||||
}
|
||||
|
||||
getenv := func(key string) string {
|
||||
if key == "TEST_SECRET" {
|
||||
return "passed_env_value"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
got, err := resolveExecRef(ref, "", pc, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "passed_env_value" {
|
||||
t.Errorf("got %q, want %q", got, "passed_env_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_ExplicitEnv(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"MY_KEY":"%s"}}' "$CUSTOM_VAR"
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
Env: map[string]string{"CUSTOM_VAR": "explicit_value"},
|
||||
}
|
||||
|
||||
got, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "explicit_value" {
|
||||
t.Errorf("got %q, want %q", got, "explicit_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_OutputExceedsMax(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
// Script outputs more than maxOutputBytes
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
python3 -c "print('x' * 200)"
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
MaxOutputBytes: 10,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for output exceeding maxOutputBytes, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("exec provider output exceeded maxOutputBytes (%d)", 10)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SingleValueFileRefID is the required ref.ID for singleValue file mode
|
||||
// (aligned with OpenClaw ref-contract.ts SINGLE_VALUE_FILE_REF_ID).
|
||||
const SingleValueFileRefID = "$SINGLE_VALUE"
|
||||
|
||||
// resolveFileRef handles {source:"file"} SecretRef resolution.
|
||||
// Reads the file via assertSecurePath audit, then extracts the secret value
|
||||
// based on the provider's mode (singleValue or json with JSON Pointer).
|
||||
func resolveFileRef(ref *SecretRef, pc *ProviderConfig) (string, error) {
|
||||
if pc.Path == "" {
|
||||
return "", fmt.Errorf("file provider path is empty")
|
||||
}
|
||||
|
||||
// OpenClaw preserves user-authored `~/...` paths verbatim on disk for
|
||||
// portability and resolves them at read time. lark-cli reads the file
|
||||
// raw, so we mirror that resolution here before the audit — otherwise
|
||||
// an unambiguous home-relative path would be rejected by
|
||||
// requireAbsolutePath, which is meant to guard against cwd-relative
|
||||
// paths (a different concern). expandTildePath honours OPENCLAW_HOME so
|
||||
// a tilde inside an OPENCLAW_HOME-overridden config resolves to the
|
||||
// same absolute path OpenClaw itself would have used.
|
||||
targetPath := expandTildePath(pc.Path)
|
||||
|
||||
// Security audit on file path
|
||||
securePath, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: targetPath,
|
||||
Label: "secrets.providers file path",
|
||||
TrustedDirs: pc.TrustedDirs,
|
||||
AllowInsecurePath: pc.AllowInsecurePath,
|
||||
AllowReadableByOthers: false, // file provider: strict by default
|
||||
AllowSymlinkPath: false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file provider security audit failed: %w", err)
|
||||
}
|
||||
|
||||
// Read file content
|
||||
maxBytes := pc.MaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = DefaultFileMaxBytes
|
||||
}
|
||||
|
||||
// Note: vfs.ReadFile loads the entire file. maxBytes is enforced post-read
|
||||
// because vfs does not expose a size-limited reader. For secret files this
|
||||
// is acceptable (default limit 1 MiB; secrets are typically < 1 KB).
|
||||
data, err := vfs.ReadFile(securePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read secret file %s: %w", securePath, err)
|
||||
}
|
||||
|
||||
if len(data) > maxBytes {
|
||||
return "", fmt.Errorf("file provider exceeded maxBytes (%d)", maxBytes)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
mode := pc.Mode
|
||||
if mode == "" {
|
||||
mode = "json" // default mode per OpenClaw
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "singleValue":
|
||||
// OpenClaw requires ref.id == SINGLE_VALUE_FILE_REF_ID for singleValue mode
|
||||
if ref.ID != SingleValueFileRefID {
|
||||
return "", fmt.Errorf("singleValue file provider expects ref id %q, got %q",
|
||||
SingleValueFileRefID, ref.ID)
|
||||
}
|
||||
// Entire file content is the secret; trim trailing newline
|
||||
return strings.TrimRight(content, "\r\n"), nil
|
||||
|
||||
case "json":
|
||||
// Parse as JSON, then navigate via JSON Pointer (ref.ID)
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return "", fmt.Errorf("file provider JSON parse error: %w", err)
|
||||
}
|
||||
|
||||
value, err := ReadJSONPointer(parsed, ref.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file provider JSON Pointer %q: %w", ref.ID, err)
|
||||
}
|
||||
|
||||
// Value must be a string
|
||||
strValue, ok := value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("file provider JSON Pointer %q resolved to non-string value", ref.ID)
|
||||
}
|
||||
return strValue, nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file provider mode %q", mode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveFileRef_SingleValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(p, []byte("my_secret\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: p,
|
||||
Mode: "singleValue",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
got, err := resolveFileRef(ref, pc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "my_secret" {
|
||||
t.Errorf("got %q, want %q", got, "my_secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_SingleValue_WrongRefID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(p, []byte("my_secret\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: "WRONG_ID"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: p,
|
||||
Mode: "singleValue",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong ref ID, got nil")
|
||||
}
|
||||
want := `singleValue file provider expects ref id "$SINGLE_VALUE", got "WRONG_ID"`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_JSONMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets.json")
|
||||
content := `{"providers":{"feishu":{"key":"secret123"}}}`
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: "/providers/feishu/key"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: p,
|
||||
Mode: "json",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
got, err := resolveFileRef(ref, pc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "secret123" {
|
||||
t.Errorf("got %q, want %q", got, "secret123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_JSONMode_MissingPointer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets.json")
|
||||
content := `{"providers":{"feishu":{"key":"secret123"}}}`
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: "/providers/nonexistent/key"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: p,
|
||||
Mode: "json",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing JSON pointer, got nil")
|
||||
}
|
||||
want := `file provider JSON Pointer "/providers/nonexistent/key": json pointer "/providers/nonexistent/key": key "nonexistent" not found`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_FileNotFound(t *testing.T) {
|
||||
nonexistent := filepath.Join(t.TempDir(), "no_such_file.txt")
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: nonexistent,
|
||||
Mode: "singleValue",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_EmptyProviderPath(t *testing.T) {
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{Source: "file", Path: "", Mode: "singleValue", AllowInsecurePath: true}
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty provider path, got nil")
|
||||
}
|
||||
want := "file provider path is empty"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_JSONMode_NonStringValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets.json")
|
||||
if err := os.WriteFile(p, []byte(`{"count":42}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ref := &SecretRef{Source: "file", ID: "/count"}
|
||||
pc := &ProviderConfig{Source: "file", Path: p, Mode: "json", AllowInsecurePath: true}
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-string JSON value, got nil")
|
||||
}
|
||||
want := `file provider JSON Pointer "/count" resolved to non-string value`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_UnsupportedMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{Source: "file", Path: p, Mode: "yaml", AllowInsecurePath: true}
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported mode, got nil")
|
||||
}
|
||||
want := `unsupported file provider mode "yaml"`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_DefaultMode_IsJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets.json")
|
||||
if err := os.WriteFile(p, []byte(`{"key":"value123"}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ref := &SecretRef{Source: "file", ID: "/key"}
|
||||
pc := &ProviderConfig{Source: "file", Path: p, Mode: "", AllowInsecurePath: true}
|
||||
got, err := resolveFileRef(ref, pc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "value123" {
|
||||
t.Errorf("got %q, want %q", got, "value123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_JSONMode_InvalidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "bad.json")
|
||||
if err := os.WriteFile(p, []byte("not json"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ref := &SecretRef{Source: "file", ID: "/key"}
|
||||
pc := &ProviderConfig{Source: "file", Path: p, Mode: "json", AllowInsecurePath: true}
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_ExceedsMaxBytes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "big.txt")
|
||||
if err := os.WriteFile(p, []byte("this content is longer than 5 bytes"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: p,
|
||||
Mode: "singleValue",
|
||||
MaxBytes: 5,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file exceeding maxBytes, got nil")
|
||||
}
|
||||
want := "file provider exceeded maxBytes (5)"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveFileRef_TildePath_SingleValue is the end-to-end smoke test
|
||||
// for the fix: a singleValue file provider with a ~/-relative path
|
||||
// resolves correctly through resolveFileRef. Before this PR the audit
|
||||
// would reject the path as "must be absolute".
|
||||
func TestResolveFileRef_TildePath_SingleValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
setFakeOSHome(t, dir)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
|
||||
p := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(p, []byte("tilde_secret\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: "~/secret.txt",
|
||||
Mode: "singleValue",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
got, err := resolveFileRef(ref, pc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "tilde_secret" {
|
||||
t.Errorf("got %q, want %q", got, "tilde_secret")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveFileRef_RelativePath_StillRejected guards the absolute-path
|
||||
// audit: cwd-relative input must still be rejected even though tilde was
|
||||
// loosened. Catches regressions if expandTildePath is ever widened to
|
||||
// also expand "./..." (which would weaken the audit's invariant).
|
||||
func TestResolveFileRef_RelativePath_StillRejected(t *testing.T) {
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: "relative/secret.txt",
|
||||
Mode: "singleValue",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for relative path, got nil")
|
||||
}
|
||||
wantSub := "path must be absolute"
|
||||
if !strings.Contains(err.Error(), wantSub) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), wantSub)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveFileRef_TildePath_JSONMode verifies the tilde-expansion
|
||||
// path works for json mode (where ref id is a JSON pointer) as well as
|
||||
// singleValue mode — the mechanism is mode-agnostic.
|
||||
func TestResolveFileRef_TildePath_JSONMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
setFakeOSHome(t, dir)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
|
||||
p := filepath.Join(dir, "secrets.json")
|
||||
content := `{"providers":{"feishu":{"key":"json_via_tilde"}}}`
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: "/providers/feishu/key"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: "~/secrets.json",
|
||||
Mode: "json",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
got, err := resolveFileRef(ref, pc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "json_via_tilde" {
|
||||
t.Errorf("got %q, want %q", got, "json_via_tilde")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeGetenv(m map[string]string) func(string) string {
|
||||
return func(key string) string { return m[key] }
|
||||
}
|
||||
|
||||
func TestResolve_PlainString(t *testing.T) {
|
||||
got, err := ResolveSecretInput(SecretInput{Plain: "my_secret"}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "my_secret" {
|
||||
t.Errorf("got %q, want %q", got, "my_secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_EmptyInput(t *testing.T) {
|
||||
_, err := ResolveSecretInput(SecretInput{}, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty input, got nil")
|
||||
}
|
||||
want := "appSecret is missing or empty"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_EnvTemplate_Found(t *testing.T) {
|
||||
getenv := makeGetenv(map[string]string{"MY_VAR": "resolved_value"})
|
||||
got, err := ResolveSecretInput(SecretInput{Plain: "${MY_VAR}"}, nil, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "resolved_value" {
|
||||
t.Errorf("got %q, want %q", got, "resolved_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_EnvTemplate_NotFound(t *testing.T) {
|
||||
getenv := makeGetenv(map[string]string{})
|
||||
_, err := ResolveSecretInput(SecretInput{Plain: "${MY_VAR}"}, nil, getenv)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unset env variable, got nil")
|
||||
}
|
||||
want := `env variable "MY_VAR" referenced in openclaw.json is not set or empty`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_EnvTemplate_InvalidFormat(t *testing.T) {
|
||||
getenv := makeGetenv(map[string]string{})
|
||||
got, err := ResolveSecretInput(SecretInput{Plain: "${lowercase}"}, nil, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "${lowercase}" {
|
||||
t.Errorf("got %q, want %q (treated as plain string)", got, "${lowercase}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_EnvRef(t *testing.T) {
|
||||
getenv := makeGetenv(map[string]string{"MY_KEY": "env_val"})
|
||||
input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}}
|
||||
got, err := ResolveSecretInput(input, nil, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "env_val" {
|
||||
t.Errorf("got %q, want %q", got, "env_val")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_EnvRef_NotFound(t *testing.T) {
|
||||
getenv := makeGetenv(map[string]string{})
|
||||
input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}}
|
||||
_, err := ResolveSecretInput(input, nil, getenv)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing env variable, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_EnvRef_Allowlisted(t *testing.T) {
|
||||
getenv := makeGetenv(map[string]string{"MY_KEY": "allowed_val"})
|
||||
cfg := &SecretsConfig{
|
||||
Providers: map[string]*ProviderConfig{
|
||||
"default": {Source: "env", Allowlist: []string{"MY_KEY"}},
|
||||
},
|
||||
}
|
||||
input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}}
|
||||
got, err := ResolveSecretInput(input, cfg, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "allowed_val" {
|
||||
t.Errorf("got %q, want %q", got, "allowed_val")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_EnvRef_NotAllowlisted(t *testing.T) {
|
||||
getenv := makeGetenv(map[string]string{"MY_KEY": "some_val"})
|
||||
cfg := &SecretsConfig{
|
||||
Providers: map[string]*ProviderConfig{
|
||||
"default": {Source: "env", Allowlist: []string{"OTHER"}},
|
||||
},
|
||||
}
|
||||
input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}}
|
||||
_, err := ResolveSecretInput(input, cfg, getenv)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-allowlisted key, got nil")
|
||||
}
|
||||
want := `environment variable "MY_KEY" is not allowlisted in provider`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_UnknownSource(t *testing.T) {
|
||||
getenv := makeGetenv(map[string]string{})
|
||||
cfg := &SecretsConfig{
|
||||
Providers: map[string]*ProviderConfig{
|
||||
"default": {Source: "unknown"},
|
||||
},
|
||||
}
|
||||
input := SecretInput{Ref: &SecretRef{Source: "unknown", Provider: "default", ID: "some_id"}}
|
||||
_, err := ResolveSecretInput(input, cfg, getenv)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown source, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_ProviderNotConfigured(t *testing.T) {
|
||||
getenv := makeGetenv(map[string]string{})
|
||||
cfg := &SecretsConfig{
|
||||
Providers: map[string]*ProviderConfig{},
|
||||
}
|
||||
input := SecretInput{Ref: &SecretRef{Source: "file", Provider: "nonexistent", ID: "/some/path"}}
|
||||
_, err := ResolveSecretInput(input, cfg, getenv)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-configured provider, got nil")
|
||||
}
|
||||
want := `secret provider "nonexistent" is not configured (ref: file:nonexistent:/some/path)`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// hasTildePrefix reports whether s begins with `~` followed by end-of-string,
|
||||
// `/`, or `\` — the form OpenClaw treats as home-relative.
|
||||
func hasTildePrefix(s string) bool {
|
||||
if s == "" || s[0] != '~' {
|
||||
return false
|
||||
}
|
||||
if len(s) == 1 {
|
||||
return true
|
||||
}
|
||||
return s[1] == '/' || s[1] == '\\'
|
||||
}
|
||||
|
||||
// joinTildeSuffix expands a tilde-prefixed string against a resolved home
|
||||
// directory. Replaces only the leading `~` so the original separator
|
||||
// (forward or back slash) and suffix bytes are kept verbatim, matching
|
||||
// OpenClaw's `input.replace(/^~(?=$|[\\/])/, home)` semantics rather than
|
||||
// going through filepath.Join (which would silently drop a literal `\` on
|
||||
// POSIX). filepath.Clean is applied so `..` and duplicate separators are
|
||||
// collapsed in the same way Node's path.resolve does on each platform.
|
||||
//
|
||||
// Caller must ensure hasTildePrefix(s) is true and home is non-empty.
|
||||
func joinTildeSuffix(s, home string) string {
|
||||
if len(s) == 1 {
|
||||
return home
|
||||
}
|
||||
return filepath.Clean(home + s[1:])
|
||||
}
|
||||
|
||||
// normalizeSentinel applies OpenClaw's normalize() helper to a single
|
||||
// string: trims whitespace and treats the JS-flavoured literals
|
||||
// "undefined" / "null" (along with empty/whitespace-only) as unset.
|
||||
func normalizeSentinel(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "undefined" || v == "null" {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// osHome returns the OS-level home directory by walking OpenClaw's
|
||||
// resolution chain: HOME → USERPROFILE → OS user database (getpwuid on
|
||||
// Unix / user32 on Windows, via os/user.Current). Each candidate is
|
||||
// passed through normalizeSentinel so sentinel literals and blank
|
||||
// strings fall through.
|
||||
//
|
||||
// Matches OpenClaw's resolveRawOsHomeDir env chain so the same tilde
|
||||
// resolves against the same home under mixed shell environments and
|
||||
// accidentally-stringified env values. Go's stdlib os.UserHomeDir on
|
||||
// Unix only re-reads HOME and gives up; Node's os.homedir() still
|
||||
// returns the account home via the user database, so the explicit
|
||||
// user.Current() step is what keeps OpenClaw-authored `~/...` working
|
||||
// in HOME-unset shells.
|
||||
//
|
||||
// Deliberate hybrid contract — neither a strict mirror of OpenClaw
|
||||
// nor a strict reject-on-missing:
|
||||
//
|
||||
// - OpenClaw's final fallback is cwd (via resolveRequiredHomeDir →
|
||||
// process.cwd()). We don't do that because requireAbsolutePath
|
||||
// exists precisely to reject cwd-dependent paths; routing
|
||||
// `~/secret` through cwd would defeat the audit invariant.
|
||||
//
|
||||
// - We still go through user.Current() before giving up, even when
|
||||
// HOME is a sentinel literal ("undefined" / "null") and
|
||||
// USERPROFILE is unset. At that point OpenClaw would land on cwd,
|
||||
// and a strict implementation would reject; user.Current() lands
|
||||
// on the account home instead — cwd-independent and user-bound,
|
||||
// so it satisfies the audit's safety goal while still letting
|
||||
// ~/-authored configs resolve in a malformed-env shell.
|
||||
//
|
||||
// - Only returns "" when the env chain AND user.Current() are all
|
||||
// unresolvable, at which point the caller surfaces a clean
|
||||
// "path must be absolute" error from the audit.
|
||||
func osHome() string {
|
||||
if v := normalizeSentinel(os.Getenv("HOME")); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := normalizeSentinel(os.Getenv("USERPROFILE")); v != "" {
|
||||
return v
|
||||
}
|
||||
if u, err := user.Current(); err == nil {
|
||||
return normalizeSentinel(u.HomeDir)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// explicitOpenClawHome reads OPENCLAW_HOME with OpenClaw's normalize()
|
||||
// semantics applied.
|
||||
func explicitOpenClawHome() string {
|
||||
return normalizeSentinel(os.Getenv("OPENCLAW_HOME"))
|
||||
}
|
||||
|
||||
// absolutize returns p as an absolute path, resolving against the process
|
||||
// cwd when p is relative. Returns "" when the cwd cannot be resolved.
|
||||
// Wraps filepath.Abs semantics via vfs.Getwd because forbidigo bans
|
||||
// filepath.Abs inside internal/ packages.
|
||||
func absolutize(p string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(p) {
|
||||
return filepath.Clean(p)
|
||||
}
|
||||
wd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(wd, p)
|
||||
}
|
||||
|
||||
// openClawHome returns the home directory used to resolve `~`-relative paths
|
||||
// authored against OpenClaw's config. Closely mirrors OpenClaw's
|
||||
// home-resolution semantics so the same tilde resolves to the same
|
||||
// absolute path here as inside OpenClaw runtime under all normal
|
||||
// conditions.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. OPENCLAW_HOME env var, when set (sentinel-normalised).
|
||||
// 2. If OPENCLAW_HOME itself has a tilde prefix, expand it against the OS
|
||||
// home (see osHome); the result is empty when the OS home is
|
||||
// unresolvable.
|
||||
// 3. Otherwise fall back to the OS home.
|
||||
//
|
||||
// The returned path is absolute (relative OPENCLAW_HOME values are
|
||||
// absolutised against the process cwd, matching Node path.resolve in
|
||||
// OpenClaw's pipeline).
|
||||
//
|
||||
// Returns "" when no home can be resolved. This is a deliberate
|
||||
// divergence from OpenClaw, whose read pipeline would fall back to
|
||||
// cwd via resolveRequiredHomeDir — see osHome for the rationale.
|
||||
func openClawHome() string {
|
||||
raw := explicitOpenClawHome()
|
||||
switch {
|
||||
case raw == "":
|
||||
raw = osHome()
|
||||
case hasTildePrefix(raw):
|
||||
h := osHome()
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
raw = joinTildeSuffix(raw, h)
|
||||
}
|
||||
return absolutize(raw)
|
||||
}
|
||||
|
||||
// expandTildePath resolves a leading `~` or `~/...` prefix to OpenClaw's
|
||||
// effective home directory (see openClawHome).
|
||||
//
|
||||
// Returns the input unchanged when it lacks a tilde prefix or when
|
||||
// openClawHome cannot resolve a home directory. The latter case is a
|
||||
// deliberate divergence from OpenClaw, whose read pipeline falls back
|
||||
// to cwd — see osHome. Surfacing a "path must be absolute" error from
|
||||
// the audit is preferable to silently routing a user-authored
|
||||
// `~/secret` through cwd resolution.
|
||||
//
|
||||
// `~user` shell-style expansion is intentionally not supported (OpenClaw
|
||||
// does not support it either).
|
||||
func expandTildePath(p string) string {
|
||||
if !hasTildePrefix(p) {
|
||||
return p
|
||||
}
|
||||
home := openClawHome()
|
||||
if home == "" {
|
||||
return p
|
||||
}
|
||||
return joinTildeSuffix(p, home)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setFakeOSHome controls osHome's env-chain inputs (HOME and USERPROFILE)
|
||||
// in one call so tests stay deterministic across platforms. osHome reads
|
||||
// HOME first, then USERPROFILE, then user.Current(); setting only one of
|
||||
// the two leaves the test sensitive to whichever the runner happens to
|
||||
// have populated. Passing dir == "" disables both env entries so tests
|
||||
// can exercise the user.Current() fallback or no-home edge cases.
|
||||
func setFakeOSHome(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir)
|
||||
}
|
||||
|
||||
// isolateRuntimeWrites parks the process cwd in a fresh TempDir for the
|
||||
// test's duration. Tests that set HOME to a sentinel literal trigger Go
|
||||
// runtime side effects — most visibly the telemetry subsystem, which
|
||||
// calls os.UserConfigDir() (= "$HOME/Library/Application Support" on
|
||||
// darwin) and happily writes through a relative result like
|
||||
// "undefined/Library/...". Without isolation those files land in the
|
||||
// package or repo dir and get accidentally staged. Chdir'ing into a
|
||||
// TempDir routes the noise into a path testing.T auto-cleans.
|
||||
func isolateRuntimeWrites(t *testing.T) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(orig)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenClawHome covers the openClawHome resolution table: empty /
|
||||
// sentinel OPENCLAW_HOME falls back to the OS home, explicit absolute
|
||||
// values are used verbatim (with whitespace trimmed), and tilde-prefixed
|
||||
// values recurse through the OS home.
|
||||
func TestOpenClawHome(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
explicit := t.TempDir()
|
||||
setFakeOSHome(t, homeDir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
openclawEnv string
|
||||
want string
|
||||
}{
|
||||
{"unset falls back to OS home", "", homeDir},
|
||||
{"undefined literal treated as unset", "undefined", homeDir},
|
||||
{"null literal treated as unset", "null", homeDir},
|
||||
{"whitespace-only treated as unset", " ", homeDir},
|
||||
{"explicit absolute path used verbatim", explicit, explicit},
|
||||
{"explicit absolute path is trimmed", " " + explicit + " ", explicit},
|
||||
{"bare tilde resolves to OS home", "~", homeDir},
|
||||
{"tilde-prefixed value recurses through OS home", "~/custom", filepath.Join(homeDir, "custom")},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", tc.openclawEnv)
|
||||
got := openClawHome()
|
||||
if got != tc.want {
|
||||
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenClawHome_RelativeIsAbsolutized confirms a relative
|
||||
// OPENCLAW_HOME is resolved against the process cwd, mirroring Node's
|
||||
// path.resolve behaviour in OpenClaw.
|
||||
func TestOpenClawHome_RelativeIsAbsolutized(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", filepath.FromSlash("relative/dir"))
|
||||
got := openClawHome()
|
||||
|
||||
if !filepath.IsAbs(got) {
|
||||
t.Fatalf("openClawHome() = %q, want absolute path", got)
|
||||
}
|
||||
wantSuffix := filepath.FromSlash("relative/dir")
|
||||
if !strings.HasSuffix(got, wantSuffix) {
|
||||
t.Errorf("openClawHome() = %q, want suffix %q", got, wantSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenClawHome_FallsBackToUserDatabase pins osHome's final fallback
|
||||
// to the OS user database when HOME and USERPROFILE are both unset,
|
||||
// matching Node's os.homedir() (which uses getpwuid). Cwd-independent
|
||||
// and user-bound, so it does not conflict with the "no cwd fallback"
|
||||
// rule documented on osHome.
|
||||
func TestOpenClawHome_FallsBackToUserDatabase(t *testing.T) {
|
||||
u, err := user.Current()
|
||||
if err != nil || u.HomeDir == "" {
|
||||
t.Skip("os/user.Current() unavailable on this runner")
|
||||
}
|
||||
setFakeOSHome(t, "")
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
got := openClawHome()
|
||||
if got != u.HomeDir {
|
||||
t.Errorf("openClawHome() = %q, want %q (account home from user.Current)", got, u.HomeDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback pins that
|
||||
// a tilde-form OPENCLAW_HOME ("~/custom") expands against the
|
||||
// user-database fallback when HOME and USERPROFILE are both unset.
|
||||
// Without the user.Current() step in osHome this would have failed
|
||||
// (returning "") and dropped the bind back to the audit's
|
||||
// "path must be absolute" error.
|
||||
func TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback(t *testing.T) {
|
||||
u, err := user.Current()
|
||||
if err != nil || u.HomeDir == "" {
|
||||
t.Skip("os/user.Current() unavailable on this runner")
|
||||
}
|
||||
setFakeOSHome(t, "")
|
||||
t.Setenv("OPENCLAW_HOME", "~/custom")
|
||||
got := openClawHome()
|
||||
want := filepath.Join(u.HomeDir, "custom")
|
||||
if got != want {
|
||||
t.Errorf("openClawHome() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandTildePath covers the full input grid for expandTildePath:
|
||||
// bare tilde, tilde-slash, tilde + suffix, nested suffix, plain absolute
|
||||
// and relative literals, and the intentionally-unchanged forms (~user,
|
||||
// ~foo) that OpenClaw does not expand either.
|
||||
func TestExpandTildePath(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
absFixture := filepath.Join(fakeHome, "abs.json")
|
||||
setFakeOSHome(t, fakeHome)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"bare tilde", "~", fakeHome},
|
||||
{"tilde slash", "~/", fakeHome},
|
||||
{"tilde with file", "~/secret.json", filepath.Join(fakeHome, "secret.json")},
|
||||
{"tilde with nested path", "~/.openclaw/secret.json", filepath.Join(fakeHome, ".openclaw/secret.json")},
|
||||
{"absolute unchanged", absFixture, absFixture},
|
||||
{"relative unchanged", "foo/bar", "foo/bar"},
|
||||
{"dot relative unchanged", "../foo", "../foo"},
|
||||
{"tilde user form unchanged", "~root/foo", "~root/foo"},
|
||||
{"tilde without separator unchanged", "~foo", "~foo"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := expandTildePath(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("expandTildePath(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandTildePath_RespectsOpenClawHome verifies that with
|
||||
// OPENCLAW_HOME set, tilde expansion uses that custom home rather than
|
||||
// the OS home — the integration-level invariant that closes the
|
||||
// internal inconsistency CodeX's first review flagged.
|
||||
func TestExpandTildePath_RespectsOpenClawHome(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
clawHome := t.TempDir()
|
||||
setFakeOSHome(t, homeDir)
|
||||
t.Setenv("OPENCLAW_HOME", clawHome)
|
||||
|
||||
got := expandTildePath("~/secret.json")
|
||||
want := filepath.Join(clawHome, "secret.json")
|
||||
if got != want {
|
||||
t.Errorf("expandTildePath(%q) = %q, want %q (should use OPENCLAW_HOME)", "~/secret.json", got, want)
|
||||
}
|
||||
if got == filepath.Join(homeDir, "secret.json") {
|
||||
t.Errorf("expandTildePath unexpectedly used OS home %q instead of OPENCLAW_HOME %q", homeDir, clawHome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandTildePath_FallsBackToUserDatabase is the end-to-end
|
||||
// equivalent of TestOpenClawHome_FallsBackToUserDatabase: with HOME and
|
||||
// USERPROFILE both unset, expandTildePath still resolves `~/foo` via
|
||||
// osHome's user.Current() step. Matches Node os.homedir() and keeps
|
||||
// OpenClaw-authored configs working in minimal-env shells.
|
||||
func TestExpandTildePath_FallsBackToUserDatabase(t *testing.T) {
|
||||
u, err := user.Current()
|
||||
if err != nil || u.HomeDir == "" {
|
||||
t.Skip("os/user.Current() unavailable on this runner")
|
||||
}
|
||||
setFakeOSHome(t, "")
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
got := expandTildePath("~/foo")
|
||||
want := filepath.Join(u.HomeDir, "foo")
|
||||
if got != want {
|
||||
t.Errorf("expandTildePath(~/foo) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenClawHome_OSHomeNormalization pins OpenClaw's sentinel
|
||||
// normalisation on the env chain: the literals "undefined" / "null" /
|
||||
// blank-or-whitespace are all treated as unset, so a JS-flavoured
|
||||
// accidentally-stringified env value (e.g. `HOME=undefined` from a
|
||||
// shell wrapper) doesn't end up as a literal directory component when
|
||||
// the user authored `~/secret`. Combined with the user.Current()
|
||||
// fallback further down (see TestOpenClawHome_FallsBackToUserDatabase),
|
||||
// the contract is: a malformed HOME falls through to USERPROFILE first,
|
||||
// and only if that's also unset/sentinel do we go to the user database.
|
||||
func TestOpenClawHome_OSHomeNormalization(t *testing.T) {
|
||||
isolateRuntimeWrites(t)
|
||||
userProfileDir := t.TempDir()
|
||||
homeWinsDir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
home string
|
||||
userProfile string
|
||||
want string
|
||||
}{
|
||||
{"HOME=undefined falls through to USERPROFILE", "undefined", userProfileDir, userProfileDir},
|
||||
{"HOME=null falls through to USERPROFILE", "null", userProfileDir, userProfileDir},
|
||||
{"HOME=whitespace falls through to USERPROFILE", " ", userProfileDir, userProfileDir},
|
||||
{"HOME wins over USERPROFILE when both are valid", homeWinsDir, userProfileDir, homeWinsDir},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("HOME", tc.home)
|
||||
t.Setenv("USERPROFILE", tc.userProfile)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
if got := openClawHome(); got != tc.want {
|
||||
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd pins the
|
||||
// deliberate hybrid documented on osHome: with HOME a sentinel literal
|
||||
// and USERPROFILE unset, OpenClaw would fall back to process.cwd();
|
||||
// this implementation falls to the OS user database instead. The
|
||||
// account home is both safer (cwd-independent) and more useful (it is
|
||||
// where the user originally authored `~/...` against), so we prefer it
|
||||
// over either OpenClaw's cwd fallback or a strict reject.
|
||||
func TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd(t *testing.T) {
|
||||
isolateRuntimeWrites(t)
|
||||
u, err := user.Current()
|
||||
if err != nil || u.HomeDir == "" {
|
||||
t.Skip("os/user.Current() unavailable on this runner")
|
||||
}
|
||||
t.Setenv("HOME", "undefined")
|
||||
t.Setenv("USERPROFILE", "")
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
got := openClawHome()
|
||||
if got != u.HomeDir {
|
||||
t.Errorf("openClawHome() = %q, want %q (account home, not cwd)", got, u.HomeDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandTildePath_BackslashPreservedOnPOSIX pins that `~\secret.json`
|
||||
// expands by replacing only the `~` byte, leaving the backslash literally
|
||||
// as part of the filename — matching OpenClaw's regex-replace semantics
|
||||
// (`/^~(?=$|[\\/])/`) rather than going through filepath.Join (which would
|
||||
// drop the backslash on POSIX). On Windows backslash is a real separator,
|
||||
// so the literal-byte invariant doesn't apply.
|
||||
func TestExpandTildePath_BackslashPreservedOnPOSIX(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("backslash is a path separator on Windows; invariant only applies on POSIX")
|
||||
}
|
||||
fakeHome := t.TempDir()
|
||||
setFakeOSHome(t, fakeHome)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
|
||||
got := expandTildePath(`~\secret.json`)
|
||||
want := fakeHome + `\secret.json`
|
||||
if got != want {
|
||||
t.Errorf("expandTildePath(%q) = %q, want %q (backslash should be preserved as filename byte)", `~\secret.json`, got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenClawRoot captures the minimal subset of openclaw.json needed by config bind.
|
||||
// Unknown fields are silently ignored (forward-compatible with future OpenClaw versions).
|
||||
type OpenClawRoot struct {
|
||||
Channels ChannelsRoot `json:"channels"`
|
||||
Secrets *SecretsConfig `json:"secrets,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelsRoot holds channel configurations.
|
||||
type ChannelsRoot struct {
|
||||
Feishu *FeishuChannel `json:"feishu,omitempty"`
|
||||
}
|
||||
|
||||
// FeishuChannel represents the channels.feishu subtree.
|
||||
// Single-account: AppID + AppSecret + Brand at top level.
|
||||
// Multi-account: Accounts map (keyed by label like "work", "personal").
|
||||
//
|
||||
// Note: OpenClaw's canonical schema stores the brand under the key
|
||||
// `domain` (values "feishu" | "lark"), not `brand`. The Go field name
|
||||
// `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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// isEnabled returns true if the enabled field is nil (default) or explicitly true.
|
||||
func isEnabled(enabled *bool) bool {
|
||||
return enabled == nil || *enabled
|
||||
}
|
||||
|
||||
// SecretInput is a union type: either a plain string or a SecretRef object.
|
||||
// Implements custom JSON unmarshaling to handle both forms.
|
||||
type SecretInput struct {
|
||||
Plain string // non-empty when value is a plain string (including "${VAR}" templates)
|
||||
Ref *SecretRef // non-nil when value is a SecretRef object
|
||||
}
|
||||
|
||||
// IsZero returns true if no value was provided.
|
||||
func (s SecretInput) IsZero() bool {
|
||||
return s.Plain == "" && s.Ref == nil
|
||||
}
|
||||
|
||||
// IsPlain returns true if this is a plain string (not a SecretRef object).
|
||||
func (s SecretInput) IsPlain() bool {
|
||||
return s.Ref == nil
|
||||
}
|
||||
|
||||
// SecretRef references a secret stored externally via OpenClaw's provider system.
|
||||
type SecretRef struct {
|
||||
Source string `json:"source"` // "env" | "file" | "exec"
|
||||
Provider string `json:"provider,omitempty"` // provider alias; defaults to config.secrets.defaults.<source> or "default"
|
||||
ID string `json:"id"` // lookup key (env var name / JSON pointer / exec ref id)
|
||||
}
|
||||
|
||||
// validSources lists accepted SecretRef source values.
|
||||
var validSources = map[string]bool{
|
||||
"env": true,
|
||||
"file": true,
|
||||
"exec": true,
|
||||
}
|
||||
|
||||
// EnvTemplateRe matches OpenClaw env template strings like "${FEISHU_APP_SECRET}".
|
||||
// Only uppercase letters, digits, and underscores; 1-128 chars; must start with uppercase.
|
||||
var EnvTemplateRe = regexp.MustCompile(`^\$\{([A-Z][A-Z0-9_]{0,127})\}$`)
|
||||
|
||||
// UnmarshalJSON handles both string and object forms of SecretInput.
|
||||
func (s *SecretInput) UnmarshalJSON(data []byte) error {
|
||||
// Try string first
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
s.Plain = str
|
||||
s.Ref = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try SecretRef object
|
||||
var ref SecretRef
|
||||
if err := json.Unmarshal(data, &ref); err == nil {
|
||||
if !validSources[ref.Source] {
|
||||
return fmt.Errorf("SecretRef.source must be env|file|exec, got %q", ref.Source)
|
||||
}
|
||||
if ref.ID == "" {
|
||||
return fmt.Errorf("SecretRef.id must be non-empty")
|
||||
}
|
||||
s.Ref = &ref
|
||||
s.Plain = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("appSecret must be a string or {source, provider?, id} object")
|
||||
}
|
||||
|
||||
// MarshalJSON serializes SecretInput back to JSON.
|
||||
func (s SecretInput) MarshalJSON() ([]byte, error) {
|
||||
if s.Ref != nil {
|
||||
return json.Marshal(s.Ref)
|
||||
}
|
||||
return json.Marshal(s.Plain)
|
||||
}
|
||||
|
||||
// SecretsConfig captures the secrets.providers registry from openclaw.json.
|
||||
type SecretsConfig struct {
|
||||
Providers map[string]*ProviderConfig `json:"providers,omitempty"`
|
||||
Defaults *ProviderDefaults `json:"defaults,omitempty"`
|
||||
}
|
||||
|
||||
// ProviderDefaults holds default provider aliases for each source type.
|
||||
type ProviderDefaults struct {
|
||||
Env string `json:"env,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultProviderAlias is the fallback provider name when none is specified.
|
||||
const DefaultProviderAlias = "default"
|
||||
|
||||
// ProviderConfig holds configuration for a secret provider.
|
||||
// Fields are source-specific; unused fields for other sources are ignored.
|
||||
type ProviderConfig struct {
|
||||
Source string `json:"source"` // "env" | "file" | "exec"
|
||||
|
||||
// env source fields
|
||||
Allowlist []string `json:"allowlist,omitempty"`
|
||||
|
||||
// file source fields
|
||||
Path string `json:"path,omitempty"`
|
||||
Mode string `json:"mode,omitempty"` // "singleValue" | "json"; default "json"
|
||||
TimeoutMs int `json:"timeoutMs,omitempty"`
|
||||
MaxBytes int `json:"maxBytes,omitempty"`
|
||||
|
||||
// exec source fields
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
NoOutputTimeoutMs int `json:"noOutputTimeoutMs,omitempty"`
|
||||
MaxOutputBytes int `json:"maxOutputBytes,omitempty"`
|
||||
JSONOnly *bool `json:"jsonOnly,omitempty"` // nil = default true
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
PassEnv []string `json:"passEnv,omitempty"`
|
||||
TrustedDirs []string `json:"trustedDirs,omitempty"`
|
||||
AllowInsecurePath bool `json:"allowInsecurePath,omitempty"`
|
||||
AllowSymlinkCommand bool `json:"allowSymlinkCommand,omitempty"`
|
||||
}
|
||||
|
||||
// Default values for provider config fields (aligned with OpenClaw resolve.ts).
|
||||
const (
|
||||
DefaultFileTimeoutMs = 5000
|
||||
DefaultFileMaxBytes = 1024 * 1024 // 1 MiB
|
||||
DefaultExecTimeoutMs = 10000
|
||||
DefaultExecMaxOutputBytes = 1024 * 1024 // 1 MiB
|
||||
)
|
||||
|
||||
// ResolveDefaultProvider returns the effective provider alias for a SecretRef.
|
||||
// If ref.Provider is set, returns it; otherwise falls back to config defaults or "default".
|
||||
func ResolveDefaultProvider(ref *SecretRef, cfg *SecretsConfig) string {
|
||||
if ref.Provider != "" {
|
||||
return ref.Provider
|
||||
}
|
||||
if cfg != nil && cfg.Defaults != nil {
|
||||
switch ref.Source {
|
||||
case "env":
|
||||
if cfg.Defaults.Env != "" {
|
||||
return cfg.Defaults.Env
|
||||
}
|
||||
case "file":
|
||||
if cfg.Defaults.File != "" {
|
||||
return cfg.Defaults.File
|
||||
}
|
||||
case "exec":
|
||||
if cfg.Defaults.Exec != "" {
|
||||
return cfg.Defaults.Exec
|
||||
}
|
||||
}
|
||||
}
|
||||
return DefaultProviderAlias
|
||||
}
|
||||
|
||||
// LookupProvider resolves a provider config from the registry.
|
||||
// Returns the provider config or an error if not found.
|
||||
// Special case: env source with "default" provider returns a synthetic empty env provider.
|
||||
func LookupProvider(ref *SecretRef, cfg *SecretsConfig) (*ProviderConfig, error) {
|
||||
providerName := ResolveDefaultProvider(ref, cfg)
|
||||
|
||||
if cfg != nil && cfg.Providers != nil {
|
||||
if pc, ok := cfg.Providers[providerName]; ok {
|
||||
if pc == nil {
|
||||
return nil, fmt.Errorf("secret provider %q is configured as null", providerName)
|
||||
}
|
||||
if pc.Source != ref.Source {
|
||||
return nil, fmt.Errorf("secret provider %q has source %q but ref requests %q",
|
||||
providerName, pc.Source, ref.Source)
|
||||
}
|
||||
return pc, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: default env provider (implicit, per OpenClaw resolve.ts)
|
||||
if ref.Source == "env" && providerName == DefaultProviderAlias {
|
||||
return &ProviderConfig{Source: "env"}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("secret provider %q is not configured (ref: %s:%s:%s)",
|
||||
providerName, ref.Source, providerName, ref.ID)
|
||||
}
|
||||
|
||||
// CandidateApp represents a bindable app from OpenClaw's feishu channel config.
|
||||
type CandidateApp struct {
|
||||
Label string
|
||||
AppID string
|
||||
AppSecret SecretInput
|
||||
Brand string
|
||||
}
|
||||
|
||||
// ListCandidateApps enumerates all bindable (enabled) apps from a FeishuChannel.
|
||||
// Disabled accounts (enabled: false) are filtered out.
|
||||
func ListCandidateApps(ch *FeishuChannel) []CandidateApp {
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
if len(ch.Accounts) > 0 {
|
||||
apps := make([]CandidateApp, 0, len(ch.Accounts)+1)
|
||||
|
||||
// When accounts exist AND top-level has its own appId+appSecret,
|
||||
// include the top-level as a "default" candidate — aligned with
|
||||
// openclaw-lark getLarkAccountIds() which adds DEFAULT_ACCOUNT_ID
|
||||
// when top-level credentials are present and no explicit "default" exists.
|
||||
hasDefault := false
|
||||
for label := range ch.Accounts {
|
||||
if strings.EqualFold(strings.TrimSpace(label), "default") {
|
||||
hasDefault = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDefault && ch.AppID != "" && !ch.AppSecret.IsZero() && isEnabled(ch.Enabled) {
|
||||
apps = append(apps, CandidateApp{
|
||||
Label: "default",
|
||||
AppID: ch.AppID,
|
||||
AppSecret: ch.AppSecret,
|
||||
Brand: ch.Brand,
|
||||
})
|
||||
}
|
||||
|
||||
for label, acct := range ch.Accounts {
|
||||
if acct == nil || !isEnabled(acct.Enabled) {
|
||||
continue // skip disabled accounts
|
||||
}
|
||||
appID := acct.AppID
|
||||
if appID == "" {
|
||||
appID = ch.AppID // inherit from top-level
|
||||
}
|
||||
if appID == "" {
|
||||
continue // skip entries with no effective AppID
|
||||
}
|
||||
appSecret := acct.AppSecret
|
||||
if appSecret.IsZero() {
|
||||
appSecret = ch.AppSecret // inherit from top-level
|
||||
}
|
||||
brand := acct.Brand
|
||||
if brand == "" {
|
||||
brand = ch.Brand
|
||||
}
|
||||
apps = append(apps, CandidateApp{
|
||||
Label: label,
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
Brand: brand,
|
||||
})
|
||||
}
|
||||
return apps
|
||||
}
|
||||
|
||||
// Single account at top level — check if channel itself is enabled
|
||||
if ch.AppID != "" && isEnabled(ch.Enabled) {
|
||||
return []CandidateApp{{
|
||||
Label: "",
|
||||
AppID: ch.AppID,
|
||||
AppSecret: ch.AppSecret,
|
||||
Brand: ch.Brand,
|
||||
}}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecretInput_MarshalJSON_PlainString(t *testing.T) {
|
||||
input := SecretInput{Plain: "my_secret"}
|
||||
data, err := input.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := `"my_secret"`
|
||||
if string(data) != want {
|
||||
t.Errorf("got %s, want %s", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretInput_MarshalJSON_SecretRef(t *testing.T) {
|
||||
input := SecretInput{Ref: &SecretRef{Source: "env", ID: "MY_VAR"}}
|
||||
data, err := input.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var ref SecretRef
|
||||
if err := json.Unmarshal(data, &ref); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if ref.Source != "env" {
|
||||
t.Errorf("source = %q, want %q", ref.Source, "env")
|
||||
}
|
||||
if ref.ID != "MY_VAR" {
|
||||
t.Errorf("id = %q, want %q", ref.ID, "MY_VAR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretInput_UnmarshalJSON_InvalidSource(t *testing.T) {
|
||||
data := []byte(`{"source":"invalid","id":"key"}`)
|
||||
var input SecretInput
|
||||
err := json.Unmarshal(data, &input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid source, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretInput_UnmarshalJSON_EmptyID(t *testing.T) {
|
||||
data := []byte(`{"source":"env","id":""}`)
|
||||
var input SecretInput
|
||||
err := json.Unmarshal(data, &input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretInput_UnmarshalJSON_InvalidType(t *testing.T) {
|
||||
data := []byte(`42`)
|
||||
var input SecretInput
|
||||
err := json.Unmarshal(data, &input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for numeric input, got nil")
|
||||
}
|
||||
want := "appSecret must be a string or {source, provider?, id} object"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDefaultProvider_ExplicitProvider(t *testing.T) {
|
||||
ref := &SecretRef{Source: "env", Provider: "my-custom", ID: "KEY"}
|
||||
got := ResolveDefaultProvider(ref, nil)
|
||||
if got != "my-custom" {
|
||||
t.Errorf("got %q, want %q", got, "my-custom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDefaultProvider_FromDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
defaults *ProviderDefaults
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "env default",
|
||||
source: "env",
|
||||
defaults: &ProviderDefaults{Env: "my-env-prov"},
|
||||
want: "my-env-prov",
|
||||
},
|
||||
{
|
||||
name: "file default",
|
||||
source: "file",
|
||||
defaults: &ProviderDefaults{File: "my-file-prov"},
|
||||
want: "my-file-prov",
|
||||
},
|
||||
{
|
||||
name: "exec default",
|
||||
source: "exec",
|
||||
defaults: &ProviderDefaults{Exec: "my-exec-prov"},
|
||||
want: "my-exec-prov",
|
||||
},
|
||||
{
|
||||
name: "no defaults configured",
|
||||
source: "env",
|
||||
defaults: &ProviderDefaults{},
|
||||
want: DefaultProviderAlias,
|
||||
},
|
||||
{
|
||||
name: "nil defaults",
|
||||
source: "env",
|
||||
defaults: nil,
|
||||
want: DefaultProviderAlias,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ref := &SecretRef{Source: tt.source, ID: "KEY"}
|
||||
cfg := &SecretsConfig{Defaults: tt.defaults}
|
||||
got := ResolveDefaultProvider(ref, cfg)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDefaultProvider_NilConfig(t *testing.T) {
|
||||
ref := &SecretRef{Source: "env", ID: "KEY"}
|
||||
got := ResolveDefaultProvider(ref, nil)
|
||||
if got != DefaultProviderAlias {
|
||||
t.Errorf("got %q, want %q", got, DefaultProviderAlias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupProvider_SourceMismatch(t *testing.T) {
|
||||
cfg := &SecretsConfig{
|
||||
Providers: map[string]*ProviderConfig{
|
||||
"default": {Source: "file"},
|
||||
},
|
||||
}
|
||||
ref := &SecretRef{Source: "env", ID: "KEY"}
|
||||
_, err := LookupProvider(ref, cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for source mismatch, got nil")
|
||||
}
|
||||
want := `secret provider "default" has source "file" but ref requests "env"`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupProvider_ImplicitDefaultEnv(t *testing.T) {
|
||||
// Default env provider is implicitly available even without explicit config
|
||||
ref := &SecretRef{Source: "env", ID: "KEY"}
|
||||
pc, err := LookupProvider(ref, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pc.Source != "env" {
|
||||
t.Errorf("source = %q, want %q", pc.Source, "env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCandidateApps_NilChannel(t *testing.T) {
|
||||
got := ListCandidateApps(nil)
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCandidateApps_SingleAccount(t *testing.T) {
|
||||
ch := &FeishuChannel{
|
||||
AppID: "cli_single",
|
||||
AppSecret: SecretInput{Plain: "secret"},
|
||||
Brand: "feishu",
|
||||
}
|
||||
got := ListCandidateApps(ch)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("count = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].AppID != "cli_single" {
|
||||
t.Errorf("appId = %q, want %q", got[0].AppID, "cli_single")
|
||||
}
|
||||
if got[0].Label != "" {
|
||||
t.Errorf("label = %q, want empty", got[0].Label)
|
||||
}
|
||||
if got[0].Brand != "feishu" {
|
||||
t.Errorf("brand = %q, want %q", got[0].Brand, "feishu")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCandidateApps_SingleAccount_Disabled(t *testing.T) {
|
||||
disabled := false
|
||||
ch := &FeishuChannel{
|
||||
Enabled: &disabled,
|
||||
AppID: "cli_disabled",
|
||||
AppSecret: SecretInput{Plain: "secret"},
|
||||
}
|
||||
got := ListCandidateApps(ch)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected 0 apps for disabled channel, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCandidateApps_MultiAccount_InheritTopLevel(t *testing.T) {
|
||||
ch := &FeishuChannel{
|
||||
AppID: "cli_top_level",
|
||||
Brand: "lark",
|
||||
Accounts: map[string]*FeishuAccount{
|
||||
"work": {
|
||||
// No AppID → inherits from top-level
|
||||
AppSecret: SecretInput{Plain: "secret"},
|
||||
// No Brand → inherits from top-level
|
||||
},
|
||||
},
|
||||
}
|
||||
got := ListCandidateApps(ch)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("count = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].AppID != "cli_top_level" {
|
||||
t.Errorf("inherited appId = %q, want %q", got[0].AppID, "cli_top_level")
|
||||
}
|
||||
if got[0].Brand != "lark" {
|
||||
t.Errorf("inherited brand = %q, want %q", got[0].Brand, "lark")
|
||||
}
|
||||
if got[0].Label != "work" {
|
||||
t.Errorf("label = %q, want %q", got[0].Label, "work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCandidateApps_MultiAccount_InheritAppSecret(t *testing.T) {
|
||||
// Reproduces the "default": {} edge case from real openclaw.json configs
|
||||
// where an empty account object should inherit appSecret from the top-level channel.
|
||||
ch := &FeishuChannel{
|
||||
AppID: "cli_fake_top_level",
|
||||
AppSecret: SecretInput{Plain: "fake_top_level_secret"},
|
||||
Brand: "feishu",
|
||||
Accounts: map[string]*FeishuAccount{
|
||||
"default": {}, // empty — should inherit everything from top-level
|
||||
"other": {
|
||||
Enabled: boolPtr(true),
|
||||
AppID: "cli_fake_other",
|
||||
AppSecret: SecretInput{Plain: "fake_other_secret"},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := ListCandidateApps(ch)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("count = %d, want 2", len(got))
|
||||
}
|
||||
// Find the "default" account
|
||||
var def *CandidateApp
|
||||
for i := range got {
|
||||
if got[i].Label == "default" {
|
||||
def = &got[i]
|
||||
}
|
||||
}
|
||||
if def == nil {
|
||||
t.Fatal("default account not found in candidates")
|
||||
}
|
||||
if def.AppID != "cli_fake_top_level" {
|
||||
t.Errorf("default appId = %q, want inherited top-level", def.AppID)
|
||||
}
|
||||
if def.AppSecret.IsZero() {
|
||||
t.Error("default appSecret should inherit from top-level, got zero")
|
||||
}
|
||||
if def.AppSecret.Plain != "fake_top_level_secret" {
|
||||
t.Errorf("default appSecret = %q, want inherited top-level", def.AppSecret.Plain)
|
||||
}
|
||||
if def.Brand != "feishu" {
|
||||
t.Errorf("default brand = %q, want inherited top-level", def.Brand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCandidateApps_ImplicitDefault_WhenTopLevelHasCredentials(t *testing.T) {
|
||||
// When accounts exist but none is named "default", and top-level has
|
||||
// its own appId+appSecret, the top-level should be included as a
|
||||
// synthetic "default" candidate (aligned with openclaw-lark plugin).
|
||||
ch := &FeishuChannel{
|
||||
AppID: "cli_top",
|
||||
AppSecret: SecretInput{Plain: "top_secret"},
|
||||
Brand: "feishu",
|
||||
Accounts: map[string]*FeishuAccount{
|
||||
"ethan": {
|
||||
AppID: "cli_ethan",
|
||||
AppSecret: SecretInput{Plain: "ethan_secret"},
|
||||
Brand: "lark",
|
||||
},
|
||||
},
|
||||
}
|
||||
got := ListCandidateApps(ch)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("count = %d, want 2 (default + ethan)", len(got))
|
||||
}
|
||||
var def, ethan *CandidateApp
|
||||
for i := range got {
|
||||
switch got[i].Label {
|
||||
case "default":
|
||||
def = &got[i]
|
||||
case "ethan":
|
||||
ethan = &got[i]
|
||||
}
|
||||
}
|
||||
if def == nil {
|
||||
t.Fatal("implicit default candidate not found")
|
||||
}
|
||||
if def.AppID != "cli_top" {
|
||||
t.Errorf("default appId = %q, want %q", def.AppID, "cli_top")
|
||||
}
|
||||
if ethan == nil {
|
||||
t.Fatal("ethan candidate not found")
|
||||
}
|
||||
if ethan.AppID != "cli_ethan" {
|
||||
t.Errorf("ethan appId = %q, want %q", ethan.AppID, "cli_ethan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCandidateApps_NoImplicitDefault_WhenExplicitDefaultExists(t *testing.T) {
|
||||
// When accounts already contain a "default" entry, don't duplicate it.
|
||||
ch := &FeishuChannel{
|
||||
AppID: "cli_top",
|
||||
AppSecret: SecretInput{Plain: "top_secret"},
|
||||
Accounts: map[string]*FeishuAccount{
|
||||
"default": {}, // inherits top-level
|
||||
"other": {AppID: "cli_other", AppSecret: SecretInput{Plain: "s"}},
|
||||
},
|
||||
}
|
||||
got := ListCandidateApps(ch)
|
||||
defaultCount := 0
|
||||
for _, c := range got {
|
||||
if c.Label == "default" {
|
||||
defaultCount++
|
||||
}
|
||||
}
|
||||
if defaultCount != 1 {
|
||||
t.Errorf("expected exactly 1 default candidate, got %d", defaultCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCandidateApps_NoImplicitDefault_WhenTopLevelMissingSecret(t *testing.T) {
|
||||
// Top-level has appId but no appSecret → no implicit default.
|
||||
ch := &FeishuChannel{
|
||||
AppID: "cli_top",
|
||||
// no appSecret
|
||||
Accounts: map[string]*FeishuAccount{
|
||||
"ethan": {AppID: "cli_ethan", AppSecret: SecretInput{Plain: "s"}},
|
||||
},
|
||||
}
|
||||
got := ListCandidateApps(ch)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("count = %d, want 1 (only ethan)", len(got))
|
||||
}
|
||||
if got[0].Label != "ethan" {
|
||||
t.Errorf("label = %q, want %q", got[0].Label, "ethan")
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
func TestListCandidateApps_MultiAccount_DisabledFiltered(t *testing.T) {
|
||||
disabled := false
|
||||
ch := &FeishuChannel{
|
||||
Accounts: map[string]*FeishuAccount{
|
||||
"active": {
|
||||
AppID: "cli_active",
|
||||
AppSecret: SecretInput{Plain: "secret"},
|
||||
},
|
||||
"disabled": {
|
||||
Enabled: &disabled,
|
||||
AppID: "cli_disabled",
|
||||
AppSecret: SecretInput{Plain: "secret"},
|
||||
},
|
||||
"nil_acct": nil,
|
||||
},
|
||||
}
|
||||
got := ListCandidateApps(ch)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("count = %d, want 1 (disabled and nil filtered out)", len(got))
|
||||
}
|
||||
if got[0].AppID != "cli_active" {
|
||||
t.Errorf("appId = %q, want %q", got[0].AppID, "cli_active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCandidateApps_EmptyAppID(t *testing.T) {
|
||||
ch := &FeishuChannel{
|
||||
AppID: "",
|
||||
// No accounts, no appId → no candidates
|
||||
}
|
||||
got := ListCandidateApps(ch)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected 0 apps for empty appId, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEnabled_Nil(t *testing.T) {
|
||||
if !isEnabled(nil) {
|
||||
t.Error("nil should default to enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEnabled_True(t *testing.T) {
|
||||
v := true
|
||||
if !isEnabled(&v) {
|
||||
t.Error("explicit true should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEnabled_False(t *testing.T) {
|
||||
v := false
|
||||
if isEnabled(&v) {
|
||||
t.Error("explicit false should be disabled")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user