feat(browser): add scoped cookie sync

Fixes #49
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-05-24 11:27:02 +07:00
1 parent f9440baca2
commit 68684e4859
53 files changed
+2563 -77

No files matched your search

+131
View File
@@ -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 ""
}
}
+76
View File
@@ -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")
}
}
+3
View File
@@ -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.
+3
View File
@@ -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 {
+7
View File
@@ -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)
+33
View File
@@ -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.
+43
View File
@@ -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=<agent>&domain=<domain>`.
- Restart the gateway after changing browser launch settings that affect manager startup.
+6
View File
@@ -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**
@@ -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".
@@ -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": ["<all_urls>"],
"action": {
"default_title": "Sync cookies to GoClaw",
"default_popup": "popup.html"
}
}
@@ -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;
}
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GoClaw Cookie Sync</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<main>
<header>
<h1>GoClaw</h1>
<button id="refresh" type="button" title="Refresh cookies">Refresh</button>
</header>
<section class="settings">
<label>
Gateway URL
<input id="gatewayUrl" type="url" placeholder="http://localhost:18790" />
</label>
<label>
Auth token
<input id="token" type="password" autocomplete="off" />
</label>
<label>
User ID
<input id="userId" type="text" autocomplete="off" placeholder="user@example.com" />
</label>
<label>
Agent ID
<input id="agentId" type="text" autocomplete="off" placeholder="default" />
</label>
</section>
<section class="toolbar">
<button id="grant" type="button">Grant Site Access</button>
<button id="selectAll" type="button">Select All</button>
</section>
<p id="status" role="status"></p>
<div id="cookies" class="cookies"></div>
<footer>
<button id="sync" type="button">Sync Selected</button>
</footer>
</main>
<script src="popup.js"></script>
</body>
</html>
@@ -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";
}
+1
View File
@@ -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).
+16 -4
View File
@@ -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 ---
+3 -2
View File
@@ -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",
+12
View File
@@ -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)
+5
View File
@@ -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)
+185
View File
@@ -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")})
}
+113
View File
@@ -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
}
+209
View File
@@ -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)
}
}
+85
View File
@@ -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"],
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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"
+110
View File
@@ -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
}
}
+156
View File
@@ -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()
}
+1
View File
@@ -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),
@@ -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()
}
@@ -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)
}
}
+1
View File
@@ -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),
+29 -1
View File
@@ -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.
+33
View File
@@ -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);
+1
View File
@@ -24,6 +24,7 @@ type Stores struct {
Contacts ContactStore
Activity ActivityStore
Snapshots SnapshotStore
BrowserCookies BrowserCookieStore
SecureCLI SecureCLIStore
SecureCLIGrants SecureCLIAgentGrantStore
APIKeys APIKeyStore
+1 -1
View File
@@ -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
@@ -0,0 +1,2 @@
-- Revert migration 000069: selected browser cookie sync
DROP TABLE IF EXISTS browser_cookies;
+34
View File
@@ -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);
+28 -6
View File
@@ -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)
+51 -31
View File
@@ -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
+15 -1
View File
@@ -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()
+11 -1
View File
@@ -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)
+22 -2
View File
@@ -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)
+49 -7
View File
@@ -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()
}
+56
View File
@@ -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")
}
}
+28
View File
@@ -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)
}
+21 -13
View File
@@ -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()
+15 -8
View File
@@ -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"`
}
+17
View File
@@ -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",
+17
View File
@@ -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",
+17
View File
@@ -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": "已禁用",
+6
View File
@@ -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}
/>
<ToolsBrowserSection
data={config.tools as any}
onSave={(v) => patch({ tools: v })}
saving={saving}
/>
<ToolsExecSection
data={config.tools as any}
onSave={(v) => patch({ tools: v })}
@@ -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<string, any>;
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<void>;
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<ToolsData>(data ?? {});
const [dirty, setDirty] = useState(false);
useEffect(() => {
setDraft(data ?? {});
setDirty(false);
}, [data]);
const browser = (draft.browser ?? {}) as BrowserConfig;
const updateBrowser = (patch: Partial<BrowserConfig>) => {
setDraft((prev) => ({
...prev,
browser: { ...(prev.browser ?? {}), ...patch },
}));
setDirty(true);
};
if (!data) return null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Globe2 className="h-4 w-4" />
{t("browser.title")}
</CardTitle>
<CardDescription>{t("browser.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="space-y-1">
<Label className="text-sm font-medium">{t("browser.enabled")}</Label>
<p className="text-xs text-muted-foreground">{t("browser.enabledHint")}</p>
</div>
<Switch checked={browser.enabled !== false} onCheckedChange={(v) => updateBrowser({ enabled: v })} />
</div>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="space-y-1">
<Label className="text-sm font-medium">{t("browser.headless")}</Label>
<p className="text-xs text-muted-foreground">{t("browser.headlessHint")}</p>
</div>
<Switch checked={browser.headless !== false} onCheckedChange={(v) => updateBrowser({ headless: v })} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<InfoLabel tip={t("browser.remoteUrlTip")}>{t("browser.remoteUrl")}</InfoLabel>
<Input
value={browser.remote_url ?? ""}
onChange={(e) => updateBrowser({ remote_url: e.target.value })}
placeholder="ws://chrome:9222"
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("browser.maxPagesTip")}>{t("browser.maxPages")}</InfoLabel>
<Input
type="number"
min={1}
value={browser.max_pages ?? ""}
onChange={(e) => updateBrowser({ max_pages: toNumber(e.target.value) })}
placeholder="5"
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("browser.actionTimeoutTip")}>{t("browser.actionTimeout")}</InfoLabel>
<Input
type="number"
min={1000}
value={browser.action_timeout_ms ?? ""}
onChange={(e) => updateBrowser({ action_timeout_ms: toNumber(e.target.value) })}
placeholder="30000"
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("browser.idleTimeoutTip")}>{t("browser.idleTimeout")}</InfoLabel>
<Input
type="number"
min={0}
value={browser.idle_timeout_ms ?? ""}
onChange={(e) => updateBrowser({ idle_timeout_ms: toNumber(e.target.value) })}
placeholder="600000"
/>
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="flex items-start gap-3">
<Cookie className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div className="space-y-1">
<Label className="text-sm font-medium">{t("browser.cookieSync")}</Label>
<p className="text-xs text-muted-foreground">{t("browser.cookieSyncHint")}</p>
</div>
</div>
<Switch
checked={browser.cookie_sync_enabled !== false}
onCheckedChange={(v) => updateBrowser({ cookie_sync_enabled: v })}
className="shrink-0"
/>
</div>
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(draft)} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}