diff --git a/config.example.yaml b/config.example.yaml index 7cfe17c9..3a4cbc66 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -242,6 +242,12 @@ nonstream-keepalive-interval: 0 # When false (default), only checks R/E prefix + base64 + first byte 0x12. # antigravity-signature-bypass-strict: false +# Antigravity provider behavior. +# antigravity: +# sensitive-words: # optional: words to obfuscate with zero-width characters in system instructions +# - "API" +# - "proxy" + # Gemini API keys # gemini-api-key: # - api-key: "AIzaSy...01" # may be omitted when base-url is set, for example with header-based authentication diff --git a/docs/management/api.md b/docs/management/api.md index 01b9fce9..2c71612d 100644 --- a/docs/management/api.md +++ b/docs/management/api.md @@ -101,6 +101,10 @@ The table below is extracted from the final Home route registry built by `intern | Method | Path | | --- | --- | | `GET` | `/anthropic-auth-url` | +| `DELETE` | `/antigravity` | +| `GET` | `/antigravity` | +| `PATCH` | `/antigravity` | +| `PUT` | `/antigravity` | | `GET` | `/antigravity-auth-url` | | `POST` | `/api-call` | | `GET` | `/api-key-usage` | @@ -367,6 +371,9 @@ Example response: }, "antigravity-signature-cache-enabled": true, "antigravity-signature-bypass-strict": false, + "antigravity": { + "sensitive-words": ["word"] + }, "gemini-api-key": [], "interactions-api-key": [], "codex-api-key": [], @@ -558,6 +565,32 @@ Successful writes return: { "status": "ok" } ``` +### `/antigravity` Config Root + +`GET /antigravity` returns: + +```json +{ + "antigravity": { + "sensitive-words": ["API", "proxy"] + } +} +``` + +`GET /antigravity` returns the persisted `antigravity` provider config root. + +`PUT /antigravity` accepts a raw antigravity object, `{ "value": }`, or `{ "antigravity": }`. It replaces the complete `antigravity` root and validates its schema. + +`PATCH /antigravity` accepts the same body shapes and applies object merge-patch semantics to the existing `antigravity` root: submitted object fields are merged recursively, `null` removes a field, and arrays are replaced as whole values. + +`DELETE /antigravity` removes the root from the config snapshot. + +Successful writes return: + +```json +{ "status": "ok" } +``` + ## Nodes, Version, and Certificates ### GET `/nodes` @@ -4133,6 +4166,7 @@ These fields are accepted by Home YAML config. `PUT /config.yaml` accepts non-cr | `routing.session-affinity-ttl` | string | Session-to-auth binding duration. | | `antigravity-signature-cache-enabled` | boolean pointer | Enables Antigravity thinking signature cache validation. | | `antigravity-signature-bypass-strict` | boolean pointer | Controls strictness of Antigravity signature bypass. | +| `antigravity.sensitive-words` | array of string | Words to obfuscate with zero-width characters in system instructions. | | `gemini-api-key` | array of `GeminiKey` | Gemini API-key credentials; use provider-key routes. | | `interactions-api-key` | array of `GeminiKey` | Native Google Interactions API-key credentials; use provider-key routes. | | `codex-api-key` | array of `CodexKey` | Codex API-key credentials; use provider-key routes. | diff --git a/docs/management/api_CN.md b/docs/management/api_CN.md index 93e5c1c3..14189da7 100644 --- a/docs/management/api_CN.md +++ b/docs/management/api_CN.md @@ -101,6 +101,10 @@ DB-backed handler 通常同时返回机器可读 `error` 和可读 `message`: | Method | Path | | --- | --- | | `GET` | `/anthropic-auth-url` | +| `DELETE` | `/antigravity` | +| `GET` | `/antigravity` | +| `PATCH` | `/antigravity` | +| `PUT` | `/antigravity` | | `GET` | `/antigravity-auth-url` | | `POST` | `/api-call` | | `GET` | `/api-key-usage` | @@ -367,6 +371,9 @@ DB-backed handler 通常同时返回机器可读 `error` 和可读 `message`: }, "antigravity-signature-cache-enabled": true, "antigravity-signature-bypass-strict": false, + "antigravity": { + "sensitive-words": ["word"] + }, "gemini-api-key": [], "interactions-api-key": [], "codex-api-key": [], @@ -558,6 +565,32 @@ openai-compatibility { "status": "ok" } ``` +### `/antigravity` Config Root + +`GET /antigravity` 输出: + +```json +{ + "antigravity": { + "sensitive-words": ["API", "proxy"] + } +} +``` + +`GET /antigravity` 返回持久化的 `antigravity` provider config root。 + +`PUT /antigravity` 接受原始 antigravity object、`{ "value": }` 或 `{ "antigravity": }`。它会替换完整 `antigravity` root,并校验其 schema。 + +`PATCH /antigravity` 接受相同 body 形态,并对现有 `antigravity` root 应用 object merge-patch 语义:提交的 object 字段会递归合并,`null` 删除字段,array 作为整体替换。 + +`DELETE /antigravity` 从 config snapshot 删除该 root。 + +写入成功返回: + +```json +{ "status": "ok" } +``` + ## 节点、版本和证书 ### GET `/nodes` @@ -4101,6 +4134,7 @@ DELETE query: | `routing.session-affinity-ttl` | string | Session-to-auth binding 持续时间。 | | `antigravity-signature-cache-enabled` | boolean pointer | 启用 Antigravity thinking signature cache validation。 | | `antigravity-signature-bypass-strict` | boolean pointer | 控制 Antigravity signature bypass 严格程度。 | +| `antigravity.sensitive-words` | array of string | 在 system instructions 中使用零宽字符混淆的敏感词列表。 | | `gemini-api-key` | array of `GeminiKey` | Gemini API-key credentials;应使用 provider-key routes。 | | `interactions-api-key` | array of `GeminiKey` | 原生 Google Interactions API-key credentials;应使用 provider-key routes。 | | `codex-api-key` | array of `CodexKey` | Codex API-key credentials;应使用 provider-key routes。 | diff --git a/internal/cluster/config_snapshot.go b/internal/cluster/config_snapshot.go index 08a606f4..643ffc51 100644 --- a/internal/cluster/config_snapshot.go +++ b/internal/cluster/config_snapshot.go @@ -196,6 +196,7 @@ func RuntimeConfigFromRoot(root map[string]any) (*appconfig.Config, []byte, erro cfg.SanitizeOpenAICompatibility() cfg.SanitizeOAuthModelAlias() cfg.SanitizePayloadRules() + cfg.SanitizeAntigravity() if cfg.Pprof.Addr == "" { cfg.Pprof.Addr = appconfig.DefaultPprofAddr } diff --git a/internal/cluster/management/config_test.go b/internal/cluster/management/config_test.go index 5466be0d..ae45f844 100644 --- a/internal/cluster/management/config_test.go +++ b/internal/cluster/management/config_test.go @@ -777,3 +777,102 @@ func TestPutPortRejectsOutOfRangeValues(t *testing.T) { t.Fatalf("snapshot port = %s, want 8317", snapshot["port"]) } } + +func TestAntigravityConfigRoutes(t *testing.T) { + db, cleanup := openManagementLogTestDB(t) + defer cleanup() + + repo := cluster.NewRepository(db) + handler := NewHandler(repo, nil, "127.0.0.1", 8327) + engine := gin.New() + engine.GET("/antigravity", handler.GetConfigRoot("/antigravity")) + engine.PUT("/antigravity", handler.PutConfigRoot("/antigravity")) + engine.PATCH("/antigravity", handler.PatchConfigRoot("/antigravity")) + engine.DELETE("/antigravity", handler.DeleteConfigRoot("/antigravity")) + engine.GET("/config", handler.GetConfig) + engine.GET("/config.yaml", handler.GetConfigYAML) + + // Initially empty + getResp := httptest.NewRecorder() + engine.ServeHTTP(getResp, httptest.NewRequest(http.MethodGet, "/antigravity", nil)) + if getResp.Code != http.StatusOK { + t.Fatalf("initial GET /antigravity status = %d", getResp.Code) + } + + // PUT /antigravity + putPayload := `{ + "sensitive-words": ["API", "proxy"] + }` + putReq := httptest.NewRequest(http.MethodPut, "/antigravity", strings.NewReader(putPayload)) + putReq.Header.Set("Content-Type", "application/json") + putResp := httptest.NewRecorder() + engine.ServeHTTP(putResp, putReq) + if putResp.Code != http.StatusOK { + t.Fatalf("PUT /antigravity status = %d, body = %s", putResp.Code, putResp.Body.String()) + } + + // GET /antigravity + getResp2 := httptest.NewRecorder() + engine.ServeHTTP(getResp2, httptest.NewRequest(http.MethodGet, "/antigravity", nil)) + if getResp2.Code != http.StatusOK { + t.Fatalf("GET /antigravity status = %d", getResp2.Code) + } + if !strings.Contains(getResp2.Body.String(), `"API"`) || !strings.Contains(getResp2.Body.String(), `"proxy"`) { + t.Fatalf("GET /antigravity body = %s, want API and proxy", getResp2.Body.String()) + } + + // GET /config + configResp := httptest.NewRecorder() + engine.ServeHTTP(configResp, httptest.NewRequest(http.MethodGet, "/config", nil)) + if configResp.Code != http.StatusOK { + t.Fatalf("GET /config status = %d", configResp.Code) + } + if !strings.Contains(configResp.Body.String(), `"antigravity"`) || !strings.Contains(configResp.Body.String(), `"sensitive-words"`) { + t.Fatalf("GET /config body = %s, want antigravity object", configResp.Body.String()) + } + + // GET /config.yaml + yamlResp := httptest.NewRecorder() + engine.ServeHTTP(yamlResp, httptest.NewRequest(http.MethodGet, "/config.yaml", nil)) + if yamlResp.Code != http.StatusOK { + t.Fatalf("GET /config.yaml status = %d", yamlResp.Code) + } + if !strings.Contains(yamlResp.Body.String(), "antigravity:") || !strings.Contains(yamlResp.Body.String(), "sensitive-words:") { + t.Fatalf("GET /config.yaml body = %s, want antigravity yaml", yamlResp.Body.String()) + } + + // PATCH /antigravity + patchPayload := `{ + "sensitive-words": ["internal-secret"] + }` + patchReq := httptest.NewRequest(http.MethodPatch, "/antigravity", strings.NewReader(patchPayload)) + patchReq.Header.Set("Content-Type", "application/json") + patchResp := httptest.NewRecorder() + engine.ServeHTTP(patchResp, patchReq) + if patchResp.Code != http.StatusOK { + t.Fatalf("PATCH /antigravity status = %d, body = %s", patchResp.Code, patchResp.Body.String()) + } + + getResp3 := httptest.NewRecorder() + engine.ServeHTTP(getResp3, httptest.NewRequest(http.MethodGet, "/antigravity", nil)) + if !strings.Contains(getResp3.Body.String(), "internal-secret") { + t.Fatalf("GET /antigravity after PATCH body = %s", getResp3.Body.String()) + } + + // DELETE /antigravity + deleteReq := httptest.NewRequest(http.MethodDelete, "/antigravity", nil) + deleteResp := httptest.NewRecorder() + engine.ServeHTTP(deleteResp, deleteReq) + if deleteResp.Code != http.StatusOK { + t.Fatalf("DELETE /antigravity status = %d, body = %s", deleteResp.Code, deleteResp.Body.String()) + } + + getResp4 := httptest.NewRecorder() + engine.ServeHTTP(getResp4, httptest.NewRequest(http.MethodGet, "/antigravity", nil)) + if getResp4.Code != http.StatusOK { + t.Fatalf("GET /antigravity after DELETE status = %d", getResp4.Code) + } + if strings.Contains(getResp4.Body.String(), "internal-secret") { + t.Fatalf("GET /antigravity after DELETE body = %s, expected deleted", getResp4.Body.String()) + } +} diff --git a/internal/config/antigravity_test.go b/internal/config/antigravity_test.go new file mode 100644 index 00000000..f939691b --- /dev/null +++ b/internal/config/antigravity_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestParseConfig_AntigravitySensitiveWords(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yaml") + content := []byte(` +antigravity: + sensitive-words: + - " API " + - "" + - "proxy" + - " " +`) + if errWrite := os.WriteFile(configPath, content, 0o600); errWrite != nil { + t.Fatalf("write config file: %v", errWrite) + } + + cfg, errLoad := LoadConfigOptional(configPath, false) + if errLoad != nil { + t.Fatalf("LoadConfigOptional() error = %v", errLoad) + } + + want := []string{"API", "proxy"} + if !reflect.DeepEqual(cfg.Antigravity.SensitiveWords, want) { + t.Fatalf("Antigravity.SensitiveWords = %#v, want %#v", cfg.Antigravity.SensitiveWords, want) + } +} + +func TestSanitizeAntigravity(t *testing.T) { + tests := []struct { + name string + in []string + want []string + }{ + { + name: "nil slice", + in: nil, + want: nil, + }, + { + name: "empty slice", + in: []string{}, + want: []string{}, + }, + { + name: "trims whitespace and drops empty", + in: []string{" word1 ", "", " ", "word2"}, + want: []string{"word1", "word2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ + Antigravity: AntigravityConfig{ + SensitiveWords: tt.in, + }, + } + cfg.SanitizeAntigravity() + if len(tt.want) == 0 && len(cfg.Antigravity.SensitiveWords) == 0 { + return + } + if !reflect.DeepEqual(cfg.Antigravity.SensitiveWords, tt.want) { + t.Fatalf("SanitizeAntigravity() = %#v, want %#v", cfg.Antigravity.SensitiveWords, tt.want) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8b5e78b7..42742c63 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,6 +131,9 @@ type Config struct { AntigravitySignatureBypassStrict *bool `yaml:"antigravity-signature-bypass-strict,omitempty" json:"antigravity-signature-bypass-strict,omitempty"` + // Antigravity configures provider-wide Antigravity request behavior. + Antigravity AntigravityConfig `yaml:"antigravity" json:"antigravity"` + // GeminiKey defines Gemini API key configurations with optional routing overrides. GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"` @@ -199,6 +202,12 @@ type CodexHeaderDefaults struct { BetaFeatures string `yaml:"beta-features" json:"beta-features"` } +// AntigravityConfig configures provider-wide Antigravity request behavior. +type AntigravityConfig struct { + // SensitiveWords is a list of words to obfuscate with zero-width characters in system instructions. + SensitiveWords []string `yaml:"sensitive-words,omitempty" json:"sensitive-words,omitempty"` +} + // TLSConfig holds HTTPS server settings. type TLSConfig struct { // Enable toggles HTTPS server mode. @@ -846,6 +855,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Validate raw payload rules and drop invalid entries. cfg.SanitizePayloadRules() + // Sanitize Antigravity configuration. + cfg.SanitizeAntigravity() + ForceHomeRuntimeConfig(&cfg) // Return the populated configuration struct. @@ -941,6 +953,21 @@ func (cfg *Config) SanitizeClaudeHeaderDefaults() { cfg.ClaudeHeaderDefaults.Timeout = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timeout) } +// SanitizeAntigravity trims surrounding whitespace and drops empty words from Antigravity sensitive words. +func (cfg *Config) SanitizeAntigravity() { + if cfg == nil || len(cfg.Antigravity.SensitiveWords) == 0 { + return + } + cleaned := make([]string, 0, len(cfg.Antigravity.SensitiveWords)) + for _, word := range cfg.Antigravity.SensitiveWords { + word = strings.TrimSpace(word) + if word != "" { + cleaned = append(cleaned, word) + } + } + cfg.Antigravity.SensitiveWords = cleaned +} + // SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases. // It trims whitespace, normalizes channel keys to lower-case, drops empty entries, // allows multiple aliases per upstream name, and ensures aliases are unique within each channel. diff --git a/internal/managementhttp/server.go b/internal/managementhttp/server.go index 2380edd0..556f5047 100644 --- a/internal/managementhttp/server.go +++ b/internal/managementhttp/server.go @@ -348,6 +348,10 @@ func registerClusterManagementRoutes(r *RouteRegistry, handler *clustermanagemen r.Set(http.MethodPut, "/payload", handler.PutConfigRoot("/payload")) r.Set(http.MethodPatch, "/payload", handler.PatchConfigRoot("/payload")) r.Set(http.MethodDelete, "/payload", handler.DeleteConfigRoot("/payload")) + r.Set(http.MethodGet, "/antigravity", handler.GetConfigRoot("/antigravity")) + r.Set(http.MethodPut, "/antigravity", handler.PutConfigRoot("/antigravity")) + r.Set(http.MethodPatch, "/antigravity", handler.PatchConfigRoot("/antigravity")) + r.Set(http.MethodDelete, "/antigravity", handler.DeleteConfigRoot("/antigravity")) r.Set(http.MethodGet, "/auth-files", handler.ListAuthFiles) r.Set(http.MethodGet, "/auth-files/models", handler.GetAuthFileModels) diff --git a/internal/watcher/diff/antigravity_diff_test.go b/internal/watcher/diff/antigravity_diff_test.go new file mode 100644 index 00000000..63bac593 --- /dev/null +++ b/internal/watcher/diff/antigravity_diff_test.go @@ -0,0 +1,27 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPIHome/internal/config" +) + +func TestConfigDiff_AntigravitySensitiveWords(t *testing.T) { + oldCfg := &config.Config{ + Antigravity: config.AntigravityConfig{ + SensitiveWords: []string{"API"}, + }, + } + newCfg := &config.Config{ + Antigravity: config.AntigravityConfig{ + SensitiveWords: []string{"API", "proxy"}, + }, + } + + diff := BuildConfigChangeDetails(oldCfg, newCfg) + joined := strings.Join(diff, "; ") + if !strings.Contains(joined, "antigravity.sensitive-words: 1 -> 2") { + t.Fatalf("BuildConfigChangeDetails() = %q, want antigravity.sensitive-words change", joined) + } +} diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 4aff19a1..aad984ef 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -86,6 +86,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.QuotaExceeded.AntigravityCredits != newCfg.QuotaExceeded.AntigravityCredits { changes = append(changes, fmt.Sprintf("quota-exceeded.antigravity-credits: %t -> %t", oldCfg.QuotaExceeded.AntigravityCredits, newCfg.QuotaExceeded.AntigravityCredits)) } + if !reflect.DeepEqual(oldCfg.Antigravity.SensitiveWords, newCfg.Antigravity.SensitiveWords) { + changes = append(changes, fmt.Sprintf("antigravity.sensitive-words: %d -> %d", len(oldCfg.Antigravity.SensitiveWords), len(newCfg.Antigravity.SensitiveWords))) + } if oldCfg.Routing.Strategy != newCfg.Routing.Strategy { changes = append(changes, fmt.Sprintf("routing.strategy: %s -> %s", oldCfg.Routing.Strategy, newCfg.Routing.Strategy))