mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
1 parent
f9440baca2
commit
68684e4859
53 files changed
+2563
-77
No files matched your search
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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="[email protected]" />
|
||||
</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";
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
@@ -24,6 +24,7 @@ type Stores struct {
|
||||
Contacts ContactStore
|
||||
Activity ActivityStore
|
||||
Snapshots SnapshotStore
|
||||
BrowserCookies BrowserCookieStore
|
||||
SecureCLI SecureCLIStore
|
||||
SecureCLIGrants SecureCLIAgentGrantStore
|
||||
APIKeys APIKeyStore
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"`
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "已禁用",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user