Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ proxy:
max_stdin_message_size: 1MB
replay_cache_ttl: 2m
replay_cache_max_entries: 10000
credential_cache_ttl: 0s
max_output_size: 100MB
max_connection_lifetime: 30m

Expand Down
14 changes: 14 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ proxy:
max_stdin_message_size: 1MB
replay_cache_ttl: 2m
replay_cache_max_entries: 10000
credential_cache_ttl: 0s
write_timeout: 30s
max_output_size: 100MB
max_connection_lifetime: 30m
Expand Down Expand Up @@ -50,6 +51,7 @@ proxy:
max_stdin_message_size: 1MB # Max wrapper->daemon NDJSON message
replay_cache_ttl: 2m # Replay protection cache TTL
replay_cache_max_entries: 10000 # Replay cache size bound
credential_cache_ttl: 0s # Credential cache TTL (0 disables)
write_timeout: 30s # Write deadline per response message
max_output_size: 100MB # Kill tool if output exceeds this
max_connection_lifetime: 30m # Hard cap on connection duration
Expand Down Expand Up @@ -127,6 +129,7 @@ proxy:
max_stdin_message_size: 1MB # Max stdin/control message size
replay_cache_ttl: 2m # Replay detection TTL
replay_cache_max_entries: 10000 # Replay cache size cap
credential_cache_ttl: 0s # Credential fetch cache TTL (0 disables)
write_timeout: 30s # Write deadline per response message
max_output_size: 100MB # Kill tool if output exceeds this
max_connection_lifetime: 30m # Hard cap on connection duration
Expand Down Expand Up @@ -169,6 +172,17 @@ Controls replay protection for authenticated requests. Reuse of the same signed

Note: the TTL has a floor of 10 seconds — values below 10s are clamped.

### `credential_cache_ttl`

Optional in-memory TTL cache for credential fetch results.

- Default: `0` (disabled)
- Format: Go duration (`30s`, `2m`, `1h`)
- Scope: only `op://` (1Password) and `bw:` (Bitwarden) credential sources
- `claw-wrap check` always bypasses this cache and fetches credentials live

Use this to reduce repeated upstream secret-store latency for frequently-invoked tools.

### `write_timeout`

Deadline for each response message written back to the wrapper. Default: `30s`. Prevents slow/stalled clients from holding connections open indefinitely.
Expand Down
2 changes: 2 additions & 0 deletions docs/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ proxy:
max_stdin_message_size: 1MB
replay_cache_ttl: 2m
replay_cache_max_entries: 10000
credential_cache_ttl: 0s
max_output_size: 100MB
max_connection_lifetime: 30m

Expand Down Expand Up @@ -137,6 +138,7 @@ claw-wrap list
# Check credentials are accessible
# Run from host/admin context (outside sandbox).
# In strict firejail, this may fail by design.
# This check bypasses credential_cache_ttl and always fetches live.
claw-wrap check

# Test gh through claw-wrap
Expand Down
16 changes: 15 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type ProxyConfig struct {
HMACSecretFile string `yaml:"hmac_secret_file"` // e.g., "/run/openclaw/auth"
PassBinary string `yaml:"pass_binary"` // e.g., "/usr/bin/pass"
OPBinary string `yaml:"op_binary"` // e.g., "/usr/local/bin/op"
OPTokenFile string `yaml:"op_token_file"` // e.g., "/etc/openclaw/1password.token"
OPTokenFile string `yaml:"op_token_file"` // e.g., "/etc/openclaw/1password.token"
BWBinary string `yaml:"bw_binary"` // e.g., "/usr/local/bin/bw"
AgeIdentityFile string `yaml:"age_identity_file"` // e.g., "/etc/openclaw/age-identity"
MaxConnections int `yaml:"max_connections"` // e.g., 64
Expand All @@ -58,6 +58,7 @@ type ProxyConfig struct {
MaxConnectionLifetime string `yaml:"max_connection_lifetime"` // e.g., "10m" (0 = unlimited)
ReplayCacheTTL string `yaml:"replay_cache_ttl"` // e.g., "2m"
ReplayCacheMax int `yaml:"replay_cache_max_entries"`
CredentialCacheTTL string `yaml:"credential_cache_ttl"` // e.g., "30s" (0/empty disables)
}

// SecurityConfig holds security policy flags.
Expand Down Expand Up @@ -512,6 +513,19 @@ func (c *Config) GetReplayCacheMaxEntries() int {
return c.Proxy.ReplayCacheMax
}

// GetCredentialCacheTTL returns credential cache TTL.
// Returns 0 (disabled) when unset, invalid, or non-positive.
func (c *Config) GetCredentialCacheTTL() time.Duration {
if c.Proxy == nil || c.Proxy.CredentialCacheTTL == "" {
return 0
}
d, err := ParseDuration(c.Proxy.CredentialCacheTTL)
if err != nil || d <= 0 {
return 0
}
return d
}

// GetWriteTimeout returns the write deadline for daemon→client responses.
func (c *Config) GetWriteTimeout() time.Duration {
if c.Proxy == nil || c.Proxy.WriteTimeout == "" {
Expand Down
25 changes: 25 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,9 @@ func TestGetProxySecurityDefaults(t *testing.T) {
if got := cfg.GetReplayCacheMaxEntries(); got != DefaultReplayCacheMaxEntries {
t.Errorf("GetReplayCacheMaxEntries() = %d, want %d", got, DefaultReplayCacheMaxEntries)
}
if got := cfg.GetCredentialCacheTTL(); got != 0 {
t.Errorf("GetCredentialCacheTTL() = %v, want 0", got)
}
}

func TestGetReplayCacheTTL_Floor(t *testing.T) {
Expand Down Expand Up @@ -659,6 +662,28 @@ func TestGetReplayCacheTTL_Floor(t *testing.T) {
}
}

func TestGetCredentialCacheTTL(t *testing.T) {
tests := []struct {
name string
cfg *Config
want time.Duration
}{
{"nil proxy", &Config{}, 0},
{"empty value", &Config{Proxy: &ProxyConfig{}}, 0},
{"valid", &Config{Proxy: &ProxyConfig{CredentialCacheTTL: "30s"}}, 30 * time.Second},
{"invalid", &Config{Proxy: &ProxyConfig{CredentialCacheTTL: "bad"}}, 0},
{"zero", &Config{Proxy: &ProxyConfig{CredentialCacheTTL: "0s"}}, 0},
{"negative", &Config{Proxy: &ProxyConfig{CredentialCacheTTL: "-5s"}}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.GetCredentialCacheTTL(); got != tt.want {
t.Errorf("GetCredentialCacheTTL() = %v, want %v", got, tt.want)
}
})
}
}

func TestGetMaxOutputSize(t *testing.T) {
tests := []struct {
name string
Expand Down
199 changes: 199 additions & 0 deletions internal/credentials/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package credentials

import (
"sync"
"time"
)

const (
minCredentialCacheSweepInterval = 5 * time.Second
maxCredentialCacheSweepInterval = time.Minute
)

type credentialCacheEntry struct {
value string
expiresAt time.Time
}

type credentialCache struct {
mu sync.RWMutex
ttl time.Duration
entries map[string]credentialCacheEntry
sweeperStop chan struct{}
sweeperDone chan struct{}
sweeperInterval time.Duration
}

var (
credentialResultCache = newCredentialCache()
credentialCacheNow = time.Now
credentialTickerFactory = func(interval time.Duration) (<-chan time.Time, func()) {
ticker := time.NewTicker(interval)
return ticker.C, ticker.Stop
}
)

func newCredentialCache() *credentialCache {
return &credentialCache{
entries: make(map[string]credentialCacheEntry),
}
}

// SetCredentialCacheTTL configures the in-memory credential cache TTL.
// Non-positive values disable the cache.
func SetCredentialCacheTTL(ttl time.Duration) {
credentialResultCache.SetTTL(ttl)
}

func (c *credentialCache) SetTTL(ttl time.Duration) {
var stopCh chan struct{}
var doneCh chan struct{}
startSweeper := false
interval := time.Duration(0)

c.mu.Lock()
switch {
case ttl <= 0:
c.ttl = 0
c.entries = make(map[string]credentialCacheEntry)
stopCh, doneCh = c.detachSweeperLocked()
case ttl != c.ttl:
c.ttl = ttl
c.entries = make(map[string]credentialCacheEntry)
stopCh, doneCh = c.detachSweeperLocked()
startSweeper = true
interval = sweepInterval(ttl)
default:
// Same positive TTL should keep current sweeper unchanged.
if c.sweeperStop == nil {
startSweeper = true
interval = sweepInterval(ttl)
}
}
c.mu.Unlock()

stopCredentialCacheSweeper(stopCh, doneCh)
if startSweeper {
c.startSweeper(interval)
}
}

func (c *credentialCache) Get(key string, now time.Time) (string, bool) {
c.mu.RLock()
ttl := c.ttl
entry, ok := c.entries[key]
c.mu.RUnlock()

if ttl <= 0 || !ok {
return "", false
}
if !now.Before(entry.expiresAt) {
c.mu.Lock()
if current, exists := c.entries[key]; exists && !now.Before(current.expiresAt) {
delete(c.entries, key)
}
c.mu.Unlock()
return "", false
}

return entry.value, true
}

func (c *credentialCache) Set(key, value string, now time.Time) {
if key == "" || value == "" {
return
}

c.mu.Lock()
defer c.mu.Unlock()
if c.ttl <= 0 {
return
}
c.sweepExpiredLocked(now)
c.entries[key] = credentialCacheEntry{
value: value,
expiresAt: now.Add(c.ttl),
}
}

func (c *credentialCache) sweepExpiredLocked(now time.Time) {
for key, entry := range c.entries {
if !now.Before(entry.expiresAt) {
delete(c.entries, key)
}
}
}

func (c *credentialCache) detachSweeperLocked() (chan struct{}, chan struct{}) {
stopCh := c.sweeperStop
doneCh := c.sweeperDone
c.sweeperStop = nil
c.sweeperDone = nil
c.sweeperInterval = 0
return stopCh, doneCh
}

func stopCredentialCacheSweeper(stopCh, doneCh chan struct{}) {
if stopCh == nil || doneCh == nil {
return
}
close(stopCh)
<-doneCh
}

func (c *credentialCache) startSweeper(interval time.Duration) {
if interval <= 0 {
return
}

c.mu.Lock()
if c.ttl <= 0 || c.sweeperStop != nil {
c.mu.Unlock()
return
}
stopCh := make(chan struct{})
doneCh := make(chan struct{})
c.sweeperStop = stopCh
c.sweeperDone = doneCh
c.sweeperInterval = interval
c.mu.Unlock()

tickCh, stopTicker := credentialTickerFactory(interval)
go func() {
defer close(doneCh)
defer stopTicker()
for {
select {
case <-stopCh:
return
case <-tickCh:
now := credentialCacheNow()
c.mu.Lock()
c.sweepExpiredLocked(now)
c.mu.Unlock()
}
}
}()
}

func sweepInterval(ttl time.Duration) time.Duration {
if ttl <= 0 {
return 0
}
interval := ttl / 2
if interval < minCredentialCacheSweepInterval {
interval = minCredentialCacheSweepInterval
}
if interval > maxCredentialCacheSweepInterval {
interval = maxCredentialCacheSweepInterval
}
return interval
}

func isCredentialCacheableBackend(backend Backend) bool {
return backend == Backend1Password || backend == BackendBitwarden
}

func credentialCacheKey(parsed *ParsedSource) string {
return string(parsed.Backend) + "\x00" + parsed.Path + "\x00" + parsed.JQExpr
}
Loading