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 }
{t("agent.gatewayOperatorAccessDesc")}
++ {gatewayOperatorNotice.message} +
+ )} {error &&{error}
}