diff --git a/cmd/gateway_http_client.go b/cmd/gateway_http_client.go index b1788a67..9508fa69 100644 --- a/cmd/gateway_http_client.go +++ b/cmd/gateway_http_client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "strings" "time" "github.com/nextlevelbuilder/goclaw/internal/config" @@ -29,6 +30,9 @@ var healthClient = &http.Client{Timeout: 3 * time.Second} // resolveGatewayBaseURL reads host/port from config and returns http://host:port. func resolveGatewayBaseURL() string { + if server := strings.TrimSpace(os.Getenv("GOCLAW_SERVER")); server != "" { + return strings.TrimRight(server, "/") + } cfg, err := config.Load(resolveConfigPath()) if err != nil { return "http://127.0.0.1:18790" diff --git a/cmd/gateway_http_client_test.go b/cmd/gateway_http_client_test.go new file mode 100644 index 00000000..e41c783e --- /dev/null +++ b/cmd/gateway_http_client_test.go @@ -0,0 +1,12 @@ +package cmd + +import "testing" + +func TestResolveGatewayBaseURLUsesGOCLAWServer(t *testing.T) { + t.Setenv("GOCLAW_SERVER", "http://127.0.0.1:19999/") + + got := resolveGatewayBaseURL() + if got != "http://127.0.0.1:19999" { + t.Fatalf("resolveGatewayBaseURL()=%q, want GOCLAW_SERVER without trailing slash", got) + } +} diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index c270af1d..b96cd04c 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -33,6 +33,11 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir agentsH = httpapi.NewAgentsHandler(stores.Agents, stores.Providers, providerReg, stores.DB, stores.Tracing, defaultWorkspace, msgBus, summoner, isOwner) agentsH.SetImportStores(stores.Memory, stores.KnowledgeGraph) agentsH.SetDataDir(dataDir) + if stores.SecureCLI != nil && stores.SecureCLIGrants != nil { + if agentCreds, ok := stores.SecureCLI.(store.SecureCLIAgentCredentialStore); ok { + agentsH.SetGatewayOperatorBootstrap(stores.SecureCLI, stores.SecureCLIGrants, agentCreds, gatewayAddr) + } + } } if stores != nil && stores.Skills != nil { diff --git a/cmd/setup_agent.go b/cmd/setup_agent.go index dc2f905f..664cf72c 100644 --- a/cmd/setup_agent.go +++ b/cmd/setup_agent.go @@ -16,6 +16,7 @@ func setupAgentStep() { return } + grantGatewayOperatorAccess := false if len(agents) > 0 { fmt.Printf(" Found %d existing agent(s):\n", len(agents)) for _, a := range agents { @@ -30,12 +31,31 @@ func setupAgentStep() { } else { fmt.Println(" No agents yet. Let's create your first one.") fmt.Println() + grant, err := promptConfirm("Grant this first agent local gateway operator access via the goclaw CLI?", false) + if err != nil { + return + } + grantGatewayOperatorAccess = grant + if grantGatewayOperatorAccess { + fmt.Println(" The agent will get revocable SecureCLI access to run local gateway commands.") + fmt.Println() + } } - createAgent() + createAgent(grantGatewayOperatorAccess) } -func createAgent() { +type setupAgentCreateResponse struct { + httpAgent + GatewayOperatorBootstrap *gatewayOperatorBootstrapResponse `json:"gateway_operator_bootstrap,omitempty"` +} + +type gatewayOperatorBootstrapResponse struct { + Status string `json:"status"` + Warning string `json:"warning,omitempty"` +} + +func createAgent(grantGatewayOperatorAccess bool) { agentKey, err := promptString("Agent key (slug)", "e.g. assistant, coder", "assistant") if err != nil { return @@ -80,19 +100,43 @@ func createAgent() { return } - body := map[string]any{ - "agent_key": agentKey, - "display_name": displayName, - "agent_type": agentType, - "provider": findProviderType(providers, providerID), - "model": model, - } + body := setupAgentCreatePayload(agentKey, displayName, agentType, findProviderType(providers, providerID), model, grantGatewayOperatorAccess) - _, err = gatewayHTTPPost("/v1/agents", body) + result, err := gatewayHTTPPostTyped[setupAgentCreateResponse]("/v1/agents", body) if err != nil { fmt.Fprintf(os.Stderr, " Error: %v\n", err) return } - fmt.Printf(" Agent %q created (%s).\n\n", agentKey, model) + fmt.Printf(" Agent %q created (%s).\n", agentKey, model) + printGatewayOperatorBootstrapResult(result.GatewayOperatorBootstrap) + fmt.Println() +} + +func setupAgentCreatePayload(agentKey, displayName, agentType, providerType, model string, grantGatewayOperatorAccess bool) map[string]any { + body := map[string]any{ + "agent_key": agentKey, + "display_name": displayName, + "agent_type": agentType, + "provider": providerType, + "model": model, + } + if grantGatewayOperatorAccess { + body["grant_gateway_operator_access"] = true + } + return body +} + +func printGatewayOperatorBootstrapResult(result *gatewayOperatorBootstrapResponse) { + if result == nil { + return + } + switch result.Status { + case "granted": + fmt.Println(" Gateway operator access granted. Try: goclaw agent list") + case "warning", "skipped": + if result.Warning != "" { + fmt.Printf(" Warning: %s\n", result.Warning) + } + } } diff --git a/cmd/setup_agent_test.go b/cmd/setup_agent_test.go new file mode 100644 index 00000000..26357e06 --- /dev/null +++ b/cmd/setup_agent_test.go @@ -0,0 +1,15 @@ +package cmd + +import "testing" + +func TestSetupAgentCreatePayloadIncludesGatewayOperatorConsentOnlyWhenTrue(t *testing.T) { + withoutConsent := setupAgentCreatePayload("assistant", "Assistant", "predefined", "anthropic", "claude", false) + if _, ok := withoutConsent["grant_gateway_operator_access"]; ok { + t.Fatalf("grant_gateway_operator_access must be absent without explicit consent: %#v", withoutConsent) + } + + withConsent := setupAgentCreatePayload("assistant", "Assistant", "predefined", "anthropic", "claude", true) + if got, ok := withConsent["grant_gateway_operator_access"].(bool); !ok || !got { + t.Fatalf("grant_gateway_operator_access missing after consent: %#v", withConsent) + } +} diff --git a/internal/http/agents.go b/internal/http/agents.go index f7e506af..601ee3ae 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -26,25 +26,30 @@ import ( // AgentsHandler handles agent CRUD and sharing endpoints. type AgentsHandler struct { - agents store.AgentStore - providers store.ProviderStore - providerReg *providers.Registry - db *sql.DB - tracingStore store.TracingStore - memoryStore store.MemoryStore // for import (nil = disabled) - kgStore store.KnowledgeGraphStore // for import (nil = disabled) - episodicStore store.EpisodicStore // for import (nil in SQLite/lite builds) - vaultStore store.VaultStore // for vault import (nil = disabled) - toolsReg ToolPreviewLister // for system prompt preview tool resolution (nil = fallback) - skillsLoader SkillPreviewBuilder // for system prompt preview pinned skills (nil = skip) - skillAccessStore store.SkillAccessStore // for system prompt preview skill filtering (nil = skip) - teamStore store.TeamStore // for system prompt preview team context (nil = skip) - agentLinkStore store.AgentLinkStore // for system prompt preview delegation targets (nil = skip) - defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace") - dataDir string // resolved data directory (e.g. "~/.goclaw/data") — for team workspace export - msgBus *bus.MessageBus // for cache invalidation events (nil = no events) - summoner *AgentSummoner // LLM-based agent setup (nil = disabled) - isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured) + agents store.AgentStore + providers store.ProviderStore + providerReg *providers.Registry + db *sql.DB + tracingStore store.TracingStore + memoryStore store.MemoryStore // for import (nil = disabled) + kgStore store.KnowledgeGraphStore // for import (nil = disabled) + episodicStore store.EpisodicStore // for import (nil in SQLite/lite builds) + vaultStore store.VaultStore // for vault import (nil = disabled) + toolsReg ToolPreviewLister // for system prompt preview tool resolution (nil = fallback) + skillsLoader SkillPreviewBuilder // for system prompt preview pinned skills (nil = skip) + skillAccessStore store.SkillAccessStore // for system prompt preview skill filtering (nil = skip) + teamStore store.TeamStore // for system prompt preview team context (nil = skip) + agentLinkStore store.AgentLinkStore // for system prompt preview delegation targets (nil = skip) + secureCLI store.SecureCLIStore + secureCLIGrants store.SecureCLIAgentGrantStore + secureCLIAgentCreds store.SecureCLIAgentCredentialStore + defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace") + dataDir string // resolved data directory (e.g. "~/.goclaw/data") — for team workspace export + gatewayAddr string + msgBus *bus.MessageBus // for cache invalidation events (nil = no events) + summoner *AgentSummoner // LLM-based agent setup (nil = disabled) + isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured) + findGatewayOperatorBinary func() (string, error) } // NewAgentsHandler creates a handler for agent management endpoints. @@ -222,10 +227,14 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { return } - var req store.AgentData - if !bindJSON(w, r, locale, &req) { + var createReq struct { + store.AgentData + GrantGatewayOperatorAccess bool `json:"grant_gateway_operator_access,omitempty"` + } + if !bindJSON(w, r, locale, &createReq) { return } + req := createReq.AgentData if !isValidSlug(req.AgentKey) { writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "agent_key")) @@ -317,6 +326,16 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { emitAudit(h.msgBus, r, "agent.created", "agent", req.ID.String()) publicAgent := canonicalizeAgentForResponse(&req) + if createReq.GrantGatewayOperatorAccess { + writeJSON(w, http.StatusCreated, struct { + store.AgentData + GatewayOperatorBootstrap *gatewayOperatorBootstrapResult `json:"gateway_operator_bootstrap,omitempty"` + }{ + AgentData: publicAgent, + GatewayOperatorBootstrap: h.bootstrapGatewayOperatorForCreatedAgent(r.Context(), req.ID, locale), + }) + return + } writeJSON(w, http.StatusCreated, publicAgent) } diff --git a/internal/http/agents_gateway_operator_bootstrap.go b/internal/http/agents_gateway_operator_bootstrap.go new file mode 100644 index 00000000..07288d7c --- /dev/null +++ b/internal/http/agents_gateway_operator_bootstrap.go @@ -0,0 +1,333 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const gatewayOperatorBinaryName = "goclaw" + +var ( + errGatewayOperatorSecureCLIUnavailable = errors.New("gateway operator securecli unavailable") + errGatewayOperatorTokenMissing = errors.New("gateway operator token missing") + errGatewayOperatorBinaryMissing = errors.New("gateway operator goclaw binary missing") + errGatewayOperatorExistingReview = errors.New("gateway operator existing credential requires review") + errGatewayOperatorRegisterFailed = errors.New("gateway operator register failed") + errGatewayOperatorCredentialFailed = errors.New("gateway operator credential failed") +) + +type gatewayOperatorBootstrapResult struct { + Status string `json:"status"` + BinaryID uuid.UUID `json:"binary_id,omitempty"` + GrantID uuid.UUID `json:"grant_id,omitempty"` + Warning string `json:"warning,omitempty"` +} + +func (h *AgentsHandler) SetGatewayOperatorBootstrap(secureCLI store.SecureCLIStore, grants store.SecureCLIAgentGrantStore, agentCreds store.SecureCLIAgentCredentialStore, gatewayAddr string) { + h.secureCLI = secureCLI + h.secureCLIGrants = grants + h.secureCLIAgentCreds = agentCreds + h.gatewayAddr = gatewayAddr + if h.findGatewayOperatorBinary == nil { + h.findGatewayOperatorBinary = defaultFindGatewayOperatorBinary + } +} + +func (h *AgentsHandler) bootstrapGatewayOperatorForCreatedAgent(ctx context.Context, agentID uuid.UUID, locale string) *gatewayOperatorBootstrapResult { + if h.secureCLI == nil || h.secureCLIGrants == nil || h.secureCLIAgentCreds == nil { + return &gatewayOperatorBootstrapResult{Status: "warning", Warning: i18n.T(locale, i18n.MsgGatewayOperatorSecureCLIUnavailable)} + } + first, err := h.isDeterministicFirstAgent(ctx, agentID) + if err != nil { + slog.Warn("gateway_operator.bootstrap.first_agent_check_failed", "agent_id", agentID, "error", err) + return &gatewayOperatorBootstrapResult{Status: "warning", Warning: i18n.T(locale, i18n.MsgGatewayOperatorEligibilityFailed)} + } + if !first { + return &gatewayOperatorBootstrapResult{Status: "skipped", Warning: i18n.T(locale, i18n.MsgGatewayOperatorNotFirstAgent)} + } + + result, err := h.bootstrapGatewayOperatorAccess(ctx, agentID) + if err != nil { + slog.Warn("gateway_operator.bootstrap.failed", "agent_id", agentID, "error", err) + return &gatewayOperatorBootstrapResult{Status: "warning", Warning: gatewayOperatorWarning(locale, err)} + } + result.Status = "granted" + return result +} + +func (h *AgentsHandler) isDeterministicFirstAgent(ctx context.Context, agentID uuid.UUID) (bool, error) { + if h.agents == nil || agentID == uuid.Nil { + return false, nil + } + agents, err := h.agents.List(ctx, "") + if err != nil { + return false, err + } + if len(agents) == 0 { + return false, nil + } + firstID := deterministicFirstAgentID(agents) + return firstID == agentID, nil +} + +func deterministicFirstAgentID(agents []store.AgentData) uuid.UUID { + sort.SliceStable(agents, func(i, j int) bool { + if !agents[i].CreatedAt.Equal(agents[j].CreatedAt) { + return agents[i].CreatedAt.Before(agents[j].CreatedAt) + } + return agents[i].ID.String() < agents[j].ID.String() + }) + if len(agents) == 0 { + return uuid.Nil + } + return agents[0].ID +} + +func (h *AgentsHandler) bootstrapGatewayOperatorAccess(ctx context.Context, agentID uuid.UUID) (*gatewayOperatorBootstrapResult, error) { + if strings.TrimSpace(pkgGatewayToken) == "" { + return nil, errGatewayOperatorTokenMissing + } + if h.secureCLI == nil || h.secureCLIGrants == nil || h.secureCLIAgentCreds == nil { + return nil, errGatewayOperatorSecureCLIUnavailable + } + findBinary := h.findGatewayOperatorBinary + if findBinary == nil { + findBinary = defaultFindGatewayOperatorBinary + } + binaryPath, err := findBinary() + if err != nil { + return nil, errGatewayOperatorBinaryMissing + } + + binary, err := h.ensureGatewayOperatorBinary(ctx, binaryPath) + if err != nil { + return nil, err + } + grant, err := h.ensureGatewayOperatorGrant(ctx, binary.ID, agentID) + if err != nil { + return nil, err + } + if err := h.setGatewayOperatorAgentCredential(ctx, binary.ID, agentID); err != nil { + return nil, err + } + + h.emitCacheInvalidate("secure_cli", binary.ID.String()) + return &gatewayOperatorBootstrapResult{BinaryID: binary.ID, GrantID: grant.ID}, nil +} + +func (h *AgentsHandler) ensureGatewayOperatorBinary(ctx context.Context, binaryPath string) (*store.SecureCLIBinary, error) { + existing, err := h.findGatewayOperatorBinaryConfig(ctx) + if err != nil { + return nil, errGatewayOperatorCredentialFailed + } + if existing != nil { + return h.applyGatewayOperatorBinaryPolicy(ctx, existing, binaryPath) + } + + binary := &store.SecureCLIBinary{ + BinaryName: gatewayOperatorBinaryName, + BinaryPath: &binaryPath, + Description: gatewayOperatorDescription(), + DenyArgs: gatewayOperatorDenyArgs(), + DenyVerbose: gatewayOperatorDenyVerbose(), + TimeoutSeconds: 30, + Tips: gatewayOperatorTips(), + IsGlobal: false, + Enabled: true, + CreatedBy: gatewayOperatorActor(ctx), + } + if err := h.secureCLI.Create(ctx, binary); err != nil { + if found, findErr := h.findGatewayOperatorBinaryConfig(ctx); findErr == nil && found != nil { + if updated, updateErr := h.applyGatewayOperatorBinaryPolicy(ctx, found, binaryPath); updateErr == nil { + return updated, nil + } + } + return nil, errGatewayOperatorRegisterFailed + } + return binary, nil +} + +func (h *AgentsHandler) applyGatewayOperatorBinaryPolicy(ctx context.Context, binary *store.SecureCLIBinary, binaryPath string) (*store.SecureCLIBinary, error) { + if err := h.secureCLI.Update(ctx, binary.ID, gatewayOperatorBinaryPolicyUpdates(binaryPath)); err != nil { + return nil, errGatewayOperatorCredentialFailed + } + binary.IsGlobal = false + binary.Enabled = true + binary.BinaryPath = &binaryPath + binary.Description = gatewayOperatorDescription() + binary.DenyArgs = gatewayOperatorDenyArgs() + binary.DenyVerbose = gatewayOperatorDenyVerbose() + binary.TimeoutSeconds = 30 + binary.Tips = gatewayOperatorTips() + binary.AdapterName = nil + return binary, nil +} + +func (h *AgentsHandler) findGatewayOperatorBinaryConfig(ctx context.Context) (*store.SecureCLIBinary, error) { + binaries, err := h.secureCLI.List(ctx) + if err != nil { + return nil, err + } + for i := range binaries { + if strings.EqualFold(strings.TrimSpace(binaries[i].BinaryName), gatewayOperatorBinaryName) { + b := binaries[i] + return &b, nil + } + } + return nil, nil +} + +func gatewayOperatorBinaryPolicyUpdates(binaryPath string) map[string]any { + return map[string]any{ + "binary_path": binaryPath, + "description": gatewayOperatorDescription(), + "deny_args": gatewayOperatorDenyArgs(), + "deny_verbose": gatewayOperatorDenyVerbose(), + "timeout_seconds": 30, + "tips": gatewayOperatorTips(), + "is_global": false, + "enabled": true, + "adapter_name": nil, + } +} + +func gatewayOperatorDescription() string { + return "Local GoClaw gateway operator CLI" +} + +func gatewayOperatorTips() string { + return "Use for local gateway operations such as `goclaw agent list`. Auth/setup/migration/backup/restore and verbose/debug commands are blocked." +} + +func gatewayOperatorDenyArgs() json.RawMessage { + raw, _ := json.Marshal([]string{ + `^auth\b`, + `^onboard\b`, + `^setup\b`, + `^migrate\b`, + `^upgrade\b`, + `^backup\b`, + `^restore\b`, + `^tenant-backup\b`, + `^tenant-restore\b`, + `^config\s+set\b`, + }) + return raw +} + +func gatewayOperatorDenyVerbose() json.RawMessage { + raw, _ := json.Marshal([]string{`-v`, `--verbose`, `--debug`}) + return raw +} + +func (h *AgentsHandler) ensureGatewayOperatorGrant(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentGrant, error) { + grants, err := h.secureCLIGrants.ListByAgent(ctx, agentID) + if err != nil { + return nil, errGatewayOperatorCredentialFailed + } + for i := range grants { + if grants[i].BinaryID != binaryID { + continue + } + if !grants[i].Enabled { + if err := h.secureCLIGrants.Update(ctx, grants[i].ID, map[string]any{"enabled": true}); err != nil { + return nil, errGatewayOperatorCredentialFailed + } + grants[i].Enabled = true + } + return &grants[i], nil + } + + grant := &store.SecureCLIAgentGrant{ + BinaryID: binaryID, + AgentID: agentID, + Enabled: true, + } + if err := h.secureCLIGrants.Create(ctx, grant); err != nil { + return nil, errGatewayOperatorCredentialFailed + } + return grant, nil +} + +func (h *AgentsHandler) setGatewayOperatorAgentCredential(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error { + env, err := store.SerializeSecureCLIEnv(map[string]store.SecureCLIEnvEntry{ + "GOCLAW_GATEWAY_TOKEN": { + Kind: store.SecureCLIEnvKindSensitive, + Value: pkgGatewayToken, + }, + "GOCLAW_SERVER": { + Kind: store.SecureCLIEnvKindSensitive, + Value: gatewayOperatorServerURL(h.gatewayAddr), + }, + }) + if err != nil { + return errGatewayOperatorCredentialFailed + } + if err := h.secureCLIAgentCreds.SetAgentCredentials(ctx, binaryID, agentID, env, gatewayOperatorActor(ctx)); err != nil { + return errGatewayOperatorCredentialFailed + } + return nil +} + +func gatewayOperatorWarning(locale string, err error) string { + switch { + case errors.Is(err, errGatewayOperatorSecureCLIUnavailable): + return i18n.T(locale, i18n.MsgGatewayOperatorSecureCLIUnavailable) + case errors.Is(err, errGatewayOperatorTokenMissing): + return i18n.T(locale, i18n.MsgGatewayOperatorTokenMissing) + case errors.Is(err, errGatewayOperatorBinaryMissing): + return i18n.T(locale, i18n.MsgGatewayOperatorBinaryMissing) + case errors.Is(err, errGatewayOperatorExistingReview): + return i18n.T(locale, i18n.MsgGatewayOperatorExistingReview) + case errors.Is(err, errGatewayOperatorRegisterFailed): + return i18n.T(locale, i18n.MsgGatewayOperatorRegisterFailed) + default: + return i18n.T(locale, i18n.MsgGatewayOperatorCredentialFailed) + } +} + +func gatewayOperatorActor(ctx context.Context) string { + if userID := strings.TrimSpace(store.UserIDFromContext(ctx)); userID != "" { + return userID + } + return "system" +} + +func gatewayOperatorServerURL(addr string) string { + addr = strings.TrimSpace(addr) + if addr == "" { + return "http://127.0.0.1:18790" + } + if u, err := url.Parse(addr); err == nil && u.Scheme != "" { + return strings.TrimRight(addr, "/") + } + return "http://" + strings.TrimRight(addr, "/") +} + +func defaultFindGatewayOperatorBinary() (string, error) { + if exe, err := os.Executable(); err == nil && strings.EqualFold(filepath.Base(exe), gatewayOperatorBinaryName) && skills.IsExecutableFile(exe) { + return exe, nil + } + if path, err := exec.LookPath(gatewayOperatorBinaryName); err == nil && skills.IsExecutableFile(path) { + return path, nil + } + if path, ok := skills.FindRuntimeExecutable(gatewayOperatorBinaryName); ok && skills.IsExecutableFile(path) { + return path, nil + } + return "", fmt.Errorf("%s binary not found", gatewayOperatorBinaryName) +} diff --git a/internal/http/agents_gateway_operator_bootstrap_test.go b/internal/http/agents_gateway_operator_bootstrap_test.go new file mode 100644 index 00000000..9027ad46 --- /dev/null +++ b/internal/http/agents_gateway_operator_bootstrap_test.go @@ -0,0 +1,656 @@ +package http + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type gatewayOperatorSecureCLIStore struct { + binaries []store.SecureCLIBinary + created *store.SecureCLIBinary + updated map[uuid.UUID]map[string]any +} + +func (s *gatewayOperatorSecureCLIStore) Create(_ context.Context, b *store.SecureCLIBinary) error { + cp := *b + if cp.ID == uuid.Nil { + cp.ID = store.GenNewID() + b.ID = cp.ID + } + s.created = &cp + s.binaries = append(s.binaries, cp) + return nil +} + +func (s *gatewayOperatorSecureCLIStore) Get(context.Context, uuid.UUID) (*store.SecureCLIBinary, error) { + return nil, sql.ErrNoRows +} + +func (s *gatewayOperatorSecureCLIStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error { + if s.updated == nil { + s.updated = map[uuid.UUID]map[string]any{} + } + cp := map[string]any{} + for k, v := range updates { + cp[k] = v + } + s.updated[id] = cp + for i := range s.binaries { + if s.binaries[i].ID != id { + continue + } + for k, v := range updates { + switch k { + case "binary_path": + if path, ok := v.(string); ok { + s.binaries[i].BinaryPath = &path + } + case "description": + if description, ok := v.(string); ok { + s.binaries[i].Description = description + } + case "deny_args": + if raw, ok := v.(json.RawMessage); ok { + s.binaries[i].DenyArgs = raw + } + case "deny_verbose": + if raw, ok := v.(json.RawMessage); ok { + s.binaries[i].DenyVerbose = raw + } + case "timeout_seconds": + if timeoutSeconds, ok := v.(int); ok { + s.binaries[i].TimeoutSeconds = timeoutSeconds + } + case "tips": + if tips, ok := v.(string); ok { + s.binaries[i].Tips = tips + } + case "is_global": + if isGlobal, ok := v.(bool); ok { + s.binaries[i].IsGlobal = isGlobal + } + case "enabled": + if enabled, ok := v.(bool); ok { + s.binaries[i].Enabled = enabled + } + case "adapter_name": + if adapterName, ok := v.(*string); ok { + s.binaries[i].AdapterName = adapterName + } else { + s.binaries[i].AdapterName = nil + } + } + } + return nil + } + return nil +} + +func (s *gatewayOperatorSecureCLIStore) Delete(context.Context, uuid.UUID) error { return nil } + +func (s *gatewayOperatorSecureCLIStore) List(context.Context) ([]store.SecureCLIBinary, error) { + out := make([]store.SecureCLIBinary, len(s.binaries)) + copy(out, s.binaries) + return out, nil +} + +func (s *gatewayOperatorSecureCLIStore) LookupByBinary(context.Context, string, *uuid.UUID, string) (*store.SecureCLIBinary, error) { + return nil, sql.ErrNoRows +} + +func (s *gatewayOperatorSecureCLIStore) ListEnabled(context.Context) ([]store.SecureCLIBinary, error) { + return nil, nil +} + +func (s *gatewayOperatorSecureCLIStore) ListForAgent(context.Context, uuid.UUID) ([]store.SecureCLIBinary, error) { + return nil, nil +} + +func (s *gatewayOperatorSecureCLIStore) IsRegisteredBinary(context.Context, string) (bool, error) { + return false, nil +} + +func (s *gatewayOperatorSecureCLIStore) GetUserCredentials(context.Context, uuid.UUID, string) (*store.SecureCLIUserCredential, error) { + return nil, sql.ErrNoRows +} + +func (s *gatewayOperatorSecureCLIStore) SetUserCredentials(context.Context, uuid.UUID, string, []byte) error { + return nil +} + +func (s *gatewayOperatorSecureCLIStore) SetUserCredentialsTyped(context.Context, uuid.UUID, string, []byte, *string, *string) error { + return nil +} + +func (s *gatewayOperatorSecureCLIStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error { + return nil +} + +func (s *gatewayOperatorSecureCLIStore) ListUserCredentials(context.Context, uuid.UUID) ([]store.SecureCLIUserCredential, error) { + return nil, nil +} + +type gatewayOperatorGrantStore struct { + grants map[uuid.UUID]*store.SecureCLIAgentGrant + env map[uuid.UUID][]byte +} + +func (s *gatewayOperatorGrantStore) BinaryExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *gatewayOperatorGrantStore) AgentExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *gatewayOperatorGrantStore) Create(_ context.Context, g *store.SecureCLIAgentGrant) error { + if s.grants == nil { + s.grants = map[uuid.UUID]*store.SecureCLIAgentGrant{} + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + cp := *g + s.grants[g.ID] = &cp + return nil +} + +func (s *gatewayOperatorGrantStore) Get(_ context.Context, id uuid.UUID) (*store.SecureCLIAgentGrant, error) { + if g := s.grants[id]; g != nil { + cp := *g + return &cp, nil + } + return nil, sql.ErrNoRows +} + +func (s *gatewayOperatorGrantStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error { + g := s.grants[id] + if g == nil { + return sql.ErrNoRows + } + if enabled, ok := updates["enabled"].(bool); ok { + g.Enabled = enabled + } + return nil +} + +func (s *gatewayOperatorGrantStore) Delete(context.Context, uuid.UUID) error { return nil } + +func (s *gatewayOperatorGrantStore) ListByBinary(_ context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentGrant, error) { + out := []store.SecureCLIAgentGrant{} + for _, g := range s.grants { + if g.BinaryID == binaryID { + out = append(out, *g) + } + } + return out, nil +} + +func (s *gatewayOperatorGrantStore) ListByAgent(_ context.Context, agentID uuid.UUID) ([]store.SecureCLIAgentGrant, error) { + out := []store.SecureCLIAgentGrant{} + for _, g := range s.grants { + if g.AgentID == agentID { + out = append(out, *g) + } + } + return out, nil +} + +func (s *gatewayOperatorGrantStore) UpdateGrantEnv(_ context.Context, grantID uuid.UUID, plaintextEnv []byte) error { + if s.env == nil { + s.env = map[uuid.UUID][]byte{} + } + if s.grants[grantID] == nil { + return sql.ErrNoRows + } + cp := append([]byte(nil), plaintextEnv...) + s.env[grantID] = cp + s.grants[grantID].EncryptedEnv = cp + return nil +} + +type gatewayOperatorAgentCredentialStore struct { + env map[uuid.UUID][]byte + createdBy map[uuid.UUID]string +} + +func (s *gatewayOperatorAgentCredentialStore) BinaryExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *gatewayOperatorAgentCredentialStore) AgentExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *gatewayOperatorAgentCredentialStore) GetAgentCredentials(_ context.Context, _ uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentCredential, error) { + if s.env == nil || len(s.env[agentID]) == 0 { + return nil, nil + } + return &store.SecureCLIAgentCredential{ + ID: store.GenNewID(), + AgentID: agentID, + EncryptedEnv: append([]byte(nil), s.env[agentID]...), + CreatedBy: s.createdBy[agentID], + }, nil +} + +func (s *gatewayOperatorAgentCredentialStore) SetAgentCredentials(_ context.Context, _ uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error { + if s.env == nil { + s.env = map[uuid.UUID][]byte{} + } + if s.createdBy == nil { + s.createdBy = map[uuid.UUID]string{} + } + s.env[agentID] = append([]byte(nil), encryptedEnv...) + s.createdBy[agentID] = createdBy + return nil +} + +func (s *gatewayOperatorAgentCredentialStore) SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, _ *string, _ *string, createdBy string) error { + return s.SetAgentCredentials(ctx, binaryID, agentID, encryptedEnv, createdBy) +} + +func (s *gatewayOperatorAgentCredentialStore) DeleteAgentCredentials(context.Context, uuid.UUID, uuid.UUID) error { + return nil +} + +func (s *gatewayOperatorAgentCredentialStore) ListAgentCredentials(context.Context, uuid.UUID) ([]store.SecureCLIAgentCredential, error) { + return nil, nil +} + +func gatewayOperatorContext() context.Context { + ctx := context.Background() + ctx = store.WithTenantID(ctx, uuid.MustParse("0193a5b0-7000-7000-8000-000000000002")) + ctx = store.WithUserID(ctx, "system") + ctx = store.WithRole(ctx, store.RoleOwner) + return ctx +} + +type gatewayOperatorAgentStore struct { + store.AgentStore + agents []store.AgentData + files map[uuid.UUID]map[string]string +} + +func (s *gatewayOperatorAgentStore) Create(_ context.Context, agent *store.AgentData) error { + if agent.ID == uuid.Nil { + agent.ID = store.GenNewID() + } + if agent.CreatedAt.IsZero() { + agent.CreatedAt = time.Now().UTC() + } + agent.UpdatedAt = agent.CreatedAt + cp := *agent + s.agents = append(s.agents, cp) + return nil +} + +func (s *gatewayOperatorAgentStore) GetByKey(_ context.Context, agentKey string) (*store.AgentData, error) { + for i := range s.agents { + if s.agents[i].AgentKey == agentKey { + cp := s.agents[i] + return &cp, nil + } + } + return nil, sql.ErrNoRows +} + +func (s *gatewayOperatorAgentStore) List(context.Context, string) ([]store.AgentData, error) { + out := make([]store.AgentData, len(s.agents)) + copy(out, s.agents) + return out, nil +} + +func (s *gatewayOperatorAgentStore) GetAgentContextFiles(context.Context, uuid.UUID) ([]store.AgentContextFileData, error) { + return nil, nil +} + +func (s *gatewayOperatorAgentStore) SetAgentContextFile(_ context.Context, agentID uuid.UUID, fileName, content string) error { + if s.files == nil { + s.files = map[uuid.UUID]map[string]string{} + } + if s.files[agentID] == nil { + s.files[agentID] = map[string]string{} + } + s.files[agentID][fileName] = content + return nil +} + +func TestGatewayOperatorBootstrapCreatesBinaryGrantAndSensitiveTokenEnv(t *testing.T) { + setupTestToken(t, "test-gateway-token") + secureCLI := &gatewayOperatorSecureCLIStore{} + grants := &gatewayOperatorGrantStore{} + agentCreds := &gatewayOperatorAgentCredentialStore{} + agentID := uuid.New() + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/usr/local/bin/goclaw", nil + } + + result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID) + if err != nil { + t.Fatalf("bootstrap returned error: %v", err) + } + if result.BinaryID == uuid.Nil || result.GrantID == uuid.Nil { + t.Fatalf("bootstrap result missing IDs: %#v", result) + } + if secureCLI.created == nil { + t.Fatal("expected goclaw secure CLI binary to be registered") + } + if secureCLI.created.BinaryName != "goclaw" { + t.Fatalf("binary name=%q, want goclaw", secureCLI.created.BinaryName) + } + if secureCLI.created.IsGlobal { + t.Fatal("gateway operator binary must be non-global") + } + if secureCLI.created.BinaryPath == nil || *secureCLI.created.BinaryPath != "/usr/local/bin/goclaw" { + t.Fatalf("binary path not recorded: %#v", secureCLI.created.BinaryPath) + } + if !strings.Contains(string(secureCLI.created.DenyArgs), "auth") { + t.Fatalf("expected lifecycle/auth deny patterns, got %s", string(secureCLI.created.DenyArgs)) + } + + if len(grants.env) != 0 { + t.Fatalf("gateway token must not be stored in revealable grant env: %#v", grants.env) + } + + envJSON := agentCreds.env[agentID] + if len(envJSON) == 0 { + t.Fatal("expected non-revealable per-agent credential env") + } + entries, err := store.ParseSecureCLIEnv(envJSON) + if err != nil { + t.Fatalf("parse agent credential env: %v", err) + } + tokenEntry := entries["GOCLAW_GATEWAY_TOKEN"] + if tokenEntry.Kind != store.SecureCLIEnvKindSensitive || tokenEntry.Value != "test-gateway-token" { + t.Fatalf("token entry not stored as sensitive override: %#v", tokenEntry) + } + if entries["GOCLAW_SERVER"].Value != "http://127.0.0.1:18790" { + t.Fatalf("GOCLAW_SERVER not injected: %#v", entries["GOCLAW_SERVER"]) + } + + safeEnv := agentCredentialResponse(store.SecureCLIAgentCredential{EncryptedEnv: envJSON}).Env + if safeEnv["GOCLAW_GATEWAY_TOKEN"].Value != nil || !safeEnv["GOCLAW_GATEWAY_TOKEN"].Masked { + encoded, _ := json.Marshal(safeEnv) + t.Fatalf("gateway token not masked in agent credential response: %s", encoded) + } +} + +func TestGatewayOperatorBootstrapReusesExistingGrant(t *testing.T) { + setupTestToken(t, "rotated-test-token") + binaryID := uuid.New() + grantID := uuid.New() + agentID := uuid.New() + secureCLI := &gatewayOperatorSecureCLIStore{ + binaries: []store.SecureCLIBinary{{ + BaseModel: store.BaseModel{ID: binaryID}, + BinaryName: "goclaw", + Enabled: true, + IsGlobal: false, + }}, + } + grants := &gatewayOperatorGrantStore{ + grants: map[uuid.UUID]*store.SecureCLIAgentGrant{ + grantID: { + BaseModel: store.BaseModel{ID: grantID}, + BinaryID: binaryID, + AgentID: agentID, + Enabled: false, + }, + }, + } + agentCreds := &gatewayOperatorAgentCredentialStore{} + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/usr/local/bin/goclaw", nil + } + + result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID) + if err != nil { + t.Fatalf("bootstrap returned error: %v", err) + } + if result.GrantID != grantID { + t.Fatalf("expected existing grant %s, got %s", grantID, result.GrantID) + } + if !grants.grants[grantID].Enabled { + t.Fatal("existing disabled grant should be re-enabled") + } + if secureCLI.created != nil { + t.Fatal("existing binary should be reused, not recreated") + } + if len(agentCreds.env[agentID]) == 0 { + t.Fatal("expected agent credential env to be refreshed for existing grant") + } +} + +func TestGatewayOperatorBootstrapConvertsExistingGlobalBinaryToGrantScoped(t *testing.T) { + setupTestToken(t, "test-gateway-token") + binaryID := uuid.New() + agentID := uuid.New() + secureCLI := &gatewayOperatorSecureCLIStore{ + binaries: []store.SecureCLIBinary{{ + BaseModel: store.BaseModel{ID: binaryID}, + BinaryName: "goclaw", + Enabled: true, + IsGlobal: true, + }}, + } + grants := &gatewayOperatorGrantStore{} + agentCreds := &gatewayOperatorAgentCredentialStore{} + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "127.0.0.1:19999") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/opt/goclaw/current/goclaw", nil + } + + result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID) + if err != nil { + t.Fatalf("bootstrap returned error: %v", err) + } + if result.BinaryID != binaryID { + t.Fatalf("expected existing binary %s, got %s", binaryID, result.BinaryID) + } + if secureCLI.created != nil { + t.Fatal("existing binary should be policy-updated, not duplicated") + } + updates := secureCLI.updated[binaryID] + if updates == nil { + t.Fatal("expected existing global binary to be updated") + } + if updates["is_global"] != false || updates["enabled"] != true { + t.Fatalf("expected explicit non-global enabled policy, got %#v", updates) + } + if got := secureCLI.binaries[0].BinaryPath; got == nil || *got != "/opt/goclaw/current/goclaw" { + t.Fatalf("binary path not updated: %#v", got) + } + if len(grants.grants) != 1 { + t.Fatalf("expected one explicit agent grant, got %d", len(grants.grants)) + } + entries, err := store.ParseSecureCLIEnv(agentCreds.env[agentID]) + if err != nil { + t.Fatalf("parse agent credential env: %v", err) + } + if entries["GOCLAW_SERVER"].Value != "http://127.0.0.1:19999" { + t.Fatalf("GOCLAW_SERVER not normalized: %#v", entries["GOCLAW_SERVER"]) + } +} + +func TestGatewayOperatorBootstrapRewritesUnsafeExistingNonGlobalBinary(t *testing.T) { + setupTestToken(t, "test-gateway-token") + binaryID := uuid.New() + agentID := uuid.New() + unsafePath := "/tmp/wrapper-goclaw" + adapterName := "git" + secureCLI := &gatewayOperatorSecureCLIStore{ + binaries: []store.SecureCLIBinary{{ + BaseModel: store.BaseModel{ID: binaryID}, + BinaryName: "goclaw", + BinaryPath: &unsafePath, + Enabled: true, + IsGlobal: false, + DenyArgs: json.RawMessage(`[]`), + DenyVerbose: json.RawMessage(`[]`), + TimeoutSeconds: 300, + AdapterName: &adapterName, + }}, + } + grants := &gatewayOperatorGrantStore{} + agentCreds := &gatewayOperatorAgentCredentialStore{} + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/opt/goclaw/current/goclaw", nil + } + + result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID) + if err != nil { + t.Fatalf("bootstrap returned error: %v", err) + } + if result.BinaryID != binaryID { + t.Fatalf("expected existing binary %s, got %s", binaryID, result.BinaryID) + } + updates := secureCLI.updated[binaryID] + if updates == nil { + t.Fatal("expected unsafe existing binary policy to be rewritten before grant") + } + if got := secureCLI.binaries[0].BinaryPath; got == nil || *got != "/opt/goclaw/current/goclaw" { + t.Fatalf("safe binary path not enforced: %#v", got) + } + if !strings.Contains(string(secureCLI.binaries[0].DenyArgs), "migrate") { + t.Fatalf("safe deny policy not enforced: %s", string(secureCLI.binaries[0].DenyArgs)) + } + if secureCLI.binaries[0].AdapterName != nil { + t.Fatalf("gateway operator binary must use passthrough adapter, got %q", *secureCLI.binaries[0].AdapterName) + } + if len(grants.grants) != 1 || len(agentCreds.env[agentID]) == 0 { + t.Fatal("expected grant and non-revealable credential after safe policy rewrite") + } +} + +func TestAgentsCreateAddsGatewayOperatorBootstrapMetadataWhenRequested(t *testing.T) { + setupTestToken(t, "test-gateway-token") + agents := &gatewayOperatorAgentStore{} + secureCLI := &gatewayOperatorSecureCLIStore{} + grants := &gatewayOperatorGrantStore{} + agentCreds := &gatewayOperatorAgentCredentialStore{} + handler := &AgentsHandler{ + agents: agents, + defaultWorkspace: "/tmp/workspace", + } + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/usr/local/bin/goclaw", nil + } + + body := []byte(`{ + "agent_key":"assistant", + "display_name":"Assistant", + "provider":"anthropic", + "model":"claude", + "grant_gateway_operator_access":true + }`) + req := httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewReader(body)) + req = req.WithContext(gatewayOperatorContext()) + rr := httptest.NewRecorder() + + handler.handleCreate(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var response struct { + ID uuid.UUID `json:"id"` + AgentKey string `json:"agent_key"` + GatewayOperatorBootstrap *gatewayOperatorBootstrapResult `json:"gateway_operator_bootstrap"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v body=%s", err, rr.Body.String()) + } + if response.ID == uuid.Nil || response.AgentKey != "assistant" { + t.Fatalf("unexpected agent response: %#v", response) + } + if response.GatewayOperatorBootstrap == nil || response.GatewayOperatorBootstrap.Status != "granted" { + t.Fatalf("expected granted bootstrap metadata, got %#v body=%s", response.GatewayOperatorBootstrap, rr.Body.String()) + } + if len(grants.grants) != 1 { + t.Fatalf("expected one grant, got %d", len(grants.grants)) + } + if len(agentCreds.env[response.ID]) == 0 { + t.Fatal("expected per-agent gateway credentials to be stored") + } + if strings.Contains(rr.Body.String(), "test-gateway-token") { + t.Fatalf("gateway token leaked in create response: %s", rr.Body.String()) + } +} + +func TestGatewayOperatorBootstrapFailsClosedWithoutToken(t *testing.T) { + setupTestToken(t, "") + secureCLI := &gatewayOperatorSecureCLIStore{} + grants := &gatewayOperatorGrantStore{} + agentCreds := &gatewayOperatorAgentCredentialStore{} + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "/usr/local/bin/goclaw", nil + } + + _, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), uuid.New()) + if err == nil { + t.Fatal("expected missing gateway token to fail closed") + } + if secureCLI.created != nil || len(grants.grants) > 0 || len(agentCreds.env) > 0 { + t.Fatal("missing token must not create binary, grant, or agent credential") + } +} + +func TestGatewayOperatorBootstrapReportsBinaryDiscoveryFailure(t *testing.T) { + setupTestToken(t, "test-gateway-token") + handler := &AgentsHandler{} + handler.SetGatewayOperatorBootstrap(&gatewayOperatorSecureCLIStore{}, &gatewayOperatorGrantStore{}, &gatewayOperatorAgentCredentialStore{}, "http://127.0.0.1:18790") + handler.findGatewayOperatorBinary = func() (string, error) { + return "", errors.New("not found") + } + + _, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), uuid.New()) + if !errors.Is(err, errGatewayOperatorBinaryMissing) { + t.Fatalf("expected binary discovery warning, got %v", err) + } +} + +func TestGatewayOperatorFirstAgentGateUsesDeterministicEarliestRow(t *testing.T) { + firstID := uuid.MustParse("0193a5b0-7000-7000-8000-000000000001") + secondID := uuid.MustParse("0193a5b0-7000-7000-8000-000000000002") + createdAt := time.Date(2026, 6, 12, 10, 0, 0, 0, time.UTC) + + got := deterministicFirstAgentID([]store.AgentData{ + {BaseModel: store.BaseModel{ID: secondID, CreatedAt: createdAt.Add(time.Second)}}, + {BaseModel: store.BaseModel{ID: firstID, CreatedAt: createdAt}}, + }) + + if got != firstID { + t.Fatalf("deterministic first id=%s, want %s", got, firstID) + } + + tieWinner := deterministicFirstAgentID([]store.AgentData{ + {BaseModel: store.BaseModel{ID: secondID, CreatedAt: createdAt}}, + {BaseModel: store.BaseModel{ID: firstID, CreatedAt: createdAt}}, + }) + if tieWinner != firstID { + t.Fatalf("tie winner id=%s, want lexical uuid %s", tieWinner, firstID) + } +} diff --git a/internal/http/secure_cli_agent_grants_test.go b/internal/http/secure_cli_agent_grants_test.go index 9a4f322c..68ade8a5 100644 --- a/internal/http/secure_cli_agent_grants_test.go +++ b/internal/http/secure_cli_agent_grants_test.go @@ -186,6 +186,27 @@ func TestSecureCLIGrantCreateValidatesBinaryAndAgentScope(t *testing.T) { } } +func TestValidateAndSerializeEnvVarsRejectsGoClawGatewayToken(t *testing.T) { + rr := httptest.NewRecorder() + + envJSON, ok := validateAndSerializeEnvVars(rr, "en", json.RawMessage(`{ + "GOCLAW_GATEWAY_TOKEN": {"kind":"sensitive","value":"test-secret-token"} + }`)) + + if ok || envJSON != nil { + t.Fatalf("expected GOCLAW_GATEWAY_TOKEN to be rejected by public env validator") + } + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "GOCLAW_GATEWAY_TOKEN") { + t.Fatalf("expected rejected key in response, got %s", rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "test-secret-token") { + t.Fatalf("secret value leaked in validation error: %s", rr.Body.String()) + } +} + func TestSecureCLIGrantUpdateRejectsInvalidEnvVarsBeforeScalarUpdate(t *testing.T) { binaryID := uuid.New() grantID := uuid.New() diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 37c11706..7a3b05db 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -21,9 +21,17 @@ func init() { MsgInvalidUpdates: "invalid updates", // Agent - MsgAgentNotFound: "agent not found: %s", - MsgCannotDeleteDefault: "cannot delete the default agent", - MsgUserCtxRequired: "user context required", + MsgAgentNotFound: "agent not found: %s", + MsgCannotDeleteDefault: "cannot delete the default agent", + MsgUserCtxRequired: "user context required", + MsgGatewayOperatorSecureCLIUnavailable: "Gateway operator access was skipped because SecureCLI storage is unavailable.", + MsgGatewayOperatorEligibilityFailed: "Agent was created, but gateway operator access could not verify first-agent eligibility.", + MsgGatewayOperatorNotFirstAgent: "Gateway operator access was not granted because this is not the first agent.", + MsgGatewayOperatorTokenMissing: "Gateway operator access was skipped because the gateway token is not configured.", + MsgGatewayOperatorBinaryMissing: "Gateway operator access was skipped because the goclaw binary could not be discovered.", + MsgGatewayOperatorExistingReview: "Gateway operator access was skipped because an existing goclaw CLI credential requires manual review.", + MsgGatewayOperatorRegisterFailed: "Gateway operator access was skipped because the goclaw CLI credential could not be registered.", + MsgGatewayOperatorCredentialFailed: "Gateway operator access was skipped because credentials could not be stored.", // Chat MsgRateLimitExceeded: "rate limit exceeded — please wait", diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index 194834be..83091133 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -21,9 +21,17 @@ func init() { MsgInvalidUpdates: "cập nhật không hợp lệ", // Agent - MsgAgentNotFound: "không tìm thấy agent: %s", - MsgCannotDeleteDefault: "không thể xóa agent mặc định", - MsgUserCtxRequired: "yêu cầu ngữ cảnh người dùng", + MsgAgentNotFound: "không tìm thấy agent: %s", + MsgCannotDeleteDefault: "không thể xóa agent mặc định", + MsgUserCtxRequired: "yêu cầu ngữ cảnh người dùng", + MsgGatewayOperatorSecureCLIUnavailable: "Đã bỏ qua quyền gateway operator vì kho SecureCLI chưa khả dụng.", + MsgGatewayOperatorEligibilityFailed: "Agent đã được tạo, nhưng không thể xác minh đây là agent đầu tiên để cấp quyền gateway operator.", + MsgGatewayOperatorNotFirstAgent: "Không cấp quyền gateway operator vì đây không phải agent đầu tiên.", + MsgGatewayOperatorTokenMissing: "Đã bỏ qua quyền gateway operator vì gateway token chưa được cấu hình.", + MsgGatewayOperatorBinaryMissing: "Đã bỏ qua quyền gateway operator vì không tìm thấy binary goclaw.", + MsgGatewayOperatorExistingReview: "Đã bỏ qua quyền gateway operator vì credential CLI goclaw hiện có cần kiểm tra thủ công.", + MsgGatewayOperatorRegisterFailed: "Đã bỏ qua quyền gateway operator vì không thể đăng ký credential CLI goclaw.", + MsgGatewayOperatorCredentialFailed: "Đã bỏ qua quyền gateway operator vì không thể lưu credential.", // Chat MsgRateLimitExceeded: "vượt quá giới hạn tốc độ — vui lòng đợi", diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index c1989d84..259bc620 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -21,9 +21,17 @@ func init() { MsgInvalidUpdates: "更新内容无效", // Agent - MsgAgentNotFound: "未找到Agent:%s", - MsgCannotDeleteDefault: "无法删除默认Agent", - MsgUserCtxRequired: "需要用户上下文", + MsgAgentNotFound: "未找到Agent:%s", + MsgCannotDeleteDefault: "无法删除默认Agent", + MsgUserCtxRequired: "需要用户上下文", + MsgGatewayOperatorSecureCLIUnavailable: "已跳过网关 operator 访问,因为 SecureCLI 存储不可用。", + MsgGatewayOperatorEligibilityFailed: "Agent 已创建,但无法验证其是否为第一个 Agent 来授予网关 operator 访问。", + MsgGatewayOperatorNotFirstAgent: "未授予网关 operator 访问,因为这不是第一个 Agent。", + MsgGatewayOperatorTokenMissing: "已跳过网关 operator 访问,因为未配置网关 token。", + MsgGatewayOperatorBinaryMissing: "已跳过网关 operator 访问,因为无法发现 goclaw binary。", + MsgGatewayOperatorExistingReview: "已跳过网关 operator 访问,因为现有 goclaw CLI credential 需要手动检查。", + MsgGatewayOperatorRegisterFailed: "已跳过网关 operator 访问,因为无法注册 goclaw CLI credential。", + MsgGatewayOperatorCredentialFailed: "已跳过网关 operator 访问,因为无法存储 credential。", // Chat MsgRateLimitExceeded: "请求频率超限 — 请稍候", diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index 34442601..6628476e 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -22,9 +22,17 @@ const ( MsgInvalidUpdates = "error.invalid_updates" // "invalid updates" // --- Agent --- - MsgAgentNotFound = "error.agent_not_found" // "agent not found: %s" - MsgCannotDeleteDefault = "error.cannot_delete_default" // "cannot delete the default agent" - MsgUserCtxRequired = "error.user_ctx_required" // "user context required" + MsgAgentNotFound = "error.agent_not_found" // "agent not found: %s" + MsgCannotDeleteDefault = "error.cannot_delete_default" // "cannot delete the default agent" + MsgUserCtxRequired = "error.user_ctx_required" // "user context required" + MsgGatewayOperatorSecureCLIUnavailable = "gateway_operator.secure_cli_unavailable" + MsgGatewayOperatorEligibilityFailed = "gateway_operator.eligibility_failed" + MsgGatewayOperatorNotFirstAgent = "gateway_operator.not_first_agent" + MsgGatewayOperatorTokenMissing = "gateway_operator.token_missing" + MsgGatewayOperatorBinaryMissing = "gateway_operator.binary_missing" + MsgGatewayOperatorExistingReview = "gateway_operator.existing_review" + MsgGatewayOperatorRegisterFailed = "gateway_operator.register_failed" + MsgGatewayOperatorCredentialFailed = "gateway_operator.credential_failed" // --- Chat --- MsgRateLimitExceeded = "error.rate_limit" // "rate limit exceeded — please wait" diff --git a/internal/tools/credentialed_exec.go b/internal/tools/credentialed_exec.go index bc4f731e..bcf7cd0e 100644 --- a/internal/tools/credentialed_exec.go +++ b/internal/tools/credentialed_exec.go @@ -426,7 +426,7 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL // Step 4: Register credential values for output scrubbing for _, v := range envMap { - AddCredentialScrubValues(v) + AddScrubValuesCtx(ctx, v) } // Step 5: Resolve binary to absolute path and verify against config diff --git a/internal/tools/credentialed_exec_env_test.go b/internal/tools/credentialed_exec_env_test.go index 609ad15d..99e7febc 100644 --- a/internal/tools/credentialed_exec_env_test.go +++ b/internal/tools/credentialed_exec_env_test.go @@ -184,6 +184,48 @@ func TestExec_RapidAPIWithRequiredEnvReachesDirectExec(t *testing.T) { } } +func TestExec_GoClawGatewayTokenRawOutputIsScrubbed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell script fixture is POSIX-only") + } + + const token = "plain-gateway-token-SHOULD-NOT-LEAK-12345" + binDir := t.TempDir() + binPath := filepath.Join(binDir, "goclaw") + if err := os.WriteFile(binPath, []byte("#!/bin/sh\nprintf '%s\\n' \"$GOCLAW_GATEWAY_TOKEN\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + stub := newStubSecureCLIStore() + stub.byName["goclaw"] = &store.SecureCLIBinary{ + BinaryName: "goclaw", + BinaryPath: &binPath, + EncryptedEnv: []byte(`{ + "GOCLAW_GATEWAY_TOKEN":{"kind":"sensitive","value":"` + token + `"}, + "GOCLAW_SERVER":{"kind":"sensitive","value":"http://127.0.0.1:18790"} + }`), + TimeoutSeconds: 10, + DenyArgs: json.RawMessage("[]"), + DenyVerbose: json.RawMessage("[]"), + } + + tool := NewExecTool(t.TempDir(), false) + tool.SetSecureCLIStore(stub) + + ctx := store.WithTenantID(store.WithAgentID(context.Background(), uuid.New()), uuid.New()) + result := tool.Execute(ctx, map[string]any{"command": "goclaw agent list"}) + + if result.IsError { + t.Fatalf("expected goclaw direct exec to run, got: %s", result.ForLLM) + } + if strings.Contains(result.ForLLM, token) { + t.Fatalf("raw gateway token leaked into output: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "[REDACTED]") { + t.Fatalf("expected gateway token to be redacted, got: %s", result.ForLLM) + } +} + func TestExec_GHMissingRequiredEnvFailsBeforeRawAuth(t *testing.T) { stub := newStubSecureCLIStore() stub.byName["gh"] = &store.SecureCLIBinary{ diff --git a/internal/tools/env_scrub.go b/internal/tools/env_scrub.go index f2e8ece6..7b792232 100644 --- a/internal/tools/env_scrub.go +++ b/internal/tools/env_scrub.go @@ -20,6 +20,7 @@ var staticCredentialEnvKeys = []string{ "NPM_TOKEN", "DOCKER_PASSWORD", "DOCKER_AUTH", + "GOCLAW_GATEWAY_TOKEN", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", diff --git a/internal/tools/env_scrub_test.go b/internal/tools/env_scrub_test.go index 033cabec..c614dcd2 100644 --- a/internal/tools/env_scrub_test.go +++ b/internal/tools/env_scrub_test.go @@ -19,6 +19,7 @@ func TestScrubCredentialEnv_StripsStatic(t *testing.T) { in := []string{ "HOME=/root", "GH_TOKEN=secret-abc", + "GOCLAW_GATEWAY_TOKEN=goclaw-secret", "PATH=/usr/bin", "AWS_SECRET_ACCESS_KEY=topsecret", "RAPIDAPI_KEY=rapid-secret", @@ -34,6 +35,9 @@ func TestScrubCredentialEnv_StripsStatic(t *testing.T) { if envContains(out, "RAPIDAPI_KEY") { t.Fatalf("RAPIDAPI_KEY must be scrubbed, got: %v", out) } + if envContains(out, "GOCLAW_GATEWAY_TOKEN") { + t.Fatalf("GOCLAW_GATEWAY_TOKEN must be scrubbed, got: %v", out) + } if !envContains(out, "HOME") || !envContains(out, "PATH") { t.Fatalf("essential vars must be preserved, got: %v", out) } diff --git a/ui/web/src/i18n/locales/en/setup.json b/ui/web/src/i18n/locales/en/setup.json index d6b4ac4e..72812125 100644 --- a/ui/web/src/i18n/locales/en/setup.json +++ b/ui/web/src/i18n/locales/en/setup.json @@ -75,6 +75,11 @@ "personalityHintBottom": "Customize this prompt to shape your agent's personality and expertise.", "selfEvolve": "Self-Evolution", "selfEvolveDesc": "Allow agent to evolve its communication style over time via SOUL.md", + "gatewayOperatorAccess": "Gateway operator access", + "gatewayOperatorAccessDesc": "Grant revocable SecureCLI access to the local goclaw CLI for this first agent.", + "gatewayOperatorGranted": "Gateway operator access granted", + "gatewayOperatorGrantedDesc": "The agent can use the local goclaw CLI. Review or revoke it in Packages → CLI Credentials.", + "gatewayOperatorWarning": "Gateway operator access warning", "creating": "Creating...", "create": "Create Agent", "summoningFailed": "Summoning failed. Please adjust your settings and try again.", diff --git a/ui/web/src/i18n/locales/vi/setup.json b/ui/web/src/i18n/locales/vi/setup.json index 2594dfb6..9b1fbafb 100644 --- a/ui/web/src/i18n/locales/vi/setup.json +++ b/ui/web/src/i18n/locales/vi/setup.json @@ -75,6 +75,11 @@ "personalityHintBottom": "Tùy chỉnh prompt này để định hình cá tính và chuyên môn của agent.", "selfEvolve": "Tự tiến hóa", "selfEvolveDesc": "Cho phép agent tự phát triển phong cách giao tiếp theo thời gian qua SOUL.md", + "gatewayOperatorAccess": "Quyền gateway operator", + "gatewayOperatorAccessDesc": "Cấp quyền SecureCLI có thể thu hồi vào CLI goclaw cục bộ cho agent đầu tiên này.", + "gatewayOperatorGranted": "Đã cấp quyền gateway operator", + "gatewayOperatorGrantedDesc": "Agent có thể dùng CLI goclaw cục bộ. Xem lại hoặc thu hồi trong Packages → CLI Credentials.", + "gatewayOperatorWarning": "Cảnh báo quyền gateway operator", "creating": "Đang tạo...", "create": "Tạo agent", "summoningFailed": "Triệu hồi thất bại. Vui lòng điều chỉnh cài đặt và thử lại.", diff --git a/ui/web/src/i18n/locales/zh/setup.json b/ui/web/src/i18n/locales/zh/setup.json index 3a6ae819..74adc4d7 100644 --- a/ui/web/src/i18n/locales/zh/setup.json +++ b/ui/web/src/i18n/locales/zh/setup.json @@ -75,6 +75,11 @@ "personalityHintBottom": "自定义此提示词以塑造Agent的个性和专业领域。", "selfEvolve": "自我进化", "selfEvolveDesc": "允许Agent通过 SOUL.md 随时间进化其沟通风格", + "gatewayOperatorAccess": "网关 operator 访问", + "gatewayOperatorAccessDesc": "为此首个 Agent 授予可撤销的本地 goclaw CLI SecureCLI 访问。", + "gatewayOperatorGranted": "已授予网关 operator 访问", + "gatewayOperatorGrantedDesc": "Agent 可以使用本地 goclaw CLI。可在 Packages → CLI Credentials 中查看或撤销。", + "gatewayOperatorWarning": "网关 operator 访问警告", "creating": "创建中...", "create": "创建Agent", "summoningFailed": "召唤失败,请调整设置后重试。", diff --git a/ui/web/src/pages/setup/step-agent.tsx b/ui/web/src/pages/setup/step-agent.tsx index d291f78c..d4932bf7 100644 --- a/ui/web/src/pages/setup/step-agent.tsx +++ b/ui/web/src/pages/setup/step-agent.tsx @@ -13,6 +13,7 @@ import { SummoningModal } from "@/pages/agents/summoning-modal"; import { useAgentPresets } from "@/pages/agents/agent-presets"; import { useWsEvent } from "@/hooks/use-ws-event"; import { slugify } from "@/lib/slug"; +import { toast } from "@/stores/use-toast-store"; import type { ProviderData } from "@/types/provider"; import type { AgentData } from "@/types/agent"; @@ -42,6 +43,11 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent } const [selfEvolve, setSelfEvolve] = useState( Boolean(existingAgent?.self_evolve), ); + const [gatewayOperatorAccess, setGatewayOperatorAccess] = useState(false); + const [gatewayOperatorNotice, setGatewayOperatorNotice] = useState<{ + variant: "success" | "warning"; + message: string; + } | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -121,6 +127,7 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent } setLoading(true); setError(""); + setGatewayOperatorNotice(null); try { if (isEditing) { @@ -147,9 +154,19 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent } agent_description: description.trim() || null, self_evolve: selfEvolve, emoji: selectedEmoji || null, + grant_gateway_operator_access: gatewayOperatorAccess || undefined, }; const result = await createAgent(data) as AgentData; + const bootstrap = result.gateway_operator_bootstrap; + if (bootstrap?.status === "granted") { + const message = t("agent.gatewayOperatorGrantedDesc"); + setGatewayOperatorNotice({ variant: "success", message }); + toast.success(t("agent.gatewayOperatorGranted"), message); + } else if (bootstrap?.warning) { + setGatewayOperatorNotice({ variant: "warning", message: bootstrap.warning }); + toast.warning(t("agent.gatewayOperatorWarning"), bootstrap.warning); + } setAgentResult(result); setSummoningOutcome("pending"); setCreatedAgent({ id: result.id, name: displayName.trim() || agentKey }); @@ -242,8 +259,34 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent } + {!isEditing && ( +
+
+ +

{t("agent.gatewayOperatorAccessDesc")}

+
+ +
+ )} + {gatewayOperatorNotice && ( +

+ {gatewayOperatorNotice.message} +

+ )} {error &&

{error}

}
diff --git a/ui/web/src/types/agent.ts b/ui/web/src/types/agent.ts index a5933d23..2e12ea78 100644 --- a/ui/web/src/types/agent.ts +++ b/ui/web/src/types/agent.ts @@ -212,6 +212,15 @@ export interface AgentData { other_config?: Record | null; budget_monthly_cents?: number | null; tenant_id?: string; + grant_gateway_operator_access?: boolean; + gateway_operator_bootstrap?: GatewayOperatorBootstrapResult | null; +} + +export interface GatewayOperatorBootstrapResult { + status: "granted" | "warning" | "skipped" | string; + binary_id?: string; + grant_id?: string; + warning?: string; } export interface AgentShareData {