fix: expose binary provenance in local JSON

This commit is contained in:
bellman
2026-06-03 20:03:39 +09:00
parent 372ec09c47
commit ce116d9dfa
4 changed files with 207 additions and 6 deletions

View File

@@ -7759,7 +7759,11 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
795. **`claw skills install /nonexistent` returned `skill_not_found + hint:null` and `claw skills uninstall x` returned `unsupported_skills_action + hint:null`** — dogfooded 2026-05-27 on `491f179a`. Both error kinds were missing from `fallback_hint_for_error_kind` table, so even though classify returned a typed kind, the hint field was always null. Fix: added `"skill_not_found"` → hint suggesting `claw skills list` / `claw skills install`; added `"unsupported_skills_action"` → hint listing supported actions. Integration test `skills_install_not_found_and_unsupported_action_have_hints_795` covers both paths. 57 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori skills lifecycle probe on `491f179a`, 2026-05-27.
796. **`claw agents show <name> <extra>` and `claw skills show <name> <extra>` returned confusing `agent_not_found`/`skill_not_found` for the concatenated "name extra" string** — dogfooded 2026-05-27 on `18b4cee5`. `join_optional_args` passes all tokens as a space-joined string; both `show` handlers called `split_once(' ')` to extract the name but did not check if the remainder (after the first split) contained additional tokens. Extra positional args (including `--flags`) became part of the "name", silently mangling the lookup. Fix: added second `split_once(' ')` on the extracted name; if the result has two parts, return `unexpected_extra_args` with a usage hint. Valid single-name lookups are unaffected. Two new integration tests `agents_show_extra_positional_arg_returns_unexpected_extra_796`, `skills_show_extra_positional_arg_returns_unexpected_extra_796`. 59 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori agents/skills show extra-arg probe on `18b4cee5`, 2026-05-27.
797. **Installed `claw version --output-format json` reports `git_sha:null` / `Git SHA unknown`, so dogfood cannot tie the binary under test to a source revision** — dogfooded 2026-05-27 from `#clawcode-building-in-public` using the installed `/home/bellman/.cargo/bin/claw` binary in a clean `ultraworkers/claw-code` checkout. `claw version --output-format json` returned `{"kind":"version","version":"0.1.0","git_sha":null,"target":null,"build_date":"2026-03-31"...}` while `claw status --output-format json` only reported workspace state (`git_branch`, clean/dirty counts) and did not provide any executable-vs-workspace provenance comparison. This is a clawability gap in event/log opacity and stale-binary confusion: an operator can run `doctor/status/version` successfully but still cannot prove which commit the installed CLI came from, whether it matches `origin/main`, or whether the observed behavior is from a stale packaged binary. **Required fix shape:** (a) embed build git SHA/target/build provenance in installed/release binaries whenever the source tree is available; (b) when provenance is missing, emit a typed `binary_provenance.status:"unknown"` rather than only `git_sha:null`; (c) have `status`/`doctor` include a redaction-safe comparison between executable provenance and workspace HEAD when running inside a git checkout; (d) add regression/packaging coverage proving release/local install paths preserve or explicitly classify provenance. **Why this matters:** dogfood reports and automation need to distinguish current-source failures from stale or unknown binary lineage before opening/rebasing/closing PRs. Source: gaebal-gajae live dogfood on 2026-05-27; active repo checkout had open PR #3124 DIRTY with no checks and PR #3125 CLEAN, but the installed binary itself could not identify its source revision.
797. **DONE — Installed `claw version --output-format json` reports `git_sha:null` / `Git SHA unknown`, so dogfood cannot tie the binary under test to a source revision** — dogfooded 2026-05-27 from `#clawcode-building-in-public` using an installed binary in a clean `ultraworkers/claw-code` checkout. The gap was that version/status/doctor did not provide a structured executable-vs-workspace provenance object when build metadata was missing or stale. [SCOPE: claw-code]
**Fix applied.** `version --output-format json` now includes a `binary_provenance` object with `status:"known"|"unknown"`, build git SHA, target, build date, executable path, workspace HEAD SHA, `workspace_match`, and a structured hint when provenance is missing or mismatched. `status --output-format json` exposes the same object, and `doctor --output-format json` includes it in the `system` check so dogfood reports can distinguish current-source failures from stale or unknown binary lineage.
**Verification.** `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli version_status_doctor_include_binary_provenance_797 -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli version_emits_json_when_requested -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli doctor_and_resume_status_emit_json_when_requested -- --nocapture`; `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test output_format_contract -- --nocapture`; direct probes `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json version` and `cargo run --manifest-path rust/Cargo.toml -q -p rusty-claude-cli -- --output-format json status`; `cargo build --manifest-path rust/Cargo.toml --workspace --locked`.
798. **`claw plugins show <name> <extra-arg>` returned `unexpected_extra_args` + `hint:null`** — dogfooded 2026-05-27 on `9976585f`. The plugins arg parser at the top level emitted `"unexpected extra arguments after 'claw plugins show ...': ..."` with no `\n` delimiter (parity gap with #791 config fix). Fix: appended `\nUsage: claw plugins [list|show <id>|...]` to the error format string. Integration test `plugins_extra_args_have_non_null_hint_797`. Committed as `bff37000`. 60 CLI contract tests pass. [SCOPE: claw-code]

View File

@@ -349,6 +349,8 @@ These are the models registered in the built-in alias table with known token lim
| `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
| `grok-2` | `grok-2` | xAI | — | — |
| `kimi` | `kimi-k2.5` | DashScope | 16 384 | 256 000 |
| `qwen-max` | `qwen-max` | DashScope | 8 192 | 131 072 |
| `qwen-plus` | `qwen-plus` | DashScope | 8 192 | 131 072 |
| `gpt-4.1` / `gpt-4.1-mini` / `gpt-4.1-nano` | same | OpenAI-compatible | 32 768 | 1 047 576 |
| `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` | same | OpenAI-compatible | 128 000 | 1 000 000 / 400 000 |

View File

@@ -2680,6 +2680,7 @@ fn render_doctor_report(
session_lifecycle: classify_session_lifecycle_for(&cwd),
boot_preflight,
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
binary_provenance: binary_provenance_for(Some(&cwd)),
// Doctor path has its own config check; StatusContext here is only
// fed into health renderers that don't read config_load_error.
config_load_error: config.as_ref().err().map(ToString::to_string),
@@ -3297,6 +3298,14 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
if let Some(model) = default_model {
details.push(format!("Default model {model}"));
}
let binary_provenance = binary_provenance_for(Some(cwd));
details.push(format!(
"Binary provenance status={} workspace_match={}",
binary_provenance.status(),
binary_provenance
.workspace_match
.map_or_else(|| "unknown".to_string(), |matches| matches.to_string())
));
DiagnosticCheck::new(
"System",
DiagnosticLevel::Ok,
@@ -3310,6 +3319,10 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
("version".to_string(), json!(VERSION)),
("build_target".to_string(), json!(BUILD_TARGET)),
("git_sha".to_string(), json!(GIT_SHA)),
(
"binary_provenance".to_string(),
binary_provenance.json_value(),
),
("default_model".to_string(), json!(default_model)),
]))
}
@@ -3493,17 +3506,19 @@ fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::erro
}
fn version_json_value() -> serde_json::Value {
let executable_path = env::current_exe().ok().map(|p| p.display().to_string());
let cwd = env::current_dir().ok();
let binary_provenance = binary_provenance_for(cwd.as_deref());
json!({
"kind": "version",
"action": "show",
"status": "ok",
"message": render_version_report(),
"version": VERSION,
"git_sha": GIT_SHA,
"target": BUILD_TARGET,
"build_date": DEFAULT_DATE,
"executable_path": executable_path,
"git_sha": binary_provenance.git_sha,
"target": binary_provenance.target,
"build_date": binary_provenance.build_date,
"executable_path": binary_provenance.executable_path,
"binary_provenance": binary_provenance.json_value(),
})
}
@@ -3718,6 +3733,7 @@ struct StatusContext {
session_lifecycle: SessionLifecycleSummary,
boot_preflight: BootPreflightSnapshot,
sandbox_status: runtime::SandboxStatus,
binary_provenance: BinaryProvenance,
/// #143: when `.claw.json` (or another loaded config file) fails to parse,
/// we capture the parse error here and still populate every field that
/// doesn't depend on runtime config (workspace, git, sandbox defaults,
@@ -3732,6 +3748,87 @@ struct StatusContext {
config_load_error_kind: Option<&'static str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BinaryProvenance {
git_sha: Option<String>,
target: Option<String>,
build_date: String,
executable_path: Option<String>,
workspace_git_sha: Option<String>,
workspace_match: Option<bool>,
hint: Option<String>,
}
impl BinaryProvenance {
fn status(&self) -> &'static str {
if self.git_sha.is_some() {
"known"
} else {
"unknown"
}
}
fn json_value(&self) -> serde_json::Value {
json!({
"status": self.status(),
"git_sha": self.git_sha,
"target": self.target,
"build_date": self.build_date,
"executable_path": self.executable_path,
"workspace_git_sha": self.workspace_git_sha,
"workspace_match": self.workspace_match,
"hint": self.hint,
})
}
}
fn known_build_metadata(value: Option<&str>) -> Option<String> {
let value = value?.trim();
if value.is_empty() || value == "unknown" {
None
} else {
Some(value.to_string())
}
}
fn binary_provenance_for(cwd: Option<&Path>) -> BinaryProvenance {
let git_sha = known_build_metadata(GIT_SHA);
let target = known_build_metadata(BUILD_TARGET);
let workspace_git_sha = cwd.and_then(|cwd| {
run_git_capture_in(cwd, &["rev-parse", "--short", "HEAD"])
.map(|sha| sha.trim().to_string())
.filter(|sha| !sha.is_empty())
});
let workspace_match = git_sha
.as_deref()
.zip(workspace_git_sha.as_deref())
.map(|(binary, workspace)| binary.starts_with(workspace) || workspace.starts_with(binary));
let hint = if git_sha.is_none() {
Some(
"Build metadata did not include a git SHA; rebuild from a git checkout before filing provenance-sensitive dogfood reports."
.to_string(),
)
} else if workspace_match == Some(false) {
Some(
"The running binary was built from a different commit than the current workspace HEAD; rebuild or switch binaries before attributing behavior to this checkout."
.to_string(),
)
} else {
None
};
BinaryProvenance {
git_sha,
target,
build_date: DEFAULT_DATE.to_string(),
executable_path: env::current_exe()
.ok()
.map(|path| path.display().to_string()),
workspace_git_sha,
workspace_match,
hint,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BranchFreshness {
upstream: Option<String>,
@@ -7500,6 +7597,7 @@ fn status_json_value(
"restricted": allowed_tools.is_some(),
"entries": allowed_tool_entries,
},
"binary_provenance": context.binary_provenance.json_value(),
"usage": {
"messages": usage.message_count,
"turns": usage.turns,
@@ -7627,6 +7725,7 @@ fn status_context(
session_lifecycle: classify_session_lifecycle_for(&cwd),
boot_preflight,
sandbox_status,
binary_provenance: binary_provenance_for(Some(&cwd)),
config_load_error,
config_load_error_kind,
})
@@ -14801,6 +14900,7 @@ mod tests {
},
boot_preflight: test_boot_preflight(),
sandbox_status: runtime::SandboxStatus::default(),
binary_provenance: super::binary_provenance_for(None),
config_load_error: None,
config_load_error_kind: None,
},
@@ -14946,6 +15046,7 @@ mod tests {
},
boot_preflight: test_boot_preflight(),
sandbox_status: runtime::SandboxStatus::default(),
binary_provenance: super::binary_provenance_for(None),
config_load_error: None,
config_load_error_kind: None,
};
@@ -14984,6 +15085,7 @@ mod tests {
},
boot_preflight: test_boot_preflight(),
sandbox_status: runtime::SandboxStatus::default(),
binary_provenance: super::binary_provenance_for(None),
config_load_error: None,
config_load_error_kind: None,
};

View File

@@ -145,6 +145,80 @@ fn version_emits_json_when_requested() {
parsed["executable_path"].is_string(),
"executable_path must be a string in version JSON so callers can identify which binary is running"
);
let binary_provenance = parsed["binary_provenance"]
.as_object()
.expect("version JSON must include binary_provenance object (#797)");
assert!(matches!(
binary_provenance["status"].as_str(),
Some("known" | "unknown")
));
assert_eq!(binary_provenance["git_sha"], parsed["git_sha"]);
assert_eq!(binary_provenance["target"], parsed["target"]);
assert_eq!(binary_provenance["build_date"], parsed["build_date"]);
assert_eq!(
binary_provenance["executable_path"],
parsed["executable_path"]
);
assert!(
binary_provenance["hint"].is_string() || binary_provenance["hint"].is_null(),
"binary provenance must classify missing/stale lineage with a structured hint field"
);
}
#[test]
fn version_status_doctor_include_binary_provenance_797() {
let root = git_temp_dir("binary-provenance-797");
fs::write(root.join("tracked.txt"), "v1").expect("write tracked file");
let git_commands: &[&[&str]] = &[
&["config", "user.email", "test@claw.test"],
&["config", "user.name", "Test"],
&["add", "tracked.txt"],
&["commit", "-m", "init"],
];
for args in git_commands {
let output = Command::new("git")
.args(*args)
.current_dir(&root)
.output()
.expect("git fixture command should launch");
assert!(
output.status.success(),
"git fixture command failed: {args:?}\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let version = assert_json_command(&root, &["--output-format", "json", "version"]);
assert_eq!(version["kind"], "version");
assert!(matches!(
version["binary_provenance"]["status"].as_str(),
Some("known" | "unknown")
));
assert!(version["binary_provenance"]["workspace_git_sha"].is_string());
assert!(
version["binary_provenance"]["workspace_match"].is_boolean()
|| version["binary_provenance"]["workspace_match"].is_null()
);
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
assert_eq!(status["kind"], "status");
assert_eq!(
status["binary_provenance"]["workspace_git_sha"],
version["binary_provenance"]["workspace_git_sha"]
);
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
let system = doctor["checks"]
.as_array()
.expect("doctor checks")
.iter()
.find(|check| check["name"] == "system")
.expect("system check");
assert_eq!(
system["binary_provenance"]["workspace_git_sha"],
version["binary_provenance"]["workspace_git_sha"]
);
}
#[test]
@@ -767,6 +841,17 @@ fn doctor_and_resume_status_emit_json_when_requested() {
.expect("workspace check");
assert!(workspace["cwd"].as_str().is_some());
assert!(workspace["in_git_repo"].is_boolean());
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
assert_eq!(status["kind"], "status");
assert!(matches!(
status["binary_provenance"]["status"].as_str(),
Some("known" | "unknown")
));
assert!(status["binary_provenance"]["executable_path"].is_string());
assert!(
status["binary_provenance"]["workspace_match"].is_boolean()
|| status["binary_provenance"]["workspace_match"].is_null()
);
let boot_preflight = checks
.iter()
@@ -800,6 +885,14 @@ fn doctor_and_resume_status_emit_json_when_requested() {
assert!(sandbox["enabled"].is_boolean());
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
let system = checks
.iter()
.find(|check| check["name"] == "system")
.expect("system check");
assert!(matches!(
system["binary_provenance"]["status"].as_str(),
Some("known" | "unknown")
));
let session_path = write_session_fixture(&root, "resume-json", Some("hello"));
let resumed = assert_json_command(
&root,