fix: attribute config precedence in JSON
This commit is contained in:
parent
bcc5bfde9c
commit
94be902ce1
|
|
@ -6351,7 +6351,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||||
424. **DONE — `--model` accepts bare canonical provider model names and Anthropic routing prefixes are stripped before provider calls** — fixed 2026-06-03 in `fix: normalize Anthropic model routing`. `validate_model_syntax()` now accepts unambiguous bare `claude-*` and `gpt-*` model IDs while preserving raw model provenance, and Anthropic `/v1/messages` plus `/v1/messages/count_tokens` request bodies strip the CLI-only `anthropic/` routing prefix so default/alias models do not reach Anthropic as `anthropic/claude-*`. Existing `qwen-*`/`grok-*` prefix-hint behavior remains intentionally unchanged for provider families whose bare names are ambiguous with DashScope/xAI routing. Regression coverage: `standard_messages_body_strips_anthropic_routing_prefix`, `send_message_strips_anthropic_routing_prefix_on_wire`, `default_model_alias_uses_anthropic_routing_prefix`, and the bare `--model=claude-opus-4-6 status` / `--model gpt-4 prompt` parser assertions in `parses_single_word_command_aliases_without_falling_back_to_prompt_mode`.
|
424. **DONE — `--model` accepts bare canonical provider model names and Anthropic routing prefixes are stripped before provider calls** — fixed 2026-06-03 in `fix: normalize Anthropic model routing`. `validate_model_syntax()` now accepts unambiguous bare `claude-*` and `gpt-*` model IDs while preserving raw model provenance, and Anthropic `/v1/messages` plus `/v1/messages/count_tokens` request bodies strip the CLI-only `anthropic/` routing prefix so default/alias models do not reach Anthropic as `anthropic/claude-*`. Existing `qwen-*`/`grok-*` prefix-hint behavior remains intentionally unchanged for provider families whose bare names are ambiguous with DashScope/xAI routing. Regression coverage: `standard_messages_body_strips_anthropic_routing_prefix`, `send_message_strips_anthropic_routing_prefix_on_wire`, `default_model_alias_uses_anthropic_routing_prefix`, and the bare `--model=claude-opus-4-6 status` / `--model gpt-4 prompt` parser assertions in `parses_single_word_command_aliases_without_falling_back_to_prompt_mode`.
|
||||||
|
|
||||||
|
|
||||||
425. **Config file precedence (`.claw/settings.json` always wins over `.claw.json`) is undocumented in user-facing surfaces — `config --output-format json` reports both files as `loaded:true` with no `precedence_rank` or `wins_for_keys` attribution; sibling: deprecation warning fires 4× per status invocation (was 3× in #424, regression upward)** — dogfooded 2026-05-11 by Jobdori on `d7dbe951` in response to Clawhip pinpoint nudge at `1503237744451649537`. Reproduction: create `.claw.json` with `{"model":"anthropic/claude-sonnet-4-6"}` and `.claw/settings.json` with `{"model":"anthropic/claude-opus-4-7"}` in the same workspace. `claw status --output-format json` returns `model:"anthropic/claude-opus-4-7", model_source:"config"`. Reverse the files (.claw.json=opus, settings.json=sonnet) → `model:"anthropic/claude-sonnet-4-6"`. Confirmed: `.claw/settings.json` **always** wins over `.claw.json` for conflicting keys, regardless of file mtime or alphabetical order. `claw config --output-format json` reports both as `loaded:true` with no `precedence_rank`, `effective_for_keys`, or `shadowed_keys` attribution. The only signal of precedence is the final merged value in `status` — automation cannot programmatically discover which file contributed which key without re-implementing the merge logic. **Sibling bug (regression from #424):** the `enabledPlugins` deprecation warning now fires **4 times** in stderr per single `status` invocation (was 3× in #424's probe at HEAD `6c0c305a`; current HEAD `d7dbe951` shows 4×). Config load count went up by 1. **Sibling bug observed in config-section probe:** `claw config model --output-format json` with a `.claw.json` that contains a benign unknown key (e.g., `"alpha":"x"`) returns `{"error":"/path/.claw.json: unknown key \"alpha\" (line 1)","kind":"unknown"}` — the entire config command fails with a generic `unknown` kind instead of (a) tolerating unrecognized keys with a warning, or (b) emitting a typed `kind:"unknown_key"` error scoped to the offending file/key. **Required fix shape:** (a) document precedence order in `USAGE.md` (`.claw/settings.local.json > .claw/settings.json > .claw.json` for project scope; `user`/`system` scope at each layer); (b) add `precedence_rank:int` and optional `wins_for_keys:[string]` / `shadowed_keys:[string]` to each entry in `config --output-format json` `files[]`; (c) dedupe the deprecation warning to fire **once per discovered file** instead of N× per load pass; (d) make `config <section> --output-format json` tolerate unknown keys with warnings, OR emit `kind:"unknown_key"` with `path:` and `key:` fields scoped to the offending file. **Why this matters:** users mixing legacy `.claw.json` with new `.claw/settings.json` have no way to verify which file is actually controlling their runtime. The undocumented precedence + missing per-key attribution forces trial-and-error to debug config drift. Cross-references #407 (config files no load_error) and #415 (config section returns merged_keys count not values). Source: Jobdori live dogfood, `d7dbe951`, 2026-05-11.
|
425. **DONE — config JSON exposes file precedence attribution and unknown config keys are warnings** — fixed 2026-06-03 in `fix: attribute config precedence in JSON`. Runtime config inspection now reports every discovered file with `precedence_rank`, `wins_for_keys`, and `shadowed_keys`, so `.claw/settings.json` overriding legacy `.claw.json` is visible without reimplementing merge order. Unknown keys are tolerated as structured validation warnings, including `claw config <section> --output-format json`, while wrong-type errors still fail. The deprecation-warning path remains deduplicated once per process for text-mode `status`, and JSON config surfaces collect warnings structurally without stderr duplication. Docs in `USAGE.md` now spell out the precedence chain and JSON attribution fields. Regression coverage: `config_json_attributes_precedence_and_shadowed_keys_425`, `config_section_json_tolerates_unknown_keys_as_warnings_425`, `status_deduplicates_config_deprecation_warnings_per_invocation_425`, and the runtime validator unknown-key warning tests.
|
||||||
|
|
||||||
|
|
||||||
426. **`ANTHROPIC_MODEL` env var bypasses the `invalid_model_syntax` validator that `--model` enforces — bogus model strings are accepted with `status:"ok"`, deferred-failing only when the first API call is made** — dogfooded 2026-05-11 by Jobdori on `3730b459` in response to Clawhip pinpoint nudge at `1503245298800136296`. Reproduction (asymmetric validation): `claw --model bogus-model-xyz status --output-format json` returns `kind:"invalid_model_syntax"` exit 1; `ANTHROPIC_MODEL=bogus-model-xyz claw status --output-format json` returns `model:"bogus-model-xyz", model_raw:"bogus-model-xyz", model_source:"env", status:"ok"` — the doctor surface lies that the configured model is valid when it is not. The bogus model only manifests as a failure when the first prompt fires and the API rejects it with 404/400. Three sibling discoveries in the same probe: (a) **alias indirection invisible**: `ANTHROPIC_MODEL=opus claw status --output-format json` returns `model:"claude-opus-4-6", model_raw:"opus", model_source:"env"` — the `opus` alias resolves to `claude-opus-4-6` (the *previous* frontier, not the current `claude-opus-4-7` released 2026-04-16). Users typing `opus` get yesterday's model with no warning. (b) **`CLAW_MODEL` env var silently ignored**: `CLAW_MODEL=opus claw status` shows `model:"claude-opus-4-6" model_source:"default"` — the `CLAW_MODEL` env var (the project-namespaced equivalent that users expect) does not exist; only `ANTHROPIC_MODEL` is honored. No warning when a `CLAW_*` env var that looks like it should work is set. (c) **`ANTHROPIC_DEFAULT_MODEL` also silently ignored**: the longer-named env var that some Anthropic SDKs use is not recognized. **Required fix shape:** (a) symmetric validation: `ANTHROPIC_MODEL` env value must pass the same `invalid_model_syntax` check that `--model` does, and `claw status` must return `kind:"invalid_model"` / `status:"warn"` (not `status:"ok"`) when the resolved model is unrecognized; (b) expose alias resolution in `status`: add `model_alias_resolved_to:string|null` field so automation can see `opus → claude-opus-4-6`; (c) bump the `opus` alias to `claude-opus-4-7` (current frontier) or document the alias-to-version mapping policy explicitly; (d) accept `CLAW_MODEL` and `ANTHROPIC_DEFAULT_MODEL` env vars with parity to `ANTHROPIC_MODEL`, OR emit a warning when those env vars are set but unrecognized. **Why this matters:** the most common automation pattern is `export ANTHROPIC_MODEL=...` in a shell rc file. Bogus values pass silently, alias indirection hides the actual model in use, and `CLAW_MODEL` looking like a working name but doing nothing is a footgun. Cross-references #424 (bare canonical names rejected at validator level) — together #424 + #426 make model selection inconsistent across CLI flag, env var, and alias paths. Source: Jobdori live dogfood, `3730b459`, 2026-05-11.
|
426. **`ANTHROPIC_MODEL` env var bypasses the `invalid_model_syntax` validator that `--model` enforces — bogus model strings are accepted with `status:"ok"`, deferred-failing only when the first API call is made** — dogfooded 2026-05-11 by Jobdori on `3730b459` in response to Clawhip pinpoint nudge at `1503245298800136296`. Reproduction (asymmetric validation): `claw --model bogus-model-xyz status --output-format json` returns `kind:"invalid_model_syntax"` exit 1; `ANTHROPIC_MODEL=bogus-model-xyz claw status --output-format json` returns `model:"bogus-model-xyz", model_raw:"bogus-model-xyz", model_source:"env", status:"ok"` — the doctor surface lies that the configured model is valid when it is not. The bogus model only manifests as a failure when the first prompt fires and the API rejects it with 404/400. Three sibling discoveries in the same probe: (a) **alias indirection invisible**: `ANTHROPIC_MODEL=opus claw status --output-format json` returns `model:"claude-opus-4-6", model_raw:"opus", model_source:"env"` — the `opus` alias resolves to `claude-opus-4-6` (the *previous* frontier, not the current `claude-opus-4-7` released 2026-04-16). Users typing `opus` get yesterday's model with no warning. (b) **`CLAW_MODEL` env var silently ignored**: `CLAW_MODEL=opus claw status` shows `model:"claude-opus-4-6" model_source:"default"` — the `CLAW_MODEL` env var (the project-namespaced equivalent that users expect) does not exist; only `ANTHROPIC_MODEL` is honored. No warning when a `CLAW_*` env var that looks like it should work is set. (c) **`ANTHROPIC_DEFAULT_MODEL` also silently ignored**: the longer-named env var that some Anthropic SDKs use is not recognized. **Required fix shape:** (a) symmetric validation: `ANTHROPIC_MODEL` env value must pass the same `invalid_model_syntax` check that `--model` does, and `claw status` must return `kind:"invalid_model"` / `status:"warn"` (not `status:"ok"`) when the resolved model is unrecognized; (b) expose alias resolution in `status`: add `model_alias_resolved_to:string|null` field so automation can see `opus → claude-opus-4-6`; (c) bump the `opus` alias to `claude-opus-4-7` (current frontier) or document the alias-to-version mapping policy explicitly; (d) accept `CLAW_MODEL` and `ANTHROPIC_DEFAULT_MODEL` env vars with parity to `ANTHROPIC_MODEL`, OR emit a warning when those env vars are set but unrecognized. **Why this matters:** the most common automation pattern is `export ANTHROPIC_MODEL=...` in a shell rc file. Bogus values pass silently, alias indirection hides the actual model in use, and `CLAW_MODEL` looking like a working name but doing nothing is a footgun. Cross-references #424 (bare canonical names rejected at validator level) — together #424 + #426 make model selection inconsistent across CLI flag, env var, and alias paths. Source: Jobdori live dogfood, `3730b459`, 2026-05-11.
|
||||||
|
|
|
||||||
2
USAGE.md
2
USAGE.md
|
|
@ -537,6 +537,8 @@ Runtime config is loaded in this order, with later entries overriding earlier on
|
||||||
4. `<repo>/.claw/settings.json`
|
4. `<repo>/.claw/settings.json`
|
||||||
5. `<repo>/.claw/settings.local.json`
|
5. `<repo>/.claw/settings.local.json`
|
||||||
|
|
||||||
|
The list is also the precedence chain: project-local settings override project settings, project settings override the legacy project `.claw.json`, and project files override user files. `claw --output-format json config` includes each discovered file's `precedence_rank`, `wins_for_keys`, and `shadowed_keys` so automation can see which file controls each effective key without reimplementing the merge order.
|
||||||
|
|
||||||
## Hook configuration
|
## Hook configuration
|
||||||
|
|
||||||
`hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.PostToolUseFailure` accept either legacy command strings or object-style entries with a `matcher` and nested command hooks:
|
`hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.PostToolUseFailure` accept either legacy command strings or object-style entries with a `matcher` and nested command hooks:
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,10 @@ pub struct ConfigFileReport {
|
||||||
pub status: ConfigFileStatus,
|
pub status: ConfigFileStatus,
|
||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
pub detail: Option<String>,
|
pub detail: Option<String>,
|
||||||
|
pub precedence_rank: usize,
|
||||||
|
pub wins_for_keys: Vec<String>,
|
||||||
|
pub shadowed_keys: Vec<String>,
|
||||||
|
key_paths: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort inspection of the config discovery and load pipeline.
|
/// Best-effort inspection of the config discovery and load pipeline.
|
||||||
|
|
@ -463,12 +467,14 @@ impl ConfigLoader {
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
let mut load_error = None;
|
let mut load_error = None;
|
||||||
|
|
||||||
for entry in self.discover() {
|
for (index, entry) in self.discover().into_iter().enumerate() {
|
||||||
|
let precedence_rank = index + 1;
|
||||||
if let Err(error) = crate::config_validate::check_unsupported_format(&entry.path) {
|
if let Err(error) = crate::config_validate::check_unsupported_format(&entry.path) {
|
||||||
let detail = error.to_string();
|
let detail = error.to_string();
|
||||||
load_error.get_or_insert_with(|| detail.clone());
|
load_error.get_or_insert_with(|| detail.clone());
|
||||||
files.push(ConfigFileReport::load_error(
|
files.push(ConfigFileReport::load_error(
|
||||||
entry,
|
entry,
|
||||||
|
precedence_rank,
|
||||||
"unsupported_format",
|
"unsupported_format",
|
||||||
detail,
|
detail,
|
||||||
));
|
));
|
||||||
|
|
@ -478,18 +484,28 @@ impl ConfigLoader {
|
||||||
let parsed = match read_optional_json_object(&entry.path) {
|
let parsed = match read_optional_json_object(&entry.path) {
|
||||||
Ok(OptionalConfigFile::Loaded(parsed)) => parsed,
|
Ok(OptionalConfigFile::Loaded(parsed)) => parsed,
|
||||||
Ok(OptionalConfigFile::NotFound) => {
|
Ok(OptionalConfigFile::NotFound) => {
|
||||||
files.push(ConfigFileReport::not_found(entry));
|
files.push(ConfigFileReport::not_found(entry, precedence_rank));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Ok(OptionalConfigFile::Skipped { reason, detail }) => {
|
Ok(OptionalConfigFile::Skipped { reason, detail }) => {
|
||||||
files.push(ConfigFileReport::skipped(entry, reason, detail));
|
files.push(ConfigFileReport::skipped(
|
||||||
|
entry,
|
||||||
|
precedence_rank,
|
||||||
|
reason,
|
||||||
|
detail,
|
||||||
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let reason = config_error_reason(&error).to_string();
|
let reason = config_error_reason(&error).to_string();
|
||||||
let detail = error.to_string();
|
let detail = error.to_string();
|
||||||
load_error.get_or_insert_with(|| detail.clone());
|
load_error.get_or_insert_with(|| detail.clone());
|
||||||
files.push(ConfigFileReport::load_error(entry, reason, detail));
|
files.push(ConfigFileReport::load_error(
|
||||||
|
entry,
|
||||||
|
precedence_rank,
|
||||||
|
reason,
|
||||||
|
detail,
|
||||||
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -504,6 +520,7 @@ impl ConfigLoader {
|
||||||
load_error.get_or_insert_with(|| detail.clone());
|
load_error.get_or_insert_with(|| detail.clone());
|
||||||
files.push(ConfigFileReport::load_error(
|
files.push(ConfigFileReport::load_error(
|
||||||
entry,
|
entry,
|
||||||
|
precedence_rank,
|
||||||
"validation_error",
|
"validation_error",
|
||||||
detail,
|
detail,
|
||||||
));
|
));
|
||||||
|
|
@ -521,6 +538,7 @@ impl ConfigLoader {
|
||||||
load_error.get_or_insert_with(|| detail.clone());
|
load_error.get_or_insert_with(|| detail.clone());
|
||||||
files.push(ConfigFileReport::load_error(
|
files.push(ConfigFileReport::load_error(
|
||||||
entry,
|
entry,
|
||||||
|
precedence_rank,
|
||||||
"validation_error",
|
"validation_error",
|
||||||
detail,
|
detail,
|
||||||
));
|
));
|
||||||
|
|
@ -532,15 +550,23 @@ impl ConfigLoader {
|
||||||
{
|
{
|
||||||
let detail = error.to_string();
|
let detail = error.to_string();
|
||||||
load_error.get_or_insert_with(|| detail.clone());
|
load_error.get_or_insert_with(|| detail.clone());
|
||||||
files.push(ConfigFileReport::load_error(entry, "parse_error", detail));
|
files.push(ConfigFileReport::load_error(
|
||||||
|
entry,
|
||||||
|
precedence_rank,
|
||||||
|
"parse_error",
|
||||||
|
detail,
|
||||||
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let key_paths = collect_config_key_paths(&parsed.object);
|
||||||
deep_merge_objects(&mut merged, &parsed.object);
|
deep_merge_objects(&mut merged, &parsed.object);
|
||||||
loaded_entries.push(entry.clone());
|
loaded_entries.push(entry.clone());
|
||||||
files.push(ConfigFileReport::loaded(entry));
|
files.push(ConfigFileReport::loaded(entry, precedence_rank, key_paths));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
annotate_config_file_precedence(&mut files);
|
||||||
|
|
||||||
let runtime_config = match build_runtime_config(merged, loaded_entries, mcp_servers) {
|
let runtime_config = match build_runtime_config(merged, loaded_entries, mcp_servers) {
|
||||||
Ok(config) => Some(config),
|
Ok(config) => Some(config),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
@ -559,47 +585,121 @@ impl ConfigLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigFileReport {
|
impl ConfigFileReport {
|
||||||
fn loaded(entry: ConfigEntry) -> Self {
|
fn loaded(entry: ConfigEntry, precedence_rank: usize, key_paths: Vec<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
entry,
|
entry,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
status: ConfigFileStatus::Loaded,
|
status: ConfigFileStatus::Loaded,
|
||||||
reason: None,
|
reason: None,
|
||||||
detail: None,
|
detail: None,
|
||||||
|
precedence_rank,
|
||||||
|
wins_for_keys: Vec::new(),
|
||||||
|
shadowed_keys: Vec::new(),
|
||||||
|
key_paths,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn not_found(entry: ConfigEntry) -> Self {
|
fn not_found(entry: ConfigEntry, precedence_rank: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
entry,
|
entry,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
status: ConfigFileStatus::NotFound,
|
status: ConfigFileStatus::NotFound,
|
||||||
reason: Some("not_found".to_string()),
|
reason: Some("not_found".to_string()),
|
||||||
detail: None,
|
detail: None,
|
||||||
|
precedence_rank,
|
||||||
|
wins_for_keys: Vec::new(),
|
||||||
|
shadowed_keys: Vec::new(),
|
||||||
|
key_paths: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn skipped(entry: ConfigEntry, reason: String, detail: Option<String>) -> Self {
|
fn skipped(
|
||||||
|
entry: ConfigEntry,
|
||||||
|
precedence_rank: usize,
|
||||||
|
reason: String,
|
||||||
|
detail: Option<String>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
entry,
|
entry,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
status: ConfigFileStatus::Skipped,
|
status: ConfigFileStatus::Skipped,
|
||||||
reason: Some(reason),
|
reason: Some(reason),
|
||||||
detail,
|
detail,
|
||||||
|
precedence_rank,
|
||||||
|
wins_for_keys: Vec::new(),
|
||||||
|
shadowed_keys: Vec::new(),
|
||||||
|
key_paths: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_error(entry: ConfigEntry, reason: impl Into<String>, detail: String) -> Self {
|
fn load_error(
|
||||||
|
entry: ConfigEntry,
|
||||||
|
precedence_rank: usize,
|
||||||
|
reason: impl Into<String>,
|
||||||
|
detail: String,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
entry,
|
entry,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
status: ConfigFileStatus::LoadError,
|
status: ConfigFileStatus::LoadError,
|
||||||
reason: Some(reason.into()),
|
reason: Some(reason.into()),
|
||||||
detail: Some(detail),
|
detail: Some(detail),
|
||||||
|
precedence_rank,
|
||||||
|
wins_for_keys: Vec::new(),
|
||||||
|
shadowed_keys: Vec::new(),
|
||||||
|
key_paths: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn annotate_config_file_precedence(files: &mut [ConfigFileReport]) {
|
||||||
|
let mut winning_file_by_key = BTreeMap::new();
|
||||||
|
for (index, file) in files.iter().enumerate() {
|
||||||
|
if !file.loaded {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for key in &file.key_paths {
|
||||||
|
winning_file_by_key.insert(key.clone(), index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, file) in files.iter_mut().enumerate() {
|
||||||
|
if !file.loaded {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut wins_for_keys = Vec::new();
|
||||||
|
let mut shadowed_keys = Vec::new();
|
||||||
|
for key in &file.key_paths {
|
||||||
|
if winning_file_by_key.get(key).copied() == Some(index) {
|
||||||
|
wins_for_keys.push(key.clone());
|
||||||
|
} else {
|
||||||
|
shadowed_keys.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.wins_for_keys = wins_for_keys;
|
||||||
|
file.shadowed_keys = shadowed_keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_config_key_paths(object: &BTreeMap<String, JsonValue>) -> Vec<String> {
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for (key, value) in object {
|
||||||
|
collect_config_key_paths_for_value(key, value, &mut keys);
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_config_key_paths_for_value(prefix: &str, value: &JsonValue, keys: &mut Vec<String>) {
|
||||||
|
match value {
|
||||||
|
JsonValue::Object(object) if !object.is_empty() => {
|
||||||
|
for (key, nested) in object {
|
||||||
|
collect_config_key_paths_for_value(&format!("{prefix}.{key}"), nested, keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => keys.push(prefix.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_runtime_config(
|
fn build_runtime_config(
|
||||||
merged: BTreeMap<String, JsonValue>,
|
merged: BTreeMap<String, JsonValue>,
|
||||||
loaded_entries: Vec<ConfigEntry>,
|
loaded_entries: Vec<ConfigEntry>,
|
||||||
|
|
@ -2982,23 +3082,23 @@ mod tests {
|
||||||
.expect("write user settings");
|
.expect("write user settings");
|
||||||
|
|
||||||
// when
|
// when
|
||||||
let error = ConfigLoader::new(&cwd, &home)
|
let (_config, warnings) = ConfigLoader::new(&cwd, &home)
|
||||||
.load()
|
.load_collecting_warnings()
|
||||||
.expect_err("config should fail");
|
.expect("unknown config keys should load with warnings");
|
||||||
|
|
||||||
// then
|
// then
|
||||||
let rendered = error.to_string();
|
let rendered = warnings.join("\n");
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains(&user_settings.display().to_string()),
|
rendered.contains(&user_settings.display().to_string()),
|
||||||
"error should include file path, got: {rendered}"
|
"warning should include file path, got: {rendered}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("line 3"),
|
rendered.contains("line 3"),
|
||||||
"error should include line number, got: {rendered}"
|
"warning should include line number, got: {rendered}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("telemetry"),
|
rendered.contains("telemetry"),
|
||||||
"error should name the offending field, got: {rendered}"
|
"warning should name the offending field, got: {rendered}"
|
||||||
);
|
);
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
|
@ -3020,28 +3120,23 @@ mod tests {
|
||||||
.expect("write user settings");
|
.expect("write user settings");
|
||||||
|
|
||||||
// when
|
// when
|
||||||
let error = ConfigLoader::new(&cwd, &home)
|
let (_config, warnings) = ConfigLoader::new(&cwd, &home)
|
||||||
.load()
|
.load_collecting_warnings()
|
||||||
.expect_err("config should fail");
|
.expect("legacy unknown config keys should load with warnings");
|
||||||
|
|
||||||
// then
|
// then
|
||||||
let rendered = error.to_string();
|
let rendered = warnings.join("\n");
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains(&user_settings.display().to_string()),
|
rendered.contains(&user_settings.display().to_string()),
|
||||||
"error should include file path, got: {rendered}"
|
"warning should include file path, got: {rendered}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("line 3"),
|
rendered.contains("line 3"),
|
||||||
"error should include line number, got: {rendered}"
|
"warning should include line number, got: {rendered}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("allowedTools"),
|
rendered.contains("allowedTools"),
|
||||||
"error should call out the unknown field, got: {rendered}"
|
"warning should name the offending field, got: {rendered}"
|
||||||
);
|
|
||||||
// allowedTools is an unknown key; validator should name it in the error
|
|
||||||
assert!(
|
|
||||||
rendered.contains("allowedTools"),
|
|
||||||
"error should name the offending field, got: {rendered}"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
|
@ -3101,19 +3196,19 @@ mod tests {
|
||||||
fs::write(&user_settings, "{\n \"modle\": \"opus\"\n}\n").expect("write user settings");
|
fs::write(&user_settings, "{\n \"modle\": \"opus\"\n}\n").expect("write user settings");
|
||||||
|
|
||||||
// when
|
// when
|
||||||
let error = ConfigLoader::new(&cwd, &home)
|
let (_config, warnings) = ConfigLoader::new(&cwd, &home)
|
||||||
.load()
|
.load_collecting_warnings()
|
||||||
.expect_err("config should fail");
|
.expect("unknown config keys should load with warnings");
|
||||||
|
|
||||||
// then
|
// then
|
||||||
let rendered = error.to_string();
|
let rendered = warnings.join("\n");
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("modle"),
|
rendered.contains("modle"),
|
||||||
"error should name the offending field, got: {rendered}"
|
"warning should name the offending field, got: {rendered}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("model"),
|
rendered.contains("model"),
|
||||||
"error should suggest the closest known key, got: {rendered}"
|
"warning should suggest the closest known key, got: {rendered}"
|
||||||
);
|
);
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
|
|
||||||
|
|
@ -424,9 +424,10 @@ fn validate_object_keys(
|
||||||
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
|
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
|
||||||
// Deprecated key — handled separately, not an unknown-key error.
|
// Deprecated key — handled separately, not an unknown-key error.
|
||||||
} else {
|
} else {
|
||||||
// Unknown key.
|
// Unknown key — preserve compatibility by surfacing it as a warning
|
||||||
|
// instead of blocking otherwise valid config files.
|
||||||
let suggestion = suggest_field(key, &known_names);
|
let suggestion = suggest_field(key, &known_names);
|
||||||
result.errors.push(ConfigDiagnostic {
|
result.warnings.push(ConfigDiagnostic {
|
||||||
path: path_display.to_string(),
|
path: path_display.to_string(),
|
||||||
field: field_path,
|
field: field_path,
|
||||||
line: find_key_line(source, key),
|
line: find_key_line(source, key),
|
||||||
|
|
@ -605,10 +606,11 @@ mod tests {
|
||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "unknownField");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "unknownField");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result.errors[0].kind,
|
result.warnings[0].kind,
|
||||||
DiagnosticKind::UnknownKey { .. }
|
DiagnosticKind::UnknownKey { .. }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -688,9 +690,10 @@ mod tests {
|
||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].line, Some(3));
|
assert_eq!(result.warnings.len(), 1);
|
||||||
assert_eq!(result.errors[0].field, "badKey");
|
assert_eq!(result.warnings[0].line, Some(3));
|
||||||
|
assert_eq!(result.warnings[0].field, "badKey");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -719,8 +722,9 @@ mod tests {
|
||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "hooks.BadHook");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "hooks.BadHook");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -785,8 +789,9 @@ mod tests {
|
||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "permissions.denyAll");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "permissions.denyAll");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -800,8 +805,9 @@ mod tests {
|
||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "sandbox.containerMode");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "sandbox.containerMode");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -815,8 +821,9 @@ mod tests {
|
||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "plugins.autoUpdate");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "plugins.autoUpdate");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -830,8 +837,9 @@ mod tests {
|
||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "oauth.secret");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "oauth.secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -866,8 +874,9 @@ mod tests {
|
||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
match &result.errors[0].kind {
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
match &result.warnings[0].kind {
|
||||||
DiagnosticKind::UnknownKey {
|
DiagnosticKind::UnknownKey {
|
||||||
suggestion: Some(s),
|
suggestion: Some(s),
|
||||||
} => assert_eq!(s, "model"),
|
} => assert_eq!(s, "model"),
|
||||||
|
|
@ -878,7 +887,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn format_diagnostics_includes_all_entries() {
|
fn format_diagnostics_includes_all_entries() {
|
||||||
// given
|
// given
|
||||||
let source = r#"{"permissionMode": "plan", "badKey": 1}"#;
|
let source = r#"{"model": 42, "badKey": 1}"#;
|
||||||
let parsed = JsonValue::parse(source).expect("valid json");
|
let parsed = JsonValue::parse(source).expect("valid json");
|
||||||
let object = parsed.as_object().expect("object");
|
let object = parsed.as_object().expect("object");
|
||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
@ -890,7 +899,7 @@ mod tests {
|
||||||
assert!(output.contains("warning:"));
|
assert!(output.contains("warning:"));
|
||||||
assert!(output.contains("error:"));
|
assert!(output.contains("error:"));
|
||||||
assert!(output.contains("badKey"));
|
assert!(output.contains("badKey"));
|
||||||
assert!(output.contains("permissionMode"));
|
assert!(output.contains("model"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -8654,6 +8654,30 @@ fn config_file_report_json(file: &ConfigFileReport) -> serde_json::Value {
|
||||||
serde_json::Value::String(source.to_string()),
|
serde_json::Value::String(source.to_string()),
|
||||||
);
|
);
|
||||||
object.insert("loaded".to_string(), serde_json::Value::Bool(file.loaded));
|
object.insert("loaded".to_string(), serde_json::Value::Bool(file.loaded));
|
||||||
|
object.insert(
|
||||||
|
"precedence_rank".to_string(),
|
||||||
|
serde_json::Value::Number(serde_json::Number::from(file.precedence_rank)),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"wins_for_keys".to_string(),
|
||||||
|
serde_json::Value::Array(
|
||||||
|
file.wins_for_keys
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(serde_json::Value::String)
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"shadowed_keys".to_string(),
|
||||||
|
serde_json::Value::Array(
|
||||||
|
file.shadowed_keys
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(serde_json::Value::String)
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
object.insert(
|
object.insert(
|
||||||
"status".to_string(),
|
"status".to_string(),
|
||||||
serde_json::Value::String(file.status.as_str().to_string()),
|
serde_json::Value::String(file.status.as_str().to_string()),
|
||||||
|
|
|
||||||
|
|
@ -1458,6 +1458,163 @@ fn config_json_reports_deprecations_structurally_without_stderr_duplicate_815()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_deduplicates_config_deprecation_warnings_per_invocation_425() {
|
||||||
|
let root = unique_temp_dir("status-warning-dedup-425");
|
||||||
|
let config_home = root.join("config-home");
|
||||||
|
let home = root.join("home");
|
||||||
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
fs::write(
|
||||||
|
config_home.join("settings.json"),
|
||||||
|
r#"{"enabledPlugins": {}}"#,
|
||||||
|
)
|
||||||
|
.expect("deprecated config fixture should write");
|
||||||
|
|
||||||
|
let envs = [
|
||||||
|
(
|
||||||
|
"CLAW_CONFIG_HOME",
|
||||||
|
config_home.to_str().expect("utf8 config home"),
|
||||||
|
),
|
||||||
|
("HOME", home.to_str().expect("utf8 home")),
|
||||||
|
];
|
||||||
|
let output = run_claw(&root, &["status"], &envs);
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"stdout:\n{}\n\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
|
||||||
|
let warning_count = stderr
|
||||||
|
.matches("field \"enabledPlugins\" is deprecated")
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
warning_count, 1,
|
||||||
|
"status should emit the deprecated enabledPlugins warning once per process:\n{stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_json_attributes_precedence_and_shadowed_keys_425() {
|
||||||
|
let root = unique_temp_dir("config-precedence-425");
|
||||||
|
let config_home = root.join("config-home");
|
||||||
|
let home = root.join("home");
|
||||||
|
fs::create_dir_all(root.join(".claw")).expect("workspace config should exist");
|
||||||
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claw.json"),
|
||||||
|
r#"{"model":"anthropic/claude-sonnet-4-6","env":{"A":"legacy","B":"legacy"}}"#,
|
||||||
|
)
|
||||||
|
.expect("legacy project config fixture should write");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claw").join("settings.json"),
|
||||||
|
r#"{"model":"anthropic/claude-opus-4-6","env":{"A":"settings","C":"settings"}}"#,
|
||||||
|
)
|
||||||
|
.expect("project settings fixture should write");
|
||||||
|
|
||||||
|
let envs = [
|
||||||
|
(
|
||||||
|
"CLAW_CONFIG_HOME",
|
||||||
|
config_home.to_str().expect("utf8 config home"),
|
||||||
|
),
|
||||||
|
("HOME", home.to_str().expect("utf8 home")),
|
||||||
|
];
|
||||||
|
let parsed = assert_json_command_with_env(&root, &["--output-format", "json", "config"], &envs);
|
||||||
|
let files = parsed["files"].as_array().expect("files array");
|
||||||
|
let legacy = files
|
||||||
|
.iter()
|
||||||
|
.find(|file| {
|
||||||
|
file["source"] == "project"
|
||||||
|
&& file["path"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|path| path.ends_with(".claw.json"))
|
||||||
|
})
|
||||||
|
.expect("project .claw.json entry");
|
||||||
|
let settings = files
|
||||||
|
.iter()
|
||||||
|
.find(|file| {
|
||||||
|
file["source"] == "project"
|
||||||
|
&& file["path"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|path| path.ends_with(".claw/settings.json"))
|
||||||
|
})
|
||||||
|
.expect("project .claw/settings.json entry");
|
||||||
|
|
||||||
|
assert_eq!(legacy["status"], "loaded");
|
||||||
|
assert_eq!(settings["status"], "loaded");
|
||||||
|
assert!(
|
||||||
|
settings["precedence_rank"].as_u64().expect("settings rank")
|
||||||
|
> legacy["precedence_rank"].as_u64().expect("legacy rank"),
|
||||||
|
"later project settings must outrank legacy project config: legacy={legacy} settings={settings}"
|
||||||
|
);
|
||||||
|
for key in ["model", "env.A"] {
|
||||||
|
assert!(
|
||||||
|
legacy["shadowed_keys"]
|
||||||
|
.as_array()
|
||||||
|
.expect("legacy shadowed keys")
|
||||||
|
.iter()
|
||||||
|
.any(|value| value.as_str() == Some(key)),
|
||||||
|
"legacy config should report {key} as shadowed: {legacy}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
settings["wins_for_keys"]
|
||||||
|
.as_array()
|
||||||
|
.expect("settings winning keys")
|
||||||
|
.iter()
|
||||||
|
.any(|value| value.as_str() == Some(key)),
|
||||||
|
"project settings should report {key} as winning: {settings}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
legacy["wins_for_keys"]
|
||||||
|
.as_array()
|
||||||
|
.expect("legacy winning keys")
|
||||||
|
.iter()
|
||||||
|
.any(|value| value.as_str() == Some("env.B")),
|
||||||
|
"unshadowed legacy keys should remain attributed to .claw.json: {legacy}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_section_json_tolerates_unknown_keys_as_warnings_425() {
|
||||||
|
let root = unique_temp_dir("config-unknown-warning-425");
|
||||||
|
let config_home = root.join("config-home");
|
||||||
|
let home = root.join("home");
|
||||||
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
fs::write(root.join(".claw.json"), r#"{"model":"opus","alpha":"x"}"#)
|
||||||
|
.expect("legacy config fixture should write");
|
||||||
|
|
||||||
|
let envs = [
|
||||||
|
(
|
||||||
|
"CLAW_CONFIG_HOME",
|
||||||
|
config_home.to_str().expect("utf8 config home"),
|
||||||
|
),
|
||||||
|
("HOME", home.to_str().expect("utf8 home")),
|
||||||
|
];
|
||||||
|
let parsed = assert_json_command_with_env(
|
||||||
|
&root,
|
||||||
|
&["--output-format", "json", "config", "model"],
|
||||||
|
&envs,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(parsed["status"], "ok");
|
||||||
|
assert_eq!(parsed["section"], "model");
|
||||||
|
assert_eq!(parsed["section_value"], "opus");
|
||||||
|
assert!(
|
||||||
|
parsed["warnings"]
|
||||||
|
.as_array()
|
||||||
|
.expect("warnings array")
|
||||||
|
.iter()
|
||||||
|
.any(|warning| warning
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|text| text.contains("unknown key \"alpha\""))),
|
||||||
|
"unknown keys should be structural warnings, not section failures: {parsed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_json_reports_structured_unloaded_file_reasons_407() {
|
fn config_json_reports_structured_unloaded_file_reasons_407() {
|
||||||
let root = unique_temp_dir("config-file-status-407");
|
let root = unique_temp_dir("config-file-status-407");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue