diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index bace505d7..ae6ad8a2d 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -408,6 +408,26 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) { } } +// Env template form: secret = "${VAR}" should resolve via the SecretInput +// pipeline (same path openclaw uses), so the keychain receives the env value +// not the literal template string. +func TestConfigBindRun_LarkChannel_EnvTemplate(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + clearAgentEnv(t) + + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + t.Setenv("LARK_APP_SECRET", "resolved_via_env") + writeLarkChannelFixture(t, fakeHome, + `{"accounts":{"app":{"id":"cli_lc_env","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil { + t.Fatalf("expected success, got error: %v", err) + } +} + // tenant: "lark" should land as Brand("lark"), not normalized to "feishu". func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) { saveWorkspace(t) diff --git a/cmd/config/binder.go b/cmd/config/binder.go index ee1780840..8a9426674 100644 --- a/cmd/config/binder.go +++ b/cmd/config/binder.go @@ -312,13 +312,22 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { return nil, output.Errorf(output.ExitInternal, "lark-channel", "internal: appID %q does not match config", appID) } - if b.cfg.Accounts.App.Secret == "" { + if b.cfg.Accounts.App.Secret.IsZero() { return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", fmt.Sprintf("accounts.app.secret is empty in %s", b.path), "run lark-channel-bridge's setup to populate the app credential") } - stored, err := core.ForStorage(appID, core.PlainSecret(b.cfg.Accounts.App.Secret), b.opts.Factory.Keychain) + // Resolve through the same SecretInput pipeline openclaw uses, so + // bridge configs can use ${VAR} / env / file / exec just like openclaw. + secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv) + if err != nil { + return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", + fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err), + fmt.Sprintf("check appSecret configuration in %s", b.path)) + } + + stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain) if err != nil { return nil, output.Errorf(output.ExitInternal, "lark-channel", "keychain unavailable: %v", err) diff --git a/internal/binding/lark_channel.go b/internal/binding/lark_channel.go index 511f19dd7..f80afb53a 100644 --- a/internal/binding/lark_channel.go +++ b/internal/binding/lark_channel.go @@ -15,6 +15,11 @@ import ( // 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. @@ -26,13 +31,17 @@ type LarkChannelAccounts struct { } // LarkChannelApp is the bot app credential entry. -// Bridge stores the secret as plain text — secret-resolve indirection -// (${VAR} / file: / exec:) is intentionally not supported here, matching -// the bridge's on-disk format. +// +// `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 string `json:"secret"` - Tenant string `json:"tenant"` // "feishu" | "lark" + ID string `json:"id"` + Secret SecretInput `json:"secret"` + Tenant string `json:"tenant"` // "feishu" | "lark" } // ReadLarkChannelConfig reads and parses ~/.lark-channel/config.json. diff --git a/internal/binding/lark_channel_test.go b/internal/binding/lark_channel_test.go index 2883144b5..4908556b4 100644 --- a/internal/binding/lark_channel_test.go +++ b/internal/binding/lark_channel_test.go @@ -24,8 +24,11 @@ func TestReadLarkChannelConfig_Valid(t *testing.T) { if got := root.Accounts.App.ID; got != "cli_abc123" { t.Errorf("ID = %q, want %q", got, "cli_abc123") } - if got := root.Accounts.App.Secret; got != "plain_secret" { - t.Errorf("Secret = %q, want %q", got, "plain_secret") + 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") @@ -92,8 +95,74 @@ func TestReadLarkChannelConfig_PartialFields(t *testing.T) { if root.Accounts.App.ID != "" { t.Errorf("expected empty ID, got %q", root.Accounts.App.ID) } - if root.Accounts.App.Secret != "" { - t.Errorf("expected empty Secret, got %q", root.Accounts.App.Secret) + 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") } }