package types import ( "database/sql/driver" "encoding/json" "fmt" "log" "os" "strings" "time" "github.com/Tencent/WeKnora/internal/utils" "gorm.io/gorm" ) // retrieverEngineMapping maps RETRIEVE_DRIVER values to retriever engine configurations var retrieverEngineMapping = map[string][]RetrieverEngineParams{ "postgres": { {RetrieverType: KeywordsRetrieverType, RetrieverEngineType: PostgresRetrieverEngineType}, {RetrieverType: VectorRetrieverType, RetrieverEngineType: PostgresRetrieverEngineType}, }, "elasticsearch_v7": { {RetrieverType: KeywordsRetrieverType, RetrieverEngineType: ElasticsearchRetrieverEngineType}, }, "elasticsearch_v8": { {RetrieverType: KeywordsRetrieverType, RetrieverEngineType: ElasticsearchRetrieverEngineType}, {RetrieverType: VectorRetrieverType, RetrieverEngineType: ElasticsearchRetrieverEngineType}, }, "qdrant": { {RetrieverType: KeywordsRetrieverType, RetrieverEngineType: QdrantRetrieverEngineType}, {RetrieverType: VectorRetrieverType, RetrieverEngineType: QdrantRetrieverEngineType}, }, "milvus": { {RetrieverType: VectorRetrieverType, RetrieverEngineType: MilvusRetrieverEngineType}, {RetrieverType: KeywordsRetrieverType, RetrieverEngineType: MilvusRetrieverEngineType}, }, "weaviate": { {RetrieverType: KeywordsRetrieverType, RetrieverEngineType: WeaviateRetrieverEngineType}, {RetrieverType: VectorRetrieverType, RetrieverEngineType: WeaviateRetrieverEngineType}, }, "doris": { {RetrieverType: KeywordsRetrieverType, RetrieverEngineType: DorisRetrieverEngineType}, {RetrieverType: VectorRetrieverType, RetrieverEngineType: DorisRetrieverEngineType}, }, "sqlite": { {RetrieverType: KeywordsRetrieverType, RetrieverEngineType: SQLiteRetrieverEngineType}, {RetrieverType: VectorRetrieverType, RetrieverEngineType: SQLiteRetrieverEngineType}, }, "tencent_vectordb": { {RetrieverType: KeywordsRetrieverType, RetrieverEngineType: TencentVectorDBRetrieverEngineType}, {RetrieverType: VectorRetrieverType, RetrieverEngineType: TencentVectorDBRetrieverEngineType}, }, "opensearch": { {RetrieverType: KeywordsRetrieverType, RetrieverEngineType: OpenSearchRetrieverEngineType}, {RetrieverType: VectorRetrieverType, RetrieverEngineType: OpenSearchRetrieverEngineType}, }, } // GetRetrieverEngineMapping returns the retriever engine mapping // This allows other packages to access the driver capabilities func GetRetrieverEngineMapping() map[string][]RetrieverEngineParams { return retrieverEngineMapping } // GetDefaultRetrieverEngines returns the default retriever engines based on RETRIEVE_DRIVER env func GetDefaultRetrieverEngines() []RetrieverEngineParams { result := []RetrieverEngineParams{} seen := make(map[string]bool) for _, driver := range strings.Split(os.Getenv("RETRIEVE_DRIVER"), ",") { driver = strings.TrimSpace(driver) if params, ok := retrieverEngineMapping[driver]; ok { for _, p := range params { key := string(p.RetrieverType) + ":" + string(p.RetrieverEngineType) if !seen[key] { seen[key] = true result = append(result, p) } } } } return result } // Tenant represents the tenant type Tenant struct { // ID ID uint64 `yaml:"id" json:"id" gorm:"primaryKey"` // Name Name string `yaml:"name" json:"name"` // Description Description string `yaml:"description" json:"description"` // Status Status string `yaml:"status" json:"status" gorm:"default:'active'"` // Retriever engines RetrieverEngines RetrieverEngines `yaml:"retriever_engines" json:"retriever_engines" gorm:"type:json"` // Business Business string `yaml:"business" json:"business"` // Storage quota (Bytes), default is 10GB, including vector, original file, text, index, etc. StorageQuota int64 `yaml:"storage_quota" json:"storage_quota" gorm:"default:10737418240"` // Storage used (Bytes) StorageUsed int64 `yaml:"storage_used" json:"storage_used" gorm:"default:0"` // Global Context configuration for this workspace (default for all sessions) ContextConfig *ContextConfig `yaml:"context_config" json:"context_config" gorm:"type:jsonb"` // Global WebSearch configuration for this workspace WebSearchConfig *WebSearchConfig `yaml:"web_search_config" json:"web_search_config" gorm:"type:jsonb"` // Parser engine config overrides (MinerU endpoint, API key, etc.). Used when parsing documents; overrides env. ParserEngineConfig *ParserEngineConfig `yaml:"parser_engine_config" json:"parser_engine_config" gorm:"type:jsonb"` // Credentials config: third-party provider credentials (e.g. WeKnoraCloud AppID/AppSecret) Credentials *CredentialsConfig `yaml:"credentials" json:"credentials" gorm:"type:jsonb"` // Storage engine config: parameters for Local, MinIO, COS. Used for document/file storage and docreader. StorageEngineConfig *StorageEngineConfig `yaml:"storage_engine_config" json:"storage_engine_config" gorm:"type:jsonb"` // DefaultStorageBackendID is the workspace default concrete storage instance. DefaultStorageBackendID *string `yaml:"default_storage_backend_id" json:"default_storage_backend_id,omitempty" gorm:"column:default_storage_backend_id;type:varchar(36)"` // Chat history config: knowledge base configuration for indexing and searching chat messages via vector search ChatHistoryConfig *ChatHistoryConfig `yaml:"chat_history_config" json:"chat_history_config" gorm:"type:jsonb"` // Retrieval config: global search/retrieval parameters shared by knowledge search and message search RetrievalConfig *RetrievalConfig `yaml:"retrieval_config" json:"retrieval_config" gorm:"type:jsonb"` // Memory config: workspace switch for cross-session long-term memory MemoryConfig *MemoryConfig `yaml:"memory_config" json:"memory_config" gorm:"type:jsonb"` // API principal config: controls how X-API-Key requests map to terminal principals. APIPrincipalConfig *APIPrincipalConfig `yaml:"api_principal_config" json:"-" gorm:"type:jsonb"` // Creation time CreatedAt time.Time `yaml:"created_at" json:"created_at"` // Last updated time UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"` // Deletion time DeletedAt gorm.DeletedAt `yaml:"deleted_at" json:"deleted_at" gorm:"index"` } // RetrieverEngines represents the retriever engines for a tenant type RetrieverEngines struct { Engines []RetrieverEngineParams `yaml:"engines" json:"engines" gorm:"type:json"` } // GetEffectiveEngines returns the tenant's engines if configured, otherwise returns system defaults func (t *Tenant) GetEffectiveEngines() []RetrieverEngineParams { if len(t.RetrieverEngines.Engines) > 0 { return t.RetrieverEngines.Engines } return GetDefaultRetrieverEngines() } // BeforeCreate is a hook function that is called before creating a tenant func (t *Tenant) BeforeCreate(tx *gorm.DB) error { if t.RetrieverEngines.Engines == nil { t.RetrieverEngines.Engines = []RetrieverEngineParams{} } return nil } // Value implements the driver.Valuer interface, used to convert RetrieverEngines to database value func (c RetrieverEngines) Value() (driver.Value, error) { return json.Marshal(c) } // Scan implements the sql.Scanner interface, used to convert database value to RetrieverEngines. // It supports both the legacy bare-array format (e.g. [{...}, {...}]) and the current // object-wrapped format (e.g. {"engines": [{...}, {...}]}). func (c *RetrieverEngines) Scan(value interface{}) error { if value == nil { return nil } b, ok := value.([]byte) if !ok { return nil } // Try the current object format first: {"engines": [...]} if err := json.Unmarshal(b, c); err == nil { return nil } // Fallback: legacy bare-array format: [{...}, {...}] var engines []RetrieverEngineParams if err := json.Unmarshal(b, &engines); err != nil { return fmt.Errorf("retriever_engines: cannot unmarshal as object or array: %w", err) } c.Engines = engines return nil } // CredentialsConfig holds third-party provider credentials at the tenant level. // Stored as a single JSONB column; each provider is a nested object so new // providers can be added without schema changes. type CredentialsConfig struct { WeKnoraCloud *WeKnoraCloudCredentials `json:"weknoracloud,omitempty"` } // WeKnoraCloudCredentials stores WeKnoraCloud AppID and AppSecret. // AppSecret is AES-256 encrypted before persisting to database. type WeKnoraCloudCredentials struct { AppID string `json:"app_id"` AppSecret string `json:"app_secret"` } type APIPrincipalMode string const ( APIPrincipalModeTenant APIPrincipalMode = "tenant" APIPrincipalModeDirect APIPrincipalMode = "direct_header" APIPrincipalModeSignedToken APIPrincipalMode = "signed_token" ) // APIPrincipalConfig controls how tenant API-key requests map to terminal // principals. Direct header mode is low-assurance and should only be used for // trusted server-to-server calls; signed-token mode verifies the user claim. type APIPrincipalConfig struct { Mode APIPrincipalMode `json:"mode"` DirectHeaderName string `json:"direct_header_name,omitempty"` SignedTokenHeaderName string `json:"signed_token_header_name,omitempty"` // RequireDirectHeader, when true in direct_header mode, rejects API-key // requests that omit the configured user-id header instead of falling // back to the tenant-level principal. RequireDirectHeader bool `json:"require_direct_header,omitempty"` HMACSecret string `json:"hmac_secret,omitempty"` } func (c *APIPrincipalConfig) Value() (driver.Value, error) { if c == nil { return nil, nil } cp := *c if cp.HMACSecret != "" { if key := utils.GetAESKey(); key != nil { if encrypted, err := utils.EncryptAESGCM(cp.HMACSecret, key); err == nil { cp.HMACSecret = encrypted } } } return json.Marshal(&cp) } func (c *APIPrincipalConfig) Scan(value interface{}) error { if value == nil { return nil } b, ok := value.([]byte) if !ok { return nil } if err := json.Unmarshal(b, c); err != nil { return err } if plain, ok := utils.DecryptStoredSecretLenient(c.HMACSecret); ok { c.HMACSecret = plain } else { log.Printf("[crypto] tenant api_principal_config.hmac_secret: decrypt failed (SYSTEM_AES_KEY missing/rotated?), treating as unconfigured") c.HMACSecret = "" } return nil } // GetWeKnoraCloud returns the WeKnoraCloud credentials, or nil if not configured. func (c *CredentialsConfig) GetWeKnoraCloud() *WeKnoraCloudCredentials { if c == nil || c.WeKnoraCloud == nil { return nil } if c.WeKnoraCloud.AppID == "" || c.WeKnoraCloud.AppSecret == "" { return nil } return c.WeKnoraCloud } // Value implements the driver.Valuer interface for CredentialsConfig func (c *CredentialsConfig) Value() (driver.Value, error) { if c == nil { return nil, nil } cp := *c if cp.WeKnoraCloud != nil && cp.WeKnoraCloud.AppSecret != "" { if key := utils.GetAESKey(); key != nil { if encrypted, err := utils.EncryptAESGCM(cp.WeKnoraCloud.AppSecret, key); err == nil { cp.WeKnoraCloud = &WeKnoraCloudCredentials{AppID: cp.WeKnoraCloud.AppID, AppSecret: encrypted} } } } return json.Marshal(cp) } // Scan implements the sql.Scanner interface for CredentialsConfig func (c *CredentialsConfig) Scan(value interface{}) error { if value == nil { return nil } b, ok := value.([]byte) if !ok { return nil } if err := json.Unmarshal(b, c); err != nil { return err } if c.WeKnoraCloud != nil { if plain, ok := utils.DecryptStoredSecretLenient(c.WeKnoraCloud.AppSecret); ok { c.WeKnoraCloud.AppSecret = plain } else { log.Printf("[crypto] tenant credentials we_knora_cloud.app_secret: decrypt failed (SYSTEM_AES_KEY missing/rotated?), treating as unconfigured") c.WeKnoraCloud.AppSecret = "" } } return nil } // ParserEngineConfig holds tenant-level overrides for document parser engines (e.g. MinerU endpoint, API key). // These values take precedence over environment variables when parsing documents. type ParserEngineConfig struct { // ChatParserEngineRules selects parser engines for session-scoped chat // documents. Knowledge bases keep their own rules in ChunkingConfig. ChatParserEngineRules []ParserEngineRule `json:"chat_parser_engine_rules,omitempty"` MinerUEndpoint string `json:"mineru_endpoint"` // MinerU 自建服务端点 MinerUAPIKey string `json:"mineru_api_key"` // MinerU 云 API Key // MinerU 自建解析参数 MinerUModel string `json:"mineru_model,omitempty"` // backend: pipeline, vlm-*, hybrid-* MinerUVLMServerURL string `json:"mineru_vlm_server_url,omitempty"` // vLLM 服务器地址 (vlm-http-client / hybrid-http-client) MinerUEnableFormula *bool `json:"mineru_enable_formula,omitempty"` MinerUEnableTable *bool `json:"mineru_enable_table,omitempty"` MinerUParseMethod string `json:"mineru_parse_method,omitempty"` // MinerUEnableOCR is retained for compatibility with configurations saved // before parse_method supported auto/ocr/txt. MinerUEnableOCR *bool `json:"mineru_enable_ocr,omitempty"` MinerULanguage string `json:"mineru_language,omitempty"` // MinerU 云 API 解析参数 MinerUCloudModel string `json:"mineru_cloud_model,omitempty"` // model_version: pipeline, vlm, MinerU-HTML MinerUCloudEnableFormula *bool `json:"mineru_cloud_enable_formula,omitempty"` MinerUCloudEnableTable *bool `json:"mineru_cloud_enable_table,omitempty"` MinerUCloudEnableOCR *bool `json:"mineru_cloud_enable_ocr,omitempty"` MinerUCloudLanguage string `json:"mineru_cloud_language,omitempty"` // OpenDataLoader PDF (docreader engine); hybrid requires opendataloader-pdf-hybrid service. ODLHybrid string `json:"odl_hybrid,omitempty"` // off (default), docling-fast, hancom-ai ODLHybridURL string `json:"odl_hybrid_url,omitempty"` // e.g. http://odl-hybrid:5002 ODLHybridMode string `json:"odl_hybrid_mode,omitempty"` // auto, full ODLHybridFallback *bool `json:"odl_hybrid_fallback,omitempty"` ODLMarkdownWithHTML *bool `json:"odl_markdown_with_html,omitempty"` // PaddleOCR-VL self-hosted pipeline service (full /layout-parsing API). PaddleOCRVLEndpoint string `json:"paddleocr_vl_endpoint,omitempty"` // e.g. http://paddleocr-vl:8080 PaddleOCRVLUseSealRecognition *bool `json:"paddleocr_vl_use_seal_recognition,omitempty"` PaddleOCRVLUseChartRecognition *bool `json:"paddleocr_vl_use_chart_recognition,omitempty"` // PaddleOCR-VL AI Studio cloud API. PaddleOCRVLCloudToken string `json:"paddleocr_vl_cloud_token,omitempty"` PaddleOCRVLCloudModel string `json:"paddleocr_vl_cloud_model,omitempty"` // e.g. PaddleOCR-VL-1.6 PaddleOCRVLCloudUseSealRecognition *bool `json:"paddleocr_vl_cloud_use_seal_recognition,omitempty"` PaddleOCRVLCloudUseChartRecognition *bool `json:"paddleocr_vl_cloud_use_chart_recognition,omitempty"` } const ( MinerUParseMethodAuto = "auto" MinerUParseMethodOCR = "ocr" MinerUParseMethodText = "txt" ) // ResolveMinerUParseMethod normalizes the explicit MinerU parse method and // maps the legacy OCR toggle to the closest safe behavior. The old enabled // value maps to auto instead of ocr so digital PDFs keep their native text // layer while scanned PDFs are still detected and OCRed by MinerU. func ResolveMinerUParseMethod(method string, legacyOCREnabled *bool) string { switch strings.ToLower(strings.TrimSpace(method)) { case MinerUParseMethodAuto: return MinerUParseMethodAuto case MinerUParseMethodOCR: return MinerUParseMethodOCR case MinerUParseMethodText: return MinerUParseMethodText } if legacyOCREnabled != nil && !*legacyOCREnabled { return MinerUParseMethodText } return MinerUParseMethodAuto } func (c *ParserEngineConfig) ResolveChatParserEngine(fileType string) string { if c != nil { normalized := normalizeParserFileType(fileType) for _, rule := range c.ChatParserEngineRules { for _, candidate := range rule.FileTypes { if normalizeParserFileType(candidate) == normalized { return strings.TrimSpace(rule.Engine) } } } } return DefaultParserEngine(fileType) } // ToOverridesMap returns a map suitable for ParserEngineOverrides in parse requests. // Keys are snake_case (mineru_endpoint, mineru_api_key, etc.). func (c *ParserEngineConfig) ToOverridesMap() map[string]string { if c == nil { return nil } m := make(map[string]string) if c.MinerUEndpoint != "" { m["mineru_endpoint"] = c.MinerUEndpoint } if c.MinerUAPIKey != "" { m["mineru_api_key"] = c.MinerUAPIKey } if c.MinerUModel != "" { m["mineru_model"] = c.MinerUModel } if c.MinerUVLMServerURL != "" { m["mineru_vlm_server_url"] = c.MinerUVLMServerURL } if c.MinerUEnableFormula != nil { m["mineru_enable_formula"] = fmt.Sprintf("%v", *c.MinerUEnableFormula) } if c.MinerUEnableTable != nil { m["mineru_enable_table"] = fmt.Sprintf("%v", *c.MinerUEnableTable) } if c.MinerUParseMethod != "" || c.MinerUEnableOCR != nil { m["mineru_parse_method"] = ResolveMinerUParseMethod(c.MinerUParseMethod, c.MinerUEnableOCR) } if c.MinerUEnableOCR != nil { m["mineru_enable_ocr"] = fmt.Sprintf("%v", *c.MinerUEnableOCR) } if c.MinerULanguage != "" { m["mineru_language"] = c.MinerULanguage } if c.MinerUCloudModel != "" { m["mineru_cloud_model"] = c.MinerUCloudModel } if c.MinerUCloudEnableFormula != nil { m["mineru_cloud_enable_formula"] = fmt.Sprintf("%v", *c.MinerUCloudEnableFormula) } if c.MinerUCloudEnableTable != nil { m["mineru_cloud_enable_table"] = fmt.Sprintf("%v", *c.MinerUCloudEnableTable) } if c.MinerUCloudEnableOCR != nil { m["mineru_cloud_enable_ocr"] = fmt.Sprintf("%v", *c.MinerUCloudEnableOCR) } if c.MinerUCloudLanguage != "" { m["mineru_cloud_language"] = c.MinerUCloudLanguage } if c.ODLHybrid != "" { m["odl_hybrid"] = c.ODLHybrid } if c.ODLHybridURL != "" { m["odl_hybrid_url"] = c.ODLHybridURL } if c.ODLHybridMode != "" { m["odl_hybrid_mode"] = c.ODLHybridMode } if c.ODLHybridFallback != nil { m["odl_hybrid_fallback"] = fmt.Sprintf("%v", *c.ODLHybridFallback) } if c.ODLMarkdownWithHTML != nil { m["odl_markdown_with_html"] = fmt.Sprintf("%v", *c.ODLMarkdownWithHTML) } if c.PaddleOCRVLEndpoint != "" { m["paddleocr_vl_endpoint"] = c.PaddleOCRVLEndpoint } if c.PaddleOCRVLUseSealRecognition != nil { m["paddleocr_vl_use_seal_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLUseSealRecognition) } if c.PaddleOCRVLUseChartRecognition != nil { m["paddleocr_vl_use_chart_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLUseChartRecognition) } if c.PaddleOCRVLCloudToken != "" { m["paddleocr_vl_cloud_token"] = c.PaddleOCRVLCloudToken } if c.PaddleOCRVLCloudModel != "" { m["paddleocr_vl_cloud_model"] = c.PaddleOCRVLCloudModel } if c.PaddleOCRVLCloudUseSealRecognition != nil { m["paddleocr_vl_cloud_use_seal_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLCloudUseSealRecognition) } if c.PaddleOCRVLCloudUseChartRecognition != nil { m["paddleocr_vl_cloud_use_chart_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLCloudUseChartRecognition) } if len(m) == 0 { return nil } return m } // Value implements the driver.Valuer interface for ParserEngineConfig func (c *ParserEngineConfig) Value() (driver.Value, error) { if c == nil { return nil, nil } return json.Marshal(c) } // Scan implements the sql.Scanner interface for ParserEngineConfig func (c *ParserEngineConfig) Scan(value interface{}) error { if value == nil { return nil } b, ok := value.([]byte) if !ok { return nil } return json.Unmarshal(b, c) } // StorageEngineConfig holds tenant-level storage engine parameters for Local, MinIO, COS, TOS, S3, OSS, KS3, and OBS. // Knowledge bases select which provider to use; parameters are read from here. type StorageEngineConfig struct { DefaultProvider string `json:"default_provider"` // "local", "minio", "cos", "tos", "s3", "oss", "ks3", "obs" Local *LocalEngineConfig `json:"local,omitempty"` MinIO *MinIOEngineConfig `json:"minio,omitempty"` COS *COSEngineConfig `json:"cos,omitempty"` TOS *TOSEngineConfig `json:"tos,omitempty"` S3 *S3EngineConfig `json:"s3,omitempty"` OSS *OSSEngineConfig `json:"oss,omitempty"` KS3 *KS3EngineConfig `json:"ks3,omitempty"` OBS *OBSEngineConfig `json:"obs,omitempty"` } // LocalEngineConfig is for local file system storage (single-machine deployment only). type LocalEngineConfig struct { PathPrefix string `json:"path_prefix"` } // MinIOEngineConfig is for MinIO/S3-compatible object storage. // Mode "docker" uses env vars for endpoint/credentials; "remote" uses the fields below. type MinIOEngineConfig struct { Mode string `json:"mode"` // "docker" or "remote" Endpoint string `json:"endpoint"` AccessKeyID string `json:"access_key_id"` SecretAccessKey string `json:"secret_access_key"` BucketName string `json:"bucket_name"` UseSSL bool `json:"use_ssl"` PathPrefix string `json:"path_prefix"` } // COSEngineConfig is for Tencent Cloud COS. type COSEngineConfig struct { SecretID string `json:"secret_id"` SecretKey string `json:"secret_key"` Region string `json:"region"` BucketName string `json:"bucket_name"` AppID string `json:"app_id"` PathPrefix string `json:"path_prefix"` TempBucketName string `json:"temp_bucket_name"` TempRegion string `json:"temp_region"` } // TOSEngineConfig is for Volcengine TOS (火山引擎对象存储). type TOSEngineConfig struct { Endpoint string `json:"endpoint"` Region string `json:"region"` AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` BucketName string `json:"bucket_name"` PathPrefix string `json:"path_prefix"` TempBucketName string `json:"temp_bucket_name"` TempRegion string `json:"temp_region"` } // S3EngineConfig is for AWS S3 and S3-compatible object storage. type S3EngineConfig struct { Endpoint string `json:"endpoint"` Region string `json:"region"` AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` BucketName string `json:"bucket_name"` PathPrefix string `json:"path_prefix"` UseSSL bool `json:"use_ssl"` ForcePathStyle bool `json:"force_path_style"` } // OSSEngineConfig is for Alibaba Cloud OSS (对象存储服务). type OSSEngineConfig struct { Endpoint string `json:"endpoint"` Region string `json:"region"` AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` BucketName string `json:"bucket_name"` PathPrefix string `json:"path_prefix"` UseTempBucket bool `json:"use_temp_bucket"` TempBucketName string `json:"temp_bucket_name"` TempRegion string `json:"temp_region"` } // KS3EngineConfig is for Kingsoft Cloud KS3 object storage. type KS3EngineConfig struct { Endpoint string `json:"endpoint"` Region string `json:"region"` AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` BucketName string `json:"bucket_name"` PathPrefix string `json:"path_prefix"` } // OBSEngineConfig is for Huawei Cloud OBS (对象存储服务). type OBSEngineConfig struct { Endpoint string `json:"endpoint"` Region string `json:"region"` AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` BucketName string `json:"bucket_name"` PathPrefix string `json:"path_prefix"` UseSSL bool `json:"use_ssl"` } // Value implements the driver.Valuer interface for StorageEngineConfig func (c *StorageEngineConfig) Value() (driver.Value, error) { if c == nil { return nil, nil } return json.Marshal(c) } // Scan implements the sql.Scanner interface for StorageEngineConfig func (c *StorageEngineConfig) Scan(value interface{}) error { if value == nil { return nil } b, ok := value.([]byte) if !ok { return nil } return json.Unmarshal(b, c) } // TenantSandboxConfig is one named sandbox backend a workspace maintains. // // It is self-contained: provider fields are not inherited from process // environment. Leaving a required provider field empty is rejected on save. type TenantSandboxConfig struct { // SandboxType is cube, e2b, or docker; disabled is the hidden policy row. SandboxType string `json:"sandbox_type,omitempty"` // ── 通用配置(跨后端生效)────────────────────────────────── // DefaultTimeoutSec is the per-execution timeout in seconds. 0 uses the // program's built-in default. DefaultTimeoutSec int `json:"default_timeout_sec,omitempty"` // AllowPrivateEndpoints permits this workspace config to reach RFC1918 or // loopback cluster endpoints. Link-local/cloud-metadata addresses remain // blocked. It is explicit in the UI instead of hidden in process env. AllowPrivateEndpoints bool `json:"allow_private_endpoints,omitempty"` // EnvVars are additional environment variables injected into every // sandbox created for this tenant. 🔒 Values are encrypted at rest. // These become visible to all scripts running in the tenant's // sandboxes — do not place secrets here that scripts must not access. EnvVars map[string]string `json:"env_vars,omitempty"` // VolumeMount configures an optional shared volume mounted into every // sandbox created for this tenant. Currently used for tenant-installed // skills, but the configuration itself is skill-agnostic and can serve // any volume-mount use case (shared datasets, pre-installed toolchains, // etc.). VolumeMount *VolumeMountConfig `json:"volume_mount,omitempty"` // SkillImage points at the snapshot that carries this config's installed // skills. Empty means "use the base template". Written only by the skill // install/remove path: MergeSandboxConfigForUpdate ignores client values // so a settings-form save cannot wipe or plant the pointer. SkillImage *SkillImageConfig `json:"skill_image,omitempty"` // SkillRollout decides whether sessions that already hold a sandbox of // this config rebuild after a skill install or removal. Empty and // SkillRolloutNextTurn rebuild on the next chat turn. SkillRolloutNewSession // leaves those sandboxes on the previous image; only sessions that start // afterwards boot the new snapshot. SkillRollout string `json:"skill_rollout,omitempty"` // Network is the outbound/inbound network policy applied to every sandbox // created from this config — chat sessions, skill installs and deep // connectivity probes alike. nil and the zero value mean the same thing: // outbound egress allowed, inbound public access closed. Network *SandboxNetworkPolicy `json:"network,omitempty"` // ── 后端专属配置(同一时刻只有一个生效,由 SandboxType 决定)─── Cube *CubeSandboxConfig `json:"cube,omitempty"` E2B *E2BSandboxConfig `json:"e2b,omitempty"` Docker *DockerSandboxConfig `json:"docker,omitempty"` } // CubeSandboxConfig addresses one CubeSandbox deployment. APIURL, ProxyURL, // SandboxDomain and TemplateID are all required; APIKey is optional because the // common single-node setup runs unauthenticated. type CubeSandboxConfig struct { APIURL string `json:"api_url,omitempty"` ProxyURL string `json:"proxy_url,omitempty"` SandboxDomain string `json:"sandbox_domain,omitempty"` APIKey string `json:"api_key,omitempty"` // 加密 TemplateID string `json:"template_id,omitempty"` // HTTPTimeoutSec bounds each HTTP call to the sandbox control plane. // 0 means use the built-in default (30s), never the deployment's value. HTTPTimeoutSec int `json:"http_timeout_sec,omitempty"` CubeSandboxTTLSeconds int `json:"cube_sandbox_ttl_seconds,omitempty"` // DNSServers are Cube template nameserver IPs. Empty uses Cubelet's default. DNSServers []string `json:"dns_servers,omitempty"` } // E2BSandboxConfig addresses one E2B-protocol control plane: E2B Cloud, a // self-hosted E2B Infrastructure, or any E2B-compatible implementation // (CubeSandbox, Agent-Sandbox, …). APIKey and TemplateID are required; APIURL // and SandboxDomain are optional because go-e2b resolves both on its own when // they are empty. type E2BSandboxConfig struct { APIURL string `json:"api_url,omitempty"` SandboxDomain string `json:"sandbox_domain,omitempty"` APIKey string `json:"api_key,omitempty"` // 加密 TemplateID string `json:"template_id,omitempty"` // ProxyURL is the data-plane gateway that fronts envd. E2B Cloud resolves // "-." through public DNS and TLS, so it // needs no value here. Self-hosted E2B-compatible control planes usually // serve every sandbox from one gateway address and expect the sandbox // authority in the Host header; setting this makes WeKnora dial the // gateway directly instead of requiring wildcard DNS and a certificate // for the sandbox domain. An "http://" gateway also downgrades the // data-plane scheme, which the E2B SDK otherwise pins to https. ProxyURL string `json:"proxy_url,omitempty"` // HTTPTimeoutSec bounds each HTTP call to the sandbox control plane. // 0 means use the built-in default (30s), never the deployment's value. HTTPTimeoutSec int `json:"http_timeout_sec,omitempty"` E2BSandboxTTLSeconds int `json:"e2b_sandbox_ttl_seconds,omitempty"` } // DockerSandboxConfig addresses one Docker daemon. Image is required and plays // the role a template ID plays for the MicroVM backends: every session // container is created from it. // // The daemon endpoint is deliberately the only connection field, and TLS // material is referenced by path rather than stored here. Client certificates // are deployment infrastructure mounted onto the WeKnora host; keeping them // out of the database keeps them out of backups, exports and API responses. type DockerSandboxConfig struct { Image string `json:"image,omitempty"` // Host is the daemon endpoint in DOCKER_HOST form. Empty means the local // unix socket. Host string `json:"host,omitempty"` // TLSCertPath is a directory on the WeKnora host containing ca.pem, // cert.pem and key.pem. Required when Host is a TCP endpoint. TLSCertPath string `json:"tls_cert_path,omitempty"` // CPULimit is the number of CPU cores one sandbox may use. 0 uses the // built-in default. CPULimit float64 `json:"cpu_limit,omitempty"` // MemoryLimitMB caps one sandbox's memory. 0 uses the built-in default. MemoryLimitMB int `json:"memory_limit_mb,omitempty"` // PidsLimit caps how many processes one sandbox may run. 0 uses the // built-in default. PidsLimit int `json:"pids_limit,omitempty"` // NetworkMode is the Docker network sandboxes join: "bridge" (default) or // "none" for no egress. Nothing else is accepted — host and container: // modes share another namespace outright, and a named network is usually // the deployment's own compose network, which would put the sandbox next // to Postgres and Redis. NetworkMode string `json:"network_mode,omitempty"` // Runtime selects an alternative OCI runtime such as "runsc" (gVisor). // Empty uses the daemon default. Runtime string `json:"runtime,omitempty"` // IdleTTLSeconds is how long a session container may go unused before it // is reclaimed. The daemon has no idle timeout of its own, so this is what // stops an abandoned session from pinning host memory indefinitely. IdleTTLSeconds int `json:"idle_ttl_seconds,omitempty"` // HTTPTimeoutSec bounds each Engine API call. 0 uses the built-in default. HTTPTimeoutSec int `json:"http_timeout_sec,omitempty"` } // VolumeMountConfig configures a shared volume mount into every sandbox // created for this tenant. Currently implemented for E2B volumes (used to // share tenant-installed skills across sandbox sessions), but the schema is // intentionally skill-agnostic so it can serve other volume-based use cases // in the future. type VolumeMountConfig struct { // Enabled toggles the volume mount for this tenant. Enabled bool `json:"enabled"` // MountPath is the sandbox-internal path where the volume is mounted. // Default: /weknora/tenant/skills (customizable per use case). MountPath string `json:"mount_path,omitempty"` // Provider identifies the volume backend. Currently "e2b" or "cube". Provider string `json:"provider,omitempty"` // VolumeID is the provider-specific volume identifier, populated after // EnsureVolume / CreateVolume succeeds. VolumeID string `json:"volume_id,omitempty"` // VolumeName is the human-readable volume name, e.g. // "weknora-tenant--skills". VolumeName string `json:"volume_name,omitempty"` // VolumeOwnerFingerprint = sha256(provider + APIKey + APIURL). // Used to detect when the tenant switched to a different backend or // API key, at which point the volume is no longer reachable and must // be recreated. VolumeOwnerFingerprint string `json:"volume_owner_fingerprint,omitempty"` } const ( // SkillRolloutNextTurn rebuilds a live session's sandbox on its next chat // turn after the skill image changes. This is the default. SkillRolloutNextTurn = "next_turn" // SkillRolloutNewSession leaves live sandboxes on the previous image. // Only a session that starts after the install or removal boots the new one. SkillRolloutNewSession = "new_session" ) // RebuildsExistingOnSkillChange reports whether an install or removal should // mark already-bound sandboxes stale. Unknown values fail toward rebuilding // so a corrupted row cannot pin every session on a retired image. func (c *TenantSandboxConfig) RebuildsExistingOnSkillChange() bool { if c == nil { return true } return strings.TrimSpace(c.SkillRollout) != SkillRolloutNewSession } // SkillImageConfig is the pointer to the snapshot that carries the skills // installed on this sandbox config. Snapshot IDs double as template IDs, so // nothing else is needed to boot sessions from it. // // It holds no secrets, so it is not encrypted by TenantSandboxConfig.Value. type SkillImageConfig struct { // SnapshotID is the currently effective snapshot; empty = base template. SnapshotID string `json:"snapshot_id,omitempty"` // Generation increments on every successful install/remove, for naming // and troubleshooting. Generation int `json:"generation,omitempty"` // BuiltAt records when this generation was produced. BuiltAt time.Time `json:"built_at,omitempty"` // BaseTemplateID is the template this chain was originally built from; // the rebuild path starts over from it. BaseTemplateID string `json:"base_template_id,omitempty"` // OwnerFingerprint identifies the provider account that owns the snapshot. // Snapshots are invisible across accounts, so a mismatch means "fall back // to the base template" rather than "fail". OwnerFingerprint string `json:"owner_fingerprint,omitempty"` } // Value implements the driver.Valuer interface. Every secret-bearing field // (Cube.APIKey, E2B.APIKey, all EnvVars values, and injected header values) is // encrypted before persisting. EnvVars are included because environment // variables routinely carry credentials, and their values are handed to tenant // scripts verbatim. The receiver is never mutated: nested structs and the map // are copied first. func (c *TenantSandboxConfig) Value() (driver.Value, error) { if c == nil { return nil, nil } cp := *c key := utils.GetAESKey() encrypt := func(plain string) string { if plain == "" || key == nil { return plain } encrypted, err := utils.EncryptAESGCM(plain, key) if err != nil { return plain } return encrypted } if c.Cube != nil { cube := *c.Cube cube.APIKey = encrypt(cube.APIKey) cp.Cube = &cube } if c.E2B != nil { e2b := *c.E2B e2b.APIKey = encrypt(e2b.APIKey) cp.E2B = &e2b } // Keys stay readable so operators can still see which variables are set. if len(c.EnvVars) > 0 { envVars := make(map[string]string, len(c.EnvVars)) for name, value := range c.EnvVars { envVars[name] = encrypt(value) } cp.EnvVars = envVars } // Injected headers are the sandbox-side way to call an API without the // credential ever entering the sandbox, so their values are secrets. if c.Network != nil { cp.Network = c.Network.CloneWithSecrets(encrypt) } return json.Marshal(&cp) } // Scan implements the sql.Scanner interface. Secrets that cannot be decrypted // (missing or rotated SYSTEM_AES_KEY) are blanked and logged rather than // failing the whole load, matching ModelParameters.Scan — a tenant must stay // listable even if its sandbox credentials became unreadable. func (c *TenantSandboxConfig) Scan(value interface{}) error { if value == nil { return nil } b, ok := value.([]byte) if !ok { return nil } if err := json.Unmarshal(b, c); err != nil { return err } decrypt := func(stored, label string) string { if stored == "" { return "" } if plain, ok := utils.DecryptStoredSecretLenient(stored); ok { return plain } log.Printf("[crypto] tenant_sandbox_config.%s: decrypt failed "+ "(SYSTEM_AES_KEY missing/rotated?), treating as unconfigured", label) return "" } if c.Cube != nil { c.Cube.APIKey = decrypt(c.Cube.APIKey, "cube.api_key") } if c.E2B != nil { c.E2B.APIKey = decrypt(c.E2B.APIKey, "e2b.api_key") } for name, stored := range c.EnvVars { c.EnvVars[name] = decrypt(stored, "env_vars."+name) } if c.Network != nil { // CloneWithSecrets has no rule/header context, so rotated-key failures // share one label rather than identifying the individual credential. c.Network = c.Network.CloneWithSecrets(func(stored string) string { return decrypt(stored, "network.injected_header") }) } return nil }