From 68684e48590b5d5662b157819ff5e36d3a640a0b Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Sun, 24 May 2026 11:27:02 +0700 Subject: [PATCH] feat(browser): add scoped cookie sync Fixes #49 --- cmd/browser_cookie_provider.go | 131 +++++++++++ cmd/browser_cookie_provider_test.go | 76 +++++++ cmd/gateway.go | 3 + cmd/gateway_http_wiring.go | 3 + cmd/gateway_system_config_sync.go | 7 + docs/18-http-api.md | 33 +++ docs/browser-cookie-sync-threat-model.md | 43 ++++ docs/project-changelog.md | 6 + .../chrome-selected-cookie-sync/README.md | 13 ++ .../chrome-selected-cookie-sync/manifest.json | 12 + .../chrome-selected-cookie-sync/popup.css | 121 ++++++++++ .../chrome-selected-cookie-sync/popup.html | 49 ++++ .../chrome-selected-cookie-sync/popup.js | 196 ++++++++++++++++ internal/config/config_channels.go | 1 + internal/config/config_extras_test.go | 20 +- internal/config/config_load.go | 5 +- internal/config/config_system.go | 12 + internal/gateway/server.go | 5 + internal/http/browser_cookies.go | 185 ++++++++++++++++ internal/http/browser_cookies_payload.go | 113 ++++++++++ internal/http/browser_cookies_test.go | 209 ++++++++++++++++++ internal/http/openapi_spec.json | 85 +++++++ internal/i18n/catalog_en.go | 6 + internal/i18n/catalog_vi.go | 6 + internal/i18n/catalog_zh.go | 6 + internal/i18n/keys.go | 6 + internal/store/browser_cookie_store.go | 110 +++++++++ internal/store/pg/browser_cookies.go | 156 +++++++++++++ internal/store/pg/factory.go | 1 + internal/store/sqlitestore/browser-cookies.go | 166 ++++++++++++++ .../store/sqlitestore/browser-cookies_test.go | 175 +++++++++++++++ internal/store/sqlitestore/factory.go | 1 + internal/store/sqlitestore/schema.go | 30 ++- internal/store/sqlitestore/schema.sql | 33 +++ internal/store/stores.go | 1 + internal/upgrade/version.go | 2 +- migrations/000069_browser_cookies.down.sql | 2 + migrations/000069_browser_cookies.up.sql | 34 +++ pkg/browser/actions.go | 34 ++- pkg/browser/browser.go | 82 ++++--- pkg/browser/browser_page.go | 16 +- pkg/browser/browser_remote.go | 12 +- pkg/browser/browser_tabs.go | 24 +- pkg/browser/browser_tenant.go | 56 ++++- pkg/browser/browser_test.go | 56 +++++ pkg/browser/cookies.go | 28 +++ pkg/browser/tool.go | 34 +-- pkg/browser/types.go | 23 +- ui/web/src/i18n/locales/en/config.json | 17 ++ ui/web/src/i18n/locales/vi/config.json | 17 ++ ui/web/src/i18n/locales/zh/config.json | 17 ++ ui/web/src/pages/config/config-page.tsx | 6 + .../config/sections/tools-browser-section.tsx | 155 +++++++++++++ 53 files changed, 2563 insertions(+), 77 deletions(-) create mode 100644 cmd/browser_cookie_provider.go create mode 100644 cmd/browser_cookie_provider_test.go create mode 100644 docs/browser-cookie-sync-threat-model.md create mode 100644 extensions/chrome-selected-cookie-sync/README.md create mode 100644 extensions/chrome-selected-cookie-sync/manifest.json create mode 100644 extensions/chrome-selected-cookie-sync/popup.css create mode 100644 extensions/chrome-selected-cookie-sync/popup.html create mode 100644 extensions/chrome-selected-cookie-sync/popup.js create mode 100644 internal/http/browser_cookies.go create mode 100644 internal/http/browser_cookies_payload.go create mode 100644 internal/http/browser_cookies_test.go create mode 100644 internal/store/browser_cookie_store.go create mode 100644 internal/store/pg/browser_cookies.go create mode 100644 internal/store/sqlitestore/browser-cookies.go create mode 100644 internal/store/sqlitestore/browser-cookies_test.go create mode 100644 migrations/000069_browser_cookies.down.sql create mode 100644 migrations/000069_browser_cookies.up.sql create mode 100644 pkg/browser/cookies.go create mode 100644 ui/web/src/pages/config/sections/tools-browser-section.tsx 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 da35b29e..debcc1da 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -172,6 +172,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_http_wiring.go b/cmd/gateway_http_wiring.go index 94d52245..28efdb47 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 bcfd5100..84560781 100644 --- a/cmd/gateway_system_config_sync.go +++ b/cmd/gateway_system_config_sync.go @@ -118,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/docs/18-http-api.md b/docs/18-http-api.md index 333be42d..97aa7001 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/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 2c903525..68fcb9fd 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -6,6 +6,12 @@ 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** 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/config/config_channels.go b/internal/config/config_channels.go index a4f3d401..a125d50e 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -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/server.go b/internal/gateway/server.go index 5301e58c..b742ef87 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 4013913d..c88fd7e5 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 fe5c1073..fd3e62af 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 0fac3cbb..ba5f9c77 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 f6644b51..b2b80122 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/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/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/factory.go b/internal/store/pg/factory.go index 22ef792c..6027e483 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/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 3f2614a2..2f7e7112 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/upgrade/version.go b/internal/upgrade/version.go index db629515..7ba6bf92 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 = 68 +const RequiredSchemaVersion uint = 69 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/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/config.json b/ui/web/src/i18n/locales/en/config.json index e130312c..a1059e77 100644 --- a/ui/web/src/i18n/locales/en/config.json +++ b/ui/web/src/i18n/locales/en/config.json @@ -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/config.json b/ui/web/src/i18n/locales/vi/config.json index 7a5ab1ba..b0c6a77d 100644 --- a/ui/web/src/i18n/locales/vi/config.json +++ b/ui/web/src/i18n/locales/vi/config.json @@ -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/config.json b/ui/web/src/i18n/locales/zh/config.json index d5541ea0..1994f1df 100644 --- a/ui/web/src/i18n/locales/zh/config.json +++ b/ui/web/src/i18n/locales/zh/config.json @@ -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/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/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 && ( +
+ +
+ )} +
+
+ ); +}