Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions docs/management/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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": [],
Expand Down Expand Up @@ -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": <antigravity> }`, or `{ "antigravity": <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`
Expand Down Expand Up @@ -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. |
Expand Down
34 changes: 34 additions & 0 deletions docs/management/api_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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": [],
Expand Down Expand Up @@ -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": <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`
Expand Down Expand Up @@ -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。 |
Expand Down
1 change: 1 addition & 0 deletions internal/cluster/config_snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
99 changes: 99 additions & 0 deletions internal/cluster/management/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
74 changes: 74 additions & 0 deletions internal/config/antigravity_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
27 changes: 27 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions internal/managementhttp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading