mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
merge(dev): resolve usage cap conflicts
This commit is contained in:
commit
7fee3073df
111 files changed
+3956
-248
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")
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,9 @@ func runGateway() {
|
||||
}
|
||||
|
||||
pgStores, traceCollector, snapshotWorker := setupStoresAndTracing(cfg, dataDir, msgBus)
|
||||
if browserMgr != nil && pgStores != nil && pgStores.BrowserCookies != nil && cfg.Tools.Browser.CookieSyncEnabled {
|
||||
browserMgr.SetCookieProvider(newStoreBrowserCookieProvider(pgStores.BrowserCookies))
|
||||
}
|
||||
|
||||
// Recover from crashes: flip ghost 'summoning' rows to 'summon_failed'.
|
||||
// Summon goroutines don't survive process restart; stale DB rows would trap the UI.
|
||||
|
||||
@@ -84,19 +84,17 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
|
||||
// Inbound debounce: merge rapid messages from the same sender before processing.
|
||||
// Matching TS createInboundDebouncer from src/auto-reply/inbound-debounce.ts.
|
||||
debounceMs := cfg.Gateway.InboundDebounceMs
|
||||
if debounceMs == 0 {
|
||||
debounceMs = 1000 // default: 1000ms
|
||||
}
|
||||
debouncer := bus.NewInboundDebouncer(
|
||||
time.Duration(debounceMs)*time.Millisecond,
|
||||
debouncer := bus.NewInboundDebouncerFunc(
|
||||
func(msg bus.InboundMessage) time.Duration {
|
||||
return resolveInboundDebounceDelay(ctx, msg, deps)
|
||||
},
|
||||
func(msg bus.InboundMessage) {
|
||||
processNormalMessage(ctx, msg, deps)
|
||||
},
|
||||
)
|
||||
defer debouncer.Stop()
|
||||
|
||||
slog.Info("inbound debounce configured", "debounce_ms", debounceMs)
|
||||
slog.Info("inbound debounce configured", "global_debounce_ms", cfg.Gateway.InboundDebounceMs, "agent_override", true)
|
||||
|
||||
// Track background goroutines (subagent announces, teammate messages)
|
||||
// so shutdown can wait for in-flight work to complete.
|
||||
@@ -141,6 +139,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
}
|
||||
|
||||
// --- Normal messages: route through debouncer ---
|
||||
prepareInboundDebounceMessage(&msg, deps)
|
||||
debouncer.Push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func prepareInboundDebounceMessage(msg *bus.InboundMessage, deps *ConsumerDeps) {
|
||||
if msg == nil || deps == nil || deps.Cfg == nil || msg.AgentID != "" {
|
||||
return
|
||||
}
|
||||
msg.AgentID = resolveAgentRoute(deps.Cfg, msg.Channel, msg.ChatID, msg.PeerKind)
|
||||
}
|
||||
|
||||
func resolveInboundDebounceDelay(ctx context.Context, msg bus.InboundMessage, deps *ConsumerDeps) time.Duration {
|
||||
debounceMs := 0
|
||||
if deps != nil && deps.Cfg != nil {
|
||||
debounceMs = deps.Cfg.Gateway.InboundDebounceMs
|
||||
}
|
||||
if deps == nil || deps.AgentStore == nil || msg.AgentID == "" {
|
||||
return inboundDebounceDuration(debounceMs)
|
||||
}
|
||||
|
||||
agentCtx := ctx
|
||||
if msg.TenantID != uuid.Nil {
|
||||
agentCtx = store.WithTenantID(agentCtx, msg.TenantID)
|
||||
} else {
|
||||
agentCtx = store.WithTenantID(agentCtx, store.MasterTenantID)
|
||||
}
|
||||
|
||||
agentData, err := getInboundDebounceAgent(agentCtx, deps.AgentStore, msg.AgentID)
|
||||
if err != nil || agentData == nil {
|
||||
if err != nil {
|
||||
slog.Debug("inbound debounce: agent config unavailable", "agent", msg.AgentID, "error", err)
|
||||
}
|
||||
return inboundDebounceDuration(debounceMs)
|
||||
}
|
||||
if overrideMs, ok := agentData.ParseInboundDebounceMs(); ok {
|
||||
debounceMs = overrideMs
|
||||
}
|
||||
return inboundDebounceDuration(debounceMs)
|
||||
}
|
||||
|
||||
func getInboundDebounceAgent(ctx context.Context, agentStore store.AgentStore, agentID string) (*store.AgentData, error) {
|
||||
if parsed, err := uuid.Parse(agentID); err == nil && parsed != uuid.Nil {
|
||||
return agentStore.GetByID(ctx, parsed)
|
||||
}
|
||||
return agentStore.GetByKey(ctx, agentID)
|
||||
}
|
||||
|
||||
func inboundDebounceDuration(ms int) time.Duration {
|
||||
if ms <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
@@ -84,6 +84,9 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
|
||||
cronCtx, cancelCron := context.WithTimeout(context.Background(), jobTimeout)
|
||||
defer cancelCron()
|
||||
cronCtx = store.WithTenantID(cronCtx, job.TenantID)
|
||||
if job.Payload.CredentialUserID != "" {
|
||||
cronCtx = store.WithCredentialUserID(cronCtx, job.Payload.CredentialUserID)
|
||||
}
|
||||
|
||||
// Reset session before each cron run to prevent tool errors from previous
|
||||
// runs from polluting the context and blocking future executions (#294).
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func TestCronJobHandlerInjectsPayloadCredentialUserID(t *testing.T) {
|
||||
wantCredentialUserID := "tenant-user-123"
|
||||
var gotCredentialUserID string
|
||||
|
||||
sched := scheduler.NewScheduler(
|
||||
scheduler.DefaultLanes(),
|
||||
scheduler.QueueConfig{
|
||||
Mode: scheduler.QueueModeQueue,
|
||||
Cap: 1,
|
||||
Drop: scheduler.DropOld,
|
||||
DebounceMs: 0,
|
||||
MaxConcurrent: 1,
|
||||
},
|
||||
func(ctx context.Context, req agent.RunRequest) (*agent.RunResult, error) {
|
||||
gotCredentialUserID = store.CredentialUserIDFromContext(ctx)
|
||||
return &agent.RunResult{Content: "ok"}, nil
|
||||
},
|
||||
)
|
||||
defer sched.Stop()
|
||||
|
||||
handler := makeCronJobHandler(
|
||||
sched,
|
||||
nil,
|
||||
&config.Config{},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := handler(&store.CronJob{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: uuid.New(),
|
||||
Name: "credentialed-report",
|
||||
AgentID: "reporter",
|
||||
UserID: "group:telegram:-100123",
|
||||
Stateless: true,
|
||||
Payload: store.CronPayload{
|
||||
Kind: "agent_turn",
|
||||
Message: "run gh issue list",
|
||||
CredentialUserID: wantCredentialUserID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("cron handler returned error: %v", err)
|
||||
}
|
||||
if result == nil || result.Content != "ok" {
|
||||
t.Fatalf("cron result = %#v, want content ok", result)
|
||||
}
|
||||
if gotCredentialUserID != wantCredentialUserID {
|
||||
t.Fatalf("credential user ID in scheduled context = %q, want %q", gotCredentialUserID, wantCredentialUserID)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -78,6 +78,9 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *
|
||||
set(key, fmt.Sprintf("%d", val))
|
||||
}
|
||||
}
|
||||
setIntAllowZero := func(key string, val int) {
|
||||
set(key, fmt.Sprintf("%d", val))
|
||||
}
|
||||
setBool := func(key string, val *bool) {
|
||||
if val != nil {
|
||||
set(key, fmt.Sprintf("%t", *val))
|
||||
@@ -102,7 +105,7 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *
|
||||
setInt("gateway.rate_limit_rpm", cfg.Gateway.RateLimitRPM)
|
||||
setInt("gateway.max_message_chars", cfg.Gateway.MaxMessageChars)
|
||||
set("gateway.injection_action", cfg.Gateway.InjectionAction)
|
||||
setInt("gateway.inbound_debounce_ms", cfg.Gateway.InboundDebounceMs)
|
||||
setIntAllowZero("gateway.inbound_debounce_ms", cfg.Gateway.InboundDebounceMs)
|
||||
setBool("gateway.block_reply", cfg.Gateway.BlockReply)
|
||||
setBool("gateway.tool_status", cfg.Gateway.ToolStatus)
|
||||
setInt("gateway.task_recovery_interval_sec", cfg.Gateway.TaskRecoveryIntervalSec)
|
||||
@@ -115,6 +118,13 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *
|
||||
set("tools.profile", cfg.Tools.Profile)
|
||||
setInt("tools.rate_limit_per_hour", cfg.Tools.RateLimitPerHour)
|
||||
setBool("tools.scrub_credentials", cfg.Tools.ScrubCredentials)
|
||||
set("tools.browser.enabled", fmt.Sprintf("%t", cfg.Tools.Browser.Enabled))
|
||||
set("tools.browser.headless", fmt.Sprintf("%t", cfg.Tools.Browser.Headless))
|
||||
set("tools.browser.remote_url", cfg.Tools.Browser.RemoteURL)
|
||||
setInt("tools.browser.action_timeout_ms", cfg.Tools.Browser.ActionTimeoutMs)
|
||||
setIntAllowZero("tools.browser.idle_timeout_ms", cfg.Tools.Browser.IdleTimeoutMs)
|
||||
setInt("tools.browser.max_pages", cfg.Tools.Browser.MaxPages)
|
||||
set("tools.browser.cookie_sync_enabled", fmt.Sprintf("%t", cfg.Tools.Browser.CookieSyncEnabled))
|
||||
|
||||
// TTS
|
||||
set("tts.provider", cfg.Tts.Provider)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type captureSystemConfigStore struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (s *captureSystemConfigStore) Get(_ context.Context, key string) (string, error) {
|
||||
return s.data[key], nil
|
||||
}
|
||||
|
||||
func (s *captureSystemConfigStore) Set(_ context.Context, key, value string) error {
|
||||
s.data[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *captureSystemConfigStore) Delete(_ context.Context, key string) error {
|
||||
delete(s.data, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *captureSystemConfigStore) List(_ context.Context) (map[string]string, error) {
|
||||
out := make(map[string]string, len(s.data))
|
||||
maps.Copy(out, s.data)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestSeedConfigForContextPersistsZeroInboundDebounce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sc := &captureSystemConfigStore{data: map[string]string{}}
|
||||
cfg := config.Default()
|
||||
cfg.Gateway.InboundDebounceMs = 0
|
||||
|
||||
seedConfigForContext(store.WithTenantID(context.Background(), store.MasterTenantID), sc, cfg, false)
|
||||
|
||||
if got := sc.data["gateway.inbound_debounce_ms"]; got != "0" {
|
||||
t.Fatalf("gateway.inbound_debounce_ms = %q, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ The consumer routes system messages based on sender ID prefixes:
|
||||
|
||||
### Inbound Debounce
|
||||
|
||||
Normal channel messages pass through the shared inbound debouncer before agent execution. `gateway.inbound_debounce_ms` merges rapid text messages from the same `channel:chatID:senderID`; `0` uses the 1000ms default and `-1` disables debounce. Media messages bypass the wait window after flushing pending text, and command/control messages such as stop/reset and system escalations bypass debounce.
|
||||
Normal channel messages pass through the shared inbound debouncer before agent execution. `gateway.inbound_debounce_ms` merges rapid text messages from the same `channel:chatID:senderID:agentID`; `0` means no debounce and positive values set the wait window. Agents can override the global value with `other_config.inbound_debounce_ms`; unset inherits the global config. Media messages bypass the wait window after flushing pending text, and command/control messages such as stop/reset and system escalations bypass debounce.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ Agent `budget_monthly_cents` values are bridged into `usage_cap_policies` with `
|
||||
|
||||
Migration versions:
|
||||
|
||||
- PostgreSQL: `000069_usage_caps_pricing`, `000070_usage_cap_policies`, `000071_agent_budget_usage_cap_bridge`.
|
||||
- PostgreSQL: `000070_usage_caps_pricing`, `000071_usage_cap_policies`, `000072_agent_budget_usage_cap_bridge`.
|
||||
- SQLite: no schema change; feature is not active in Lite.
|
||||
|
||||
---
|
||||
|
||||
@@ -135,7 +135,7 @@ To install additional packages: pip3 install <pkg> or npm install -g <pkg>
|
||||
|
||||
- Run Python/Node scripts via exec tool
|
||||
- Install packages via `pip3 install` / `npm install -g`
|
||||
- Access files in `/app/workspace/` including `.media/` subdirectory
|
||||
- Access files in `/app/workspace/`, including `.uploads/` for current user uploads and `.media/` for legacy media refs
|
||||
- Read skill files from `.goclaw/skills-store/`
|
||||
|
||||
### What Agents CANNOT Do
|
||||
@@ -152,16 +152,18 @@ To install additional packages: pip3 install <pkg> or npm install -g <pkg>
|
||||
Uploaded files (from web chat, Telegram, Discord, etc.) are persisted to:
|
||||
|
||||
```
|
||||
/app/workspace/.media/{sessionHash}/{uuid}.{ext}
|
||||
/app/workspace/.uploads/{safe-original-name}-{8hex}.{ext}
|
||||
```
|
||||
|
||||
Uploads without a usable original filename fall back to `{uuid}.{ext}`. Legacy media refs may still resolve from `.media/{sessionHash}/{uuid}.{ext}`.
|
||||
|
||||
The `enrichDocumentPaths()` function injects the full path into `<media:document>` tags:
|
||||
|
||||
```
|
||||
<media:document name="report.pdf" path="/app/workspace/.media/abc123/uuid.pdf">
|
||||
<media:document name="report.pdf" path="/app/workspace/.uploads/report-a1b2c3d4.pdf">
|
||||
```
|
||||
|
||||
Agents can read these files directly via exec — no copy to `/tmp` needed.
|
||||
Agents can read these files directly via exec — no copy to `/tmp` needed. For archive uploads such as `.zip`, inspect or extract with commands like `unzip -l "<path>"` or `unzip -q "<path>" -d <output-dir>`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -106,7 +106,7 @@ Send a message to an agent and trigger execution.
|
||||
|
||||
When `stream: true`, intermediate events are emitted: `chunk`, `tool.call`, `tool.result`, `run.started`, `run.completed`.
|
||||
|
||||
Rapid text-only `chat.send` requests for the same user and session are debounced by `gateway.inbound_debounce_ms`: `0` uses the 1000ms default and `-1` disables debounce. The merged message keeps request params from the latest send and joins text with newlines. Cancel keywords bypass debounce and abort the active run immediately. Media sends bypass the wait window and drain any pending text into the same dispatch.
|
||||
Rapid text-only `chat.send` requests for the same user and session are debounced by `gateway.inbound_debounce_ms`: `0` means no debounce and positive values set the wait window. Agents can override the global value with `other_config.inbound_debounce_ms`; unset inherits the global config. The merged message keeps request params from the latest send and joins text with newlines. Cancel keywords bypass debounce and abort the active run immediately. Media sends bypass the wait window and drain any pending text into the same dispatch.
|
||||
|
||||
### `chat.history`
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -4,6 +4,27 @@ Significant changes, features, and fixes in reverse chronological order.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-24
|
||||
|
||||
### Browser cookie sync and config UI
|
||||
|
||||
**Features**
|
||||
|
||||
- Added scoped browser cookie sync API, encrypted cookie store, browser runtime cookie application, dashboard browser settings, and a selected-cookie Chrome extension prototype.
|
||||
|
||||
### Cron SecureCLI credential context
|
||||
|
||||
**Fixes**
|
||||
|
||||
- Fixed cron-triggered agent turns so credentialed CLI lookups preserve the explicit tenant user credential identity captured when the cron job is created.
|
||||
- Kept cron `user_id` ownership unchanged for group-scoped list/remove behavior while storing credential lookup identity separately in cron payload metadata.
|
||||
|
||||
**Tests**
|
||||
|
||||
- Added regression coverage for cron payload credential metadata, legacy payload compatibility, cron scheduler context injection, and SQLite cron persistence parity.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-22
|
||||
|
||||
### Usage Cap budget controls
|
||||
@@ -30,11 +51,13 @@ Significant changes, features, and fixes in reverse chronological order.
|
||||
|
||||
**Features**
|
||||
|
||||
- Added per-agent inbound debounce override via `other_config.inbound_debounce_ms`; unset inherits the global gateway setting.
|
||||
- Added Web Chat debounce for rapid text-only `chat.send` calls using `gateway.inbound_debounce_ms`.
|
||||
- Clarified shared inbound debounce behavior in docs and Web UI config help text.
|
||||
|
||||
**Fixes**
|
||||
|
||||
- Fixed inbound debounce semantics so `gateway.inbound_debounce_ms=0` means no debounce and positive values set the wait window.
|
||||
- Fixed Slack `debounce_delay: 0` so it disables per-thread batching instead of falling back to the default.
|
||||
|
||||
**Tests**
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# GoClaw Selected Cookie Sync Extension
|
||||
|
||||
Chrome MV3 extension for explicit selected-cookie sync into GoClaw browser sessions.
|
||||
|
||||
Security model:
|
||||
- No automatic background sync.
|
||||
- Requests host permission only for the active tab origin.
|
||||
- Requests gateway-origin permission before sending the selected cookies.
|
||||
- Sends only checked cookies.
|
||||
- Sends `userId` and `agentId`; the gateway derives tenant scope from auth.
|
||||
- Stores extension settings in `chrome.storage.local`.
|
||||
|
||||
Load locally from `chrome://extensions` with Developer Mode and "Load unpacked".
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "GoClaw Selected Cookie Sync",
|
||||
"version": "0.1.0",
|
||||
"description": "Explicitly sync selected cookies from the active tab to a scoped GoClaw browser session.",
|
||||
"permissions": ["activeTab", "cookies", "storage", "tabs"],
|
||||
"optional_host_permissions": ["<all_urls>"],
|
||||
"action": {
|
||||
"default_title": "Sync cookies to GoClaw",
|
||||
"default_popup": "popup.html"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 380px;
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
background: #f7f8fb;
|
||||
font: 13px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
header,
|
||||
.toolbar,
|
||||
footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.settings {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
min-height: 34px;
|
||||
width: 100%;
|
||||
border: 1px solid #c8cfda;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #fff;
|
||||
color: #172033;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 34px;
|
||||
border: 1px solid #b8c1cf;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
color: #172033;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
#sync {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
color: #fff;
|
||||
border-color: #176b4d;
|
||||
background: #18825b;
|
||||
}
|
||||
|
||||
#sync:hover {
|
||||
background: #126d4c;
|
||||
}
|
||||
|
||||
#status {
|
||||
min-height: 18px;
|
||||
margin: 10px 0 8px;
|
||||
color: #526074;
|
||||
}
|
||||
|
||||
.cookies {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cookie-row {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid #d9dee7;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cookie-row input {
|
||||
width: 16px;
|
||||
min-height: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cookie-name {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cookie-meta {
|
||||
overflow-wrap: anywhere;
|
||||
color: #526074;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>GoClaw Cookie Sync</title>
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>GoClaw</h1>
|
||||
<button id="refresh" type="button" title="Refresh cookies">Refresh</button>
|
||||
</header>
|
||||
|
||||
<section class="settings">
|
||||
<label>
|
||||
Gateway URL
|
||||
<input id="gatewayUrl" type="url" placeholder="http://localhost:18790" />
|
||||
</label>
|
||||
<label>
|
||||
Auth token
|
||||
<input id="token" type="password" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
User ID
|
||||
<input id="userId" type="text" autocomplete="off" placeholder="user@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
Agent ID
|
||||
<input id="agentId" type="text" autocomplete="off" placeholder="default" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="toolbar">
|
||||
<button id="grant" type="button">Grant Site Access</button>
|
||||
<button id="selectAll" type="button">Select All</button>
|
||||
</section>
|
||||
|
||||
<p id="status" role="status"></p>
|
||||
<div id="cookies" class="cookies"></div>
|
||||
|
||||
<footer>
|
||||
<button id="sync" type="button">Sync Selected</button>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,196 @@
|
||||
const state = {
|
||||
tab: null,
|
||||
originPattern: "",
|
||||
cookies: [],
|
||||
};
|
||||
|
||||
const els = {
|
||||
gatewayUrl: document.getElementById("gatewayUrl"),
|
||||
token: document.getElementById("token"),
|
||||
userId: document.getElementById("userId"),
|
||||
agentId: document.getElementById("agentId"),
|
||||
grant: document.getElementById("grant"),
|
||||
refresh: document.getElementById("refresh"),
|
||||
selectAll: document.getElementById("selectAll"),
|
||||
sync: document.getElementById("sync"),
|
||||
cookies: document.getElementById("cookies"),
|
||||
status: document.getElementById("status"),
|
||||
};
|
||||
|
||||
init().catch((err) => setStatus(err.message, true));
|
||||
|
||||
async function init() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
state.tab = tab;
|
||||
state.originPattern = originPatternFor(tab?.url || "");
|
||||
const settings = await chrome.storage.local.get(["gatewayUrl", "token", "userId", "agentId"]);
|
||||
els.gatewayUrl.value = settings.gatewayUrl || "http://localhost:18790";
|
||||
els.token.value = settings.token || "";
|
||||
els.userId.value = settings.userId || "";
|
||||
els.agentId.value = settings.agentId || "default";
|
||||
bindEvents();
|
||||
await loadCookies();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
for (const input of [els.gatewayUrl, els.token, els.userId, els.agentId]) {
|
||||
input.addEventListener("change", saveSettings);
|
||||
}
|
||||
els.grant.addEventListener("click", requestSiteAccess);
|
||||
els.refresh.addEventListener("click", loadCookies);
|
||||
els.selectAll.addEventListener("click", selectAllCookies);
|
||||
els.sync.addEventListener("click", syncSelected);
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
await chrome.storage.local.set({
|
||||
gatewayUrl: els.gatewayUrl.value.trim(),
|
||||
token: els.token.value,
|
||||
userId: els.userId.value.trim(),
|
||||
agentId: els.agentId.value.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
async function requestSiteAccess() {
|
||||
if (!state.originPattern) {
|
||||
setStatus("Active tab is not an HTTP site.", true);
|
||||
return;
|
||||
}
|
||||
const granted = await chrome.permissions.request({ origins: [state.originPattern] });
|
||||
if (!granted) {
|
||||
setStatus("Site access not granted.", true);
|
||||
return;
|
||||
}
|
||||
await loadCookies();
|
||||
}
|
||||
|
||||
async function loadCookies() {
|
||||
if (!state.tab?.url || !state.originPattern) {
|
||||
setStatus("Open an HTTP site tab first.", true);
|
||||
return;
|
||||
}
|
||||
const hasPermission = await chrome.permissions.contains({ origins: [state.originPattern] });
|
||||
if (!hasPermission) {
|
||||
state.cookies = [];
|
||||
renderCookies();
|
||||
setStatus("Grant access for this site before reading cookies.");
|
||||
return;
|
||||
}
|
||||
state.cookies = await chrome.cookies.getAll({ url: state.tab.url });
|
||||
renderCookies();
|
||||
setStatus(`${state.cookies.length} cookies available for this site.`);
|
||||
}
|
||||
|
||||
function renderCookies() {
|
||||
els.cookies.textContent = "";
|
||||
if (state.cookies.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "cookie-meta";
|
||||
empty.textContent = "No cookies loaded.";
|
||||
els.cookies.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const cookie of state.cookies) {
|
||||
const row = document.createElement("label");
|
||||
row.className = "cookie-row";
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.dataset.cookieKey = cookieKey(cookie);
|
||||
const body = document.createElement("div");
|
||||
const name = document.createElement("div");
|
||||
name.className = "cookie-name";
|
||||
name.textContent = cookie.name;
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "cookie-meta";
|
||||
meta.textContent = `${cookie.domain}${cookie.path || "/"}${cookie.httpOnly ? " · HttpOnly" : ""}${cookie.secure ? " · Secure" : ""}`;
|
||||
body.append(name, meta);
|
||||
row.append(checkbox, body);
|
||||
els.cookies.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllCookies() {
|
||||
for (const checkbox of els.cookies.querySelectorAll("input[type='checkbox']")) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSelected() {
|
||||
await saveSettings();
|
||||
const selectedKeys = new Set(
|
||||
[...els.cookies.querySelectorAll("input[type='checkbox']:checked")].map((el) => el.dataset.cookieKey),
|
||||
);
|
||||
const cookies = state.cookies.filter((cookie) => selectedKeys.has(cookieKey(cookie)));
|
||||
if (cookies.length === 0) {
|
||||
setStatus("Select at least one cookie.", true);
|
||||
return;
|
||||
}
|
||||
const gatewayUrl = els.gatewayUrl.value.trim().replace(/\/+$/, "");
|
||||
const userId = els.userId.value.trim();
|
||||
const agentId = els.agentId.value.trim();
|
||||
if (!gatewayUrl || !userId || !agentId) {
|
||||
setStatus("Gateway URL, User ID, and Agent ID are required.", true);
|
||||
return;
|
||||
}
|
||||
if (!(await ensureOriginPermission(gatewayUrl))) {
|
||||
setStatus("Gateway access not granted.", true);
|
||||
return;
|
||||
}
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-GoClaw-User-Id": userId,
|
||||
};
|
||||
if (els.token.value) {
|
||||
headers.Authorization = `Bearer ${els.token.value}`;
|
||||
}
|
||||
const response = await fetch(`${gatewayUrl}/v1/browser/cookies/sync`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
agent_id: agentId,
|
||||
source: "chrome-selected-cookie-sync",
|
||||
cookies: cookies.map((cookie) => ({
|
||||
domain: cookie.domain,
|
||||
name: cookie.name,
|
||||
path: cookie.path,
|
||||
value: cookie.value,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
expirationDate: cookie.expirationDate,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setStatus(data.error || `Sync failed with HTTP ${response.status}.`, true);
|
||||
return;
|
||||
}
|
||||
setStatus(`Synced ${data.synced ?? cookies.length} cookies.`);
|
||||
}
|
||||
|
||||
async function ensureOriginPermission(rawUrl) {
|
||||
const pattern = originPatternFor(rawUrl);
|
||||
if (!pattern) return false;
|
||||
if (await chrome.permissions.contains({ origins: [pattern] })) return true;
|
||||
return chrome.permissions.request({ origins: [pattern] });
|
||||
}
|
||||
|
||||
function originPatternFor(rawUrl) {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
||||
return `${url.protocol}//${url.host}/*`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function cookieKey(cookie) {
|
||||
return `${cookie.domain}\n${cookie.path}\n${cookie.name}`;
|
||||
}
|
||||
|
||||
function setStatus(message, isError = false) {
|
||||
els.status.textContent = message;
|
||||
els.status.style.color = isError ? "#b42318" : "#526074";
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
// Resolve merged tenant user identity for credential lookups.
|
||||
// Keeps UserID unchanged (session/workspace scoping) but sets a separate
|
||||
// CredentialUserID for SecureCLI, MCP, and other per-user features.
|
||||
if l.userResolver != nil && req.UserID != "" {
|
||||
if l.userResolver != nil && req.UserID != "" && store.ExplicitCredentialUserIDFromContext(ctx) == "" {
|
||||
credUserID := l.resolveCredentialUserID(ctx, *req)
|
||||
if credUserID != "" && credUserID != req.UserID {
|
||||
ctx = store.WithCredentialUserID(ctx, credUserID)
|
||||
@@ -354,7 +354,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
providerName = l.provider.Name()
|
||||
}
|
||||
// Extract resolved credential user ID (set earlier via WithCredentialUserID, empty if not resolved).
|
||||
credUserID, _ := ctx.Value(store.CredentialUserIDKey).(string)
|
||||
credUserID := store.ExplicitCredentialUserIDFromContext(ctx)
|
||||
rc := &store.RunContext{
|
||||
AgentID: l.agentUUID,
|
||||
AgentKey: l.id,
|
||||
|
||||
@@ -27,10 +27,11 @@ func isTextMime(mime string) bool {
|
||||
}
|
||||
|
||||
// collectRefsByKind gathers MediaRefs of a given kind from message history
|
||||
// (reverse order) and current-turn refs. Historical first, current last.
|
||||
// in chronological order, then appends current-turn refs. The last ref is the
|
||||
// newest document for read_document's omitted media_id fallback.
|
||||
func collectRefsByKind(messages []providers.Message, currentRefs []providers.MediaRef, kind string) []providers.MediaRef {
|
||||
var refs []providers.MediaRef
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
for i := range messages {
|
||||
for _, ref := range messages[i].MediaRefs {
|
||||
if ref.Kind == kind {
|
||||
refs = append(refs, ref)
|
||||
|
||||
@@ -72,6 +72,14 @@ func TestPersistMedia_NamingScheme(t *testing.T) {
|
||||
wantSlug: "",
|
||||
wantExtPat: `\.pdf`,
|
||||
},
|
||||
{
|
||||
name: "zip_archive_preserves_archive_extension",
|
||||
filename: "codex.zip",
|
||||
content: "zip bytes",
|
||||
mime: "application/zip",
|
||||
wantSlug: "codex",
|
||||
wantExtPat: `\.zip`,
|
||||
},
|
||||
}
|
||||
|
||||
var loop Loop
|
||||
|
||||
@@ -73,6 +73,34 @@ func TestEnrichImageIDs_SkipsAlreadyEnriched(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRefsByKindOrdersOldestToNewestThenCurrent(t *testing.T) {
|
||||
messages := []providers.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "old",
|
||||
MediaRefs: []providers.MediaRef{{ID: "old-doc", Kind: "document"}},
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "latest-history",
|
||||
MediaRefs: []providers.MediaRef{{ID: "latest-history-doc", Kind: "document"}},
|
||||
},
|
||||
}
|
||||
current := []providers.MediaRef{{ID: "current-doc", Kind: "document"}}
|
||||
|
||||
got := collectRefsByKind(messages, current, "document")
|
||||
want := []string{"old-doc", "latest-history-doc", "current-doc"}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d refs, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i].ID != want[i] {
|
||||
t.Fatalf("ref[%d] = %q, want %q", i, got[i].ID, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testMediaStore creates a temporary media.Store for tests.
|
||||
func testMediaStore(t *testing.T) *media.Store {
|
||||
t.Helper()
|
||||
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
// InboundDebouncer buffers rapid inbound messages from the same sender
|
||||
// and merges them into a single message before calling flushFn.
|
||||
type InboundDebouncer struct {
|
||||
debounceMs time.Duration
|
||||
mu sync.Mutex
|
||||
buffers map[string]*debounceBuffer
|
||||
flushFn func(InboundMessage)
|
||||
delayFn func(InboundMessage) time.Duration
|
||||
mu sync.Mutex
|
||||
buffers map[string]*debounceBuffer
|
||||
flushFn func(InboundMessage)
|
||||
}
|
||||
|
||||
type debounceBuffer struct {
|
||||
@@ -30,18 +30,30 @@ type debounceBuffer struct {
|
||||
// NewInboundDebouncer creates a debouncer with the given window and flush callback.
|
||||
// If debounceMs <= 0, messages are passed through immediately (debouncing disabled).
|
||||
func NewInboundDebouncer(debounceMs time.Duration, flushFn func(InboundMessage)) *InboundDebouncer {
|
||||
return NewInboundDebouncerFunc(func(InboundMessage) time.Duration {
|
||||
return debounceMs
|
||||
}, flushFn)
|
||||
}
|
||||
|
||||
// NewInboundDebouncerFunc creates a debouncer whose window can vary per message.
|
||||
func NewInboundDebouncerFunc(delayFn func(InboundMessage) time.Duration, flushFn func(InboundMessage)) *InboundDebouncer {
|
||||
if delayFn == nil {
|
||||
delayFn = func(InboundMessage) time.Duration { return 0 }
|
||||
}
|
||||
return &InboundDebouncer{
|
||||
debounceMs: debounceMs,
|
||||
buffers: make(map[string]*debounceBuffer),
|
||||
flushFn: flushFn,
|
||||
delayFn: delayFn,
|
||||
buffers: make(map[string]*debounceBuffer),
|
||||
flushFn: flushFn,
|
||||
}
|
||||
}
|
||||
|
||||
// Push adds a message to the debounce buffer.
|
||||
// If debouncing is disabled or the message should bypass (media), it is flushed immediately.
|
||||
func (d *InboundDebouncer) Push(msg InboundMessage) {
|
||||
debounceMs := d.delayFn(msg)
|
||||
|
||||
// Disabled: pass through immediately.
|
||||
if d.debounceMs <= 0 {
|
||||
if debounceMs <= 0 {
|
||||
d.flushFn(msg)
|
||||
return
|
||||
}
|
||||
@@ -70,13 +82,13 @@ func (d *InboundDebouncer) Push(msg InboundMessage) {
|
||||
if buf.timer != nil {
|
||||
buf.timer.Stop()
|
||||
}
|
||||
buf.timer = time.AfterFunc(d.debounceMs, func() {
|
||||
buf.timer = time.AfterFunc(debounceMs, func() {
|
||||
d.flushKey(key)
|
||||
})
|
||||
|
||||
if len(buf.messages) == 1 {
|
||||
slog.Debug("inbound debounce: buffering",
|
||||
"key", key, "debounce_ms", d.debounceMs.Milliseconds())
|
||||
"key", key, "debounce_ms", debounceMs.Milliseconds())
|
||||
} else {
|
||||
slog.Debug("inbound debounce: message appended",
|
||||
"key", key, "buffered", len(buf.messages))
|
||||
@@ -127,9 +139,9 @@ func (d *InboundDebouncer) flushKey(key string) {
|
||||
d.flushFn(merged)
|
||||
}
|
||||
|
||||
// debounceKey builds the buffer key: channel:chatID:senderID.
|
||||
// debounceKey builds the buffer key: channel:chatID:senderID:agentID.
|
||||
func debounceKey(msg InboundMessage) string {
|
||||
return msg.Channel + ":" + msg.ChatID + ":" + msg.SenderID
|
||||
return msg.Channel + ":" + msg.ChatID + ":" + msg.SenderID + ":" + msg.AgentID
|
||||
}
|
||||
|
||||
// mergeInboundMessages combines multiple messages into one.
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestInboundDebouncerMergesRapidText(t *testing.T) {
|
||||
|
||||
func TestInboundDebouncerDisabledPassesThrough(t *testing.T) {
|
||||
out := make(chan InboundMessage, 2)
|
||||
d := NewInboundDebouncer(-1, func(msg InboundMessage) {
|
||||
d := NewInboundDebouncer(0, func(msg InboundMessage) {
|
||||
out <- msg
|
||||
})
|
||||
|
||||
@@ -41,6 +41,48 @@ func TestInboundDebouncerDisabledPassesThrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundDebouncerDynamicDelay(t *testing.T) {
|
||||
out := make(chan InboundMessage, 2)
|
||||
d := NewInboundDebouncerFunc(func(msg InboundMessage) time.Duration {
|
||||
if msg.AgentID == "instant" {
|
||||
return 0
|
||||
}
|
||||
return 20 * time.Millisecond
|
||||
}, func(msg InboundMessage) {
|
||||
out <- msg
|
||||
})
|
||||
defer d.Stop()
|
||||
|
||||
d.Push(InboundMessage{Channel: "telegram", ChatID: "chat-1", SenderID: "user-1", AgentID: "instant", Content: "one"})
|
||||
d.Push(InboundMessage{Channel: "telegram", ChatID: "chat-1", SenderID: "user-1", AgentID: "debounced", Content: "two"})
|
||||
d.Push(InboundMessage{Channel: "telegram", ChatID: "chat-1", SenderID: "user-1", AgentID: "debounced", Content: "three"})
|
||||
|
||||
if got := waitInbound(t, out); got.Content != "one" {
|
||||
t.Fatalf("instant content = %q", got.Content)
|
||||
}
|
||||
if got := waitInbound(t, out); got.Content != "two\nthree" {
|
||||
t.Fatalf("debounced content = %q", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundDebouncerSeparatesAgents(t *testing.T) {
|
||||
out := make(chan InboundMessage, 2)
|
||||
d := NewInboundDebouncer(20*time.Millisecond, func(msg InboundMessage) {
|
||||
out <- msg
|
||||
})
|
||||
defer d.Stop()
|
||||
|
||||
d.Push(InboundMessage{Channel: "telegram", ChatID: "chat-1", SenderID: "user-1", AgentID: "agent-a", Content: "a"})
|
||||
d.Push(InboundMessage{Channel: "telegram", ChatID: "chat-1", SenderID: "user-1", AgentID: "agent-b", Content: "b"})
|
||||
|
||||
first := waitInbound(t, out)
|
||||
second := waitInbound(t, out)
|
||||
got := map[string]string{first.AgentID: first.Content, second.AgentID: second.Content}
|
||||
if got["agent-a"] != "a" || got["agent-b"] != "b" {
|
||||
t.Fatalf("agent buffers = %#v, want separate flushes", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundDebouncerMediaFlushesPendingTextFirst(t *testing.T) {
|
||||
out := make(chan InboundMessage, 2)
|
||||
d := NewInboundDebouncer(time.Minute, func(msg InboundMessage) {
|
||||
|
||||
@@ -36,9 +36,11 @@ const (
|
||||
// Stored in RAM only (not persisted to DB) — used by channels that defer media
|
||||
// download until the bot is actually mentioned (e.g. Telegram).
|
||||
type MediaRef struct {
|
||||
Type string // "image", "video", "audio", "voice", "document", "animation"
|
||||
FileID string // platform-specific file ID for lazy download
|
||||
FileSize int64 // file size in bytes (0 if unknown) — used to skip large files
|
||||
Type string // "image", "video", "audio", "voice", "document", "animation"
|
||||
FileID string // platform-specific file ID for lazy download
|
||||
FileSize int64 // file size in bytes (0 if unknown) — used to skip large files
|
||||
FileName string // original filename, if the platform exposes one
|
||||
ContentType string // MIME type, if the platform exposes one
|
||||
}
|
||||
|
||||
// HistoryEntry represents a single tracked group message.
|
||||
|
||||
@@ -513,14 +513,8 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
|
||||
// Resolve deferred media from history entries (lazy download).
|
||||
if histRefs := c.GroupHistory().CollectMediaRefs(localKey); len(histRefs) > 0 {
|
||||
histMedia, histErrors := c.resolveMediaRefs(ctx, histRefs)
|
||||
for _, m := range histMedia {
|
||||
mediaFiles = append(mediaFiles, bus.MediaFile{
|
||||
Path: m.FilePath,
|
||||
MimeType: m.ContentType,
|
||||
Filename: m.FileName,
|
||||
})
|
||||
}
|
||||
if len(histMedia) > 0 {
|
||||
mediaFiles = prependMediaInfoFiles(mediaFiles, histMedia)
|
||||
histTags := buildMediaTags(histMedia)
|
||||
annotated = "[Media from recent group messages — only analyze if user asks about them]\n" + histTags + "\n[/Media]\n\n" + annotated
|
||||
}
|
||||
@@ -577,12 +571,12 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
|
||||
// user sees typing indicator → first content appears directly.
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_id": fmt.Sprintf("%d", message.MessageID),
|
||||
"user_id": fmt.Sprintf("%d", user.ID),
|
||||
"message_id": fmt.Sprintf("%d", message.MessageID),
|
||||
"user_id": fmt.Sprintf("%d", user.ID),
|
||||
tools.MetaUsername: user.Username,
|
||||
"first_name": user.FirstName,
|
||||
"is_group": fmt.Sprintf("%t", isGroup),
|
||||
"local_key": localKey,
|
||||
"first_name": user.FirstName,
|
||||
"is_group": fmt.Sprintf("%t", isGroup),
|
||||
"local_key": localKey,
|
||||
}
|
||||
if message.Chat.Title != "" {
|
||||
metadata[tools.MetaChatTitle] = message.Chat.Title
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
@@ -23,6 +24,9 @@ const (
|
||||
// defaultMediaMaxBytes is the default max download size for the official Bot API (20 MB).
|
||||
defaultMediaMaxBytes int64 = 20 * 1024 * 1024
|
||||
|
||||
// officialAPIOutboundMaxBytes is Telegram's upload limit for Bot API media sends.
|
||||
officialAPIOutboundMaxBytes int64 = 50 * 1024 * 1024
|
||||
|
||||
// localAPIDefaultMaxBytes is the default max download size when a local Bot API server
|
||||
// is configured. The local server supports up to 2 GB; we default to 200 MB and let
|
||||
// downstream providers enforce their own limits.
|
||||
@@ -356,6 +360,24 @@ func buildMediaTags(mediaList []MediaInfo) string {
|
||||
return media.BuildMediaTags(mediaList)
|
||||
}
|
||||
|
||||
func prependMediaInfoFiles(current []bus.MediaFile, history []MediaInfo) []bus.MediaFile {
|
||||
if len(history) == 0 {
|
||||
return current
|
||||
}
|
||||
ordered := make([]bus.MediaFile, 0, len(history)+len(current))
|
||||
for _, m := range history {
|
||||
if m.FilePath == "" {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, bus.MediaFile{
|
||||
Path: m.FilePath,
|
||||
MimeType: m.ContentType,
|
||||
Filename: m.FileName,
|
||||
})
|
||||
}
|
||||
return append(ordered, current...)
|
||||
}
|
||||
|
||||
// extractDocumentContent delegates to the shared media package.
|
||||
func extractDocumentContent(filePath, fileName string) (string, error) {
|
||||
return media.ExtractDocumentContent(filePath, fileName)
|
||||
@@ -409,22 +431,51 @@ func extractMediaRefs(msg *telego.Message) []channels.MediaRef {
|
||||
refs = append(refs, channels.MediaRef{Type: "image", FileID: photo.FileID, FileSize: int64(photo.FileSize)})
|
||||
}
|
||||
if msg.Video != nil {
|
||||
refs = append(refs, channels.MediaRef{Type: "video", FileID: msg.Video.FileID, FileSize: int64(msg.Video.FileSize)})
|
||||
refs = append(refs, channels.MediaRef{
|
||||
Type: "video",
|
||||
FileID: msg.Video.FileID,
|
||||
FileSize: int64(msg.Video.FileSize),
|
||||
FileName: msg.Video.FileName,
|
||||
ContentType: msg.Video.MimeType,
|
||||
})
|
||||
}
|
||||
if msg.VideoNote != nil {
|
||||
refs = append(refs, channels.MediaRef{Type: "video", FileID: msg.VideoNote.FileID, FileSize: int64(msg.VideoNote.FileSize)})
|
||||
}
|
||||
if msg.Animation != nil {
|
||||
refs = append(refs, channels.MediaRef{Type: "animation", FileID: msg.Animation.FileID, FileSize: int64(msg.Animation.FileSize)})
|
||||
refs = append(refs, channels.MediaRef{
|
||||
Type: "animation",
|
||||
FileID: msg.Animation.FileID,
|
||||
FileSize: int64(msg.Animation.FileSize),
|
||||
FileName: msg.Animation.FileName,
|
||||
ContentType: msg.Animation.MimeType,
|
||||
})
|
||||
}
|
||||
if msg.Audio != nil {
|
||||
refs = append(refs, channels.MediaRef{Type: "audio", FileID: msg.Audio.FileID, FileSize: int64(msg.Audio.FileSize)})
|
||||
refs = append(refs, channels.MediaRef{
|
||||
Type: "audio",
|
||||
FileID: msg.Audio.FileID,
|
||||
FileSize: int64(msg.Audio.FileSize),
|
||||
FileName: msg.Audio.FileName,
|
||||
ContentType: msg.Audio.MimeType,
|
||||
})
|
||||
}
|
||||
if msg.Voice != nil {
|
||||
refs = append(refs, channels.MediaRef{Type: "voice", FileID: msg.Voice.FileID, FileSize: int64(msg.Voice.FileSize)})
|
||||
refs = append(refs, channels.MediaRef{
|
||||
Type: "voice",
|
||||
FileID: msg.Voice.FileID,
|
||||
FileSize: int64(msg.Voice.FileSize),
|
||||
ContentType: msg.Voice.MimeType,
|
||||
})
|
||||
}
|
||||
if msg.Document != nil {
|
||||
refs = append(refs, channels.MediaRef{Type: "document", FileID: msg.Document.FileID, FileSize: int64(msg.Document.FileSize)})
|
||||
refs = append(refs, channels.MediaRef{
|
||||
Type: "document",
|
||||
FileID: msg.Document.FileID,
|
||||
FileSize: int64(msg.Document.FileSize),
|
||||
FileName: msg.Document.FileName,
|
||||
ContentType: msg.Document.MimeType,
|
||||
})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
@@ -489,9 +540,12 @@ func (c *Channel) resolveMediaRefs(ctx context.Context, refs []channels.MediaRef
|
||||
continue
|
||||
}
|
||||
results = append(results, MediaInfo{
|
||||
Type: ref.Type,
|
||||
FilePath: filePath,
|
||||
FileID: ref.FileID,
|
||||
Type: ref.Type,
|
||||
FilePath: filePath,
|
||||
FileID: ref.FileID,
|
||||
ContentType: ref.ContentType,
|
||||
FileName: ref.FileName,
|
||||
FileSize: ref.FileSize,
|
||||
})
|
||||
}
|
||||
return results, errs
|
||||
|
||||
@@ -3,6 +3,11 @@ package telegram
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
)
|
||||
|
||||
// --- buildMediaTags tests ---
|
||||
@@ -148,3 +153,52 @@ func TestBuildMediaTags_UnknownType(t *testing.T) {
|
||||
t.Errorf("expected empty string for unknown type, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMediaRefsPreservesDocumentMetadata(t *testing.T) {
|
||||
msg := &telego.Message{
|
||||
Document: &telego.Document{
|
||||
FileID: "telegram-file-id",
|
||||
FileName: "codex.zip",
|
||||
MimeType: "application/zip",
|
||||
FileSize: 1234,
|
||||
},
|
||||
}
|
||||
|
||||
got := extractMediaRefs(msg)
|
||||
want := []channels.MediaRef{{
|
||||
Type: "document",
|
||||
FileID: "telegram-file-id",
|
||||
FileSize: 1234,
|
||||
FileName: "codex.zip",
|
||||
ContentType: "application/zip",
|
||||
}}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d refs, want %d", len(got), len(want))
|
||||
}
|
||||
if got[0] != want[0] {
|
||||
t.Fatalf("ref = %+v, want %+v", got[0], want[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrependMediaInfoFilesKeepsHistoryBeforeCurrent(t *testing.T) {
|
||||
current := []bus.MediaFile{{
|
||||
Path: "/workspace/.uploads/current.pdf",
|
||||
MimeType: "application/pdf",
|
||||
Filename: "current.pdf",
|
||||
}}
|
||||
history := []MediaInfo{{
|
||||
Type: "document",
|
||||
FilePath: "/workspace/.uploads/codex.zip",
|
||||
ContentType: "application/zip",
|
||||
FileName: "codex.zip",
|
||||
}}
|
||||
|
||||
got := prependMediaInfoFiles(current, history)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d files, want 2", len(got))
|
||||
}
|
||||
if got[0].Path != history[0].FilePath || got[1].Path != current[0].Path {
|
||||
t.Fatalf("order = [%q, %q], want history before current", got[0].Path, got[1].Path)
|
||||
}
|
||||
}
|
||||
@@ -386,18 +386,8 @@ func (c *Channel) sendMediaMessage(ctx context.Context, chatID int64, msg bus.Ou
|
||||
}
|
||||
}
|
||||
|
||||
// Honor MediaMaxBytes for outbound sends.
|
||||
// Prevents attempting to upload huge files that would fail via Telegram Bot API or local proxy.
|
||||
maxBytes := c.config.MediaMaxBytes
|
||||
if maxBytes == 0 {
|
||||
if c.config.APIServer != "" {
|
||||
maxBytes = localAPIDefaultMaxBytes
|
||||
} else {
|
||||
maxBytes = defaultMediaMaxBytes
|
||||
}
|
||||
}
|
||||
if info, err := os.Stat(media.URL); err == nil && info.Size() > maxBytes {
|
||||
return fmt.Errorf("outbound media too large: %d bytes (limit %d)", info.Size(), maxBytes)
|
||||
if err := c.validateOutboundMediaSize(media.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send based on content type.
|
||||
@@ -455,6 +445,29 @@ func (c *Channel) sendMediaMessage(ctx context.Context, chatID int64, msg bus.Ou
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Channel) validateOutboundMediaSize(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxBytes := c.outboundMediaMaxBytes()
|
||||
if info.Size() > maxBytes {
|
||||
return fmt.Errorf("outbound media too large for Telegram upload: %d bytes (limit %d)", info.Size(), maxBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Channel) outboundMediaMaxBytes() int64 {
|
||||
if c.config.APIServer == "" {
|
||||
return officialAPIOutboundMaxBytes
|
||||
}
|
||||
if c.config.MediaMaxBytes > localAPIDefaultMaxBytes {
|
||||
return c.config.MediaMaxBytes
|
||||
}
|
||||
return localAPIDefaultMaxBytes
|
||||
}
|
||||
|
||||
// sendHTML sends a single HTML message, falling back to plain text if Telegram rejects the HTML.
|
||||
// replyTo and threadID are optional (0 = omit). General topic (1) is handled by resolveThreadIDForSend.
|
||||
func (c *Channel) sendHTML(ctx context.Context, chatID int64, htmlContent string, replyTo, threadID int) error {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
func TestOutboundMediaMaxBytes_OfficialAPIUsesUploadLimit(t *testing.T) {
|
||||
ch := &Channel{config: config.TelegramConfig{MediaMaxBytes: defaultMediaMaxBytes}}
|
||||
|
||||
if got := ch.outboundMediaMaxBytes(); got != officialAPIOutboundMaxBytes {
|
||||
t.Fatalf("outboundMediaMaxBytes() = %d, want %d", got, officialAPIOutboundMaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOutboundMediaSize_AllowsVideoBelowOfficialUploadLimit(t *testing.T) {
|
||||
ch := &Channel{config: config.TelegramConfig{MediaMaxBytes: defaultMediaMaxBytes}}
|
||||
path := sparseFile(t, 43_500_000)
|
||||
|
||||
if err := ch.validateOutboundMediaSize(path); err != nil {
|
||||
t.Fatalf("validateOutboundMediaSize() returned error for 43.5 MB file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOutboundMediaSize_RejectsOfficialAPIUploadAboveLimit(t *testing.T) {
|
||||
ch := &Channel{config: config.TelegramConfig{MediaMaxBytes: 100 * 1024 * 1024}}
|
||||
path := sparseFile(t, officialAPIOutboundMaxBytes+1)
|
||||
|
||||
err := ch.validateOutboundMediaSize(path)
|
||||
if err == nil {
|
||||
t.Fatal("validateOutboundMediaSize() returned nil, want size error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outbound media too large for Telegram upload") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOutboundMediaSize_LocalAPIServerAllowsLargerUploads(t *testing.T) {
|
||||
ch := &Channel{config: config.TelegramConfig{APIServer: "http://127.0.0.1:8081"}}
|
||||
path := sparseFile(t, 150*1024*1024)
|
||||
|
||||
if err := ch.validateOutboundMediaSize(path); err != nil {
|
||||
t.Fatalf("validateOutboundMediaSize() returned error for local API upload: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sparseFile(t *testing.T, size int64) string {
|
||||
t.Helper()
|
||||
|
||||
file, err := os.CreateTemp(t.TempDir(), "telegram-media-*")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
path := file.Name()
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if err := os.Truncate(path, size); err != nil {
|
||||
t.Fatalf("Truncate: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -370,7 +370,7 @@ type GatewayConfig struct {
|
||||
MaxMessageChars int `json:"max_message_chars,omitempty"` // max user message characters (default 32000)
|
||||
RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled)
|
||||
InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off"
|
||||
InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid channel/Web Chat messages from same sender/session (default 1000ms, -1 = disabled)
|
||||
InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid channel/Web Chat messages from same sender/session (0 = no wait)
|
||||
Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas
|
||||
BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false)
|
||||
ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true)
|
||||
@@ -435,6 +435,7 @@ type BrowserToolConfig struct {
|
||||
ActionTimeoutMs int `json:"action_timeout_ms,omitempty"` // per-action timeout in ms (default 30000)
|
||||
IdleTimeoutMs int `json:"idle_timeout_ms,omitempty"` // idle page auto-close in ms (default 600000, 0=disabled)
|
||||
MaxPages int `json:"max_pages,omitempty"` // max open pages per tenant (default 5)
|
||||
CookieSyncEnabled bool `json:"cookie_sync_enabled"` // apply selected synced cookies to scoped browser sessions
|
||||
}
|
||||
|
||||
// ToolPolicySpec defines a tool policy at any level (global, per-agent, per-provider).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -223,7 +223,7 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
|
||||
m.dispatchChatSends(append(pending, item))
|
||||
return
|
||||
}
|
||||
if delay := chatDebounceDelay(m.cfg); delay > 0 {
|
||||
if delay := chatDebounceDelay(m.cfg, loop.OtherConfig()); delay > 0 {
|
||||
m.debouncer.Push(debounceKey, delay, item)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package methods
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type chatSendRequest struct {
|
||||
@@ -127,13 +129,16 @@ func mergeChatSendRequests(items []chatSendRequest) chatSendParams {
|
||||
return last
|
||||
}
|
||||
|
||||
func chatDebounceDelay(cfg *config.Config) time.Duration {
|
||||
func chatDebounceDelay(cfg *config.Config, agentOtherConfig json.RawMessage) time.Duration {
|
||||
debounceMs := 0
|
||||
if cfg != nil {
|
||||
debounceMs = cfg.Gateway.InboundDebounceMs
|
||||
}
|
||||
if debounceMs == 0 {
|
||||
debounceMs = 1000
|
||||
if overrideMs, ok := store.ParseInboundDebounceMsFromOtherConfig(agentOtherConfig); ok {
|
||||
debounceMs = overrideMs
|
||||
}
|
||||
if debounceMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(debounceMs) * time.Millisecond
|
||||
}
|
||||
|
||||
@@ -71,18 +71,23 @@ func TestChatDebouncerDiscardDropsPendingBeforeCancel(t *testing.T) {
|
||||
assertNoChatDebounceFlush(t, out)
|
||||
}
|
||||
|
||||
func TestChatDebounceDelayDefaultAndDisabled(t *testing.T) {
|
||||
if got := chatDebounceDelay(&config.Config{}); got != time.Second {
|
||||
t.Fatalf("default debounce = %s, want 1s", got)
|
||||
func TestChatDebounceDelayGlobalAndAgentOverride(t *testing.T) {
|
||||
if got := chatDebounceDelay(&config.Config{}, nil); got != 0 {
|
||||
t.Fatalf("default debounce = %s, want disabled", got)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.InboundDebounceMs = -1
|
||||
if got := chatDebounceDelay(cfg); got >= 0 {
|
||||
t.Fatalf("disabled debounce = %s, want negative duration", got)
|
||||
}
|
||||
cfg.Gateway.InboundDebounceMs = 250
|
||||
if got := chatDebounceDelay(cfg); got != 250*time.Millisecond {
|
||||
t.Fatalf("custom debounce = %s, want 250ms", got)
|
||||
if got := chatDebounceDelay(cfg, nil); got != 250*time.Millisecond {
|
||||
t.Fatalf("global debounce = %s, want 250ms", got)
|
||||
}
|
||||
if got := chatDebounceDelay(cfg, []byte(`{"inbound_debounce_ms":0}`)); got != 0 {
|
||||
t.Fatalf("agent disabled debounce = %s, want disabled", got)
|
||||
}
|
||||
if got := chatDebounceDelay(cfg, []byte(`{"inbound_debounce_ms":500}`)); got != 500*time.Millisecond {
|
||||
t.Fatalf("agent custom debounce = %s, want 500ms", got)
|
||||
}
|
||||
if got := chatDebounceDelay(cfg, []byte(`{"other":true}`)); got != 250*time.Millisecond {
|
||||
t.Fatalf("agent inherit debounce = %s, want 250ms", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
func TestConfigPatchPersistsInboundDebounceMs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := config.Default()
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
methods := NewConfigMethods(cfg, cfgPath, nil, nil)
|
||||
client, responses := gateway.NewCapturingTestClient(permissions.RoleOwner, store.MasterTenantID, "owner", 1)
|
||||
params, err := json.Marshal(map[string]string{
|
||||
"raw": `{"gateway":{"inbound_debounce_ms":500}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
methods.handlePatch(
|
||||
store.WithTenantID(context.Background(), store.MasterTenantID),
|
||||
client,
|
||||
&protocol.RequestFrame{
|
||||
Type: protocol.FrameTypeRequest,
|
||||
ID: "patch-inbound-debounce",
|
||||
Method: protocol.MethodConfigPatch,
|
||||
Params: params,
|
||||
},
|
||||
)
|
||||
|
||||
res := readConfigPatchResponse(t, responses)
|
||||
if !res.OK {
|
||||
t.Fatalf("config.patch failed: %#v", res.Error)
|
||||
}
|
||||
if cfg.Gateway.InboundDebounceMs != 500 {
|
||||
t.Fatalf("in-memory inbound_debounce_ms = %d, want 500", cfg.Gateway.InboundDebounceMs)
|
||||
}
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`"inbound_debounce_ms": 500`)) {
|
||||
t.Fatalf("saved config missing inbound_debounce_ms=500:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func readConfigPatchResponse(t *testing.T, responses <-chan []byte) protocol.ResponseFrame {
|
||||
t.Helper()
|
||||
select {
|
||||
case raw := <-responses:
|
||||
var res protocol.ResponseFrame
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return res
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for config.patch response")
|
||||
return protocol.ResponseFrame{}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func (m *CronMethods) handleList(ctx context.Context, client *gateway.Client, re
|
||||
userID = client.UserID()
|
||||
}
|
||||
jobs := m.service.ListJobs(ctx, params.IncludeDisabled, "", userID)
|
||||
jobs = store.RedactCronJobsCredentialContext(jobs)
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"jobs": jobs,
|
||||
@@ -112,7 +113,7 @@ func (m *CronMethods) handleCreate(ctx context.Context, client *gateway.Client,
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"job": job,
|
||||
"job": store.RedactCronJobCredentialContext(*job),
|
||||
}))
|
||||
emitAudit(m.eventBus, client, "cron.created", "cron", job.ID)
|
||||
}
|
||||
@@ -131,14 +132,17 @@ func (m *CronMethods) handleDelete(ctx context.Context, client *gateway.Client,
|
||||
return
|
||||
}
|
||||
|
||||
job, ok := m.service.GetJob(ctx, params.JobID)
|
||||
if !ok {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgJobNotFound)))
|
||||
return
|
||||
}
|
||||
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
|
||||
job, ok := m.service.GetJob(ctx, params.JobID)
|
||||
if !ok || job.UserID != client.UserID() {
|
||||
if job.UserID != client.UserID() {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.service.RemoveJob(ctx, params.JobID); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
|
||||
return
|
||||
@@ -165,13 +169,23 @@ func (m *CronMethods) handleToggle(ctx context.Context, client *gateway.Client,
|
||||
return
|
||||
}
|
||||
|
||||
job, ok := m.service.GetJob(ctx, params.JobID)
|
||||
if !ok {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgJobNotFound)))
|
||||
return
|
||||
}
|
||||
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
|
||||
job, ok := m.service.GetJob(ctx, params.JobID)
|
||||
if !ok || job.UserID != client.UserID() {
|
||||
if job.UserID != client.UserID() {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
|
||||
return
|
||||
}
|
||||
}
|
||||
if params.Enabled {
|
||||
if err := store.CheckCronCredentialOwner(ctx, job); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job credential context")))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.service.EnableJob(ctx, params.JobID, params.Enabled); err != nil {
|
||||
code := protocol.ErrInvalidRequest
|
||||
@@ -213,13 +227,21 @@ func (m *CronMethods) handleUpdate(ctx context.Context, client *gateway.Client,
|
||||
return
|
||||
}
|
||||
|
||||
existing, ok := m.service.GetJob(ctx, jobID)
|
||||
if !ok {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgJobNotFound)))
|
||||
return
|
||||
}
|
||||
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
|
||||
existing, ok := m.service.GetJob(ctx, jobID)
|
||||
if !ok || existing.UserID != client.UserID() {
|
||||
if existing.UserID != client.UserID() {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := store.CheckCronCredentialOwner(ctx, existing); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job credential context")))
|
||||
return
|
||||
}
|
||||
|
||||
job, err := m.service.UpdateJob(ctx, jobID, params.Patch)
|
||||
if err != nil {
|
||||
@@ -232,7 +254,7 @@ func (m *CronMethods) handleUpdate(ctx context.Context, client *gateway.Client,
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"job": job,
|
||||
"job": store.RedactCronJobCredentialContext(*job),
|
||||
}))
|
||||
emitAudit(m.eventBus, client, "cron.updated", "cron", jobID)
|
||||
}
|
||||
@@ -272,6 +294,10 @@ func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := store.CheckCronCredentialOwner(ctx, job); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job credential context")))
|
||||
return
|
||||
}
|
||||
|
||||
// Respond immediately — job execution happens in background
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
|
||||
@@ -14,12 +14,15 @@ import (
|
||||
// ---- stub CronStore ----
|
||||
|
||||
type stubCronStore struct {
|
||||
jobs map[string]*store.CronJob
|
||||
addErr error
|
||||
removeErr error
|
||||
updateErr error
|
||||
enableErr error
|
||||
addedJob *store.CronJob
|
||||
jobs map[string]*store.CronJob
|
||||
addErr error
|
||||
removeErr error
|
||||
updateErr error
|
||||
enableErr error
|
||||
addedJob *store.CronJob
|
||||
updateCnt int
|
||||
enableCnt int
|
||||
runCnt int
|
||||
}
|
||||
|
||||
func newStubCronStore() *stubCronStore {
|
||||
@@ -36,10 +39,10 @@ func (s *stubCronStore) AddJob(_ context.Context, name string, schedule store.Cr
|
||||
return nil, s.addErr
|
||||
}
|
||||
job := &store.CronJob{
|
||||
ID: "new-job-id",
|
||||
Name: name,
|
||||
UserID: userID,
|
||||
Enabled: true,
|
||||
ID: "new-job-id",
|
||||
Name: name,
|
||||
UserID: userID,
|
||||
Enabled: true,
|
||||
Schedule: schedule,
|
||||
}
|
||||
s.addedJob = job
|
||||
@@ -71,6 +74,7 @@ func (s *stubCronStore) RemoveJob(_ context.Context, jobID string) error {
|
||||
}
|
||||
|
||||
func (s *stubCronStore) UpdateJob(_ context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
|
||||
s.updateCnt++
|
||||
if s.updateErr != nil {
|
||||
return nil, s.updateErr
|
||||
}
|
||||
@@ -82,6 +86,7 @@ func (s *stubCronStore) UpdateJob(_ context.Context, jobID string, patch store.C
|
||||
}
|
||||
|
||||
func (s *stubCronStore) EnableJob(_ context.Context, jobID string, enabled bool) error {
|
||||
s.enableCnt++
|
||||
if s.enableErr != nil {
|
||||
return s.enableErr
|
||||
}
|
||||
@@ -98,15 +103,16 @@ func (s *stubCronStore) GetRunLog(_ context.Context, _ string, _, _ int) ([]stor
|
||||
func (s *stubCronStore) Status() map[string]any { return map[string]any{"running": true} }
|
||||
|
||||
// Lifecycle stubs (not called in unit tests)
|
||||
func (s *stubCronStore) Start() error { return nil }
|
||||
func (s *stubCronStore) Stop() {}
|
||||
func (s *stubCronStore) Start() error { return nil }
|
||||
func (s *stubCronStore) Stop() {}
|
||||
func (s *stubCronStore) SetOnJob(_ func(*store.CronJob) (*store.CronJobResult, error)) {}
|
||||
func (s *stubCronStore) SetOnEvent(_ func(store.CronEvent)) {}
|
||||
func (s *stubCronStore) SetOnEvent(_ func(store.CronEvent)) {}
|
||||
func (s *stubCronStore) RunJob(_ context.Context, _ string, _ bool) (bool, string, error) {
|
||||
s.runCnt++
|
||||
return true, "", nil
|
||||
}
|
||||
func (s *stubCronStore) SetDefaultTimezone(_ string) {}
|
||||
func (s *stubCronStore) GetDueJobs(_ time.Time) []store.CronJob { return nil }
|
||||
func (s *stubCronStore) SetDefaultTimezone(_ string) {}
|
||||
func (s *stubCronStore) GetDueJobs(_ time.Time) []store.CronJob { return nil }
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
@@ -160,7 +166,7 @@ func TestCronCreate_MissingName_ReturnsInvalidRequest(t *testing.T) {
|
||||
m := buildCronMethods(t, svc)
|
||||
client := nullClient()
|
||||
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
|
||||
"message": "hello",
|
||||
"message": "hello",
|
||||
"schedule": map[string]any{"kind": "every", "everyMs": 60000},
|
||||
})
|
||||
m.handleCreate(context.Background(), client, req)
|
||||
@@ -199,8 +205,8 @@ func TestCronCreate_ValidParams_CreatesJob(t *testing.T) {
|
||||
m := buildCronMethods(t, svc)
|
||||
client := nullClient()
|
||||
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
|
||||
"name": "my-daily-job",
|
||||
"message": "do the thing",
|
||||
"name": "my-daily-job",
|
||||
"message": "do the thing",
|
||||
"schedule": map[string]any{"kind": "every", "everyMs": 3600000},
|
||||
})
|
||||
m.handleCreate(context.Background(), client, req)
|
||||
@@ -259,3 +265,90 @@ func TestCronToggle_MissingJobID_ReturnsInvalidRequest(t *testing.T) {
|
||||
m.handleToggle(context.Background(), client, req)
|
||||
// No panic = jobId-required error path hit
|
||||
}
|
||||
|
||||
func TestCronToggle_BlocksCredentialBoundEnableByDifferentUser(t *testing.T) {
|
||||
svc := newStubCronStore()
|
||||
svc.jobs["job-1"] = &store.CronJob{
|
||||
ID: "job-1",
|
||||
UserID: "",
|
||||
Payload: store.CronPayload{
|
||||
CredentialUserID: "tenant-user-a",
|
||||
},
|
||||
}
|
||||
m := buildCronMethods(t, svc)
|
||||
client := nullClient()
|
||||
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
|
||||
|
||||
req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"jobId": "job-1", "enabled": true})
|
||||
m.handleToggle(ctx, client, req)
|
||||
|
||||
if svc.enableCnt != 0 {
|
||||
t.Fatalf("EnableJob called %d times, want 0", svc.enableCnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronToggle_AllowsCredentialBoundDisableByDifferentUser(t *testing.T) {
|
||||
svc := newStubCronStore()
|
||||
svc.jobs["job-1"] = &store.CronJob{
|
||||
ID: "job-1",
|
||||
UserID: "",
|
||||
Payload: store.CronPayload{
|
||||
CredentialUserID: "tenant-user-a",
|
||||
},
|
||||
}
|
||||
m := buildCronMethods(t, svc)
|
||||
client := nullClient()
|
||||
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
|
||||
|
||||
req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"jobId": "job-1", "enabled": false})
|
||||
m.handleToggle(ctx, client, req)
|
||||
|
||||
if svc.enableCnt != 1 {
|
||||
t.Fatalf("EnableJob called %d times, want 1", svc.enableCnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronUpdate_BlocksCredentialBoundJobByDifferentUser(t *testing.T) {
|
||||
svc := newStubCronStore()
|
||||
svc.jobs["job-1"] = &store.CronJob{
|
||||
ID: "job-1",
|
||||
UserID: "",
|
||||
Payload: store.CronPayload{
|
||||
CredentialUserID: "tenant-user-a",
|
||||
},
|
||||
}
|
||||
m := buildCronMethods(t, svc)
|
||||
client := nullClient()
|
||||
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
|
||||
|
||||
req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{
|
||||
"jobId": "job-1",
|
||||
"patch": map[string]any{"message": "run gh issue list"},
|
||||
})
|
||||
m.handleUpdate(ctx, client, req)
|
||||
|
||||
if svc.updateCnt != 0 {
|
||||
t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronRun_BlocksCredentialBoundJobByDifferentUser(t *testing.T) {
|
||||
svc := newStubCronStore()
|
||||
svc.jobs["job-1"] = &store.CronJob{
|
||||
ID: "job-1",
|
||||
UserID: "",
|
||||
Payload: store.CronPayload{
|
||||
CredentialUserID: "tenant-user-a",
|
||||
},
|
||||
}
|
||||
m := buildCronMethods(t, svc)
|
||||
client := nullClient()
|
||||
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
|
||||
|
||||
req := cronReqFrame(t, protocol.MethodCronRun, map[string]any{"jobId": "job-1", "mode": "force"})
|
||||
m.handleRun(ctx, client, req)
|
||||
|
||||
if svc.runCnt != 0 {
|
||||
t.Fatalf("RunJob called %d times, want 0", svc.runCnt)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -109,6 +109,12 @@ func ExtFromMime(mime string) string {
|
||||
return ".wav"
|
||||
case strings.HasPrefix(mime, "application/pdf"):
|
||||
return ".pdf"
|
||||
case strings.HasPrefix(mime, "application/zip"), strings.HasPrefix(mime, "application/x-zip-compressed"):
|
||||
return ".zip"
|
||||
case strings.HasPrefix(mime, "application/x-tar"):
|
||||
return ".tar"
|
||||
case strings.HasPrefix(mime, "application/gzip"), strings.HasPrefix(mime, "application/x-gzip"):
|
||||
return ".gz"
|
||||
case mime == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
return ".docx"
|
||||
case mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package media
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtFromMimeArchiveTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
mime string
|
||||
want string
|
||||
}{
|
||||
{mime: "application/zip", want: ".zip"},
|
||||
{mime: "application/x-zip-compressed", want: ".zip"},
|
||||
{mime: "application/x-tar", want: ".tar"},
|
||||
{mime: "application/gzip", want: ".gz"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mime, func(t *testing.T) {
|
||||
if got := ExtFromMime(tt.mime); got != tt.want {
|
||||
t.Fatalf("ExtFromMime(%q) = %q, want %q", tt.mime, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseInboundDebounceMsFromOtherConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw []byte
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{name: "missing", raw: []byte(`{"prompt_mode":"full"}`), ok: false},
|
||||
{name: "zero", raw: []byte(`{"inbound_debounce_ms":0}`), want: 0, ok: true},
|
||||
{name: "positive", raw: []byte(`{"inbound_debounce_ms":500}`), want: 500, ok: true},
|
||||
{name: "malformed", raw: []byte(`{"inbound_debounce_ms":`), ok: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := ParseInboundDebounceMsFromOtherConfig(tc.raw)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("ParseInboundDebounceMsFromOtherConfig() = (%d, %t), want (%d, %t)", got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -253,6 +253,28 @@ func (a *AgentData) ParseAllowImageGeneration() bool {
|
||||
return *bag.AllowImageGeneration
|
||||
}
|
||||
|
||||
// ParseInboundDebounceMs returns the per-agent inbound debounce override.
|
||||
// Missing or malformed config means "inherit global gateway.inbound_debounce_ms".
|
||||
func (a *AgentData) ParseInboundDebounceMs() (int, bool) {
|
||||
if a == nil {
|
||||
return 0, false
|
||||
}
|
||||
return ParseInboundDebounceMsFromOtherConfig(a.OtherConfig)
|
||||
}
|
||||
|
||||
func ParseInboundDebounceMsFromOtherConfig(raw json.RawMessage) (int, bool) {
|
||||
if len(raw) <= 2 {
|
||||
return 0, false
|
||||
}
|
||||
var bag struct {
|
||||
InboundDebounceMs *int `json:"inbound_debounce_ms"`
|
||||
}
|
||||
if json.Unmarshal(raw, &bag) != nil || bag.InboundDebounceMs == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *bag.InboundDebounceMs, true
|
||||
}
|
||||
|
||||
// validPromptModes is the set of allowed prompt_mode values.
|
||||
var validPromptModes = map[string]bool{
|
||||
"full": true, "task": true, "minimal": true, "none": true,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -116,10 +116,19 @@ func WithCredentialUserID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, CredentialUserIDKey, id)
|
||||
}
|
||||
|
||||
// ExplicitCredentialUserIDFromContext returns only the explicitly injected credential identity.
|
||||
// Unlike CredentialUserIDFromContext, it does not fall back to UserID.
|
||||
func ExplicitCredentialUserIDFromContext(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(CredentialUserIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CredentialUserIDFromContext returns the resolved identity for credential lookups.
|
||||
// Falls back to RunContext.CredentialUserID, then UserIDFromContext.
|
||||
func CredentialUserIDFromContext(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(CredentialUserIDKey).(string); ok && v != "" {
|
||||
if v := ExplicitCredentialUserIDFromContext(ctx); v != "" {
|
||||
return v
|
||||
}
|
||||
if rc := RunContextFromCtx(ctx); rc != nil && rc.CredentialUserID != "" {
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCronJobNotFound = errors.New("cron job not found")
|
||||
ErrCronJobNoFutureRun = errors.New("cron job has no future run")
|
||||
ErrCronJobNotFound = errors.New("cron job not found")
|
||||
ErrCronJobNoFutureRun = errors.New("cron job has no future run")
|
||||
ErrCronCredentialOwnerMismatch = errors.New("cron credential context belongs to a different user")
|
||||
)
|
||||
|
||||
// CronJob represents a scheduled job.
|
||||
@@ -48,9 +49,40 @@ type CronSchedule struct {
|
||||
|
||||
// CronPayload describes what a job does when triggered.
|
||||
type CronPayload struct {
|
||||
Kind string `json:"kind" db:"-"`
|
||||
Message string `json:"message" db:"-"`
|
||||
Command string `json:"command,omitempty" db:"-"`
|
||||
Kind string `json:"kind" db:"-"`
|
||||
Message string `json:"message" db:"-"`
|
||||
Command string `json:"command,omitempty" db:"-"`
|
||||
CredentialUserID string `json:"credentialUserId,omitempty" db:"-"`
|
||||
}
|
||||
|
||||
// CheckCronCredentialOwner blocks user-triggered mutation/execution of a
|
||||
// credential-bound cron job unless the caller owns that credential context.
|
||||
func CheckCronCredentialOwner(ctx context.Context, job *CronJob) error {
|
||||
if job == nil || job.Payload.CredentialUserID == "" {
|
||||
return nil
|
||||
}
|
||||
if ExplicitCredentialUserIDFromContext(ctx) == job.Payload.CredentialUserID {
|
||||
return nil
|
||||
}
|
||||
return ErrCronCredentialOwnerMismatch
|
||||
}
|
||||
|
||||
// RedactCronJobCredentialContext returns a response-safe copy of a cron job.
|
||||
func RedactCronJobCredentialContext(job CronJob) CronJob {
|
||||
job.Payload.CredentialUserID = ""
|
||||
return job
|
||||
}
|
||||
|
||||
// RedactCronJobsCredentialContext returns response-safe copies of cron jobs.
|
||||
func RedactCronJobsCredentialContext(jobs []CronJob) []CronJob {
|
||||
if len(jobs) == 0 {
|
||||
return jobs
|
||||
}
|
||||
redacted := make([]CronJob, len(jobs))
|
||||
for i := range jobs {
|
||||
redacted[i] = RedactCronJobCredentialContext(jobs[i])
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
// CronJobState tracks runtime state for a job.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -104,6 +106,80 @@ func TestNextRunForToggle_ExpiredAtReturnsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronPayloadCredentialUserIDJSONRoundTrip(t *testing.T) {
|
||||
payload := CronPayload{
|
||||
Kind: "agent_turn",
|
||||
Message: "run credentialed report",
|
||||
CredentialUserID: "tenant-user-123",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
t.Fatalf("payload JSON is invalid: %s", data)
|
||||
}
|
||||
|
||||
var got CronPayload
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if got.CredentialUserID != payload.CredentialUserID {
|
||||
t.Fatalf("credential user ID = %q, want %q", got.CredentialUserID, payload.CredentialUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronPayloadLegacyJSONKeepsEmptyCredentialUserID(t *testing.T) {
|
||||
var got CronPayload
|
||||
if err := json.Unmarshal([]byte(`{"kind":"agent_turn","message":"legacy"}`), &got); err != nil {
|
||||
t.Fatalf("unmarshal legacy payload: %v", err)
|
||||
}
|
||||
if got.CredentialUserID != "" {
|
||||
t.Fatalf("legacy payload credential user ID = %q, want empty", got.CredentialUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCronCredentialOwnerBlocksMismatch(t *testing.T) {
|
||||
ctx := WithCredentialUserID(context.Background(), "tenant-user-b")
|
||||
job := &CronJob{Payload: CronPayload{CredentialUserID: "tenant-user-a"}}
|
||||
|
||||
if err := CheckCronCredentialOwner(ctx, job); !errors.Is(err, ErrCronCredentialOwnerMismatch) {
|
||||
t.Fatalf("got %v, want ErrCronCredentialOwnerMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCronCredentialOwnerIgnoresUserIDFallback(t *testing.T) {
|
||||
ctx := WithUserID(context.Background(), "tenant-user-a")
|
||||
job := &CronJob{Payload: CronPayload{CredentialUserID: "tenant-user-a"}}
|
||||
|
||||
if err := CheckCronCredentialOwner(ctx, job); !errors.Is(err, ErrCronCredentialOwnerMismatch) {
|
||||
t.Fatalf("got %v, want ErrCronCredentialOwnerMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCronCredentialOwnerAllowsMatchAndLegacy(t *testing.T) {
|
||||
ctx := WithCredentialUserID(context.Background(), "tenant-user-a")
|
||||
if err := CheckCronCredentialOwner(ctx, &CronJob{Payload: CronPayload{CredentialUserID: "tenant-user-a"}}); err != nil {
|
||||
t.Fatalf("matching credential owner should pass: %v", err)
|
||||
}
|
||||
if err := CheckCronCredentialOwner(context.Background(), &CronJob{}); err != nil {
|
||||
t.Fatalf("legacy job should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactCronJobCredentialContext(t *testing.T) {
|
||||
job := CronJob{Payload: CronPayload{CredentialUserID: "tenant-user-a", Message: "run"}}
|
||||
redacted := RedactCronJobCredentialContext(job)
|
||||
|
||||
if redacted.Payload.CredentialUserID != "" {
|
||||
t.Fatalf("redacted credential user ID = %q, want empty", redacted.Payload.CredentialUserID)
|
||||
}
|
||||
if job.Payload.CredentialUserID == "" {
|
||||
t.Fatal("redaction mutated source job")
|
||||
}
|
||||
}
|
||||
|
||||
//go:fix inline
|
||||
func int64Ptr(v int64) *int64 {
|
||||
return new(v)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -24,7 +24,9 @@ func (s *PGCronStore) AddJob(ctx context.Context, name string, schedule store.Cr
|
||||
}
|
||||
|
||||
payload := store.CronPayload{
|
||||
Kind: "agent_turn", Message: message,
|
||||
Kind: "agent_turn",
|
||||
Message: message,
|
||||
CredentialUserID: store.ExplicitCredentialUserIDFromContext(ctx),
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
|
||||
|
||||
@@ -78,30 +78,30 @@ func validCronRow(payloadJSON []byte) []any {
|
||||
expr := "*/5 * * * *"
|
||||
tz := "UTC"
|
||||
return []any{
|
||||
id, // id
|
||||
tenantID, // tenant_id
|
||||
(*uuid.UUID)(nil), // agent_id
|
||||
(*string)(nil), // user_id
|
||||
"test-job", // name
|
||||
true, // enabled
|
||||
"cron", // schedule_kind
|
||||
&expr, // cron_expression
|
||||
(*time.Time)(nil), // run_at
|
||||
&tz, // timezone
|
||||
(*int64)(nil), // interval_ms
|
||||
payloadJSON, // payload
|
||||
false, // delete_after_run
|
||||
false, // stateless
|
||||
false, // deliver
|
||||
"", // deliver_channel
|
||||
"", // deliver_to
|
||||
false, // wake_heartbeat
|
||||
(*time.Time)(nil), // next_run_at
|
||||
(*time.Time)(nil), // last_run_at
|
||||
(*string)(nil), // last_status
|
||||
(*string)(nil), // last_error
|
||||
now, // created_at
|
||||
now, // updated_at
|
||||
id, // id
|
||||
tenantID, // tenant_id
|
||||
(*uuid.UUID)(nil), // agent_id
|
||||
(*string)(nil), // user_id
|
||||
"test-job", // name
|
||||
true, // enabled
|
||||
"cron", // schedule_kind
|
||||
&expr, // cron_expression
|
||||
(*time.Time)(nil), // run_at
|
||||
&tz, // timezone
|
||||
(*int64)(nil), // interval_ms
|
||||
payloadJSON, // payload
|
||||
false, // delete_after_run
|
||||
false, // stateless
|
||||
false, // deliver
|
||||
"", // deliver_channel
|
||||
"", // deliver_to
|
||||
false, // wake_heartbeat
|
||||
(*time.Time)(nil), // next_run_at
|
||||
(*time.Time)(nil), // last_run_at
|
||||
(*string)(nil), // last_status
|
||||
(*string)(nil), // last_error
|
||||
now, // created_at
|
||||
now, // updated_at
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,17 +123,18 @@ func TestScanCronRow_MalformedPayloadJSON(t *testing.T) {
|
||||
// TestScanCronRow_ValidPayload verifies correct behavior with valid JSON.
|
||||
func TestScanCronRow_ValidPayload(t *testing.T) {
|
||||
payload := store.CronPayload{
|
||||
Kind: "message",
|
||||
Message: "hello world",
|
||||
Kind: "message",
|
||||
Message: "hello world",
|
||||
CredentialUserID: "tenant-user-123",
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
|
||||
// Build row with deliver/deliver_channel/deliver_to set as dedicated columns.
|
||||
rowVals := validCronRow(payloadJSON)
|
||||
// Indices: stateless=13, deliver=14, deliver_channel=15, deliver_to=16, wake_heartbeat=17
|
||||
rowVals[14] = true // deliver
|
||||
rowVals[14] = true // deliver
|
||||
rowVals[15] = "telegram" // deliver_channel
|
||||
rowVals[16] = "user123" // deliver_to
|
||||
rowVals[16] = "user123" // deliver_to
|
||||
row := &mockRowScanner{values: rowVals}
|
||||
|
||||
job, err := scanCronRow(row)
|
||||
@@ -144,6 +145,9 @@ func TestScanCronRow_ValidPayload(t *testing.T) {
|
||||
if job.Payload.Message != "hello world" {
|
||||
t.Errorf("expected Message 'hello world', got %q", job.Payload.Message)
|
||||
}
|
||||
if job.Payload.CredentialUserID != "tenant-user-123" {
|
||||
t.Errorf("expected CredentialUserID 'tenant-user-123', got %q", job.Payload.CredentialUserID)
|
||||
}
|
||||
if job.DeliverChannel != "telegram" {
|
||||
t.Errorf("expected DeliverChannel 'telegram', got %q", job.DeliverChannel)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ func (s *SQLiteCronStore) AddJob(ctx context.Context, name string, schedule stor
|
||||
}
|
||||
|
||||
payload := store.CronPayload{
|
||||
Kind: "agent_turn", Message: message,
|
||||
Kind: "agent_turn",
|
||||
Message: message,
|
||||
CredentialUserID: store.ExplicitCredentialUserIDFromContext(ctx),
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
|
||||
@@ -395,7 +397,6 @@ func (s *SQLiteCronStore) lockCronJobForMutation(ctx context.Context, tx *sql.Tx
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
|
||||
func execCronJobUpdateTx(ctx context.Context, tx *sql.Tx, id uuid.UUID, updates map[string]any) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
@@ -433,4 +434,3 @@ func execCronJobUpdateTx(ctx context.Context, tx *sql.Tx, id uuid.UUID, updates
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -65,6 +65,29 @@ func TestSQLiteCronStore_ReenableRestoresNextRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteCronStore_AddJobPersistsCredentialUserID(t *testing.T) {
|
||||
cronStore, ctx, _ := newTestSQLiteCronStore(t)
|
||||
ctx = store.WithCredentialUserID(ctx, "tenant-user-123")
|
||||
everyMS := int64(time.Minute / time.Millisecond)
|
||||
|
||||
job, err := cronStore.AddJob(ctx, "job-credential-context", store.CronSchedule{
|
||||
Kind: "every",
|
||||
EveryMS: &everyMS,
|
||||
}, "run credentialed report", false, "", "", "", "group:telegram:-100123")
|
||||
if err != nil {
|
||||
t.Fatalf("AddJob error: %v", err)
|
||||
}
|
||||
if job == nil {
|
||||
job = mustOnlyJob(t, cronStore, ctx)
|
||||
}
|
||||
if job.UserID != "group:telegram:-100123" {
|
||||
t.Fatalf("cron user ID = %q, want group-scoped owner", job.UserID)
|
||||
}
|
||||
if job.Payload.CredentialUserID != "tenant-user-123" {
|
||||
t.Fatalf("credential user ID = %q, want tenant-user-123", job.Payload.CredentialUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteCronStore_EnableAlreadyEnabledPreservesNextRun(t *testing.T) {
|
||||
cronStore, ctx, _ := newTestSQLiteCronStore(t)
|
||||
everyMS := int64(time.Hour / time.Millisecond)
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-5
@@ -185,6 +185,7 @@ func (t *CronTool) handleStatus() *Result {
|
||||
func (t *CronTool) handleList(ctx context.Context, args map[string]any, agentID, userID string) *Result {
|
||||
includeDisabled, _ := args["includeDisabled"].(bool)
|
||||
jobs := t.cronStore.ListJobs(ctx, includeDisabled, agentID, userID)
|
||||
jobs = store.RedactCronJobsCredentialContext(jobs)
|
||||
|
||||
result := map[string]any{
|
||||
"jobs": jobs,
|
||||
@@ -305,7 +306,7 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID,
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(map[string]any{"job": job}, "", " ")
|
||||
data, _ := json.MarshalIndent(map[string]any{"job": store.RedactCronJobCredentialContext(*job)}, "", " ")
|
||||
return NewResult(string(data))
|
||||
}
|
||||
|
||||
@@ -334,9 +335,13 @@ func (t *CronTool) handleUpdate(ctx context.Context, args map[string]any, agentI
|
||||
return ErrorResult("jobId is required for update action")
|
||||
}
|
||||
|
||||
if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil {
|
||||
existing, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID)
|
||||
if errResult != nil {
|
||||
return errResult
|
||||
}
|
||||
if err := store.CheckCronCredentialOwner(ctx, existing); err != nil {
|
||||
return ErrorResult("permission denied: cron job uses a credential context owned by another user")
|
||||
}
|
||||
|
||||
patchObj, ok := args["patch"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -360,7 +365,7 @@ func (t *CronTool) handleUpdate(ctx context.Context, args map[string]any, agentI
|
||||
return ErrorResult(fmt.Sprintf("failed to update cron job: %v", err))
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(map[string]any{"job": job}, "", " ")
|
||||
data, _ := json.MarshalIndent(map[string]any{"job": store.RedactCronJobCredentialContext(*job)}, "", " ")
|
||||
return NewResult(string(data))
|
||||
}
|
||||
|
||||
@@ -370,7 +375,8 @@ func (t *CronTool) handleRemove(ctx context.Context, args map[string]any, agentI
|
||||
return ErrorResult("jobId is required for remove action")
|
||||
}
|
||||
|
||||
if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil {
|
||||
_, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID)
|
||||
if errResult != nil {
|
||||
return errResult
|
||||
}
|
||||
|
||||
@@ -388,9 +394,13 @@ func (t *CronTool) handleRun(ctx context.Context, args map[string]any, agentID,
|
||||
return ErrorResult("jobId is required for run action")
|
||||
}
|
||||
|
||||
if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil {
|
||||
existing, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID)
|
||||
if errResult != nil {
|
||||
return errResult
|
||||
}
|
||||
if err := store.CheckCronCredentialOwner(ctx, existing); err != nil {
|
||||
return ErrorResult("permission denied: cron job uses a credential context owned by another user")
|
||||
}
|
||||
|
||||
runMode, _ := args["runMode"].(string)
|
||||
force := runMode == "force"
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type testCronStore struct {
|
||||
jobs map[string]*store.CronJob
|
||||
updateCnt int
|
||||
runCnt int
|
||||
lastForce bool
|
||||
updateErr error
|
||||
runErr error
|
||||
}
|
||||
|
||||
func newTestCronStore(job *store.CronJob) *testCronStore {
|
||||
jobs := map[string]*store.CronJob{}
|
||||
if job != nil {
|
||||
jobs[job.ID] = job
|
||||
}
|
||||
return &testCronStore{jobs: jobs}
|
||||
}
|
||||
|
||||
func (s *testCronStore) AddJob(context.Context, string, store.CronSchedule, string, bool, string, string, string, string) (*store.CronJob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *testCronStore) GetJob(_ context.Context, jobID string) (*store.CronJob, bool) {
|
||||
job, ok := s.jobs[jobID]
|
||||
return job, ok
|
||||
}
|
||||
|
||||
func (s *testCronStore) ListJobs(context.Context, bool, string, string) []store.CronJob {
|
||||
var jobs []store.CronJob
|
||||
for _, job := range s.jobs {
|
||||
jobs = append(jobs, *job)
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
func (s *testCronStore) RemoveJob(context.Context, string) error { return nil }
|
||||
|
||||
func (s *testCronStore) UpdateJob(_ context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
|
||||
s.updateCnt++
|
||||
if s.updateErr != nil {
|
||||
return nil, s.updateErr
|
||||
}
|
||||
job := s.jobs[jobID]
|
||||
if patch.Message != "" {
|
||||
job.Payload.Message = patch.Message
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *testCronStore) EnableJob(context.Context, string, bool) error { return nil }
|
||||
func (s *testCronStore) GetRunLog(context.Context, string, int, int) ([]store.CronRunLogEntry, int) {
|
||||
return nil, 0
|
||||
}
|
||||
func (s *testCronStore) Status() map[string]any { return map[string]any{} }
|
||||
func (s *testCronStore) Start() error { return nil }
|
||||
func (s *testCronStore) Stop() {}
|
||||
func (s *testCronStore) SetOnJob(func(*store.CronJob) (*store.CronJobResult, error)) {}
|
||||
func (s *testCronStore) SetOnEvent(func(store.CronEvent)) {}
|
||||
|
||||
func (s *testCronStore) RunJob(_ context.Context, _ string, force bool) (bool, string, error) {
|
||||
s.runCnt++
|
||||
s.lastForce = force
|
||||
return true, "", s.runErr
|
||||
}
|
||||
|
||||
func (s *testCronStore) GetDueJobs(time.Time) []store.CronJob { return nil }
|
||||
func (s *testCronStore) SetDefaultTimezone(string) {}
|
||||
|
||||
func TestCronToolBlocksCredentialBoundUpdateByDifferentUser(t *testing.T) {
|
||||
cronStore := newTestCronStore(&store.CronJob{
|
||||
ID: "job-1",
|
||||
UserID: "group:telegram:-100123",
|
||||
Payload: store.CronPayload{
|
||||
Message: "old",
|
||||
CredentialUserID: "tenant-user-a",
|
||||
},
|
||||
})
|
||||
tool := NewCronTool(cronStore)
|
||||
ctx := store.WithUserID(context.Background(), "group:telegram:-100123")
|
||||
ctx = store.WithCredentialUserID(ctx, "tenant-user-b")
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"action": "update",
|
||||
"jobId": "job-1",
|
||||
"patch": map[string]any{"message": "run gh issue list"},
|
||||
})
|
||||
|
||||
if !result.IsError || !strings.Contains(result.ForLLM, "credential context") {
|
||||
t.Fatalf("expected credential context error, got %#v", result)
|
||||
}
|
||||
if cronStore.updateCnt != 0 {
|
||||
t.Fatalf("UpdateJob called %d times, want 0", cronStore.updateCnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronToolListRedactsCredentialUserID(t *testing.T) {
|
||||
cronStore := newTestCronStore(&store.CronJob{
|
||||
ID: "job-1",
|
||||
UserID: "group:telegram:-100123",
|
||||
Payload: store.CronPayload{
|
||||
Message: "run gh issue list",
|
||||
CredentialUserID: "tenant-user-a",
|
||||
},
|
||||
})
|
||||
tool := NewCronTool(cronStore)
|
||||
ctx := store.WithUserID(context.Background(), "group:telegram:-100123")
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{"action": "list", "includeDisabled": true})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("list returned error: %#v", result)
|
||||
}
|
||||
if strings.Contains(result.ForLLM, "tenant-user-a") || strings.Contains(result.ForLLM, "credentialUserId") {
|
||||
t.Fatalf("credential identity leaked in list response: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronToolBlocksCredentialBoundRunByDifferentUser(t *testing.T) {
|
||||
cronStore := newTestCronStore(&store.CronJob{
|
||||
ID: "job-1",
|
||||
UserID: "group:telegram:-100123",
|
||||
Payload: store.CronPayload{
|
||||
Message: "run gh issue list",
|
||||
CredentialUserID: "tenant-user-a",
|
||||
},
|
||||
})
|
||||
tool := NewCronTool(cronStore)
|
||||
ctx := store.WithUserID(context.Background(), "group:telegram:-100123")
|
||||
ctx = store.WithCredentialUserID(ctx, "tenant-user-b")
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"action": "run",
|
||||
"jobId": "job-1",
|
||||
"runMode": "force",
|
||||
})
|
||||
|
||||
if !result.IsError || !strings.Contains(result.ForLLM, "credential context") {
|
||||
t.Fatalf("expected credential context error, got %#v", result)
|
||||
}
|
||||
if cronStore.runCnt != 0 {
|
||||
t.Fatalf("RunJob called %d times, want 0", cronStore.runCnt)
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,7 @@ func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID, doc
|
||||
}
|
||||
}
|
||||
if ref == nil {
|
||||
slog.Warn("read_document: media_id not found, falling back to most recent", "media_id", mediaID)
|
||||
ref = &refs[len(refs)-1]
|
||||
return "", "", fmt.Errorf("document media_id %q not found in this conversation", mediaID)
|
||||
}
|
||||
} else {
|
||||
// Use the last (most recent) document ref.
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestResolveDocumentFileMatchesUploadedFilenameAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDocumentFileInvalidMediaIDFallsBackToMostRecent(t *testing.T) {
|
||||
func TestResolveDocumentFileInvalidMediaIDReturnsError(t *testing.T) {
|
||||
refs := []providers.MediaRef{
|
||||
{ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/old.pdf", MimeType: "application/pdf"},
|
||||
{ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/latest.pdf", MimeType: "application/pdf"},
|
||||
@@ -75,6 +75,23 @@ func TestResolveDocumentFileInvalidMediaIDFallsBackToMostRecent(t *testing.T) {
|
||||
tool := NewReadDocumentTool(nil, nil)
|
||||
ctx := WithMediaDocRefs(context.Background(), refs)
|
||||
gotPath, _, err := tool.resolveDocumentFile(ctx, "not-a-real-media-id", "")
|
||||
if err == nil {
|
||||
t.Fatalf("resolveDocumentFile returned path %q, want explicit media_id error", gotPath)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not-a-real-media-id") {
|
||||
t.Fatalf("error = %q, want requested media_id", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDocumentFileOmittedMediaIDUsesLastRef(t *testing.T) {
|
||||
refs := []providers.MediaRef{
|
||||
{ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/old.pdf", MimeType: "application/pdf"},
|
||||
{ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/latest.pdf", MimeType: "application/pdf"},
|
||||
}
|
||||
|
||||
tool := NewReadDocumentTool(nil, nil)
|
||||
ctx := WithMediaDocRefs(context.Background(), refs)
|
||||
gotPath, _, err := tool.resolveDocumentFile(ctx, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDocumentFile returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 71
|
||||
const RequiredSchemaVersion uint = 72
|
||||
@@ -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);
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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"`
|
||||
}
|
||||
@@ -815,6 +815,16 @@
|
||||
"memoryFlush": "Memory Flush",
|
||||
"memoryFlushTip": "Before compaction, the agent gets a turn to save important context to memory files. Also triggers Knowledge Graph extraction."
|
||||
},
|
||||
"inboundDebounce": {
|
||||
"title": "Inbound Debounce",
|
||||
"description": "Override rapid Web Chat and channel message batching for this agent.",
|
||||
"mode": "Mode",
|
||||
"modeTip": "Inherit uses the global Behavior setting. Custom stores an override on this agent.",
|
||||
"inherit": "Inherit Global",
|
||||
"custom": "Custom",
|
||||
"debounceMs": "Debounce (ms)",
|
||||
"debounceMsTip": "Set 0 for no debounce; positive values merge follow-ups after that wait."
|
||||
},
|
||||
"contextPruning": {
|
||||
"title": "Context Pruning",
|
||||
"description": "Trim old tool results to save context window",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"gateway.rateLimitRpm": "Rate Limit (RPM)",
|
||||
"gateway.rateLimitRpmTip": "Maximum requests per minute per user. Set to 0 to disable rate limiting.",
|
||||
"gateway.inboundDebounceMs": "Inbound Debounce (ms)",
|
||||
"gateway.inboundDebounceMsTip": "Delay in milliseconds before processing rapid channel or Web Chat follow-ups. Set 0 for the 1000ms default, or -1 to disable.",
|
||||
"gateway.inboundDebounceMsTip": "Delay in milliseconds before processing rapid channel or Web Chat follow-ups. Set 0 for no debounce; positive values merge follow-ups after that wait.",
|
||||
"gateway.injectionAction": "Injection Detection Action",
|
||||
|
||||
"agents.title": "Agent Defaults",
|
||||
@@ -210,6 +210,23 @@
|
||||
"tools.execAllowlistLabel": "Allowed Commands (one pattern per line)",
|
||||
"tools.scrubCredentials": "Scrub Credentials",
|
||||
|
||||
"browser.title": "Browser Automation",
|
||||
"browser.description": "Runtime settings for the server-side browser tool",
|
||||
"browser.enabled": "Enabled",
|
||||
"browser.enabledHint": "Expose the browser tool to agents.",
|
||||
"browser.headless": "Headless",
|
||||
"browser.headlessHint": "Launch local Chrome without a visible window.",
|
||||
"browser.remoteUrl": "Remote CDP URL",
|
||||
"browser.remoteUrlTip": "Connect to an existing Chrome DevTools endpoint instead of launching local Chrome.",
|
||||
"browser.maxPages": "Max Pages",
|
||||
"browser.maxPagesTip": "Maximum open pages per isolated browser scope.",
|
||||
"browser.actionTimeout": "Action Timeout (ms)",
|
||||
"browser.actionTimeoutTip": "Maximum time for one browser action.",
|
||||
"browser.idleTimeout": "Idle Timeout (ms)",
|
||||
"browser.idleTimeoutTip": "Close idle pages after this duration. Set 0 to disable.",
|
||||
"browser.cookieSync": "Selected Cookie Sync",
|
||||
"browser.cookieSyncHint": "Apply explicitly synced cookies to matching user and agent browser sessions.",
|
||||
|
||||
"tts.title": "Text-to-Speech",
|
||||
"tts.configured": "Configured",
|
||||
"tts.disabled": "Disabled",
|
||||
|
||||
@@ -800,6 +800,16 @@
|
||||
"memoryFlush": "Ghi nhớ trước nén",
|
||||
"memoryFlushTip": "Trước khi nén, agent được một lượt để lưu ngữ cảnh quan trọng vào file bộ nhớ. Cũng kích hoạt trích xuất Knowledge Graph."
|
||||
},
|
||||
"inboundDebounce": {
|
||||
"title": "Debounce đầu vào",
|
||||
"description": "Override việc gộp tin nhắn nhanh liên tiếp từ Web Chat và channel cho agent này.",
|
||||
"mode": "Chế độ",
|
||||
"modeTip": "Inherit dùng cấu hình global trong Behavior. Custom lưu override trên agent này.",
|
||||
"inherit": "Kế thừa global",
|
||||
"custom": "Tùy chỉnh",
|
||||
"debounceMs": "Debounce (ms)",
|
||||
"debounceMsTip": "Đặt 0 để không debounce; giá trị dương sẽ gộp tin nhắn sau thời gian chờ đó."
|
||||
},
|
||||
"contextPruning": {
|
||||
"title": "Cắt bớt ngữ cảnh",
|
||||
"description": "Cắt bỏ kết quả công cụ cũ để tiết kiệm cửa sổ ngữ cảnh",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"gateway.rateLimitRpm": "Giới hạn tốc độ (RPM)",
|
||||
"gateway.rateLimitRpmTip": "Số yêu cầu tối đa mỗi phút mỗi người dùng. Đặt 0 để tắt giới hạn.",
|
||||
"gateway.inboundDebounceMs": "Debounce đầu vào (ms)",
|
||||
"gateway.inboundDebounceMsTip": "Độ trễ tính bằng mili giây trước khi xử lý các tin nhắn nhanh liên tiếp từ channel hoặc Web Chat. Đặt 0 để dùng mặc định 1000ms, hoặc -1 để tắt.",
|
||||
"gateway.inboundDebounceMsTip": "Độ trễ tính bằng mili giây trước khi xử lý các tin nhắn nhanh liên tiếp từ channel hoặc Web Chat. Đặt 0 để không debounce; giá trị dương sẽ gộp tin nhắn sau thời gian chờ đó.",
|
||||
"gateway.injectionAction": "Hành động phát hiện injection",
|
||||
|
||||
"agents.title": "Mặc định agent",
|
||||
@@ -210,6 +210,23 @@
|
||||
"tools.execAllowlistLabel": "Lệnh được phép (mỗi dòng một mẫu)",
|
||||
"tools.scrubCredentials": "Ẩn thông tin xác thực",
|
||||
|
||||
"browser.title": "Tự động hóa trình duyệt",
|
||||
"browser.description": "Cài đặt runtime cho công cụ trình duyệt phía máy chủ",
|
||||
"browser.enabled": "Đã bật",
|
||||
"browser.enabledHint": "Cho phép agent dùng công cụ trình duyệt.",
|
||||
"browser.headless": "Headless",
|
||||
"browser.headlessHint": "Chạy Chrome cục bộ không hiện cửa sổ.",
|
||||
"browser.remoteUrl": "Remote CDP URL",
|
||||
"browser.remoteUrlTip": "Kết nối tới endpoint Chrome DevTools có sẵn thay vì khởi chạy Chrome cục bộ.",
|
||||
"browser.maxPages": "Số trang tối đa",
|
||||
"browser.maxPagesTip": "Số trang đang mở tối đa cho mỗi phạm vi trình duyệt cô lập.",
|
||||
"browser.actionTimeout": "Timeout thao tác (ms)",
|
||||
"browser.actionTimeoutTip": "Thời gian tối đa cho một thao tác trình duyệt.",
|
||||
"browser.idleTimeout": "Timeout không hoạt động (ms)",
|
||||
"browser.idleTimeoutTip": "Đóng trang không hoạt động sau thời lượng này. Đặt 0 để tắt.",
|
||||
"browser.cookieSync": "Đồng bộ cookie đã chọn",
|
||||
"browser.cookieSyncHint": "Áp dụng cookie được đồng bộ rõ ràng vào phiên trình duyệt đúng người dùng và agent.",
|
||||
|
||||
"tts.title": "Chuyển văn bản thành giọng nói",
|
||||
"tts.configured": "Đã cấu hình",
|
||||
"tts.disabled": "Đã tắt",
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user