diff --git a/.gitignore b/.gitignore index 2188783..8fc5b36 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,16 @@ migrate_working_dir/ .pub/ /build/ /coverage/ +android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +android/local.properties +linux/flutter/generated_plugin_registrant.cc +linux/flutter/generated_plugin_registrant.h +linux/flutter/generated_plugins.cmake +linux/flutter/ephemeral/ +windows/flutter/generated_plugin_registrant.cc +windows/flutter/generated_plugin_registrant.h +windows/flutter/generated_plugins.cmake +windows/flutter/ephemeral/ # Symbolication related app.*.symbols diff --git a/_rules/global.md b/_rules/global.md index 632d574..3a66620 100644 --- a/_rules/global.md +++ b/_rules/global.md @@ -63,24 +63,23 @@ - 插件必须实现 `BotPlugin` 并在 `init()` 注册到 `PluginManager`。 - `BotPlugin` 关键方法组: `GetID/GetAssetName/GetManifest/GetAssetUISchema/ScanAssets/AssessRisks/MitigateRisk/StartProtection/StopProtection/GetProtectionStatus`。 -- 每个资产实例必须生成稳定 `asset_id`(由名称、配置路径、端口、进程路径等指纹计算)。 +- 每个资产实例必须生成稳定 `asset_id`,仅由名称与配置路径参与指纹计算。 - 运行时绑定关系:`1 asset_id : 1 plugin instance`。 - 防护、事件、指标、状态查询必须按 `asset_id` 路由,避免跨实例串数据。 - FFI 防护接口只接受 `asset_id`,禁止传 `asset_name`;Go 内部通过实例绑定解析插件名。 ### 6.1 `asset_id` 生成规范 -- 统一调用 `core.ComputeAssetID(name, configPath, ports, processPaths)`,禁止插件自实现另一套算法。 -- 参与指纹字段:`name`、`config_path`、`ports`、`process_paths`。 +- 统一调用 `core.ComputeAssetID(name, configPath)`,禁止插件自实现另一套算法。 +- **仅**允许以下字段参与指纹:`name`、`config_path`。 +- **禁止**将运行态动态信息(`ports`、`process_paths`、`pid`、`service_name` 等)卷入指纹,否则 bot 启停会导致 `asset_id` 漂移,出现"同一资产对应多条策略"或"启用防护后策略丢失"的问题。 - 规范化顺序: - `name` 小写:`name=` - `config_path` 非空:`config=` - - `ports` 升序:`ports=1,2,3` - - `process_paths` 字典序:`paths=/a,/b` - 使用 `|` 拼接 canonical 字符串 - 哈希算法:`sha256(canonical)`,取前 6 字节十六进制(12 位)。 - 输出格式:`:<12hex>`(例:`openclaw:1a2b3c4d5e6f`)。 -- 字段不变则 `asset_id` 必须稳定;任一指纹字段变化必须触发 `asset_id` 变化。 +- 同一 `(name, config_path)` 必须在任何运行态下产生相同 `asset_id`;`config_path` 变化必须触发 `asset_id` 变化。 ### 6.2 `mitigation` 生成规范 @@ -98,6 +97,7 @@ - 监控/审计相关实现必须满足:并发可关联、链路可追溯、写入幂等、失败不阻断主业务。 - 详细实现规范见独立文档: - [`_rules/audit_chain.md`](audit_chain.md) + - [`_rules/security_event.md`](security_event.md) ## 8. 沙箱规范 diff --git a/_rules/security_event.md b/_rules/security_event.md new file mode 100644 index 0000000..fab64e7 --- /dev/null +++ b/_rules/security_event.md @@ -0,0 +1,60 @@ +# 安全事件规范(SecurityEvent) + +> 适用范围:防护监控界面的"拦截次数"计数与"安全事件"列表的数据生成路径。 +> 本文档描述触发口径与职责边界;`_rules/global.md` §7 仅保留索引。 + +## 1. 目标与定位 + +- `SecurityEvent`:防护链路中每一次"被防护信号"的离散记录,作为监控界面事件面板的权威数据源。 +- 约束:**"拦截次数" ≤ "安全事件数量"**,即拦截计数每递增一次,事件列表必然存在对应记录。 + +## 2. 广义事件边界 + +以下防护信号必须生成 `SecurityEvent`(广义定义,Q1 口径): + +1. ShepherdGate 启发式命中(`isSensitiveByRules` / `detectCriticalCommand` / `detectToolResultInjection`) +2. ShepherdGate ReAct agent 深度分析的非 `Allowed` 决策 +3. ShepherdGate post-validation override 强制拦截(即 LLM `Allowed=true` 但 Go 侧因提示词注入风险强制改写) +4. 代理层 token 配额命中的阻断(会话级 / 每日级) +5. 沙箱命中后的阻断(`SANDBOX_BLOCKED` 分支) + +## 3. 单一权威写入点(强约束) + +- **唯一写入点**:`go_lib/core/proxy/proxy_protection.go::emitSecurityEvent`,在 `ProxyProtection` 的决策汇聚点调用。 +- **同步语义**:`emitSecurityEvent` 必须与 `blockedCount++` / `warningCount++` 位于同一代码块,禁止拆分到异步路径,保证监控面板两侧数据一致。 +- **禁止写入点**: + - ShepherdGate 内部(`shepherd_react_analyzer.go` 启发式分支)**不得**直接调用 `AddSecurityEvent`,结果由 `ShepherdDecision` 向上透传、由代理层统一落库。 + - 沙箱 hook 审计日志(`sandbox.StartHookLogWatcherByKey`)**不得**再被插件网关消费为 `SecurityEvent`。沙箱命中事件仅通过代理层的 `SANDBOX_BLOCKED` 分支记录。 + - `record_security_event` 工具(ReAct agent 可调用)是"观察性补充",不作为拦截计数的来源,仅承载额外上下文(允许轻度重复,不计入"拦截次数")。 + +## 4. 字段口径 + +| 字段 | 来源 | +| --- | --- | +| `Source` | 固定为 `"react_agent"`(代理层写入点),沿用既有 UI 徽章分类 | +| `EventType` | 严格按决策语义:`"blocked"`(硬拦截、配额、沙箱)/ `"needs_confirmation"`(ShepherdGate `NEEDS_CONFIRMATION` 决策,待用户确认);观察性工具可写 `"tool_execution"` 等 | +| `ActionDesc` | 优先使用 `decision.ActionDesc`,缺省时回退到 `decision.Reason` | +| `RiskType` | 优先使用 `decision.RiskType`,缺省时回退到 `decision.Status` / 分支常量(`QUOTA` / `SANDBOX_BLOCKED`) | +| `Detail` | `decision.Reason`;post-validation override 额外前置 `post_validation_override \| ` 标签 | +| `RequestID` | 绑定当前请求上下文的 `requestID`,便于与 `TruthRecord` / `AuditChain` 交叉 | + +## 5. Post-validation Override 识别 + +- `shepherd.PostValidationOverrideTag` 常量定义于 `go_lib/core/shepherd/shepherd_gate.go`。 +- 代理层通过 `strings.Contains(decision.Reason, shepherd.PostValidationOverrideTag)` 识别并在 `Detail` 加前缀,避免修改 `ShepherdDecision` 结构。 + +## 6. Fail-open 约定 + +- `AddSecurityEvent` 内部任何持久化或推送失败都不得阻断代理主流程。 +- 监控/审计异常与代理决策解耦,延续 `_rules/audit_chain.md` §2 的 fail-open 原则。 + +## 7. 职责边界表 + +| 层 | 职责 | 是否写 SecurityEvent | +| --- | --- | --- | +| ShepherdGate 启发式 | 返回 `ReactRiskDecision` | 否(由代理汇聚) | +| ShepherdGate ReAct agent | 返回 `ReactRiskDecision` | 否(由代理汇聚) | +| ShepherdGate post-validation | 覆写 `Allowed=false` 并追加 tag | 否(由代理汇聚) | +| `ProxyProtection` 决策汇聚 | `blockedCount++` + `emitSecurityEvent` | **是(唯一权威写入点)** | +| 沙箱 hook 审计日志 | 本地审计文件落盘 | 否(不再被消费为 `SecurityEvent`) | +| `record_security_event` 工具 | agent 观察性补录 | 是(观察性,不计入拦截次数) | diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java deleted file mode 100644 index 2036eb5..0000000 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ /dev/null @@ -1,49 +0,0 @@ -package io.flutter.plugins; - -import androidx.annotation.Keep; -import androidx.annotation.NonNull; -import io.flutter.Log; - -import io.flutter.embedding.engine.FlutterEngine; - -/** - * Generated file. Do not edit. - * This file is generated by the Flutter tool based on the - * plugins that support the Android platform. - */ -@Keep -public final class GeneratedPluginRegistrant { - private static final String TAG = "GeneratedPluginRegistrant"; - public static void registerWith(@NonNull FlutterEngine flutterEngine) { - try { - flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); - } - try { - flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); - } - } -} diff --git a/android/local.properties b/android/local.properties deleted file mode 100644 index 09fdbb2..0000000 --- a/android/local.properties +++ /dev/null @@ -1,2 +0,0 @@ -sdk.dir=/Users/kidbei/Library/Android/sdk -flutter.sdk=/Users/kidbei/flutter \ No newline at end of file diff --git a/go_lib/chatmodel-routing/adapter/providers.go b/go_lib/chatmodel-routing/adapter/providers.go index b928762..d3aa85d 100644 --- a/go_lib/chatmodel-routing/adapter/providers.go +++ b/go_lib/chatmodel-routing/adapter/providers.go @@ -146,10 +146,13 @@ var supportedProviders = []ProviderInfo{ APIKeyHint: "Your Doubao API key", ModelHint: "doubao-seed-1-6-250615", }, { + // 千帆 ModelBuilder V2 OpenAI 兼容接口,使用单一 API Key(Bearer Token),无需 Secret Key + // 调用路径:POST {baseURL}/chat/completions,Authorization: Bearer Name: ProviderQianfan, DisplayName: "Qianfan", Icon: "cloud", Scope: ScopeSecurity, - NeedsEndpoint: true, NeedsAPIKey: true, NeedsSecretKey: true, AutoV1Suffix: false, - DefaultBaseURL: "https://aip.baidubce.com/rpc/2.0/ai_custom/v1", DefaultModel: "ERNIE-4.0-8K", - APIKeyHint: "Your Qianfan Access Key", ModelHint: "ERNIE-4.0-8K, ERNIE-Speed-128K", + NeedsEndpoint: true, NeedsAPIKey: true, NeedsSecretKey: false, AutoV1Suffix: false, + DefaultBaseURL: "https://qianfan.baidubce.com/v2", DefaultModel: "ernie-4.5-turbo-128k", + APIKeyHint: "Your Qianfan API Key (bce-v3/...)", + ModelHint: "ernie-4.5-turbo-128k, ernie-speed-128k, deepseek-v3.1", }, { Name: ProviderErnie, DisplayName: "Ernie", Icon: "message-square", Scope: ScopeSecurity, diff --git a/go_lib/core/asset.go b/go_lib/core/asset.go index 209742a..e99e3e5 100644 --- a/go_lib/core/asset.go +++ b/go_lib/core/asset.go @@ -3,14 +3,14 @@ package core import ( "crypto/sha256" "fmt" - "sort" "strings" ) // Asset 定义检测到的资产结构 type Asset struct { // ID is a deterministic fingerprint hash uniquely identifying this asset instance. - // Computed from name + config_path + ports + process_paths via ComputeAssetID(). + // Computed from name + config_path via ComputeAssetID(); runtime-dynamic fields such + // as ports/process_paths are intentionally excluded so the ID is stable across restarts. ID string `json:"id"` // SourcePlugin is the asset name of the plugin that discovered this asset. SourcePlugin string `json:"source_plugin"` @@ -56,37 +56,18 @@ type DisplayItem struct { // ComputeAssetID generates a deterministic fingerprint ID for an asset instance. // The ID is composed of the lowercase asset name and a 12-char hex hash derived from -// the config path, sorted ports, and sorted process paths. -// Same instance scanned at different times always produces the same ID. -func ComputeAssetID(name string, configPath string, ports []int, processPaths []string) string { - // Build a canonical string from all fingerprint components - var parts []string - +// the config path. Runtime-dynamic attributes (ports, process paths, pid, etc.) are +// intentionally excluded so the ID stays stable regardless of whether the bot is +// currently running. Same instance scanned at different times always produces the +// same ID, which is required for protection/policy binding by asset_id. +func ComputeAssetID(name string, configPath string) string { nameLower := strings.ToLower(name) - parts = append(parts, "name="+nameLower) + parts := []string{"name=" + nameLower} if configPath != "" { parts = append(parts, "config="+configPath) } - if len(ports) > 0 { - sortedPorts := make([]int, len(ports)) - copy(sortedPorts, ports) - sort.Ints(sortedPorts) - portStrs := make([]string, len(sortedPorts)) - for i, p := range sortedPorts { - portStrs[i] = fmt.Sprintf("%d", p) - } - parts = append(parts, "ports="+strings.Join(portStrs, ",")) - } - - if len(processPaths) > 0 { - sortedPaths := make([]string, len(processPaths)) - copy(sortedPaths, processPaths) - sort.Strings(sortedPaths) - parts = append(parts, "paths="+strings.Join(sortedPaths, ",")) - } - canonical := strings.Join(parts, "|") hash := sha256.Sum256([]byte(canonical)) shortHash := fmt.Sprintf("%x", hash[:6]) // 12 hex chars diff --git a/go_lib/core/asset_test.go b/go_lib/core/asset_test.go index 222ca73..b1835d8 100644 --- a/go_lib/core/asset_test.go +++ b/go_lib/core/asset_test.go @@ -191,59 +191,45 @@ func TestAssetEmpty(t *testing.T) { } } -func TestComputeAssetID_DeterministicAndOrderInsensitive(t *testing.T) { - id1 := ComputeAssetID( - "Openclaw", - "/Users/test/.openclaw/config.json", - []int{3000, 13436}, - []string{"/usr/local/bin/openclaw", "/Applications/Openclaw.app"}, - ) - id2 := ComputeAssetID( - "openclaw", - "/Users/test/.openclaw/config.json", - []int{13436, 3000}, - []string{"/Applications/Openclaw.app", "/usr/local/bin/openclaw"}, - ) +func TestComputeAssetID_DeterministicAndCaseInsensitive(t *testing.T) { + id1 := ComputeAssetID("Openclaw", "/Users/test/.openclaw/config.json") + id2 := ComputeAssetID("openclaw", "/Users/test/.openclaw/config.json") if id1 != id2 { t.Fatalf("expected deterministic id, got id1=%s id2=%s", id1, id2) } } +// TestComputeAssetID_IgnoresRuntimeDynamics guards the invariant that +// ports/process_paths or any other runtime-dynamic info must NOT drift the ID +// when the bot starts/stops. The ID depends only on name + config_path. +func TestComputeAssetID_IgnoresRuntimeDynamics(t *testing.T) { + // Before protection starts: bot not running, no ports/processes observed. + idBeforeStart := ComputeAssetID("Openclaw", "/Users/test/.openclaw/config.json") + // After protection restarts openclaw: the same instance now exposes ports + // and process paths. ID must not change. + idAfterStart := ComputeAssetID("Openclaw", "/Users/test/.openclaw/config.json") + + if idBeforeStart != idAfterStart { + t.Fatalf("asset_id must be stable across runtime state changes, got before=%s after=%s", + idBeforeStart, idAfterStart) + } +} + func TestComputeAssetID_UniqueAcrossPlugins(t *testing.T) { - openID := ComputeAssetID( - "Openclaw", - "/Users/test/.bot/config.json", - []int{3000}, - []string{"/usr/local/bin/bot"}, - ) - nullID := ComputeAssetID( - "Nullclaw", - "/Users/test/.bot/config.json", - []int{3000}, - []string{"/usr/local/bin/bot"}, - ) + openID := ComputeAssetID("Openclaw", "/Users/test/.bot/config.json") + nullID := ComputeAssetID("Nullclaw", "/Users/test/.bot/config.json") if openID == nullID { t.Fatalf("asset id collision across plugin types: %s", openID) } } -func TestComputeAssetID_UniqueForDifferentFingerprint(t *testing.T) { - id1 := ComputeAssetID( - "Openclaw", - "/Users/test/.openclaw/config-a.json", - []int{3000}, - []string{"/usr/local/bin/openclaw"}, - ) - id2 := ComputeAssetID( - "Openclaw", - "/Users/test/.openclaw/config-b.json", - []int{3000}, - []string{"/usr/local/bin/openclaw"}, - ) +func TestComputeAssetID_UniqueForDifferentConfigPath(t *testing.T) { + id1 := ComputeAssetID("Openclaw", "/Users/test/.openclaw/config-a.json") + id2 := ComputeAssetID("Openclaw", "/Users/test/.openclaw/config-b.json") if id1 == id2 { - t.Fatalf("expected different ids for different fingerprint, got %s", id1) + t.Fatalf("expected different ids for different config_path, got %s", id1) } } diff --git a/go_lib/core/modelfactory/model_factory.go b/go_lib/core/modelfactory/model_factory.go index 8cc03f2..ffac732 100644 --- a/go_lib/core/modelfactory/model_factory.go +++ b/go_lib/core/modelfactory/model_factory.go @@ -3,7 +3,6 @@ package modelfactory import ( "context" "fmt" - "os" "strings" "time" @@ -16,7 +15,6 @@ import ( "github.com/cloudwego/eino-ext/components/model/gemini" "github.com/cloudwego/eino-ext/components/model/ollama" "github.com/cloudwego/eino-ext/components/model/openai" - "github.com/cloudwego/eino-ext/components/model/qianfan" "github.com/cloudwego/eino-ext/components/model/qwen" "github.com/cloudwego/eino/components/model" "google.golang.org/genai" @@ -65,12 +63,12 @@ func ValidateSecurityModelConfig(config *repository.SecurityModelConfig) error { return fmt.Errorf("Gemini model name is required") } case "qianfan": + if config.APIKey == "" { + return fmt.Errorf("Qianfan API key is required") + } if config.Model == "" { return fmt.Errorf("Qianfan model name is required") } - if config.APIKey == "" && os.Getenv("QIANFAN_ACCESS_KEY") == "" { - return fmt.Errorf("Qianfan access key is required (set api_key or QIANFAN_ACCESS_KEY env)") - } case "qwen": if config.APIKey == "" { return fmt.Errorf("Qwen API key is required") @@ -240,18 +238,27 @@ func createGeminiModel(ctx context.Context, config *repository.SecurityModelConf }) } +// createQianfanModel 创建千帆 ChatModel 实例。 +// 千帆 ModelBuilder V2 提供 OpenAI 兼容协议: +// - 默认 BaseURL:https://qianfan.baidubce.com/v2 +// - 鉴权:Authorization: Bearer (单一 API Key,无需 Secret Key) +// - 参考文档:https://cloud.baidu.com/doc/qianfan-api/s/3m7of64lb func createQianfanModel(ctx context.Context, config *repository.SecurityModelConfig) (model.ChatModel, error) { + if config.APIKey == "" { + return nil, fmt.Errorf("Qianfan API key is required") + } if config.Model == "" { return nil, fmt.Errorf("Qianfan model name is required") } - if config.APIKey != "" { - os.Setenv("QIANFAN_ACCESS_KEY", config.APIKey) - } - if config.SecretKey != "" { - os.Setenv("QIANFAN_SECRET_KEY", config.SecretKey) + baseURL := config.Endpoint + if baseURL == "" { + baseURL = "https://qianfan.baidubce.com/v2" } - return qianfan.NewChatModel(ctx, &qianfan.ChatModelConfig{ - Model: config.Model, + return openai.NewChatModel(ctx, &openai.ChatModelConfig{ + APIKey: config.APIKey, + BaseURL: baseURL, + Model: config.Model, + Timeout: 120 * time.Second, }) } diff --git a/go_lib/core/proxy/proxy_protection.go b/go_lib/core/proxy/proxy_protection.go index 24cccf4..233ea04 100644 --- a/go_lib/core/proxy/proxy_protection.go +++ b/go_lib/core/proxy/proxy_protection.go @@ -793,6 +793,24 @@ func (pp *ProxyProtection) emitMonitorSecurityDecision(status, reason string, bl }) } +// emitSecurityEvent persists a SecurityEvent for the protection monitor's event panel. +// Must be co-located with any proxy-level interception (blockedCount++, quota/sandbox +// block) so the "intercept count" and "event list" remain monotonically consistent. +// Source is fixed to "react_agent" to align with the existing UI badge taxonomy. +// Fail-open: persistence failures inside AddSecurityEvent must not block the proxy flow. +func (pp *ProxyProtection) emitSecurityEvent(requestID, eventType, actionDesc, riskType, detail string) { + shepherd.GetSecurityEventBuffer().AddSecurityEvent(shepherd.SecurityEvent{ + EventType: eventType, + ActionDesc: actionDesc, + RiskType: riskType, + Detail: detail, + Source: "react_agent", + AssetName: pp.assetName, + AssetID: pp.assetID, + RequestID: requestID, + }) +} + func (pp *ProxyProtection) emitMonitorResponseReturned(status, returnedText, returnedRaw string) { pp.sendLog("monitor_response_returned", map[string]interface{}{ "status": status, diff --git a/go_lib/core/proxy/proxy_protection_handler.go b/go_lib/core/proxy/proxy_protection_handler.go index d729417..fd66b9e 100644 --- a/go_lib/core/proxy/proxy_protection_handler.go +++ b/go_lib/core/proxy/proxy_protection_handler.go @@ -83,6 +83,7 @@ func (pp *ProxyProtection) onRequest(ctx context.Context, req *openai.ChatComple pp.emitMonitorRequestCreated(req, rawBody, stream) pp.emitMonitorSecurityDecision("QUOTA_EXCEEDED", reason, true, mockMsg) + pp.emitSecurityEvent(requestID, "blocked", "Conversation token quota exceeded", "QUOTA", reason) pp.emitMonitorResponseReturned("QUOTA_EXCEEDED", mockMsg, mockMsg) pp.auditLogSafe("set_decision_quota_conversation", func(tracker *AuditChainTracker) { tracker.SetRequestDecision(requestID, "BLOCK", "QUOTA", reason, 100) @@ -145,6 +146,7 @@ func (pp *ProxyProtection) onRequest(ctx context.Context, req *openai.ChatComple pp.emitMonitorRequestCreated(req, rawBody, stream) pp.emitMonitorSecurityDecision("QUOTA_EXCEEDED", reason, true, mockMsg) + pp.emitSecurityEvent(requestID, "blocked", "Daily token quota exceeded", "QUOTA", reason) pp.emitMonitorResponseReturned("QUOTA_EXCEEDED", mockMsg, mockMsg) pp.auditLogSafe("set_decision_quota_daily", func(tracker *AuditChainTracker) { tracker.SetRequestDecision(requestID, "BLOCK", "QUOTA", reason, 100) @@ -692,6 +694,7 @@ func (pp *ProxyProtection) onRequest(ctx context.Context, req *openai.ChatComple false, "", ) + pp.emitSecurityEvent(requestID, "blocked", "Sandbox blocked tool execution", "SANDBOX_BLOCKED", sandboxReason) pp.auditLogSafe("set_decision_sandbox_blocked", func(tracker *AuditChainTracker) { tracker.SetRequestDecision( requestID, @@ -783,6 +786,27 @@ func (pp *ProxyProtection) onRequest(ctx context.Context, req *openai.ChatComple pp.warningCount++ pp.statsMu.Unlock() pp.sendMetricsToCallback() + // Co-locate the SecurityEvent write with blockedCount++ so the UI + // "intercept count" and "event list" stay monotonically consistent. + shepherdActionDesc := strings.TrimSpace(decision.ActionDesc) + if shepherdActionDesc == "" { + shepherdActionDesc = decision.Reason + } + shepherdRiskType := strings.TrimSpace(decision.RiskType) + if shepherdRiskType == "" { + shepherdRiskType = decision.Status + } + shepherdDetail := decision.Reason + if strings.Contains(decision.Reason, shepherd.PostValidationOverrideTag) { + shepherdDetail = "post_validation_override | " + decision.Reason + } + // NEEDS_CONFIRMATION 不是"已阻断",而是"待用户确认";事件类型要与决策状态对齐, + // 方便前端按语义着色与本地化展示(_rules/security_event.md §4)。 + shepherdEventType := "blocked" + if decision.Status == "NEEDS_CONFIRMATION" { + shepherdEventType = "needs_confirmation" + } + pp.emitSecurityEvent(requestID, shepherdEventType, shepherdActionDesc, shepherdRiskType, shepherdDetail) pp.emitMonitorResponseReturned(decision.Status, securityMsg, securityMsg) pp.auditLogSafe("set_decision_shepherd_blocked", func(tracker *AuditChainTracker) { tracker.SetRequestDecision(requestID, recordAction, recordRiskLevel, decision.Reason, 100) diff --git a/go_lib/core/proxy/proxy_protection_security_event_test.go b/go_lib/core/proxy/proxy_protection_security_event_test.go new file mode 100644 index 0000000..1dfe77d --- /dev/null +++ b/go_lib/core/proxy/proxy_protection_security_event_test.go @@ -0,0 +1,148 @@ +package proxy + +import ( + "strings" + "testing" + + "go_lib/core/shepherd" +) + +// drainSecurityEvents empties the global buffer so tests do not contaminate each other. +func drainSecurityEvents() []shepherd.SecurityEvent { + return shepherd.GetSecurityEventBuffer().GetAndClearSecurityEvents() +} + +// newProxyForSecurityEventTest builds the thinnest ProxyProtection instance needed +// to exercise emitSecurityEvent — the helper only reads assetName/assetID and the +// shepherd buffer, so we skip heavyweight construction. +func newProxyForSecurityEventTest(assetName, assetID string) *ProxyProtection { + return &ProxyProtection{assetName: assetName, assetID: assetID} +} + +// TestEmitSecurityEvent_FixedSourceAndFields verifies every proxy-authored +// SecurityEvent carries the canonical fields required by the monitor panel +// (source=react_agent, request/asset binding, payload passthrough). +func TestEmitSecurityEvent_FixedSourceAndFields(t *testing.T) { + _ = drainSecurityEvents() + + pp := newProxyForSecurityEventTest("openclaw", "asset-123") + pp.emitSecurityEvent("req-1", "blocked", "Blocked tool_shell", "CRITICAL", "reason text") + + events := drainSecurityEvents() + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + ev := events[0] + if ev.Source != "react_agent" { + t.Errorf("expected Source=react_agent, got %q", ev.Source) + } + if ev.EventType != "blocked" { + t.Errorf("expected EventType=blocked, got %q", ev.EventType) + } + if ev.ActionDesc != "Blocked tool_shell" { + t.Errorf("unexpected ActionDesc: %q", ev.ActionDesc) + } + if ev.RiskType != "CRITICAL" { + t.Errorf("unexpected RiskType: %q", ev.RiskType) + } + if ev.Detail != "reason text" { + t.Errorf("unexpected Detail: %q", ev.Detail) + } + if ev.RequestID != "req-1" || ev.AssetID != "asset-123" || ev.AssetName != "openclaw" { + t.Errorf("binding fields mismatch: %+v", ev) + } + if ev.ID == "" || ev.Timestamp == "" { + t.Errorf("expected ID and Timestamp to be auto-filled, got %+v", ev) + } +} + +// TestEmitSecurityEvent_QuotaBranch simulates the conversation/daily quota branches: +// both should record a "blocked" event with RiskType=QUOTA. +func TestEmitSecurityEvent_QuotaBranch(t *testing.T) { + _ = drainSecurityEvents() + + pp := newProxyForSecurityEventTest("nullclaw", "asset-q") + pp.emitSecurityEvent("req-session", "blocked", "Conversation token quota exceeded", "QUOTA", "Conversation token quota exceeded (5000/5000)") + pp.emitSecurityEvent("req-daily", "blocked", "Daily token quota exceeded", "QUOTA", "Daily token quota exceeded (10000/10000)") + + events := drainSecurityEvents() + if len(events) != 2 { + t.Fatalf("expected 2 quota events, got %d", len(events)) + } + for _, ev := range events { + if ev.RiskType != "QUOTA" || ev.EventType != "blocked" { + t.Errorf("expected blocked/QUOTA, got event=%+v", ev) + } + } +} + +// TestEmitSecurityEvent_SandboxBlocked checks the SANDBOX_BLOCKED path emits +// an event instead of relying on the sandbox hook log watcher (removed per D2). +func TestEmitSecurityEvent_SandboxBlocked(t *testing.T) { + _ = drainSecurityEvents() + + pp := newProxyForSecurityEventTest("dintalclaw", "asset-sb") + pp.emitSecurityEvent("req-sb", "blocked", "Sandbox blocked tool execution", "SANDBOX_BLOCKED", "tool result already blocked by ClawdSecbot sandbox") + + events := drainSecurityEvents() + if len(events) != 1 { + t.Fatalf("expected 1 sandbox event, got %d", len(events)) + } + if events[0].RiskType != "SANDBOX_BLOCKED" { + t.Errorf("expected RiskType=SANDBOX_BLOCKED, got %q", events[0].RiskType) + } +} + +// TestEmitSecurityEvent_NeedsConfirmationEventType replays the ShepherdGate +// non-ALLOWED branch logic: when decision.Status == NEEDS_CONFIRMATION the +// event must carry EventType="needs_confirmation", otherwise "blocked". +// UI relies on this split for coloring and localization (待确认 vs 已拦截). +func TestEmitSecurityEvent_NeedsConfirmationEventType(t *testing.T) { + _ = drainSecurityEvents() + + pp := newProxyForSecurityEventTest("openclaw", "asset-nc") + + // Mirror the handler's shepherdEventType selection. + resolve := func(status string) string { + if status == "NEEDS_CONFIRMATION" { + return "needs_confirmation" + } + return "blocked" + } + + pp.emitSecurityEvent("req-nc", resolve("NEEDS_CONFIRMATION"), "desc", "CRITICAL", "reason") + pp.emitSecurityEvent("req-bk", resolve("BLOCKED"), "desc", "CRITICAL", "reason") + + events := drainSecurityEvents() + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].EventType != "needs_confirmation" { + t.Errorf("expected needs_confirmation, got %q", events[0].EventType) + } + if events[1].EventType != "blocked" { + t.Errorf("expected blocked, got %q", events[1].EventType) + } +} + +// TestPostValidationOverrideTag_Detectable guards the shepherd->proxy contract: +// proxy uses strings.Contains(decision.Reason, shepherd.PostValidationOverrideTag) +// to mark overridden events, so the tag must stay literal and non-empty. +func TestPostValidationOverrideTag_Detectable(t *testing.T) { + if shepherd.PostValidationOverrideTag == "" { + t.Fatal("PostValidationOverrideTag must not be empty") + } + reason := "LLM allowed but injected. " + shepherd.PostValidationOverrideTag + if !strings.Contains(reason, shepherd.PostValidationOverrideTag) { + t.Fatalf("tag not detectable in reason: %q", reason) + } + + // Simulate the proxy-side Detail decoration used in the non-ALLOWED branch. + detail := reason + if strings.Contains(reason, shepherd.PostValidationOverrideTag) { + detail = "post_validation_override | " + reason + } + if !strings.HasPrefix(detail, "post_validation_override | ") { + t.Fatalf("expected post_validation_override prefix, got %q", detail) + } +} diff --git a/go_lib/core/scanner/plugin_pipeline.go b/go_lib/core/scanner/plugin_pipeline.go index 0be0ac0..db29def 100644 --- a/go_lib/core/scanner/plugin_pipeline.go +++ b/go_lib/core/scanner/plugin_pipeline.go @@ -94,12 +94,10 @@ func ScanSingleMergedAsset(opts PluginAssetScanOptions) ([]core.Asset, error) { if configPathFingerprint == "" { configPathFingerprint = strings.TrimSpace(opts.ConfigPath) } - mergedAsset.ID = core.ComputeAssetID( - assetName, - configPathFingerprint, - mergedAsset.Ports, - mergedAsset.ProcessPaths, - ) + // asset_id is intentionally derived from name + config_path only so the ID stays + // stable across bot start/stop; ports/process_paths are retained on the asset for + // display and risk evaluation but must not participate in the fingerprint. + mergedAsset.ID = core.ComputeAssetID(assetName, configPathFingerprint) logging.Info("%s asset scan completed, id=%s, ports=%v, processes=%v", assetName, mergedAsset.ID, mergedAsset.Ports, mergedAsset.ProcessPaths) diff --git a/go_lib/core/shepherd/shepherd_gate.go b/go_lib/core/shepherd/shepherd_gate.go index 0722a27..894f84f 100644 --- a/go_lib/core/shepherd/shepherd_gate.go +++ b/go_lib/core/shepherd/shepherd_gate.go @@ -17,6 +17,13 @@ import ( "github.com/cloudwego/eino/schema" ) +// PostValidationOverrideTag is appended to a ReAct decision's reason when the +// Go post-validation layer forcibly overrides an LLM-allowed decision due to +// prompt injection in tool results. Downstream layers (proxy, UI classifiers) +// detect this tag to attribute the block source without extending the decision +// struct. +const PostValidationOverrideTag = "[Post-validation: tool result prompt injection must be blocked]" + // ShepherdDecision represents the decision from ShepherdGate type ShepherdDecision struct { Status string `json:"-"` // Internal status: ALLOWED | NEEDS_CONFIRMATION @@ -337,7 +344,7 @@ func (sg *ShepherdGate) CheckToolCall(ctx context.Context, contextMessages []Con "LLM allowed but prompt injection detected in tool result, forcing block. "+ "risk_type=%s, risk_level=%s", reactDecision.RiskType, reactDecision.RiskLevel) reactDecision.Allowed = false - reactDecision.Reason = reactDecision.Reason + " [Post-validation: tool result prompt injection must be blocked]" + reactDecision.Reason = reactDecision.Reason + " " + PostValidationOverrideTag } } diff --git a/go_lib/core/shepherd/shepherd_react_analyzer.go b/go_lib/core/shepherd/shepherd_react_analyzer.go index 6e42056..85597b8 100644 --- a/go_lib/core/shepherd/shepherd_react_analyzer.go +++ b/go_lib/core/shepherd/shepherd_react_analyzer.go @@ -336,22 +336,10 @@ func (a *ToolCallReActAnalyzer) Analyze(ctx context.Context, contextMessages []C logging.Info("[ShepherdGate][Analyze][%s] heuristic hit: allowed=%v, skill=heuristic, reason=%s", session.ID, heuristic.Allowed, shortenForLog(heuristic.Reason, 300)) heuristic.Usage = &Usage{} - - eventType := "blocked" - if heuristic.Allowed { - eventType = "tool_execution" - } - securityEventBuffer.AddSecurityEvent(SecurityEvent{ - BotID: botIDFromContext(ctx), - EventType: eventType, - ActionDesc: heuristic.Reason, - RiskType: heuristic.RiskLevel, - Source: "heuristic", - AssetName: a.assetName, - AssetID: a.assetID, - RequestID: reqID, - }) - + // SecurityEvent is persisted by the proxy decision sink after this decision + // propagates up (see ProxyProtection.emitSecurityEvent). Do not emit here to + // avoid duplicated records per the single-authoritative-source rule + // (_rules/security_event.md). return heuristic, nil } traceGuard(session.ID, "Heuristic", "no critical pattern hit, proceeding to ADK agent") diff --git a/go_lib/go.mod b/go_lib/go.mod index 1114cfb..e929f7a 100644 --- a/go_lib/go.mod +++ b/go_lib/go.mod @@ -11,7 +11,6 @@ require ( github.com/cloudwego/eino-ext/components/model/gemini v0.1.28 github.com/cloudwego/eino-ext/components/model/ollama v0.1.8 github.com/cloudwego/eino-ext/components/model/openai v0.1.8 - github.com/cloudwego/eino-ext/components/model/qianfan v0.1.4 github.com/cloudwego/eino-ext/components/model/qwen v0.1.5 github.com/google/uuid v1.6.0 github.com/openai/openai-go v1.12.0 @@ -45,8 +44,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/baidubce/bce-qianfan-sdk/go/qianfan v0.0.15 // indirect - github.com/baidubce/bce-sdk-go v0.9.258 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect @@ -63,10 +60,8 @@ require ( github.com/eino-contrib/ollama v0.1.0 // indirect github.com/evanphx/json-patch v0.5.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect @@ -80,7 +75,6 @@ require ( github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/meguminnnnnnnnn/go-openai v0.1.1 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -90,14 +84,8 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.21.0 // indirect - github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -111,7 +99,6 @@ require ( go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect diff --git a/go_lib/go.sum b/go_lib/go.sum index 427ab03..da1dde1 100644 --- a/go_lib/go.sum +++ b/go_lib/go.sum @@ -104,10 +104,6 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/baidubce/bce-qianfan-sdk/go/qianfan v0.0.15 h1:q5/ik4RC1ePaHfDZQarQXxfO3OTZmvGu9hmSvqfQIM8= -github.com/baidubce/bce-qianfan-sdk/go/qianfan v0.0.15/go.mod h1:f/kIWWvAHAcU7bzgkfN30SkpN0I4lLvsJkljVK6v5YY= -github.com/baidubce/bce-sdk-go v0.9.258 h1:PLYTIPS/xuuOe1lEQgnmKVGWFag2QHcX9fBAMDq44gw= -github.com/baidubce/bce-sdk-go v0.9.258/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -166,8 +162,6 @@ github.com/cloudwego/eino-ext/components/model/ollama v0.1.8 h1:+BStnQlkRxWMV9js github.com/cloudwego/eino-ext/components/model/ollama v0.1.8/go.mod h1:C3rf3yy2nEoXFP/CQJne4gbiu1pREKplHKmFlhuOzPE= github.com/cloudwego/eino-ext/components/model/openai v0.1.8 h1:uVCE8nNvbhD37xGFgdKESWjvChDSkCAMA+DodhFRBaM= github.com/cloudwego/eino-ext/components/model/openai v0.1.8/go.mod h1:K6g2VgULehhJC5dgFdPW3u7gZNZ1p6DhnfA5UhkRpNY= -github.com/cloudwego/eino-ext/components/model/qianfan v0.1.4 h1:wCl6EDT4eacyBAopQ0N6bQVwRy6wW9HNmIag9zBljkI= -github.com/cloudwego/eino-ext/components/model/qianfan v0.1.4/go.mod h1:ShVCwEhltA7hyc4jYfxMS5TbF6N/RATlNHt/jpGgzWI= github.com/cloudwego/eino-ext/components/model/qwen v0.1.5 h1:H0Irkydo2INyYNqjJTCRjfvycPNJMC2FcfmebfL83hM= github.com/cloudwego/eino-ext/components/model/qwen v0.1.5/go.mod h1:PTn/QxFqwmFW8fB4PtxjXB77uGtabOuWHahe751+Ras= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13 h1:z0bI5TH3nE+uDQiRhxBQMvk2HswlDUM3xP38+VSgpSQ= @@ -223,12 +217,8 @@ github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHqu github.com/franela/goblin v0.0.0-20210519012713-85d372ac71e2/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo= github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= @@ -252,8 +242,6 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -461,8 +449,6 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -553,8 +539,6 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= -github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -573,14 +557,6 @@ github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sS github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= @@ -601,8 +577,6 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -674,8 +648,6 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= diff --git a/go_lib/plugins/dintalclaw/asset_scanner.go b/go_lib/plugins/dintalclaw/asset_scanner.go index e6d2174..0363128 100644 --- a/go_lib/plugins/dintalclaw/asset_scanner.go +++ b/go_lib/plugins/dintalclaw/asset_scanner.go @@ -133,12 +133,7 @@ func (s *DintalclawAssetScanner) rewriteStableAssetID(asset *core.Asset) { configPath = strings.TrimSpace(s.configPath) } prev := asset.ID - asset.ID = core.ComputeAssetID( - dintalclawAssetName, - configPath, - nil, - nil, - ) + asset.ID = core.ComputeAssetID(dintalclawAssetName, configPath) if prev != "" && prev != asset.ID { logging.Info("[DintalclawScanner] Rewrote volatile asset_id %s -> %s (stable)", prev, asset.ID) } diff --git a/go_lib/plugins/dintalclaw/gateway_platform_linux.go b/go_lib/plugins/dintalclaw/gateway_platform_linux.go index 0eb49e9..c30ae6b 100644 --- a/go_lib/plugins/dintalclaw/gateway_platform_linux.go +++ b/go_lib/plugins/dintalclaw/gateway_platform_linux.go @@ -355,8 +355,6 @@ func restartDintalclawProcess(req *GatewayRestartRequest) (map[string]interface{ preloadLib, policyPath, sandboxLogPath) } } - } else { - sandbox.StopHookLogWatcherByKey(instanceKey) } stdinR, stdinW, pipeErr := os.Pipe() @@ -389,21 +387,8 @@ func restartDintalclawProcess(req *GatewayRestartRequest) (map[string]interface{ time.Sleep(2 * time.Second) - // 启动 sandbox 日志监控 - if req.SandboxEnabled && sandboxLogPath != "" { - sandbox.StartHookLogWatcherByKey(instanceKey, sandboxLogPath, func(event sandbox.HookLogEvent) { - eventType, actionDesc, riskType, source := sandbox.MapHookEventToSecurityEvent(event) - GetSecurityEventBuffer().AddSecurityEvent(SecurityEvent{ - EventType: eventType, - ActionDesc: actionDesc, - RiskType: riskType, - Source: source, - Detail: event.Detail, - AssetName: req.AssetName, - AssetID: req.AssetID, - }) - }) - } + // Sandbox hook audit log is no longer harvested into SecurityEvents here. + // Proxy decision sink is the sole authoritative source (see _rules/security_event.md). result := map[string]interface{}{ "success": true, diff --git a/go_lib/plugins/hermes/asset_scanner.go b/go_lib/plugins/hermes/asset_scanner.go index 961f50b..7b16cf2 100644 --- a/go_lib/plugins/hermes/asset_scanner.go +++ b/go_lib/plugins/hermes/asset_scanner.go @@ -120,7 +120,7 @@ func (s *HermesAssetScanner) rewriteStableAssetID(asset *core.Asset) { configPath = strings.TrimSpace(s.configPath) } previous := strings.TrimSpace(asset.ID) - asset.ID = core.ComputeAssetID(hermesAssetName, configPath, nil, nil) + asset.ID = core.ComputeAssetID(hermesAssetName, configPath) if previous != "" && previous != asset.ID { logging.Info("[HermesScanner] rewrote volatile asset_id %s -> %s", previous, asset.ID) } diff --git a/go_lib/plugins/hermes/asset_scanner_helpers_test.go b/go_lib/plugins/hermes/asset_scanner_helpers_test.go index 299f910..a475a07 100644 --- a/go_lib/plugins/hermes/asset_scanner_helpers_test.go +++ b/go_lib/plugins/hermes/asset_scanner_helpers_test.go @@ -82,7 +82,7 @@ func TestRewriteStableAssetID_FallbackToScannerConfigPath(t *testing.T) { asset := &core.Asset{ID: "volatile", Metadata: map[string]string{}} scanner.rewriteStableAssetID(asset) - want := core.ComputeAssetID(hermesAssetName, "/tmp/hermes/config.yaml", nil, nil) + want := core.ComputeAssetID(hermesAssetName, "/tmp/hermes/config.yaml") if asset.ID != want { t.Fatalf("asset id mismatch: got=%s want=%s", asset.ID, want) } diff --git a/go_lib/plugins/hermes/asset_scanner_test.go b/go_lib/plugins/hermes/asset_scanner_test.go index 5599407..1a89c31 100644 --- a/go_lib/plugins/hermes/asset_scanner_test.go +++ b/go_lib/plugins/hermes/asset_scanner_test.go @@ -21,7 +21,7 @@ func TestRewriteStableAssetID_UsesConfigPathOnly(t *testing.T) { if asset.ID == "" { t.Fatal("expected non-empty stable asset id") } - want := core.ComputeAssetID(hermesAssetName, "/Users/test/.hermes/config.yaml", nil, nil) + want := core.ComputeAssetID(hermesAssetName, "/Users/test/.hermes/config.yaml") if asset.ID != want { t.Fatalf("asset id mismatch: got=%s want=%s", asset.ID, want) } diff --git a/go_lib/plugins/nullclaw/gateway_platform_linux.go b/go_lib/plugins/nullclaw/gateway_platform_linux.go index c764b02..9681d3a 100644 --- a/go_lib/plugins/nullclaw/gateway_platform_linux.go +++ b/go_lib/plugins/nullclaw/gateway_platform_linux.go @@ -128,18 +128,8 @@ func restartNullclawGateway(req *GatewayRestartRequest) (map[string]interface{}, } time.Sleep(2 * time.Second) - sandbox.StartHookLogWatcherByKey(instanceKey, logPath, func(event sandbox.HookLogEvent) { - eventType, actionDesc, riskType, source := sandbox.MapHookEventToSecurityEvent(event) - GetSecurityEventBuffer().AddSecurityEvent(SecurityEvent{ - EventType: eventType, - ActionDesc: actionDesc, - RiskType: riskType, - Source: source, - Detail: event.Detail, - AssetName: req.AssetName, - AssetID: req.AssetID, - }) - }) + // Sandbox hook audit log is no longer harvested into SecurityEvents here. + // Proxy decision sink is the sole authoritative source (see _rules/security_event.md). return map[string]interface{}{ "success": true, @@ -151,8 +141,6 @@ func restartNullclawGateway(req *GatewayRestartRequest) (map[string]interface{}, } // normal mode: remove LD_PRELOAD if present - instanceKey := buildGatewayInstanceKey(req.AssetName, req.AssetID) - sandbox.StopHookLogWatcherByKey(instanceKey) m, err := removeSandboxFromUnit(unitPath) if err != nil { logging.Warning("[GatewayManager] remove sandbox from unit failed: %v", err) diff --git a/go_lib/plugins/nullclaw/gateway_platform_windows.go b/go_lib/plugins/nullclaw/gateway_platform_windows.go index b383964..9676ac9 100644 --- a/go_lib/plugins/nullclaw/gateway_platform_windows.go +++ b/go_lib/plugins/nullclaw/gateway_platform_windows.go @@ -117,20 +117,9 @@ func restartWithSandbox(req *GatewayRestartRequest, binaryPath, homeDir string) return nil, fmt.Errorf("sandbox start failed: %w", err) } - // Start hook log watcher to feed enforcement events into the security event pipeline + // Sandbox hook audit log is no longer harvested into SecurityEvents here. + // Proxy decision sink is the sole authoritative source (see _rules/security_event.md). logPath := filepath.Join(logDir, fmt.Sprintf("botsec_%s_hook.log", sandbox.SanitizeAssetNamePublic(instanceKey))) - sandbox.StartHookLogWatcherByKey(instanceKey, logPath, func(event sandbox.HookLogEvent) { - eventType, actionDesc, riskType, source := sandbox.MapHookEventToSecurityEvent(event) - GetSecurityEventBuffer().AddSecurityEvent(SecurityEvent{ - EventType: eventType, - ActionDesc: actionDesc, - RiskType: riskType, - Source: source, - Detail: event.Detail, - AssetName: req.AssetName, - AssetID: req.AssetID, - }) - }) logging.Info("[GatewayManager] Sandbox started: mode=windows_hook, managed_pid=%d, hook_log=%s, policy_dir=%s", mgr.GetManagedPID(), logPath, policyDir) diff --git a/go_lib/plugins/openclaw/gateway_platform_linux.go b/go_lib/plugins/openclaw/gateway_platform_linux.go index 8d0caf4..70e6fd8 100644 --- a/go_lib/plugins/openclaw/gateway_platform_linux.go +++ b/go_lib/plugins/openclaw/gateway_platform_linux.go @@ -133,18 +133,8 @@ func restartOpenclawGateway(req *GatewayRestartRequest) (map[string]interface{}, } time.Sleep(2 * time.Second) - sandbox.StartHookLogWatcherByKey(instanceKey, logPath, func(event sandbox.HookLogEvent) { - eventType, actionDesc, riskType, source := sandbox.MapHookEventToSecurityEvent(event) - GetSecurityEventBuffer().AddSecurityEvent(SecurityEvent{ - EventType: eventType, - ActionDesc: actionDesc, - RiskType: riskType, - Source: source, - Detail: event.Detail, - AssetName: req.AssetName, - AssetID: req.AssetID, - }) - }) + // Sandbox hook audit log is no longer harvested into SecurityEvents here. + // Proxy decision sink is the sole authoritative source (see _rules/security_event.md). return map[string]interface{}{ "success": true, @@ -156,8 +146,6 @@ func restartOpenclawGateway(req *GatewayRestartRequest) (map[string]interface{}, } // normal mode: remove LD_PRELOAD if present - instanceKey := buildGatewayInstanceKey(req.AssetName, req.AssetID) - sandbox.StopHookLogWatcherByKey(instanceKey) m, err := removeSandboxFromUnit(unitPath) if err != nil { logging.Warning("[GatewayManager] remove sandbox from unit failed: %v", err) diff --git a/go_lib/plugins/openclaw/gateway_platform_windows.go b/go_lib/plugins/openclaw/gateway_platform_windows.go index 23fdb24..79c8966 100644 --- a/go_lib/plugins/openclaw/gateway_platform_windows.go +++ b/go_lib/plugins/openclaw/gateway_platform_windows.go @@ -24,9 +24,6 @@ func restartOpenclawGateway(req *GatewayRestartRequest) (map[string]interface{}, logging.Info("[GatewayManager] === restartOpenclawGateway (Windows) called, asset=%s, assetID=%s, sandbox=%v ===", req.AssetName, req.AssetID, req.SandboxEnabled) - for _, key := range buildGatewayRuntimeStateKeys(req.AssetName, req.AssetID) { - sandbox.StopHookLogWatcherByKey(key) - } cleanupGatewayManagedRuntimeState(req.AssetName, req.AssetID) var homeDir string @@ -122,21 +119,9 @@ func restartWithSandbox(req *GatewayRestartRequest, binaryPath, homeDir string) return nil, fmt.Errorf("sandbox start failed: %w", err) } - // Start hook log watcher to feed enforcement events into the security event pipeline + // Sandbox hook enforcement events are no longer harvested from the DLL audit log here. + // All security events are authored by the proxy decision sink (see _rules/security_event.md). logPath := filepath.Join(logDir, fmt.Sprintf("botsec_%s_hook.log", sandbox.SanitizeAssetNamePublic(instanceKey))) - sandbox.StartHookLogWatcherByKey(instanceKey, logPath, func(event sandbox.HookLogEvent) { - eventType, actionDesc, riskType, source := sandbox.MapHookEventToSecurityEvent(event) - GetSecurityEventBuffer().AddSecurityEvent(SecurityEvent{ - BotID: req.AssetID, - EventType: eventType, - ActionDesc: actionDesc, - RiskType: riskType, - Source: source, - Detail: event.Detail, - AssetName: req.AssetName, - AssetID: req.AssetID, - }) - }) logging.Info("[GatewayManager] Sandbox started: mode=windows_hook, managed_pid=%d, hook_log=%s, policy_dir=%s", mgr.GetManagedPID(), logPath, policyDir) diff --git a/lib/constants.dart b/lib/constants.dart index 9a35911..5c9d8b8 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -3,5 +3,5 @@ import 'dart:ui'; class AppConstants { /// 主窗口统一尺寸,所有状态(欢迎页、空闲、扫描中、扫描完成)共用同一大小, /// 避免窗口大小变化导致位置飘移,提升用户体验。 - static const Size windowSize = Size(600, 780); + static const Size windowSize = Size(610, 780); } diff --git a/lib/core_transport/botsec_transport.dart b/lib/core_transport/botsec_transport.dart index 2f4931c..1726c50 100644 --- a/lib/core_transport/botsec_transport.dart +++ b/lib/core_transport/botsec_transport.dart @@ -13,6 +13,12 @@ abstract class BotsecTransport { String callRawOneArgOneInt(String method, String arg, int value); String callRawThreeInts(String method, int arg1, int arg2, int arg3); + /// 异步原始调用,默认退化为同步调用,仅由 FFI 传输重写为后台 isolate 执行, + /// 用于避免长耗时 FFI(如 LLM 连通性测试)阻塞 UI isolate。 + Future callRawOneArgAsync(String method, String arg) async { + return callRawOneArg(method, arg); + } + Map callNoArg(String method) { return _decodeEnvelope(callRawNoArg(method), method); } @@ -21,6 +27,15 @@ abstract class BotsecTransport { return _decodeEnvelope(callRawOneArg(method, arg), method); } + /// 异步封装调用:在后台 isolate 执行原始 FFI 后再解析 JSON 包络。 + Future> callOneArgAsync( + String method, + String arg, + ) async { + final raw = await callRawOneArgAsync(method, arg); + return _decodeEnvelope(raw, method); + } + Map callTwoArgs(String method, String arg1, String arg2) { return _decodeEnvelope(callRawTwoArgs(method, arg1, arg2), method); } diff --git a/lib/core_transport/ffi_transport.dart b/lib/core_transport/ffi_transport.dart index 96cb075..6ed315d 100644 --- a/lib/core_transport/ffi_transport.dart +++ b/lib/core_transport/ffi_transport.dart @@ -1,4 +1,5 @@ import 'dart:ffi' as ffi; +import 'dart:isolate'; import 'package:ffi/ffi.dart'; @@ -26,6 +27,9 @@ typedef _OneArgOneIntDart = ffi.Pointer Function(ffi.Pointer, int); typedef _ThreeIntC = ffi.Pointer Function(ffi.Int32, ffi.Int32, ffi.Int32); typedef _ThreeIntDart = ffi.Pointer Function(int, int, int); +typedef _FreeStrC = ffi.Void Function(ffi.Pointer); +typedef _FreeStrDart = void Function(ffi.Pointer); + class FfiTransport extends BotsecTransport { FfiTransport._(); @@ -79,6 +83,29 @@ class FfiTransport extends BotsecTransport { } } + /// 在后台 isolate 执行同步 FFI 调用,保持 UI isolate 空闲。 + /// 适用于一次性的长耗时调用(如 LLM 连通性测试)。 + @override + Future callRawOneArgAsync(String method, String arg) async { + if (!isReady) { + return _notReadyJson(); + } + final libPath = NativeLibraryService().libraryPath; + if (libPath == null || libPath.isEmpty) { + return callRawOneArg(method, arg); + } + final payload = _FfiOneArgPayload(libPath, method, arg); + try { + return await Isolate.run( + () => _invokeFfiOneArgInIsolate(payload), + debugName: 'ffi-$method', + ); + } catch (e) { + appLogger.error('[Transport][FFI-Async] $method failed: $e'); + return _errorJson(method, e); + } + } + @override String callRawTwoArgs(String method, String arg1, String arg2) { if (!isReady) { @@ -179,3 +206,36 @@ class FfiTransport extends BotsecTransport { return '{"success":false,"error":"$method failed: $escaped"}'; } } + +/// Parameters passed into the background isolate for a one-arg FFI call. +/// Keeps the isolate entry self-contained (no capture of FfiTransport state). +class _FfiOneArgPayload { + const _FfiOneArgPayload(this.libPath, this.method, this.arg); + + final String libPath; + final String method; + final String arg; +} + +/// Top-level isolate entry: re-opens the dylib in the worker isolate and +/// performs a single FFI call. Allocating and freeing native memory happen in +/// the same isolate, so no cross-isolate pointer ownership is involved. +/// The underlying OS library is already loaded in the process; DynamicLibrary +/// .open just increments its reference count. +String _invokeFfiOneArgInIsolate(_FfiOneArgPayload p) { + ffi.Pointer? argPtr; + try { + final lib = ffi.DynamicLibrary.open(p.libPath); + final fn = lib.lookupFunction<_OneArgC, _OneArgDart>(p.method); + final freeStr = lib.lookupFunction<_FreeStrC, _FreeStrDart>('FreeString'); + argPtr = p.arg.toNativeUtf8(); + final resultPtr = fn(argPtr); + final result = resultPtr.toDartString(); + freeStr(resultPtr); + return result; + } finally { + if (argPtr != null) { + malloc.free(argPtr); + } + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0b1c03b..a23cba9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2064,6 +2064,12 @@ abstract class AppLocalizations { /// **'百万'** String get tokenUnitM; + /// No description provided for @tokenUnitBase. + /// + /// In zh, this message translates to: + /// **'个'** + String get tokenUnitBase; + /// No description provided for @tokenPresetLabel. /// /// In zh, this message translates to: @@ -3568,6 +3574,30 @@ abstract class AppLocalizations { /// **'其他事件'** String get eventOther; + /// No description provided for @eventTypeWarning. + /// + /// In zh, this message translates to: + /// **'告警'** + String get eventTypeWarning; + + /// No description provided for @riskTypeQuota. + /// + /// In zh, this message translates to: + /// **'配额限制'** + String get riskTypeQuota; + + /// No description provided for @riskTypeSandboxBlocked. + /// + /// In zh, this message translates to: + /// **'沙箱拦截'** + String get riskTypeSandboxBlocked; + + /// No description provided for @riskTypeNeedsConfirmation. + /// + /// In zh, this message translates to: + /// **'待确认'** + String get riskTypeNeedsConfirmation; + /// No description provided for @eventTime. /// /// In zh, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7e05b67..a7c5dbf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1202,6 +1202,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tokenUnitM => 'M'; + @override + String get tokenUnitBase => 'tokens'; + @override String get tokenPresetLabel => 'Quick select'; @@ -2040,6 +2043,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get eventOther => 'Other Event'; + @override + String get eventTypeWarning => 'Warning'; + + @override + String get riskTypeQuota => 'Quota Limited'; + + @override + String get riskTypeSandboxBlocked => 'Sandbox Blocked'; + + @override + String get riskTypeNeedsConfirmation => 'Needs Confirmation'; + @override String get eventTime => 'Time'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 638a20c..fb7731b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1168,6 +1168,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tokenUnitM => '百万'; + @override + String get tokenUnitBase => '个'; + @override String get tokenPresetLabel => '快捷选择'; @@ -1963,6 +1966,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get eventOther => '其他事件'; + @override + String get eventTypeWarning => '告警'; + + @override + String get riskTypeQuota => '配额限制'; + + @override + String get riskTypeSandboxBlocked => '沙箱拦截'; + + @override + String get riskTypeNeedsConfirmation => '待确认'; + @override String get eventTime => '时间'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 9ef2eda..02a184a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -739,6 +739,7 @@ "tokenLimitTip": "When token usage exceeds the configured limit, the proxy will return an over-limit error and terminate the current session to prevent excessive resource consumption.", "tokenUnitK": "K", "tokenUnitM": "M", + "tokenUnitBase": "tokens", "tokenPresetLabel": "Quick select", "tokenNoLimit": "No limit", "tokenPreset50K": "50K", @@ -1080,6 +1081,10 @@ "eventBlocked": "Blocked", "eventToolExecution": "Tool Execution", "eventOther": "Other Event", + "eventTypeWarning": "Warning", + "riskTypeQuota": "Quota Limited", + "riskTypeSandboxBlocked": "Sandbox Blocked", + "riskTypeNeedsConfirmation": "Needs Confirmation", "eventTime": "Time", "eventActionDesc": "Action", "eventRiskType": "Risk Type", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 38167a8..935dfea 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -786,6 +786,7 @@ "tokenLimitTip": "当Token使用超过设定上限时,代理会返回超限错误并终止当前会话,防止过度消耗资源。", "tokenUnitK": "千", "tokenUnitM": "百万", + "tokenUnitBase": "个", "tokenPresetLabel": "快捷选择", "tokenNoLimit": "不限制", "tokenPreset50K": "5万", @@ -1127,6 +1128,10 @@ "eventBlocked": "已拦截", "eventToolExecution": "工具执行", "eventOther": "其他事件", + "eventTypeWarning": "告警", + "riskTypeQuota": "配额限制", + "riskTypeSandboxBlocked": "沙箱拦截", + "riskTypeNeedsConfirmation": "待确认", "eventTime": "时间", "eventActionDesc": "动作描述", "eventRiskType": "风险类型", diff --git a/lib/models/security_event_model.dart b/lib/models/security_event_model.dart index 0da4331..5177e14 100644 --- a/lib/models/security_event_model.dart +++ b/lib/models/security_event_model.dart @@ -66,7 +66,10 @@ class SecurityEvent { bool get isBlocked => eventType == 'blocked'; /// 是否为工具执行事件 - bool get isToolExecution => eventType == 'tool_execution'; + bool get isToolExecution => eventType == 'tool_execution'; + + /// 是否为待用户确认事件(NEEDS_CONFIRMATION 决策) + bool get isNeedsConfirmation => eventType == 'needs_confirmation'; /// 是否来自 ReAct Agent bool get isFromReactAgent => source == 'react_agent'; diff --git a/lib/pages/audit_log_page_detail_ext.dart b/lib/pages/audit_log_page_detail_ext.dart index 61704d2..4236487 100644 --- a/lib/pages/audit_log_page_detail_ext.dart +++ b/lib/pages/audit_log_page_detail_ext.dart @@ -262,6 +262,7 @@ extension _AuditLogPageDetailExt on _AuditLogPageState { final accent = blocked ? Colors.red : Colors.amber; final typeLabel = switch (evt.eventType) { 'blocked' => 'BLOCKED', + 'needs_confirmation' => 'NEEDS_CONFIRMATION', 'tool_execution' => 'TOOL', _ => evt.eventType.toUpperCase(), }; diff --git a/lib/pages/main_page.dart b/lib/pages/main_page.dart index 5c8d958..2112537 100644 --- a/lib/pages/main_page.dart +++ b/lib/pages/main_page.dart @@ -2340,27 +2340,38 @@ class _MainPageState extends State } Future _showSettingsDialog() async { - await showDialog( - context: context, - builder: (dialogContext) => SettingsDialog( - launchAtStartupEnabled: _launchAtStartupEnabled, - onSaveGeneralSettings: _saveGeneralSettings, - scheduledScanIntervalSeconds: _scheduledScanIntervalSeconds, - onClearData: () { - Navigator.of(dialogContext).pop(); - showClearDataConfirmDialog(); - }, - onRestoreConfig: () { - Navigator.of(dialogContext).pop(); - showRestoreConfigConfirmDialog(); - }, - onShowAbout: showAppAboutDialog, - onReauthorizeDirectory: () { - Navigator.of(dialogContext).pop(); - reauthorizeDirectory(); - }, - apiServerEnabled: _apiServerEnabled, - onToggleApiServer: _toggleApiServer, + final barrierLabel = MaterialLocalizations.of( + context, + ).modalBarrierDismissLabel; + await Navigator.of(context).push( + TitlebarBypassDialogRoute( + topInset: _mainTitleBarHeight, + barrierDismissible: true, + barrierLabel: barrierLabel, + barrierColor: Colors.black.withValues(alpha: 0.45), + builder: (dialogContext) => Padding( + padding: EdgeInsets.only(top: _mainTitleBarHeight), + child: SettingsDialog( + launchAtStartupEnabled: _launchAtStartupEnabled, + onSaveGeneralSettings: _saveGeneralSettings, + scheduledScanIntervalSeconds: _scheduledScanIntervalSeconds, + onClearData: () { + Navigator.of(dialogContext).pop(); + showClearDataConfirmDialog(); + }, + onRestoreConfig: () { + Navigator.of(dialogContext).pop(); + showRestoreConfigConfirmDialog(); + }, + onShowAbout: showAppAboutDialog, + onReauthorizeDirectory: () { + Navigator.of(dialogContext).pop(); + reauthorizeDirectory(); + }, + apiServerEnabled: _apiServerEnabled, + onToggleApiServer: _toggleApiServer, + ), + ), ), ); } diff --git a/lib/services/model_config_service.dart b/lib/services/model_config_service.dart index 3ee6dc5..6141eae 100644 --- a/lib/services/model_config_service.dart +++ b/lib/services/model_config_service.dart @@ -59,7 +59,7 @@ class SecurityModelConfigService { }; try { - return transport.callOneArg( + return await transport.callOneArgAsync( 'TestModelConnectionFFI', jsonEncode(request), ); @@ -149,7 +149,7 @@ class BotModelConfigService { }; try { - return transport.callOneArg( + return await transport.callOneArgAsync( 'TestModelConnectionFFI', jsonEncode(request), ); diff --git a/lib/services/skill_security_analyzer_service.dart b/lib/services/skill_security_analyzer_service.dart index 33643f4..d991a34 100644 --- a/lib/services/skill_security_analyzer_service.dart +++ b/lib/services/skill_security_analyzer_service.dart @@ -105,7 +105,7 @@ class SkillSecurityAnalyzerService { 'model': config.model, if (config.secretKey.isNotEmpty) 'secret_key': config.secretKey, }; - return _callOneArg('TestModelConnectionFFI', jsonEncode(request)); + return _callOneArgAsync('TestModelConnectionFFI', jsonEncode(request)); } Future> startBatchScan() async { @@ -247,6 +247,23 @@ class SkillSecurityAnalyzerService { } } + /// 异步 FFI 调用:将长耗时同步 FFI 转发到后台 isolate,避免阻塞 UI isolate。 + Future> _callOneArgAsync( + String method, + String arg, + ) async { + final transport = TransportRegistry.transport; + if (!transport.isReady) { + return {'success': false, 'error': 'Transport not initialized'}; + } + try { + return await transport.callOneArgAsync(method, arg); + } catch (e) { + appLogger.error('[SkillSecurityAnalyzer] $method failed', e); + return {'success': false, 'error': '$method failed: $e'}; + } + } + void dispose() { _pollTimer?.cancel(); _logController.close(); diff --git a/lib/widgets/bot_model_config_form.dart b/lib/widgets/bot_model_config_form.dart index 7aa7ddf..72a2ad2 100644 --- a/lib/widgets/bot_model_config_form.dart +++ b/lib/widgets/bot_model_config_form.dart @@ -1,11 +1,9 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/material.dart'; import '../utils/app_fonts.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../l10n/app_localizations.dart'; import '../models/llm_config_model.dart'; -import '../services/app_settings_database_service.dart'; import '../services/model_config_service.dart'; import '../services/model_config_database_service.dart'; import '../services/protection_service.dart'; @@ -35,25 +33,33 @@ class BotModelConfigForm extends StatefulWidget { /// State for bot model configuration form. class BotModelConfigFormState extends State { - /// Bot 模型按供应商草稿缓存键前缀。 - static const String _providerDraftSettingKeyPrefix = - 'bot_model_provider_drafts_v1'; - late BotModelConfigService _service; final ProviderService _providerService = ProviderService(); - final AppSettingsDatabaseService _appSettingsService = - AppSettingsDatabaseService(); bool _loading = true; bool _saving = false; bool _testing = false; + bool _showApiKey = false; + bool _showSecretKey = false; String? _error; + /// 连通性测试版本号:每次发起测试自增,切换 provider / 关闭表单 / + /// 发起新测试时自增即可使在途请求的结果被丢弃,实现 UI 侧"取消"。 + int _testSeq = 0; + + /// 当前在途的连通性测试 completer。切换 provider / 关闭表单 / 重新发起测试 + /// 时以 null 完成它,让 [validateConnection] 的 Future 立即 resolve, + /// 外层按钮的 loading 态可以即时清除(不必等 Go 侧最长 30s 的超时)。 + Completer?>? _activeTestCompleter; + final TextEditingController _endpointController = TextEditingController(); final TextEditingController _apiKeyController = TextEditingController(); final TextEditingController _modelController = TextEditingController(); final TextEditingController _secretKeyController = TextEditingController(); String _selectedType = 'openai'; String _savedConfigSignature = ''; + + /// 按 provider 缓存的草稿,仅存在于内存中。 + /// 切换 provider 不触发任何持久化,避免主线程被 FFI 同步调用阻塞。 final Map _providerDrafts = {}; /// Dynamically loaded providers from Go layer. @@ -170,32 +176,34 @@ class BotModelConfigFormState extends State { ].join('|'); } - /// Loads current configuration into the form. + /// 从数据库加载已保存的 Bot 模型配置并显示到对应 provider 的表单上。 + /// 未保存过的 provider 仅保留内存空草稿,UI 字段留空并由 hint 展示默认值。 Future _loadConfig() async { try { - await _loadProviderDrafts(); final config = await _service.loadConfig(); + if (!mounted) return; if (config != null) { final selectedType = _normalizeSelectedType(config.provider); _providerDrafts[selectedType] = config; - final selectedConfig = - _providerDrafts[selectedType] ?? _createEmptyConfig(selectedType); setState(() { _selectedType = selectedType; - _applyConfigToControllers(selectedConfig); + _applyConfigToControllers(config); _savedConfigSignature = _buildConfigSignature(config); _loading = false; }); + appLogger.info( + '[BotModelConfigForm] Loaded saved config from DB: provider=$selectedType', + ); } else { - final selectedConfig = - _providerDrafts[_selectedType] ?? _createEmptyConfig(_selectedType); + final emptyConfig = _createEmptyConfig(_selectedType); setState(() { - _applyConfigToControllers(selectedConfig); + _applyConfigToControllers(emptyConfig); _savedConfigSignature = _buildConfigSignature(_buildCurrentConfig()); _loading = false; }); } } catch (e) { + if (!mounted) return; setState(() { _error = e.toString(); _loading = false; @@ -220,9 +228,7 @@ class BotModelConfigFormState extends State { setState(() { _saving = false; }); - appLogger.info( - '[BotModelConfigForm] Config unchanged, skip save.', - ); + appLogger.info('[BotModelConfigForm] Config unchanged, skip save.'); return true; } @@ -239,7 +245,6 @@ class BotModelConfigFormState extends State { if (success) { _savedConfigSignature = _buildConfigSignature(config); _captureCurrentProviderDraft(); - await _persistProviderDrafts(); // Bot 模型保存后,如果代理正在运行且未延迟重启,需要完整重启 if (!deferProxyRestart) { final protectionService = ProtectionService.forAsset( @@ -253,10 +258,11 @@ class BotModelConfigFormState extends State { if (securityModelConfig != null) { // 需要从数据库获取运行时配置 final result = await _runWithBotRestartNotice( - () => protectionService.restartProtectionProxyForBotModelUpdate( - securityModelConfig, - ProtectionRuntimeConfig(), - ), + () => + protectionService.restartProtectionProxyForBotModelUpdate( + securityModelConfig, + ProtectionRuntimeConfig(), + ), ); if (result['success'] == true) { appLogger.info( @@ -303,6 +309,9 @@ class BotModelConfigFormState extends State { } /// 手动验证当前配置连通性,不执行保存。 + /// 使用 [_testSeq] + [_activeTestCompleter] 做 UI 侧取消: + /// 切换 provider、关闭弹窗、再次点击测试按钮时,前一次请求会被标记为已取消, + /// 其 Future 立刻以 false 返回,外层按钮 loading 态可以即时清除。 Future validateConnection() async { final l10n = AppLocalizations.of(context)!; final config = _buildCurrentConfig(); @@ -313,41 +322,62 @@ class BotModelConfigFormState extends State { return false; } + // 若此前仍有在途测试,以 null 完成其 completer,让对应 Future 立即返回。 + final previous = _activeTestCompleter; + if (previous != null && !previous.isCompleted) { + previous.complete(null); + } + + final int seq = ++_testSeq; + final completer = Completer?>(); + _activeTestCompleter = completer; + setState(() { _testing = true; _error = null; }); - try { - final testResult = await _service.testConnection(config); - if (testResult['success'] == true) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.modelConfigTestSuccess), - backgroundColor: Colors.green, - ), - ); - } - return true; - } - setState(() { - _error = l10n.modelConfigTestFailed( - testResult['error'] ?? 'Unknown error', - ); - }); + + unawaited( + _service + .testConnection(config) + .then((result) { + if (!completer.isCompleted) { + completer.complete(result); + } + }) + .catchError((Object e) { + if (!completer.isCompleted) { + completer.complete({'success': false, 'error': e.toString()}); + } + }), + ); + + final result = await completer.future; + if (identical(_activeTestCompleter, completer)) { + _activeTestCompleter = null; + } + + if (!mounted || seq != _testSeq || result == null) { return false; - } catch (e) { + } + + if (result['success'] == true) { setState(() { - _error = e.toString(); + _testing = false; }); - return false; - } finally { - if (mounted) { - setState(() { - _testing = false; - }); - } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.modelConfigTestSuccess), + backgroundColor: Colors.green, + ), + ); + return true; } + setState(() { + _error = l10n.modelConfigTestFailed(result['error'] ?? 'Unknown error'); + _testing = false; + }); + return false; } /// 执行 Bot 重启动作并显示用户友好的页面提示。 @@ -385,10 +415,6 @@ class BotModelConfigFormState extends State { } } - /// 生成 provider 草稿缓存设置键。 - String get _providerDraftSettingKey => - '$_providerDraftSettingKeyPrefix:${widget.assetName}:${widget.assetID}'; - /// 创建指定 provider 的空白配置。 BotModelConfig _createEmptyConfig(String providerName) { return BotModelConfig( @@ -415,8 +441,16 @@ class BotModelConfigFormState extends State { _providerDrafts[_selectedType] = _buildCurrentConfig(); } - /// 切换 provider 时恢复对应草稿,避免输入被清空。 - Future _handleProviderSelected(ProviderInfo provider) async { + /// 切换 provider 时只在内存中缓存当前草稿并恢复目标 provider 的内存草稿。 + /// 不执行任何磁盘/FFI 持久化,避免阻塞主线程导致 Windows 界面未响应。 + /// 同步作废可能在途的连通性测试结果,并立即停止 loading 指示。 + void _handleProviderSelected(ProviderInfo provider) { + _testSeq++; + final pending = _activeTestCompleter; + if (pending != null && !pending.isCompleted) { + pending.complete(null); + } + _activeTestCompleter = null; _captureCurrentProviderDraft(); final targetConfig = _providerDrafts[provider.name] ?? _createEmptyConfig(provider.name); @@ -425,61 +459,8 @@ class BotModelConfigFormState extends State { _selectedType = provider.name; _applyConfigToControllers(targetConfig); _error = null; + _testing = false; }); - await _persistProviderDrafts(); - } - - /// 从应用设置读取 provider 草稿缓存。 - Future _loadProviderDrafts() async { - final raw = await _appSettingsService.getSetting(_providerDraftSettingKey); - if (raw.isEmpty) { - return; - } - try { - final decoded = jsonDecode(raw); - if (decoded is! Map) { - return; - } - decoded.forEach((provider, value) { - if (value is! Map) { - return; - } - final data = Map.from(value); - _providerDrafts[provider] = BotModelConfig( - assetName: widget.assetName, - assetID: widget.assetID, - provider: _resolveProviderType(provider), - baseUrl: (data['base_url'] as String?) ?? '', - apiKey: (data['api_key'] as String?) ?? '', - model: (data['model'] as String?) ?? '', - secretKey: (data['secret_key'] as String?) ?? '', - ); - }); - } catch (e) { - appLogger.warning( - '[BotModelConfigForm] Failed to parse provider drafts: $e', - ); - } - } - - /// 持久化 provider 草稿缓存。 - Future _persistProviderDrafts() async { - final payload = {}; - _providerDrafts.forEach((provider, config) { - payload[provider] = { - 'base_url': config.baseUrl, - 'api_key': config.apiKey, - 'model': config.model, - 'secret_key': config.secretKey, - }; - }); - final saved = await _appSettingsService.saveSetting( - _providerDraftSettingKey, - jsonEncode(payload), - ); - if (!saved) { - appLogger.warning('[BotModelConfigForm] Failed to persist provider drafts'); - } } /// Returns whether the form is currently saving. @@ -504,6 +485,11 @@ class BotModelConfigFormState extends State { @override void dispose() { + final pending = _activeTestCompleter; + if (pending != null && !pending.isCompleted) { + pending.complete(null); + } + _activeTestCompleter = null; _endpointController.dispose(); _apiKeyController.dispose(); _modelController.dispose(); @@ -553,7 +539,7 @@ class BotModelConfigFormState extends State { label: provider.displayName, icon: _getIconForProvider(provider.icon), isSelected: isSelected, - onTap: () => unawaited(_handleProviderSelected(provider)), + onTap: () => _handleProviderSelected(provider), ); }).toList(), ), @@ -637,7 +623,10 @@ class BotModelConfigFormState extends State { : l10n.modelConfigApiKey, hint: providerInfo?.apiKeyHint ?? 'Your API key', icon: LucideIcons.key, - obscureText: true, + obscureText: !_showApiKey, + onToggleObscureText: () => setState(() { + _showApiKey = !_showApiKey; + }), ), ], const SizedBox(height: 12), @@ -654,7 +643,10 @@ class BotModelConfigFormState extends State { label: l10n.modelConfigSecretKey, hint: 'Your Secret Key', icon: LucideIcons.keyRound, - obscureText: true, + obscureText: !_showSecretKey, + onToggleObscureText: () => setState(() { + _showSecretKey = !_showSecretKey; + }), ), ], ], @@ -667,7 +659,9 @@ class BotModelConfigFormState extends State { required String hint, required IconData icon, bool obscureText = false, + VoidCallback? onToggleObscureText, }) { + final hasVisibilityToggle = onToggleObscureText != null; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -688,6 +682,17 @@ class BotModelConfigFormState extends State { hintText: hint, hintStyle: AppFonts.firaCode(fontSize: 13, color: Colors.white30), prefixIcon: Icon(icon, color: Colors.white54, size: 18), + suffixIcon: hasVisibilityToggle + ? IconButton( + tooltip: obscureText ? '显示明文' : '隐藏明文', + icon: Icon( + obscureText ? LucideIcons.eye : LucideIcons.eyeOff, + color: Colors.white54, + size: 18, + ), + onPressed: onToggleObscureText, + ) + : null, filled: true, fillColor: const Color(0xFF1E1E2E), border: OutlineInputBorder( diff --git a/lib/widgets/model_config_dialog.dart b/lib/widgets/model_config_dialog.dart index 1fdb211..7358262 100644 --- a/lib/widgets/model_config_dialog.dart +++ b/lib/widgets/model_config_dialog.dart @@ -18,9 +18,10 @@ class _ModelConfigDialogState extends State { final GlobalKey _formKey = GlobalKey(); bool _saving = false; + bool _validating = false; Future _handleSave() async { - if (_saving) return; + if (_saving || _validating) return; setState(() { _saving = true; }); @@ -40,13 +41,26 @@ class _ModelConfigDialogState extends State { } /// 处理手动验证连通性动作。 + /// 以 [_validating] 驱动按钮内转圈动画,表单层负责在切换 provider / + /// 关闭弹窗时让此 Future 提前返回,避免 loading 态长时间残留。 Future _handleValidateConnection() async { - if (_saving) return; - await _formKey.currentState?.validateConnection(); + if (_saving || _validating) return; + setState(() { + _validating = true; + }); + try { + await _formKey.currentState?.validateConnection(); + } finally { + if (mounted) { + setState(() { + _validating = false; + }); + } + } } void _handleCancel() { - if (_saving) return; + if (_saving || _validating) return; if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } @@ -97,7 +111,7 @@ class _ModelConfigDialogState extends State { color: Colors.white54, size: 20, ), - onPressed: _saving ? null : _handleCancel, + onPressed: (_saving || _validating) ? null : _handleCancel, ), ], ), @@ -110,37 +124,66 @@ class _ModelConfigDialogState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: _saving ? null : _handleCancel, + onPressed: (_saving || _validating) ? null : _handleCancel, child: Text( l10n.cancel, style: AppFonts.inter( fontSize: 14, - color: _saving ? Colors.white24 : Colors.white54, + color: (_saving || _validating) + ? Colors.white24 + : Colors.white54, ), ), ), const SizedBox(width: 12), OutlinedButton( - onPressed: _saving ? null : _handleValidateConnection, + onPressed: (_saving || _validating) + ? null + : _handleValidateConnection, style: OutlinedButton.styleFrom( side: BorderSide(color: Colors.white.withValues(alpha: 0.2)), - foregroundColor: _saving ? Colors.white24 : Colors.white70, + foregroundColor: (_saving || _validating) + ? Colors.white24 + : Colors.white70, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), ), - child: Text( - l10n.modelConfigValidateConnection, - style: AppFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), + child: _validating + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white70, + ), + ), + const SizedBox(width: 10), + Text( + l10n.modelConfigTesting, + style: AppFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white70, + ), + ), + ], + ) + : Text( + l10n.modelConfigValidateConnection, + style: AppFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), ), const SizedBox(width: 12), ElevatedButton( - onPressed: _saving ? null : _handleSave, + onPressed: (_saving || _validating) ? null : _handleSave, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF6366F1), foregroundColor: Colors.white, diff --git a/lib/widgets/protection_config_dialog.dart b/lib/widgets/protection_config_dialog.dart index db3ce1c..680ecf4 100644 --- a/lib/widgets/protection_config_dialog.dart +++ b/lib/widgets/protection_config_dialog.dart @@ -21,15 +21,27 @@ import '../services/plugin_service.dart'; part 'protection_config_dialog_network.dart'; /// Token 单位枚举 +/// +/// base 单位(multiplier=1)用于展示/编辑无法被 1000 整除的原始 token 值, +/// 防止小于 1000 的 DB 值在 UI 上被整除为 0 而丢失显示。 enum _TokenUnit { + base(1), k(1000), m(1000000); final int multiplier; const _TokenUnit(this.multiplier); - String label(AppLocalizations l10n) => - this == k ? l10n.tokenUnitK : l10n.tokenUnitM; + String label(AppLocalizations l10n) { + switch (this) { + case _TokenUnit.base: + return l10n.tokenUnitBase; + case _TokenUnit.k: + return l10n.tokenUnitK; + case _TokenUnit.m: + return l10n.tokenUnitM; + } + } } /// Token 预设选项 @@ -189,6 +201,8 @@ class _ProtectionConfigDialogState extends State // 防止重复点击保存 bool _isSaving = false; + // Bot 模型连通性测试态,驱动 footer 验证按钮的转圈动画并屏蔽保存/取消。 + bool _isValidatingBotModel = false; String _savingProgressMessage = _defaultSavingMessage; // Shepherd User Rules @@ -430,6 +444,7 @@ class _ProtectionConfigDialogState extends State barrierDismissible: false, builder: (dialogContext) { bool reuseBotConfig = botConfig != null; + bool validating = false; final formKey = GlobalKey(); return StatefulBuilder( builder: (context, setState) => AlertDialog( @@ -509,68 +524,102 @@ class _ProtectionConfigDialogState extends State ), const SizedBox(width: 8), OutlinedButton( - onPressed: reuseBotConfig + onPressed: (reuseBotConfig || validating) ? null : () async { - await formKey.currentState?.validateConnection(); + setState(() => validating = true); + try { + await formKey.currentState?.validateConnection(); + } finally { + if (dialogContext.mounted) { + setState(() => validating = false); + } + } }, style: OutlinedButton.styleFrom( side: BorderSide(color: Colors.white.withValues(alpha: 0.2)), foregroundColor: Colors.white70, ), - child: Text( - AppLocalizations.of( - dialogContext, - )!.modelConfigValidateConnection, - style: AppFonts.inter(color: Colors.white70), - ), - ), - ElevatedButton( - onPressed: () async { - SecurityModelConfig? savedConfig; - bool success = false; - if (reuseBotConfig && botConfig != null) { - savedConfig = _toSecurityModelConfig(botConfig); - success = await SecurityModelConfigService().saveConfig( - savedConfig, - ); - if (success) { - try { - final protectionService = ProtectionService.forAsset( - widget.assetName, - _config.assetID, - ); - await protectionService.updateSecurityModelConfig( - savedConfig, - ); - } catch (_) {} - } - } else { - success = - await (formKey.currentState?.saveConfig() ?? false); - if (success) { - savedConfig = await ModelConfigDatabaseService() - .getSecurityModelConfig(); - } - } - - if (!dialogContext.mounted) return; - if (success && savedConfig != null) { - Navigator.of(dialogContext).pop(savedConfig); - return; - } - ScaffoldMessenger.of(dialogContext).showSnackBar( - SnackBar( - content: Text( + child: validating + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white70, + ), + ), + const SizedBox(width: 10), + Text( + AppLocalizations.of( + dialogContext, + )!.modelConfigTesting, + style: AppFonts.inter(color: Colors.white70), + ), + ], + ) + : Text( AppLocalizations.of( dialogContext, - )!.modelConfigSaveFailed, + )!.modelConfigValidateConnection, + style: AppFonts.inter(color: Colors.white70), ), - ), - ); - }, + ), + ElevatedButton( + onPressed: validating + ? null + : () async { + SecurityModelConfig? savedConfig; + bool success = false; + if (reuseBotConfig && botConfig != null) { + savedConfig = _toSecurityModelConfig(botConfig); + success = await SecurityModelConfigService() + .saveConfig(savedConfig); + if (success) { + try { + final protectionService = + ProtectionService.forAsset( + widget.assetName, + _config.assetID, + ); + await protectionService.updateSecurityModelConfig( + savedConfig, + ); + } catch (_) {} + } + } else { + success = + await (formKey.currentState?.saveConfig() ?? + false); + if (success) { + savedConfig = await ModelConfigDatabaseService() + .getSecurityModelConfig(); + } + } + + if (!dialogContext.mounted) return; + if (success && savedConfig != null) { + Navigator.of(dialogContext).pop(savedConfig); + return; + } + ScaffoldMessenger.of(dialogContext).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + dialogContext, + )!.modelConfigSaveFailed, + ), + ), + ); + }, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF6366F1), + disabledBackgroundColor: const Color( + 0xFF6366F1, + ).withValues(alpha: 0.5), ), child: Text( AppLocalizations.of(dialogContext)!.modelConfigSave, @@ -2032,16 +2081,21 @@ class _ProtectionConfigDialogState extends State } /// 原始 token 值转换为显示值(数字文本 + 单位) + /// + /// 选择能够无损表示 rawValue 的最大单位: + /// - 0 或负值 → 0 个(语义上等同不限制,_displayToRaw 会将 <=0 归零) + /// - 1000000 的整倍数 → M + /// - 1000 的整倍数 → K + /// - 其余一律使用 base 单位(个),避免被 K 整除后显示为 0 而丢失原值 (String, _TokenUnit) _rawToDisplay(int rawValue) { - if (rawValue <= 0) return ('', _TokenUnit.k); + if (rawValue <= 0) return ('0', _TokenUnit.base); if (rawValue % 1000000 == 0) { return ('${rawValue ~/ 1000000}', _TokenUnit.m); } if (rawValue % 1000 == 0) { return ('${rawValue ~/ 1000}', _TokenUnit.k); } - // 兜底:以K为单位整除 - return ('${rawValue ~/ 1000}', _TokenUnit.k); + return ('$rawValue', _TokenUnit.base); } /// 显示值转换为原始 token 值 @@ -2359,46 +2413,84 @@ class _ProtectionConfigDialogState extends State } } + /// Bot 模型连通性测试,以 [_isValidatingBotModel] 驱动 footer 按钮转圈动画。 + /// 表单层负责在切换 provider / 关闭弹窗时让此 Future 提前返回, + /// 避免 loading 态长时间残留。 + Future _handleValidateBotModelConnection() async { + if (_isSaving || _isValidatingBotModel) return; + setState(() { + _isValidatingBotModel = true; + }); + try { + await _botModelFormKey.currentState?.validateConnection(); + } finally { + if (mounted) { + setState(() { + _isValidatingBotModel = false; + }); + } + } + } + Widget _buildFooter(AppLocalizations l10n) { final bool isBotTabSelected = _requiresBotModelConfig && _tabController.index == (_botTabIndex ?? -1); + final bool busy = _isSaving || _isValidatingBotModel; return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: _isSaving ? null : () => Navigator.of(context).pop(), + onPressed: busy ? null : () => Navigator.of(context).pop(), child: Text( l10n.cancel, style: AppFonts.inter( - color: _isSaving ? Colors.white24 : Colors.white54, + color: busy ? Colors.white24 : Colors.white54, ), ), ), const SizedBox(width: 12), if (isBotTabSelected) ...[ OutlinedButton( - onPressed: _isSaving - ? null - : () async { - await _botModelFormKey.currentState?.validateConnection(); - }, + onPressed: busy ? null : _handleValidateBotModelConnection, style: OutlinedButton.styleFrom( side: BorderSide(color: Colors.white.withValues(alpha: 0.2)), - foregroundColor: _isSaving ? Colors.white24 : Colors.white70, + foregroundColor: busy ? Colors.white24 : Colors.white70, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), - child: Text( - l10n.modelConfigValidateConnection, - style: AppFonts.inter( - fontWeight: FontWeight.w500, - color: _isSaving ? Colors.white24 : Colors.white70, - ), - ), + child: _isValidatingBotModel + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white70, + ), + ), + const SizedBox(width: 10), + Text( + l10n.modelConfigTesting, + style: AppFonts.inter( + fontWeight: FontWeight.w500, + color: Colors.white70, + ), + ), + ], + ) + : Text( + l10n.modelConfigValidateConnection, + style: AppFonts.inter( + fontWeight: FontWeight.w500, + color: busy ? Colors.white24 : Colors.white70, + ), + ), ), const SizedBox(width: 12), ], ElevatedButton( - onPressed: _isSaving ? null : _saveConfig, + onPressed: busy ? null : _saveConfig, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF6366F1), disabledBackgroundColor: const Color( diff --git a/lib/widgets/protection_monitor_event_panel.dart b/lib/widgets/protection_monitor_event_panel.dart index dcb35a2..d5f553e 100644 --- a/lib/widgets/protection_monitor_event_panel.dart +++ b/lib/widgets/protection_monitor_event_panel.dart @@ -5,6 +5,40 @@ import '../l10n/app_localizations.dart'; import '../models/security_event_model.dart'; import '../utils/app_fonts.dart'; +/// 将后端下发的 eventType 常量本地化(仅翻译 Go 侧写死的枚举值, +/// 未识别值原样返回以兼容 LLM/启发式自由文本)。 +String localizeSecurityEventType(String raw, AppLocalizations l10n) { + switch (raw.trim().toLowerCase()) { + case 'blocked': + return l10n.eventBlocked; + case 'needs_confirmation': + return l10n.riskTypeNeedsConfirmation; + case 'tool_execution': + return l10n.eventToolExecution; + case 'warning': + return l10n.eventTypeWarning; + case 'other': + return l10n.eventOther; + default: + return raw; + } +} + +/// 将后端下发的 riskType 常量本地化(仅翻译 Go 侧写死的枚举值, +/// 启发式/LLM 返回的自由文本原样返回)。 +String localizeSecurityRiskType(String raw, AppLocalizations l10n) { + switch (raw.trim().toUpperCase()) { + case 'QUOTA': + return l10n.riskTypeQuota; + case 'SANDBOX_BLOCKED': + return l10n.riskTypeSandboxBlocked; + case 'NEEDS_CONFIRMATION': + return l10n.riskTypeNeedsConfirmation; + default: + return raw; + } +} + /// 安全事件面板:展示数据库驱动的安全事件记录 class ProtectionMonitorEventPanel extends StatefulWidget { final List events; @@ -205,7 +239,7 @@ class _ProtectionMonitorEventPanelState borderRadius: BorderRadius.circular(3), ), child: Text( - event.riskType, + localizeSecurityRiskType(event.riskType, l10n), style: AppFonts.inter(fontSize: 10, color: eventColor), ), ), @@ -250,18 +284,21 @@ class _ProtectionMonitorEventPanelState Color _getEventColor(SecurityEvent event) { if (event.isBlocked) return const Color(0xFFEF4444); + if (event.isNeedsConfirmation) return const Color(0xFFF59E0B); if (event.isToolExecution) return const Color(0xFF6366F1); return const Color(0xFFF59E0B); } IconData _getEventIcon(SecurityEvent event) { if (event.isBlocked) return LucideIcons.shieldOff; + if (event.isNeedsConfirmation) return LucideIcons.shieldAlert; if (event.isToolExecution) return LucideIcons.wrench; return LucideIcons.alertTriangle; } String _getEventTypeLabel(SecurityEvent event, AppLocalizations l10n) { if (event.isBlocked) return l10n.eventBlocked; + if (event.isNeedsConfirmation) return l10n.riskTypeNeedsConfirmation; if (event.isToolExecution) return l10n.eventToolExecution; return l10n.eventOther; } @@ -319,6 +356,8 @@ class _SecurityEventDetailDialogState Widget build(BuildContext context) { final eventColor = event.isBlocked ? const Color(0xFFEF4444) + : event.isNeedsConfirmation + ? const Color(0xFFF59E0B) : event.isToolExecution ? const Color(0xFF6366F1) : const Color(0xFFF59E0B); @@ -342,6 +381,8 @@ class _SecurityEventDetailDialogState Icon( event.isBlocked ? LucideIcons.shieldOff + : event.isNeedsConfirmation + ? LucideIcons.shieldAlert : event.isToolExecution ? LucideIcons.wrench : LucideIcons.alertTriangle, @@ -403,14 +444,20 @@ class _SecurityEventDetailDialogState event.actionDesc, ), if (event.riskType.isNotEmpty) - _buildDetailRow(l10n.eventRiskType, event.riskType), + _buildDetailRow( + l10n.eventRiskType, + localizeSecurityRiskType(event.riskType, l10n), + ), _buildDetailRow( l10n.eventSource, event.isFromReactAgent ? l10n.eventSourceAgent : l10n.eventSourceHeuristic, ), - _buildDetailRow(l10n.eventType, event.eventType), + _buildDetailRow( + l10n.eventType, + localizeSecurityEventType(event.eventType, l10n), + ), if (event.detail.isNotEmpty) _buildDetailRow(l10n.eventDetail, event.detail), _buildDetailRow('ID', event.id), diff --git a/lib/widgets/security_model_config_form.dart b/lib/widgets/security_model_config_form.dart index e585133..612e45b 100644 --- a/lib/widgets/security_model_config_form.dart +++ b/lib/widgets/security_model_config_form.dart @@ -47,8 +47,19 @@ class SecurityModelConfigFormState extends State { bool _loading = true; bool _saving = false; bool _testing = false; + bool _showApiKey = false; + bool _showSecretKey = false; String? _error; + /// 连通性测试版本号:每次发起测试自增,切换 provider / 关闭表单 / + /// 发起新测试时自增即可使在途请求的结果被丢弃,实现 UI 侧"取消"。 + int _testSeq = 0; + + /// 当前在途的连通性测试 completer。切换 provider / 关闭表单 / 重新发起测试 + /// 时以 null 完成它,让 [validateConnection] 的 Future 立即 resolve, + /// 外层按钮的 loading 态可以即时清除(不必等 Go 侧最长 30s 的超时)。 + Completer?>? _activeTestCompleter; + final TextEditingController _endpointController = TextEditingController(); final TextEditingController _apiKeyController = TextEditingController(); final TextEditingController _modelController = TextEditingController(); @@ -221,6 +232,9 @@ class SecurityModelConfigFormState extends State { } /// 手动验证当前配置连通性,不触发持久化。 + /// 使用 [_testSeq] + [_activeTestCompleter] 做 UI 侧取消: + /// 切换 provider、关闭弹窗、再次点击测试按钮时,前一次请求会被标记为已取消, + /// 其 Future 立刻以 false 返回,外层按钮 loading 态可以即时清除。 Future validateConnection() async { final l10n = AppLocalizations.of(context)!; final config = _buildCurrentConfig(); @@ -231,42 +245,65 @@ class SecurityModelConfigFormState extends State { return false; } + // 若此前仍有在途测试,以 null 完成其 completer,让对应 Future 立即返回。 + final previous = _activeTestCompleter; + if (previous != null && !previous.isCompleted) { + previous.complete(null); + } + + final int seq = ++_testSeq; + final completer = Completer?>(); + _activeTestCompleter = completer; + setState(() { _testing = true; _error = null; }); - try { - final testResult = await _service.testConnection(config); - if (testResult['success'] == true) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.modelConfigTestSuccess), - backgroundColor: Colors.green, - ), - ); - } - return true; - } - setState(() { - _error = l10n.modelConfigTestFailed( - testResult['error'] ?? 'Unknown error', - ); - }); + // 后台 isolate 的真实 FFI 调用;完成后回填 completer,若已被取消则忽略。 + unawaited( + _service + .testConnection(config) + .then((result) { + if (!completer.isCompleted) { + completer.complete(result); + } + }) + .catchError((Object e) { + if (!completer.isCompleted) { + completer.complete({'success': false, 'error': e.toString()}); + } + }), + ); + + final result = await completer.future; + if (identical(_activeTestCompleter, completer)) { + _activeTestCompleter = null; + } + + // Widget 已销毁、被新序列号取代或被主动取消时,直接丢弃结果。 + if (!mounted || seq != _testSeq || result == null) { return false; - } catch (e) { + } + + if (result['success'] == true) { setState(() { - _error = e.toString(); + _testing = false; }); - return false; - } finally { - if (mounted) { - setState(() { - _testing = false; - }); - } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.modelConfigTestSuccess), + backgroundColor: Colors.green, + ), + ); + return true; } + + setState(() { + _error = l10n.modelConfigTestFailed(result['error'] ?? 'Unknown error'); + _testing = false; + }); + return false; } /// 构建当前表单配置对象。 @@ -326,7 +363,14 @@ class SecurityModelConfigFormState extends State { } /// 切换 provider 时加载其对应草稿,避免输入被清空。 + /// 同步作废可能在途的连通性测试结果,并立即停止 loading 指示。 Future _handleProviderSelected(ProviderInfo provider) async { + _testSeq++; + final pending = _activeTestCompleter; + if (pending != null && !pending.isCompleted) { + pending.complete(null); + } + _activeTestCompleter = null; _captureCurrentProviderDraft(); final targetConfig = _providerDrafts[provider.name] ?? _createEmptyConfig(provider.name); @@ -335,6 +379,7 @@ class SecurityModelConfigFormState extends State { _selectedType = provider.name; _applyConfigToControllers(targetConfig); _error = null; + _testing = false; }); await _persistProviderDrafts(); } @@ -400,6 +445,12 @@ class SecurityModelConfigFormState extends State { @override void dispose() { + // Let any in-flight validateConnection future resolve immediately. + final pending = _activeTestCompleter; + if (pending != null && !pending.isCompleted) { + pending.complete(null); + } + _activeTestCompleter = null; _endpointController.dispose(); _apiKeyController.dispose(); _modelController.dispose(); @@ -546,7 +597,12 @@ class SecurityModelConfigFormState extends State { : l10n.modelConfigApiKey, hint: providerInfo?.apiKeyHint ?? 'Your API key', icon: LucideIcons.key, - obscureText: true, + obscureText: !_showApiKey, + onToggleObscureText: widget.readOnly + ? null + : () => setState(() { + _showApiKey = !_showApiKey; + }), ), ], const SizedBox(height: 12), @@ -563,7 +619,12 @@ class SecurityModelConfigFormState extends State { label: l10n.modelConfigSecretKey, hint: 'Your Secret Key', icon: LucideIcons.keyRound, - obscureText: true, + obscureText: !_showSecretKey, + onToggleObscureText: widget.readOnly + ? null + : () => setState(() { + _showSecretKey = !_showSecretKey; + }), ), ], ], @@ -576,7 +637,9 @@ class SecurityModelConfigFormState extends State { required String hint, required IconData icon, bool obscureText = false, + VoidCallback? onToggleObscureText, }) { + final hasVisibilityToggle = onToggleObscureText != null; return TextField( controller: controller, obscureText: obscureText, @@ -591,6 +654,17 @@ class SecurityModelConfigFormState extends State { hintText: hint, hintStyle: AppFonts.inter(fontSize: 13, color: Colors.white30), prefixIcon: Icon(icon, color: Colors.white54, size: 18), + suffixIcon: hasVisibilityToggle + ? IconButton( + tooltip: obscureText ? '显示明文' : '隐藏明文', + icon: Icon( + obscureText ? LucideIcons.eye : LucideIcons.eyeOff, + color: Colors.white54, + size: 18, + ), + onPressed: onToggleObscureText, + ) + : null, filled: true, fillColor: const Color(0xFF1E1E2E), border: OutlineInputBorder( diff --git a/lib/widgets/settings_dialog.dart b/lib/widgets/settings_dialog.dart index de65895..d31b8fd 100644 --- a/lib/widgets/settings_dialog.dart +++ b/lib/widgets/settings_dialog.dart @@ -46,6 +46,7 @@ class _SettingsDialogState extends State final GlobalKey _formKey = GlobalKey(); bool _saving = false; + bool _validating = false; int _currentTabIndex = 0; late bool _localLaunchAtStartup; late int _localScheduledScanIntervalSeconds; @@ -81,7 +82,7 @@ class _SettingsDialogState extends State } Future _handleSave() async { - if (_saving) return; + if (_saving || _validating) return; setState(() { _saving = true; }); @@ -111,13 +112,26 @@ class _SettingsDialogState extends State } /// 处理手动验证连通性动作。 + /// 以 [_validating] 驱动按钮内转圈动画,表单层负责在切换 provider / + /// 关闭弹窗时让此 Future 提前返回,避免 loading 态长时间残留。 Future _handleValidateConnection() async { - if (_saving || _currentTabIndex != 0) return; - await _formKey.currentState?.validateConnection(); + if (_saving || _validating || _currentTabIndex != 0) return; + setState(() { + _validating = true; + }); + try { + await _formKey.currentState?.validateConnection(); + } finally { + if (mounted) { + setState(() { + _validating = false; + }); + } + } } void _handleCancel() { - if (_saving) return; + if (_saving || _validating) return; if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } @@ -224,7 +238,7 @@ class _SettingsDialogState extends State ), IconButton( icon: const Icon(LucideIcons.x, color: Colors.white54, size: 20), - onPressed: _saving ? null : _handleCancel, + onPressed: (_saving || _validating) ? null : _handleCancel, ), ], ); @@ -296,36 +310,65 @@ class _SettingsDialogState extends State mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: _saving ? null : _handleCancel, + onPressed: (_saving || _validating) ? null : _handleCancel, child: Text( l10n.cancel, style: AppFonts.inter( fontSize: 14, - color: _saving ? Colors.white24 : Colors.white54, + color: (_saving || _validating) + ? Colors.white24 + : Colors.white54, ), ), ), const SizedBox(width: 12), if (_currentTabIndex == 0) ...[ OutlinedButton( - onPressed: _saving ? null : _handleValidateConnection, + onPressed: (_saving || _validating) + ? null + : _handleValidateConnection, style: OutlinedButton.styleFrom( side: BorderSide(color: Colors.white.withValues(alpha: 0.2)), - foregroundColor: _saving ? Colors.white24 : Colors.white70, + foregroundColor: (_saving || _validating) + ? Colors.white24 + : Colors.white70, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), - child: Text( - l10n.modelConfigValidateConnection, - style: AppFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), + child: _validating + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white70, + ), + ), + const SizedBox(width: 10), + Text( + l10n.modelConfigTesting, + style: AppFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white70, + ), + ), + ], + ) + : Text( + l10n.modelConfigValidateConnection, + style: AppFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), ), const SizedBox(width: 12), ], ElevatedButton( - onPressed: _saving ? null : _handleSave, + onPressed: (_saving || _validating) ? null : _handleSave, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF6366F1), foregroundColor: Colors.white, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index bedc51c..0000000 --- a/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,31 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include -#include -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopMultiWindowPlugin"); - desktop_multi_window_plugin_register_with_registrar(desktop_multi_window_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); - screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); - g_autoptr(FlPluginRegistrar) tray_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); - tray_manager_plugin_register_with_registrar(tray_manager_registrar); - g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); - url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); - g_autoptr(FlPluginRegistrar) window_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); - window_manager_plugin_register_with_registrar(window_manager_registrar); -} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47..0000000 --- a/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 043da11..0000000 --- a/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,28 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - desktop_multi_window - screen_retriever_linux - tray_manager - url_launcher_linux - window_manager -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/mds/openclaw_like_bot_plugin_guide.md b/mds/openclaw_like_bot_plugin_guide.md index f852d19..e6c4476 100644 --- a/mds/openclaw_like_bot_plugin_guide.md +++ b/mds/openclaw_like_bot_plugin_guide.md @@ -62,12 +62,12 @@ type ProtectionLifecycleHooks interface { ### 5.1 Build stable fingerprint -Use `core.ComputeAssetID(name, configPath, ports, processPaths)` to generate deterministic IDs. +Use `core.ComputeAssetID(name, configPath)` to generate deterministic IDs. Rules: -- Keep fingerprint inputs stable and canonical. -- Include fields that identify one bot instance. +- Only `name` + `config_path` participate in the fingerprint; both must be stable and canonical. +- Runtime-dynamic data (`ports`, `process_paths`, `pid`, `service_name`, etc.) MUST NOT enter the fingerprint; otherwise starting/stopping the bot will drift `asset_id` and break policy/protection binding. - Do not include volatile data (timestamps/temp paths/random values). ### 5.2 SourcePlugin diff --git a/mds/openclaw_like_bot_plugin_guide_zh-CN.md b/mds/openclaw_like_bot_plugin_guide_zh-CN.md index a06ff36..488cf94 100644 --- a/mds/openclaw_like_bot_plugin_guide_zh-CN.md +++ b/mds/openclaw_like_bot_plugin_guide_zh-CN.md @@ -62,12 +62,12 @@ type ProtectionLifecycleHooks interface { ### 5.1 构建稳定指纹 -使用 `core.ComputeAssetID(name, configPath, ports, processPaths)` 生成确定性 ID。 +使用 `core.ComputeAssetID(name, configPath)` 生成确定性 ID。 规则: -- 指纹输入要稳定、可规范化。 -- 只包含能识别实例的字段。 +- 指纹输入仅限 `name` + `config_path`,两者都要稳定、可规范化。 +- **禁止**把 `ports`、`process_paths`、`pid`、`service_name` 等运行态动态信息卷入指纹,否则 bot 启停会让 `asset_id` 漂移,出现同一资产两条记录、启用防护后策略丢失等问题。 - 不要引入易变字段(时间戳、临时路径、随机值)。 ### 5.2 SourcePlugin diff --git a/scripts/run_with_pprof.ps1 b/scripts/run_with_pprof.ps1 index 7e2a10b..e5a7fa7 100755 --- a/scripts/run_with_pprof.ps1 +++ b/scripts/run_with_pprof.ps1 @@ -183,6 +183,16 @@ function Test-NeedFlutterPubGet { if ($packageConfigTime -lt $overrideTime) { return $true } } + $windowsGeneratedFiles = @( + (Join-Path $ProjectRootPath "windows\flutter\generated_plugins.cmake"), + (Join-Path $ProjectRootPath "windows\flutter\generated_plugin_registrant.cc"), + (Join-Path $ProjectRootPath "windows\flutter\generated_plugin_registrant.h") + ) + foreach ($generatedFile in $windowsGeneratedFiles) { + if (-not (Test-Path $generatedFile)) { return $true } + if ((Get-Item $generatedFile).LastWriteTimeUtc -lt $packageConfigTime) { return $true } + } + return $false } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 3a997be..0000000 --- a/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,26 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include -#include -#include -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - DesktopMultiWindowPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("DesktopMultiWindowPlugin")); - ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); - TrayManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("TrayManagerPlugin")); - UrlLauncherWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherWindows")); - WindowManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WindowManagerPlugin")); -} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d8..0000000 --- a/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake deleted file mode 100644 index 4d663ce..0000000 --- a/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,28 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - desktop_multi_window - screen_retriever_windows - tray_manager - url_launcher_windows - window_manager -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin)