diff --git a/README.md b/README.md index 51810f62..0f338ae7 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,7 @@ bt trace import claude SESSION_ID bt trace import claude SESSION_ID --attach ``` -See the agent guides for limitations. Antigravity setup requires a -Unix-compatible shell. +See the agent guides for limitations. ## Manage tracing diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs index a8c23abf..e89fa6f5 100644 --- a/bt-daemon/src/lib.rs +++ b/bt-daemon/src/lib.rs @@ -387,7 +387,7 @@ pub(crate) fn should_flush_ingress_event(env: &wire::Envelope) -> bool { /// must never fail the agent's turn should treat any `Err` as non-fatal and /// exit 0. pub async fn run_hook( - args: HookArgs, + mut args: HookArgs, mut route: SessionRoute, host: HostInfo, ) -> anyhow::Result<()> { @@ -403,6 +403,8 @@ pub async fn run_hook( } let mut payload = read_stdin_json()?; + resolve_dynamic_hook_versions(&mut args, &payload); + if let Some(field) = &args.transcript_path_field { add_transcript_observation(&mut payload, field); } @@ -430,6 +432,69 @@ pub async fn run_hook( Ok(()) } +fn resolve_dynamic_hook_versions(args: &mut HookArgs, payload: &serde_json::Value) { + if args.source_version.is_none() { + args.source_version = source_version_from_env(&args.source) + .or_else(|| source_version_from_payload(&args.source, payload)); + } + if args.plugin_version.is_none() { + args.plugin_version = plugin_version_from_plugin_root(&args.source); + } +} + +fn source_version_from_env(source: &str) -> Option { + let key = match source { + "claude-code" => "CLAUDE_CODE_VERSION", + "codex" => "CODEX_VERSION", + "grok" => "GROK_VERSION", + "antigravity" => "AGY_VERSION", + _ => return None, + }; + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn source_version_from_payload(source: &str, payload: &serde_json::Value) -> Option { + let fields: &[&str] = match source { + "codex" => &["cli_version", "version"], + "claude-code" | "grok" | "antigravity" => &["version", "cli_version"], + _ => &[], + }; + fields + .iter() + .find_map(|field| json_str_field(payload, field)) +} + +fn plugin_version_from_plugin_root(source: &str) -> Option { + let root = match source { + "claude-code" => std::env::var_os("CLAUDE_PLUGIN_ROOT"), + "codex" => std::env::var_os("PLUGIN_ROOT"), + "grok" => std::env::var_os("GROK_PLUGIN_ROOT"), + // Antigravity runs plugin hooks with the plugin root as cwd. + "antigravity" => std::env::current_dir() + .ok() + .map(|path| path.into_os_string()), + _ => None, + }?; + let manifest = match source { + "claude-code" => ".claude-plugin/plugin.json", + "codex" => ".codex-plugin/plugin.json", + "grok" => ".grok-plugin/plugin.json", + "antigravity" => "plugin.json", + _ => return None, + }; + plugin_version_from_manifest(&PathBuf::from(root).join(manifest)) +} + +fn plugin_version_from_manifest(path: &std::path::Path) -> Option { + serde_json::from_slice::(&std::fs::read(path).ok()?) + .ok()? + .get("version")? + .as_str() + .map(str::to_owned) +} + /// Apply one invocation-local JSON metadata override to a non-secret route. /// /// The route is then carried unchanged through live hooks, managed runs, and @@ -1658,6 +1723,23 @@ mod tests { assert_ne!(initialize["client"]["plugin_version"], "1.0.13"); } + #[test] + fn hook_versions_are_discovered_without_hook_configuration_literals() { + let payload = serde_json::json!({"cli_version": "2.3.4"}); + assert_eq!( + source_version_from_payload("codex", &payload).as_deref(), + Some("2.3.4") + ); + + let temp = tempfile::tempdir().unwrap(); + let manifest = temp.path().join("plugin.json"); + std::fs::write(&manifest, br#"{"version":"5.6.7"}"#).unwrap(); + assert_eq!( + plugin_version_from_manifest(&manifest).as_deref(), + Some("5.6.7") + ); + } + #[test] fn hook_flush_recognizes_native_and_documented_terminal_events() { for event in ["session_end", "SessionEnd"] { diff --git a/bt-daemon/src/setup.rs b/bt-daemon/src/setup.rs index eefe3e72..123cc61c 100644 --- a/bt-daemon/src/setup.rs +++ b/bt-daemon/src/setup.rs @@ -24,7 +24,6 @@ const OPENCODE_PACKAGE_MANIFEST: &str = include_str!("../../src/plugins/opencode/content/package.json"); const ANTIGRAVITY_PLUGIN: &str = "braintrust-antigravity-tracing"; const LEGACY_CLAUDE_TRACING_ENV_KEYS: [&str; 2] = ["BRAINTRUST_CC_PROJECT", "BRAINTRUST_CC_DEBUG"]; -#[cfg(unix)] const ANTIGRAVITY_PLUGIN_SOURCE: &str = "https://github.com/braintrustdata/braintrust-antigravity-plugin"; @@ -144,7 +143,6 @@ fn pi_update_required() -> bool { trait CommandRunner { fn json(&mut self, program: &str, args: &[&str]) -> anyhow::Result; - #[cfg(unix)] fn json_in_home(&mut self, program: &str, args: &[&str], home: &Path) -> anyhow::Result; fn run(&mut self, program: &str, args: &[&str]) -> anyhow::Result<()>; fn run_in_home(&mut self, program: &str, args: &[&str], home: &Path) -> anyhow::Result<()>; @@ -168,7 +166,6 @@ impl CommandRunner for SystemCommandRunner { .with_context(|| format!("`{program} {}` returned invalid JSON", args.join(" "))) } - #[cfg(unix)] fn json_in_home(&mut self, program: &str, args: &[&str], home: &Path) -> anyhow::Result { let output = ProcessCommand::new(program) .args(args) @@ -722,7 +719,6 @@ fn remove_legacy_antigravity_registration(config_dir: &Path) -> anyhow::Result<( Ok(()) } -#[cfg(unix)] fn setup_antigravity_at(runner: &mut impl CommandRunner, config_dir: &Path) -> anyhow::Result<()> { runner.run_in_home( "agy", @@ -738,11 +734,6 @@ fn setup_antigravity_at(runner: &mut impl CommandRunner, config_dir: &Path) -> a remove_legacy_antigravity_registration(config_dir) } -#[cfg(not(unix))] -fn setup_antigravity_at(_: &mut impl CommandRunner, _: &Path) -> anyhow::Result<()> { - bail!("Google Antigravity tracing setup currently requires a Unix-compatible `sh`") -} - fn setup_antigravity(runner: &mut impl CommandRunner) -> anyhow::Result<()> { setup_antigravity_at(runner, &paths::antigravity_config_dir()) } @@ -764,7 +755,6 @@ fn disable_antigravity(runner: &mut impl CommandRunner) -> anyhow::Result<()> { disable_antigravity_at(runner, &paths::antigravity_config_dir()) } -#[cfg(unix)] fn update_antigravity_at(runner: &mut impl CommandRunner, config_dir: &Path) -> anyhow::Result<()> { let home = antigravity_home(config_dir)?; let plugins = runner.json_in_home("agy", &["plugin", "list"], home)?; @@ -788,16 +778,10 @@ fn update_antigravity_at(runner: &mut impl CommandRunner, config_dir: &Path) -> ) } -#[cfg(unix)] fn update_antigravity(runner: &mut impl CommandRunner) -> anyhow::Result<()> { update_antigravity_at(runner, &paths::antigravity_config_dir()) } -#[cfg(not(unix))] -fn update_antigravity(_: &mut impl CommandRunner) -> anyhow::Result<()> { - bail!("Google Antigravity tracing updates currently require a Unix-compatible `sh`") -} - fn enable_tracing_at(path: &Path, mut route: SessionRoute) -> anyhow::Result<()> { let mut settings = load_object(path)?; if route.additional_metadata.is_none() { @@ -1002,7 +986,6 @@ mod tests { .ok_or_else(|| anyhow::anyhow!("missing fake JSON response")) } - #[cfg(unix)] fn json_in_home( &mut self, program: &str, @@ -1030,16 +1013,13 @@ mod tests { } } - #[cfg(unix)] struct MissingAgyRunner; - #[cfg(unix)] impl CommandRunner for MissingAgyRunner { fn json(&mut self, _: &str, _: &[&str]) -> anyhow::Result { unreachable!() } - #[cfg(unix)] fn json_in_home(&mut self, _: &str, _: &[&str], _: &Path) -> anyhow::Result { unreachable!() } @@ -1393,7 +1373,6 @@ mod tests { } #[test] - #[cfg(unix)] fn antigravity_installs_published_plugin_and_removes_legacy_registration() { let temp = tempfile::tempdir().unwrap(); let config_dir = temp.path().join(".gemini/config"); @@ -1420,7 +1399,6 @@ mod tests { } #[test] - #[cfg(unix)] fn antigravity_update_checks_and_updates_the_same_overridden_home() { let temp = tempfile::tempdir().unwrap(); let config_dir = temp.path().join(".gemini/config"); @@ -1445,7 +1423,6 @@ mod tests { } #[test] - #[cfg(unix)] fn antigravity_setup_is_idempotent() { let temp = tempfile::tempdir().unwrap(); let config_dir = temp.path().join(".gemini/config"); @@ -1480,7 +1457,6 @@ mod tests { } #[test] - #[cfg(unix)] fn antigravity_setup_relies_on_native_plugin_hooks() { let temp = tempfile::tempdir().unwrap(); let config_dir = temp.path().join(".gemini/config"); @@ -1494,7 +1470,6 @@ mod tests { } #[test] - #[cfg(unix)] fn antigravity_setup_reports_a_missing_cli_without_changing_hooks() { let temp = tempfile::tempdir().unwrap(); let config_dir = temp.path().join(".gemini/config"); diff --git a/bt-daemon/tests/codex_translator.rs b/bt-daemon/tests/codex_translator.rs index 29082419..a7d12089 100644 --- a/bt-daemon/tests/codex_translator.rs +++ b/bt-daemon/tests/codex_translator.rs @@ -453,6 +453,7 @@ fn attached_codex_root_merge_preserves_external_parent() { flush_mode: FlushMode::FireAndForget, additional_metadata: None, tags: Vec::new(), + span_plugins: Vec::new(), }), }; let registry = Registry::default_agents(); @@ -1664,6 +1665,7 @@ fn codex_root_source_merge_after_stop_keeps_external_parent() { flush_mode: FlushMode::FireAndForget, additional_metadata: None, tags: Vec::new(), + span_plugins: Vec::new(), }), }; let registry = Registry::default_agents(); diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs index f8558aad..38e13eec 100644 --- a/bt-daemon/tests/pipeline.rs +++ b/bt-daemon/tests/pipeline.rs @@ -2452,81 +2452,32 @@ fn prefix_through_lines(bytes: &[u8], line_count: usize) -> usize { } #[cfg(all(feature = "cli", unix))] -async fn assert_packaged_grok_hook_mapping( - temp: &Path, - plugin_version: &str, - payload: &serde_json::Value, -) { - use std::os::unix::fs::PermissionsExt; - use tokio::io::AsyncWriteExt; - - let fake_bt = temp.join("record-bt.sh"); - let args_file = temp.join("packaged-args.txt"); - let stdin_file = temp.join("packaged-stdin.json"); - std::fs::write( - &fake_bt, - "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$BT_ARGS_FILE\"\ncat > \"$BT_STDIN_FILE\"\n", +fn assert_packaged_grok_hook_mapping() { + let hooks: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(grok_package_path("hooks/hooks.json")).unwrap(), ) .unwrap(); - std::fs::set_permissions(&fake_bt, std::fs::Permissions::from_mode(0o755)).unwrap(); - - let mut child = tokio::process::Command::new("bash") - .arg(grok_package_path("hooks/forward.sh")) - .env("BT_BIN", &fake_bt) - .env("BT_ARGS_FILE", &args_file) - .env("BT_STDIN_FILE", &stdin_file) - .env("GROK_VERSION", "1.0.13") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() - .unwrap(); - let encoded = serde_json::to_vec(payload).unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&encoded) - .await - .unwrap(); - let output = child.wait_with_output().await.unwrap(); - assert!( - output.status.success(), - "packaged Grok hook failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let args: Vec<_> = std::fs::read_to_string(args_file) - .unwrap() - .lines() - .map(str::to_string) - .collect(); - assert_eq!( - args, - [ - "trace", - "hook", - "--source", - "grok", - "--plugin-version", - plugin_version, - "--session-id-field", - "sessionId", - "--event-field", - "hookEventName", - "--transcript-path-field", - "transcriptPath", - "--source-version", - "1.0.13", - ] - ); - let forwarded: serde_json::Value = - serde_json::from_slice(&std::fs::read(stdin_file).unwrap()).unwrap(); - assert_eq!( - &forwarded, payload, - "the adapter must forward stdin unchanged" - ); + let expected_args = serde_json::json!([ + "trace", + "hook", + "--source", + "grok", + "--session-id-field", + "sessionId", + "--event-field", + "hookEventName", + "--transcript-path-field", + "transcriptPath", + ]); + for groups in hooks["hooks"].as_object().unwrap().values() { + for group in groups.as_array().unwrap() { + for hook in group["hooks"].as_array().unwrap() { + assert_eq!(hook["type"], "command"); + assert_eq!(hook["command"], "bt"); + assert_eq!(hook["args"], expected_args); + } + } + } } #[cfg(all(feature = "cli", unix))] @@ -2689,7 +2640,7 @@ async fn packaged_grok_hook_replays_bounded_transcripts_to_isolated_debug_routes "cwd": "/repo/primary", "workspaceRoot": "/repo" }); - assert_packaged_grok_hook_mapping(temp.path(), plugin_version, &primary_stop).await; + assert_packaged_grok_hook_mapping(); let flushes = Arc::new(Mutex::new(HashMap::new())); let created = Arc::new(Mutex::new(Vec::new())); diff --git a/scripts/set-plugin-version.py b/scripts/set-plugin-version.py index 266edcfa..4ae365a3 100644 --- a/scripts/set-plugin-version.py +++ b/scripts/set-plugin-version.py @@ -2,9 +2,8 @@ """Set a release version on every distributed version surface for an agent. Versioning is per-plugin: every plugin under an agent carries its own -.-plugin/plugin.json with a `version` field. Grok's hook adapter also -embeds the plugin version forwarded to the daemon, so its manifest and adapter -constant are stamped together. The marketplace manifest is NOT touched. +.-plugin/plugin.json with a `version` field. The marketplace manifest is +NOT touched. Only version values are rewritten, so surrounding files keep their formatting. @@ -23,10 +22,6 @@ } VERSION_RE = re.compile(r'("version"\s*:\s*")[^"]*(")') -GROK_ADAPTER = "src/plugins/grok/content/hooks/forward.sh" -GROK_PLUGIN_VERSION_RE = re.compile(r'^(PLUGIN_VERSION=")[^"]*(")$', re.MULTILINE) - - def main() -> None: if len(sys.argv) != 3: sys.exit("usage: set-plugin-version.py ") @@ -40,8 +35,6 @@ def main() -> None: sys.exit(f"no plugin manifests found for '{agent}' ({pattern})") surfaces = [(path, VERSION_RE, "version") for path in manifests] - if agent == "grok": - surfaces.append((GROK_ADAPTER, GROK_PLUGIN_VERSION_RE, "PLUGIN_VERSION")) changes = [] for path, version_re, label in surfaces: diff --git a/scripts/test-hook-forwarders.sh b/scripts/test-hook-forwarders.sh old mode 100644 new mode 100755 index 11791b6e..27edb56e --- a/scripts/test-hook-forwarders.sh +++ b/scripts/test-hook-forwarders.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash -# Exercise the packaged hook shims with a fake bt CLI. This proves that raw -# stdin and the canonical source identity reach `bt trace hook`, and that -# installer or forwarding failures never fail an agent hook. +# Exercise direct hook execution with a fake bt CLI. This proves that raw stdin +# and each canonical source identity reach `bt trace hook` without a shell shim. set -euo pipefail @@ -18,11 +17,11 @@ exit "${BT_STUB_STATUS:-0}" EOF chmod +x "$TEST_DIR/bt" -PAYLOAD='{"session_id":"shim-test","hook_event_name":"SessionStart","message":"unchanged"}' +PAYLOAD='{"session_id":"direct-test","hook_event_name":"SessionStart","message":"unchanged"}' exercise() { local name="$1" - local source="$2" + local expected_args="$2" shift 2 local args_file="$TEST_DIR/$name.args" local stdin_file="$TEST_DIR/$name.stdin" @@ -33,78 +32,52 @@ exercise() { BT_CAPTURE_STDIN="$stdin_file" \ "$@" - [[ "$(cat "$args_file")" == "trace hook --source $source" ]] - [[ "$(cat "$stdin_file")" == "$PAYLOAD" ]] - - # The shim must swallow a daemon-client failure after forwarding the payload. - printf '%s' "$PAYLOAD" | env \ - PATH="$TEST_DIR:$PATH" \ - BT_CAPTURE_ARGS="$args_file" \ - BT_CAPTURE_STDIN="$stdin_file" \ - BT_STUB_STATUS=23 \ - "$@" -} - -exercise claude claude-code \ - bash "$DIST_DIR/claude/plugins/trace-claude-code/hooks/forward.sh" -exercise codex codex \ - bash "$DIST_DIR/codex/plugins/trace-codex/bin/codex-hook.sh" - -# Exercise first-use bootstrap without touching the developer's installation. -# The fake curl materializes a fake bt binary and emits a no-op installer body. -BOOTSTRAP_DIR="$TEST_DIR/bootstrap" -mkdir "$BOOTSTRAP_DIR" -cat > "$BOOTSTRAP_DIR/curl" <<'EOF' -#!/bin/bash -printf '%s\n' "$*" > "$BT_INSTALL_CAPTURE" -cp "$BT_INSTALLABLE" "$BT_INSTALL_DEST" -chmod +x "$BT_INSTALL_DEST" -printf '%s\n' '#!/bin/bash' 'exit 0' -EOF -cat > "$TEST_DIR/installable-bt" <<'EOF' -#!/bin/bash -printf '%s\n' "$*" > "$BT_CAPTURE_ARGS" -cat > "$BT_CAPTURE_STDIN" -EOF -chmod +x "$BOOTSTRAP_DIR/curl" "$TEST_DIR/installable-bt" - -bootstrap() { - local name="$1" - local source="$2" - shift 2 - local args_file="$TEST_DIR/$name.bootstrap.args" - local stdin_file="$TEST_DIR/$name.bootstrap.stdin" - local install_file="$TEST_DIR/$name.install.args" - - rm -f "$BOOTSTRAP_DIR/bt" - printf '%s' "$PAYLOAD" | env \ - PATH="$BOOTSTRAP_DIR:/usr/bin:/bin" \ - BT_CAPTURE_ARGS="$args_file" \ - BT_CAPTURE_STDIN="$stdin_file" \ - BT_INSTALL_CAPTURE="$install_file" \ - BT_INSTALLABLE="$TEST_DIR/installable-bt" \ - BT_INSTALL_DEST="$BOOTSTRAP_DIR/bt" \ - XDG_BIN_HOME="$BOOTSTRAP_DIR" \ - CARGO_HOME="$BOOTSTRAP_DIR/cargo" \ - "$@" - - [[ "$(cat "$install_file")" == "-fsSL https://bt.dev/cli/install.sh" ]] - [[ "$(cat "$args_file")" == "trace hook --source $source" ]] + [[ "$(cat "$args_file")" == "$expected_args" ]] [[ "$(cat "$stdin_file")" == "$PAYLOAD" ]] } -bootstrap claude claude-code \ - bash "$DIST_DIR/claude/plugins/trace-claude-code/hooks/forward.sh" -bootstrap codex codex \ - bash "$DIST_DIR/codex/plugins/trace-codex/bin/codex-hook.sh" - -# No bt binary is also fail-open. Use an empty path so the test does not depend -# on whether the host running the suite has bt or curl installed. -EMPTY_PATH="$TEST_DIR/empty-path" -mkdir "$EMPTY_PATH" -printf '%s' "$PAYLOAD" | PATH="$EMPTY_PATH" XDG_BIN_HOME="$EMPTY_PATH" CARGO_HOME="$EMPTY_PATH" /bin/bash \ - "$DIST_DIR/claude/plugins/trace-claude-code/hooks/forward.sh" -printf '%s' "$PAYLOAD" | PATH="$EMPTY_PATH" XDG_BIN_HOME="$EMPTY_PATH" CARGO_HOME="$EMPTY_PATH" /bin/bash \ - "$DIST_DIR/codex/plugins/trace-codex/bin/codex-hook.sh" +exercise claude 'trace hook --source claude-code' \ + bt trace hook --source claude-code +exercise codex 'trace hook --source codex' \ + bt trace hook --source codex +exercise grok 'trace hook --source grok --session-id-field sessionId --event-field hookEventName --transcript-path-field transcriptPath' \ + bt trace hook --source grok --session-id-field sessionId --event-field hookEventName --transcript-path-field transcriptPath +exercise antigravity 'trace hook --source antigravity --session-id-field conversationId --event Stop --transcript-path-field transcriptPath --flush-on-turn-end' \ + bt trace hook --source antigravity --session-id-field conversationId --event Stop --transcript-path-field transcriptPath --flush-on-turn-end + +python3 - "$DIST_DIR" <<'PY' +import json +import sys +from pathlib import Path + +dist = Path(sys.argv[1]) + +codex = json.loads((dist / "codex/plugins/trace-codex/hooks/hooks.json").read_text())["hooks"] +for groups in codex.values(): + for group in groups: + for hook in group["hooks"]: + assert hook["command"] == "bt trace hook --source codex" + assert hook["commandWindows"] == "bt trace hook --source codex" + +claude = json.loads((dist / "claude/plugins/trace-claude-code/hooks/hooks.json").read_text())["hooks"] +for groups in claude.values(): + for group in groups: + for hook in group["hooks"]: + assert hook["command"] == "bt trace hook --source claude-code" + assert "args" not in hook + +grok = json.loads((dist / "grok/hooks/hooks.json").read_text())["hooks"] +for groups in grok.values(): + for group in groups: + for hook in group["hooks"]: + assert hook["command"] == "bt" + assert hook["args"][:4] == ["trace", "hook", "--source", "grok"] + +antigravity = json.loads((dist / "antigravity/hooks.json").read_text())["braintrust-antigravity-tracing"] +for groups in antigravity.values(): + for group in groups: + for hook in group.get("hooks", [group]): + assert hook["command"].startswith("bt trace hook --source antigravity ") +PY echo "test: hook forwarders OK" diff --git a/src/plugins/antigravity/content/README.md b/src/plugins/antigravity/content/README.md index ec2ac6db..51846e92 100644 --- a/src/plugins/antigravity/content/README.md +++ b/src/plugins/antigravity/content/README.md @@ -12,7 +12,6 @@ Trace Google Antigravity coding sessions in Braintrust. Prerequisites: - [Google Antigravity](https://antigravity.google/) -- A Unix-compatible `sh` (persistent setup is not supported on Windows) - The [Braintrust CLI (`bt`)](https://www.braintrust.dev/docs/reference/cli/quickstart) Install the plugin and choose where traces are sent: @@ -24,7 +23,8 @@ bt trace enable antigravity --project my-coding-agent Setup installs the hooks and saves non-secret routing settings in `~/.gemini/config/braintrust.json`. Use `--profile` or `--org` to select a -Braintrust profile or organization. Restart Antigravity after setup. +Braintrust profile or organization. Restart Antigravity after setup. The plugin +invokes the installed `bt` CLI directly on macOS, Linux, and Windows. ## What is captured diff --git a/src/plugins/antigravity/content/bin/antigravity-hook.sh b/src/plugins/antigravity/content/bin/antigravity-hook.sh deleted file mode 100755 index 6b0d81d0..00000000 --- a/src/plugins/antigravity/content/bin/antigravity-hook.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -# Thin, credential-free Antigravity hook adapter. Antigravity runs hooks from -# the directory containing hooks.json and requires a JSON response on stdout. -# Tracing is deliberately fail-open: a missing or unhealthy bt CLI must never -# interrupt the coding-agent loop. - -event=${1:-} -bt_bin=${BT_BIN:-bt} - -if [ -n "$event" ] && command -v "$bt_bin" >/dev/null 2>&1; then - "$bt_bin" trace hook \ - --source antigravity \ - --session-id-field conversationId \ - --event "$event" \ - --transcript-path-field transcriptPath \ - --flush-on-turn-end \ - >/dev/null 2>&1 || : -fi - -case "$event" in - Stop) printf '{"decision":""}\n' ;; - *) printf '{}\n' ;; -esac - -exit 0 diff --git a/src/plugins/antigravity/content/hooks.json b/src/plugins/antigravity/content/hooks.json index 675f3716..3726ed7f 100644 --- a/src/plugins/antigravity/content/hooks.json +++ b/src/plugins/antigravity/content/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "sh \"./bin/antigravity-hook.sh\" PostToolUse" + "command": "bt trace hook --source antigravity --session-id-field conversationId --event PostToolUse --transcript-path-field transcriptPath --flush-on-turn-end" } ] } @@ -14,19 +14,19 @@ "PreInvocation": [ { "type": "command", - "command": "sh \"./bin/antigravity-hook.sh\" PreInvocation" + "command": "bt trace hook --source antigravity --session-id-field conversationId --event PreInvocation --transcript-path-field transcriptPath --flush-on-turn-end" } ], "PostInvocation": [ { "type": "command", - "command": "sh \"./bin/antigravity-hook.sh\" PostInvocation" + "command": "bt trace hook --source antigravity --session-id-field conversationId --event PostInvocation --transcript-path-field transcriptPath --flush-on-turn-end" } ], "Stop": [ { "type": "command", - "command": "sh \"./bin/antigravity-hook.sh\" Stop" + "command": "bt trace hook --source antigravity --session-id-field conversationId --event Stop --transcript-path-field transcriptPath --flush-on-turn-end" } ] } diff --git a/src/plugins/antigravity/test/test_hook.sh b/src/plugins/antigravity/test/test_hook.sh deleted file mode 100755 index 54990284..00000000 --- a/src/plugins/antigravity/test/test_hook.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -HOOK="${1:?usage: test_hook.sh }" -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -BT_STUB="$TMP/bt" -cp /dev/stdin "$BT_STUB" <<'STUB' -#!/bin/sh -printf '%s\n' "$@" > "$BT_STUB_ARGS" -cp /dev/stdin "$BT_STUB_STDIN" -exit "${BT_STUB_EXIT:-0}" -STUB -chmod +x "$BT_STUB" - -export BT_STUB_ARGS="$TMP/args" -export BT_STUB_STDIN="$TMP/stdin" -payload='{"conversationId":"test","transcriptPath":"/tmp/transcript.jsonl"}' - -response=$(printf '%s' "$payload" | BT_BIN="$BT_STUB" "$HOOK" PostInvocation) -[[ "$response" == '{}' ]] -cmp -s "$TMP/stdin" <(printf '%s' "$payload") -grep -Fx -- 'trace' "$TMP/args" >/dev/null -grep -Fx -- 'antigravity' "$TMP/args" >/dev/null -grep -Fx -- 'conversationId' "$TMP/args" >/dev/null -grep -Fx -- 'transcriptPath' "$TMP/args" >/dev/null - -response=$(printf '%s' "$payload" | BT_STUB_EXIT=1 BT_BIN="$BT_STUB" "$HOOK" Stop) -[[ "$response" == '{"decision":""}' ]] - -echo "test: antigravity hook adapter OK" diff --git a/src/plugins/antigravity/validate.sh b/src/plugins/antigravity/validate.sh index cbdefcc2..4322ba87 100755 --- a/src/plugins/antigravity/validate.sh +++ b/src/plugins/antigravity/validate.sh @@ -4,10 +4,9 @@ set -euo pipefail TARGET_DIR="${1:?usage: validate.sh }" fail() { echo "validate: $*" >&2; exit 1; } -for file in plugin.json hooks.json bin/antigravity-hook.sh README.md LICENSE; do +for file in plugin.json hooks.json README.md LICENSE; do [[ -f "$TARGET_DIR/$file" ]] || fail "missing $file" done -[[ -x "$TARGET_DIR/bin/antigravity-hook.sh" ]] || fail "hook adapter is not executable" if command -v jq >/dev/null 2>&1; then jq empty "$TARGET_DIR/plugin.json" "$TARGET_DIR/hooks.json" >/dev/null \ @@ -17,6 +16,28 @@ else python3 -m json.tool "$TARGET_DIR/hooks.json" >/dev/null || fail "invalid hooks.json" fi -"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test/test_hook.sh" \ - "$TARGET_DIR/bin/antigravity-hook.sh" +python3 - "$TARGET_DIR/hooks.json" <<'PY' || fail "Antigravity hooks must invoke bt directly" +import json +import sys + +with open(sys.argv[1]) as f: + hooks = json.load(f)["braintrust-antigravity-tracing"] + +expected = { + "PostToolUse": "PostToolUse", + "PreInvocation": "PreInvocation", + "PostInvocation": "PostInvocation", + "Stop": "Stop", +} +assert set(hooks) == set(expected) +for event, expected_event in expected.items(): + for group in hooks[event]: + for hook in group.get("hooks", [group]): + assert hook["type"] == "command" + assert hook["command"] == ( + "bt trace hook --source antigravity --session-id-field conversationId " + f"--event {expected_event} --transcript-path-field transcriptPath " + "--flush-on-turn-end" + ) +PY echo "validate: antigravity dist OK ($TARGET_DIR)" diff --git a/src/plugins/claude/content/plugins/trace-claude-code/.claude-plugin/plugin.json b/src/plugins/claude/content/plugins/trace-claude-code/.claude-plugin/plugin.json index e98e8115..6e63c7f6 100644 --- a/src/plugins/claude/content/plugins/trace-claude-code/.claude-plugin/plugin.json +++ b/src/plugins/claude/content/plugins/trace-claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "trace-claude-code", "description": "Automatically trace Claude Code conversations to Braintrust for observability. Captures sessions, conversation turns, and tool calls as hierarchical traces.", - "version": "3.0.0", + "version": "3.0.1", "author": { "name": "Braintrust" } diff --git a/src/plugins/claude/content/plugins/trace-claude-code/README.md b/src/plugins/claude/content/plugins/trace-claude-code/README.md index 1cba9c8c..e62229ed 100644 --- a/src/plugins/claude/content/plugins/trace-claude-code/README.md +++ b/src/plugins/claude/content/plugins/trace-claude-code/README.md @@ -7,10 +7,10 @@ bt trace hook --source claude-code ``` Use `bt trace enable claude --project ` to install and configure it. -The hook first installs the `bt` CLI with the official installer when it is not -already available, then forwards the event. The plugin is credential-free and -fail-open; the `bt` CLI and shared daemon own authentication, event journaling, -trace construction, and delivery. +Claude Code directly executes the configured `bt` CLI for each hook; it does +not launch a shell or an intermediate forwarding script. The plugin is +credential-free; the `bt` CLI and shared daemon own authentication, event +journaling, trace construction, and delivery. ## Supported surfaces diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/forward.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/forward.sh deleted file mode 100644 index 72a660ec..00000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/forward.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# Thin, fail-open bridge from Claude Code hooks to the shared Braintrust daemon. - -BT_INSTALL_URL="https://bt.dev/cli/install.sh" - -resolve_bt() { - if command -v bt >/dev/null 2>&1; then - command -v bt - return 0 - fi - - local candidate - for candidate in \ - "${XDG_BIN_HOME:-${HOME:-}/.local/bin}/bt" \ - "${CARGO_HOME:-${HOME:-}/.cargo}/bin/bt"; do - if [[ -n "$candidate" && -x "$candidate" ]]; then - printf '%s\n' "$candidate" - return 0 - fi - done - return 1 -} - -BT_BIN="$(resolve_bt || true)" -if [[ -z "$BT_BIN" ]]; then - if ! command -v curl >/dev/null 2>&1; then - printf 'trace-claude-code: curl is required to install bt; tracing skipped\n' >&2 - exit 0 - fi - - printf 'trace-claude-code: bt CLI not found; installing it now\n' >&2 - if ! (set -o pipefail; curl -fsSL "$BT_INSTALL_URL" | bash) >&2; then - printf 'trace-claude-code: bt installation failed; tracing skipped\n' >&2 - exit 0 - fi - - BT_BIN="$(resolve_bt || true)" - if [[ -z "$BT_BIN" ]]; then - printf 'trace-claude-code: bt was installed but is not executable; tracing skipped\n' >&2 - exit 0 - fi -fi - -"$BT_BIN" trace hook --source claude-code || true -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json b/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json index 805b7134..43ca36a4 100644 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json +++ b/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -28,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -40,7 +40,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -52,7 +52,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -64,7 +64,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -75,7 +75,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -87,7 +87,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -99,7 +99,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -110,7 +110,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -122,7 +122,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -133,7 +133,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] @@ -144,7 +144,7 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh\"", + "command": "bt trace hook --source claude-code", "async": false } ] diff --git a/src/plugins/claude/validate.sh b/src/plugins/claude/validate.sh index e6bea1cb..967adaeb 100755 --- a/src/plugins/claude/validate.sh +++ b/src/plugins/claude/validate.sh @@ -32,7 +32,6 @@ check_json "$MARKETPLACE" required=( "plugins/trace-claude-code/.claude-plugin/plugin.json" "plugins/trace-claude-code/hooks/hooks.json" - "plugins/trace-claude-code/hooks/forward.sh" ) for rel in "${required[@]}"; do [[ -f "$TARGET_DIR/$rel" ]] || fail "missing $rel" @@ -59,7 +58,7 @@ if find "$TARGET_DIR" -name '.mcp.json' -print -quit | grep -q .; then fi python3 - "$TARGET_DIR/plugins/trace-claude-code/hooks/hooks.json" <<'PY' \ - || fail "Claude hooks do not all use the blocking daemon forwarder" + || fail "Claude hooks do not all directly execute the bt daemon client" import json import sys @@ -76,22 +75,11 @@ for definitions in hooks.values(): for definition in definitions: for hook in definition["hooks"]: assert hook["type"] == "command" - assert hook["command"] == 'bash "${CLAUDE_PLUGIN_ROOT}/hooks/forward.sh"' + assert hook["command"] == "bt trace hook --source claude-code" + assert "args" not in hook assert hook["async"] is False PY -grep -Fq 'trace hook --source claude-code' \ - "$TARGET_DIR/plugins/trace-claude-code/hooks/forward.sh" \ - || fail "Claude forwarder does not invoke bt trace hook with source claude-code" -python3 - "$TARGET_DIR/plugins/trace-claude-code/hooks/forward.sh" <<'PY' \ - || fail "Claude forwarder does not install bt before forwarding" -import sys - -text = open(sys.argv[1]).read() -assert text.index("command -v bt") < text.index("curl -fsSL") -assert text.index("curl -fsSL") < text.index("trace hook --source claude-code") -PY - if find "$TARGET_DIR/plugins/trace-claude-code" -type f \( \ -name 'common.sh' -o -name 'worker.sh' -o -name 'setup.sh' -o \ -name 'package.json' -o -name 'pnpm-lock.yaml' \) -print -quit | grep -q .; then diff --git a/src/plugins/codex/content/plugins/trace-codex/.codex-plugin/plugin.json b/src/plugins/codex/content/plugins/trace-codex/.codex-plugin/plugin.json index 5db234d8..8c09f154 100644 --- a/src/plugins/codex/content/plugins/trace-codex/.codex-plugin/plugin.json +++ b/src/plugins/codex/content/plugins/trace-codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "trace-codex", - "version": "2.0.0", + "version": "2.0.1", "description": "Trace Codex sessions to Braintrust (session, turn, and tool spans).", "author": { "name": "Braintrust", diff --git a/src/plugins/codex/content/plugins/trace-codex/README.md b/src/plugins/codex/content/plugins/trace-codex/README.md index c28b5641..efc5553e 100644 --- a/src/plugins/codex/content/plugins/trace-codex/README.md +++ b/src/plugins/codex/content/plugins/trace-codex/README.md @@ -7,11 +7,11 @@ tracing daemon through: bt trace hook --source codex ``` -The plugin is intentionally a thin, fail-open adapter. Its launcher installs -the `bt` CLI with the official installer when it is not already available, then -forwards the event. The `bt` CLI owns authentication, configuration, event -journaling, trace construction, and delivery to Braintrust. No credentials are -stored in the plugin. +The plugin invokes the installed `bt` CLI directly for each hook. Install `bt` +before enabling the plugin; it owns authentication, configuration, event +journaling, trace construction, and delivery to Braintrust. It discovers the +installed plugin and Codex versions dynamically. No credentials are stored in +the plugin. ## Setup @@ -32,9 +32,8 @@ bt trace doctor codex bt trace status ``` -Hook setup or forwarding never fails a Codex turn. If installation fails or the -daemon cannot accept an event, the launcher reports the failure and -exits successfully. +Hook setup or forwarding never fails a Codex turn. If the CLI is unavailable or +the daemon cannot accept an event, Codex reports the hook failure and continues. ## Additional root metadata diff --git a/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.sh b/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.sh deleted file mode 100644 index e07e15c6..00000000 --- a/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# Thin, fail-open bridge from Codex hooks to the shared Braintrust daemon. - -BT_INSTALL_URL="https://bt.dev/cli/install.sh" - -resolve_bt() { - if command -v bt >/dev/null 2>&1; then - command -v bt - return 0 - fi - - local candidate - for candidate in \ - "${XDG_BIN_HOME:-${HOME:-}/.local/bin}/bt" \ - "${CARGO_HOME:-${HOME:-}/.cargo}/bin/bt"; do - if [[ -n "$candidate" && -x "$candidate" ]]; then - printf '%s\n' "$candidate" - return 0 - fi - done - return 1 -} - -BT_BIN="$(resolve_bt || true)" -if [[ -z "$BT_BIN" ]]; then - if ! command -v curl >/dev/null 2>&1; then - printf 'trace-codex: curl is required to install bt; tracing skipped\n' >&2 - exit 0 - fi - - printf 'trace-codex: bt CLI not found; installing it now\n' >&2 - if ! (set -o pipefail; curl -fsSL "$BT_INSTALL_URL" | bash) >&2; then - printf 'trace-codex: bt installation failed; tracing skipped\n' >&2 - exit 0 - fi - - BT_BIN="$(resolve_bt || true)" - if [[ -z "$BT_BIN" ]]; then - printf 'trace-codex: bt was installed but is not executable; tracing skipped\n' >&2 - exit 0 - fi -fi - -"$BT_BIN" trace hook --source codex || true -exit 0 diff --git a/src/plugins/codex/content/plugins/trace-codex/hooks/hooks.json b/src/plugins/codex/content/plugins/trace-codex/hooks/hooks.json index a722d393..768e5ca5 100644 --- a/src/plugins/codex/content/plugins/trace-codex/hooks/hooks.json +++ b/src/plugins/codex/content/plugins/trace-codex/hooks/hooks.json @@ -5,8 +5,8 @@ "hooks": [ { "type": "command", - "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", - "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "command": "bt trace hook --source codex", + "commandWindows": "bt trace hook --source codex", "statusMessage": "Braintrust tracing" } ] @@ -17,8 +17,8 @@ "hooks": [ { "type": "command", - "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", - "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "command": "bt trace hook --source codex", + "commandWindows": "bt trace hook --source codex", "statusMessage": "Braintrust tracing" } ] @@ -29,8 +29,8 @@ "hooks": [ { "type": "command", - "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", - "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "command": "bt trace hook --source codex", + "commandWindows": "bt trace hook --source codex", "statusMessage": "Braintrust tracing" } ] @@ -41,8 +41,8 @@ "hooks": [ { "type": "command", - "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", - "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "command": "bt trace hook --source codex", + "commandWindows": "bt trace hook --source codex", "statusMessage": "Braintrust tracing" } ] @@ -53,8 +53,8 @@ "hooks": [ { "type": "command", - "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", - "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "command": "bt trace hook --source codex", + "commandWindows": "bt trace hook --source codex", "statusMessage": "Braintrust tracing" } ] @@ -65,8 +65,8 @@ "hooks": [ { "type": "command", - "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", - "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "command": "bt trace hook --source codex", + "commandWindows": "bt trace hook --source codex", "statusMessage": "Braintrust tracing" } ] @@ -77,8 +77,8 @@ "hooks": [ { "type": "command", - "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", - "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "command": "bt trace hook --source codex", + "commandWindows": "bt trace hook --source codex", "statusMessage": "Braintrust tracing" } ] @@ -89,8 +89,8 @@ "hooks": [ { "type": "command", - "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", - "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "command": "bt trace hook --source codex", + "commandWindows": "bt trace hook --source codex", "statusMessage": "Braintrust tracing" } ] @@ -101,8 +101,8 @@ "hooks": [ { "type": "command", - "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", - "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "command": "bt trace hook --source codex", + "commandWindows": "bt trace hook --source codex", "statusMessage": "Braintrust tracing" } ] @@ -113,8 +113,8 @@ "hooks": [ { "type": "command", - "command": "bash \"${PLUGIN_ROOT}/bin/codex-hook.sh\"", - "commandWindows": "bash \"${PLUGIN_ROOT}\\bin\\codex-hook.sh\"", + "command": "bt trace hook --source codex", + "commandWindows": "bt trace hook --source codex", "statusMessage": "Braintrust tracing" } ] diff --git a/src/plugins/codex/validate.sh b/src/plugins/codex/validate.sh index 019ca1a0..0b0de2ff 100755 --- a/src/plugins/codex/validate.sh +++ b/src/plugins/codex/validate.sh @@ -32,7 +32,6 @@ check_json "$MARKETPLACE" required=( "plugins/trace-codex/.codex-plugin/plugin.json" "plugins/trace-codex/hooks/hooks.json" - "plugins/trace-codex/bin/codex-hook.sh" ) for rel in "${required[@]}"; do [[ -f "$TARGET_DIR/$rel" ]] || fail "missing $rel" @@ -74,24 +73,12 @@ for definitions in hooks.values(): for definition in definitions: for hook in definition["hooks"]: assert hook["type"] == "command" - assert hook["command"] == 'bash "${PLUGIN_ROOT}/bin/codex-hook.sh"' - assert hook["commandWindows"] == 'bash "${PLUGIN_ROOT}\\bin\\codex-hook.sh"' -PY - -grep -Fq 'trace hook --source codex' \ - "$TARGET_DIR/plugins/trace-codex/bin/codex-hook.sh" \ - || fail "Codex Unix forwarder does not invoke bt trace hook with source codex" -python3 - "$TARGET_DIR/plugins/trace-codex/bin/codex-hook.sh" <<'PY' \ - || fail "Codex forwarder does not install bt before forwarding" -import sys - -text = open(sys.argv[1]).read() -assert text.index("command -v bt") < text.index("curl -fsSL") -assert text.index("curl -fsSL") < text.index("trace hook --source codex") + assert hook["command"] == "bt trace hook --source codex" + assert hook["commandWindows"] == "bt trace hook --source codex" PY if find "$TARGET_DIR/plugins/trace-codex" -type f \( \ -name 'package.json' -o -name 'pnpm-lock.yaml' -o -name 'tsconfig.json' -o \ - -name 'codex-hook-*' \) -print -quit | grep -q .; then + -name 'codex-hook-*' -o -name 'codex-hook.sh' \) -print -quit | grep -q .; then fail "Codex tracing plugin still contains a legacy tracing runtime" fi diff --git a/src/plugins/grok/content/.grok-plugin/plugin.json b/src/plugins/grok/content/.grok-plugin/plugin.json index 166b3419..feda6f2e 100644 --- a/src/plugins/grok/content/.grok-plugin/plugin.json +++ b/src/plugins/grok/content/.grok-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "trace-grok", "description": "Beta Braintrust tracing for Grok sessions with transcript-reconstructed LLM and tool spans.", - "version": "0.0.3", + "version": "0.0.4", "author": { "name": "Braintrust" } diff --git a/src/plugins/grok/content/README.md b/src/plugins/grok/content/README.md index d9ac457c..10c93179 100644 --- a/src/plugins/grok/content/README.md +++ b/src/plugins/grok/content/README.md @@ -44,8 +44,10 @@ Each traced session includes: - session metadata such as Grok and plugin versions, working directory, workspace, and native session ID. -The plugin sends events to the local `bt` daemon, which handles credentials -and uploads traces. +The plugin invokes the installed `bt` CLI directly for each event. `bt` owns +credentials, daemon startup, and trace delivery. It records the installed +plugin and Grok versions dynamically; no release-specific hook edits are +needed. ## Caveats diff --git a/src/plugins/grok/content/hooks/forward.sh b/src/plugins/grok/content/hooks/forward.sh deleted file mode 100755 index 906315a4..00000000 --- a/src/plugins/grok/content/hooks/forward.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -# Thin, fail-open bridge from Grok hooks to the shared Braintrust daemon. - -PLUGIN_VERSION="0.0.3" - -resolve_bt() { - if [[ -n "${BT_BIN:-}" && -x "$BT_BIN" ]]; then - printf '%s\n' "$BT_BIN" - return 0 - fi - if command -v bt >/dev/null 2>&1; then - command -v bt - return 0 - fi - - local candidate - for candidate in \ - "${XDG_BIN_HOME:-${HOME:-}/.local/bin}/bt" \ - "${CARGO_HOME:-${HOME:-}/.cargo}/bin/bt"; do - if [[ -n "$candidate" && -x "$candidate" ]]; then - printf '%s\n' "$candidate" - return 0 - fi - done - return 1 -} - -BT_BIN="$(resolve_bt || true)" -if [[ -z "$BT_BIN" ]]; then - printf 'trace-grok: bt CLI not found; tracing skipped\n' >&2 - exit 0 -fi - -BT_ARGS=( - trace hook - --source grok - --plugin-version "$PLUGIN_VERSION" - --session-id-field sessionId - --event-field hookEventName - --transcript-path-field transcriptPath -) -if [[ -n "${GROK_VERSION:-}" ]]; then - BT_ARGS+=(--source-version "$GROK_VERSION") -fi - -"$BT_BIN" "${BT_ARGS[@]}" || true -exit 0 diff --git a/src/plugins/grok/content/hooks/hooks.json b/src/plugins/grok/content/hooks/hooks.json index b6eb91f4..22b4a33a 100644 --- a/src/plugins/grok/content/hooks/hooks.json +++ b/src/plugins/grok/content/hooks/hooks.json @@ -1,49 +1,49 @@ { "hooks": { "SessionStart": [ - { "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "UserPromptSubmit": [ - { "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "PreToolUse": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "PostToolUse": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "PostToolUseFailure": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "PermissionDenied": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "Stop": [ - { "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "StopFailure": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "StopCancelled": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "Notification": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "SubagentStart": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "SubagentStop": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "PreCompact": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "PostCompact": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ], "SessionEnd": [ - { "matcher": ".*", "hooks": [{ "type": "command", "command": "bash \"${GROK_PLUGIN_ROOT}/hooks/forward.sh\"" }] } + { "matcher": ".*", "hooks": [{ "type": "command", "command": "bt", "args": ["trace", "hook", "--source", "grok", "--session-id-field", "sessionId", "--event-field", "hookEventName", "--transcript-path-field", "transcriptPath"] }] } ] } } diff --git a/src/plugins/grok/local-dev.sh b/src/plugins/grok/local-dev.sh index f93b895e..fd3b2253 100755 --- a/src/plugins/grok/local-dev.sh +++ b/src/plugins/grok/local-dev.sh @@ -280,12 +280,8 @@ chmod +x "$BT_WRAPPER" installed_hooks="$installed_path/hooks/hooks.json" dist_hooks="$DIST_DIR/hooks/hooks.json" -installed_forward="$installed_path/hooks/forward.sh" -dist_forward="$DIST_DIR/hooks/forward.sh" original_hooks="$DEV_DIR/hooks.json.original" original_dist_hooks="$DEV_DIR/dist-hooks.json.original" -original_forward="$DEV_DIR/forward.sh.original" -original_dist_forward="$DEV_DIR/dist-forward.sh.original" # Grok's local-source refresh can leave an interrupted development run's # temporary env injection in the installed copy. Reset it from the freshly @@ -293,13 +289,8 @@ original_dist_forward="$DEV_DIR/dist-forward.sh.original" if [[ "$installed_hooks" != "$dist_hooks" ]]; then cp "$dist_hooks" "$installed_hooks" fi -if [[ "$installed_forward" != "$dist_forward" ]]; then - cp "$dist_forward" "$installed_forward" -fi cp "$installed_hooks" "$original_hooks" cp "$dist_hooks" "$original_dist_hooks" -cp "$installed_forward" "$original_forward" -cp "$dist_forward" "$original_dist_forward" restore_hooks() { if [[ -f "$original_hooks" && -n "$installed_hooks" ]]; then @@ -308,12 +299,6 @@ restore_hooks() { if [[ -f "$original_dist_hooks" ]]; then cp "$original_dist_hooks" "$dist_hooks" 2>/dev/null || true fi - if [[ -f "$original_forward" ]]; then - cp "$original_forward" "$installed_forward" 2>/dev/null || true - fi - if [[ -f "$original_dist_forward" ]]; then - cp "$original_dist_forward" "$dist_forward" 2>/dev/null || true - fi } cleanup() { @@ -329,66 +314,40 @@ trap cleanup EXIT INT TERM # Grok's hook runner intentionally does not pass arbitrary parent-process # environment variables through. Add the isolated local route explicitly to # this installed copy, then restore the original file when the session exits. -python3 - "$installed_hooks" "$dist_hooks" "$BT_WRAPPER" "$DAEMON_BIN" "$SOCKET" "$CONFIG" <<'PY' +python3 - "$installed_hooks" "$dist_hooks" "$BT_WRAPPER" <<'PY' import json import sys -installed_path, dist_path, bt_bin, daemon_bin, socket, config = sys.argv[1:] -local_env = { - "BT_BIN": bt_bin, - "BT_DAEMON_BIN": daemon_bin, - "BT_DAEMON_SOCKET": socket, - "BT_DAEMON_CONFIG": config, -} +installed_path, dist_path, bt_bin = sys.argv[1:] for path in (installed_path, dist_path): with open(path) as f: document = json.load(f) for groups in document["hooks"].values(): for group in groups: for handler in group["hooks"]: - handler["env"] = {**handler.get("env", {}), **local_env} + handler["command"] = bt_bin with open(path, "w") as f: json.dump(document, f, indent=2) f.write("\n") PY -# Pin the adapter itself to the local wrapper. This remains effective even when -# Grok strips parent-process variables, and patching both copies survives its -# local-source refresh during /reload-plugins. -python3 - "$installed_forward" "$dist_forward" "$BT_WRAPPER" "$DEV_DIR/grok-adapter.stderr" <<'PY' -import shlex -import sys - -for path in sys.argv[1:3]: - with open(path) as f: - lines = f.readlines() - lines.insert(1, f"exec 2>>{shlex.quote(sys.argv[4])}\n") - lines.insert(1, f"export BT_BIN={shlex.quote(sys.argv[3])}\n") - with open(path, "w") as f: - f.writelines(lines) -PY - if [[ "$SKIP_PLUGIN_RELOAD" == true ]]; then # Grok 1.0.13 does not activate installed plugin hooks at process startup. # For a clean demo, install this artifact's hooks into the isolated run home, # where Grok discovers them as trusted global hooks without a slash command. direct_hooks_dir="$GROK_HOME_DIR/hooks" - direct_forward="$direct_hooks_dir/forward.sh" mkdir -p "$direct_hooks_dir" - cp "$dist_forward" "$direct_forward" - chmod +x "$direct_forward" - python3 - "$dist_hooks" "$direct_hooks_dir/braintrust.json" "$direct_forward" <<'PY' + python3 - "$dist_hooks" "$direct_hooks_dir/braintrust.json" "$BT_WRAPPER" <<'PY' import json -import shlex import sys -source, destination, forward = sys.argv[1:] +source, destination, bt_bin = sys.argv[1:] with open(source) as f: document = json.load(f) for groups in document["hooks"].values(): for group in groups: for handler in group["hooks"]: - handler["command"] = f"bash {shlex.quote(forward)}" + handler["command"] = bt_bin with open(destination, "w") as f: json.dump(document, f, indent=2) f.write("\n") @@ -427,11 +386,11 @@ done PROBE_SESSION="grok-local-dev-probe" PROBE_JOURNAL="" printf '%s' "{\"hookEventName\":\"local_dev_probe\",\"sessionId\":\"$PROBE_SESSION\"}" | \ - BT_BIN="$BT_WRAPPER" \ - BT_DAEMON_BIN="$DAEMON_BIN" \ - BT_DAEMON_SOCKET="$SOCKET" \ - BT_DAEMON_CONFIG="$CONFIG" \ - "$DIST_DIR/hooks/forward.sh" + "$BT_WRAPPER" trace hook \ + --source grok \ + --session-id-field sessionId \ + --event-field hookEventName \ + --transcript-path-field transcriptPath for _ in {1..100}; do PROBE_JOURNAL="$(find_probe_journal "$DATA_DIR/journal" "$PROBE_SESSION" || true)" [[ -n "$PROBE_JOURNAL" ]] && break diff --git a/src/plugins/grok/test/test_hook.sh b/src/plugins/grok/test/test_hook.sh deleted file mode 100755 index 78164df9..00000000 --- a/src/plugins/grok/test/test_hook.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -HOOK="${1:?usage: test_hook.sh }" -HOOK_DIR="${HOOK%/*}" -[[ "$HOOK_DIR" != "$HOOK" ]] || HOOK_DIR="." -HOOKS_JSON="$HOOK_DIR/hooks.json" -MANIFEST="$HOOK_DIR/../.grok-plugin/plugin.json" -PLUGIN_VERSION="$( - python3 - "$MANIFEST" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - print(json.load(handle)["version"]) -PY -)" -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -cat >"$TMP/bt" <<'STUB' -#!/bin/sh -{ - printf '%s\n' "$#" - printf '%s\n' "$@" -} >"$BT_CAPTURE_ARGS" -cat >"$BT_CAPTURE_STDIN" -exit "${BT_STUB_STATUS:-0}" -STUB -chmod +x "$TMP/bt" - -payload='{"hookEventName":"pre_tool_use","sessionId":"test","transcriptPath":"/tmp/session.jsonl","toolName":"read_file"}' -payload_file="$TMP/payload" -args="$TMP/args" -stdin="$TMP/stdin" -expected_args="$TMP/expected-args" -printf '%s\n' "$payload" >"$payload_file" - -cat "$payload_file" | env \ - BT_BIN="$TMP/bt" \ - BT_CAPTURE_ARGS="$args" \ - BT_CAPTURE_STDIN="$stdin" \ - GROK_VERSION= \ - BT_STUB_STATUS=0 \ - "$HOOK" - -printf '%s\n' \ - 12 \ - trace \ - hook \ - --source \ - grok \ - --plugin-version \ - "$PLUGIN_VERSION" \ - --session-id-field \ - sessionId \ - --event-field \ - hookEventName \ - --transcript-path-field \ - transcriptPath \ - >"$expected_args" -cmp -s "$expected_args" "$args" \ - || { echo "test: unexpected bt arguments" >&2; exit 1; } -cmp -s "$payload_file" "$stdin" \ - || { echo "test: hook payload changed" >&2; exit 1; } - -# Forward an already-available Grok version without launching Grok to discover it. -cat "$payload_file" | env \ - BT_BIN="$TMP/bt" \ - BT_CAPTURE_ARGS="$args" \ - BT_CAPTURE_STDIN="$stdin" \ - BT_STUB_STATUS=0 \ - GROK_VERSION="1.0.13 beta" \ - "$HOOK" -printf '%s\n' \ - 14 \ - trace \ - hook \ - --source \ - grok \ - --plugin-version \ - "$PLUGIN_VERSION" \ - --session-id-field \ - sessionId \ - --event-field \ - hookEventName \ - --transcript-path-field \ - transcriptPath \ - --source-version \ - "1.0.13 beta" \ - >"$expected_args" -cmp -s "$expected_args" "$args" \ - || { echo "test: unexpected source-version arguments" >&2; exit 1; } -cmp -s "$payload_file" "$stdin" \ - || { echo "test: source-version forwarding changed the hook payload" >&2; exit 1; } - -# Forwarding failures must not interrupt Grok. -cat "$payload_file" | env \ - BT_BIN="$TMP/bt" \ - BT_CAPTURE_ARGS="$args" \ - BT_CAPTURE_STDIN="$stdin" \ - BT_STUB_STATUS=23 \ - GROK_VERSION= \ - "$HOOK" -cmp -s "$payload_file" "$stdin" \ - || { echo "test: forwarding failure changed the hook payload" >&2; exit 1; } - -# A missing host CLI must diagnose and return without attempting installation. -mkdir "$TMP/no-bt" "$TMP/host-home" -cat >"$TMP/no-bt/curl" <<'STUB' -#!/bin/sh -: >"$CURL_CALLED" -exit 0 -STUB -chmod +x "$TMP/no-bt/curl" -diagnostic="$TMP/diagnostic" -/usr/bin/env -u BT_BIN -u GROK_VERSION \ - PATH="$TMP/no-bt" \ - HOME="$TMP/host-home" \ - XDG_BIN_HOME="$TMP/host-home/bin" \ - CARGO_HOME="$TMP/host-home/cargo" \ - CURL_CALLED="$TMP/curl-called" \ - /bin/bash "$HOOK" <"$payload_file" 2>"$diagnostic" -[[ "$(<"$diagnostic")" == "trace-grok: bt CLI not found; tracing skipped" ]] \ - || { echo "test: unexpected missing-CLI diagnostic" >&2; exit 1; } -[[ ! -e "$TMP/curl-called" ]] \ - || { echo "test: missing bt attempted an installation" >&2; exit 1; } -shopt -s nullglob dotglob -host_files=("$TMP/host-home"/*) -shopt -u nullglob dotglob -(( ${#host_files[@]} == 0 )) \ - || { echo "test: missing bt mutated the host home" >&2; exit 1; } - -python3 - "$HOOKS_JSON" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as handle: - hooks = json.load(handle)["hooks"] - -assert "SessionEnd" in hooks, "terminal hook must use exact SessionEnd spelling" -assert "SessionStop" not in hooks, "non-native terminal spelling must not be registered" -for event, groups in hooks.items(): - for group in groups: - for hook in group["hooks"]: - assert "timeout" not in hook, f"{event} must use Grok's default timeout" -PY - -echo "test: grok hook adapter OK" diff --git a/src/plugins/grok/validate.sh b/src/plugins/grok/validate.sh index 87d326d3..491cc2d0 100755 --- a/src/plugins/grok/validate.sh +++ b/src/plugins/grok/validate.sh @@ -8,7 +8,7 @@ fail() { echo "validate: $*" >&2; exit 1; } [[ -x "$SRC_DIR/local-dev.sh" ]] || fail "local-dev.sh is not executable" bash -n "$SRC_DIR/local-dev.sh" || fail "local-dev.sh has invalid shell syntax" -for file in .grok-plugin/plugin.json hooks/hooks.json hooks/forward.sh README.md LICENSE; do +for file in .grok-plugin/plugin.json hooks/hooks.json README.md LICENSE; do [[ -f "$TARGET_DIR/$file" ]] || fail "missing $file" done @@ -23,8 +23,6 @@ assert "END OF TERMS AND CONDITIONS" in text assert "placeholder" not in text.lower() assert "todo" not in text.lower() PY -[[ -x "$TARGET_DIR/hooks/forward.sh" ]] || fail "hook adapter is not executable" - python3 - "$TARGET_DIR/hooks/hooks.json" <<'PY' || fail "invalid Grok hooks" import json import sys @@ -44,7 +42,12 @@ for event, groups in hooks.items(): for hook in group["hooks"]: assert hook == { "type": "command", - "command": 'bash "${GROK_PLUGIN_ROOT}/hooks/forward.sh"', + "command": "bt", + "args": [ + "trace", "hook", "--source", "grok", + "--session-id-field", "sessionId", "--event-field", "hookEventName", + "--transcript-path-field", "transcriptPath", + ], } PY @@ -55,9 +58,6 @@ else || fail "invalid plugin manifest" fi -TEST_LOG="$(mktemp -d)/grok-hook-data" -trap 'rm -rf "$(dirname "$TEST_LOG")"' EXIT -"$SRC_DIR/test/test_hook.sh" "$TARGET_DIR/hooks/forward.sh" "$TEST_LOG" "$SRC_DIR/test/test_local_dev.sh" echo "validate: grok dist OK ($TARGET_DIR)"