mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 04:20:16 +00:00
297 lines
9.7 KiB
Go
297 lines
9.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
func TestMergeCredentialedEnvPerUserOverridesGrantEnv(t *testing.T) {
|
|
binary := &store.SecureCLIBinary{
|
|
EncryptedEnv: []byte(`{"SHARED_KEY":"binary","BINARY_ONLY":"base"}`),
|
|
}
|
|
binary.MergeGrantOverrides(&store.SecureCLIAgentGrant{
|
|
EncryptedEnv: []byte(`{"SHARED_KEY":"grant","GRANT_ONLY":"agent"}`),
|
|
})
|
|
binary.UserEnv = []byte(`{"SHARED_KEY":"user","USER_ONLY":"personal"}`)
|
|
|
|
env, err := mergeCredentialedEnv(binary)
|
|
if err != nil {
|
|
t.Fatalf("mergeCredentialedEnv returned error: %v", err)
|
|
}
|
|
|
|
if got := env["SHARED_KEY"]; got != "user" {
|
|
t.Fatalf("expected per-user env to win for duplicate key, got %q", got)
|
|
}
|
|
if got := env["GRANT_ONLY"]; got != "agent" {
|
|
t.Fatalf("expected grant env key to remain, got %q", got)
|
|
}
|
|
if got := env["USER_ONLY"]; got != "personal" {
|
|
t.Fatalf("expected per-user env key to remain, got %q", got)
|
|
}
|
|
if _, ok := env["BINARY_ONLY"]; ok {
|
|
t.Fatal("expected agent grant env to replace binary default env")
|
|
}
|
|
}
|
|
|
|
func TestMergeCredentialedEnvFailsClosedOnInvalidUserEnv(t *testing.T) {
|
|
_, err := mergeCredentialedEnv(&store.SecureCLIBinary{
|
|
EncryptedEnv: []byte(`{"SHARED_KEY":"grant"}`),
|
|
UserEnv: []byte(`{broken json`),
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected invalid per-user env JSON to fail closed")
|
|
}
|
|
}
|
|
|
|
func TestMergeCredentialedEnvFlattensSensitiveValueEntries(t *testing.T) {
|
|
binary := &store.SecureCLIBinary{
|
|
EncryptedEnv: []byte(`{
|
|
"TOKEN":{"kind":"sensitive","value":"secret"},
|
|
"PUBLIC_BASE_URL":{"kind":"value","value":"https://goclaw.sh"}
|
|
}`),
|
|
UserEnv: []byte(`{"PUBLIC_BASE_URL":{"kind":"value","value":"https://user.example"}}`),
|
|
}
|
|
|
|
env, err := mergeCredentialedEnv(binary)
|
|
if err != nil {
|
|
t.Fatalf("mergeCredentialedEnv() error = %v", err)
|
|
}
|
|
if env["TOKEN"] != "secret" {
|
|
t.Fatalf("TOKEN = %q", env["TOKEN"])
|
|
}
|
|
if env["PUBLIC_BASE_URL"] != "https://user.example" {
|
|
t.Fatalf("PUBLIC_BASE_URL = %q", env["PUBLIC_BASE_URL"])
|
|
}
|
|
}
|
|
|
|
func TestMergeCredentialedEnvUsesAgentEnvCredential(t *testing.T) {
|
|
typ := "env"
|
|
binary := &store.SecureCLIBinary{
|
|
EncryptedEnv: []byte(`{"SHARED_KEY":"binary","BINARY_ONLY":"base"}`),
|
|
}
|
|
binary.SetEffectiveCredential([]byte(`{"SHARED_KEY":"agent","AGENT_ONLY":"scoped"}`), &typ, nil, "agent", "")
|
|
|
|
env, err := mergeCredentialedEnv(binary)
|
|
if err != nil {
|
|
t.Fatalf("mergeCredentialedEnv() error = %v", err)
|
|
}
|
|
if env["SHARED_KEY"] != "agent" {
|
|
t.Fatalf("SHARED_KEY = %q, want agent override", env["SHARED_KEY"])
|
|
}
|
|
if env["AGENT_ONLY"] != "scoped" {
|
|
t.Fatalf("AGENT_ONLY = %q", env["AGENT_ONLY"])
|
|
}
|
|
}
|
|
|
|
func TestMergeCredentialedEnvDoesNotFlattenTypedCredentialBlob(t *testing.T) {
|
|
typ := "pat"
|
|
binary := &store.SecureCLIBinary{
|
|
EncryptedEnv: []byte(`{"PUBLIC_BASE_URL":"https://goclaw.sh"}`),
|
|
}
|
|
binary.SetEffectiveCredential([]byte(`{"token":"ghp_not_real_token"}`), &typ, nil, "agent", "")
|
|
|
|
env, err := mergeCredentialedEnv(binary)
|
|
if err != nil {
|
|
t.Fatalf("mergeCredentialedEnv() error = %v", err)
|
|
}
|
|
if _, ok := env["token"]; ok {
|
|
t.Fatalf("typed credential blob was flattened into child env: %#v", env)
|
|
}
|
|
if env["PUBLIC_BASE_URL"] != "https://goclaw.sh" {
|
|
t.Fatalf("PUBLIC_BASE_URL = %q", env["PUBLIC_BASE_URL"])
|
|
}
|
|
}
|
|
|
|
func TestExec_RapidAPIMissingRequiredEnvFailsBeforeBinaryResolution(t *testing.T) {
|
|
stub := newStubSecureCLIStore()
|
|
stub.byName["rapidapi"] = &store.SecureCLIBinary{
|
|
BinaryName: "rapidapi",
|
|
EncryptedEnv: []byte("{}"),
|
|
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())
|
|
ctx = store.WithCredentialUserID(ctx, "tenant-user-rapidapi")
|
|
result := tool.Execute(ctx, map[string]any{"command": "rapidapi search weather"})
|
|
|
|
if !result.IsError {
|
|
t.Fatalf("expected missing RAPIDAPI_KEY to fail")
|
|
}
|
|
if !strings.Contains(result.ForLLM, "missing required credential env") {
|
|
t.Fatalf("expected actionable missing-env error, got: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "RAPIDAPI_KEY") {
|
|
t.Fatalf("expected missing key name in error, got: %s", result.ForLLM)
|
|
}
|
|
if strings.Contains(result.ForLLM, "not found in PATH") || strings.Contains(result.ForLLM, "Binary resolution failed") {
|
|
t.Fatalf("missing env should be reported before binary resolution, got: %s", result.ForLLM)
|
|
}
|
|
if strings.Contains(result.ForLLM, "tenant-user-rapidapi") {
|
|
t.Fatalf("credential user id leaked into user-facing error: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
func TestExec_RapidAPIWithRequiredEnvReachesDirectExec(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell script fixture is POSIX-only")
|
|
}
|
|
|
|
binDir := t.TempDir()
|
|
binPath := filepath.Join(binDir, "rapidapi")
|
|
if err := os.WriteFile(binPath, []byte("#!/bin/sh\n[ -n \"$RAPIDAPI_KEY\" ] || exit 43\nprintf 'rapidapi args:%s\\n' \"$*\"\n"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stub := newStubSecureCLIStore()
|
|
stub.byName["rapidapi"] = &store.SecureCLIBinary{
|
|
BinaryName: "rapidapi",
|
|
BinaryPath: &binPath,
|
|
EncryptedEnv: []byte("{}"),
|
|
UserEnv: []byte(`{"RAPIDAPI_KEY":"test-key-not-real"}`),
|
|
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": "rapidapi search weather"})
|
|
|
|
if result.IsError {
|
|
t.Fatalf("expected rapidapi direct exec to run, got: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "rapidapi args:search weather") {
|
|
t.Fatalf("expected script output, got: %s", result.ForLLM)
|
|
}
|
|
if strings.Contains(result.ForLLM, "test-key-not-real") {
|
|
t.Fatalf("credential value leaked into output: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
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{
|
|
BinaryName: "gh",
|
|
EncryptedEnv: []byte("{}"),
|
|
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": "gh issue list --repo digitopvn/goclaw --limit 1"})
|
|
|
|
if !result.IsError {
|
|
t.Fatalf("expected missing GH_TOKEN to fail")
|
|
}
|
|
if !strings.Contains(result.ForLLM, "missing required credential env") {
|
|
t.Fatalf("expected actionable missing-env error, got: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "GH_TOKEN") {
|
|
t.Fatalf("expected GH_TOKEN in error, got: %s", result.ForLLM)
|
|
}
|
|
if strings.Contains(result.ForLLM, "gh auth login") {
|
|
t.Fatalf("raw gh auth guidance leaked through: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
func TestExec_GitRemoteCommandWithoutTypedCredentialFailsBeforeRawAuth(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell script fixture is POSIX-only")
|
|
}
|
|
|
|
binDir := t.TempDir()
|
|
binPath := filepath.Join(binDir, "git")
|
|
if err := os.WriteFile(binPath, []byte("#!/bin/sh\nprintf 'raw git reached\\n' >&2\nexit 128\n"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
adapterName := "git"
|
|
stub := newStubSecureCLIStore()
|
|
stub.byName["git"] = &store.SecureCLIBinary{
|
|
BinaryName: "git",
|
|
BinaryPath: &binPath,
|
|
EncryptedEnv: []byte("{}"),
|
|
TimeoutSeconds: 10,
|
|
DenyArgs: json.RawMessage("[]"),
|
|
DenyVerbose: json.RawMessage("[]"),
|
|
AdapterName: &adapterName,
|
|
}
|
|
|
|
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": "git push -u origin feature"})
|
|
|
|
if !result.IsError {
|
|
t.Fatalf("expected missing typed git credential to fail")
|
|
}
|
|
if !strings.Contains(result.ForLLM, "Git credential resolution failed") {
|
|
t.Fatalf("expected git credential diagnostic, got: %s", result.ForLLM)
|
|
}
|
|
if strings.Contains(result.ForLLM, "raw git reached") {
|
|
t.Fatalf("raw git command executed before credential resolution failed: %s", result.ForLLM)
|
|
}
|
|
}
|