fix(skills): make open-skills sync opt-in and configurable

This commit is contained in:
Chummy 2026-02-20 16:34:43 +08:00
parent d0674c4b98
commit a2e9c0d1e1
10 changed files with 312 additions and 29 deletions

View file

@ -887,6 +887,18 @@ See [aieos.org](https://aieos.org) for the full schema and live examples.
For a task-oriented command guide, see [`docs/commands-reference.md`](docs/commands-reference.md). For a task-oriented command guide, see [`docs/commands-reference.md`](docs/commands-reference.md).
### Open-Skills Opt-In
Community `open-skills` sync is disabled by default. Enable it explicitly in `config.toml`:
```toml
[skills]
open_skills_enabled = true
# open_skills_dir = "/path/to/open-skills" # optional
```
You can also override at runtime with `ZEROCLAW_OPEN_SKILLS_ENABLED` and `ZEROCLAW_OPEN_SKILLS_DIR`.
## Development ## Development
```bash ```bash

View file

@ -114,6 +114,21 @@ Notes:
- `reasoning_enabled = true` explicitly requests reasoning for supported providers (`think: true` on `ollama`). - `reasoning_enabled = true` explicitly requests reasoning for supported providers (`think: true` on `ollama`).
- Unset keeps provider defaults. - Unset keeps provider defaults.
## `[skills]`
| Key | Default | Purpose |
|---|---|---|
| `open_skills_enabled` | `false` | Opt-in loading/sync of community `open-skills` repository |
| `open_skills_dir` | unset | Optional local path for `open-skills` (defaults to `$HOME/open-skills` when enabled) |
Notes:
- Security-first default: ZeroClaw does **not** clone or sync `open-skills` unless `open_skills_enabled = true`.
- Environment overrides:
- `ZEROCLAW_OPEN_SKILLS_ENABLED` accepts `1/0`, `true/false`, `yes/no`, `on/off`.
- `ZEROCLAW_OPEN_SKILLS_DIR` overrides the repository path when non-empty.
- Precedence for enable flag: `ZEROCLAW_OPEN_SKILLS_ENABLED``skills.open_skills_enabled` in `config.toml` → default `false`.
## `[composio]` ## `[composio]`
| Key | Default | Purpose | | Key | Default | Purpose |

View file

@ -308,7 +308,10 @@ impl Agent {
.classification_config(config.query_classification.clone()) .classification_config(config.query_classification.clone())
.available_hints(available_hints) .available_hints(available_hints)
.identity_config(config.identity.clone()) .identity_config(config.identity.clone())
.skills(crate::skills::load_skills(&config.workspace_dir)) .skills(crate::skills::load_skills_with_config(
&config.workspace_dir,
config,
))
.auto_save(config.memory.auto_save) .auto_save(config.memory.auto_save)
.build() .build()
} }

View file

@ -1348,7 +1348,7 @@ pub async fn run(
.collect(); .collect();
// ── Build system prompt from workspace MD files (OpenClaw framework) ── // ── Build system prompt from workspace MD files (OpenClaw framework) ──
let skills = crate::skills::load_skills(&config.workspace_dir); let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
let mut tool_descs: Vec<(&str, &str)> = vec![ let mut tool_descs: Vec<(&str, &str)> = vec![
( (
"shell", "shell",
@ -1778,7 +1778,7 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
.map(|b| b.board.clone()) .map(|b| b.board.clone())
.collect(); .collect();
let skills = crate::skills::load_skills(&config.workspace_dir); let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
let mut tool_descs: Vec<(&str, &str)> = vec![ let mut tool_descs: Vec<(&str, &str)> = vec![
("shell", "Execute terminal commands."), ("shell", "Execute terminal commands."),
("file_read", "Read file contents."), ("file_read", "Read file contents."),

View file

@ -2302,7 +2302,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
&config, &config,
)); ));
let skills = crate::skills::load_skills(&workspace); let skills = crate::skills::load_skills_with_config(&workspace, &config);
// Collect tool descriptions for the prompt // Collect tool descriptions for the prompt
let mut tool_descs: Vec<(&str, &str)> = vec![ let mut tool_descs: Vec<(&str, &str)> = vec![

View file

@ -11,9 +11,9 @@ pub use schema::{
IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, MultimodalConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, MultimodalConfig,
ObservabilityConfig, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, ObservabilityConfig, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope,
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig,
StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig, SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode,
TunnelConfig, WebSearchConfig, WebhookConfig, TelegramConfig, TunnelConfig, WebSearchConfig, WebhookConfig,
}; };
#[cfg(test)] #[cfg(test)]

View file

@ -94,6 +94,10 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub agent: AgentConfig, pub agent: AgentConfig,
/// Skills loading and community repository behavior (`[skills]`).
#[serde(default)]
pub skills: SkillsConfig,
/// Model routing rules — route `hint:<name>` to specific provider+model combos. /// Model routing rules — route `hint:<name>` to specific provider+model combos.
#[serde(default)] #[serde(default)]
pub model_routes: Vec<ModelRouteConfig>, pub model_routes: Vec<ModelRouteConfig>,
@ -325,6 +329,28 @@ impl Default for AgentConfig {
} }
} }
/// Skills loading configuration (`[skills]` section).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SkillsConfig {
/// Enable loading and syncing the community open-skills repository.
/// Default: `false` (opt-in).
#[serde(default)]
pub open_skills_enabled: bool,
/// Optional path to a local open-skills repository.
/// If unset, defaults to `$HOME/open-skills` when enabled.
#[serde(default)]
pub open_skills_dir: Option<String>,
}
impl Default for SkillsConfig {
fn default() -> Self {
Self {
open_skills_enabled: false,
open_skills_dir: None,
}
}
}
/// Multimodal (image) handling configuration (`[multimodal]` section). /// Multimodal (image) handling configuration (`[multimodal]` section).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MultimodalConfig { pub struct MultimodalConfig {
@ -2742,6 +2768,7 @@ impl Default for Config {
reliability: ReliabilityConfig::default(), reliability: ReliabilityConfig::default(),
scheduler: SchedulerConfig::default(), scheduler: SchedulerConfig::default(),
agent: AgentConfig::default(), agent: AgentConfig::default(),
skills: SkillsConfig::default(),
model_routes: Vec::new(), model_routes: Vec::new(),
embedding_routes: Vec::new(), embedding_routes: Vec::new(),
heartbeat: HeartbeatConfig::default(), heartbeat: HeartbeatConfig::default(),
@ -3235,6 +3262,27 @@ impl Config {
} }
} }
// Open-skills opt-in flag: ZEROCLAW_OPEN_SKILLS_ENABLED
if let Ok(flag) = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED") {
if !flag.trim().is_empty() {
match flag.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => self.skills.open_skills_enabled = true,
"0" | "false" | "no" | "off" => self.skills.open_skills_enabled = false,
_ => tracing::warn!(
"Ignoring invalid ZEROCLAW_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)"
),
}
}
}
// Open-skills directory override: ZEROCLAW_OPEN_SKILLS_DIR
if let Ok(path) = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR") {
let trimmed = path.trim();
if !trimmed.is_empty() {
self.skills.open_skills_dir = Some(trimmed.to_string());
}
}
// Gateway port: ZEROCLAW_GATEWAY_PORT or PORT // Gateway port: ZEROCLAW_GATEWAY_PORT or PORT
if let Ok(port_str) = if let Ok(port_str) =
std::env::var("ZEROCLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT")) std::env::var("ZEROCLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
@ -3574,6 +3622,7 @@ mod tests {
assert!(c.default_model.as_deref().unwrap().contains("claude")); assert!(c.default_model.as_deref().unwrap().contains("claude"));
assert!((c.default_temperature - 0.7).abs() < f64::EPSILON); assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
assert!(c.api_key.is_none()); assert!(c.api_key.is_none());
assert!(!c.skills.open_skills_enabled);
assert!(c.workspace_dir.to_string_lossy().contains("workspace")); assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
assert!(c.config_path.to_string_lossy().contains("config.toml")); assert!(c.config_path.to_string_lossy().contains("config.toml"));
} }
@ -3596,6 +3645,7 @@ mod tests {
.expect("schema should expose top-level properties"); .expect("schema should expose top-level properties");
assert!(properties.contains_key("default_provider")); assert!(properties.contains_key("default_provider"));
assert!(properties.contains_key("skills"));
assert!(properties.contains_key("gateway")); assert!(properties.contains_key("gateway"));
assert!(properties.contains_key("channels_config")); assert!(properties.contains_key("channels_config"));
assert!(!properties.contains_key("workspace_dir")); assert!(!properties.contains_key("workspace_dir"));
@ -3745,6 +3795,7 @@ default_temperature = 0.7
}, },
reliability: ReliabilityConfig::default(), reliability: ReliabilityConfig::default(),
scheduler: SchedulerConfig::default(), scheduler: SchedulerConfig::default(),
skills: SkillsConfig::default(),
model_routes: Vec::new(), model_routes: Vec::new(),
embedding_routes: Vec::new(), embedding_routes: Vec::new(),
query_classification: QueryClassificationConfig::default(), query_classification: QueryClassificationConfig::default(),
@ -3941,6 +3992,7 @@ tool_dispatcher = "xml"
runtime: RuntimeConfig::default(), runtime: RuntimeConfig::default(),
reliability: ReliabilityConfig::default(), reliability: ReliabilityConfig::default(),
scheduler: SchedulerConfig::default(), scheduler: SchedulerConfig::default(),
skills: SkillsConfig::default(),
model_routes: Vec::new(), model_routes: Vec::new(),
embedding_routes: Vec::new(), embedding_routes: Vec::new(),
query_classification: QueryClassificationConfig::default(), query_classification: QueryClassificationConfig::default(),
@ -4900,6 +4952,40 @@ default_temperature = 0.7
std::env::remove_var("ZEROCLAW_PROVIDER"); std::env::remove_var("ZEROCLAW_PROVIDER");
} }
#[test]
async fn env_override_open_skills_enabled_and_dir() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
assert!(!config.skills.open_skills_enabled);
assert!(config.skills.open_skills_dir.is_none());
std::env::set_var("ZEROCLAW_OPEN_SKILLS_ENABLED", "true");
std::env::set_var("ZEROCLAW_OPEN_SKILLS_DIR", "/tmp/open-skills");
config.apply_env_overrides();
assert!(config.skills.open_skills_enabled);
assert_eq!(
config.skills.open_skills_dir.as_deref(),
Some("/tmp/open-skills")
);
std::env::remove_var("ZEROCLAW_OPEN_SKILLS_ENABLED");
std::env::remove_var("ZEROCLAW_OPEN_SKILLS_DIR");
}
#[test]
async fn env_override_open_skills_enabled_invalid_value_keeps_existing_value() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
config.skills.open_skills_enabled = true;
std::env::set_var("ZEROCLAW_OPEN_SKILLS_ENABLED", "maybe");
config.apply_env_overrides();
assert!(config.skills.open_skills_enabled);
std::env::remove_var("ZEROCLAW_OPEN_SKILLS_ENABLED");
}
#[test] #[test]
async fn env_override_provider_fallback() { async fn env_override_provider_fallback() {
let _env_guard = env_override_lock().await; let _env_guard = env_override_lock().await;

View file

@ -884,9 +884,7 @@ async fn main() -> Result<()> {
integration_command, integration_command,
} => integrations::handle_command(integration_command, &config), } => integrations::handle_command(integration_command, &config),
Commands::Skills { skill_command } => { Commands::Skills { skill_command } => skills::handle_command(skill_command, &config),
skills::handle_command(skill_command, &config.workspace_dir)
}
Commands::Migrate { migrate_command } => { Commands::Migrate { migrate_command } => {
migration::handle_command(migrate_command, &config).await migration::handle_command(migrate_command, &config).await

View file

@ -160,6 +160,7 @@ pub async fn run_wizard() -> Result<Config> {
reliability: crate::config::ReliabilityConfig::default(), reliability: crate::config::ReliabilityConfig::default(),
scheduler: crate::config::schema::SchedulerConfig::default(), scheduler: crate::config::schema::SchedulerConfig::default(),
agent: crate::config::schema::AgentConfig::default(), agent: crate::config::schema::AgentConfig::default(),
skills: crate::config::SkillsConfig::default(),
model_routes: Vec::new(), model_routes: Vec::new(),
embedding_routes: Vec::new(), embedding_routes: Vec::new(),
heartbeat: HeartbeatConfig::default(), heartbeat: HeartbeatConfig::default(),
@ -398,6 +399,7 @@ async fn run_quick_setup_with_home(
reliability: crate::config::ReliabilityConfig::default(), reliability: crate::config::ReliabilityConfig::default(),
scheduler: crate::config::schema::SchedulerConfig::default(), scheduler: crate::config::schema::SchedulerConfig::default(),
agent: crate::config::schema::AgentConfig::default(), agent: crate::config::schema::AgentConfig::default(),
skills: crate::config::SkillsConfig::default(),
model_routes: Vec::new(), model_routes: Vec::new(),
embedding_routes: Vec::new(), embedding_routes: Vec::new(),
heartbeat: HeartbeatConfig::default(), heartbeat: HeartbeatConfig::default(),

View file

@ -71,9 +71,28 @@ fn default_version() -> String {
/// Load all skills from the workspace skills directory /// Load all skills from the workspace skills directory
pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> { pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> {
load_skills_with_open_skills_config(workspace_dir, None, None)
}
/// Load skills using runtime config values (preferred at runtime).
pub fn load_skills_with_config(workspace_dir: &Path, config: &crate::config::Config) -> Vec<Skill> {
load_skills_with_open_skills_config(
workspace_dir,
Some(config.skills.open_skills_enabled),
config.skills.open_skills_dir.as_deref(),
)
}
fn load_skills_with_open_skills_config(
workspace_dir: &Path,
config_open_skills_enabled: Option<bool>,
config_open_skills_dir: Option<&str>,
) -> Vec<Skill> {
let mut skills = Vec::new(); let mut skills = Vec::new();
if let Some(open_skills_dir) = ensure_open_skills_repo() { if let Some(open_skills_dir) =
ensure_open_skills_repo(config_open_skills_enabled, config_open_skills_dir)
{
skills.extend(load_open_skills(&open_skills_dir)); skills.extend(load_open_skills(&open_skills_dir));
} }
@ -158,33 +177,79 @@ fn load_open_skills(repo_dir: &Path) -> Vec<Skill> {
skills skills
} }
fn open_skills_enabled() -> bool { fn parse_open_skills_enabled(raw: &str) -> Option<bool> {
if let Ok(raw) = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED") { match raw.trim().to_ascii_lowercase().as_str() {
let value = raw.trim().to_ascii_lowercase(); "1" | "true" | "yes" | "on" => Some(true),
return !matches!(value.as_str(), "0" | "false" | "off" | "no"); "0" | "false" | "no" | "off" => Some(false),
} _ => None,
// Keep tests deterministic and network-free by default.
!cfg!(test)
}
fn resolve_open_skills_dir() -> Option<PathBuf> {
if let Ok(path) = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(PathBuf::from(trimmed));
} }
} }
UserDirs::new().map(|dirs| dirs.home_dir().join("open-skills")) fn open_skills_enabled_from_sources(
config_open_skills_enabled: Option<bool>,
env_override: Option<&str>,
) -> bool {
if let Some(raw) = env_override {
if let Some(enabled) = parse_open_skills_enabled(&raw) {
return enabled;
}
if !raw.trim().is_empty() {
tracing::warn!(
"Ignoring invalid ZEROCLAW_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)"
);
}
} }
fn ensure_open_skills_repo() -> Option<PathBuf> { config_open_skills_enabled.unwrap_or(false)
if !open_skills_enabled() { }
fn open_skills_enabled(config_open_skills_enabled: Option<bool>) -> bool {
let env_override = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED").ok();
open_skills_enabled_from_sources(config_open_skills_enabled, env_override.as_deref())
}
fn resolve_open_skills_dir_from_sources(
env_dir: Option<&str>,
config_dir: Option<&str>,
home_dir: Option<&Path>,
) -> Option<PathBuf> {
let parse_dir = |raw: &str| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(PathBuf::from(trimmed))
}
};
if let Some(env_dir) = env_dir.and_then(parse_dir) {
return Some(env_dir);
}
if let Some(config_dir) = config_dir.and_then(parse_dir) {
return Some(config_dir);
}
home_dir.map(|home| home.join("open-skills"))
}
fn resolve_open_skills_dir(config_open_skills_dir: Option<&str>) -> Option<PathBuf> {
let env_dir = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR").ok();
let home_dir = UserDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
resolve_open_skills_dir_from_sources(
env_dir.as_deref(),
config_open_skills_dir,
home_dir.as_deref(),
)
}
fn ensure_open_skills_repo(
config_open_skills_enabled: Option<bool>,
config_open_skills_dir: Option<&str>,
) -> Option<PathBuf> {
if !open_skills_enabled(config_open_skills_enabled) {
return None; return None;
} }
let repo_dir = resolve_open_skills_dir()?; let repo_dir = resolve_open_skills_dir(config_open_skills_dir)?;
if !repo_dir.exists() { if !repo_dir.exists() {
if !clone_open_skills_repo(&repo_dir) { if !clone_open_skills_repo(&repo_dir) {
@ -542,10 +607,11 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
/// Handle the `skills` CLI command /// Handle the `skills` CLI command
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn handle_command(command: crate::SkillCommands, workspace_dir: &Path) -> Result<()> { pub fn handle_command(command: crate::SkillCommands, config: &crate::config::Config) -> Result<()> {
let workspace_dir = &config.workspace_dir;
match command { match command {
crate::SkillCommands::List => { crate::SkillCommands::List => {
let skills = load_skills(workspace_dir); let skills = load_skills_with_config(workspace_dir, config);
if skills.is_empty() { if skills.is_empty() {
println!("No skills installed."); println!("No skills installed.");
println!(); println!();
@ -711,6 +777,35 @@ pub fn handle_command(command: crate::SkillCommands, workspace_dir: &Path) -> Re
mod tests { mod tests {
use super::*; use super::*;
use std::fs; use std::fs;
use std::sync::{Mutex, OnceLock};
fn open_skills_env_lock() -> &'static Mutex<()> {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
ENV_LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvVarGuard {
key: &'static str,
original: Option<String>,
}
impl EnvVarGuard {
fn unset(key: &'static str) -> Self {
let original = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(value) = &self.original {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
#[test] #[test]
fn load_empty_skills_dir() { fn load_empty_skills_dir() {
@ -1071,6 +1166,78 @@ description = "Bare minimum"
assert_eq!(skills.len(), 1); assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "from-toml"); // TOML takes priority assert_eq!(skills[0].name, "from-toml"); // TOML takes priority
} }
#[test]
fn open_skills_enabled_resolution_prefers_env_then_config_then_default_false() {
assert!(!open_skills_enabled_from_sources(None, None));
assert!(open_skills_enabled_from_sources(Some(true), None));
assert!(!open_skills_enabled_from_sources(Some(true), Some("0")));
assert!(open_skills_enabled_from_sources(Some(false), Some("yes")));
// Invalid env values should fall back to config.
assert!(open_skills_enabled_from_sources(
Some(true),
Some("invalid")
));
assert!(!open_skills_enabled_from_sources(
Some(false),
Some("invalid")
));
}
#[test]
fn resolve_open_skills_dir_resolution_prefers_env_then_config_then_home() {
let home = Path::new("/tmp/home-dir");
assert_eq!(
resolve_open_skills_dir_from_sources(
Some("/tmp/env-skills"),
Some("/tmp/config"),
Some(home)
),
Some(PathBuf::from("/tmp/env-skills"))
);
assert_eq!(
resolve_open_skills_dir_from_sources(
Some(" "),
Some("/tmp/config-skills"),
Some(home)
),
Some(PathBuf::from("/tmp/config-skills"))
);
assert_eq!(
resolve_open_skills_dir_from_sources(None, None, Some(home)),
Some(PathBuf::from("/tmp/home-dir/open-skills"))
);
assert_eq!(resolve_open_skills_dir_from_sources(None, None, None), None);
}
#[test]
fn load_skills_with_config_reads_open_skills_dir_without_network() {
let _env_guard = open_skills_env_lock().lock().unwrap();
let _enabled_guard = EnvVarGuard::unset("ZEROCLAW_OPEN_SKILLS_ENABLED");
let _dir_guard = EnvVarGuard::unset("ZEROCLAW_OPEN_SKILLS_DIR");
let dir = tempfile::tempdir().unwrap();
let workspace_dir = dir.path().join("workspace");
fs::create_dir_all(workspace_dir.join("skills")).unwrap();
let open_skills_dir = dir.path().join("open-skills-local");
fs::create_dir_all(&open_skills_dir).unwrap();
fs::write(open_skills_dir.join("README.md"), "# open skills\n").unwrap();
fs::write(
open_skills_dir.join("http_request.md"),
"# HTTP request\nFetch API responses.\n",
)
.unwrap();
let mut config = crate::config::Config::default();
config.workspace_dir = workspace_dir.clone();
config.skills.open_skills_enabled = true;
config.skills.open_skills_dir = Some(open_skills_dir.to_string_lossy().to_string());
let skills = load_skills_with_config(&workspace_dir, &config);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "http_request");
}
} }
#[cfg(test)] #[cfg(test)]