Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
6f98669
test(setup): add tests for cleanPath and isGWSService
priyanshujain Mar 16, 2026
dfd873a
fix(config): add SyncDays field to GmailConfig
priyanshujain Mar 16, 2026
bbb25d0
test(config): verify SyncDays round-trips through save/load
priyanshujain Mar 16, 2026
75a3515
fix(setup): persist Gmail sync window selection to config
priyanshujain Mar 16, 2026
0158f26
fix(gmail): use config SyncDays as --days default when flag not set
priyanshujain Mar 16, 2026
22e9ef7
fix(setup): change Gmail access level to single-select
priyanshujain Mar 16, 2026
61d6d4b
feat(setup): add iMessage to source picker on macOS
priyanshujain Mar 16, 2026
7ca25b9
fix(setup): add WhatsApp config handler in source loop
priyanshujain Mar 16, 2026
b5ee463
feat(setup): add timezone prompt when not configured
priyanshujain Mar 16, 2026
ca4931e
fix(doctor): add missing database checks
priyanshujain Mar 16, 2026
4647bbb
test(doctor): add tests for check functions
priyanshujain Mar 16, 2026
eda3266
fix(status): add contacts database handling in status command
priyanshujain Mar 16, 2026
ca004ed
fix(config): add PasswordRef and ResolvedPassword to RemoteConfig
priyanshujain Mar 16, 2026
977b5e4
fix(setup): store remote password in keychain
priyanshujain Mar 16, 2026
f1e62f0
fix(cli): resolve remote password from keychain at all call sites
priyanshujain Mar 16, 2026
00a773c
fix(setup): add skip option when ngrok is not installed
priyanshujain Mar 16, 2026
c84524b
fix(setup): add skip option when gws CLI is not installed
priyanshujain Mar 16, 2026
99db265
docs(readme): lead Quick Start with obk setup, update counts
priyanshujain Mar 16, 2026
ffd4b75
test(config): add ResolvedPassword and PasswordRef round-trip tests
priyanshujain Mar 16, 2026
bc500f7
test(setup): add errSetupSkipped sentinel test
priyanshujain Mar 16, 2026
058510c
test(doctor): verify all expected database names are checked
priyanshujain Mar 16, 2026
4855a0b
feat(gmail): move Google auth commands into gmail package
priyanshujain Mar 16, 2026
48c90ba
feat(whatsapp): move WhatsApp auth commands into whatsapp package
priyanshujain Mar 16, 2026
b11b061
feat(slack): move Slack auth commands into slack package
priyanshujain Mar 16, 2026
aca4c93
docs(readme): update architecture diagram with safety layer detail
priyanshujain Mar 16, 2026
d03fa7c
refactor(cli): remove auth package, deregister from root
priyanshujain Mar 16, 2026
c978f2f
fix(cli): update all auth command string references to resource-first…
priyanshujain Mar 16, 2026
2cb4b4a
refactor(cli): rename config profiles show to describe
priyanshujain Mar 16, 2026
ab9b4cb
refactor(cli): rename config show to config list
priyanshujain Mar 16, 2026
6ee90fe
refactor(cli): rename auth status to auth list across all providers
priyanshujain Mar 16, 2026
404194d
refactor(cli): normalize Short descriptions to "Manage X data source"…
priyanshujain Mar 16, 2026
8dec3a1
feat(cli): add --json flag to commands missing structured output
priyanshujain Mar 16, 2026
9954fca
feat(cli): add confirmation prompts to destructive commands
priyanshujain Mar 16, 2026
8821961
docs(cli): add Example field to all leaf commands
priyanshujain Mar 16, 2026
559111d
docs(cli): add CLI UX guide documenting command conventions
priyanshujain Mar 16, 2026
10a044b
fix(docs): bind safety layer text to containers to prevent overflow
priyanshujain Mar 16, 2026
d0b0c8a
fix(docs): widen sources and SQLite sections to match safety layer
priyanshujain Mar 16, 2026
22eb1f6
fix(docs): restore original diagram dimensions, fix text with smaller…
priyanshujain Mar 16, 2026
b5ff123
fix(docs): bind all text to containers to prevent overflow
priyanshujain Mar 16, 2026
9cd783f
fix(config): return error from ResolvedPassword instead of silent fal…
priyanshujain Mar 16, 2026
559faa4
fix(status): add contacts to status output
priyanshujain Mar 16, 2026
7cb0a98
fix(setup): validate timezone input with time.LoadLocation
priyanshujain Mar 16, 2026
d8487da
docs(readme): clarify that only sensitive actions need approval
priyanshujain Mar 16, 2026
1de33d8
refactor(cli): unify service and server into single `obk service` com…
priyanshujain Mar 16, 2026
f602408
fix(docker): update CMD to use unified service command
priyanshujain Mar 16, 2026
8c86d53
test(server): update docker test to use unified service command
priyanshujain Mar 16, 2026
a88e567
test(service): update DefaultConfig test args to match new command st…
priyanshujain Mar 16, 2026
931ad41
docs(remote): update command references to unified service command
priyanshujain Mar 16, 2026
703aadb
fix(launchd): use named wrapper scripts so Login Items shows distinct…
priyanshujain Mar 16, 2026
7870cf3
fix(make): update Makefile to use unified service restart
priyanshujain Mar 16, 2026
7f1a0d2
fix(launchd): rename wrappers to openbotkit-daemon and openbotkit-server
priyanshujain Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,10 @@ install:
@if pgrep -f "$(BINARY)\|$(ALIAS)" > /dev/null 2>&1; then \
echo "Restarting running services..."; \
$(GOBIN)/$(ALIAS) service restart 2>/dev/null || true; \
$(GOBIN)/$(ALIAS) server restart 2>/dev/null || true; \
fi

update-local: install
$(GOBIN)/$(ALIAS) service restart
$(GOBIN)/$(ALIAS) server restart

uninstall:
rm -f $(GOBIN)/$(BINARY) $(GOBIN)/$(ALIAS)
36 changes: 17 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ AI agents can now read your email, send messages, and browse the web on your beh

## How OpenBotKit Is Different

**Every action needs your permission.** When the assistant wants to send an email or a WhatsApp message, it sends you a preview on Telegram with Approve and Deny buttons. You review it, you decide. This is enforced in code — the AI cannot skip this step.
**Sensitive actions need your permission.** When the assistant wants to send an email, a WhatsApp message, or modify your calendar, it sends you a preview on Telegram with Approve and Deny buttons. You review it, you decide. This is enforced in code — the AI cannot skip this step. Read-only actions like searching your email or reading notes happen instantly without interrupting you.

**Your data stays on your device.** Emails, messages, notes, and contacts sync into SQLite databases on your machine. OpenBotKit connects directly to Gmail's API, WhatsApp's protocol, and Apple Notes. No cloud relay, no third-party middleware.

Expand All @@ -25,8 +25,8 @@ AI agents can now read your email, send messages, and browse the web on your beh

1. Your data syncs locally from Gmail, WhatsApp, Apple Notes, and other sources
2. You talk to the assistant via Telegram or the terminal
3. The assistant reads your local data to answer questions
4. When it wants to **send** something, it asks for your approval first on Telegram
3. The assistant reads your local data to answer questions — no approval needed
4. When it wants to **send a message, email, or modify your calendar**, it asks for your approval first
5. You approve or deny — then it acts

## Integrations
Expand Down Expand Up @@ -60,29 +60,25 @@ cd openbotkit && make install
### 2. Set up your sources

```bash
# Initialize configuration
obk config init

# Connect Gmail (opens browser for OAuth2)
obk gmail auth login
obk gmail sync

# Connect WhatsApp (scan QR code with your phone)
obk whatsapp auth login
obk whatsapp sync
# Guided setup — walks you through sources, models, and Telegram
obk setup

# Check what's connected
obk status
```

### 3. Connect Telegram (for approvals)
<details>
<summary>Alternative: manual setup</summary>

```bash
# Set up Telegram bot for chat + approval flow
obk config init
obk gmail auth login && obk gmail sync
obk whatsapp auth login && obk whatsapp sync
obk telegram setup
```
</details>

### 4. Start your assistant
### 3. Start your assistant

```bash
# Start the assistant
Expand All @@ -104,7 +100,7 @@ Ask things like:

Safety is not a feature — it's the foundation. OpenBotKit has 8 defense layers:

1. **Approval gates** — Every write action (send email, send message) requires explicit approval, enforced in code. The AI cannot bypass this.
1. **Approval gates** — Sensitive write actions (send email, send message, modify calendar) require explicit approval, enforced in code. The AI cannot bypass this.
2. **Local-first data** — Your data lives in SQLite on your machine. Nothing leaves unless you send it.
3. **Prompt injection defense** — Content boundaries, injection scanning (plain text, base64, homoglyph), and system prompt hardening.
4. **Tiered risk levels** — Low-risk actions notify you. Medium-risk actions need approval. High-risk actions need approval with full preview.
Expand All @@ -125,14 +121,16 @@ OpenBotKit is a single Go binary. No bloated frameworks, no 200-dependency packa
| **Sync engine** | Background daemon (launchd/systemd) keeps your local data fresh |
| **CLI** (`obk`) | Search, read, and send across all sources from the terminal |
| **Agent** | Built-in agent loop with tool use, safety gates, and multi-LLM support |
| **Skills** | 14 plain-text skill definitions that the agent loads on demand |
| **Skills** | 100+ plain-text skill definitions that the agent loads on demand |
| **Channels** | Telegram bot and CLI for interaction; Telegram for approval flow |

### Supported LLM Providers

- Anthropic Claude (default)
- OpenAI
- Google Gemini
- OpenAI
- OpenRouter
- Groq

## Data Directory

Expand Down
2 changes: 1 addition & 1 deletion assistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Personal assistant powered by Claude Code with access to email, WhatsApp, conver

- `obk` must be in PATH: `make install` (from repo root)
- Gmail synced: `obk gmail sync` (after `obk setup`)
- WhatsApp synced: `obk auth whatsapp login`
- WhatsApp synced: `obk whatsapp auth login`
- Google Workspace (optional): configured via `obk setup` (requires `gws` CLI)

## What's inside
Expand Down
26 changes: 23 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,28 @@ func (c *Config) IsRemote() bool { return c.ResolvedMode() == ModeRemote }
func (c *Config) IsServer() bool { return c.ResolvedMode() == ModeServer }

type RemoteConfig struct {
Server string `yaml:"server,omitempty"`
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
Server string `yaml:"server,omitempty"`
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
PasswordRef string `yaml:"password_ref,omitempty"`
}

// ResolvedPassword resolves the password from PasswordRef using the supplied
// resolver. If PasswordRef is set and resolution fails, the error is returned
// instead of silently falling back to plain text. When PasswordRef is empty,
// the plain-text Password field is returned.
func (r *RemoteConfig) ResolvedPassword(resolve func(string) (string, error)) (string, error) {
if r.PasswordRef != "" {
if resolve == nil {
return "", fmt.Errorf("password_ref %q set but no credential resolver available", r.PasswordRef)
}
pw, err := resolve(r.PasswordRef)
if err != nil {
return "", fmt.Errorf("resolving password_ref %q: %w", r.PasswordRef, err)
}
return pw, nil
}
return r.Password, nil
}

type AuthConfig struct {
Expand Down Expand Up @@ -132,6 +151,7 @@ type WhatsAppConfig struct {
type GmailConfig struct {
CredentialsFile string `yaml:"credentials_file,omitempty"`
DownloadAttachments bool `yaml:"download_attachments,omitempty"`
SyncDays int `yaml:"sync_days,omitempty"`
Storage StorageConfig `yaml:"storage,omitempty"`
}

Expand Down
105 changes: 105 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -568,6 +569,110 @@ func TestCustomProfile_EmptyLabelAndDescription(t *testing.T) {
}
}

func TestResolvedPassword_FromRef(t *testing.T) {
r := &RemoteConfig{
Password: "plain",
PasswordRef: "keychain:obk/remote",
}
resolver := func(ref string) (string, error) {
if ref == "keychain:obk/remote" {
return "secret-from-keychain", nil
}
return "", fmt.Errorf("not found")
}
got, err := r.ResolvedPassword(resolver)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "secret-from-keychain" {
t.Fatalf("expected keychain password, got %q", got)
}
}

func TestResolvedPassword_RefFailsReturnsError(t *testing.T) {
r := &RemoteConfig{
Password: "plain",
PasswordRef: "keychain:obk/missing",
}
resolver := func(ref string) (string, error) {
return "", fmt.Errorf("not found")
}
_, err := r.ResolvedPassword(resolver)
if err == nil {
t.Fatal("expected error when resolver fails, got nil")
}
}

func TestResolvedPassword_NoRef(t *testing.T) {
r := &RemoteConfig{
Password: "plain",
}
got, err := r.ResolvedPassword(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "plain" {
t.Fatalf("expected plain password, got %q", got)
}
}

func TestResolvedPassword_NilResolverWithRef(t *testing.T) {
r := &RemoteConfig{
Password: "plain",
PasswordRef: "keychain:obk/remote",
}
_, err := r.ResolvedPassword(nil)
if err == nil {
t.Fatal("expected error when resolver is nil but password_ref is set")
}
}

func TestPasswordRef_RoundTrip(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")

cfg := Default()
cfg.Mode = ModeRemote
cfg.Remote = &RemoteConfig{
Server: "https://example.com:8443",
Username: "user",
PasswordRef: "keychain:obk/remote",
}
if err := cfg.SaveTo(cfgPath); err != nil {
t.Fatalf("save: %v", err)
}

loaded, err := LoadFrom(cfgPath)
if err != nil {
t.Fatalf("load: %v", err)
}
if loaded.Remote.PasswordRef != "keychain:obk/remote" {
t.Fatalf("PasswordRef not preserved: %q", loaded.Remote.PasswordRef)
}
if loaded.Remote.Password != "" {
t.Fatalf("expected empty Password, got %q", loaded.Remote.Password)
}
}

func TestGmailSyncDays_RoundTrip(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")

cfg := Default()
cfg.Gmail.SyncDays = 30
if err := cfg.SaveTo(cfgPath); err != nil {
t.Fatalf("save: %v", err)
}

loaded, err := LoadFrom(cfgPath)
if err != nil {
t.Fatalf("load: %v", err)
}
if loaded.Gmail.SyncDays != 30 {
t.Fatalf("expected SyncDays=30, got %d", loaded.Gmail.SyncDays)
}
}

func TestSaveAndLoad(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
Expand Down
38 changes: 34 additions & 4 deletions daemon/service/launchd.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ func (m *launchdManager) plistPath() (string, error) {
return filepath.Join(home, "Library", "LaunchAgents", m.label()+".plist"), nil
}

func (m *launchdManager) wrapperPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("get home dir: %w", err)
}
return filepath.Join(home, ".obk", "bin", "openbotkit-"+m.name), nil
}

func shellescape(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

func (m *launchdManager) Install(cfg *ServiceConfig) error {
path, err := m.plistPath()
if err != nil {
Expand All @@ -60,12 +72,26 @@ func (m *launchdManager) Install(cfg *ServiceConfig) error {
return fmt.Errorf("create log dir: %w", err)
}

var argLines []string
argLines = append(argLines, fmt.Sprintf("\t\t<string>%s</string>", cfg.BinaryPath))
// Create a named wrapper script so macOS Login Items shows
// "obk-daemon" / "obk-server" instead of two identical "obk" entries.
wrapper, err := m.wrapperPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(wrapper), 0700); err != nil {
return fmt.Errorf("create wrapper dir: %w", err)
}

parts := []string{shellescape(cfg.BinaryPath)}
for _, a := range cfg.Args {
argLines = append(argLines, fmt.Sprintf("\t\t<string>%s</string>", a))
parts = append(parts, shellescape(a))
}
argsXML := strings.Join(argLines, "\n")
script := "#!/bin/sh\nexec " + strings.Join(parts, " ") + "\n"
if err := os.WriteFile(wrapper, []byte(script), 0755); err != nil {
return fmt.Errorf("write wrapper: %w", err)
}

argsXML := fmt.Sprintf("\t\t<string>%s</string>", wrapper)

var envXML string
if len(cfg.Env) > 0 {
Expand Down Expand Up @@ -130,6 +156,10 @@ func (m *launchdManager) Uninstall() error {
return fmt.Errorf("remove plist: %w", err)
}

if wrapper, err := m.wrapperPath(); err == nil {
os.Remove(wrapper)
}

return nil
}

Expand Down
19 changes: 18 additions & 1 deletion daemon/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestDetectPlatform(t *testing.T) {
}

func TestDefaultConfig(t *testing.T) {
cfg, err := DefaultConfig("daemon", []string{"service", "run"})
cfg, err := DefaultConfig("daemon", []string{"service", "run", "daemon"})
if err != nil {
t.Fatalf("DefaultConfig failed: %v", err)
}
Expand All @@ -49,6 +49,23 @@ func TestDefaultConfig(t *testing.T) {
}
}

func TestShellescape(t *testing.T) {
tests := []struct {
in, want string
}{
{"/usr/local/bin/obk", "'/usr/local/bin/obk'"},
{"service", "'service'"},
{"/path with spaces/obk", "'/path with spaces/obk'"},
{"it's", `'it'\''s'`},
}
for _, tt := range tests {
got := shellescape(tt.in)
if got != tt.want {
t.Errorf("shellescape(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}

func TestNewManager(t *testing.T) {
mgr, err := NewManager("daemon")

Expand Down
Loading
Loading