Add Vault settings UI and go-core KV bridge
This commit is contained in:
parent
31d83cfde9
commit
7c68bcdc39
325
go/go_core/internal/shared/vault.go
Normal file
325
go/go_core/internal/shared/vault.go
Normal file
@ -0,0 +1,325 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type VaultKVResult struct {
|
||||
Operation string `json:"operation"`
|
||||
Mount string `json:"mount"`
|
||||
Path string `json:"path"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func HandleVaultKVTool(arguments map[string]any) (string, error) {
|
||||
request, err := buildVaultKVRequest(arguments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := executeVaultKVRequest(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
type vaultKVRequest struct {
|
||||
baseURL string
|
||||
token string
|
||||
namespace string
|
||||
operation string
|
||||
mount string
|
||||
path string
|
||||
data map[string]any
|
||||
cas int
|
||||
}
|
||||
|
||||
func buildVaultKVRequest(arguments map[string]any) (vaultKVRequest, error) {
|
||||
baseURL := strings.TrimSpace(EnvOrDefault("VAULT_SERVER_URL", ""))
|
||||
if baseURL == "" {
|
||||
return vaultKVRequest{}, errors.New("VAULT_SERVER_URL environment variable not set")
|
||||
}
|
||||
token := strings.TrimSpace(EnvOrDefault("VAULT_SERVER_ROOT_ACCESS_TOKEN", ""))
|
||||
if token == "" {
|
||||
return vaultKVRequest{}, errors.New("VAULT_SERVER_ROOT_ACCESS_TOKEN environment variable not set")
|
||||
}
|
||||
operation := strings.ToLower(strings.TrimSpace(StringArg(arguments, "operation", "")))
|
||||
if operation == "" {
|
||||
return vaultKVRequest{}, errors.New("operation is required")
|
||||
}
|
||||
path := normalizeVaultPath(StringArg(arguments, "path", ""))
|
||||
if path == "" {
|
||||
return vaultKVRequest{}, errors.New("path is required")
|
||||
}
|
||||
data, err := vaultDataArg(arguments["data"])
|
||||
if err != nil {
|
||||
return vaultKVRequest{}, err
|
||||
}
|
||||
return vaultKVRequest{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
namespace: strings.TrimSpace(EnvOrDefault("VAULT_NAMESPACE", "")),
|
||||
operation: operation,
|
||||
mount: normalizeVaultMount(StringArg(arguments, "mount", "secret")),
|
||||
path: path,
|
||||
data: data,
|
||||
cas: vaultCASArg(arguments["cas"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func executeVaultKVRequest(request vaultKVRequest) (VaultKVResult, error) {
|
||||
switch request.operation {
|
||||
case "get", "read":
|
||||
return vaultKVRead(request)
|
||||
case "put", "write":
|
||||
return vaultKVWrite(request)
|
||||
case "list":
|
||||
return vaultKVList(request)
|
||||
case "delete":
|
||||
return vaultKVDelete(request)
|
||||
default:
|
||||
return VaultKVResult{}, fmt.Errorf("unsupported operation: %s", request.operation)
|
||||
}
|
||||
}
|
||||
|
||||
func vaultKVRead(request vaultKVRequest) (VaultKVResult, error) {
|
||||
response, err := doVaultRequest(
|
||||
request,
|
||||
http.MethodGet,
|
||||
vaultDataURL(request.mount, request.path),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return VaultKVResult{}, err
|
||||
}
|
||||
dataBlock := mapArg(response["data"])
|
||||
return VaultKVResult{
|
||||
Operation: "read",
|
||||
Mount: request.mount,
|
||||
Path: request.path,
|
||||
Data: mapArg(dataBlock["data"]),
|
||||
Metadata: mapArg(dataBlock["metadata"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func vaultKVWrite(request vaultKVRequest) (VaultKVResult, error) {
|
||||
if len(request.data) == 0 {
|
||||
return VaultKVResult{}, errors.New("data is required for write operations")
|
||||
}
|
||||
payload := map[string]any{"data": request.data}
|
||||
if request.cas > 0 {
|
||||
payload["options"] = map[string]any{"cas": request.cas}
|
||||
}
|
||||
response, err := doVaultRequest(
|
||||
request,
|
||||
http.MethodPost,
|
||||
vaultDataURL(request.mount, request.path),
|
||||
payload,
|
||||
)
|
||||
if err != nil {
|
||||
return VaultKVResult{}, err
|
||||
}
|
||||
return VaultKVResult{
|
||||
Operation: "write",
|
||||
Mount: request.mount,
|
||||
Path: request.path,
|
||||
Data: request.data,
|
||||
Metadata: mapArg(mapArg(response["data"])["metadata"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func vaultKVList(request vaultKVRequest) (VaultKVResult, error) {
|
||||
response, err := doVaultRequest(
|
||||
request,
|
||||
"LIST",
|
||||
vaultMetadataURL(request.mount, request.path),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return VaultKVResult{}, err
|
||||
}
|
||||
dataBlock := mapArg(response["data"])
|
||||
return VaultKVResult{
|
||||
Operation: "list",
|
||||
Mount: request.mount,
|
||||
Path: request.path,
|
||||
Keys: stringSliceArg(dataBlock["keys"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func vaultKVDelete(request vaultKVRequest) (VaultKVResult, error) {
|
||||
_, err := doVaultRequest(
|
||||
request,
|
||||
http.MethodDelete,
|
||||
vaultDataURL(request.mount, request.path),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return VaultKVResult{}, err
|
||||
}
|
||||
return VaultKVResult{
|
||||
Operation: "delete",
|
||||
Mount: request.mount,
|
||||
Path: request.path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func doVaultRequest(
|
||||
request vaultKVRequest,
|
||||
method string,
|
||||
target string,
|
||||
payload map[string]any,
|
||||
) (map[string]any, error) {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, target, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("X-Vault-Token", request.token)
|
||||
if request.namespace != "" {
|
||||
httpRequest.Header.Set("X-Vault-Namespace", request.namespace)
|
||||
}
|
||||
if payload != nil {
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
response, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
bodyBytes, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf(
|
||||
"vault api error %d: %s",
|
||||
response.StatusCode,
|
||||
strings.TrimSpace(string(bodyBytes)),
|
||||
)
|
||||
}
|
||||
if len(strings.TrimSpace(string(bodyBytes))) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(bodyBytes, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func vaultDataURL(mount, path string) string {
|
||||
return fmt.Sprintf("%s/data/%s", vaultBasePath(mount), vaultPathSegments(path))
|
||||
}
|
||||
|
||||
func vaultMetadataURL(mount, path string) string {
|
||||
return fmt.Sprintf("%s/metadata/%s", vaultBasePath(mount), vaultPathSegments(path))
|
||||
}
|
||||
|
||||
func vaultBasePath(mount string) string {
|
||||
return fmt.Sprintf("%s/v1/%s", strings.TrimRight(strings.TrimSpace(EnvOrDefault("VAULT_SERVER_URL", "")), "/"), url.PathEscape(normalizeVaultMount(mount)))
|
||||
}
|
||||
|
||||
func vaultPathSegments(path string) string {
|
||||
segments := strings.Split(normalizeVaultPath(path), "/")
|
||||
for index, segment := range segments {
|
||||
segments[index] = url.PathEscape(segment)
|
||||
}
|
||||
return strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
func normalizeVaultMount(raw string) string {
|
||||
trimmed := strings.Trim(strings.TrimSpace(raw), "/")
|
||||
if trimmed == "" {
|
||||
return "secret"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func normalizeVaultPath(raw string) string {
|
||||
return strings.Trim(strings.TrimSpace(raw), "/")
|
||||
}
|
||||
|
||||
func vaultDataArg(raw any) (map[string]any, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
switch typed := raw.(type) {
|
||||
case map[string]any:
|
||||
return typed, nil
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
|
||||
return nil, errors.New("data must be a JSON object")
|
||||
}
|
||||
return decoded, nil
|
||||
default:
|
||||
return nil, errors.New("data must be an object")
|
||||
}
|
||||
}
|
||||
|
||||
func vaultCASArg(raw any) int {
|
||||
switch typed := raw.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
case string:
|
||||
return IntArg(typed, 0)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func mapArg(raw any) map[string]any {
|
||||
switch typed := raw.(type) {
|
||||
case map[string]any:
|
||||
return typed
|
||||
default:
|
||||
return map[string]any{}
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceArg(raw any) []string {
|
||||
values, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
142
go/go_core/internal/shared/vault_test.go
Normal file
142
go/go_core/internal/shared/vault_test.go
Normal file
@ -0,0 +1,142 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleVaultKVToolReadsSecretData(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("X-Vault-Token"); got != "root-token" {
|
||||
t.Fatalf("unexpected token header: %s", got)
|
||||
}
|
||||
if got := r.Header.Get("X-Vault-Namespace"); got != "platform/team-a" {
|
||||
t.Fatalf("unexpected namespace header: %s", got)
|
||||
}
|
||||
if got := r.URL.Path; got != "/v1/secret/data/apps/demo" {
|
||||
t.Fatalf("unexpected request path: %s", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"data": map[string]any{
|
||||
"api_key": "demo-key",
|
||||
},
|
||||
"metadata": map[string]any{
|
||||
"version": 3,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv("VAULT_SERVER_URL", server.URL)
|
||||
t.Setenv("VAULT_SERVER_ROOT_ACCESS_TOKEN", "root-token")
|
||||
t.Setenv("VAULT_NAMESPACE", "platform/team-a")
|
||||
|
||||
output, err := HandleVaultKVTool(map[string]any{
|
||||
"operation": "read",
|
||||
"path": "apps/demo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleVaultKVTool returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, `"api_key": "demo-key"`) {
|
||||
t.Fatalf("expected secret data in output, got %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVaultKVToolWritesSecretData(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
if got := r.URL.Path; got != "/v1/secret/data/apps/demo" {
|
||||
t.Fatalf("unexpected request path: %s", got)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
data := mapArg(payload["data"])
|
||||
if got := data["enabled"]; got != true {
|
||||
t.Fatalf("unexpected data payload: %v", payload)
|
||||
}
|
||||
options := mapArg(payload["options"])
|
||||
if got := options["cas"]; got != float64(2) {
|
||||
t.Fatalf("unexpected cas payload: %v", payload)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"version": 4,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv("VAULT_SERVER_URL", server.URL)
|
||||
t.Setenv("VAULT_SERVER_ROOT_ACCESS_TOKEN", "root-token")
|
||||
|
||||
output, err := HandleVaultKVTool(map[string]any{
|
||||
"operation": "write",
|
||||
"path": "apps/demo",
|
||||
"data": map[string]any{
|
||||
"enabled": true,
|
||||
},
|
||||
"cas": 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleVaultKVTool returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, `"version": 4`) {
|
||||
t.Fatalf("expected metadata in output, got %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVaultKVToolListsSecretKeys(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "LIST" {
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
if got := r.URL.Path; got != "/v1/secret/metadata/apps" {
|
||||
t.Fatalf("unexpected request path: %s", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"keys": []string{"demo", "prod"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv("VAULT_SERVER_URL", server.URL)
|
||||
t.Setenv("VAULT_SERVER_ROOT_ACCESS_TOKEN", "root-token")
|
||||
|
||||
output, err := HandleVaultKVTool(map[string]any{
|
||||
"operation": "list",
|
||||
"path": "apps",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleVaultKVTool returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, `"demo"`) || !strings.Contains(output, `"prod"`) {
|
||||
t.Fatalf("expected listed keys in output, got %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVaultKVToolRequiresEnvironment(t *testing.T) {
|
||||
_, err := HandleVaultKVTool(map[string]any{
|
||||
"operation": "read",
|
||||
"path": "apps/demo",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "VAULT_SERVER_URL") {
|
||||
t.Fatalf("expected missing environment error, got %v", err)
|
||||
}
|
||||
}
|
||||
@ -131,6 +131,21 @@ func handleRequest(request shared.RPCRequest) map[string]any {
|
||||
"required": []string{"prompt"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "vault_kv",
|
||||
"description": "HashiCorp Vault K/V v2 bridge",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"operation": map[string]any{"type": "string"},
|
||||
"mount": map[string]any{"type": "string"},
|
||||
"path": map[string]any{"type": "string"},
|
||||
"data": map[string]any{"type": "object"},
|
||||
"cas": map[string]any{"type": "number"},
|
||||
},
|
||||
"required": []string{"operation", "path"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "tools/call":
|
||||
@ -156,6 +171,12 @@ func handleRequest(request shared.RPCRequest) map[string]any {
|
||||
return shared.ToolErrorResult(request.ID, err)
|
||||
}
|
||||
return shared.ToolTextResult(request.ID, content)
|
||||
case "vault_kv":
|
||||
content, err := shared.HandleVaultKVTool(params.Arguments)
|
||||
if err != nil {
|
||||
return shared.ToolErrorResult(request.ID, err)
|
||||
}
|
||||
return shared.ToolTextResult(request.ID, content)
|
||||
default:
|
||||
return shared.ErrorResponse(
|
||||
request.ID,
|
||||
|
||||
80
go/go_core/internal/toolbridge/runner_test.go
Normal file
80
go/go_core/internal/toolbridge/runner_test.go
Normal file
@ -0,0 +1,80 @@
|
||||
package toolbridge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xworkmate/go_core/internal/shared"
|
||||
)
|
||||
|
||||
func TestHandleRequestListsVaultKVTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
response := handleRequest(sharedRequest("tools/list", nil))
|
||||
result := mapStringAny(response["result"])
|
||||
tools := result["tools"].([]map[string]any)
|
||||
found := false
|
||||
for _, tool := range tools {
|
||||
if tool["name"] == "vault_kv" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected vault_kv tool in %v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestCallsVaultKVTool(t *testing.T) {
|
||||
var requestPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestPath = r.URL.Path
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"data": map[string]any{
|
||||
"demo": "value",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv("VAULT_SERVER_URL", server.URL)
|
||||
t.Setenv("VAULT_SERVER_ROOT_ACCESS_TOKEN", "root-token")
|
||||
|
||||
response := handleRequest(sharedRequest("tools/call", map[string]any{
|
||||
"name": "vault_kv",
|
||||
"arguments": map[string]any{
|
||||
"operation": "read",
|
||||
"path": "apps/demo",
|
||||
},
|
||||
}))
|
||||
result := mapStringAny(response["result"])
|
||||
content := result["content"].([]map[string]any)
|
||||
text := strings.TrimSpace(content[0]["text"].(string))
|
||||
if !strings.Contains(text, `"demo": "value"`) {
|
||||
t.Fatalf("unexpected tool output: %s", text)
|
||||
}
|
||||
if requestPath != "/v1/secret/data/apps/demo" {
|
||||
t.Fatalf("unexpected request path: %s", requestPath)
|
||||
}
|
||||
}
|
||||
|
||||
func sharedRequest(method string, params map[string]any) shared.RPCRequest {
|
||||
return shared.RPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: 1,
|
||||
Method: method,
|
||||
Params: params,
|
||||
}
|
||||
}
|
||||
|
||||
func mapStringAny(raw any) map[string]any {
|
||||
if typed, ok := raw.(map[string]any); ok {
|
||||
return typed
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
@ -23,6 +23,10 @@ func handleChatTool(arguments map[string]any) (string, error) {
|
||||
return shared.HandleChatTool(arguments)
|
||||
}
|
||||
|
||||
func handleVaultKVTool(arguments map[string]any) (string, error) {
|
||||
return shared.HandleVaultKVTool(arguments)
|
||||
}
|
||||
|
||||
func runClaudeReview(
|
||||
prompt,
|
||||
model,
|
||||
|
||||
@ -362,8 +362,8 @@ desktop:
|
||||
description: Desktop account access section
|
||||
ui_surface: settings_page
|
||||
vault_server:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Desktop Vault server integration section
|
||||
ui_surface: settings_page
|
||||
|
||||
@ -332,11 +332,21 @@ extension SettingsPageGatewayConnectionMixinInternal
|
||||
) {
|
||||
final hasStoredVaultToken =
|
||||
controller.settingsController.secureRefs['vault_token'] != null;
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText(
|
||||
'这里维护 Vault K/V 接入的服务地址与 root token。URL 进入设置草稿;root token 只会进入安全存储,不会写入普通 settings 持久层。',
|
||||
'Manage the Vault K/V endpoint and root token here. The URL stays in the settings draft, while the root token is persisted only through secure storage.',
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
EditableFieldInternal(
|
||||
label: appText('地址', 'Address'),
|
||||
fieldKey: const ValueKey('vault-server-url-field'),
|
||||
label: 'VAULT_SERVER_URL',
|
||||
value: settings.vault.address,
|
||||
onSubmitted: (value) => saveSettingsInternal(
|
||||
controller,
|
||||
@ -344,33 +354,34 @@ extension SettingsPageGatewayConnectionMixinInternal
|
||||
),
|
||||
),
|
||||
EditableFieldInternal(
|
||||
label: appText('命名空间', 'Namespace'),
|
||||
fieldKey: const ValueKey('vault-namespace-field'),
|
||||
label: appText('Namespace(可选)', 'Namespace (optional)'),
|
||||
value: settings.vault.namespace,
|
||||
onSubmitted: (value) => saveSettingsInternal(
|
||||
controller,
|
||||
settings.copyWith(vault: settings.vault.copyWith(namespace: value)),
|
||||
),
|
||||
),
|
||||
EditableFieldInternal(
|
||||
label: appText('认证模式', 'Auth Mode'),
|
||||
value: settings.vault.authMode,
|
||||
onSubmitted: (value) => saveSettingsInternal(
|
||||
controller,
|
||||
settings.copyWith(vault: settings.vault.copyWith(authMode: value)),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
EditableFieldInternal(
|
||||
label: appText('Token 引用', 'Token Ref'),
|
||||
value: settings.vault.tokenRef,
|
||||
onSubmitted: (value) => saveSettingsInternal(
|
||||
controller,
|
||||
settings.copyWith(vault: settings.vault.copyWith(tokenRef: value)),
|
||||
child: Text(
|
||||
appText(
|
||||
'当前固定使用 token 模式,安全引用保持为 vault_token。',
|
||||
'The current integration uses token auth, with the secure reference fixed to vault_token.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
buildSecureFieldInternal(
|
||||
fieldKey: const ValueKey('vault-root-access-token-field'),
|
||||
controller: vaultTokenControllerInternal,
|
||||
label:
|
||||
'${appText('Vault Token', 'Vault Token')} (${settings.vault.tokenRef})',
|
||||
label: 'VAULT_SERVER_ROOT_ACCESS_TOKEN (${settings.vault.tokenRef})',
|
||||
hasStoredValue: hasStoredVaultToken,
|
||||
fieldState: vaultTokenStateInternal,
|
||||
onStateChanged: (value) =>
|
||||
@ -378,12 +389,12 @@ extension SettingsPageGatewayConnectionMixinInternal
|
||||
loadValue: controller.settingsController.loadVaultToken,
|
||||
onSubmitted: (value) async => controller.saveVaultTokenDraft(value),
|
||||
storedHelperText: appText(
|
||||
'已安全保存,默认以 **** 显示,点击查看后读取真实值。',
|
||||
'Stored securely. Shows as **** until you reveal it.',
|
||||
'已安全保存;Vault root token 只会在测试连接或显式保存时使用。',
|
||||
'Stored securely. The Vault root token is only used for test/apply flows.',
|
||||
),
|
||||
emptyHelperText: appText(
|
||||
'输入后先进入草稿;保存后才会写入安全存储。',
|
||||
'Values stage into draft first and only persist to secure storage after Save.',
|
||||
'输入后先进入草稿;点击 Save / Apply 后才会写入安全存储。',
|
||||
'Values stage into draft first and only persist to secure storage after Save / Apply.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@ -118,8 +118,8 @@ extension SettingsPageSectionsMixinInternal on SettingsPageStateInternal {
|
||||
context,
|
||||
title: detail.label,
|
||||
description: appText(
|
||||
'只在这里维护 Vault 地址、命名空间和安全 token 引用。',
|
||||
'Maintain Vault endpoint, namespace, and secure token references here.',
|
||||
'在这里维护 Vault 服务地址、可选 namespace,以及只进入安全存储的 root token。',
|
||||
'Maintain the Vault server URL, optional namespace, and the root token that only persists in secure storage here.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@ -30,11 +30,13 @@ import 'settings_page_device.dart';
|
||||
class EditableFieldInternal extends StatefulWidget {
|
||||
const EditableFieldInternal({
|
||||
super.key,
|
||||
this.fieldKey,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
final Key? fieldKey;
|
||||
final String label;
|
||||
final String value;
|
||||
final ValueChanged<String> onSubmitted;
|
||||
@ -76,7 +78,7 @@ class EditableFieldStateInternal extends State<EditableFieldInternal> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: TextFormField(
|
||||
key: ValueKey('${widget.label}:${widget.value}'),
|
||||
key: widget.fieldKey ?? ValueKey('${widget.label}:${widget.value}'),
|
||||
controller: controllerInternal,
|
||||
decoration: InputDecoration(labelText: widget.label),
|
||||
onChanged: widget.onSubmitted,
|
||||
|
||||
@ -383,7 +383,7 @@ class VaultConfig {
|
||||
factory VaultConfig.defaults() {
|
||||
return const VaultConfig(
|
||||
address: 'http://127.0.0.1:8200',
|
||||
namespace: 'default',
|
||||
namespace: '',
|
||||
authMode: 'token',
|
||||
tokenRef: 'vault_token',
|
||||
);
|
||||
|
||||
@ -199,23 +199,24 @@ void main() {
|
||||
expect(find.text('账号本地模式'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage workspace tab no longer exposes remote project root', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
testWidgets(
|
||||
'SettingsPage workspace tab no longer exposes remote project root',
|
||||
(WidgetTester tester) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('工作区'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('工作区'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('远程项目根目录'), findsNothing);
|
||||
expect(find.text('Remote Project Root'), findsNothing);
|
||||
});
|
||||
expect(find.text('远程项目根目录'), findsNothing);
|
||||
expect(find.text('Remote Project Root'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('SettingsPage integration tab exposes unified gateway controls', (
|
||||
WidgetTester tester,
|
||||
@ -234,7 +235,15 @@ void main() {
|
||||
expect(find.text('OpenClaw Gateway'), findsWidgets);
|
||||
expect(find.text('LLM 接入点'), findsOneWidget);
|
||||
expect(find.text('ACP 外部接入'), findsOneWidget);
|
||||
expect(find.text('Vault Server'), findsNothing);
|
||||
expect(find.text('Vault Server'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('vault-server-url-field')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('vault-root-access-token-field')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('ai-gateway-url-field')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('gateway-mode-field')), findsNothing);
|
||||
expect(find.text('认证诊断'), findsNothing);
|
||||
@ -275,20 +284,10 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage can expose vault section when feature enabled', (
|
||||
testWidgets('SettingsPage vault card exposes concrete K/V fields', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final manifest = UiFeatureManifest.fallback().copyWithFeature(
|
||||
platform: UiFeaturePlatform.desktop,
|
||||
module: 'settings',
|
||||
feature: 'vault_server',
|
||||
enabled: true,
|
||||
releaseTier: UiFeatureReleaseTier.experimental,
|
||||
);
|
||||
final controller = await createTestController(
|
||||
tester,
|
||||
uiFeatureManifest: manifest,
|
||||
);
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
@ -300,6 +299,13 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Vault Server'), findsOneWidget);
|
||||
expect(find.text('VAULT_SERVER_URL'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('VAULT_SERVER_ROOT_ACCESS_TOKEN'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('vault-save-button')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('vault-apply-button')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage integration tab exposes ACP provider endpoints', (
|
||||
|
||||
107
test/features/settings_vault_persistence_suite.dart
Normal file
107
test/features/settings_vault_persistence_suite.dart
Normal file
@ -0,0 +1,107 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/features/settings/settings_page.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
import '../test_support.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'SettingsPage Vault card updates the draft URL and token input state',
|
||||
(WidgetTester tester) async {
|
||||
late _VaultSettingsTestController controller;
|
||||
late Directory testRoot;
|
||||
await tester.runAsync(() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
testRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-vault-widget-tests-',
|
||||
);
|
||||
controller = _VaultSettingsTestController(
|
||||
store: SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async =>
|
||||
'${testRoot.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => testRoot.path,
|
||||
),
|
||||
);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
});
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(() async {
|
||||
if (await testRoot.exists()) {
|
||||
await testRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('集成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('vault-server-url-field')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('vault-namespace-field')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('vault-root-access-token-field')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('vault-server-url-field')),
|
||||
'https://vault.example.com',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('vault-namespace-field')),
|
||||
'platform/team-a',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('vault-root-access-token-field')),
|
||||
'vault-root-secret',
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.settingsDraft.vault.address,
|
||||
'https://vault.example.com',
|
||||
);
|
||||
expect(
|
||||
controller.settings.vault.address,
|
||||
isNot('https://vault.example.com'),
|
||||
);
|
||||
expect(controller.settingsDraft.vault.namespace, 'platform/team-a');
|
||||
expect(controller.hasSettingsDraftChanges, isTrue);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _VaultSettingsTestController extends AppController {
|
||||
_VaultSettingsTestController({super.store});
|
||||
|
||||
@override
|
||||
Future<void> refreshMultiAgentMounts({bool sync = false}) async {}
|
||||
}
|
||||
|
||||
Future<void> _waitFor(bool Function() predicate) async {
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 10));
|
||||
while (!predicate()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
throw StateError('condition not met before timeout');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
7
test/features/settings_vault_persistence_test.dart
Normal file
7
test/features/settings_vault_persistence_test.dart
Normal file
@ -0,0 +1,7 @@
|
||||
import '../test_suite_stub.dart'
|
||||
if (dart.library.io) 'settings_vault_persistence_suite.dart'
|
||||
as suite;
|
||||
|
||||
void main() {
|
||||
suite.main();
|
||||
}
|
||||
@ -108,6 +108,35 @@ void registerSecureConfigStoreSuiteSecretsTestsInternal() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore keeps Vault root token out of the settings snapshot payload',
|
||||
() async {
|
||||
final tempDirectory = await createTempDirectoryInternal(
|
||||
'xworkmate-config-store-vault-secret-',
|
||||
);
|
||||
final store = createStoreFromTempDirectoryInternal(tempDirectory);
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
vault: SettingsSnapshot.defaults().vault.copyWith(
|
||||
address: 'https://vault.example.com',
|
||||
namespace: 'platform/team-a',
|
||||
),
|
||||
);
|
||||
|
||||
await store.saveSettingsSnapshot(snapshot);
|
||||
await store.saveVaultToken('vault-root-secret');
|
||||
|
||||
expect(await store.loadVaultToken(), 'vault-root-secret');
|
||||
expect(
|
||||
(await store.loadSecureRefs())['vault_token'],
|
||||
'vault-root-secret',
|
||||
);
|
||||
expect(
|
||||
(await store.loadSettingsSnapshot()).toJsonString(),
|
||||
isNot(contains('vault-root-secret')),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore exposes an explicit secrets write failure when durable secret storage is unavailable',
|
||||
() async {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user