feat: grant first setup agent gateway operator access

This commit is contained in:
Goon committed 2026-06-12 10:40:59 +07:00
1 parent 95fd1f8f2d
commit c320de5a87
22 files changed
+1300 -45

No files matched your search

+4
View File
@@ -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"
+12
View File
@@ -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)
}
}
+5
View File
@@ -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 {
+55 -11
View File
@@ -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)
}
}
}
+15
View File
@@ -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)
}
}
+40 -21
View File
@@ -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)
}
@@ -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)
}
@@ -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)
}
}
@@ -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()
+11 -3
View File
@@ -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",
+11 -3
View File
@@ -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",
+11 -3
View File
@@ -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: "请求频率超限 — 请稍候",
+11 -3
View File
@@ -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"
+1 -1
View File
@@ -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
@@ -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{
+1
View File
@@ -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",
+4
View File
@@ -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)
}
+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -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": "召唤失败,请调整设置后重试。",
+43
View File
@@ -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 }
</div>
<Switch id="setup-self-evolve" checked={selfEvolve} onCheckedChange={setSelfEvolve} />
</div>
{!isEditing && (
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="space-y-0.5">
<Label htmlFor="setup-gateway-operator" className="text-sm font-normal">
{t("agent.gatewayOperatorAccess")}
</Label>
<p className="text-xs text-muted-foreground">{t("agent.gatewayOperatorAccessDesc")}</p>
</div>
<Switch
id="setup-gateway-operator"
checked={gatewayOperatorAccess}
onCheckedChange={setGatewayOperatorAccess}
/>
</div>
)}
</div>
{gatewayOperatorNotice && (
<p
className={`rounded-md border px-3 py-2 text-xs ${
gatewayOperatorNotice.variant === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200"
: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200"
}`}
>
{gatewayOperatorNotice.message}
</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className={`flex ${onBack ? "justify-between" : "justify-end"} gap-2`}>
+9
View File
@@ -212,6 +212,15 @@ export interface AgentData {
other_config?: Record<string, unknown> | 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 {