diff --git a/cmd/browser_cookie_provider.go b/cmd/browser_cookie_provider.go new file mode 100644 index 00000000..5c94c1e7 --- /dev/null +++ b/cmd/browser_cookie_provider.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + "github.com/go-rod/rod/lib/proto" + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/browser" +) + +type storeBrowserCookieProvider struct { + cookies store.BrowserCookieStore +} + +func newStoreBrowserCookieProvider(cookies store.BrowserCookieStore) browser.CookieProvider { + if cookies == nil { + return nil + } + return &storeBrowserCookieProvider{cookies: cookies} +} + +func (p *storeBrowserCookieProvider) CookiesForURL(ctx context.Context, scope browser.BrowserScope, targetURL string) ([]*proto.NetworkCookieParam, error) { + u, err := url.Parse(targetURL) + if err != nil { + return nil, fmt.Errorf("parse target url: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, nil + } + storeScope, err := browserScopeToCookieScope(scope) + if err != nil { + return nil, err + } + cookies, err := p.cookies.List(ctx, storeScope, store.BrowserCookieFilter{}) + if err != nil { + return nil, err + } + + host := strings.ToLower(u.Hostname()) + path := u.EscapedPath() + if path == "" { + path = "/" + } + now := time.Now().UTC() + params := make([]*proto.NetworkCookieParam, 0, len(cookies)) + for _, c := range cookies { + if !browserCookieMatchesURL(c, host, path, now) { + continue + } + param := &proto.NetworkCookieParam{ + Name: c.Name, + Value: c.Value, + URL: targetURL, + Path: c.Path, + Secure: c.Secure, + HTTPOnly: c.HTTPOnly, + SameSite: browserCookieSameSite(c.SameSite), + } + if strings.HasPrefix(c.Domain, ".") { + param.Domain = c.Domain + } + if c.ExpiresAt != nil { + param.Expires = proto.TimeSinceEpoch(float64(c.ExpiresAt.Unix())) + } + params = append(params, param) + } + return params, nil +} + +func browserScopeToCookieScope(scope browser.BrowserScope) (store.BrowserCookieScope, error) { + tenantID := store.MasterTenantID + if strings.TrimSpace(scope.TenantID) != "" { + parsed, err := uuid.Parse(strings.TrimSpace(scope.TenantID)) + if err != nil { + return store.BrowserCookieScope{}, fmt.Errorf("invalid browser tenant scope: %w", err) + } + tenantID = parsed + } + cookieScope := store.BrowserCookieScope{ + TenantID: tenantID, + UserID: strings.TrimSpace(scope.UserID), + AgentID: strings.TrimSpace(scope.AgentID), + } + if err := cookieScope.Validate(); err != nil { + return store.BrowserCookieScope{}, err + } + return cookieScope, nil +} + +func browserCookieMatchesURL(c store.BrowserCookie, host, requestPath string, now time.Time) bool { + domain := strings.ToLower(strings.TrimSpace(c.Domain)) + if domain == "" { + return false + } + if c.ExpiresAt != nil && !c.ExpiresAt.After(now) { + return false + } + hostOnly := !strings.HasPrefix(domain, ".") + matchDomain := strings.TrimPrefix(domain, ".") + if hostOnly { + if host != matchDomain { + return false + } + } else if host != matchDomain && !strings.HasSuffix(host, "."+matchDomain) { + return false + } + cookiePath := c.Path + if cookiePath == "" { + cookiePath = "/" + } + return requestPath == cookiePath || strings.HasPrefix(requestPath, strings.TrimRight(cookiePath, "/")+"/") +} + +func browserCookieSameSite(value string) proto.NetworkCookieSameSite { + switch strings.ToLower(strings.TrimSpace(value)) { + case "strict": + return proto.NetworkCookieSameSiteStrict + case "lax": + return proto.NetworkCookieSameSiteLax + case "none", "no_restriction", "no-restriction": + return proto.NetworkCookieSameSiteNone + default: + return "" + } +} diff --git a/cmd/browser_cookie_provider_test.go b/cmd/browser_cookie_provider_test.go new file mode 100644 index 00000000..b559b0e2 --- /dev/null +++ b/cmd/browser_cookie_provider_test.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/browser" +) + +type fakeStoreCookieProviderStore struct { + items []store.BrowserCookie + scope store.BrowserCookieScope +} + +func (f *fakeStoreCookieProviderStore) Upsert(context.Context, store.BrowserCookieScope, []store.BrowserCookie) (int, error) { + return 0, nil +} + +func (f *fakeStoreCookieProviderStore) List(_ context.Context, scope store.BrowserCookieScope, _ store.BrowserCookieFilter) ([]store.BrowserCookie, error) { + f.scope = scope + return f.items, nil +} + +func (f *fakeStoreCookieProviderStore) Delete(context.Context, store.BrowserCookieScope, store.BrowserCookieFilter) (int, error) { + return 0, nil +} + +func TestStoreBrowserCookieProviderFiltersAndConvertsCookies(t *testing.T) { + tenantID := uuid.New() + expiresAt := time.Now().UTC().Add(time.Hour) + fake := &fakeStoreCookieProviderStore{items: []store.BrowserCookie{ + {Domain: ".example.com", Name: "parent", Path: "/", Value: "parent-secret", Secure: true, HTTPOnly: true, SameSite: "Lax", ExpiresAt: &expiresAt}, + {Domain: "app.example.com", Name: "host", Path: "/app", Value: "host-secret"}, + {Domain: "other.example.com", Name: "skip", Path: "/", Value: "skip"}, + }} + provider := newStoreBrowserCookieProvider(fake) + + got, err := provider.CookiesForURL(context.Background(), browser.BrowserScope{ + TenantID: tenantID.String(), + UserID: "user-a", + AgentID: "agent-a", + }, "https://app.example.com/app/page") + if err != nil { + t.Fatalf("CookiesForURL: %v", err) + } + if fake.scope.TenantID != tenantID || fake.scope.UserID != "user-a" || fake.scope.AgentID != "agent-a" { + t.Fatalf("scope = %+v", fake.scope) + } + if len(got) != 2 { + t.Fatalf("cookies len = %d, want 2: %+v", len(got), got) + } + if got[0].Name != "parent" || got[0].Domain != ".example.com" || !got[0].HTTPOnly { + t.Fatalf("parent cookie mismatch: %+v", got[0]) + } + if got[1].Name != "host" || got[1].Domain != "" || got[1].URL == "" { + t.Fatalf("host cookie should be URL-scoped: %+v", got[1]) + } +} + +func TestBrowserCookieMatchesURLRejectsExpiredAndWrongPath(t *testing.T) { + now := time.Now().UTC() + expiredAt := now.Add(-time.Minute) + if browserCookieMatchesURL(store.BrowserCookie{Domain: "example.com", Name: "expired", Path: "/", ExpiresAt: &expiredAt}, "example.com", "/", now) { + t.Fatal("expired cookie matched") + } + if browserCookieMatchesURL(store.BrowserCookie{Domain: "example.com", Name: "wrong-path", Path: "/admin"}, "example.com", "/app", now) { + t.Fatal("wrong-path cookie matched") + } + if !browserCookieMatchesURL(store.BrowserCookie{Domain: ".example.com", Name: "sub", Path: "/"}, "app.example.com", "/app", now) { + t.Fatal("parent-domain cookie did not match subdomain") + } +} diff --git a/cmd/gateway.go b/cmd/gateway.go index 5a2e6006..a3798128 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -173,6 +173,9 @@ func runGateway() { } pgStores, traceCollector, snapshotWorker := setupStoresAndTracing(cfg, dataDir, msgBus) + if browserMgr != nil && pgStores != nil && pgStores.BrowserCookies != nil && cfg.Tools.Browser.CookieSyncEnabled { + browserMgr.SetCookieProvider(newStoreBrowserCookieProvider(pgStores.BrowserCookies)) + } // Recover from crashes: flip ghost 'summoning' rows to 'summon_failed'. // Summon goroutines don't survive process restart; stale DB rows would trap the UI. diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 3bd7e78a..e2759ef1 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -84,19 +84,17 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents // Inbound debounce: merge rapid messages from the same sender before processing. // Matching TS createInboundDebouncer from src/auto-reply/inbound-debounce.ts. - debounceMs := cfg.Gateway.InboundDebounceMs - if debounceMs == 0 { - debounceMs = 1000 // default: 1000ms - } - debouncer := bus.NewInboundDebouncer( - time.Duration(debounceMs)*time.Millisecond, + debouncer := bus.NewInboundDebouncerFunc( + func(msg bus.InboundMessage) time.Duration { + return resolveInboundDebounceDelay(ctx, msg, deps) + }, func(msg bus.InboundMessage) { processNormalMessage(ctx, msg, deps) }, ) defer debouncer.Stop() - slog.Info("inbound debounce configured", "debounce_ms", debounceMs) + slog.Info("inbound debounce configured", "global_debounce_ms", cfg.Gateway.InboundDebounceMs, "agent_override", true) // Track background goroutines (subagent announces, teammate messages) // so shutdown can wait for in-flight work to complete. @@ -141,6 +139,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents } // --- Normal messages: route through debouncer --- + prepareInboundDebounceMessage(&msg, deps) debouncer.Push(msg) } } diff --git a/cmd/gateway_consumer_debounce.go b/cmd/gateway_consumer_debounce.go new file mode 100644 index 00000000..a0c98419 --- /dev/null +++ b/cmd/gateway_consumer_debounce.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func prepareInboundDebounceMessage(msg *bus.InboundMessage, deps *ConsumerDeps) { + if msg == nil || deps == nil || deps.Cfg == nil || msg.AgentID != "" { + return + } + msg.AgentID = resolveAgentRoute(deps.Cfg, msg.Channel, msg.ChatID, msg.PeerKind) +} + +func resolveInboundDebounceDelay(ctx context.Context, msg bus.InboundMessage, deps *ConsumerDeps) time.Duration { + debounceMs := 0 + if deps != nil && deps.Cfg != nil { + debounceMs = deps.Cfg.Gateway.InboundDebounceMs + } + if deps == nil || deps.AgentStore == nil || msg.AgentID == "" { + return inboundDebounceDuration(debounceMs) + } + + agentCtx := ctx + if msg.TenantID != uuid.Nil { + agentCtx = store.WithTenantID(agentCtx, msg.TenantID) + } else { + agentCtx = store.WithTenantID(agentCtx, store.MasterTenantID) + } + + agentData, err := getInboundDebounceAgent(agentCtx, deps.AgentStore, msg.AgentID) + if err != nil || agentData == nil { + if err != nil { + slog.Debug("inbound debounce: agent config unavailable", "agent", msg.AgentID, "error", err) + } + return inboundDebounceDuration(debounceMs) + } + if overrideMs, ok := agentData.ParseInboundDebounceMs(); ok { + debounceMs = overrideMs + } + return inboundDebounceDuration(debounceMs) +} + +func getInboundDebounceAgent(ctx context.Context, agentStore store.AgentStore, agentID string) (*store.AgentData, error) { + if parsed, err := uuid.Parse(agentID); err == nil && parsed != uuid.Nil { + return agentStore.GetByID(ctx, parsed) + } + return agentStore.GetByKey(ctx, agentID) +} + +func inboundDebounceDuration(ms int) time.Duration { + if ms <= 0 { + return 0 + } + return time.Duration(ms) * time.Millisecond +} diff --git a/cmd/gateway_cron.go b/cmd/gateway_cron.go index 8bb9759b..3f1ac4d8 100644 --- a/cmd/gateway_cron.go +++ b/cmd/gateway_cron.go @@ -84,6 +84,9 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg cronCtx, cancelCron := context.WithTimeout(context.Background(), jobTimeout) defer cancelCron() cronCtx = store.WithTenantID(cronCtx, job.TenantID) + if job.Payload.CredentialUserID != "" { + cronCtx = store.WithCredentialUserID(cronCtx, job.Payload.CredentialUserID) + } // Reset session before each cron run to prevent tool errors from previous // runs from polluting the context and blocking future executions (#294). diff --git a/cmd/gateway_cron_test.go b/cmd/gateway_cron_test.go new file mode 100644 index 00000000..fc6e7073 --- /dev/null +++ b/cmd/gateway_cron_test.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/scheduler" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestCronJobHandlerInjectsPayloadCredentialUserID(t *testing.T) { + wantCredentialUserID := "tenant-user-123" + var gotCredentialUserID string + + sched := scheduler.NewScheduler( + scheduler.DefaultLanes(), + scheduler.QueueConfig{ + Mode: scheduler.QueueModeQueue, + Cap: 1, + Drop: scheduler.DropOld, + DebounceMs: 0, + MaxConcurrent: 1, + }, + func(ctx context.Context, req agent.RunRequest) (*agent.RunResult, error) { + gotCredentialUserID = store.CredentialUserIDFromContext(ctx) + return &agent.RunResult{Content: "ok"}, nil + }, + ) + defer sched.Stop() + + handler := makeCronJobHandler( + sched, + nil, + &config.Config{}, + nil, + nil, + nil, + ) + + result, err := handler(&store.CronJob{ + ID: uuid.NewString(), + TenantID: uuid.New(), + Name: "credentialed-report", + AgentID: "reporter", + UserID: "group:telegram:-100123", + Stateless: true, + Payload: store.CronPayload{ + Kind: "agent_turn", + Message: "run gh issue list", + CredentialUserID: wantCredentialUserID, + }, + }) + if err != nil { + t.Fatalf("cron handler returned error: %v", err) + } + if result == nil || result.Content != "ok" { + t.Fatalf("cron result = %#v, want content ok", result) + } + if gotCredentialUserID != wantCredentialUserID { + t.Fatalf("credential user ID in scheduled context = %q, want %q", gotCredentialUserID, wantCredentialUserID) + } +} diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index 3eac9db3..3479472e 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -96,6 +96,9 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( if h.secureCLIGrant != nil { d.server.SetSecureCLIGrantHandler(h.secureCLIGrant) } + if d.pgStores != nil && d.pgStores.BrowserCookies != nil { + d.server.SetBrowserCookiesHandler(httpapi.NewBrowserCookiesHandler(d.pgStores.BrowserCookies)) + } // Activity audit log API if d.pgStores.Activity != nil { diff --git a/cmd/gateway_system_config_sync.go b/cmd/gateway_system_config_sync.go index dbff536f..84560781 100644 --- a/cmd/gateway_system_config_sync.go +++ b/cmd/gateway_system_config_sync.go @@ -78,6 +78,9 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg * set(key, fmt.Sprintf("%d", val)) } } + setIntAllowZero := func(key string, val int) { + set(key, fmt.Sprintf("%d", val)) + } setBool := func(key string, val *bool) { if val != nil { set(key, fmt.Sprintf("%t", *val)) @@ -102,7 +105,7 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg * setInt("gateway.rate_limit_rpm", cfg.Gateway.RateLimitRPM) setInt("gateway.max_message_chars", cfg.Gateway.MaxMessageChars) set("gateway.injection_action", cfg.Gateway.InjectionAction) - setInt("gateway.inbound_debounce_ms", cfg.Gateway.InboundDebounceMs) + setIntAllowZero("gateway.inbound_debounce_ms", cfg.Gateway.InboundDebounceMs) setBool("gateway.block_reply", cfg.Gateway.BlockReply) setBool("gateway.tool_status", cfg.Gateway.ToolStatus) setInt("gateway.task_recovery_interval_sec", cfg.Gateway.TaskRecoveryIntervalSec) @@ -115,6 +118,13 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg * set("tools.profile", cfg.Tools.Profile) setInt("tools.rate_limit_per_hour", cfg.Tools.RateLimitPerHour) setBool("tools.scrub_credentials", cfg.Tools.ScrubCredentials) + set("tools.browser.enabled", fmt.Sprintf("%t", cfg.Tools.Browser.Enabled)) + set("tools.browser.headless", fmt.Sprintf("%t", cfg.Tools.Browser.Headless)) + set("tools.browser.remote_url", cfg.Tools.Browser.RemoteURL) + setInt("tools.browser.action_timeout_ms", cfg.Tools.Browser.ActionTimeoutMs) + setIntAllowZero("tools.browser.idle_timeout_ms", cfg.Tools.Browser.IdleTimeoutMs) + setInt("tools.browser.max_pages", cfg.Tools.Browser.MaxPages) + set("tools.browser.cookie_sync_enabled", fmt.Sprintf("%t", cfg.Tools.Browser.CookieSyncEnabled)) // TTS set("tts.provider", cfg.Tts.Provider) diff --git a/cmd/gateway_system_config_sync_test.go b/cmd/gateway_system_config_sync_test.go new file mode 100644 index 00000000..3b9e2b2c --- /dev/null +++ b/cmd/gateway_system_config_sync_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "context" + "maps" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type captureSystemConfigStore struct { + data map[string]string +} + +func (s *captureSystemConfigStore) Get(_ context.Context, key string) (string, error) { + return s.data[key], nil +} + +func (s *captureSystemConfigStore) Set(_ context.Context, key, value string) error { + s.data[key] = value + return nil +} + +func (s *captureSystemConfigStore) Delete(_ context.Context, key string) error { + delete(s.data, key) + return nil +} + +func (s *captureSystemConfigStore) List(_ context.Context) (map[string]string, error) { + out := make(map[string]string, len(s.data)) + maps.Copy(out, s.data) + return out, nil +} + +func TestSeedConfigForContextPersistsZeroInboundDebounce(t *testing.T) { + t.Parallel() + + sc := &captureSystemConfigStore{data: map[string]string{}} + cfg := config.Default() + cfg.Gateway.InboundDebounceMs = 0 + + seedConfigForContext(store.WithTenantID(context.Background(), store.MasterTenantID), sc, cfg, false) + + if got := sc.data["gateway.inbound_debounce_ms"]; got != "0" { + t.Fatalf("gateway.inbound_debounce_ms = %q, want 0", got) + } +} diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index c0137f1b..6e70ae68 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -69,7 +69,7 @@ The consumer routes system messages based on sender ID prefixes: ### Inbound Debounce -Normal channel messages pass through the shared inbound debouncer before agent execution. `gateway.inbound_debounce_ms` merges rapid text messages from the same `channel:chatID:senderID`; `0` uses the 1000ms default and `-1` disables debounce. Media messages bypass the wait window after flushing pending text, and command/control messages such as stop/reset and system escalations bypass debounce. +Normal channel messages pass through the shared inbound debouncer before agent execution. `gateway.inbound_debounce_ms` merges rapid text messages from the same `channel:chatID:senderID:agentID`; `0` means no debounce and positive values set the wait window. Agents can override the global value with `other_config.inbound_debounce_ms`; unset inherits the global config. Media messages bypass the wait window after flushing pending text, and command/control messages such as stop/reset and system escalations bypass debounce. --- diff --git a/docs/06-store-data-model.md b/docs/06-store-data-model.md index efdac94a..bfbbbc8e 100644 --- a/docs/06-store-data-model.md +++ b/docs/06-store-data-model.md @@ -108,7 +108,7 @@ Agent `budget_monthly_cents` values are bridged into `usage_cap_policies` with ` Migration versions: -- PostgreSQL: `000069_usage_caps_pricing`, `000070_usage_cap_policies`, `000071_agent_budget_usage_cap_bridge`. +- PostgreSQL: `000070_usage_caps_pricing`, `000071_usage_cap_policies`, `000072_agent_budget_usage_cap_bridge`. - SQLite: no schema change; feature is not active in Lite. --- diff --git a/docs/14-skills-runtime.md b/docs/14-skills-runtime.md index ac8fe740..7b91ff02 100644 --- a/docs/14-skills-runtime.md +++ b/docs/14-skills-runtime.md @@ -135,7 +135,7 @@ To install additional packages: pip3 install or npm install -g - Run Python/Node scripts via exec tool - Install packages via `pip3 install` / `npm install -g` -- Access files in `/app/workspace/` including `.media/` subdirectory +- Access files in `/app/workspace/`, including `.uploads/` for current user uploads and `.media/` for legacy media refs - Read skill files from `.goclaw/skills-store/` ### What Agents CANNOT Do @@ -152,16 +152,18 @@ To install additional packages: pip3 install or npm install -g Uploaded files (from web chat, Telegram, Discord, etc.) are persisted to: ``` -/app/workspace/.media/{sessionHash}/{uuid}.{ext} +/app/workspace/.uploads/{safe-original-name}-{8hex}.{ext} ``` +Uploads without a usable original filename fall back to `{uuid}.{ext}`. Legacy media refs may still resolve from `.media/{sessionHash}/{uuid}.{ext}`. + The `enrichDocumentPaths()` function injects the full path into `` tags: ``` - + ``` -Agents can read these files directly via exec — no copy to `/tmp` needed. +Agents can read these files directly via exec — no copy to `/tmp` needed. For archive uploads such as `.zip`, inspect or extract with commands like `unzip -l ""` or `unzip -q "" -d `. --- diff --git a/docs/18-http-api.md b/docs/18-http-api.md index de1069f5..d2b16dad 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -38,6 +38,39 @@ API keys are hashed with SHA-256 before lookup — the raw key is never stored. --- +## Browser Cookie Sync + +Selected-cookie sync stores user-approved browser cookies for server-side browser automation. Endpoints require operator auth and `X-GoClaw-User-Id`; the request body cannot set `tenant_id` or `user_id`. + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/v1/browser/cookies/sync` | Store selected cookies for one `agent_id` | +| `GET` | `/v1/browser/cookies?agent_id=...` | List synced cookie metadata; values are redacted | +| `DELETE` | `/v1/browser/cookies?agent_id=...&domain=...&name=...` | Delete scoped synced cookies | + +Example sync request: + +```json +{ + "agent_id": "default", + "source": "chrome-selected-cookie-sync", + "cookies": [{ + "domain": ".example.com", + "name": "session", + "path": "/", + "value": "cookie-value", + "secure": true, + "httpOnly": true, + "sameSite": "lax", + "expirationDate": 1770000000 + }] +} +``` + +See [Browser Cookie Sync Threat Model](browser-cookie-sync-threat-model.md) for isolation and encryption details. + +--- + ## 2. Chat Completions OpenAI-compatible chat API for programmatic access to agents. diff --git a/docs/19-websocket-rpc.md b/docs/19-websocket-rpc.md index 25e9610e..c1d77d57 100644 --- a/docs/19-websocket-rpc.md +++ b/docs/19-websocket-rpc.md @@ -106,7 +106,7 @@ Send a message to an agent and trigger execution. When `stream: true`, intermediate events are emitted: `chunk`, `tool.call`, `tool.result`, `run.started`, `run.completed`. -Rapid text-only `chat.send` requests for the same user and session are debounced by `gateway.inbound_debounce_ms`: `0` uses the 1000ms default and `-1` disables debounce. The merged message keeps request params from the latest send and joins text with newlines. Cancel keywords bypass debounce and abort the active run immediately. Media sends bypass the wait window and drain any pending text into the same dispatch. +Rapid text-only `chat.send` requests for the same user and session are debounced by `gateway.inbound_debounce_ms`: `0` means no debounce and positive values set the wait window. Agents can override the global value with `other_config.inbound_debounce_ms`; unset inherits the global config. The merged message keeps request params from the latest send and joins text with newlines. Cancel keywords bypass debounce and abort the active run immediately. Media sends bypass the wait window and drain any pending text into the same dispatch. ### `chat.history` diff --git a/docs/browser-cookie-sync-threat-model.md b/docs/browser-cookie-sync-threat-model.md new file mode 100644 index 00000000..12a15554 --- /dev/null +++ b/docs/browser-cookie-sync-threat-model.md @@ -0,0 +1,43 @@ +# Browser Cookie Sync Threat Model + +Selected cookie sync lets a user copy specific Chrome cookies into a GoClaw server-side browser session for one agent. + +## Assets + +- Browser cookies and session tokens. +- Tenant, user, and agent isolation boundaries. +- Server-side browser incognito contexts. +- Audit logs showing sync and delete events. + +## Trust Boundaries + +- Chrome extension runs on the user's machine and only sends cookies after explicit selection. +- HTTP API derives tenant and user from gateway auth, API key auth, or paired-browser auth. +- Client payload may choose `agent_id`, but cannot choose `tenant_id` or `user_id`. +- Cookie values are encrypted before database persistence. + +## Controls + +- `POST /v1/browser/cookies/sync`, `GET /v1/browser/cookies`, and `DELETE /v1/browser/cookies` require operator auth. +- Sync fails when the request context has no user or no agent. +- Store unique key is `(tenant_id, user_id, agent_id, domain, path, name)`. +- Cookie values are encrypted at rest and never returned by list responses. +- Browser runtime applies cookies only to matching domain/path for the same tenant, user, and agent scope. +- Extension asks for active-site host permission and sends only checked cookies. + +## Main Risks + +| Risk | Mitigation | +|------|------------| +| Cross-user cookie leak | Store and browser scope include `tenant_id`, `user_id`, and `agent_id`. | +| Client spoofs user | API ignores user fields in JSON body; user comes from auth context. | +| Plaintext persistence | Cookie store fails closed when encryption key is missing. | +| Oversized cookie payload | API caps body size, cookie count, and per-cookie value size. | +| Overbroad extension access | Extension requests host permission for the current origin before reading cookies. | +| Accidental value exposure | List endpoint returns metadata only. Logs never include cookie values. | + +## Operational Notes + +- Set `GOCLAW_ENCRYPTION_KEY` before enabling cookie sync. +- Revoke synced cookies with `DELETE /v1/browser/cookies?agent_id=&domain=`. +- Restart the gateway after changing browser launch settings that affect manager startup. diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 84b9c87c..19832eb6 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -4,6 +4,27 @@ Significant changes, features, and fixes in reverse chronological order. --- +## 2026-05-24 + +### Browser cookie sync and config UI + +**Features** + +- Added scoped browser cookie sync API, encrypted cookie store, browser runtime cookie application, dashboard browser settings, and a selected-cookie Chrome extension prototype. + +### Cron SecureCLI credential context + +**Fixes** + +- Fixed cron-triggered agent turns so credentialed CLI lookups preserve the explicit tenant user credential identity captured when the cron job is created. +- Kept cron `user_id` ownership unchanged for group-scoped list/remove behavior while storing credential lookup identity separately in cron payload metadata. + +**Tests** + +- Added regression coverage for cron payload credential metadata, legacy payload compatibility, cron scheduler context injection, and SQLite cron persistence parity. + +--- + ## 2026-05-22 ### Usage Cap budget controls @@ -30,11 +51,13 @@ Significant changes, features, and fixes in reverse chronological order. **Features** +- Added per-agent inbound debounce override via `other_config.inbound_debounce_ms`; unset inherits the global gateway setting. - Added Web Chat debounce for rapid text-only `chat.send` calls using `gateway.inbound_debounce_ms`. - Clarified shared inbound debounce behavior in docs and Web UI config help text. **Fixes** +- Fixed inbound debounce semantics so `gateway.inbound_debounce_ms=0` means no debounce and positive values set the wait window. - Fixed Slack `debounce_delay: 0` so it disables per-thread batching instead of falling back to the default. **Tests** diff --git a/extensions/chrome-selected-cookie-sync/README.md b/extensions/chrome-selected-cookie-sync/README.md new file mode 100644 index 00000000..869f1603 --- /dev/null +++ b/extensions/chrome-selected-cookie-sync/README.md @@ -0,0 +1,13 @@ +# GoClaw Selected Cookie Sync Extension + +Chrome MV3 extension for explicit selected-cookie sync into GoClaw browser sessions. + +Security model: +- No automatic background sync. +- Requests host permission only for the active tab origin. +- Requests gateway-origin permission before sending the selected cookies. +- Sends only checked cookies. +- Sends `userId` and `agentId`; the gateway derives tenant scope from auth. +- Stores extension settings in `chrome.storage.local`. + +Load locally from `chrome://extensions` with Developer Mode and "Load unpacked". diff --git a/extensions/chrome-selected-cookie-sync/manifest.json b/extensions/chrome-selected-cookie-sync/manifest.json new file mode 100644 index 00000000..c5c67802 --- /dev/null +++ b/extensions/chrome-selected-cookie-sync/manifest.json @@ -0,0 +1,12 @@ +{ + "manifest_version": 3, + "name": "GoClaw Selected Cookie Sync", + "version": "0.1.0", + "description": "Explicitly sync selected cookies from the active tab to a scoped GoClaw browser session.", + "permissions": ["activeTab", "cookies", "storage", "tabs"], + "optional_host_permissions": [""], + "action": { + "default_title": "Sync cookies to GoClaw", + "default_popup": "popup.html" + } +} diff --git a/extensions/chrome-selected-cookie-sync/popup.css b/extensions/chrome-selected-cookie-sync/popup.css new file mode 100644 index 00000000..6bb501ae --- /dev/null +++ b/extensions/chrome-selected-cookie-sync/popup.css @@ -0,0 +1,121 @@ +* { + box-sizing: border-box; +} + +body { + width: 380px; + margin: 0; + color: #172033; + background: #f7f8fb; + font: 13px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +main { + padding: 12px; +} + +header, +.toolbar, +footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +h1 { + margin: 0; + font-size: 16px; + letter-spacing: 0; +} + +.settings { + display: grid; + gap: 8px; + margin: 12px 0; +} + +label { + display: grid; + gap: 4px; + font-weight: 600; +} + +input { + min-height: 34px; + width: 100%; + border: 1px solid #c8cfda; + border-radius: 6px; + padding: 6px 8px; + background: #fff; + color: #172033; + font: inherit; +} + +button { + min-height: 34px; + border: 1px solid #b8c1cf; + border-radius: 6px; + padding: 6px 10px; + background: #fff; + color: #172033; + font: inherit; + font-weight: 650; + cursor: pointer; +} + +button:hover { + background: #eef2f7; +} + +#sync { + width: 100%; + margin-top: 10px; + color: #fff; + border-color: #176b4d; + background: #18825b; +} + +#sync:hover { + background: #126d4c; +} + +#status { + min-height: 18px; + margin: 10px 0 8px; + color: #526074; +} + +.cookies { + max-height: 260px; + overflow: auto; + display: grid; + gap: 6px; +} + +.cookie-row { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + gap: 8px; + padding: 8px; + border: 1px solid #d9dee7; + border-radius: 6px; + background: #fff; +} + +.cookie-row input { + width: 16px; + min-height: 16px; + margin-top: 2px; +} + +.cookie-name { + overflow-wrap: anywhere; + font-weight: 700; +} + +.cookie-meta { + overflow-wrap: anywhere; + color: #526074; + font-size: 12px; +} diff --git a/extensions/chrome-selected-cookie-sync/popup.html b/extensions/chrome-selected-cookie-sync/popup.html new file mode 100644 index 00000000..50ca072c --- /dev/null +++ b/extensions/chrome-selected-cookie-sync/popup.html @@ -0,0 +1,49 @@ + + + + + + GoClaw Cookie Sync + + + +
+
+

GoClaw

+ +
+ +
+ + + + +
+ +
+ + +
+ +

+
+ +
+ +
+
+ + + diff --git a/extensions/chrome-selected-cookie-sync/popup.js b/extensions/chrome-selected-cookie-sync/popup.js new file mode 100644 index 00000000..360530fa --- /dev/null +++ b/extensions/chrome-selected-cookie-sync/popup.js @@ -0,0 +1,196 @@ +const state = { + tab: null, + originPattern: "", + cookies: [], +}; + +const els = { + gatewayUrl: document.getElementById("gatewayUrl"), + token: document.getElementById("token"), + userId: document.getElementById("userId"), + agentId: document.getElementById("agentId"), + grant: document.getElementById("grant"), + refresh: document.getElementById("refresh"), + selectAll: document.getElementById("selectAll"), + sync: document.getElementById("sync"), + cookies: document.getElementById("cookies"), + status: document.getElementById("status"), +}; + +init().catch((err) => setStatus(err.message, true)); + +async function init() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + state.tab = tab; + state.originPattern = originPatternFor(tab?.url || ""); + const settings = await chrome.storage.local.get(["gatewayUrl", "token", "userId", "agentId"]); + els.gatewayUrl.value = settings.gatewayUrl || "http://localhost:18790"; + els.token.value = settings.token || ""; + els.userId.value = settings.userId || ""; + els.agentId.value = settings.agentId || "default"; + bindEvents(); + await loadCookies(); +} + +function bindEvents() { + for (const input of [els.gatewayUrl, els.token, els.userId, els.agentId]) { + input.addEventListener("change", saveSettings); + } + els.grant.addEventListener("click", requestSiteAccess); + els.refresh.addEventListener("click", loadCookies); + els.selectAll.addEventListener("click", selectAllCookies); + els.sync.addEventListener("click", syncSelected); +} + +async function saveSettings() { + await chrome.storage.local.set({ + gatewayUrl: els.gatewayUrl.value.trim(), + token: els.token.value, + userId: els.userId.value.trim(), + agentId: els.agentId.value.trim(), + }); +} + +async function requestSiteAccess() { + if (!state.originPattern) { + setStatus("Active tab is not an HTTP site.", true); + return; + } + const granted = await chrome.permissions.request({ origins: [state.originPattern] }); + if (!granted) { + setStatus("Site access not granted.", true); + return; + } + await loadCookies(); +} + +async function loadCookies() { + if (!state.tab?.url || !state.originPattern) { + setStatus("Open an HTTP site tab first.", true); + return; + } + const hasPermission = await chrome.permissions.contains({ origins: [state.originPattern] }); + if (!hasPermission) { + state.cookies = []; + renderCookies(); + setStatus("Grant access for this site before reading cookies."); + return; + } + state.cookies = await chrome.cookies.getAll({ url: state.tab.url }); + renderCookies(); + setStatus(`${state.cookies.length} cookies available for this site.`); +} + +function renderCookies() { + els.cookies.textContent = ""; + if (state.cookies.length === 0) { + const empty = document.createElement("div"); + empty.className = "cookie-meta"; + empty.textContent = "No cookies loaded."; + els.cookies.append(empty); + return; + } + for (const cookie of state.cookies) { + const row = document.createElement("label"); + row.className = "cookie-row"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.dataset.cookieKey = cookieKey(cookie); + const body = document.createElement("div"); + const name = document.createElement("div"); + name.className = "cookie-name"; + name.textContent = cookie.name; + const meta = document.createElement("div"); + meta.className = "cookie-meta"; + meta.textContent = `${cookie.domain}${cookie.path || "/"}${cookie.httpOnly ? " · HttpOnly" : ""}${cookie.secure ? " · Secure" : ""}`; + body.append(name, meta); + row.append(checkbox, body); + els.cookies.append(row); + } +} + +function selectAllCookies() { + for (const checkbox of els.cookies.querySelectorAll("input[type='checkbox']")) { + checkbox.checked = true; + } +} + +async function syncSelected() { + await saveSettings(); + const selectedKeys = new Set( + [...els.cookies.querySelectorAll("input[type='checkbox']:checked")].map((el) => el.dataset.cookieKey), + ); + const cookies = state.cookies.filter((cookie) => selectedKeys.has(cookieKey(cookie))); + if (cookies.length === 0) { + setStatus("Select at least one cookie.", true); + return; + } + const gatewayUrl = els.gatewayUrl.value.trim().replace(/\/+$/, ""); + const userId = els.userId.value.trim(); + const agentId = els.agentId.value.trim(); + if (!gatewayUrl || !userId || !agentId) { + setStatus("Gateway URL, User ID, and Agent ID are required.", true); + return; + } + if (!(await ensureOriginPermission(gatewayUrl))) { + setStatus("Gateway access not granted.", true); + return; + } + const headers = { + "Content-Type": "application/json", + "X-GoClaw-User-Id": userId, + }; + if (els.token.value) { + headers.Authorization = `Bearer ${els.token.value}`; + } + const response = await fetch(`${gatewayUrl}/v1/browser/cookies/sync`, { + method: "POST", + headers, + body: JSON.stringify({ + agent_id: agentId, + source: "chrome-selected-cookie-sync", + cookies: cookies.map((cookie) => ({ + domain: cookie.domain, + name: cookie.name, + path: cookie.path, + value: cookie.value, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + expirationDate: cookie.expirationDate, + })), + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + setStatus(data.error || `Sync failed with HTTP ${response.status}.`, true); + return; + } + setStatus(`Synced ${data.synced ?? cookies.length} cookies.`); +} + +async function ensureOriginPermission(rawUrl) { + const pattern = originPatternFor(rawUrl); + if (!pattern) return false; + if (await chrome.permissions.contains({ origins: [pattern] })) return true; + return chrome.permissions.request({ origins: [pattern] }); +} + +function originPatternFor(rawUrl) { + try { + const url = new URL(rawUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") return ""; + return `${url.protocol}//${url.host}/*`; + } catch { + return ""; + } +} + +function cookieKey(cookie) { + return `${cookie.domain}\n${cookie.path}\n${cookie.name}`; +} + +function setStatus(message, isError = false) { + els.status.textContent = message; + els.status.style.color = isError ? "#b42318" : "#526074"; +} diff --git a/internal/agent/loop_context.go b/internal/agent/loop_context.go index 287bc883..03bade8e 100644 --- a/internal/agent/loop_context.go +++ b/internal/agent/loop_context.go @@ -45,7 +45,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup // Resolve merged tenant user identity for credential lookups. // Keeps UserID unchanged (session/workspace scoping) but sets a separate // CredentialUserID for SecureCLI, MCP, and other per-user features. - if l.userResolver != nil && req.UserID != "" { + if l.userResolver != nil && req.UserID != "" && store.ExplicitCredentialUserIDFromContext(ctx) == "" { credUserID := l.resolveCredentialUserID(ctx, *req) if credUserID != "" && credUserID != req.UserID { ctx = store.WithCredentialUserID(ctx, credUserID) @@ -354,7 +354,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup providerName = l.provider.Name() } // Extract resolved credential user ID (set earlier via WithCredentialUserID, empty if not resolved). - credUserID, _ := ctx.Value(store.CredentialUserIDKey).(string) + credUserID := store.ExplicitCredentialUserIDFromContext(ctx) rc := &store.RunContext{ AgentID: l.agentUUID, AgentKey: l.id, diff --git a/internal/agent/loop_input_media.go b/internal/agent/loop_input_media.go index fa7989eb..49cb3394 100644 --- a/internal/agent/loop_input_media.go +++ b/internal/agent/loop_input_media.go @@ -27,10 +27,11 @@ func isTextMime(mime string) bool { } // collectRefsByKind gathers MediaRefs of a given kind from message history -// (reverse order) and current-turn refs. Historical first, current last. +// in chronological order, then appends current-turn refs. The last ref is the +// newest document for read_document's omitted media_id fallback. func collectRefsByKind(messages []providers.Message, currentRefs []providers.MediaRef, kind string) []providers.MediaRef { var refs []providers.MediaRef - for i := len(messages) - 1; i >= 0; i-- { + for i := range messages { for _, ref := range messages[i].MediaRefs { if ref.Kind == kind { refs = append(refs, ref) diff --git a/internal/agent/media_persist_test.go b/internal/agent/media_persist_test.go index 297f4a94..b819d208 100644 --- a/internal/agent/media_persist_test.go +++ b/internal/agent/media_persist_test.go @@ -72,6 +72,14 @@ func TestPersistMedia_NamingScheme(t *testing.T) { wantSlug: "", wantExtPat: `\.pdf`, }, + { + name: "zip_archive_preserves_archive_extension", + filename: "codex.zip", + content: "zip bytes", + mime: "application/zip", + wantSlug: "codex", + wantExtPat: `\.zip`, + }, } var loop Loop diff --git a/internal/agent/media_test.go b/internal/agent/media_test.go index cd66173c..f5fd4144 100644 --- a/internal/agent/media_test.go +++ b/internal/agent/media_test.go @@ -73,6 +73,34 @@ func TestEnrichImageIDs_SkipsAlreadyEnriched(t *testing.T) { } } +func TestCollectRefsByKindOrdersOldestToNewestThenCurrent(t *testing.T) { + messages := []providers.Message{ + { + Role: "user", + Content: "old", + MediaRefs: []providers.MediaRef{{ID: "old-doc", Kind: "document"}}, + }, + { + Role: "user", + Content: "latest-history", + MediaRefs: []providers.MediaRef{{ID: "latest-history-doc", Kind: "document"}}, + }, + } + current := []providers.MediaRef{{ID: "current-doc", Kind: "document"}} + + got := collectRefsByKind(messages, current, "document") + want := []string{"old-doc", "latest-history-doc", "current-doc"} + + if len(got) != len(want) { + t.Fatalf("got %d refs, want %d", len(got), len(want)) + } + for i := range want { + if got[i].ID != want[i] { + t.Fatalf("ref[%d] = %q, want %q", i, got[i].ID, want[i]) + } + } +} + // testMediaStore creates a temporary media.Store for tests. func testMediaStore(t *testing.T) *media.Store { t.Helper() diff --git a/internal/bus/inbound_debounce.go b/internal/bus/inbound_debounce.go index 6506ce7e..8df92cce 100644 --- a/internal/bus/inbound_debounce.go +++ b/internal/bus/inbound_debounce.go @@ -16,10 +16,10 @@ import ( // InboundDebouncer buffers rapid inbound messages from the same sender // and merges them into a single message before calling flushFn. type InboundDebouncer struct { - debounceMs time.Duration - mu sync.Mutex - buffers map[string]*debounceBuffer - flushFn func(InboundMessage) + delayFn func(InboundMessage) time.Duration + mu sync.Mutex + buffers map[string]*debounceBuffer + flushFn func(InboundMessage) } type debounceBuffer struct { @@ -30,18 +30,30 @@ type debounceBuffer struct { // NewInboundDebouncer creates a debouncer with the given window and flush callback. // If debounceMs <= 0, messages are passed through immediately (debouncing disabled). func NewInboundDebouncer(debounceMs time.Duration, flushFn func(InboundMessage)) *InboundDebouncer { + return NewInboundDebouncerFunc(func(InboundMessage) time.Duration { + return debounceMs + }, flushFn) +} + +// NewInboundDebouncerFunc creates a debouncer whose window can vary per message. +func NewInboundDebouncerFunc(delayFn func(InboundMessage) time.Duration, flushFn func(InboundMessage)) *InboundDebouncer { + if delayFn == nil { + delayFn = func(InboundMessage) time.Duration { return 0 } + } return &InboundDebouncer{ - debounceMs: debounceMs, - buffers: make(map[string]*debounceBuffer), - flushFn: flushFn, + delayFn: delayFn, + buffers: make(map[string]*debounceBuffer), + flushFn: flushFn, } } // Push adds a message to the debounce buffer. // If debouncing is disabled or the message should bypass (media), it is flushed immediately. func (d *InboundDebouncer) Push(msg InboundMessage) { + debounceMs := d.delayFn(msg) + // Disabled: pass through immediately. - if d.debounceMs <= 0 { + if debounceMs <= 0 { d.flushFn(msg) return } @@ -70,13 +82,13 @@ func (d *InboundDebouncer) Push(msg InboundMessage) { if buf.timer != nil { buf.timer.Stop() } - buf.timer = time.AfterFunc(d.debounceMs, func() { + buf.timer = time.AfterFunc(debounceMs, func() { d.flushKey(key) }) if len(buf.messages) == 1 { slog.Debug("inbound debounce: buffering", - "key", key, "debounce_ms", d.debounceMs.Milliseconds()) + "key", key, "debounce_ms", debounceMs.Milliseconds()) } else { slog.Debug("inbound debounce: message appended", "key", key, "buffered", len(buf.messages)) @@ -127,9 +139,9 @@ func (d *InboundDebouncer) flushKey(key string) { d.flushFn(merged) } -// debounceKey builds the buffer key: channel:chatID:senderID. +// debounceKey builds the buffer key: channel:chatID:senderID:agentID. func debounceKey(msg InboundMessage) string { - return msg.Channel + ":" + msg.ChatID + ":" + msg.SenderID + return msg.Channel + ":" + msg.ChatID + ":" + msg.SenderID + ":" + msg.AgentID } // mergeInboundMessages combines multiple messages into one. diff --git a/internal/bus/inbound_debounce_test.go b/internal/bus/inbound_debounce_test.go index b68ac8f6..59b2b1cf 100644 --- a/internal/bus/inbound_debounce_test.go +++ b/internal/bus/inbound_debounce_test.go @@ -26,7 +26,7 @@ func TestInboundDebouncerMergesRapidText(t *testing.T) { func TestInboundDebouncerDisabledPassesThrough(t *testing.T) { out := make(chan InboundMessage, 2) - d := NewInboundDebouncer(-1, func(msg InboundMessage) { + d := NewInboundDebouncer(0, func(msg InboundMessage) { out <- msg }) @@ -41,6 +41,48 @@ func TestInboundDebouncerDisabledPassesThrough(t *testing.T) { } } +func TestInboundDebouncerDynamicDelay(t *testing.T) { + out := make(chan InboundMessage, 2) + d := NewInboundDebouncerFunc(func(msg InboundMessage) time.Duration { + if msg.AgentID == "instant" { + return 0 + } + return 20 * time.Millisecond + }, func(msg InboundMessage) { + out <- msg + }) + defer d.Stop() + + d.Push(InboundMessage{Channel: "telegram", ChatID: "chat-1", SenderID: "user-1", AgentID: "instant", Content: "one"}) + d.Push(InboundMessage{Channel: "telegram", ChatID: "chat-1", SenderID: "user-1", AgentID: "debounced", Content: "two"}) + d.Push(InboundMessage{Channel: "telegram", ChatID: "chat-1", SenderID: "user-1", AgentID: "debounced", Content: "three"}) + + if got := waitInbound(t, out); got.Content != "one" { + t.Fatalf("instant content = %q", got.Content) + } + if got := waitInbound(t, out); got.Content != "two\nthree" { + t.Fatalf("debounced content = %q", got.Content) + } +} + +func TestInboundDebouncerSeparatesAgents(t *testing.T) { + out := make(chan InboundMessage, 2) + d := NewInboundDebouncer(20*time.Millisecond, func(msg InboundMessage) { + out <- msg + }) + defer d.Stop() + + d.Push(InboundMessage{Channel: "telegram", ChatID: "chat-1", SenderID: "user-1", AgentID: "agent-a", Content: "a"}) + d.Push(InboundMessage{Channel: "telegram", ChatID: "chat-1", SenderID: "user-1", AgentID: "agent-b", Content: "b"}) + + first := waitInbound(t, out) + second := waitInbound(t, out) + got := map[string]string{first.AgentID: first.Content, second.AgentID: second.Content} + if got["agent-a"] != "a" || got["agent-b"] != "b" { + t.Fatalf("agent buffers = %#v, want separate flushes", got) + } +} + func TestInboundDebouncerMediaFlushesPendingTextFirst(t *testing.T) { out := make(chan InboundMessage, 2) d := NewInboundDebouncer(time.Minute, func(msg InboundMessage) { diff --git a/internal/channels/history.go b/internal/channels/history.go index 36692857..054d61a4 100644 --- a/internal/channels/history.go +++ b/internal/channels/history.go @@ -36,9 +36,11 @@ const ( // Stored in RAM only (not persisted to DB) — used by channels that defer media // download until the bot is actually mentioned (e.g. Telegram). type MediaRef struct { - Type string // "image", "video", "audio", "voice", "document", "animation" - FileID string // platform-specific file ID for lazy download - FileSize int64 // file size in bytes (0 if unknown) — used to skip large files + Type string // "image", "video", "audio", "voice", "document", "animation" + FileID string // platform-specific file ID for lazy download + FileSize int64 // file size in bytes (0 if unknown) — used to skip large files + FileName string // original filename, if the platform exposes one + ContentType string // MIME type, if the platform exposes one } // HistoryEntry represents a single tracked group message. diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index 7999103c..685d621f 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -513,14 +513,8 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { // Resolve deferred media from history entries (lazy download). if histRefs := c.GroupHistory().CollectMediaRefs(localKey); len(histRefs) > 0 { histMedia, histErrors := c.resolveMediaRefs(ctx, histRefs) - for _, m := range histMedia { - mediaFiles = append(mediaFiles, bus.MediaFile{ - Path: m.FilePath, - MimeType: m.ContentType, - Filename: m.FileName, - }) - } if len(histMedia) > 0 { + mediaFiles = prependMediaInfoFiles(mediaFiles, histMedia) histTags := buildMediaTags(histMedia) annotated = "[Media from recent group messages — only analyze if user asks about them]\n" + histTags + "\n[/Media]\n\n" + annotated } @@ -577,12 +571,12 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { // user sees typing indicator → first content appears directly. metadata := map[string]string{ - "message_id": fmt.Sprintf("%d", message.MessageID), - "user_id": fmt.Sprintf("%d", user.ID), + "message_id": fmt.Sprintf("%d", message.MessageID), + "user_id": fmt.Sprintf("%d", user.ID), tools.MetaUsername: user.Username, - "first_name": user.FirstName, - "is_group": fmt.Sprintf("%t", isGroup), - "local_key": localKey, + "first_name": user.FirstName, + "is_group": fmt.Sprintf("%t", isGroup), + "local_key": localKey, } if message.Chat.Title != "" { metadata[tools.MetaChatTitle] = message.Chat.Title diff --git a/internal/channels/telegram/media.go b/internal/channels/telegram/media.go index d83a6ec1..1a1faa8c 100644 --- a/internal/channels/telegram/media.go +++ b/internal/channels/telegram/media.go @@ -14,6 +14,7 @@ import ( "github.com/mymmrac/telego" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/tools" @@ -23,6 +24,9 @@ const ( // defaultMediaMaxBytes is the default max download size for the official Bot API (20 MB). defaultMediaMaxBytes int64 = 20 * 1024 * 1024 + // officialAPIOutboundMaxBytes is Telegram's upload limit for Bot API media sends. + officialAPIOutboundMaxBytes int64 = 50 * 1024 * 1024 + // localAPIDefaultMaxBytes is the default max download size when a local Bot API server // is configured. The local server supports up to 2 GB; we default to 200 MB and let // downstream providers enforce their own limits. @@ -356,6 +360,24 @@ func buildMediaTags(mediaList []MediaInfo) string { return media.BuildMediaTags(mediaList) } +func prependMediaInfoFiles(current []bus.MediaFile, history []MediaInfo) []bus.MediaFile { + if len(history) == 0 { + return current + } + ordered := make([]bus.MediaFile, 0, len(history)+len(current)) + for _, m := range history { + if m.FilePath == "" { + continue + } + ordered = append(ordered, bus.MediaFile{ + Path: m.FilePath, + MimeType: m.ContentType, + Filename: m.FileName, + }) + } + return append(ordered, current...) +} + // extractDocumentContent delegates to the shared media package. func extractDocumentContent(filePath, fileName string) (string, error) { return media.ExtractDocumentContent(filePath, fileName) @@ -409,22 +431,51 @@ func extractMediaRefs(msg *telego.Message) []channels.MediaRef { refs = append(refs, channels.MediaRef{Type: "image", FileID: photo.FileID, FileSize: int64(photo.FileSize)}) } if msg.Video != nil { - refs = append(refs, channels.MediaRef{Type: "video", FileID: msg.Video.FileID, FileSize: int64(msg.Video.FileSize)}) + refs = append(refs, channels.MediaRef{ + Type: "video", + FileID: msg.Video.FileID, + FileSize: int64(msg.Video.FileSize), + FileName: msg.Video.FileName, + ContentType: msg.Video.MimeType, + }) } if msg.VideoNote != nil { refs = append(refs, channels.MediaRef{Type: "video", FileID: msg.VideoNote.FileID, FileSize: int64(msg.VideoNote.FileSize)}) } if msg.Animation != nil { - refs = append(refs, channels.MediaRef{Type: "animation", FileID: msg.Animation.FileID, FileSize: int64(msg.Animation.FileSize)}) + refs = append(refs, channels.MediaRef{ + Type: "animation", + FileID: msg.Animation.FileID, + FileSize: int64(msg.Animation.FileSize), + FileName: msg.Animation.FileName, + ContentType: msg.Animation.MimeType, + }) } if msg.Audio != nil { - refs = append(refs, channels.MediaRef{Type: "audio", FileID: msg.Audio.FileID, FileSize: int64(msg.Audio.FileSize)}) + refs = append(refs, channels.MediaRef{ + Type: "audio", + FileID: msg.Audio.FileID, + FileSize: int64(msg.Audio.FileSize), + FileName: msg.Audio.FileName, + ContentType: msg.Audio.MimeType, + }) } if msg.Voice != nil { - refs = append(refs, channels.MediaRef{Type: "voice", FileID: msg.Voice.FileID, FileSize: int64(msg.Voice.FileSize)}) + refs = append(refs, channels.MediaRef{ + Type: "voice", + FileID: msg.Voice.FileID, + FileSize: int64(msg.Voice.FileSize), + ContentType: msg.Voice.MimeType, + }) } if msg.Document != nil { - refs = append(refs, channels.MediaRef{Type: "document", FileID: msg.Document.FileID, FileSize: int64(msg.Document.FileSize)}) + refs = append(refs, channels.MediaRef{ + Type: "document", + FileID: msg.Document.FileID, + FileSize: int64(msg.Document.FileSize), + FileName: msg.Document.FileName, + ContentType: msg.Document.MimeType, + }) } return refs } @@ -489,9 +540,12 @@ func (c *Channel) resolveMediaRefs(ctx context.Context, refs []channels.MediaRef continue } results = append(results, MediaInfo{ - Type: ref.Type, - FilePath: filePath, - FileID: ref.FileID, + Type: ref.Type, + FilePath: filePath, + FileID: ref.FileID, + ContentType: ref.ContentType, + FileName: ref.FileName, + FileSize: ref.FileSize, }) } return results, errs diff --git a/internal/channels/telegram/media_test.go b/internal/channels/telegram/media_test.go index f568db34..a9822c70 100644 --- a/internal/channels/telegram/media_test.go +++ b/internal/channels/telegram/media_test.go @@ -3,6 +3,11 @@ package telegram import ( "strings" "testing" + + "github.com/mymmrac/telego" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" ) // --- buildMediaTags tests --- @@ -148,3 +153,52 @@ func TestBuildMediaTags_UnknownType(t *testing.T) { t.Errorf("expected empty string for unknown type, got: %q", got) } } + +func TestExtractMediaRefsPreservesDocumentMetadata(t *testing.T) { + msg := &telego.Message{ + Document: &telego.Document{ + FileID: "telegram-file-id", + FileName: "codex.zip", + MimeType: "application/zip", + FileSize: 1234, + }, + } + + got := extractMediaRefs(msg) + want := []channels.MediaRef{{ + Type: "document", + FileID: "telegram-file-id", + FileSize: 1234, + FileName: "codex.zip", + ContentType: "application/zip", + }} + + if len(got) != len(want) { + t.Fatalf("got %d refs, want %d", len(got), len(want)) + } + if got[0] != want[0] { + t.Fatalf("ref = %+v, want %+v", got[0], want[0]) + } +} + +func TestPrependMediaInfoFilesKeepsHistoryBeforeCurrent(t *testing.T) { + current := []bus.MediaFile{{ + Path: "/workspace/.uploads/current.pdf", + MimeType: "application/pdf", + Filename: "current.pdf", + }} + history := []MediaInfo{{ + Type: "document", + FilePath: "/workspace/.uploads/codex.zip", + ContentType: "application/zip", + FileName: "codex.zip", + }} + + got := prependMediaInfoFiles(current, history) + if len(got) != 2 { + t.Fatalf("got %d files, want 2", len(got)) + } + if got[0].Path != history[0].FilePath || got[1].Path != current[0].Path { + t.Fatalf("order = [%q, %q], want history before current", got[0].Path, got[1].Path) + } +} diff --git a/internal/channels/telegram/send.go b/internal/channels/telegram/send.go index dbaa4e86..c64149ea 100644 --- a/internal/channels/telegram/send.go +++ b/internal/channels/telegram/send.go @@ -386,18 +386,8 @@ func (c *Channel) sendMediaMessage(ctx context.Context, chatID int64, msg bus.Ou } } - // Honor MediaMaxBytes for outbound sends. - // Prevents attempting to upload huge files that would fail via Telegram Bot API or local proxy. - maxBytes := c.config.MediaMaxBytes - if maxBytes == 0 { - if c.config.APIServer != "" { - maxBytes = localAPIDefaultMaxBytes - } else { - maxBytes = defaultMediaMaxBytes - } - } - if info, err := os.Stat(media.URL); err == nil && info.Size() > maxBytes { - return fmt.Errorf("outbound media too large: %d bytes (limit %d)", info.Size(), maxBytes) + if err := c.validateOutboundMediaSize(media.URL); err != nil { + return err } // Send based on content type. @@ -455,6 +445,29 @@ func (c *Channel) sendMediaMessage(ctx context.Context, chatID int64, msg bus.Ou return nil } +func (c *Channel) validateOutboundMediaSize(path string) error { + info, err := os.Stat(path) + if err != nil { + return nil + } + + maxBytes := c.outboundMediaMaxBytes() + if info.Size() > maxBytes { + return fmt.Errorf("outbound media too large for Telegram upload: %d bytes (limit %d)", info.Size(), maxBytes) + } + return nil +} + +func (c *Channel) outboundMediaMaxBytes() int64 { + if c.config.APIServer == "" { + return officialAPIOutboundMaxBytes + } + if c.config.MediaMaxBytes > localAPIDefaultMaxBytes { + return c.config.MediaMaxBytes + } + return localAPIDefaultMaxBytes +} + // sendHTML sends a single HTML message, falling back to plain text if Telegram rejects the HTML. // replyTo and threadID are optional (0 = omit). General topic (1) is handled by resolveThreadIDForSend. func (c *Channel) sendHTML(ctx context.Context, chatID int64, htmlContent string, replyTo, threadID int) error { diff --git a/internal/channels/telegram/send_media_limit_test.go b/internal/channels/telegram/send_media_limit_test.go new file mode 100644 index 00000000..de4ff4d0 --- /dev/null +++ b/internal/channels/telegram/send_media_limit_test.go @@ -0,0 +1,65 @@ +package telegram + +import ( + "os" + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +func TestOutboundMediaMaxBytes_OfficialAPIUsesUploadLimit(t *testing.T) { + ch := &Channel{config: config.TelegramConfig{MediaMaxBytes: defaultMediaMaxBytes}} + + if got := ch.outboundMediaMaxBytes(); got != officialAPIOutboundMaxBytes { + t.Fatalf("outboundMediaMaxBytes() = %d, want %d", got, officialAPIOutboundMaxBytes) + } +} + +func TestValidateOutboundMediaSize_AllowsVideoBelowOfficialUploadLimit(t *testing.T) { + ch := &Channel{config: config.TelegramConfig{MediaMaxBytes: defaultMediaMaxBytes}} + path := sparseFile(t, 43_500_000) + + if err := ch.validateOutboundMediaSize(path); err != nil { + t.Fatalf("validateOutboundMediaSize() returned error for 43.5 MB file: %v", err) + } +} + +func TestValidateOutboundMediaSize_RejectsOfficialAPIUploadAboveLimit(t *testing.T) { + ch := &Channel{config: config.TelegramConfig{MediaMaxBytes: 100 * 1024 * 1024}} + path := sparseFile(t, officialAPIOutboundMaxBytes+1) + + err := ch.validateOutboundMediaSize(path) + if err == nil { + t.Fatal("validateOutboundMediaSize() returned nil, want size error") + } + if !strings.Contains(err.Error(), "outbound media too large for Telegram upload") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateOutboundMediaSize_LocalAPIServerAllowsLargerUploads(t *testing.T) { + ch := &Channel{config: config.TelegramConfig{APIServer: "http://127.0.0.1:8081"}} + path := sparseFile(t, 150*1024*1024) + + if err := ch.validateOutboundMediaSize(path); err != nil { + t.Fatalf("validateOutboundMediaSize() returned error for local API upload: %v", err) + } +} + +func sparseFile(t *testing.T, size int64) string { + t.Helper() + + file, err := os.CreateTemp(t.TempDir(), "telegram-media-*") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + path := file.Name() + if err := file.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := os.Truncate(path, size); err != nil { + t.Fatalf("Truncate: %v", err) + } + return path +} diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 9cd8167e..a125d50e 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -370,7 +370,7 @@ type GatewayConfig struct { MaxMessageChars int `json:"max_message_chars,omitempty"` // max user message characters (default 32000) RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled) InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off" - InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid channel/Web Chat messages from same sender/session (default 1000ms, -1 = disabled) + InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid channel/Web Chat messages from same sender/session (0 = no wait) Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false) ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true) @@ -435,6 +435,7 @@ type BrowserToolConfig struct { ActionTimeoutMs int `json:"action_timeout_ms,omitempty"` // per-action timeout in ms (default 30000) IdleTimeoutMs int `json:"idle_timeout_ms,omitempty"` // idle page auto-close in ms (default 600000, 0=disabled) MaxPages int `json:"max_pages,omitempty"` // max open pages per tenant (default 5) + CookieSyncEnabled bool `json:"cookie_sync_enabled"` // apply selected synced cookies to scoped browser sessions } // ToolPolicySpec defines a tool policy at any level (global, per-agent, per-provider). diff --git a/internal/config/config_extras_test.go b/internal/config/config_extras_test.go index 6b9f2b17..6b2bc150 100644 --- a/internal/config/config_extras_test.go +++ b/internal/config/config_extras_test.go @@ -275,11 +275,14 @@ func TestCronConfig_ToRetryConfig_Custom(t *testing.T) { func TestApplySystemConfigs(t *testing.T) { cfg := Default() cfg.ApplySystemConfigs(map[string]string{ - "agent.default_provider": "openai", - "agent.default_model": "gpt-4o", - "agent.context_window": "100000", - "gateway.rate_limit_rpm": "60", + "agent.default_provider": "openai", + "agent.default_model": "gpt-4o", + "agent.context_window": "100000", + "gateway.rate_limit_rpm": "60", "gateway.max_message_chars": "50000", + "tools.browser.enabled": "false", + "tools.browser.remote_url": "ws://chrome:9222", + "tools.browser.max_pages": "9", }) if cfg.Agents.Defaults.Provider != "openai" { @@ -294,6 +297,15 @@ func TestApplySystemConfigs(t *testing.T) { if cfg.Gateway.RateLimitRPM != 60 { t.Errorf("rate_limit_rpm: got %d", cfg.Gateway.RateLimitRPM) } + if cfg.Tools.Browser.Enabled { + t.Error("tools.browser.enabled: got true, want false") + } + if cfg.Tools.Browser.RemoteURL != "ws://chrome:9222" { + t.Errorf("tools.browser.remote_url: got %q", cfg.Tools.Browser.RemoteURL) + } + if cfg.Tools.Browser.MaxPages != 9 { + t.Errorf("tools.browser.max_pages: got %d", cfg.Tools.Browser.MaxPages) + } } // --- Save / Load round-trip --- diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 83afe346..205dd511 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -97,8 +97,9 @@ func Default() *Config { }, Tools: ToolsConfig{ Browser: BrowserToolConfig{ - Enabled: true, - Headless: true, + Enabled: true, + Headless: true, + CookieSyncEnabled: true, }, ExecApproval: ExecApprovalCfg{ Security: "full", diff --git a/internal/config/config_system.go b/internal/config/config_system.go index 3d225ece..4789327c 100644 --- a/internal/config/config_system.go +++ b/internal/config/config_system.go @@ -61,6 +61,18 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) { str("tools.profile", &c.Tools.Profile) integer("tools.rate_limit_per_hour", &c.Tools.RateLimitPerHour) boolean("tools.scrub_credentials", &c.Tools.ScrubCredentials) + boolValue := func(key string, dst *bool) { + if v, ok := configs[key]; ok && v != "" { + *dst = v == "true" || v == "1" + } + } + boolValue("tools.browser.enabled", &c.Tools.Browser.Enabled) + boolValue("tools.browser.headless", &c.Tools.Browser.Headless) + str("tools.browser.remote_url", &c.Tools.Browser.RemoteURL) + integer("tools.browser.action_timeout_ms", &c.Tools.Browser.ActionTimeoutMs) + integer("tools.browser.idle_timeout_ms", &c.Tools.Browser.IdleTimeoutMs) + integer("tools.browser.max_pages", &c.Tools.Browser.MaxPages) + boolValue("tools.browser.cookie_sync_enabled", &c.Tools.Browser.CookieSyncEnabled) // TTS str("tts.provider", &c.Tts.Provider) diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index aa0e98cc..3e02e70e 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -223,7 +223,7 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re m.dispatchChatSends(append(pending, item)) return } - if delay := chatDebounceDelay(m.cfg); delay > 0 { + if delay := chatDebounceDelay(m.cfg, loop.OtherConfig()); delay > 0 { m.debouncer.Push(debounceKey, delay, item) return } diff --git a/internal/gateway/methods/chat_debounce.go b/internal/gateway/methods/chat_debounce.go index 8a62bf3e..513a405c 100644 --- a/internal/gateway/methods/chat_debounce.go +++ b/internal/gateway/methods/chat_debounce.go @@ -2,6 +2,7 @@ package methods import ( "context" + "encoding/json" "strings" "sync" "time" @@ -9,6 +10,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/store" ) type chatSendRequest struct { @@ -127,13 +129,16 @@ func mergeChatSendRequests(items []chatSendRequest) chatSendParams { return last } -func chatDebounceDelay(cfg *config.Config) time.Duration { +func chatDebounceDelay(cfg *config.Config, agentOtherConfig json.RawMessage) time.Duration { debounceMs := 0 if cfg != nil { debounceMs = cfg.Gateway.InboundDebounceMs } - if debounceMs == 0 { - debounceMs = 1000 + if overrideMs, ok := store.ParseInboundDebounceMsFromOtherConfig(agentOtherConfig); ok { + debounceMs = overrideMs + } + if debounceMs <= 0 { + return 0 } return time.Duration(debounceMs) * time.Millisecond } diff --git a/internal/gateway/methods/chat_debounce_test.go b/internal/gateway/methods/chat_debounce_test.go index 1644e0dd..47226f10 100644 --- a/internal/gateway/methods/chat_debounce_test.go +++ b/internal/gateway/methods/chat_debounce_test.go @@ -71,18 +71,23 @@ func TestChatDebouncerDiscardDropsPendingBeforeCancel(t *testing.T) { assertNoChatDebounceFlush(t, out) } -func TestChatDebounceDelayDefaultAndDisabled(t *testing.T) { - if got := chatDebounceDelay(&config.Config{}); got != time.Second { - t.Fatalf("default debounce = %s, want 1s", got) +func TestChatDebounceDelayGlobalAndAgentOverride(t *testing.T) { + if got := chatDebounceDelay(&config.Config{}, nil); got != 0 { + t.Fatalf("default debounce = %s, want disabled", got) } cfg := &config.Config{} - cfg.Gateway.InboundDebounceMs = -1 - if got := chatDebounceDelay(cfg); got >= 0 { - t.Fatalf("disabled debounce = %s, want negative duration", got) - } cfg.Gateway.InboundDebounceMs = 250 - if got := chatDebounceDelay(cfg); got != 250*time.Millisecond { - t.Fatalf("custom debounce = %s, want 250ms", got) + if got := chatDebounceDelay(cfg, nil); got != 250*time.Millisecond { + t.Fatalf("global debounce = %s, want 250ms", got) + } + if got := chatDebounceDelay(cfg, []byte(`{"inbound_debounce_ms":0}`)); got != 0 { + t.Fatalf("agent disabled debounce = %s, want disabled", got) + } + if got := chatDebounceDelay(cfg, []byte(`{"inbound_debounce_ms":500}`)); got != 500*time.Millisecond { + t.Fatalf("agent custom debounce = %s, want 500ms", got) + } + if got := chatDebounceDelay(cfg, []byte(`{"other":true}`)); got != 250*time.Millisecond { + t.Fatalf("agent inherit debounce = %s, want 250ms", got) } } diff --git a/internal/gateway/methods/config_patch_test.go b/internal/gateway/methods/config_patch_test.go new file mode 100644 index 00000000..cd86e722 --- /dev/null +++ b/internal/gateway/methods/config_patch_test.go @@ -0,0 +1,73 @@ +package methods + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +func TestConfigPatchPersistsInboundDebounceMs(t *testing.T) { + t.Parallel() + + cfg := config.Default() + cfgPath := filepath.Join(t.TempDir(), "config.json") + methods := NewConfigMethods(cfg, cfgPath, nil, nil) + client, responses := gateway.NewCapturingTestClient(permissions.RoleOwner, store.MasterTenantID, "owner", 1) + params, err := json.Marshal(map[string]string{ + "raw": `{"gateway":{"inbound_debounce_ms":500}}`, + }) + if err != nil { + t.Fatal(err) + } + + methods.handlePatch( + store.WithTenantID(context.Background(), store.MasterTenantID), + client, + &protocol.RequestFrame{ + Type: protocol.FrameTypeRequest, + ID: "patch-inbound-debounce", + Method: protocol.MethodConfigPatch, + Params: params, + }, + ) + + res := readConfigPatchResponse(t, responses) + if !res.OK { + t.Fatalf("config.patch failed: %#v", res.Error) + } + if cfg.Gateway.InboundDebounceMs != 500 { + t.Fatalf("in-memory inbound_debounce_ms = %d, want 500", cfg.Gateway.InboundDebounceMs) + } + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte(`"inbound_debounce_ms": 500`)) { + t.Fatalf("saved config missing inbound_debounce_ms=500:\n%s", data) + } +} + +func readConfigPatchResponse(t *testing.T, responses <-chan []byte) protocol.ResponseFrame { + t.Helper() + select { + case raw := <-responses: + var res protocol.ResponseFrame + if err := json.Unmarshal(raw, &res); err != nil { + t.Fatal(err) + } + return res + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for config.patch response") + return protocol.ResponseFrame{} + } +} diff --git a/internal/gateway/methods/cron.go b/internal/gateway/methods/cron.go index 1061f568..1eab7dd9 100644 --- a/internal/gateway/methods/cron.go +++ b/internal/gateway/methods/cron.go @@ -52,6 +52,7 @@ func (m *CronMethods) handleList(ctx context.Context, client *gateway.Client, re userID = client.UserID() } jobs := m.service.ListJobs(ctx, params.IncludeDisabled, "", userID) + jobs = store.RedactCronJobsCredentialContext(jobs) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "jobs": jobs, @@ -112,7 +113,7 @@ func (m *CronMethods) handleCreate(ctx context.Context, client *gateway.Client, } client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ - "job": job, + "job": store.RedactCronJobCredentialContext(*job), })) emitAudit(m.eventBus, client, "cron.created", "cron", job.ID) } @@ -131,14 +132,17 @@ func (m *CronMethods) handleDelete(ctx context.Context, client *gateway.Client, return } + job, ok := m.service.GetJob(ctx, params.JobID) + if !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgJobNotFound))) + return + } if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { - job, ok := m.service.GetJob(ctx, params.JobID) - if !ok || job.UserID != client.UserID() { + if job.UserID != client.UserID() { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) return } } - if err := m.service.RemoveJob(ctx, params.JobID); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return @@ -165,13 +169,23 @@ func (m *CronMethods) handleToggle(ctx context.Context, client *gateway.Client, return } + job, ok := m.service.GetJob(ctx, params.JobID) + if !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgJobNotFound))) + return + } if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { - job, ok := m.service.GetJob(ctx, params.JobID) - if !ok || job.UserID != client.UserID() { + if job.UserID != client.UserID() { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) return } } + if params.Enabled { + if err := store.CheckCronCredentialOwner(ctx, job); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job credential context"))) + return + } + } if err := m.service.EnableJob(ctx, params.JobID, params.Enabled); err != nil { code := protocol.ErrInvalidRequest @@ -213,13 +227,21 @@ func (m *CronMethods) handleUpdate(ctx context.Context, client *gateway.Client, return } + existing, ok := m.service.GetJob(ctx, jobID) + if !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgJobNotFound))) + return + } if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { - existing, ok := m.service.GetJob(ctx, jobID) - if !ok || existing.UserID != client.UserID() { + if existing.UserID != client.UserID() { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) return } } + if err := store.CheckCronCredentialOwner(ctx, existing); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job credential context"))) + return + } job, err := m.service.UpdateJob(ctx, jobID, params.Patch) if err != nil { @@ -232,7 +254,7 @@ func (m *CronMethods) handleUpdate(ctx context.Context, client *gateway.Client, } client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ - "job": job, + "job": store.RedactCronJobCredentialContext(*job), })) emitAudit(m.eventBus, client, "cron.updated", "cron", jobID) } @@ -272,6 +294,10 @@ func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req return } } + if err := store.CheckCronCredentialOwner(ctx, job); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job credential context"))) + return + } // Respond immediately — job execution happens in background client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ diff --git a/internal/gateway/methods/cron_test.go b/internal/gateway/methods/cron_test.go index 2bc1da83..4e5315c9 100644 --- a/internal/gateway/methods/cron_test.go +++ b/internal/gateway/methods/cron_test.go @@ -14,12 +14,15 @@ import ( // ---- stub CronStore ---- type stubCronStore struct { - jobs map[string]*store.CronJob - addErr error - removeErr error - updateErr error - enableErr error - addedJob *store.CronJob + jobs map[string]*store.CronJob + addErr error + removeErr error + updateErr error + enableErr error + addedJob *store.CronJob + updateCnt int + enableCnt int + runCnt int } func newStubCronStore() *stubCronStore { @@ -36,10 +39,10 @@ func (s *stubCronStore) AddJob(_ context.Context, name string, schedule store.Cr return nil, s.addErr } job := &store.CronJob{ - ID: "new-job-id", - Name: name, - UserID: userID, - Enabled: true, + ID: "new-job-id", + Name: name, + UserID: userID, + Enabled: true, Schedule: schedule, } s.addedJob = job @@ -71,6 +74,7 @@ func (s *stubCronStore) RemoveJob(_ context.Context, jobID string) error { } func (s *stubCronStore) UpdateJob(_ context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) { + s.updateCnt++ if s.updateErr != nil { return nil, s.updateErr } @@ -82,6 +86,7 @@ func (s *stubCronStore) UpdateJob(_ context.Context, jobID string, patch store.C } func (s *stubCronStore) EnableJob(_ context.Context, jobID string, enabled bool) error { + s.enableCnt++ if s.enableErr != nil { return s.enableErr } @@ -98,15 +103,16 @@ func (s *stubCronStore) GetRunLog(_ context.Context, _ string, _, _ int) ([]stor func (s *stubCronStore) Status() map[string]any { return map[string]any{"running": true} } // Lifecycle stubs (not called in unit tests) -func (s *stubCronStore) Start() error { return nil } -func (s *stubCronStore) Stop() {} +func (s *stubCronStore) Start() error { return nil } +func (s *stubCronStore) Stop() {} func (s *stubCronStore) SetOnJob(_ func(*store.CronJob) (*store.CronJobResult, error)) {} -func (s *stubCronStore) SetOnEvent(_ func(store.CronEvent)) {} +func (s *stubCronStore) SetOnEvent(_ func(store.CronEvent)) {} func (s *stubCronStore) RunJob(_ context.Context, _ string, _ bool) (bool, string, error) { + s.runCnt++ return true, "", nil } -func (s *stubCronStore) SetDefaultTimezone(_ string) {} -func (s *stubCronStore) GetDueJobs(_ time.Time) []store.CronJob { return nil } +func (s *stubCronStore) SetDefaultTimezone(_ string) {} +func (s *stubCronStore) GetDueJobs(_ time.Time) []store.CronJob { return nil } // ---- helpers ---- @@ -160,7 +166,7 @@ func TestCronCreate_MissingName_ReturnsInvalidRequest(t *testing.T) { m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{ - "message": "hello", + "message": "hello", "schedule": map[string]any{"kind": "every", "everyMs": 60000}, }) m.handleCreate(context.Background(), client, req) @@ -199,8 +205,8 @@ func TestCronCreate_ValidParams_CreatesJob(t *testing.T) { m := buildCronMethods(t, svc) client := nullClient() req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{ - "name": "my-daily-job", - "message": "do the thing", + "name": "my-daily-job", + "message": "do the thing", "schedule": map[string]any{"kind": "every", "everyMs": 3600000}, }) m.handleCreate(context.Background(), client, req) @@ -259,3 +265,90 @@ func TestCronToggle_MissingJobID_ReturnsInvalidRequest(t *testing.T) { m.handleToggle(context.Background(), client, req) // No panic = jobId-required error path hit } + +func TestCronToggle_BlocksCredentialBoundEnableByDifferentUser(t *testing.T) { + svc := newStubCronStore() + svc.jobs["job-1"] = &store.CronJob{ + ID: "job-1", + UserID: "", + Payload: store.CronPayload{ + CredentialUserID: "tenant-user-a", + }, + } + m := buildCronMethods(t, svc) + client := nullClient() + ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b") + + req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"jobId": "job-1", "enabled": true}) + m.handleToggle(ctx, client, req) + + if svc.enableCnt != 0 { + t.Fatalf("EnableJob called %d times, want 0", svc.enableCnt) + } +} + +func TestCronToggle_AllowsCredentialBoundDisableByDifferentUser(t *testing.T) { + svc := newStubCronStore() + svc.jobs["job-1"] = &store.CronJob{ + ID: "job-1", + UserID: "", + Payload: store.CronPayload{ + CredentialUserID: "tenant-user-a", + }, + } + m := buildCronMethods(t, svc) + client := nullClient() + ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b") + + req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"jobId": "job-1", "enabled": false}) + m.handleToggle(ctx, client, req) + + if svc.enableCnt != 1 { + t.Fatalf("EnableJob called %d times, want 1", svc.enableCnt) + } +} + +func TestCronUpdate_BlocksCredentialBoundJobByDifferentUser(t *testing.T) { + svc := newStubCronStore() + svc.jobs["job-1"] = &store.CronJob{ + ID: "job-1", + UserID: "", + Payload: store.CronPayload{ + CredentialUserID: "tenant-user-a", + }, + } + m := buildCronMethods(t, svc) + client := nullClient() + ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b") + + req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{ + "jobId": "job-1", + "patch": map[string]any{"message": "run gh issue list"}, + }) + m.handleUpdate(ctx, client, req) + + if svc.updateCnt != 0 { + t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt) + } +} + +func TestCronRun_BlocksCredentialBoundJobByDifferentUser(t *testing.T) { + svc := newStubCronStore() + svc.jobs["job-1"] = &store.CronJob{ + ID: "job-1", + UserID: "", + Payload: store.CronPayload{ + CredentialUserID: "tenant-user-a", + }, + } + m := buildCronMethods(t, svc) + client := nullClient() + ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b") + + req := cronReqFrame(t, protocol.MethodCronRun, map[string]any{"jobId": "job-1", "mode": "force"}) + m.handleRun(ctx, client, req) + + if svc.runCnt != 0 { + t.Fatalf("RunJob called %d times, want 0", svc.runCnt) + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index db0d617d..6cb1b0cf 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -515,6 +515,11 @@ func (s *Server) SetSecureCLIGrantHandler(h *httpapi.SecureCLIGrantHandler) { s.handlers = append(s.handlers, h) } +// SetBrowserCookiesHandler sets the selected browser-cookie sync handler. +func (s *Server) SetBrowserCookiesHandler(h *httpapi.BrowserCookiesHandler) { + s.handlers = append(s.handlers, h) +} + // SetPackagesHandler sets the runtime package management handler. func (s *Server) SetPackagesHandler(h *httpapi.PackagesHandler) { s.handlers = append(s.handlers, h) diff --git a/internal/http/browser_cookies.go b/internal/http/browser_cookies.go new file mode 100644 index 00000000..03b9f6f8 --- /dev/null +++ b/internal/http/browser_cookies.go @@ -0,0 +1,185 @@ +package http + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const ( + maxBrowserCookieSyncBodyBytes = 1 << 20 + maxBrowserCookieSyncItems = 200 + maxBrowserCookieValueBytes = 16 << 10 +) + +var ( + errBrowserCookieInvalidURL = errors.New("invalid cookie url") + errBrowserCookieValueTooLarge = errors.New("cookie value too large") + errBrowserCookieTooManyCookies = errors.New("too many cookies") +) + +// BrowserCookiesHandler stores selected browser cookies for server-side browser sessions. +type BrowserCookiesHandler struct { + cookies store.BrowserCookieStore +} + +func NewBrowserCookiesHandler(cookies store.BrowserCookieStore) *BrowserCookiesHandler { + return &BrowserCookiesHandler{cookies: cookies} +} + +func (h *BrowserCookiesHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /v1/browser/cookies/sync", requireAuth(permissions.RoleOperator, h.handleSync)) + mux.HandleFunc("GET /v1/browser/cookies", requireAuth(permissions.RoleOperator, h.handleList)) + mux.HandleFunc("DELETE /v1/browser/cookies", requireAuth(permissions.RoleOperator, h.handleDelete)) +} + +func (h *BrowserCookiesHandler) handleSync(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + var req browserCookieSyncRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBrowserCookieSyncBodyBytes)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + agentID := firstBrowserCookieNonEmpty(req.AgentID, req.Agent) + scope, ok := h.scopeFromRequest(w, r, agentID) + if !ok { + return + } + if len(req.Cookies) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "cookies")}) + return + } + if len(req.Cookies) > maxBrowserCookieSyncItems { + h.writeValidationError(w, locale, errBrowserCookieTooManyCookies) + return + } + + source := strings.TrimSpace(req.Source) + if source == "" { + source = "chrome-extension" + } + cookies := make([]store.BrowserCookie, 0, len(req.Cookies)) + for _, item := range req.Cookies { + c, err := item.toStoreCookie(source) + if err != nil { + h.writeValidationError(w, locale, err) + return + } + cookies = append(cookies, c) + } + + count, err := h.cookies.Upsert(r.Context(), scope, cookies) + if err != nil { + h.writeStoreError(w, locale, "browser_cookie_sync.upsert", err) + return + } + slog.Info("browser_cookie_sync.synced", + "tenant_id", scope.TenantID, + "user_id", scope.UserID, + "agent_id", scope.AgentID, + "count", count, + "source", source, + ) + writeJSON(w, http.StatusOK, map[string]any{"synced": count}) +} + +func (h *BrowserCookiesHandler) handleList(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + scope, ok := h.scopeFromRequest(w, r, r.URL.Query().Get("agent_id")) + if !ok { + return + } + cookies, err := h.cookies.List(r.Context(), scope, browserCookieFilterFromQuery(r)) + if err != nil { + h.writeStoreError(w, locale, "browser_cookie_sync.list", err) + return + } + items := make([]browserCookieMetadata, 0, len(cookies)) + for _, c := range cookies { + items = append(items, browserCookieMetadata{ + Domain: c.Domain, + Name: c.Name, + Path: c.Path, + Secure: c.Secure, + HTTPOnly: c.HTTPOnly, + SameSite: c.SameSite, + ExpiresAt: c.ExpiresAt, + Source: c.Source, + UpdatedAt: c.UpdatedAt, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (h *BrowserCookiesHandler) handleDelete(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + scope, ok := h.scopeFromRequest(w, r, r.URL.Query().Get("agent_id")) + if !ok { + return + } + deleted, err := h.cookies.Delete(r.Context(), scope, browserCookieFilterFromQuery(r)) + if err != nil { + h.writeStoreError(w, locale, "browser_cookie_sync.delete", err) + return + } + slog.Info("browser_cookie_sync.deleted", + "tenant_id", scope.TenantID, + "user_id", scope.UserID, + "agent_id", scope.AgentID, + "count", deleted, + ) + writeJSON(w, http.StatusOK, map[string]any{"deleted": deleted}) +} + +func (h *BrowserCookiesHandler) scopeFromRequest(w http.ResponseWriter, r *http.Request, agentID string) (store.BrowserCookieScope, bool) { + locale := store.LocaleFromContext(r.Context()) + scope := store.BrowserCookieScopeFromContext(r.Context(), agentID) + if err := scope.Validate(); err != nil { + slog.Warn("security.browser_cookie_sync.scope_denied", "error", err, "path", r.URL.Path) + h.writeValidationError(w, locale, err) + return store.BrowserCookieScope{}, false + } + return scope, true +} + +func (h *BrowserCookiesHandler) writeValidationError(w http.ResponseWriter, locale string, err error) { + msg := i18n.T(locale, i18n.MsgInvalidRequest, "browser cookies") + switch { + case errors.Is(err, store.ErrBrowserCookieTenantRequired): + msg = i18n.T(locale, i18n.MsgRequired, "tenant_id") + case errors.Is(err, store.ErrBrowserCookieUserRequired): + msg = i18n.T(locale, i18n.MsgRequired, "user_id") + case errors.Is(err, store.ErrBrowserCookieAgentRequired): + msg = i18n.T(locale, i18n.MsgRequired, "agent_id") + case errors.Is(err, store.ErrBrowserCookieDomainRequired): + msg = i18n.T(locale, i18n.MsgRequired, "domain") + case errors.Is(err, store.ErrBrowserCookieNameRequired): + msg = i18n.T(locale, i18n.MsgRequired, "name") + case errors.Is(err, store.ErrBrowserCookiePathRequired): + msg = i18n.T(locale, i18n.MsgRequired, "path") + case errors.Is(err, errBrowserCookieInvalidURL): + msg = i18n.T(locale, i18n.MsgInvalidCookieURL) + case errors.Is(err, errBrowserCookieValueTooLarge): + msg = i18n.T(locale, i18n.MsgBrowserCookieValueTooLarge) + case errors.Is(err, errBrowserCookieTooManyCookies): + msg = i18n.T(locale, i18n.MsgBrowserCookieTooMany) + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) +} + +func (h *BrowserCookiesHandler) writeStoreError(w http.ResponseWriter, locale, op string, err error) { + if errors.Is(err, store.ErrBrowserCookieEncryptionRequired) { + slog.Warn("security.browser_cookie_sync.encryption_required", "op", op) + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgBrowserCookieEncryptionRequired)}) + return + } + slog.Error(op, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "browser cookies")}) +} diff --git a/internal/http/browser_cookies_payload.go b/internal/http/browser_cookies_payload.go new file mode 100644 index 00000000..c31b294b --- /dev/null +++ b/internal/http/browser_cookies_payload.go @@ -0,0 +1,113 @@ +package http + +import ( + "net/http" + "net/url" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type browserCookieSyncRequest struct { + AgentID string `json:"agent_id"` + Agent string `json:"agent,omitempty"` + Source string `json:"source,omitempty"` + Cookies []browserCookieSyncPayload `json:"cookies"` +} + +type browserCookieSyncPayload struct { + Domain string `json:"domain"` + Name string `json:"name"` + Path string `json:"path"` + Value string `json:"value"` + URL string `json:"url,omitempty"` + Secure bool `json:"secure"` + HTTPOnly bool `json:"httpOnly"` + HTTPOnlySnake bool `json:"http_only"` + SameSite string `json:"sameSite"` + SameSiteSnake string `json:"same_site"` + ExpiresAt *time.Time `json:"expiresAt"` + ExpiresAtSnake *time.Time `json:"expires_at"` + ExpirationDate *float64 `json:"expirationDate"` + ExpirationDateSnake *float64 `json:"expiration_date"` +} + +type browserCookieMetadata struct { + Domain string `json:"domain"` + Name string `json:"name"` + Path string `json:"path"` + Secure bool `json:"secure"` + HTTPOnly bool `json:"httpOnly"` + SameSite string `json:"sameSite,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Source string `json:"source,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (p browserCookieSyncPayload) toStoreCookie(source string) (store.BrowserCookie, error) { + domain := strings.TrimSpace(p.Domain) + if domain == "" && p.URL != "" { + u, err := url.Parse(p.URL) + if err != nil { + return store.BrowserCookie{}, errBrowserCookieInvalidURL + } + domain = u.Hostname() + } + if len(p.Value) > maxBrowserCookieValueBytes { + return store.BrowserCookie{}, errBrowserCookieValueTooLarge + } + c := store.NormalizeBrowserCookie(store.BrowserCookie{ + Domain: domain, + Name: p.Name, + Path: p.Path, + Value: p.Value, + Secure: p.Secure, + HTTPOnly: p.HTTPOnly || p.HTTPOnlySnake, + SameSite: firstBrowserCookieNonEmpty(p.SameSite, p.SameSiteSnake), + ExpiresAt: firstTime(p.ExpiresAt, p.ExpiresAtSnake, timeFromUnixSeconds(p.ExpirationDate), timeFromUnixSeconds(p.ExpirationDateSnake)), + Source: source, + }) + if err := store.ValidateBrowserCookie(c); err != nil { + return store.BrowserCookie{}, err + } + return c, nil +} + +func browserCookieFilterFromQuery(r *http.Request) store.BrowserCookieFilter { + q := r.URL.Query() + return store.BrowserCookieFilter{ + Domain: q.Get("domain"), + Name: q.Get("name"), + Path: q.Get("path"), + } +} + +func firstBrowserCookieNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func firstTime(values ...*time.Time) *time.Time { + for _, v := range values { + if v != nil { + t := v.UTC() + return &t + } + } + return nil +} + +func timeFromUnixSeconds(v *float64) *time.Time { + if v == nil { + return nil + } + sec := int64(*v) + nsec := int64((*v - float64(sec)) * 1e9) + t := time.Unix(sec, nsec).UTC() + return &t +} diff --git a/internal/http/browser_cookies_test.go b/internal/http/browser_cookies_test.go new file mode 100644 index 00000000..ef09170b --- /dev/null +++ b/internal/http/browser_cookies_test.go @@ -0,0 +1,209 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + nethttp "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type fakeBrowserCookieStore struct { + upsertScope store.BrowserCookieScope + upsertItems []store.BrowserCookie + listItems []store.BrowserCookie + deleteScope store.BrowserCookieScope + deleteCount int + err error +} + +func (f *fakeBrowserCookieStore) Upsert(_ context.Context, scope store.BrowserCookieScope, cookies []store.BrowserCookie) (int, error) { + f.upsertScope = scope + f.upsertItems = append([]store.BrowserCookie(nil), cookies...) + if f.err != nil { + return 0, f.err + } + return len(cookies), nil +} + +func (f *fakeBrowserCookieStore) List(_ context.Context, scope store.BrowserCookieScope, _ store.BrowserCookieFilter) ([]store.BrowserCookie, error) { + if f.err != nil { + return nil, f.err + } + out := make([]store.BrowserCookie, 0, len(f.listItems)) + for _, c := range f.listItems { + if c.TenantID == scope.TenantID && c.UserID == scope.UserID && c.AgentID == scope.AgentID { + out = append(out, c) + } + } + return out, nil +} + +func (f *fakeBrowserCookieStore) Delete(_ context.Context, scope store.BrowserCookieScope, _ store.BrowserCookieFilter) (int, error) { + f.deleteScope = scope + if f.err != nil { + return 0, f.err + } + return f.deleteCount, nil +} + +func browserCookieRequestContext() context.Context { + ctx := store.WithTenantID(context.Background(), store.MasterTenantID) + ctx = store.WithUserID(ctx, "user-a") + ctx = store.WithRole(ctx, "operator") + return ctx +} + +func TestBrowserCookiesHandlerSyncUsesAuthScopeAndNormalizesPayload(t *testing.T) { + fake := &fakeBrowserCookieStore{} + handler := NewBrowserCookiesHandler(fake) + body := strings.NewReader(`{ + "agent_id":"agent-a", + "user_id":"malicious-user", + "cookies":[{ + "url":"https://example.com/app", + "name":"session", + "value":"secret-cookie", + "httpOnly":true, + "sameSite":"Lax" + }] + }`) + req := httptest.NewRequest(nethttp.MethodPost, "/v1/browser/cookies/sync", body).WithContext(browserCookieRequestContext()) + rec := httptest.NewRecorder() + + handler.handleSync(rec, req) + + if rec.Code != nethttp.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if fake.upsertScope.UserID != "user-a" || fake.upsertScope.AgentID != "agent-a" { + t.Fatalf("scope = %+v, want auth user + request agent", fake.upsertScope) + } + if len(fake.upsertItems) != 1 { + t.Fatalf("upsert items = %d, want 1", len(fake.upsertItems)) + } + got := fake.upsertItems[0] + if got.Domain != "example.com" || got.Path != "/" || got.Value != "secret-cookie" || !got.HTTPOnly { + t.Fatalf("normalized cookie mismatch: %+v", got) + } +} + +func TestBrowserCookiesHandlerListNeverReturnsCookieValue(t *testing.T) { + updatedAt := time.Now().UTC() + fake := &fakeBrowserCookieStore{ + listItems: []store.BrowserCookie{{ + TenantID: store.MasterTenantID, + UserID: "user-a", + AgentID: "agent-a", + Domain: "example.com", + Name: "session", + Path: "/", + Value: "secret-cookie", + HTTPOnly: true, + UpdatedAt: updatedAt, + }}, + } + handler := NewBrowserCookiesHandler(fake) + req := httptest.NewRequest(nethttp.MethodGet, "/v1/browser/cookies?agent_id=agent-a", nil).WithContext(browserCookieRequestContext()) + rec := httptest.NewRecorder() + + handler.handleList(rec, req) + + if rec.Code != nethttp.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var body map[string]json.RawMessage + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if bytes.Contains(body["items"], []byte("secret-cookie")) || bytes.Contains(body["items"], []byte("value")) { + t.Fatalf("list leaked cookie value: %s", body["items"]) + } +} + +func TestBrowserCookiesHandlerRequiresUserAndAgentScope(t *testing.T) { + handler := NewBrowserCookiesHandler(&fakeBrowserCookieStore{}) + req := httptest.NewRequest(nethttp.MethodPost, "/v1/browser/cookies/sync", strings.NewReader(`{"cookies":[{"domain":"example.com","name":"session","value":"v"}]}`)) + req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID)) + rec := httptest.NewRecorder() + + handler.handleSync(rec, req) + + if rec.Code != nethttp.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestBrowserCookiesHandlerEncryptionMisconfigIsUnavailable(t *testing.T) { + handler := NewBrowserCookiesHandler(&fakeBrowserCookieStore{err: store.ErrBrowserCookieEncryptionRequired}) + req := httptest.NewRequest(nethttp.MethodPost, "/v1/browser/cookies/sync", strings.NewReader(`{ + "agent_id":"agent-a", + "cookies":[{"domain":"example.com","name":"session","value":"v"}] + }`)).WithContext(browserCookieRequestContext()) + rec := httptest.NewRecorder() + + handler.handleSync(rec, req) + + if rec.Code != nethttp.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} + +func TestBrowserCookiesHandlerRejectsOversizedCookieValue(t *testing.T) { + handler := NewBrowserCookiesHandler(&fakeBrowserCookieStore{}) + tooLarge := strings.Repeat("x", maxBrowserCookieValueBytes+1) + body, _ := json.Marshal(map[string]any{ + "agent_id": "agent-a", + "cookies": []map[string]any{{ + "domain": "example.com", + "name": "session", + "value": tooLarge, + }}, + }) + req := httptest.NewRequest(nethttp.MethodPost, "/v1/browser/cookies/sync", bytes.NewReader(body)).WithContext(browserCookieRequestContext()) + rec := httptest.NewRecorder() + + handler.handleSync(rec, req) + + if rec.Code != nethttp.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if !strings.Contains(rec.Body.String(), "cookie value too large") { + t.Fatalf("body = %s", rec.Body.String()) + } +} + +func TestBrowserCookiesHandlerDeleteUsesScopedStore(t *testing.T) { + fake := &fakeBrowserCookieStore{deleteCount: 1} + handler := NewBrowserCookiesHandler(fake) + req := httptest.NewRequest(nethttp.MethodDelete, "/v1/browser/cookies?agent_id=agent-a&domain=example.com&name=session", nil) + req = req.WithContext(browserCookieRequestContext()) + rec := httptest.NewRecorder() + + handler.handleDelete(rec, req) + + if rec.Code != nethttp.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if fake.deleteScope.UserID != "user-a" || fake.deleteScope.AgentID != "agent-a" { + t.Fatalf("delete scope = %+v", fake.deleteScope) + } +} + +func TestBrowserCookiesHandlerGenericStoreErrorsAreInternal(t *testing.T) { + handler := NewBrowserCookiesHandler(&fakeBrowserCookieStore{err: errors.New("db down")}) + req := httptest.NewRequest(nethttp.MethodGet, "/v1/browser/cookies?agent_id=agent-a", nil).WithContext(browserCookieRequestContext()) + rec := httptest.NewRecorder() + + handler.handleList(rec, req) + + if rec.Code != nethttp.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } +} diff --git a/internal/http/openapi_spec.json b/internal/http/openapi_spec.json index 4ed39720..784119e1 100644 --- a/internal/http/openapi_spec.json +++ b/internal/http/openapi_spec.json @@ -26,6 +26,7 @@ { "name": "MCP Servers", "description": "MCP server configuration and grants" }, { "name": "Custom Tools", "description": "Custom tool definitions" }, { "name": "Built-in Tools", "description": "Built-in tool configuration" }, + { "name": "Browser", "description": "Browser automation cookie sync" }, { "name": "Memory", "description": "Agent memory (pgvector) management" }, { "name": "Knowledge Graph", "description": "Entity knowledge graph" }, { "name": "Channels", "description": "Channel instance management" }, @@ -63,6 +64,90 @@ } } }, + "/v1/browser/cookies/sync": { + "post": { + "tags": ["Browser"], + "summary": "Sync selected browser cookies", + "description": "Stores explicitly selected cookies for the authenticated user and one agent. Cookie values are encrypted at rest and are never returned by list APIs.", + "parameters": [ + { "name": "X-GoClaw-User-Id", "in": "header", "required": true, "schema": { "type": "string" }, "description": "Authenticated external user ID for cookie scope" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["agent_id", "cookies"], + "properties": { + "agent_id": { "type": "string" }, + "source": { "type": "string", "example": "chrome-selected-cookie-sync" }, + "cookies": { + "type": "array", + "maxItems": 200, + "items": { + "type": "object", + "required": ["name", "value"], + "properties": { + "domain": { "type": "string", "example": ".example.com" }, + "url": { "type": "string", "example": "https://example.com/app" }, + "name": { "type": "string" }, + "path": { "type": "string", "example": "/" }, + "value": { "type": "string" }, + "secure": { "type": "boolean" }, + "httpOnly": { "type": "boolean" }, + "sameSite": { "type": "string" }, + "expiresAt": { "type": "string", "format": "date-time" }, + "expirationDate": { "type": "number", "description": "Unix seconds as returned by chrome.cookies" } + } + } + } + } + } + } + } + }, + "responses": { + "200": { "description": "Cookies synced", "content": { "application/json": { "schema": { "type": "object", "properties": { "synced": { "type": "integer" } } } } } }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "503": { "description": "Cookie encryption is not configured" } + } + } + }, + "/v1/browser/cookies": { + "get": { + "tags": ["Browser"], + "summary": "List synced browser cookie metadata", + "description": "Lists metadata for cookies scoped to the authenticated user and requested agent. Cookie values are redacted.", + "parameters": [ + { "name": "agent_id", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "domain", "in": "query", "schema": { "type": "string" } }, + { "name": "name", "in": "query", "schema": { "type": "string" } }, + { "name": "path", "in": "query", "schema": { "type": "string" } }, + { "name": "X-GoClaw-User-Id", "in": "header", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Cookie metadata", "content": { "application/json": { "schema": { "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object", "properties": { "domain": { "type": "string" }, "name": { "type": "string" }, "path": { "type": "string" }, "secure": { "type": "boolean" }, "httpOnly": { "type": "boolean" }, "sameSite": { "type": "string" }, "expiresAt": { "type": "string", "format": "date-time" }, "source": { "type": "string" }, "updatedAt": { "type": "string", "format": "date-time" } } } } } } } } }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "503": { "description": "Cookie encryption is not configured" } + } + }, + "delete": { + "tags": ["Browser"], + "summary": "Delete synced browser cookies", + "parameters": [ + { "name": "agent_id", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "domain", "in": "query", "schema": { "type": "string" } }, + { "name": "name", "in": "query", "schema": { "type": "string" } }, + { "name": "path", "in": "query", "schema": { "type": "string" } }, + { "name": "X-GoClaw-User-Id", "in": "header", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Cookies deleted", "content": { "application/json": { "schema": { "type": "object", "properties": { "deleted": { "type": "integer" } } } } } }, + "400": { "$ref": "#/components/responses/BadRequest" } + } + } + }, "/v1/chat/completions": { "post": { "tags": ["Chat"], diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index c87a7a03..9ec12d1e 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -77,6 +77,12 @@ func init() { MsgCannotCancel: "agent is not being summoned", MsgInvalidPath: "invalid path", + // Browser cookies + MsgBrowserCookieTooMany: "too many browser cookies in one sync request", + MsgInvalidCookieURL: "invalid cookie URL", + MsgBrowserCookieValueTooLarge: "cookie value too large", + MsgBrowserCookieEncryptionRequired: "browser cookie encryption is not configured", + // Tenant backup / restore MsgRestoreNewModeRejectsTenantID: "mode=new creates a fresh tenant; pass tenant_slug (not tenant_id) as the new tenant's target slug", diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index db80a909..4dc62ea8 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -77,6 +77,12 @@ func init() { MsgCannotCancel: "agent không trong trạng thái đang triệu hồi", MsgInvalidPath: "đường dẫn không hợp lệ", + // Browser cookies + MsgBrowserCookieTooMany: "quá nhiều cookie trình duyệt trong một yêu cầu đồng bộ", + MsgInvalidCookieURL: "URL cookie không hợp lệ", + MsgBrowserCookieValueTooLarge: "giá trị cookie quá lớn", + MsgBrowserCookieEncryptionRequired: "chưa cấu hình mã hoá cookie trình duyệt", + // Tenant backup / restore MsgRestoreNewModeRejectsTenantID: "mode=new tạo tenant mới; dùng tenant_slug (không phải tenant_id) làm slug cho tenant mới", diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index 698ff34c..85d88923 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -77,6 +77,12 @@ func init() { MsgCannotCancel: "Agent 未处于召唤状态", MsgInvalidPath: "路径无效", + // Browser cookies + MsgBrowserCookieTooMany: "单次同步请求中的浏览器 Cookie 过多", + MsgInvalidCookieURL: "Cookie URL 无效", + MsgBrowserCookieValueTooLarge: "Cookie 值过大", + MsgBrowserCookieEncryptionRequired: "尚未配置浏览器 Cookie 加密", + // Tenant backup / restore MsgRestoreNewModeRejectsTenantID: "mode=new 会创建新租户;请传 tenant_slug(而非 tenant_id)作为新租户的 slug", diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index b53a7648..6d31d667 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -78,6 +78,12 @@ const ( MsgCannotCancel = "error.cannot_cancel_summon" // "agent is not being summoned" MsgInvalidPath = "error.invalid_path" // "invalid path" + // --- Browser cookies --- + MsgBrowserCookieTooMany = "error.browser_cookie_too_many" // "too many browser cookies in one sync request" + MsgInvalidCookieURL = "error.invalid_cookie_url" // "invalid cookie URL" + MsgBrowserCookieValueTooLarge = "error.browser_cookie_value_too_large" // "cookie value too large" + MsgBrowserCookieEncryptionRequired = "error.browser_cookie_encryption_required" // "browser cookie encryption is not configured" + // --- Tenant backup / restore --- MsgRestoreNewModeRejectsTenantID = "error.restore_new_mode_rejects_tenant_id" // "mode=new uses tenant_slug; tenant_id is not accepted" diff --git a/internal/media/store.go b/internal/media/store.go index 162871b6..f9cada68 100644 --- a/internal/media/store.go +++ b/internal/media/store.go @@ -109,6 +109,12 @@ func ExtFromMime(mime string) string { return ".wav" case strings.HasPrefix(mime, "application/pdf"): return ".pdf" + case strings.HasPrefix(mime, "application/zip"), strings.HasPrefix(mime, "application/x-zip-compressed"): + return ".zip" + case strings.HasPrefix(mime, "application/x-tar"): + return ".tar" + case strings.HasPrefix(mime, "application/gzip"), strings.HasPrefix(mime, "application/x-gzip"): + return ".gz" case mime == "application/vnd.openxmlformats-officedocument.wordprocessingml.document": return ".docx" case mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": diff --git a/internal/media/store_test.go b/internal/media/store_test.go new file mode 100644 index 00000000..e8d1a12d --- /dev/null +++ b/internal/media/store_test.go @@ -0,0 +1,23 @@ +package media + +import "testing" + +func TestExtFromMimeArchiveTypes(t *testing.T) { + tests := []struct { + mime string + want string + }{ + {mime: "application/zip", want: ".zip"}, + {mime: "application/x-zip-compressed", want: ".zip"}, + {mime: "application/x-tar", want: ".tar"}, + {mime: "application/gzip", want: ".gz"}, + } + + for _, tt := range tests { + t.Run(tt.mime, func(t *testing.T) { + if got := ExtFromMime(tt.mime); got != tt.want { + t.Fatalf("ExtFromMime(%q) = %q, want %q", tt.mime, got, tt.want) + } + }) + } +} diff --git a/internal/store/agent_inbound_debounce_test.go b/internal/store/agent_inbound_debounce_test.go new file mode 100644 index 00000000..20ec30f9 --- /dev/null +++ b/internal/store/agent_inbound_debounce_test.go @@ -0,0 +1,28 @@ +package store + +import "testing" + +func TestParseInboundDebounceMsFromOtherConfig(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + raw []byte + want int + ok bool + }{ + {name: "missing", raw: []byte(`{"prompt_mode":"full"}`), ok: false}, + {name: "zero", raw: []byte(`{"inbound_debounce_ms":0}`), want: 0, ok: true}, + {name: "positive", raw: []byte(`{"inbound_debounce_ms":500}`), want: 500, ok: true}, + {name: "malformed", raw: []byte(`{"inbound_debounce_ms":`), ok: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := ParseInboundDebounceMsFromOtherConfig(tc.raw) + if ok != tc.ok || got != tc.want { + t.Fatalf("ParseInboundDebounceMsFromOtherConfig() = (%d, %t), want (%d, %t)", got, ok, tc.want, tc.ok) + } + }) + } +} diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index a8ed8b04..c4409099 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -253,6 +253,28 @@ func (a *AgentData) ParseAllowImageGeneration() bool { return *bag.AllowImageGeneration } +// ParseInboundDebounceMs returns the per-agent inbound debounce override. +// Missing or malformed config means "inherit global gateway.inbound_debounce_ms". +func (a *AgentData) ParseInboundDebounceMs() (int, bool) { + if a == nil { + return 0, false + } + return ParseInboundDebounceMsFromOtherConfig(a.OtherConfig) +} + +func ParseInboundDebounceMsFromOtherConfig(raw json.RawMessage) (int, bool) { + if len(raw) <= 2 { + return 0, false + } + var bag struct { + InboundDebounceMs *int `json:"inbound_debounce_ms"` + } + if json.Unmarshal(raw, &bag) != nil || bag.InboundDebounceMs == nil { + return 0, false + } + return *bag.InboundDebounceMs, true +} + // validPromptModes is the set of allowed prompt_mode values. var validPromptModes = map[string]bool{ "full": true, "task": true, "minimal": true, "none": true, diff --git a/internal/store/browser_cookie_store.go b/internal/store/browser_cookie_store.go new file mode 100644 index 00000000..cabbf259 --- /dev/null +++ b/internal/store/browser_cookie_store.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +var ( + ErrBrowserCookieEncryptionRequired = errors.New("browser cookie encryption key required") + ErrBrowserCookieTenantRequired = errors.New("browser cookie tenant_id required") + ErrBrowserCookieUserRequired = errors.New("browser cookie user_id required") + ErrBrowserCookieAgentRequired = errors.New("browser cookie agent_id required") + ErrBrowserCookieDomainRequired = errors.New("browser cookie domain required") + ErrBrowserCookieNameRequired = errors.New("browser cookie name required") + ErrBrowserCookiePathRequired = errors.New("browser cookie path required") +) + +// BrowserCookieScope is the tenant/user/agent boundary for synced browser cookies. +type BrowserCookieScope struct { + TenantID uuid.UUID + UserID string + AgentID string +} + +// BrowserCookie stores one selected browser cookie. Value is plaintext only at API +// and browser-application boundaries; stores encrypt it at rest. +type BrowserCookie struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + UserID string `json:"user_id" db:"user_id"` + AgentID string `json:"agent_id" db:"agent_id"` + Domain string `json:"domain" db:"domain"` + Name string `json:"name" db:"name"` + Path string `json:"path" db:"path"` + Value string `json:"-" db:"-"` + Secure bool `json:"secure" db:"secure"` + HTTPOnly bool `json:"http_only" db:"http_only"` + SameSite string `json:"same_site,omitempty" db:"same_site"` + ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` + Source string `json:"source,omitempty" db:"source"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type BrowserCookieFilter struct { + Domain string + Name string + Path string +} + +type BrowserCookieStore interface { + Upsert(ctx context.Context, scope BrowserCookieScope, cookies []BrowserCookie) (int, error) + List(ctx context.Context, scope BrowserCookieScope, filter BrowserCookieFilter) ([]BrowserCookie, error) + Delete(ctx context.Context, scope BrowserCookieScope, filter BrowserCookieFilter) (int, error) +} + +func BrowserCookieScopeFromContext(ctx context.Context, agentID string) BrowserCookieScope { + tid := TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = MasterTenantID + } + return BrowserCookieScope{ + TenantID: tid, + UserID: CredentialUserIDFromContext(ctx), + AgentID: strings.TrimSpace(agentID), + } +} + +func (s BrowserCookieScope) Validate() error { + switch { + case s.TenantID == uuid.Nil: + return ErrBrowserCookieTenantRequired + case strings.TrimSpace(s.UserID) == "": + return ErrBrowserCookieUserRequired + case strings.TrimSpace(s.AgentID) == "": + return ErrBrowserCookieAgentRequired + default: + return nil + } +} + +func NormalizeBrowserCookie(c BrowserCookie) BrowserCookie { + c.Domain = strings.ToLower(strings.TrimSpace(c.Domain)) + c.Name = strings.TrimSpace(c.Name) + c.Path = strings.TrimSpace(c.Path) + c.SameSite = strings.TrimSpace(c.SameSite) + c.Source = strings.TrimSpace(c.Source) + if c.Path == "" { + c.Path = "/" + } + return c +} + +func ValidateBrowserCookie(c BrowserCookie) error { + c = NormalizeBrowserCookie(c) + switch { + case c.Domain == "": + return ErrBrowserCookieDomainRequired + case c.Name == "": + return ErrBrowserCookieNameRequired + case c.Path == "": + return ErrBrowserCookiePathRequired + default: + return nil + } +} diff --git a/internal/store/context.go b/internal/store/context.go index fcc6199b..4b0f3a78 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -116,10 +116,19 @@ func WithCredentialUserID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, CredentialUserIDKey, id) } +// ExplicitCredentialUserIDFromContext returns only the explicitly injected credential identity. +// Unlike CredentialUserIDFromContext, it does not fall back to UserID. +func ExplicitCredentialUserIDFromContext(ctx context.Context) string { + if v, ok := ctx.Value(CredentialUserIDKey).(string); ok { + return v + } + return "" +} + // CredentialUserIDFromContext returns the resolved identity for credential lookups. // Falls back to RunContext.CredentialUserID, then UserIDFromContext. func CredentialUserIDFromContext(ctx context.Context) string { - if v, ok := ctx.Value(CredentialUserIDKey).(string); ok && v != "" { + if v := ExplicitCredentialUserIDFromContext(ctx); v != "" { return v } if rc := RunContextFromCtx(ctx); rc != nil && rc.CredentialUserID != "" { diff --git a/internal/store/cron_store.go b/internal/store/cron_store.go index 6b4e687d..1ac5e651 100644 --- a/internal/store/cron_store.go +++ b/internal/store/cron_store.go @@ -12,8 +12,9 @@ import ( ) var ( - ErrCronJobNotFound = errors.New("cron job not found") - ErrCronJobNoFutureRun = errors.New("cron job has no future run") + ErrCronJobNotFound = errors.New("cron job not found") + ErrCronJobNoFutureRun = errors.New("cron job has no future run") + ErrCronCredentialOwnerMismatch = errors.New("cron credential context belongs to a different user") ) // CronJob represents a scheduled job. @@ -48,9 +49,40 @@ type CronSchedule struct { // CronPayload describes what a job does when triggered. type CronPayload struct { - Kind string `json:"kind" db:"-"` - Message string `json:"message" db:"-"` - Command string `json:"command,omitempty" db:"-"` + Kind string `json:"kind" db:"-"` + Message string `json:"message" db:"-"` + Command string `json:"command,omitempty" db:"-"` + CredentialUserID string `json:"credentialUserId,omitempty" db:"-"` +} + +// CheckCronCredentialOwner blocks user-triggered mutation/execution of a +// credential-bound cron job unless the caller owns that credential context. +func CheckCronCredentialOwner(ctx context.Context, job *CronJob) error { + if job == nil || job.Payload.CredentialUserID == "" { + return nil + } + if ExplicitCredentialUserIDFromContext(ctx) == job.Payload.CredentialUserID { + return nil + } + return ErrCronCredentialOwnerMismatch +} + +// RedactCronJobCredentialContext returns a response-safe copy of a cron job. +func RedactCronJobCredentialContext(job CronJob) CronJob { + job.Payload.CredentialUserID = "" + return job +} + +// RedactCronJobsCredentialContext returns response-safe copies of cron jobs. +func RedactCronJobsCredentialContext(jobs []CronJob) []CronJob { + if len(jobs) == 0 { + return jobs + } + redacted := make([]CronJob, len(jobs)) + for i := range jobs { + redacted[i] = RedactCronJobCredentialContext(jobs[i]) + } + return redacted } // CronJobState tracks runtime state for a job. diff --git a/internal/store/cron_store_test.go b/internal/store/cron_store_test.go index ea21cfd6..92005b62 100644 --- a/internal/store/cron_store_test.go +++ b/internal/store/cron_store_test.go @@ -1,6 +1,8 @@ package store import ( + "context" + "encoding/json" "errors" "testing" "time" @@ -104,6 +106,80 @@ func TestNextRunForToggle_ExpiredAtReturnsError(t *testing.T) { } } +func TestCronPayloadCredentialUserIDJSONRoundTrip(t *testing.T) { + payload := CronPayload{ + Kind: "agent_turn", + Message: "run credentialed report", + CredentialUserID: "tenant-user-123", + } + + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + if !json.Valid(data) { + t.Fatalf("payload JSON is invalid: %s", data) + } + + var got CronPayload + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if got.CredentialUserID != payload.CredentialUserID { + t.Fatalf("credential user ID = %q, want %q", got.CredentialUserID, payload.CredentialUserID) + } +} + +func TestCronPayloadLegacyJSONKeepsEmptyCredentialUserID(t *testing.T) { + var got CronPayload + if err := json.Unmarshal([]byte(`{"kind":"agent_turn","message":"legacy"}`), &got); err != nil { + t.Fatalf("unmarshal legacy payload: %v", err) + } + if got.CredentialUserID != "" { + t.Fatalf("legacy payload credential user ID = %q, want empty", got.CredentialUserID) + } +} + +func TestCheckCronCredentialOwnerBlocksMismatch(t *testing.T) { + ctx := WithCredentialUserID(context.Background(), "tenant-user-b") + job := &CronJob{Payload: CronPayload{CredentialUserID: "tenant-user-a"}} + + if err := CheckCronCredentialOwner(ctx, job); !errors.Is(err, ErrCronCredentialOwnerMismatch) { + t.Fatalf("got %v, want ErrCronCredentialOwnerMismatch", err) + } +} + +func TestCheckCronCredentialOwnerIgnoresUserIDFallback(t *testing.T) { + ctx := WithUserID(context.Background(), "tenant-user-a") + job := &CronJob{Payload: CronPayload{CredentialUserID: "tenant-user-a"}} + + if err := CheckCronCredentialOwner(ctx, job); !errors.Is(err, ErrCronCredentialOwnerMismatch) { + t.Fatalf("got %v, want ErrCronCredentialOwnerMismatch", err) + } +} + +func TestCheckCronCredentialOwnerAllowsMatchAndLegacy(t *testing.T) { + ctx := WithCredentialUserID(context.Background(), "tenant-user-a") + if err := CheckCronCredentialOwner(ctx, &CronJob{Payload: CronPayload{CredentialUserID: "tenant-user-a"}}); err != nil { + t.Fatalf("matching credential owner should pass: %v", err) + } + if err := CheckCronCredentialOwner(context.Background(), &CronJob{}); err != nil { + t.Fatalf("legacy job should pass: %v", err) + } +} + +func TestRedactCronJobCredentialContext(t *testing.T) { + job := CronJob{Payload: CronPayload{CredentialUserID: "tenant-user-a", Message: "run"}} + redacted := RedactCronJobCredentialContext(job) + + if redacted.Payload.CredentialUserID != "" { + t.Fatalf("redacted credential user ID = %q, want empty", redacted.Payload.CredentialUserID) + } + if job.Payload.CredentialUserID == "" { + t.Fatal("redaction mutated source job") + } +} + //go:fix inline func int64Ptr(v int64) *int64 { return new(v) diff --git a/internal/store/pg/browser_cookies.go b/internal/store/pg/browser_cookies.go new file mode 100644 index 00000000..a2e7a5db --- /dev/null +++ b/internal/store/pg/browser_cookies.go @@ -0,0 +1,156 @@ +package pg + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type PGBrowserCookieStore struct { + db *sql.DB + encKey string +} + +func NewPGBrowserCookieStore(db *sql.DB, encKey string) *PGBrowserCookieStore { + return &PGBrowserCookieStore{db: db, encKey: encKey} +} + +func (s *PGBrowserCookieStore) Upsert(ctx context.Context, scope store.BrowserCookieScope, cookies []store.BrowserCookie) (int, error) { + if s.encKey == "" { + return 0, store.ErrBrowserCookieEncryptionRequired + } + if err := scope.Validate(); err != nil { + return 0, err + } + now := time.Now().UTC() + count := 0 + for _, raw := range cookies { + c := store.NormalizeBrowserCookie(raw) + if err := store.ValidateBrowserCookie(c); err != nil { + return count, err + } + encrypted, err := crypto.Encrypt(c.Value, s.encKey) + if err != nil { + return count, fmt.Errorf("encrypt browser cookie: %w", err) + } + id := c.ID + if id == uuid.Nil { + id = store.GenNewID() + } + res, err := s.db.ExecContext(ctx, ` + INSERT INTO browser_cookies ( + id, tenant_id, user_id, agent_id, domain, name, path, encrypted_value, + secure, http_only, same_site, expires_at, source, created_at, updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (tenant_id, user_id, agent_id, domain, path, name) + DO UPDATE SET encrypted_value = EXCLUDED.encrypted_value, + secure = EXCLUDED.secure, + http_only = EXCLUDED.http_only, + same_site = EXCLUDED.same_site, + expires_at = EXCLUDED.expires_at, + source = EXCLUDED.source, + updated_at = EXCLUDED.updated_at`, + id, scope.TenantID, scope.UserID, scope.AgentID, c.Domain, c.Name, c.Path, encrypted, + c.Secure, c.HTTPOnly, c.SameSite, c.ExpiresAt, c.Source, now, now, + ) + if err != nil { + return count, err + } + if n, _ := res.RowsAffected(); n > 0 { + count++ + } + } + return count, nil +} + +func (s *PGBrowserCookieStore) List(ctx context.Context, scope store.BrowserCookieScope, filter store.BrowserCookieFilter) ([]store.BrowserCookie, error) { + if s.encKey == "" { + return nil, store.ErrBrowserCookieEncryptionRequired + } + if err := scope.Validate(); err != nil { + return nil, err + } + query := `SELECT id, tenant_id, user_id, agent_id, domain, name, path, encrypted_value, + secure, http_only, same_site, expires_at, source, created_at, updated_at + FROM browser_cookies + WHERE tenant_id = $1 AND user_id = $2 AND agent_id = $3 + AND (expires_at IS NULL OR expires_at > NOW())` + args := []any{scope.TenantID, scope.UserID, scope.AgentID} + next := 4 + if filter.Domain != "" { + query += fmt.Sprintf(" AND domain = $%d", next) + args = append(args, strings.ToLower(strings.TrimSpace(filter.Domain))) + next++ + } + if filter.Name != "" { + query += fmt.Sprintf(" AND name = $%d", next) + args = append(args, strings.TrimSpace(filter.Name)) + next++ + } + if filter.Path != "" { + query += fmt.Sprintf(" AND path = $%d", next) + args = append(args, strings.TrimSpace(filter.Path)) + } + query += " ORDER BY domain, path, name" + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return s.scanRows(rows) +} + +func (s *PGBrowserCookieStore) Delete(ctx context.Context, scope store.BrowserCookieScope, filter store.BrowserCookieFilter) (int, error) { + if err := scope.Validate(); err != nil { + return 0, err + } + query := `DELETE FROM browser_cookies WHERE tenant_id = $1 AND user_id = $2 AND agent_id = $3` + args := []any{scope.TenantID, scope.UserID, scope.AgentID} + next := 4 + if filter.Domain != "" { + query += fmt.Sprintf(" AND domain = $%d", next) + args = append(args, strings.ToLower(strings.TrimSpace(filter.Domain))) + next++ + } + if filter.Name != "" { + query += fmt.Sprintf(" AND name = $%d", next) + args = append(args, strings.TrimSpace(filter.Name)) + next++ + } + if filter.Path != "" { + query += fmt.Sprintf(" AND path = $%d", next) + args = append(args, strings.TrimSpace(filter.Path)) + } + res, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return int(n), nil +} + +func (s *PGBrowserCookieStore) scanRows(rows *sql.Rows) ([]store.BrowserCookie, error) { + var out []store.BrowserCookie + for rows.Next() { + var c store.BrowserCookie + var encrypted string + if err := rows.Scan(&c.ID, &c.TenantID, &c.UserID, &c.AgentID, &c.Domain, &c.Name, &c.Path, + &encrypted, &c.Secure, &c.HTTPOnly, &c.SameSite, &c.ExpiresAt, &c.Source, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, err + } + value, err := crypto.Decrypt(encrypted, s.encKey) + if err != nil { + return nil, fmt.Errorf("decrypt browser cookie: %w", err) + } + c.Value = value + out = append(out, c) + } + return out, rows.Err() +} diff --git a/internal/store/pg/cron_crud.go b/internal/store/pg/cron_crud.go index aad26ee3..f3633c21 100644 --- a/internal/store/pg/cron_crud.go +++ b/internal/store/pg/cron_crud.go @@ -24,7 +24,9 @@ func (s *PGCronStore) AddJob(ctx context.Context, name string, schedule store.Cr } payload := store.CronPayload{ - Kind: "agent_turn", Message: message, + Kind: "agent_turn", + Message: message, + CredentialUserID: store.ExplicitCredentialUserIDFromContext(ctx), } payloadJSON, _ := json.Marshal(payload) diff --git a/internal/store/pg/cron_scan_test.go b/internal/store/pg/cron_scan_test.go index 8246511d..061f5eeb 100644 --- a/internal/store/pg/cron_scan_test.go +++ b/internal/store/pg/cron_scan_test.go @@ -78,30 +78,30 @@ func validCronRow(payloadJSON []byte) []any { expr := "*/5 * * * *" tz := "UTC" return []any{ - id, // id - tenantID, // tenant_id - (*uuid.UUID)(nil), // agent_id - (*string)(nil), // user_id - "test-job", // name - true, // enabled - "cron", // schedule_kind - &expr, // cron_expression - (*time.Time)(nil), // run_at - &tz, // timezone - (*int64)(nil), // interval_ms - payloadJSON, // payload - false, // delete_after_run - false, // stateless - false, // deliver - "", // deliver_channel - "", // deliver_to - false, // wake_heartbeat - (*time.Time)(nil), // next_run_at - (*time.Time)(nil), // last_run_at - (*string)(nil), // last_status - (*string)(nil), // last_error - now, // created_at - now, // updated_at + id, // id + tenantID, // tenant_id + (*uuid.UUID)(nil), // agent_id + (*string)(nil), // user_id + "test-job", // name + true, // enabled + "cron", // schedule_kind + &expr, // cron_expression + (*time.Time)(nil), // run_at + &tz, // timezone + (*int64)(nil), // interval_ms + payloadJSON, // payload + false, // delete_after_run + false, // stateless + false, // deliver + "", // deliver_channel + "", // deliver_to + false, // wake_heartbeat + (*time.Time)(nil), // next_run_at + (*time.Time)(nil), // last_run_at + (*string)(nil), // last_status + (*string)(nil), // last_error + now, // created_at + now, // updated_at } } @@ -123,17 +123,18 @@ func TestScanCronRow_MalformedPayloadJSON(t *testing.T) { // TestScanCronRow_ValidPayload verifies correct behavior with valid JSON. func TestScanCronRow_ValidPayload(t *testing.T) { payload := store.CronPayload{ - Kind: "message", - Message: "hello world", + Kind: "message", + Message: "hello world", + CredentialUserID: "tenant-user-123", } payloadJSON, _ := json.Marshal(payload) // Build row with deliver/deliver_channel/deliver_to set as dedicated columns. rowVals := validCronRow(payloadJSON) // Indices: stateless=13, deliver=14, deliver_channel=15, deliver_to=16, wake_heartbeat=17 - rowVals[14] = true // deliver + rowVals[14] = true // deliver rowVals[15] = "telegram" // deliver_channel - rowVals[16] = "user123" // deliver_to + rowVals[16] = "user123" // deliver_to row := &mockRowScanner{values: rowVals} job, err := scanCronRow(row) @@ -144,6 +145,9 @@ func TestScanCronRow_ValidPayload(t *testing.T) { if job.Payload.Message != "hello world" { t.Errorf("expected Message 'hello world', got %q", job.Payload.Message) } + if job.Payload.CredentialUserID != "tenant-user-123" { + t.Errorf("expected CredentialUserID 'tenant-user-123', got %q", job.Payload.CredentialUserID) + } if job.DeliverChannel != "telegram" { t.Errorf("expected DeliverChannel 'telegram', got %q", job.DeliverChannel) } diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index b22c1a90..bde42a89 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -44,6 +44,7 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { Contacts: NewPGContactStore(db), Activity: NewPGActivityStore(db), Snapshots: NewPGSnapshotStore(db), + BrowserCookies: NewPGBrowserCookieStore(db, cfg.EncryptionKey), SecureCLI: NewPGSecureCLIStore(db, cfg.EncryptionKey), SecureCLIGrants: NewPGSecureCLIAgentGrantStore(db, cfg.EncryptionKey), APIKeys: NewPGAPIKeyStore(db), diff --git a/internal/store/sqlitestore/browser-cookies.go b/internal/store/sqlitestore/browser-cookies.go new file mode 100644 index 00000000..c353a92f --- /dev/null +++ b/internal/store/sqlitestore/browser-cookies.go @@ -0,0 +1,166 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type SQLiteBrowserCookieStore struct { + db *sql.DB + encKey string +} + +func NewSQLiteBrowserCookieStore(db *sql.DB, encKey string) *SQLiteBrowserCookieStore { + return &SQLiteBrowserCookieStore{db: db, encKey: encKey} +} + +func (s *SQLiteBrowserCookieStore) Upsert(ctx context.Context, scope store.BrowserCookieScope, cookies []store.BrowserCookie) (int, error) { + if s.encKey == "" { + return 0, store.ErrBrowserCookieEncryptionRequired + } + if err := scope.Validate(); err != nil { + return 0, err + } + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339Nano) + count := 0 + for _, raw := range cookies { + c := store.NormalizeBrowserCookie(raw) + if err := store.ValidateBrowserCookie(c); err != nil { + return count, err + } + encrypted, err := crypto.Encrypt(c.Value, s.encKey) + if err != nil { + return count, fmt.Errorf("encrypt browser cookie: %w", err) + } + id := c.ID + if id == uuid.Nil { + id = store.GenNewID() + } + var expiresAt any + if c.ExpiresAt != nil { + expiresAt = c.ExpiresAt.UTC().Format(time.RFC3339Nano) + } + res, err := s.db.ExecContext(ctx, ` + INSERT INTO browser_cookies ( + id, tenant_id, user_id, agent_id, domain, name, path, encrypted_value, + secure, http_only, same_site, expires_at, source, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (tenant_id, user_id, agent_id, domain, path, name) + DO UPDATE SET encrypted_value = excluded.encrypted_value, + secure = excluded.secure, + http_only = excluded.http_only, + same_site = excluded.same_site, + expires_at = excluded.expires_at, + source = excluded.source, + updated_at = excluded.updated_at`, + id.String(), scope.TenantID.String(), scope.UserID, scope.AgentID, c.Domain, c.Name, c.Path, encrypted, + c.Secure, c.HTTPOnly, c.SameSite, expiresAt, c.Source, nowStr, nowStr, + ) + if err != nil { + return count, err + } + if n, _ := res.RowsAffected(); n > 0 { + count++ + } + } + return count, nil +} + +func (s *SQLiteBrowserCookieStore) List(ctx context.Context, scope store.BrowserCookieScope, filter store.BrowserCookieFilter) ([]store.BrowserCookie, error) { + if s.encKey == "" { + return nil, store.ErrBrowserCookieEncryptionRequired + } + if err := scope.Validate(); err != nil { + return nil, err + } + query := `SELECT id, tenant_id, user_id, agent_id, domain, name, path, encrypted_value, + secure, http_only, same_site, expires_at, source, created_at, updated_at + FROM browser_cookies + WHERE tenant_id = ? AND user_id = ? AND agent_id = ? + AND (expires_at IS NULL OR expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))` + args := []any{scope.TenantID.String(), scope.UserID, scope.AgentID} + if filter.Domain != "" { + query += " AND domain = ?" + args = append(args, strings.ToLower(strings.TrimSpace(filter.Domain))) + } + if filter.Name != "" { + query += " AND name = ?" + args = append(args, strings.TrimSpace(filter.Name)) + } + if filter.Path != "" { + query += " AND path = ?" + args = append(args, strings.TrimSpace(filter.Path)) + } + query += " ORDER BY domain, path, name" + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return s.scanRows(rows) +} + +func (s *SQLiteBrowserCookieStore) Delete(ctx context.Context, scope store.BrowserCookieScope, filter store.BrowserCookieFilter) (int, error) { + if err := scope.Validate(); err != nil { + return 0, err + } + query := `DELETE FROM browser_cookies WHERE tenant_id = ? AND user_id = ? AND agent_id = ?` + args := []any{scope.TenantID.String(), scope.UserID, scope.AgentID} + if filter.Domain != "" { + query += " AND domain = ?" + args = append(args, strings.ToLower(strings.TrimSpace(filter.Domain))) + } + if filter.Name != "" { + query += " AND name = ?" + args = append(args, strings.TrimSpace(filter.Name)) + } + if filter.Path != "" { + query += " AND path = ?" + args = append(args, strings.TrimSpace(filter.Path)) + } + res, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return int(n), nil +} + +func (s *SQLiteBrowserCookieStore) scanRows(rows *sql.Rows) ([]store.BrowserCookie, error) { + var out []store.BrowserCookie + for rows.Next() { + var c store.BrowserCookie + var id, tenantID, encrypted string + var expiresAt nullSqliteTime + createdAt, updatedAt := scanTimePair() + if err := rows.Scan(&id, &tenantID, &c.UserID, &c.AgentID, &c.Domain, &c.Name, &c.Path, + &encrypted, &c.Secure, &c.HTTPOnly, &c.SameSite, &expiresAt, &c.Source, createdAt, updatedAt); err != nil { + return nil, err + } + c.ID = uuid.MustParse(id) + c.TenantID = uuid.MustParse(tenantID) + if expiresAt.Valid { + c.ExpiresAt = &expiresAt.Time + } + c.CreatedAt = createdAt.Time + c.UpdatedAt = updatedAt.Time + value, err := crypto.Decrypt(encrypted, s.encKey) + if err != nil { + return nil, fmt.Errorf("decrypt browser cookie: %w", err) + } + c.Value = value + out = append(out, c) + } + return out, rows.Err() +} diff --git a/internal/store/sqlitestore/browser-cookies_test.go b/internal/store/sqlitestore/browser-cookies_test.go new file mode 100644 index 00000000..e82e4505 --- /dev/null +++ b/internal/store/sqlitestore/browser-cookies_test.go @@ -0,0 +1,175 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const browserCookieTestKey = "12345678901234567890123456789012" + +func newBrowserCookieStoreFixture(t *testing.T) (*sql.DB, *SQLiteBrowserCookieStore, store.BrowserCookieScope) { + t.Helper() + + db, err := OpenDB(filepath.Join(t.TempDir(), "browser-cookies.db")) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + if err := EnsureSchema(db); err != nil { + t.Fatalf("EnsureSchema: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + scope := store.BrowserCookieScope{ + TenantID: store.MasterTenantID, + UserID: "user-a", + AgentID: "agent-a", + } + return db, NewSQLiteBrowserCookieStore(db, browserCookieTestKey), scope +} + +func TestSQLiteBrowserCookieStoreEncryptsAndListsWithinScope(t *testing.T) { + db, cookies, scope := newBrowserCookieStoreFixture(t) + expiresAt := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + + count, err := cookies.Upsert(context.Background(), scope, []store.BrowserCookie{{ + Domain: "Example.COM", + Name: "session", + Path: "", + Value: "secret-cookie", + Secure: true, + HTTPOnly: true, + SameSite: "Lax", + ExpiresAt: &expiresAt, + Source: "chrome-extension", + }}) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if count != 1 { + t.Fatalf("Upsert count = %d, want 1", count) + } + + var raw string + if err := db.QueryRow(`SELECT encrypted_value FROM browser_cookies WHERE tenant_id = ?`, scope.TenantID.String()).Scan(&raw); err != nil { + t.Fatalf("read encrypted_value: %v", err) + } + if raw == "secret-cookie" || !crypto.IsEncrypted(raw) { + t.Fatalf("cookie value not encrypted at rest: %q", raw) + } + + got, err := cookies.List(context.Background(), scope, store.BrowserCookieFilter{Domain: "example.com"}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 { + t.Fatalf("List len = %d, want 1", len(got)) + } + if got[0].Domain != "example.com" || got[0].Path != "/" || got[0].Value != "secret-cookie" { + t.Fatalf("cookie round trip mismatch: %+v", got[0]) + } + if !got[0].Secure || !got[0].HTTPOnly || got[0].SameSite != "Lax" { + t.Fatalf("cookie metadata mismatch: %+v", got[0]) + } +} + +func TestSQLiteBrowserCookieStoreIsolatesByUserAndAgent(t *testing.T) { + _, cookies, scope := newBrowserCookieStoreFixture(t) + otherUser := scope + otherUser.UserID = "user-b" + otherAgent := scope + otherAgent.AgentID = "agent-b" + + if _, err := cookies.Upsert(context.Background(), scope, []store.BrowserCookie{{ + Domain: "example.com", + Name: "session", + Value: "secret-cookie", + }}); err != nil { + t.Fatalf("Upsert owner: %v", err) + } + if _, err := cookies.Upsert(context.Background(), otherUser, []store.BrowserCookie{{ + Domain: "example.com", + Name: "session", + Value: "other-user-cookie", + }}); err != nil { + t.Fatalf("Upsert other user: %v", err) + } + if _, err := cookies.Upsert(context.Background(), otherAgent, []store.BrowserCookie{{ + Domain: "example.com", + Name: "session", + Value: "other-agent-cookie", + }}); err != nil { + t.Fatalf("Upsert other agent: %v", err) + } + + got, err := cookies.List(context.Background(), scope, store.BrowserCookieFilter{}) + if err != nil { + t.Fatalf("List owner: %v", err) + } + if len(got) != 1 || got[0].Value != "secret-cookie" { + t.Fatalf("owner list leaked wrong cookies: %+v", got) + } +} + +func TestSQLiteBrowserCookieStoreDeleteAndExpiry(t *testing.T) { + _, cookies, scope := newBrowserCookieStoreFixture(t) + expiredAt := time.Now().UTC().Add(-time.Hour).Truncate(time.Second) + validAt := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + + if _, err := cookies.Upsert(context.Background(), scope, []store.BrowserCookie{ + {Domain: "example.com", Name: "expired", Value: "expired", ExpiresAt: &expiredAt}, + {Domain: "example.com", Name: "valid", Value: "valid", ExpiresAt: &validAt}, + }); err != nil { + t.Fatalf("Upsert: %v", err) + } + + got, err := cookies.List(context.Background(), scope, store.BrowserCookieFilter{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 || got[0].Name != "valid" { + t.Fatalf("expiry filter mismatch: %+v", got) + } + + deleted, err := cookies.Delete(context.Background(), scope, store.BrowserCookieFilter{Name: "valid"}) + if err != nil { + t.Fatalf("Delete: %v", err) + } + if deleted != 1 { + t.Fatalf("Delete count = %d, want 1", deleted) + } + got, err = cookies.List(context.Background(), scope, store.BrowserCookieFilter{}) + if err != nil { + t.Fatalf("List after delete: %v", err) + } + if len(got) != 0 { + t.Fatalf("List after delete = %+v, want empty", got) + } +} + +func TestSQLiteBrowserCookieStoreRequiresEncryptionKey(t *testing.T) { + db, _, scope := newBrowserCookieStoreFixture(t) + cookies := NewSQLiteBrowserCookieStore(db, "") + + _, err := cookies.Upsert(context.Background(), scope, []store.BrowserCookie{{ + Domain: "example.com", + Name: "session", + Value: "secret-cookie", + }}) + if !errors.Is(err, store.ErrBrowserCookieEncryptionRequired) { + t.Fatalf("Upsert err = %v, want ErrBrowserCookieEncryptionRequired", err) + } + + _, err = cookies.List(context.Background(), scope, store.BrowserCookieFilter{}) + if !errors.Is(err, store.ErrBrowserCookieEncryptionRequired) { + t.Fatalf("List err = %v, want ErrBrowserCookieEncryptionRequired", err) + } +} diff --git a/internal/store/sqlitestore/cron_crud.go b/internal/store/sqlitestore/cron_crud.go index cefcdaca..054e0eff 100644 --- a/internal/store/sqlitestore/cron_crud.go +++ b/internal/store/sqlitestore/cron_crud.go @@ -28,7 +28,9 @@ func (s *SQLiteCronStore) AddJob(ctx context.Context, name string, schedule stor } payload := store.CronPayload{ - Kind: "agent_turn", Message: message, + Kind: "agent_turn", + Message: message, + CredentialUserID: store.ExplicitCredentialUserIDFromContext(ctx), } payloadJSON, _ := json.Marshal(payload) @@ -395,7 +397,6 @@ func (s *SQLiteCronStore) lockCronJobForMutation(ctx context.Context, tx *sql.Tx return &state, nil } - func execCronJobUpdateTx(ctx context.Context, tx *sql.Tx, id uuid.UUID, updates map[string]any) error { if len(updates) == 0 { return nil @@ -433,4 +434,3 @@ func execCronJobUpdateTx(ctx context.Context, tx *sql.Tx, id uuid.UUID, updates } return nil } - diff --git a/internal/store/sqlitestore/cron_crud_test.go b/internal/store/sqlitestore/cron_crud_test.go index 8423df1a..f43489e1 100644 --- a/internal/store/sqlitestore/cron_crud_test.go +++ b/internal/store/sqlitestore/cron_crud_test.go @@ -65,6 +65,29 @@ func TestSQLiteCronStore_ReenableRestoresNextRun(t *testing.T) { } } +func TestSQLiteCronStore_AddJobPersistsCredentialUserID(t *testing.T) { + cronStore, ctx, _ := newTestSQLiteCronStore(t) + ctx = store.WithCredentialUserID(ctx, "tenant-user-123") + everyMS := int64(time.Minute / time.Millisecond) + + job, err := cronStore.AddJob(ctx, "job-credential-context", store.CronSchedule{ + Kind: "every", + EveryMS: &everyMS, + }, "run credentialed report", false, "", "", "", "group:telegram:-100123") + if err != nil { + t.Fatalf("AddJob error: %v", err) + } + if job == nil { + job = mustOnlyJob(t, cronStore, ctx) + } + if job.UserID != "group:telegram:-100123" { + t.Fatalf("cron user ID = %q, want group-scoped owner", job.UserID) + } + if job.Payload.CredentialUserID != "tenant-user-123" { + t.Fatalf("credential user ID = %q, want tenant-user-123", job.Payload.CredentialUserID) + } +} + func TestSQLiteCronStore_EnableAlreadyEnabledPreservesNextRun(t *testing.T) { cronStore, ctx, _ := newTestSQLiteCronStore(t) everyMS := int64(time.Hour / time.Millisecond) diff --git a/internal/store/sqlitestore/factory.go b/internal/store/sqlitestore/factory.go index 2efcdeb5..527a6447 100644 --- a/internal/store/sqlitestore/factory.go +++ b/internal/store/sqlitestore/factory.go @@ -60,6 +60,7 @@ func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) { Activity: NewSQLiteActivityStore(db), APIKeys: NewSQLiteAPIKeyStore(db), ConfigPermissions: NewSQLiteConfigPermissionStore(db), + BrowserCookies: NewSQLiteBrowserCookieStore(db, cfg.EncryptionKey), Memory: NewSQLiteMemoryStore(db), SubagentTasks: NewSQLiteSubagentTaskStore(db), AgentLinks: NewSQLiteAgentLinkStore(db), diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index cba00eb3..767bfce6 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -16,7 +16,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 38 +const SchemaVersion = 39 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -737,6 +737,34 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_bitrix_portals_tenant_name ON bitrix_portals (tenant_id, name); CREATE UNIQUE INDEX IF NOT EXISTS idx_bitrix_portals_domain ON bitrix_portals (LOWER(TRIM(domain)));`, + + // Version 38 → 39: selected browser cookie sync. + 38: `CREATE TABLE IF NOT EXISTS browser_cookies ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + agent_id VARCHAR(255) NOT NULL, + domain TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '/', + encrypted_value TEXT NOT NULL, + secure INTEGER NOT NULL DEFAULT 0, + http_only INTEGER NOT NULL DEFAULT 0, + same_site VARCHAR(32) NOT NULL DEFAULT '', + expires_at TEXT, + source VARCHAR(64) NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (TRIM(domain) <> ''), + CHECK (TRIM(name) <> ''), + CHECK (TRIM(path) <> '') +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_browser_cookies_scope_unique + ON browser_cookies (tenant_id, user_id, agent_id, domain, path, name); +CREATE INDEX IF NOT EXISTS idx_browser_cookies_scope_domain + ON browser_cookies (tenant_id, user_id, agent_id, domain); +CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at + ON browser_cookies (expires_at);`, } // addHooksTables is the SQLite incremental migration for schema v19 → v20. diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index 54657a4f..9b9f5228 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -1844,3 +1844,36 @@ CREATE TABLE IF NOT EXISTS workstation_activity ( CREATE INDEX IF NOT EXISTS idx_ws_activity_ws_time ON workstation_activity(workstation_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_ws_activity_tenant_time ON workstation_activity(tenant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_ws_activity_retention ON workstation_activity(created_at); + +-- ============================================================ +-- Table: browser_cookies (migration 000069) +-- User-selected cookies for server-side browser contexts. +-- Values are AES-256-GCM ciphertext. Scope is tenant + user + agent. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS browser_cookies ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + agent_id VARCHAR(255) NOT NULL, + domain TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '/', + encrypted_value TEXT NOT NULL, + secure INTEGER NOT NULL DEFAULT 0, + http_only INTEGER NOT NULL DEFAULT 0, + same_site VARCHAR(32) NOT NULL DEFAULT '', + expires_at TEXT, + source VARCHAR(64) NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (TRIM(domain) <> ''), + CHECK (TRIM(name) <> ''), + CHECK (TRIM(path) <> '') +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_browser_cookies_scope_unique + ON browser_cookies (tenant_id, user_id, agent_id, domain, path, name); +CREATE INDEX IF NOT EXISTS idx_browser_cookies_scope_domain + ON browser_cookies (tenant_id, user_id, agent_id, domain); +CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at + ON browser_cookies (expires_at); diff --git a/internal/store/stores.go b/internal/store/stores.go index 3c008a55..7a1a9887 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -24,6 +24,7 @@ type Stores struct { Contacts ContactStore Activity ActivityStore Snapshots SnapshotStore + BrowserCookies BrowserCookieStore SecureCLI SecureCLIStore SecureCLIGrants SecureCLIAgentGrantStore APIKeys APIKeyStore diff --git a/internal/tools/cron.go b/internal/tools/cron.go index 32b96a43..5eccee04 100644 --- a/internal/tools/cron.go +++ b/internal/tools/cron.go @@ -185,6 +185,7 @@ func (t *CronTool) handleStatus() *Result { func (t *CronTool) handleList(ctx context.Context, args map[string]any, agentID, userID string) *Result { includeDisabled, _ := args["includeDisabled"].(bool) jobs := t.cronStore.ListJobs(ctx, includeDisabled, agentID, userID) + jobs = store.RedactCronJobsCredentialContext(jobs) result := map[string]any{ "jobs": jobs, @@ -305,7 +306,7 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID, } } - data, _ := json.MarshalIndent(map[string]any{"job": job}, "", " ") + data, _ := json.MarshalIndent(map[string]any{"job": store.RedactCronJobCredentialContext(*job)}, "", " ") return NewResult(string(data)) } @@ -334,9 +335,13 @@ func (t *CronTool) handleUpdate(ctx context.Context, args map[string]any, agentI return ErrorResult("jobId is required for update action") } - if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { + existing, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID) + if errResult != nil { return errResult } + if err := store.CheckCronCredentialOwner(ctx, existing); err != nil { + return ErrorResult("permission denied: cron job uses a credential context owned by another user") + } patchObj, ok := args["patch"].(map[string]any) if !ok { @@ -360,7 +365,7 @@ func (t *CronTool) handleUpdate(ctx context.Context, args map[string]any, agentI return ErrorResult(fmt.Sprintf("failed to update cron job: %v", err)) } - data, _ := json.MarshalIndent(map[string]any{"job": job}, "", " ") + data, _ := json.MarshalIndent(map[string]any{"job": store.RedactCronJobCredentialContext(*job)}, "", " ") return NewResult(string(data)) } @@ -370,7 +375,8 @@ func (t *CronTool) handleRemove(ctx context.Context, args map[string]any, agentI return ErrorResult("jobId is required for remove action") } - if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { + _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID) + if errResult != nil { return errResult } @@ -388,9 +394,13 @@ func (t *CronTool) handleRun(ctx context.Context, args map[string]any, agentID, return ErrorResult("jobId is required for run action") } - if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { + existing, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID) + if errResult != nil { return errResult } + if err := store.CheckCronCredentialOwner(ctx, existing); err != nil { + return ErrorResult("permission denied: cron job uses a credential context owned by another user") + } runMode, _ := args["runMode"].(string) force := runMode == "force" diff --git a/internal/tools/cron_test.go b/internal/tools/cron_test.go new file mode 100644 index 00000000..a6a886c1 --- /dev/null +++ b/internal/tools/cron_test.go @@ -0,0 +1,153 @@ +package tools + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type testCronStore struct { + jobs map[string]*store.CronJob + updateCnt int + runCnt int + lastForce bool + updateErr error + runErr error +} + +func newTestCronStore(job *store.CronJob) *testCronStore { + jobs := map[string]*store.CronJob{} + if job != nil { + jobs[job.ID] = job + } + return &testCronStore{jobs: jobs} +} + +func (s *testCronStore) AddJob(context.Context, string, store.CronSchedule, string, bool, string, string, string, string) (*store.CronJob, error) { + return nil, nil +} + +func (s *testCronStore) GetJob(_ context.Context, jobID string) (*store.CronJob, bool) { + job, ok := s.jobs[jobID] + return job, ok +} + +func (s *testCronStore) ListJobs(context.Context, bool, string, string) []store.CronJob { + var jobs []store.CronJob + for _, job := range s.jobs { + jobs = append(jobs, *job) + } + return jobs +} + +func (s *testCronStore) RemoveJob(context.Context, string) error { return nil } + +func (s *testCronStore) UpdateJob(_ context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) { + s.updateCnt++ + if s.updateErr != nil { + return nil, s.updateErr + } + job := s.jobs[jobID] + if patch.Message != "" { + job.Payload.Message = patch.Message + } + return job, nil +} + +func (s *testCronStore) EnableJob(context.Context, string, bool) error { return nil } +func (s *testCronStore) GetRunLog(context.Context, string, int, int) ([]store.CronRunLogEntry, int) { + return nil, 0 +} +func (s *testCronStore) Status() map[string]any { return map[string]any{} } +func (s *testCronStore) Start() error { return nil } +func (s *testCronStore) Stop() {} +func (s *testCronStore) SetOnJob(func(*store.CronJob) (*store.CronJobResult, error)) {} +func (s *testCronStore) SetOnEvent(func(store.CronEvent)) {} + +func (s *testCronStore) RunJob(_ context.Context, _ string, force bool) (bool, string, error) { + s.runCnt++ + s.lastForce = force + return true, "", s.runErr +} + +func (s *testCronStore) GetDueJobs(time.Time) []store.CronJob { return nil } +func (s *testCronStore) SetDefaultTimezone(string) {} + +func TestCronToolBlocksCredentialBoundUpdateByDifferentUser(t *testing.T) { + cronStore := newTestCronStore(&store.CronJob{ + ID: "job-1", + UserID: "group:telegram:-100123", + Payload: store.CronPayload{ + Message: "old", + CredentialUserID: "tenant-user-a", + }, + }) + tool := NewCronTool(cronStore) + ctx := store.WithUserID(context.Background(), "group:telegram:-100123") + ctx = store.WithCredentialUserID(ctx, "tenant-user-b") + + result := tool.Execute(ctx, map[string]any{ + "action": "update", + "jobId": "job-1", + "patch": map[string]any{"message": "run gh issue list"}, + }) + + if !result.IsError || !strings.Contains(result.ForLLM, "credential context") { + t.Fatalf("expected credential context error, got %#v", result) + } + if cronStore.updateCnt != 0 { + t.Fatalf("UpdateJob called %d times, want 0", cronStore.updateCnt) + } +} + +func TestCronToolListRedactsCredentialUserID(t *testing.T) { + cronStore := newTestCronStore(&store.CronJob{ + ID: "job-1", + UserID: "group:telegram:-100123", + Payload: store.CronPayload{ + Message: "run gh issue list", + CredentialUserID: "tenant-user-a", + }, + }) + tool := NewCronTool(cronStore) + ctx := store.WithUserID(context.Background(), "group:telegram:-100123") + + result := tool.Execute(ctx, map[string]any{"action": "list", "includeDisabled": true}) + + if result.IsError { + t.Fatalf("list returned error: %#v", result) + } + if strings.Contains(result.ForLLM, "tenant-user-a") || strings.Contains(result.ForLLM, "credentialUserId") { + t.Fatalf("credential identity leaked in list response: %s", result.ForLLM) + } +} + +func TestCronToolBlocksCredentialBoundRunByDifferentUser(t *testing.T) { + cronStore := newTestCronStore(&store.CronJob{ + ID: "job-1", + UserID: "group:telegram:-100123", + Payload: store.CronPayload{ + Message: "run gh issue list", + CredentialUserID: "tenant-user-a", + }, + }) + tool := NewCronTool(cronStore) + ctx := store.WithUserID(context.Background(), "group:telegram:-100123") + ctx = store.WithCredentialUserID(ctx, "tenant-user-b") + + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "jobId": "job-1", + "runMode": "force", + }) + + if !result.IsError || !strings.Contains(result.ForLLM, "credential context") { + t.Fatalf("expected credential context error, got %#v", result) + } + if cronStore.runCnt != 0 { + t.Fatalf("RunJob called %d times, want 0", cronStore.runCnt) + } +} diff --git a/internal/tools/read_document_resolve.go b/internal/tools/read_document_resolve.go index 6f5bfd9f..24de1584 100644 --- a/internal/tools/read_document_resolve.go +++ b/internal/tools/read_document_resolve.go @@ -43,8 +43,7 @@ func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID, doc } } if ref == nil { - slog.Warn("read_document: media_id not found, falling back to most recent", "media_id", mediaID) - ref = &refs[len(refs)-1] + return "", "", fmt.Errorf("document media_id %q not found in this conversation", mediaID) } } else { // Use the last (most recent) document ref. diff --git a/internal/tools/read_document_resolve_test.go b/internal/tools/read_document_resolve_test.go index bc2ac937..172ac87f 100644 --- a/internal/tools/read_document_resolve_test.go +++ b/internal/tools/read_document_resolve_test.go @@ -66,7 +66,7 @@ func TestResolveDocumentFileMatchesUploadedFilenameAlias(t *testing.T) { } } -func TestResolveDocumentFileInvalidMediaIDFallsBackToMostRecent(t *testing.T) { +func TestResolveDocumentFileInvalidMediaIDReturnsError(t *testing.T) { refs := []providers.MediaRef{ {ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/old.pdf", MimeType: "application/pdf"}, {ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/latest.pdf", MimeType: "application/pdf"}, @@ -75,6 +75,23 @@ func TestResolveDocumentFileInvalidMediaIDFallsBackToMostRecent(t *testing.T) { tool := NewReadDocumentTool(nil, nil) ctx := WithMediaDocRefs(context.Background(), refs) gotPath, _, err := tool.resolveDocumentFile(ctx, "not-a-real-media-id", "") + if err == nil { + t.Fatalf("resolveDocumentFile returned path %q, want explicit media_id error", gotPath) + } + if !strings.Contains(err.Error(), "not-a-real-media-id") { + t.Fatalf("error = %q, want requested media_id", err.Error()) + } +} + +func TestResolveDocumentFileOmittedMediaIDUsesLastRef(t *testing.T) { + refs := []providers.MediaRef{ + {ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/old.pdf", MimeType: "application/pdf"}, + {ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/latest.pdf", MimeType: "application/pdf"}, + } + + tool := NewReadDocumentTool(nil, nil) + ctx := WithMediaDocRefs(context.Background(), refs) + gotPath, _, err := tool.resolveDocumentFile(ctx, "", "") if err != nil { t.Fatalf("resolveDocumentFile returned error: %v", err) } diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 3ce25186..10ad72c6 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 71 +const RequiredSchemaVersion uint = 72 diff --git a/migrations/000069_browser_cookies.down.sql b/migrations/000069_browser_cookies.down.sql new file mode 100644 index 00000000..95bb83ed --- /dev/null +++ b/migrations/000069_browser_cookies.down.sql @@ -0,0 +1,2 @@ +-- Revert migration 000069: selected browser cookie sync +DROP TABLE IF EXISTS browser_cookies; diff --git a/migrations/000069_browser_cookies.up.sql b/migrations/000069_browser_cookies.up.sql new file mode 100644 index 00000000..f6a8c2be --- /dev/null +++ b/migrations/000069_browser_cookies.up.sql @@ -0,0 +1,34 @@ +-- Migration 000069: selected browser cookie sync +-- Stores user-selected cookies for server-side browser contexts. Values are +-- AES-256-GCM ciphertext from internal/crypto/aes.go; cookie scope is tenant, +-- user, and agent to prevent cross-principal reuse. + +CREATE TABLE IF NOT EXISTS browser_cookies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + agent_id VARCHAR(255) NOT NULL, + domain TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '/', + encrypted_value TEXT NOT NULL, + secure BOOLEAN NOT NULL DEFAULT FALSE, + http_only BOOLEAN NOT NULL DEFAULT FALSE, + same_site VARCHAR(32) NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ, + source VARCHAR(64) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT browser_cookies_domain_not_empty CHECK (TRIM(domain) <> ''), + CONSTRAINT browser_cookies_name_not_empty CHECK (TRIM(name) <> ''), + CONSTRAINT browser_cookies_path_not_empty CHECK (TRIM(path) <> '') +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_browser_cookies_scope_unique + ON browser_cookies (tenant_id, user_id, agent_id, domain, path, name); + +CREATE INDEX IF NOT EXISTS idx_browser_cookies_scope_domain + ON browser_cookies (tenant_id, user_id, agent_id, domain); + +CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at + ON browser_cookies (expires_at); diff --git a/migrations/000069_usage_caps_pricing.down.sql b/migrations/000070_usage_caps_pricing.down.sql similarity index 100% rename from migrations/000069_usage_caps_pricing.down.sql rename to migrations/000070_usage_caps_pricing.down.sql diff --git a/migrations/000069_usage_caps_pricing.up.sql b/migrations/000070_usage_caps_pricing.up.sql similarity index 100% rename from migrations/000069_usage_caps_pricing.up.sql rename to migrations/000070_usage_caps_pricing.up.sql diff --git a/migrations/000070_usage_cap_policies.down.sql b/migrations/000071_usage_cap_policies.down.sql similarity index 100% rename from migrations/000070_usage_cap_policies.down.sql rename to migrations/000071_usage_cap_policies.down.sql diff --git a/migrations/000070_usage_cap_policies.up.sql b/migrations/000071_usage_cap_policies.up.sql similarity index 100% rename from migrations/000070_usage_cap_policies.up.sql rename to migrations/000071_usage_cap_policies.up.sql diff --git a/migrations/000071_agent_budget_usage_cap_bridge.down.sql b/migrations/000072_agent_budget_usage_cap_bridge.down.sql similarity index 100% rename from migrations/000071_agent_budget_usage_cap_bridge.down.sql rename to migrations/000072_agent_budget_usage_cap_bridge.down.sql diff --git a/migrations/000071_agent_budget_usage_cap_bridge.up.sql b/migrations/000072_agent_budget_usage_cap_bridge.up.sql similarity index 100% rename from migrations/000071_agent_budget_usage_cap_bridge.up.sql rename to migrations/000072_agent_budget_usage_cap_bridge.up.sql diff --git a/pkg/browser/actions.go b/pkg/browser/actions.go index fffe6996..116ba763 100644 --- a/pkg/browser/actions.go +++ b/pkg/browser/actions.go @@ -3,6 +3,7 @@ package browser import ( "context" "fmt" + "strings" "time" "github.com/go-rod/rod" @@ -46,11 +47,15 @@ func (m *Manager) Type(ctx context.Context, targetID, ref, text string, opts Typ if opts.Slowly { // Type character by character with delay for _, ch := range text { - el.MustInput(string(ch)) + if err := rod.Try(func() { el.MustInput(string(ch)) }); err != nil { + return fmt.Errorf("type input: %w", err) + } time.Sleep(50 * time.Millisecond) } } else { - el.MustInput(text) + if err := rod.Try(func() { el.MustInput(text) }); err != nil { + return fmt.Errorf("type input: %w", err) + } } if opts.Submit { @@ -108,7 +113,7 @@ func (m *Manager) Wait(ctx context.Context, targetID string, opts WaitOpts) erro // Wait for text to appear if opts.Text != "" { return rod.Try(func() { - page.Timeout(30 * time.Second).MustElementR("*", opts.Text) + page.Timeout(30*time.Second).MustElementR("*", opts.Text) }) } @@ -138,9 +143,7 @@ func (m *Manager) Wait(ctx context.Context, targetID string, opts WaitOpts) erro // Wait for URL if opts.URL != "" { - wait := page.WaitNavigation(proto.PageLifecycleEventNameLoad) - wait() - return nil + return waitForURL(ctx, page, opts.URL, 30*time.Second) } // Default: wait for page to stabilize @@ -148,6 +151,25 @@ func (m *Manager) Wait(ctx context.Context, targetID string, opts WaitOpts) erro return nil } +func waitForURL(ctx context.Context, page *rod.Page, want string, timeout time.Duration) error { + deadline := time.After(timeout) + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + info, _ := page.Info() + if info != nil && strings.Contains(info.URL, want) { + return nil + } + select { + case <-deadline: + return fmt.Errorf("timeout waiting for URL containing %q", want) + case <-ticker.C: + case <-ctx.Done(): + return ctx.Err() + } + } +} + // Evaluate runs JavaScript on a page. func (m *Manager) Evaluate(ctx context.Context, targetID, js string) (string, error) { tenantID := tenantIDFromCtx(ctx) diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index 6a51dac7..8db1726f 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -13,22 +13,23 @@ import ( // Manager handles the Chrome browser lifecycle and page management. type Manager struct { - mu sync.Mutex - browser *rod.Browser - launcher *launcher.Launcher // retained for PID-based cleanup on crash - refs *RefStore - pages map[string]*rod.Page // targetID → page - console map[string][]ConsoleMessage // targetID → console messages - tenantCtxs map[string]*rod.Browser // tenantID → incognito browser context - pageTenants map[string]string // targetID → tenantID (for filtering) - pageLastUsed map[string]time.Time // targetID → last access time - headless bool - remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher - actionTimeout time.Duration // per-action context timeout (default 30s) - idleTimeout time.Duration // auto-close pages idle longer than this (default 10m, 0=disabled) - maxPages int // max open pages per tenant (default 5) - stopReaper chan struct{} // signal to stop the reaper goroutine - logger *slog.Logger + mu sync.Mutex + browser *rod.Browser + launcher *launcher.Launcher // retained for PID-based cleanup on crash + refs *RefStore + pages map[string]*rod.Page // targetID → page + console map[string][]ConsoleMessage // targetID → console messages + tenantCtxs map[string]*rod.Browser // browser scope key → incognito browser context + pageTenants map[string]string // targetID → browser scope key (for filtering) + pageLastUsed map[string]time.Time // targetID → last access time + headless bool + remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher + actionTimeout time.Duration // per-action context timeout (default 30s) + idleTimeout time.Duration // auto-close pages idle longer than this (default 10m, 0=disabled) + maxPages int // max open pages per tenant (default 5) + cookieProvider CookieProvider + stopReaper chan struct{} // signal to stop the reaper goroutine + logger *slog.Logger } // Option configures a Manager. @@ -65,6 +66,11 @@ func WithMaxPages(n int) Option { return func(m *Manager) { m.maxPages = n } } +// WithCookieProvider sets the provider for selected cookie sync into new pages. +func WithCookieProvider(p CookieProvider) Option { + return func(m *Manager) { m.cookieProvider = p } +} + // New creates a Manager with options. func New(opts ...Option) *Manager { m := &Manager{ @@ -90,6 +96,13 @@ func (m *Manager) ActionTimeout() time.Duration { return m.actionTimeout } +// SetCookieProvider updates the selected-cookie provider after store setup. +func (m *Manager) SetCookieProvider(p CookieProvider) { + m.mu.Lock() + defer m.mu.Unlock() + m.cookieProvider = p +} + // touchPageLocked updates the last-used timestamp for a page. Must be called with mu held. func (m *Manager) touchPageLocked(targetID string) { m.pageLastUsed[targetID] = time.Now() @@ -248,28 +261,27 @@ func (m *Manager) cleanupDeadBrowserLocked() { // Pages opened without a tenant context or by the master tenant use the main browser directly. const MasterTenantID = "0193a5b0-7000-7000-8000-000000000001" -// tenantBrowserLocked returns an isolated incognito browser context for the given tenant. -// Master tenant and empty string use the main browser (no isolation needed). +// tenantBrowserLocked returns an isolated incognito browser context for the given scope. +// Legacy master/empty scopes with no user/agent use the main browser. // Must be called with mu held. -func (m *Manager) tenantBrowserLocked(tenantID string) (*rod.Browser, error) { +func (m *Manager) tenantBrowserLocked(scopeKey string) (*rod.Browser, error) { if m.browser == nil { return nil, fmt.Errorf("browser not running") } - // Master tenant or no tenant: use main browser - if tenantID == "" || tenantID == MasterTenantID { + if scopeKey == "" || scopeKey == MasterTenantID { return m.browser, nil } // Return existing incognito context - if ctx, ok := m.tenantCtxs[tenantID]; ok { + if ctx, ok := m.tenantCtxs[scopeKey]; ok { return ctx, nil } - // Create new incognito context for this tenant + // Create new incognito context for this scope. incognito, err := m.browser.Incognito() if err != nil { - return nil, fmt.Errorf("create incognito context for tenant %s: %w", tenantID, err) + return nil, fmt.Errorf("create incognito context for browser scope %s: %w", scopeKey, err) } - m.tenantCtxs[tenantID] = incognito - m.logger.Info("created incognito browser context", "tenant", tenantID) + m.tenantCtxs[scopeKey] = incognito + m.logger.Info("created incognito browser context", "scope", scopeKey) return incognito, nil } @@ -278,15 +290,23 @@ func (m *Manager) Status() *StatusInfo { m.mu.Lock() defer m.mu.Unlock() + info := &StatusInfo{ + Running: m.browser != nil, + Headless: m.headless, + RemoteURL: m.remoteURL, + ActionTimeoutMs: int(m.actionTimeout / time.Millisecond), + IdleTimeoutMs: int(m.idleTimeout / time.Millisecond), + MaxPages: m.maxPages, + IsolationMode: "tenant_user_agent", + CookieSync: m.cookieProvider != nil, + } + if m.browser == nil { - return &StatusInfo{Running: false} + return info } pages, _ := m.browser.Pages() - info := &StatusInfo{ - Running: true, - Tabs: len(pages), - } + info.Tabs = len(pages) if len(pages) > 0 { if pageInfo, err := pages[0].Info(); err == nil { info.URL = pageInfo.URL diff --git a/pkg/browser/browser_page.go b/pkg/browser/browser_page.go index dfbba3f8..620844c7 100644 --- a/pkg/browser/browser_page.go +++ b/pkg/browser/browser_page.go @@ -83,7 +83,13 @@ func (m *Manager) Screenshot(ctx context.Context, targetID string, fullPage bool // Navigate navigates a page to a URL. // A ctx-cancel watchdog closes the page if ctx is done during the blocking WaitStable call. func (m *Manager) Navigate(ctx context.Context, targetID, url string) error { - tenantID := tenantIDFromCtx(ctx) + scope := scopeFromCtx(ctx) + cookies, err := m.cookiesForURL(ctx, scope, url) + if err != nil { + return fmt.Errorf("load browser cookies: %w", err) + } + + tenantID := scope.Key() m.mu.Lock() page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() @@ -96,6 +102,14 @@ func (m *Manager) Navigate(ctx context.Context, targetID, url string) error { stop := watchPageClose(ctx, page) defer stop() + if len(cookies) > 0 { + if err := page.SetCookies(cookies); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("set browser cookies: %w", err) + } + } if err := page.Navigate(url); err != nil { if ctx.Err() != nil { return ctx.Err() diff --git a/pkg/browser/browser_remote.go b/pkg/browser/browser_remote.go index 9e713e62..68017bda 100644 --- a/pkg/browser/browser_remote.go +++ b/pkg/browser/browser_remote.go @@ -95,6 +95,16 @@ func (m *Manager) getPage(targetID string) (*rod.Page, error) { // If tenantID is set and the page belongs to a different tenant, access is denied. // Must be called with m.mu held. func (m *Manager) getPageForTenant(targetID, tenantID string) (*rod.Page, error) { + if tenantID != "" && tenantID != MasterTenantID && targetID == "" { + for tid, page := range m.pages { + if m.pageTenants[tid] == tenantID { + m.touchPageLocked(tid) + return page, nil + } + } + return nil, fmt.Errorf("no tabs open") + } + page, err := m.getPage(targetID) if err != nil { return nil, err @@ -113,7 +123,7 @@ func (m *Manager) getPageForTenant(targetID, tenantID string) (*rod.Page, error) if targetID == "" { resolvedTID = string(page.TargetID) } - if owner, ok := m.pageTenants[resolvedTID]; ok && owner != tenantID { + if owner, ok := m.pageTenants[resolvedTID]; !ok || owner != tenantID { return nil, fmt.Errorf("tab not found: %s", targetID) } m.touchPageLocked(resolvedTID) diff --git a/pkg/browser/browser_tabs.go b/pkg/browser/browser_tabs.go index c1e5d5a4..3fc91e62 100644 --- a/pkg/browser/browser_tabs.go +++ b/pkg/browser/browser_tabs.go @@ -70,10 +70,16 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { // Pages are created within the tenant's incognito browser context for isolation. // If the tenant already has maxPages open, the oldest idle page is closed first. func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { + scope := scopeFromCtx(ctx) + cookies, err := m.cookiesForURL(ctx, scope, url) + if err != nil { + return nil, fmt.Errorf("load browser cookies: %w", err) + } + m.mu.Lock() defer m.mu.Unlock() - tenantID := tenantIDFromCtx(ctx) + tenantID := scope.Key() // Enforce max pages per tenant if m.maxPages > 0 { @@ -85,10 +91,24 @@ func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { return nil, err } - page, err := b.Page(proto.TargetCreateTarget{URL: url}) + initialURL := url + if len(cookies) > 0 { + initialURL = "about:blank" + } + page, err := b.Page(proto.TargetCreateTarget{URL: initialURL}) if err != nil { return nil, fmt.Errorf("open tab: %w", err) } + if len(cookies) > 0 { + if err := page.SetCookies(cookies); err != nil { + _ = page.Close() + return nil, fmt.Errorf("set browser cookies: %w", err) + } + if err := page.Navigate(url); err != nil { + _ = page.Close() + return nil, fmt.Errorf("navigate after setting cookies: %w", err) + } + } // Watchdog: close page on ctx cancel to unblock WaitStable CDP call. stopWatchdog := watchPageClose(ctx, page) diff --git a/pkg/browser/browser_tenant.go b/pkg/browser/browser_tenant.go index cc7a73bc..6a0b8795 100644 --- a/pkg/browser/browser_tenant.go +++ b/pkg/browser/browser_tenant.go @@ -1,20 +1,62 @@ package browser -import "context" +import ( + "context" + "strings" +) -// browserTenantKey is a context key for passing tenant ID to browser operations. +// browserTenantKey is a context key for passing browser scope to operations. type browserTenantKey struct{} +// BrowserScope identifies one isolated server-side browser context. +type BrowserScope struct { + TenantID string + UserID string + AgentID string +} + // WithTenantID returns a context with the browser tenant ID set. // This is used to isolate browser pages per tenant via incognito contexts. func WithTenantID(ctx context.Context, tenantID string) context.Context { - return context.WithValue(ctx, browserTenantKey{}, tenantID) + scope := scopeFromCtx(ctx) + scope.TenantID = tenantID + return context.WithValue(ctx, browserTenantKey{}, scope) } -// tenantIDFromCtx extracts the tenant ID from context. -func tenantIDFromCtx(ctx context.Context) string { - if v, ok := ctx.Value(browserTenantKey{}).(string); ok { +// WithScope returns a context with the full browser isolation scope set. +func WithScope(ctx context.Context, scope BrowserScope) context.Context { + return context.WithValue(ctx, browserTenantKey{}, scope) +} + +// Key returns the stable page/context owner key for this browser scope. +func (s BrowserScope) Key() string { + tenant := strings.TrimSpace(s.TenantID) + user := strings.TrimSpace(s.UserID) + agent := strings.TrimSpace(s.AgentID) + if user == "" && agent == "" { + return tenant + } + return "tenant=" + tenant + "|user=" + user + "|agent=" + agent +} + +func (s BrowserScope) usesMainBrowser() bool { + key := s.Key() + return key == "" || (s.TenantID == MasterTenantID && strings.TrimSpace(s.UserID) == "" && strings.TrimSpace(s.AgentID) == "") +} + +// scopeFromCtx extracts the browser isolation scope from context. +func scopeFromCtx(ctx context.Context) BrowserScope { + switch v := ctx.Value(browserTenantKey{}).(type) { + case BrowserScope: return v + case string: + return BrowserScope{TenantID: v} + default: + return BrowserScope{} } - return "" +} + +// tenantIDFromCtx returns the effective browser owner key for legacy callers. +func tenantIDFromCtx(ctx context.Context) string { + return scopeFromCtx(ctx).Key() } diff --git a/pkg/browser/browser_test.go b/pkg/browser/browser_test.go index 77d250c8..d3025966 100644 --- a/pkg/browser/browser_test.go +++ b/pkg/browser/browser_test.go @@ -1,12 +1,14 @@ package browser import ( + "context" "encoding/json" "net" "net/http" "net/http/httptest" "strings" "testing" + "time" ) // --- resolveToIPv4 --- @@ -198,6 +200,9 @@ func TestManagerOptions(t *testing.T) { m := New( WithHeadless(true), WithRemoteURL("ws://chrome:9222"), + WithActionTimeout(2*time.Second), + WithIdleTimeout(0), + WithMaxPages(7), ) if !m.headless { t.Error("WithHeadless(true) not applied") @@ -205,6 +210,16 @@ func TestManagerOptions(t *testing.T) { if m.remoteURL != "ws://chrome:9222" { t.Errorf("WithRemoteURL not applied: %q", m.remoteURL) } + status := m.Status() + if status.ActionTimeoutMs != 2000 { + t.Errorf("ActionTimeoutMs = %d, want 2000", status.ActionTimeoutMs) + } + if status.IdleTimeoutMs != 0 { + t.Errorf("IdleTimeoutMs = %d, want 0", status.IdleTimeoutMs) + } + if status.MaxPages != 7 { + t.Errorf("MaxPages = %d, want 7", status.MaxPages) + } } func TestManagerStopWhenNil(t *testing.T) { @@ -222,3 +237,44 @@ func TestManagerStatusWhenStopped(t *testing.T) { t.Error("Status.Running should be false when browser is nil") } } + +func TestBrowserScopeKeyIncludesTenantUserAndAgent(t *testing.T) { + scope := BrowserScope{ + TenantID: "tenant-a", + UserID: "user-a", + AgentID: "agent-a", + } + got := scope.Key() + for _, want := range []string{"tenant=tenant-a", "user=user-a", "agent=agent-a"} { + if !strings.Contains(got, want) { + t.Fatalf("scope key %q missing %q", got, want) + } + } +} + +func TestBrowserScopeKeyFallsBackToTenantForLegacyContext(t *testing.T) { + ctx := WithTenantID(context.Background(), "tenant-a") + if got := tenantIDFromCtx(ctx); got != "tenant-a" { + t.Fatalf("tenantIDFromCtx legacy key = %q, want tenant-a", got) + } +} + +func TestGetPageForTenantRejectsUnownedEmptyTarget(t *testing.T) { + m := New() + m.pages["master-tab"] = nil + + _, err := m.getPageForTenant("", "tenant-a") + if err == nil { + t.Fatal("expected scoped caller with no owned tabs to be denied") + } +} + +func TestGetPageForTenantRejectsUnownedExplicitTarget(t *testing.T) { + m := New() + m.pages["master-tab"] = nil + + _, err := m.getPageForTenant("master-tab", "tenant-a") + if err == nil { + t.Fatal("expected scoped caller to be denied unowned tab") + } +} diff --git a/pkg/browser/cookies.go b/pkg/browser/cookies.go new file mode 100644 index 00000000..83c7f7e7 --- /dev/null +++ b/pkg/browser/cookies.go @@ -0,0 +1,28 @@ +package browser + +import ( + "context" + "net/url" + + "github.com/go-rod/rod/lib/proto" +) + +// CookieProvider returns selected cookies for one isolated browser scope and URL. +type CookieProvider interface { + CookiesForURL(ctx context.Context, scope BrowserScope, targetURL string) ([]*proto.NetworkCookieParam, error) +} + +func browserURLSupportsCookies(targetURL string) bool { + u, err := url.Parse(targetURL) + if err != nil { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} + +func (m *Manager) cookiesForURL(ctx context.Context, scope BrowserScope, targetURL string) ([]*proto.NetworkCookieParam, error) { + if m.cookieProvider == nil || !browserURLSupportsCookies(targetURL) { + return nil, nil + } + return m.cookieProvider.CookiesForURL(ctx, scope, targetURL) +} diff --git a/pkg/browser/tool.go b/pkg/browser/tool.go index 1d819d04..1a81023d 100644 --- a/pkg/browser/tool.go +++ b/pkg/browser/tool.go @@ -139,8 +139,28 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *tools.R } // Propagate tenant ID from store context to browser context for page isolation. + scope := BrowserScope{} if tid := store.TenantIDFromContext(ctx); tid.String() != "00000000-0000-0000-0000-000000000000" { - ctx = WithTenantID(ctx, tid.String()) + scope.TenantID = tid.String() + } + scope.UserID = store.CredentialUserIDFromContext(ctx) + if agentID := store.AgentIDFromContext(ctx); agentID.String() != "00000000-0000-0000-0000-000000000000" { + scope.AgentID = agentID.String() + } else { + scope.AgentID = store.AgentKeyFromContext(ctx) + } + ctx = WithScope(ctx, scope) + + // Apply per-action timeout before startup so remote Chrome failures are bounded too. + switch action { + case "open", "navigate", "snapshot", "screenshot", "act", "tabs": + timeout := t.manager.ActionTimeout() + if ms, ok := args["timeoutMs"].(float64); ok && ms > 0 { + timeout = time.Duration(ms) * time.Millisecond + } + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() } // Auto-start browser for actions that need it @@ -151,18 +171,6 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *tools.R } } - // Apply per-action timeout for heavy operations - switch action { - case "open", "navigate", "snapshot", "screenshot", "act": - timeout := t.manager.ActionTimeout() - if ms, ok := args["timeoutMs"].(float64); ok && ms > 0 { - timeout = time.Duration(ms) * time.Millisecond - } - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, timeout) - defer cancel() - } - switch action { case "status": return t.handleStatus() diff --git a/pkg/browser/types.go b/pkg/browser/types.go index d93944d4..0a5b0f87 100644 --- a/pkg/browser/types.go +++ b/pkg/browser/types.go @@ -84,16 +84,23 @@ type WaitOpts struct { // ConsoleMessage is a captured browser console message. type ConsoleMessage struct { - Level string `json:"level"` // "log", "warn", "error", "info" - Text string `json:"text"` - URL string `json:"url,omitempty"` - LineNo int `json:"lineNo,omitempty"` - ColNo int `json:"colNo,omitempty"` + Level string `json:"level"` // "log", "warn", "error", "info" + Text string `json:"text"` + URL string `json:"url,omitempty"` + LineNo int `json:"lineNo,omitempty"` + ColNo int `json:"colNo,omitempty"` } // StatusInfo describes the current browser state. type StatusInfo struct { - Running bool `json:"running"` - Tabs int `json:"tabs"` - URL string `json:"url,omitempty"` // current tab URL + Running bool `json:"running"` + Tabs int `json:"tabs"` + URL string `json:"url,omitempty"` // current tab URL + Headless bool `json:"headless"` + RemoteURL string `json:"remoteUrl,omitempty"` + ActionTimeoutMs int `json:"actionTimeoutMs"` + IdleTimeoutMs int `json:"idleTimeoutMs"` + MaxPages int `json:"maxPages"` + IsolationMode string `json:"isolationMode"` + CookieSync bool `json:"cookieSync"` } diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index a0d28566..547c5cbf 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -815,6 +815,16 @@ "memoryFlush": "Memory Flush", "memoryFlushTip": "Before compaction, the agent gets a turn to save important context to memory files. Also triggers Knowledge Graph extraction." }, + "inboundDebounce": { + "title": "Inbound Debounce", + "description": "Override rapid Web Chat and channel message batching for this agent.", + "mode": "Mode", + "modeTip": "Inherit uses the global Behavior setting. Custom stores an override on this agent.", + "inherit": "Inherit Global", + "custom": "Custom", + "debounceMs": "Debounce (ms)", + "debounceMsTip": "Set 0 for no debounce; positive values merge follow-ups after that wait." + }, "contextPruning": { "title": "Context Pruning", "description": "Trim old tool results to save context window", diff --git a/ui/web/src/i18n/locales/en/config.json b/ui/web/src/i18n/locales/en/config.json index c345a62c..a1059e77 100644 --- a/ui/web/src/i18n/locales/en/config.json +++ b/ui/web/src/i18n/locales/en/config.json @@ -42,7 +42,7 @@ "gateway.rateLimitRpm": "Rate Limit (RPM)", "gateway.rateLimitRpmTip": "Maximum requests per minute per user. Set to 0 to disable rate limiting.", "gateway.inboundDebounceMs": "Inbound Debounce (ms)", - "gateway.inboundDebounceMsTip": "Delay in milliseconds before processing rapid channel or Web Chat follow-ups. Set 0 for the 1000ms default, or -1 to disable.", + "gateway.inboundDebounceMsTip": "Delay in milliseconds before processing rapid channel or Web Chat follow-ups. Set 0 for no debounce; positive values merge follow-ups after that wait.", "gateway.injectionAction": "Injection Detection Action", "agents.title": "Agent Defaults", @@ -210,6 +210,23 @@ "tools.execAllowlistLabel": "Allowed Commands (one pattern per line)", "tools.scrubCredentials": "Scrub Credentials", + "browser.title": "Browser Automation", + "browser.description": "Runtime settings for the server-side browser tool", + "browser.enabled": "Enabled", + "browser.enabledHint": "Expose the browser tool to agents.", + "browser.headless": "Headless", + "browser.headlessHint": "Launch local Chrome without a visible window.", + "browser.remoteUrl": "Remote CDP URL", + "browser.remoteUrlTip": "Connect to an existing Chrome DevTools endpoint instead of launching local Chrome.", + "browser.maxPages": "Max Pages", + "browser.maxPagesTip": "Maximum open pages per isolated browser scope.", + "browser.actionTimeout": "Action Timeout (ms)", + "browser.actionTimeoutTip": "Maximum time for one browser action.", + "browser.idleTimeout": "Idle Timeout (ms)", + "browser.idleTimeoutTip": "Close idle pages after this duration. Set 0 to disable.", + "browser.cookieSync": "Selected Cookie Sync", + "browser.cookieSyncHint": "Apply explicitly synced cookies to matching user and agent browser sessions.", + "tts.title": "Text-to-Speech", "tts.configured": "Configured", "tts.disabled": "Disabled", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 8bb5c2a5..9bac61c0 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -800,6 +800,16 @@ "memoryFlush": "Ghi nhớ trước nén", "memoryFlushTip": "Trước khi nén, agent được một lượt để lưu ngữ cảnh quan trọng vào file bộ nhớ. Cũng kích hoạt trích xuất Knowledge Graph." }, + "inboundDebounce": { + "title": "Debounce đầu vào", + "description": "Override việc gộp tin nhắn nhanh liên tiếp từ Web Chat và channel cho agent này.", + "mode": "Chế độ", + "modeTip": "Inherit dùng cấu hình global trong Behavior. Custom lưu override trên agent này.", + "inherit": "Kế thừa global", + "custom": "Tùy chỉnh", + "debounceMs": "Debounce (ms)", + "debounceMsTip": "Đặt 0 để không debounce; giá trị dương sẽ gộp tin nhắn sau thời gian chờ đó." + }, "contextPruning": { "title": "Cắt bớt ngữ cảnh", "description": "Cắt bỏ kết quả công cụ cũ để tiết kiệm cửa sổ ngữ cảnh", diff --git a/ui/web/src/i18n/locales/vi/config.json b/ui/web/src/i18n/locales/vi/config.json index 87da884d..b0c6a77d 100644 --- a/ui/web/src/i18n/locales/vi/config.json +++ b/ui/web/src/i18n/locales/vi/config.json @@ -42,7 +42,7 @@ "gateway.rateLimitRpm": "Giới hạn tốc độ (RPM)", "gateway.rateLimitRpmTip": "Số yêu cầu tối đa mỗi phút mỗi người dùng. Đặt 0 để tắt giới hạn.", "gateway.inboundDebounceMs": "Debounce đầu vào (ms)", - "gateway.inboundDebounceMsTip": "Độ trễ tính bằng mili giây trước khi xử lý các tin nhắn nhanh liên tiếp từ channel hoặc Web Chat. Đặt 0 để dùng mặc định 1000ms, hoặc -1 để tắt.", + "gateway.inboundDebounceMsTip": "Độ trễ tính bằng mili giây trước khi xử lý các tin nhắn nhanh liên tiếp từ channel hoặc Web Chat. Đặt 0 để không debounce; giá trị dương sẽ gộp tin nhắn sau thời gian chờ đó.", "gateway.injectionAction": "Hành động phát hiện injection", "agents.title": "Mặc định agent", @@ -210,6 +210,23 @@ "tools.execAllowlistLabel": "Lệnh được phép (mỗi dòng một mẫu)", "tools.scrubCredentials": "Ẩn thông tin xác thực", + "browser.title": "Tự động hóa trình duyệt", + "browser.description": "Cài đặt runtime cho công cụ trình duyệt phía máy chủ", + "browser.enabled": "Đã bật", + "browser.enabledHint": "Cho phép agent dùng công cụ trình duyệt.", + "browser.headless": "Headless", + "browser.headlessHint": "Chạy Chrome cục bộ không hiện cửa sổ.", + "browser.remoteUrl": "Remote CDP URL", + "browser.remoteUrlTip": "Kết nối tới endpoint Chrome DevTools có sẵn thay vì khởi chạy Chrome cục bộ.", + "browser.maxPages": "Số trang tối đa", + "browser.maxPagesTip": "Số trang đang mở tối đa cho mỗi phạm vi trình duyệt cô lập.", + "browser.actionTimeout": "Timeout thao tác (ms)", + "browser.actionTimeoutTip": "Thời gian tối đa cho một thao tác trình duyệt.", + "browser.idleTimeout": "Timeout không hoạt động (ms)", + "browser.idleTimeoutTip": "Đóng trang không hoạt động sau thời lượng này. Đặt 0 để tắt.", + "browser.cookieSync": "Đồng bộ cookie đã chọn", + "browser.cookieSyncHint": "Áp dụng cookie được đồng bộ rõ ràng vào phiên trình duyệt đúng người dùng và agent.", + "tts.title": "Chuyển văn bản thành giọng nói", "tts.configured": "Đã cấu hình", "tts.disabled": "Đã tắt", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index fef22a44..48c7ead0 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -800,6 +800,16 @@ "memoryFlush": "压缩前记忆", "memoryFlushTip": "压缩前,Agent可以将重要上下文保存到记忆文件。同时触发知识图谱提取。" }, + "inboundDebounce": { + "title": "入站防抖", + "description": "为此 Agent 覆盖 Web Chat 和频道快速连续消息的合并设置。", + "mode": "模式", + "modeTip": "继承会使用全局 Behavior 设置。自定义会在此 Agent 上保存覆盖值。", + "inherit": "继承全局", + "custom": "自定义", + "debounceMs": "防抖(毫秒)", + "debounceMsTip": "设为 0 表示不防抖;正数会在该等待时间后合并连续消息。" + }, "contextPruning": { "title": "上下文裁剪", "description": "裁剪旧工具结果以节省上下文窗口", diff --git a/ui/web/src/i18n/locales/zh/config.json b/ui/web/src/i18n/locales/zh/config.json index af808a73..1994f1df 100644 --- a/ui/web/src/i18n/locales/zh/config.json +++ b/ui/web/src/i18n/locales/zh/config.json @@ -42,7 +42,7 @@ "gateway.rateLimitRpm": "速率限制(RPM)", "gateway.rateLimitRpmTip": "每用户每分钟最大请求数。设为 0 禁用速率限制。", "gateway.inboundDebounceMs": "入站防抖(毫秒)", - "gateway.inboundDebounceMsTip": "处理来自频道或 Web Chat 的快速连续消息前的延迟毫秒数。设为 0 使用默认 1000ms,设为 -1 禁用。", + "gateway.inboundDebounceMsTip": "处理来自频道或 Web Chat 的快速连续消息前的延迟毫秒数。设为 0 表示不防抖;正数会在该等待时间后合并连续消息。", "gateway.injectionAction": "注入检测动作", "agents.title": "Agent 默认值", @@ -210,6 +210,23 @@ "tools.execAllowlistLabel": "允许的命令(每行一个模式)", "tools.scrubCredentials": "擦除凭证", + "browser.title": "浏览器自动化", + "browser.description": "服务器端浏览器工具的运行时设置", + "browser.enabled": "已启用", + "browser.enabledHint": "向 Agent 暴露浏览器工具。", + "browser.headless": "无头模式", + "browser.headlessHint": "启动本地 Chrome 时不显示窗口。", + "browser.remoteUrl": "远程 CDP URL", + "browser.remoteUrlTip": "连接到现有 Chrome DevTools 端点,而不是启动本地 Chrome。", + "browser.maxPages": "最大页面数", + "browser.maxPagesTip": "每个隔离浏览器范围允许打开的最大页面数。", + "browser.actionTimeout": "操作超时(毫秒)", + "browser.actionTimeoutTip": "单个浏览器操作的最长时间。", + "browser.idleTimeout": "空闲超时(毫秒)", + "browser.idleTimeoutTip": "页面空闲超过此时间后关闭。设为 0 禁用。", + "browser.cookieSync": "选定 Cookie 同步", + "browser.cookieSyncHint": "将明确同步的 Cookie 应用于匹配的用户和 Agent 浏览器会话。", + "tts.title": "文字转语音", "tts.configured": "已配置", "tts.disabled": "已禁用", diff --git a/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx b/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx index c1fd3196..96becf72 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx @@ -13,7 +13,7 @@ import type { } from "@/types/agent"; import { ChatGPTOAuthRoutingSection, ThinkingSection, WorkspaceSharingSection, CompactionSection, - ContextPruningSection, ModelFallbackSection, SandboxSection, + ContextPruningSection, InboundDebounceSection, ModelFallbackSection, SandboxSection, } from "./config-sections"; import { WorkspaceSection } from "./general-sections"; import { useProviders } from "@/pages/providers/hooks/use-providers"; @@ -57,6 +57,8 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age const [chatgptRouting, setChatgptRouting] = useState(init.chatgptRouting); const [modelFallback, setModelFallback] = useState(init.modelFallback); const [comp, setComp] = useState(init.comp); + const [inboundDebounceMode, setInboundDebounceMode] = useState(init.inboundDebounceMode); + const [inboundDebounceMs, setInboundDebounceMs] = useState(init.inboundDebounceMs); const [pruneEnabled, setPruneEnabled] = useState(init.pruneEnabled); const [prune, setPrune] = useState(init.prune); const [sbEnabled, setSbEnabled] = useState(init.sbEnabled); @@ -76,6 +78,8 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age setModelFallback(s.modelFallback); setWsSharing(s.wsSharing); setComp(s.comp); + setInboundDebounceMode(s.inboundDebounceMode); + setInboundDebounceMs(s.inboundDebounceMs); setPruneEnabled(s.pruneEnabled); setPrune(s.prune); setSbEnabled(s.sbEnabled); @@ -129,6 +133,8 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age modelFallback, wsSharing, comp, + inboundDebounceMode, + inboundDebounceMs, pruneEnabled, prune, sbEnabled, @@ -229,6 +235,12 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age />
+ ; + const rawInboundDebounceMs = otherConfig.inbound_debounce_ms; + const inboundDebounceMs = + typeof rawInboundDebounceMs === "number" && Number.isFinite(rawInboundDebounceMs) + ? Math.max(0, Math.trunc(rawInboundDebounceMs)) + : undefined; return { reasoningMode, @@ -100,6 +109,8 @@ export function deriveState( {} ) as WorkspaceSharingConfig, comp: agent.compaction_config ?? {}, + inboundDebounceMode: inboundDebounceMs === undefined ? "inherit" : "custom", + inboundDebounceMs: inboundDebounceMs ?? 0, pruneEnabled: agent.context_pruning?.mode === "cache-ttl", prune: agent.context_pruning ?? {}, sbEnabled: agent.sandbox_config != null, @@ -122,6 +133,8 @@ export interface BuildAdvancedUpdatePayloadParams { modelFallback: ModelFallbackConfig; wsSharing: WorkspaceSharingConfig; comp: CompactionConfig; + inboundDebounceMode: InboundDebounceOverrideMode; + inboundDebounceMs: number; pruneEnabled: boolean; prune: ContextPruningConfig; sbEnabled: boolean; @@ -135,7 +148,8 @@ export function buildAdvancedUpdatePayload( agent, currentProvider, providersLoading, providerModelsLoading, expertReasoningAvailable, reasoningMode, reasoningEffort, reasoningExpert, reasoningFallback, thinkingLevel, chatgptRouting, wsSharing, - modelFallback, comp, pruneEnabled, prune, sbEnabled, sb, + modelFallback, comp, inboundDebounceMode, inboundDebounceMs, + pruneEnabled, prune, sbEnabled, sb, } = params; const routingPayload = buildAgentOtherConfigWithChatGPTOAuthRouting( @@ -156,6 +170,11 @@ export function buildAdvancedUpdatePayload( model_fallback: normalizeModelFallbackForPayload(modelFallback), ...routingPayload, }; + updates.other_config = buildOtherConfigWithInboundDebounce( + updates.other_config, + inboundDebounceMode, + inboundDebounceMs, + ); // Build reasoning_config and thinking_level as top-level fields if (reasoningMode === "inherit") { @@ -192,6 +211,24 @@ export function buildAdvancedUpdatePayload( return updates; } +function buildOtherConfigWithInboundDebounce( + base: unknown, + mode: InboundDebounceOverrideMode, + debounceMs: number, +): Record | null { + const bag = isPlainObject(base) ? { ...base } : {}; + if (mode === "inherit") { + delete bag.inbound_debounce_ms; + } else { + bag.inbound_debounce_ms = Math.max(0, Math.trunc(Number.isFinite(debounceMs) ? debounceMs : 0)); + } + return Object.keys(bag).length > 0 ? bag : null; +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + function normalizeModelFallbackForPayload(config: ModelFallbackConfig): ModelFallbackConfig { const candidates = (config.candidates ?? []) .map((candidate) => ({ diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/inbound-debounce-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/inbound-debounce-section.tsx new file mode 100644 index 00000000..847d9090 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/config-sections/inbound-debounce-section.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { InboundDebounceOverrideMode } from "@/types/agent"; +import { InfoLabel } from "./config-section"; + +interface InboundDebounceSectionProps { + mode: InboundDebounceOverrideMode; + debounceMs: number; + onModeChange: (mode: InboundDebounceOverrideMode) => void; + onDebounceMsChange: (value: number) => void; +} + +export function InboundDebounceSection({ + mode, + debounceMs, + onModeChange, + onDebounceMsChange, +}: InboundDebounceSectionProps) { + const { t } = useTranslation("agents"); + const s = "configSections.inboundDebounce"; + return ( +
+
+

{t(`${s}.title`)}

+

{t(`${s}.description`)}

+
+
+
+
+ {t(`${s}.mode`)} + +
+
+ {t(`${s}.debounceMs`)} + onDebounceMsChange(Math.max(0, Number(event.target.value)))} + placeholder="0" + /> +
+
+
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/index.ts b/ui/web/src/pages/agents/agent-detail/config-sections/index.ts index 40563194..2c2ed95b 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/index.ts +++ b/ui/web/src/pages/agents/agent-detail/config-sections/index.ts @@ -9,3 +9,4 @@ export { ThinkingSection } from "./thinking-section"; export { WorkspaceSharingSection } from "./workspace-sharing-section"; export { ChatGPTOAuthRoutingSection } from "./chatgpt-oauth-routing-section"; export { ModelFallbackSection } from "./model-fallback-section"; +export { InboundDebounceSection } from "./inbound-debounce-section"; diff --git a/ui/web/src/pages/config/config-page.tsx b/ui/web/src/pages/config/config-page.tsx index 6bf1c7a3..c8d21f53 100644 --- a/ui/web/src/pages/config/config-page.tsx +++ b/ui/web/src/pages/config/config-page.tsx @@ -15,6 +15,7 @@ import { BehaviorSection } from "./sections/behavior-section"; import { AiDefaultsSection } from "./sections/ai-defaults-section"; import { QuotaSection } from "./sections/quota-section"; import { ToolsProfileSection } from "./sections/tools-profile-section"; +import { ToolsBrowserSection } from "./sections/tools-browser-section"; import { ToolsExecSection } from "./sections/tools-exec-section"; import { ShellSecuritySection } from "./sections/shell-security-section"; import { TtsSection } from "./sections/tts-section"; @@ -138,6 +139,11 @@ export function ConfigPage() { onSave={(v) => patch({ tools: v })} saving={saving} /> + patch({ tools: v })} + saving={saving} + /> patch({ tools: v })} diff --git a/ui/web/src/pages/config/sections/behavior-rate-card.tsx b/ui/web/src/pages/config/sections/behavior-rate-card.tsx index 5c891733..b15331cc 100644 --- a/ui/web/src/pages/config/sections/behavior-rate-card.tsx +++ b/ui/web/src/pages/config/sections/behavior-rate-card.tsx @@ -59,8 +59,8 @@ export function BehaviorRateCard({ value, onChange }: Props) { type="number" value={value.inbound_debounce_ms ?? ""} onChange={(e) => update({ inbound_debounce_ms: Number(e.target.value) })} - placeholder="1000 (-1 = disabled)" - min={-1} + placeholder="0 (no debounce)" + min={0} />
diff --git a/ui/web/src/pages/config/sections/behavior-section.tsx b/ui/web/src/pages/config/sections/behavior-section.tsx index 94126856..37b63e18 100644 --- a/ui/web/src/pages/config/sections/behavior-section.tsx +++ b/ui/web/src/pages/config/sections/behavior-section.tsx @@ -83,13 +83,8 @@ export function BehaviorSection({ config, onPatch, saving }: Props) { (v: T) => { setter(v); setDirty(true); }; const handleSave = () => { - // Strip masked/secret values to avoid overwriting real secrets with "***" - const gwClean = Object.fromEntries( - Object.entries(gw).filter(([, v]) => typeof v !== "string" || v !== "***"), - ); onPatch({ gateway: { - ...gwClean, tool_status: ux.tool_status, block_reply: ux.block_reply, max_message_chars: rate.max_message_chars, @@ -98,12 +93,11 @@ export function BehaviorSection({ config, onPatch, saving }: Props) { injection_action: security.injection_action, }, agents: { - ...config.agents, - defaults: { ...ag, intent_classify: ux.intent_classify }, + defaults: { intent_classify: ux.intent_classify }, }, - tools: { ...tl, scrub_credentials: security.scrub_credentials }, - sessions: { ...ss, ...sessions }, - channels: { ...ch, pending_compaction: pendingCompaction }, + tools: { scrub_credentials: security.scrub_credentials }, + sessions, + channels: { pending_compaction: pendingCompaction }, }); }; diff --git a/ui/web/src/pages/config/sections/tools-browser-section.tsx b/ui/web/src/pages/config/sections/tools-browser-section.tsx new file mode 100644 index 00000000..dc8b3a1a --- /dev/null +++ b/ui/web/src/pages/config/sections/tools-browser-section.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from "react"; +import { Cookie, Globe2, Save } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { InfoLabel } from "@/components/shared/info-label"; + +type ToolsData = Record; +type BrowserConfig = { + enabled?: boolean; + headless?: boolean; + remote_url?: string; + action_timeout_ms?: number; + idle_timeout_ms?: number; + max_pages?: number; + cookie_sync_enabled?: boolean; +}; + +interface Props { + data: ToolsData | undefined; + onSave: (value: ToolsData) => Promise; + saving: boolean; +} + +const toNumber = (value: string) => { + if (value.trim() === "") return undefined; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +}; + +export function ToolsBrowserSection({ data, onSave, saving }: Props) { + const { t } = useTranslation("config"); + const [draft, setDraft] = useState(data ?? {}); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + setDraft(data ?? {}); + setDirty(false); + }, [data]); + + const browser = (draft.browser ?? {}) as BrowserConfig; + const updateBrowser = (patch: Partial) => { + setDraft((prev) => ({ + ...prev, + browser: { ...(prev.browser ?? {}), ...patch }, + })); + setDirty(true); + }; + + if (!data) return null; + + return ( + + + + + {t("browser.title")} + + {t("browser.description")} + + +
+
+
+ +

{t("browser.enabledHint")}

+
+ updateBrowser({ enabled: v })} /> +
+ +
+
+ +

{t("browser.headlessHint")}

+
+ updateBrowser({ headless: v })} /> +
+
+ +
+
+ {t("browser.remoteUrl")} + updateBrowser({ remote_url: e.target.value })} + placeholder="ws://chrome:9222" + /> +
+
+ {t("browser.maxPages")} + updateBrowser({ max_pages: toNumber(e.target.value) })} + placeholder="5" + /> +
+
+ {t("browser.actionTimeout")} + updateBrowser({ action_timeout_ms: toNumber(e.target.value) })} + placeholder="30000" + /> +
+
+ {t("browser.idleTimeout")} + updateBrowser({ idle_timeout_ms: toNumber(e.target.value) })} + placeholder="600000" + /> +
+
+ +
+
+ +
+ +

{t("browser.cookieSyncHint")}

+
+
+ updateBrowser({ cookie_sync_enabled: v })} + className="shrink-0" + /> +
+ + {dirty && ( +
+ +
+ )} +
+
+ ); +} diff --git a/ui/web/src/types/agent.ts b/ui/web/src/types/agent.ts index 9016c1fc..c4934b97 100644 --- a/ui/web/src/types/agent.ts +++ b/ui/web/src/types/agent.ts @@ -108,6 +108,7 @@ export type EffectiveChatGPTOAuthRoutingStrategy = export type ChatGPTOAuthRoutingOverrideMode = "inherit" | "custom"; export type ReasoningOverrideMode = "inherit" | "custom"; +export type InboundDebounceOverrideMode = "inherit" | "custom"; export interface AgentReasoningConfig { override_mode?: ReasoningOverrideMode; @@ -115,6 +116,11 @@ export interface AgentReasoningConfig { fallback?: "downgrade" | "provider_default" | "off"; } +export interface InboundDebounceConfig { + override_mode?: InboundDebounceOverrideMode; + inbound_debounce_ms?: number; +} + export interface ChatGPTOAuthRoutingConfig { override_mode?: ChatGPTOAuthRoutingOverrideMode; strategy?: ChatGPTOAuthRoutingStrategy;