mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat: grant first setup agent gateway operator access
This commit is contained in:
1 parent
95fd1f8f2d
commit
c320de5a87
22 files changed
+1300
-45
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "召唤失败,请调整设置后重试。",
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user