diff --git a/go/go_core/internal/shared/vault.go b/go/go_core/internal/shared/vault.go new file mode 100644 index 00000000..d1482f88 --- /dev/null +++ b/go/go_core/internal/shared/vault.go @@ -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 == "" { + continue + } + result = append(result, text) + } + return result +} diff --git a/go/go_core/internal/shared/vault_test.go b/go/go_core/internal/shared/vault_test.go new file mode 100644 index 00000000..292a73cf --- /dev/null +++ b/go/go_core/internal/shared/vault_test.go @@ -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) + } +} diff --git a/go/go_core/internal/toolbridge/runner.go b/go/go_core/internal/toolbridge/runner.go index f7ccdac1..827173e7 100644 --- a/go/go_core/internal/toolbridge/runner.go +++ b/go/go_core/internal/toolbridge/runner.go @@ -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, diff --git a/go/go_core/internal/toolbridge/runner_test.go b/go/go_core/internal/toolbridge/runner_test.go new file mode 100644 index 00000000..2b6ef184 --- /dev/null +++ b/go/go_core/internal/toolbridge/runner_test.go @@ -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{} +} diff --git a/go/go_core/main_tools.go b/go/go_core/main_tools.go index c12a7d0c..8fb824af 100644 --- a/go/go_core/main_tools.go +++ b/go/go_core/main_tools.go @@ -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, diff --git a/lib/app/ui_feature_manifest_fallback.dart b/lib/app/ui_feature_manifest_fallback.dart index 323438c2..94f9f983 100644 --- a/lib/app/ui_feature_manifest_fallback.dart +++ b/lib/app/ui_feature_manifest_fallback.dart @@ -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 diff --git a/lib/features/settings/settings_page_gateway_connection.dart b/lib/features/settings/settings_page_gateway_connection.dart index 1903e24c..10184e72 100644 --- a/lib/features/settings/settings_page_gateway_connection.dart +++ b/lib/features/settings/settings_page_gateway_connection.dart @@ -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), diff --git a/lib/features/settings/settings_page_sections.dart b/lib/features/settings/settings_page_sections.dart index 2621d2cc..0493c399 100644 --- a/lib/features/settings/settings_page_sections.dart +++ b/lib/features/settings/settings_page_sections.dart @@ -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), diff --git a/lib/features/settings/settings_page_widgets.dart b/lib/features/settings/settings_page_widgets.dart index bc7a5f3e..75913f7d 100644 --- a/lib/features/settings/settings_page_widgets.dart +++ b/lib/features/settings/settings_page_widgets.dart @@ -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 onSubmitted; @@ -76,7 +78,7 @@ class EditableFieldStateInternal extends State { 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, diff --git a/lib/runtime/runtime_models_configs.dart b/lib/runtime/runtime_models_configs.dart index 8bd3551e..1b4f36be 100644 --- a/lib/runtime/runtime_models_configs.dart +++ b/lib/runtime/runtime_models_configs.dart @@ -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', ); diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index e0904e59..5516cb8e 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -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', ( diff --git a/test/features/settings_vault_persistence_suite.dart b/test/features/settings_vault_persistence_suite.dart new file mode 100644 index 00000000..8abafeb7 --- /dev/null +++ b/test/features/settings_vault_persistence_suite.dart @@ -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({}); + 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 refreshMultiAgentMounts({bool sync = false}) async {} +} + +Future _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.delayed(const Duration(milliseconds: 20)); + } +} diff --git a/test/features/settings_vault_persistence_test.dart b/test/features/settings_vault_persistence_test.dart new file mode 100644 index 00000000..5b57eb53 --- /dev/null +++ b/test/features/settings_vault_persistence_test.dart @@ -0,0 +1,7 @@ +import '../test_suite_stub.dart' + if (dart.library.io) 'settings_vault_persistence_suite.dart' + as suite; + +void main() { + suite.main(); +} diff --git a/test/runtime/secure_config_store_suite_secrets.dart b/test/runtime/secure_config_store_suite_secrets.dart index 53e6b1e5..442952a8 100644 --- a/test/runtime/secure_config_store_suite_secrets.dart +++ b/test/runtime/secure_config_store_suite_secrets.dart @@ -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 {