feat: add kwallet dbus client

This commit is contained in:
thibaud-leclere 2026-04-10 10:47:52 +02:00
parent 3f75357c89
commit 14191cae1a
6 changed files with 607 additions and 2 deletions

4
go.mod
View file

@ -1,3 +1,7 @@
module email-mcp module email-mcp
go 1.25.0 go 1.25.0
require github.com/godbus/dbus/v5 v5.2.2
require golang.org/x/sys v0.27.0 // indirect

4
go.sum Normal file
View file

@ -0,0 +1,4 @@
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View file

@ -2,11 +2,13 @@ package cli
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"email-mcp/internal/secretstore" "email-mcp/internal/secretstore"
"email-mcp/internal/secretstore/kwallet"
) )
type MCPRunner interface { type MCPRunner interface {
@ -68,7 +70,7 @@ func (a *App) runSetup(ctx context.Context) error {
return err return err
} }
if err := a.store.Save(ctx, secretstore.DefaultAccountKey, cred); err != nil { if err := a.store.Save(ctx, secretstore.DefaultAccountKey, cred); err != nil {
return err return mapAppError(err)
} }
return nil return nil
} }
@ -77,5 +79,24 @@ func (a *App) runMCP(ctx context.Context) error {
if a.runner == nil { if a.runner == nil {
return fmt.Errorf("mcp runner is not configured") return fmt.Errorf("mcp runner is not configured")
} }
return a.runner.Run(ctx) return mapAppError(a.runner.Run(ctx))
}
func mapAppError(err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, kwallet.ErrKWalletUnavailable):
return fmt.Errorf("kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running")
case errors.Is(err, kwallet.ErrKWalletDisabled):
return fmt.Errorf("kwallet is disabled in this KDE session")
case errors.Is(err, kwallet.ErrKWalletOpenFailed):
return fmt.Errorf("kwallet could not be opened; unlock the wallet and try again")
case errors.Is(err, kwallet.ErrCredentialNotFound):
return fmt.Errorf("credentials not configured; run `email-mcp setup`")
default:
return err
}
} }

View file

@ -0,0 +1,64 @@
package cli
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
"email-mcp/internal/secretstore"
"email-mcp/internal/secretstore/kwallet"
)
type errorStoreStub struct {
err error
}
func (s errorStoreStub) Save(context.Context, string, secretstore.Credential) error {
return s.err
}
func (s errorStoreStub) Load(context.Context, string) (secretstore.Credential, error) {
return secretstore.Credential{}, s.err
}
func TestAppRunSetupMapsUnavailableWalletError(t *testing.T) {
app := NewAppWithDependencies(&promptStub{
credential: secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "secret",
},
}, errorStoreStub{
err: fmt.Errorf("%w: session bus missing", kwallet.ErrKWalletUnavailable),
}, nil, &bytes.Buffer{})
err := app.Run([]string{"setup"})
if err == nil {
t.Fatal("expected setup to fail")
}
if !strings.Contains(strings.ToLower(err.Error()), "kwallet is not available") {
t.Fatalf("expected mapped kwallet error, got %v", err)
}
}
func TestMapAppErrorMapsMissingCredentialError(t *testing.T) {
err := mapAppError(fmt.Errorf("%w: missing entry", kwallet.ErrCredentialNotFound))
if err == nil {
t.Fatal("expected mapped error")
}
if !strings.Contains(err.Error(), "run `email-mcp setup`") {
t.Fatalf("expected setup guidance, got %v", err)
}
}
func TestMapAppErrorLeavesUnknownErrorsUntouched(t *testing.T) {
wantErr := errors.New("boom")
err := mapAppError(wantErr)
if !errors.Is(err, wantErr) {
t.Fatalf("expected original error, got %v", err)
}
}

View file

@ -0,0 +1,308 @@
package kwallet
import (
"context"
"errors"
"fmt"
"strings"
"github.com/godbus/dbus/v5"
)
const (
kwalletInterface = "org.kde.KWallet"
kwalletAppID = "email-mcp"
kwalletFolderName = "email-mcp"
)
var (
ErrKWalletUnavailable = errors.New("kwallet is not available")
ErrKWalletDisabled = errors.New("kwallet is disabled")
ErrKWalletOpenFailed = errors.New("kwallet could not be opened")
ErrCredentialNotFound = errors.New("credentials not configured")
)
type ClientImpl struct {
probe func(context.Context) error
open func(context.Context) error
read func(context.Context, string) ([]byte, error)
write func(context.Context, string, []byte) error
}
type dbusConnection interface {
Object(dest string, path dbus.ObjectPath) dbusObject
}
type dbusObject interface {
CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...any) *dbus.Call
}
type kwalletService struct {
name string
path dbus.ObjectPath
}
type walletSession struct {
connect func() (dbusConnection, error)
services []kwalletService
object dbusObject
handle int32
}
type sessionBusConnection struct {
conn *dbus.Conn
}
type sessionBusObject struct {
object dbus.BusObject
}
func NewDefaultWalletClient() Client {
return newClientImpl(newWalletSession(defaultSessionBusConnection))
}
func newClientImpl(session *walletSession) ClientImpl {
return ClientImpl{
probe: session.probe,
open: session.open,
read: session.readEntry,
write: session.writeEntry,
}
}
func newWalletSession(connect func() (dbusConnection, error)) *walletSession {
return &walletSession{
connect: connect,
services: []kwalletService{
{name: "org.kde.kwalletd6", path: "/modules/kwalletd6"},
{name: "org.kde.kwalletd5", path: "/modules/kwalletd5"},
},
}
}
func defaultSessionBusConnection() (dbusConnection, error) {
conn, err := dbus.SessionBus()
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrKWalletUnavailable, err)
}
return sessionBusConnection{conn: conn}, nil
}
func (c sessionBusConnection) Object(dest string, path dbus.ObjectPath) dbusObject {
return sessionBusObject{object: c.conn.Object(dest, path)}
}
func (o sessionBusObject) CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...any) *dbus.Call {
return o.object.CallWithContext(ctx, method, flags, args...)
}
func (c ClientImpl) IsAvailable(ctx context.Context) error {
if c.probe == nil {
return ErrKWalletUnavailable
}
return c.probe(ctx)
}
func (c ClientImpl) Open(ctx context.Context) error {
if c.open == nil {
return nil
}
return c.open(ctx)
}
func (c ClientImpl) WriteEntry(ctx context.Context, key string, value []byte) error {
if c.write == nil {
return fmt.Errorf("kwallet write operation is not configured")
}
return c.write(ctx, key, value)
}
func (c ClientImpl) ReadEntry(ctx context.Context, key string) ([]byte, error) {
if c.read == nil {
return nil, ErrCredentialNotFound
}
return c.read(ctx, key)
}
func (s *walletSession) probe(ctx context.Context) error {
_, err := s.ensureObject(ctx)
return err
}
func (s *walletSession) open(ctx context.Context) error {
object, err := s.ensureObject(ctx)
if err != nil {
return err
}
if s.handle != 0 {
return nil
}
walletName, err := s.walletName(ctx, object)
if err != nil {
return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err)
}
handle, err := s.callInt32(ctx, object, "open", walletName, int64(0), kwalletAppID)
if err != nil {
return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err)
}
if handle < 0 {
return fmt.Errorf("%w: handle %d", ErrKWalletOpenFailed, handle)
}
hasFolder, err := s.callBool(ctx, object, "hasFolder", handle, kwalletFolderName, kwalletAppID)
if err != nil {
return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err)
}
if !hasFolder {
created, err := s.callBool(ctx, object, "createFolder", handle, kwalletFolderName, kwalletAppID)
if err != nil {
return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err)
}
if !created {
return fmt.Errorf("%w: folder %q could not be created", ErrKWalletOpenFailed, kwalletFolderName)
}
}
s.handle = handle
return nil
}
func (s *walletSession) writeEntry(ctx context.Context, key string, value []byte) error {
if err := s.open(ctx); err != nil {
return err
}
object, err := s.ensureObject(ctx)
if err != nil {
return err
}
code, err := s.callInt32(ctx, object, "writeEntry", s.handle, kwalletFolderName, key, value, kwalletAppID)
if err != nil {
return fmt.Errorf("kwallet write failed: %w", err)
}
if code != 0 {
return fmt.Errorf("kwallet write failed with code %d", code)
}
return nil
}
func (s *walletSession) readEntry(ctx context.Context, key string) ([]byte, error) {
if err := s.open(ctx); err != nil {
return nil, err
}
object, err := s.ensureObject(ctx)
if err != nil {
return nil, err
}
hasEntry, err := s.callBool(ctx, object, "hasEntry", s.handle, kwalletFolderName, key, kwalletAppID)
if err != nil {
return nil, err
}
if !hasEntry {
return nil, fmt.Errorf("%w: key %q", ErrCredentialNotFound, key)
}
value, err := s.callBytes(ctx, object, "readEntry", s.handle, kwalletFolderName, key, kwalletAppID)
if err != nil {
return nil, err
}
return value, nil
}
func (s *walletSession) ensureObject(ctx context.Context) (dbusObject, error) {
if s.object != nil {
return s.object, nil
}
conn, err := s.connect()
if err != nil {
if errors.Is(err, ErrKWalletUnavailable) {
return nil, err
}
return nil, fmt.Errorf("%w: %v", ErrKWalletUnavailable, err)
}
var lastErr error
for _, service := range s.services {
object := conn.Object(service.name, service.path)
enabled, err := s.callBool(ctx, object, "isEnabled")
if err != nil {
lastErr = err
continue
}
if !enabled {
return nil, ErrKWalletDisabled
}
s.object = object
return s.object, nil
}
if lastErr == nil {
lastErr = errors.New("no kwallet service responded")
}
return nil, fmt.Errorf("%w: %v", ErrKWalletUnavailable, lastErr)
}
func (s *walletSession) walletName(ctx context.Context, object dbusObject) (string, error) {
name, err := s.callString(ctx, object, "networkWallet")
if err != nil {
return "", err
}
name = strings.TrimSpace(name)
if name != "" {
return name, nil
}
name, err = s.callString(ctx, object, "localWallet")
if err != nil {
return "", err
}
name = strings.TrimSpace(name)
if name == "" {
return "", errors.New("kwallet did not report a wallet name")
}
return name, nil
}
func (s *walletSession) callBool(ctx context.Context, object dbusObject, name string, args ...any) (bool, error) {
var value bool
if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil {
return false, err
}
return value, nil
}
func (s *walletSession) callInt32(ctx context.Context, object dbusObject, name string, args ...any) (int32, error) {
var value int32
if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil {
return 0, err
}
return value, nil
}
func (s *walletSession) callString(ctx context.Context, object dbusObject, name string, args ...any) (string, error) {
var value string
if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil {
return "", err
}
return value, nil
}
func (s *walletSession) callBytes(ctx context.Context, object dbusObject, name string, args ...any) ([]byte, error) {
var value []byte
if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil {
return nil, err
}
return value, nil
}
func kwalletMethod(name string) string {
return kwalletInterface + "." + name
}

View file

@ -0,0 +1,204 @@
package kwallet
import (
"context"
"errors"
"reflect"
"testing"
"github.com/godbus/dbus/v5"
)
type stubCall struct {
body []any
err error
}
type recordedCall struct {
method string
args []any
}
type stubObject struct {
responses map[string][]stubCall
calls []recordedCall
}
func (o *stubObject) CallWithContext(_ context.Context, method string, _ dbus.Flags, args ...any) *dbus.Call {
o.calls = append(o.calls, recordedCall{method: method, args: append([]any(nil), args...)})
queue := o.responses[method]
if len(queue) == 0 {
return &dbus.Call{Err: errors.New("unexpected method: " + method)}
}
response := queue[0]
o.responses[method] = queue[1:]
return &dbus.Call{Body: response.body, Err: response.err}
}
type stubConnection struct {
objects map[string]*stubObject
}
func (c *stubConnection) Object(dest string, path dbus.ObjectPath) dbusObject {
return c.objects[dest+"|"+string(path)]
}
func TestClientIsAvailableReturnsErrorWhenServiceIsMissing(t *testing.T) {
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": {
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{err: errors.New("service missing")}},
},
},
"org.kde.kwalletd5|/modules/kwalletd5": {
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{err: errors.New("service missing")}},
},
},
},
}, nil
}))
err := client.IsAvailable(context.Background())
if err == nil {
t.Fatal("expected availability error")
}
if !errors.Is(err, ErrKWalletUnavailable) {
t.Fatalf("expected unavailable error, got %v", err)
}
}
func TestClientOpenUsesNetworkWalletAndCreatesFolder(t *testing.T) {
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{false}}},
kwalletMethod("createFolder"): {{body: []any{true}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
if err := client.Open(context.Background()); err != nil {
t.Fatalf("Open returned error: %v", err)
}
wantMethods := []string{
kwalletMethod("isEnabled"),
kwalletMethod("networkWallet"),
kwalletMethod("open"),
kwalletMethod("hasFolder"),
kwalletMethod("createFolder"),
}
gotMethods := make([]string, 0, len(walletObject.calls))
for _, call := range walletObject.calls {
gotMethods = append(gotMethods, call.method)
}
if !reflect.DeepEqual(gotMethods, wantMethods) {
t.Fatalf("unexpected methods: got %v want %v", gotMethods, wantMethods)
}
openArgs := walletObject.calls[2].args
if len(openArgs) != 3 || openArgs[0] != "kdewallet" || openArgs[1] != int64(0) || openArgs[2] != kwalletAppID {
t.Fatalf("unexpected open args: %#v", openArgs)
}
}
func TestClientWriteEntryWritesBytesToConfiguredFolder(t *testing.T) {
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("writeEntry"): {{body: []any{int32(0)}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
if err := client.WriteEntry(context.Background(), "default", []byte("payload")); err != nil {
t.Fatalf("WriteEntry returned error: %v", err)
}
writeArgs := walletObject.calls[len(walletObject.calls)-1].args
wantArgs := []any{int32(42), kwalletFolderName, "default", []byte("payload"), kwalletAppID}
if !reflect.DeepEqual(writeArgs, wantArgs) {
t.Fatalf("unexpected write args: got %#v want %#v", writeArgs, wantArgs)
}
}
func TestClientReadEntryReturnsCredentialNotFoundWhenEntryIsMissing(t *testing.T) {
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("hasEntry"): {{body: []any{false}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
_, err := client.ReadEntry(context.Background(), "default")
if err == nil {
t.Fatal("expected missing credential error")
}
if !errors.Is(err, ErrCredentialNotFound) {
t.Fatalf("expected missing credential error, got %v", err)
}
for _, call := range walletObject.calls {
if call.method == kwalletMethod("readEntry") {
t.Fatal("did not expect readEntry call when key is missing")
}
}
}
func TestClientReadEntryReadsStoredPayload(t *testing.T) {
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("hasEntry"): {{body: []any{true}}},
kwalletMethod("readEntry"): {{body: []any{[]byte("payload")}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
value, err := client.ReadEntry(context.Background(), "default")
if err != nil {
t.Fatalf("ReadEntry returned error: %v", err)
}
if got := string(value); got != "payload" {
t.Fatalf("unexpected payload: %q", got)
}
}